学习汇编语言的笔记。

使用的教学材料:

  • Linux Foundation X [LFD117x] Foundations of RISC-V Assembly Programming

汇编语言可以用于开发操作系统和编译器,不过我认为也可以用于「入门」计算机 —— 学了总是好事。

基础知识

RISC-V

这是一种指令集架构规范,为处理器奠定了通用的机器代码语言基础。
RISC 指的是「精简指令集计算机」,而 V 代表的是罗马数字 5。
RISC - V 属于一种开放式指令集架构。

Assembly

Assembly 语言可以被直接翻译成机器码,是最低级的编程语言。需要记住的是,「低级」不代表是一件坏事。

学习 Assembly 以及如何使用它可以帮助我们理解软件和硬件的内部。

微处理器

通常来说,处理器包含控制单元、算术逻辑单元、寄存器以及用于输入输出的信号与数据线路。

  • 处理器利用控制单元来执行指令
  • 算数逻辑单元负责对整数执行算术和逻辑运算
  • 寄存器是位于处理器内部的一种小型存储单元。处理器能够将数据从外部存储器或其他设备快速加载到寄存器中,在寄存器内对数据进行操作,随后将结果写入外部存储器或者其他设备
    • 程序计数器是一种特殊的寄存器,用于保存当前待执行指令的位置信息。控制单元可以通过程序计数器来确定程序的执行进度

微处理器可以被分为 RISC 和 CISC,后者是复杂指令集计算机。两者的区别在于,RISC 架构拥有的指令数量较少。特定情况下,CISC 架构可能只需要一条指令就能做到一件事情,而 RISC 架构下需要执行更多的指令。

继续说 RISC。典型的 RISC 处理器会执行典型的五级流水线工作:

  1. 指令获取(IF)
  2. 指令译码(ID)
  3. 指令执行(EX)
  4. 存储器访问(MEM)
  5. 写回(WB)

具体流程是这样的:

  1. 处理器从程序计数器所指向的内存地址获取指令
  2. 指令是以二进制代码形式编码的,所以系统随后会对其进行译码、确定并获取执行所需的其他信息
  3. 进入执行阶段,利用算术逻辑单元进行计算
  4. 如果指令涉及内存访问,则会在接下来的存储器访问阶段进行处理
  5. 如果运算产生了结果,该结果将在最后一个阶段被写回寄存器

该流程可以按照单周期方式执行,也可以采用流水线技术执行。后者可以实现各阶段的并行处理,从而提升效率。

Ripes

Ripes 是一款专为演示 RV32IMC 和 RV64IMC 架构上机器代码执行过程而设计的模拟器。
总体而言,Ripes 是快速入门并实践学习汇编语言的极佳工具。

安装

课程使用的系统是 Debian / Ubuntu 系,我用的是 Arch Linux 系,部分包会对不上。这里只写我安装了什么:

1
sudo pacman -S qemu-system-riscv qemu-user

可以使用 qemu-riscv-64-static -help 命令就可以。

这是用于在我们的计算机上安装专门针对 RISC - V 架构的系统仿真器。
接着我们需要安装交叉编译器,用来让我们的 C 语言源代码编译成 RISC - V 指令集。

1
sudo pacman -S riscv64-linux-gnu-gcc

当编译一个动态链接的 RISC - V 程序时,编译器会在生成的二进制文件里写入一个解释器的绝对路径。对于 64 位的 RISC - V Linux 程序,这个解释器通常是 ld-linux-riscv64-lp64d.so.1 默认预期位置在 /lib/ 目录下。然而在我们的宿主机上,这个文件实际上位于交叉编译器的安装目录中,而不是系统的根目录 /lib/ 下。

当使用 qemu-riscv64 运行程序时,QEMU 会尝试加载程序,但程序本身会告诉系统去 /lib/ 找解释器。如果找不到就会报错。

因此我们需要建立一个软链接,将位于深层目录的解释器映射到程序预期的 /lib/ 位置,从而欺骗程序使其能够找到加载器。

这里有两个选择:

  1. 链接到 /lib/
  2. 链接到 /usr/lib/

两者其实差不多,都可以。我就用后者来示范了。

1
sudo ln -s /usr/riscv64-linux-gnu/lib/ld-linux-riscv64-lp64d.so.1 /usr/lib/ld-linux-riscv64-lp64d.so.1

但其实还有一种方法,更适合「洁癖党」,那就是指定 Sysroot 路径。这样 QEMU 就会在那个目录下查找 /lib//usr/lib 了,我们也不需要弄乱系统的目录。我的建议是设置一个环境变量:

~/.zshrc
1
export QEMU_LD_PREFIX=/usr/riscv64-linux-gnu

无论如何,都可以使用 riscv64-linux-gnu-as --version 来进行检查。

然后我们需要安装一个调试器:

1
sudo pacman -S gdb

运行 gdb -h 来检查。

让我们创建一个测试文件 example.s

:tangle example.s
1
2
3
4
5
.globl _start
_start:
addi x10, x0, 7
addi x17, x0, 93
ecall

先使用 as 将源码转换为目标文件:

:results output
1
riscv64-linux-gnu-as -o example.o example.s

再将 example.o 链接为可执行文件 example

:results output
1
riscv64-linux-gnu-ld -o example example.o

最后在 QEMU 中执行:

:results output
1
qemu-riscv64 example

最终的结果是返回了个 7

我们也可以使用 riscv64-linux-gnu-objdump 来审视我们的二进制文件:

:results output
1
riscv64-linux-gnu-objdump -sd example
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

example: file format elf64-littleriscv

Contents of section .text:
100b0 13057000 9308d005 73000000 ..p.....s...
Contents of section .riscv.attributes:
0000 415e0000 00726973 63760001 54000000 A^...riscv..T...
0010 05727636 34693270 315f6d32 70305f61 .rv64i2p1<u>m2p0</u>a
0020 3270315f 66327032 5f643270 325f7a69 2p1<u>f2p2</u>d2p2_zi
0030 63737232 70305f7a 6966656e 63656932 csr2p0_zifencei2
0040 70305f7a 6d6d756c 3170305f 7a61616d p0<u>zmmul1p0</u>zaam
0050 6f317030 5f7a616c 72736331 703000 o1p0_zalrsc1p0.

Disassembly of section .text:

00000000000100b0 <_start>:
100b0: 00700513 li a0,7
100b4: 05d00893 li a7,93
100b8: 00000073 ecall

这可以将机器码逆向翻译回人类可读的汇编指令,帮我们验证编译器是否真的生成了预期中的代码。

Contents of section .text 处展示了内存中的原始字节数据。

1
100b0 13057000 9308d005 73000000

其中地址 100b0 是这段代码在内存中存放的起始地址,数据 13057000 是第一个 4 字节(32 位)的内容。

在计算机科学中,位(bit,有时也可以叫比特位)是信息的最小单位,只能显示 0 或者 1 两种状态,也就是「假」与「真」。而字节(byte)是计算机存储和处理数据的基本单位,通常规定 1 个字节由 8 个位组成。

13057000 这串数据实际上是十六进制表示法。

这个「十六」并非物理上的容量单位,而是「进制」这个数字概念,具体代表的是在一个单独的数位上,到底拥有多少种不同的独立状态或者符号。例如人类最熟悉的十进制,每一个数位拥有从 0 到 9 这十个独立符号,当状态数值超过 9 时,就必须向前进位(1 + 9 = 10,个位超过了 9,就会向前面的十位进位)。

