简介:东南大学信息学院短学期数字系统设计课程配套的FPGA自动售货机项目源代码,面向正在学习数字电路与FPGA开发的本科生,以经典自动售货机为对象,完整演示了从需求分析、HDL编码到仿真综合、下载上板的工程流程。压缩包共120个文件、约853KB,涵盖Verilog/VHDL源文件(.v/.vhd)、Xilinx ISE工程配置(.xise/.prj)、综合与仿真中间文件(.ngc/.bit/.wdb)、以及C语言辅助文件等,其中bit文件可直接用于FPGA板卡验证,便于读者对照理解状态机跳转、商品识别、投币找零、数码管与按键接口等关键设计。目前已有662人学习下载,适合用做实操参考、课程设计模板或短学期验收准备。研究该项目代码及工程目录,能帮助快速掌握FPGA开发工具链使用、时序约束设置和在线调试方法,是一份贴近教学实际的高质量样例。
1. FPGA自动售货机:短学期数字系统设计最值得复现的一个工程
如果你正在经历数字系统设计的短学期课程,大概率会被要求独立完成一个完整的FPGA工程。而自动售货机这个题目,几乎各地高校都在用,因为它踩中了数字系统设计最核心的知识点:状态机、时序控制、按键消抖、数码管/液晶显示、仿真验证。我手里这份资源是一整套FPGA自动售货机的源代码和工程文件,包含完整的Verilog实现、约束文件、仿真testbench和配套文档,不是零散代码片段,而是能直接落地的工程级模板。适合两类人:一是课程进行中需要参考完整写法的同学,二是想通过一个完整项目把状态机设计彻底吃透的从业者。本文按我实际拆解这套代码的顺序,把架构选型、核心模块、仿真参数、板级调试逐一讲清,重点放在那些你照着课本写不出来、但板上调试时一定会撞上的细节。
2. 状态机设计:自动售货机控制核心的状态编码与三段式写法
2.1 为什么状态机是这台机器的灵魂
自动售货机的业务逻辑本质是一个离散事件响应系统:投币、选择商品、出货、找零,每一步都对应一个确定的状态,并且状态转移由外部输入(按键、投币信号)驱动。用状态机描述这台机器,是数字系统设计课程最基本的训练目标,也几乎是所有FPGA实现的共同选择。
关于状态机的写法,业内主要有两种流派。一种是单段式,把状态转移和输出写在一个always块里,代码短,但组合逻辑和时序逻辑混在一起,时序收敛差,后期维护也头疼。另一种是三段式,把状态转移、次态逻辑、输出逻辑分别用三个always块描述。我拿到这套源代码后发现它的控制模块用的是标准三段式Moore状态机——输出只取决于当前状态,不直接受输入影响,这让波形分析变得非常直观,仿真调试时看状态编码和输出的对应关系,一眼就能定位问题。
三段式的核心价值在于把时序逻辑和组合逻辑物理隔离:第一个always块负责状态寄存器的同步更新,第二个always块负责根据当前状态和输入计算次态,第三个always块负责根据当前状态生成输出。这样写出来的代码综合后结构清晰,时序路径短,尤其是状态多的时候优势更明显。
2.2 状态编码与参数定义
在自动售货机这个场景里,状态数量取决于你设计的业务复杂度。最简单版本只有三个状态:空闲、投币、出货。带找零的版本则至少要五个状态。这套源代码定义了一个比较完整的状态集,我把它整理成参数表方便对照:
| 状态名 | 参数定义 | 状态含义 | 输出动作 |
|---|---|---|---|
| IDLE | 4'd0 | 空闲状态,等待投币 | 无 |
| COIN_INSERT | 4'd1 | 投币累计中,等待选择商品 | 显示当前金额 |
| SELECT | 4'd2 | 商品已选择,等待确认或取消 | 显示商品价格 |
| CONFIRM | 4'd3 | 确认购买,开始扣款 | 扣减金额 |
| DISPENSE | 4'd4 | 出货中,产生出货脉冲 | 输出商品、找零信号 |
| CHANGE | 4'd5 | 找零计算与发放 | 输出找零脉冲 |
| ERROR | 4'd6 | 余额不足或异常状态 | 报警 |
状态编码采用4位独热码显然浪费,这里用二进制编码即可,4位能覆盖全部7个状态。代码中parameter定义如下:
parameter IDLE = 4'd0, COIN_INSERT = 4'd1, SELECT = 4'd2, CONFIRM = 4'd3, DISPENSE = 4'd4, CHANGE = 4'd5, ERROR = 4'd6;用localparam还是parameter,在模块内部建议用localparam,避免外部模块误引用内部状态编码。这套代码用了parameter,不影响功能,但从代码规范角度我可以给个改进建议。状态转移的核心逻辑集中在第二个always块里,代码如下:
always @(*) begin case (current_state) IDLE: begin if (coin_in) next_state = COIN_INSERT; else next_state = IDLE; end COIN_INSERT: begin if (select_key) next_state = SELECT; else if (cancel_key) next_state = IDLE; else next_state = COIN_INSERT; end // 其余状态转移逻辑类似 default: next_state = IDLE; endcase end这里说明一下设计意图:IDLE状态下检测到投币信号coin_in,进入投币累计状态;在COIN_INSERT状态下用户可以选择商品或取消;进入SELECT后等待确认,如果余额足够则进入CONFIRM,不够则回到COIN_INSERT继续投币。这样设计的优点是每次状态转移只依赖一个明确的输入条件,组合逻辑简单,综合后不会出现长路径延迟。
2.3 输出逻辑与时序配合
第三个always块负责生成输出信号。需要特别注意的一点是,Moore状态机的输出是当前状态的函数,所以输出信号的变化会比状态跳变晚一个时钟周期。这在硬件上表现为:状态机已经跳转到DISPENSE,但out_valid信号要到下一个时钟沿才拉高。如果你的后端电路对时序要求严格(比如直接驱动外部电平敏感的锁存器),这个一拍延迟必须算进时序预算里。
这套代码中输出逻辑的关键片段:
always @(*) begin case (current_state) DISPENSE: begin dispense_pulse = 1'b1; change_en = 1'b0; end CHANGE: begin change_pulse = 1'b1; dispense_pulse = 1'b0; end default: begin dispense_pulse = 1'b0; change_pulse = 1'b0; change_en = 1'b0; end endcase end这里dispense_pulse和change_pulse都是单周期脉冲信号,用来驱动外部模块的使能端。实际验证中发现一个坑:如果外部执行机构(模拟电机、LED指示灯)需要持续高电平驱动而不是脉冲,单周期脉冲可能不够,需要在外围加一个锁存器把脉冲保持住,或者在状态机中增加一个延时状态,让DISPENSE状态持续多个时钟周期。这套代码的处理方式是增加了一个计数器,在DISPENSE状态中计数到设定值后才跳转,类似延时退出的思路,这点设计得很实用。
3. 按键消抖与数码管显示:FPGA与外部交互的两个关键细节
3.1 机械按键消抖的计数器实现方案
这是整套代码里最有工程味道的部分。课本上讲消抖通常只给原理图,说RC电路加施密特触发器,但在FPGA里,板上已经集成了按键电路,你没法改硬件,只能靠逻辑消抖。这套代码用的是计数器消抖方案——检测到按键电平变化后,启动一个20ms左右的计数器,计数期间忽略输入变化,计数结束后再次采样确认电平稳定。
代码实现如下:
reg [19:0] debounce_cnt; reg key_in_d1, key_in_d2; wire key_stable; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin debounce_cnt <= 20'd0; key_in_d1 <= 1'b1; key_in_d2 <= 1'b1; end else begin key_in_d1 <= key_in; key_in_d2 <= key_in_d1; if (key_in_d1 != key_in_d2) begin debounce_cnt <= 20'd0; // 检测到变化,清零重新计数 end else if (debounce_cnt < 20'd999_999) begin debounce_cnt <= debounce_cnt + 1'b1; // 稳定计数 end end end assign key_stable = (debounce_cnt == 20'd999_999) ? key_in_d2 : key_in_d1;这段代码的消抖思路是:持续采样按键电平,用两个寄存器做打拍同步,当相邻两次采样值不同时,说明按键还在抖动,计数器清零;当采样值连续稳定达到设定时间后,才认为按键真正按下。这里20'd999_999配合50MHz时钟,对应约20ms的消抖时间。这个时间常数不是固定死的,如果你用25MHz时钟,需要改成20'd499_999;用100MHz则改成20'd1_999_999。很多同学在这个参数上翻车,直接把50MHz的代码搬到其他开发板,导致消抖失效或者按键响应迟钝。
需要补充的是,按键输出去抖后要产生单周期脉冲。代码中通常会在消抖信号稳定后,再打一拍做上升沿检测:
reg key_stable_d; always @(posedge clk) key_stable_d <= key_stable; wire key_pulse = key_stable & !key_stable_d;这样每次按键只会产生一个时钟周期的脉冲,避免状态机在一个按键按下期间被多次触发。这个细节如果漏掉,状态机会在按键按住期间反复跳转,表现就是投币一次计入了多次金额。
3.2 数码管动态扫描显示的利益权衡
这套开发板配的是6位数码管,驱动方式采用动态扫描。动态扫描的原理是:数码管的段选线共用,位选线独立,利用人眼视觉暂留效应,快速轮流点亮每一位。这样只需要一套段选驱动逻辑加一个位选译码器,IO占用从6×8=48个降到8+6=14个,对FPGA的IO资源压力大幅降低。
扫描时钟的设计有个经验值:每个数码管点亮时间约1~4ms,6位数码管一轮扫描周期控制在6~24ms内,刷新率约40~160Hz,低于40Hz会明显闪烁。代码中用了一个分频计数器实现扫描时钟:
reg [15:0] scan_cnt; reg [2:0] scan_pos; wire scan_clk_en; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin scan_cnt <= 16'd0; scan_pos <= 3'd0; end else begin scan_cnt <= scan_cnt + 1'b1; if (scan_cnt == 16'd9999) begin // 1kHz scan_cnt <= 16'd0; scan_pos <= scan_pos + 1'b1; if (scan_pos == 3'd5) scan_pos <= 3'd0; end end end这里16'd9999在50MHz时钟下得到约5kHz的频率,每位数码管点亮约0.2ms,刷新率约830Hz,肉眼完全看不到闪烁。其实这个频率偏快,个人测试下来1~2kHz就已经很稳定了,留有余量应对不同的数码管响应速度。扫描位置scan_pos依次指向第0到第5位,每个周期切换一次位选信号。
动态扫描的一个隐藏风险是位选信号和段选信号之间的切换毛刺。当扫描位置刚切换时,段选数据还没来得及更新到新一位的内容,这一瞬间数码管上会短暂显示上一帧的数据,表现为轻微的重影或亮度不均。两个解决办法:一是在位选切换前后插入一段空操作周期,即先关掉所有位选(位选全无效),再更新段选数据,最后开启目标位选;二是把段选寄存器的更新放在位选切换的同一时钟沿,利用寄存器同步避免组合逻辑竞争。这套代码采用的是第二种方案,段选输出打了一拍寄存器,实际效果清晰无重影。
3.3 金额显示的数据通路
投币金额要实时显示在数码管上,这里涉及二进制数到BCD码的转换。开发板上数码管段选驱动接收的是标准BCD码,但状态机里维护的金额变量是二进制数。直接除以10和取模可以完成转换,但在FPGA里用除法器综合出来的资源较大,对时序也有影响。更好的做法是使用二进制转BCD的移位加3算法,代码紧凑且无需除法器:
reg [7:0] bin_value; reg [11:0] bcd_value; integer i; always @(*) begin bcd_value = 12'd0; for (i = 7; i >= 0; i = i - 1) begin bcd_value = {bcd_value[10:0], bin_value[i]}; if (i < 7 && bcd_value[3:0] > 4'd4) bcd_value[3:0] = bcd_value[3:0] + 4'd3; if (i < 7 && bcd_value[7:4] > 4'd4) bcd_value[7:4] = bcd_value[7:4] + 4'd3; if (i < 7 && bcd_value[11:8] > 4'd4) bcd_value[11:8] = bcd_value[11:8] + 4'd3; end end这段代码逻辑上等价于对每一位做“大于4加3”的判断,本质上是一个逐位迭代的算法。需要注意bcd_value[11:0]宽度要和转换的最大值匹配。如果金额上限是9.9元即99,用8位二进制足够,BCD结果占12位;如果你把商品定价扩展到几十元,需要相应加宽。在这套代码里金额显示模块单独放在display_top.v中,状态机只负责把total_amount数值传给它,这样显示逻辑就算改动也不会影响控制模块。
4. 仿真验证与板级调试:一套可以少走弯路的测试流程
4.1 testbench结构与激励时序参数
拿到源码后第一件事不是上板,而是跑仿真。这套代码自带一个功能完整的testbench,测试场景覆盖了正常购买、余额不足、找零、取消购买四条路径。testbench的核心是构造真实时序的激励信号,不能只是简单地把输入拉高拉低。
一个合格的testbench要包含:时钟和复位信号的初始化、模拟按键输入的毛刺信号(故意制造抖动来验证消抖模块)、完整的状态机行为注入点、必要的内部信号观测(通过层次化引用来查看内部状态寄存器的值)。
以下是这套testbench中关键激励部分的写法:
initial begin clk = 0; rst_n = 0; coin_in = 0; select_key = 0; cancel_key = 0; #100; rst_n = 1; // 模拟第一次投币:1元 #200; coin_in = 1; #100; coin_in = 0; // 模拟按键抖动:2ms内多次翻转 #200; select_key = 1; #1; select_key = 0; #1; select_key = 1; #1; select_key = 0; #1; select_key = 1; #200; select_key = 0; // 观察状态变化和金额显示 #1000; $finish; end这里模拟按键抖动那段很关键:如果没有消抖模块,这串脉冲会被状态机识别成多次按键动作,导致逻辑混乱。跑仿真时你可以用这个激励分别测试带消抖和不带消抖的版本,直观看到消抖的必要性。注意,仿真时钟频率通常设置成和实际板子一致,即50MHz,这样仿真波形的时间参数和真实板上完全对应,#200代表200个时间单位的延时,在该testbench中对应4微秒。
4.2 Modelsim/Questa跑仿真的具体操作
很多同学卡在仿真工具的使用上,不是代码的问题。这里给一套我在多台机器上验证过的流程,以Questa/ModelSim为例:
第一步,建立一个仿真工程目录,把所有源文件和testbench放到同一个文件夹。第二步,在Transcript窗口逐条执行编译命令:
vlib work vlog -sv vending_machine_top.v vending_machine_fsm.v debounce.v display_top.v tb_vending_machine.v vsim -novopt work.tb_vending_machine注意-novopt参数在某些新版本中已被默认关闭,如果报不支持可以不加。另外如果编译时出现vlog找不到模块的错误,检查是否所有文件都被包含在编译列表里,Verilog对文件编译顺序有要求,被例化的子模块必须先编译,所以把顶层文件放最后编译。
仿真跑完后的波形查看是定位问题的关键。打开Wave窗口后,需要把状态机内部信号拖进去观察。可以通过层次结构展开tb下的DUT内部信号:
add wave -divider "FSM内部状态" add wave -position end sim:/tb_vending_machine/dut/current_state add wave -position end sim:/tb_vending_machine/dut/next_state add wave -position end sim:/tb_vending_machine/dut/total_amount add wave -position end sim:/tb_vending_machine/dut/dispense_pulse add wave -position end sim:/tb_vending_machine/dut/change_pulse观察current_state的波形变化是否符合预期,重点检查状态跳转的时刻和输入信号的对应关系。如果发现状态没有按预期跳转,先看coin_in或select_key的信号是否被正确采样,再看时钟沿是否对齐。
4.3 上板调试的常见故障对照
仿真通过不上板正常,这是数字系统设计的经典魔咒。结合这套代码的调试记录,我整理了一份故障对照表,按出现频率排序:
| 故障现象 | 大概率原因 | 排查方法 |
|---|---|---|
| 按键完全无响应 | 复位信号没释放或按键未消抖 | 用逻辑分析仪抓按键信号,检查复位时序 |
| 按键响应一次但触发多次 | 消抖计数器宽度不够,抖动未完全滤除 | 增大消抖时间到30~50ms |
| 数码管显示乱码 | 段选信号和位选信号时序未对齐 | 检查段选寄存器是否打拍,位选是否关闭 |
| 投币金额不对 | coin信号被当成持续高电平而非脉冲 | 检查上升沿检测逻辑,确认按键产生的是单周期脉冲 |
| 出货信号不出现 | 状态机卡在CONFIRM | 查看余额判断条件,是否因为比较器位宽不足 |
| FPGA不识别代码 | 未设置正确的顶层模块 | 检查综合设置中top module是否为vending_machine_top |
上板调试的步骤有讲究:先单独测数码管扫描——写一个固定显示"123456"的测试模块,确认显示通路正常;再测按键输入——把按键状态直接连到LED灯,按下去灯亮、松开灯灭,确认消抖后的信号正确。这两步通过后再把状态机接进去,调试效率最高。这套源代码的顶层文件里其实也包含了类似的自检模式,注释里写明了通过拨码开关切换测试模式,这个设计很贴心,可以快速排除硬件问题。
5. 避坑指南:数字系统设计FPGA实现中反复出现的五个问题
5.1 复位信号的同步处理
现象:复位释放瞬间,状态机偶尔进入未知状态,运行一段时间后功能紊乱。
原因:外部按键或板上电源产生的复位信号是异步的,直接作为异步复位用,其释放时刻与时钟沿的关系不确定,导致状态寄存器出现亚稳态。
解决:把异步复位信号先经过两级同步器打拍,再用同步后的信号做复位。修改后的复位逻辑如下:
reg rst_n_d1, rst_n_d2; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin rst_n_d1 <= 1'b0; rst_n_d2 <= 1'b0; end else begin rst_n_d1 <= 1'b1; rst_n_d2 <= rst_n_d1; end end wire rst_n_sync = rst_n_d2;所有时序逻辑的复位端都接rst_n_sync,确保复位释放与时钟沿对齐。这在原先的课程项目里没被强调,但工程上这是最基本的要求。
5.2 状态机的default分支遗漏
现象:上板操作了十几分钟后,机器突然卡死,怎么按都没反应。
原因:状态机在综合时,如果没有覆盖所有状态编码,出现未定义状态时跳到哪里不定,FPGA实际运行中由于干扰或未初始化,状态寄存器可能落到未定义编码上,没有default就永远停在死循环里。
解决:所有case语句必须带default分支,并且default要回到最安全的IDLE状态或复位状态。哪怕设计时觉得所有case已覆盖,default仍然要写。这是血泪经验,很多同学在仿真时怎么也复现不了,一上板就被打脸。
5.3 时钟分频方式选择
现象:VGA或数码管显示出现闪烁,时序错乱。
原因:用always块里的计数器产生分频时钟,输出时钟存在相位噪声和占空比不稳定的问题,用它做其他模块的时钟时触发时序违规。
解决:对于数码管扫描这种低速应用,更好的方式是用时钟使能信号替代分频时钟。即保持全局时钟不变,用计数器产生一个使能脉冲,模块内部在使能有效时才进行状态更新。这样全局始终只有一个时钟域,避免跨时钟域问题:
always @(posedge clk) begin if (scan_enable) begin scan_pos <= scan_pos + 1'b1; end end5.4 位宽不匹配导致金额计算错误
现象:投了5元买2.5元的商品,找零显示正常,但再买一罐时余额变成负数。
原因:total_amount和商品价格减法运算时,位宽不足导致借位溢出。比如用6位二进制表示金额,最大只能表示63分即0.63元,显然不够用。
解决:统一所有金额信号位宽,并考虑符号位。建议把金额单位设为分(整数),如1元=100分,商品2.5元=250分,这样最大金额99.9元=9990分,用14位二进制足够。代码里如果用了[7:0]表示金额,最大255分即2.55元,一旦超过立即溢出。
5.5 综合后仿真与功能仿真行为不一致
现象:功能仿真一切正常,综合后仿真或上板就出问题,状态跳转错乱。
原因:功能仿真没有时序概念,所有信号都是立即变化;综合后仿真包含门延迟,信号变化有先后顺序。比如组合逻辑输出在时钟沿附近变化,被时序逻辑采样时可能采到中间态。
解决:关键是让组合逻辑的输出在时钟沿之后稳定足够长时间。检查所有输入信号是否与时钟同步——外部按键信号必须经过消抖、同步后才能进入状态机,不能直接接在case条件里。另外综合设置中可以把fsm_encoding指定为one-hot或sequential,有时默认的编码方式与原设计意图不一致也会导致问题。
6. 从工程模板到自己的设计:改参数与加功能的技巧
拿到这套源码,直接照抄不是一个好选择。你至少要做三件事:改参数、加状态、换接口。
改参数是最容易的。商品价格、投币面额、找零上限都在parameter集中定义,比如:
parameter GOODS_PRICE = 8'd250; // 2.5元 parameter COIN_1_YUAN = 8'd100; parameter COIN_5_JIAO = 8'd50;改价格只需调一个值,但要注意总金额位宽是否足够。如果原来设计支持的最大金额是99.9元,你改成199.9元,所有相关信号的位宽都要同步加宽。
加功能是进阶的必经之路。常见的扩展方向是增加多个商品选择。原来的SELECT状态只有一个按键对应一个商品,改成多商品需要把SELECT状态变成SELECT_1、SELECT_2或增加商品索引寄存器。我建议用寄存器的方案而非增加状态,否则状态数膨胀后综合面积和时序都会恶化。具体做法:在SELECT状态下,检测不同的选择按键,把对应的商品编号写入一个goods_index寄存器,然后用case(goods_index)生成不同商品的出货信号。这样状态数不变,功能扩展性好。
换接口是移植的关键。如果你用的开发板和这套代码配套的不是同一款,引脚约束文件必须重写。Xilinx的约束文件是xdc格式,Altera是qsf格式。原代码中的约束片段大致这么写:
set_property PACKAGE_PIN M18 [get_ports {clk}] set_property IOSTANDARD LVCMOS33 [get_ports {clk}]必须逐一核对开发板原理图中的引脚编号,特别是时钟引脚不能随便绑,如果板子上50MHz时钟不在这个位置,直接换过去会烧器件。复位按键的引脚同理,先看原理图再写约束,这是上板前最后一道闸门。
整套代码里我认为最有学习价值的模块不是状态机,而是debounce.v和display_top.v这两个外围模块。状态机的写法课本和网上很容易找到参考,但把按键消抖做到时间参数可调、把动态扫描做到无重影无闪烁,这些才是真正区分课程设计好坏的地方。我当年第一次做这个项目,就因为省掉了消抖模块,按键触发状态机时有时无,排查了整整一个下午。从那以后,我每次拿到别人的FPGA工程,第一件事就是看它的复位同步和外围接口有没有补齐,这两个位置绝对不会偷工减料。
这套工程文件,建议你拿到手后先按仿真流程完整跑一遍testbench,确认四条测试路径全部通过,再对照约束文件修改成自己板子的引脚,最后上板调试。如果你正在为短学期项目找一份可靠的参考实现,这份代码够用。希望帮到你。
本文还有配套的精品资源,点击获取