单周期CPU

RISC指令集设计与单周期CPU数据通路实现方法

Posted by CloudingYu on March 4, 2026

一、RISC 指令集体系结构

1.1 RISC 设计原则

本课程采用 精简指令集计算机Reduced Instruction Set Computer, RISC)设计,PPT 以 MIPS 为例讲解,实验做的是 RISC-V,两者核心标准差别非常小。ARM 也是 RISC 的典型代表。

RISC 的核心设计准则:

  1. 指令功能尽可能简单:每条指令只做一个明确的任务。不能像复杂指令集那样一条指令里包含 move、add、load 等多个操作。
  2. 所有指令长度相同:在 MIPS 中均为 32 位。
  3. 只有 Load/Store 指令访问存储器:其他所有指令都在寄存器之间执行。
  4. 大部分操作在寄存器之间进行:通过大量寄存器减少访存次数。

精简了指令系统,硬件就简单,流水线也方便。RISC(精简)和 CISC(Complex Instruction Set Computer,复杂指令集)各有各的用处,不要说谁好谁不好。在计算机领域,尤其是《计算机程序设计艺术》(The Art of Computer Programming)这样的经典中,不要随便批评——精简有精简的妙处,复杂有复杂的用处。

1.2 MIPS 寄存器文件

MIPS 有 32 个通用寄存器(编号 0 到 31),每个 32 位宽:

  • $0(零号寄存器):硬件写死为 0。任何写入操作对其无效,读取始终返回 0。
  • $at(汇编器临时寄存器):汇编器保留使用。
  • $v0-$v1:函数返回值。
  • $a0-$a3:函数参数。
  • $t0-$t9:临时变量。
  • $s0-$s7:保存变量。
  • $gp, $sp, $fp, $ra:全局指针、栈指针、帧指针、返回地址。

为什么要把零号寄存器写死为零?因为零太常用了。把零变成一个寄存器后,很多操作直接变成寄存器操作,速度反而更快。比如做 NOT 操作——把某个寄存器与零做 NOR 就行了,不需要专门的 NOT 指令。

汇编中的助记符(如 $t0, $s0)只是为了帮助人类记忆,在 RISC-V 中则用 x0-x31 表示,仅仅是写法不同。

1.3 为什么只有 32 个寄存器

MIPS 指令固定 32 位。在 R 型指令格式中,需要三个寄存器字段(RS、RT、RD),每个字段如果放 5 位($2^5 = 32$),三个字段就是 15 位。如果寄存器放 6 位($2^6 = 64$),三个字段就要 18 位,后面的功能码和操作码空间就被严重挤压。每个字段的选取都在互相挤压——你想把 RS 放 6 位、RT 放 6 位,那 OP 和 funct 就没空间了。所以最后只能约定:RS 放 5 位。

因此 32 个寄存器不是白来的,也不是当初设计者特别喜欢”32”这个数字,而是 32 位固定指令长度的硬约束。

资源永远是紧张的,不紧张的不叫资源。空气随便吸,但干净的空气装在罐子里就能卖——那才叫资源。你管一个团队,有做软件的、做硬件的、做安全的、做维护的,每个人都想获取更多资源、做出更大贡献。你把资源给了一个地方,别的地方一定少,少了以后这事就玩不转——这就是木桶理论。

1.4 MIPS 三种指令格式

MIPS 有 3 种指令格式(RISC-V 有 6 种,分得更细):

R 型(Register 型)

寄存器-寄存器操作,用于算术逻辑运算。

字段 OP RS RT RD SHAMT FUNCT
位宽 6 5 5 5 5 6
  • OP:操作码(6 位,$2^6 = 64$ 种可能)
  • RS:源寄存器 1
  • RT:源寄存器 2
  • RD:目标寄存器
  • SHAMT:移位量(用于移位指令)
  • FUNCT:功能码(与 OP 配合区分具体操作)

OP 只有 6 位,最多 64 个操作码。指令格式一旦设计好并在芯片上流片,无法修改。所以必须保证不出错,如果出错了,就只能将其解释为”feature”。

