☰
FPGA豆浆机面板课设:状态机与Verilog完整实现
2026/10/19 7:46:33 网站建设 项目流程

简介:这份FPGA课程设计资源围绕豆浆机面板控制项目,面向电子工程、自动化等专业学习FPGA与HDL开发的本科生,适合作为课程设计参考或综合实训案例。压缩包共188个文件,约4.51MB,主要包含Verilog/VHDL源码(.v/.vhd)、Quartus工程文件(.qpf/.qsf/.sof)、仿真波形(.vwf/.vcd)以及项目报告文档(.docx/.txt)等,覆盖从编码、综合到下载验证的完整流程。已有813人学习,资源热度适中。压缩包内除完整设计源码外,还包含可烧写的.sof配置文件和ModelSim仿真文件,便于读者直接上板调试或修改功能;同时附带设计说明文档和readme,帮助理解按键控制、LED显示、数码管计时、温度检测及报警模块的层次划分,可作为独立完成同类面板设计的进阶参考。

1. 为什么说豆浆机面板是被低估的FPGA课程设计题

FPGA课程设计里,豆浆机面板是我见过最容易被“看轻”的项目。很多人拿到题的直觉是“不就是点亮数码管、接几个按键”,真做起来才发现,显示只是最后一步,整份工程的工作量压在那台机器的状态流转上:加热、打浆、煮沸、保温,每个阶段持续多久,哪个键能中断流程,异常时怎么退出——这些逻辑写下来,比单纯点灯至少多三倍代码。它适合想用HDL完整体验一遍“按键输入、状态机控制、显示输出”闭环的人,也适合课设时间紧张、需要一份能跑又能改的方案的人。它不复杂,但恰好把按键消抖、定时、动态扫描、PWM这几件小事情串成了一个完整的小系统,跑通它,你对FPGA的掌控感会上一个台阶。

2. 整体架构映射:六个状态决定了系统边界

看一份面板工程,不要先翻显示代码,先看这台豆浆机到底有哪几种行为。我拆包后的第一件事,是把“机器状态”画成一张表,而不是急着看Verilog。状态表就是整个工程的骨架,后面所有模块都是为这张表服务的。

2.1 功能需求拆解:从操作流程到状态定义

这批课设的豆浆机流程一般是:上电待机,按启动进入加热,加热结束进入打浆,打浆结束再煮沸,煮沸完成后保温,保温中按停止回到待机。任何运行阶段按取消都要能立刻退出。另外还要有异常保护状态,比如温度或溢出信号异常时进报警,报警按任意键复位。

状态含义进入条件主要输出
IDLE待机上电复位、保温结束或报警复位仅显示待机提示,执行部件全停
HEAT加热待机下按启动加热管开启,显示加热阶段与剩余时间
GRIND打浆加热计时结束电机全速运转,加热管关断
BOIL煮沸打浆计时结束加热管再次开启,进入二次加热
WARM保温煮沸计时结束加热管间歇开启,电机低速搅动
ALARM报警检测到溢出或器件异常蜂鸣器持续鸣响,屏蔽启动

这六个状态一旦定下来,状态机的边界就清晰了。转移条件也要在写代码前逐条列出来:启动键在IDLE才有效,取消键在HEAT、GRIND、BOIL、WARM都有效,阶段超时是状态推进的主要触发源。把这些条件写到纸面上,比在always块里临时补逻辑要靠谱得多。

2.2 模块划分与工程目录:把控制、定时、显示解耦

状态机定好后,剩下的模块划分就顺理成章了。我一般会按“输入处理、核心控制、时间基准、显示输出、执行驱动”五层拆,模块之间尽量不互相引用,顶层只做连线。这份工程的目录结构通常长这样:

