☰
用FPGA手搓自动售货机:从状态机设计到完整工程实践
2026/10/29 2:23:18 网站建设 项目流程

这两周我抽空做了一个挺有意思的实验项目:用FPGA开发板手搓了一台自动售货机。不光是那种按按钮就亮灯的demo,而是真把投币、选货、找零、出货控制、余额显示、甚至“再来一瓶”彩蛋都跑通的完整逻辑。中间踩了不少坑,也把状态机、按键消抖、动态数码管扫描、串口调试这些FPGA基本功反复磨了几遍。准备把这台“手搓售货机”的完整过程和关键代码思路记录下来,给正在玩FPGA开发、准备做课设、或者想在手头板子上找点实战练手的朋友一个可以直接抄作业的参考。

先说结论:这个项目没有用单片机、没有用嵌入式Linux、没有用网络模块,纯粹靠一块入门级FPGA开发板加上几个按键、数码管、小电机模块,完成了自动售货机的核心控制闭环。整个过程会涉及到FPGA项目里最常被问到的几个点:状态机怎么写、时序怎么收敛、按键抖动怎么消除、多个模块怎么联调。如果你能把这个项目完整做下来,FPGA入门阶段的逻辑基本就算打通了。

1. 项目整体设计:售货机不只是“投币出货”这么简单

1.1 核心需求拆解

把自动售货机当做一个系统来看,它的核心需求其实很清晰:

  • 用户投币,系统识别金额
  • 用户选择商品,系统判断余额是否足够
  • 如果余额够,出货并扣款
  • 如果余额不够,提示用户继续投币或退币
  • 操作完成后可以找零或退币

这四个步骤看起来简单,但在FPGA里落地时,每一步都要拆成硬件逻辑。比如“投币”对应的是一个脉冲信号,“识别金额”需要把不同币值映射成不同的数字量,“余额判断”是一个比较器,“扣款找零”是一个减法器和状态寄存器,“出货”是一路受控的电机使能信号。

我这次实现的功能定为:

功能项实现方式备注
支持两种币值两路按键模拟投币1元和5元真实场景可用硬币识别器输出脉冲代替
商品选择两个按键选择A/B商品,单价分别为3元和6元售价可改参数
出货控制输出一路高电平脉冲,驱动SG90舵机或直流电机真实机器可接弹出机构
找零提示显示当前余额,退币时输出退币脉冲显示用两位数码管
彩蛋功能伪随机“再来一瓶”,购买成功后有一定概率触发纯属增加难度,但面试加分

这样的一个小系统,正好覆盖了FPGA开发里最核心的几个知识点:状态机、计数器、边沿检测、分段译码、动态扫描、参数化设计。

1.2 架构选型:为什么用FPGA而不是单片机

可能有人会问,自动售货机这种控制逻辑,用一颗STM32或者ESP32不是更顺手?为什么非要用FPGA来搓?

我在做项目前的第一反应也是用单片机,但仔细想了一下这个题目的价值,用FPGA的好处其实非常明显:

第一,FPGA天然是并行硬件。几路按键信号、数码管刷新、出货控制、蜂鸣器提示是同时发生的,如果用单片机轮询扫描,逻辑上没问题,但写入和直观性不如硬件状态机。FPGA这边可以做到真正的“同时监听”,比如投币口和选货键同时触发,也不会丢事件。

第二,这个项目非常适合练状态机。自动售货机本质上是一个典型的Moore型状态机:待机->投币->选货->出货->找零/退币。状态转移条件清清楚楚,用Verilog写出来以后,比单片机上面向过程的代码更容易讲清楚逻辑,做课设答辩时也更好展示。

第三,FPGA可以工程化地处理按键消抖和显示刷新。这些在单片机里经常用软件delay实现的逻辑,在FPGA里要靠计数器或者状态机,换了个思路,但其实更接近硬件本质,能学到东西。

