CS110 计算机体系结构 3 RISC-V (上)

aaaaa Lv4

从这一章开始,我们正式深入 RISC-V 这门汇编语言,代入计算机的视角,看机器是如何把 C 代码一步步解读为二进制指令的。


RISC-V 语言简介

RISC 是一种指令集体系结构 ISA,全称为精简指令集计算机 Reduced Instruction Set Computer,而常见的 ISA 还包括 CISC(复杂指令集计算机 Complex Instruction Set Computer,早期计算机架构),X86(Intel 使用),ARM(苹果、华为使用)等。而 RISC-V 是 UC Berkeley 在 2010 年启动的开源项目,有较强的可扩展性,因此本课程采用 RISC-V RV32I 为范例。

Snipaste_2026-03-17_08-41-15

说人话,就是 RISC-V 是一种常用于教学的汇编语言,是编译器的产出物,需要使用汇编器来解码——处于 CALL 中的 C 和 A 之间的语言。


RISC-V 基础语法

作为计算机汇编语言的一种,RISC-V 代码的所有指令都是面向指令、内存或寄存器的,会清晰、具体地告诉计算机,应该把哪个值从哪里取出来、做什么处理、再存到哪里去。注意,区别于常用的高级语言(如 C,C++,Python 等),RISC-V 是没有变量的概念的,一切数据如果不存储下来,就会被丢弃。其实高级语言的变量就是一种对于寄存器存储的封装,而汇编语言就是解决封装内部问题的地方。

RISC-V 每一行都是一条指令,使用 # 开头来标记注释——这一点和 Python 很像,但是鉴于 RISC-V 灾难般的可读性,只有写注释才能保证明天你还能看懂今天写的代码。

在“没收”了变量这一工具后,RISC-V 提供了 32 个可供使用的寄存器(本质上是虚拟寄存器,一对多地映射到部分物理寄存器上),分别命名为 x0 x1x31。其中,x0 寄存器是不可修改的,它的值始终为 0。在这些寄存器之外,有一个特殊的寄存器 PC(程序计数器 Program Counter),记录了程序执行到位置的首地址。

RISC-V RV32I 的语句根据功能和编码分为 6 个类别:R-Type, I-Type, S-Type, B-Type, U-Type, J-Type。我们一类类来看。

R-Type

R-Type 其中的 R 指的是 Register,即 R-Type 指令是对寄存器直接操作的指令。一般的寄存器计算都属于 R-Type 指令。

RISC-V 的 R-Type 指令一般写成 符号 目标寄存器,寄存器1,寄存器2 的形式,具体所有的 R-Type 指令如下:

  • 加法:add rd, rs1, rs2

    • add x5, x2, x1x1x2 寄存器内的值直接相加,并存储在寄存器 x5
    • 有进位;会忽略溢出位
  • 减法:sub rd, rs1, rs2

    • sub x5, x2, x1x2 - x1 的值存入 x5
  • 逻辑操作:and/or/xor rd, rs1, rs2

    • xor x6, x1, x5x1 ^ x5 存入 x6
    • 通过与 0x FFFF FFFF 取异或来实现取反
  • 比较:slt/sltu rd, rs1, rs2 (set if less than for signed/unsigned)

    • slt x5, x2, x1 用有符号类型(2’s complement)解读,如果 x2 < x1,则把 x5 设为 1,否则设为 0
    • sltu x6, x5, x3 用无符号类型(直接读数)解读,如果 x5 < x3,则把 x6 设为 1,否则设为 0
  • 移位操作:sll/srl/sra rd, rs1, rs2 (shift left/right logic/arithmetic)

    • sll x5, x2, x4x2 左移 x4 位,并存入 x5

    • srl x6, x1, x4x1 右移 x4 位,用0填充左侧高位,并存入 x6

    • sra x7, x3, x4x3 右移 x4 位,用原符号位(最高位)填充左侧高位,并存入 x7

    • 移位次数范围为 [0, 31],也就是说,rs2 只读取其低 5 bit 的数据,其余高位数据直接忽略

