简介:数字系统设计是硬件工程师的核心技能,而有限状态机(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 <= 2'b0; 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 <= 20'd0; end else if (coin_posedge) begin cnt <= 20'd0; end else if (cnt < 20'd1_000_000) begin cnt <= cnt + 1'b1; end end // 确认后的计数逻辑 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin balance <= 5'd0; coin_valid <= 1'b0; end else if (cnt == 20'd999_999) begin // 假设传感器标示: coin_type=0 表示5角,1表示1元 if (coin_type) balance <= balance + 5'd10; // 1元=10角 else balance <= balance + 5'd5; coin_valid <= 1'b1; end else begin coin_valid <= 1'b0; end end endmodule建议把余额的单位统一为“角”,用整数运算避免小数问题。比如1元就是10,5角就是5,商品价格250表示2元5角。这样找零计算就完全是整数加减法,不会出现浮点数比较的坑。
3.2 商品选择与余额比较
商品选择模块需要处理两个问题:选了什么商品以及余额够不够。我当时用两个按键分别代表可乐(2元5角)和矿泉水(2元),按下按键后状态机先检查余额和库存,条件都满足才进入出货状态。
这里有一个容易漏掉的细节:选货操作和投币操作是可以穿插发生的。也就是说,用户可能先投了1元,又按了可乐键,发现余额不够,继续投币,再按可乐键。这个过程中状态机会在“投币状态”和“选择状态”之间反复切换。如果你把投币逻辑和选货逻辑写在同一个状态里,就会出现漏掉投币事件的情况。
我的做法是,状态机里单独设一个IDLE基础状态,任何状态都可以接收投币事件并更新余额,然后在余额更新之后判断当前是否满足某个等待中的购买请求。相当于把“投币事件”设计成异步于主流程的公共事件,这样就不会丢数据了。
3.3 找零模块:贪心算法的状态化表达
找零逻辑是这个项目里最能体现数字系统设计思维的地方。现实中的自动售货机可能需要考虑多种面额的组合优化,但在短学期这个题目约束下,找零只用5角硬币,根本不涉及组合优化,本质上就是“用5角硬币凑出余额减去商品价格的差值”。所以这个模块的设计反而简单。但简单不等于可以马虎,你要考虑的是:找零是一个过程,不是一个瞬间。
什么叫过程?就是说,零钱是一枚一枚吐出来的,每吐一枚需要一定的时间。你的状态机必须能够表达“正在找零中”这种中间状态,并且计算还剩多少零钱没找完。我当时是用一个change_remain寄存器记录剩余待找零金额,一个change_cnt寄存器记录已经吐出的硬币数量,每吐一枚,change_remain减5,change_cnt加1,直到change_remain归零。
// 找零状态机核心片段 // 状态:IDLE -> CALC -> COIN_OUT -> DONE always @(posedge clk or negedge rst_n) begin if (!rst_n) begin change_remain <= 5'd0; change_cnt <= 4'd0; change_state <= IDLE; end else begin case (change_state) IDLE: begin if (start_change) begin change_remain <= balance - price; change_cnt <= 4'd0; change_state <= COIN_OUT; end end COIN_OUT: begin if (change_remain >= 5) begin change_remain <= change_remain - 5; change_cnt <= change_cnt + 1'b1; coin_out_pulse <= 1'b1; end else begin change_state <= DONE; end end DONE: begin coin_out_pulse <= 1'b0; 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 -voptargs=+acc 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 <= 2'b0; end else begin rst_sync <= {rst_sync[0], 1'b1}; 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”最后都能追溯到初始化没做好、复位没做好、同步没做好这三件事上。对照着查一遍,比瞎改代码有效得多。
本文还有配套的精品资源,点击获取