I 型(Immediate 型)

寄存器与立即数操作,结果放入寄存器。

字段 OP RS RT Immediate
位宽 6 5 5 16

立即数只有 16 位(有符号补码表示),范围为 $[-2^{15}, 2^{15}-1]$。

如果需要一个超过 16 位的立即数,I 型指令无法直接解决。必须用 LUI(Load Upper Immediate)先把高 16 位装入寄存器,再用 ORI 拼接低 16 位。这加重了编译器的负担——CPU 已经生产出来改不了了,只能由编译器来处理。

J 型(Jump 型)

无条件跳转,26 位跳转地址。

字段 OP Address
位宽 6 26

26 位的地址看似只能跳 $2^{26}$ 个字节的距离,但注意所有指令都是 32 位一组、地址末两位永远是 0(因为地址是 0、4、8、C…对齐的)。所以末两位不用存——26 位实际上可以寻址 $2^{26}$ 条指令,相当于 $2^{28}$ 个字节。

在做 PC 加法时注意——PC 每次加 4(不是加 1),因为每条指令 32 位 = 4 字节。

1.5 立即数的符号扩展 vs 零扩展

16 位立即数在送入 32 位 ALU 运算前必须扩展为 32 位,有两种策略:

  • 符号扩展(Sign Extension):将立即数的最高位(符号位)复制 16 次填充高 16 位。保持有符号数的值不变。
  • 零扩展(Zero Extension):高 16 位补 0。用于无符号数。

在 MIPS 中,addi 默认做符号扩展。如果做无符号加法 addiu,也只有 15 位有效(最高位视为符号位做扩展)。这是设计时的权衡——少写一条”无符号 addi”指令,用符号扩展覆盖所有场景,硬件实现也更简单。少写一条指令,芯片面积就少一点,成本就低一点。

1.6 C 语言到汇编的映射示例

以简单的求和循环为例:

1
2
3
4
int sum = 0;
for (int i = 0; i <= 100; i++) {
    sum += i;
}

编译为 MIPS 汇编时:

  • int sum = 0:利用零号寄存器,add $s0, $zero, $zero(零号寄存器加 0 得到 0)
  • int i = 0addi $s1, $zero, 0add $s1, $zero, $zero
  • 上界 100 放入寄存器:addi $t0, $zero, 100
  • 循环体内用 slt(Set Less Than)比较,beq 条件分支
  • 如果立即数超过 16 位范围,则需要 LUI + ORI 两条指令完成

映射关系本质上就是把 C 代码映射到汇编,但实际的寄存器分配和上下文约束会让事情变复杂。C 语言里 a = 一个值,这个值如果是 16 位以内,用一条 addi 就行;如果超过 16 位,就必须用 LUI + ORI 两条指令。


二、单周期 CPU 数据通路设计

2.1 设计总览与核心概念

单周期 CPU 的数据通路由以下核心模块组成:

  1. PCProgram Counter,程序计数器):有时钟的时序逻辑
  2. 指令存储器(Instruction Memory):本质上是 ROM
  3. 寄存器堆Register File
  4. ALUArithmetic Logic Unit,算术逻辑单元):组合逻辑
  5. 数据存储器(Data Memory)
  6. 控制单元(Control Unit)
  7. 多路选择器(MUX):数据通路的”阀门”

在单周期 CPU 的电路图中,带时钟小三角箭头的模块并不多——只有 PC 和存储器。其他全是组合逻辑——输入一变化,里面全是与或非门,马上就输出,没有延迟等待。

2.2 组合逻辑 vs 时序逻辑

  • 组合逻辑(Combinational Logic):输出仅取决于当前输入,输入变化输出立刻变化。用 assign 实现(Verilog)或 always_comb(SystemVerilog)。
  • 时序逻辑(Sequential Logic):输出取决于时钟沿。只有在时钟上升沿(或下降沿)到来时才更新状态。需要 always_ff @(posedge clk) 实现。