fpga_soymilk/ ├── rtl/ │ ├── top_soymilk.v # 顶层连线模块 │ ├── clk_div.v # 时钟分频,产生1kHz扫描基准和秒脉冲 │ ├── key_debounce.v # 按键消抖与边沿检测 │ ├── soymilk_ctrl.v # 三段式状态机,核心控制 │ ├── stage_timer.v # 阶段定时器,单次计时结束后输出done │ ├── seg_display.v # 四位动态扫描显示 │ ├── beep_driver.v # 蜂鸣器方波驱动 │ └── pwm_motor.v # 电机PWM调速输出 ├── sim/ │ ├── tb_soymilk.v # 顶层测试激励 │ └── run.do # ModelSim / Vivado XSim 批处理脚本 ├── constraint/ │ └── board_pins.xdc # 引脚约束,时钟、按键、数码管、执行部件 └── doc/ └── design_note.md # 状态表、转移条件、模块接口说明

模块边界拆在哪很关键。状态机不直接驱动数码管,只把当前state广播给显示模块;定时模块独立存在,状态机不自己维护计数器。这样改显示逻辑不影响状态跳转,单独仿真某个模块也不用连带加载一堆无关代码。顶层top_soymilk.v只负责例化子模块并连线,综合时设为顶层文件就可以了。

2.3 时钟与复位:分频策略和HDL写法

开发板上通常只有一个系统时钟,而豆浆机面板需要两类时钟不同频率的信号:数码管动态扫描要1kHz左右的刷新时钟,阶段定时要1Hz的秒基准。不做分频直接写计数器,位数大且通用性差,常见做法是先用一个分频模块产生“tick脉冲”,后续模块都以这个tick作为使能信号,而不是真的切换时钟。

// clk_div.v: 通用分频器,输出单周期tick module clk_div #( parameter DIV = 20_000_000 // 分频系数,按系统时钟换算 )( input wire clk, // 系统时钟,比如 20MHz input wire rst_n, // 异步复位,低有效 output reg tick // 每 DIV 个时钟周期拉高一个时钟周期 ); reg [24:0] cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 0; tick <= 0; end else if (cnt == DIV - 1) begin cnt <= 0; tick <= 1; end else begin cnt <= cnt + 1; tick <= 0; end end endmodule

这里DIV参数是按系统时钟直接换算的:20MHz时钟,要产生1Hz秒脉冲,DIV就是20_000_000;显示扫描要1kHz,DIV就是20_000。把DIV设为参数,换开发板时只需要改顶层传来的参数,不需要动分频逻辑本身。复位用的是异步复位、低电平有效,按键复位和上电复位共用同一个rst_n输入,这也是FPGA工程里最普遍的做法,简单可靠。

提示:注意分频器输出的是“使能脉冲”而不是真正的新时钟。用tick做门控的这种写法,可以避免多时钟域带来的跨时钟问题,综合时序也更容易收敛。

3. 核心代码拆解:三段式状态机与定时控制

状态机是这份课程设计的灵魂。状态数只有六个,但涉及按键中断、计时完成、异常触发三条转移来源,写不好就容易出现“状态跳过去了但输出没跟上”的毛病。这里用三段式写法,比一段式更直观,也更方便答辩时讲清楚。

3.1 状态机主体:三段式写法与状态转移条件

六个状态用独热码编码,每个状态占一个bit。独热码的好处是综合后每个状态只有一个寄存器置1,波形里看状态跳变一目了然,代价是多用几个寄存器,对课设资源来说完全可以忽略。