同理,十六进制意味着每一个单独的数位拥有 16 种独立的状态,为了填补数字 9 之后的符号空缺,系统引入了 a 到 f,对应十进制的 10 到 15。

理解了进制代表着「状态的数量」后,就可以将其与底层的「位」建立起直接的数学映射。先前说过,位只有 01 这两个状态。如果想要使用纯续的二进制来表示更多的状态,就必须将多个位组合在一起来观察:1 个位是 2 种状态;2 个位组合在一起(00011011)是 4 种状态;3 个位是 8 种状态。

十六进制中每一个字符(0-9a-f)就代表 4 个位,总计 16 种状态。

计算机科学选择大量采用十六进制,其根本原因是为了在机器的物理状态和人类的阅读习惯之间寻找妥协:人类无法快速阅读和心算二进制流,而如果将其转换为人类最熟悉的十进制,二进制与十进制之间的互相转换又必须经过整体的乘除法运算。由于 10 不是 2 的整数次幂,这种转换无法在底层的比特位上做到规整的一一对应。

既然 1 个十六进制字符代表 4 个位,那么需要 2 个连续的十六进制字符并排,来转录 1 个字节的完整物理状态 —— 因为 1 个物理字节由 4 个比特位组成。

13057000 里共有 8 个字符(13057000),意味着总共有 32 个位,或者说 4 个字节。对于 RISC - V 架构而言,其标准的指令长度固定为 32 位,也正好对应了内存中这 4 个连续字节所占据的空间。

littleriscv 指明了小端序,低字节需要存放在低地址。

小端序又是什么呢?它描述的是多字节数据在计算机内存中的排列顺序。由于内存是被组织成一个线性的字节序列,即每个地址对应一个字节,当我们要在内存中存放一个跨越多个字节的数值,比如一个 32 位的整数时,就面临着把哪一端放在低地址的选择。

小端序指的是将数据的最低有效字节存放在内存的最低地址处,而最高有效字节则存放在最高地址处。这种存储方式的优势在于从低地址读取数据时,不需要进行地址计算即可直接获得数值的低位部分。

要想更加理解这一部分,就必须深入理解计算机是如何「看待」数据的物理本质。计算机的内存可以类比于一排无限长的储物柜,每个储物柜上都有编号,叫作「地址」,从 0 开始,依次递增。低地址是编号较小的位置,高地址自然就是编号较大的位置。

问题在于,一个储物柜只能存放 1 个字节。我们想要存储的数据往往是多个字节,要想存进内存就必须占用连续的储物柜。用 13057000 举例:你有着身为最高位的 00 以及身为最低位的 13,面前是低地址 0 和高地址 3,你应该把数据的头放在低地址还是把尾放在低地址?

更直观一些,假设我们要存储十六进制数 0x12345678,其中 12 是最高有效字节,相当于「千位」, 78 是最低有效字节,相当于「个位」。而我们会占用从 0x1000x103 这四个地址。

人类的阅读习惯是从左到右,从大到小(例如数字),所以先把最大的头 12 放在最开始的低地址 0x100

内存地址 0x100 0x101 0x102 0x103
存放内容 12 34 56 78

而小端序更符合计算逻辑。计算机在做加法时,是先算个位,再算十位。把最低位放在低地址(数据的起始地址),CPU 读取数据时,直接从起始地址就能拿到最低位开始运算。因此就会变成:

内存地址 0x100 0x101 0x102 0x103
存放内容 78 56 34 12

理解了小端序,就能解释为什么会出现 00700513 这样的内容。在 objdump 输出中,左侧的 13057000 是按照内存地址从低到高显示的: 13 (地址 N), 05 (N+1), 70 (N+2), 00 (N + 3)。正因为是小端序,放在低地址(最左边)的 0x13 实际上是数值的最低位,而放在高地址(最右边)的 0x00 是最高位。

当 CPU 读取这 4 个字节组成一个 32 位整数(指令)时,它会按照权重将这些字节重新组装,最终形成符合人类书写习惯的大端数值 0x00700513 (高位在左)。

Disassembly of section .text 处是 objdump 将机器码翻译回汇编指令的结果,也是我们验证代码逻辑的核心区域。

1
100b0: 00700513 li a0,7

第一行指令中显示, 100b0 是指令的内存地址; 00700513 是 CPU 实际执行的 32 位机器码,也是上面提到的「被倒过来」的指令; li a0,7 是反汇编后的汇编代码。

我们源码写的是 addi x10, x0, 7,寄存器中 objdump 默认使用 ABI 名称, x10 寄存器在函数调用规范中用于传递参数或者返回值,因此被称为 a0 Argument/Return 0 的意思。

ABI 全称为 Application Binary Interface(应用程序二进制接口),是一套规范,定义了软件组件之间如何在机器码层面进行交互。它规定了每个寄存器的专用功能,其名称也可以让汇编代码更具可读性。

objdump 识别出 addi x10, x0, 7 的语义实际上就是「将立即数 7 加载到 a0 中」,因此它显示了更易读的伪指令 li(Load Immediate,加载立即数)。这两者在机器码层面是完全一样的。

1
100b4: 05d00893 li a7,93

第二行指令中显示, 05d00893 是机器码; li a7,93 对应的是源码中的 addi x17, x0, 93

x17 的 ABI 别名是 a7。在 Linux RISC - V 系统调用规矩中, a7 寄存器用来存放系统调用号。 93 代表的是 exit 的系统调用。

1
100b8: 00000073 ecall

第三行指令中显示, 00000073ecall 指令的固定机器码。

ecall 即 Environment Call,它会触发中断,将控制权交给操作系统内核。内核检查 a7 中的值(93),知道你要退出,然后检查 a0 中的值(7),将其作为退出状态码返回。

至于中间的 .riscv.attributes 部分,它是编译器留下的指纹。右侧的 ASCII 码显示了该程序编译时所使用的架构扩展列表。

1
qemu-riscv64 -g 1234 example

启动另一个终端来连接 GDB(目录需要是同一个):

1
gdb -q example

当显示 (gdb) 时输入:

1
target remote :1234

我们可以使用命令 display /3i $pc 来打印内存信息。其中 3 代表着数量, i 代表着指令, $pc 则代表程序计数器寄存器,这个寄存器始终存储着 CPU 即将执行的下一条指令的内存地址。

output
1
2
3
4
1: x/3i $pc
=> 0x100b0 <_start>: li a0,7
0x100b4 <_start+4>: li a7,93
0x100b8 <_start+8>: ecall

得出的结果中,带有箭头标记的一行就是当前 CPU 暂停的位置,也就是下一刻将被执行的指令。随后的两行则展示了未来的执行路径。

接下来可以使用 si 来命令 CPU 向前走一步,返回的结果中可以看到箭头出现在了下一行指令。

使用 c 来解除暂停,让 CPU 全速运行直到遇到下一个断点或者程序结束,我们就能看到返回码 7

最后 q 来结束当前的 GDB 调试会话。

上述的所有没有解释明白的术语,在后续都有讲解。

教程中还提到了可以在 Visual Studio Code 中使用 PlatformIO 插件,不过我不用 Visual Studio Code,就不说了。

星光板

StarFive VisionFive(以下简称星光板)是一款基于 RISC - V 架构的单板计算机,相当于 RISC - V 版本的树莓派。

