ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

FPGA豆浆机面板设计实战:Verilog状态机与按键消抖教程

FPGA豆浆机面板设计实战:Verilog状态机与按键消抖教程

简介:面向FPGA课程设计任务,这份压缩包提供了基于Verilog的豆浆机控制面板完整方案,适合电子信息类本科生、自学FPGA的开发者参考。资源围绕豆浆机六档功能选择、按键操作、LED状态指示、数码管倒计时、温度检测与干烧/溢出蜂鸣报警等模块展开,并用LED点阵模拟电机和加热器工作状态,涵盖HDL源码、Quartus工程文件、测试仿真文件及配套说明文档。包内188个文件,以tdf、vhd、v等硬件描述语言文件为核心,附带qsf/qpf工程配置、sof/pof下载文件、vwf仿真波形和docx文档,整体约4.51MB,目录结构清晰。资源包还包含完整工程约束与下载配置,可直接在开发板上综合、布局布线并验证设计效果。已有813人学习下载,适合需要完整理解FPGA豆浆机控制器设计流程、模块划分与EDA工具操作的读者。

1. 把豆浆机面板搬进 FPGA:一个小课设里被反复折腾的时序逻辑

深夜的实验室里,几个同学围着一块开发板同时沉默。按键按下一次,数码管上的模式编号却跳了两个档;状态机刚跑起来,倒计时数字又乱跳。用单片机做这个豆浆机面板本不难,但课设偏偏要求 FPGA + HDL。这份压缩包里是一份完整的 FPGA 课程设计作品——豆浆机面板设计,用 Verilog 在入门级开发板上实现了模式选择、阶段倒计时、启动暂停取消、蜂鸣器提示这一整套操作逻辑。它算不上复杂工程,但状态机、按键消抖、数码管动态扫描、计时分频这些时序逻辑的典型问题全都占了。适合正在做课设的学生,也能给第一次写状态机的 FPGA 初学者留一份能跑通、能动手改的参考。

2. 项目全貌:面板资源、目录结构与数据流

2.1 面板功能拆解:三个按键、四个状态、五种模式

豆浆机面板的“表面功夫”是按键和显示,但真正决定设计成败的是背面的状态流转。这套设计把面板资源分成三块:输入是按键,输出分成状态呈现和声音提示。板上只用了三个独立按键,分别是模式切换 MODE、启动暂停 START、取消 CANCEL,没有做矩阵键盘。课设场景下用户真正需要的操作就这么多,按键越多,状态机要处理的异常分支越多,复杂度会成倍上升。

按键之外的输出资源需要提前交代清楚,因为后面的模块全在为这三样东西服务:

外设资源数量/形式在这个面板里的职责
按键3 个独立按键模式选择、启动暂停切换、取消退出
数码管4 位七段数码管前两位显示当前模式编号,后两位显示剩余秒数
LED3 个独立 LED指示浸泡、研磨、煮浆保温三个阶段的进度
蜂鸣器1 个有源蜂鸣器按键反馈短鸣、流程完成长鸣

豆浆程序内置三种模式:干豆、湿豆、米糊,每种模式对应一条独立的时间线。例如干豆模式是浸泡 15 秒、研磨 20 秒、煮浆 25 秒、保温 15 秒;湿豆模式则把浸泡阶段去掉,直接进入研磨。真实的豆浆机时间以分钟为单位,但课设需要在仿真和现场演示时快速看到完整流程,所以这套设计把时间压缩到了秒级,参数全部集中在一个模块里,想改回分钟级只需改常量。

状态流转是这套面板设计的核心。整个系统只有四个状态:空闲 IDLE、运行 RUN、暂停 PAUSE、完成 DONE。空闲时按 MODE 在三种程序之间循环切换;按 START 从空闲进入运行;运行中按 START 在运行与暂停之间切换,按 CANCEL 直接回空闲;所有阶段跑完后进入完成状态,蜂鸣器响一段时间,再按任意键回空闲。我整理文档时手绘过这个状态转移图,比写一大段文字管用得多,动手改代码前建议你也先画一张。