// soymilk_ctrl.v: 三段式状态机,组合逻辑算次态,时序逻辑存现态 module soymilk_ctrl #( parameter CLK_HZ = 20_000_000 )( input wire clk, input wire rst_n, input wire key_start_pulse, // 启动键,消抖后的单周期脉冲 input wire key_cancel_pulse, // 取消键,消抖后的单周期脉冲 input wire key_stop_pulse, // 停止键,保温阶段使用 input wire heat_done, // 加热计时完成 input wire grind_done, // 打浆计时完成 input wire boil_done, // 煮沸计时完成 input wire alert_in, // 异常信号,高电平有效 output wire heater_en, // 加热管使能 output wire motor_en, // 电机使能 output wire beep_long_en, // 持续蜂鸣,报警用 output reg [5:0] state // 当前状态,供显示模块使用 ); localparam IDLE = 6'b000001, HEAT = 6'b000010, GRIND = 6'b000100, BOIL = 6'b001000, WARM = 6'b010000, ALARM = 6'b100000; reg [5:0] next_state; // 第一段:组合逻辑计算次态 always @(*) begin next_state = state; // 默认保持当前状态 if (alert_in) begin next_state = ALARM; // 异常优先级最高 end else begin case (state) IDLE: begin if (key_start_pulse) next_state = HEAT; end HEAT: begin if (key_cancel_pulse) next_state = IDLE; else if (heat_done) next_state = GRIND; end GRIND: begin if (key_cancel_pulse) next_state = IDLE; else if (grind_done) next_state = BOIL; end BOIL: begin if (key_cancel_pulse) next_state = IDLE; else if (boil_done) next_state = WARM; end WARM: begin if (key_cancel_pulse) next_state = IDLE; else if (key_stop_pulse) next_state = IDLE; end ALARM: begin if (key_cancel_pulse) next_state = IDLE; end default: next_state = IDLE; endcase end end // 第二段:时序逻辑寄存现态 always @(posedge clk or negedge rst_n) begin if (!rst_n) state <= IDLE; else state <= next_state; end // 第三段:输出逻辑,按状态直接映射 always @(*) begin {heater_en, motor_en, beep_long_en} = 3'b000; case (state) HEAT: heater_en = 1'b1; GRIND: motor_en = 1'b1; BOIL: heater_en = 1'b1; WARM: begin heater_en = 1'b1; motor_en = 1'b1; end ALARM: beep_long_en = 1'b1; default: ; endcase end endmodule

这段代码有三个值得注意的地方。第一,组合逻辑里先写“next_state = state”作为默认值,再在case里覆盖,避免综合后产生不必要的锁存器。第二,异常信号优先级最高,放在case外面判断,保证报警状态在任何时刻都能抢到控制权。第三,输出逻辑单独一段,把状态与动作的映射集中在一起,以后要调“保温阶段加热管间歇开启”,只需要改这一段,不必动转移逻辑。

参数CLK_HZ本段暂时没有使用,但保留它可以让这个模块与时序参数保持一致。真正用到时钟计数的是stage_timer。

3.2 按键消抖与边沿检测:稳定窗口与单周期脉冲

机械按键按下和松开时会有几毫秒到十几毫秒的抖动,在这期间电平会反复跳变。如果直接用按键电平驱动状态机,一次按下可能被识别成多次操作。消抖的常见做法是“连续采样一段时间窗口,窗口内电平不变才认为稳定”,稳定后再做边沿检测,输出一个单周期脉冲。

// key_debounce.v: 按键消抖 + 边沿检测,输出按下/松开单周期脉冲 module key_debounce #( parameter CLK_HZ = 20_000_000, // 系统时钟频率 parameter MS_TARGET = 20 // 消抖窗口,单位毫秒 )( input wire clk, input wire rst_n, input wire key_in, // 按键原始输入,低电平有效 output wire key_press, // 按下脉冲,持续一个时钟周期 output wire key_release // 松开脉冲,持续一个时钟周期 ); localparam MAX_CNT = CLK_HZ * MS_TARGET / 1000 - 1; reg [24:0] cnt; reg key_level; // 消抖后的稳定电平 reg key_level_d; // 打一拍,用于边沿检测 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 0; key_level <= 1'b1; // 默认松开状态 end else if (key_in != key_level) begin cnt <= cnt + 1; if (cnt == MAX_CNT) begin // 持续稳定达到窗口长度 key_level <= key_in; // 接受这个新电平 cnt <= 0; end end else begin cnt <= 0; // 电平未变化,计数器清零 end end always @(posedge clk or negedge rst_n) begin if (!rst_n) key_level_d <= 1'b1; else key_level_d <= key_level; end // 稳定电平的下降沿 = 按下,上升沿 = 松开 assign key_press = (key_level == 1'b0) && (key_level_d == 1'b1); assign key_release = (key_level == 1'b1) && (key_level_d == 1'b0); endmodule