介于我手上没有这个物理开发板,和物理外设相关的代码我都无法运行,只当学习运算逻辑了。

教程中展示了如何在板子内调用生成 RISC - V 代码的工具,例如 asgcc;在我这个 x86 电脑上,就只能使用 riscv64-linux-gnu-as 了。同理, ./example 这样的命令在 x86 电脑上要改用 qemu-riscv64 example

在之前的步骤,我们的 GDB 会提示 (No debugging symbols found),这是因为我们生成的只是纯粹的机器码。需要添加 -g 参数,汇编器才会将源代码的调试信息嵌入到生成的目标文件中。

:results output
1
2
riscv64-linux-gnu-as -g -o example.o example.s
riscv64-linux-gnu-ld -o example example.o

接下来是关键的调试步骤。

打开终端 A,充当服务端:

1
qemu-riscv64 -g 1234 example

打开另一个终端 B,充当客户端。

客户端连接成功后,输入:

1
layout src

因为这次添加了 -g,我们会看到熟悉的源代码窗口,而不是汇编代码。

接着输入:

1
b 5

意思是在第 5 行打断点。

最后使用 c 命令,让程序全速运行,直到撞上第 5 行的断点并自动停下。此时输入:

1
info reg x10

会看到寄存器 x10 的值确实是 7。

指令集架构

ISA(Instruction Set Architecture, 指令集架构)是软件与硬件之间的契约。它定义了处理器能够理解的所有操作指令(比方说加法、跳转、内存读写),以及处理器向软件暴露的状态。

RISC - V 的设计哲学是模块化的。基础指令集代表了 CPU 必须实现的最小功能集合。其他的扩展功能,如浮点运算,都是可选的插件。这与 x86 这种将所有功能捆绑在一起的架构并不相同。这意味着,只要掌握了基础指令集,代码就能在任何一个标准的 RISC - V 处理器上运行。

非特权指令集

现代处理器通常至少有两种运行模式:

  1. 特权模式:操作系统内核运行的模式,可以执行所有指令,包括管理硬件、中断和内存映射的指令;
  2. 非特权模式:普通应用程序运行的模式。

基础架构的分类

RISC - V 通过简单的命名规则来区分不同的基础架构变体:

  • RV:代表 RISC - V;
  • 32 / 64 / 128:代表寄存器的位宽和地址空间的大小;
  • I:代表整数;
  • E:代表嵌入式。

RV64I 是目前我们正在使用的架构,因为工具链是 riscv64。它是 64 位的标准整数指令集。
RV32I 是 32 位的标准版本,在微控制器领域非常流行。
RV128I 是为未来设计的 128 位架构,用于应对未来海量内存寻址的需求。
RV32E 是为了极低成本、极低功耗的微控制器而设计的。它将寄存器数量从标准的 32 个砍到了 16 个,以节省芯片面积。

寄存器大小与地址空间

寄存器大小与地址空间是理解 32 位或 64 位计算机本质的关键。在 RV64I 中,通用寄存器(例如 x10)的物理宽度是 64 个比特,这意味着单一一条指令可以直接处理一个最大为 26412^{64} - 1 的整数。如果在 RV32I 上处理同样的数,就需要多条指令将其拆分处理。

寄存器的宽度也决定了指针的大小。在 RV32 中,指针是 32 位的,因此 CPU 最多只能寻址 2322^{32} 个字节的内存,即 4 GB。这对于现代桌面应用来说往往是不够的。在 RV64 中,指针是 64 位的,理论寻址空间高达 2642^{64} 字节,即 160 亿 GB,消除了内存容量的限制。

RV32I 的基础指令集仅包含 40 条指令,也就是说,只需要这 40 条最基本的指令就可以构建出所有的计算逻辑!

模块化命名规则

RISC - V 的设计允许通过组合不同的扩展模块来定制处理器的功能。

先前说过,RISC - V 处理器都必须至少支持一个基础整数指令集。在此基础上,处理器设计者可以选择添加额外的硬件功能,这些功能被称为扩展。处理器的完整型号就是「基础架构名 + 扩展后缀」。

这些是标准扩展代码:

  • M(Integer Multiplication and Division):整数乘除法。硬件直接支持整数的乘法和除法运算。如果没有这个扩展,处理器需要通过软件算法模拟乘除法,速度会显著变慢;
  • A(Atomic Instructions):原子指令。支持在单一操作中完成内存的「读取 - 修改 - 写入」过程。这对于多线程同步和并发控制至关重要,例如实现互斥锁;
  • F(Single-Precision Floating Point):单精度浮点数。硬件支持 32 位浮点数运算;
  • D(Double-Precision Floating Point):双精度浮点数。硬件支持 64 位浮点数运算。通常依赖于 F 扩展;
  • Q(Quad-Precision Floating Point):四精度浮点数。硬件支持 128 位浮点数运算;
  • C(Compressed Instructions):压缩指令。支持 16 位长度的指令,而标准 RISC - V 指令为 32 位。这可以显著减少程序占用的内存空间,提高代码密度;
  • V(Vector Operations):向量操作。支持 SIMD(单指令多数据)操作,允许一条指令同时处理一组数据,常用于高性能计算和 AI 加速。

这里使用两个例子:

  • RV32IMAC:
    • RV32I:基础是 32 位整数架构;
    • M:支持硬件乘除法;
    • A:支持原子操作;
    • C:支持压缩指令;
    • 但是不支持浮点运算,是一个纯整数处理器。
  • RV64IMAFDC(通常也被称为 RV64GC):
    • RV64I:基础是 64 位整数架构;
    • 剩下的意味着它同时支持乘除法、原子操作、单 / 双精度浮点运算和压缩指令。这是一个全功能的通用处理器配置,类似于我们电脑内的 CPU。

某些情况下会出现 SU 后缀,例如 RV64IMAFDCSU。这两个字母不代表具体的运算指令,而是代表处理器支持的特权模式:

  • S(Supervisor Mode):监管者模式。这是操作系统内核,如 Linux Kernel,运行所需的权限级别。支持 S 模式意味着该处理器有能力运行完整的操作系统;
  • U(User Mode):用户模式。这是普通应用程序运行的受限权限级别。

因此,如果一个处理器列出了 S,事实就是它被设计为可以运行操作系统;如果只支持机器模式(也是默认都有的),则它通常只能运行简单的裸机代码。

硬件层面的寄存器结构

RISC - V 的基础指令集规定处理器必须包含 32 个通用整数寄存器,物理编号为 x0x31。此外,还有一个独立的程序计数器 PC,用于记录当前正在执行的指令地址。

寄存器的位宽直接对应于基础架构的位数。在 RV32I 架构中,每个寄存器(x0x31)和 PC 都是 32 位的(4 字节);而在 RV64I 架构中,它们则是 64 位的(8 字节)。这意味着寄存器即用于存储参与运算的数据,也用于存储内存地址(指针)。

在硬件层面, x0 是一个特殊的寄存器。它被硬件强制硬连线位常数 0。无论程序向 x0 写入什么值,读取时得到的结果永远是 0。这是为了简化指令集,例如「将寄存器清零」或「移动数据」等操作都可以复用通用的算术指令,而无需设计专用的指令。

ABI

