0%
12 min read 学习笔记 学习路线

计算机组成原理 Notes (Part 1): CPU 架构与 RISC-V 指令集

计算机组成原理学习笔记(上):CPU 架构、RISC-V 数据表示、算术/内存/控制流/系统调用/伪指令、过程调用与浮点数。

这是 计算机组成原理 Notes 系列的第一期。

本门课程的主要内容

  • CPU架构
  • 汇编语言(主要是RISC-V,包含少量MIPS)

CPU架构

Core of CPU

ALU

CU

寄存器

  • 通用寄存器
  • 程序计数器(PC): 存储下一条指令的地址, 执行以后自动加4
  • 指令寄存器(IR): 存储当前正在执行的指令
  • 状态寄存器(SR): 存储CPU的状态信息,如条件码、控制位等
  • 浮点寄存器(FPU): 用于浮点运算的寄存器
  • 特殊寄存器(如堆栈指针SP、全局指针GP等): 用于特定功能的寄存器

Hazard Unit

数据通路设计

Pipline

Branch Prediction

Cache

Hazard Unit

BUS

RISC-V指令集

数据表示

  • 整数:有符号整数使用补码表示,无符号整数直接使用二进制表示。
  • 浮点数:使用IEEE 754标准表示,分为单精度(32位)和双精度(64位)。
  • 字符:使用ASCII编码,每个字符占用一个字节(8位)。

拓展

  • 零拓展(Zero Extension):将较小的数据类型扩展为较大的数据类型时,在高位填充0。例如,将一个8位的无符号整数0x7F(127)零拓展为16位,结果是0x007F。
  • 符号拓展(Sign Extension):将较小的数据类型扩展为较大的数据类型时,在高位填充符号位(即最高位)。例如,将一个8位的有符号整数0xFF(-1)符号拓展为16位,结果是0xFFFF。

数据对齐

RISC-V要求数据在内存中按照其大小进行对齐。例如,32位整数必须存储在4字节边界上,64位整数必须存储在8字节边界上。数据对齐可以提高内存访问效率,但也可能导致内存浪费。

RISC-V算术指令

add x1, x2, x3  # x1 = x2 + x3
sub x1, x2, x3  # x1 = x2 - x3
addi x1, x2, 10 # x1 = x2 + 10
sll x1, x2, 5   # x1 = x2 << 5
srl x1, x2, 5   # x1 = x2 >> 5
xor x1, x2, x3  # x1 = x2 ^ x3 
  • add: 加法
  • sub: 减法
  • addi: 加法立即数
  • sll: shift left logical
  • srl: shift right logical
  • xor: 按位异或
  • addu, subu…: 无符号加法、无符号减法等指令,区别在于是否考虑溢出。

RISC-V内存访问指令

lw x1, 0(x2)   # 从地址x2 + 0处加载一个字到x1
sw x1, 0(x2)   # 将x1中的字存储到地址x2 + 0处
  • lw: Load Word, 从第二个操作数指定的内存地址加载一个字(4字节)到第一个操作数指定的寄存器中。注意,不是将内存地址加载到寄存器,而是将内存地址处的内容加载到寄存器。
  • sw: Store Word

RISC-V控制流指令

beq x1, x2, label  # 如果x1 == x2,则跳转到label
bne x1, x2, label  # 如果x1 != x2,则跳转到label
j label           # 无条件跳转到label
jal x1, label      # 将返回地址存储到x1,并跳转到label
jalr x1, 0(x2)    # 将返回地址存储到x1,并跳转到x2 + 0
  • beq: Branch if Equal
  • bne: Branch if Not Equal
  • j: Jump
  • jal: Jump and Link
  • jalr: Jump and Link Register

我们来看一个简单的while循环:

loop:
    beq x1, x2, exit  # 如果x1 == x2,则跳转到exit
    addi x1, x1, 1   # x1 = x1 + 1
    j loop           # 跳转回loop标签处继续执行
exit:

如果是For循环则稍微复杂一些,尤其是操作数组(连续内存)时:

    li x1, 0          # i = 0
    li x2, 10         # n = 10
loop:
    beq x1, x2, exit  # 如果i == n,则跳转到exit
    lw x3, 0(x4)      # 从数组地址x4 + i*4处加载一个字到x3
    addi x1, x1, 1   # i = i + 1
    addi x4, x4, 4   # 移动到下一个数组元素的地址
    j loop           # 跳转回loop标签处继续执行

