简介:这是基于Verilog的两层简易电梯控制系统设计工程,面向FPGA课程设计、数字逻辑实验或Vivado实战练习,解决电梯呼叫、楼层判断、状态显示及附加蜂鸣器、流水灯等功能的完整实现。设计定义4个按键(KEY0~KEY3)和4个指示灯(LED0~LED3),分别处理1楼外上呼、2楼外下呼及轿厢内楼层选择,并以两个数码管分时显示运行状态与当前楼层,代码结构适合学习状态机、按键消抖和数码管驱动。资源共112个文件,压缩包约1.86MB,包含.v源码、.xdc约束、.dcp综合结果、.bit比特流及完整Vivado工程,并附rpt、log、jou等实现报告,可直接打开工程复用或烧录验证,目录结构清晰。目前已有2249人学习下载。通过研读工程可独立掌握电梯控制的完整设计思路,借助bit/dcp快速上板验证,结合报告还能排查时序与综合环节的常见问题,是一款值得参考的FPGA课设资源。
1. Verilog简易电梯控制系统设计:两层状态机为什么是最考验基础功的一道题
Verilog简易电梯控制系统设计看起来就是实验课上的小题目:两层楼,上下两个按钮,再加一个蜂鸣器和一组流水灯当附加分。可真要在FPGA板上把整个系统调通就会发现,把“两层”写清楚比照抄一个四层电梯例程难得多:楼层少,意味着每次呼叫的优先级、到站停车、开门计时都要考虑得很细,否则按一次按钮电梯就来回跑,蜂鸣器该响的时候不响,流水灯闪得人眼晕。这篇文章围绕两层的核心状态机展开,把蜂鸣器驱动、流水灯联动和仿真上板的完整流程都拆开讲,适合正在做课程设计、竞赛作品或者刚入门Verilog的人直接照着搭。
2. 电梯主体逻辑:用Verilog case搭建两层调度与电机驱动控制
做两层电梯,最关键的部分不是电机驱动,也不是LED显示,而是那一套“当前在哪层、要去哪层、门开多久”的状态机。楼层少反而对状态设计更苛刻,因为每个转移条件都会直接被按键和到位传感器检验到。我一般会先把状态和转移条件画在一张表上,确认没有遗漏,再动笔写Verilog。
2.1 两层电梯最少需要几个状态:先画一张转移表
两层电梯的轿厢只有两个物理位置,但控制器不能只记两个bit。因为电机从上电到真正刹停需要时间,开门也需要持续时间,过程状态不能省略。最小可运行方案用四个状态:待机、上行、下行、开门等待。
| 状态 | 含义 | 典型输出 |
|---|---|---|
| ST_IDLE | 轿厢停在当前楼层,等新的呼叫 | 电机停止,门闭合 |
| ST_UP | 电机正转,朝2F运行 | motor_up=1,motor_down=0 |
| ST_DOWN | 电机反转,朝1F运行 | motor_up=0,motor_down=1 |
| ST_OPEN | 到达目标楼层,开门等乘客 | 电机停止,蜂鸣器使能 |
如果要求严格模拟电梯门,ST_OPEN还要再细分成“正在开门、门全开等待、正在关门”三个阶段。但课程设计里通常用延时代替,把开门时间固定成500ms或者1s,状态机回到待机。这样写出来的代码交到仿真和上板都容易验证。
状态编码这里我建议用localparam而不是直接写01、10这些数字。理由是case分支一多,魔法数最容易把状态转移写错,而且综合工具会重新选编码,没必要在手写代码里做无谓优化。
// 四个状态的定义,放在模块内部 localparam ST_IDLE = 2'b00; localparam ST_UP = 2'b01; localparam ST_DOWN = 2'b10; localparam ST_OPEN = 2'b11;这里用2bit编码,刚好能容纳四个状态。如果后面要加“开门中”“关门中”这些过程状态,就扩到3bit,localparam会自动适配宽度。注意todo数别写错:ST_UP是上行,ST_DOWN是下行,这两个命名的可读性直接决定你三天后再看代码还能不能改得动。
2.2 用Verilog case写核心状态机:三段式 always 与 default 分支的作用
电梯主控我习惯写成三段式:第一个always负责时序寄存,第二个always负责组合逻辑决定下一个状态,第三个always负责输出。组合逻辑这一段全部用case展开,每种状态一个分支,分支没有覆盖到的场景用default兜底。
module elevator_core( input wire clk, // 系统时钟 50MHz input wire rst_n, // 异步复位,低有效 input wire req_1f, // 1F上行请求,已去抖且已取沿 input wire req_2f, // 2F下行请求,已去抖且已取沿 input wire arrive_1f, // 1F到位传感器 input wire arrive_2f, // 2F到位传感器 output reg motor_up, // 电机正转 output reg motor_down, // 电机反转 output reg beep_en, // 蜂鸣器使能 output reg door_lock // 开门状态标志 ); localparam ST_IDLE = 2'b00; localparam ST_UP = 2'b01; localparam ST_DOWN = 2'b10; localparam ST_OPEN = 2'b11; reg [1:0] state, next_state; reg cur_floor; // 0表示停在1F,1表示停在2F reg [31:0] open_timer; // 开门等待计数器 localparam OPEN_TICKS = 25_000_000; // 500ms @ 50MHz // 时序状态更新 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= ST_IDLE; cur_floor <= 1'b0; open_timer<= 32'd0; end else begin state <= next_state; if (arrive_1f) cur_floor <= 1'b0; if (arrive_2f) cur_floor <= 1'b1; if (state == ST_OPEN) begin if (open_timer < OPEN_TICKS) open_timer <= open_timer + 1'b1; else open_timer <= 32'd0; end else begin open_timer <= 32'd0; end end end // 组合逻辑:决定下一个状态 always @(*) begin next_state = state; // 默认保持 case (state) ST_IDLE: begin if (cur_floor == 1'b0 && req_1f) next_state = ST_UP; else if (cur_floor == 1'b1 && req_2f) next_state = ST_DOWN; end ST_UP: begin if (arrive_2f) next_state = ST_OPEN; end ST_DOWN: begin if (arrive_1f) next_state = ST_OPEN; end ST_OPEN: begin if (open_timer >= OPEN_TICKS) next_state = ST_IDLE; end default: next_state = ST_IDLE; endcase end // 输出逻辑 always @(*) begin motor_up = 1'b0; motor_down = 1'b0; beep_en = 1'b0; door_lock = 1'b0; case (state) ST_UP: motor_up = 1'b1; ST_DOWN: motor_down = 1'b1; ST_OPEN: begin beep_en = 1'b1; door_lock = 1'b1; end endcase end endmodule这里把关键逻辑都写在组合逻辑case里了,时序块只负责状态寄存和计数器。next_state在分支开头先默认等于state,这样每个分支不需要写完整的else语句,即使漏写某一条转移条件,状态也会保持而不是被综合成锁存器。这是Verilog case在状态机里最常见的基本功,漏掉default或者漏写默认赋值,工具会报latch warning,上板后表现就是“某个状态进去出不来”。
cur_floor的更新让电梯知道自己当前在几层。两个到位传感器同时为高的场景在真实电梯里不可能出现,但如果仿真里给了这种激励,上面代码会让cur_floor最终变成1F或者2F取决于if顺序。想要更严谨可以写成case或者加priority,课程设计里这点优先级不致命。
这段代码里没有处理“电梯在1F而2F有人按下行请求”的情况,因为两层校园简易电梯通常只把按钮放在当前楼层,作为简化边界处理。如果项目要求跨层派梯,就得引入目标楼层寄存器和pending标志,复杂度会明显上升,我会在避坑章单独讲。
2.3 按键不能直接进状态机:计数器去抖与滑动窗口思路
机械按键按下到稳定导通,中间有5ms到20ms的抖动。50MHz时钟下这个抖动就是几十万个周期,直接把按键电平送进状态机,一次按压会产生大量上升沿,电梯会连续触发多个请求。最稳的做法是先在按键后面加一级去抖模块,再把“确认后的电平”取一次上升沿,作为状态机的请求信号。
常见做法是计数器去抖:输入与上次确认值不一致时清零计数,连续一致超过设定时间就更新确认值。这个思路本质上就是滑动窗口滤波,窗口长度内多数采样值一致才采纳,对短毛刺和接触抖动都有效。
module debounce #( parameter CNT_MAX = 1_000_000 // 50MHz下约20ms )( input wire clk, input wire rst_n, input wire key_in, output reg key_clean ); reg [19:0] cnt; reg key_sync; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 20'd0; key_sync <= 1'b0; key_clean <= 1'b0; end else begin key_sync <= key_in; // 先把异步按键同步进时钟域 if (key_sync != key_clean) begin // 输入变化了,重新计时 cnt <= 20'd0; key_clean <= key_sync; end else if (cnt < CNT_MAX) begin cnt <= cnt + 1'b1; end end end endmodule这个模块里key_clean不是按键的实时电平,而是“连续稳定超过20ms之后的确认电平”。仿真时观察key_clean,会发现它比key_in晚了约20ms,这正是期望行为。上板测的时候不要以为是延迟bug。
确认电平之后还要取一次上升沿,才送给电梯状态机:
reg key_clean_d; wire key_pulse = key_clean & ~key_clean_d; always @(posedge clk or negedge rst_n) begin if (!rst_n) key_clean_d <= 1'b0; else key_clean_d <= key_clean; endkey_pulse只会拉高一个时钟周期,所以状态机不会因为按键一直按住而反复触发。注意req_1f和req_2f在进状态机前都要经过这一套同步、去抖、取沿,三个步骤缺一个都会在后面排查时花掉好几小时。
3. 蜂鸣器附加项:从SS8050驱动电路原理图到Verilog分频鸣响
蜂鸣器在很多课程设计里是最后才加的功能,结果也是最容易卡住的一环。问题往往不在Verilog,而在硬件:拿到的蜂鸣器是有源还是无源、驱动电路的基极电阻选多大、FPGA引脚能不能直接带负载。这三个问题不解决,代码再对也不响。
3.1 有源蜂鸣器和无源蜂鸣器别接错:驱动电路与三极管电阻选型
先把两种蜂鸣器的区别说透,这是硬件选型的第一道关:
| 类型 | 内部振荡器 | 控制信号 | Verilog侧写法 | 声音特点 |
|---|---|---|---|---|
| 有源蜂鸣器 | 有,上电自振 | 高电平就响 | 输出电平开关 | 声音固定,音量偏大 |
| 无源蜂鸣器 | 无,需要外部方波 | 2kHz到4kHz方波 | 需要分频器产生方波 | 频率可调,可做不同提示音 |
有源蜂鸣器内部有振荡电路,给高电平就发声,Verilog只要输出beep_en电平即可。但它的缺点是只能发单音,而且长时间通电容易发热。无源蜂鸣器内部没有振荡源,必须给方波才能响,常见做法是产生2kHz到4kHz的频率,频率越接近蜂鸣器自身谐振点,声音越响。
无论哪种蜂鸣器,工作电流都在20mA到40mA左右,FPGA引脚直接驱动能力往往不够,所以要用三极管做开关。最常用的就是SS8050这类NPN三极管,电路原理图可以概括成这样:FPGA引脚通过1k到4.7k的电阻接到三极管基极,发射极接地,集电极接蜂鸣器负极,蜂鸣器正极接VCC,蜂鸣器两端反向并联一个IN4148二极管吸收关断尖峰。
基极电阻阻值不是随便选的。假设VCC是3.3V,蜂鸣器工作电流30mA,SS8050的放大倍数按100估算,基极电流至少需要0.3mA。基极电阻上限是(3.3-0.7)/0.3mA,算出来约8.7k,所以1k到4.7k都可靠。如果用了100k电阻,三极管进入不了饱和区,蜂鸣器只会发出很弱的吱吱声甚至完全无声,这是驱动电路里最常见的翻车点。
无源蜂鸣器的接线和有源一致,区别只在驱动信号:有源给直流电平,无源给方波。上板前先用万用表确认蜂鸣器类型,标签上写的是“有源”“自激”还是有源型号后缀,别靠听声音猜。
3.2 Verilog生成驱动方波:分频器与可调频率参数
无源蜂鸣器需要外部方波,这一步要在Verilog里做分频。假设系统时钟50MHz,蜂鸣器额定频率2kHz,那么一个完整周期是500us,50MHz下周期数就是25000,所以半周期计数值是12500。
分频器代码我一般写成带参数的形式,这样换蜂鸣器不用改逻辑,只改参数即可:
module buzzer_driver #( parameter CLK_FREQ = 50_000_000, parameter BUZZ_FREQ = 2_000, parameter BUZZ_MS = 500 )( input wire clk, input wire rst_n, input wire beep_en, // 电梯状态机给的鸣响使能 output reg buzzer_out // 接SS8050基极 ); localparam DIV_MAX = CLK_FREQ / (BUZZ_FREQ * 2) - 1; localparam DUR_MAX = CLK_FREQ / 1000 * BUZZ_MS / (CLK_FREQ / BUZZ_FREQ) - 1; reg [31:0] div_cnt; reg [31:0] dur_cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin div_cnt <= 32'd0; dur_cnt <= 32'd0; buzzer_out<= 1'b0; end else if (beep_en) begin // 方波分频 if (div_cnt >= DIV_MAX) begin div_cnt <= 32'd0; buzzer_out <= ~buzzer_out; end else begin div_cnt <= div_cnt + 1'b1; end // 鸣响时长计数 if (dur_cnt < DUR_MAX) dur_cnt <= dur_cnt + 1'b1; else begin buzzer_out <= 1'b0; end end else begin div_cnt <= 32'd0; dur_cnt <= 32'd0; buzzer_out <= 1'b0; end end endmoduleDIV_MAX根据CLK_FREQ和BUZZ_FREQ自动计算,半周期翻转一次buzzer_out,生成50%占空比的方波。DUR_MAX用来限制鸣响时长,BUZZ_MS设500就响半秒后自动停。这样电梯开门期间beep_en一直拉高,蜂鸣器也不会响个没完。
这里有个Verilog参数的踩坑点:BUZZ_MS改大改小都会影响DUR_MAX,但如果你把DUR_MAX写死在代码里,换了一个鸣响时长需求就要改两处。把计算式放在localparam里,后调参数时只需要动一个数,这就是参数递增思路在模块化设计里的实际应用。
有源蜂鸣器不需要这个分频模块,直接用电梯核心输出的beep_en电平经三极管驱动即可。不要为了“统一代码”把有源也接到方波上,那样会听到断断续续的怪声,而且长期方波驱动有源蜂鸣器会缩短寿命。
3.3 蜂鸣器和电梯联动:开门提示音只响一次的实现
蜂鸣器要从附加项变成可用功能,必须跟电梯状态联动。常见做法是到站开门时拉高beep_en,开门计时结束就拉低。上面的电梯核心代码里,ST_OPEN状态下beep_en被置1,这已经满足基本需求。
但如果你观察波形,会发现beep_en从ST_OPEN开始一直保持到open_timer结束,蜂鸣器会连续响满500ms。有些项目想要“嘀一声”而不是长鸣,那就得在蜂鸣器模块里做单次触发。最简单办法是让电梯核心输出一个beep_pulse,只维持一个周期,蜂鸣器模块内部用自己的计时器决定响多长时间。
另一种做法是在ST_OPEN进入时用边沿触发一个状态标志:电梯核心每次从ST_UP或ST_DOWN转回ST_OPEN,都把beep_en拉高一个时钟周期,蜂鸣器模块再根据BUZZ_MS自行计时。这个设计的好处是把“鸣响时长”从主状态机里剥离出去,主状态机只管开门,蜂鸣器细节全部下沉到自己的模块,后面调试也无干扰。
实际联调时,我习惯先单独给buzzer_driver一个常高电平的beep_en测试信号,确认蜂鸣器本身能响,再接回电梯总系统。否则蜂鸣器不响分不清是驱动电路问题还是beep_en没送过来,排查范围一下子从几十行代码变成了整个工程。
4. 流水灯附加项:移位寄存器与电梯运行方向联动的调法
流水灯在电梯项目里的定位通常是运行方向指示:上行时灯光从左往右流,下行时从右往左流,停在某层时流水灯停止。这个功能的Verilog实现不难,难在跟电梯状态联动时容易把多写一个always块,导致综合器报多驱动错误。
4.1 循环移位的最小代码:单个always块驱动LED
流水灯本质就是一个移位寄存器。用一组长位宽的reg保存LED状态,每个时钟周期移动一位。为了让肉眼看到移动效果,不能每个周期都移位,要加一个分频计数器。
module flow_led #( parameter LED_NUM = 8, parameter DIV_MAX = 12_500_000 // 50MHz下250ms移一位 )( input wire clk, input wire rst_n, input wire shift_en, // 电梯运行期间才移位 input wire dir_up, // 1上行左移,0下行右移 output reg [LED_NUM-1:0] led ); reg [31:0] tick; reg dir_reg; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin tick <= 32'd0; dir_reg <= 1'b1; led <= {LED_NUM{1'b0}}; led[0] <= 1'b1; // 复位后只有最右侧亮 end else if (shift_en) begin dir_reg <= dir_up; if (tick < DIV_MAX) begin tick <= tick + 1'b1; end else begin tick <= 32'd0; if (dir_reg == 1'b1) led <= {led[LED_NUM-2:0], led[LED_NUM-1]}; // 循环左移 else led <= {led[0], led[LED_NUM-1:1]}; // 循环右移 end end end endmodule循环左移的写法是把最高位拼到最低位,其余位依次左移;循环右移反过来。这里dir_reg在移位发生时取的是上一拍的方向值,所以实际运动的建立需要等一个时钟周期。这个延迟对LED人眼感知完全没影响,但仿真时看到方向切换有一拍延迟是正常的,不要当成bug去改。
复位时把led[0]置1,其他位置0,这是为了上电后第一眼就能看到流水灯有且只有一个亮点。如果复位时不初始化,LED在上板瞬间可能是随机状态,给人感觉像坏了。
4.2 方向映射:上行左移、下行右移的组合逻辑
flow_led只负责移位,direction信号从哪来?从电梯主控的状态输出里拿。电梯核心里有motor_up和motor_down,直接把这两个信号映射成dir_up和shift_en:
assign shift_en = motor_up | motor_down; assign dir_up = motor_up;电机上行时shift_en为高,方向为1,流水灯左移;电机下行时同样有shift_en,方向为0,右移;电梯停在ST_IDLE或ST_OPEN时shift_en为0,灯静止。
这块组合逻辑可以在顶层模块里写,也可以在flow_led模块内部通过输入端口完成。我建议放在顶层,因为电梯核心和流水灯模块之间要保持信号方向单一,不要让flow_led反向控制电梯主逻辑。
有一个容易忽略的细节:shift_en拉高后,流水灯是等tick计数满才移一位,不是shift_en一变就立刻换灯。所以从方向切换的电机输出到第一个LED滑动,两者之间会有最多DIV_MAX个时钟周期的延迟。如果项目要求方向变化时流水灯立即反向,可在组合逻辑中单独传一个sync_reset信号给流水灯模块,复位tick计数,这样方向切换的响应会更快。
4.3 闪烁速率和复位初值:三个参数要一起调
流水灯速度不合适的案例见过很多。DIV_MAX设成25_000_000,250ms移一位,8个灯走完一圈是2s;设成2_500_000就是25ms移一位,一圈只要200ms,视觉上就会感觉是在闪烁而不是流动。
调整参数时记住一个基准公式:LED_NUM个灯,每秒移位数 = CLK_FREQ / DIV_MAX。想要一圈耗时T秒,DIV_MAX = CLK_FREQ / LED_NUM * T。如果DIV_MAX写小了一个数量级,比如把12_500_000少写一个0变成1_250_000,8个灯一圈只花200ms,LED会有明显的频闪感,这就是参数多零少零的坑。
复位初值也要同步考虑。上面代码led[0]初始置1,方向是左移,第一个动作是亮点从右往左跑。如果实验室流水灯硬件焊接顺序是从左到右编号,这个初始方向和丝印方向相反,只需把init置1的位改成led[LED_NUM-1]就行。
最忌讳的是在多个always块里直接给同一个led寄存器赋值。有人为了“上行亮一段、下行亮一段”写了两个always块,综合时工具直接报multi-driven net error,即使不报,上板也没有一盏灯能按预期亮。流水灯的状态只有一个,就应该只在一个时序块里维护,方向、使能都作为输入信号送进来。
5. 避坑与常见问题:两层电梯上板前最容易踩的五类坑
这个项目代码量不大,但每个模块单独测都正常,合在一起容易出现各种奇怪现象。下面按调试优先级列出我实际遇到过的问题,每条都按现象、原因、解决三步写清楚。
5.1 按一次按钮电梯来回跑:请求没有边沿化
现象:在1F按上行按钮,电机上行到2F开门后立刻转入下行,又回到1F,如此反复。仿真里也能看到同一个请求被状态机使用了两次。
原因:按键去抖模块输出了干净的key_clean电平,但状态机直接用这个电平作为req信号。只要按钮还按着,req就一直为高,状态机每次回到ST_IDLE都会再次判断条件成立,于是把同一次按压当成了两次请求。
解决:在去抖模块输出后必须取上升沿,生成单周期的key_pulse信号,再送给电梯核心。我通常把同步、去抖、取沿三段逻辑全部封装成一个小模块,顶层只面对key_pulse,状态机只看脉冲信号,这个问题就彻底消失。取沿的写法是上一节给的key_clean & ~key_clean_d,千万别漏了中间那一拍延迟。
5.2 蜂鸣器不响或长鸣:有源、无源混用和驱动电流不足
现象:一种表现是上电后蜂鸣器一直长鸣,不受beep_en控制;另一种是beep_en已经拉高,但蜂鸣器只发出细微沙沙声甚至完全无声。
原因:长鸣大概率是硬件接成了有源蜂鸣器,而Verilog里把它当无源驱动,输出的是分频方波。有源蜂鸣器只要接到高电平的方波正半周期就会响,所以听感上是断断续续或者持续响。不响则多半是三极管基极电阻太大,或者蜂鸣器额定频率和Verilog产生的频率差太多,比如你把BUZZ_FREQ设成20kHz,而蜂鸣器是2.7kHz谐振,音量会衰减到几乎听不见。
解决:先确认手头硬件是有源还是无源。有源蜂鸣器直接输出beep_en电平;无源蜂鸣器必须用buzzer_driver的分频方波。基极电阻控制在1k到4.7k,SS8050的驱动能力足够支撑这类小蜂鸣器。上板前用示波器或者逻辑分析仪量一下buzzer_out在beep_en为高时有没有方波翻转,这是最快的定位方法。
5.3 流水灯常亮或快闪:多驱动与分频参数
现象:LED全部常亮,或者快速均匀闪烁,看起来不像流水更像频闪。综合时可能报出multi-driven相关警告。
原因:常亮通常是同一个led寄存器在两个always块里被赋值,综合工具最后选择了一个驱动源,或者运行结果完全不确定。快闪则是DIV_MAX太小,移位速度超过了人眼分辨极限。
解决:先改代码,确保led只在一个always块里被驱动。方向控制和使能控制全部作为输入信号,不要在外面直接assign led某个bit。快闪的参数按前面的公式重算,以50MHz为例,8个灯一圈4秒就要DIV_MAX约25_000_000,一圈2秒就是12_500_000。综合前打开warning信息,看到multi-driven字样再返工,别等上板再猜。
5.4 仿真全对上板就玄学:异步信号没有同步
现象:Modelsim或Vivado仿真里波形完美,按键、电机、蜂鸣器逻辑全对,一烧到板子就偶发误触发,按钮按十次有一两次电梯抽风。
原因:按键是异步信号,直接进入时钟驱动的always块时,违反了触发器的建立保持时间,产生亚稳态。仿真模型理想化不会暴露这个问题,真实芯片里亚稳态会导致状态机跳到完全意外的分支。
解决:按键进FPGA后第一件事是接两级同步寄存器,再接去抖模块,不要直接进状态机。前面debounce模块里的key_sync就是一级同步,但我在顶层还会再加一级,确保跨时钟域信号先被稳定的采样到。复位按键也有同样问题,如果rst_n是用独立按键产生,上板前也要同步处理,否则复位信号的释放时刻可能离时钟沿太近,造成系统刚启动就跑飞。
5.5 模块例化出错:功能隔离和端口连接要注意
现象:电机正常,蜂鸣器无声,流水灯方向反了。单看每个子模块都正确,合起来就不对。
原因:顶层模块例化时端口连接出问题。常见是把beep_en接到了流水灯的移位使能上,或者把motor_down当成dir_up传给流水灯。用位置端口连接时,端口顺序一旦调整,编译可能通过但功能完全错乱。
解决:顶层例化全部用显式端口映射,写成.port_name(signal)格式,不依赖模块定义时的端口顺序。这点在verilog模块例化里属于基本规范,但对这个项目格外重要,因为三个子模块的端口名相似度极高,beep_en和shift_en都是enable,dir_up和motor_up都带up。例化完成后在仿真里分别观察每个输出端口,确认buzzer_out有方波、led在电机运行时才移位。
5.6 状态编码的毛刺:格雷码与one-hot的取舍
现象:状态从ST_UP跳到ST_OPEN时,motor_down瞬间闪了一下高电平,仿真波形里能看到一根很窄的毛刺,上板后表现为电机正反转继电器偶尔抖动。
原因:二进制编码里ST_UP是2‘b01,ST_OPEN是2’b11,只有一位变化,毛刺概率低;但如果状态编码是ST_UP=2‘b01,ST_DOWN=2’b10这种相邻跨越多位的设计,组合逻辑在状态跳变瞬间可能会经过不存在的中间状态,导致输出短暂误判。
解决:状态编码优先选三种方案之一:二进制且保证相邻状态只差一位,或者用格雷码,或者用one-hot。格雷码适合相邻状态连续转移的电梯状态机,从ST_UP到ST_OPEN相邻状态只差一位,从ST_DOWN到ST_OPEN也一样。one-hot以资源换稳定,四个状态用四个bit,综合后毛刺最少。课程设计里我倾向用one-hot,代码可读性和Debug体验都好很多,占用那点寄存器对FPGA来说完全可以忽略。
6. Testbench仿真到上板验证:两层电梯交付前的检查清单
仿真和上板验证分别覆盖两套诉求,这一章给一个能直接跑的最小testbench,再加一个上板检查清单。先看testbench:
module tb_elevator_top; reg clk, rst_n; reg btn_1f, btn_2f; wire motor_up, motor_down; wire beep_en; wire [7:0] leds; elevator_top dut( .clk(clk), .rst_n(rst_n), .btn_1f(btn_1f), .btn_2f(btn_2f), .motor_up(motor_up), .motor_down(motor_down), .beep_en(beep_en), .leds(leds) ); initial begin clk = 0; forever #10 clk = ~clk; // 50MHz end initial begin rst_n = 0; btn_1f = 0; btn_2f = 0; #100 rst_n = 1; // 场景:轿厢在1F,按1F上行请求 btn_1f = 1; #21_000_000; // 模拟按键稳定20ms以上 btn_1f = 0; #500_000_000; // 跑10ms*... $finish; end endmodule仿真验证重点看五件事:去抖模块的key_clean是否在20ms后才变为高;key_pulse是否只有一个周期;电梯从ST_IDLE转ST_UP后motor_up保持为高;流水灯方向为左移;buzzer_out在ST_OPEN期间有方波翻转。这五条全过,再上板。
上板后的检查顺序我从项目里学到的经验是:先测流水灯独立工作,再测蜂鸣器独立工作,最后接回电梯总系统。因为流水灯和蜂鸣器是附加项,出问题不影响电梯主逻辑,分开测能快速缩小故障范围。主逻辑先给独立按键测试,确认上下行和到位开门没问题,再把附加项接上。
我自己的习惯是上板前在testbench里多跑两组场景:一组是电梯在2F按2F下行请求,另一组是电梯运行中途突然按复位。第二组场景特别能暴露亚稳态和状态不初始化的问题,很多“板子会抽风”的故障都能在这个仿真里提前看到。如果复位后状态机没有回到ST_IDLE,说明状态变量没有可靠的复位初值,必须返工。
另外建议把DIV_MAX、OPEN_TICKS、BUZZ_MS这些时间参数全部用localparam集中放好,不要散落在多个模块里。改一处参数只影响一个功能,反过来找问题也方便。两层的电梯虽然小,但它把状态机、分频器、移位寄存器、跨时钟域处理这些FPGA基础能力完整串了一遍,这套方案跑通之后,再往四层、多按钮优先级调度方向扩展,就只是加状态和加case分支的体力活了。希望这片梳理能帮到你,少走我踩过的弯路。
本文还有配套的精品资源,点击获取