先前已经提到了 ABI,此处详细说一下 ABI 将寄存器按功能划分成了多少类:

  • 特殊功能寄存器
    • zero(x0):恒为 0;
    • rax1,Return Address):返回地址寄存器。当发生函数调用(call)时,硬件会将「下一条指令的地址」自动保存到这里,以便函数执行完后能跳回原来的位置;
    • spx2,Stack Pointer):栈指针。指向当前栈顶的内存地址,用于管理函数调用栈;
    • gpx3,Global Pointer)和 tpx4,Thread Pointer):分别用于快速访问全局变量和线程本地存储。
  • 函数调用与参数传递
    • a0-a7x10-x17,Arguments):参数寄存器。用于将参数传递给函数。其中 a0a1 还兼职用于存储函数的返回值。如果参数超过 8 个,多余的参数通常会通过栈(内存)传递。
  • 临时与保存寄存器
    • t0-t6 (Temporaries):临时寄存器。被调用函数(Callee)可以随意修改这些寄存器,不需要恢复原值。因此,调用者(Caller)如果想在函数调用后继续使用 t 寄存器里的值,必须自己在调用前保存它们;
    • s0-s11 (Saved Registers):保存寄存器。被调用函数必须保证这些寄存器的值在函数退出时与进入时一致。如果函数内部需要使用这些寄存器,必须先将旧值压入栈中保存,使用完后再从栈中恢复。其中 s0 有时也被用作 fp (Frame Pointer),即帧指针。

加载 - 存储架构

在 RISC - V 中,所有的算术和逻辑运算只能在寄存器之间进行。CPU 无法直接对内存中的数据执行加法操作。

如果想要修改内存中的一个变量,必须遵循严格的「三步走」流程:

  1. Load:使用加载指令(如 lw)将数据从内存读取到某个通用寄存器中;
  2. Compute:在寄存器内部使用算术指令(如 add)对数据进行计算;
  3. Store:使用存储指令(如 sw)将计算结果从寄存器写回到内存中。

这种设计虽然使得单条指令的功能变弱了,比方说不能像 x86 的 add [eax] 5 那样一步到位,但简化了硬件电路设计,使指令流水线更加规整和高效。

addi 指令为例,用三个核心层级来讲解汇编指令如何被 CPU 执行、寄存器如何变化以及机器码在内存中的基本布局。

两个例子:

  1. addi x1, x0, 1
  2. addi x1, x1, 1

一、在指令层级上, addi 的全程是 Add Immediate(立即数加法)。它的标准语法格式是 addi rd, rs1, imm

  • rd (Destination Register):目标寄存器,用来存放计算结果;
  • rs1 (Source Register 1):源寄存器,提供基础数值;
  • imm (Immediate):立即数。这是一个直接写在指令里的常数,而不是从寄存器里读的。
    • 限制:它必须是一个 12 位有符号整数。这意味着它的取值范围被限制在 [2048,2047][-2048, 2047] 之间。

第一个例子取出了 x0 的值,并添加立即数 1,结果便是 0+110 + 1 ` 1。然后将这个结果 1 写入寄存器 x1= 。 第二个例子取出 x1 当前的值,并添加立即数 1,结果是 $1 + 1 2$。然后将这个结果 2 写入寄存器 `x1=,实际上实现了自增的操作。

二、 PC 指针与步进。

在 RISC - V 的基础指令集中,每条指令的长度固定为 4 字节(32 位)。每当 CPU 取出一条指令后, PC 寄存器会自动加 4,使得 CPU 能够指向内存中的下一条指令。

三、内存与机器码层级上,代码到底是如何被存放的?

假设代码从地址 0x0 开始:

内存地址 机器码 对应的汇编
0x0 0x00100093 addi x1, x0, 1
0x4 0x00108093 addi x1, x1, 1
  • 地址 0x0:存放第一条指令。执行时,CPU 处于地址 0x0,执行完后 PC 变为 0x4
  • 地址 0x4:存放第二条指令。因为上一条指令占了 4 个字节(0, 1, 2, 3),所以下一条指令必须从 4 开始。

执行流演示:

  1. 初始状态: PC 0x0=;
  2. 取指:CPU 读取地址 0x0 处的数据 00100093
  3. 执行:解析为 addi x1, x0, 1。寄存器 x1 变为 1;
  4. 更新: PC 自动加 4,变为 0x4
  5. 取指:CPU 读取地址 0x4 处的数据 00108093
  6. 执行:解析为 addi x1, x1, 1。寄存器 x1 变为 2。

教程推荐使用 Ripes 模拟器来直观观看数据通路中,信号是如何流动的,以及 PC 指针是如何跳变的。

六大指令类型

基础指令集将所有功能划分为 6 种类型,每种类型对应一种特定的比特位布局:

类型 英文全称 中文含义 典型用途 示例指令
R Register 寄存器型 纯寄存器之间的运算 add x1, x2, x3
I Immediate 立即数型 寄存器与小常数运算,或加载内存 addi x1, x0, 1
S Store 存储型 将数据存入内存 sw x1, 0(x2)
B Branch 分支型 条件跳转 beq x1, x2, label
U Upper 高位立即数 设置长立即数的高 20 位 lui x1, 0x1000
J Jump 跳转型 无条件跳转 jal , label

根据先前的例子, addi x1, x0, 1 的机器码是 0x00100093。我们可以通过查表并结合已知信息推导出这个编码:

  • Opcode(操作码): 0x13 (二进制 0010011);
  • funct3(功能码): 0x0 (二进制 000);
  • Type(类型):属于 I-type。

因此 addi x1, x0, 1 的机器码按位组合如下:

  • 二进制序列: 0000 0000 0001 0000 0000 0000 1001 0011
  • 由于每 4 个二进制位可以转换为 1 个十六进制数字,最终结果就是 0x00100093

要看懂这个解析,我们需要深入了解指令编码的具体布局。虽然 RISC - V 的指令长度固定为 32 位,但这 32 个比特并非随意排列,而是被划分为了不同的字段。每种指令类型都有其独特的字段排布方式。对于 addi 这种 I-type 指令,其 32 位数据的内部结构由高位到低位依次如下:

首先,其 32 位空间被分割为 5 个部分:

比特位置 31:20 19:15 14:12 11:7 6:0
字段名称 imm[11:0] rs1 funct3 rd 操作码
含义 立即数 源寄存器 功能码 目标寄存器 操作码
长度 12 位 5 位 3 位 5 位 7 位

其次,我们将具体的汇编指令映射到上述结构中:

  • 操作码:这是识别指令家族的。对于所有的 I-type 算术指令(如 addiori 等),操作码固定为 0010011 也就是十六进制的 0x13
  • rd:指令中的目标是 x1。在二进制中,1 表示为 00001
  • funct3:由于操作码定义了一组指令,我们需要额外的 3 位来区分具体是「加法」还是「异或」等。对于加法 addi,该值为 000
  • rs1:指令中的源操作数是 x0。在二进制中,0 表示为 00000
  • imm:我们要加的数值是 1。I-type 允许 12 位的有符号立即数,因此 1 表示为 000000000001

最后,我们需要将上述二进制片段按顺序连接起来。值得注意的是,我们要用的是从高位 31 到低位 0 的顺序:

  • imm000000000001
  • rs100000
  • funct3000
  • rd00001
  • 操作码0010011

连在一起我们就得到了 32 位二进制流: 00000000000100000000000010010011

