简介:面向数字电路课程设计的Verilog饮料贩卖机完整项目,依托Quartus II与FPGA平台,覆盖投币、找零、商品选择、状态机控制等典型功能模块,适合本科生完成数电课设或系统入门硬件描述语言。资源包共160个文件,大小约7.2MB,包含5个Verilog源码、Quartus工程文件、ModelSim测试脚本、仿真波形以及设计报告文档等,基本覆盖从代码编写、功能仿真到工程综合的完整流程;readme与综合报告也有助于对照验证实现细节。压缩包内还有大量工程备份与配置缓存,便于对照项目组织方式与调试过程;核心源码分别对应主状态机、按键消抖和分频逻辑。目前已有171人学习,工程中的源码与测试脚本可直接复用,既能帮助理解状态转移和找零算法,也为撰写课设报告和后续功能扩展提供了清晰参考。
1. 项目概述与课设核心思维
1.1 一个经典课设背后的真正考点
看到"verilog饮料贩卖机-SWJTU-数电课设"这个题目,很多同学的直观反应是"这不就是个状态机嘛"。确实,饮料贩卖机是数电课设里流传多年的经典题目,但它真正要训练的不是"会写状态机",而是你能不能把一堆看似零散的需求——投币识别、金额累计、按键选择、出货控制、找零逻辑——用数字逻辑的思维统一到一套时钟节拍下,让它们并行运转又不互相打架。
这个题目在西南交大的数电课设中出现率极高,原因很简单:它麻雀虽小、五脏俱全。一个完整的饮料贩卖机系统基本覆盖了数字电路设计的几大核心技能树:有限状态机(FSM)、时序逻辑设计、边沿检测与消抖处理、参数化设计,以及最关键的系统级模块划分能力。如果你能把这道题从代码到仿真完整啃下来,Verilog 入门到进阶的最后一层窗户纸基本也就捅破了。
1.2 适合谁看,能解决什么问题
如果你属于下面三类人中的任何一类,这篇内容都能直接落地使用:
- 正在做数电课设的在校生:可以直接参考整体的模块划分思路、状态机设计方法和 testbench 写法,避免"代码能过编译但仿真跑不出效果"的尴尬。
- 刚自学 Verilog 想找练手项目的初学者:饮料贩卖机的状态规模适中(一般 4~6 个状态),比流水灯、计数器有挑战性,又比 CPU 设计友好得多,是进阶的理想跳板。
- 准备数字 IC 相关笔试面试的求职者:贩卖机是面试里经常被拿出来聊的状态机题目,考察点通常是"状态怎么编码""输出会不会有毛刺""投币和找零冲突怎么处理",把课设里的逻辑理透了,这些面试题的底层思路是通用的。
这篇博文会从需求拆解开始,一路走到代码实现、仿真验证和常见坑位排查,全程用我实际做过的方案来讲,不发参考代码"阉割版",只讲能跑通的完整链路。
2. 需求拆解与方案选型
2.1 先把需求翻译成输入输出
任何课设拿到手,第一件事不是打开 Vivado 或者 Quartus 写代码,而是拿张纸把需求翻译成清晰的输入输出定义。饮料贩卖机常见的设计需求是这样写的:
- 投币口支持 1 元(或 2 元、5 角)硬币输入
- 有若干种饮料可选,价格不同(常见设定:可乐 3 元,矿泉水 2 元,或者统一 3 元)
- 金额足够时出货,金额不够时提示余额不足
- 可停止投币并退回当前余额
- 需要数码管显示当前投入金额
把这些口语化的需求映射到数字电路上,实际上就是以下接口:
输入端口:
clk和rst_n:系统时钟与异步复位- 投币信号(比如
coin_1、coin_5),每个脉冲代表一枚硬币 - 饮料选择信号(比如
sel_a、sel_b) - 退币信号
refund
输出端口:
- 出货信号(
out_a、out_b),脉宽一个周期或者持续到下一个状态 - 找零信号或找零金额
change - 当前金额的数值输出,通常是 BCD 码或者二进制送数码管
一个关键点我放在前面提醒:投币和选择信号是异步的,必须做同步化处理,否则在仿真里可能看不出来问题,但上板的时候会有概率性故障。实战中我会把每个外部输入先打两拍(两级触发器同步),再做边沿检测,这样能同时解决亚稳态和重复触发两个问题。
2.2 状态机选型:Moore 还是 Mealy
饮料贩卖机的核心控制逻辑适合用 Moore 状态机实现。原因很实际:Moore 机的输出只取决于当前状态,与输入无关,因此输出在一个完整时钟周期内保持稳定,不会因为输入信号的毛刺而抖动。Mealy 机虽然有时可以减少中间状态,但它的输出同时依赖输入,在按键和投币信号本身就存在不确定性的场景下,Mealy 输出很容易出现毛刺。
饮料贩卖机的核心决策逻辑是"当前金额 + 当前行为",这个业务天然适合状态驱动输出。所以我的方案是:核心 FSM 采用 Moore 架构,状态里直接编码当前累计金额,输出由状态直接译出。
这样一来,整个设计分成了两个层面:顶层是 FSM 根据"当前处于多少金额"这个状态决定能干什么;底层是各种辅助模块(消抖、显示、找零)负责把物理信号变成干净的逻辑信号。分层设计的好处后面你会感受到——调试的时候不需要把十几个 always 块摊成一锅粥。
2.3 模块划分:不要一块代码写到底
很多学生习惯把所有逻辑写在一个顶层文件里,仿真出问题之后根本无从下手。我个人的习惯是至少分成四个模块:
| 模块名 | 职责 | 关键点 |
|---|---|---|
debounce | 按键与投币信号消抖 | 延时打拍+电平判断,消除机械抖动 |
clk_div | 分频产生低频时钟 | 数码管扫描/按键采样统一使用 |
fsm_core | 核心状态机,维护当前金额、出货、找零 | 所有状态转移在时钟上升沿触发 |
seg_display | BCD码转七段数码管显示 | 注意扫描显示时候选信号的时序 |
这样的好处是在 testbench 里可以分别实例化每个子模块做单元测试,也可以把整个顶层拉通做系统仿真,排查问题时定位范围一目了然。后面我在第 3 节会给出每个模块的核心代码框架,第 4 节配合仿真和板级调试说明其中容易踩的坑。
3. 核心模块设计与状态机实现
3.1 按键与投币信号消抖模块
物理按键和硬币传感器输出的信号有一个共同特点:电平不稳定。机械按键按下瞬间会产生几毫秒到十几毫秒的抖动,硬币通过传感器时输出脉冲也不是理想的方波。如果直接把这种信号喂给状态机,一个按键可能会被识别成三四次操作,这时就需要消抖。
消抖的思路很淳朴:等信号稳定了再采。具体做法是使用低速采样时钟(典型值 1kHz),对输入信号连续采样 N 次,如果 N 次采样值相同,就认为电平稳定了。以下是工程上非常常用的一种消抖实现:
module debounce #( parameter N = 20 // 采样次数 )( input wire clk, input wire rst_n, input wire key_in, output reg key_out ); reg [4:0] cnt; reg key_tmp0, key_tmp1; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin key_tmp0 <= 1'b1; // 默认高电平(按键低有效) key_tmp1 <= 1'b1; key_out <= 1'b1; cnt <= 5'd0; end else begin key_tmp0 <= key_in; key_tmp1 <= key_tmp0; // 第一级同步 if (key_tmp1 != key_out) begin if (cnt == N-1) begin key_out <= key_tmp1; // 连续N个周期稳定,输出更新 cnt <= 5'd0; end else cnt <= cnt + 1'b1; end else cnt <= 5'd0; end end endmodule注意这里有一个小经验:外部信号进来先接一级触发器(key_tmp0、key_tmp1)做同步,因为按键信号相对系统时钟是异步的,不处理的话有概率触发触发器亚稳态。虽然课设里大概率不会遇到,但养成这个习惯对以后做真实工程项目非常重要。
3.2 核心状态机的状态定义
饮料贩卖机的状态定义有很多种写法,我见过有人把"当前金额"详细拆成 0 元、1 元、2 元…… 每个金额一个状态,这种做法的好处是直观,坏处非常致命——一旦金额种类变多(比如支持 5 元纸币、10 元纸币),状态数量爆炸式增长,代码维护成本极高。
我的做法是状态与金额解耦:FSM 只描述"空闲、投币、出货、找零、退币"等操作状态,当前金额用独立的寄存器维护。这样设计的好处是:日后想增加饮料种类、增加投币面额,你只需要改的是"金额判断逻辑"和"输出译码逻辑",FSM 本身不会膨胀。
核心状态定义如下:
localparam IDLE = 3'b000; // 空闲,等待投币 localparam COIN = 3'b001; // 投币中,累计金额 localparam SELECT = 3'b010; // 选择饮料 localparam SELL = 3'b011; // 出货并扣款 localparam CHANGE = 3'b100; // 找零 localparam REFUND = 3'b101; // 手动退币状态转移的核心逻辑其实只有几条线:
IDLE状态下如果有投币脉冲,进入COIN状态并把面额累加到金额寄存器。- 投币过程中可以连续投币,金额寄存器实时更新。
- 按下饮料选择按键时,进入
SELECT状态,比较"当前金额 >= 饮料价格"是否成立。 - 金额足够则进入
SELL状态,拉高出货脉冲,同时金额减去对应价格。 - 需要找零则进入
CHANGE状态,输出零钱;不需要找零直接回到IDLE。 - 任何时候按下退币,进入
REFUND状态,金额清零并输出当前余额。
这里有一个隐含的细节需要特别注意:状态判断和金额比较都在同一个时钟沿完成,还是分成两个周期?我最后采用的是"状态跳转与金额更新在同一个时钟周期完成"的方案,因为贩卖机的状态切换本来就不需要多周期握手,一个节拍延迟对用户体验完全无感,还能节省状态数量。如果是在真实产品里做支付逻辑,那是另一个维度的问题,课设阶段不需要过度设计。
3.3 顶层实例化与连接
顶层模块把各个子模块像积木一样搭起来,需要注意的只有各信号命名清晰、便于仿真观察这一点。
module vending_machine_top ( input wire clk_50m, input wire rst_n, input wire key_1yuan, input wire key_5jiao, input wire key_sel_a, input wire key_sel_b, input wire key_refund, output wire [3:0] seg_data, output wire [1:0] seg_sel, output wire out_a, output wire out_b, output wire [3:0] change_cnt ); wire key_1yuan_db, key_5jiao_db, key_sel_a_db, key_sel_b_db, key_refund_db; wire btn_pulse_1yuan, btn_pulse_5jiao, btn_pulse_sela, btn_pulse_selb, btn_pulse_refund; wire clk_1k; // 低频分频时钟 clk_div #(.DIV(50_000)) u_clk_div ( .clk_in (clk_50m), .rst_n (rst_n), .clk_out(clk_1k) ); // 每个输入独立消抖 debounce #(.N(20)) u_db_1yuan ( .clk(clk_1k), .rst_n(rst_n), .key_in(key_1yuan), .key_out(key_1yuan_db) ); // ... 其余按键同理 // 边沿检测,将电平变为单周期脉冲 pulse_gen u_pulse_1yuan ( .clk(clk_1k), .rst_n(rst_n), .level_in(key_1yuan_db), .pulse_out(btn_pulse_1yuan) ); // ... 其余脉冲同理 // 核心状态机 fsm_core u_fsm ( .clk(clk_1k), .rst_n(rst_n), .coin_1yuan(btn_pulse_1yuan), .coin_5jiao(btn_pulse_5jiao), .sel_a(btn_pulse_sela), .sel_b(btn_pulse_selb), .refund(btn_pulse_refund), .out_a(out_a), .out_b(out_b), .change_cnt(change_cnt) ); // 数码管显示 seg_display u_seg ( .clk(clk_50m), .rst_n(rst_n), .amount(amount_display), .seg_data(seg_data), .seg_sel(seg_sel) ); endmodule从这个顶层连接可以看出来,每个模块的接口设计其实是先在顶层规划好的。我自己的习惯是先画顶层框图,再逐个实现子模块,而不是写一步看一步。这样到最后集成的时候,基本上不需要改接口。
4. 实操过程与仿真验证
4.1 从零搭建仿真环境(Vivado + Modelsim 双选)
仿真环境的选择,课设阶段我建议首选 Modelsim(或 Questasim),因为它的波形查看和调试体验对学生党来说足够简单直观。Vivado 自带的仿真器也不是不能用,但加载大工程时的速度和波形操作的顺滑度,Modelsim 明显更舒服。
具体流程三步走:
- 在 Modelsim 里建立新工程,添加所有设计文件和 testbench 文件。
- 编译通过后,在 Library 里找到
testbench模块,右键 Simulate。 - 打开 wave 窗口,把需要观察的信号拖入波形面板,运行
run -all看波形。
有一个很容易忽略的细节:Modelsim 默认的仿真时间单位是 1ns,但你的分频后时钟可能是 1kHz(周期 1ms),run -all默认只跑极短的时间,不修改运行时长会导致波形上看不到任何翻转。建议在 testbench 里直接用带时基的语句控制,比如#1000ms;然后看波形。
4.2 Testbench 怎么写才算覆盖到位
我批过不少课设代码,相当多学生栽在 testbench 写得过于随意上——只验证了一条"投币够钱然后出货"的路径,仿真波形看着是过了,但老师一追问边界情况就露馅。好的 testbench 至少要覆盖下面几类场景:
- 正常购买流程:连续投币两枚 1 元,购买 2 元饮料,验证出货和余额清零。
- 金额不足流程:只投一枚 1 元就购买 2 元饮料,验证不出货、提示不足。
- 找零流程:投 3 元购买 2 元饮料,验证找零 1 元。
- 连续购买流程:第一次购买完成后不再投币,直接购买另一种饮料,验证金额不足不可购买。
- 退币流程:投币后未购买,直接按退币,验证余额清零。
- 异步输入乱序:投币、按键几乎同时到达(模拟真实操作),验证系统不会误触发。
testbench 的关键代码框架长这样:
`timescale 1ns / 1ps module tb_vending_machine; reg clk_50m = 0; reg rst_n = 0; reg key_1yuan = 1; reg key_sel_a = 1; // ... 其他信号 // 50MHz 时钟 always #10 clk_50m = ~clk_50m; // 实例化被测设计 vending_machine_top dut ( .clk_50m(clk_50m), .rst_n(rst_n), .key_1yuan(key_1yuan), .key_sel_a(key_sel_a) // ... ); // 模拟一次按键动作:低有效,拉低一段时间再释放 task press_key; input signal; begin signal = 0; #100; // 模拟按下过程 signal = 1; #100; // 模拟释放过程 end endtask initial begin rst_n = 0; #100; rst_n = 1; #100; // 场景1:投两枚1元,购买2元饮料 press_key(key_1yuan); #100; press_key(key_1yuan); #100; press_key(key_sel_a); #1000; // 场景2:找零流程 // ... $finish; end endmoduletask可以封装重复的按键激励,让 testbench 可读性大增。我还会在关键位置加$display打印当前金额、当前状态的辅助信息,这样不用盯着波形猜逻辑,控制台直接看文字描述帮助极大。
4.3 仿真调试中常见的三类问题
第一类:状态机完全不跳转。波形图里state一直是IDLE,投币脉冲也观察到了,但状态就是不动。大概率是复位没释放,或者状态机里的状态判断条件写错。我的排查习惯是:先在 testbench 里把内部信号state和coin_pulse同时拉进波形窗口,对照两者的时序关系,确认脉冲是否在时钟上升沿到来之前已经有效。如果脉冲时序没问题,再看状态转移的case分支条件是否和信号名对得上。
第二类:按键一次触发多次。这个基本可以断定是消抖模块的参数没过关。注意我给消抖模块的输入时钟是分频后的clk_1k,如果你把系统 50MHz 时钟直接接进去,采样次数又是 20,那消抖窗口只有 400ns,物理按键的抖动完全躲不过去。采样时钟频率必须远低于抖动频率,1kHz 采样时钟配 20 次采样,大约能覆盖 20ms 的抖动窗口,一般机械按键都扛得住。
第三类:仿真时间设置不足导致什么都看不到。Modelsim 里如果直接run -all,默认常走到仿真时间上限就停,而你分频后的时钟周期是 1ms,一次投币加出货流程至少要几个毫秒,波形的横轴拉不长自然什么都看不出来。testbench 里用#100ms;这种带单位的延迟语句可以避免这种问题。
5. 常见问题与避坑指南
5.1 阻塞赋值与非阻塞赋值混用
这是 Verilog 初学者最容易踩的坑,没有之一。在状态机里,所有时序逻辑的 always 块里一律使用非阻塞赋值<=,这个没有例外。
我之前见过一个学生的代码,状态转移用非阻塞赋值,但金额累加那条语句误写成了阻塞赋值,导致仿真结果里金额永远比预期晚一个周期,看上去就是"找零总是少一元"的诡异现象。如果你在仿真中发现了类似"差一拍"的 bug,优先检查是不是赋值方式混用了。
5.2 状态编码如何选择
课设规模的状态机,我推荐直接使用独热码(one-hot)。原理不复杂:每个状态一个 bit 位,状态判断从"多 bit 比较"变成了"单 bit 判断",时序路径更短。在现代 FPGA 上 LUT 资源非常充裕,独热码消耗的那点触发器资源根本不算事,但带来的时序收益是实打实的。
写成参数化定义就是:
localparam IDLE = 6'b000001; localparam COIN = 6'b000010; localparam SELECT = 6'b000100; localparam SELL = 6'b001000; localparam CHANGE = 6'b010000; localparam REFUND = 6'b100000;不过有一点要注意:程序里判断状态时我习惯用casez或者if (state[2])这种单 bit 判断,而不是用state == SELECT这种完整匹配,否则独热码的优势就发挥不出来了。
个人经验:课设代码里加一个(* syn_encoding = "onehot" *)的 synthesis attribute,工具就会自动做状态编码转换。当然最稳的还是自己直接在代码里写清楚,不依赖工具默认行为。
5.3 显示模块的动态扫描与余晖效应
如果开发板上用四位一体数码管,动态扫描显示是标准做法。所谓动态扫描,就是快速轮流点亮每位数码管(比如 1ms 切换一次),人眼因为视觉暂留效应看到的是"四位数同时亮着"。
这个模块有两个细节:
- 扫描频率不能太低也不能太高。太低会看到明显闪烁,太高可能由于位选切换太快导致显示不均。我实测 500Hz 到 1kHz 的扫描频率表现不错,也就是每位点亮 1~2ms。
- 切换数据时先在段选端输出,再切换位选。如果先切换位选再更新数据,数码管上会出现短暂的前一位数字残影,看起来有点脏。工程上这个小顺序问题不算致命,但丝印清楚的显示会给验收加分。
6. 工程代码的组织习惯与验收准备
6.1 文件命名与目录结构
课设代码量不大,但不代表可以随意堆放。我会在工程根目录建rtl、sim、doc三个文件夹,rtl 下放设计源码,sim 下放 testbench,doc 下放设计文档和波形截图。文件命名统一用模块名_功能描述.v的格式,比如fsm_core.v、debounce_key.v。这样做的好处是答辩的时候演示工程,老师一眼扫过去就知道你的代码组织能力,印象分会好不少。
6.2 答辩演示的加分细节
如果你的课设有实物演示环节,有几个小心得供参考:
- 上板之前把所有外设(按键、数码管、LED)单独测一遍,确认硬件没问题再测整体逻辑。很多系统故障其实是硬件接触不良。
- 演示顺序从简到难:先单投币显示金额,再购买出货,最后找零退币,这样每一步都经得起追问。
- 提前准备好波形截图放在 PPT 或演示文档里,尤其是状态跳转、找零时序这些关键波形。答辩时如果能在波形图里指出你设计的核心信号,说服力远胜于"我的代码能跑"。
6.3 一个可以快速试跑的完整状态机代码骨架
快速给出一份可以跑通的核心 FSM 代码(分频和消抖模块见前文,这里只列核心逻辑),方便拿到工程上直接对照修改:
module fsm_core ( input wire clk, input wire rst_n, input wire coin_1yuan, input wire coin_5jiao, input wire sel_a, input wire sel_b, input wire refund, output reg out_a, output reg out_b, output reg [3:0] change_cnt ); localparam PRICE_A = 4'd3; // 饮料A价格3元 localparam PRICE_B = 4'd2; // 饮料B价格2元 localparam IDLE = 6'b000001; localparam COIN = 6'b000010; localparam SELECT = 6'b000100; localparam SELL = 6'b001000; localparam CHANGE = 6'b010000; localparam REFUND = 6'b100000; reg [5:0] state, next_state; reg [3:0] amount; // 当前累计金额,单位:元 // 状态寄存器 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: begin if (coin_1yuan || coin_5jiao) next_state = COIN; end COIN: begin if (sel_a || sel_b) next_state = SELECT; else if (refund) next_state = REFUND; else if (coin_1yuan || coin_5jiao) next_state = COIN; end SELECT: begin if ((sel_a && (amount >= PRICE_A)) || (sel_b && (amount >= PRICE_B))) next_state = SELL; else next_state = IDLE; end SELL: begin next_state = (change_cnt > 0) ? CHANGE : IDLE; end CHANGE: begin next_state = IDLE; end REFUND: begin next_state = IDLE; end default: next_state = IDLE; endcase end // 时序逻辑:金额变化 always @(posedge clk or negedge rst_n) begin if (!rst_n) amount <= 4'd0; else case (state) COIN: begin if (coin_1yuan) amount <= amount + 4'd1; else if (coin_5jiao) amount <= amount + 4'd0; // 5角根据需求处理 end SELL: begin if (sel_a) amount <= amount - PRICE_A; else if (sel_b) amount <= amount - PRICE_B; end REFUND: amount <= 4'd0; endcase end // 输出译码 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin out_a <= 1'b0; out_b <= 1'b0; change_cnt <= 4'd0; end else begin out_a <= 1'b0; out_b <= 1'b0; case (state) SELL: begin if (sel_a) out_a <= 1'b1; else if (sel_b) out_b <= 1'b1; change_cnt <= amount - ((sel_a) ? PRICE_A : PRICE_B); end endcase end end endmodule说明一下:上面的骨架把 5 角硬币的累加逻辑简化了,实际要支持 5 角的话要么把金额单位换成"角",比如所有价格乘 10,以小单位整数表示金额,要么增加一个半元的判断逻辑。我实际做的时候更喜欢用"角"做单位,这样所有货币面额都是整数,比较大小、做减法都不容易出错,这是从代码可维护性角度的一个很实用的选择。
7. 从课设到工程的习惯养成
我见过太多人把课设当成"写完代码跑出波形就算结束",其实这恰恰是把模电数电学活的最好机会。饮料贩卖机这个题目本身并不复杂,但如果你在完成它的过程中养成了模块化设计、写完整 testbench、主动验证边界条件的习惯,那这门课设的价值就远远超过学分本身。
说一个我在课设里踩过最深的坑:仿真全对,上板翻车。当时按键消抖用了系统时钟直采,仿真和波形完美,一到真机上按键就疯了一样跳。后来排查了半天才发现是采样时钟频率太高,按键的机械抖动完全没滤掉。从那以后我养成一个习惯,仿真通过只是第一步,任何一个模块在接入真机之前都要拿实际的物理特性去推演一遍——按键抖动多少毫秒、数码管扫描频率该多大、时钟分频以后每周期多久,这些参数全都来自器件手册和生活经验,仿真软件没法自动帮你虑掉。
数字逻辑设计有一个比喻我特别喜欢:写 Verilog 不像写软件,更像画工程蓝图——你描述的每一根线、每一个寄存器,最后都会变成板上真实的电路。饮料贩卖机这种入门级项目里,你画的每一根线都能在课设板上找到对应物,这种把抽象代码映射到物理硬件的"构建感",是这门课最能让人上瘾的部分。
如果你在用这份思路做课设的过程中碰到状态机跑飞、按键时序不对或仿真波形不如预期之类的问题,欢迎在评论区把波形截图发出来一起诊断。我自己当年做这个题目也是踩了无数坑才跑通全流程的,多一个人讨论,心里就多一分底。
本文还有配套的精品资源,点击获取