I-Type

I-Type 其中的 I 指的是 Immediate,即 I-Type 指令是操作立即数的指令。涉及立即数的很多指令都属于 I-Type,其中的立即数 imm 是 12 bit 大小的。注意,一般我们写代码的时候,立即数采用十进制书写(如果硬要写作十六进制,建议加 0x 前缀)

RISC-V 中 I-Type 是最复杂的指令类型之一,具体有如下几个指令:

  • 加法:addi rd, rs1, imm
    • addi x5, x4, -10x4 + (-10) 存入 x5
  • 类似地, 定义 andi ori xori slti sltui
    • sltui 会把立即数也识别成无符号模式,因此立即数为负数会出现大数比较
  • 移位:slli/srli/srai rd, rs1, imm
    • 只移位 imm 的最低的5位的值
  • 加载字:lw rd, imm(rs1) (load word)
    • lw x1, 12(x4) 从地址 x4 + 12 (Byte)开始,(从内存)读取连续的 4 byte = 32 bit 存入 x1
  • 加载半字:lh/lhu rd, imm(rs1) (load halfword for signed/unsigned)
    • lh/lhu x1, 12(x4) 从地址 x4 + 12 (Byte)开始,(从内存)读取连续的 2 byte,以有符号(原最高位填充高位)/无符号(0填充高位)模式拓展为 4 byte 存入 x1
  • 加载字节:lb/lbu rd, imm(rs1) (load byte for signed/unsigned)
    • lb/lbu x1, 12(x4) 从地址 x4 + 12 (Byte)开始,(从内存)读取连续的 1 byte,以有符号(原最高位填充高位)/无符号(0填充高位)模式拓展为 4 byte 存入 x1
  • 跳转与链接寄存器 jalr rd, imm(rs1) (jump and link register)
    • jalr x1, imm(ra) 跳转到 (ra + imm)&~1,并保存返回地址 PC+4 (即下一条指令的地址)到 x1
    • jalr x0, 0(ra)/ret/jr ra 无条件跳转到命令计数 ra ,一般在函数返回时使用

S-Type

S-Type 中的 S 指的是 Smile! Sweet! Save,即 S-Type 指令是将寄存器数据储存到内存的指令。注意这里不是存储到寄存器!可以想象在编程时寄存器就是我们的工作台,而内存是手边的箱子;我们希望存储的时候,我们显然会把东西存在箱子里而非工作台上,因此 S-Type 用于将寄存器数据存入内存。

S-Type 是相对简单的一种指令类型,只有几种不同的存储长度之分:

  • 保存字:sw rs2, imm(rs1)(save word)
    • sw x1, 12(x4)x1 的值(4 byte = 32 bit)存入内存地址 x4 + 12
  • 保存半字:sh rs2, imm(rs1) (save halfword)
    • sh x1, 12(x4)x1 的值的低位 2 byte = 16 bit 存入 x4 + 12
  • 保存字节:sb rs2, imm(rs1) (save byte)
    • sb x1, 12(x4)x1 的值的低位 1 byte = 8 bit 存入 x4 + 12

另外,在规范上讲,强烈建议(但是语法不强制要求)存储对齐:字保存在 4 byte 的整数倍地址上,半字保存在 2 byte 的整数倍地址上。这样,在内存搜索时,数据会更快、更方便地被找到(为什么更快?学完缓存你就明白了)。

B-Type

B-Type 相比于前几种面向数据的指令不同,B 指的是 Branch,也就是分支。B-Type 指令可以在代码中实现逻辑判断和分支循环。

  • 取等跳转:beq/bne rs1, rs2, label (branch if equal/not equal)
    • beq x1, x2, loop 如果 x1 == x2,则跳转到标签 loop
    • bne x1, x2, loop 如果 x1 != x2,则跳转到标签 loop
  • 比较跳转:blt/bltu/bge/bgeu rs1, rs2, label (branch if less than/greater or equal to for signal/unsignal)
    • blt x1, x2, loop 如果按有符号数值比较 x1 < x2,则跳转到标签 loop