组合逻辑”马上”执行——什么时候执行?说不清,就是”马上”。在组合逻辑里,所有代码同时执行,没有先后顺序之分。这就是为什么硬件比软件难 debug 得多。软件在 always 块里还能一行一行来,组合逻辑就没法逐行调试。

因此必须对每个单元模块真正理解后,再把它们拼到一起。拼到一起之后没法逐步 debug——因为所有组件一起执行。写的时候一定要分成小模块,对每个模块做测试,看波形图,这是标准方法。

2.3 PC(程序计数器)

PC 是一个带时钟的寄存器:

  • 输入:32 位(下一条指令地址)
  • 输出:32 位(当前指令地址)
  • 行为:当时钟上升沿到来时,将输入的新值锁存到输出端。如果 reset 有效,则输出初始化为特定值(如 0x00400000,MIPS 中用户程序的起始地址)。

PC 不是永远加 4——PC 还要跳转。有时加 offset,有时直接跳到绝对地址(J 型指令)。所以 PC 的更新逻辑需要多路选择:PC+4(顺序执行)或 PC+4+offset(分支)或绝对跳转地址。

2.4 指令存储器(Instruction Memory)

本质是一个 ROM(只读存储器),输入是 32 位地址,输出是 32 位指令。

  • 行为是纯组合逻辑:只要给出地址,就输出指令,不管什么条件、时钟。
  • 在实验中通常用数组实现(reg [31:0] imem [0:N-1]),初始化时把指令手动填进去。

2.5 寄存器堆(Register File)

寄存器堆是 CPU 中读-写混合的存储模块。

读端口(两个独立读端口):

  • 输入:RA1(5 位,读地址 1)、RA2(5 位,读地址 2)
  • 输出:RD1(32 位,读数据 1)、RD2(32 位,读数据 2)
  • 行为:纯组合逻辑——只要 RA1/RA2 变化,RD1/RD2 马上变化。RA 是否为 0 需要判断:如果 RA=0,直接输出 0(零号寄存器特性);否则输出对应寄存器的值。

写端口

  • 输入:WA(5 位,写地址)、WD(32 位,写数据)、WE(写使能)
  • 行为:时序逻辑——只有在 clk 上升沿且 WE=1 时,才将 WD 写入 WA 指定的寄存器。

关键要点:A1 指定的寄存器数据传输到输出端口 RD1——没有加任何条件,没有说 WE 等于 0 还是等于 1,也没有说 clock 上升沿来。这就是组合逻辑的本质——无条件的、即时的。

写操作必须是 clk 和 we 同时成立才做。读了数据做加法,绕一圈要写回来——但数据传到写端口不代表能写进去。WE 可能已经拉高了,数据到 WA 了,但 clk 还没来——万事俱备,只欠 clock,什么也干不了。

2.6 符号扩展单元(Sign Extend)

16 位立即数扩展为 32 位:

  • 将立即数的最高位 (imm[15]) 重复 16 次,拼接在低 16 位之前
  • 纯组合逻辑:用 Verilog 的拼接操作 , imm}

符号扩展非常重要。因为 ALU 需要两个 32 位数相加,如果给它一个 16 位和一个 32 位数,高位怎么处理?电路缺输入就无法正常工作。

2.7 ALU(算术逻辑单元)

ALU 是纯组合逻辑:

  • 输入:A(32 位)、B(32 位)、ALUControl(控制信号)
  • 输出:Result(32 位)、Zero(1 位,为 1 表示结果为 0)

行为:always_comb——只要任何输入产生变化,马上重新计算。由 ALUControl 决定做加法、减法、与、或等操作。MIPS 中通过 OP 码和 FUNCT 码共同生成 ALUControl 信号。

在单周期 CPU 中,ALU 简化版可能只实现 AND、OR、ADD、SUB 四条指令。两位控制信号,可以再往上扩展。

2.8 数据存储器(Data Memory)

  • 输入:Address(32 位)、WriteData(32 位)、MemWrite(写使能)、MemRead(读使能)
  • 输出:ReadData(32 位)
  • 行为:读是组合逻辑(地址一来就出数据),写是时序逻辑(clk 来且 MemWrite 有效才写入)

