资讯详情

FPGA自动售货机控制与倒计时显示:Verilog状态机设计完整工程笔记

📅 2026/10/21 19:14:12 | 华诺云谱 👁 阅读
FPGA自动售货机控制与倒计时显示:Verilog状态机设计完整工程笔记
第一次把自动售货机的Verilog工程在Quartus II里完整跑通我花了三个晚上。真正卡住我的不是状态机也不是倒计时模块而是这两个模块在连线上互相“打架”——这大概是绝大多数做这个题目的同学都会撞上的地方。这篇文章就围绕自动售货机控制与倒计时显示这个经典项目把从需求拆分、模块设计、代码实现到上板调试的完整链路讲清楚当作一份可以照着走一遍的工程笔记。适合正要交EDA课程设计、准备电子竞赛或者刚开始接触FPGA状态机设计的人参考。先说一个前提不同学校对这个题目的具体指标要求不太一样有的要求双模式自动售卖自动找零有的要求支持多个商品价格。我这里按最常见的一个版本展开投币确认后选择商品进入15秒倒计时支付窗口倒计时结束未确认则退币取消交易确认支付后出货并回到空闲状态。这个流程虽然简单但已经覆盖了状态机、分频、消抖、倒计时、数码管扫描显示这几个FPGA入门绕不开的知识点做完之后换别的题目也能复用大部分代码。1. 项目拆解自动售货机到底要“控制”什么“显示”什么很多同学拿到题目第一反应是打开Quartus II写代码。我的建议是先别急拿张纸把需求拆清楚。这个项目表面上就两个词控制和显示但“控制”背后是一整套状态决策逻辑“显示”背后是一条独立的BCD数据链路。两者如果不提前规划好接口写代码的时候一定会被各种信号命名和时序关系绕晕。1.1 三层信号流输入、决策、输出之间的关系我习惯把整个系统拆成三个层次来看输入层投币按键、商品选择按键、支付确认按键这些按键信号经过消抖和边沿检测后变成干净的单时钟脉冲送给决策层。决策层核心状态机根据当前状态和输入信号决定下一步跳转到哪个状态同时产生倒计时使能、出货脉冲、退币脉冲等控制信号。输出层状态机产生的控制信号驱动LED、出货电机倒计时模块产生的BCD数据经过译码后送数码管显示。这个分层有一个实际好处调试的时候可以单独验证每一层。比如按键按下后先看消抖模块输出的脉冲是否正常再看状态机是否按预期跳转。如果一上来就把所有逻辑写在同一个always块里仿真波形一拉出来全是信号根本分不清是哪一层出的问题。1.2 “控制”的核心是状态机“显示”的核心是倒计时标题里把“控制”和“倒计时显示”并列容易让人误以为这是两个独立的加分项。实际上倒计时是状态机的“眼睛”——状态机需要知道支付窗口是否超时才能决定是出货还是退币。换句话说倒计时的归零信号是状态机的一个输入条件倒计时的启停由状态机控制。两者是双向耦合的关系这也正是这个项目比单纯跑马灯、数字钟更有锻炼价值的地方。我设计的核心流程是这样的上电后自动售货机处于空闲状态数码管显示预设的15秒倒计时初值按下投币键后进入投币状态此时可以选择商品按下商品选择键后进入倒计时支付状态倒计时开始每秒递减如果在倒计时归零前按下确认键进入出货状态电机动作如果倒计时归零仍未确认进入退币状态模拟退币动作结束后回到空闲。这里要注意一个细节倒计时显示的初值15甚至倒计时时长本身都应该做成可配置的参数。因为在代码调试阶段你可能想把15秒改成2秒来快速验证状态机跳转如果这个值散落在多个模块里每次改都要全局搜索。用parameter定义后改一处即可。1.3 模块划分与端口规划基于上面的分析我把工程划分成五个子模块外加一个顶层模块完成例化。模块名称和功能规划如下模块名功能主要对外信号clk_div分频产生1Hz倒计时时钟和1kHz扫描时钟clk_1hz, clk_1kkey_debounce四路按键消抖、同步、边沿检测key_coin, key_sel, key_ok, key_pos向量vending_fsm核心状态机控制整机流程state输出、cnt_en、motor_pulse、refund_pulsecountdownBCD倒计时计数器提供秒个位和十位sec_ones[3:0], sec_tens[3:0], timeout信号seg_scan数码管动态扫描显示seg[7:0], sel[3:0]顶层模块负责把这些模块连接起来信号命名统一采用小写加下划线。这里我想强调一个习惯顶层里的wire信号命名一定要和模块端口名保持一致比如vs_sec_tens这种带前缀的命名方式虽然多敲几个字母但仿真时看波形信号列表一眼就能定位是哪条链路省下的调试时间绝对超过多敲的这几下键盘。2. 状态机设计与倒计时的联动机制真正容易出问题的部分状态机是决策层的核心。很多教材喜欢用二进制编码表示状态但我在这个项目里推荐独热码五个状态用五位的独热码每个状态占一位。原因有两个一是可读性好 parameter定义的IDLE、COIN、SEL、DISP、REFUND一眼就能看懂二是独热码的状态译码逻辑简单组合逻辑路径短不容易出现显隐竞争。代价是多用几个触发器对Cyclone IV这种器件来说完全不是问题。2.1 五个状态的定义与跳转条件我把状态转移关系整理成下面这张表写代码之前先把这个表画出来后面所有逻辑都围绕它展开当前状态跳转条件下一状态关键动作IDLE投币按键上升沿COIN倒计时预装载15COIN商品选择按键上升沿SEL启动倒计时使能SEL确认按键上升沿DISP停止倒计时输出出货脉冲SEL倒计时归零timeoutREFUND停止倒计时输出退币脉冲DISP出货计时完成IDLE复位所有输出REFUND退币计时完成IDLE复位所有输出这里有一个容易忽略的问题SEL状态下确认按键和倒计时超时理论上可能出现在同一个时钟沿附近。比如倒计时最后一秒用户卡着点按确认键。如果代码写成两个独立的if可能出现两个状态同时被赋值的竞争问题。我的处理方式是在状态跳转逻辑里给确认键更高的优先级先判断确认按键脉冲再判断timeout信号。理由是和用户直觉一致用户既然按了确认就应当认为交易成功即使他的动作稍微晚于理论截止点也应该宽容处理。2.2 为什么倒计时寄存器要放在状态机外面这是个设计取舍问题。把计数器直接写在状态机的always块里当然能跑但后续维护会很难受状态机的每个状态都要去判断计数器当前值、处理进位借位状态机代码会膨胀到很难读。而且倒计时需要同时服务多个状态——IDLE里预装载、SEL里递减、DISP里保持——这些控制逻辑放在状态机里会让状态机变成一团乱麻。倒计时独立成模块后状态机只需要给它两个信号预装载信号load和使能信号cnt_en。倒计时模块内部维护秒计数归零时拉高timeout输出。状态机在SEL状态时把cnt_en拉高在跳转到DISP或REFUND时把cnt_en拉低各自职责清晰仿真时也能单独观察sec_ones和sec_tens的波形是否正确。2.3 状态机核心代码与优先级处理下面是vending_fsm模块的核心代码框架。这里用的三段式写法里的第一段和第二段状态跳转和输出控制分开便于阅读module vending_fsm ( input wire clk, input wire rst_n, input wire key_coin, input wire key_sel, input wire key_ok, input wire timeout, output reg cnt_en, output reg load_cnt, output reg motor_pulse, output reg refund_pulse, output reg [4:0] state ); parameter IDLE 5b00001; parameter COIN 5b00010; parameter SEL 5b00100; parameter DISP 5b01000; parameter REFUND 5b10000; reg [4:0] next_state; always (posedge clk or negedge rst_n) begin if (!rst_n) state IDLE; else state next_state; end always (*) begin next_state state; case (state) IDLE: if (key_coin) next_state COIN; COIN: if (key_sel) next_state SEL; SEL: begin if (key_ok) next_state DISP; else if (timeout) next_state REFUND; end DISP: if (disp_done) next_state IDLE; REFUND: if (refund_done) next_state IDLE; default: next_state IDLE; endcase end always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt_en 1b0; load_cnt 1b0; motor_pulse 1b0; refund_pulse 1b0; end else begin case (next_state) SEL: cnt_en 1b1; default: cnt_en 1b0; endcase // 进入SEL前一个周期需要load_cnt拉高预装载 // 这里通过组合逻辑或时序逻辑实现均可 end end endmodule需要说明的是上面代码里disp_done和refund_done这段我通常用两个小计数器实现。出货动作需要让电机驱动引脚保持一段时间高电平比如持续2秒这时候在状态机内部再例化一个简单的定时计数器即可。还有load_cnt信号的时序要特别注意它需要在进入SEL状态之前产生一个脉冲让倒计时模块把15写入寄存器然后在SEL状态下cnt_en拉高开始递减。不少第一次写状态机的同学会漏掉default分支。Verilog的case如果没有default综合工具可能会推断出锁存器状态机一旦受到干扰跳到未定义的状态就再也没法回来了。正确写法里default无条件回到IDLE。这是综合告警里最常见也最值得重视的一条经验。3. 分频、按键消抖与BCD倒计时三大基础电路一次讲透状态机画好之后剩下三个基础电路模块支撑它运行分频器提供时钟消抖电路把物理按键信号变成干净的逻辑脉冲倒计时模块提供秒计数。这三个电路单独拿出来都很简单但放在一起时有一堆细节值得注意。3.1 50MHz到1Hz的分频系数计算与参数化写法开发板上的晶振通常是50MHz而倒计时需要1Hz的秒时钟。分频系数的计算是50_000_000次时钟翻转对应1秒。我这里采用“计数到半周期取反”的方式生成占空比50%的方波计数器需要计到25_000_000。位数怎么确定2的25次方是33,554,432大于25,000,000所以一个25位寄存器就够。模块写成参数化形式module clk_div #( parameter DIV_NUM 50_000_000 )( input wire clk, input wire rst_n, output reg clk_out ); reg [24:0] cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt 25d0; clk_out 1b0; end else if (cnt (DIV_NUM/2 - 1)) begin cnt 25d0; clk_out ~clk_out; end else begin cnt cnt 1b1; end end endmodule参数化最大的好处是仿真提速。实际工程里验证倒计时如果用真实的1Hz时钟仿真跑到超时判断需要15秒的虚拟时间波形拉出来几千个时钟周期完全没法看。我的做法是在例化时覆盖参数比如例化成DIV_NUM100仿真相隔50个时钟周期就翻转一次很快就能看到秒计数的递减过程。上板之前改回50_000_000重新综合即可。3.2 按键消抖机械抖动、亚稳态和边沿检测按键是机械触点按下和松开的过程中会有大约5到20毫秒的抖动表现为电平在高低之间来回跳。如果不做消抖状态机可能在一个按键动作里被触发多次比如按一下投币键显示屏上的金额或状态直接跳了好几个。我的消抖思路是用一个1kHz的采样时钟连续采样到20次相同电平才认为按键状态稳定。另一个容易被忽视的是亚稳态问题。按键信号相对FPGA时钟是异步的直接用这个信号驱动状态机有概率采到正在变化的中间电平。工程上标准做法是先经过两级触发器同步再进消抖逻辑。我习惯把“同步消抖边沿检测”做在同一个模块里输出single-shot脉冲每个按键动作只产生一个时钟周期的高电平状态机只需要检测这个脉冲的上升沿。下面给出单个按键的处理思路// 两级同步器 reg key_sync1, key_sync2; always (posedge clk or negedge rst_n) begin if (!rst_n) begin key_sync1 1b1; key_sync2 1b1; end else begin key_sync1 key_in; key_sync2 key_sync1; end end // 1kHz采样下连续20次一致才算稳定 reg [4:0] deb_cnt; reg key_stable; always (posedge clk_1k or negedge rst_n) begin if (!rst_n) begin deb_cnt 5d0; key_stable 1b1; end else if (key_sync2 ! key_stable) begin deb_cnt 5d0; key_stable key_sync2; end else if (deb_cnt 5d19) begin key_stable key_sync2; end else begin deb_cnt deb_cnt 1b1; end end // 边沿检测打一拍后取上升沿 reg key_r; always (posedge clk or negedge rst_n) begin if (!rst_n) begin key_r 1b1; end else begin key_r key_stable; end end assign key_pos key_stable ~key_r;注意我这里默认按键按下为低电平所以初始值都设为1。不同开发板的按键电路可能相反接成高电平有效的话要把初始值和判断逻辑反向。这个细节直接决定上板之后按键是不是“反着来”。3.3 BCD倒计时借位逻辑从15减到0的正确写法倒计时模块用BCD码保存计数值个位和十位各4位这样送到数码管时可以不做二进制转BCD的额外转换。整个倒计时从15开始个位是5、十位是1。每秒减1最关键的逻辑在于个位从0减到9时需要向十位借位。很多第一次写的同学在这里出错写出来的计数器在个位为0时继续减结果0变成9、再变成8与十位完全没有联动。推荐写法是先判断个位是否为0。如果个位不为0个位直接减一如果个位为0且允许继续计数个位置9十位减一。十位减到0且个位为0时产生归零信号。同时要让使能信号与归零信号做“与”运算避免归零后时钟继续驱动导致个位再次从9往下走形成第二轮倒计时。参考代码module countdown ( input wire clk_1hz, input wire rst_n, input wire load, input wire en_cnt, output reg [3:0] sec_ones, output reg [3:0] sec_tens, output wire timeout ); wire zero (sec_tens 4d0) (sec_ones 4d0); wire cnt_en en_cnt (~zero); assign timeout zero; always (posedge clk_1hz or negedge rst_n) begin if (!rst_n) begin sec_ones 4d5; sec_tens 4d1; end else if (load) begin sec_ones 4d5; sec_tens 4d1; end else if (cnt_en) begin if (sec_ones 4d0) begin sec_ones 4d9; if (sec_tens 4d0) sec_tens 4d0; else sec_tens sec_tens - 1b1; end else begin sec_ones sec_ones - 1b1; end end end endmodule这里load信号的优先级比cnt_en高保证每次进入SEL状态前状态机先把倒计时初值装进去避免上次残留值继续参与递减。另外提醒一点这里用的是1Hz时钟驱动的同步时序逻辑不复用系统时钟再去做全局时钟使能方案简单可靠秒计数之间的毛刺风险也完全可以接受。4. 数码管动态扫描显示与顶层模块对接倒计时数据最终要显示在数码管上。如果每个数码管的8个段都单独占用FPGA引脚4位数码管需要32个引脚太浪费。动态扫描就是让4个数码管轮流点亮利用人眼视觉暂留形成“同时显示”的错觉。代价是显示驱动逻辑复杂一点但引脚从32根降到8根段选加上4根位选共12根这个交换非常划算。4.1 扫描频率从哪来从视觉暂留出发反推人眼对50Hz以上的闪烁基本没有感知所以每一位数码管的刷新率不能低于50Hz。4位轮流扫描意味着扫描时钟周期乘以4之后要小于20毫秒。我取每位刷新率250Hz对应的扫描时钟就是1kHz每个数码管点亮1毫秒4位循环一遍4毫秒远高于视觉暂留要求既无闪烁亮度也均匀。1kHz扫描时钟从哪里来同样是分频模块。实际工程里我的clk_div模块例化了两次一次输出1Hz分频参数50_000_000一次输出1kHz分频参数50_000。你也可以做一个参数可配置的分频器例化两次时传入不同参数。这样写的好处是模块足够通用以后做数字钟、秒表还能直接用。4.2 消隐处理与扫描代码动态扫描最容易出的视觉问题是“拖影”和串扰。原因是位选信号切换时段选数据还停留在上一位的数值上导致新点亮的位短暂显示了上一位的内容。解决方法是插入一个消隐周期先关闭所有位选再更新段选数据最后重新打开目标位选。顺序上“先灭、再换数据、再点亮”三者必须在同一个扫描节拍内完成。参考代码如下reg [1:0] scan_cnt; reg [3:0] sel_out; reg [7:0] seg_out; wire [3:0] disp_data; wire [7:0] seg_code; always (posedge clk_1k or negedge rst_n) begin if (!rst_n) begin scan_cnt 2d0; sel_out 4b0000; seg_out 8b1111_1111; end else begin // 第一步先全灭 seg_out 8b1111_1111; // 第二步根据当前位选切换disp_data组合逻辑得到seg_code // 第三步打开新的位选 case (scan_cnt) 2d0: begin disp_data sec_ones; sel_out 4b1110; end 2d1: begin disp_data sec_tens; sel_out 4b1101; end 2d2: begin disp_data 4d0; // 第三位可以显示商品编号或空 sel_out 4b1011; end 2d3: begin disp_data 4d0; sel_out 4b0111; end endcase scan_cnt scan_cnt 1b1; seg_out seg_code; end end这里用了低电平有效的段选和位选具体是否取反要看开发板原理图这是上板前必须确认的一项。还有小数点dp位一般不用就置为无效电平。4.3 顶层例化的端口对接细节顶层模块是最后组装的地方也是新手最容易被综合器“教育”的地方。我的顶层里把五个子模块全部例化出来信号连接遵循按名连接不按位置连接。按位置连接在模块端口顺序变化时非常容易错位按名连接则每次例化都清清楚楚。顶层里容易犯的错误是端口位宽不匹配比如sec_ones声明为[3:0]接出去时写成4位没问题但有些人习惯把零散的wire信号连接时漏掉某一位综合器可能给出端口宽度不匹配的告警这种隐性问题在仿真里不一定暴露。还有一个高频问题未连接的输入端口。状态机的disp_done信号如果忘了从定时计数器引出或者直接接地状态机会卡在出货状态出不来。我建议在顶层里给每个子模块的输入都明确接上信号哪怕暂时不用也要接到常数上避免悬空。Quartus II对悬空输入往往只是告警不会报错这类问题查起来非常费精力。5. Quartus II上综合仿真与上板调试的问题记录代码写完只是第一步。我自己的经验是这个项目至少有一半的时间花在仿真和上板调试上。这里把我实际遇到过的问题按阶段整理出来有些问题现在回头看简直哭笑不得但当时确实卡了很久。5.1 仿真阶段容易被忽略的三个陷阱第一个陷阱是复位信号的释放时机。testbench里我习惯写#100后再拉高复位但需要确保复位释放的时刻离时钟上升沿有一定距离。如果复位信号和时钟沿同时变化仿真器可能给出仿真不确定态状态机第一个周期就进不了IDLE。稳妥做法是在复位释放前先让时钟自由跑几个周期再在时钟下降沿附近释放复位。第二个陷阱是分频系数拦路。用真实50_000_000参数做仿真等一个1Hz时钟需要5000万个仿真周期倒计时还没开始仿真时间已经爆炸了。建议把分频模块做成可配置参数仿真时例化DIV_NUM比较小的版本比如DIV_NUM10一个仿真周期很快就能过去专门验证倒计时借位逻辑。上板前再改回真实参数。第三个陷阱是观察信号选错。Quartus II自带的仿真工具里默认可能不会把内部reg信号添加到波形窗口。我仿真状态机时最关心state[4:0]、sec_ones、sec_tens、timeout这几个内部信号如果没添加拉出来的波形永远只有顶层输入输出根本没法定位问题。建议仿真前把工程层次里需要观察的信号手动加入波形列表。5.2 综合告警里隐藏的逻辑隐患Quartus II综合完成之后的告警窗口值得花时间逐条过一遍尤其注意下面几类推断锁存器告警通常出现在组合逻辑always块里没有覆盖所有分支。解决方法是补全if-else或case的default分支。状态机不符合项告警Quartus可以对状态机做自动编码优化但如果你已经手动指定了独热码parameter建议关闭状态机自动编码或者使用综合属性保留原有编码否则可能出现仿真与综合行为不一致。时钟相关告警如果发现“clk_1hz is not a clock”之类的提示说明分频后的信号被当成普通信号处理。倒计时模块确实靠它驱动这时候可以考虑在工程里添加基本的时序约束或者至少在芯片规划器里检查一下全局时钟资源的使用情况。另外强烈建议不要忽视latch告警。有的新手认为“反正FPGA里没有真实锁存器告警就告警”但综合工具为了满足这种写法会引入额外的组合逻辑反馈路径轻则浪费资源重则出现功能异常。认真对待每一条告警是FPGA调试里最值得养成的习惯。5.3 上板调试按什么顺序做最省时间上板调试不能直接“一把梭”——把所有代码烧进去发现不对再到处猜。我推荐的顺序是分模块验证每验证一层就烧一次板验证分频和LED先只烧分频器把1Hz信号接到板载LED上看LED是否以约1秒为周期闪烁。这一层通过说明时钟链路和引脚分配没问题。验证按键消抖把按键的key_pos信号接到LED按下一次LED翻转一次。如果LED一次跳多下说明消抖阈值或边沿检测逻辑有问题。验证倒计时先把倒计时输出的sec_ones接到LED或数码管上人工输入load和en_cnt信号确认递减和归零正确。验证状态机把state[4:0]接到5个LED上模拟投币、选品、确认、超时观察LED的亮灭组合是否按状态表跳转。这一步不需要数码管也能完成强烈建议先做因为LED的二进制亮灭比数码管的内容更好判断。最后联调显示所有模块都接上此时如果显示异常问题基本锁定在数码管扫描或译码部分排查范围缩小很多。按这个顺序我遇到过的几种常见故障也能快速定位故障现象可能原因排查方向数码管显示乱跳、拖影明显扫描频率太低或缺少消隐提高1kHz扫描时钟确认位选切换顺序某一位永远不亮位选信号错误或引脚没分配用示波器或LED检查sel信号复查引脚约束倒计时不走或一次跳好几秒1Hz分频异常、使能信号时序不对先看LED闪烁频率再看cnt_en时序按键按下去偶尔失灵消抖时间不够或亚稳态未处理增大消抖计数阈值检查两级同步器状态机卡死没有任何反应缺default分支或复位没接对检查综合告警查看state信号仿真波形还有一个值得提的实用技巧不要把内部信号只留着仿真看可以把state、timeout等关键信号引到扩展IO口上用逻辑分析仪或者示波器抓。这样上板后能直接观察状态机的真实跳转而不只是依赖肉眼判断数码管显示的对错。最后说一点个人体会。很多人做这个项目喜欢把“显示”放在第一个做因为数码管亮起来很有成就感。但实际开发顺序应该是反过来的先把状态机和倒计时这条“控制链路”用LED验证通再去做数码管显示。因为显示本身依赖倒计时数据如果倒计时逻辑不对显示出来的数字再漂亮也没有意义。先分模块验证、后联调看起来每一步都在“绕远路”实际却是整个调试过程里最快的一条路。我第一次就是反着来结果状态机出了问题时数码管上的数字干扰了我对故障的判断白白浪费了一整晚。希望你不用再踩这个坑。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