参数说明:CLK_HZ乘以MS_TARGET再除以1000,得到的就是消抖窗口对应的时钟周期数。20MHz时钟、20毫秒窗口,MAX_CNT约等于399999,计数需要25位,所以cnt声明为[24:0]。如果仿真时不想等那么久,可以把MS_TARGET临时改成1或2,仿真速度会快很多,上板前再改回来。

这段消抖逻辑里有一个容易被忽略的细节:key_level只有在“电平与当前稳定值不同且持续了足够长时间”之后才会跳变。这意味着抖动期间key_level不会跟着抖,后面边沿检测拿到的就是一个干净的单周期脉冲。状态机拿这个脉冲做触发,一次按键只会产生一次状态转移。

3.3 阶段定时:让每个阶段持续可配置的时间

豆浆机的每个阶段都要持续一段时间才进入下一状态。这个计时功能如果写进状态机里,状态机会变得很臃肿,而且仿真时想缩短时间也很麻烦。我一般把“单次计时”做成独立模块,状态机只需要拉高start,等待done回来。

// stage_timer.v: 可配置秒数的单次定时器 module stage_timer #( parameter CLK_HZ = 20_000_000, // 系统时钟频率 parameter SECONDS = 5 // 本阶段持续秒数 )( input wire clk, input wire rst_n, input wire start, // 开始计时,高电平有效 input wire clear, // 清零并复位,状态机转移时拉起 output reg done // 计时完成,输出高电平 ); reg [24:0] tick_cnt; reg [3:0] sec_cnt; wire one_sec = (tick_cnt == CLK_HZ - 1); always @(posedge clk or negedge rst_n) begin if (!rst_n) begin tick_cnt <= 0; sec_cnt <= 0; done <= 0; end else if (clear) begin tick_cnt <= 0; sec_cnt <= 0; done <= 0; end else if (start) begin if (one_sec) begin tick_cnt <= 0; if (sec_cnt == SECONDS - 1) begin done <= 1; // 最后一秒结束,拉高完成信号 end else begin sec_cnt <= sec_cnt + 1'b1; end end else begin tick_cnt <= tick_cnt + 1'b1; end end end endmodule

逻辑说明:start拉高后开始跑秒,tick_cnt先从0数到CLK_HZ-1,产生每秒一次的使能;到了整秒,sec_cnt加1。当sec_cnt到达SECONDS-1且再次到整秒时,done拉高。状态机看到done为高,执行状态转移,同时把clear拉高,定时器立刻清零复位,等待下一次start。这种做法让“时长参数”完全集中在例化位置,比散落在状态机里的计数器好维护得多。

注意done是电平信号而不是脉冲,状态机看到done后必须在下个周期拉高clear,否则定时器会保持done状态。这也提醒写状态机时不要太依赖“等它自己掉下来”,主动清才可靠。

4. 显示与驱动实现:动态扫描、段码映射与PWM

豆浆机面板的显示部分通常是四位数码管:第一位显示当前阶段编号(比如1加热、2打浆、3煮沸、4保温),后面两位或三位显示剩余时间。这一章解决的是“显示不闪、按键时数码管不花、电机转速可控”这三个实际问题。

4.1 数码管动态扫描:刷新时序与消隐处理

