CS110 计算机体系结构 8 搭建一个 CPU

aaaaa Lv4

前两章介绍了关于计算机硬件的底层内容,但是本质上还是电路的基础知识,和计算机本身关系不太大。这一章我们将把计算机的 CPU 拆开来讲,从头开始搭建一个 CPU。


数据通路

数据通路,就是数据处理和存储的流程。而 CPU 的设计,大致会按照数据通路分成以下部分:程序计数器(Program Counter,简称 PC)、寄存器组(Register File)、算术逻辑单元(Arithmetic & Logic Unit,简称 ALU)。这些数据通路是数据流动的路径,然而具体如何操控这些数据的流动,则需要一个控制器(Controller)来管理。这样,整个 CPU 的几个部分就一目了然了,如下图:

Snipaste_2026-04-07_09-21-34

由于课程项目会要求使用相关模拟软件来手搓一个 CPU,请读者务必熟悉各个部分的作用和逻辑结构。接下来我们逐一介绍这些部分。

PC 寄存器

程序计数器 PC 本质上是一个寄存器,存储了当前程序执行到的代码语句的内存地址,后续解码器通过读入 PC 寄存器的值,然后在指令内存(Instruction memory, Imem)中查询指定指令,解码后传给控制器,指导 CPU 工作。

因此,PC 寄存器本质上需要实现的功能有 3 个:+4、跳转到后 x 字节的相对地址处、跳转到 x 字节(绝对地址)处。显然,这分别对应着 RISC-V 中的常规代码执行(每句语句 32 bit = 4 byte)、相对距离跳转(B-Type 跳转,jal 跳转等)和绝对地址跳转(jalr 跳转等)三个功能。我们先尝试实现一个最简单的、只会每个周期 +4 的寄存器作为我们的 PC:

Snipaste_2026-04-07_09-54-43

剩下的两种跳转等我们实现完整的 CPU 时再具体实现。

寄存器组 Register File

1 个 32 位的寄存器只能同时存储 1 个 32 位二进制数,这显然不符合 RISC-V 的要求——我们需要 32 个 32 位寄存器来存储我们的中间值。因此,我们会设计 32 个寄存器合并为一组,供 CPU 电路选择待操作的寄存器,这个整体被称为寄存器组。

从功能上,寄存器组需要完成的功能是:接收一个需要写入的寄存器编号、需要写入的值和一个需要读出的寄存器编号,然后根据编号找到正确的寄存器,做正确的操作后输出正确的值。

这里补充一个我们在设计硬件电路时很常用的思路:电路不像程序,电路是写死的,不能根据程序而灵活变化物理结构,因此对于一些只在某些特定情况下需要输出的结果,我们一般会留出输出的接口,在不需要使用时忽略这些输出结果。这样的做法本质上不会影响执行速度——这只是让应该空闲的电路跑起来得到无效的值,本质上和有效的电路是并行的,并不会影响执行的效率。在这里,不是每次寄存器操作都会同时涉及读和写,此时不涉及的那部分电路也会有输出(而不是不让它输出),只是我们选择忽略这部分输出罢了。

算术逻辑单元 ALU

ALU 是 CPU 中执行“计算”功能的关键单元,正是它支持了所有的算术与逻辑操作,包括 ADD SUB SLL SLT SLTU XOR SRL SRA OR AND ADDI SLTI SLTIU XORI ORI ANDI 等。当然,相信读者已经发现,这里部分操作,如 ADDADDI 本质上没有区别——事实上,实现这部分电路时,我们会在输入处使用多路选择器(Multiplexer, MUX)来选择输入来源,然后对于本质相同的运算,会使用相同的组合电路来实现。因为 ALU 本质上就是一个巨大的组合电路,其中并不存在时钟或寄存器,因此,每一条标准 RISC-V RV32I 指令在 ALU 中的运算都能在 1 个时间周期内完成。


不同类型指令的数据通路

CPU 需要支持 RISC-V 的各类指令,而各种指令会共用以上各个部分,把它们混杂在一起不方便读者理解,因此我们将指令按照类型分开,分别讨论每个类型的指令的正常执行需要什么样的数据通路。

R-Type

Snipaste_2026-04-09_08-29-50

整个处理过程是线性的:在最左上角是 PC,程序从 PC 中读取执行到的指令的地址,然后从 Instruction Memory 中找到对应的指令,并从中按位提取出需要操作的寄存器编号 rd rs1 rs2 传入寄存器组 Reg.file;寄存器组读出指定寄存器里储存的值 x[rs1]x[rs2],传入 ALU。ALU 的内部详细结构画在左下角:两个输入分别传入 10 种不同的运算,算出结果后,取其中需要的结果输出。ALU 输出的结果传回寄存器组,并保存在编号为 rd 的寄存器中。

我们不妨统计一下执行完一条 R-Type 指令的延迟:从 PC 中读出指令地址传入指令内存 Imem,并读出对应的指令需要一定的延迟,于此同时,PC 的值更新成下一条指令的地址;寄存器组处理读出的值需要一定的时间;ALU 本身存在延迟;把 ALU 输出的值写回寄存器组存在延迟。我们把这些延迟分别计算出来并相加,就是 R-Type 指令的执行延迟:

Snipaste_2026-04-14_08-30-18

本质上,这里黄色的循环和蓝色的循环是同时进行的,因此延迟会取最大值,也就是蓝色的循环: 读取指令 Instruction Fetch + 解码 Decode + 执行 Execute + 写回 Write Back。

I-Type 运算部分

Snipaste_2026-04-09_08-34-44

