1. 从一道经典面试题说起自动售卖机的数字逻辑世界最近在帮几个准备数字IC校招的朋友梳理手撕代码的题目发现“自动售卖饮料机”这道题出现的频率相当高。它不像一些复杂的算法题那样天马行空而是非常接地气考察的是工程师将现实生活中的一个具体场景用数字逻辑语言Verilog进行精确建模和实现的能力。这恰恰是数字IC设计工程师日常工作的缩影理解需求定义接口设计状态机处理时序最后用硬件描述语言把它“造”出来。这道题的核心是要求我们设计一个能够接收硬币、计算金额、选择商品、出货并找零的有限状态机FSM。听起来简单但里面藏着不少“坑”。比如如何优雅地处理“投币”这个异步事件商品价格变动时状态机怎么适应找零逻辑会不会出现竞争冒险这些细节正是区分“功能实现”和“可靠设计”的关键。今天我就以一个从业者的视角带大家完整地走一遍这道题的设计流程。我们不只追求“跑通仿真”更要深挖每个设计决策背后的“为什么”分享我在实际项目中积累的状态机设计心得和那些容易踩的坑。无论你是正在备战面试的学生还是想巩固基础的在职工程师相信这篇长文都能给你带来一些实实在在的启发。2. 需求拆解与系统架构定义把问题框定清楚动手写代码之前最重要的一步是把模糊的“自动售卖机”概念转化为清晰、无歧义的数字系统规格。很多初学者栽跟头不是因为Verilog不熟而是需求没吃透。2.1 明确功能边界与接口信号首先我们得定义这个售卖机的基本规则。为了便于说明和仿真我们设定一个经典场景饮料种类两种可乐Cola和雪碧Sprite。饮料价格可乐3元雪碧2元。价格最好设计成参数方便后续修改。支持币种只接收1元硬币。这是一个简化但核心逻辑累加是一样的。如果支持5角、1元无非是累加单元变成0.5。核心功能投币每投入1元当前金额增加1元。选择商品用户按下对应按钮如sel_cola,sel_sprite。判断与出货若当前金额 所选商品价格则发出“出货”信号dispense并指示饮料类型drink_type。计算找零金额change。完成出货后系统复位等待下一次交易。取消交易用户可随时按下取消键cancel退回已投入的全部金额return_coins。基于以上我们可以定义模块的输入输出端口了。这是硬件设计的“合同”必须严谨。module vending_machine ( input wire clk, // 时钟信号 input wire rst_n, // 低电平有效的异步复位信号 // 用户输入 input wire coin_in, // 投币脉冲信号高电平有效代表投入1元 input wire sel_cola, // 选择可乐高电平有效 input wire sel_sprite, // 选择雪碧高电平有效 input wire cancel, // 取消交易高电平有效 // 系统输出 output reg dispense, // 出货脉冲信号高电平有效 output reg [1:0] drink_type, // 出货类型2‘b01-可乐2’b10-雪碧 output reg [3:0] current_money, // 当前投币金额显示单位元用于调试或显示 output reg [3:0] change, // 找零金额输出 output reg return_coins // 退币脉冲信号高电平有效 ); // 参数定义增强代码可读性和可维护性 parameter PRICE_COLA 3; parameter PRICE_SPRITE 2; localparam IDLE 3b000; localparam COUNTING 3b001; localparam CHECK 3b010; localparam DISPENSE 3b011; localparam RETURN 3b100; // 状态寄存器 reg [2:0] current_state, next_state; reg [3:0] money_reg; // 内部金额寄存器这里有几个关键设计点投币信号coin_in我将其定义为脉冲信号而不是电平信号。这意味着当检测到coin_in的一个上升沿或高电平脉冲时才认为投入了一枚硬币。这更符合物理世界硬币投入的瞬间行为也能有效防止用户按住一个按钮就无限投币的bug。在硬件中这通常由一个消抖后的按键信号产生。金额表示current_money和内部的money_reg用了4位宽[3:0]最大表示15元对于这个场景足够了。如果价格或金额可能更大需要相应调整位宽。状态编码我使用了独热码One-Hot吗不这里用了简单的二进制码localparam。对于只有5个状态的小型FSM二进制码更节省触发器。但如果状态数较多比如超过10个独热码在FPGA上的译码逻辑更简单时序可能更好。这是一个设计权衡。2.2 核心状态机FSM设计三段式是王道状态机是自动售卖机的“大脑”。我强烈推荐使用“三段式”风格来编写Verilog状态机。它将时序部分状态转移、组合部分次态逻辑和输出逻辑清晰分离代码结构好综合结果稳定不易产生锁存器。第一段同步时序逻辑描述状态寄存器always (posedge clk or negedge rst_n) begin if (!rst_n) begin current_state IDLE; money_reg 4b0; end else begin current_state next_state; // 在IDLE状态清零金额或者在投币时累加 case (next_state) IDLE: money_reg 4b0; COUNTING: if (coin_in) money_reg money_reg 4d1; default: money_reg money_reg; // 其他状态保持金额 endcase end end这段代码在时钟上升沿更新当前状态和金额寄存器。注意金额的更新逻辑我放在了这里并且与next_state关联。这是一种常见的做法确保金额的变化和状态转移在同一时钟沿发生避免异步累加导致的时序问题。coin_in的判断放在COUNTING状态下更符合逻辑只有在计费状态下投币才有效。第二段组合逻辑描述次态转移条件这是状态机的核心逻辑需要仔细考虑所有可能的输入组合和当前状态。always (*) begin // 默认状态防止锁存器生成 next_state current_state; case (current_state) IDLE: begin if (coin_in) begin next_state COUNTING; end else begin next_state IDLE; end end COUNTING: begin if (cancel) begin next_state RETURN; end else if (sel_cola || sel_sprite) begin next_state CHECK; end else begin next_state COUNTING; // 继续等待投币或选择 end end CHECK: begin // 检查金额是否足够购买所选商品 if ((sel_cola money_reg PRICE_COLA) || (sel_sprite money_reg PRICE_SPRITE)) begin next_state DISPENSE; end else begin // 金额不足回到COUNTING状态继续投币 next_state COUNTING; end end DISPENSE: begin // 出货状态只维持一个时钟周期然后回到IDLE next_state IDLE; end RETURN: begin // 退币状态也只维持一个时钟周期 next_state IDLE; end default: next_state IDLE; endcase end这里有几个极易出错的细节always (*)敏感列表使用*让综合工具自动推断避免遗漏信号。next_state默认值在always块开头给next_state赋一个默认值这里是current_state。这至关重要它确保了在所有未列出的case分支下next_state都有一个确定的值从而避免了Verilog中可怕的“锁存器Latch”推断。锁存器在ASIC中可能引起时序问题在FPGA中也是不良设计。CHECK状态的逻辑这里必须同时判断商品选择和金额是否足够。(sel_cola money_reg PRICE_COLA)是一个完整的条件。如果用户按下了选择键但钱不够状态机应回到COUNTING等待继续投币而不是卡住或出错。DISPENSE和RETURN状态它们被设计成“瞬态”即只持续一个时钟周期然后立刻回到IDLE。这是因为“出货”和“退币”是动作而不是持续的状态。我们用脉冲信号来驱动这些动作。第三段时序/组合逻辑描述输出输出逻辑可以根据需求是组合的与当前状态和输入有关或时序的在时钟沿后输出。对于dispense、return_coins这类脉冲信号通常用时序逻辑产生一个周期的高电平更稳定。always (posedge clk or negedge rst_n) begin if (!rst_n) begin dispense 1b0; drink_type 2b00; change 4b0; return_coins 1b0; current_money 4b0; end else begin // 默认输出 dispense 1b0; return_coins 1b0; current_money money_reg; // 实时更新显示金额 case (current_state) CHECK: begin // 在CHECK状态计算找零和饮料类型为下一个周期的DISPENSE做准备 if (sel_cola money_reg PRICE_COLA) begin drink_type 2b01; // 可乐 change money_reg - PRICE_COLA; end else if (sel_sprite money_reg PRICE_SPRITE) begin drink_type 2b10; // 雪碧 change money_reg - PRICE_SPRITE; end end DISPENSE: begin // 在DISPENSE状态发出出货脉冲 dispense 1b1; end RETURN: begin // 在RETURN状态发出退币脉冲找零金额就是当前全部金额 return_coins 1b1; change money_reg; end default: begin // 其他状态保持或清零 change 4b0; drink_type 2b00; end endcase end end注意输出寄存的优缺点。这里我将输出都放在时序逻辑里。好处是输出信号不会出现毛刺Glitch更稳定直接满足下游模块的时序要求。缺点是输出会比导致它产生的输入如状态转移条件晚一个时钟周期。在这个场景下这是完全可以接受的甚至更安全。例如dispense脉冲在进入DISPENSE状态的同一个时钟上升沿产生清晰明确。3. 关键设计细节与防坑指南代码框架搭好了但一个健壮的设计离不开对细节的打磨。下面这些点是面试官喜欢追问也是实际项目容易出问题的地方。3.1 投币信号的同步化与边沿检测我们的设计假设coin_in是一个干净的、与时钟同步的脉冲。但现实中按键或硬币传感器产生的信号是异步的且带有机械抖动。直接使用会导致一次投币被误认为是多次。解决方案在顶层模块或者单独一个子模块里对coin_in进行同步化和边沿检测。// 同步化用两级触发器打拍消除亚稳态 reg coin_sync1, coin_sync2; always (posedge clk or negedge rst_n) begin if (!rst_n) begin coin_sync1 1b0; coin_sync2 1b0; end else begin coin_sync1 coin_in_raw; // coin_in_raw 是来自物理按键的异步信号 coin_sync2 coin_sync1; end end // 边沿检测检测同步后信号的上升沿 reg coin_sync2_dly; always (posedge clk or negedge rst_n) begin if (!rst_n) coin_sync2_dly 1b0; else coin_sync2_dly coin_sync2; end wire coin_pulse coin_sync2 ~coin_sync2_dly; // 得到干净的上升沿脉冲 // 然后将 coin_pulse 连接到 vending_machine 模块的 coin_in 端口这就是经典的“同步器边沿检测”电路。第一级同步器将异步信号拉入时钟域第二级进一步稳定它并用于边沿检测。coin_pulse就是一个时钟周期宽的高电平脉冲完美符合我们状态机对投币信号的定义。3.2 选择与取消信号的防抖处理sel_cola,sel_sprite,cancel同样面临抖动问题。但它们与coin_in略有不同用户可能长时间按住选择键。我们需要的是“按下”这个事件而不是持续的高电平。 一种更鲁棒的做法是对按键信号进行消抖后也进行边沿检测生成一个单周期脉冲作为状态机的输入。或者在状态机逻辑中明确处理只有在COUNTING状态下从低到高的跳变才被视为一次有效的选择请求。我们的代码采用了组合逻辑判断(sel_cola || sel_sprite)在实际硬件中最好将这些输入信号也进行同步和边沿检测生成sel_cola_pulse这样的信号再输入核心FSM。3.3 找零逻辑的陷阱与金额寄存器管理找零计算change money_reg - PRICE_XXX看似简单但要注意money_reg的更新时机。 在我们的设计中money_reg在COUNTING状态下当coin_in有效时更新。而找零计算发生在CHECK状态。这里存在一个潜在的时序路径coin_in-money_reg-change。如果用户投币后几乎同时按下选择键系统可能在同一个时钟周期内经历COUNTING-CHECK的转移。此时money_reg的新值加1后的值需要在一个时钟周期内稳定下来并用于CHECK状态中的比较和减法计算。在高速时钟下这可能会成为关键路径。优化思路可以将金额比较逻辑提前。例如在COUNTING状态一旦money_reg更新立即组合逻辑判断是否商品价格并生成一个ready_to_buy信号。这样CHECK状态可以更快做出决策。另一种更简单稳妥的办法是确保时钟周期足够慢能够覆盖这些逻辑和路径延迟。对于售卖机这种低速控制场景通常不是问题但作为设计者必须有这个意识。3.4 状态机编码风格与综合优化我们用了二进制编码3b000,3b001...。综合工具会将其映射到3个触发器Flip-Flop。独热码One-Hot如5个状态用5位5b00001,5b00010...会使用更多触发器但次态和输出逻辑可能更简单因为每个状态就是一根线为高。二进制码触发器少节省面积但状态译码逻辑相对复杂可能影响速度。独热码触发器多面积大但译码简单直接看某一位在FPGA中由于触发器资源丰富而组合逻辑资源相对珍贵有时反而能获得更好的性能。 对于这个小设计两者皆可。但在大型设计中需要根据目标器件ASIC/FPGA和性能要求进行选择。在代码中用parameter或localparam定义状态常量而不是直接使用3b001这样的“魔数”能让代码更易读、易维护。4. 完整的Testbench设计与仿真分析设计完成必须用仿真来验证。一个全面的测试平台Testbench是质量的保证。我们需要模拟各种正常和异常的用户行为。4.1 构建系统化的测试用例我们编写一个Verilog Testbench主要包含以下测试序列正常购买流程投币3次 - 选择可乐 - 观察出货和找零。金额不足继续投币投币1次 - 选择雪碧应失败回计数状态- 再投币1次 - 选择雪碧成功。取消交易投币2次 - 取消 - 观察退币信号和找零金额。连续购买完成一次可乐购买后系统应回到IDLE等待下一次投币。边界与异常投币过程中同时按选择和取消优先级测试通常取消优先。在非COUNTING状态按选择键应无效。在出货或退币脉冲期间输入新信号应被忽略因为状态瞬间就跳走了。4.2 仿真代码示例与波形解读下面是一个简化的Testbench框架timescale 1ns/1ps module tb_vending_machine(); reg clk, rst_n; reg coin_in, sel_cola, sel_sprite, cancel; wire dispense, return_coins; wire [1:0] drink_type; wire [3:0] current_money, change; // 实例化被测模块 vending_machine uut ( .clk(clk), .rst_n(rst_n), .coin_in(coin_in), .sel_cola(sel_cola), .sel_sprite(sel_sprite), .cancel(cancel), .dispense(dispense), .drink_type(drink_type), .current_money(current_money), .change(change), .return_coins(return_coins) ); // 时钟生成 always #10 clk ~clk; // 50MHz时钟 // 测试过程 initial begin // 初始化 clk 0; rst_n 0; coin_in0; sel_cola0; sel_sprite0; cancel0; #100 rst_n 1; // 释放复位 // 测试用例1正常购买可乐 $display( Test 1: Buy Cola ); repeat(3) begin // 投3个币 (posedge clk); #1; coin_in 1; (posedge clk); #1; coin_in 0; end (posedge clk); #1; sel_cola 1; // 选择可乐 (posedge clk); #1; sel_cola 0; // 等待出货完成观察几个周期 repeat(5) (posedge clk); // 测试用例2取消交易 $display( Test 2: Cancel Transaction ); repeat(2) begin // 投2个币 (posedge clk); #1; coin_in 1; (posedge clk); #1; coin_in 0; end (posedge clk); #1; cancel 1; (posedge clk); #1; cancel 0; repeat(5) (posedge clk); // 测试用例3金额不足后继续投币 $display( Test 3: Insufficient money then add ); (posedge clk); #1; coin_in 1; // 投1币 (posedge clk); #1; coin_in 0; (posedge clk); #1; sel_sprite 1; // 选雪碧钱不够(需要2元) (posedge clk); #1; sel_sprite 0; // 此时应仍在COUNTING状态再投1币 (posedge clk); #1; coin_in 1; (posedge clk); #1; coin_in 0; (posedge clk); #1; sel_sprite 1; // 再选雪碧应成功 (posedge clk); #1; sel_sprite 0; repeat(5) (posedge clk); $finish; end endmodule在仿真波形中使用ModelSim、VCS或Vivado Simulator等工具查看我们需要重点观察状态转移current_state是否按照我们设计的路径变化IDLE-COUNTING-CHECK-DISPENSE-IDLE...金额累加每次coin_in脉冲后current_money是否准确加1输出脉冲在正确的状态DISPENSE/RETURNdispense和return_coins是否只产生一个时钟周期的高脉冲输出数据drink_type和change在出货或退币时输出值是否正确异步复位在仿真开始阶段复位信号有效时所有寄存器状态、金额、输出是否都被清零4.3 覆盖率驱动测试进阶对于工业级设计仅有功能测试是不够的。我们会使用代码覆盖率工具来评估测试的完备性。行覆盖率代码的每一行是否都被执行过条件覆盖率if-else、case语句中的每个条件分支是否都被测试到例如我们是否测试了CHECK状态下money_reg PRICE_COLA为真和为假两种情况状态机覆盖率状态机的每个状态是否都进入过每个状态之间的可能转移是否都被触发 通过分析覆盖率报告我们可以发现测试盲点补充测试用例从而极大地提升验证质量。虽然对于这个简单设计手动测试即可但建立覆盖率意识是成为专业验证工程师的重要一步。5. 从仿真到实现可综合性与实际部署考量我们的RTL代码在仿真中正确不代表就能直接变成可工作的硬件。还需要考虑可综合性和实际物理接口。5.1 综合注意事项与物理接口映射避免异步逻辑我们的设计全部采用同步时序逻辑always (posedge clk)这是好的。确保没有在组合逻辑always块中生成寄存器通过给每个分支完整赋值来避免锁存器。复位策略我们使用了低电平有效的异步复位rst_n。这在FPGA和ASIC中都是常见的。需要确保复位信号干净无毛刺。在实际系统中复位可能来自电源管理芯片或按键。输出驱动dispense和return_coins是脉冲信号它们可能需要驱动一个真实的执行机构比如电机或电磁铁。我们的模块输出驱动能力很弱标准逻辑电平。通常需要增加一个输出驱动级例如通过一个晶体管或继电器驱动电路来提供足够的电流。在FPGA设计中需要注意管脚分配和I/O电平标准。输入防抖与同步如前所述这是必须的。这部分电路可以放在另一个模块也可以与主FSM集成。在FPGA上还可以利用片上的专用硬件资源。5.2 扩展性思考如何设计一个更通用的售卖机这个简单设计可以作为一个内核很容易扩展多种价格与币种将商品价格和币值定义为数组或查找表LUT。金额寄存器用整数表示如以分为单位。投币信号可以带一个币值参数coin_value[3:0]。库存管理为每种饮料增加一个库存计数器。在CHECK状态不仅要检查金额还要检查库存是否大于0。出货时DISPENSE状态递减库存。显示与交互增加输出端口驱动数码管或LCD显示当前金额、商品价格、库存等信息。可以增加“查询价格”等输入按钮。网络与管理增加通信接口如UART、SPI可以将交易记录、库存状态上报给后台管理系统或接收远程定价、补货指令。安全与容错增加看门狗定时器防止状态机跑飞。对输入信号进行冗余校验。设计这样一个模块的过程就是一个完整的微控制器或简单逻辑系统的开发流程。它锻炼的是系统思维、硬件描述语言功底和对时序逻辑的深刻理解。手撕代码的目的从来不是背题而是掌握这种将复杂现实问题分解、抽象、并用硬件语言实现的能力。下次当你再看到“自动售卖机”时希望你能立刻在脑海中勾勒出它的状态转移图以及那些确保它稳定可靠运行的关键细节。