简介:这份FPGA数字系统设计大作业文档面向高校软工院选课程学生及正在入门VHDL与有限状态机的读者,围绕PARWAN处理器架构展开,重点整理CONTROL-SECTION状态机的设计思路、实现流程与后仿结果,可用于课程作业参考、实验复盘和查漏补缺。内容涵盖运算、控制、寻址三部分结构,PC、MAR、ALU、SR、IR与CONTROLLER等部件的作用,以及取址、运算阶段的状态切换,并给出Spartan3E XC3S500E芯片上的工程创建、测试文件编写、实验代码和仿真截图,便于对照理解状态机如何控制处理器流程。包内共1个doc文件,约763KB,篇幅集中、查阅方便。已有554人学习,适合需要补充VHDL设计案例、梳理PARWAN处理器工作过程或核对后仿结果的学生使用。
1. 先把控制段选定再动手:一份 FPGA 数字系统设计大作业的拆题顺序
软件工程专业的学生拿到 FPGA 数字系统设计大作业,最常见的动作是打开集成开发环境建工程,然后对着空白源文件发呆——题面只有两句话:讲清 PARWAN 处理器的架构和特点,再从 DATA-SECTION 或 CONTROL-SECTION 里挑一个部件,交出 VHDL 设计和后仿结果。看着简单,真要交出一份能被追问的作业,难点不在写代码,在于你事先有没有想清楚哪部分该先做。我的建议是先确定控制段,因为控制段的信号全部来自架构图,看得见、数得清,写完状态机再回头看运算部分,很多细节会顺着信号名自己浮现出来。适合直接照搬的读者:赶大作业的本科生,以及想复习一遍经典处理器控制通路的从业者。
2. PARWAN 处理器架构拆解:运算、控制、寻址三段与关键寄存器的职责边界
架构综述是这份作业的第一问,分值不低,但它不是让你抄一段概述交差。真正要写出来的是“哪部分负责什么、信号怎么走、为什么这么分”。PARWAN 的结构图可以粗暴地分成左中右三块:左边运算,中间控制,右边寻址取数。能把这句白话翻译成一段有寄存器名字、有信号流向的说明,第一问就算过关了。
2.1 三段式数据通路:从 PC 到 ALU 再到 SR 的信号流
从整体看,执行过程是一个闭环:控制 → 取址/取数据 → 运算 → 回到控制。取址环节的主角是 PC 和 MAR,PC 存当前指令地址,MAR 锁存要送上总线的地址,地址经 ADBUS 送到存储体,取回的数据进 IR。IR 的操作码字段送给控制器,控制器据此决定下一个状态要发哪些控制信号。数据部分从 cache 取出操作数后送到 ALU 做算术逻辑运算,需要移位时过一遍 SHU,运算结果写回寄存器堆,同时把零标志、进位标志这类状态记录进 SR,供条件转移类指令使用。
这条链路里有两个容易讲漏的点。一是 PC 和 ALU 之间也存在通路,某些寻址方式(比如相对寻址)需要把 PC 的值送进 ALU 参与地址计算,再通过 ADBUS 取数,这是“寻址也可能需要运算”的具体体现。二是 SR 的定位,它不是简单的结果缓存,而是控制段的输入源之一,条件转移指令能不能跳,取决于 SR 里的标志位。把这两点写进综述,第一问就从“看懂了图”升级成“读懂了数据流”。
按我一般的做法,综述段落按“部件—职责—输入—输出”四要素展开,每个部件写两到三句,不要写成散文。部件之间的耦合关系单独一段讲,尤其是控制段如何同时依赖 IR 和 SR 两路输入。这样写出来的综述有骨架,答辩时被问“为什么 ALU 要接 PC”也答得上。
2.2 控制段的输入输出清单:哪些信号必须由状态机驱动
把架构图落到代码之前,先列一张信号表。这张表是整个设计的合同,后面写实体、写测试平台、写波形注释都靠它。PARWAN 控制段的端口通常可以归成三组:时钟复位类、来自数据通路的输入类、送往各部件输出类。
| 信号名 | 方向 | 连接对象 | 作用说明 |
|---|---|---|---|
| clk | in | 全局时钟 | 状态机的推进节拍,所有时序逻辑共用 |
| rst_n | in | 外部复位 | 低有效异步复位,把状态机拉回取指态 |
| opcode | in | IR 操作码字段 | 决定进入执行态后发哪种运算控制 |
| z_flag / c_flag | in | SR | 条件转移、条件跳转的判断依据 |
| pc_inc | out | PC | 顺序执行时地址加一 |
| pc_load | out | PC | 转移指令时装载目标地址 |
| mar_load | out | MAR | 把地址锁存进地址寄存器 |
| mem_read / mem_write | out | 存储体 | 读使能与写使能,二者不可同时有效 |
| ir_load | out | IR | 取回的指令打入指令寄存器 |
| reg_we | out | 寄存器堆 | 结果写回使能 |
| alu_op | out | ALU | 运算类型选择,位宽按指令集定 |
| shu_op | out | SHU | 移位方向与移位量选择 |
| sr_we | out | SR | 状态标志更新使能 |
| halt | out | 全局 | 停机指示,进入停机态后保持 |
这张表的价值在于反向检查:架构图上每一根从控制段出来的线,表里必须有对应项;表里每一项,后面状态机的输出逻辑里必须被驱动。我见过不少作业的翻车点就在这里——综述里写了“控制器控制 MAR”,代码里根本没有 mar_load 这个端口,答辩老师一眼就能看出来。
2.3 从架构图到端口列表:把“看得懂”变成“写得出”
列完表之后不要急着写 VHDL,先做一次周期归属划分:每个输出信号在哪一个状态有效。这一步是架构分析和状态机设计之间的桥。
mar_load 和 mem_read 只在取指阶段有效,因为地址只在取指时送上总线;ir_load 只在译码阶段有效,因为指令数据是取指阶段末尾才从存储体返回的;alu_op 和 shu_op 在执行阶段有效;reg_we 和 sr_we 在写回阶段有效;pc_inc 在写回阶段末尾发,让 PC 指向下一条指令。pc_load 是个例外,它出现在执行阶段的转移类指令里,因为目标地址在执行阶段算出来。
做完这一步,你会发现有些信号天然互斥(读和写不会同时有效),有些信号天然绑定(mar_load 和 mem_read 基本成对出现)。把这些关系记下来,写输出逻辑时就不用一个状态一个状态地硬凑,而是按语义分组译码。这也解释了为什么控制段值得先做:它的信号语义最规整,做顺了,DATA-SECTION 那部分只是照搬同样的方法论。
3. 控制段状态机设计:FETCH/DECODE/EXEC/WB 四态主干与转移条件
状态机设计是这份作业的核心交付物,也是后仿截图的主角。划分状态的方法有很多种,按指令功能划、按时序节拍划、按微操作划,教学项目里最稳妥的是按时序节拍划——一条指令的执行被切成若干个时间步,每个时间步对应一个状态,同一状态里允许并行发出多个不冲突的控制信号。
3.1 状态怎么划:四个工作态加一个停机态
我一般会划成五个状态:S_FETCH、S_DECODE、S_EXEC、S_WB、S_HALT。
S_FETCH 负责取指。这个状态下 MAR 锁存 PC 送来的地址,同时发出存储器读使能。到了这个状态的时钟上升沿之后,存储体的数据线上出现指令内容。
S_DECODE 负责译码。取回的指令打入 IR,同时控制器读 IR 的操作码字段。如果操作码是停机指令,直接进 S_HALT,不再执行后续状态。
S_EXEC 负责执行。ALU 按 opcode 选定的运算类型工作,需要移位的走 SHU。条件转移类指令在这个状态读取 SR 的标志位,决定 pc_load 是否有效。
S_WB 负责写回。运算结果写进寄存器堆,状态标志写进 SR,PC 递增指向下一条指令。
S_HALT 是终止态,进入后 halt 信号拉高并保持,只有复位能把它拉回 S_FETCH。
为什么不把取指和执行合并成一个状态?因为取指需要访存,执行需要运算,两者用的硬件资源不同,合并会让输出逻辑变得含混,波形上也看不出清晰的阶段划分。教学项目的状态机宁可多一个状态,也不要在一个状态里塞两种语义。
3.2 状态编码与信号命名:枚举、one-hot 与命名前缀
状态编码通常有三种选择:二进制紧凑编码、独热码(one-hot)、格雷码。VHDL 里最省事的写法是定义一个枚举类型,让综合工具自己选编码:
-- 状态枚举:可读性优先,编码交给综合工具 type state_t is (S_FETCH, S_DECODE, S_EXEC, S_WB, S_HALT); signal cur_state, nxt_state : state_t;枚举的好处是代码自解释,波形工具里可以把枚举值显示成名字而不是一串二进制,后仿截图直接能看懂。缺点是综合结果依赖工具策略,在某些器件上可能编成紧凑二进制,组合译码路径会稍长。这个项目状态只有五个,译码延迟完全不是瓶颈,用枚举没有任何问题。
独热码适合状态数量多、速度优先的场合,每个状态一位,译码等于一次与门。教学项目里用独热码的唯一好处是波形上每一拍只有一位是 1,看转移关系很直观。如果老师强调“能看到状态位”,可以改成独热码,代价是代码里要手写常量定义,不如枚举清爽。
命名上给自己定两条规矩:状态一律 S_ 前缀加全大写,信号一律小写加下划线,低有效信号加 _n 后缀。这三条规矩看着琐碎,但当你写到第三个进程、端口列表有十几个名字的时候,命名混乱是最大的时间黑洞。
3.3 状态转移表:把每个状态的出口条件写死
状态图画在纸上是一回事,写成代码是另一回事,中间必须有一张转移表把出口条件固定下来。这张表同时也是后仿时核对波形的清单。
| 当前状态 | 转移条件 | 次态 | 该状态有效的控制输出 |
|---|---|---|---|
| S_FETCH | 无条件 | S_DECODE | mar_load=1, mem_read=1 |
| S_DECODE | opcode ≠ 停机码 | S_EXEC | ir_load=1 |
| S_DECODE | opcode = 停机码 | S_HALT | ir_load=1, halt=1 |
| S_EXEC | 指令需要写回 | S_WB | alu_op / shu_op 按 opcode 译码 |
| S_EXEC | 指令不需要写回 | S_FETCH | alu_op, pc_inc=1 |
| S_WB | 无条件 | S_FETCH | reg_we=1, sr_we=1, pc_inc=1 |
| S_HALT | 复位有效 | S_FETCH | halt=1 |
表里最容易被忽略的是“不需要写回”这条分支。存储类指令、转移类指令的结果不落在寄存器堆里,如果强行走 S_WB,会多发一个 reg_we,把无关寄存器写脏。这是功能仿真阶段最典型的错误之一:单条指令测不出问题,跑一段小程序就发现寄存器值莫名其妙变了。把这条分支写进转移表,代码里就是一个 if 判断的事。
另一个细节是 S_EXEC 到 S_HALT 没有直接通路。停机指令在译码阶段就已经识别出来,不必进执行态。这样设计的好处是停机判断只在一个地方出现,不会出现“执行态里也要判停机”的重复逻辑。
4. VHDL 实现:从实体端口清单到三进程可综合写法
端口表和状态表都有了,落到代码就是把它们翻译一遍。控制段这种规模型号的状态机,推荐三进程写法:一个进程管状态寄存器的时序更新,一个进程管次态的组合逻辑,一个进程管输出的组合译码。分开写的好处是每一块职责单一,改状态划分不用动输出逻辑,改输出不用碰转移条件。
4.1 实体与端口声明:接口先定死,后面才好改
实体部分严格照第 2 章那张信号表来写,一个不多一个不少:
library ieee; use ieee.std_logic_1164.all; entity par_control_unit is port ( clk : in std_logic; rst_n : in std_logic; -- 低电平异步复位 opcode : in std_logic_vector(3 downto 0); -- 来自 IR 的操作码字段 z_flag : in std_logic; -- 来自 SR 的零标志 c_flag : 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; -- 存储器读使能 mem_write : out std_logic; -- 存储器写使能 ir_load : out std_logic; -- 指令打入 IR reg_we : out std_logic; -- 寄存器堆写使能 alu_op : out std_logic_vector(2 downto 0); -- ALU 运算选择 shu_op : out std_logic_vector(1 downto 0); -- 移位方向与位数 sr_we : out std_logic; -- 状态寄存器更新 halt : out std_logic -- 停机指示 ); end entity par_control_unit;几个参数需要说明。opcode 的位宽取 4 位是教学指令集的常见规模,够表达十六种操作;alu_op 取 3 位可以编出八种运算(加、减、与、或、异或、取反、比较、直通);shu_op 两位分别表示方向和是否移位。位宽不是死的,如果你的指令集里操作更少,压到 2 位也没问题,但要在综述里对应说明,不要代码和文档两张皮。
端口方向全部按“控制段是信号发出方”来定。控制器本身不存储数据,除了状态寄存器之外没有任何内部数据通路,所以不存在双向端口,这也是控制段比数据段好写的地方。
4.2 三进程结构:状态寄存器、次态逻辑、输出逻辑
完整实现如下,三个进程职责分明:
architecture rtl of par_control_unit is type state_t is (S_FETCH, S_DECODE, S_EXEC, S_WB, S_HALT); signal cur_state, nxt_state : state_t; begin -- 进程一:状态寄存器。异步复位,同步更新,只做这一件事 seq_state : 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 seq_state; -- 进程二:次态逻辑。纯组合,只依赖当前状态和输入,不碰输出信号 comb_next : process (cur_state, opcode, z_flag) begin nxt_state <= cur_state; -- 默认保持,防止推断出锁存器 case cur_state is when S_FETCH => nxt_state <= S_DECODE; when S_DECODE => if opcode = "1111" then -- 停机指令 nxt_state <= S_HALT; else nxt_state <= S_EXEC; end if; when S_EXEC => if opcode = "0001" or opcode = "0010" then nxt_state <= S_WB; -- 算术逻辑类指令需要写回 else nxt_state <= S_FETCH; -- 存储类、转移类不写寄存器堆 end if; when S_WB => nxt_state <= S_FETCH; when others => nxt_state <= S_HALT; end case; end process comb_next; -- 进程三:输出译码。先给全部输出赋默认值,再按状态覆盖 comb_out : process (cur_state, opcode) begin pc_inc <= '0'; pc_load <= '0'; mar_load <= '0'; mem_read <= '0'; mem_write <= '0'; ir_load <= '0'; reg_we <= '0'; sr_we <= '0'; halt <= '0'; alu_op <= "000"; shu_op <= "00"; case cur_state is when S_FETCH => mar_load <= '1'; mem_read <= '1'; when S_DECODE => ir_load <= '1'; when S_EXEC => alu_op <= opcode(2 downto 0); -- 低三位直接映射运算类型 if opcode = "0011" then -- 条件转移:零标志为 1 才装载 PC pc_load <= z_flag; end if; when S_WB => reg_we <= '1'; sr_we <= '1'; pc_inc <= '1'; when others => halt <= '1'; end case; end process comb_out; end architecture rtl;代码里有三处值得单独说明。第一,次态进程开头给 nxt_state 赋默认值,输出进程开头把全部输出清零,这是组合进程的铁律。综合工具判断锁存器的依据是“某条路径下信号没有被赋值”,只要有一条分支漏了,就会生成一个意外的锁存器,功能仿真可能看不出异常,后仿和上板一定出问题。
第二,pc_load 用 z_flag 直接驱动,而不是写成 if z_flag = '1' then pc_load <= '1'。在 VHDL 里把 std_logic 信号直接赋给 std_logic 输出是合法的,综合出来就是一根线,更简洁;但如果你后续要做多个条件的与或,还是老老实实写完整条件表达式,可读性更重要。
第三,输出进程用的是 Moore 型写法,输出只与当前状态有关,不与输入组合。Moore 型的优势是输出无毛刺、时序好分析,缺点是响应晚一拍——条件转移的判断结果要到下一个状态才体现。教学项目里这个延迟不影响正确性,但如果你的指令集要求 pc_load 和 alu_op 在同一拍生效,就得改成 Mealy 型,把 opcode 直接引入输出表达式。两种写法的取舍在答辩里经常被问到,提前想好说法。
4.3 输出译码与状态译码的取舍
上面代码用的是“按状态译码”,输出逻辑跟着状态走。另一种写法是“按信号译码”,每个输出信号单独写一个条件表达式,把所有能让它有效的状态或条件列全。前者代码短、结构清晰,后者修改方便、单个信号的有效条件一目了然。
状态少的项目用按状态译码更划算,因为一眼能看出每个状态发什么。状态一多(比如超过十个),按状态写会变成一大段 case,改一个信号要翻好几个分支,这时候按信号译码更合适。我一般会先用按状态译码把功能跑通,如果后面指令集扩展到十几个操作,再重构成按信号译码,测试用例不用改,只看波形对不对得上就行。
还有个小技巧:把输出信号的默认值集中写在进程开头,覆盖写在各状态分支里,这样任何一条改动都不会破坏“无锁存器”这个前提。这条规矩看着笨,但它是我见过最有效的一条防翻车措施。
5. 避坑清单:控制段设计里最容易翻车的五个点
控制段的代码量不大,问题往往不在算法上,而在一些“看起来没问题”的细节里。下面五条是我在带项目和改作业里反复见到的翻车点,按现象、原因、解决三段的顺序写,对得上就能省下大半天。
5.1 波形里输出出现短暂尖峰
现象:功能仿真时,某些控制信号在本不该有效的状态里短暂拉高,或者出现一个宽度不足一个时钟周期的毛刺。
原因:组合输出进程里漏了默认赋值,综合工具推断出锁存器,把上一拍的值保持了下来;或者输出表达式里引入了多个输入的竞争,产生了冒险。
解决:把输出进程开头那段默认赋值补全,一个输出不漏。补完之后再跑一次综合报告,确认没有 latch 警告。如果还有毛刺,检查是不是把 opcode 直接组合进了输出而未做同步处理,Moore 型输出可以先寄存一拍再用。
5.2 仿真波形全是未定义值
现象:测试平台跑起来,被测模块的所有输出都是 U,没有任何跳变。
原因:端口映射时名字或位宽对不上,或者测试平台里没有给复位信号,状态机停在枚举类型的初始值上无法推进;也有可能是被测模块实体的端口名改过,测试平台没同步。
解决:先看编译报告里的端口匹配提示,再检查测试平台里复位是不是真的给过低电平。枚举类型的信号在没复位时是未定义状态,case 语句对它没有匹配分支,输出自然不动。养成习惯:测试平台第一段激励永远是复位,等待若干个时钟周期再放开。
5.3 状态机节拍对不上,指令执行晚了两拍
现象:波形上取指阶段正常,但译码和执行阶段之间多出一两个空状态,或者一条指令占用的时钟周期数和设计文档不一致。
原因:状态转移条件和时钟边沿搞混了。状态寄存器在上升沿更新,次态逻辑算的是“下一拍是什么”,如果测试平台按“当前拍就应该出现执行态输出”来核对,就会觉得晚了一拍。另一种情况是把需要两个时钟周期完成的访存操作塞进了一个状态。
解决:画一张时序对照图,横轴是时钟周期,纵轴是状态和关键输出,把每个输出出现的拍次标出来,再和测试平台的等待时间对齐。凡是有访存的状态,确认存储模型的读延迟是否已经计入。
5.4 只做功能仿真,跳过综合后仿真
现象:功能仿真全绿,交作业时被要求提供后仿结果,一跑门级仿真输出全乱或者根本不跳变。
原因:功能仿真不包含门延迟,建立时间、保持时间、组合路径延迟全被忽略;后仿需要综合后的网表和延时文件,测试平台里还要处理门级网表的端口命名变化和初始化问题。
解决:综合完成后导出网表和 SDF 延时文件,在仿真工具里做反标,仿真精度调到皮秒级。门级网表里寄存器初始值可能未定义,测试平台要保证复位信号在仿真开始后立刻有效并持续足够长的时间。后仿跑出来的波形才是作业里那张截图该有的样子。
5.5 复位只在代码里写了,没在板上验证过
现象:下载到开发板后,按复位键系统不回到初始状态,或者上电后状态机停在某个中间态。
原因:代码里的异步复位只保证了仿真行为,实际板上复位信号的释放时刻和时钟不同步,可能出现亚稳态;或者复位按键的有效电平搞反了,代码写低有效,板上按键按下是高电平。
解决:确认开发板复位按键的极性,低有效就在端口名加 _n 并在约束文件里对应好。异步复位、同步释放是更稳的做法:复位信号先打两拍再送进状态机,代价是多两个触发器,换来的是上电可靠性。
6. 验收与答辩:从波形截图到口述逻辑的自查方法
交作业前的最后一步不是再改代码,而是按验收清单过一遍。我把常被追问的点整理成一张表,逐项打勾比反复读代码有效得多。
| 检查项 | 合格标准 |
|---|---|
| 架构综述 | 能画出三段结构,说清 PC、MAR、IR、ALU、SR 各自的输入输出 |
| 控制段信号表 | 架构图上的每根控制线在端口列表里都有对应项 |
| 状态转移表 | 状态图、转移表、代码三者一致,无孤立状态 |
| 功能仿真 | 每个状态至少出现一次,停机态和复位路径都覆盖到 |
| 综合报告 | 无锁存器推断警告,无未驱动信号警告 |
| 后仿结果 | 有网表反标,波形上无建立/保持违例,截图能看清状态名 |
| 波形注释 | 关键控制信号有标注,状态名显示为枚举而非二进制 |
答辩环节的讲法比代码本身更影响印象分。进门先用三十秒讲架构:左边运算、中间控制、右边寻址取数,一句话说清闭环。然后花一分钟讲状态划分的理由,重点解释为什么取指和译码要分开、为什么不需要写回的指令不走写回态——这两个问题几乎必问。最后指着波形讲一个完整指令周期,从 S_FETCH 的 mar_load 和 mem_read,到 S_DECODE 的 ir_load,再到 S_EXEC 的 alu_op 和 S_WB 的 reg_we,一路指下来,节奏就稳了。
被问到“为什么用三进程不用两进程”时,答案不是“习惯”,而是“状态寄存器和次态逻辑分开后,改转移条件不影响输出译码,改输出译码不影响转移条件,两个组合进程各自有默认赋值,锁存器风险被隔离在局部”。这种回答背后是真写过、改过、返工过。被问到后仿和功能仿真的差别,就讲延迟反标和门级网表的初始化,别背概念。
从那以后我每次交这类大作业,都会先把控制段的信号表打印出来贴在屏幕边上,代码写完对着表逐个端口打勾,再跑仿真。这张纸救过我至少两次——一次是漏了 mem_write,一次是把 pc_load 和 pc_inc 弄反了。顺序理清楚,后面的活都是体力活。希望帮到你。
本文还有配套的精品资源,点击获取