上述的实现是比较简洁的。

RISC-V系统调用

li a7, 1        # 系统调用号1:打印整数
li a0, 42       # 要打印的整数
blt a7, 0, exit   # 如果系统调用号小于0,跳转到exit
bge a7, 10, exit  # 如果系统调用号大于等于10,跳转到exit
bltu a7, 0, exit   # 如果系统调用号小于0,跳转到exit
bgeu a7, 10, exit  # 如果系统调用号大于等于10,跳转到exit
ecall           # 触发系统调用
  • li: Load Immediate
  • ecall: Environment Call (系统调用指令)

RISC-V伪指令

li x1, 1000     # 伪指令,加载立即数1000到x1
mv x1, x2       # 伪指令,x1 = x2

伪指令是汇编器提供的便利指令,实际会被翻译成一系列基本指令。

过程调用

在计算机中,我们通常需要调用程序来处理数据,这就涉及到过程调用。过程调用是指在程序中调用一个函数或子程序来执行特定的任务。在RISC-V中,过程调用通常使用jal和jalr指令来实现。

程序计数器

程序计数器(PC)是一个特殊的寄存器,用于存储下一条指令的地址。当执行jal或jalr指令时,PC会被更新为目标地址,同时将返回地址存储在指定的寄存器中,以便在函数执行完成后能够返回到调用点继续执行。

每执行一条指令,PC会自动加4(因为每条指令占用4字节),除非遇到跳转指令(如jal或jalr),此时PC会被更新为跳转目标地址。这就是为什么汇编程序会自动顺序执行,除非遇到跳转指令。例如,在以下代码中:

beq x1, x2, else  # 如果x1 == x2,则跳转到else
addi x1, x1, 1   # x1 = x1 + 1
else: add x3, x1, x2   # x3 = x1 + x2

此时,与C语言不同,程序不会自动识别“else”标签,无论如何else标签后面的指令都会被执行,除非在else标签前面有一个跳转指令(如beq)将程序跳转到例如exit之类的退出标签处。

寄存器约定

在RISC-V中,寄存器约定是一种约定俗成的规则,用于规定不同寄存器的用途和使用方式。以下是一些常见的寄存器约定:

  • x0: 零寄存器,始终为0
  • x1-x7: 临时寄存器,函数调用时不需要保存
  • x8-x15: 保存寄存器,函数调用时需要保存
  • x16-x23: 参数寄存器,用于传递函数参数
  • x24-x31: 保留寄存器,供操作系统使用
  • a0-a7: 用于函数返回值和参数传递的寄存器(caller reg)
  • s0-s11: 用于保存函数调用过程中需要保留的寄存器(callee reg)
  • t0-t6: 用于临时计算的寄存器(caller reg)
  • ra: 返回地址寄存器,用于存储函数返回地址(caller reg)
  • sp: 堆栈指针寄存器,用于管理函数调用的堆栈
  • gp: 全局指针寄存器,用于访问全局数据
  • tp: 线程指针寄存器,用于访问线程局部数据
  • fp: 帧指针寄存器,用于管理函数调用的栈帧

上述的寄存器约定有助于程序员在编写汇编代码时更好地组织和管理寄存器的使用,避免冲突和混乱。

Caller reg and Callee reg:

Caller reg指的是,调用之后Caller中需要恢复的寄存器,Callee reg指的是,调用之后Callee中需要恢复的寄存器

调用过程

在RISC-V中,调用过程通常涉及以下步骤:

  1. 将函数参数传递到指定的寄存器(如a0-a7)。
  2. 使用jal指令跳转到函数的入口地址,并将返回地址(一般是pc+4)存储到ra寄存器中。在jal命令中,我们只需要指定函数的入口地址,编译器会自动计算返回地址并存储到ra寄存器中。
  3. 在函数内部执行所需的操作,可能会使用s0-s11寄存器来保存需要保留的值。
  4. 使用jalr指令返回到调用点,跳转到ra寄存器中存储的返回地址。

Leaf Procedure

