ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

自动售货机FSM设计实战:从Verilog状态机到数字IC面试进阶

自动售货机FSM设计实战:从Verilog状态机到数字IC面试进阶 如果要在数字设计里挑一个既适合教学、又适合考核的经典题目自动售货机一定排在前列。这个看似只涉及“收钱、找零、出货”的小系统拆开来看其实覆盖了状态机建模、时序控制、边界处理甚至后续还能延伸到总线接口设计。我做数字IC设计这些年面试候选人也特别喜欢用这道题表面人人会写但一问到状态怎么定义、输出怎么处理、遇到非预期投币怎么办就能看出对方是背过一个标准答案还是真的理解了状态机的本质。这篇文章从硬件实战角度把自动售货机的FSM设计、Verilog实现、仿真验证和面试进阶版本完整走一遍适合正在学数字设计、准备FPGA实验或数字IC面试的读者。1. 从“投币逻辑”到“状态机建模”这个题目为什么能考出真水平1.1 需求边界先跟“生意人”确认清楚再动手很多第一次接触这个实验的人拿到题目就急着写代码结果写到一半发现很多前提没定义清楚商品多少钱接受什么面额的硬币最多能投多少钱找零怎么给这些不确定项直接决定了FSM的状态数量和跳转逻辑。一个最常见的教学版需求是这样的自动售货机只接受5角和1元两种硬币商品售价固定为2元顾客可以连续投币当累计投入金额大于等于2元时机器吐出一瓶商品并计算找零。这个设定之所以经典是因为状态数少、边界清晰非常适合用来练习FSM。但请注意这里已经有两个隐含约定必须明确写进设计文档里投币金额有没有上限如果没有上限顾客持续投1元硬币状态就会无限增加。实际设计中一般设定“累计金额超过2.5元后不再接收新的投币”或者把溢出情况单独拉出来处理。“大于等于2元”时的动作是什么是立刻出货并按差额找零还是允许客户继续投币“再加一瓶商品”教学实验里通常选择前者也就是累计金额一旦满足条件就进入出货流程并回到初始状态。如果这些边界条件不先定清楚后续写状态转移表时一定会乱。这是我实际带实验时看到的最多问题不是学生不会写代码而是需求里有太多没有约束的自由变量。1.2 状态定义金额是状态而不是变量自动售货机的核心问题可以抽象成一句话根据当前已投入的金额决定下一次投币后系统跑到哪里去。基于这个思路最直接的状态定义就是用“当前累计金额”来划分IDLE当前金额为0元这也是复位后的初始状态S0_5当前金额为0.5元S1_0当前金额为1.0元S1_5当前金额为1.5元S2_0当前金额为2.0元达到售价可出货S2_5当前金额为2.5元超额需要出货并找回0.5元这里有一个关键的设计思维金额在这里不是用一个数值寄存器保存而是被编码成了状态本身。为什么因为FSM最擅长表达“离散的、有限的、预先可知的”系统行为。对于只有几个离散金额档位的场景把它展开成枚举状态最直观也最容易做形式化验证。如果商品价格可变、投币档位很多那再去考虑用计数器加比较器来实现这在后面的进阶部分会展开讲。还有一个容易被忽略的点状态S2_0到底算“已经买完”还是“可以继续买”在简化模型里S2_0是一个临时状态表示“金额已满足条件但还没出货”。进入S2_0后状态机不等待下一次投币而是立刻发出出货脉冲并在下一个时钟沿回到IDLE。这样设计是为了保证“出货”这个动作不会在一个状态内无限停留。1.3 状态编码选择二进制、格雷码还是独热码状态定义确定后下一步是选择状态编码。这个选择看似小事却直接决定触发器的数量和组合逻辑的复杂度。编码方式触发器数量组合逻辑复杂度典型适用场景二进制编码log2(状态数)较高需要译码比较状态数较多、对面积敏感格雷码log2(状态数)较高跨时钟域、需要减少信号翻转独热码状态数较低译码逻辑简单状态数少、时序约束严格自动售货机核心状态通常只有5到6个我推荐直接用独热码。原因有两点第一状态数少时独热码的触发器浪费几乎可以忽略第二独热码每个状态只有一位为1RTL代码里当前状态的可读性非常强仿真时一眼就能看出状态机走到哪一步。如果你写代码时习惯用current_state IDLE这样的判断独热码会让综合工具更容易优化出较短的路径。有些人可能会质疑二进制编码状态数少不是更省资源吗对教学实验的FPGA来说那几分之一毫瓦的功耗差根本不值一提但组合逻辑上的额外译码、以及排错时看状态变量“不知道现在是几号状态”的痛苦会实打实拖慢开发进度。面试里被问到“为什么用独热码”时从这个角度回答一般都比背教材定义要来得让人信服。2. 状态转移表与输出逻辑把“累计、比较、找零”算到每一个分支2.1 以“5角/1元硬币商品2元”为例的转移表状态定义清楚后转移关系就可以直接列成一张表。这张表是所有代码的“设计规格”建议动手写Verilog之前先在纸上画出来或者至少写在注释里。当前状态输入5角硬币输入1元硬币IDLES0_5S1_0S0_5S1_0S1_5S1_0S1_5S2_0S1_5S2_0S2_5S2_0出货找0元回IDLE出货找0元回IDLES2_5出货找0.5元回IDLE出货找0.5元回IDLE这张表看起来简单但里面藏着很多容易被问倒的细节。比如为什么S2_0状态下只要有任意一枚硬币投进来都直接出货找零因为这个模型的业务规则是“金额达到2元即成交”S2_0并不存在“继续接受投币”的资格。再比如S2_5状态为什么要存在而不是直接从S2_0就跳回IDLE因为找零5角这个动作需要一个状态来承载S2_5不仅仅表示“当前金额”还隐含着“本单需要执行找零动作”的语义。另外要注意状态转移表里每个单元格都必须有确定的去处不能留空。遇到其实不可能发生的输入组合也需要默认回到IDLE或停留在当前状态这既是为了防止综合出奇怪的锁存器也是工程上“防御式设计”的习惯。2.2 输出逻辑出货和找零是脉冲不是电平很多人第一次写完状态机发现仿真波形里out_item一直是高电平而不是自己期望的“只高一个周期”。这是因为输出信号直接被组合逻辑赋值成了“当前状态为S2_0时就为1”而状态从S2_0变回IDLE需要等下一个时钟沿所以输出就持续了一整个时钟周期以上。这里需要分清两种输出风格摩尔型输出输出只和当前状态有关。示例中out_item 1当且仅当状态为S2_0这是典型的摩尔型。米利型输出输出还和输入信号有关。比如“如果当前状态为S1_5且此时输入一枚1元硬币则立刻输出找零信号”这是米利型。教学实验里推荐用摩尔型输出跟状态保持同步不容易产生组合逻辑毛刺。更重要的是下游模块比如出货电机驱动、找零指示灯通常需要的是一个单周期脉冲而不是持续电平。正确的做法是从S2_0态开始在组合逻辑中生成出货脉冲同时状态在下一个时钟沿跳回IDLE这样out_item的宽度就正好是一个时钟周期。如果后面接的模块需要更长的驱动时间再加一级脉冲展宽或计数器来控制不要试图靠拉长FSM状态停留时间来解决问题。2.3 不可避免的边界分支超额投币和“刚好够钱”投币过程是逐枚发生的真实场景里存在很多“不友好”的情况。比如顾客手里正好只有两张1元和一枚5角他会先投两枚1元此时系统已经满足出货条件并出了货回到IDLE然后顾客又投了一枚5角——这在现实中是不是相当于“2.5元买了一瓶商品被吞了5角”为了避免这种情况工程实现里一般有两种策略。第一种是“一达到售价立刻出货并找零”投第二枚1元的那一瞬间系统就出货找零0元剩余5角不再接收第二种是“允许累计金额继续增加等待顾客按确认键后再统一计算”。两种策略说不上谁绝对正确但必须在设计文档里写清楚。我的建议是在教学版里明确采用“达到即出货”的规则并把S2_5单独定义出来作为“超额头地点”。这样设计的好处是找零动作有明确的时序窗口系统先出一个周期出货脉冲再出一个周期找零脉冲两个动作在时间上分离开观察波形和板级调试都会清晰很多。如果你希望完全避免“超额”这个分支也可以把状态定义成“金额大于等于2元即回到IDLE”但那样的话当输入1元硬币使金额从1.5跳到2.5时是否需要找零5角就变得模糊了。所以把S2_5状态保留下来是更完整、更贴近实际的做法。3. 可综合的FSM代码怎么写直接可用的三段式Verilog模板3.1 一段式、二段式、三段式到底差在哪写FSM的风格主要有一、二、三段式三种。我把它们之间的差异用一句话概括一段式把所有逻辑塞进一个always块看着省事但状态跳转和输出混在一起状态一多就变成意大利面二段式把“状态寄存器”和“组合逻辑输出”分开比一段式清晰但输出仍然是组合逻辑容易出毛刺三段式把状态寄存器、次态组合逻辑、输出寄存器三个always分开是目前工程上最推荐的做法。三段式的结构可以这样理解第一个always负责“现在在哪里”它永远是时序逻辑在时钟沿把次态赋值给当前状态第二个always负责“下一步要去哪”它是纯组合逻辑只负责根据当前状态和输入计算次态第三个always负责“出去执行什么动作”通常是时序逻辑把输出信号在时钟沿打一拍避免组合毛刺。对于自动售货机这种输出种类不超过三四种、状态数不超过十个的小项目二段式也能跑但既然要练就练正确的工程习惯。三段式写出来的代码后续加状态、加输出、加总线接口时都不容易失控。3.2 完整代码实现下面这段代码是我在实验里常用的模板输入coin_5和coin_1表示分别投入5角或1元硬币同一时刻只允许一种有效输出out_item和out_change分别表示出货和找零。module vending_machine ( input wire clk, input wire rst_n, input wire coin_5, // 投入5角硬币单周期脉冲 input wire coin_1, // 投入1元硬币单周期脉冲 output reg out_item, // 出货信号单周期脉冲 output reg out_change // 找零信号单周期脉冲 ); // 独热码状态编码 localparam IDLE 6b000001; localparam S0_5 6b000010; localparam S1_0 6b000100; localparam S1_5 6b001000; localparam S2_0 6b010000; localparam S2_5 6b100000; reg [5:0] current_state; reg [5:0] next_state; // 第一段状态寄存器 always (posedge clk 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_5) next_state S0_5; else if (coin_1) next_state S1_0; end S0_5: begin if (coin_5) next_state S1_0; else if (coin_1) next_state S1_5; end S1_0: begin if (coin_5) next_state S1_5; else if (coin_1) next_state S2_0; end S1_5: begin if (coin_5) next_state S2_0; else if (coin_1) next_state S2_5; end S2_0: begin // 已满足购买条件无论投入哪种硬币都出货并回IDLE if (coin_5 || coin_1) next_state IDLE; end S2_5: begin // 超额完成购买出货加找零后回IDLE if (coin_5 || coin_1) next_state IDLE; end default: next_state IDLE; endcase end // 第三段输出时序逻辑 always (posedge clk or negedge rst_n) begin if (!rst_n) begin out_item 1b0; out_change 1b0; end else begin out_item 1b0; out_change 1b0; if (current_state S2_0 || current_state S2_5) out_item 1b1; if (current_state S2_5) out_change 1b1; end end endmodule3.3 代码里最容易写错的两个地方第一个容易出错的地方是第二段组合逻辑里next_state current_state;这条赋值。不要小看这行默认赋值它能防止没覆盖到的分支产生锁存器。组合逻辑的always块如果没有把所有条件都列全综合工具就会推断出锁存器这在FSM里是灾难性的问题轻则行为奇怪重则时序违规。前面状态表里有几个状态在某种输入下“不需要跳转”这时默认赋值就保证了状态停留在原处。第二个容易出错的地方是第三段输出逻辑。我把out_item和out_change都在同一段时序逻辑里默认拉低然后根据当前状态拉高一个周期。这样做有一个好处输出天然是寄存器输出不会出现组合逻辑毛刺。但你要注意这里是用current_state而不是next_state来判断输出也就是说状态进入S2_0之后的那个时钟周期出货信号才会拉高。这是延迟一拍的行为和状态跳转同步也让波形更容易理解。4. 仿真与验证光看波形还不够用例必须覆盖这些角落4.1 一个趁手的testbench怎么写写testbench的目标不只是“把状态机跑起来”而是让每一个转移分支都被执行到。这里有一个简单的习惯把每个投币动作封装成一个 task每次投币间隔若干个时钟周期模拟真实场景中顾客一枚一枚投入硬币的节奏。timescale 1ns/1ps module tb_vending_machine; reg clk; reg rst_n; reg coin_5; reg coin_1; wire out_item; wire out_change; vending_machine dut ( .clk (clk), .rst_n (rst_n), .coin_5 (coin_5), .coin_1 (coin_1), .out_item (out_item), .out_change(out_change) ); initial begin clk 0; forever #10 clk ~clk; // 50MHz时钟 end task do_coin_5; begin (posedge clk); coin_5 1; (posedge clk); coin_5 0; end endtask task do_coin_1; begin (posedge clk); coin_1 1; (posedge clk); coin_1 0; end endtask integer error_cnt; initial begin rst_n 0; coin_5 0; coin_1 0; error_cnt 0; repeat (2) (posedge clk); rst_n 1; // 用例1投入两枚1元正常出货 do_coin_1; do_coin_1; repeat (2) (posedge clk); if (out_item ! 1b1) begin $display(FAIL: 用例1 出货信号未拉高); error_cnt error_cnt 1; end repeat (2) (posedge clk); // 用例2投入1元5角5角正好2元 do_coin_1; do_coin_5; do_coin_5; repeat (2) (posedge clk); if (out_change ! 1b0) begin $display(FAIL: 用例2 不应找零); error_cnt error_cnt 1; end repeat (2) (posedge clk); // 用例3投入1元1元5角超额出货并找零5角 do_coin_1; do_coin_1; do_coin_5; repeat (2) (posedge clk); if (out_change ! 1b1) begin $display(FAIL: 用例3 找零信号未拉高); error_cnt error_cnt 1; end repeat (2) (posedge clk); if (error_cnt 0) $display(ALL TEST PASSED); else $display(TEST FAILED, error_cnt%0d, error_cnt); $finish; end initial begin $dumpfile(tb_vending_machine.vcd); $dumpvars(0, tb_vending_machine); end endmodule这样写好的testbench跑完仿真后不仅要看ALL TEST PASSED还要在波形窗口里人工确认每个状态跳转都符合预期。状态机的“隐藏bug”经常出现在波形上看起来非常正常、判断语句却漏了的情况。4.2 基础用例与边界用例的覆盖基础用例就是正常购买闭环半角组合到1元、1元加5角到1.5元、两枚1元到2元并出货。真正容易出问题的边界用例要单独列出来连续快速投币如果每个时钟周期都来一枚1元状态机会不会丢币这取决于你设计时是否约束“同一时刻最多一个投币事件”。testbench里可以故意让coin_5和coin_1在同一个周期同时为高观察状态机是否进入一个合理状态。复位后立即投币复位撤除后的第一个周期就开始投币输出是否正常有些设计里复位释放沿和时钟沿配合不好会导致第一个事件丢失。超额后继续投币在S2_5状态下继续投币状态会去哪里如果代码和状态表一致应该回IDLE并完成找零。如果不一致这里就会暴露出来。长时间无输入状态停留在任意状态时所有信号是否保持不变、无毛刺、无自跳变这种用例能捕捉到组合逻辑里不完整的条件分支。对应到具体代码这些用例都不是“跑一下波形”就完事建议在testbench里直接用if断言关键信号而不是靠人盯着波形找。自动化断言虽然写起来麻烦但在后续修改设计时能立刻告诉你“改坏了没有”。4.3 仿真里最常见的“假通过”现象我见过不少人写出能通过仿真的状态机综合到板子上却行为异常最常见的原因是“仿真里没有初始化”。如果testbench一开始不给复位或者给出的复位宽度不够状态寄存器在仿真里就会是X态。某些EDA工具里一条路径恰好避开了X态传播功能仿真照样报PASS但综合后的门级仿真或在FPGA上电瞬间X态就会变成真实的不确定态表现就是偶尔第一次投币失灵。另一个“假通过”现象是只看最终输出、不看内部状态。比如用例设计里“出货信号最终为1”就算通过却忽略了出货脉冲宽度是否正确、是否和找零信号重叠、是否跨了太多周期。这种行为异常在功能仿真阶段不会报错但下游FIFO或电机控制模块对脉冲宽度敏感时就会出问题。建议每个testbench都至少打印当前状态的值或者用自动化方式比对每个时钟周期的状态转移是否符合状态表。5. 从课程实验到IC面试题状态压缩、退币逻辑与AXI接口进阶5.1 变形一价格可变的自动售货机与“状态压缩”面试题很少直接要你写“两元售货机”因为标准答案已经被背烂了。常见的第一层变形是“商品价格不再是固定的2元而是可通过寄存器配置”。这个改动一下就把状态枚举法打回原形如果价格是3.5元还是5角/1元硬币状态数会变成十多个如果价格可变状态数就直接爆炸了。正确的思路是把“金额”从状态中抽离出来变成寄存器变量。当前累计金额存在一个reg [4:0] amount里投币时做加法然后用if (amount price)判断是否出货找零。FSM只保留几个大状态IDLE、COLLECT收币中、VEND出货、CHANGE找零。这就是所谓的“状态压缩”也是从“背状态表”到“理解状态机本质”的分水岭。面试追问到这里时你还要能说明两种方案各自的代价枚举FSM的时序最好控制、逻辑最直观但灵活性差压缩FSM加数值比较灵活但需要额外处理金额加法可能出现的溢出而且输出逻辑要额外判断比较结果组合路径会更长一些。5.2 变形二取消购买与退币状态机面试里更常见的升级是增加“退币”功能。顾客投币后如果反悔可以按下一个“取消”按钮系统需要退回已经投入的所有金额。这个功能最考验的是找零如何“拆兑”假如系统里有1元和5角两种硬币储备退回1.5元时是退一枚1元和一枚5角还是退三枚5角在数字逻辑层面这个问题的本质是“组合找零优化”。简单实现可以用贪心算法先退大面额再退小面额。状态机需要增加退币子状态每一拍退一枚硬币退完后回到IDLE。更讲究的做法是维护一个“硬币库存计数器”库存不足时优先保证小额找零或者直接报错这在真实售货机里对应的是“硬币找零不足”提示。把这个逻辑画成状态图状态数往往比主流程还多非常适合作为综合性的课后设计题。5.3 变形三把FSM封装成AXI外设向IC岗位的要求靠拢近几年数字IC面试题里博主经常看到“axi协议数字ic设计面试”相关热搜词说明越来越多公司已经不满足于只考裸状态机而是要求候选人能把FSM封装到标准总线接口里。这其实是自动售货机题目天然的延伸如果把“投币”当成CPU通过总线写入的一个寄存器事件把“出货”“找零”当成状态寄存器的输出状态那么整个售货机就是一个标准的AXI-Lite外设。进阶的学习路径可以按三步走。第一步保持当前模块不变先在顶层加一组寄存器控制寄存器、投币寄存器、状态寄存器第二步用一个AXI-Lite从机接口模块把这些寄存器挂到总线上CPU写投币寄存器等于触发一次投币事件CPU读状态寄存器得知当前金额和是否出货第三步把状态机内部信号拉到AXI的写响应通道或者中断引脚上让CPU可以轮询或中断方式掌握交易结果。这个过程走完你就不再是只会写状态机的人而是能把控制逻辑放进SoC里的数字设计工程师。当然我这里不会展开完整的AXI协议时序那是另一篇文章的体量。但当你把自动售货机这个实验走到这一步说明你已经把“状态机设计”和“总线接口设计”串成了一条线这在简历和面试里都是实打实的项目点。6. 实际调试中踩过的坑从板级抖动到综合不一致6.1 按键消抖与异步输入同步实验板上最常见的现象是状态机仿真完全正确下载到板子上投一次币金额却跳了两个档。原因几乎都是机械按键的抖动。一个按键压下去金属触点会在几百微秒到几毫秒内反复通断如果这些毛刺直接进FSM的输入状态机会把这些抖动当作多次投币事件。处理办法是标配两级同步加消抖先用两个触发器把异步输入同步到时钟域避免亚稳态再做边沿检测或计数器消抖。不要觉得自动售货机实验小就不处理这个实际工程里的“按钮输入必须消抖”几乎是铁律。至于同步打两拍是任何异步信号进入系统前都必须做的基本功这不只是防抖更是防止亚稳态扩散到整个状态机。6.2 复位风格和初始状态的坑很多人习惯用always (posedge clk or negedge rst_n)写异步复位这在FPGA上很常见。但要注意复位撤除的时刻和时钟上升沿十分接近时触发器可能进入亚稳态。仿真里由于是理想模型这个问题不明显实际芯片或FPGA里这是一个真实的时序风险。更务实的建议是统一使用同步复位逻辑上只是把rst_n的判断放进posedge clk的同步块里。同步复位的好处是撤除时间严格由时钟控制不会出现“复位释放竞争”。尤其是当自动售货机升级为AXI外设后CPU通过总线配置寄存器时总线的复位信号通常是异步的这时同步复位更容易和总线协议对齐。6.3 找零与出货并发的“视觉故障”还有一个调试时很有意思的现象找零信号和出货信号由同一个外围设备比如LED灯组显示时很容易出现“看起来只亮了出货灯没亮找零灯”的错觉。原因不是逻辑错而是两个脉冲都只有一个时钟周期人眼根本分辨不出来。这时候不要着急改RTL先用逻辑分析仪或板载LED的状态寄存器锁定行为确认两个脉冲确实在预期周期出现再去决定是否需要对找零信号做脉冲展宽。实际项目中这类下游视觉表达问题都是靠“把内部状态寄存器的值引出来观察”来解决的。SPI、UART调试口或者直接把current_state接到板上LED上都是好办法。我自己的习惯是调试阶段让关键状态量可视化状态机行为一目了然之后再接回实际外设。自动售货机这个实验我反反复复做过很多遍从大二第一次用拨码开关手工触发状态跳转到后来在公司面试时用它的升级版来考察候选人每次都能从里面挖出新的东西。如果你现在正在学状态机建议不要只满足于仿真通过把每一个小坑都亲手踩一遍再想想如果价格可变、如果增加退币、如果挂到总线上你的结构会怎么变。这一套走完你收获的绝不仅仅是一个实验分数。
返回列表