VHDL CPU进阶:攻克乘除法单元与打印机握手设计
2026/9/12 7:24:22 网站建设 项目流程

简介:这是一份基于VHDL实现简单CPU功能的完整源码工程,面向数字逻辑、计算机组成原理课程设计或FPGA入门学习者。代码实现了加减乘除与移位运算,可在MAX+PLUS 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_addrdata_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()转换,这在有符号比较指令中很关键,否则负数比较会出现完全错误的结果。unsignedsigned混用是 CPU 设计中最常见的野错误,比如地址比较要用 unsigned,数据比较要看指令类型决定。定义时把端口ab的输入宽度设成可配的 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_vectorunsigned混算再截断,最后往寄存器堆写入时只取低 32 位。

参数方面的经验数值:32 位无符号乘法用 DSP48E1 通常消耗 3 个乘法器,一个做高位截断、一个做低位截断、一个做符号修正;纯 LUT 实现的乘法器则要占用近 600 个 slice,cpu天梯图上看到的 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 存储器映射到 IO:VHDL Printer 的地址分配策略

Printer 在设计稿里通常是一个字符输出设备,CPU 通过向某个地址写入 ASCII 码来输出文本。最简单的映射方式是存载式 IO,把打印机控制寄存器映射到数据总线的高地址段,存储器与cpu的连接在这里体现为:数据 RAM 和 Printer 挂在同一条数据总线上,通过地址译码区分。地址段规划如下表:

地址范围设备方向说明
0x00000000 - 0x0000FFFFdata_ram读 / 写普通数据内存
0x10000000print_data写入待打印的 ASCII 码
0x10000004print_ctrlbit0 为打印忙标志
0x10000008print_statusbit0 为 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 loadpercentage

wmic 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 通路和乘除单元。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询