Leaf Procedure(叶子过程)是指在函数调用过程中不再调用其他函数的函数。由于Leaf Procedure不需要保存返回地址,因此可以直接使用jalr指令返回到调用点,而不需要使用jal指令跳转到函数入口地址。这种优化可以提高函数调用的效率,减少不必要的跳转和寄存器使用。

我们使用sp寄存器来管理函数调用的堆栈空间。在Leaf Procedure中,我们可以直接使用sp寄存器来分配和释放局部变量的空间,而不需要担心返回地址的保存和恢复。例如:

leaf_function:
    addi sp, sp, -16  # 为局部变量分配空间
    sw ra, 12(sp)     # 保存返回地址
    # 执行函数体的操作
    lw ra, 12(sp)     # 恢复返回地址
    addi sp, sp, 16   # 释放局部变量空间
    jalr x0, 0(ra)   # 返回到调用点

sp指针从高地址向低地址增长,因此在分配空间时需要将sp减去相应的字节数,在释放空间时需要将sp加回相应的字节数。如果不恢复栈指针,可能会导致栈空间泄漏或栈溢出等问题。

Non-Leaf Procedure

Non-Leaf Procedure(非叶子过程)是指在函数调用过程中会调用其他函数的函数。由于Non-Leaf Procedure需要保存返回地址,因此必须使用jal指令跳转到函数入口地址,并将返回地址存储到ra寄存器中。在Non-Leaf Procedure中,可能还需要使用s0-s11寄存器来保存需要保留的值,以便在调用其他函数时不丢失重要数据。

嵌套与递归

在RISC-V中,嵌套函数调用和递归函数调用都可以通过上述的调用过程来实现。对于嵌套函数调用,内层函数可以继续调用其他函数,而外层函数会等待内层函数执行完成后再继续执行。对于递归函数调用,函数可以直接或间接地调用自身,直到满足某个终止条件为止。

下面是一个简单的递归函数调用的例子:

factorial:
    # 基本情况: if n <= 1, return 1
    li   t0, 1
    ble  a0, t0, base_case
    
    # 递归情况
    addi sp, sp, -8         # 分配栈空间 (16字节对齐),从高到低
    sw   ra, 4(sp)          # 保存返回地址
    sw   s0, 0(sp)          # 保存 s0 (调用者保存),此处为上一层的参数a0
    # 每一次递归深入,保存当时的参数a0和ra,共8字节
    
    mv   s0, a0             # s0 = n,将参数保存到
    addi a0, a0, -1         # a0 = n-1
    jal  factorial          # 递归调用 factorial(n-1)
    
    # 此时 a0 = (n-1)!
    mul  a0, s0, a0         # a0 = n * (n-1)!
    
    # 恢复现场
    lw   s0, 0(sp) # 取出栈指针指向的数,存入s0,提供给上一层使用
    lw   ra, 4(sp)
    addi sp, sp, 8
    jr   ra

base_case:
    li   a0, 1              # return 1,将结果保存到a0
    jr   ra

我们来看一下指令

addi a0, x0, 5
jal ra, factorial

li a7, 1
ecall

指令组成

RISC-V 指令采用固定长度(32位)的编码格式,根据指令功能的不同,分为以下六种基本格式:

指令格式

格式名称用途结构
R-type寄存器型算术逻辑运算funct7 | rs2 | rs1 | funct3 | rd | opcode
I-type立即数型立即数运算、加载imm[11:0] | rs1 | funct3 | rd | opcode
S-type存储型存储指令imm[11:5] | rs2 | rs1 | funct3 | imm[4:0] | opcode
B-type分支型条件分支imm[12,10:5] | rs2 | rs1 | funct3 | imm[4:1,11] | opcode
U-type上置立即数型加载高位立即数imm[31:12] | rd | opcode
J-type跳转型无条件跳转imm[20,10:1,11,19:12] | rd | opcode

各字段含义

  • opcode (7位): 操作码,决定指令类型
  • rd (5位): 目标寄存器编号
  • rs1/rs2 (5位): 源寄存器编号
  • funct3/funct7 (3/7位): 功能码,细化操作类型
  • imm: 立即数,不同格式中位数不同

指令分类

1. 算术运算指令

指令格式功能说明
ADDRrd = rs1 + rs2加法
SUBRrd = rs1 - rs2减法
ADDIIrd = rs1 + imm加立即数
LUIUrd = imm << 12加载高位立即数
AUIPCUrd = PC + (imm << 12)加PC到高位立即数