为了将其转换成人类可读的十六进制,我们将这 32 位每 4 位分为一组进行转换:

二进制分组 0000 0000 0001 0000 0000 0000 1001 0011
十六进制值 0 0 1 0 0 0 9 3

最终组合结果为: 0x00100093。这就是机器真正看到并执行的内容!

其他类型的编码方式见下图:

立即数类型指令集中与算术、逻辑运算相关的具体指令

这些指令的一个共同特征是:它们都接受一个源寄存器和一个立即数作为输入,运算后将结果存入目标寄存器。所有这些指令共享同一个 操作码,这意味着 CPU 是通过 funct3 字段,以及移位指令中的部分立即数位来区分具体是要执行加法、异或还是移位操作。

我们将这些指令分为三类进行讲解:

一、算术与逻辑运算。

这组指令对寄存器数值进行基础数字或位操作。

指令 全称 功能描述 关键点
<6> <10> <10> <8>
addi Add Immediate rd rs1 + imm` 唯一的加法指令。RISC - V 没有专门的 subi 指令。因为立即数 `immi= 是有符号的(-2048 到 2047),如果你想做减法,只需加上一个复数即可。
xori XOR Immediate rd rs1 ^ imm= 按位异或。常用于翻转特定位。
ori OR Immediate rd rs1 \vert{} imm= 按位或。常用于将特定位置 1。
andi AND Immediate rd rs1 & imm= 按位与。常用于掩码操作(清零特定位)。
假设寄存器 x1 的值为 0x9 (二进制 1001)。执行 xori x1, x1, 0x3
  • 源数据: 10010x9
  • 立即数: 00110x3
  • 异或结果: 10100xA
  • 最终 x1 变为 0xA

二、移位运算。

移位指令用于将二进制位向左或向右移动。对于 I-Type 移位指令,立即数指定了移位的位数。

值得注意的是,移位指令在编码上比较特殊。由于移位量只需要 5 个比特,I-Type 指令原本 12 位的立即数空间被拆分了:低 5 位用于存移位量,高 7 位(imm[11:5])被用来作为辅助功能码,区分逻辑移位和算术移位。

指令 全称 符号 填充规则
slli Shift Left Logical Imm. << 逻辑左移;所有位向左移,低位(右侧)空出的位置补 0
srli Shift Right Logical Imm. >> 逻辑右移;所有位向右移,高位(左侧)空出的位置补 0
srai Shift Right Arithmetic Imm. >> 算术右移;所有位向右移,高位(左侧)空出的位置补符号位(即维持最高位不变)。适用于有符号数(补码),这能保证负数右移后依然是负数

作为一个例子,假设 x10x91001)。执行 slli x1, x1, 0x2,即左移 2 位。原始内容是 00...001001,左移后变成 00...100100 (低位补了两个 0),等于 36,即十六进制 0x24

三、比较运算。

这类指令用于数值比较,结果不是返回差值,而是返回布尔值 0 或者 1。

指令 全称 功能描述 区别
slti Set Less Than Imm. rs1 < immrd=1,否则 0 有符号比较。它将操作数视为补码。例如,它会认为 -1 小于 10
sltiu Set Less Than Imm. Unsigned rs1 < immrd=1,否则 0 无符号比较。它将操作视为纯正整数。例如,它会认为 -1,即全 1 的 0xFFFFFFFF,是一个巨大的正数,因此 -1 大于 10
寄存器类型

在先前的 I-Type(立即数型)指令中,指令的 32 个比特位被分配给了一个目标寄存器(rd)、一个源寄存器(rs1)和一个 12 位的立即数(imm)。当程序需要将两个都在寄存器中的变量进行运算时,I-Type 就无能为力了。因此,RISC - V 设计了 R-Type 格式。

指令名称 操作码 funct3 funct7 描述
add(加法) 0110011 0x0 0x00 rd = rs1 + rs2
#ERROR 0110011 0x0 0x20 rd = rs1 - rs2
xor(异或) 0110011 0x4 0x00 rd = rs1 ^ rs2
or(或) 0110011 0x6 0x00 rd = rs1 \vert rs2
and(与) 0110011 0x7 0x00 rd = rs1 & rs2
sll(逻辑左移) 0110011 0x1 0x00 rd = rs1 << rs2
srl(逻辑右移) 0110011 0x5 0x00 rd = rs1 >> rs2
sra(算术右移) 0110011 0x5 0x20 rd = rs1 >> rs2
slt(小于置位) 0110011 0x2 0x00 rd = (rs1 < rs2)? 0:1
sltu(无符号小于置位) 0110011 0x3 0x00 rd = (rs1 < rs2)? 0:1

R-Type 指令的物理编码结构将原本用于存放 12 位立即数的空间重新进行了分配。它划出了 5 个比特位用于指定第二个源寄存器(rs2),而剩余的 7 个比特位则被定位为 funct7 也叫 7 位功能码。所有的 R-Type 算术和逻辑指令都共享同一个基本的操作码,操作码 为 0110011。CPU 首先读取操作码,得知这是一条 R-Type 算术指令,接着通过结合 funct3funct7,才能最终确定具体要执行的硬件电路。

例如加法和减法。RISC - V 的 I-Type 指令中不存在 subi (立即数减法),因为程序员可以直接加上一个负的立即数。但是在纯寄存器运算中,加法和减法是截然不同的操作。在 R-Type 中,addsub 拥有完全相同的操作码和 funct3,它们唯一的物理区别就在于 funct7 字段:addfunct70x00,而 subfunct70x20。硬件编码器会捕捉到这一个比特位的差异,从而指示算术逻辑单元执行减法而不是加法。

这种 R-Type 架构同样适用于移位操作。在前面提到的立即数移位指令(如 slli)中,移位的步长是硬编码在指令里的常数。而在 R-Type 的移位指令中(sllsrlsra)中,移位的步长来自于第二个源寄存器(rs2)的最低 5 个比特位。这意味着程序可以在运行时,动态计算并决定需要移位的具体位数,从而极大地提升了运算的灵活性。

依然是作为例子:

地址 机器码 汇编含义
0x0 0x00100093 addi x1, x0, 1
0x4 0x00108093 addi x1, x1, 1
0x8 0x00108133 add x2, x1, x1

程序计数器会从地址 0x0 启动,第一条 I-Type 指令将 x1 初始化为 1。随后 PC 步进至 0x4,第二条 I-Type 指令通过自增操作使 x1 的值更新为 2。当 PC 到达 0x8,CPU 读取并解码出第一条 R-Type 指令(add)。此时,指令指示 ALU 取出 rs1(即 x1,值为 2)和 rs2(依然是 x1,值为 2)的内容。ALU 执行加法后得到 4,并将该结果通过数据通路写回到目标寄存器 rd(即 x2)。至此,x2 的状态被确立为 4。

加载与存储

如何与主内存进行数据交换,以及如何高效地生成大型的 32 位常数和内存地址呢?

RISC - V 采用了严格的「加载 - 存储」架构。ALU 无法直接读取或修改内存中的数据。所有的运算必须在寄存器内完成。这就构成了数据流转的基本逻辑:使用 Load 指令将内存数据搬运到寄存器,在寄存器中计算完毕后,再使用 Store 指令将结果搬运回内存。