U-Type

U-Type 中的 U 指的是 Upper,是为了解决 I-Type 中 Immediate 立即数只能使用 12 位的问题的指令,主要有两种:

  • 更高位立即数加 PC auipc rd, imm[31:12] (Add upper immediate to program counter)
    • auipc x6, 0xABCDEPC + 0xABCDE000 存入寄存器 x6
  • 加载更高位立即数 lui rd, imm[31:12] (Load upper immediate)
    • lui x5, 0xABCDE 在寄存器 x5 的高20位中加载立即数,即寄存器的值增加 0xABCDE000

J-Type

J-Type 中的 J 指的是 Jump,也就是说 J-Type 指令负责代码跳转和链接,它只有一种指令:

  • 跳转与链接 jal rd, label (jump and link)

    • jal x1, func/call func/jal func 跳转到标签 func,并把返回地址 PC+4 (即下一条指令的地址)保存到 x1(即ra)

    • jal x0, loop/j loop 无条件跳转到标签 loop

伪指令 Pseudo-instruction

以上内容是 RISC-V 语法中本来就有的指令,是标准编译器可以直接翻译成二进制的代码,但在实际使用中,为了编写方便,还存在一些伪指令 Pseudo-instruction,而从这些伪指令翻译到指令又有标准的拓展方式,因此这部分也请和以上的语法格式一并记录在 Cheatsheet 上!

  • nop 无操作
    • 拓展成 addi x0, x0, 0
  • j loop 无条件跳转
    • 拓展成 jal x0, loop
  • jr rd 跳转到 rd
    • 拓展成 jalr x0, 0(rd)
  • jal func 跳转到标签 func,并把返回地址 PC+4 (即下一条指令的地址)保存到 x1
    • 拓展成 jal x1, func
  • call func 调用函数(同上);可近可远
    • 拓展成 auipc ra, offset[31:12] jalr ra, offset[11:0](ra)
  • tail func 尾调用优化,不保存返回地址,保持返回地址仍为父级返回地址;可近可远
    • 拓展成 auipc t1, offset[31:12] jalr x0, offset[11:0](t1)
  • not rd, rs 按位取反
    • 拓展成 xori rd, rs, -1
  • beqz rs, func 等于0则分支跳转
    • 拓展成 beq rs, x0, func
  • bgt rs1, rs2, func 大于则分支跳转
    • 拓展成 blt rs2, rs1, func
  • ret 函数返回到返回地址 ra
    • 拓展成 jalr x0, 0(ra)
  • li rd, imm 加载立即数(自动处理过大的立即数)
    • li x5, 0xABCDE112 拓展成 lui x5, 0xABCDE addi x5, x5, 0x112
    • li x5, 0xDEADBEEF 拓展成 lui x5, 0xDEADC addi x5, x5, 0xEEF (低12位最高位拓展为 0xFFFFFEEF,要高位补1)
  • la x1, test_input 把变量 test_input 的地址(可近可远)存入寄存器 x1
    • 拓展成 auipc x1, offset[31:12] addi x1, x1, offset[11:0]
  • mv rs1, rs2 将寄存器 rs2 的值赋值给 rs1
    • 拓展成 addi rs1, rs2, 0

调用规范 Calling Convention

RISC-V 的语法规则非常简单,而这会导致非常“多样性”的编程习惯,可能导致不同程序员写出来的汇编代码出现各种兼容性问题——因此我们需要一个调用规范。

寄存器

在调用规范中规定了不同寄存器的作用以及别名,大致分为 zero, ra, sp, t[emporary], s[aved], a 这几种,对照表如下:

Snipaste_2026-03-19_09-31-01

在你的 Cheatsheet 上试着整理出寄存器编号-别名-管理者的速查表吧!

调用函数

