这是 计算机组成原理 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中,调用过程通常涉及以下步骤:
- 将函数参数传递到指定的寄存器(如a0-a7)。
- 使用jal指令跳转到函数的入口地址,并将返回地址(一般是pc+4)存储到ra寄存器中。在jal命令中,我们只需要指定函数的入口地址,编译器会自动计算返回地址并存储到ra寄存器中。
- 在函数内部执行所需的操作,可能会使用s0-s11寄存器来保存需要保留的值。
- 使用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. 算术运算指令
| 指令 | 格式 | 功能 | 说明 |
|---|---|---|---|
ADD | R | rd = rs1 + rs2 | 加法 |
SUB | R | rd = rs1 - rs2 | 减法 |
ADDI | I | rd = rs1 + imm | 加立即数 |
LUI | U | rd = imm << 12 | 加载高位立即数 |
AUIPC | U | rd = PC + (imm << 12) | 加PC到高位立即数 |
2. 逻辑运算指令
| 指令 | 格式 | 功能 | 说明 |
|---|---|---|---|
AND/OR/XOR | R | 位与/或/异或 | 逻辑运算 |
ANDI/ORI/XORI | I | 与/或/异或立即数 | 立即数逻辑运算 |
SLL/SRL/SRA | R | 逻辑/算术移位 | 移位操作 |
3. 比较指令
| 指令 | 格式 | 功能 | 说明 |
|---|---|---|---|
SLT/SLTU | R | rd = (rs1 < rs2) ? 1 : 0 | 小于置位(有/无符号) |
SLTI/SLTIU | I | rd = (rs1 < imm) ? 1 : 0 | 小于立即数置位 |
4. 分支指令
| 指令 | 格式 | 功能 | 说明 |
|---|---|---|---|
BEQ/BNE | B | 等于/不等于分支 | 条件跳转 |
BLT/BLTU | B | 小于分支(有/无符号) | 条件跳转 |
BGE/BGEU | B | 大于等于分支(有/无符号) | 条件跳转 |
JAL | J | rd = PC+4; PC += imm | 跳转并链接 |
JALR | I | rd = PC+4; PC = rs1 + imm | 寄存器跳转并链接 |
5. 加载存储指令
| 指令 | 格式 | 功能 | 说明 |
|---|---|---|---|
LB/LH/LW/LD | I | rd = M[rs1+imm] | 加载字节/半字/字/双字 |
LBU/LHU/LWU | I | 无符号加载 | 零扩展加载 |
SB/SH/SW/SD | S | M[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
地址计算:
特点:
- 支持访问结构体、数组元素、栈帧中的变量
- 偏移量为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
地址计算:
特点:
- 偏移量是字(2字节)对齐的,因此指令编码中省略最低位(总是0)
- B-type:13位有符号偏移(实际表示范围 ±4KB)
- J-type:21位有符号偏移(实际表示范围 ±1MB)
- 支持位置无关代码(PIC),代码可以在内存中任意位置执行
跳转范围:
| 指令类型 | 偏移位数 | 实际范围 | 用途 |
|---|---|---|---|
| B-type | 13位 | PC ± 4KB | 条件分支(循环、if语句) |
| J-type | 21位 | 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) | ±4KB | PC相对,13位有符号偏移 |
| J-type (Jump) | ±1MB | PC相对,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) | 1 | 1 | 0为正,1为负 |
| 指数位 (E) | 8 | 11 | 移码表示,需减去偏置值 |
| 尾数位 (M) | 23 | 52 | 小数部分 |
单精度(32位)浮点数格式:
31 | 30 23 | 22 0
S | 指数E | 尾数M
1位 8位 23位
双精度(64位)浮点数格式:
63 | 62 52 | 51 0
S | 指数E | 尾数M
1位 11位 52位
真值计算公式:
- 单精度偏置值:
bias = 127 - 双精度偏置值:
bias = 1023
规格化浮点数
规格化条件:指数位不全为0且不全为1。
隐含最高位:规格化浮点数默认尾数的最高位为1(即 1.M),该位不存储,称为隐含位或隐藏位。因此单精度实际有效位数为24位(1位隐含 + 23位存储)。
非规格化数:当指数位全为0时,表示非规格化数:
非规格化数用于表示非常接近0的数值,以及 ±0(尾数也为0时)。
特殊值的指数编码:
| 指数位 | 尾数位 | 含义 |
|---|---|---|
| 全0 | 全0 | ±0 |
| 全0 | 非0 | 非规格化数 |
| 全1 | 全0 | ±∞(无穷大) |
| 全1 | 非0 | NaN(非数) |
浮点数的数据段范围
单精度(32位)范围:
| 类型 | 最小正值 | 最大正值 | 说明 |
|---|---|---|---|
| 规格化数 | 正常表示范围 | ||
| 非规格化数 | 接近0的子范围 |
双精度(64位)范围:
| 类型 | 最小正值 | 最大正值 |
|---|---|---|
| 规格化数 |
典型数值的编码示例:
| 数值 | 符号位 | 指数位(含偏置) | 尾数位 |
|---|---|---|---|
+1.0 | 0 | 01111111 (127) | 全0 |
-1.0 | 1 | 01111111 (127) | 全0 |
+0.0 | 0 | 全0 | 全0 |
+∞ | 0 | 全1 | 全0 |
NaN | 0 | 全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
浮点数乘除法
浮点数乘法:
运算步骤:
- 指数相加:(减去一个偏置值避免重复计算)
- 尾数相乘:
- 规格化:调整尾数和指数
- 舍入:处理超出精度的位
- 确定符号:同号得正,异号得负
浮点数除法:
运算步骤:
- 指数相减:
- 尾数相除:
- 规格化与舍入
- 确定符号
特殊处理:
- 除数为0 → 结果为
±∞或NaN - 被除数为
±∞且除数为±∞→NaN 0 / 0→NaN
乘法器实现:现代 CPU 使用 Booth 算法或阵列乘法器加速尾数乘法,指数运算通过加法器完成。
Comments