简介:东南大学信息学院短学期数字系统设计课程配套源代码,主题为基于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 = 4'b0001; localparam COIN_COUNT = 4'b0010; localparam SELECT = 4'b0100; localparam OUTPUT = 4'b1000; localparam PRICE_COLA = 4'd6; // 6个五角,即三元 localparam PRICE_WATER = 4'd4; // 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 <= 1'b0; out_water <= 1'b0; change_pulse <= 1'b0; end else begin out_cola <= 1'b0; out_water <= 1'b0; change_pulse <= 1'b0; if (current_state == OUTPUT) begin if (coin_sum >= PRICE_COLA && btn_cola) begin out_cola <= 1'b1; change_pulse <= (coin_sum > PRICE_COLA); end else if (coin_sum >= PRICE_WATER && btn_water) begin out_water <= 1'b1; 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 <= 1'b1; key_sync1 <= 1'b1; cnt <= 0; key_stable <= 1'b1; 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 <= 1'b1; key_pulse <= 1'b0; 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 <= 1'b0; 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 = 5'd31; // 最多累计 15.5 元 always @(posedge clk_1k or negedge rst_n) begin if (!rst_n) coin_sum <= 5'd0; else if (clear) coin_sum <= 5'd0; else if (coin_one) coin_sum <= (coin_sum >= MAX_SUM - 1) ? MAX_SUM : coin_sum + 5'd2; else if (coin_half) coin_sum <= (coin_sum >= MAX_SUM) ? MAX_SUM : coin_sum + 5'd1; 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 <= 1'b0; change_pulse <= 1'b0; end else begin change_pulse <= 1'b0; if (!change_active && start) begin change_active <= 1'b1; remain <= change_amt; step <= 0; end else if (change_active) begin if (remain > 0) begin // 每两个时钟周期输出一个找零脉冲 if (step >= 1) begin change_pulse <= 1'b1; remain <= remain - 1; step <= 0; end else begin step <= step + 1; end end else begin change_active <= 1'b0; 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 <= 2'd0; else scan_pos <= scan_pos + 1; end always @(*) begin case (scan_pos) 2'd0: begin an = 4'b1110; scan_data = price_disp[3:0] > 9 ? 4'hF : price_disp[3:0]; end 2'd1: begin an = 4'b1101; scan_data = (price_disp / 4'd10) % 4'd10; end 2'd2: begin an = 4'b1011; scan_data = (price_disp / 4'd100) % 4'd10; end 2'd3: begin an = 4'b0111; scan_data = 4'b0000; // 最高位显示 0 end endcase end // 段码译码查找表 always @(*) begin case (scan_data) 4'd0: seg = 8'hC0; 4'd1: seg = 8'hF9; 4'd2: seg = 8'hA4; 4'd3: seg = 8'hB0; 4'd4: seg = 8'h99; 4'd5: seg = 8'h92; 4'd6: seg = 8'h82; 4'd7: seg = 8'hF8; 4'd8: seg = 8'h80; 4'd9: seg = 8'h90; default: seg = 8'hFF; endcase end endmodule注意price_disp是以五角为单位的总金额,而显示时要把单位转换回元角格式。上面代码用price_disp / 4'd10来提取十位,% 4'd10提取个位,但这里隐藏着一个约定:price_disp的低两位直接映射到小数部分(角),除以10得到的商就是元。换句话说,显示格式固定的情况,编码上可以直接把低两位当作角、高两位当作元来用。如果你的显示要求带小数点,需要在小数位对应的seg输出上强制把小数点段点亮——资源包里的实现是单独加了一个dp_en信号控制,我这里没有展开,因为原理完全一样。还有一个上板常见的“鬼影”问题:位选信号切换时,如果段选数据还没稳定,上一个位的显示数据会残留到下一个位,产生轻微重影。解决方法是把an和seg的输出寄存器后一拍,或者采用“先灭位再切换段选”的顺序——也就是先把所有位选都拉到无效电平,更新段选,再拉有效位选。代码里我用always @(*)组合逻辑直接输出,工具链通常会自动平衡延迟,但对某些低端板卡还是建议你手动加一级寄存器做输出寄存。5. 上板排查:五个高频翻车点的现象、原因与修复5.1 按键按下后状态连跳,一次投币被当成多次现象:只按了一次选择键,状态机直接越过SELECT进入OUTPUT,数码管金额也莫名减少。原因是按键信号没有消抖直接进了状态机,物理抖动产生了多个上升沿。修复:在按键输入后加debounce模块,并把消抖后的信号再通过边沿检测产生单脉冲。我在 3.1 节里已经给出完整模块,这里提醒一句:消抖窗口太小会残留部分抖动,太大则按键响应明显变慢,20ms 是一个用起来比较舒服的平衡点。5.2 状态机进入非法状态后“死机”现象:上电后一切正常,某次连续快速按键后,绿灯不亮、按键无响应、数码管黑屏,只有复位能救回来。原因:状态变量被外部干扰或组合逻辑毛刺打到了独热码以外的非法值,比如4'b0111。如果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显示1,COIN_COUNT显示2,SELECT显示3,OUTPUT显示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 自动售货机的源码工程,如果你正在做类似的课程设计或者想练手状态机,可以拿来直接当骨架,照着上面的几个点做裁剪和扩展,比如加更多的商品种类、改成支持扫码支付接口的模拟模块。希望帮到你。本文还有配套的精品资源,点击获取
简介:东南大学信息学院短学期数字系统设计课程配套源代码,主题为基于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 = 4'b0001; localparam COIN_COUNT = 4'b0010; localparam SELECT = 4'b0100; localparam OUTPUT = 4'b1000; localparam PRICE_COLA = 4'd6; // 6个五角,即三元 localparam PRICE_WATER = 4'd4; // 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 <= 1'b0; out_water <= 1'b0; change_pulse <= 1'b0; end else begin out_cola <= 1'b0; out_water <= 1'b0; change_pulse <= 1'b0; if (current_state == OUTPUT) begin if (coin_sum >= PRICE_COLA && btn_cola) begin out_cola <= 1'b1; change_pulse <= (coin_sum > PRICE_COLA); end else if (coin_sum >= PRICE_WATER && btn_water) begin out_water <= 1'b1; 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 <= 1'b1; key_sync1 <= 1'b1; cnt <= 0; key_stable <= 1'b1; 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 <= 1'b1; key_pulse <= 1'b0; 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 <= 1'b0; 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 = 5'd31; // 最多累计 15.5 元 always @(posedge clk_1k or negedge rst_n) begin if (!rst_n) coin_sum <= 5'd0; else if (clear) coin_sum <= 5'd0; else if (coin_one) coin_sum <= (coin_sum >= MAX_SUM - 1) ? MAX_SUM : coin_sum + 5'd2; else if (coin_half) coin_sum <= (coin_sum >= MAX_SUM) ? MAX_SUM : coin_sum + 5'd1; 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 <= 1'b0; change_pulse <= 1'b0; end else begin change_pulse <= 1'b0; if (!change_active && start) begin change_active <= 1'b1; remain <= change_amt; step <= 0; end else if (change_active) begin if (remain > 0) begin // 每两个时钟周期输出一个找零脉冲 if (step >= 1) begin change_pulse <= 1'b1; remain <= remain - 1; step <= 0; end else begin step <= step + 1; end end else begin change_active <= 1'b0; 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 <= 2'd0; else scan_pos <= scan_pos + 1; end always @(*) begin case (scan_pos) 2'd0: begin an = 4'b1110; scan_data = price_disp[3:0] > 9 ? 4'hF : price_disp[3:0]; end 2'd1: begin an = 4'b1101; scan_data = (price_disp / 4'd10) % 4'd10; end 2'd2: begin an = 4'b1011; scan_data = (price_disp / 4'd100) % 4'd10; end 2'd3: begin an = 4'b0111; scan_data = 4'b0000; // 最高位显示 0 end endcase end // 段码译码查找表 always @(*) begin case (scan_data) 4'd0: seg = 8'hC0; 4'd1: seg = 8'hF9; 4'd2: seg = 8'hA4; 4'd3: seg = 8'hB0; 4'd4: seg = 8'h99; 4'd5: seg = 8'h92; 4'd6: seg = 8'h82; 4'd7: seg = 8'hF8; 4'd8: seg = 8'h80; 4'd9: seg = 8'h90; default: seg = 8'hFF; endcase end endmodule注意price_disp是以五角为单位的总金额,而显示时要把单位转换回元角格式。上面代码用price_disp / 4'd10来提取十位,% 4'd10提取个位,但这里隐藏着一个约定:price_disp的低两位直接映射到小数部分(角),除以10得到的商就是元。换句话说,显示格式固定的情况,编码上可以直接把低两位当作角、高两位当作元来用。如果你的显示要求带小数点,需要在小数位对应的seg输出上强制把小数点段点亮——资源包里的实现是单独加了一个dp_en信号控制,我这里没有展开,因为原理完全一样。还有一个上板常见的“鬼影”问题:位选信号切换时,如果段选数据还没稳定,上一个位的显示数据会残留到下一个位,产生轻微重影。解决方法是把an和seg的输出寄存器后一拍,或者采用“先灭位再切换段选”的顺序——也就是先把所有位选都拉到无效电平,更新段选,再拉有效位选。代码里我用always @(*)组合逻辑直接输出,工具链通常会自动平衡延迟,但对某些低端板卡还是建议你手动加一级寄存器做输出寄存。5. 上板排查:五个高频翻车点的现象、原因与修复5.1 按键按下后状态连跳,一次投币被当成多次现象:只按了一次选择键,状态机直接越过SELECT进入OUTPUT,数码管金额也莫名减少。原因是按键信号没有消抖直接进了状态机,物理抖动产生了多个上升沿。修复:在按键输入后加debounce模块,并把消抖后的信号再通过边沿检测产生单脉冲。我在 3.1 节里已经给出完整模块,这里提醒一句:消抖窗口太小会残留部分抖动,太大则按键响应明显变慢,20ms 是一个用起来比较舒服的平衡点。5.2 状态机进入非法状态后“死机”现象:上电后一切正常,某次连续快速按键后,绿灯不亮、按键无响应、数码管黑屏,只有复位能救回来。原因:状态变量被外部干扰或组合逻辑毛刺打到了独热码以外的非法值,比如4'b0111。如果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显示1,COIN_COUNT显示2,SELECT显示3,OUTPUT显示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 自动售货机的源码工程,如果你正在做类似的课程设计或者想练手状态机,可以拿来直接当骨架,照着上面的几个点做裁剪和扩展,比如加更多的商品种类、改成支持扫码支付接口的模拟模块。希望帮到你。本文还有配套的精品资源,点击获取