指令 名称 格式 操作码 funct3 描述
lb 加载字节 I 0000011 0x0 rd = M[rs1+imm][7:0]
lh 加载半字 I 0000011 0x1 rd = M[rs1+imm][15:0]
lw 加载字 I 0000011 0x2 rd = M[rs1+imm][31:0]
lbu 加载无符号字节 I 0000011 0x4 rd = M[rs1+imm][7:0]
lhu 加载无符号半字 I 0000011 0x5 rd = M[rs1+imm][15:0]
sb 存储字节 S 0100011 0x0 M[rs1+imm][7:0] = rs2[7:0]
sh 存储半字 S 0100011 0x1 M[rs1+imm][15:0] = rs2[15:0]
sw 存储字 S 0100011 0x2 M[rs1+imm][31:0] = rs2[31:0]

该表格列出了所有的 Load 和 Store 指令。这些指令的寻址模式被称为「基址寻址」。以语法 lb x2, 0(x1) 为例,括号内的 x1 是基址寄存器(rs1),提供了一个基础的内存地址;括号外的 0 是立即数偏移量(imm)。硬件会将 x1 的值与立即数相加,计算出最终的物理内存地址,然后从该地址读取数据存入 x2rd)中。

对于 Load 指令,RISC - V 提供了按字节(8 位,lb)、半字(16 位,lh)和字(32 位,lw)加载的选项。由于目标寄存器总是 32 位的,当加载不足 32 位的数据(如 8 位的字节)时,就会出现高 24 位如何填充的问题。带有 u 后缀的指令(如 lbu,无符号加载)会在高位全部填充 0;而不带 u 的指令(如 lb,有符号加载)则会进行「符号扩展」,即将最高位(符号位)复制填充到所有的剩余高位中,以维持负数的数字意义。Store 指令则不需要区分符号,因为它们仅仅是截断寄存器中的低位数据,并覆盖到内存中,如 sb 只取寄存器的最低 8 位写入内存。

既然内存地址通常是一个 32 位的巨大数值,而基于 I-Type 和 S-Type 的指令只能容纳 12 位的立即数,我们该如何将一个 32 位的地址放入寄存器以便后续寻址呢?

早期的笨办法是通过复杂的算术运算。程序员被迫先用 addi 将 1 放入寄存器,然后再用 slli 将其左移 28 位,才能勉强凑出 0x10000000 这个地址。这种操作极其低效,且难以泛用。

为了解决这个问题,RISC - V 引入了 U-Type(Upper Immediate,高位立即数)指令格式。U-Type 指令舍弃了源寄存器字段,将立即数的可用空间直接扩展到了 20 位。

指令 名称 操作码 funct3 描述
lui 加载高位立即数 0110111 rd = imm << 12
auipc 将高位立即数加至 PC 0010111 rd = PC + (imm << 12)

lui 的硬件逻辑非常纯粹:它获取指令中提供的 20 位立即数,直接将其放置在目标寄存器的高 20 位(即向左移位 12 次),并将底部的 12 位强制清零。因为在十六进制中,每 4 个二进制等于 1 个十六进制字符,左移 12 位正好等同于在数字末尾追加 3 个零。这样,只需要一条 lui 指令,就可以极快地构造出 32 位数值的上半部分。

auipclui 机制类似,但多了一个步骤:它在将高 20 位就位后,会将其与当前的 PC 的值相加。在现代操作系统中,程序加载到内存的绝对地址往往是随机的,程序必须能够相对于其自身的位置去寻找变量。auipc 允许处理器以当前执行的指令地址为基准,向前或向后计算出相距甚远的数据地址。

控制流

在先前的学习中,我们已经知道 PC 在默认情况下会在每个时钟周期自动加 4,以此顺序执行内存中的指令。控制流的物理本质就是通过特定的条件或指令强行重写 PC 寄存器的值,从而改变指令获取的物理地址。

条件分支指令(B-Type)的核心机制是让 ALU 对两个源寄存器进行比较。如果满足指令规定的比较条件,硬件会将指令中携带的立即数与当前的 PC 值相加,并将这个计算结果强行写入 PC 寄存器,完成物理跳转。如果条件不满足,PC 则按常规加 4,继续执行内存中紧挨着的下一条指令。

在条件比较中,区分有符号和无符号至关重要。底层的机器码只是一串二进制位,没有内在的正负之分。

作为个例子:

地址 机器码 意思 注释
0x00 0xffb00093 addi x1, x0, -5 x1 = -5
0x04 0x00500113 addi x2, x0, 5 x2 = 5
0x08 0x0020c463 blt x1, x2, 0x8 if (x1 < x2) pc = pc + 8
0x0c 0x00100193 addi x3, x0, 1 skipped if (x1 < x2): x3 = 1
0x10 0x00200193 addi x3, x0, 2 x3 = 2

寄存器 x1 先是被赋予了 -5。在 32 位系统中,-5 的底层补码是以 1 开头的一串极长的数据(即 0xfffffffb),而 x25。当使用 blt 进行比较时,硬件逻辑会将最高位视为符号位,从而正确得出 -5 小于 5 的结论,并触发跳转。

但如果错用了 bltu,硬件会纯续从数值大小的角度将 0xfffffffb 视为一个巨大的正数,此时比较结果为假,程序就会错误地跳过分支。

除了基于条件的短距离跳转,RISC - V 还提供了用于实现函数调用和返回的无条件跳转指令,即 jal(Jump and Link)和 jalr(Jump and Link Register)。这类指令不仅要改变 PC 的值以实现跳转,还承担着「记录来处」的责任。

所谓的 Link(链接),指的是在覆盖 PC 寄存器之前,硬件会先将当前的 PC 值加上 4 保存到一个指定的目标寄存器中。这个被保存的地址就是返回地址。通过这种机制,程序在跳入一个代码片段执行完毕后,可以再次通过读取该寄存器的值跳回到最初离开的位置,这是实现程序模块化和复用的底层基础。

jaljalr 的区别在于计算目标地址的方式。jal 属于 J-Type 指令,它将当前的 PC 值与指令中携带的大范围立即数相加来实现相对位置的跳转。而 jalr 属于 I-Type 指令,它通过读取一个源寄存器的值,加上立即数偏移量来计算跳转的绝对物理地址。

这是又一个例子:

地址 机器码 意思 注释
0x00 0x00c000ef jal x1, 0xc set pc to 0x0 + 0xc = 0xc, x1 = pc + 4 = 0x4
0x04 0x00000013 addi x0, x0, 0 无操作
0x08 0x00010093 addi x1, x2, 0 set x1 = x2
0x0c 0x00008167 jalr x2, x1, 0 set pc to x1, x2 = pc + 4 = 0x10
0x10 0x00100093 addi x1, x0, 1 x1 = 1

这是一个相对复杂点的跳转时序,实际上模拟了一个状态机的执行轨迹。程序从地址 0x00 开始,执行 jal x1, 0xc。此时 CPU 计算跳转目标为 0x00 加上 0xc 等于 0x0c,同时将返回地址 0x04 保存到寄存器 x1 中。

随后 PC 跳转至 0x0c,执行 jalr x2, x1, 0。此时 x1 的值为 0x04,因此跳转目标为 0x04 加上 0 等于 0x04。同时,这条指令需要保存新的返回地址,即当前的 0x0c 加上 4 等于 0x10,并将其写入寄存器 x2 中。