2. 逻辑运算指令

指令格式功能说明
AND/OR/XORR位与/或/异或逻辑运算
ANDI/ORI/XORII与/或/异或立即数立即数逻辑运算
SLL/SRL/SRAR逻辑/算术移位移位操作

3. 比较指令

指令格式功能说明
SLT/SLTURrd = (rs1 < rs2) ? 1 : 0小于置位(有/无符号)
SLTI/SLTIUIrd = (rs1 < imm) ? 1 : 0小于立即数置位

4. 分支指令

指令格式功能说明
BEQ/BNEB等于/不等于分支条件跳转
BLT/BLTUB小于分支(有/无符号)条件跳转
BGE/BGEUB大于等于分支(有/无符号)条件跳转
JALJrd = PC+4; PC += imm跳转并链接
JALRIrd = PC+4; PC = rs1 + imm寄存器跳转并链接

5. 加载存储指令

指令格式功能说明
LB/LH/LW/LDIrd = M[rs1+imm]加载字节/半字/字/双字
LBU/LHU/LWUI无符号加载零扩展加载
SB/SH/SW/SDSM[rs1+imm] = rs2存储字节/半字/字/双字

寻址方式

寻址方式是指确定操作数所在位置的方法。RISC-V 支持以下几种主要寻址方式:

1. 寄存器寻址 (Register Addressing)

原理:操作数直接存放在寄存器中,指令中给出寄存器编号。

格式:操作码  rd, rs1, rs2
示例:ADD x5, x6, x7    # x5 = x6 + x7

特点

  • 无需内存访问,速度最快
  • 所有 R-type 指令采用此方式
  • 寄存器数量有限(32个)

适用指令:ADD, SUB, AND, OR, XOR, SLL, SRL, SRA 等

2. 立即数寻址 (Immediate Addressing)

原理:操作数直接包含在指令中,作为指令的一部分。

格式:操作码  rd, rs1, imm
示例:ADDI x5, x6, 10   # x5 = x6 + 10

特点

  • 无需额外的内存访问
  • 立即数范围受限(I-type: 12位有符号数,范围 -2048~2047)
  • 常用于常数运算、小数值操作

适用指令:ADDI, ANDI, ORI, XORI, SLTI, SLLI, SRLI, SRAI 等

大范围立即数加载

# 加载 32位立即数 0x12345678 到 x5
lui  x5, 0x12345      # x5 = 0x12345000
addi x5, x5, 0x678    # x5 = 0x12345678

# 或使用伪指令
li   x5, 0x12345678   # 汇编器自动展开为上述指令
3. Base寻址 / 位移寻址 (Base/Displacement Addressing)

原理:操作数的有效地址 = 基址寄存器值 + 偏移量(立即数)

格式:操作码  rd, offset(base)
示例:LW x5, 8(x6)     # x5 = M[x6 + 8]
     SW x5, 4(x7)     # M[x7 + 4] = x5

地址计算

Effective Address=Base Register+SignExtended(offset)\text{Effective Address} = \text{Base Register} + \text{SignExtended}(\text{offset})

特点

  • 支持访问结构体、数组元素、栈帧中的变量
  • 偏移量为12位有符号数(-2048~2047)
  • 基址寄存器内容不变

典型应用

# 访问结构体成员(假设基址在 x10)
lw x5, 0(x10)    # 成员 offset 0
lw x6, 4(x10)    # 成员 offset 4
lh x7, 8(x10)    # 成员 offset 8

# 栈帧访问
sw ra, 4(sp)     # 保存返回地址到栈
lw ra, 4(sp)     # 恢复返回地址
4. PC相对寻址 (PC-Relative Addressing)

原理:目标地址 = 当前 PC 值 + 偏移量(指令编码中的立即数)

格式:操作码  rs1, rs2, label
      操作码  rd, label

示例:BEQ x5, x6, loop   # if(x5==x6) PC = PC + offset
     JAL x1, function   # x1 = PC+4; PC = PC + offset

地址计算

Target Address=PC+SignExtended(offset×2)\text{Target Address} = \text{PC} + \text{SignExtended}(\text{offset} \times 2)

