FPGA自动售货机设计:同步状态机与跨时钟域实战指南 简介本资源是东南大学信息学院《数字系统设计》短学期课程的FPGA实践项目——自动售货机硬件控制系统面向电子工程、计算机科学及相关专业本科生与FPGA初学者聚焦数字逻辑建模、时序控制与硬件协同设计能力培养。压缩包含160个文件总大小1.46MB以10个VHDL源文件vhd为核心实现状态机与控制逻辑辅以11个约束文件xdc、14个综合报告rst、11个实现报告rpt及多个DPC/DCP工程文件完整覆盖从代码编写、约束配置、综合布局布线到仿真验证的全流程bat与tcl脚本支持一键编译调试。已有534人学习下载资源提供可直接在Vivado中打开的完整工程含Lock_Top_routed.dcp等关键实现文件附带详细日志与备份记录便于理解FPGA开发闭环、复现设计流程并快速定位时序违例等典型问题。1. 为什么用 FPGA 做自动售货机比用单片机更值得在数字系统设计课上死磕这不是一个“用 Verilog 写个状态机就交作业”的玩具项目——东南大学信息学院《数字系统设计》课程里这个“自动售货机”实验本质是一次对时序建模能力、异步信号处理边界、资源约束意识和硬件调试直觉的集中拷问。学生常卡在按键消抖后状态跳变、找零逻辑一加就错、硬币识别信号毛刺导致多计数、VGA 显示刷新和控制逻辑抢时钟……这些不是仿真波形能轻易骗过的“玄学翻车”。它要求你真正理解FPGA 不是跑代码的 CPU而是用逻辑门实时编织一张响应网络每一个always (posedge clk)都在和建立/保持时间赛跑每一条assign都可能成为时序路径上的黑匣子。适合刚学完组合/时序电路、正啃 Verilog 基础、但还没被综合报错和时序违例毒打过的新手也适合想把课本里的 Mealy/Moore 状态机、同步复位、跨时钟域握手这些概念焊进真实引脚电平里的熟手。它不追求商业功能完整而追求每个模块都能独立验证、每个信号都能用逻辑分析仪抓到、每次修改都能预判资源变化——这才是数字系统设计的肌肉记忆起点。2. 从顶层框图到模块拆解为什么必须用同步设计状态机驱动核心流程自动售货机的物理行为天然符合有限状态机FSM建模投币 → 选择商品 → 判定金额 → 出货 → 找零 → 复位。但直接写一个大而全的 FSM 容易失控。我们采用分层同步设计法顶层只做时钟域划分与信号路由核心控制逻辑下沉到vending_ctrl模块用 Moore 型状态机主导主干流程再用独立的coin_detector、dispense_logic、display_driver模块并行处理外设。关键理由有三第一避免异步输入直接进主状态机。硬币传感器、按键开关都是机械触点存在毫秒级抖动若直接接进always (posedge clk)的敏感列表一次按下可能触发多次状态跳转。必须先经两级寄存器同步synchronizer再送入边沿检测电路。第二分离数据通路与时序控制。金额累加、找零计算属于纯组合逻辑若混在状态机里会拉长关键路径导致时序收敛失败。应单独用adder_subtractor模块实现其输出通过寄存器打拍后供状态机读取。第三为后续扩展留出明确接口。比如未来加红外扫码模块只需替换product_select子模块不碰主控状态机。这种解耦不是教条而是某高校实验室在迭代 7 版教学 Demo 后血泪经验模块越小仿真越快出错定位越准。2.1 顶层模块top_vending时钟、复位与端口定义的硬性规范// top_vending.v module top_vending ( input wire clk_50m, // 50MHz 板载晶振所有逻辑以此为基准 input wire rst_n, // 低电平异步复位注意必须用异步复位 // 输入硬币信号高电平有效、按键高电平有效、商品选择拨码开关 input wire coin_in, // 单枚硬币脉冲宽度约 10ms需同步 input wire [3:0] key_in, // 4x4 矩阵键盘扫描结果或简化为 4 位拨码 input wire [2:0] sel_sw, // 3 位拨码开关对应 8 种商品 // 输出LED 指示、数码管段码、VGA 控制信号、电机驱动使能 output reg [7:0] led_out, // 8 位 LED显示当前状态码如 0x01等待投币 output reg [7:0] seg_data, // 共阴极数码管段码0-9,A-F output reg [3:0] seg_sel, // 数码管位选4 位 output reg vga_hsync, output reg vga_vsync, output reg [2:0] vga_rgb, // 简化 VGA仅 3 位 RGB output reg motor_en // 出货电机使能高电平启动 ); // 时钟分频生成 1kHz 用于消抖和状态机节拍 wire clk_1k; clk_divider #(.DIV_CNT(50000)) uut_clk_div ( .clk_in(clk_50m), .rst_n(rst_n), .clk_out(clk_1k) ); // 同步化硬币输入两级寄存器防亚稳态 wire coin_sync; synchronizer #(.STAGES(2)) uut_coin_sync ( .clk(clk_1k), .rst_n(rst_n), .async_in(coin_in), .sync_out(coin_sync) ); // 主控制器实例化核心 vending_ctrl uut_ctrl ( .clk(clk_1k), .rst_n(rst_n), .coin_pulse(coin_sync), // 已同步的硬币脉冲 .key_in(key_in), .sel_sw(sel_sw), .led_out(led_out), .seg_data(seg_data), .seg_sel(seg_sel), .vga_hsync(vga_hsync), .vga_vsync(vga_vsync), .vga_rgb(vga_rgb), .motor_en(motor_en) ); endmodule逻辑说明顶层不参与业务逻辑只做三件事——分频、同步、连线。clk_1k是整个系统节奏基准所有状态机跳转、数码管扫描、VGA 行场同步都基于它。synchronizer模块必须用#(.STAGES(2))显式例化不能手写两级reg否则综合工具可能优化掉中间寄存器失去同步意义。rst_n必须是异步复位因为上电瞬间各模块寄存器初值不确定同步复位无法保证所有 FF 同时清零。2.2 核心控制器vending_ctrlMoore 状态机的 6 个状态与转移条件Moore 型状态机的输出只取决于当前状态不依赖输入组合抗干扰强、时序干净。本设计定义 6 个状态状态名含义输出特征转移条件IDLE等待投币LED0x01数码管显示余额 0coin_pulse 1ACCUMULATING累加金额LED0x02数码管动态更新余额coin_pulse 1或key_in ! 0SELECTING商品选择中LED0x03VGA 显示商品菜单key_in有效且余额足够DISPENSING出货中LED0x04motor_en1VGA 显示OUTsel_sw选定且确认GIVING_CHANGE找零中LED0x05数码管倒计时找零额出货完成且找零 0RESET_WAIT复位等待LED0x00所有输出清零key_in[0]长按 2s// vending_ctrl.v节选关键状态转移部分 localparam IDLE 3b000, ACCUMULATING 3b001, SELECTING 3b010, DISPENSING 3b011, GIVING_CHANGE 3b100, RESET_WAIT 3b101; reg [2:0] state, next_state; reg [7:0] balance; // 当前余额单位角 // 状态寄存器同步时序 always (posedge clk or negedge rst_n) begin if (!rst_n) state IDLE; else state next_state; end // 下一状态逻辑组合逻辑 always (*) begin next_state state; // 默认保持 case (state) IDLE: if (coin_pulse) next_state ACCUMULATING; ACCUMULATING: begin if (key_in ! 4b0000) next_state SELECTING; else if (coin_pulse) next_state ACCUMULATING; // 继续投币 end SELECTING: if (valid_selection balance price[sel_sw]) next_state DISPENSING; DISPENSING: if (motor_done) begin if (change_amt 0) next_state GIVING_CHANGE; else next_state IDLE; end GIVING_CHANGE: if (change_cnt 0) next_state IDLE; RESET_WAIT: if (key_in[0] reset_timer 2000) next_state IDLE; endcase end // Moore 输出逻辑仅由 state 决定 always (posedge clk or negedge rst_n) begin if (!rst_n) begin led_out 8h00; seg_data 8h00; seg_sel 4h0; vga_rgb 3h0; motor_en 1b0; end else begin case (state) IDLE: begin led_out 8h01; seg_data bcd_encode(balance); // BCD 编码函数 seg_sel 4h1; // 选最低位数码管 vga_rgb 3h0; motor_en 1b0; end DISPENSING: begin led_out 8h04; seg_data 8h3F; // 0 seg_sel 4h0; vga_rgb 3h7; // 白色 motor_en 1b1; end // 其他状态类似... endcase end end参数说明balance用 8 位寄存器存储最大 255 角 25.5 元覆盖教学需求price[sel_sw]是 3-bit 地址索引的 ROM 查表存 8 种商品单价如 5, 8, 10, 12... 单位角bcd_encode()是自定义函数将二进制转为 2 位 BCD 供数码管显示。关键细节motor_done信号来自电机驱动模块的反馈如光电开关检测到位绝不能用固定延时替代——这是硬件闭环思维的分水岭。3. 三大外设模块的落地细节消抖、显示、驱动一个都不能少自动售货机的“真实感”来自外设交互。学生最容易在这里翻车数码管乱闪、VGA 图像撕裂、电机狂转不停。问题根源往往不在算法而在时序匹配与驱动能力。3.1 硬币检测与消抖两级同步 边沿检测的黄金组合硬币传感器输出的是机械抖动脉冲典型 10ms 宽含 1~5ms 毛刺。直接采样会导致coin_pulse多触发。必须用同步化 边沿检测两步走// coin_detector.v module coin_detector ( input wire clk, input wire rst_n, input wire coin_raw, // 传感器原始输出 output reg coin_pulse // 单周期高脉冲宽度1个clk_1k周期 ); reg [1:0] coin_sync; // 两级同步寄存器 reg coin_prev; // 同步化已包含在顶层此处为完整性展示 always (posedge clk or negedge rst_n) begin if (!rst_n) coin_sync 2b00; else coin_sync {coin_sync[0], coin_raw}; end // 边沿检测上升沿产生单周期脉冲 always (posedge clk or negedge rst_n) begin if (!rst_n) begin coin_prev 1b0; coin_pulse 1b0; end else begin coin_prev coin_sync[1]; // 同步后的稳定信号 coin_pulse ~coin_prev coin_sync[1]; // 上升沿 end end endmodule为什么必须两级单级同步无法消除亚稳态Metastability当coin_raw在clk采样沿附近变化FF 可能进入中间电压态持续数纳秒才稳定导致后续逻辑误判。两级寄存器将亚稳态概率降至工程可接受水平1e-12。血泪经验某同学用单级同步在 50MHz 下测试 1000 次投币第 873 次出现双计数用逻辑分析仪抓到亚稳态波形才信服。3.2 数码管动态扫描4 位共阴极的时序精确控制开发板常用 4 位共阴极数码管需动态扫描每位点亮约 2ms4 位轮询总周期 8ms ≈ 125Hz。关键在位选信号与段码信号严格同步否则出现“鬼影”。// seg_display.v module seg_display ( input wire clk_1k, input wire rst_n, input wire [7:0] data_in, // 待显示的 8 位数据BCD 编码 output reg [3:0] seg_sel, // 位选低电平有效 output reg [7:0] seg_data // 段码共阴极高电平点亮 ); reg [1:0] digit_cnt; // 0-3 计数选择当前位 reg [7:0] seg_buf [3:0]; // 4 个缓冲区存每位段码 // 位计数器1kHz 分频后每位显示 250 个周期 ≈ 2ms always (posedge clk_1k or negedge rst_n) begin if (!rst_n) digit_cnt 2b00; else digit_cnt digit_cnt 1b1; end // 段码缓冲将 8 位输入拆成 4 个 BCD 位 always (posedge clk_1k or negedge rst_n) begin if (!rst_n) begin seg_buf[0] 8h00; seg_buf[1] 8h00; seg_buf[2] 8h00; seg_buf[3] 8h00; end else begin // data_in 是 2 位 BCD如 0x12 表示 12 角高位在 data_in[7:4] seg_buf[0] bcd_to_seg(data_in[3:0]); // 个位 seg_buf[1] bcd_to_seg(data_in[7:4]); // 十位 seg_buf[2] 8h3F; // 0百位教学版不显示 seg_buf[3] 8h3F; // 0千位 end end // 动态扫描输出 always (posedge clk_1k or negedge rst_n) begin if (!rst_n) begin seg_sel 4b1111; seg_data 8h00; end else begin case (digit_cnt) 2b00: begin seg_sel 4b1110; seg_data seg_buf[0]; end // 位0 2b01: begin seg_sel 4b1101; seg_data seg_buf[1]; end // 位1 2b10: begin seg_sel 4b1011; seg_data seg_buf[2]; end // 位2 2b11: begin seg_sel 4b0111; seg_data seg_buf[3]; end // 位3 endcase end end // BCD 转段码查表共阴极 function [7:0] bcd_to_seg; input [3:0] bcd; case (bcd) 4h0: bcd_to_seg 8b00111111; // 0 4h1: bcd_to_seg 8b00000110; // 1 4h2: bcd_to_seg 8b01011011; // 2 // ... 其他数字 default: bcd_to_seg 8b00000000; endcase endfunction endmodule关键参数digit_cnt用 2 位计数器确保 4 位轮询seg_sel低电平有效所以4b1110表示选中第 0 位最右seg_data必须在seg_sel切换后立即更新否则位选与段码错位。避坑提示若发现某位数码管亮度明显偏低大概率是该位seg_sel有效时间过短计数器溢出或复位异常用示波器测seg_sel[0]和seg_data时序即可定位。3.3 VGA 显示驱动640x48060Hz 的最小可行方案教学板 VGA 接口通常只接 R/G/B 各 2 位共 6 位色深无需复杂帧缓存。我们用字符模式定义 8x16 点阵字体用 1KB RAM 存 128 个字符VGA 控制器实时查表生成像素。// vga_controller.v简化版仅行场同步字符地址生成 module vga_controller ( input wire clk_25m, // VGA 需要 25.175MHz用 PLL 从 50MHz 分频 input wire rst_n, input wire [2:0] char_data, // 当前显示字符 ASCII 码0-127 output reg vga_hsync, output reg vga_vsync, output reg [1:0] vga_r, output reg [1:0] vga_g, output reg [1:0] vga_b ); // VGA 时序参数640x48060Hz localparam H_TOTAL 800, // 行总周期 H_SYNC 96, // 行同步脉宽 H_BP 48, // 行后沿 H_ACT 640, // 有效显示区 H_FP 16; // 行前沿 localparam V_TOTAL 525, // 帧总周期 V_SYNC 2, // 场同步脉宽 V_BP 33, // 场后沿 V_ACT 480, // 有效显示区 V_FP 10; // 场前沿 reg [9:0] h_cnt, v_cnt; // 行/场计数器 reg [9:0] x, y; // 当前像素坐标0-639, 0-479 // 行计数器 always (posedge clk_25m or negedge rst_n) begin if (!rst_n) h_cnt 0; else if (h_cnt H_TOTAL-1) h_cnt 0; else h_cnt h_cnt 1; end // 场计数器 always (posedge clk_25m or negedge rst_n) begin if (!rst_n) v_cnt 0; else if ((h_cnt H_TOTAL-1) (v_cnt V_TOTAL-1)) v_cnt 0; else if (h_cnt H_TOTAL-1) v_cnt v_cnt 1; end // 像素坐标仅在有效区计算 always (posedge clk_25m or negedge rst_n) begin if (!rst_n) begin x 0; y 0; end else if (h_cnt H_ACT v_cnt V_ACT) begin x h_cnt; y v_cnt; end else begin x 0; y 0; end end // 行/场同步信号生成 assign vga_hsync (h_cnt H_ACT H_BP) (h_cnt H_ACT H_BP H_SYNC); assign vga_vsync (v_cnt V_ACT V_BP) (v_cnt V_ACT V_BP V_SYNC); // 字符显示逻辑将屏幕分为 80x30 字符格80*8640, 30*16480 reg [6:0] char_addr; // 字符内存地址0-127 reg [3:0] x_char, y_char; // 字符内坐标0-7, 0-15 always (*) begin char_addr (y[9:4] * 80) x[9:3]; // 行号*80 列号 x_char x[2:0]; y_char y[3:0]; end // 字体 ROM 查表简化用 case 代替 ROM reg [1:0] pixel_color; always (*) begin pixel_color 2b00; // 黑色背景 if (x_char 8 y_char 16) begin case (char_data) 8h20: ; // 空格全黑 8h30: if (font_0[y_char][x_char]) pixel_color 2b11; // 0 字模 // ... 其他字符 endcase end end // 输出 RGB2 位色深 assign vga_r (pixel_color 2b11) ? 2b11 : 2b00; assign vga_g (pixel_color 2b11) ? 2b11 : 2b00; assign vga_b (pixel_color 2b11) ? 2b11 : 2b00; endmodule为什么用 25.175MHz这是 VGA 640x48060Hz 的标准像素时钟误差超过 0.1% 会导致图像滚动或撕裂。开发板 PLL 必须精确配置不能用近似分频。实操技巧先用示波器测vga_hsync频率是否为 31.469kHz640x480 的行频再测vga_vsync是否为 59.94Hz达标再调字符显示。4. 避坑指南数字系统设计课上最常踩的 5 个坑及后悔药这些不是理论假设而是某高校连续三年实验报告里高频出现的错误附带现场抓到的波形证据和一招解决法。4.1 现象按键按下一次状态机跳了两次原因按键未消抖或消抖逻辑写在状态机内部导致key_in在一个时钟周期内多次变化被always (posedge clk)采样多次。解决必须用独立key_debouncer模块输出key_valid信号单周期脉冲且该信号必须经同步器后再送入主状态机。验证方法用逻辑分析仪抓key_raw和key_valid确认key_valid是干净单脉冲。4.2 现象数码管显示数字正确但某一位始终不亮或常亮原因seg_sel信号未用寄存器打拍或seg_data更新与seg_sel切换不同步导致位选无效时段码已改变。解决seg_sel和seg_data必须在同一always (posedge clk)块中赋值且seg_sel切换后至少延迟 1 个周期再更新seg_data。关键代码always (posedge clk_1k) begin ... case(digit_cnt) ... end中seg_sel和seg_data必须同块赋值。4.3 现象VGA 显示图像正常但文字边缘模糊或错位原因字符坐标计算用了x[9:0]直接除法如x/8综合工具将其转为组合逻辑引入长路径延迟导致像素时序偏移。解决改用移位实现整除x 3或用计数器生成字符地址如char_x_cnt每 8 个像素加 1。教训FPGA 里除法是面积/时序黑洞能移位绝不除。4.4 现象综合后资源占用爆表LUT 90%根本无法布局布线原因在状态机里写了大数组如reg [7:0] price[7:0]或用了未初始化的reg数组综合工具推断为 RAM但教学板无 Block RAM。解决价格表改用case语句查表case(sel_sw) 3b000: p8; ... endcase所有reg数组必须显式初始化。检查命令看综合报告.rpt文件中的Inferred Memory条目非零即危险。4.5 现象电机启动后不转或转几秒就停逻辑分析仪显示motor_en一直为高原因motor_en是使能信号但电机驱动芯片如 L298N需要 PWM 调速或需外部限流电阻。单纯高电平无法驱动。解决增加pwm_generator模块将motor_en作为使能输出 5kHz PWM 波占空比 80%在motor_en为高时启动 PWM为低时强制 PWM0。硬件验证用万用表测电机两端电压应为 PWM 平均电压如 5V*0.84V。5. 时序约束与引脚分配让代码从仿真走向真实开发板的临门一脚写完 Verilog 只完成了 70%剩下 30% 是让工具链理解你的硬件意图。不加约束的代码综合后可能在仿真里完美上板就时序违例——这是数字设计从“能跑”到“可靠”的分水岭。5.1 为什么必须写 XDC 约束文件一个反例说明某同学的clk_1k分频器在仿真中输出精准 1kHz但上板后用示波器实测为 987Hz。原因综合工具将分频计数器优化成了组合逻辑链实际路径延迟导致频率漂移。解决方案是添加时钟约束强制工具将其综合为寄存器链并指定时序目标。# constraints.xdc # 创建主时钟50MHz create_clock -period 20.000 -name clk_50m [get_ports clk_50m] # 创建衍生时钟1kHz基于 clk_50m 的分频器输出 create_generated_clock -name clk_1k -source [get_pins clk_divider/UUT/clk_out_reg/C] -divide_by 50000 [get_pins vending_ctrl/clk] # 约束关键输入硬币信号为异步输入需设置输入延迟 set_input_delay -clock clk_1k -max 10.0 [get_ports coin_in] set_input_delay -clock clk_1k -min 0.0 [get_ports coin_in] # 约束输出数码管段码需在位选后 5ns 内稳定 set_output_delay -clock clk_1k -max 5.0 [get_ports seg_data] set_output_delay -clock clk_1k -min 0.0 [get_ports seg_data]参数解读-period 20.000对应 50MHz1/50e620ns-divide_by 50000告诉工具clk_1k是clk_50m的 50000 分频工具会据此计算时序路径set_input_delay告诉工具coin_in最晚在clk_1k上升沿前 10ns 到达避免建立时间违例。没有这些工具就按“最快路径”优化结果就是“最不可靠路径”。5.2 引脚分配的生死线VGA、数码管、按键的物理映射原则开发板原理图是唯一真理。常见错误是凭记忆分配引脚导致 VGA 的R0/R1接反显示全红。必须严格按以下三步查原理图找到 VGA 接口的R0,R1,G0,G1,B0,B1,HSYNC,VSYNC对应的 FPGA 引脚号如Y10,Z10,W10,U10,Y9,W9,T8,T9查开发板手册确认这些引脚支持 LVCMOS33 电平VGA 标准且未被其他功能复用XDC 中精确绑定# VGA 引脚以某款开发板为例 set_property PACKAGE_PIN Y10 [get_ports vga_r[0]] set_property IOSTANDARD LVCMOS33 [get_ports vga_r[0]] set_property PACKAGE_PIN Z10 [get_ports vga_r[1]] set_property IOSTANDARD LVCMOS33 [get_ports vga_r[1]] set_property PACKAGE_PIN W10 [get_ports vga_g[0]] set_property IOSTANDARD LVCMOS33 [get_ports vga_g[0]] # ... 其他引脚同理 # 数码管位选共阴极低电平有效 set_property PACKAGE_PIN U13 [get_ports seg_sel[0]] # 位0 set_property PACKAGE_PIN T13 [get_ports seg_sel[1]] # 位1 set_property PACKAGE_PIN T14 [get_ports seg_sel[2]] # 位2 set_property PACKAGE_PIN U14 [get_ports seg_sel[3]] # 位3 # 按键高电平有效内部上拉 set_property PACKAGE_PIN T18 [get_ports key_in[0]] # KEY0 set_property PACKAGE_PIN U18 [get_ports key_in[1]] # KEY1 set_property PACKAGE_PIN U17 [get_ports key_in[2]] # KEY2 set_property PACKAGE_PIN T17 [get_ports key_in[3]] # KEY3致命细节seg_sel是低电平有效但开发板原理图中位选引脚可能接了反相器此时 XDC 中需标注INVERTED属性key_in若为按键必须确认开发板已内置上拉电阻否则需在 XDC 中加PULLUP。验证方法约束后运行Report IO Planning检查所有端口是否成功绑定无Unconstrained报告。5.3 时序报告解读看懂 Critical Path 和 Slack综合实现后打开report_timing_summary.rpt重点关注三列项目含义健康值风险提示WNS (Worst Negative Slack)最差负裕量≥ 0ps 0ps 表示时序违例必修TNS (Total Negative Slack)总负裕量 0ps 0ps 表示多条路径违例Critical Path关键路径描述clk_1k to vending_ctrl/motor_en定位到具体模块和信号例如报告中Slack (MET) : 1.234ns (required time - arrival time) Source: vending_ctrl/coin_pulse_reg/C Destination: vending_ctrl/state_reg[0]/D Path Group: clk_1k说明从coin_pulse_reg到state_reg[0]的路径有 1.234ns 富余安全。若为-0.876ns则需优化在路径中插入寄存器register retiming或降低clk_1k本文还有配套的精品资源点击获取