当然,我也不是完全否定单片机方案,如果是做成产品,单片机成本更低,开发更快。但要说学习价值,FPGA这条路线把数字逻辑的“骨架”暴露得更彻底。我的结论是:如果你已经会了单片机,再用FPGA复刻一遍这个场景,理解层级会完全不同。

1.3 顶层模块拆解与数据流

顶层设计我采用了“一个顶层 + 多个功能子模块”的经典结构:

vending_machine_top ├── key_debounce // 按键消抖模块,每个按键一个实例 │ ├── key_1yuan │ ├── key_5yuan │ ├── key_select_a │ ├── key_select_b │ └── key_refund ├── fsm_control // 核心状态机 ├── display_scan // 数码管动态扫描 ├── money_calc // 余额增减计算(其实合并进FSM也可以) ├── random_gen // 伪随机数生成(彩蛋模块) └── beep_ctrl // 提示音控制

顶层只负责例化和连线,不写具体逻辑。模块之间通过以下几组信号交互:

  • money_add_pulse[1:0]:投币请求,位0代表1元,位1代表5元
  • select_request[1:0]:选货请求,位0代表A,位1代表B
  • current_balance[7:0]:当前余额BCD码
  • out_goods_pulse:出货脉冲
  • refund_pulse:退币脉冲
  • display_data[7:0]:显示数据BCD码

用BCD码而不是二进制表示余额,是因为直接给数码管做段码译码时非常方便,省掉了二进制转BCD的麻烦。对于两位数以内的余额,直接用8位寄存器,高4位存十位,低4位存个位,显示时分别取高低四位查表就行。

如果以后要扩展更多商品或者更高金额,建议写成参数化模块,比如商品数量、商品价格都作为参数,而不是写死在代码里。

2. 硬件准备与开发板选型

2.1 板子选型和外设摸底

这次用的是一块常见的Xilinx Artix-7系列入门板,具体型号就不报了,核心芯片是XC7A35T。选它的原因很简单:开发板自带的按键、数码管、LED足够多,主时钟100MHz,逻辑资源完全够用,而且ISE/Vivado的生态资料多,查问题方便。实际上只要是能跑起来的板子都能做这个项目,区别只在于引脚分配和外设连接。

我用到的板载外设资源如下:

外设数量用途
独立按键5个投币、选货、退币
两位/四位一体数码管2位显示余额和售价
LED4个状态提示
蜂鸣器1个操作提示音
舵机接口外接出货机构

如果你用的是黑金、高云、紫光同创这些其他家的板子,也没问题,核心逻辑基本通用,只需要改引脚约束。如果是完全零基础选板,个人建议选按键数量不少于4个、带数码管的型号,否则光外接按键和显示电路就要搭半天的面包板。

2.2 引脚规划与电路连接

引脚约束是FPGA开发里容易被忽略但特别容易出问题的一环。尤其是按键和数码管,如果不先看清楚原理图是低电平有效还是高电平有效,代码里很容易写反极性,导致按键按下去没反应或者一直触发。

我这次的实际规划:

set_property PACKAGE_PIN R12 [get_ports {key_1yuan}] set_property IOSTANDARD LVCMOS33 [get_ports {key_1yuan}]

需要注意的是,不同的板子按键按下时,IO电平是不同的,需要看原理图。如果是低电平有效,那么模块里默认状态应为高电平,按下时为低,这时消抖后再取反作为有效脉冲。按键和数码管这些慢速外设统一用LVCMOS33即可,不需要走Bank的差分信号。

引脚分配的另一个重要建议:尽量把同一功能模块的引脚放在同一个Bank,避免跨Bank带来位置约束困难。对于数码管的段选信号,如果板子布线允许,优先放在同一个字节通道内,综合布线时能减少延迟偏差,但这属于优化项,不影响功能。

2.3 时钟与复位设计

这个项目的主时钟是板载100MHz晶振。但自动售货机的人机交互属于慢速事件,不可能每过一个时钟周期都去处理一次按键,所以我的做法是先用一个分频计数器产生一个1kHz的节拍信号,作为按键扫描和20ms消抖计时的基准;数码管的动态扫描刷新频率在1kHz左右即可,肉眼看不到闪烁。所有模块仍然是100MHz时钟域,只是用使能信号做节拍。

