FPGA实战:用状态机设计自动售货机(附完整Verilog代码)
FPGA实战用状态机设计自动售货机附完整Verilog代码最近在整理自己的FPGA学习笔记时翻到了一个几年前做的自动售货机小项目。当时为了彻底搞懂状态机在实际项目中的应用我花了整整一个周末从需求分析、状态划分到模块调试一步步把它做了出来。这个项目麻雀虽小五脏俱全它完美地融合了状态机FSM的核心思想、按键处理、数码管驱动、外设控制等多个FPGA开发中的基础且关键的技能点。对于刚接触FPGA不久或者已经学完基础语法但苦于没有合适项目练手的朋友来说复现这样一个系统其收获远比单纯看十篇理论文章要大得多。今天我就把这个项目的完整设计思路和Verilog代码分享出来。我们不会止步于功能的简单实现而是会深入探讨如何构建一个健壮、可扩展的有限状态机如何处理实际工程中的按键抖动、时序协调等问题并最终将它们整合成一个可以实际在开发板上运行的系统。无论你是电子爱好者、嵌入式方向的学生还是希望夯实数字逻辑设计基础的工程师相信这篇内容都能给你带来一些实实在在的启发和可以直接上手的代码。1. 项目需求分析与系统架构设计在动手写第一行代码之前我们必须先把需求理清楚。一个自动售货机的核心逻辑其实并不复杂选择商品、投入钱币、判断金额、执行出货或报警。但要用硬件描述语言HDL在FPGA上实现就需要将这些自然语言描述转化为精确的、有时序要求的硬件行为。我们的售货机设定售卖两种商品商品A单价40元和商品B单价50元。用户的操作流程被设计为三个明确的阶段商品选择阶段通过两个独立按键K1, K2分别增加两种商品的购买数量并通过数码管实时显示。投币支付阶段用户确认商品选择后进入投币环节。通过另外两个按键模拟投入10元和20元硬币数码管同步显示投入的各类硬币数量。交易判定与执行阶段用户确认支付完成系统自动计算商品总价和投入总金额。若金额相等则控制LED灯以特定次数闪烁模拟出货若金额不足或超额则触发蜂鸣器报警。基于这个流程我们可以勾勒出系统的顶层模块图。整个设计采用自顶向下Top-Down的方法核心是一个有限状态机FSM控制器它负责协调所有子模块的工作。模块名称功能描述关键输入/输出信号顶层模块 (top)系统入口实例化并连接所有子模块时钟clk复位rst_n按键k1, k2, k4LEDled1, led2数码管seg_sel, seg_led蜂鸣器siren按键消抖模块 (debounce)消除机械按键的抖动确保一次按压产生一个稳定的电平变化原始按键信号noisy消抖后信号clean分频模块 (clk_div)将系统高频时钟分频产生用于状态机、LED闪烁的低频时钟系统时钟clk分频后时钟clk_1hz核心状态机 (fsm_ctrl)系统大脑根据当前状态和输入产生控制信号并更新状态消抖后按键key1, key2, key4输出商品/硬币计数、LED/蜂鸣器使能数码管显示模块 (seg_driver)动态扫描显示4个数字商品A/B数量10元/20元硬币数量16位显示数据data[15:0]片选seg_sel[5:0]段选seg_led[7:0]LED驱动模块 (led_driver)根据状态机指令控制指定LED闪烁指定次数闪烁使能led_en商品数量cnt_a, cnt_bLED输出led1, led2蜂鸣器驱动模块 (buzzer_driver)根据状态机指令产生特定频率的报警声报警使能alarm_en蜂鸣器输出siren提示这种模块化设计的好处是显而易见的。每个模块功能独立接口清晰不仅便于单独仿真和调试也极大地提高了代码的可重用性。例如debounce和seg_driver模块几乎可以原封不动地移植到其他任何需要按键和显示的项目中。顶层模块的代码主要任务就是“连线”。它定义了整个系统的输入输出端口并像搭积木一样将各个子模块实例化连接起来。下面是一个简化的顶层模块结构示意module top ( input wire clk, // 系统时钟例如25MHz input wire rst_n, // 低电平复位信号 input wire k1, k2, k4, // 原始按键输入 output wire led1, led2, output wire [5:0] seg_sel, output wire [7:0] seg_led, output wire siren ); // 内部连线声明 wire key1, key2, key4; // 消抖后的按键信号 wire clk_1hz; // 1Hz时钟 wire [3:0] cnt_a, cnt_b, coin_10, coin_20; // 各类计数 wire led_en, alarm_en; // 控制使能 // 模块实例化 debounce u_debounce_k1 (.clk(clk), .rst_n(rst_n), .key_in(k1), .key_out(key1)); // ... 其他按键消抖实例 clk_div u_clk_div (.clk(clk), .rst_n(rst_n), .clk_out(clk_1hz)); fsm_ctrl u_fsm ( .clk(clk_1hz), // 状态机使用1Hz时钟便于观察 .rst_n(rst_n), .key1(key1), .key2(key2), .key4(key4), .cnt_a(cnt_a), .cnt_b(cnt_b), .coin_10(coin_10), .coin_20(coin_20), .led_en(led_en), .alarm_en(alarm_en) ); seg_driver u_seg ( .clk(clk), .rst_n(rst_n), .data({cnt_a, cnt_b, coin_10, coin_20}), // 拼接显示数据 .seg_sel(seg_sel), .seg_led(seg_led) ); led_driver u_led ( .clk(clk_1hz), .rst_n(rst_n), .en(led_en), .cnt_a(cnt_a), .cnt_b(cnt_b), .led1(led1), .led2(led2) ); buzzer_driver u_buzzer ( .clk(clk), .rst_n(rst_n), .en(alarm_en), .siren(siren) ); endmodule有了清晰的架构我们就可以逐个击破每个模块了。首先从最基础但至关重要的时钟和按键处理开始。2. 基础模块实现时钟、按键与显示在数字系统中干净的时钟和稳定的输入信号是可靠工作的基石。对于我们的售货机首要任务就是处理好来自外界的“毛刺”。2.1 时钟分频为系统提供合适的心跳FPGA开发板通常提供高频的晶振时钟如25MHz、50MHz、100MHz。直接用它来驱动状态机或LED闪烁速度太快人眼无法分辨。因此我们需要一个分频模块将高频时钟转换为低频时钟例如1Hz。这里采用计数器实现当计数器计满N/2个时钟周期时输出时钟翻转一次从而产生占空比为50%的方波。module clk_div #( parameter SYS_CLK_FREQ 25_000_000, // 系统时钟频率25MHz parameter OUT_CLK_FREQ 1 // 输出时钟频率1Hz )( input wire clk, input wire rst_n, output reg clk_out ); // 计算计数值N (系统时钟频率) / (2 * 输出时钟频率) - 1 localparam CNT_MAX (SYS_CLK_FREQ / (2 * OUT_CLK_FREQ)) - 1; reg [24:0] cnt; // 计数器需要足够宽的位宽 always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt 0; clk_out 0; end else if (cnt CNT_MAX) begin cnt 0; clk_out ~clk_out; // 达到计数值时钟翻转 end else begin cnt cnt 1; end end endmodule使用参数化设计只需修改SYS_CLK_FREQ和OUT_CLK_FREQ就能轻松适配不同的开发板和频率需求代码复用性极高。2.2 按键消抖告别误触的烦恼机械按键在闭合和断开的瞬间会产生持续数毫秒的抖动在数字电路中会被识别为多次快速按压。消抖的目的就是过滤掉这些抖动确保一次稳定的按压只产生一次有效的电平跳变。常用的方法是延时采样当检测到按键电平变化后等待一段时间如20ms如果电平保持稳定则认为是一次有效的按键事件。module debounce #( parameter DELAY_MS 20, // 消抖延时时间 parameter CLK_FREQ 25_000_000 // 时钟频率 )( input wire clk, input wire rst_n, input wire key_in, // 原始按键输入低电平有效 output reg key_out // 消抖后输出低电平有效 ); // 将延时时间转换为时钟周期数 localparam DELAY_CYCLES (DELAY_MS * CLK_FREQ) / 1000; reg [19:0] cnt; // 计数器 reg key_reg; // 按键状态寄存器 always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt 0; key_reg 1b1; // 默认按键未按下高电平 key_out 1b1; end else begin key_reg key_in; // 缓存上一拍按键值 if (key_reg ! key_in) begin // 检测到边沿计数器清零重新开始计时 cnt 0; end else if (cnt DELAY_CYCLES) begin // 计时中保持输出不变 cnt cnt 1; end else begin // 计时结束抖动已消除更新稳定输出 key_out key_in; end end end endmodule注意这里的key_in和key_out都设定为低电平有效即按下为0这是为了匹配开发板上常见的按键电路按键按下时接地。代码中的计数器位宽需要根据DELAY_CYCLES的大小进行调整确保不会溢出。2.3 数码管动态扫描让信息清晰可见我们需要用4位数码管分别显示商品A、商品B的数量以及10元、20元硬币的数量。如果为每个数码管单独驱动需要大量IO口。动态扫描技术利用人眼的视觉暂留效应通过快速轮流点亮每一个数码管实现多位显示的同时节省IO资源。核心思想是一个高频扫描时钟通常几百Hz到几KHz驱动一个循环计数器依次选通拉低对应的数码管位选信号seg_sel。根据当前选通的位数从待显示的数据中取出对应的4位二进制数BCD码通过查表法转换为7段数码管的段选信号seg_led。module seg_driver ( input wire clk, // 高速扫描时钟如1kHz input wire rst_n, input wire [15:0] data, // 16位显示数据每4位一组 output reg [5:0] seg_sel, // 6位数码管片选低电平有效 output reg [7:0] seg_led // 8段段选含小数点低电平有效 ); reg [19:0] scan_cnt; // 扫描计数器 reg [3:0] data_disp; // 当前要显示的数字0-9 reg [2:0] sel_idx; // 当前选中的数码管索引 // 生成扫描时钟和索引 always (posedge clk or negedge rst_n) begin if (!rst_n) begin scan_cnt 0; sel_idx 0; end else begin scan_cnt scan_cnt 1; if (scan_cnt 20d50000) begin // 控制扫描频率 scan_cnt 0; sel_idx (sel_idx 3d3) ? 3d0 : sel_idx 1; end end end // 根据索引选择当前显示的数字和片选信号 always (*) begin case (sel_idx) 3d0: begin seg_sel 6b111110; data_disp data[3:0]; end // 显示最低4位 3d1: begin seg_sel 6b111101; data_disp data[7:4]; end 3d2: begin seg_sel 6b111011; data_disp data[11:8]; end 3d3: begin seg_sel 6b110111; data_disp data[15:12]; end // 显示最高4位 default: begin seg_sel 6b111111; data_disp 4h0; end endcase end // 七段译码器将4位二进制数转换为段选信号 always (*) begin case (data_disp) 4h0: seg_led 8b1100_0000; // 0 4h1: seg_led 8b1111_1001; // 1 4h2: seg_led 8b1010_0100; // 2 4h3: seg_led 8b1011_0000; // 3 4h4: seg_led 8b1001_1001; // 4 4h5: seg_led 8b1001_0010; // 5 4h6: seg_led 8b1000_0010; // 6 4h7: seg_led 8b1111_1000; // 7 4h8: seg_led 8b1000_0000; // 8 4h9: seg_led 8b1001_0000; // 9 default: seg_led 8b1100_0000; // 默认显示0 endcase end endmodule基础模块搭建好后整个系统的“四肢”和“感官”就准备好了。接下来我们将设计整个项目的“大脑”——有限状态机。3. 核心逻辑有限状态机FSM的设计与实现有限状态机是数字系统控制逻辑设计的灵魂。它将复杂的行为分解为有限的几个状态State并明确规定在每种状态下根据不同的输入Input进行何种操作输出并跳转到哪个下一状态Next State。对于我们的自动售货机状态划分直接对应了用户的操作流程。3.1 状态定义与状态转移图我们定义五个状态IDLE(空闲/选择状态)等待用户选择商品。K1/K2按键增加商品A/B的数量数码管显示当前数量。按下K4确认键进入投币状态。PAY(投币状态)等待用户投入硬币。K1/K2按键分别增加10元/20元硬币数量数码管同步更新。按下K4进入金额比较状态。COMPARE(比较状态)这是一个瞬间完成的组合逻辑判断状态。系统计算商品总价 40*cnt_a 50*cnt_b和投入总额 10*coin_10 20*coin_20。根据比较结果无条件跳转到DELIVER出货或ALARM报警状态。DELIVER(出货状态)使能LED驱动模块让指定LED闪烁对应商品次数模拟出货。完成后自动回到IDLE状态。ALARM(报警状态)使能蜂鸣器驱动模块发出报警声。等待用户手动复位或定时后自动回到IDLE状态。用状态转移图表示如下文字描述复位(rst_n0) | v [IDLE] | K4确认 v [PAY] | K4确认 v [COMPARE] / \ 金额相等 金额不等 / \ v v [DELIVER] [ALARM] | | ----- [IDLE] ----(手动复位或超时)3.2 三段式状态机编码实践在Verilog中推荐使用三段式风格编写状态机其结构清晰易于综合和调试第一段同步时序逻辑描述状态寄存器。负责在时钟边沿进行状态转换。第二段组合逻辑描述状态转移条件。根据当前状态和输入决定下一状态是什么。第三段时序或组合逻辑描述每个状态的输出。定义在每种状态下输出信号应该是什么值。下面是我们售货机状态机的核心代码框架module fsm_ctrl ( input wire clk, // 时钟建议用1Hz慢时钟 input wire rst_n, // 异步复位低有效 input wire key1, key2, key4, // 消抖后的按键低有效 output reg [3:0] cnt_a, cnt_b, // 商品A/B数量0-15 output reg [3:0] coin_10, coin_20, // 10元/20元硬币数量0-15 output reg led_en, // LED闪烁使能高有效 output reg alarm_en // 蜂鸣器报警使能高有效 ); // 状态编码使用独热码One-Hot或二进制码。独热码更安全但占用触发器多。 parameter S_IDLE 3b000; parameter S_PAY 3b001; parameter S_COMPARE 3b010; parameter S_DELIVER 3b011; parameter S_ALARM 3b100; reg [2:0] current_state, next_state; // 第一段状态寄存器 always (posedge clk or negedge rst_n) begin if (!rst_n) current_state S_IDLE; else current_state next_state; end // 第二段状态转移逻辑组合逻辑 always (*) begin next_state current_state; // 默认保持当前状态 case (current_state) S_IDLE: begin if (!key4) // 按下确认键 next_state S_PAY; end S_PAY: begin if (!key4) // 按下确认键进入比较 next_state S_COMPARE; end S_COMPARE: begin // 比较是组合逻辑瞬间完成根据结果跳转 if ((40*cnt_a 50*cnt_b) (10*coin_10 20*coin_20)) next_state S_DELIVER; else next_state S_ALARM; end S_DELIVER: begin // 假设出货需要时间这里用计数器实现延时后跳回IDLE // 为简化我们设计为进入此状态后由LED驱动模块控制闪烁次数闪烁完成后给出完成信号状态机再跳转。 // 此处先简化为固定延时后跳转。 if (deliver_done) // deliver_done是一个来自LED模块或内部计时器的信号 next_state S_IDLE; end S_ALARM: begin if (alarm_timeout || !key4) // 报警超时或按键复位 next_state S_IDLE; end default: next_state S_IDLE; endcase end // 第三段输出逻辑与时序逻辑控制计数器和使能信号 reg [3:0] deliver_timer; // 出货状态计时器 reg deliver_done; reg [7:0] alarm_timer; // 报警状态计时器 reg alarm_timeout; always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt_a 4d0; cnt_b 4d0; coin_10 4d0; coin_20 4d0; led_en 1b0; alarm_en 1b0; deliver_timer 4d0; deliver_done 1b0; alarm_timer 8d0; alarm_timeout 1b0; end else begin // 默认输出 led_en 1b0; alarm_en 1b0; deliver_done 1b0; alarm_timeout 1b0; case (current_state) S_IDLE: begin // 商品数量计数防止溢出 if (!key1 cnt_a 4d15) cnt_a cnt_a 1b1; if (!key2 cnt_b 4d15) cnt_b cnt_b 1b1; // 进入新交易前清空硬币计数也可不清取决于需求 coin_10 4d0; coin_20 4d0; end S_PAY: begin // 硬币数量计数防止溢出 if (!key1 coin_10 4d15) coin_10 coin_10 1b1; if (!key2 coin_20 4d15) coin_20 coin_20 1b1; end S_DELIVER: begin led_en 1b1; // 使能LED驱动 // 简单延时逻辑实际应与LED模块联动 if (deliver_timer 4d10) deliver_timer deliver_timer 1b1; else begin deliver_done 1b1; deliver_timer 4d0; end end S_ALARM: begin alarm_en 1b1; // 使能蜂鸣器 // 报警持续一段时间 if (alarm_timer 8d100) alarm_timer alarm_timer 1b1; else alarm_timeout 1b1; end // S_COMPARE状态无操作纯组合逻辑判断 endcase end end endmodule这个状态机清晰地刻画了整个交易流程。其中S_COMPARE状态是一个关键设计点它没有时钟周期的延迟一旦进入就立即根据组合逻辑的计算结果决定下一个状态这体现了硬件并发执行的特性。4. 外设驱动与系统集成调试状态机发出了控制指令最后还需要执行单元——LED和蜂鸣器驱动模块来具体实现“闪烁”和“鸣叫”的效果。同时将所有模块集成后如何进行有效的调试也是项目成功的关键。4.1 LED驱动模块精准控制闪烁次数LED驱动模块接收来自状态机的使能信号led_en和商品数量cnt_a,cnt_b。它的任务是当led_en有效时控制led1闪烁cnt_a次led2闪烁cnt_b次。闪烁需要有明确的频率如1Hz和占空比。module led_driver ( input wire clk, // 1Hz时钟用于控制闪烁周期 input wire rst_n, input wire en, // 使能信号高电平有效 input wire [3:0] cnt_a, cnt_b, // 商品A/B的数量 output reg led1, led2 // LED输出高电平点亮 ); reg led1_toggle, led2_toggle; reg [3:0] flash_cnt_a, flash_cnt_b; // 已完成的闪烁次数计数 reg [1:0] state_a, state_b; // 每个LED的简单状态机0-等待1-点亮2-熄灭计数 // 使用1Hz时钟每个周期为1秒。假设点亮0.5秒熄灭0.5秒。 always (posedge clk or negedge rst_n) begin if (!rst_n) begin led1 1b0; led2 1b0; flash_cnt_a 4d0; flash_cnt_b 4d0; state_a 2d0; state_b 2d0; end else if (en) begin // 控制LED1闪烁 case (state_a) 2d0: begin // 等待开始或准备下一次闪烁 if (flash_cnt_a cnt_a) begin state_a 2d1; led1 1b1; // 点亮 end end 2d1: begin // 点亮半周期 state_a 2d2; led1 1b0; // 熄灭 end 2d2: begin // 熄灭半周期并计数 flash_cnt_a flash_cnt_a 1b1; state_a 2d0; // 回到等待判断是否进行下一次闪烁 end endcase // 控制LED2闪烁逻辑同LED1 case (state_b) 2d0: begin if (flash_cnt_b cnt_b) begin state_b 2d1; led2 1b1; end end 2d1: begin state_b 2d2; led2 1b0; end 2d2: begin flash_cnt_b flash_cnt_b 1b1; state_b 2d0; end endcase end else begin // 使能无效复位所有状态 led1 1b0; led2 1b0; flash_cnt_a 4d0; flash_cnt_b 4d0; state_a 2d0; state_b 2d0; end end endmodule4.2 蜂鸣器驱动模块生成可听的报警音蜂鸣器分为有源和无源两种。有源蜂鸣器给高电平就响无源蜂鸣器需要给一定频率的方波驱动才会发声。这里我们假设驱动的是无源蜂鸣器通过产生一个几百Hz到几KHz的方波来发声。module buzzer_driver #( parameter CLK_FREQ 25_000_000, // 系统时钟频率 parameter BUZZ_FREQ 2000 // 蜂鸣器发声频率(Hz) )( input wire clk, input wire rst_n, input wire en, // 报警使能 output reg siren // 蜂鸣器控制信号 ); // 计算产生指定频率方波所需的计数值 localparam HALF_PERIOD (CLK_FREQ / (2 * BUZZ_FREQ)) - 1; reg [15:0] cnt; // 计数器 always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt 0; siren 1b0; end else if (en) begin if (cnt HALF_PERIOD) begin cnt 0; siren ~siren; // 翻转输出产生方波 end else begin cnt cnt 1; end end else begin cnt 0; siren 1b0; // 使能无效时关闭蜂鸣器 end end endmodule4.3 系统集成与调试技巧将所有模块在顶层连接好后编译、综合、布局布线最后生成比特流文件下载到FPGA开发板。调试阶段可能会遇到各种问题这里分享几个实用的技巧分模块仿真在写每个子模块如debounce, fsm_ctrl时就为其编写简单的Testbench进行仿真确保逻辑正确。使用ModelSim或Vivado自带的仿真工具观察波形是否符合预期。SignalTap II / ILA 在线调试这是Intel/Altera和Xilinx FPGA提供的强大工具。可以将设计中的关键信号如状态机状态current_state、按键消抖后的信号key1、各类计数器等添加到逻辑分析仪中在板级运行时实时抓取这些信号的波形直观地看到系统运行过程是定位问题的利器。渐进式集成不要一次性集成所有模块。可以先让状态机和数码管显示工作验证商品选择和投币计数功能。然后再加入LED和蜂鸣器驱动。每增加一个功能都进行测试。添加调试输出可以在代码中临时添加一些调试用的LED灯或数码管显示比如用LED显示当前状态机的状态编码这样即使没有逻辑分析仪也能对系统运行有一个基本的判断。当我第一次把这个系统调通看到LED灯按照我购买的商品数量准确闪烁时那种成就感是巨大的。它不仅仅是一个简单的售货机模型更是一个完整的、涵盖了输入、处理、输出全流程的微型数字系统。你可以在此基础上进行无限扩展比如增加更多商品种类、支持找零功能、加入余额显示、甚至用VGA或液晶屏做一个图形化界面。FPGA的魅力就在于只要逻辑想得清楚硬件资源足够你几乎可以实现任何数字系统。
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2412739.html
如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!