1. 项目概述:为什么一个两层楼电梯控制器值得花两周时间手写Verilog?
你可能刚做完数字逻辑实验课的七段数码管显示,或者正对着Quartus II里报错的“17.1 error: failure to obtain a verilog simulation license”发愁——别急,这个两层楼电梯控制器,就是我当年在电子科技大学数电实验室里,用一块黑金FPGA开发板(型号AX301,Xilinx Spartan-6系列)从零搭起的第一个“能动的系统”。它不跑Linux,不接MIPI,不调PyTorch,就老老实实控制两个楼层按钮、两扇门、一个轿厢位置和一套状态机。但正是这个看似简单的项目,让我第一次真正理解了时序约束怎么写才不被综合器优化掉关键信号、为什么电梯不能一按按钮就立刻开门关门、数码管动态显示背后藏着多少个隐性计数器。
核心关键词——FPGA、Verilog、电梯控制系统、数字系统实验——不是堆砌,而是这条技术路径的真实切片:它属于数字系统设计的“承上启下”环节。上承组合逻辑(如编码器、译码器),下启完整嵌入式系统(如带UART通信的智能电梯)。而“两层楼”这个限定,恰恰是教学与工程的黄金平衡点:足够简单到能在单块FPGA上完成全部功能验证,又足够复杂到必须引入状态机、消抖、优先级调度、时序协调等真实设计要素。我试过直接用纯组合逻辑实现,结果仿真波形里全是毛刺,上板后按钮一按,轿厢在1楼和2楼之间疯狂“抽搐”——后来才明白,这不是逻辑写错了,是根本没给信号留出稳定建立时间。
适合谁来参考?如果你正在准备电子科技大学《数字系统实验》课程设计,或刚入门FPGA开发、手头只有基础开发板(比如黑金AX301、正点原子达芬奇、安路小蜜蜂),又不想一上来就被Vivado HLS或PYNQ绕晕,那这个项目就是你的最佳练手靶子。它不依赖任何IP核(连PLL都手动分频),所有代码可读、可调试、可逐行单步跟踪。我当年就是靠它搞懂了ModelSim里怎么设置force信号强制触发开门动作,也靠它第一次把ILA(Integrated Logic Analyzer)探针插进自己的代码里,亲眼看到状态机跳转的每一个cycle。
2. 整体架构设计:三层结构如何避免“一锅炖”式混乱
2.1 为什么必须分层?——从“按钮一按就动”到“安全可靠运行”的认知跃迁
初学者最容易犯的错误,就是把所有功能揉进一个大模块:检测按钮→判断目标楼层→驱动电机→控制门→更新数码管。表面看逻辑通顺,实际一上板就崩溃。原因有三:
第一,时钟域混用。按钮消抖需要毫秒级延时(对应几十MHz主频下的几万周期),而数码管扫描需要1kHz左右刷新率,电机控制信号则要求微秒级响应精度。全塞进一个always @(posedge clk)块里,要么消抖失效,要么数码管闪烁,要么电机抖动。
第二,异步信号未同步。物理按钮是机械开关,按下瞬间会产生数十微秒的抖动(bouncing),若直接采样进FPGA内部寄存器,一个按键可能被识别成3~5次有效触发。这在电梯里意味着:你按一次2楼按钮,系统可能记成“去2楼→去1楼→再回2楼”,轿厢直接原地打转。
第三,状态耦合过紧。开门动作必须严格依赖“轿厢已停稳+所在楼层匹配+无其他更高优先级请求”三个条件同时满足,缺一不可。如果把这些判断和电机驱动写在一起,修改一个条件就得重测整个模块,debug成本指数级上升。
所以我最终采用三级流水线式架构:顶层模块(Top_Level)只做资源分配与信号粘合;中间控制层(Elevator_Controller)专注业务逻辑——状态机调度、请求队列管理、优先级仲裁;底层驱动层(Driver_Layer)负责硬件交互——按钮/传感器消抖、数码管扫描、电机方向PWM生成。这种分法不是为了炫技,而是让每个模块职责单一、接口清晰、可独立验证。比如测试消抖模块时,我甚至不用烧写FPGA,直接在ModelSim里用$readmemb读入一段模拟抖动波形文件,观察输出是否干净——这比反复下载bitstream快十倍。
2.2 模块划分与数据流:一张图看懂信号怎么“走对路”
整个系统数据流向非常明确,像一条单向传送带:
物理世界 → 按钮/传感器 → 消抖模块 → 请求寄存器 → 控制器状态机 ↓ 数码管显示 ← 扫描控制器 ↓ 电机驱动 ← PWM发生器 ↓ 轿厢位置反馈 ← 光电编码器(模拟)这里的关键枢纽是请求寄存器(Request_Reg)。它不是简单存个0/1,而是用两个8位寄存器分别记录:
up_req[1:0]:记录各楼层向上请求(1楼按↑=bit0置1,2楼按↑=bit1置1,但2楼本身无向上请求,所以bit1恒为0)down_req[1:0]:记录各楼层向下请求(1楼无向下请求,bit0恒0;2楼按↓=bit1置1)
为什么用8位?因为预留了扩展空间——未来加到4层楼,只需改定义,不用动状态机。而控制器每次只读取当前有效请求,结合轿厢当前位置(由current_floor[1:0]表示),通过状态机决定下一步动作。这种解耦设计,让状态机代码异常清爽:它只关心“我在哪”“要去哪”“有没有人等”,完全不碰按钮硬件细节。
提示:顶层模块的端口命名必须体现物理意义。例如
btn_up_1(1楼向上按钮)、led_door_open(门开指示灯)、seg_data[7:0](数码管段码),而非a、b、c。我见过太多同学因端口名模糊,在引脚约束文件(.qsf)里配错IO,导致按钮失灵却以为是逻辑bug,白白浪费半天。
2.3 时钟策略:一个主频如何喂饱所有外设
开发板提供50MHz晶振,但不同模块需求差异巨大:
- 按钮消抖:需≥20ms稳定采样,即至少100万个50MHz周期,用18位计数器(2^18=262144≈26万)刚好够用;
- 数码管扫描:6位数码管需1kHz刷新率,即每ms刷新一位,用15位计数器(2^15=32768,对应655μs)分频出1.5kHz,再用计数器选6路;
- 电机控制:直流电机用PWM调速,占空比精度要求不高,100kHz载波频率足够,用9位计数器(2^9=512)分频得97.6kHz;
- 状态机主控:所有决策基于10ms节拍(即100Hz),用17位计数器(2^17=131072≈131ms)分频出精确10ms脉冲。
这些分频器全部由同一个50MHz时钟驱动,但各自独立计数,互不干扰。关键技巧在于:所有跨时钟域信号必须两级寄存器同步。比如按钮消抖后的btn_valid信号要送入10ms节拍模块,必须先经sync_reg1、sync_reg2两级DFF打拍,否则亚稳态会导致状态机误跳转。我曾因此卡了三天,最后用SignalTap抓波形才发现btn_valid在节拍边沿附近出现毛刺——这就是FPGA开发最真实的“玄学”时刻。
3. 核心模块详解:从Verilog代码到物理动作的每一行注释
3.1 按钮消抖模块:为什么20ms是黄金阈值?
机械按钮抖动持续时间通常为5~20ms,低于10ms可能滤不净,高于30ms会让用户感觉“按键迟钝”。我们取20ms作为消抖窗口,对应50MHz时钟下的1,000,000个周期。代码核心逻辑如下:
module btn_debounce ( input clk, input rst_n, input btn_in, // 原始按钮信号(低电平有效) output reg btn_out // 消抖后稳定信号 ); reg [19:0] cnt; // 20位计数器,最大值1,048,575 > 1,000,000 reg btn_sync1, btn_sync2; // 同步寄存器链 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin btn_sync1 <= 1'b1; btn_sync2 <= 1'b1; cnt <= 20'h0; btn_out <= 1'b1; end else begin // 第一级同步:将异步btn_in拉进clk域 btn_sync1 <= btn_in; btn_sync2 <= btn_sync1; // 当btn_sync2稳定为低(按键按下),启动计数 if (btn_sync2 == 1'b0) begin if (cnt < 20'hF4240) // 1,000,000 decimal cnt <= cnt + 1'b1; else btn_out <= 1'b0; // 计满后输出低电平 end else begin // 按键释放,计数清零,输出高电平 cnt <= 20'h0; btn_out <= 1'b1; end end end endmodule这段代码的精妙之处在于:它不依赖外部延时芯片,纯数字逻辑实现;计数器复位与输出更新严格绑定在btn_sync2状态变化上,避免了“计数中途按键释放导致输出异常”的经典bug。我实测过,同一颗按键在不同温度下抖动时间略有差异,但20ms阈值覆盖了99%的商用按钮。如果你用的是轻触开关(tactile switch),建议把20'hF4240换成20'hC3500(约80万周期,对应16ms),手感更跟手。
3.2 状态机设计:四状态循环如何覆盖所有工况?
电梯控制器本质是一个有限状态机(FSM),我采用**独热编码(One-Hot)**而非格雷码,原因很实在:Spartan-6 FPGA的LUT资源足够,独热码让状态转换条件一目了然,且综合后时序更优。四个核心状态定义如下:
| 状态名 | 含义 | 进入条件 | 退出条件 | 输出动作 |
|---|---|---|---|---|
| IDLE | 空闲待命 | 上电复位或任务完成 | 任一有效请求到来 | 关门、停止电机、熄灭运行指示灯 |
| MOVING | 运行中 | target_floor != current_floor | 到达目标楼层(位置传感器触发) | 驱动电机、更新current_floor、点亮运行灯 |
| OPENING | 开门 | MOVING结束且door_open_time未到 | door_open_time计满(2s) | 打开电磁锁、启动开门电机、点亮开门灯 |
| CLOSING | 关门 | OPENING结束 | 门关闭传感器触发 或 超时(3s) | 关闭电磁锁、启动关门电机、熄灭开门灯 |
关键细节:
- 位置检测:用两个光电开关模拟楼层传感器。1楼开关触发时
pos_sensor_1==1,2楼触发时pos_sensor_2==1,轿厢在中间时两者均为0。状态机在MOVING态持续监测这两个信号,一旦任一为1,立即跳转OPENING。 - 超时保护:
OPENING和CLOSING都设硬超时。比如开门超时仍没收到传感器信号,强制进入CLOSING,防止门一直开着——这是安全规范硬性要求。 - 请求清除:只有当轿厢停稳且门完全打开后,才清除对应楼层的请求位。否则可能出现“2楼按了按钮,轿厢到2楼开门瞬间,1楼又按按钮,系统误判为新请求”的逻辑漏洞。
3.3 数码管动态显示:如何用Verilog手搓“人眼看不见的闪烁”
6位共阴极数码管,每位8段(a~g+dp),需6个位选信号(dig_sel[5:0])和1个段选信号(seg_data[7:0])。动态扫描原理是:快速轮询每一位,每次只亮一位,靠视觉暂留形成“全亮”假象。刷新率必须>60Hz,否则肉眼可见闪烁。
我的扫描模块用15位计数器分频出1.5kHz,再用3位计数器scan_cnt[2:0]循环0~5,每周期选一位:
always @(posedge clk_1k5 or negedge rst_n) begin if (!rst_n) begin scan_cnt <= 3'h0; dig_sel <= 6'h3F; // 全灭 seg_data <= 8'h00; end else begin scan_cnt <= scan_cnt + 1'b1; case (scan_cnt) 3'h0: begin dig_sel <= 6'hFE; seg_data <= floor_to_seg(current_floor); end // 选第0位(最左) 3'h1: begin dig_sel <= 6'hFD; seg_data <= req_to_seg(up_req, down_req); end // 第1位:上行请求 3'h2: begin dig_sel <= 6'hFB; seg_data <= req_to_seg(up_req, down_req); end // 第2位:下行请求 3'h3: begin dig_sel <= 6'hF7; seg_data <= state_to_seg(state); end // 第3位:当前状态 3'h4: begin dig_sel <= 6'hEF; seg_data <= motor_dir_to_seg(motor_dir); end // 第4位:电机方向 3'h5: begin dig_sel <= 6'hDF; seg_data <= door_state_to_seg(door_state); end // 第5位:门状态 endcase end end这里有个隐藏技巧:不同位显示内容更新时机不同。current_floor每10ms更新一次,state每状态跳转时更新,但dig_sel和seg_data必须在每个扫描周期内稳定输出。所以我把所有转换函数(如floor_to_seg)写成纯组合逻辑,避免时序延迟导致某一位显示错乱。实测下来,用1.5kHz扫描,人眼完全无法察觉闪烁,且FPGA功耗比静态显示降低70%。
3.4 电机与门控驱动:PWM与继电器的务实选择
FPGA IO驱动能力有限(Spartan-6单IO最大24mA),无法直接驱动电机或电磁锁。我采用IO→三极管→继电器→负载的经典方案:
- 电机方向控制:用两个IO口
motor_dir_a、motor_dir_b控制H桥驱动芯片(如L298N),10=正转(上行),01=反转(下行),00/11=刹车; - 电机使能:
motor_en接PWM信号,占空比0~100%调节速度,避免突启突停; - 门控:
door_open、door_close分别控制两路继电器,开门时吸合电磁锁并启动开门电机,关门时切断电源并施加反向力。
PWM生成代码极其简洁:
reg [9:0] pwm_cnt; reg [9:0] pwm_duty; // 占空比寄存器,0~1023对应0~100% wire pwm_out; always @(posedge clk_100k or negedge rst_n) begin if (!rst_n) begin pwm_cnt <= 10'h0; end else begin pwm_cnt <= pwm_cnt + 1'b1; end end assign pwm_out = (pwm_cnt < pwm_duty) ? 1'b1 : 1'b0;pwm_duty值由状态机根据运行阶段设定:MOVING初期设为30%(缓启),中段升至80%(高速),临近目标楼层降至20%(缓停)。这种分段PWM,比固定占空比运行平稳得多,轿厢停靠精度从±5cm提升到±1cm。
4. 实操全流程:从Quartus II新建工程到板载验证的踩坑实录
4.1 工程创建与约束配置:.qsf文件里藏着多少“隐形规则”
在Quartus II 18.1中新建工程,关键步骤如下:
- 器件选型:Project → Device → Family选“Spartan-6”,Device选“XC6SLX4-2CSG225C”(黑金AX301标配);
- 引脚约束:必须编辑
.qsf文件,而非GUI界面。因为GUI有时会漏配某些特殊IO(如JTAG调试口)。我的核心约束片段如下:
# 按钮(低电平有效) set_location_assignment PIN_A11 -to btn_up_1 set_location_assignment PIN_B11 -to btn_down_1 set_location_assignment PIN_C11 -to btn_up_2 set_location_assignment PIN_D11 -to btn_down_2 # 数码管段码(共阴极,高电平点亮) set_location_assignment PIN_E12 -to seg_a set_location_assignment PIN_F12 -to seg_b set_location_assignment PIN_G12 -to seg_c set_location_assignment PIN_H12 -to seg_d set_location_assignment PIN_J12 -to seg_e set_location_assignment PIN_K12 -to seg_f set_location_assignment PIN_L12 -to seg_g set_location_assignment PIN_M12 -to seg_dp # 位选(低电平选中) set_location_assignment PIN_A12 -to dig_0 set_location_assignment PIN_B12 -to dig_1 set_location_assignment PIN_C12 -to dig_2 set_location_assignment PIN_D12 -to dig_3 set_location_assignment PIN_E13 -to dig_4 set_location_assignment PIN_F13 -to dig_5 # 电机与门控 set_location_assignment PIN_G13 -to motor_dir_a set_location_assignment PIN_H13 -to motor_dir_b set_location_assignment PIN_J13 -to motor_en set_location_assignment PIN_K13 -to door_open set_location_assignment PIN_L13 -to door_close注意:
dig_x位选信号必须配置为低电平有效,因为共阴极数码管的位选端接阴极,低电平时该位导通。若配成高电平,所有位永远不亮。这是我第一次烧写失败的根本原因——查了三小时波形,最后发现是.qsf里写反了极性。
4.2 仿真验证:用Testbench抓住90%的逻辑错误
写完代码绝不直接上板!必须先用ModelSim做功能仿真。我的testbench核心思路是:用$readmemb读入预设波形文件,模拟真实操作序列。例如btn_wave.txt内容如下:
00000000 00000001 // 1楼按↑ 00000001 00000001 00000000 // 松开 00000000 00000000 00000010 // 2楼按↓ ...testbench代码加载该文件,并在每个时钟周期赋值给btn_up_1等信号。这样能精准复现“快速连按”、“长按不放”、“多键并发”等边界场景。我曾发现一个致命bug:当1楼按↑后立即2楼按↓,状态机竟跳过MOVING直接OPENING——原因是请求寄存器更新与状态机采样存在1个时钟周期竞争。解决方案是在状态机里加一级寄存器缓存请求信号,用req_latched替代直连up_req。
4.3 板载调试:SignalTap II如何成为你的“电子显微镜”
Quartus自带SignalTap II逻辑分析仪,是FPGA调试神器。配置要点:
- 采样时钟:必须选系统主时钟(50MHz),不能选分频后时钟,否则采样点不准;
- 触发条件:设置
state == MOVING && pos_sensor_1 == 1,抓取轿厢到达1楼瞬间的信号; - 信号深度:至少4096点,确保捕获完整动作周期(开门→停稳→关门);
- 探针插入:在Verilog代码中用
(* chip_pin = "PIN_A11" *)语法标记关键信号,避免手动连线出错。
我用SignalTap抓到过最诡异的问题:door_open信号在OPENING态本应持续2s,但波形显示只维持了1.2s就跳变。排查发现是door_open_time计数器在OPENING入口处未清零,导致计时起点偏移。这种问题仿真里根本看不到,只有上板实测才能暴露。
4.4 常见问题速查表:那些让你怀疑人生的报错与现象
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 按钮无响应 | .qsf引脚约束错误;按钮硬件虚焊;消抖模块未使能 | 用万用表测按钮两端电压;SignalTap抓btn_in原始信号 | 重新检查.qsf;更换按钮;确认rst_n已释放 |
| 数码管全暗 | dig_sel全高(未选中任何位);seg_data全0;扫描时钟未生成 | SignalTap抓dig_sel、seg_data、clk_1k5 | 检查扫描计数器是否溢出;确认段码转换函数返回非零值 |
| 轿厢运行抖动 | PWM载波频率过低;电机驱动电路接触不良;位置传感器灵敏度不足 | 示波器测motor_en波形;观察传感器LED是否稳定亮灭 | 将PWM频率提至100kHz;加固继电器焊点;调整传感器距离 |
| 开门后不关门 | CLOSING态超时值设太小;门关闭传感器失效;door_close信号未输出 | SignalTap抓door_state、door_close、pos_sensor_door | 增大close_timeout计数值;用万用表测传感器通断;检查继电器线圈电压 |
| 状态机卡死 | 异步复位释放过快;未覆盖所有状态转移;亚稳态未处理 | ModelSim查看状态变量波形;检查case语句default分支 | 加长rst_n低电平持续时间;补全所有next_state;关键信号两级同步 |
特别提醒:“17.1 error: failure to obtain a verilog simulation license”这个报错,本质是ModelSim许可证过期。免费版ModelSim-Altera Starter Edition仅支持1000行代码以下仿真。解决方案有两个:一是用Icarus Verilog(开源,命令行友好),二是把testbench拆成多个小模块分别仿真。我当年就是用Icarus,配合GTKWave波形查看器,效率反而更高。
5. 进阶延伸与经验沉淀:从课堂作业到真实工程的思维跃迁
5.1 如何把“两层楼”升级为“四层楼”?——架构不变,只改三处
这个项目最大的价值,不在于完成两层控制,而在于其可扩展性设计。当我把系统升级到四层楼时,只改动了三处:
- 请求寄存器:
up_req[3:0]、down_req[3:0],增加bit2(3楼↑)、bit3(4楼↑/↓); - 状态机:在
MOVING态增加楼层间距离计算,abs(target-floor)>1时启用“加速-匀速-减速”三段式PWM,避免长距离运行过慢; - 数码管显示:第1、2位显示当前楼层,第3、4位显示目标楼层,第5、6位显示运行方向(↑/↓),用
seg_data查表实现。
没有重构,没有推倒重来,所有原有模块(消抖、扫描、PWM)无缝复用。这印证了一个真理:好的数字系统设计,扩展成本应该远低于初始开发成本。很多同学喜欢一上来就写“通用n层电梯”,结果状态机分支爆炸,debug到崩溃。不如先吃透两层,再以模块为单位叠加。
5.2 真实电梯里的“隐藏需求”:教学项目与工业产品的鸿沟
课堂设计往往忽略安全冗余。真实电梯必须满足:
- 双传感器校验:每个楼层用两个独立光电开关,信号一致才认定到位,防止单点失效;
- 紧急停止:独立硬件电路,不经过FPGA,按下立即切断电机电源;
- 故障自检:上电时自动检测所有传感器、按钮、门锁状态,异常则蜂鸣报警并锁定运行。
我在课程设计里加了简易自检:上电后IDLE态持续5s,期间若任一传感器常亮或常灭,err_led闪烁。虽不及工业标准,但已触及可靠性设计的核心——故障检测比故障恢复更重要。
5.3 我的三个血泪教训:写在最后的真心话
- 不要迷信“仿真通过=板子OK”。我曾在一个周五下午仿真全绿,兴冲冲烧写,结果板子毫无反应。折腾到深夜才发现,开发板USB供电不足,电机启动时电压跌落导致FPGA复位。换用外置5V/2A电源后一切正常。——硬件调试,永远从供电开始。
- Verilog里没有“理所当然”。比如
always @(posedge clk)块里,若对reg型变量既读又写,综合器可能生成锁存器(latch),造成不可预测行为。务必养成习惯:所有reg变量在always块内必须有完整赋值(每个分支都赋值),或明确声明为wire。 - 文档比代码重要十倍。我给自己写的每个模块都配了
.md说明:输入/输出信号列表、时序图、典型波形截图、已知限制。两年后重看项目,5分钟就能找回所有逻辑。而那些没写文档的同学,现在还在翻旧邮件找pwm_duty的默认值。
这个两层电梯控制器,是我FPGA生涯的“成人礼”。它不炫技,不浮夸,就用最朴素的Verilog,解决最实在的物理世界问题。当你亲手让一个轿厢在两层楼之间平稳往返,看着数码管上数字跳动,听着电机嗡鸣渐起又渐息——那一刻,代码不再是屏幕上的字符,而是真实世界的脉搏。