简介:这份FPGA数字系统设计大作业文档面向电子信息、计算机等专业修读数字系统设计课程的学生,围绕PARWAN处理器架构与CONTROL-SECTION状态机展开,可作为课程大作业的参考范例。正文从运算、控制、寻址三部分梳理PARWAN的整体结构,说明PC、MAR、ALU、SR、IR与CONTROLLER的职责及“控制—取址/取数—运算—控制”的执行流程,并给出CONTROL-SECTION状态机的VHDL设计与后仿结果。压缩包内为1个doc文档,约763KB,按目的、原理、步骤、结果、总结五部分组织,含芯片型号选择、Test Bench编写、代码与仿真截图等环节,便于对照复现实验、理解有限状态机如何驱动数据通路。已有554人学习,适合准备FPGA实验或需要完成同类大作业的同学参考。
1. 波形对得上,板子不一定认:控制器的时序账要从状态机算起
FPGA 开发里最容易被糊弄过去的一步,是“仿真通过”。行为仿真里状态机在每个时钟沿干净地跳一次,波形漂亮得像教科书;可走完综合、映射、布线再回来做后仿,同一份代码就冒出毛刺、多出一拍、状态短暂跑飞。问题往往不在语法,而在状态机到底被综合成了什么。PARWAN 处理器这门大作业恰好把矛盾摆在明面上:它的 CONTROL-SECTION 是一个纯状态机,取址、取数、运算、写回全靠它拍板,任何一个状态的时序错位,整条数据通路跟着错。搞懂这个状态机怎么被写成 VHDL、又怎么被综合成触发器加组合逻辑,才算真正摸到 FPGA 数字系统设计的底层机制。
2. PARWAN 的三段式切分:控制、运算、寻址为什么必须分开
把 PARWAN 的结构图摊开看,是一个比较规整的三分格局:左边是运算部分,中间是控制部分,右边是寻址与取数部分。很多人第一次看会觉得这只是画图方便,实际上这条分界线决定了整个处理器能不能在一个时钟周期里完成一件确定的事。三分法的本质是把“谁来做决定”“谁来做计算”“谁去搬数据”拆成三条独立的时间线,让控制信号只需要在最关键的那几个时刻有效,其余时间数据通路自己流动。
从数据流向上看,整个回路是:控制器发出控制字 → 取址/取数 → ALU 运算与 SHU 移位 → SR 记录状态 → IR 更新指令 → 控制器据此决定下一步。这条链是闭环的,闭环就意味着存在反馈路径,而反馈路径上的任何一拍的延迟都会被下一轮放大。取址阶段从 PC 出发,经 MAR 驱动地址总线 ADBUS,从存储器取出指令送进 IR;执行阶段再从这里分流,R 型指令走 ALU,访存指令走地址计算,分支指令走比较。控制器的每一个状态,实际上就是在给这条通路上的一组多路选择器打标记。
2.1 取址通路:PC、MAR 与 ADBUS 的一次往返
PC 存的是当前指令的地址,MAR 存的是当前要送上地址总线的那个值。初看这两个寄存器功能重复,为什么不直接把 PC 接到地址总线上?原因在于访存指令。执行lw或sw时,送上地址总线的是 ALU 算出来的数据地址,而不是下一条指令的地址;此时 PC 必须原封不动地保持指令流的位置,等着这一轮访存结束后加 4 继续走。如果只有 PC 一个寄存器,访存就得先把 PC 的值搬到别处暂存,再写回去,反而多出两次搬运。
所以常见的实现思路是:PC 和 ALUOut 各接一路,经一个二选一的多路器(控制信号习惯上叫IorD)选出一路写进 MAR。这个多路器就是“取指令”和“取数据”两种模式的分水岭,IorD = 0走 PC 取指令,IorD = 1走 ALU 结果取数据。所有寻址方式——立即数偏移、基址加偏移、PC 相对——最后都收敛成“把 ALU 算出的地址塞进 MAR”这一个动作。
时序上这一步的账要算清楚。MAR 在时钟沿被写入后,地址总线上的值需要一段建立时间才能被存储器稳定采样;存储器本身的访问延迟(教学模型里常按一个周期估算)过完,数据才在数据总线上稳定;再等到下一个时钟沿,MemRead有效时数据才被打进 IR。这就解释了为什么取址要占一个完整周期而不是“零周期”:地址建立、存储器访问、数据稳定这三段串起来,构成了周期长度的下界。整个处理器的最高频率,本质上是被这条最慢路径卡住的。
注意:MAR 的位宽必须和数据地址空间对齐,而不是和指令位宽对齐。教学模型里指令 32 位、地址 32 位看起来一样,一旦地址空间缩到 16 位,MAR 少截几位,高位地址就会在仿真里悄悄变 0,波形上完全看不出来。
2.2 ALU、SHU 与 SR:状态位如何回流成控制条件
ALU 的两个输入不是随便接的。A 口通常经一个二选一(ALUSrcA)在 PC 和寄存器堆第一读口之间选,B 口的选择更多(ALUSrcB两位),分别在寄存器堆第二读口、常数 4、符号扩展后的立即数、左移两位后的立即数之间选。这四种组合覆盖了处理器里绝大多数地址计算:PC + 4算下一条指令,寄存器 + 立即数算访存地址,寄存器 + 寄存器做 R 型运算,立即数左移两位拼跳转目标。
SHU 移位器通常挂在 ALU 输出之后,专职做“左移两位”。分支指令的偏移量在指令里是相对值,要乘 4 才能和字节地址对齐;跳转指令的目标地址拼接也依赖这一步。把这个移位单独拆出来而不是塞进 ALU,是为了让 ALU 保持纯粹的算术逻辑功能,时序更短,也让控制信号少一档组合。
真正让闭环成立的是 SR。ALU 的输出有 32 位,但控制器只关心其中一位:结果是不是零。SR 把这个零标志锁存下来,送进控制器当作分支判断的输入。换句话说,运算部分把“一大坨数据”压缩成“一位结论”反馈回去,控制器只根据这一位决定beq到底跳不跳。这就是 PARWAN 里“运算 → 控制”那条回边,也是整个设计里唯一一处数据宽度被剧烈收缩的地方。
这条回边带来一个必须正视的时序问题:zero标志是在时钟沿之后由 ALU 组合逻辑算出来的,而控制器的PCWriteCond又直接依赖它。如果PCWriteCond是组合输出,那么zero上任何一个毛刺都会原样透传到 PC 的写使能上。教学模型里因为这个毛刺恰好落在时钟沿附近而被采样到的概率不大,但它确实是后仿波形里“偶尔多跳一次”的典型来源。
3. CONTROL-SECTION 状态机:把“下一步做什么”写成状态转移
控制器的本质就是一个有限状态机。它没有自己的数据,全部行为都体现在“当前处于哪个状态”和“在这个状态下输出什么控制字”这两件事上。PARWAN 这类多周期处理器,一条指令通常被切成取址、译码、执行、访存、写回几个阶段,每个阶段对应一个状态,状态之间的跳转条件由指令的操作码和 ALU 的零标志共同决定。
状态划分的粒度直接决定设计的复杂度。切得太细,状态数爆炸,状态转移表长得没法维护;切得太粗,一个状态里塞了太多互斥的操作,输出译码的组合逻辑会变得又深又慢。教学模型里比较常见的做法是按“数据通路被使用的方式”来切:取址用一个状态,译码用一个状态,然后按指令类型分流到访存地址计算、访存读、访存写、R 型执行、分支、跳转这几个状态,每个状态里数据通路的配置基本固定。
状态转移表是整个设计的骨架,写代码之前先把它列出来,比直接敲 VHDL 靠谱得多。常见的实现思路是这样一张表:
| 当前状态 | 跳转条件 | 次态 | 该状态的主要动作 |
|---|---|---|---|
| FETCH | 无条件 | DECODE | MAR←PC,读存储器,IR←数据,PC←PC+4 |
| DECODE | opcode = lw/sw | MEMADR | ALU 算基址加偏移 |
| DECODE | opcode = R 型 | EXEC | 读寄存器堆两个口 |
| DECODE | opcode = beq | BRANCH | 读寄存器堆,准备比较 |
| DECODE | opcode = j | JUMP | 拼跳转目标 |
| MEMADR | 指令是 lw | MEMREAD | 地址写入 MAR,发起读 |
| MEMADR | 指令是 sw | MEMWRITE | 地址写入 MAR,写数据准备 |
| MEMREAD | 无条件 | MEMWB | 存储器数据进寄存器堆写口 |
| EXEC | 无条件 | ALUWB | ALU 运算,结果写回 |
| BRANCH | zero = 1 | FETCH | PC←分支目标 |
| BRANCH | zero = 0 | FETCH | PC 保持 |
这张表里有两个细节值得单独拎出来。一是FETCH状态里 PC 自增和 MAR 装载同时发生,因为这两件事互不冲突,合并进一个状态能省一个周期;二是BRANCH是唯一一个跳转条件依赖运行时数据的非终结状态,其余状态的次态在译码那一刻就确定了。
3.1 三段式写法的动机:为什么状态寄存器必须独立成段
VHDL 里描述状态机有三种常见写法,一段式、两段式、三段式。一段式把所有逻辑塞进一个时钟触发的进程,代码看着紧凑,但综合工具面对这种写法时,往往会推断出“输出也带时钟”的结构,输出在状态跳变的同一个沿发生变化。对后级电路来说,这个沿既可能是数据变化的沿,也可能是被采样的沿,竞争就埋下了。
两段式把状态寄存器和“次态加输出”拆开,输出是纯组合逻辑,直接从当前状态译码。这种做法在波形上看最直观,状态一变输出立刻跟着变,没有任何延迟。代价是输出路径上挂着一整条译码链,状态编码位数越多,这条链越深;而且组合输出天然带毛刺,状态切换的瞬间多位同时翻转,输出上会冒出几个纳秒的尖峰。
三段式是我在教学模型里最推荐的写法:第一段只做状态寄存器的时序更新,第二段算次态,第三段单独算输出。三段式相比两段式多出来的那一“段”,本质上是在强迫你明确回答一个问题——这个输出到底该在什么时候有效?如果输出只依赖状态(摩尔型),那么它和状态同步变化;如果输出还依赖输入(米利型),那就要额外考虑输入的稳定性。把这个问题在代码结构上物理隔开,后仿的时候波形会干净很多。
提示:三段式不是银弹。如果第三段也做成时序输出(把输出再打一拍),输出会比状态晚一个周期,所有依赖这些控制信号的数据通路动作都要跟着往后挪。要么保持组合输出,要么在时序预算里预留这一拍,不能两头都想要。
3.2 状态编码与转移条件
状态编码在 FPGA 上和 ASIC 上的最优解不一样。ASIC 里常用二进制编码,触发器少、面积省;FPGA 里每个触发器都是现成的资源,反而更在意译码逻辑的级数。one-hot 编码每个状态占一位,译码就是一次与运算,速度最快,代价是状态数等于触发器数;二进制编码触发器少,但译码要经过多级比较,状态一多就慢。教学模型里状态数通常在十个上下,one-hot 的综合结果往往更干净,波形也更好读,一眼就能看出当前是哪个状态。
状态转移条件要区分“确定转移”和“条件转移”。确定转移是指次态只由当前状态决定,比如FETCH → DECODE,这类转移在次态逻辑里直接赋值即可;条件转移依赖操作码或标志位,必须写成完整的case分支,并且每个分支都要有默认值。这里有一个非常容易翻车的地方:case语句如果漏了when others,综合工具会认为存在未覆盖的输入组合,于是推断出一个锁存器来“保持上次的值”。锁存器在 FPGA 里是真实存在的资源,综合不会报错,仿真也跑得通,但它的时序分析模型和触发器完全不同,后仿时延迟对不上,状态机会在最不该出错的地方出错。
转移条件里的信号采样时机也要留意。操作码来自 IR,而 IR 是在上一个状态的时钟沿被写入的,到本状态时已经稳定;zero来自 SR,同样是在上一个沿锁存。两个输入都稳定,case分支就不会出现中途切换的问题。这也是为什么状态机的输入尽量来自寄存器而不是组合逻辑——寄存器把不确定性关在了时钟沿之内。
3.3 摩尔型输出与米利型输出的取舍
按输出依赖谁,状态机分摩尔型和米利型。PARWAN 控制器里绝大多数输出只依赖当前状态:MemRead只在MEMREAD状态有效,RegWrite只在MEMWB和ALUWB有效,IRWrite只在FETCH有效。这些是标准的摩尔型输出,行为确定、波形规整。
例外是PCWriteCond。分支指令要不要写 PC,取决于zero这一位运行时数据,而zero不是状态的一部分。所以这个输出必须是米利型——它同时依赖当前状态BRANCH和输入zero。米利型输出的固有风险是输入毛刺会透传到输出,zero由 ALU 组合逻辑产生,翻转过程中可能出现短暂的假零,如果恰好被PCWriteCond采到,PC 就会被错误地更新。
工程上的处理办法有几种。最保守的是把zero在 SR 里多打一拍再用,用一拍的延迟换稳定性;激进一点的是接受毛刺,因为 PC 的写入最终还要经过时钟沿把关,只要毛刺不在沿附近就没影响;还有一种折中是让PCWriteCond在BRANCH状态的第二个时钟沿才有效,人为错开zero的翻转窗口。教学模型里通常选第一种,代价是多一个状态,但波形上完全没有歧义。
4. VHDL 落地:控制器状态机的代码级拆解
把上面这些讨论落到代码,控制器的实体其实很朴素:时钟、复位、操作码、零标志四个输入,一堆控制信号输出。真正有信息量的是架构体内部的三段结构,以及每一段里那些“不写就会出事”的细节。
4.1 实体与端口定义
端口列表基本就是状态转移表里出现过的所有控制信号的集合。写实体的时候要注意两件事:输出信号的位宽要和它驱动的多路选择器的选择端数量对得上;复位极性要和开发板上的按键保持一致,否则上板第一件事就是按了没反应。
library ieee; use ieee.std_logic_1164.all; entity par_control_unit is port ( clk : in std_logic; reset : in std_logic; -- 高电平异步复位 opcode : in std_logic_vector(5 downto 0); -- IR[31:26],指令操作码 zero : in std_logic; -- SR 输出的零标志 pc_write : out std_logic; -- PC 无条件写使能 pc_write_cond : out std_logic; -- PC 条件写使能(分支) i_or_d : out std_logic; -- 0: PC 送 MAR, 1: ALUOut 送 MAR mem_read : out std_logic; mem_write : out std_logic; ir_write : out std_logic; -- 指令寄存器写使能 mem_to_reg : out std_logic; -- 写回数据源选择 pc_source : out std_logic_vector(1 downto 0); -- PC 下一值来源 alu_op : out std_logic_vector(1 downto 0); -- ALU 运算类型 alu_src_a : out std_logic; -- 0: PC, 1: 寄存器堆 A 口 alu_src_b : out std_logic_vector(1 downto 0); -- B 口四选一 reg_write : out std_logic; reg_dst : out std_logic -- 写回目标寄存器编号来源 ); end entity par_control_unit;opcode取 6 位,是因为教学模型里指令高 6 位就是操作码字段,lw、sw、R 型、beq、j各自的编码固定。alu_op只取 2 位,是因为 ALU 在控制器层面只需要区分“做加法”“做减法”“按功能码来”这几种模式,具体做哪一种由指令低位的功能码在 ALU 内部再译一次。这种两级译码是常见做法,能把控制器的输出位宽压下来。
pc_source取 2 位而不是 1 位,是因为 PC 的下一值有三个来源:ALU 结果(顺序执行加 4)、分支目标、跳转目标。三选一至少需要两位选择线,多出来的一种编码留作将来扩展。
4.2 状态寄存器与次态逻辑
第一段和第二段是状态机的骨架。第一段只负责“沿来了就换状态”,第二段只负责“根据当前状态和输入算出下一个状态”。两段之间只通过cur_state和nxt_state两个信号通信,边界非常清晰。
architecture rtl of par_control_unit is type state_type is (S_FETCH, S_DECODE, S_MEMADR, S_MEMREAD, S_MEMWB, S_MEMWRITE, S_EXEC, S_ALUWB, S_BRANCH, S_JUMP); signal cur_state : state_type; signal nxt_state : state_type; begin -- 第一段:状态寄存器,只做时序更新 seq_state : process(clk, reset) begin if reset = '1' then cur_state <= S_FETCH; -- 复位落回取址状态 elsif rising_edge(clk) then cur_state <= nxt_state; end if; end process; -- 第二段:次态组合逻辑 comb_next : process(cur_state, opcode) begin nxt_state <= cur_state; -- 默认保持,杜绝锁存器推断 case cur_state is when S_FETCH => nxt_state <= S_DECODE; -- 取址完成必进译码 when S_DECODE => case opcode is when "100011" => nxt_state <= S_MEMADR; -- lw when "101011" => nxt_state <= S_MEMADR; -- sw when "000000" => nxt_state <= S_EXEC; -- R 型 when "000100" => nxt_state <= S_BRANCH; -- beq when "000010" => nxt_state <= S_JUMP; -- j when others => nxt_state <= S_FETCH; -- 非法指令,回取址 end case; when S_MEMADR => if opcode = "100011" then nxt_state <= S_MEMREAD; -- lw 走读通路 else nxt_state <= S_MEMWRITE; -- sw 走写通路 end if; when S_MEMREAD => nxt_state <= S_MEMWB; when S_MEMWB => nxt_state <= S_FETCH; when S_MEMWRITE => nxt_state <= S_FETCH; when S_EXEC => nxt_state <= S_ALUWB; when S_ALUWB => nxt_state <= S_FETCH; when S_BRANCH => nxt_state <= S_FETCH; when S_JUMP => nxt_state <= S_FETCH; when others => nxt_state <= S_FETCH; end case; end process;这段代码里有三处是踩过坑才会记得写的。第一处是nxt_state <= cur_state;这个默认赋值,它在case之前,作用是保证任何一个未覆盖的分支都不会让nxt_state保持旧值从而推断出锁存器。第二处是内外两层case的嵌套顺序——先按状态分,再按操作码分,因为操作码只在S_DECODE状态下有意义,提前判会浪费组合逻辑。第三处是when others在内外两层都有,外层兜住状态编码里的非法值,内层兜住操作码里的非法编码,这两层缺一层,综合报告里就会出现 latch 警告。
S_BRANCH的次态无条件回S_FETCH看起来有点奇怪,因为分支到底跳不跳还没判断。实际上判断发生在输出段:pc_write_cond在S_BRANCH状态下等于zero,PC 写不写由这一位决定,而状态本身无论如何都回取址。状态转移和输出动作在这里是解耦的,这也是三段式写法的一个附带好处——你可以在不改状态图的前提下调整输出行为。
4.3 输出译码与时序对齐
第三段是整份代码里最长的一段,因为每个状态都要把十几个控制信号完整地赋一遍。写法上有个硬性要求:进程一开始先把所有输出清零,然后再按状态覆盖。这个“默认清零”不是风格问题,而是正确性问题。
-- 第三段:输出译码,纯组合,默认全零 comb_out : process(cur_state, zero) begin -- 默认值必须写在 case 之前 pc_write <= '0'; pc_write_cond <= '0'; i_or_d <= '0'; mem_read <= '0'; mem_write <= '0'; ir_write <= '0'; mem_to_reg <= '0'; pc_source <= "00"; alu_op <= "00"; alu_src_a <= '0'; alu_src_b <= "00"; reg_write <= '0'; reg_dst <= '0'; case cur_state is when S_FETCH => i_or_d <= '0'; -- PC 送 MAR mem_read <= '1'; ir_write <= '1'; -- 存储器数据打进 IR alu_src_a <= '0'; -- A 口取 PC alu_src_b <= "01"; -- B 口取常数 4 alu_op <= "00"; -- 加法 pc_write <= '1'; -- PC <- PC + 4 pc_source <= "00"; when S_DECODE => alu_src_a <= '0'; alu_src_b <= "11"; -- B 口取左移两位后的立即数 alu_op <= "00"; -- 先算好分支/跳转目标,后续状态直接用 when S_MEMADR => alu_src_a <= '1'; -- A 口取寄存器堆 alu_src_b <= "10"; -- B 口取符号扩展立即数 alu_op <= "00"; when S_MEMREAD => i_or_d <= '1'; -- ALUOut 送 MAR mem_read <= '1'; when S_MEMWB => reg_dst <= '0'; -- 写回目标取 IR[20:16] mem_to_reg <= '1'; -- 数据来自存储器 reg_write <= '1'; when S_MEMWRITE => i_or_d <= '1'; mem_write <= '1'; when S_EXEC => alu_src_a <= '1'; alu_src_b <= "00"; -- B 口取寄存器堆第二口 alu_op <= "10"; -- 按功能码运算 when S_ALUWB => reg_dst <= '1'; -- 写回目标取 IR[15:11] mem_to_reg <= '0'; -- 数据来自 ALUOut reg_write <= '1'; when S_BRANCH => alu_src_a <= '1'; alu_src_b <= "00"; alu_op <= "01"; -- 减法,结果决定 zero pc_write_cond <= zero; -- 唯一的米利型输出 pc_source <= "01"; -- 分支目标来自 ALUOut when S_JUMP => pc_write <= '1'; pc_source <= "10"; -- 跳转目标来自拼接结果 when others => null; -- 默认值已经兜底 end case; end process; end architecture rtl;默认清零这一段的价值在后仿里体现得最明显。假设某个状态漏掉了reg_write的赋值,没有默认值的话,综合工具会推断出一个锁存器来保持上一次的值,于是这个状态里寄存器堆莫名其妙被写了一次。行为仿真里因为信号初值是'0',看起来一切正常;后仿里锁存器的初始状态是不定的,波形上就会出现一个X,然后一路污染下去。
S_DECODE里提前把分支目标和跳转目标算好,是一个很典型的面积换时间的取舍。这个状态本来只做译码,把 ALU 顺手用上不增加任何硬件,省下的却是后续S_BRANCH和S_JUMP各一个周期。代价是alu_src_b在S_DECODE和S_MEMADR之间要切换,多路选择器的选择信号翻转更频繁,功耗略高。教学模型里这点功耗无所谓,但如果是真做低功耗设计,就要重新权衡。
pc_write_cond <= zero;这一行是整段代码里唯一的米利型输出,也是后仿最容易出问题的地方。zero来自 SR,SR 在S_EXEC的时钟沿锁存 ALU 结果,到S_BRANCH时已经稳定,理论上不会翻转。但如果 ALU 的组合延迟加上 SR 的建立时间超过了时钟周期,zero就会在S_BRANCH期间发生翻转,pc_write_cond跟着抖一下。后仿波形上表现为 PC 偶尔多写一次,而且只在特定指令序列下复现,查起来非常磨人。
5. 排查:仿真过、综合过、后仿崩的几种典型
5.1 综合报告出现 latch inferred,仿真却一切正常
现象:行为仿真波形完全正确,状态跳转、控制信号都对,但综合日志里有一行Latch inferred for signal ...。上板后功能时好时坏,换个指令序列就崩。
原因:case语句没有覆盖所有分支,或者输出进程里没有默认赋值,综合工具为了保证“未覆盖情况下信号保持原值”,推断出了电平敏感锁存器。锁存器在行为仿真里表现为“保持上次值”,恰好和设计意图一致,所以看不出来;但它对毛刺透明,后仿时输入上的任何尖峰都会穿透到输出。
解决:输出进程第一行开始就把所有输出赋默认值,case里加when others,次态逻辑同样处理。检查综合日志里所有 latch 警告,一条都不能留。
5.2 后仿波形上状态多停了一拍
现象:行为仿真里S_MEMREAD只停一个周期,后仿里停了两个,导致后续所有控制信号整体后移。
原因:状态寄存器的时钟到输出延迟(Tco)加上次态组合逻辑的延迟,超过了时钟周期。后仿反标了布线延迟后,次态在时钟沿到来时还没算稳,触发器采到的是旧值,于是多等一拍。
解决:先用时序分析工具看关键路径的 slack,如果为负就降频,或者把次态逻辑里的深组合链拆开。教学模型里常见的是把操作码译码提前到上一个状态做,减少S_DECODE这一拍的组合深度。
5.3 testbench 里激励和时钟沿同时变化
现象:同一个 testbench,换台机器跑出来的波形不一样,操作码有时候被采到旧值。
原因:激励进程里用了wait for 20 ns这种绝对时间,而时钟沿也恰好落在 20 ns,两个进程在同一个仿真时刻被唤醒,VHDL 仿真器的 delta 周期执行顺序不确定,谁先谁后取决于实现。
解决:激励统一改成在时钟下降沿驱动,或者在时钟沿之后加一个小的稳定时间再赋值。比如wait until falling_edge(clk); opcode <= "100011";,这样数据在下一个上升沿之前一定已经稳定。
5.4 复位释放后第一个状态不对
现象:按下复位再松开,状态机没有回到S_FETCH,而是停在某个中间状态,或者直接跳到S_DECODE。
原因:异步复位信号是按键直接给的,释放沿没有和时钟同步,触发器的复位释放时间落在时钟沿附近,出现亚稳态,不同触发器复位释放的时刻不一致,状态寄存器采到了混合值。
解决:加一个复位同步器,把异步复位打两拍再送给状态机;或者干脆改成同步复位,复位信号只在时钟沿被采样。教学模型里状态数少,两种做法差别不大,但习惯要养成。
5.5 控制信号在状态切换瞬间出现窄脉冲
现象:后仿波形上reg_write在状态从S_ALUWB跳回S_FETCH的瞬间冒出一个几纳秒的尖峰。
原因:三段式的第三段是组合输出,状态编码多位同时翻转时,译码逻辑的中间态可能短暂命中另一个状态的输出条件,产生冒险。行为仿真没有延迟模型,看不到这个尖峰;后仿反标延迟后就暴露出来。
解决:如果后级电路对这个尖峰敏感,把输出再打一拍变成时序输出,用一拍的延迟换干净;如果不敏感(比如后级是触发器,只在时钟沿采样),可以保留组合输出,但要在时序约束里保证尖峰不落在采样窗口内。
6. 设计取舍:三段式、one-hot 与后仿,到底值不值得
把整份设计回头再看一遍,真正需要做判断的地方其实只有三处,而这三处的答案都不是绝对的。
第一处是状态机写法。一段式代码最短,适合状态数少于五个、输出不带反馈的简单场景;两段式波形最直观,适合调试阶段快速定位;三段式结构最清晰,输出行为最可控,适合状态数上两位数、控制信号带反馈的场合。PARWAN 控制器正好落在第三类里,所以三段式是合理的。但如果哪天状态数缩到三四个,硬套三段式反而增加阅读成本——结构是为复杂度服务的,不是反过来。
第二处是状态编码。one-hot 在 FPGA 上译码快、波形好读,代价是触发器用量随状态数线性增长;二进制编码省触发器,但译码级数随状态数对数增长。状态数在十个上下时,两种编码的面积差在几十个触发器量级,对现代 FPGA 来说可以忽略,此时选 one-hot 纯粹是为了调试方便。状态数上百时,one-hot 的触发器开销就不可忽视了,得换回二进制或者格雷码。
第三处是后仿到底做不做。行为仿真能验证逻辑正确性,综合能验证可综合性,但只有后仿能验证“加上真实延迟之后还能不能工作”。教学模型规模小、时钟频率低,很多问题在行为仿真里就被掩盖了,后仿跑一遍往往能揪出锁存器、竞争、时序违例这几类问题。我一般会这么理解:后仿不是为了证明设计对,而是为了证明设计在最坏情况下也不出错,这两件事的含金量完全不同。
那次做控制器状态机,我在行为仿真里看到波形完美就直接去综合,结果上板之后分支指令十次里错一次,查了两天才发现是pc_write_cond的米利型输出在zero翻转窗口里被采到了。从那以后我每次写完状态机,都强制走一遍“综合看 latch 警告 → 后仿看毛刺 → 时序分析看 slack”这三步,一步都不能省。这份 PARWAN 大作业里的设计文档和 VHDL 代码,把状态转移表、控制信号定义、testbench 结构和后仿步骤都留了完整记录,拿它对着自己的实现逐行核一遍,比对着波形瞎猜快得多。希望帮到你。
本文还有配套的精品资源,点击获取