此时 PC 再次跳转回 0x040x04 处的指令是将 x0 加上 0,实际上是一条不改变任何物理状态的空操作。程序继续顺序执行到 0x08,这里的指令将 x2 的值(即刚才保存的 0x10)复制给 x1

程序继续顺序执行,再次来到 0x0c。此时再次执行 jalr x2, x1, 0。由于在上一步,x1 的值已经被更新为 0x10,因此这次的跳转目标变成了 0x10 加上 0 等于 0x10。同时,新的返回地址 0x10 再次被写入 x2

最终,PC 跳转至 0x10 执行加法指令。

系统接口

在现代计算机体系结构中,为了保证系统的稳定与安全,硬件在物理上划分了不同的运行模式。普通应用程序默认运行在权限极低的「用户模式」下,这种模式下的代码被物理电路限制,无法直接操作底层硬件。而操作系统内核运行在高权限的「监管者模式」或者「机器模式」下,拥有掌控所有物理资源的最高权限。

当运行在用户模式下的程序需要执行必须由高权限才能完成的动作时,就必须通过一种机制,在硬件层面上安全地将控制权移交给操作系统。ecall 指令就是为此而生的。

它在执行时,会在处理器内部触发一个同步异常。这个物理动作会瞬间打断当前用户程序的线性执行流,将 PC 的值强行替换为操作系统内核预先设定的异常处理程序的物理地址,并同时提升处理器的硬件特权级别。

然而,操作系统内核在接管控制权后,必须知道用户程序究竟想要请求什么服务。这就需要依靠 ABI 指定的寄存器契约。在 Linux RISC - V 的契约中,程序员必须在触发 ecall 之前,将特定的系统调用号放入指定的物理寄存器 x17(ABI 名称为 a7)中。内核被唤醒后,会第一时间去读取 a7 寄存器的电平状态,以此来决定分配哪一段内核代码去执行具体的任务。

教程中提供的例子正是描述了这样一个完整的系统调用物理过程,其具体内容如下表所示:

地址 机器码 意思 注释
0x00 0x05d00893 addi x17, x0, 93 设置 a7 = 93,对应 Linux 的 exit 系统调用
0x04 0x00000073 ecall 触发环境调用,将控制权移交系统内核

在这个执行序列中,程序首先位于内存地址 0x00。指令解码器将机器码 0x05d00893 翻译为 addi x17, x0, 93。硬件电路读取恒为 0 的 x0 寄存器,将其与立即数 93 相加,并将结果存在 x17 寄存器中,也就是 a7。在 Linux 内核的系统调用映射表中,数字 93 严格对应着 sys_exit 的服务。随后 PC 步进至 0x04,处理器读取并执行 ecall 指令。此时,硬件权限提升,Linux 内核介入,检查到 a7 寄存器内部的数据为 93,于是内核执行进程清理工作,彻底在内存中抹除该程序的运行状态。

另一个系统接口指令是 ebreak。与 ecall 移交控制权给操作系统不同,ebreak 的物理设计目的是将处理器的控制权移交给调试器。执行 ebreak 同样会触发一个异常,迫使 CPU 暂停当前的流水线工作。

不过程序员在编写汇编源码时,极少会手动写入 ebreak 指令。它的主要应用场景是在幕后由调试软件动态注入。当我们在调试器中对代码的某一行下达「打断点」的指令时,调试器会在物理内存中定位到该行代码对应的原始机器码,并将其悄悄替换为 ebreak 的机器码。

当微处理器的高速缓存读取并执行到这个被篡改的内存地址时,ebreak 触发异常,CPU 物理挂起,调试器趁机接管控制台,允许我们通过输入命令去查看各个寄存器内部的电平状态。当我们在调试器中输入继续运行的指令后,调试器会再次访问那段内存,将 ebreak 机器码抹除,并把原本正确的指令机器码填补回去,最后让 CPU 恢复物理运转。

内存排序

在现代微处理器架构中,为了极致追求物理执行效率,硬件通常会采用乱序执行技术,并配备多级高速缓存和存储缓冲区。这意味着,机器码在内存中的线性排列顺序,并不完全等同于 CPU 最终将计算结果写入主内存的物理时序。在单核处理器中,这种乱序执行对程序员是透明的,硬件内部的依赖检查机制会保证最终结果的正确性。

然而在拥有多个 RISC - V 硬件线程且共享同一块物理主内存的系统中,这种乱序写入会导致严重的同步问题。当核心 A 向共享内存写入一组数据,随后写入一个标志位表示数据已准备就绪时;核心 B 如果通过物理总线先预测到了标志位的改变,却因为核心 A 的缓存延迟而读取到了尚未更新的旧数据,就会引发并发程序的逻辑崩溃。

fence 指令的物理作用就是强制实施严格的内存访问顺序,以解决上述的可见性倒置问题。当微处理器的指令流水线读取到 fence 指令时,它会在硬件层面上将程序的执行流划分为两个绝对隔离的集合:位于 fence 之前的指令集合,以及位于 fence 之后的指令集合。

执行 fence 指令时,当前处理器核心会暂时挂起后续集合中所有涉及内存访问指令的执行。硬件控制电路会等待并确保,前序集合中的所有内存读写操作不仅在当前核心内执行完毕,而且其产生的电平状态改变必须通过系统总线被推送至全局,确保被系统内的所有其他物理核心准确观测到。只有在这一物理可见性保证达成之后,后续集合中的内存访问指令才被允许启动。

这种硬件级别的时序强制保证,是构建现代并发软件的基石。在操作系统的底层实现中,开发人员利用 fence 指令配合原子操作,来构建互斥锁等同步机制,从而确保多个进程或线程在操作共享内存区块时不会发生物理状态的竞态条件。

指令 名称 类型 操作码 funct3 描述
fence Fence I 0001111 0x0 rdrs1 处于保留状态。针对所有内存访问类型的常规 fence 指令,其立即数 imm = 0b000011111111

根据该表,由于 fence 并不进行实际的算术数据搬运,其目标寄存器 rd 和源寄存器 rs1 在基础用法中被硬件忽略并保留为空。

该指令的核心在于其 12 位的立即数。在 fence 指令的微架构实现中,这 12 个比特位被精确划分为输入和输出两组掩码,用于细粒度地指定具体需要对哪种类型的设备的哪种操作进行强制排序。表格中给出的立即数(即十六进制的 0x0FF)是一个全为 1 的掩码。当硬件电路接收到这个特定的立即数序列时,它会执行最严格的「完全屏障」,即要求该屏障之前的所有类型的物理内存读写,必须先于该屏障之后的所有类型的物理内存读写完成全局同步。

M 扩展

在体系结构的底层设计中,基础整数指令集(RV32I)为了追求硅片面积的极简与低功耗,在物理电路上刻意移除了硬件乘法器和除法器。如果仅有基础指令集,微处理器在执行乘除法时必须依赖编译器生成的循环加法与位移指令软件算法,这会消耗大量的时钟周期。M 扩展代表着在处理器的硅片上实际蚀刻了专用的硬件乘法器和除法器逻辑电路,使得乘除法可以在极少的时钟周期内直接由硬件电路完成。

M 扩展包含的指令全部属于 R-Type 格式,这意味着所有参与运算的数据必须预先加载到通用寄存器中,且指令的操作码统一为 011011。硬件译码器通过识别 7 位功能码(funct7,M 扩展固定为 0x01,即 0000001)与 3 位功能码(funct3)的电平信号组合,来精确激活对应的乘除法物理电路。