四个数码管的段码引脚在板子上往往共用一条总线,要同时显示不同内容,只能靠轮询点亮:任意时刻只点亮一位,高速切换,利用视觉暂留让人感觉四位都在亮。这个方案的刷新频率一般取1kHz左右,太快位选切换过于频繁,反而容易产生串扰;太慢则明显闪烁。

// seg_display.v: 四位动态扫描显示模块 module seg_display #( parameter CLK_HZ = 20_000_000, parameter SCAN_HZ = 1000 // 每位刷新频率 )( input wire clk, input wire rst_n, input wire [3:0] place0, // 第1位,阶段编号 input wire [3:0] place1, // 第2位,剩余时间十位 input wire [3:0] place2, // 第3位,剩余时间个位 input wire [3:0] place3, // 第4位,固定显示0或其他标识 output reg [3:0] seg_sel, // 位选,低电平有效 output reg [7:0] seg_data // 段码,共阳数码管低电平点亮 ); localparam SCAN_CNT = CLK_HZ / SCAN_HZ - 1; reg [14:0] cnt; reg [1:0] scan_pos; always @(posedge clk or negedge rst_n) begin if (!rst_n) cnt <= 0; else if (cnt == SCAN_CNT) cnt <= 0; else cnt <= cnt + 1; end wire scan_tick = (cnt == SCAN_CNT); always @(posedge clk or negedge rst_n) begin if (!rst_n) scan_pos <= 0; else if (scan_tick) scan_pos <= scan_pos + 1'b1; end // 段码查找表:共阳数码管,0-9 对应编码 function [7:0] seg_enc(input [3:0] num); case (num) 4'd0: seg_enc = 8'b1100_0000; 4'd1: seg_enc = 8'b1111_1001; 4'd2: seg_enc = 8'b1010_0100; 4'd3: seg_enc = 8'b1011_0000; 4'd4: seg_enc = 8'b1001_1001; 4'd5: seg_enc = 8'b1001_0010; 4'd6: seg_enc = 8'b1000_0010; 4'd7: seg_enc = 8'b1111_1000; 4'd8: seg_enc = 8'b1000_0000; 4'd9: seg_enc = 8'b1001_0000; default: seg_enc = 8'b1111_1111; endcase endfunction always @(*) begin seg_data = 8'b1111_1111; // 默认全部熄灭 seg_sel = 4'b1111; // 位选全部无效 case (scan_pos) 2'd0: begin seg_sel = 4'b1110; seg_data = seg_enc(place0); end 2'd1: begin seg_sel = 4'b1101; seg_data = seg_enc(place1); end 2'd2: begin seg_sel = 4'b1011; seg_data = seg_enc(place2); end 2'd3: begin seg_sel = 4'b0111; seg_data = seg_enc(place3); end endcase end endmodule

参数说明:SCAN_CNT由CLK_HZ除以SCAN_HZ得到,20MHz时钟下每位大约显示1毫秒,四个位扫完一圈约4毫秒,等效刷新率250Hz,肉眼完全看不到闪烁。段码表用的是共阳数码管编码,如果你的板子是共阴,段码值要全部取反。

这段代码里最关键的是组合逻辑中的“默认熄灭”处理。没做过动态扫描的人第一次上板,最常见的翻车是“数字旁边带着上一位的残影”。原因就是位选切换时,段码总线还带着上一位的数据,新位一亮就把旧数字的影子放出来了。把seg_data默认值设为全灭,并在case里同步切换位选和段码,可以从源头避免这个问题。

4.2 蜂鸣器与电机PWM:提示音和转速控制

蜂鸣器在豆浆机面板里承担两类职责:按键反馈短音、阶段切换提示音、异常持续报警。无源蜂鸣器需要给方波才响,频率一般在2kHz左右。这里做一个带使能的方波驱动器,报警时持续输出,普通提示由状态机控制使能时长。

