
一开始我其实是被一趟电梯“逼”上FPGA这条路的。每天上下课高峰期教学楼那部老电梯的调度逻辑实在磨人排队时我忍不住想这种控制逻辑如果换成我自己用数字电路实现一遍会怎么设计后来借着课程项目的契机我花了两周时间用VHDL在FPGA上把一个四层电梯控制器从零做了一套完整实现从状态机设计、按键仲裁、门控定时到仿真验证和上板调试全部跑通。这篇文章就是这个“VHDL实现之旅”的完整复盘。无论你是正在学数字逻辑/FPGA的新手还是对有限状态机到底能怎么落地的朋友这篇应该都能给你一些当下就能用的思路。项目本身不算复杂但麻雀虽小五脏俱全四个楼层、每层有上/下呼叫按钮、电梯轿厢内有点选楼层按钮、外部有楼层传感器和门限位开关输出侧要控制电机上行、下行还要驱动楼层显示和开关门信号。难点其实不在“电梯是什么”而在“电梯的行为怎么被翻译成一组离散状态和确定的转移条件”。1. 电梯控制器的核心问题多楼层请求下的调度仲裁1.1 从一次真实的乘梯行为里剥出控制需求我一开始犯过一个典型错误直接打开代码编辑器想写“电梯跑起来”。结果写了半小时就发现如果不先把需求定义成一张表VHDL写出来的东西根本没法综合。所以第一步必须先把外围接口和需求定死。以四层电梯为例我需要这些输入输出端口方向信号含义输入btn_up(3:0)每层的上行呼叫按钮位向量某位为1代表该层有人按上行输入btn_down(3:0)每层的下行呼叫按钮输入btn_cabin(3:0)轿厢内的目的楼层按钮输入floor_sensor(3:0)楼层平层传感器独热码哪一位为1就代表当前在哪层输入door_closed门完全关闭信号输出motor_up电机上行控制输出motor_down电机下行控制输出door_open_cmd开门命令输出floor_display(1:0)当前楼层两位二进制用于七段码解码这里有两个设计取舍值得说。第一楼层传感器用独热码而不是二进制编码是为了避免上电时不确定状态造成楼层误判。第二按钮信号没有做成“按一下产生一个脉冲”而是做成电平锁存因为电梯的呼叫请求本来就应该保持直到电梯真正到达该层。这些需求全部来自日常乘梯经验你要在楼道里按上行进轿厢后按目的楼层电梯到了会开门门关好后继续响应其他请求。没有任何一条是凭空发明的。1.2 调度仲裁策略方向优先顺路优先电梯调度算法的选择是整个项目的地基。我最后落地的策略是“方向优先 顺路优先”这是实际电梯里很常见的一种规则保持当前运行方向优先响应同方向前方楼层的请求如果当前方向前方没有请求再反向如果电梯处于空闲状态选择离当前楼层最近的请求楼层距离相同则默认向上。它的好处在于非常适合用寄存器阵列来表达。四个楼层只需要四个上行请求寄存器和四个下行请求寄存器再加一个轿厢请求寄存器仲裁逻辑就是对这些寄存器的扫描。我举个直观的例子。假设电梯在一层正向上运行去三层此时二层有人按下行呼叫。按照方向优先规则二层和三层同向电梯会在二层停一下完成下行乘客的上门服务这里其实是纯顺路逻辑——二层在三层之前且请求方向与运行方向同向所以先停二层。但如果二层乘客按的是上行呼叫呢那更加顺路先停二层再接上他继续前往三层。真正麻烦的场景是电梯在一层向上运行四层还没到一层此时又有人按下行呼叫。方向优先规则会要求先完成上方的任务等电梯返程时再停一层。这个决策用工程术语说就是“不中途折返”。为什么在硬件里特别强调不轻易折返因为每改变一次方向状态机都要经历“减速-停层-开门-关门-反向启动”这个过程电机和门机构都怕频繁换向乘客体验也会很糟糕。软件调度可以任意跳来跳去硬件控制器必须把每一次方向变化都当成一件需要确认的大事。1.3 最简仲裁电路怎么表达我最终没有用复杂的优先队列而是用了一个组合逻辑进程去扫描请求。核心思路是内部维护一组请求锁存信号例如up_req(3:0)、down_req(3:0)、cabin_req(3:0)每当电梯到达某一层并开门到位就把该层对应的请求位清除状态机进入 MOVE 状态前根据当前楼层、方向、请求位计算目标楼层。仲裁逻辑的伪码大致是这样如果当前方向为上行 从当前楼层向上扫描找到第一个存在上行请求、下行请求或轿厢请求的楼层作为目标 如果上方什么都找不到则方向取反向下找最近请求 如果当前方向为下行 对称处理 如果空闲 分别向上和向下找最近的请求楼层取绝对距离更近的那个。这组规则综合出来后其实非常规整就是几个比较器和编码器。后面我会讲到这个仲裁过程在 VHDL 里写成组合进程和状态机主进程分开调试时能省很多事。2. 状态机拆解把电梯运行过程变成VHDL能看懂的有限状态2.1 状态定义与转换条件电梯的行为看起来连续但真正抽象后只有几个状态。我定义的状态枚举是type fsm_state is ( IDLE, MOVE_UP, MOVE_DOWN, OPEN_DOOR, CLOSE_DOOR );IDLE电梯静止等待请求MOVE_UP电机正转向上运行MOVE_DOWN电机反转向下运行OPEN_DOOR输出开门命令并启动开门定时器CLOSE_DOOR等待门关闭信号同时为下一步转移做准备。状态转换条件我用表格整理如下当前状态转移条件下一状态IDLE检测到任意请求MOVE_UP / MOVE_DOWNMOVE_UP当前楼层等于目标楼层OPEN_DOORMOVE_DOWN当前楼层等于目标楼层OPEN_DOOROPEN_DOOR开门定时时间到CLOSE_DOORCLOSE_DOOR门关闭信号有效且仍有请求MOVE_UP / MOVE_DOWNCLOSE_DOOR门关闭信号有效且无请求IDLE这里有个关键点IDLE 到 MOVE_UP/MOVE_DOWN 的方向选择不能凭感觉必须调用仲裁组合进程给出的方向信号。如果仲裁进程输出的dir_cmd为UP状态机从 IDLE 进入 MOVE_UP如果为DOWN进入 MOVE_DOWN如果为NONE继续停在 IDLE。2.2 为什么门控必须拆成独立定时器开门过程不是状态机一拍能完成的。真实电梯门上有限位开关开门动作要持续几秒关门后也要等机械动作结束。把定时逻辑塞进状态机主进程会很别扭因为主进程每个时钟沿只能做一次状态转移而定时需要数百甚至数千个时钟周期。我的做法是把开门定时器做成一个独立进程状态机进入 OPEN_DOOR 时拉高door_open_cmd同时给定时器一个使能信号定时器计数到预设门限后产生door_timeout。状态机只有在收到door_timeout后才转移到 CLOSE_DOOR。这样主状态机保持简洁时序关系一目了然。从状态机设计角度说门开、门关这些机械过程的细节被隔离在单一时钟的边沿里不会让状态转换条件膨胀。这也是有限状态机工程化的一个基本原则把“等待现实世界响应”的过程抽象成独立信号主状态机只管“什么时候该开门”“什么时候门应该已经关了”。2.3 状态机里最容易写错的地方我第一次写这个状态机时犯了个特别隐蔽的错误把目标楼层算成了常数。电梯在移动过程中新请求不断进来目标楼层必须实时更新否则电梯会一直冲向最早那个目标完全无视中途新加的同向请求。后来我把“目标楼层”信号做成组合输出由仲裁进程根据当前方向实时扫描请求寄存器得出。状态机判断“是否到达目标”时直接比较current_floor和target_floor。方向变了目标自然跟着变。这个改动看似简单却是整个控制器行为是否自然的根本。还有一点楼层移动的判定。我没有用复杂的编码器计算位移而是直接用floor_sensor的独热码。为什么敢这么偷懒因为电梯安装现场本来就有平层传感器每个楼层的传感器位置固定只要传感器稳定current_floor就是可信的。这比用步进电机脉冲数推算要可靠得多。3. VHDL主干实现状态枚举、请求仲裁与门控定时3.1 实体定义与内部信号布局下面是整个控制器的主实体代码。你可以看到所有输入都是同步处理后的信号直接在端口里使用未同步的按键信号是不推荐的这点后面会专门展开。library ieee; use ieee.std_logic_1164.all; use ieee.numeric_std.all; entity elevator_controller is port ( clk : in std_logic; rst_n : in std_logic; btn_up : in std_logic_vector(3 downto 0); btn_down : in std_logic_vector(3 downto 0); btn_cabin : in std_logic_vector(3 downto 0); floor_sensor : in std_logic_vector(3 downto 0); door_closed : in std_logic; motor_up : out std_logic; motor_down : out std_logic; door_open_cmd: out std_logic; floor_display: out std_logic_vector(1 downto 0) ); end entity elevator_controller; architecture rtl of elevator_controller is type fsm_state is ( IDLE, MOVE_UP, MOVE_DOWN, OPEN_DOOR, CLOSE_DOOR ); signal state : fsm_state; signal up_req : std_logic_vector(3 downto 0); signal down_req : std_logic_vector(3 downto 0); signal cabin_req : std_logic_vector(3 downto 0); signal current_floor : unsigned(1 downto 0); signal target_floor : unsigned(1 downto 0); signal dir_cmd : std_logic; -- 1代表向上0代表向下 signal door_timeout : std_logic; signal open_door_counter : unsigned(15 downto 0); begin floor_display std_logic_vector(current_floor); ... end architecture rtl;端口信号里我把floor_sensor直接拿来生成current_floor。独热码转二进制可以用一个非常简洁的组合进程完成如果有多个位同时为 1就按固定优先级输出并拉出异常标志便于调试。3.2 请求锁存与清除逻辑按钮信号即便经过消抖也不能直接当作请求使用。因为按钮是瞬时的电梯可能在几秒后才到达该层中间几十万次时钟周期必须有人记住这个请求。请求寄存器的代码逻辑如下process(clk, rst_n) begin if rst_n 0 then up_req (others 0); down_req (others 0); cabin_req (others 0); elsif rising_edge(clk) then for i in 0 to 3 loop if btn_up(i) 1 then up_req(i) 1; end if; if btn_down(i) 1 then down_req(i) 1; end if; if btn_cabin(i) 1 then cabin_req(i) 1; end if; end loop; -- 电梯到达某层清掉该层请求 if state OPEN_DOOR then case current_floor is when 00 up_req(0) 0; down_req(0) 0; cabin_req(0) 0; when 01 up_req(1) 0; down_req(1) 0; cabin_req(1) 0; when 10 up_req(2) 0; down_req(2) 0; cabin_req(2) 0; when others up_req(3) 0; down_req(3) 0; cabin_req(3) 0; end case; end if; end if; end process;注意一个细节清除请求的时机是 OPEN_DOOR 状态而非 CLOSE_DOOR。这是有讲究的因为电梯刚开门就处于物理可达状态此时乘客可以进出请求在语义上已经满足。如果延迟到关门才清除开门期间新按下的一模一样按钮又会被锁存造成多余停靠。3.3 目标楼层仲裁的组合进程目标楼层计算不依赖时序所以用纯组合逻辑写。我给出一段示意代码process(all) begin if state MOVE_UP then if up_req(3) 1 or down_req(3) 1 or cabin_req(3) 1 then target_floor 11; elsif up_req(2) 1 or down_req(2) 1 or cabin_req(2) 1 then target_floor 10; elsif up_req(1) 1 or down_req(1) 1 or cabin_req(1) 1 then target_floor 01; else target_floor 00; end if; elsif state MOVE_DOWN then if up_req(0) 1 or down_req(0) 1 or cabin_req(0) 1 then target_floor 00; elsif up_req(1) 1 or down_req(1) 1 or cabin_req(1) 1 then target_floor 01; elsif up_req(2) 1 or down_req(2) 1 or cabin_req(2) 1 then target_floor 10; else target_floor 11; end if; else -- IDLE 和开关门状态先向上找再向下找取最近楼层 ... end if; end process;这串代码长得啰嗦但综合出来的结果很直白就是一个多路选择器。把请求位向量作为选择条件把楼层编号作为数据输出。如果你想做得更优雅可以用 for 循环配合可变量扫描但初学者最好先用最笨的展开写法综合结果反而更容易预期。3.4 主状态机转移与门控定时器状态机主进程直接按前一节的转换表写process(clk, rst_n) begin if rst_n 0 then state IDLE; motor_up 0; motor_down 0; door_open_cmd 0; elsif rising_edge(clk) then case state is when IDLE if dir_cmd 1 then state MOVE_UP; motor_up 1; elsif dir_cmd 0 then state MOVE_DOWN; motor_down 1; end if; when MOVE_UP if current_floor target_floor then state OPEN_DOOR; motor_up 0; door_open_cmd 1; end if; when MOVE_DOWN if current_floor target_floor then state OPEN_DOOR; motor_down 0; door_open_cmd 1; end if; when OPEN_DOOR if door_timeout 1 then state CLOSE_DOOR; door_open_cmd 0; end if; when CLOSE_DOOR if door_closed 1 then if dir_cmd 1 then state MOVE_UP; motor_up 1; elsif dir_cmd 0 then state MOVE_DOWN; motor_down 1; else state IDLE; end if; end if; end case; end if; end process;门控定时器独立进程则非常简单process(clk, rst_n) begin if rst_n 0 then open_door_counter (others 0); door_timeout 0; elsif rising_edge(clk) then door_timeout 0; if state OPEN_DOOR then if open_door_counter X4E20 then -- 假设20 kHz时钟约1秒 door_timeout 1; open_door_counter (others 0); else open_door_counter open_door_counter 1; end if; else open_door_counter (others 0); end if; end if; end process;如果你把定时器和状态机写进同一个进程会发现状态机在等待时无法响应其他信号调试时也容易把“状态转移”和“计数”两件事混在一起。进程拆分是我在整个项目里最受益的地方。4. 仿真阶段怎么验证电梯逻辑从Testbench到覆盖率4.1 写一个能“撒手不管”的Testbench测试台的核心思路很简单给控制器施加一组序列化的事件然后在关键时间点用断言检查状态。我先写了一个能自动跑完一轮完整乘梯流程的测试台signal clk : std_logic : 0; signal rst_n : std_logic; signal btn_up : std_logic_vector(3 downto 0) : (others 0); signal btn_down : std_logic_vector(3 downto 0) : (others 0); signal btn_cabin : std_logic_vector(3 downto 0) : (others 0); signal floor_sensor : std_logic_vector(3 downto 0) : 0001; signal door_closed : std_logic : 0; -- 模拟电梯物理层移动 process begin wait until clkevent and clk 1; case floor_sensor is when 0010 floor_sensor 0010; ... end case; end process;仿真里最妙的一点是我们可以完全控制时间。真实电梯要跑一秒的行程仿真里只花几十个时钟周期。我把状态机跑完整四层往返的时间压缩到几毫秒仿真时间然后让波形工具导出状态变化曲线一目了然。4.2 关键测试场景清单我把测试分成四类场景每一类对应一类真实问题场景输入动作预期行为单层呼叫三层乘客按轿厢按钮电梯从一层上行到三层门开等待关门后回到空闲同向顺路途中二层有同向呼叫电梯先停二层再停三层反向等待三层乘客下行一层有人上行电梯先完成下行任务再响应一层上行门开期间新请求开门时二层新请求到来不干扰当前开门关门后立刻触发新任务每次测试我都加若干断言。比如在三层到达后assert state OPEN_DOOR report 电梯到达三层但状态不是开门 severity failure; if current_floor 11 then assert motor_up 0 report 到达后电机仍在运行 severity failure; end if;这种断言测试台写起来不复杂但价值极高。它能在我重构代码后快速建立回归信心。电梯控制器的状态耦合关系多改一处很容易影响到看似无关的路径没有回归测试根本不敢动手优化。4.3 仿真覆盖不到的盲区仿真做得再完美也不可能覆盖所有真实世界输入。尤其是机械抖动和异步行输入用仿真波形手画非常费劲。但我在仿真阶段还是发现过几个关键设计缺陷最典型的就是“开门期间的新请求丢失”。当时测试用例设计里漏掉了这门一个场景仿真波形看起来一切正常直到搭好硬件用手按压按钮才暴露。这个教训后面会细说。所以我的结论是仿真能验证的是控制逻辑正确性而物理接口问题必须靠上板测试。两者互补缺一个都不行。5. 上板前的输入整形机械按键消抖与异步信号同步5.1 按键抖动为什么能毁掉整个控制器真实的机械按键按下时金属触点会由于弹性产生非常短暂的反复通断持续时间通常在 5 到 20 毫秒。如果你的 FPGA 时钟是几十 kHz在这段时间里控制器可能看到几十次“按下-释放-按下”的跳变。如果直接把抖动信号送进请求锁存进程后果是请求寄存器会在短时间内被反复置位。幸运的是 VHDL 里的if btn_up(i) 1 then up_req(i) 1;是电平锁存只要最终电平稳定在高位结果不会错。但真正危险的是在状态机状态转移的判断里直接使用按钮比如把“当前层按钮”当作台阶触发的门限信号那就会造成多跳状态。5.2 两级同步器强制信号进入时钟域按键输入相对 FPGA 内部时钟是异步的所以我先做一级同步signal btn_sync_a : std_logic_vector(3 downto 0); signal btn_sync_b : std_logic_vector(3 downto 0); process(clk) begin if rising_edge(clk) then btn_sync_a btn_up; btn_sync_b btn_sync_a; end if; end process;解释一下这个看似平淡的代码第一级触发器接收异步输入输出信号可能存在亚稳态第二级触发器再采样第一级输出让亚稳态有整整一个时钟周期去稳定。两级同步不能完全消除亚稳态但能把概率压到工程可接受的范围。5.3 可综合的消抖模块同步后还要做防抖。我的做法是加一个消抖计数器一旦输入电平与最终稳定输出不同就把计数器清零只有输入连续保持某个电平超过 N 个时钟周期才认为它是有效变化。constant DEBOUNCE_MAX : integer : 1000; -- 时钟20kHz约50ms signal counter : integer range 0 to DEBOUNCE_MAX-1; process(clk, rst_n) begin if rst_n 0 then deb_out 0; counter 0; elsif rising_edge(clk) then if raw_in deb_out then counter 0; elsif counter DEBOUNCE_MAX - 1 then deb_out raw_in; counter 0; else counter counter 1; end if; end if; end process;使用中应注意消抖时间太长会影响操作手感但太短又形同虚设。电梯按钮不是游戏手柄物理反馈本身很迟钝我把消抖时间设在 30 到 50 毫秒之间实测手感和可靠性都不错。门限位开关要求更高因为它关系到乘客安全我给door_closed也做了同样的消抖并且把门限阈值调得更长避免门还没完全关好就被状态机当成了关闭状态。上板之后我基本没有再因为按钮抖动出过问题。这块投入是绝对值得的。6. 调试中的几个怪异Bug与扩展思路6.1 Bug 1同向顺路变成反向掉头第一次综合上板后我很快发现一个诡异现象电梯本来在向上运行但二层有人按了上行电梯居然在“超过”二层后才反应过来要停继续往上走了本应顺路停下。查了很久发现我的仲裁进程在计算目标楼层时扫描顺序写反了它从最顶层往下找而不是从当前楼层往上找。结果二层明明有同向请求仲裁却选择更远的三层。修复方法很简单把 MOVE_UP 的扫描顺序改成“三层优先、二层其次、一层最后”MOVE_DOWN 反过来。这个 Bug 再次证明组合逻辑的“顺序”就是“优先级”写这几行代码时必须脑子里有一张清晰的楼层帆布图。6.2 Bug 2开门期间漏记目的楼层还有一次我做了一次手动乘梯实验人在二层轿厢里按三层的按钮正常上到三层开门我在开门瞬间又按了一次三层按钮结果电梯三层门关后没有反应了。事后分析问题出在请求清除时序OPEN_DOOR 状态才清除该层请求而我开门瞬间按的三层请求虽然在同一拍被锁存但随后到来的 OPEN_DOOR 清除逻辑又立刻把这笔按键覆盖掉了请求凭空消失。修复方法是把清除逻辑加上条件仅当请求已经真实完成——也就是进 OPEN_DOOR 时的那一拍清除门开过程中新的按钮输入不能被同一拍清零。具体做法是增加一个request_served一级延时信号让清除动作发生在进入 OPEN_DOOR 后的第一个周期这样新请求就有机会优先写入。这个 Bug 的教训是清除请求的时机不能朴素地写成“状态等于 OPEN_DOOR”必须考虑同一时钟沿上多个信号并发写同一个寄存器时的优先级。6.3 Bug 3楼层传感器偶发双位有效四层电梯只有四个传感器我用独热码时默认任何时刻最多只有一个位为 1。但上板实测中我居然抓到过floor_sensor 0101这种非法值一查是传感器信号线干扰导致相邻楼层传感器同时有效。我用一个简单的优先级编码器处理这个问题高位优先一旦出现多位有效强制认为在高楼层。同时拉出一个sensor_abnormal信号在七段码上显示错误代码。这种防御式写法在真实硬件上非常有用控制器的可靠性不能依赖传感器永远不会故障。6.4 可以继续加装的功能做完这版以后我认真列过它还能扩展的方向多部电梯联调把每部电梯的请求寄存器汇总到一个中央仲裁器根据距离和负载分配轿厢超载检测增加一个称重信号超载时禁止关门并闪烁警告灯消防模式收到消防信号后让所有轿厢立即回到首层并开门断电断电记忆用快闪式存储记录当前楼层和请求避免掉电后电梯“失忆”。每一条都是真实电梯里存在的功能也都能在现有 VHDL 框架上逐步加状态、加寄存器来实现。如果让我总结这次项目里最实用的一个习惯那就是写 VHDL 之前永远先画一张状态转换表和一张接口表然后让状态机、请求寄存器、消抖模块各管各的事。这套设计方法在电梯控制器上验证过同样适用于交通灯、自动售货机甚至更复杂的通信链路状态机。你如果在做类似的项目时不按这个节奏走大概率会在综合或上板阶段付出几倍的调试时间。