指令 名称 funct3 描述
mul Multiply 0x0 rd = (rs1 * rs2)[31:0](取乘积的低 32 位)
mulh Multiply High 0x1 rd = (rs1 * rs2)[63:32](有符号乘法,取高 32 位)
mulhsu Multiply High Sign/Uns. 0x2 rd = (rs1 * rs2)[63:32](有符号与无符号乘法,取高 32 位)
mulhu Multiply Unsigned 0x3 rd = (rs1 * rs2)[63:32](无符号乘法,取高 32 位)
div Divide 0x4 rd = rs1 / rs2(有符号除法,取商)
divu Divide Unsigned 0x5 rd = rs1 / rs2(无符号除法,取商)
rem Remainder 0x6 rd = rs1 % rs2(有符号除法,取余数)
remu Remainder Unsigned 0x7 rd = rs1 % rs2(无符号除法,取余数)

这组指令设计的核心物理矛盾在于数据位宽的溢出。当物理电路执行两个 32 位整数的乘法运算时,其产生的最大物理输出结果需要 64 根导线,即 64 个比特位才能无损承载。

在 RV32 架构下,微处理器内部的单一通用寄存器物理位宽被硬性限制为 32 位。由于一个物理卡槽无法吞下 64 位的电平信号,硬件电路在输出时必须对数据进行物理截断。RISC - V 采用了两次读取的策略:mul 指令用于将硬件乘法器底部的 32 根导线(低 32 位)连接到目标寄存器;而 mulh 系列指令则用于将硬件乘法器顶部的 32 根导线(高 32 位)连接到目标目标寄存器。根据参与运算的数据是有符号补码还是无符号纯二进制,系统提供了不同的 mulh 变体以确保最高位(符号位)扩展时的物理正确性。

举个例子:

地址 机器码 意思 注释
0x00 0x000100b7 lui x1, 0x10 通过高位立即数加载,x1 的物理状态变为 0x00010000
0x04 0x00108093 addi x1, x1, 1 x1 与 1 相加,其状态确立为被乘数:0x00010001
0x08 0x00080137 lui x2, 0x80 x2 的物理状态被确立为乘数:0x00080000
0x0c 0x022081b3 mul x3, x1, x2 执行乘法,并将结果的低 32 位截断后存入 x3,此时 x3 = 0x00080000
0x10 0x02209233 mulh x4, x1, x2 执行乘法,将结果的高 32 位截断后存入 x4,此时 x4 = 0x00000008

程序首先利用 lui(向左偏移 12 位)和 addi 指令,在 x1 寄存器中构造了十六进制数值 0x00010001。随后通过 luix2 寄存器中构造了十六进制数值 0x00080000

当在内存地址 0x0c0x10 触发乘法运算时,硬件乘法器接收这两个 32 位的输入电平,在内部产生一个 64 位的完整结果。从数学逻辑上验算:0x00010001 乘以 0x00080000,其完整的 64 位十六进制结果为 0x0000000800080000。在地址 0x0c 处,mul 指令激活了捕获低 32 位信号的数据通路,将其引流至寄存器 x3,因此 x3 呈现为 0x00080000。在地址 0x10 处,mulh 指令重新执行乘法(在实际的高性能微架构实现中,乘法器可能会缓存上一次的 64 位结果以避免重复计算),并激活捕获高 32 位信号的通路引流至 x4,因此 x4 呈现为 0x00000008。此时,x4x3 在物理上共同组合成了完整的 64 位乘积。

最后,教程指出了硬件除法器在面临「除数为零」这种未定义数学行为时的物理响应机制。在某些复杂的体系结构(如 x86)中,除以零会在硬件级别强行触发一个异常中断,迫使操作系统介入处理(通常表现为程序崩溃)。但在 RISC - V 架构中,为了维持处理单元和流水线控制逻辑的绝对精简,除以零被规定为一种不会引发物理中断的常规操作。当除法器电路接收到除数为 0 的电平信号时,其硬件连线会产生一个固定的输出:将目标寄存器内的所有物理比特位强制拉高至 1。全 1 的二进制状态在有符号补码逻辑中等同于数值 -1(即十六进制的 0xFFFFFFFF)。这就意味着,硬件将除以零的安全边界检查责任完全推卸给了软件。程序员或编译器必须在使用 div 指令之前,通过条件分支指令(如 beq rs2, x0, exception_label)主动探测除数寄存器的电平状态,以防止错误的 -1 污染后续的数据流计算。

RV64

RV64 架构的基础执行逻辑与 RV32 完全一致。这种一致性体现在操作码、指令寻址格式以及寄存器契约的完全复用上。其根本的物理变化在于微处理器内部数据通路的全面拓宽。在 RV64 架构中,微处理器内部的 32 个通用物理寄存器(x0x31)、ALU 的内部逻辑门阵列,以及连接各部件的内部数据总线,其物理导线的数量都被硬性地增加到了 64 根。这意味着,当你在 RV64 上执行一条基础的 addaddi 指令时,硬件电路默认激活的是一个 64 位的全加器,它会同时处理 64 位的电平信号,并将 64 位的完整结果写回目标寄存器。

然而,计算体系中存在大量依赖 32 位物理边界的软件逻辑。例如 C 语言中标准 int 数据类型在大多数现代系统中被强制定义为 32 位。在纯粹的 32 位物理硬件上,当一个 int 类型的整数不断递增并超越其最大物理承载上限时,最高位的进位信号会因为没有更多的物理导线而自然丢失,从而精确的截断与符号位翻转(即溢出变成负数)。如果将这种运算直接放到 RV64 的 64 位加法器中执行,进位信号会顺理成章地流向第 33 根导线,数值会继续平滑增大,原本预期的 32 位溢出截断行为在物理上彻底失效了,这将导致严重的软件逻辑谬误。

为了在 64 位的物理硬件上精准模拟 32 位的截断环境,RV64 指令集专门引入了带有 w 后缀的指令子集。在 RISC - V 的标准术语中,「字」(Word,即 w)被严格且永久地定义为 32 个比特位,而 64 位的数据被称为「双字」(Doubleword)。教程中提到的 addwaddiwsllwmulw,就是专门针对 32 位电平信号设计的截断运算指令。

当微处理器的指令解码器读取到带有 w 后缀的机器码时,控制电路会向算术逻辑单元下达特定的屏蔽指令。此时,ALU 在获取源寄存器的数据时,会从物理上切断或忽略第 32 根至第 63 根导线的输入信号,仅仅提取寄存器最底部的 32 根导线(即低 32 位)的状态作为有效的运算输入。在 ALU 内部执行完纯粹的 32 位运算并得出结果后,硬件电路还需要完成最后一步关键的物理填充:符号扩展。

由于目标寄存器本质上仍然是一个 64 位的物理卡槽,ALU 不能仅仅将低 32 位的结果写回而任由高 32 位保留先前的垃圾电平信号。根据 RISC - V 的规范,硬件电路必须提取这 32 位运算结果的最高位(即第 31 根导线,符号位)的电平状态,并通过物理连线将其强行复制、覆盖到目标寄存器高段的全部 32 根导线上。这种由底层电路强制执行的符号扩展机制,确保了 32 位截断运算产生的数据,在被放回 64 位的寄存器空间后,依然能够维持其在数学逻辑上的正负一致性。