2.2 目录结构与模块划分:每个文件干什么一眼看清

压缩包里的 RTL 代码按模块拆开,每个文件对应一个可独立仿真的单元,顶层只有例化没有逻辑。目录结构大致如下:

fpga_soymilk_panel/ ├── rtl/ │ ├── top_panel.v // 顶层模块,例化所有子模块 │ ├── key_debounce.v // 按键消抖与边沿检测 │ ├── panel_fsm.v // 主状态机与阶段流转 │ ├── stage_timer.v // 阶段计时器,输出剩余秒数 │ ├── seg_display.v // 数码管扫描显示驱动 │ ├── buzzer_ctrl.v // 蜂鸣器发声控制 │ └── clk_div.v // 分频产生 1kHz 与 1Hz 脉冲使能 ├── sim/ │ └── tb_top_panel.v // 顶层仿真测试文件 └── constraint/ └── panel_pins.xdc // 引脚约束文件

这个设计的核心思路是“单时钟、脉冲使能”。系统只有一个主时钟,分频模块不产生新时钟,而是产生两个脉冲使能信号:1kHz 给数码管动态扫描,1Hz 给秒计时。这样设计的原因我在第 5 章会专门展开,简单说就是多时钟域是 FPGA 课设里最常见的翻车点,用脉冲使能可以把风险压到最低。

数据流走向也值得先讲清楚:按键信号进key_debounce消抖并提取边沿,然后送入panel_fsm状态机;状态机根据当前状态和按键事件决定下一拍的状态,同时把状态输出到stage_timer、seg_display、buzzer_ctrl。计时器倒计时结束会产生一个完成信号回传给状态机,驱动状态跳转。整个环路里没有任何组合逻辑直接驱动外部输出,所有输出都打了一拍寄存器,这是这套代码能稳定烧进板子的关键。

3. 核心模块拆解:从按键按下到数码管跳数字的完整链路

3.1 按键消抖:两级同步与边沿检测

机械按键按下的瞬间,电平会在 5ms 到 20ms 内反复抖动。如果直接采样按键电平,状态机会被一次按压触发多次。常见处理是延时消抖,我的做法是连续采样稳定若干毫秒再确认有效,并输出一个单周期的按键事件脉冲。完整的消抖模块如下:

module key_debounce #( parameter CLK_FREQ = 50_000_000, parameter DEBOUNCE_MS = 20 ) ( input wire clk, input wire rst_n, input wire key_in, // 按键原始电平,按下为低 output wire key_press // 单周期脉冲,按下有效时拉高一个时钟 ); reg [1:0] key_sync; reg [23:0] cnt; reg stable_level; reg stable_level_d; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin key_sync <= 2'b11; // 复位后默认高电平 stable_level <= 1'b1; stable_level_d <= 1'b1; end else begin key_sync <= {key_sync[0], key_in}; // 两级同步打拍 if (key_sync[1] == stable_level) begin // 电平未变化 cnt <= 0; end else begin cnt <= cnt + 1'b1; if (cnt >= (CLK_FREQ / 1000 * DEBOUNCE_MS - 1)) begin stable_level <= key_sync[1]; cnt <= 0; end end stable_level_d <= stable_level; end end assign key_press = stable_level_d & ~stable_level; // 检测稳定电平的下降沿 endmodule

代码里有三个值得注意的参数点。第一,DEBOUNCE_MS默认 20ms。如果板子的按键抖动时间更长,在例化时覆盖这个参数即可,不用改动消抖逻辑本身。第二,CLK_FREQ必须与系统实际时钟频率一致,否则消抖时间会与预期不符。第三,最后输出的是下降沿脉冲,对应“按下”这个动作,松开时不会产生第二个事件,这样能避免一次按压导致两次模式切换的问题。

3.2 主状态机:三段式写法的模块化实现

状态机采用标准三段式:第一段做状态寄存器次态更新,第二段做状态转移条件判断,第三段做输出逻辑。这样把“下一拍跳到哪”和“当前拍输出什么”拆开,仿真时看波形能快速定位是时序问题还是组合逻辑问题。核心代码段如下:

module panel_fsm #( parameter MODE_NUM = 3 // 支持的程序模式数量 ) ( input wire clk, input wire rst_n, input wire key_mode, input wire key_start, input wire key_cancel, input wire timer_done, // 当前阶段计时结束 output reg [1:0] state, output reg [1:0] mode, // 当前选择的模式编号 output reg [1:0] stage, // 当前所处的阶段编号 output reg work_en, // 计时器使能 output reg beep_en // 蜂鸣器使能 ); localparam IDLE = 2'd0, RUN = 2'd1, PAUSE = 2'd2, DONE = 2'd3; always @(posedge clk or negedge rst_n) begin if (!rst_n) state <= IDLE; else case (state) IDLE: begin if (key_start) state <= RUN; else state <= IDLE; end RUN: begin if (key_cancel) state <= IDLE; else if (key_start) state <= PAUSE; else if (timer_done) state <= DONE; else state <= RUN; end PAUSE: begin if (key_cancel) state <= IDLE; else if (key_start) state <= RUN; else state <= PAUSE; end DONE: begin if (key_start || key_cancel) state <= IDLE; else state <= DONE; end default: state <= IDLE; endcase end always @(posedge clk or negedge rst_n) begin if (!rst_n) begin mode <= 2'd1; stage <= 2'd0; end else if (state == IDLE) begin if (key_mode) mode <= mode + 1'b1; // 空闲时切换模式 stage <= 2'd0; end else if (state == RUN && timer_done) begin stage <= stage + 1'b1; // 阶段推进 end end always @(posedge clk or negedge rst_n) begin if (!rst_n) begin work_en <= 1'b0; beep_en <= 1'b0; end else begin work_en <= (state == RUN); beep_en <= (state == DONE); end end endmodule

容易被新手忽略的是模式切换只能在 IDLE 状态下进行,启动之后key_mode被完全忽略。这样设计的目的是防止运行过程中误触模式键导致参数整体错乱。阶段推进条件timer_done在每个阶段结束时拉高一个周期,状态机在这一拍既跳状态也推进 stage,两件事同步发生,避免跨时钟握手问题。

第三段输出逻辑里,work_en只在 RUN 状态为高,PAUSE 和 DONE 时都被拉低。这样计时器模块不需要关心状态机的细节,只要看到work_en有效就开始倒计时,无效就保持当前计数值。所有控制信号都由状态寄存器同步产生,不存在组合逻辑毛刺,这也是“输出打拍”的具体应用。

3.3 阶段计时器:1Hz 使能脉冲与倒计时

计时模块根据当前模式和阶段决定本阶段总秒数,并在每个 1Hz 脉冲下减一。时间参数全部放在常量表里,整个项目修改运行时长只需要动这一处:

module stage_timer #( parameter MODE_NUM = 3, parameter STAGE_NUM = 4 ) ( input wire clk, input wire rst_n, input wire [1:0] mode, input wire [1:0] stage, input wire work_en, input wire pulse_1hz, // 1Hz 脉冲使能 output reg timer_done, // 本阶段结束 output reg [7:0] remain_sec // 剩余秒数 ); reg [7:0] stage_sec; always @(*) begin case (mode) 2'd1: case (stage) // 干豆 2'd0: stage_sec = 8'd15; 2'd1: stage_sec = 8'd20; 2'd2: stage_sec = 8'd25; default: stage_sec = 8'd15; endcase 2'd2: case (stage) // 湿豆:没有浸泡 2'd0: stage_sec = 8'd0; 2'd1: stage_sec = 8'd20; default: stage_sec = 8'd25; endcase default: stage_sec = 8'd12; // 米糊 endcase end always @(posedge clk or negedge rst_n) begin if (!rst_n) begin remain_sec <= 0; timer_done <= 0; end else if (!work_en) begin remain_sec <= stage_sec; timer_done <= (stage_sec == 0) ? 1'b1 : 1'b0; end else if (pulse_1hz) begin if (remain_sec == 0) begin timer_done <= 1'b1; end else begin remain_sec <= remain_sec - 1'b1; timer_done <= (remain_sec == 1) ? 1'b1 : 1'b0; end end end endmodule