// beep_driver.v: 蜂鸣器方波驱动 module beep_driver #( parameter CLK_HZ = 20_000_000, parameter BEEP_HZ = 2000 // 蜂鸣器标称频率,单位Hz )( input wire clk, input wire rst_n, input wire enable, // 使能,高电平鸣响 output reg beep_out ); localparam MAX_CNT = CLK_HZ / BEEP_HZ / 2 - 1; reg [12:0] cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 0; beep_out <= 0; end else if (enable) begin if (cnt == MAX_CNT) begin beep_out <= ~beep_out; // 翻转输出,构成方波 cnt <= 0; end else begin cnt <= cnt + 1; end end else begin cnt <= 0; beep_out <= 0; end end endmodule

MAX_CNT的计算逻辑是:先算出一个完整方波周期需要的时钟数,再除以2得到半周期。20MHz时钟下驱动2kHz蜂鸣器,一半周期是5000个时钟,计数器宽度13位足够。如果上板后蜂鸣器声音很小或没声音,优先怀疑这个分频系数算错了,频率落在人耳不敏感区间,不是蜂鸣器坏了。

电机控制则不同,打浆阶段要全速,保温阶段转速要低,直接用开关量只能“转或不转”,所以要上PWM。PWM模块用一个自由跑计数器,比较当前计数值和阈值,低于阈值输出高电平,高于则输出低电平,占空比由DUTY参数决定。

// pwm_motor.v: 电机PWM调速输出 module pwm_motor #( parameter CLK_HZ = 20_000_000, parameter PWM_HZ = 1000, // PWM频率,1kHz够用 parameter DUTY = 50 // 默认占空比50% )( input wire clk, input wire rst_n, input wire enable, // 使能,关断时输出低电平 output reg pwm_out ); localparam CNT_MAX = CLK_HZ / PWM_HZ - 1; localparam THRESHOLD = (CNT_MAX + 1) * DUTY / 100 - 1; reg [15:0] cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) cnt <= 0; else if (cnt == CNT_MAX) cnt <= 0; else cnt <= cnt + 1; end always @(posedge clk or negedge rst_n) begin if (!rst_n) pwm_out <= 0; else pwm_out <= enable && (cnt < THRESHOLD); end endmodule

THRESHOLD的计算有一个细节:先乘后除,避免小数截断误差。比如CNT_MAX是19999、DUTY是50,那就是20000乘以50再除以100得10000,最后减1得9999。这样从0到9999输出高、10000到19999输出低,正好是50%占空比。如果要打浆阶段全速,例化一个DUTY为100的PWM;保温阶段低速搅拌,把DUTY降到20左右即可。

4.3 显示内容组织:状态提示与倒计时如何映射

定时器和状态机给出的都是二进制数,要显示到数码管上,还需要把它们映射成四路BCD数据。这里最常见的是做一个状态到显示编号的映射函数,以及把剩余秒数拆成十位和个位。

// 显示映射:状态到阶段编号 function [3:0] state_to_disp(input [5:0] state); case (state) IDLE: state_to_disp = 4'd0; HEAT: state_to_disp = 4'd1; GRIND: state_to_disp = 4'd2; BOIL: state_to_disp = 4'd3; WARM: state_to_disp = 4'd4; ALARM: state_to_disp = 4'd9; default: state_to_disp = 4'd9; endcase endfunction // 剩余秒数拆位:sec_remain 是 BCD 码,十位和个位分别送显示 assign disp_sec_tens = sec_remain / 4'd10; assign disp_sec_ones = sec_remain % 4'd10;

面板设计有一半工作其实在这里:状态机内部用的是独热码,显示层只需要看状态编号;剩余时间从定时器拿过来后先转成BCD再拆位。把这些映射关系集中成函数或单独的always块,后面改成其他小家电流程时,只动这一层就够了。

5. 仿真与上板验证:常见问题排查

HDL工程的难点不在写代码,而在验证。仿真阶段把状态流程跑通,上板阶段把时序约束对齐,中间会踩不少坑。这一章把从testbench到真实板子的完整验证路径过一遍,最后集中列几个高频问题。

