VHDL三段式状态机实战:PARWAN控制单元仿真与排错
简介围绕PARWAN处理器架构与CONTROL-SECTION状态机展开的FPGA数字系统设计课程大作业文档面向电子信息、计算机与软件工程专业修读FPGA开发课程的学生可用于课程作业参考、综合设计复盘及VHDL状态机入门练习。文档以PARWAN处理器的运算、控制、寻址三部分结构为主线说明PC、MAR、ALU、SR、IR与CONTROLLER的职责和数据通路并给出CONTROL-SECTION状态机的VHDL设计过程在Spartan3E XC3S500EFG320封装上新建工程、编写par_control_unit_tb测试文件、编译综合并完成后仿真配合状态跳转说明与仿真波形截图便于理解取址、运算各阶段控制信号的变化。压缩包内为1个doc文件约763KB按目的、原理、步骤、结果、总结五部分组织接近课程报告模板可借鉴写作框架与实验记录方式。已有554人学习。1. 仿真波形一直不动问题出在状态机而不是板子仿真跑了两分半波形窗口里cur_state死活停在S_FETCH_MAR不动改了三遍代码才发现是次态逻辑漏了一个分支。这是拆这份 FPGA 开发大作业资料时遇到的第一个坎。整份资源围绕 PARWAN 处理器展开前半程讲架构综述后半程要求用 VHDL 把 CONTROL-SECTION 状态机设计出来给出功能仿真和后仿波形。它不大但整条链路都串联起来了数据通路、控制信号、有限状态机、测试平台最后再做一个时序仿真。适合两类人一类是刚上手 FPGA、想找一个能完整走通的练手项目另一类是组合逻辑写得挺熟、但从没独立写过一版状态机的开发者。文档里的信号表和代码骨架基本可以直接拿来当模板改。2. PARWAN 架构拆解控制、寻址、运算这三条链路怎么串联PARWAN 是一个典型的教学型处理器结构上按照「控制、寻址取数、运算」三块来划分比 8086 那种庞大的指令系统清爽得多。理解它的顺序应该是先看数据往哪走再看谁在指挥数据走最后看指挥信号在什么时刻有效。这三件事想清楚了再看代码VHDL 就只是把这些关系一行行翻译出来而已。2.1 三部分结构运算部分、控制部分、寻址部分左侧是运算部分核心是 ALU 和移位器 SHU旁边放置状态寄存器 SR用来保存零标志、进位标志这类结果信息。中间是控制部分由 IR指令寄存器和 CONTROLLER控制单元组成控制单元负责决定下一步该做什么。右侧是寻址取数部分主要由 PC程序计数器和 MAR存储器地址寄存器承担PC 保存当前指令地址MAR 保存当前要访问的内存地址中间通过地址总线 ADBUS 把地址送给存储体取回的数据再进入运算部件。从数据通路的角度描述就是PC 的值送到 MARMAR 通过 ADBUS 发起读请求存储体返回的数据落到 MBR存储器缓冲寄存器再从 MBR 进入 IR 完成取指。执行阶段 ALU 从寄存器堆或 MBR 取操作数运算结果经过 SHU 移位后写回寄存器堆同时更新 SR。整个环路可以概括成「控制、取址取数据、运算、回到控制」这四步的循环。这种结构的好处是职责边界清楚。写控制状态机时你只需要关心「当前状态应该把哪个 load 信号拉高」完全不用管 ALU 内部是怎么做加减的。反过来写数据通路时也不用操心状态怎么跳。后面要改代码这个边界就是最重要的分层依据。2.2 控制段的输入输出清单一张表看清它指挥谁控制单元的端口设计是整个实验的关键。输入端接收当前的「处境信息」输出端发出「动作指令」。输入一般只有时钟、复位、操作码以及来自 SR 的条件标志输出则会很多每个控制信号都要单独一根线。方向信号名位宽作用输入clk1系统时钟状态迁移的节拍源输入rst_n1低电平异步复位强制回到取指首态输入opcode4来自 IR 的操作码决定译码分支输入z_flag1SR 的零标志条件跳转要用输出pc_inc1PC 自增取指完成后加一输出pc_load1PC 装载跳转指令时用输出mar_load1把 PC 或计算结果打进 MAR输出mem_read1存储器读使能输出mem_write1存储器写使能输出mbr_load1读回的数据打入 MBR输出ir_load1MBR 内容打入 IR取指完成输出reg_write1寄存器堆写使能写回阶段拉高输出alu_op3ALU 运算类型选择输出shu_op2移位器模式选择输出sr_load1更新状态寄存器输出halt1停机标志这张表我一般是先画在纸上再写代码的因为 VHDL 的 entity 一旦漏了某个信号后面 test bench 里想观测都没得观。提示输出信号的数量决定了控制段是 Moore 型还是 Mealy 型。Moore 型的输出只跟当前状态有关波形干净好调Mealy 型输出还跟 opcode 有关能省状态但波形上容易出毛刺。教学项目建议一律用 Moore 型。2.3 一条指令的节拍走查从 PC 到写回把状态机拆成状态之前先在纸面上走一遍时序。以一条简单的加法指令为例节拍是这样的第一个时钟沿到来时进入送地址状态mar_load拉高PC 的当前值被打进 MAR。第二个节拍进入读存储器状态mem_read和mbr_load同时有效存储体在时钟沿前把数据准备好沿到来时数据落进 MBR。第三个节拍ir_load有效指令进入 IR同时pc_inc拉高把 PC 加一为下一条指令做准备。第四个节拍是译码此时opcode已经稳定状态机根据它决定后面走哪条分支。第五个节拍执行alu_op根据操作码给出运算类型结果在时钟沿后稳定同时sr_load更新状态标志。第六个节拍写回reg_write拉高结果写入目的寄存器然后无条件回到第一个状态。这套节拍是固定的每一条指令都至少消耗这几个周期。真正的差异在译码之后有的指令要多一拍去算有效地址有的指令要判断条件标志来决定是否跳转。状态机的复杂度就长在这里也正是在这里从「一个状态」扩展到「全部状态」才是这份作业真正的训练价值。3. 从建工程到出波形一次完整的仿真流程很多人卡住的不是代码是工具。ISE 这套老工具的交互逻辑跟现在的 Vivado 差别挺大第一次点错一个选项后面仿真就一直提示找不到顶层。这一章把从空白工程到波形窗口弹出来的每一步都走一遍照着做基本不会翻车。3.1 新建工程与器件选型为什么是 XC3S500E-FG320打开 ISE从 File 菜单进入 New Project先填工程名和存放路径。路径这一项要注意不要放在带中文或空格的目录下ISE 对这类路径的兼容性一直不太好编译到一半报「文件无法写入」的情况我见过不止一次。下一步是器件配置。开发板用的是 Spartan-3E 系列具体型号 XC3S500E封装 FG320速度等级通常是 -4。这三个参数必须跟板子丝印上一一对上选错了综合能过但引脚约束会全乱最后生成不了能用的比特流。Family : Spartan3E Device : XC3S500E Package : FG320 Speed Grade : -4 Synthesis Tool: XST (VHDL/Verilog) Simulator : ISim 或 ModelSim-SE工程建好之后是新建源文件。这里有个容易忽略的点控制状态机和测试平台要分成两个文件主设计放par_control_unit.vhd测试平台放par_control_unit_tb.vhd。放在同一个文件里也能编译但综合的时候测试平台会被当成顶层一起综合最后报一堆「信号无负载」的警告白折腾。3.2 用 VHDL Test Bench 模板搭一个仿真骨架写测试平台不要从空白文件开始。从 Project 菜单进入 New Source左侧选 VHDL Test Bench右侧填文件名par_control_unit_tb下一步会让你选择关联的设计文件。选中刚才写的控制单元ISE 会自动帮你生成端口映射和被测模块的实例化代码连时钟常量都给你写好了这部分省下来的时间足够你多调两遍波形。生成的骨架里通常包含一个CLK_PERIOD常量和一个clk not clk after CLK_PERIOD/2;的语句前者改成 20 ns 就是 50 MHz跟板子的晶振对得上。复位信号在激励进程里先拉低若干个周期再释放这个「若干个」不能太短至少要覆盖两个时钟沿否则状态机可能还没复位完就开始跑了。如果用的是 ModelSim比起在图形界面里一层层点我更推荐写一个 do 脚本一次跑完编译、加载、加波形、运行。脚本不占地方改一行参数就能重跑比每次重新点菜单舒服太多。# sim.do —— 一键完成编译、加载与波形观察 vlib work vcom -93 ../src/par_control_unit.vhd vcom -93 ../sim/par_control_unit_tb.vhd vsim -t 1ns work.par_control_unit_tb # 状态信号单独一组方便肉眼追踪迁移 add wave -divider STATE add wave -radix unsigned /par_control_unit_tb/dut/cur_state add wave -radix unsigned /par_control_unit_tb/dut/nxt_state # 控制信号按数据通路顺序排列 add wave -divider FETCH add wave /par_control_unit_tb/dut/mar_load add wave /par_control_unit_tb/dut/mem_read add wave /par_control_unit_tb/dut/mbr_load add wave /par_control_unit_tb/dut/ir_load add wave /par_control_unit_tb/dut/pc_inc add wave -divider EXEC add wave /par_control_unit_tb/dut/alu_op add wave /par_control_unit_tb/dut/sr_load add wave /par_control_unit_tb/dut/reg_write run 2 us脚本里-t 1ns是把仿真精度设成 1 纳秒比默认的皮秒级跑得快不少教学项目这个精度完全够用。vcom -93指定 VHDL-93 标准如果你的代码里用了process(all)这类 2008 语法就得换成-2008否则编译直接报语法错误。add wave -radix unsigned是让状态枚举以数字形式显示比一串符号名更容易看出跳转顺序。3.3 功能仿真与后仿真的差别波形该看哪几条功能仿真跑的是行为级代码没有延迟信息波形上信号是理想跳变的。这一步的目标只有一个验证状态迁移逻辑对不对。具体看三件事——复位后是不是落在S_FETCH_MAR、每个状态的停留周期数是否符合设计、halt拉高之后是不是真的不再迁移。后仿真是把综合、布局布线之后的网表拿来再跑一遍并且反标 SDF 时序文件。这时候波形上会出现延迟有些信号甚至会因为布线延迟出现窄毛刺。这一步验证的是「门级实现和你的意图一致」重点看建立时间。在 ISE 里生成 SDF 的路径是先跑完 Implement Design然后在工程目录下找*_timesim.sdf把它和网表文件一起加载进仿真器。两个仿真的波形对比着看如果功能仿真对、后仿不对问题大概率在时序约束上比如时钟周期约束写得太紧或者某个组合路径太长。如果两个都不对那就是代码本身有逻辑问题乖乖回去改状态机不用往下查。4. 核心代码拆解CONTROL-SECTION 状态机的 VHDL 实现状态机的写法流派很多教学代码里最常见的是两段式和三段式。两段式把时序和组合混在一起代码短但可读性一般三段式拆成状态寄存器、次态逻辑、输出逻辑三块每块职责单一出问题的时候能一眼定位到是哪一段。下面这份实现用的就是三段式。4.1 实体声明把数据通路的握手信号一次列全entity 部分对应第 2 章那张信号表一个不能少。端口命名建议加统一前缀或者后缀比如所有装载信号都叫xxx_load所有使能都叫xxx_en回头在波形窗口里按名字过滤会很方便。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 的零标志 -- 取指通路控制 pc_inc : out std_logic; pc_load : out std_logic; mar_load : out std_logic; mem_read : out std_logic; mem_write : out std_logic; mbr_load : out std_logic; ir_load : out std_logic; -- 执行与写回通路控制 alu_op : out std_logic_vector(2 downto 0); shu_op : out std_logic_vector(1 downto 0); sr_load : out std_logic; reg_write : out std_logic; halt : out std_logic ); end entity par_control_unit;opcode定成 4 位是留了余量的教学指令集一般 8 到 16 条指令4 位刚好覆盖。alu_op给 3 位能表示 8 种运算加减与或加移位就够了。如果后面要扩展乘法得把这个位宽加到 4 位同时记得同步改 test bench 里的观测信号不然波形上会显示成截断值。4.2 状态枚举与三段式写法状态用type ... is (...)枚举出来可读性比直接写000强太多综合工具也会自动给每个状态分配编码。这里定义七个状态覆盖从取指到写回的完整流程。architecture rtl of par_control_unit is type state_t is ( S_FETCH_MAR, -- 送地址MAR - PC S_FETCH_MEM, -- 读存储MBR - Mem[MAR] S_FETCH_IR, -- 取指完成IR - MBR, PC - PC1 S_DECODE, -- 译码按 opcode 分流 S_EXEC, -- 执行ALU / SHU 运算更新 SR S_WB, -- 写回Rd - 结果 S_HALT -- 停机 ); signal cur_state, nxt_state : state_t; begin -- 第一段状态寄存器只做一件事——在时钟沿把次态搬进现态 seq_reg : process(clk, rst_n) begin if rst_n 0 then cur_state S_FETCH_MAR; elsif rising_edge(clk) then cur_state nxt_state; end if; end process;这一段看着简单但有两个细节值得说。一是复位用的是异步复位rst_n不在rising_edge的敏感列表里所以复位一拉低立刻生效不用等时钟沿。二是复位值直接给了S_FETCH_MAR不是S_IDLE省掉一个空转周期波形上也更好看。次态逻辑用case展开关键是开头一定要给nxt_state赋默认值否则综合工具会推断出锁存器latch后面后仿会出现莫名其妙的保持行为。-- 第二段次态逻辑纯组合把所有分支写全 comb_next : process(cur_state, opcode, z_flag) begin nxt_state cur_state; -- 默认保持杜绝 latch case cur_state is when S_FETCH_MAR nxt_state S_FETCH_MEM; when S_FETCH_MEM nxt_state S_FETCH_IR; when S_FETCH_IR 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 nxt_state S_WB; when S_WB nxt_state S_FETCH_MAR; -- 无条件回到取指 when S_HALT nxt_state S_HALT; -- 停机后锁死 when others nxt_state S_FETCH_MAR; -- 兜底防止非法编码跑飞 end case; end process;when others这一支很多人写代码时会省掉仿真也照样能过。但如果综合时状态编码用了 one-hot未定义编码是有可能出现的加一个兜底分支成本几乎为零。4.3 输出逻辑把控制信号表翻译成 case 分支输出这一段是 Moore 型只依赖cur_state不读opcode。这样波形上每个控制信号的宽度都是整数个时钟周期一眼就能看出哪拍该干什么。-- 第三段输出逻辑Moore 型先全清零再按状态拉高 comb_out : process(cur_state) begin pc_inc 0; pc_load 0; mar_load 0; mem_read 0; mem_write 0; mbr_load 0; ir_load 0; reg_write 0; sr_load 0; halt 0; alu_op 000; shu_op 00; case cur_state is when S_FETCH_MAR mar_load 1; -- MAR 装载 PC 值 when S_FETCH_MEM mem_read 1; mbr_load 1; -- 存储器读数据落 MBR when S_FETCH_IR ir_load 1; pc_inc 1; -- 指令进 IRPC 自增 when S_DECODE null; -- 译码拍不驱动任何信号 when S_EXEC alu_op opcode(2 downto 0); -- 运算类型直取操作码低三位 sr_load 1; -- 更新状态寄存器 when S_WB reg_write 1; -- 写回寄存器堆 when S_HALT halt 1; when others null; end case; end process; end architecture rtl;开头那一串清零是整个写法的核心。如果把清零省掉case里没提到的信号就会保持上一次的值综合工具直接给你推断出一堆锁存器后仿波形上会看到本该是低电平的信号莫名其妙地吊在高位。这个坑我在别的项目上踩过一次查了大半天才发现是漏了默认赋值。alu_op opcode(2 downto 0)这一行是个简化处理把操作码的低三位直接当 ALU 控制码用实际工程里通常要做一个译码表。如果指令集里 ALU 操作和操作码不呈这种线性对应关系就要改成case opcode is的映射写法多写几行但不会出岔子。4.4 激励怎么写让状态机自己跑完一圈测试平台的作用是给足激励让状态机把所有分支都走一遍。下面的激励进程分三段先复位再给一条普通指令跑完整个取指执行流程最后给停机指令验证halt是否正确锁死。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; -- 50 MHz对应板载晶振 signal clk : std_logic : 0; signal rst_n : std_logic : 0; signal opcode : std_logic_vector(3 downto 0) : (others 0); signal z_flag : std_logic : 0; signal pc_inc, pc_load, mar_load : std_logic; signal mem_read, mem_write, mbr_load : std_logic; signal ir_load, reg_write, sr_load, halt : std_logic; signal alu_op : std_logic_vector(2 downto 0); signal shu_op : std_logic_vector(1 downto 0); begin -- 时钟自由运行 clk not clk after CLK_PERIOD / 2; dut : entity work.par_control_unit port map ( clk clk, rst_n rst_n, opcode opcode, z_flag z_flag, pc_inc pc_inc, pc_load pc_load, mar_load mar_load, mem_read mem_read, mem_write mem_write, mbr_load mbr_load, ir_load ir_load, reg_write reg_write, sr_load sr_load, halt halt, alu_op alu_op, shu_op shu_op ); stim : process begin rst_n 0; -- 复位保持 3 个周期 wait for CLK_PERIOD * 3; rst_n 1; opcode 0001; -- 一条普通运算指令 wait for CLK_PERIOD * 8; -- 跑完取指到写回 opcode 1111; -- 停机指令 wait for CLK_PERIOD * 12; assert halt 1 report 停机指令未生效检查 S_DECODE 分支 severity error; wait; -- 挂起避免进程反复重启 end process; end architecture sim;最后那个wait;很关键。VHDL 的进程如果执行到末尾没有挂起语句会自动从头再来一遍激励就会不停地重复复位波形上看到的是一段段重复的周期很容易误判成状态机跑飞。assert ... severity error是给自动化验证埋的钩子后面接 CI 或者批量跑回归脚本时能直接拿它当判据。5. 避坑与排查波形不对时先查这几处调试状态机的痛苦程度跟代码量不成正比往往一行漏写能耗掉一晚上。下面这五条是我在这类项目上反复撞见的按现象、原因、解决三段来写遇到问题时按顺序对一遍基本能定位。5.1 波形全是 U 或 X复位和初值没给现象仿真一开跑cur_state显示一段红色横线nxt_state也是未知态所有输出信号都在 X 状态。原因通常是测试平台里rst_n初值给的是U而不是0或者设计里复位用的是同步复位但时钟还没起来。解决办法是给信号声明时直接赋初值比如signal rst_n : std_logic : 0;同时在激励进程里保证复位至少维持两个时钟沿。综合后的网表里复位也有一个释放的时序要求仿真时间给得太短状态机可能还没从复位态出来就被驱动了。5.2 状态卡死不动次态逻辑漏了分支或推断出锁存器现象cur_state停在某个状态好几个周期不动nxt_state却已经变了或者干脆保持原值。原因有两类一类是case里真的漏了某个状态的分支when others又把兜底写成了原地保持另一类是次态进程的敏感列表没写全opcode变了但进程没被触发nxt_state就停在旧值上。解决办法是把case的每个分支都对着状态枚举逐个核对敏感列表里该有的信号一个不能少或者在 VHDL-2008 下直接用process(all)让工具自动推导。5.3 后仿和功能仿真对不上SDF 没反标或时钟约束缺失现象功能仿真波形完全正确网表仿真跑出来的状态跳转错位某些控制信号出现一两个纳秒的毛刺。原因通常是 SDF 时序文件没加载或者加载了但路径不对仿真器根本没读到延迟信息。另一种可能是约束文件里没写时钟周期布局布线工具按默认值优化实际路径延迟超出了你的预期。解决办法是先确认仿真日志里有没有「SDF annotation succeeded」这类提示再去 UCF 里补上NET clk TNM_NET clk; TIMESPEC TS_clk PERIOD clk 20 ns HIGH 50%;重新布线再跑一次。5.4 综合报「信号无负载」测试平台被当成顶层综合了现象综合日志里刷出一屏 warning说某个信号没有任何负载或者提示找到多个顶层模块。原因是测试平台文件也加进了工程而综合工具默认把层次结构里最上面那个当成顶层恰好点到了_tb文件。解决办法是在 Synthesize 的属性里显式指定顶层模块名或者在工程里把测试平台的综合选项关掉只保留仿真用途。这个错误不影响编译所以特别容易漏过去直到生成比特流失败才发现。5.5 板子上跑飞但仿真正常按键抖动和跨时钟域现象下载到 Spartan-3E 板子上之后按下复位键有时候能正常启动有时候直接停在乱码状态而仿真里从来没出现过。原因是机械按键的抖动被当成了多次复位信号或者板上的晶振时钟和内部逻辑之间没有做同步处理。解决办法是加一个简单的按键消抖模块用两级触发器同步加计数延时二十毫秒左右就够跨时钟域的信号一律先过两级同步器再使用。这类问题仿真里几乎不可能复现只能靠上板经验。6. 二次开发把单状态验证扩成一套完整控制通路这份资料给的是一个状态的示范真正有价值的是把它扩成完整的控制通路。我一般按三个方向改。第一是把条件跳转补上S_DECODE里读z_flag为真时把次态指向一个新增的S_BRANCH那一拍拉高pc_load而不是pc_inc。第二是把状态机参数化用generic把 ALU 位宽和状态编码方式暴露出来改指令集的时候不用动架构。第三是换成微码写法把每个状态的控制信号打包成一个常量数组用状态当索引去查表新增指令只是往 ROM 里多写一行。验证方法上我习惯做逐拍比对。先手写一份「状态到控制信号」的期望表再用 test bench 在每个时钟沿采样实际输出用assert逐项对比对不上就报severity error并打印当前状态。-- 逐拍自检期望表直接写在进程里改设计时同步改这里 check : process(clk) begin if rising_edge(clk) then case cur_state is when S_FETCH_MAR assert mar_load 1 and mem_read 0 report S_FETCH_MAR: mar_load 未拉高 severity error; when S_FETCH_MEM assert mem_read 1 and mbr_load 1 report S_FETCH_MEM: 读使能缺失 severity error; when S_WB assert reg_write 1 report S_WB: 写回使能缺失 severity error; when others null; end case; end if; end process;这套自检的好处是以后改状态机不用再盯着波形一格格数跑一遍仿真日志里没报错就说明行为没跑偏。覆盖率方面至少保证每个状态都被访问过、每个case分支都被走过用 ModelSim 的coverage report能直接看到哪些分支是空的。改造方向改动量风险点适合场景补条件跳转小加一个状态z_flag采样时机要对齐指令集扩展generic 参数化中改 entity位宽不一致导致截断多平台复用微码查表大重构输出段常量数组初始化易错指令数量多踩过的坑里印象最深的一次是改完状态机忘了同步改期望表仿真一直报错我以为是设计写错了回头翻了两个小时代码最后发现是自检进程里的旧断言没删。从那以后我每次动状态机都强制先把期望表改完再动 RTL两边的 diff 对着看一遍才提交。希望这些能帮到你少走几段弯路。本文还有配套的精品资源点击获取