简介:面向高校软件工程、电子信息类专业学生的一份FPGA数字系统设计大作业文档,围绕PARWAN处理器架构与CONTROL-SECTION状态机的VHDL设计及后仿验证展开,适合课程作业参考、实验报告撰写借鉴与核心知识点复盘。压缩包仅含1个doc文件,约763KB,内容以实验报告式结构组织,包含目的、原理、步骤、结果与总结,便于按章节查阅。文档先梳理PARWAN处理器的运算、控制、寻址三部分,说明PC、MAR、ALU、SR、IR与CONTROLLER的职责,并概括“控制—取址/取数—运算—控制”的整体流程;再给出CONTROL-SECTION状态机的VHDL设计思路、Spartan3E XC3S500E工程创建、测试文件编写、仿真波形与实验结论,同时涉及取址阶段MAR赋值、运算阶段ALU操作等状态控制细节。已有553人学习,适合需要完成同类FPGA大作业、复习有限状态机建模与VHDL仿真流程,或对照后仿结果排查设计问题的读者参考。
1. 一块老开发板上的状态机:这门 FPGA 大作业到底在练什么
实验室里最容易被跳过的一步,是把波形窗口拉宽到能看见整整六个时钟周期。不少人做完 FPGA 开发里那个 PARWAN 控制段状态机,仿真一跑,看见波形有翻转就截图交差,结果一上板,状态机在取操作数那一拍原地打转——次态逻辑漏了默认赋值,综合出一堆锁存器,行为仿真还偏偏看不出来。这份基于 FPGA 的数字系统设计大作业资料,价值就在这里:它没有停在“综述一下 PARWAN 架构”的层面,而是把控制段状态机拆到 VHDL 源码、测试平台、后仿波形三层,配合 Spartan-3E XC3S500E 这块开发板,走完了一条从原理到波形的完整链路。适合两类人:正在啃数字系统设计课、需要交一份能跑通的状态机作业的学生;以及想拿一个结构清晰的小型处理器控制单元,练手 VHDL 时序逻辑和仿真流程的工程师。下面按“架构怎么搭 → 工程怎么建 → 代码怎么写 → 坑在哪 → 怎么改”的顺序拆。
2. PARWAN 架构拆解:运算段、控制段、寻址段怎么串成一条指令流
拿到这份资料,先别急着打开工具建工程。PARWAN 这个处理器的结构图看着线很多,但把它按功能切成三块之后,每一块的职责其实很干净:左边是运算,中间是控制,右边是寻址和取数。真正决定项目难度的不是 ALU 有几种运算,而是中间那块控制段——它要在正确的节拍上,把正确的使能信号送到正确的部件上。理解这一点,后面的 VHDL 才有落笔的地方。
2.1 三段式划分:左运算、中控制、右寻址
从资料里的结构描述看,PARWAN 的数据通路可以按位置划分成三个功能区。左边是运算区,核心是 ALU 和移位器 SHU,负责算术运算与移位操作,运算结果和状态标志分别送往寄存器堆和状态寄存器 SR。中间是控制区,包含指令寄存器 IR 和控制器 CONTROLLER,它接收 IR 里的操作码,翻译成一组控制信号,决定下一个节拍谁该动。右边是寻址与取数区,核心是程序计数器 PC 和存储器地址寄存器 MAR,PC 里的地址送进 MAR,通过地址总线 ADBUS 发起访存,取回的数据再分流到指令通路或数据通路。
这条链路里有个容易被忽略的细节:PC 的值并不只往 MAR 送。资料里提到“也有从 PC 接到 ALU 的”,意思是 PC 相对寻址这类方式需要把 PC 和偏移量一起丢进 ALU 算一次。这意味着控制段在译码阶段必须能区分“这条指令的操作数地址来自 MAR 还是来自 ALU 输出”,否则寻址方式一多就会串。我一般会在控制段里单独留一个地址来源选择信号,哪怕当前只实现两种寻址方式,也先把口子开出来。
整个系统的宏观流程可以概括成一句话:控制 → 取址/取数据 → 运算 → 控制。这个闭环里,控制段既是起点也是终点,它发起的每一次访存都要在下一个控制节拍里被“收账”。这正是状态机存在的意义——把这条闭环切成离散的、每个时钟沿只前进一格的小步骤。
2.2 控制段的信号清单:PC、MAR、IR、SR、ALU 各拿到什么
把控制段当成一个黑匣子,它的输入只有操作码、状态标志和时钟复位,输出则是一把扇出的使能线和选择线。资料里的信号表列得比较散,我按“这个信号在哪个阶段有效”重新归了一次类,做成下面这张表,写 VHDL 实体的时候可以直接照着声明端口。
| 信号名 | 方向 | 作用 | 主要有效阶段 |
|---|---|---|---|
pc_inc | 输出 | PC 自增,指向下一条指令 | 译码结束、指令已锁存后 |
pc_load | 输出 | PC 装载跳转目标地址 | 执行跳转类指令时 |
mar_load | 输出 | 把地址锁进 MAR | 取指首拍、取操作数首拍 |
mem_read | 输出 | 启动一次存储器读 | 与mar_load同一拍或下一拍 |
ir_load | 输出 | 把数据总线上的指令锁进 IR | 取指完成那一拍 |
alu_op | 输出 | 选择 ALU 的运算类型 | 执行阶段 |
sr_load | 输出 | 更新状态寄存器(零标志、进位等) | 执行阶段末尾 |
done | 输出 | 标志一个指令周期结束 | 回写阶段 |
这张表里最需要提前想清楚的是mar_load和mem_read的时序关系。存储器如果是同步读(片内 Block RAM 基本都是),地址锁存和读使能可以同拍发出,数据在下一拍才有效;如果是异步读的模型,同拍就能拿到数据。这两种时序在行为仿真里差别很明显,但在综合后可能被优化成一样,所以千万别只靠后仿判断自己写对了。
另一个要留意的是sr_load。状态寄存器 SR 里装的是 ALU 运算产生的标志位,它必须在 ALU 输出稳定的那一拍才允许写入。如果把sr_load和alu_op放在同一拍,而 ALU 是组合逻辑,那一拍写入的其实是上一轮的旧结果。常见做法是把alu_op放在执行状态,sr_load挪到下一个回写状态,晚一拍,稳。
2.3 一条指令的完整节拍:把状态和阶段对齐
架构图看懂了,接下来要把它翻译成状态机。这里的对应关系是:结构图上的每一个“阶段”,在状态机里就是一个或几个状态;每个状态下有效的那些控制信号,就是状态机输出的那几根线。
我按资料里的流程走一遍典型的加法指令。第一个状态发mar_load和mem_read,把 PC 指向的地址送出去,同时 PC 此时还没有动——这是有意的,因为 PC 的自增要等指令真的取回来才算数,否则取指失败就没法重试。第二个状态把数据总线上的指令锁进 IR,同时发pc_inc,这一拍结束后 PC 已经指向下一条,IR 里是当前这条。第三个状态根据操作码判断:如果这条指令需要访存取操作数,就再发一次mar_load+mem_read,进入取操作数状态;如果操作数已经在寄存器里,直接跳到执行状态。
第四个状态是执行,alu_op根据操作码的低位选一个运算,结果送往目的寄存器。第五个状态回写,置起done,然后无条件回到取指状态,开始下一轮。整个过程五到六个时钟周期,每个周期里只有一组使能信号是高的,这就是控制段“一次只推进一步”的纪律。
提示:把这条节拍序列手画在纸上,标出每个状态对应哪几根信号拉高,再开始写 VHDL。这一步花十分钟,能省掉后面调波形的一小时。
3. 从建工程到出波形:Spartan-3E 上跑通一次完整流程
架构理清了,接下来是把它变成能仿真的东西。资料里的实验步骤写的是老一代 ISE 工具链的操作路径,菜单项和现在的 Vivado 完全不一样,但流程骨架是一样的:建工程选器件 → 加设计源文件 → 加测试平台 → 跑仿真 → 综合 → 后仿。这一节把每一步的实际操作和容易出错的地方都过一遍,照着做基本能一次跑通。
3.1 新建工程与器件选型:XC3S500E 和 FG320 这两个参数不能填错
第一步是建工程。菜单路径是 File → New Project,填工程名和存放路径,路径里千万别带中文和空格——老版本工具对这两样东西的容忍度极低,报错信息还特别晦涩,往往只说“找不到文件”,实际原因是路径里有中文。
第二步是选器件,这一步是整个流程里最容易埋雷的地方。资料里明确写了开发板用的是 Spartan-3E 系列的 XC3S500E 芯片,FG320 封装。器件型号、封装、速度等级这三个参数必须和板子实物完全一致,因为综合工具要根据型号决定片内有多少逻辑单元、多少个 Block RAM、引脚怎么分布。型号填大了,综合能过但下载到板子上直接不认;填小了,综合阶段就报资源不够。封装填错的后果更直接——引脚约束文件里的引脚编号全部对不上,工具会报一堆“引脚不存在”的错误。
工程建好之后,目录里会自动生成几个文件。我一般会把源文件按功能分开放,避免所有.vhd都堆在根目录:
| 文件 / 目录 | 类型 | 作用 |
|---|---|---|
par_control_unit.vhd | VHDL 设计源文件 | 控制段状态机本体 |
par_control_unit_tb.vhd | VHDL Test Bench | 仿真激励,只用于仿真不参与综合 |
par_control_unit.ucf | 约束文件 | 引脚分配与时钟约束 |
proj/ | 工程目录 | 工具自动生成的中间文件,不手动改 |
约束文件这一项资料里没展开,但只要你想上板验证就必须写。约束文件里至少要有两件事:时钟信号绑到板子上的全局时钟引脚,以及复位按键、LED 指示这些外设的引脚分配。引脚编号必须照着开发板原理图抄,猜不得。
3.2 挂上 VHDL Test Bench:源文件关联和顶层设置
设计文件写好后,要给它配一个测试平台。操作路径是 Project → New Source,在 Select Source Type 窗口左侧选 VHDL Test Bench,右侧 File Name 填par_control_unit_tb。注意这里生成的只是一个空壳模板,工具会自动帮你把被测实体的端口声明和例化语句填进去,但激励逻辑要自己写。
接下来这一步很多人会栽:新建 Test Bench 之后会弹出一个关联文件的选择框,必须把par_control_unit.vhd关联上去。如果跳过这步,仿真的时候工具找不到被测实体,报的错误是“component not found”,看起来像是代码写错了,实际只是没关联。
关联完成后还有一件事要确认——仿真顶层是谁。综合的顶层应该是设计实体par_control_unit,仿真的顶层应该是测试平台par_control_unit_tb。这两个设置在不同的地方:综合顶层在综合属性里设,仿真顶层在仿真属性里设。搞混了的结果是综合阶段把 Test Bench 也拿去综合,而 Test Bench 里有wait for这种不可综合的语句,综合器直接报错退出。
提示:Test Bench 文件在综合时应该被自动排除,如果没被排除,手动在文件属性里把它的“综合”选项关掉。
3.3 三步仿真:行为仿真、综合、后仿真的差别
仿真不是一个动作,是三个。资料里的实验步骤只说了“使用仿真工具验证状态机的正确性”,实际上这三步每一步验证的东西都不一样,跳过任何一步都可能把问题带到板子上。
行为仿真(Behavioral Simulation)跑的是纯 VHDL 语义,没有门延迟,没有布线延迟,信号在时钟沿上瞬间翻转。这一步验证的是逻辑正确性:状态转移对不对,输出信号在该拉高的地方有没有拉高。绝大多数设计错误在这一步就能抓出来,也是迭代最快的一步。
综合(Synthesize)是把 VHDL 翻译成门级网表。这一步不验证功能,验证的是“你写的东西能不能变成硬件”。常见的综合报错包括:组合逻辑里漏了默认赋值导致推断出锁存器、信号在多个进程里被赋值导致多驱动、位宽不匹配。综合报告里的警告要一条条看,尤其是“latch inferred”这类,它不会阻止综合,但一定会在后仿或者上板时变成玄学问题。
后仿(Post-Route Simulation)跑的是带布线延迟的网表。这一步验证的是时序:在真实的延迟下,建立时间和保持时间还满不满足。资料里的“后仿结果”截图就是这一步的产物。后仿的波形上会出现毛刺,组合逻辑输出在时钟沿附近会有短暂的抖动,这些都是正常的。真正要看的不是毛刺,而是采样时刻——在时钟有效沿那一瞬间,各个信号是不是都稳定在期望值上。
三步的顺序不能乱。行为仿真没过就去做后仿,等于在错误的逻辑上验证时序,白费时间。
3.4 从波形读出状态:把状态编码和阶段对上
后仿波形出来之后,第一件事不是截图,是把自己的状态编码和波形上的信号对上。综合工具默认会给你定义的枚举类型重新编码,你写的是S_FETCH、S_DECODE,波形上看到的可能是一串000、001。
处理办法有两个。一是在综合选项里打开“保留层次结构”和“保留枚举编码”,这样波形上能直接显示状态名;二是干脆在代码里把状态定义成std_logic_vector常量,自己指定编码,波形上看到的就是你写的值。第二种更可控,代价是丢了枚举类型的类型检查。
波形上要重点看三样东西。第一,每个状态停留的时钟周期数是不是符合预期——取指一拍、译码一拍、执行一拍,如果某个状态停了两拍,说明次态逻辑写错了或者复位没释放干净。第二,控制信号的拉高时刻是不是落在正确的状态区间内,尤其是mem_read这种和存储器打交道的信号,早一拍晚一拍都会导致读错数据。第三,状态跳转的路径是不是覆盖了你设计的所有分支,条件跳转类的指令有没有真的跳到目标状态。
我在看波形的时候习惯把时间轴缩放到刚好容纳一个完整的指令周期,这样一眼就能数出几拍。缩得太宽,细节全糊在一起;缩得太窄,看不到状态之间的因果关系。
4. 控制段状态机的 VHDL 实现:两段式写法逐段讲解
前面铺垫了架构和流程,这一节落到代码。控制段状态机是个典型的有限状态机,写法有好几种:一段式(所有逻辑塞一个进程)、两段式(时序部分管状态寄存器,组合部分管次态和输出)、三段式(状态寄存器、次态逻辑、输出寄存器各一个进程)。资料里给的实现偏两段式,这也是课程作业里最常见的写法——代码短、逻辑清楚、综合结果也够用。下面把这套代码逐段拆开讲,包括每一行为什么这么写。
4.1 实体声明:把控制信号一次列全
实体的作用是定义这个模块对外长什么样。控制段的输入很少,只有时钟、复位、操作码和状态标志;输出则是一把扇出的使能线。
library ieee; use ieee.std_logic_1164.all; entity par_control_unit is port ( clk : in std_logic; -- 系统时钟,板上一般是几十 MHz 的晶振 rst_n : in std_logic; -- 低电平复位,按下按键为 '0' op_code : in std_logic_vector(3 downto 0); -- 来自 IR 的操作码,决定进入哪个执行分支 zero : in std_logic; -- 来自 SR 的零标志,条件跳转要用 pc_inc : out std_logic; -- 送 PC:自增,指向下一条指令 pc_load : out std_logic; -- 送 PC:装载跳转目标 mar_load : out std_logic; -- 送 MAR:锁存地址 mem_read : out std_logic; -- 送存储器:读使能 ir_load : out std_logic; -- 送 IR:锁存指令 alu_op : out std_logic_vector(2 downto 0); -- 送 ALU:运算类型选择 sr_load : out std_logic; -- 送 SR:更新状态寄存器 done : out std_logic -- 一条指令执行完毕的标志 ); end entity par_control_unit;端口声明有两点要注意。一是复位信号用rst_n这种带_n后缀的命名,一眼就能看出是低有效,比写reset再靠注释说明要可靠得多。二是alu_op的位宽定成 3 位,是因为 ALU 的运算类型在这个规模的处理器里通常不超过 8 种;如果你的设计里运算种类更多,这里要相应加宽,同时记得在输出赋值的地方同步改位宽,否则综合会报位宽不匹配。
还有一个隐含的决策:op_code单独作为一个端口进来,而不是从 IR 内部取。这是为了可测性——把操作码做成外部输入,测试平台才能在不驱动真实 IR 的情况下,直接给状态机喂各种操作码,覆盖所有分支。如果状态机内部直接读 IR 的输出,测试就得连 IR 一起驱动,复杂度翻倍。
4.2 两段式状态机:状态寄存器与次态逻辑分离
架构体分成两个进程。第一个进程只做一件事:在时钟沿上把次态赋给当前态。第二个进程做两件事:根据当前态和输入算次态,同时输出控制信号。
architecture rtl of par_control_unit is -- 状态定义:顺序和编码交给综合工具决定,代码里只用名字 type state_t is (S_FETCH, S_DECODE, S_LOAD, S_EXEC, S_WB, S_HALT); signal cur_state, nxt_state : state_t; begin -- 进程一:时序逻辑,只负责状态寄存器的翻转 seq: process(clk, rst_n) begin if rst_n = '0' then cur_state <= S_FETCH; -- 异步复位到取指态,保证上电后从第一条指令开始 elsif rising_edge(clk) then cur_state <= nxt_state; -- 每个时钟沿前进一格 end if; end process; -- 进程二:组合逻辑,算次态 + 输出控制信号 comb: process(cur_state, op_code, zero) begin -- 输出默认值必须写在 case 之前,否则综合会推断出锁存器 pc_inc <= '0'; pc_load <= '0'; mar_load <= '0'; mem_read <= '0'; ir_load <= '0'; alu_op <= "000"; sr_load <= '0'; done <= '0'; nxt_state <= cur_state; -- 次态默认保持,防止意外跳转 case cur_state is when S_FETCH => mar_load <= '1'; -- PC 的值打进 MAR mem_read <= '1'; -- 发起一次存储器读 nxt_state <= S_DECODE; when S_DECODE => ir_load <= '1'; -- 数据总线上的指令锁进 IR pc_inc <= '1'; -- 同时 PC 指向下一条 if op_code = "1111" then nxt_state <= S_HALT; -- 停机指令走单独分支 else nxt_state <= S_LOAD; end if; when S_LOAD => mar_load <= '1'; -- 取操作数地址 mem_read <= '1'; nxt_state <= S_EXEC; when S_EXEC => alu_op <= op_code(2 downto 0); -- 简化处理:低三位直接当运算码 sr_load <= '1'; nxt_state <= S_WB; when S_WB => done <= '1'; -- 一个指令周期到此结束 nxt_state <= S_FETCH; when S_HALT => done <= '1'; nxt_state <= S_HALT; -- 停在停机态,等复位才能出来 when others => nxt_state <= S_FETCH; -- 兜底分支,全状态覆盖时会被综合优化掉 end case; end process; end architecture rtl;这段代码的核心逻辑在第二个进程。case之前的八行默认赋值是关键:VHDL 的组合逻辑进程如果某条路径上没给信号赋值,综合工具会认为“这个信号需要保持上一次的值”,于是推断出一个锁存器。锁存器在 FPGA 里是稀缺资源,而且在时序分析里属于不受欢迎的结构,容易产生毛刺和竞争。把所有输出先赋默认值,再在case里按需拉高,就从根本上杜绝了这个问题。
nxt_state <= cur_state;这一行是同样的道理,只是针对次态。它的作用是让每个状态的默认行为是“原地不动”,只有在case分支里显式改了次态,状态才会跳转。这样做的好处是新增状态时不容易漏写——哪怕你先加了when S_NEW =>分支但没写完整的跳转逻辑,它也只是停在那,不会跳到某个莫名其妙的地方。
when others分支看起来多余,因为上面已经把 6 个状态全覆盖了。但这个分支留着有实际价值:后续加状态时如果忘了在case里补分支,综合工具不会报错,而是落进others直接回取指态。这比随机跳转好排查得多。
参数说明上再补两句。alu_op <= op_code(2 downto 0)这种“操作码低位直接当运算码”的写法,只适合操作码编码和 ALU 运算编码恰好一致的情况。实际项目里一般要加一张译码表,把操作码映射成 ALU 的运算码,中间可能还要处理非法操作码。另外sr_load放在S_EXEC状态,意味着 ALU 是组合逻辑时,写入 SR 的其实是这一拍的运算结果——如果 ALU 是时序输出,这一拍写进去的会是旧值,必须挪到S_WB。这是两段式写法里最容易踩的时序坑之一。
4.3 输出默认值为什么必须写在 case 之前
这一点值得单独拎出来讲,因为它是这类作业里最高频的翻车点。
假设把默认赋值删掉,直接写case,在S_FETCH状态里只给mar_load和mem_read赋值,其他的pc_inc、ir_load、alu_op、sr_load、done在这个状态下都没有赋值。VHDL 语义规定:进程在没有信号赋值时,信号保持上一次的值,这个“保持”在综合后就是锁存器。
行为仿真里锁存器的表现和你的直觉可能一致,因为它建模的是同一套语义,所以波形看着挺正常。但一旦综合成网表,锁存器对毛刺极其敏感,实际板子上的行为就和仿真对不上了——这就是那种“仿真好好的,上板就不对”的经典玄学问题。
我一般的习惯是,在case前面把所有输出信号列一遍,一个不漏。这个习惯写起来啰嗦,但每次省下的调试时间都是实打实的。写完之后可以扫一眼综合报告,确认里面没有latch inferred的警告,有就说明哪里漏了。
4.4 Test Bench 的激励与观察窗口
设计写完了,需要一个测试平台去驱动它。测试平台不参与综合,所以可以随便用wait for、assert这些不可综合的语句。
library ieee; use ieee.std_logic_1164.all; entity par_control_unit_tb is end entity par_control_unit_tb; architecture sim of par_control_unit_tb is constant CLK_PERIOD : time := 20 ns; -- 对应 50MHz,按实际时钟改 signal clk : std_logic := '0'; signal rst_n : std_logic := '0'; signal op_code : std_logic_vector(3 downto 0) := (others => '0'); signal zero : std_logic := '0'; signal pc_inc, pc_load, mar_load, mem_read : std_logic; signal ir_load, sr_load, done : std_logic; signal alu_op : std_logic_vector(2 downto 0); begin -- 被测实体例化,端口名必须和设计文件一致 dut: entity work.par_control_unit port map ( clk => clk, rst_n => rst_n, op_code => op_code, zero => zero, pc_inc => pc_inc, pc_load => pc_load, mar_load => mar_load, mem_read => mem_read, ir_load => ir_load, alu_op => alu_op, sr_load => sr_load, done => done ); -- 时钟生成:半个周期翻转一次 clk <= not clk after CLK_PERIOD / 2; stim: process begin rst_n <= '0'; -- 先复位,保持两个周期 wait for 2 * CLK_PERIOD; rst_n <= '1'; op_code <= "0001"; -- 喂一条普通运算指令 wait for 6 * CLK_PERIOD; -- 观察完整的 FETCH 到 WB 一轮 op_code <= "1111"; -- 换成停机指令,验证分支跳转 wait for 6 * CLK_PERIOD; assert false report "simulation finished" severity failure; -- 用断言主动结束仿真 end process; end architecture sim;这段激励代码有两个设计取舍值得说。第一,时钟周期用constant定义,而不是在每个wait for里硬写20 ns。这样改时钟频率只需要改一个地方,仿真时长也自动跟着变。第二,复位保持了两个周期才释放,而不是一个。一个周期在理论上够,但某些工具的复位释放和第一个时钟沿之间的竞争容易让波形上出现一拍的抖动,保持两拍就干净了。
激励的覆盖度也要考虑。这段代码只测了两条指令——一条普通运算、一条停机。对于一个有多个操作码分支的状态机,至少要覆盖:普通的访存指令、不需要访存的寄存器指令、条件跳转指令(zero分别取 0 和 1 各测一遍)、以及停机指令。每条指令单独跑一轮,用注释把波形上的区段隔开,看波形的时候就能对着注释一段段核对。
assert false ... severity failure这行是用来终止仿真的。没有它,仿真会一直跑到你设定的仿真时长上限,这段时间里波形上全是复位后的静止状态,白白浪费时间。加上这行,激励跑完立刻停下,波形窗口里就只剩有效区段。
5. 避坑清单:这类大作业最容易翻车的五个地方
前面讲的是“应该怎么做”,这一节讲“实际做的时候会怎么错”。下面这几条是我和身边人踩过或者见别人踩过的,按现象、原因、解决三个部分写,遇到对应症状可以直接对着排查。
5.1 现象:行为仿真波形正常,综合报 latch inferred
原因:组合逻辑进程里,某个输出信号在部分分支上没有赋值,综合工具推断出锁存器来“保持”这个信号的历史值。两段式状态机里,只要默认赋值少写一行,就会出现这个问题。
解决:在case语句之前把进程内所有输出信号统一赋默认值,一个不留。改完重新综合,确认报告里不再有latch inferred字样。顺带检查次态信号nxt_state有没有默认赋cur_state,漏了同样会推断锁存器。
5.2 现象:状态机卡在某个状态不动,done一直是 0
原因:次态逻辑里某个分支没有给nxt_state赋值。因为组合进程里信号会保持上一轮的值,看起来就像卡住了。常见于if-else只写了if分支没写else,或者case里漏了某个状态。
解决:先在case之前写nxt_state <= cur_state;兜底,保证默认不跳转。然后逐个状态检查,确保每个状态都有明确的出口。如果波形上显示卡在某个状态,重点查这个状态内部的if语句有没有覆盖所有条件组合——条件不满足又没写else的时候,次态就保持在原地了。
5.3 现象:仿真里数据不对,alu_op明明是对的,算出来的结果是上一条指令的
原因:sr_load和alu_op放在了同一个状态里。ALU 如果是组合逻辑,这一拍的运算结果确实已经出来了,理论上同拍写入 SR 是可行的。但如果 ALU 到 SR 之间还有一级寄存器或者较长的组合路径,采样时刻就落在结果稳定之前,写进去的是旧值。
解决:把sr_load往后挪一个状态,alu_op在执行状态,sr_load在回写状态。多一个周期换确定性,在小规模设计里完全划算。改完之后看波形,确认 SR 更新的时刻在 ALU 输出稳定之后。
5.4 现象:下载到板子上没有任何反应,或者引脚输出电压不对
原因:约束文件里的引脚编号和开发板原理图对不上。这种情况在换开发板或者抄别人的约束文件时特别常见,因为不同板子的引脚分布完全不同。另一个常见原因是时钟引脚没有绑到全局时钟网络,导致时钟信号走了普通布线资源,延迟大、抖动大,状态机行为完全不可预测。
解决:引脚分配一律照着手上这块板子的原理图逐条核对,时钟和复位优先绑到全局时钟、全局复位的专用引脚。综合实现后看静态时序分析报告,如果时钟路径报出很高的偏斜,基本就是没走全局网络。
5.5 现象:后仿波形上出现大量毛刺,状态跳转看起来乱七八糟
原因:组合逻辑输出在后仿里会体现真实的门延迟,不同路径的延迟不一致,在时钟沿附近就会产生短暂的中间态。这不是设计错误,是后仿本身的性质。真正的问题是有些毛刺恰好被下游的时序单元采到了。
解决:先分清哪些毛刺是无害的、哪些被采到了。判断方法是把时钟有效沿的位置在波形上用光标标出来,只看沿上那一刻各个信号的值,沿与沿之间的抖动可以忽略。如果有毛刺被采到,说明路径延迟超过了时钟周期,要么降频,要么在关键路径上插一级寄存器打断组合逻辑。
6. 二次开发:把状态机从“能跑”改到“能扩展”
这套代码拿来交作业是够的,但真要往上加功能,两段式写法会开始别扭。我在类似的小型控制器上做过几轮改造,下面这几条是按投入产出比排的,从最划算的开始。
第一条,把两段式改成三段式,输出信号用寄存器打一拍。改法很简单:再加一个时序进程,在时钟沿上把组合进程算出来的输出锁存一次,端口输出直接接这些寄存器。代价是所有控制信号相对状态晚一个周期有效,需要在次态逻辑里把时序重新对一遍。收益是输出无毛刺、时序路径短、后仿和上板行为一致。对于要上板演示的项目,这一步的收益远大于改造成本。
第二条,把状态编码参数化。现在代码里状态是枚举类型,编码由工具决定,波形上看到的是000、001。改成一个constant数组显式指定编码,或者干脆用generic把状态数传进来,后续加状态时不用改综合选项就能在波形上直接看到有意义的编码。改动量很小,调试效率提升明显。
第三条,把操作码译码从状态机里剥出去,做成独立的组合逻辑模块。现在alu_op <= op_code(2 downto 0)这种写法把译码规则硬编码在状态机里,加一种新指令就要动状态机。剥出去之后,状态机只负责时序,译码模块只负责映射,两边可以独立改、独立测。验证方法也简单:给译码模块单独写一个测试平台,把 16 种操作码全喂一遍,看输出的运算码和使能组合对不对,不用管时序。
第四条,加一个指令计数器和非法指令检测。计数器在每个done拉高时加一,可以用来验证一段程序跑完的周期数是否符合预期。非法指令检测是在译码阶段判断操作码是否在合法范围内,不在就跳到一个S_ERROR状态并点亮一个 LED。这两个都不影响主流程,但能让调试过程有一个可观测的量化指标——尤其是周期计数,它能帮你发现那种“功能对了但多跑了两拍”的隐蔽问题。
验证改造是否成功,我习惯用同一套回归激励:固定几条指令序列,在改造前后各跑一遍行为仿真,把每拍的输出信号值导出成文本对比。如果两次结果除了预期的时序偏移之外完全一致,说明改造没有引入功能回归。这个习惯是从一次惨痛经历来的——当时为了消毛刺把输出打了拍,结果忘了同步调整mem_read和mar_load的相对时序,存储器读出来的数据错位了一拍,波形上完全看不出来,直到上板跑真实程序才发现。从那以后我每次动控制信号的时序,都强制走一遍这个前后对比,不看一眼两份导出的文本都不敢往下做。
代码和报告都在那份资料里,状态机那段照着第 4 章的三个代码块敲一遍,基本就能跑出对应的后仿波形。真要往下做,从输出寄存化开始改,收益最大也最容易验证。希望帮到你。
本文还有配套的精品资源,点击获取