ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Verilog课设实战:饮料贩卖机状态机设计与FPGA实现全攻略

Verilog课设实战:饮料贩卖机状态机设计与FPGA实现全攻略 简介面向数字电路课程设计的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_displayBCD码转七段数码管显示注意扫描显示时候选信号的时序这样的好处是在 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 1b1; // 默认高电平按键低有效 key_tmp1 1b1; key_out 1b1; cnt 5d0; 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 5d0; end else cnt cnt 1b1; end else cnt 5d0; end end endmodule注意这里有一个小经验外部信号进来先接一级触发器key_tmp0、key_tmp1做同步因为按键信号相对系统时钟是异步的不处理的话有概率触发触发器亚稳态。虽然课设里大概率不会遇到但养成这个习惯对以后做真实工程项目非常重要。3.2 核心状态机的状态定义饮料贩卖机的状态定义有很多种写法我见过有人把当前金额详细拆成 0 元、1 元、2 元…… 每个金额一个状态这种做法的好处是直观坏处非常致命——一旦金额种类变多比如支持 5 元纸币、10 元纸币状态数量爆炸式增长代码维护成本极高。我的做法是状态与金额解耦FSM 只描述空闲、投币、出货、找零、退币等操作状态当前金额用独立的寄存器维护。这样设计的好处是日后想增加饮料种类、增加投币面额你只需要改的是金额判断逻辑和输出译码逻辑FSM 本身不会膨胀。核心状态定义如下localparam IDLE 3b000; // 空闲等待投币 localparam COIN 3b001; // 投币中累计金额 localparam SELECT 3b010; // 选择饮料 localparam SELL 3b011; // 出货并扣款 localparam CHANGE 3b100; // 找零 localparam REFUND 3b101; // 手动退币状态转移的核心逻辑其实只有几条线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周期 1msrun -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 6b000001; localparam COIN 6b000010; localparam SELECT 6b000100; localparam SELL 6b001000; localparam CHANGE 6b010000; localparam REFUND 6b100000;不过有一点要注意程序里判断状态时我习惯用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 下放 testbenchdoc 下放设计文档和波形截图。文件命名统一用模块名_功能描述.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 4d3; // 饮料A价格3元 localparam PRICE_B 4d2; // 饮料B价格2元 localparam IDLE 6b000001; localparam COIN 6b000010; localparam SELECT 6b000100; localparam SELL 6b001000; localparam CHANGE 6b010000; localparam REFUND 6b100000; 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 4d0; else case (state) COIN: begin if (coin_1yuan) amount amount 4d1; else if (coin_5jiao) amount amount 4d0; // 5角根据需求处理 end SELL: begin if (sel_a) amount amount - PRICE_A; else if (sel_b) amount amount - PRICE_B; end REFUND: amount 4d0; endcase end // 输出译码 always (posedge clk or negedge rst_n) begin if (!rst_n) begin out_a 1b0; out_b 1b0; change_cnt 4d0; end else begin out_a 1b0; out_b 1b0; case (state) SELL: begin if (sel_a) out_a 1b1; else if (sel_b) out_b 1b1; change_cnt amount - ((sel_a) ? PRICE_A : PRICE_B); end endcase end end endmodule说明一下上面的骨架把 5 角硬币的累加逻辑简化了实际要支持 5 角的话要么把金额单位换成角比如所有价格乘 10以小单位整数表示金额要么增加一个半元的判断逻辑。我实际做的时候更喜欢用角做单位这样所有货币面额都是整数比较大小、做减法都不容易出错这是从代码可维护性角度的一个很实用的选择。7. 从课设到工程的习惯养成我见过太多人把课设当成写完代码跑出波形就算结束其实这恰恰是把模电数电学活的最好机会。饮料贩卖机这个题目本身并不复杂但如果你在完成它的过程中养成了模块化设计、写完整 testbench、主动验证边界条件的习惯那这门课设的价值就远远超过学分本身。说一个我在课设里踩过最深的坑仿真全对上板翻车。当时按键消抖用了系统时钟直采仿真和波形完美一到真机上按键就疯了一样跳。后来排查了半天才发现是采样时钟频率太高按键的机械抖动完全没滤掉。从那以后我养成一个习惯仿真通过只是第一步任何一个模块在接入真机之前都要拿实际的物理特性去推演一遍——按键抖动多少毫秒、数码管扫描频率该多大、时钟分频以后每周期多久这些参数全都来自器件手册和生活经验仿真软件没法自动帮你虑掉。数字逻辑设计有一个比喻我特别喜欢写 Verilog 不像写软件更像画工程蓝图——你描述的每一根线、每一个寄存器最后都会变成板上真实的电路。饮料贩卖机这种入门级项目里你画的每一根线都能在课设板上找到对应物这种把抽象代码映射到物理硬件的构建感是这门课最能让人上瘾的部分。如果你在用这份思路做课设的过程中碰到状态机跑飞、按键时序不对或仿真波形不如预期之类的问题欢迎在评论区把波形截图发出来一起诊断。我自己当年做这个题目也是踩了无数坑才跑通全流程的多一个人讨论心里就多一分底。本文还有配套的精品资源点击获取
返回列表