数据存储器写操作时要小心——写的时候,读端口也会输出数据,因为这个地址对应的存储单元内容变了,输出自然跟着变。但这个数据只是”闪了一下”——后面没人接收、不响应,就不会有影响。就像水龙头,虽然管子里有水,但阀门关着,水就流不过来。硬件设计就是这样的——数据会变化,但靠一个个阀门给它卡住。

2.9 多路选择器(MUX)

在数据通路中扮演”阀门”的角色,选择数据流向:

  • ALUSrc MUX:选择 ALU 的第二个操作数——来自寄存器堆 RD2(R 型指令)还是符号扩展的立即数(I 型指令)
  • MemtoReg MUX:选择写入寄存器堆的数据——来自 ALU 结果(R 型/I 型)还是数据存储器(Load 指令)
  • RegDst MUX:选择目标寄存器编号——RT(I 型指令)还是 RD(R 型指令)
  • PCSrc MUX:选择下一个 PC 值——PC+4 还是分支目标地址

不要以为没选中的通路是安安静静的——那边汹涌澎湃,数据不停在变化。只不过通过阀门把它屏蔽掉了而已。


三、控制路径与数据流详解

3.1 控制信号的来源

控制单元(Control Unit)根据指令的 OP 码(和 FUNCT 码)产生所有控制信号:

  • RegWrite:是否写寄存器堆
  • ALUSrc:ALU 第二操作数来源
  • MemWrite / MemRead:数据存储器读写使能
  • MemtoReg:写回数据来源
  • RegDst:目标寄存器选择
  • Branch / Jump:分支和跳转控制
  • ALUControl:ALU 操作选择

3.2 不同类型指令的数据流路径