这个模块的写法承上启下。work_en拉低时,模块把本阶段总秒数装载到remain_sec,同时如果总秒数为 0(湿豆模式没有浸泡阶段),会立刻产生timer_done,让状态机在下一拍就把 stage 推进到下一阶段,相当于跳过空阶段。remain_sec == 1时会在下一个 1Hz 脉冲上拉高timer_done,完成信号的到来和倒计时归零是同一拍,状态机跳转时看到的剩余值始终一致,不会出现“显示 0 却迟迟不跳阶段”的错位。

3.4 数码管扫描与蜂鸣器控制

四位共阴数码管要稳定显示,必须动态扫描:同一时刻只点亮一位,以人眼无法分辨的频率轮流点亮四位。1kHz 脉冲使能在这里派上用场,每来一个脉冲切换一位,扫描周期 4ms,肉眼上看四位同时常亮。关键代码如下:

module seg_display #( parameter SCAN_FREQ = 1000 ) ( input wire clk, input wire rst_n, input wire [7:0] remain_sec, // 剩余秒数 input wire [1:0] mode, // 当前模式编号 output reg [3:0] seg_sel, // 位选 output reg [7:0] seg_data // 段码 ); reg [1:0] scan_cnt; reg [3:0] number; always @(*) begin case (scan_cnt) 2'd0: begin seg_sel = 4'b0001; number = mode / 10; end 2'd1: begin seg_sel = 4'b0010; number = mode % 10; end 2'd2: begin seg_sel = 4'b0100; number = remain_sec / 10; end default: begin seg_sel = 4'b1000; number = remain_sec % 10; end endcase end always @(posedge clk or negedge rst_n) begin if (!rst_n) scan_cnt <= 0; else scan_cnt <= scan_cnt + 1'b1; // 扫描位切换 end // 段码译码:根据 number 查表输出七段码 // 开发板共阴/共阳极不同,段码表按硬件实际接线填写 always @(*) begin case (number) 4'd0: seg_data = 8'h3F; 4'd1: seg_data = 8'h06; // 其余数字查表略 default: seg_data = 8'h00; endcase end endmodule

这里我把扫描计数和段码译码分成两块。scan_cnt递增可以用外部 1kHz 脉冲门控,也可以直接用主时钟计数分频,两种写法在这个模块里都成立,差别在于前者天然避免分频时钟带来的时序收敛问题。段码表必须按自己开发板的共阴共阳接法修改,这一节属于“拿到板子应该先确认的硬件常识”,引脚接法错了,显示就会乱码。

蜂鸣器控制更简单,功能上只区分两种声音:按键反馈短鸣约 100ms,完成长鸣约 3 秒。模块内部用一个计数器区分两种时长,在 DONE 状态拉高使能,计时结束自动关断。需要特别注意的是,有源蜂鸣器给高电平就会响,无源蜂鸣器需要输出特定频率的方波,买板子时一定要确认类型,否则完成不响会让人怀疑人生。

4. 搭建工程与运行验证:从新建项目到波形仿真的完整流程

4.1 工程创建与引脚约束:三个关键步骤

拿到源码第一件事不是读代码,而是先把工程建起来跑一遍仿真。主流 EDA 工具都采用类似的组织方式,新建工程后把 rtl 目录下的 .v 文件全部添加进去,然后写约束文件把顶层信号映射到开发板物理引脚。最容易出错的地方不在语法,而在引脚号的来源——必须对照开发板原理图,看数码管的位选和段选分别接在哪个 FPGA 引脚上,不能照搬别人工程里的约束。

引脚约束文件的关键内容长这样,具体引脚号要换成自己板子实际编号:

set_property PACKAGE_PIN E3 [get_ports clk] set_property PACKAGE_PIN F6 [get_ports key_mode] set_property PACKAGE_PIN F7 [get_ports key_start] set_property PACKAGE_PIN G6 [get_ports key_cancel] set_property PACKAGE_PIN A10 [get_ports {seg_sel[0]}] set_property PACKAGE_PIN A9 [get_ports {seg_sel[1]}] create_clock -period 20.000 -name sys_clk [get_ports clk]

Vivado 系的工程用 XDC 格式,上面这段 tcl 直接可用;如果是 Quartus 系,换成 QSF 里的set_location_assignment,逻辑是等价的。create_clock这条约束虽然对课设小工程不是必须的,但加了对后续时序收敛和调试都有帮助,是养成好习惯的起点。约束完成后跑综合,如果报引脚复用或未定义错误,九成是引脚编号写错,回来再对一遍原理图。

4.2 仿真测试:三个关键测试用例

仿真测试文件建议直接调用顶层模块,把所有按键事件合成一段激励序列,用$display或波形查看器观察状态机变化。测试至少要覆盖三件事:正常走完一个流程、运行中取消、运行中暂停再恢复。下面是一段典型的“选择湿豆模式→启动→完成”激励:

initial begin rst_n = 0; key_mode = 1; key_start = 1; key_cancel = 1; repeat(4) #20 rst_n = 1; // 按一次 MODE,切到模式 2(湿豆) key_mode = 0; #20; key_mode = 1; repeat(2000000) @(posedge clk); // 等待消抖确认 // 按 START 进入运行 key_start = 0; #20; key_start = 1; // 观察 timer_done 和 stage 的变化 end

注意这里按键极性按“按下为低有效”处理,与市面上大多数开发板一致。仿真时 20ms 消抖在 50MHz 时钟下是 100 万个周期,为了加快仿真可以把DEBOUNCE_MS在 testbench 里覆盖为 1ms,或者用defparam修改。这招能让仿真速度明显提升,也是我调试时最常用的手段。

看波形的重点位置有三个:启动那一拍state从 IDLE 变 RUN,work_en同步拉高;阶段结束时timer_done和stage是否在同一拍更新;取消时state回到 IDLE,remain_sec是否重新装载。这些点确认无误后再上板,基本不会遇到行为层面的意外。

4.3 上板调试流程与现象判断

仿真通过后上板,依然可能遇到现象和设计对不上的情况,这部分最耗时。我的调试顺序是:先确认时钟,再验证按键,再看显示,最后听蜂鸣器。具体做法可以在顶层加一个计数器让 LED 周期闪烁,如果 LED 都不闪,问题多半在时钟或复位;如果 LED 正常而按键没反应,就把按键信号放到在线逻辑分析仪里抓波形,确认按键事件脉冲有没有产生。

另一个常见现象是上板后数码管亮度不均,有的位明显暗。这要么是扫描频率不对,要么是位选驱动能力不足。前者改分频参数,后者需要位选输出加三极管驱动。硬件问题拿软件调不回来,只能认栽。这类现象会单独在第 5 章列出来,方便对照自查。

5. 常见问题与排查:五个坑的记录与检讨

5.1 按键一次按下触发多次切换

现象:按一下 MODE,模式编号从 1 直接跳到 3,相当于一次按压产生了两次事件。
原因:消抖逻辑用了电平稳定判断,但没有把边沿检测放在正确位置;或者消抖时间设置太短,抖动尚未结束就认为按下有效。
解决:把消抖确认时间提高到 20ms 以上,并且最后输出必须是stable_level_d & ~stable_level的单周期脉冲,而不是直接输出稳定电平。我把这个写法固化成模板之后,抄这套代码的人很少再在这个问题上翻车。

5.2 数码管整片闪烁且亮度不均

现象:四位数字都亮,但整体在快速闪动,低位尤其昏暗。
原因:扫描频率低于 50Hz 人眼就会察觉闪烁;分频计数器写错导致扫描周期变成 40ms 就会出现这种效果。位选引脚没有加驱动管,每位点亮时间太短也会亮度不均。
解决:先量扫描脉冲频率,确认在 1kHz 附近;再检查位选硬件电路,共阴数码管的位选高电平需要足够的驱动能力。

