简介数字系统设计是硬件工程师的核心技能而有限状态机FSM则是其中最基本也最关键的建模方法。从概念上讲状态机通过对系统状态的抽象与转移将复杂时序逻辑转化为清晰的状态流转过程其原理广泛适用于控制类场景。在工程实践中基于Verilog的三段式状态机写法将状态转移、次态判断与输出寄存分离有效提升了代码的可维护性与健壮性。以FPGA自动售货机为例该应用覆盖投币识别、余额计算、商品选择、找零输出等多个典型时序环节是练习状态机设计与上板调试的理想载体。通过模块化划分、接口定义先行、仿真验证与板级调试的完整流程开发者能深刻理解数字系统设计的工程方法论。本文从自动售货机这个经典题目出发详细拆解从状态图绘制到代码实现、再到仿真与上板排错的完整链路为数字系统设计学习提供可复用的实践参考。 咱们实验室这两天正好也在带下一届短学期的学弟学妹看到不少人拿到“数字系统设计”题目单的时候一脸茫然尤其是自动售货机这个经典题目——大家第一反应都是“这不就是一个状态机吗”然后真正动手的时候才发现光是一个投币找零的边界情况就能把人绕晕。今天就把我当年在东南大学信息学院短学期做FPGA自动售货机的完整思路和踩坑记录整理出来希望能帮到正在为这个项目头疼的同学。这篇内容不光是给你一份能跑的源代码更重要的是把数字系统设计的整个思考链路讲清楚从题目拆解到模块划分从波形仿真到上板调试再到验收答辩时老师最关注的那些细节。无论你是第一次接触FPGA的小白还是已经写过几个Verilog模块、想把这个项目做得更规范的老手这篇文章都值得你花十几分钟读完。1. 短学期选题为什么自动售货机值得做一个星期1.1 课程任务拆解东南大学信息学院的短学期数字系统设计课程一般安排在夏季学期时间紧凑大概两到三周。课程的核心目标不是让你背语法而是让你完整走一遍“需求分析 → 架构设计 → RTL编码 → 仿真验证 → 上板调试 → 文档撰写”的流程。任务书里通常会给出几个可选题目自动售货机几乎年年都在原因很简单这个项目麻雀虽小五脏俱全既能覆盖数字系统设计的核心知识点又不会像图像处理或通信基带那样复杂到让人无从下手。拿到题目之后第一步一定是做需求拆解。以我当时拿到的任务书为例要求大致如下支持1元、5角两种硬币投币商品定价为2元5角支持两种商品选择比如可乐和矿泉水价格可不同支持找零功能找零用5角硬币实现用数码管显示当前投入金额和找零状态用按键模拟商品选择、退币等操作系统具有复位功能。任务书的原始描述一般就这么多剩下的全靠你自己补充细节。这里有一个重要的方法论需求拆解不是把任务书抄一遍而是把每一个模糊的描述转化成明确的设计约束。比如“支持找零”这四个字实际上隐含了一堆问题找零的最大值是多少库存不足时怎么处理找零过程中能否再次投币这些问题不提前想清楚写代码的时候就会反复返工。1.2 自动售货机的题目边界自动售货机这个题目的边界在于它是一个典型的数字控制系统而不是数据处理系统。也就是说它的核心逻辑是事件驱动的状态流转而不是大规模的数据运算。这就决定了你的设计重心应该放在状态机的健壮性、时序的准确性以及接口的清晰性上而不是去追求什么高性能架构。搞清楚这个边界你就知道该把精力花在哪里了。很多同学一上来就在纠结用几个always块、用什么编码风格其实这些都是次要的。真正重要的是你能不能把一张清晰的状态转移图先画出来。画不出状态图就动手写代码后面大概率要推倒重来。我见过太多人犯这个错误。短学期时间紧大家都想赶紧把代码跑起来结果就是写了一堆重复的、难以维护的always块状态一多就乱套。这个项目的正确打开方式是先花大半天时间把设计文档写好、把状态图画好再开始写代码。相信我这个时间花得绝对值。1.3 选型依据为什么都用Basys板而不是自己攒一块短学期项目用的是实验室统一的FPGA开发板我记得当年是Digilent的Basys 3上面是一颗Xilinx Artix-7系列芯片。之所以选这块板子除了价格适中、资料丰富之外最重要的是它的外设扩展能力——板载的拨码开关、按键、四位数码管和VGA接口刚好能覆盖自动售货机项目的所有输入输出需求不需要任何外部电路。如果你是自己学习或者在其他学校做类似项目选型逻辑也是一样的先看外设匹配度再看芯片资源最后才看性价比。自动售货机这个规模的项目任何一款主流FPGA都绰绰有余真正影响体验的是开发板上的按键有没有做硬件消抖、数码管是共阴还是共阳、引脚分配是否好在约束文件里写明。这些细节直接决定你在“赶DDL”的时候是顺风顺水还是天天跟跳线较劲。2. 先画状态图还是先写代码数字系统设计的方法论2.1 从摩尔型还是米利型说起讲状态机之前先补一个基础知识。有限状态机FSM分为两大类摩尔型Moore和米利型Mealy。区别很简单摩尔型的输出只取决于当前状态米利型的输出还取决于输入信号。自动售货机这个项目教科书上一般建议用摩尔型因为它的输出比如找零金额、出货信号只和当前状态有关不会受到输入毛刺的直接干扰更稳健。不过实际设计的时候我建议你不要死抠这个分类而是要明白你写的三段式状态机到底在干什么。三段式指的是第一段做状态转移时序逻辑第二段做次态判断组合逻辑第三段做输出寄存时序逻辑。这种写法的好处是状态转移和输出逻辑分离仿真的时候可以分开调试定位问题非常方便。2.2 顶层架构几个模块才能撑起一台售货机模块划分这个环节直接决定你后面写代码的体验。我的建议是拆成六个模块每个模块职责单一接口清晰按键消抖模块debounce负责处理物理按键的机械抖动输出稳定的单脉冲投币检测模块coin_detector识别硬币传感器的脉冲转换成金额累加信号状态机控制模块fsm_control核心状态机管理投币、选货、出货、找零的完整流程商品存储模块product_stock记录两种商品的剩余数量支持扣减和缺货判断找零计算模块change_calc根据当前余额和商品价格计算找零数量数码管显示模块display把余额、商品编号、找零信息输出到四位数码管。这六个模块不是拍脑袋想出来的而是我花了一晚上理清了数据流之后得出的结论。你想想看投币是输入选货是输入出货是输出找零是输出显示是输出库存是状态——输入、输出、状态三者天然就对应不同的模块。按照这个思路划分每个模块的代码量都不会太大调试的时候也能一个一个单独验证。2.3 接口定义先行代码实现靠后模块划分好之后先不要急着写代码。先定义接口也就是每个模块的输入输出端口。这一步一定要做得足够细致信号命名要规范位宽要明确。比如投币检测模块输出的coin_value信号是2位宽的约定“01”代表5角、“10”代表1元这种约定必须写进设计文档里不然到最后联调的时候两个人各写各的接口对不上那才叫欲哭无泪。我当时定接口花了两三个小时真正写代码的时间其实也没多长。后来想想这个习惯帮我省了太多麻烦。短学期项目虽然一个人就能完成但你面对的是Digital系统设计本身——不规范的设计习惯一定会让你在复杂项目里付出代价。自动售货机已经算简单的了接口定得好不好你写第一个模块的时候体会还不明显等到顶层例化六个模块的时候好接口和烂接口的区别就是“一遍过”和“来回改”。3. 核心模块实现三段式状态机的写法与心得3.1 投币模块识别、确认与计数投币模块是自动售货机的数据源头它的准确性直接影响所有后续逻辑。物理层面的硬币识别一般由传感器完成FPGA侧只需要处理传感器输出的脉冲信号。这个模块的设计要点有三个消抖、确认、计数。消抖和按键消抖是同一个思路只不过硬币脉冲的频率可能稍低一些。确认的意思是不能传感器一输出高电平就立刻累加而是要等脉冲稳定之后再采样一次确认不是干扰。计数就简单了根据确认后的信号将余额寄存器累加对应的面额。// 投币检测模块示例简化版 module coin_detector( input clk, input rst_n, input coin_sensor, // 传感器脉冲输入 output reg [4:0] balance, // 当前余额 output reg coin_valid // 有效投币脉冲 ); reg [1:0] sync_ff; reg [19:0] cnt; wire coin_posedge; // 两级同步 边沿检测 always (posedge clk or negedge rst_n) begin if (!rst_n) begin sync_ff 2b0; end else begin sync_ff {sync_ff[0], coin_sensor}; end end assign coin_posedge sync_ff[1] ~sync_ff[0]; // 20ms延时确认 always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt 20d0; end else if (coin_posedge) begin cnt 20d0; end else if (cnt 20d1_000_000) begin cnt cnt 1b1; end end // 确认后的计数逻辑 always (posedge clk or negedge rst_n) begin if (!rst_n) begin balance 5d0; coin_valid 1b0; end else if (cnt 20d999_999) begin // 假设传感器标示 coin_type0 表示5角1表示1元 if (coin_type) balance balance 5d10; // 1元10角 else balance balance 5d5; coin_valid 1b1; end else begin coin_valid 1b0; end end endmodule建议把余额的单位统一为“角”用整数运算避免小数问题。比如1元就是105角就是5商品价格250表示2元5角。这样找零计算就完全是整数加减法不会出现浮点数比较的坑。3.2 商品选择与余额比较商品选择模块需要处理两个问题选了什么商品以及余额够不够。我当时用两个按键分别代表可乐2元5角和矿泉水2元按下按键后状态机先检查余额和库存条件都满足才进入出货状态。这里有一个容易漏掉的细节选货操作和投币操作是可以穿插发生的。也就是说用户可能先投了1元又按了可乐键发现余额不够继续投币再按可乐键。这个过程中状态机会在“投币状态”和“选择状态”之间反复切换。如果你把投币逻辑和选货逻辑写在同一个状态里就会出现漏掉投币事件的情况。我的做法是状态机里单独设一个IDLE基础状态任何状态都可以接收投币事件并更新余额然后在余额更新之后判断当前是否满足某个等待中的购买请求。相当于把“投币事件”设计成异步于主流程的公共事件这样就不会丢数据了。3.3 找零模块贪心算法的状态化表达找零逻辑是这个项目里最能体现数字系统设计思维的地方。现实中的自动售货机可能需要考虑多种面额的组合优化但在短学期这个题目约束下找零只用5角硬币根本不涉及组合优化本质上就是“用5角硬币凑出余额减去商品价格的差值”。所以这个模块的设计反而简单。但简单不等于可以马虎你要考虑的是找零是一个过程不是一个瞬间。什么叫过程就是说零钱是一枚一枚吐出来的每吐一枚需要一定的时间。你的状态机必须能够表达“正在找零中”这种中间状态并且计算还剩多少零钱没找完。我当时是用一个change_remain寄存器记录剩余待找零金额一个change_cnt寄存器记录已经吐出的硬币数量每吐一枚change_remain减5change_cnt加1直到change_remain归零。// 找零状态机核心片段 // 状态IDLE - CALC - COIN_OUT - DONE always (posedge clk or negedge rst_n) begin if (!rst_n) begin change_remain 5d0; change_cnt 4d0; change_state IDLE; end else begin case (change_state) IDLE: begin if (start_change) begin change_remain balance - price; change_cnt 4d0; change_state COIN_OUT; end end COIN_OUT: begin if (change_remain 5) begin change_remain change_remain - 5; change_cnt change_cnt 1b1; coin_out_pulse 1b1; end else begin change_state DONE; end end DONE: begin coin_out_pulse 1b0; change_state IDLE; end endcase end end注意一个细节吐币脉冲的宽度要和物理硬币出口的控制电路匹配。开发板上一般是用一个LED或者蜂鸣器模拟硬币出口所以脉冲宽度无所谓但如果你外接真正的硬币机就要仔细查它的数据手册确定脉冲宽度和驱动能力。3.4 显示模块数码管消漂与分时刷新四位数码管显示是很多人的噩梦因为第一次接触动态扫描的时候总会出现亮度不均、闪烁、显示重影的问题。究其原因就是扫描时钟的频率和每位数码管的刷新时间没配合好。我的参数设置是扫描时钟分频到1kHz也就是每位数码管每秒钟被点亮250次四位数码管共享1kHz每位250Hz刷新率这个频率远高于人眼的闪烁融合频率约60Hz看起来就是连续亮的。扫描的位选信号an和段选信号seg要严格同步不能让段选信号在新一位位选有效之前就变化否则就会看到残影。这里特别提醒一个现象数码管亮度不均。很多人以为是自己代码问题其实是因为不同位的显示内容更新频率不同或者某些位的数字笔画多、点亮时间看起来就长。解决的办法是保证每一位的刷新时间完全一致也就是扫描计数器要严格平均分配不能在显示“8”8个段全部点亮的时候多占用几个周期。4. 上板前的仿真与上板后的Bug两轮调试实录4.1 ModelSim仿真脚本怎么写我强烈建议你花一下午学一下ModelSim/Questa的Tcl脚本仿真而不是每次都在Vivado里手动点“Run Simulation”。手动仿真最大的问题是一旦代码有语法错误你得反复编译一旦波形看完了要换一组输入又得重新点一遍。写一个do脚本能把这些操作全部自动化。一个最基本的do脚本长这样vlib work vlog ./src/*.v vlog ./tb/tb_top.v vsim -voptargsacc work.tb_top add wave -position end sim:/tb_top/* run -all每次改完代码直接执行do sim.do立刻就能看到新的波形。这个习惯一旦养成你调试的效率会翻倍。我还喜欢在testbench里加一些自动检查比如断言余额永远不会为负或者找零结束后余额一定为零——这些断言在仿真阶段就能帮你把99%的边界问题拦住。4.2 第一次上板乱码、闪烁和状态跳飞代码仿真通过满怀信心地下载到板子上结果发现数码管显示乱码按一下投币按键余额乱跳按选货键偶尔能出货偶尔不能。如果你也遇到这个情况别慌这是正常的。我当年第一次上板几乎这些问题全遇到了。先说乱码。仿真没问题、上板乱码第一个怀疑对象就是约束文件XDC管脚分配。我当时把四位数的位选信号顺序搞反了导致扫描顺序和逻辑代码不一致显示当然就是乱的。核对的管脚分配和原理图一个一个信号查花了半小时才找到问题。然后是按键抖动导致的余额乱跳。我的按键消抖代码在仿真里明明没问题上板后为什么还是抖原因在于物理按键的抖动时间并不是固定的早期的抖动可能长达几十毫秒而我的消抖窗口只采了20ms左右漏了一部分抖动毛刺。解决方法是把消抖延时加大到50ms同时增加“释放确认”逻辑——按键释放之后也要延时确认才算一次完整的按键事件。最后说状态跳飞。这个问题最隐蔽出现在状态机的组合逻辑路径上。因为用了三段式写法次态判断是纯组合逻辑如果case分支没有写完整没有default综合工具可能会生成一个锁存器状态机在某些未定义的输入组合下会跳到一个非法状态。解决方法是给case补上default分支并且把default指向IDLE上电复位之后状态机一定回到初始态。4.3 异步复位与跨时钟域的教训还有一个深坑值得单独拿出来说异步复位信号的同步释放。如果复位信号直接接到寄存器的异步复位端上电瞬间或者按下复位键的瞬间如果复位信号恰好和时钟边沿重合就可能出现寄存器没有正确复位的亚稳态问题。规范的写法是做一个“异步复位同步释放”的复位电路reg [1:0] rst_sync; always (posedge clk or negedge rst_n) begin if (!rst_n) begin rst_sync 2b0; end else begin rst_sync {rst_sync[0], 1b1}; end end wire rst_n_sync rst_sync[1];之后所有模块都用这个rst_n_sync作为复位信号避免直接用物理按钮产生的rst_n。这个处理看似多此一举但对于系统稳定性非常重要。调试的时候你可能感觉不到区别但在某些恶劣的电磁环境下比如电机启动瞬间这个细节可能决定了你的系统是稳定运行还是会随机死机。5. 验收与答辩代码规范性和演示设计5.1 设计文档的模块化表达短学期项目的最后一步一般是提交设计报告和现场演示。很多同学代码写得不错但报告写得一塌糊涂最后分数不理想非常可惜。设计报告的核心不是贴大段源码而是要清晰地表达你的设计思路。我建议报告里一定要有一张顶层结构图六个模块用方框画出来信号连接线标注清楚让老师一眼看到你的系统架构。然后是状态转移图用标准的状态图符号把投币、选货、出货、找零、退币这几个状态之间的转换条件写明白。这两张图画好了报告就成功了一半。文字部分就写每个模块的功能说明和关键代码解释不要从头到尾贴代码老师不会看的。5.2 Demo演示的两个加分细节现场演示的时候有两个细节特别加印象分。第一是演示流程要设计好不要上来就直接“投币-出货”一条龙而是先演示边界场景余额不足时按选货键系统拒绝出货并提示投币后不选货直接按退币系统能正确退回等额零钱。把这些异常流程演示一遍老师会觉得你的设计考虑得很周全。第二是全局复位的重要性。演示之前一定要先按一下复位按键让系统进入一个确定的初始状态。如果直接进入演示万一上一位同学操作留下了脏数据你投了币、选了货系统给出的结果可能是错的——这个锅你背得非常冤明明是初始化没做好。5.3 从短学期项目到后续比赛项目的余热最后说点题外话。短学期这个项目虽然不大但如果你真的认真做完了你收获的不仅仅是“自动售货机”这三个字而是完整的项目推进经验。我之后参加电赛和校内的SRTP项目用的还是这一套流程需求分析画框图、接口定义先行、模块单独仿真、顶层联调、上板排错。数字系统设计这件事70%的能力是可以迁移的自动售货机只是一块敲门砖。如果你做完之后还有富余时间不妨给这个项目加一点小功能用VGA显示商品的库存列表、用UART把交易记录打印到串口工具、或者加一个密码管理员的补货模式。这些改动都不复杂但能让你的能力边界再往外扩一圈放在简历里也是实打实的项目经历。说回代码本身——如果你现在正卡在某个模块上记住一个原则数字系统设计里面绝大多数“玄学Bug”最后都能追溯到初始化没做好、复位没做好、同步没做好这三件事上。对照着查一遍比瞎改代码有效得多。本文还有配套的精品资源点击获取