R 型指令(如 add $rd, $rs, $rt

  1. PC 给出指令地址,指令存储器输出指令
  2. RS 字段 → RA1,RT 字段 → RA2,读取两个寄存器
  3. ALUSrc=0,ALU 的 A=RD1, B=RD2
  4. ALU 做加法,输出 Result
  5. MemtoReg=0,RegDst=1(选 RD),将 Result 送到寄存器堆写端口
  6. 等 clk 来,RegWrite=1,写入 RD 指定的寄存器

I 型指令(如 addi $rt, $rs, imm

  1. 同 R 型取指令
  2. RS → RA1 读寄存器 1
  3. 16 位立即数做符号扩展为 32 位
  4. ALUSrc=1,ALU 的 A=RD1, B=符号扩展后的立即数
  5. ALU 做加法
  6. MemtoReg=0,RegDst=0(选 RT),等 clk 写回

Load 指令(如 lw $rt, offset($rs)

  1. RS → RA1 读基址寄存器
  2. offset 符号扩展
  3. ALU 计算地址 = 基址 + offset
  4. ALU 结果作为地址访问数据存储器(MemRead=1)
  5. MemtoReg=1,将数据存储器输出写回寄存器

Branch 指令(如 beq $rs, $rt, label

  1. 读 RS 和 RT 两个寄存器
  2. ALU 做减法(A - B),产生 Zero 标志
  3. 同时另一个加法器计算分支目标地址:PC+4 + (offset « 2)
  4. 如果 Zero=1(相等),PCSrc=1 选分支地址;否则 PC+4

Jump 指令

  1. 取出 26 位地址字段
  2. 左移 2 位(乘 4,变成字节地址)
  3. 拼接当前 PC+4 的高 4 位(因为 J 型只有 26 位地址,高 4 位动不了)
  4. Jump=1,多路选择器选跳转地址作为新 PC

PC 上来二话不说先加了个 4。对于 Branch,实际新 PC = (PC+4) + (offset « 2)。注意 offset 必须先左移两位——因为指令地址末两位总是 0,所以 offset 字段里存的是”第几条指令”而不是”第几个字节”。

3.3 一个时钟周期内的完整时序

时钟像”下课铃”。

上一个 clk 下降沿到当前 clk 上升沿之间

  • PC 已经稳定输出当前指令地址
  • 指令存储器输出指令(组合逻辑,立刻响应)
  • 寄存器堆的两个读端口输出数据(组合逻辑)
  • 如果有立即数,符号扩展单元输出扩展后的 32 位数
  • ALU 的两个输入已经到位,马上计算输出结果
  • 这个结果可能经过更多组合逻辑传到数据存储器地址端、传到寄存器堆写端口
  • 控制单元也早已解析出所有控制信号,各个 MUX 已经把通路接通

但什么都没发生。所有模块都只是在”准备”,因为没有 clk 来锁存。

clk 上升沿到来的瞬间

  • PC 更新为新值(下一条指令地址)
  • 寄存器堆如果 WE=1,把数据写入指定寄存器
  • 数据存储器如果 MemWrite=1,把数据写入指定地址

clk 之后:新的 PC 值立即触发新一轮的组合逻辑运算,下一条指令的数据通路开始工作。

一条 R 型指令实际要两个时钟周期才能完成——第一个周期取指令、运算,第二个周期 clk 来了才写回。但这也说得过去:在一个完整周期内把事情干完了。时钟周期必须是所有指令中执行路径最长的那条指令的延迟——取指令、读寄存器、ALU 运算、访存、写回一条龙走完的时间。其他指令做得快也要硬等,这就是单周期 CPU 的最大弊端。


四、硬件设计方法

4.1 模块化设计

每一个模块一定要确认写对。代码写好几堆,执行的时候瞬间全部执行——没法 debug。所以写的时候一定要分小模块,对每个模块单独做测试,看波形图,确认无误后再拼起来。

模块划分:

  • regfile.v — 寄存器堆
  • alu.v + alucontrol.v — ALU 及控制
  • imem.v — 指令存储器(ROM)
  • dmem.v — 数据存储器
  • controller.v — 主控制单元
  • datapath.v — 数据通路顶层(连接各模块)
  • top.v — 顶层模块(连接 datapath 和 control)

4.2 调试策略

如果想单独跟踪某根线上的信号,会发现所有的线都在变——因为组合逻辑只要有输入就变。没法看。所以一定要按模块独立验证。

调试流程:

  1. 每个模块独立写 testbench
  2. 用波形图检查输入输出是否匹配预期
  3. 确认模块正确后连接
  4. 如果拼起来出错了,一定是某个模块出问题,而不是连线问题

数据存储器读端口的数据也会在写操作时跟着变——它只是”闪了一下”就没人在意了。但如果控制信号没写好阀门,这个错误数据就会影响后续模块。硬件设计的核心就是:输入端数据汹涌澎湃地变化,靠阀门(MUX)一层层卡住,只有正确的数据在正确的时刻通过。

4.3 Verilog/SystemVerilog 混合环境

  • Verilog:always @(*)描述组合逻辑,always @(posedge clk) 描述时序逻辑
  • SystemVerilog:always_combalways_ff @(posedge clk),且输入输出用 logic 类型代替 wire/reg

五、单周期 CPU 性能分析

单周期 CPU 的性能公式:

\[\text{执行时间} = \text{指令数} \times \text{时钟周期}\]

其中时钟周期 = 所有指令中最长执行路径的延迟。因为每一条指令都必须在一个时钟周期内完成全部操作(取指、译码、执行、访存、写回),时钟周期只能迁就最慢的那条指令。

某条指令 20 个纳秒就做完了,可时钟周期是 100 纳秒——因为最长的 load 指令要 100 纳秒。计算机做的是非常快的,但只有硬等、等等等……等到 clk 来。这就是单周期最大的问题——效率低,快了也要等。

5.1 指令存储器和数据存储器的分离

在简单的教学实现中可以把指令和数据放在一起(物理上同一块 memory),但实际中:

  • 取指令每个周期都会发生
  • 写数据在某些周期也会发生
  • 如果两者共用端口,就会产生冲突,需要在控制逻辑上想办法仲裁

为了简化,教学实现分开处理——指令存储器是 ROM,数据存储器是 RAM。