5.1 Testbench设计:把六状态流程完整跑一遍

写testbench的目标不是“让波形动起来”,而是验证状态转移条件是否正确。最基本的激励是:先复位,再模拟一次启动键按下,然后让所有定时器都跑完,观察state是否按IDLE到WARM的顺序走完,最后按取消键看能否回到IDLE。

`timescale 1ns / 1ps module tb_soymilk; reg clk = 0; reg rst_n = 0; reg key_start = 1; reg key_cancel = 1; reg alert_in = 0; wire [5:0] state; wire heater_en, motor_en, beep_long_en; wire [3:0] seg_sel; wire [7:0] seg_data; top_soymilk u_top( .clk (clk), .rst_n (rst_n), .key_start (key_start), .key_cancel (key_cancel), .alert_in (alert_in), .state (state), .heater_en (heater_en), .motor_en (motor_en), .beep_long_en (beep_long_en), .seg_sel (seg_sel), .seg_data (seg_data) ); always #10 clk = ~clk; // 20MHz 时钟 // 模拟一次完整豆浆流程:启动 -> 各阶段计时 -> 保温 -> 取消 initial begin #50 rst_n = 1; #200 key_start = 0; // 按下启动键 #50 key_start = 1; // 松开 // 等待状态机跑完加热/打浆/煮沸/保温整个流程 #5_000_000; $display("Stage1: state=%b, heater=%b, motor=%b", state, heater_en, motor_en); #50 key_cancel = 0; // 取消,回到待机 #50 key_cancel = 1; #1_000; if (state == 6'b000001) $display("TEST PASS: back to IDLE"); else $display("TEST FAIL: state=%b", state); $finish; end endmodule

这段激励最需要注意的是仿真时长。如果定时器按照真实的5秒甚至更久来配置,20MHz时钟下一秒钟就要跑两千万个时钟周期,仿真器会卡到怀疑人生。我一般会在先例化时强行把内部stage_timer的SECONDS改成1或2,或者用defparam把顶层里所有SECONDS参数压小,等流程验证通过后,再加回去做一次完整时长的回归。

仿真时重点看的波形是state这组独热码。启动后它应该依次经过000010、000100、001000、010000,每一步都对应一个定时器done。如果卡在某个状态不动,就用$display打印当前状态和各模块的done信号,定位是“转移条件没满足”还是“定时器没启动”。

5.2 综合与引脚约束:让代码在板子上能跑起来

仿真是逻辑层面的验证,上板则是物理层面的验证。综合前要在工程设置里把top_soymilk设为顶层模块,然后检查综合报告里的资源占用警告,重点看有没有信号被综合成锁存器。如果状态机的输出逻辑里漏了默认值,综合器会默默给你补一个锁存器进去,后面的行为会变得非常奇怪。

引脚约束这一步上板前最容易被漏掉。代码里写的clk、rst_n、key_start都是逻辑端口,必须通过约束文件告诉FPGA这些端口对应开发板上的哪个物理引脚。时钟引脚没约束的话,工程能综合、能烧录,但板子上一片死寂,因为时钟根本没有接进来。

# board_pins.xdc 节选,Vivado 约束语法 set_property PACKAGE_PIN E3 [get_ports clk] set_property IOSTANDARD LVCMOS33 [get_ports clk] set_property PACKAGE_PIN D10 [get_ports key_start] set_property IOSTANDARD LVCMOS33 [get_ports key_start]

如果你用的是Quartus,约束写法是qsf文件里的Location和Standard分配。含义完全相同:把逻辑端口和芯片引脚绑定,并声明电平标准。做这一步时对照开发板原理图逐个核对,特别是按键是“按下为低还是按下为高”,直接决定了消抖模块里的有效电平要不要取反。

5.3 常见问题排查记录

这批课设里学生翻车最多的地方,我整理成五条排查记录,都是实际会碰到的问题,排错思路可以通用。

现象一:数码管某一位带着上一位的残影。原因是位选信号切换时,段码总线还是上一位的编码,新位一亮就把旧数字的影子拖出来。解决方法是每次位选切换前先让段码全灭,再更新位选、最后更新段码,也就是在组合逻辑里先给seg_data赋全灭作默认值。

现象二:按键按下去状态没反应,多按几次偶尔能跳。这是按键抖动和边沿检测配合的问题。抖动期间产生的多个边沿,可能让状态机连续跳两次又跳回来。解决方法是确认消抖模块输出的确实只是单周期脉冲,并在状态机里把所有按键判断放在同一个时钟沿下,避免跨时钟直接采电平。

现象三:蜂鸣器上板后不响,仿真时却有波形。常见原因是分频系数和实际蜂鸣器中心频率不匹配。仿真里只要看到翻转波形就算“响”,但真实蜂鸣器在特定频率下响度最大,偏离太多就会几乎无声。解决方法是查开发板原理图里蜂鸣器的标称频率,把BEEP_HZ改成实际值,再量一下MAX_CNT算得是否准确。

现象四:仿真完全正常,烧录上板却不运行。这种情况八成出在引脚约束。先查时钟引脚是否分配,再查复位信号是否被错误地接到了一个高有效引脚上。还可以用调试工具抓内部信号,对比仿真波形看state是不是根本没离开IDLE。

现象五:状态机跑飞,跑到没定义的状态。这是没写default分支导致的。组合逻辑里case的default必须赋一个安全状态,最好是IDLE,这样异常输入不会让状态跑到未知编码上。另外独热码状态机还有一个隐患:如果复位时间不够,状态寄存器可能是全零而不是IDLE,所以复位信号释放后要留几个周期再操作按键。

6. 二次开发:把豆浆机面板改造成你自己的时序系统

这份工程最大的复用价值,在于它的状态机骨架和显示驱动都做成了解耦结构。想把它改成咖啡机、电饭煲、空气炸锅面板,核心工作不是改显示,而是改状态表和定时参数。

先替换状态集。咖啡机可以把六个状态改成:IDLE、BREW做萃取、STEAM打奶泡、WARM保温、CLEAN清洗。转移条件仍然是按键加定时完成,输出动作从加热管、电机换成萃取泵和蒸汽阀。改的时候只动soymilk_ctrl里的localparam和case分支,显示模块里的state_to_disp函数同步更新一套映射即可。

定时时长放在参数里传,是这个工程最好用的设计之一。改时序不用进入各个模块内部,顶层例化时重定义参数就够了:

stage_timer #( .CLK_HZ (20_000_000), .SECONDS (8) // 把萃取阶段改为8秒 ) u_brew_timer ( .clk (clk), .rst_n (rst_n), .start (brew_start), .clear (brew_clear), .done (brew_done) );

如果只是调整现有流程的节奏,这种改法连状态机都不用动。但要注意,改完参数后一定重新走一遍完整的仿真流程,尤其要验证取消键在各个新状态下都能正常退出。我在改版时吃过一次亏:只改了状态名忘了改取消分支,结果咖啡机在打奶泡阶段按取消键没有任何反应,边界条件才是状态机真正的坑。

验证方法上,建议保持“先状态表后代码”的习惯。拿到任何新的小家电流程,先在纸上画出状态转移表,标注每个状态的转移条件和输出动作,确认没有遗漏的取消入口,再照搬到状态机里。仿真时把定时参数尽量调小,先快速验证全部转移路径,再恢复真实参数做一次全流程回归。从那以后我每次做这类面板工程,都强制自己先画状态表,把状态名、转移条件、输出动作三列对齐,才开始写第一行Verilog。这份豆浆机面板工程,也建议你按照“状态表→模块连线→显示映射”的顺序去读代码,希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询