特点

  • 偏移量是字(2字节)对齐的,因此指令编码中省略最低位(总是0)
  • B-type:13位有符号偏移(实际表示范围 ±4KB)
  • J-type:21位有符号偏移(实际表示范围 ±1MB)
  • 支持位置无关代码(PIC),代码可以在内存中任意位置执行

跳转范围

指令类型偏移位数实际范围用途
B-type13位PC ± 4KB条件分支(循环、if语句)
J-type21位PC ± 1MB函数调用、长跳转

示例

    addi x5, x0, 0       # i = 0
    addi x6, x0, 10      # max = 10
loop:
    beq  x5, x6, exit    # 如果 i == 10,跳转到 exit
                         # 偏移量 = exit地址 - (loop地址 + 4)
    addi x5, x5, 1       # i++
    jal  x0, loop        # 无条件跳转回 loop
exit:
    # 继续执行...
5. 间接寻址 (Indirect Addressing)

原理:操作数地址存放在寄存器中。

格式:操作码  rd, offset(rs1)
示例:JALR x0, 0(x5)    # PC = x5 + 0 (跳转表、函数指针调用)
     JALR ra, 0(ra)    # 函数返回

特点

  • 可以跳转到任意32位地址
  • 常用于函数返回、虚函数调用、switch语句实现
  • JALR 将偏移量与寄存器值相加

典型应用

# 函数指针调用
# 假设函数地址在 x10
jalr ra, 0(x10)       # 调用函数指针指向的函数

# 返回指令(jr 是 jalr 的伪指令)
jr ra                 # 等价于 jalr x0, 0(ra)

# Switch 跳转表实现
la   x5, jump_table   # 加载跳转表基址
slli x6, x6, 2        # 索引 * 4(字对齐)
add  x5, x5, x6       # 计算目标地址
lw   x5, 0(x5)        # 加载目标地址
jalr x0, 0(x5)        # 跳转到目标

寻址方式对比总结

寻址方式有效地址计算操作数位置典型指令主要用途
寄存器寻址寄存器ADD, SUB算术逻辑运算
立即数寻址指令中ADDI, LUI常数操作
Base寻址EA = 基址 + 偏移内存LW, SW数组、结构体访问
PC相对寻址EA = PC + 偏移内存(代码段)BEQ, JAL分支、跳转
间接寻址EA = 寄存器值内存JALR函数返回、函数指针
指令的寻址范围

32位地址空间中的寻址能力

指令类型寻址范围说明
I-type (Load)±2KB相对于基址寄存器的偏移
S-type (Store)±2KB相对于基址寄存器的偏移
B-type (Branch)±4KBPC相对,13位有符号偏移
J-type (Jump)±1MBPC相对,21位有符号偏移
JALR全地址空间32位寄存器值 + 12位偏移

大范围访问策略

# 访问远距离数据(超出 ±2KB 范围)
lui  x5, 0x12345      # 加载高位地址
addi x5, x5, 0x678    # 加上低位偏移
lw   x6, 0(x5)        # 加载数据

# 远距离跳转(超出 ±1MB)
lui  x5, %hi(target)  # 加载目标地址高位
addi x5, x5, %lo(target)  # 加上低位
jalr ra, 0(x5)        # 跳转到目标

浮点数

IEEE 754浮点数规格

浮点数格式

IEEE 754 标准定义了浮点数的存储格式,由三部分组成:

字段位数(单精度)位数(双精度)说明
符号位 (S)110为正,1为负
指数位 (E)811移码表示,需减去偏置值
尾数位 (M)2352小数部分

单精度(32位)浮点数格式

31 | 30      23 | 22                    0
 S |   指数E   |        尾数M
 1位   8位           23位

双精度(64位)浮点数格式

63 | 62          52 | 51                                        0
 S |     指数E      |                  尾数M
 1位      11位                52位

真值计算公式Value=(1)S×1.M×2EbiasValue = (-1)^S \times 1.M \times 2^{E - bias}

  • 单精度偏置值bias = 127
  • 双精度偏置值bias = 1023
规格化浮点数

规格化条件:指数位不全为0且不全为1。

隐含最高位:规格化浮点数默认尾数的最高位为1(即 1.M),该位不存储,称为隐含位隐藏位。因此单精度实际有效位数为24位(1位隐含 + 23位存储)。

