ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

VHDL CPU进阶:攻克乘除法单元与打印机握手设计

2026/9/12 7:24:32 拓冰建站 浏览量
VHDL CPU进阶:攻克乘除法单元与打印机握手设计 简介这是一份基于VHDL实现简单CPU功能的完整源码工程面向数字逻辑、计算机组成原理课程设计或FPGA入门学习者。代码实现了加减乘除与移位运算可在MAXPLUS II和Quartus环境下编译运行适合用来理解CPU内部数据通路与控制逻辑。压缩包共986个文件、约4.28MB除vhd、tdf等核心设计源码外还包含qpf、qsf工程配置文件sof、pof下载文件以及大量cdb、hdb、rpt等综合仿真与报告文件目录结构完整便于按模块查看。作者提供了控制单元、ALU、PC、IR等模块的工程文件并附有仿真波形与引脚分配信息可帮助读者从模块设计到整体联调走通一遍CPU实现流程。目前已有173人学习下载适合需要参考完整工程模板或进行功能扩展的实践者。1. 为什么说 VHDL 实现 CPU 的难点不在 CPU 而在打印机第一次在 FPGA 上点亮自己写的 VHDL CPU 时最先看到的结果往往不是仿真波形而是一条串口打印出来的错误指令码。ALU、寄存器堆、控制器这些逻辑在纸面上都能讲清楚真正把人和“能跑的 CPU”隔开的是乘除法这种需要多周期操作的单元以及像 Printer 这样要和外部打交道的输出外设。网上下载那些以“CPU.rar”命名的 VHDL 工程几乎都是因为这两块没打通最后只能对着黑屏看灯。本篇文章以“用 VHDL 实现一个带乘除法和 Printer 的 CPU”为主题顺着数据通路、乘除单元、打印机握手这条主线走一遍完整落地路径。涉及到的开发方法是领域内通用做法不需要特定开发板代码结构可以直接迁移。适合三类读者刚写完单周期 CPU 想加功能的同学、在设计 RISC-V 或者自研 CPU 时做对照的工程师以及要对外设接口做握手验证的硬件开发者。2. 用 VHDL 实现 CPU 的骨架ALU、寄存器堆与存储器的连接方式2.1 以存储器为中心的双总线结构怎么影响 VHDL 的端口设计很多第一次做 CPU 的开发者直接在 entity 里把指令存储器和数据存储器全部声明成数组地址线全部展开美其名曰“简化设计”。这种做法在仿真阶段确实能跑一旦上板就会遇到资源不够、时序收敛困难的问题因为 FPGA 内部的 Block RAM 端口数量是有限的。常见做法是以存储器为中心的双总线结构在 VHDL 顶层只暴露两个独立的总线接口一条取指总线一条访存总线。entity cpu_core is port ( clk : in std_logic; rst_n : in std_logic; instr_bus : out std_logic_vector(31 downto 0); instr_addr : out std_logic_vector(31 downto 0); data_addr : out std_logic_vector(31 downto 0); data_wdata : out std_logic_vector(31 downto 0); data_rdata : in std_logic_vector(31 downto 0); data_we : out std_logic; data_ce : out std_logic ); end cpu_core;这个接口把 CPU 内核和物理存储器分开了。instr_bus负责取指data_addr和data_wdata负责访存data_ce是片选信号用于后续挂接不同速度的 RAM。在另一端存储器用简单双口 RAM 实现读口给取指用写口给数据访问用这样取指和访存在时钟节拍上互相不阻塞。参数上要注意data_rdata是 in 方向很多初学者会在顶层把 RAM 输出和 CPU 写数据线接反造成仿真正常、上板读写乱套。调试时先让data_we拉低跑一遍只读代码确认取指通路正常再打开写使能。2.2 ALU 的 VHDL 实现加法器与逻辑运算的常规定义方式ALU 是 CPU 里面最容易写、也最容易被忽视的部分。常用的写法是用一个宽度为 4 的alu_op控制信号做 case 分支不要贪多把 add、sub、and、or、slt 这五种先做对。vhdl中常量的定义在这个地方能用上用常量替代魔法数后续扩展新指令时不容易错。architecture rtl of alu is constant ALU_ADD : std_logic_vector(3 downto 0) : 0000; constant ALU_SUB : std_logic_vector(3 downto 0) : 0001; constant ALU_AND : std_logic_vector(3 downto 0) : 0010; constant ALU_OR : std_logic_vector(3 downto 0) : 0011; constant ALU_SLT : std_logic_vector(3 downto 0) : 0100; begin process (alu_op, a, b) begin case alu_op is when ALU_ADD y std_logic_vector(unsigned(a) unsigned(b)); when ALU_SUB y std_logic_vector(unsigned(a) - unsigned(b)); when ALU_AND y a and b; when ALU_OR y a or b; when ALU_SLT y (0 1, others 0) when signed(a) signed(b) else (others 0); when others y (others 0); end case; end process; end rtl;ALU_SLT分支里用到了signed()转换这在有符号比较指令中很关键否则负数比较会出现完全错误的结果。unsigned和signed混用是 CPU 设计中最常见的野错误比如地址比较要用 unsigned数据比较要看指令类型决定。定义时把端口a、b的输入宽度设成可配的 generic后续要支持 64 位 CPU 时直接改例化参数。2.3 控制器状态机取指、译码、执行三段式设计的时序边界CPU 控制器的核心是一段有限状态机状态切换必须和外部存储器的读写时延对齐。这里建议采用三段式取指状态下拉instr_addr下一个时钟沿等待instr_bus锁存译码状态下把立即数扩展、寄存器读写地址、ALU 控制信号全部打一拍执行状态下写回结果。相关的cpu架构差异主要就体现在这里单周期架构一个状态完成所有事多周期架构需要把乘除单独拆出鼓形状态。type state_type is (FETCH, DECODE, EXECUTE, WRITEBACK); signal state, next_state : state_type; begin process (clk, rst_n) begin if rst_n 0 then state FETCH; elsif rising_edge(clk) then state next_state; end if; end process; process (state, instr_bus) begin case state is when FETCH next_state DECODE; when DECODE next_state EXECUTE; when EXECUTE next_state WRITEBACK; when WRITEBACK next_state FETCH; end case; end process; end;这个状态机没有处理跳转指令。遇到 branch 时要在 DECODE 阶段就计算出跳转地址并覆写instr_addr否则执行完 WRITEBACK 再返回 FETCH 会多出一个无效周期对应单总线cpu设计logisim这类课程作业里经常提到的“延迟槽”问题。参数方面instr_bus只更新一次不要在多个状态里反复寄存器赋值容易产生多驱动源错误。3. VHDL 乘除单元怎么选乘法器资源与恢复余数法的时序取舍3.1 直接写*还是手写 Booth 乘法器VHDL 里的乘法看似简单一个*运算符加两个输入就行但这背后是由综合工具推断出 DSP 块还是通用逻辑决定权在代码风格。如果两个操作数都是信号综合器会选择使用 FPGA 芯片的 DSP 硬核例如 Xilinx 的 DSP48E1优势是速度快、不占通用逻辑劣势是乘法结果的位数必须按硬核的位宽对齐。如果其中一个操作数是常量综合器会退化成移位加组合逻辑适合指令立即数和 PC 偏移计算。function mul_byte(a, b : in std_logic_vector(7 downto 0)) return std_logic_vector is variable sum : std_logic_vector(15 downto 0) : (others 0); variable tmp : std_logic_vector(15 downto 0); begin for i in 0 to 7 loop if b(i) 1 then tmp : (others 0); tmp(15 downto i 8) : a(7 downto 0); sum : std_logic_vector(unsigned(sum) unsigned(tmp)); end if; end loop; return sum; end mul_byte;这个移位加乘法的逻辑较好理解每检测到乘数的一位就把被乘数左移到对应位置再累加。注意变量tmp需要在循环体里重新清零否则上一次循环的残留数据会串进下一次加法。对于 RISC-V 设计里的 M 扩展这种函数实现可用于教学验证但实际性能要求高的场景仍然建议调用厂商 IP 核因为 Booth 编码只减少了部分积数量没有显著降低 FPGA 资源占用。乘法结果位数容易漏算两个 32 位操作数相乘结果是 64 位给结果寄存器留 32 位会导致高位截断。常见的排查办法是查看综合报告里的DSP48数量如果乘法器消耗 DSP 核数目比预期多一倍通常是乘数和被乘数的位宽声明不一致综合器为了对齐符号位多分配了资源。3.2 乘法需要的周期数与写回时机在 CPU 里实现乘法关注点不再只是组合逻辑能不能算出数而是结果什么时候写到寄存器堆。若采用流水线方式乘法器必须支持多周期操作控制状态机要插入一个WAIT_MUL状态。cpu压力测试怎么开里讲的那些指标放到硬件上就是用连续乘法指令来测试 CPU 的吞吐量正确的设计应该做到每周期能发出一条乘法指令结果通过旁路网络转发。signal mul_start : std_logic; signal mul_busy : std_logic; signal mul_result : std_logic_vector(63 downto 0); begin process (clk, rst_n) begin if rst_n 0 then mul_result (others 0); mul_busy 0; elsif rising_edge(clk) then if mul_start 1 then mul_busy 1; elsif mul_busy 1 then mul_busy 0; mul_result unsigned(a) * unsigned(b); end if; end if; end process; end;这里mul_busy只持续一个周期真实硬件乘法器往往需要 3 个时钟节拍需要把mul_busy设计成一个计数器而不是单纯的电平信号。mul_start由控制器发出注意它只能维持一个周期如果拉高在整个 EXECUTE 状态期间保持乘法器会重复启动计算造成结果多次刷新。对于乘法的写回在 VHDL 中常用std_logic_vector和unsigned混算再截断最后往寄存器堆写入时只取低 32 位。参数方面的经验数值32 位无符号乘法用 DSP48E1 通常消耗 3 个乘法器一个做高位截断、一个做低位截断、一个做符号修正纯 LUT 实现的乘法器则要占用近 600 个 slicecpu天梯图上看到的 FPGA 逻辑资源容量和这个数据直接相关下单前要按这个比例估算。3.3 除法用恢复余数法状态机实现除法比乘法麻烦一个量级不能在组合逻辑里用循环完成常见做法是恢复余数法按位做减法不够减就回退循环 32 轮。这个循环天然地需要状态机不能用for generate因为 generate 是展开时序电路不是串行执行。type div_state_type is (IDLE, COMPARE, SUBTRACT, RESTORE, DONE); begin process (clk, rst_n) begin if rst_n 0 then rem (others 0); quot (others 0); state IDLE; elsif rising_edge(clk) then case state is when IDLE if div_start 1 then rem(31 downto 1) unsigned(a); quot (others 0); state COMPARE; end if; when COMPARE if unsigned(rem) unsigned(b) then state SUBTRACT; else state DONE; end if; when SUBTRACT rem unsigned(rem) - unsigned(b); quot quot(30 downto 0) 1; state COMPARE; when others state IDLE; end case; end if; end process; end;这段代码有一个隐藏错误需要特别留意在 SUBTRACT 状态中每轮都要把b左移一位再比较否则除数和被除数有效位不对齐结果商的位置会错乱。正确做法是让b先左移 31 位再逐轮右移或者反过来把被除数右移两者等价但 VHDL 里表达出的位宽不同。cpu架构上对于乘除单元MIPS 选择协处理器方式挂接RISC-V 纳入 M 扩展本质上都是为了不阻塞主流水线这里的状态机也只有除法运行时才会占用执行周期这就是常见的多周期执行单元设计。恢复余数法的代价是平均需要 64 个周期完成一次 32 位除法优点是逻辑极其省不消耗 DSP 资源。上板测除法正确性时可以故意设置除数为 0此时状态机必须回到 IDLE 而不是陷入死循环防止cpu占用率100%怎么解决变成硬件上控制器死锁的问题。4. 为 VHDL CPU 接一台打印机Printer 握手与时序设计4.1 存储器映射到 IOVHDL Printer 的地址分配策略Printer 在设计稿里通常是一个字符输出设备CPU 通过向某个地址写入 ASCII 码来输出文本。最简单的映射方式是存载式 IO把打印机控制寄存器映射到数据总线的高地址段存储器与cpu的连接在这里体现为数据 RAM 和 Printer 挂在同一条数据总线上通过地址译码区分。地址段规划如下表地址范围设备方向说明0x00000000 - 0x0000FFFFdata_ram读 / 写普通数据内存0x10000000print_data写写入待打印的 ASCII 码0x10000004print_ctrl读bit0 为打印忙标志0x10000008print_status读bit0 为 1 表示打印完成地址译码逻辑在 VHDL 里面用简单的when语句判断即可。print_data的写信号必须和data_we以及data_addr同时有效否则 CPU 执行SW指令时会误触发两次打印。Printer 需要四个地址位宽对于嵌入式 CPU 来说可以在顶层多定义一个io_sel信号避免数据 RAM 被选中时产生组合逻辑毛刺。毛刺会导致 Printer 在上升沿采到错误的 ASCII 值仿真时通常是理想的xU不定态上板后则表现为打印机时不时输出乱码。避免毛刺的办法有两个要么在print_data_addr的译码结果上加一个寄存器打拍要么让 Printer 的时序状态机对data_we做双边沿过滤两者选其一。4.2 Printer 握手状态机的 VHDL 移植写法打印机的典型行为是CPU 把一个字节写到数据端口Printer 开始“打印”期间拉高 busy打印完成后拉低 busy 并发出中断或状态位。VHDL 里这个外部行为不能直接模拟需要实现一个内部握手状态机。type print_state is (PRINT_IDLE, PRINT_BUSY, PRINT_ACK); signal pr_state, pr_next : print_state; signal data_lat : std_logic_vector(7 downto 0); begin process (clk, rst_n) begin if rst_n 0 then busy_out 1; data_lat (others 0); pr_state PRINT_IDLE; elsif rising_edge(clk) then pr_state pr_next; if pr_state PRINT_BUSY then busy_out 1; else busy_out 0; end if; end if; end process; process (pr_state, write_en, data_in) begin case pr_state is when PRINT_IDLE if write_en 1 then data_lat data_in; pr_next PRINT_BUSY; else pr_next PRINT_IDLE; end if; when PRINT_BUSY pr_next PRINT_ACK; when PRINT_ACK pr_next PRINT_IDLE; end case; end process; end;write_en是在数据总线上组合出来的信号这里把它写成进程输入端以便状态机对上升沿敏感。Printer 的 busy 信号在复位期间必须为高否则 CPU 复位后立即查状态会误以为 Printer 空闲。data_lat的锁存发生在write_en为高的一整个周期内如果写字信号包含毛刺data_lat会抓住错误的字节鉴于此地址译码要采用下降沿采样配合读回确认。打印时机的等待逻辑通常放在 CPU 侧就是不断读取print_ctrl直到其 bit0 为 0。这一条在软件上就是 busy waiting对于只想在显示器上看到 “Hello” 的场景够用。125 微秒级别的等待由打印机的时钟节拍决定并不需要 CPU 精确计时。4.3 在控制器里新增一条 PRINT 指令要让程序直接打印需要在 CPU 控制器的译码逻辑中新增一条自定义指令指令编码不要占用标准的 opcode 高位区域。用高 8 位 0xF0 作为自定义前缀低位装入寄存器索引与日常使用的指令集不发生冲突。when 111100000000 io_sel 1; print_start instr_bus(7 downto 0); next_state FETCH;inst_bus(7 downto 0)直接作为打印字符省掉了从寄存器读数据的步骤但代价是不支持变量打印。更完善的做法是让指令低位指向寄存器编号控制器拉高reg_read_en拿到 regfile 的数据再送到 Printer 端口。VHDL 的 case 条件要求是常量值std_logic_vector按位匹配必须写完整位数可以用instr_bus(15 downto 12) 1111来做前缀匹配再子译码。加完 PRINT 指令之后要注意io_sel必须和print_start同步否则处理器执行后续指令时打印数据线上残留旧数据打印机收到错误字符。常见的验证方法是用$display在仿真中检查 Printer 收到的 ASCII 码或者直接把数值输出到 LED 灯验证高低电平是否符合预期。同时仿真时留意data_ce在打印周期内不要拉低否则地址译码输出不定态。5. 上板验证 VHDL CPU资源占用、时钟约束和压力测试的落地技巧5.1 用 wmic 检查开发机 CPU 占用定位综合瓶颈大规模 VHDL 工程的综合时间容易被低估一个 10 万门级的 CPU 设计在普通笔记本电脑上综合可能要跑 20 分钟期间开发机 CPU 会飙到 100%。搭建环境时用系统命令观察进程占用率可以确定瓶颈是在综合脚本还是在 CPU 内核设计本身。wmic cpu get processorid wmic path win32_processor get loadpercentagewmic cpu get processorid用于确认当前的 CPU 型号不是必需但可以配合输出信息判断是否需要换机器或调低并行约束策略。loadpercentage反映的是整体平均负载。跑综合时 Microsoft Windows 下的任务管理器和 Linux 下的htop能显示具体线程分配如果 CPU 占用率稳定在 100%说明综合器已经跑满。此时按下暂停键检查并行综合的策略选项cpu占用率100%怎么解决的办法是降低jobs数目避免内存交换拖慢编译。5.2 资源利用率与时序违例排查综合完成后打开 resource 报告重点看三项Slice LUT、Block RAM、DSP48。经验指标是一个最小可运行的单周期 CPU 大约消耗 3000 到 5000 个 LUT这个数据会随寄存器堆端口数上升而增加。出现逻辑资源大幅超出预估时大概率是乘除法模块没有复用比如同时写了 3 个乘法器给 ALU、页表计算、Printer 校验用。正确做法是把乘法器提升为独立模块例化一次其余地方通过数据选择器复用。时序收敛方面VHDL 的for generate展开乘法器循环会在数据通路上形成很长的级联逻辑导致关键路径延迟超过clk_gen的约束。打开 timing report 看最差违例路径如果WNS为负数优先在乘法结果后面加一级寄存器将尾路径拆成两半。检查项命令预期结果最大时钟频率综合报告 Timing Summary100 MHz 设计 120 MHz 以上片内功耗功耗分析工具调整时钟频率后下降显著LUT 利用率Utilization Report低于 70% 为优上板冒烟测试用一段循环移位的汇编代码观察到 LED 状态跳变说明取指和执行通路正常。之后才把乘法、除法和 PRINT 指令一起加入测试集单独测试的优先级排序避免一次上板全是故障点。5.3 让乘法压力测试在板级跑起来时钟分频与看门狗板级调试最麻烦的是一旦程序跑飞FPGA 就得重新下载比特流。给 CPU 加一个指令看门狗是节省时间的最实用技巧。Gcounter 模块检测同一顺序的多个时钟周期内程序计数器是否停在同一个地址连续超过 1000 个周期没有推进就自动拉高 reset让程序从头运行。在 VHDL 里实现只需要一个计数器加一条比较语句。乘法压力测试的场景是执行一段循环里反复调用乘法和除法的汇编代码如果看门狗不触发且 LED 计数正常递增说明乘除状态机在真实时钟下没有死锁。持续运行 20 分钟后记录热成像仪或手摸散热片温度热到不能碰通常意味着翻转率过高可在综合选项中增加-reduce_control_sets降低信号翻转。段结束时打印一个特殊 Hello 串并进入自检状态这样同时验证了 Printer 通路和乘除单元。本文还有配套的精品资源点击获取