☰
Verilog自动售货机状态机设计:倒计时显示与Quartus II实现
2026/10/21 19:14:25 网站建设 项目流程

自动售货机这个项目,在数字逻辑和EDA课程里算是老面孔了。用 Verilog 在 Quartus II 里写一个自动售货机控制器,再加一个倒计时显示功能,很多同学一听到需求就头大——不是不会写代码,而是不知道从哪下手。这篇文章我就从头到尾拆一遍,把状态机怎么设计、倒计时怎么挂进去、Quartus II 里怎么一步步跑通,包括上板调试时容易踩的坑,一次性讲清楚。

这个项目适合两类人看:一是正在做课程设计、需要快速出方案的学生,二是刚接触 FPGA、想拿一个小项目练手 Verilog 状态机写法的入门者。你看完之后,应该能直接照着自己的开发板搭出一套完整的、能跑、能演示的自动售货机控制器。

1. 项目需求与设计思路拆解

1.1 需求里的隐藏考点

先说说这个题目到底考什么。自动售货机控制,表面上是“投币、选货、出货、找零”,但落到 Verilog 里,核心只有一个词——状态机。你想想售货机的工作流程:等顾客投币、判断金额够不够、等顾客选商品、吐出商品、找零、回到待机。这个流程天然就是一组状态之间的跳转,没有任何复杂的算法,纯粹看你怎么把状态切得干净、切得稳。

倒计时显示看着是另一个功能,其实它和控制器是绑在一起的。常见的做法是:顾客投币后,系统启动一个倒计时(比如 30 秒),顾客必须在倒计时结束前完成选购。如果时间到了还没选,系统就自动退币、回到初始状态。这个机制既解决了“有人投币后站着不走占着机器”的问题,也给状态机加了一个“超时跳转”的触发条件——知识点来得非常自然。

我在实际带项目时,见过不少人把这两个模块分开写,最后在顶层连线,结果时钟、复位、握手信号乱七八糟,仿真怎么都对不上。我的建议是,不要一上来就追求代码量,先把“有哪些状态、哪些输入、哪些输出、哪些跳转条件”在纸上画清楚。画图不一定多规范,但状态之间的关系必须先理顺,否则后面写多少代码都是空中楼阁。

1.2 状态机的核心状态划分

结合常见的需求描述,我一般把自动售货机控制器拆成 5 个状态:

  • 空闲状态(IDLE):等待投币,数码管显示余额为 0 或显示提示符。
  • 投币状态(COIN):检测到投币信号,金额累加,显示当前余额并启动倒计时。
  • 选择状态(SELECT):允许用户按下商品选择键,判断余额是否足够。
  • 出货状态(DISPENSE):输出商品、扣除对应金额,如果需要找零则进入找零状态,否则回到 IDLE。
  • 找零状态(CHANGE):输出找零信号,稍作延时后回到 IDLE。

这里要特别注意一点:投币状态和选择状态可以合并,也可以分开。合并的写法更简单,但会让状态机的跳转条件变得很乱。分开写,状态清晰,调试时看波形一眼就能看出当前跑到哪里了。我倾向于分开,尤其是你要把这个项目写成课程设计报告的话,状态转移图画出来也更好看。

倒计时模块和状态机的握手方式,我建议做成“使能+脉冲”的形式。状态机进入 COIN 时拉高倒计时的启动信号,倒计时模块开始从预设值递减;当倒计时数到 0,回一个 done 脉冲给状态机,状态机在超时的情况下跳回 IDLE。注意,这里不要在状态机里用计数器去查倒计时到没到,而是让倒计时模块自己报结束,两个模块的职责要清晰。

2. 核心模块划分与 Verilog 代码实现要点

2.1 模块划分与端口规划

一个完整的工程,我建议至少分成三个文件:顶层模块、控制状态机模块、倒计时模块。显示部分可以放到顶层里做,也可以用单独的显示驱动模块,看你的开发板资源和个人习惯。

顶层模块的端口大致是这样的:

module vending_top( input wire clk, // 板载时钟 50MHz input wire rst_n, // 复位信号,低有效 input wire coin_1, // 投 1 元硬币按键 input wire coin_5, // 投 5 元硬币按键 input wire sel_a, // 选择商品 A input wire sel_b, // 选择商品 B output wire out_a, // 商品 A 出货信号 output wire out_b, // 商品 B 出货信号 output wire change_out, // 找零信号 output wire [7:0] seg_data, // 数码管段选 output wire [2:0] seg_sel // 数码管位选,3 位可驱动 6 个数码管 );

端口规划这里有一个容易忽略的坑:按键输入必须做消抖处理。物理按键按下和松开时,信号会有几毫秒到几十毫秒的抖动,如果不消抖,状态机可能会在同一个按键动作中被触发两三次,投币一次变成了投两次,调试时会非常迷惑。

消抖的办法很多,简单可靠的是计数消抖:检测到按键电平变化后,等 10~20ms 再确认一次。注意不要在主状态机里做这个延时,否则状态机会被卡住,正确做法是在顶层或单独的按键模块里处理完抖动,再把干净的脉冲信号送给状态机。

2.2 三段式状态机的标准写法

状态机我个人强烈推荐三段式:状态跳转、次态逻辑、输出逻辑分开写。好处太多,最实际的好处是——仿真报错的时候,你能一眼看出是组合逻辑的问题还是时序逻辑的问题,急着交作业的时候,这个能救命。

// 状态编码,用 Gray Code 或独热码也都可以 localparam IDLE = 3'd0; localparam COIN_PROC = 3'd1; localparam SELECT = 3'd2; localparam DISPENSE = 3'd3; localparam CHANGE = 3'd4; reg [2:0] state, 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 (coin_1 || coin_5) next_state = COIN_PROC; COIN_PROC: begin if (timeout_done) next_state = IDLE; // 超时,退回 else if (sel_a && money >= PRICE_A) next_state = DISPENSE; else if (sel_b && money >= PRICE_B) next_state = DISPENSE; end DISPENSE: next_state = (money == 0) ? IDLE : CHANGE; CHANGE: next_state = IDLE; default: next_state = IDLE; endcase end // 第三段:输出逻辑,使用时序输出 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin out_a <= 1'b0; out_b <= 1'b0; // 其他输出复位 end else begin out_a <= 1'b0; // 默认拉低 out_b <= 1'b0; case (next_state) DISPENSE: begin if (sel_a) out_a <= 1'b1; if (sel_b) out_b <= 1'b1; end endcase end end

这段代码里有几个细节值得说。

次态逻辑里我第一行就写了next_state = state;,这是默认赋值,防止出现 latch,也是让状态不被意外改掉的保险。很多人写组合逻辑时漏掉 default 和默认赋值,综合出来会多一堆让人摸不着头脑的锁存器,Quartus 的综合报告里会出现一堆 warning。不是说不可以,但在课程设计答辩时,信号量不小,仪表不好。

输出逻辑我用的是时序输出,也就是在next_state基础上打了一拍。好处是输出信号能严格同步到时钟边沿,几乎不会出现毛刺,而且代码风格更统一。代价是输出会比状态跳转晚一个周期,但对于售货机演示来说,一个时钟周期是纳秒级的事,肉眼完全看不出来。

2.3 倒计时模块的实现细节

倒计时模块看起来简单,但有一个经典问题容易被搞错:倒计时到 0 之后的“回绕”现象。如果你写if (cnt == 0) cnt <= preset;,那模块就会自动周期性地倒计时,不会停止。这对秒表类应用是对的,但对售货机来说,倒计时停止后必须保持 0 状态,直到新一轮投币重新启动。

我的写法是增加一个启动信号 start,只有 start 有效时才把计数初值装进去并开始递减;计数到 0 后,除非 start 再次有效,否则不再变化。

module countdown #( parameter WIDTH = 8, parameter PRESET = 8'd30 )( input wire clk, input wire rst_n, input wire start, output reg [WIDTH-1:0] cnt, output reg done ); always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= PRESET; done <= 1'b0; end else if (start) begin cnt <= PRESET; done <= 1'b0; end else if (cnt != 0) begin cnt <= cnt - 1'b1; done <= (cnt == 1) ? 1'b1 : 1'b0; // 注意这里 end end endmodule

done信号我是在cnt == 1时拉高,而不是在cnt == 0时拉高,这也是个经验细节。因为倒计时模块和状态机是同步工作的,如果等到cnt已经变成 0 再拉高 done,状态机收到信号时,实际时间已经多了一个周期;而且在值等于 0 时,下一个周期如果没被复位,就会继续等待,时序上容易出现奇怪的边沿问题。提前一拍拉高,状态机在下一个时钟跳转时刚好拿到稳定的 done。

如果你把cnt连到数码管显示,用户会看到倒计时从 2 直接变成 0,中间少了 1,看起来有点像 bug。所以在显示端也要做处理:display_value = cnt - 1,或者干脆让计数器从 PRESET+1 装起,显示时再减一。这个小细节是我实际调试时花了十几分钟才反应过来,写在这里帮你省个事。

3. Quartus II 实操流程:从建工程到上板

3.1 工程创建、器件选择与文件命名

Quartus II 的版本五花八门,我用得比较多的是 13.0 和 13.1 这个年代版本,虽然老,但稳定。新建工程时,器件型号一定要和你手上的开发板对上。绝大多数学生开发板用的是某公司 Cyclone 系列的芯片,具体型号印在板子上的芯片丝印上,比如 EP4CE6E22C8 或 EP4CE10F17C8 之类。选错型号,后面编译和下载的时候会非常痛苦,轻则警告,重则引脚分配不合法、烧录失败。

文件命名是一个特别容易翻车的地方。Quartus 要求顶层模块的模块名和文件名保持一致,比如文件叫vending_top.v,里面的模块名就必须叫vending_top。很多人从网上随便拉个代码,文件名是test.v,里面模块名是final_top,一综合就报错。我的习惯是先确定顶层模块名,再创建同名文件,养成这个习惯能少挨很多编译器毒打。

3.2 引脚分配与编译流程

代码写完后,第一步是分析和综合。在这个阶段,先别急着分配引脚,等综合通过、看 RTL Viewer 里状态机的图形符合预期,再进分配。RTL Viewer 是一个非常有用的工具,你可以在 Quartus 里打开综合后的原理图视图,把代码块生成的硬件结构可视化地检查一遍。很多逻辑错误,看代码半天看不出来,看 RTL 图一眼就发现少了一条线。

引脚分配通过 Assignment Editor 进行。开发板上有按键、数码管、LED、时钟,你要对照板子原理图逐一指定引脚。这里有几个务必警惕的坑:

  • 板上时钟的频率要和代码里的分频参数匹配,比如很多板载晶振是 50MHz,倒计时显示刷新需要 1ms 扫描一次,分频参数就按 50_000 来。
  • 数码管是共阳还是共阴,段码的表是完全相反的。最稳妥的做法是查开发板原理图,而不是靠猜。
  • 部分开发板的按键是高电平有效,按下时给高电平;另一部分则是低电平。你的代码里按键检测逻辑必须和硬件电平一致,否则按下半点反应没有。

分配完引脚后,执行全编译。编译报告的时序分析部分,对于这个项目不用太较真,项目时钟只要几十兆赫兹,根本不存在时序不收敛的问题。真正要看的其实是编译报告里的资源占用和那几个 warning——别把所有 warning 都忽略,有的 warning 会明确提示你“Detected latch”,这就说明你组合逻辑里漏了赋值,得回去改代码。

3.3 仿真验证的两种层次

仿真真的建议做,原因很简单:如果你直接烧到板子上,出了问题根本不知道模块内部是什么状态,只能瞪着眼睛看数码管瞎猜。而仿真能把你状态机的跳转过程拉出来,逐周期看,尤其适合调试“按键按了没反应”“金额计算错了”这类问题。

Quartus II 13.0 内置了仿真环境(早期版本的 Waveform 编辑器,新版本推荐用 ModelSim)。如果你是初学者,用内置的最省心:画激励波形、设置结束时间、设置主时钟、指定需要观察的信号,然后启动仿真。加上几个输入变化节点,观察状态机输出波形,就完成了一次最基本的行为仿真。

如果要用 ModelSim,就需要写 testbench。以我的经验,对于自动售货机这类中小规模模块,testbench 几十行就够了,关键是输入信号的时序对齐。你给coin_1打一个高电平脉冲,要保证它和时钟上升沿对齐,否则状态机采样时可能采到毛刺。更省事的方法是用 repeat 和 task 封装“投币”“按下选择键”这些动作,testbench 会干净很多。

仿真的层次也有讲究。行为仿真(Functional Model)只验证逻辑,不看器件时延;时序仿真(Timing Model)会带入门级布线延迟。早期调试用行为仿真就够了,资源占用和时间都快得多。上板前如果能再做一次时序仿真,能发现一部分因为时钟竞争导致的亚稳态问题——虽然课程设计不一定要求,但能让你在答辩时多讲几句话。

4. 常见问题与排查笔记

4.1 仿真结果不对,先检查这几个地方

我总结了一下,这个项目的仿真问题,至少有一半都出在复位和时钟上,反而真正逻辑错误的情况没那么多。

先说复位。如果你的复位是高电平有效,代码里写的却是rst_n低有效,硬件通电后芯片上电瞬间如果不手动复位,状态机可能直接跑飞,仿真时也会出现状态停留在未知态卡死的情况。我见过太多人卡在“状态机不跳转”,最后发现是把有效电平搞反了。

再说时钟。倒计时模块如果需要 1 秒跳一次,而开发板主时钟是 50MHz,那需要分频或直接用计数器每秒产生一个使能脉冲,而不是让倒计时模块在每个时钟周期都减一。如果你在仿真时看到 cnt 飞快地减到 0,不用怀疑代码,八成是缺了一个使能条件,才导致每个周期都在递减。

第三个高频问题是组合逻辑里的 latch。表现就是在综合报告里看到一堆 latch warning,仿真时某些信号永远高阻或保持原值。你用三段式状态机,基本能避开大半;如果你手写了二段式,小心点,次态逻辑的默认赋值一定要写上。

4.2 上板调试的实战经验

仿真通过不等于板子能跑,这句话我在这个项目上验证了无数次。上板后常见的怪问题有这么几类:

按键一按,投币金额直接加了两倍。这通常是消抖没做好,或者消抖后的信号被状态机检测到了两次边沿。我给一个不太会出错的方案:按键模块里做一个边沿检测,输出一个单时钟周期脉冲,状态机只认这个脉冲,不直接读按键电平。这样投币一次,金额只加一次,避免了状态机被同一个长高电平多次触发的问题。实际下载后发现数码管乱码、亮度不均或者数字跳动,数码管扫描刷新问题占大多数。数码管的扫描频率不能太高也不能太低:太高,人眼加权出来的亮度会不均衡甚至闪烁;太低,你会直接看到数字在跳。一般来说,1kHz 到 4kHz 是经验区,也就是每个位选信号刷新间隔 250us 到 1ms。如果你的显示模块是在顶层直接写的,建议把扫描逻辑独立出来,代码结构更清晰,调试时能单独改刷新频率,不会影响状态机逻辑。

还有一类很经典:程序烧进去之后完全没反应。先检查三个地方——时钟引脚有没有正确分配、复位按键复位时电平方向对不对、数码管引脚是否按原理图接对。如果这三个都没问题,就用 Quartus 的 SignalTap 逻辑分析仪抓一下内部状态机信号,不用改硬件,直接在下板的 FPGA 内部观察state信号在跑哪个值。SignalTap 在课程设计里不常见,但在调试这种状态机项目时确实是大杀器。哪怕你没有 SignalTap 的经验,花半小时学一下也值。


自动售货机加倒计时显示这个项目,说难不难,但它确实能把数字逻辑里最核心的几个知识点一次覆盖完:状态机设计、模块划分、仿真调试、引脚分配、时序问题。我在带这个项目的时候发现,最容易拉开差距的,其实就是调试思路。代码大家都能在网上找到参考,但能不能在仿真波形里定位“状态为什么没跳”、在上板时找到“数码管为什么不亮”,靠的是对每个模块职责和信号关系的理解。

最后分享一个小习惯。我每做一个这种带状态机的项目,都会准备一个笔记本,不是抄代码,而是画状态转移图和水位线记录调试过程。因为它能让你在写完代码之前,对“什么时候该跳”“跳到哪里去”了然于心。先把状态图画对,再动手写 Verilog,你就会发现这个项目比你想象的要容易得多。

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

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

立即咨询