资讯动态

FPGA自动售货机设计:基于Verilog状态机的完整工程拆解

发布时间:2026/10/19 17:09:35 来源:尧图企业网站定制
简介东南大学信息学院短学期数字系统设计课程配套源代码主题为基于FPGA的自动售货机。项目在Xilinx ISE平台下用Verilog/VHDL语言实现涵盖货币识别、商品选择、找零计算、库存管理等核心模块适合高校本科生与研究生作为课程设计或数字电路实践参考。资源共120个文件打包约853KB包含.v/.vhd源码、.xise工程文件、.bit比特流以及大量综合仿真报告完整呈现了从编码设计到下载验证的完整流程。已有666人学习下载。通过研读源码与工程配置可掌握状态机设计、时序控制、按键消抖、数码管显示等常用技巧加深对硬件并行执行与软件顺序执行差异的理解也能学习如何组织多层模块与调试逻辑。随包的日志和报告还给出ISE综合、布局布线、时序收敛的真实记录对排查FPGA设计中的常见问题很有借鉴意义。1. FPGA自动售货机短学期数字系统设计里最该啃透的一块硬骨头这篇要拆的资源是某高校信息学院短学期数字系统设计课程配套的FPGA自动售货机源代码包。它把状态机、按键消抖、数码管动态扫描、找零计算这些数字电路设计里的硬骨头全部揉进了一个可上板运行的工程里。很多初学者把精力花在背文法上真正面对一个“投币→选货→出货→找零”的完整闭环时反而不知道从哪里下手。这份代码最大的价值是它同时给出了控制器骨架、外设驱动和仿真测试平台三个层次适合正在学Verilog、想拿FPGA做完整小系统或者课程设计卡在状态机设计上的学生和入门工程师。从这份工程里能学到的东西比单独看几章教材要直接得多。2. 三段式状态机自动售货机控制器的骨架2.1 为什么自动售货机必须用状态机自动售货机本质上是一个事件驱动的控制系统投币到来时累加金额按下选择键时判断金额是否足够出货时扣减金额余额不为零时找零并回到待机。这种“当前所处的阶段决定下一步行为”的逻辑天然适合用有限状态机FSM描述。如果不用状态机——比如有人习惯用一堆if-else裸奔控制——代码写到二十行以后逻辑就开始乱了上板后很难排查是哪个分支跳错了。一个经典的状态划分方案是四态IDLE空闲待机、COIN_COUNT投币累计、SELECT货物选择、OUTPUT出货与找零。有些设计会把找零单独拉出来做状态但短学期课时内四个状态已经足够。项目的核心是两台货道假设可乐三元、矿泉水两元支持一元和五角两种硬币。后面代码里我会用localparam定义这些状态和价格参数改起来很便宜。这里顺便说一句选型问题这个项目用独热码one-hot还是格雷码Gray都行但资源消耗和时序表现不同。独热码状态数少、逻辑简单适合FPGA这种寄存器资源充裕的平台格雷码适合状态连续流转、相邻状态切换多的场景。自动售货机的状态跳转是典型的非线性跳变从COIN_COUNT跳到OUTPUT所以我一般更推荐独热码配default分支兜底。2.2 三段式写法的拆解与代码三段式状态机指的是第一段同步时序描述状态寄存器第二段组合逻辑计算次态第三段时序逻辑产生输出或组合逻辑产生输出。好处是结构清晰、时序收敛容易仿真时状态变迁一目了然。下面贴出核心控制器的骨架代码来源是这份资源包里的顶层控制模块我精简了无关的外设代码保留状态机主干。module vending_fsm ( input wire clk_1k, // 1kHz 系统节拍由板载时钟分频得到 input wire rst_n, // 异步复位低电平有效 input wire coin_one, // 投入一元硬币脉冲 input wire coin_half, // 投入五角硬币脉冲 input wire btn_cola, // 选择可乐按键脉冲 input wire btn_water, // 选择矿泉水按键脉冲 output reg out_cola, // 可乐出货脉冲 output reg out_water, // 矿泉水出货脉冲 output reg change_pulse, // 找零启动脉冲 output reg [3:0] state_dbg // 状态编码输出便于上板调试 ); localparam IDLE 4b0001; localparam COIN_COUNT 4b0010; localparam SELECT 4b0100; localparam OUTPUT 4b1000; localparam PRICE_COLA 4d6; // 6个五角即三元 localparam PRICE_WATER 4d4; // 4个五角即两元 reg [3:0] current_state, next_state; reg [3:0] coin_sum; // 当前投币累计以五角为单位 // 第一段状态寄存器异步复位 always (posedge clk_1k or negedge rst_n) begin if (!rst_n) current_state IDLE; else current_state next_state; end // 第二段组合逻辑计算次态 always (*) begin next_state current_state; // 默认保持 case (current_state) IDLE: begin if (coin_one || coin_half) next_state COIN_COUNT; end COIN_COUNT: begin if (btn_cola || btn_water) next_state SELECT; end SELECT: begin if ((btn_cola coin_sum PRICE_COLA) || (btn_water coin_sum PRICE_WATER)) next_state OUTPUT; else next_state COIN_COUNT; // 金额不够回到投币状态 end OUTPUT: begin next_state IDLE; // 出货找零完成回到空闲 end default: next_state IDLE; // 非法状态强制复位 endcase end // 第三段时序逻辑产生输出脉冲 always (posedge clk_1k or negedge rst_n) begin if (!rst_n) begin out_cola 1b0; out_water 1b0; change_pulse 1b0; end else begin out_cola 1b0; out_water 1b0; change_pulse 1b0; if (current_state OUTPUT) begin if (coin_sum PRICE_COLA btn_cola) begin out_cola 1b1; change_pulse (coin_sum PRICE_COLA); end else if (coin_sum PRICE_WATER btn_water) begin out_water 1b1; change_pulse (coin_sum PRICE_WATER); end end end end // 状态调试输出 assign state_dbg current_state; endmodule代码的要点在这里。第二段组合逻辑里默认next_state current_state这个赋值必须有不然综合器会推断出锁存器圈复杂度直接拉满。资源包里没有写这一行的话建议你手动补上这是很多段式写法练习项目翻车的重灾区。第三段输出统一做成单脉冲形式宽度为一个节拍后级模块只认上升沿便于在按键模块和数码管模块之间解耦。参数说明PRICE_COLA和PRICE_WATER用五角作为单位这样可以用4位位宽覆盖最多7.5元的投币累计省掉了定点小数的麻烦。coin_sum同样以五角为单位它的累加逻辑和找零逻辑在后面的模块里实现状态机只关心大小比较不关心具体钱数。3. 按键消抖与分频不处理这两个模块上板必翻车3.1 按键消抖的参数选择与单脉冲生成机械按键在按下和松开的瞬间触点会经历一段不规则的通断抖动持续时间通常在 5ms 到 20ms 之间。如果不消抖状态机会把一次按键识别成多次边沿造成“按一下出两瓶可乐”的经典事故。课程里通常只会提一句“需要消抖”但具体怎么做、阈值设多少往往要自己踩坑。项目里常见的做法是计数器消抖持续检测按键电平只有电平稳定超过 N 毫秒才认为按键有效。这个 N 取 20 是常见值因为人按按键的物理抖动很难超过 20ms。另一个细节是不能只消抖不整形——消抖后的信号还要通过边沿检测生成一个单周期脉冲否则状态机里if (btn_cola)判断会被持续高电平的按键信号反复触发。module debounce ( input wire clk, // 系统时钟 50MHz input wire rst_n, input wire key_in, // 原始按键电平 output reg key_pulse // 消抖后单脉冲输出高电平持续一个时钟周期 ); parameter CLK_FREQ 50_000_000; // 板载时钟频率 50MHz parameter DEBOUNCE_MS 20; // 消抖窗口20ms localparam MAX_CNT CLK_FREQ / 1000 * DEBOUNCE_MS - 1; reg [19:0] cnt; reg key_sync0, key_sync1; // 两级同步器消除亚稳态 reg key_stable; always (posedge clk or negedge rst_n) begin if (!rst_n) begin key_sync0 1b1; key_sync1 1b1; cnt 0; key_stable 1b1; end else begin key_sync0 key_in; key_sync1 key_sync0; if (key_sync1 ! key_stable) begin cnt 0; // 电平跳变重新计时 key_stable key_sync1; // 更新参考电平 end else if (cnt MAX_CNT) begin cnt cnt 1; // 电平稳定计数 end else begin key_stable key_sync1; // 稳定时间达到锁定 end end end reg key_prev; always (posedge clk or negedge rst_n) begin if (!rst_n) begin key_prev 1b1; key_pulse 1b0; end else begin key_prev key_stable; // 检测下降沿从按下低电平稳定后产生脉冲 key_pulse (!key_stable) key_prev; end end endmodule这个模块里有个容易被低估的设计输入按键先过两级同步器key_sync0和key_sync1。按键信号来自FPGA引脚外部和内部时钟不同源如果直接打一拍采样有概率采到亚稳态导致整个状态机在一个时钟周期内出现随机跳转。两级同步器不能绝对消除亚稳态但能把概率压到工程可接受的水平。MAX_CNT的计算方式CLK_FREQ / 1000 * DEBOUNCE_MS是把毫秒换算成时钟周期数算出来是 100,000因此位宽用了20位最大计数 1,048,575刚好不溢出。如果你换了一块 100MHz 时钟的板子记得把这个位宽扩到21位否则计数器会清零回绕消抖直接失效。3.2 分频器为什么不能用板载 50MHz 直接驱动状态机如果直接把 50MHz 时钟拿来驱动状态机和数码管扫描会带来两个问题。第一状态机节拍太快按键脉冲进来后一个时钟周期就会跳走下游模块还没反应过来第二动态扫描的频率太高数码管其实在高速轮询点亮但人眼看到的是所有位都常亮显示信息完全混在一起。所以工程里通用的做法是分频出两个低速时钟一个 1kHz 给状态机做节拍一个 1kHz 同时给数码管做扫描时钟两者同源但用途不同。module clk_div #( parameter DIV_N 50_000 // 50MHz - 1kHz )( input wire clk, input wire rst_n, output reg clk_out ); reg [15:0] cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt 0; clk_out 1b0; end else begin if (cnt DIV_N - 1) begin cnt 0; clk_out ~clk_out; // 翻转产生 1kHz 方波 end else begin cnt cnt 1; end end end endmodule参数化模块#(parameter DIV_N 50_000)这个写法的意义在于你在不同开发板上要应对不同时钟频率只要例化时重新传参数即可不用改模块内部逻辑。cnt DIV_N - 1判断占空比正好 50%对需求来说足够稳定。有两点建议分频时钟输出会经过全局时钟网络不会有时序问题但如果你的板子支持锁相环用锁相环分频更干净可以避免组合逻辑产生的毛刺分频时钟驱动的寄存器要做跨时钟域处理时务必在仿真里留意 X 态传播。4. 数码管显示与找零计算金额累加与动态扫描的配合4.1 金额累加与找零的算法设计投币累加模块不是简单的加法器——每次投币脉冲到来时把对应面额累加到coin_sum寄存器上同时要考虑最大金额上限。如果没有上限用户投了二十个一元硬币数码管显示溢出找零也会乱套。项目里习惯的做法是设置一个最大累计值比如十五元超过后直接拒绝新投入的硬币。module coin_accum ( input wire clk_1k, input wire rst_n, input wire coin_one, // 一元脉冲 input wire coin_half, // 五角脉冲 input wire clear, // 找零完成后清零 output reg [4:0] coin_sum // 单位五角最大支持 15.5 元 ); localparam MAX_SUM 5d31; // 最多累计 15.5 元 always (posedge clk_1k or negedge rst_n) begin if (!rst_n) coin_sum 5d0; else if (clear) coin_sum 5d0; else if (coin_one) coin_sum (coin_sum MAX_SUM - 1) ? MAX_SUM : coin_sum 5d2; else if (coin_half) coin_sum (coin_sum MAX_SUM) ? MAX_SUM : coin_sum 5d1; else coin_sum coin_sum; end endmodule这个模块的边界处理是重点一元的增量是2因为单位是五角所以判断上限时要留一个单位的余量否则投最后一个一元硬币时金额会溢出回绕成小金额。我见过有些人直接写coin_sum coin_sum 2结果用户投入三元后金额变成进位异常找零逻辑全部对不上。这个锅不在状态机在边界判断的疏忽。找零采用贪心思路从大面额开始找能找一元就找一元剩下的找五角。代码上通常用一组计数器组成一个小型减法循环也可以用状态机嵌套实现。资源包里的实现方式是生成change_count信号由外部模块根据coin_sum减掉商品价格后的差值产生一个串行找零脉冲序列。module change_output ( input wire clk_1k, input wire rst_n, input wire [4:0] change_amt, // 应找金额单位五角 input wire start, output reg change_pulse // 每个脉冲表示找出一枚五角硬币 ); reg [3:0] step; reg [4:0] remain; reg change_active; always (posedge clk_1k or negedge rst_n) begin if (!rst_n) begin step 0; remain 0; change_active 1b0; change_pulse 1b0; end else begin change_pulse 1b0; if (!change_active start) begin change_active 1b1; remain change_amt; step 0; end else if (change_active) begin if (remain 0) begin // 每两个时钟周期输出一个找零脉冲 if (step 1) begin change_pulse 1b1; remain remain - 1; step 0; end else begin step step 1; end end else begin change_active 1b0; end end end end endmodule找零脉冲宽度为一个 1kHz 节拍1ms实际控制电机出币的模块可以检测这个脉冲突发启动。参数这里没有复杂公式但remain - 1的减法发生时间在change_pulse拉高的同一拍上位模块若在同一拍读取remain会读到旧值——这是一个典型的读改写冲突仿真时会暴露但仿真通过不代表上板没问题因为板级时序可能刚好踩在边沿上。稳妥做法是把remain的更新推迟一拍也就是让脉冲信号成立后再过一拍完成减法。4.2 数码管动态扫描1ms 切位与参数设置数码管显示采用动态扫描本质是“分时复用”视觉暂留。四位数码管轮流点亮每一时刻只有一位导通靠快速切换让眼睛认为四位数同时在亮。这里有两个参数需要较真切换频率和段选信号与位选信号的时序关系。切换频率经验值每一位点亮 1ms四位一轮 4ms刷新率 250Hz远高于人眼的闪烁融合频率约 50Hz 以上肉眼完全看不出闪烁。如果切换周期放到 10ms 以上刷新率降到 25Hz就会看到明显的闪烁——这是最常见的“数码管闪屏”问题。module seg_scan ( input wire clk_1k, input wire rst_n, input wire [3:0] price_disp, // 当前显示金额低两位为小数部分 output reg [3:0] an, // 位选信号低电平有效 output reg [7:0] seg // 段选信号共阳数码管 ); reg [1:0] scan_pos; reg [3:0] scan_data; always (posedge clk_1k or negedge rst_n) begin if (!rst_n) scan_pos 2d0; else scan_pos scan_pos 1; end always (*) begin case (scan_pos) 2d0: begin an 4b1110; scan_data price_disp[3:0] 9 ? 4hF : price_disp[3:0]; end 2d1: begin an 4b1101; scan_data (price_disp / 4d10) % 4d10; end 2d2: begin an 4b1011; scan_data (price_disp / 4d100) % 4d10; end 2d3: begin an 4b0111; scan_data 4b0000; // 最高位显示 0 end endcase end // 段码译码查找表 always (*) begin case (scan_data) 4d0: seg 8hC0; 4d1: seg 8hF9; 4d2: seg 8hA4; 4d3: seg 8hB0; 4d4: seg 8h99; 4d5: seg 8h92; 4d6: seg 8h82; 4d7: seg 8hF8; 4d8: seg 8h80; 4d9: seg 8h90; default: seg 8hFF; endcase end endmodule注意price_disp是以五角为单位的总金额而显示时要把单位转换回元角格式。上面代码用price_disp / 4d10来提取十位% 4d10提取个位但这里隐藏着一个约定price_disp的低两位直接映射到小数部分角除以10得到的商就是元。换句话说显示格式固定的情况编码上可以直接把低两位当作角、高两位当作元来用。如果你的显示要求带小数点需要在小数位对应的seg输出上强制把小数点段点亮——资源包里的实现是单独加了一个dp_en信号控制我这里没有展开因为原理完全一样。还有一个上板常见的“鬼影”问题位选信号切换时如果段选数据还没稳定上一个位的显示数据会残留到下一个位产生轻微重影。解决方法是把an和seg的输出寄存器后一拍或者采用“先灭位再切换段选”的顺序——也就是先把所有位选都拉到无效电平更新段选再拉有效位选。代码里我用always (*)组合逻辑直接输出工具链通常会自动平衡延迟但对某些低端板卡还是建议你手动加一级寄存器做输出寄存。5. 上板排查五个高频翻车点的现象、原因与修复5.1 按键按下后状态连跳一次投币被当成多次现象只按了一次选择键状态机直接越过SELECT进入OUTPUT数码管金额也莫名减少。原因是按键信号没有消抖直接进了状态机物理抖动产生了多个上升沿。修复在按键输入后加debounce模块并把消抖后的信号再通过边沿检测产生单脉冲。我在 3.1 节里已经给出完整模块这里提醒一句消抖窗口太小会残留部分抖动太大则按键响应明显变慢20ms 是一个用起来比较舒服的平衡点。5.2 状态机进入非法状态后“死机”现象上电后一切正常某次连续快速按键后绿灯不亮、按键无响应、数码管黑屏只有复位能救回来。原因状态变量被外部干扰或组合逻辑毛刺打到了独热码以外的非法值比如4b0111。如果case中没有default分支状态机就会永久卡在非法状态上。修复组合逻辑的case末尾必须加default: next_state IDLE;这是状态机设计里不能漏的一行。另一个更稳健的做法是把状态寄存器做成“状态合法检测”每个时钟周期检查当前状态是否为合法独热码不是就强制回到IDLE代价是多一点寄存器和逻辑但对课程设计来说完全值得。5.3 数码管闪烁严重或者显示亮度明显不均匀现象板子跑起来后数码管的数字不停抖动尤其是金额较多的场景更明显。原因扫描时钟频率太低刷新率低于 50Hz。或者扫描模块里scan_pos跳变和an位选信号没有对齐。修复先确认分频参数正确——50MHz 时钟分频出 1kHz即计数 50000 翻转一次切位周期 1ms。其次检查位选信号是否带输出寄存器如果不带可以在an和seg各加一个打拍寄存器。通常这两个措施组合起来闪烁问题就能解决。5.4 仿真正常但上板后金额错乱有时正确有时错误现象testbench 里投币、选货、找零全部正确但板上实际运行时某些硬币投进来金额不加或者金额跳变到奇怪值。原因没有做跨时钟域处理。测试平台的按键脉冲是激励源直接给状态机的而板上的按键是异步信号经过同步器之前直接采样会有亚稳态风险。修复参考 3.1 的debounce模块输入先过两级触发器同步再做电平比较。另外检查复位信号板上按键复位通常也是异步的要统一使用“异步复位、同步释放”的复位方案否则复位释放那一下子可能采到中间电平。5.5 综合时出现“latch inferred”警告状态机行为异常现象综合报告里有几十个 latch 警告上板后某些输出信号保持旧值不变。原因组合逻辑的always (*)块里对所有分支路径没有给所有信号赋值比如 2.2 节中next_state不是每个if分支都有显式赋值。修复组合逻辑块开头先给next_state赋默认值 current_state让所有分支都有一条“原地不动”的路径综合器就不会推断锁存器。如果用的是case语句务必同时补default。6. 十分钟上板自检仿真验证与状态显示的一个实用技巧短学期的调试时间很紧上板一次往往要排队所以我的习惯是先把验证想清楚再动硬件。这里分享一个很实用的小技巧把状态编码直接接到数码管上显示。比如IDLE显示1COIN_COUNT显示2SELECT显示3OUTPUT显示4。这样你在按按键的时候用肉眼就能看着数码管确认状态机跳到了哪一步。一旦出现0或7立刻知道状态机进了非法状态。这个调试手段比 Lab 里接逻辑分析仪快得多而且工程里state_dbg这个输出端口就是为此预留的。仿真自检方面testbench 里要覆盖三类场景每一类都对应一个容易出 bug 的路径正常购买投三元 → 选可乐 → 出货 → 无找零、超额购买投四元 → 选可乐 → 出货 → 找零一元、以及一个最容易忽略的场景——投币未选货直接再投币金额要正确累加。如果你设计里还有取消投币的复位按键再加一个“投币到一半按复位金额清零”的用例。把这些场景写成任务函数每次改完代码跑一遍确认波形正确后再上板能把上板后踩坑的概率减掉一大半。initial begin rst_n 0; #100 rst_n 1; // 用例1正常购买 coin_click_one(); // 投1元 coin_click_half(); // 投5角 coin_click_half(); // 投5角 coin_click_half(); // 投5角 coin_click_half(); // 投5角 btn_click_cola(); // 选可乐金额3元 // 此处应看到 out_cola 拉高一个节拍change_pulse 为低 // 用例2超额购买 coin_click_one(); coin_click_one(); coin_click_one(); coin_click_one(); // 投4元 btn_click_cola(); // 此处应看到 out_cola 拉高change_pulse 拉高 // 用例3中途复位 coin_click_one(); rst_n 0; #50 rst_n 1; // 数码管金额应归零 end用例3里特别要验证的是复位后的状态一定是IDLE并且coin_sum清零。因为某些三段式写法里如果只在状态寄存器里做复位、没有清coin_sum就会出现“复位了但投币金额还在”的诡异现象——建议把复位信号在状态机和金额模块都接上并在仿真里显式检查。从那以后我每次接手这类带状态机、按键和外设的项目都会先花十分钟写完这三类自检用例再用state_dbg上板肉眼验证状态跳转。这套流程看着简单但确实让我避过好几次上板后乱成一锅粥的状况。这篇 FPGA 自动售货机的源码工程如果你正在做类似的课程设计或者想练手状态机可以拿来直接当骨架照着上面的几个点做裁剪和扩展比如加更多的商品种类、改成支持扫码支付接口的模拟模块。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