复位信号我采用的是异步复位、同步释放的写法:

always @(posedge clk or posedge rst) begin if (rst) begin state <= IDLE; end end

在真实板子调试时,如果使用按键做复位,一定也要做消抖,否则上电瞬间的毛刺会导致状态机进入异常状态。更稳妥的是用上电自动复位电路,即一个模块在几毫秒内保持rst有效,然后再释放。

3. 核心控制状态机的完整设计

3.1 状态定义与转移条件

状态机是整个售货机的大脑。我定义了几个状态,考虑了实际操作的连贯性:

localparam IDLE = 4'd0; // 待机 localparam COIN_IN = 4'd1; // 投币中 localparam SELECT_PRD = 4'd2; // 选择商品 localparam DISPATCH = 4'd3; // 出货中 localparam CHANGE = 4'd4; // 找零 localparam REFUND = 4'd5; // 退币

正常投币的流程是这样的:系统上电后在IDLE,此时按键消抖模块检测到投币脉冲后,状态跳转到COIN_IN,余额寄存器累加;在COIN_IN状态下,如果检测到选货按键,先判断余额是否大于等于商品价格,如果够,跳到DISPATCH;如果不够,继续停留在COIN_IN,数码管通过闪烁提示“余额不足”;选货完成后状态跳到CHANGE;如果用户直接按了退币键,那跳到REFUND,将余额清零并输出退币脉冲。

这里有一个细节:分拣状态机和按键检测是不同时钟域的。按键消抖模块输出的脉冲宽度为1个主时钟周期,而状态机的状态跳转也在同一时钟下,所以只要保证脉冲产生后至少维持一个时钟周期就不会漏掉。但问题在于,如果在状态机刚好处理“投币”的那一拍恰好按键松手,理论上也不会丢,因为消抖模块已经过滤掉了抖动,稳定输出一个时钟周期。

为了防止状态机紊乱,我的建议是在每个状态里都加上默认的“待在原地”分支,并且把state_next和state_reg分开,用两段式状态机书写。三段式的好处是输出逻辑与时序逻辑分离,避免组合逻辑毛刺直接驱动外部电机。

3.2 两段式FSM代码参考

核心FSM代码我截取关键部分如下:

always @(posedge clk or posedge rst) begin if (rst) begin state_reg <= IDLE; balance_reg <= 8'd0; end else begin state_reg <= state_next; case (state_reg) IDLE: begin if (coin_1_pulse) balance_reg <= balance_reg + 8'd01; else if (coin_5_pulse) balance_reg <= balance_reg + 8'd05; end COIN_IN: begin if (coin_1_pulse) balance_reg <= balance_reg + 8'd01; else if (coin_5_pulse) balance_reg <= balance_reg + 8'd05; end // ... endcase end end

在实现时我把投币累加逻辑和状态机统一放在一个always块里了,实际功能没问题,但综合后代码可读性稍差。如果你要拿去参加比赛或者做答辩,建议把余额计算单独拆出来,写成一个纯组合逻辑加寄存器更新的模块,查bug时能省很多时间。

这里也遇到一个“为什么这么写”的灵魂拷问:为什么按键要转成单周期脉冲而不是电平?因为状态机需要识别的是一次操作,而不是一个持续电平。如果直接用电平判断,按着不放会连续进入多次投币逻辑,相当于手抖一次投了好几块钱。脉冲化相当于把人的一次物理按压变成了一个“事件”,这个思路在FPGA设计里很关键。

3.3 出货与找零的联动

选货之后,系统要输出一个出货脉冲。我设计的出货脉冲宽度为200ms,这个宽度需要足以驱动舵机旋转一个角度。如果直接用状态机的单周期脉冲,连舵机都来不及响应。