非规格化数:当指数位全为0时,表示非规格化数: Value=(1)S×0.M×21biasValue = (-1)^S \times 0.M \times 2^{1 - bias}

非规格化数用于表示非常接近0的数值,以及 ±0(尾数也为0时)。

特殊值的指数编码

指数位尾数位含义
全0全0±0
全0非0非规格化数
全1全0±∞(无穷大)
全1非0NaN(非数)
浮点数的数据段范围

单精度(32位)范围

类型最小正值最大正值说明
规格化数1.0×21261.18×10381.0 \times 2^{-126} \approx 1.18 \times 10^{-38}(2223)×21273.40×1038(2 - 2^{-23}) \times 2^{127} \approx 3.40 \times 10^{38}正常表示范围
非规格化数223×21261.40×10452^{-23} \times 2^{-126} \approx 1.40 \times 10^{-45}(1223)×2126(1 - 2^{-23}) \times 2^{-126}接近0的子范围

双精度(64位)范围

类型最小正值最大正值
规格化数210222.23×103082^{-1022} \approx 2.23 \times 10^{-308}(2252)×210231.80×10308(2 - 2^{-52}) \times 2^{1023} \approx 1.80 \times 10^{308}

典型数值的编码示例

数值符号位指数位(含偏置)尾数位
+1.0001111111 (127)全0
-1.0101111111 (127)全0
+0.00全0全0
+∞0全1全0
NaN0全1非0

浮点数加法

浮点数加法需要经过多个步骤,确保结果正确且规格化:

步骤1:对阶(Alignment)

  • 比较两个操作数的指数
  • 将指数较小的数的尾数右移,使其指数与较大的对齐
  • 右移时低位丢弃,可能需要舍入

步骤2:尾数相加

  • 将对齐后的尾数进行加法运算
  • 包含隐含位参与运算(实际为 1.M

步骤3:规格化(Normalization)

  • 若结果溢出(尾数 ≥ 2),则右规:尾数右移1位,指数加1
  • 若结果最高位为0,则左规:尾数左移,指数减1,直到最高位为1

步骤4:舍入(Rounding)

  • 根据舍入模式处理超出精度的位
  • 常见模式:向偶数舍入(默认)、向零舍入、向正无穷舍入、向负无穷舍入

步骤5:判断溢出

  • 指数超出表示范围则为溢出
  • 指数上溢:结果太大,产生 ±∞
  • 指数下溢:结果太小,产生0或非规格化数

示例:计算 1.0 + 0.5

1.0  = 1.000...0 × 2^0
0.5  = 1.000...0 × 2^-1

对阶:0.5 的尾数右移1位
     0.100...0 × 2^0

相加:1.000...0 + 0.100...0 = 1.100...0 × 2^0

结果:1.5

浮点数乘除法

浮点数乘法

(M1×2E1)×(M2×2E2)=(M1×M2)×2E1+E2(M_1 \times 2^{E_1}) \times (M_2 \times 2^{E_2}) = (M_1 \times M_2) \times 2^{E_1 + E_2}

运算步骤:

  1. 指数相加Eresult=E1+E2biasE_{result} = E_1 + E_2 - bias(减去一个偏置值避免重复计算)
  2. 尾数相乘Mresult=M1×M2M_{result} = M_1 \times M_2
  3. 规格化:调整尾数和指数
  4. 舍入:处理超出精度的位
  5. 确定符号:同号得正,异号得负

浮点数除法

M1×2E1M2×2E2=M1M2×2E1E2\frac{M_1 \times 2^{E_1}}{M_2 \times 2^{E_2}} = \frac{M_1}{M_2} \times 2^{E_1 - E_2}

运算步骤:

  1. 指数相减Eresult=E1E2+biasE_{result} = E_1 - E_2 + bias
  2. 尾数相除Mresult=M1÷M2M_{result} = M_1 \div M_2
  3. 规格化与舍入
  4. 确定符号

特殊处理

  • 除数为0 → 结果为 ±∞NaN
  • 被除数为 ±∞ 且除数为 ±∞NaN
  • 0 / 0NaN

乘法器实现:现代 CPU 使用 Booth 算法或阵列乘法器加速尾数乘法,指数运算通过加法器完成。

Comments