5.3 状态机进入未知状态,显示乱码

现象:数码管显示毫无规律,像随机数字,按键全部失效。
原因:状态编码匹配但 case 分支不完整,或者状态寄存器被亚稳态污染。更隐蔽的是有人把状态机写成两段式,输出逻辑里放了复杂组合判断,让毛刺被外部电路当成有效信号。
解决:用三段式写法,case 里必须写default: state <= IDLE;。所有异步输入进状态机前先打两拍同步,防止亚稳态进入时序逻辑。

5.4 蜂鸣器长鸣不停止

现象:完成状态时蜂鸣器开始响,但几秒后还在响,按键回空闲也停不下来。
原因:蜂鸣器使能信号来自状态机的组合逻辑判断,状态已经跳走,组合输出却没有同步变化;或者完成长鸣的计数器复位条件不完善。
解决:让蜂鸣器使能由寄存器输出,状态转移的同时清除计数。更稳妥的做法是在蜂鸣器模块内部判断state == DONE,并且复位时强制关闭使能输出。

5.5 仿真一跑就通,上板就失灵

现象:testbench 里波形完美,烧进板子现象全乱,像是两个程序。
原因:仿真时钟是理想时钟,真实时钟存在抖动;更常见的是分频写法有问题——用分频后的时钟去驱动另一组 always,等于人为制造了新的时钟域,跨时钟域的握手在课设里几乎没人做对。
解决:全工程只保留一个主时钟,所有“分频”都做成脉冲使能,而不是直接生成子时钟。这是 2.2 节强调过的设计思路,也是这套工程能稳定上板的重要原因。从那以后我做 FPGA 相关的板卡项目,任何模块的时钟都坚持用主时钟加门控使能,这套习惯救了我很多次。

6. 二次开发:改出你自己的豆浆机面板

拿到这套工程,最值得动手改的是三个方向:调整时间参数、增加预约功能、更换显示载体。时间参数最简单,stage_timer里已经把三种模式的阶段秒数做成常量表,把秒级改成分钟级只需改stage_sec的赋值。改的时候注意remain_sec的位宽,当前是 8 位,最大能表示 255 秒,如果某阶段要跑 90 分钟,就必须把位宽扩到 16 位,否则综合时报位宽截断,上板后倒计时会突然归零。

预约功能是课设答辩时容易加分的改造。思路是在状态机里增加一个 PRE 状态,进入预约状态后先用 MODE 设定启动倒计时,按 START 后从预约倒计时走完自动进入 RUN。这个改动涉及状态机加一个状态、计时器增加一个预约计时通道、数码管复用显示逻辑,大概三四十行代码。建议动手前先在纸上画一版新的状态转移表,把进入 PRE 和离开 PRE 的事件标清楚,再逐段替换现有代码。

另一个实用改造是把数码管换成 LCD 或者串口打印调试信息。现在的 FPGA 工具链普遍支持在线逻辑分析仪,直接在运行时观察状态机跳转,比盯着数码管猜现象效率高得多。替换时保持顶层接口不变,只换seg_display的内部实现,顶层代码一行不用动。这也反映出当初按模块划分的价值。

验证方法上,坚持“先仿真、后上板、再从现象反推”的顺序。每次改动后至少做一次定向仿真,把涉及的状态转移和计时边界全覆盖,确认没有引入新问题再烧板子。上板时一次只验证一个改动点,别同时改多处,否则现象不对时根本不知道应该改哪里。

有个习惯是从这套豆浆机面板做完之后养成的:每次拿到一个带控制逻辑的 FPGA 工程,都强制自己先列一张状态转移表,把每个输入事件在每个状态下的跳转目标写清楚,再动手写 RTL。画表这十分钟看着像浪费时间,实际上能把后面调试的时间省掉一半。如果你打算在这个工程上做二次开发,也建议从画状态转移表开始,希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进