在 RISC-V 中,调用一个函数的抽象过程如下:

  • 把参数放在函数可以获取的位置
  • 把控制权交给函数
  • 获取函数必需的本地存储资源
  • 函数运行
  • 把函数返回值放在调用代码可以获取的位置
  • 把控制权返还给调用者代码

更具体地说,就是:

  • Caller 先把参数放在 a0-a7 里(如果寄存器不够,开栈区空间存储参数)
  • 如有必要,Caller 把 caller-saved 寄存器的值压入栈区中
  • 使用 jal/jalr 来跳转到 Callee 函数,此时 ra 被设置为 Caller 的下一行指令的地址
  • 改变 sp 来把 callee-saved 寄存器的值压入栈区
  • 如有必要,改变 sp 来把本地需要存储的变量压入栈区
  • Callee 函数执行
  • 把返回值放在 a0-a1
  • 把 callee-saved 寄存器的值从栈区弹出,恢复寄存器的值
  • 返回Caller调用点 ra
  • Caller 恢复 caller-saved 寄存器的值

其中,函数必需的本地存储资源就包括函数内用到的所有寄存器,而谁管理的寄存器,谁就要负责在函数调用结束后把寄存器的值恢复原状。那对于需要占用的寄存器,里面的旧值怎么处理呢?当然是上转转暂存入内存中进程的栈区。因此,你会看到在函数开始的地方,函数疯狂地往栈顶压入 s 寄存器的值,而函数即将返回的地方,函数又会把这些寄存器的值从栈区弹栈还原回去。我们来看个例子:

1
2
3
4
5
6
int Leaf(int g, int h, int i, int j)
{
int f;
f = (g + h) - (i + j);
return f;
}

这个 C 函数直接写成 RISC-V 是这样的:

1
2
3
4
5
6
7
8
9
10
Leaf:
addi sp, sp, -4 # adjust stack for 1 items, callee saved s1
sw s1, 0(sp) # save callee saved s1 to stack
add s1, a0, a1 # s1 = g + h
add a2, a2, a3 # j = i + j
sub a0, s1, a2 # calculate result (g + h) – (i + j)
# return value (g + h) – (i + j)
lw s1, 0(sp) # restore register s1 for caller
addi sp, sp, 4 # adjust stack to delete 1 items
jr ra # jump back to caller (pseudo-assembly:ret)

由于一个 int 是 4 byte 大小,所以我们在函数调用开头(一般被称为“前言” Prologue)把栈指针 sp 向下挪 4(栈是从高地址向低地址生长的),并把 s1 存放在新开辟的地址处。为了方便起见,我们也可以使用 Caller-saved 寄存器 t1 来代替 Callee-saved 寄存器 s1,以省去复杂的压栈弹栈过程:

1
2
3
4
5
6
Leaf:
add t1, a0, a1 # s1 = g + h
add a2, a2, a3 # j = i + j
sub a0, t1, a2 # calculate result (g + h) - (i + j)
# return value (g + h) – (i + j)
jr ra # jump back to caller (pseudo-assembly:ret)

至此,我们的 RISC-V 就已经完成一大半了。具体地说,我们已经手动地模拟了编译器的工作——把 C 代码翻译为 RISC-V 汇编代码。剩下的部分,也就是下一节,我们会模拟汇编器的工作——把 RISC-V 汇编代码汇编为二进制文件。电影里经常给黑客的电脑配上一个一大串01的背景,学完下一章,我们就也能手动地把一段代码翻译为二进制01串了!


回到目录

  • 标题: CS110 计算机体系结构 3 RISC-V (上)
  • 作者: aaaaa
  • 创建于 : 2026-08-03 17:00:00
  • 更新于 : 2026-08-03 22:06:26
  • 链接: https://redefine.ohevan.com/2026/08/03/零基础速通系列/CS110 计算机体系结构/零基础速通:CS110_计算机体系结构_3/
  • 版权声明: 版权所有 © aaaaa,禁止转载。