这里和 R-Type 的总体结构几乎是一致的,唯一的区别在于指令提取以及立即数生成器 imm.Gen。在这个部分中,立即数生成器会把指令中的立即数提取出来,进行符号位拓展后,作为第二个运算数参与 ALU 中的运算。为了兼容前面 R-Type 的电路结构,ALU 的第二个运算数需要使用一个多路选择器 MUX 进行选择。

照例,我们计算一下 I-Type 指令(运算)的延迟:

Snipaste_2026-04-14_08-34-36

由于立即数生成本质上是重新接线的问题,MUX 的延迟也较小,而寄存器中读出值需要经过多层组合电路,因此后者延迟往往更大,整个过程和 R-Type 指令的关键路径是相同的,因此延迟的表达式也是一致的。

I-Type 加载部分

Snipaste_2026-04-09_08-51-29

I-Type 指令(加载)的结构与前两种结构最大的不同在于增加了一个数据内存 Data memory, Dmem。其实,Dmem 和 Imem 在物理上是同一块内存的不同部分,在设计上把它分开,是为了方便同时读写指令内存和数据内存(为什么要同时读写?在下一章你就会理解了!)在这里,I-Type 指令(加载)需要从(数据)内存的指定地址处读取数据,并将它写回寄存器。因此,写回的部分也需要加一个 MUX,用于选择到底是把从 Dmem 中读取的值写回,还是把 ALU 的运算结果直接写回。

同理,我们计算 I-Type 指令(加载)的延迟:

Snipaste_2026-04-14_08-36-28

你会发现,这个循环有一个比前两种指令都更长的延迟,因为这里多经过了一个 Dmem,因此多一个 也很正常。

S-Type

Snipaste_2026-04-09_08-59-16

S-Type 指令多了向数据内存 Dmem 中写入数据的需求,因此需要给 Dmem 多开一个写入口——这样又多了一路控制信号,负责控制 Dmem 的操作到底是读出值还是写入值。因此,我们在前面的基础上加上这一结构,并继续计算其中的延迟:

Snipaste_2026-04-14_08-40-34

由于把数据存储进 Dmem 就万事大吉了,这个指令不需要写回,所以与前几种指令相比,延迟少了最后一段

B-Type

Snipaste_2026-04-09_09-25-41

B-Type 是相对特殊的一类指令:它会涉及到 PC 值的改变,因此需要从 ALU 接回一路信号来判断是否需要跳转。本质上,这类指令在 ALU 中实际执行的计算是减法,通过判断条件中的值之差是否等于 0 / 小于 0 来决定 beqblt 等指令的跳转与否。

如果读者在这里还记得属于 I-Type 指令的 jalr 指令,你会发现我们还没有实现这条指令——我们可以把它和 B-Type 使用相似的方式实现,即在 PC 前面加法器后增加一个 MUX,设计一个独立的加法器,用于计算 rs1 + imm 的值并把 PC 直接改为这个值(当然,也可以在加法器连接 PC 的输入处增加一个 MUX,允许选择是 pc 还是 rs1 来输入,具体有很多种等价实现方式)。

言归正传,以下是 B-Type 指令的延迟分析:

Snipaste_2026-04-14_08-43-27

这里上面的表达式计算的是是否需要跳转的延迟,下面的表达式计算的是修改 PC 的延迟,一般认为前者的延迟更大,是关键路径,因此 B-Type 指令的延迟和 R-Type、I-Type(运算)是一致的。


控制器 Controller

Snipaste_2026-04-09_09-43-44

上图中展示了一个相对完整的 RISC-V CPU 的数据通路,其实就是我们刚才不同类型指令的数据通路组合起来拼成的。我们发现,这条数据通路中有很多控制信号没有处理(上图中紫色的字段),而这些就是控制器负责的范畴。控制器会读入 Imem 中的读出的指令,根据 opcode 将其解析为不同类型的指令,然后按照各类型的语法,分别解析出指令内容以及寄存器编号,最后计算出不同控制信号的值并分别输出给数据通路的各个部分,指导它们正常工作。

在上图的数据通路实现中,这些控制信号的含义如下:

  • reg_en Register Enable,是否允许寄存器组进行更新
  • re Read Enable,是否允许从数据寄存器 Data memory 读取数据
  • we Write Enable,是否允许写入数据寄存器 Data memory
  • alu_ctrl ALU Control,决定ALU执行哪种算术或逻辑运算
  • imm_ctrl Immediate Control,控制立即数生成器 imm.Gen (对于不同指令类型)如何拓展立即数
  • wb_src Write-Back Source Select,决定最终写入寄存器的数据来自ALU计算还是数据寄存器
  • op2_src Operand 2 Source Select,决定ALU的第二个操作数来自寄存器组还是立即数
  • is_beq_instruction 决定是否为 beq 指令(相等则分支)

显然,即使是完全相同功能的 CPU,还能有无数种不同的实现方法,因此控制信号不一定局限于以上几种,还需读者根据实际数据通路的实现方式自行判断。

通过以上步骤,我们就可以手动搭建一个 CPU 的核心电路了。在 26 Spring 的 CS110 课程项目中,我们使用的是 Logisim 或者 SpinalHDL 来在计算机中仿真电路,有兴趣的读者可以自行搭建。下一章我们将在已经实现的 CPU 上做一些改进,以大幅提高 CPU 执行指令的效率。


回到目录

  • 标题: CS110 计算机体系结构 8 搭建一个 CPU
  • 作者: aaaaa
  • 创建于 : 2026-08-20 23:00:00
  • 更新于 : 2026-08-20 22:57:50
  • 链接: https://redefine.ohevan.com/2026/08/20/零基础速通系列/CS110 计算机体系结构/零基础速通:CS110_计算机体系结构_8/
  • 版权声明: 版权所有 © aaaaa,禁止转载。