于是我在FSM的DISPATCH状态下,启动一个计数器,等计数到200ms后自动拉低出货信号并跳转到CHANGE状态。这里要注意,出货脉冲的持续时间不能太短,真实舵机需要约几十到几百毫秒的脉冲序列才能转到位,所以通过延迟状态的持续周期来控制脉宽是一种简单有效的做法。

如果你接的不是舵机而是直流电机,建议再加一个限位开关输入,电机碰到限位后自动停转,否则容易出现空转或者卡死。

找零逻辑我在CHANGE状态里实现:如果余额大于0,则持续输出退币脉冲直到余额为0,然后回到IDLE。实际的硬件退币机构有的需要一个脉冲驱动一个电磁阀弹出一枚硬币,那就需要用到计数器来控制退币次数。

4. 外围模块逐个攻破

4.1 按键消抖与边沿检测

按键消抖可以说是FPGA实战里最基础也最容易写错的功能。前面提到了,按键本质是机械接触,按下和松开时都会产生数毫秒到十几毫秒的抖动。如果直接用寄存器采样,一次按键可能被检测成多次。我用的方法是计数器延时消抖,先检测到按键电平变化,然后启动20ms计时,计时结束后再次采样,如果电平状态没有变则认为是稳定按下。

代码如下,这是我最常用的一种模板:

always @(posedge clk) begin if (rst) begin key_reg <= 1'b1; cnt <= 0; key_pulse <= 1'b0; end else begin key_reg <= key_in; if (key_reg != key_in) cnt <= 0; else if (cnt < 20_000_000) cnt <= cnt + 1; else if (key_in == 1'b0) // 低有效按键 key_pulse <= 1'b1; else key_pulse <= 1'b0; end end

注意这段代码里20ms计数器对应20_000_000是100MHz时钟下的参数,如果你的板子是50MHz,那要改成10_000_000。每个人养成习惯,把计数器阈值用参数定义放在文件头部,不要直接写魔法数字,因为不同板子频率不同,复制代码的人很容易踩坑。

消抖之后还要做边沿检测,因为上面代码里已经有脉冲标志,实际上就是把按键按下瞬间和持续按住的区别分开。只要按键不松手,不会再触发新的脉冲,这样的效果正是售货机需要的。

4.2 数码管动态扫描与显示

显示余额我用了两位共阴数码管。如果板载数码管位选是高电平有效,那扫描逻辑就是循环拉高每一位,同时根据位序送出对应数字的段码。扫描频率我设定为1kHz,也就是说每位显示大约0.5ms,对整个刷新周期而言是1ms,肉眼基本看不到闪烁。

这里的关键点是“动态扫描”其实利用了人眼的视觉暂留效应。一次只能亮一位,但快速循环点亮多位数码管,看起来就是同时亮的。我在写扫描逻辑时的经验是,刷新周期不能太慢,否则会有很明显闪烁感;但也不能太快,因为位选切换瞬间会露出短暂的“拖影”。实测1ms到2ms刷新周期是舒服的区间。

为了让显示更友好,我还加了一个小数点和状态指示灯。比如待机时显示“00”,余额不足时闪烁“--”之类的提示。闪烁效果的实现也很简单,用另一个慢速时钟每250ms改变一次显示数据的使能信号即可。

4.3 扩展玩法:伪随机“再来一瓶”

这个彩蛋算是我觉得整个项目最提亮的部分。先设定一个概率,比如20%触发,算法上用线性反馈移位寄存器(LFSR)产生一个伪随机数,每次出货后采样一次,如果伪随机数落在触发区间内,数码管会短暂显示“LP”代替原本的“00”,同时蜂鸣器叫三声,代表命中“再来一瓶”,对应余额变化为返还一瓶的金额。

LFSR的代码很短:

always @(posedge clk) begin if (rst) lfsr_reg <= 16'hACE1; else if (calc_en) lfsr_reg <= {lfsr_reg[14:0], lfsr_reg[15] ^ lfsr_reg[13] ^ lfsr_reg[12] ^ lfsr_reg[10]}; end

这个功能纯粹是加着玩的,但对于学习价值来说非常值得,因为它涉及到了伪随机数生成、概率判断和状态机时机协作。如果以后你想把售货机扩展为带有互动营销的游戏机,这个彩蛋逻辑就是雏形。

5. 仿真、时序与上板实录

5.1 用仿真先跑通功能

FPGA开发绝对不能一上来就直接上板。我的习惯是每一步都先做行为仿真,用Testbench给激励信号,观察各模块的输出波形。先验证按键消抖,再验证状态机跳转,最后把顶层连起来做整体仿真。这样可以省掉大量在板级调试时烧时间的问题,因为波形里可以清楚地看到内部状态的跳转原因。

Testbench的一个粗框架:

initial begin clk = 0; rst = 1; #100; rst = 0; // 模拟一次投币 #1000; key_1yuan = 1'b0; #20_000_000; // 等消抖 key_1yuan = 1'b1; // 观察balance和state end

仿真初期我也遇到过状态机“卡死”的情况,后来发现是状态跳转条件里用了按键电平,但电平持续时间超过了状态机在一个状态停留的时间,导致一直跳转。改成脉冲事件后问题消失。所以我建议仿真时要特别关注“重复触发”和“临界时间”这两类情况。

5.2 时序约束与跨时钟域问题

这个项目的逻辑都在单一100MHz时钟域下,正常情况下时序不会有压力。但涉及到出货输出、蜂鸣器输出时,如果信号直接来自状态机组合逻辑,可能会有毛刺。为了避免这种情况,建议所有输出都打一拍寄存器,也就是在FSM输出逻辑后再加一级DFF。

我在初次上板时确实遇到了一个怪异现象:按下购买键后,舵机偶尔会抖动一下但没能转到位。后来用ILA抓信号,发现出货信号不是干净的单脉冲,而是有长达几百纳秒的毛刺串,舵机在反复收到短暂高电平和低电平后,根本来不及转动。这些问题单纯从功能仿真里是看不出来的,只有带上时序约束、跑完实现后,用在线逻辑分析仪才看得到。

解决方法有两个:一是把所有外部输出信号都寄存一级,消除组合逻辑毛刺;二是在XDC里给相关的输出信号加上MAXDELAY约束,保证信号从状态寄存器到输出引脚的时间可控。对这类慢速外设不要求严格的建立保持时间,但至少要保证没有毛刺。

5.3 实测调试结果

整个系统联调时,我遇到的最难排查的问题是“按下选货A键后,系统自动以为是同时投了5元”。这个问题折腾了一晚上,后来发现是开发板的按键电平默认悬空,没有接上拉或下拉电阻,导致按键未按下时电平不稳定,而我的消抖模块又因为计数阈值太长,在把不稳定的电平当成有效电平处理。

解决方式是在XDC中给按键输入引脚使能内部上拉:

set_property PULLUP true [get_ports {key_select_a}]

当然前提是你的FPGA的IO支持内部弱上拉。现在很多板卡默认在原理图里加了上拉,但我手头这块板子在部分按键上没有加,所以才踩了这个坑。这也提醒了我,拿到一块不熟悉的板子,第一步一定是仔细看原理图,别光看代码。

最终实测结果:所有按键功能正常,投币和选货的联动事件没有丢,余额计算正确,舵机出货时间和蜂鸣器提示时序都符合预期。整体功耗大约0.5瓦,放在桌面跑了一天也没有出现逻辑错乱。

6. 踩坑教训与新手避坑建议

6.1 我踩过的四个值得说的坑

第一个是复位引脚被复用。有的板子把几个按键或者LED和复位引脚共用了,如果不看原理图直接分配引脚,会导致无法烧录或者上电后一直处于复位状态。我这块板子有一个按键是与配置引脚复用的,最开始没注意,后来看原理图才发现。

第二个是数码管的段码表高低位容易反。不同板子的原理图,段码的a和dp对应位并不统一,完全照网上的例子,很容易出现数字乱显示。我的经验是把段码表写成参数或常量,先在板上跑一个“8.”的自检程序,把每一位都点亮一下,就能确认映射关系。

第三个是按键消抖时间不能一刀切。机械按键的抖动时间一般在5~10ms之间,我用20ms其实有些太保守,导致响应偏肉。后来改成10ms,手感立刻好了很多。这个时间不宜太长也不宜太短,如果板子上按键用的电容滤波较好,5ms都可以。

第四个是仿真通过也不代表一定能工作。我前面提到的毛刺问题就是典型,行为仿真默认是零延迟的理想电路,根本看不到组合逻辑毛刺。如果对输出质量要求高,一定要做后仿真或者用芯片原语提供的延迟模型跑一遍,或者直接用ILA实测。很多新手卡在“仿真对了但板子跑不对”的阶段,本质上就是忽略了这个差异。

6.2 如果从头再选型,我会怎么做

如果让我重新规划这个项目,我会考虑直接用FPGA作为主控,同时把通信接口统一留出来,比如预留串口或者I2C,方便扩展成真实可用的售货机。现在的课程设计或竞赛里,很流行把FPGA项目加上摄像头、图像识别等功能,自动售货机如果和图像识别结合在一起,就可以实现“识别瓶子回收换钱”的玩法,但这已经不是同一个量级的项目了。

从学习路径的角度看,我的建议是先跑通纯逻辑版本,再考虑传感器融合。FPGA上做自动售货机最重要的收获不是“会写状态机”,而是学会把真实世界的需求拆成模块化数字逻辑。这个能力是通用的,后面做图像处理、PCIe、DDR3等复杂项目时同样依赖。

入门级的同学可以按这个顺序练习:

  1. 先点亮一颗LED,理解引脚约束
  2. 做按键消抖,理解异步事件怎么变成同步事件
  3. 写一个计数器做呼吸灯,理解PWM和分频
  4. 再写数码管动态扫描,理解刷新率的影响
  5. 最后把状态机套进来,完成一个完整闭环

6.3 代码之外的经验

最后说说代码之外的经验。这类硬件项目最忌讳的是“先抄代码,再验证”。如果一开始就看别人的完整工程,你可能运行得很开心,但根本不知道每个模块和信号到底为什么存在。我的做法是先按需求画出状态转移图,手写每个模块的接口定义,然后再动手写代码。整个过程大约集中在三四天晚上,每天只专注一个模块。

调试工具上,Vivado的ILA和VIO非常推荐。ILA相当于逻辑分析仪,可以抓内部信号;VIO可以手动拉高拉低内部寄存器信号,相当于软件调试器。就算没有外接示波器,也能通过这两个IP把内部的信号变化看得一清二楚。如果是国产FPGA平台,一般也有类似工具,例如高云的示波器和在线逻辑分析功能,紫光同创也提供类似的调试核,底层思路一致。

还有一个调试心得:建议在代码里加一个计数器形式的“系统运行标志”,比如每500ms翻转一次某个LED,这样上电后先看LED是否在闪,能确认时钟和复位是否正常。不要一上来就去测复杂的投币选货逻辑,那样只能浪费时间在排查基础问题上。

如果你也想做一个类似的项目,建议拿到板子后先花半天摸清开发环境,再花一天规划模块,然后动手写代码和仿真验证,最后留出充足时间上板调。如果完整走下来,一次面试时聊到“FPGA项目经历”,这个自动售货机完全可以作为支撑项目,而且它能被问到的点非常多:状态机编码方式、格雷码还是独热码、消抖的误区、数码管扫描频率计算、LFSR原理、FPGA选型要点,这些话题都能展开。

我做这台售货机最大的体会是,FPGA开发里真正考验人的不是某一个模块有多难,而是模块之间的时序配合。按键消抖、数码管扫描、状态机跳转,任何一环稍微错一个时钟周期,整体行为就不对。可一旦把所有信号对齐到同一套时钟节拍上,整个系统就会变得非常自然。然后你会发现,原来数字逻辑的“手感”是这么养成的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询