Appearance
低级指令的统一模型:计算、访存、控制流与环境交互
学习C语言后,一个进阶的方向就是学习汇编。学习汇编对逆向分析、程序调试及系统编程都有帮助,然而很多人对汇编望而却步,认为其指令繁多、功能复杂,把汇编学习看做背诵指令表,导致无法真正理解其设计。本文将会以ARM64汇编为主线,并通过对照x86汇编、解释型语言字节码等帮助读者理解计算机的核心工作原理。本文沿用常见说法称为 ARM64,严格说这里讨论的是 AArch64 执行状态下的指令。
本文的阅读路线如下:先用状态机模型理解指令语义,再用 ARM64 和 x86-64 对照观察计算、访存、控制流和环境交互如何落到具体指令上,随后扩展到 WASM、Python bytecode、LLVM IR 和专用硬件。核心问题始终只有一个:一条指令读取什么状态、写入什么状态,以及它是否改变普通控制流。
程序的本质是状态机
在外部看,一个程序运行时,是在执行代码。但回到数学抽象上说,它是一个状态机在不断推进。
从3n+1猜想认识状态机
状态机包括两个部分:可变的状态和固定的状态转移规则。为了不这么抽象,我们来介绍一个非常简单的状态机:基于考拉兹猜想的状态机。
考拉兹猜想的状态机非常简单,我们给定任意一个正整数。每次推进状态机时,按顺序检查规则,并执行第一条满足的规则。
- 如果这个数字是1,停机
- 如果这个数字是偶数,将其除以2
- 如果这个数字是奇数,将其乘以3再加1
在这里,上面的三条规则就是状态转移规则,而这里给定的数字就是初始状态。按照这个规则操作数字就是推进状态机,推进会改变状态,但不会改变转移规则,随着推进,状态机最终会停机或陷入循环。
例如 10 -> 5 -> 16 -> 8 -> 4 -> 2 -> 1 -> 停机。
这个例子还展示了一个状态机的重要特性:无后效性。即状态机如何转移仅和当前状态有关,和过去的行为无关。例如初始给出16的计算过程和从10推进到16后的计算过程并无任何区别,初始给出20,计算到10后的计算过程和初始给10的计算过程也完全一样。
TIP
考拉兹认为无论初始数字是什么,这个状态机都一定会停机。虽然规则简单到小学生都能看懂,但该猜想至今也没有被证明。
复杂的状态机
对于真实世界的程序来说,其状态机要比考拉兹猜想的状态机复杂无数倍,但其核心仍然是一组可变的状态+固定的状态转移规则。
以 ARM64 为例,我们可以暂时把 CPU 想象成一个巨大的解释器,它不断读取当前 PC 指向的机器码,根据机器码的内容执行对应规则,然后更新寄存器、内存和 PC。
C
for (;;) {
instruction = memory[pc];
switch (decode(instruction)) {
case LDR:
// 把指定内存地址中的内容加载到指定寄存器
registers[dst] = memory[address];
pc += 4;
break;
case STR:
// 把指定寄存器中的内容存储到指定内存地址
memory[address] = registers[src];
pc += 4;
break;
case ADDS:
result = registers[a] + registers[b];
registers[dst] = result;
flags.N = high_bit(result);
flags.Z = result == 0;
flags.C = unsigned_carry(registers[a], registers[b]);
flags.V = signed_overflow(registers[a], registers[b], result);
pc += 4;
break;
case B_VS:
// 如果 V 标志位为 1,说明上一次带标志位的算术操作发生了有符号溢出
if (flags.V == 1)
pc = pc + offset;
else
pc += 4;
break;
default:
raise_illegal_instruction_exception();
break;
}
}这个例子当然不是 CPU 的真实实现,但它表达了一个重要事实:指令不是一串需要死记硬背的助记符,而是一组状态转移规则的名字。
LDR 改变寄存器状态,STR 改变内存状态,ADDS 改变寄存器状态并更新标志位,B.VS 根据标志位改变控制流状态。CPU 当前拥有的寄存器、内存、状态标志位、PC 等共同构成了可变状态;而指令集手册描述的每条指令语义,则构成了固定的状态转移规则。
对于真实 CPU 来说,这些规则最终落实在硬件电路中;对于 JVM、WASM、Python bytecode 这样的虚拟机或解释器来说,这些规则则落实在解释器、JIT 或运行时系统的实现中。它们的形式不同,但抽象上都可以看成“读取一条指令,根据规则更新状态,然后进入下一步”。
汇编的设计思想
CPU 或虚拟机提供了一套固定的状态转移规则,并通过指令集手册告诉程序员:每条指令会读取什么状态、写入什么状态、可能改变哪些隐含状态,以及在什么条件下进入异常或跳转。
而程序本身,则提供了状态机运行所需的一部分初始状态:代码、数据、符号、重定位信息等。操作系统、加载器、ABI 和运行时环境会进一步补全这个初始状态,例如建立进程地址空间、设置栈、初始化寄存器、跳转到入口函数。
机器码是 CPU 真正执行的二进制编码;汇编则是机器码的人类可读形式。一套机器码编码、寄存器规则、寻址方式、异常行为和内存模型相关约束,共同构成了我们通常所说的指令集架构,也就是 ISA。ISA 提供了代码可以使用的基础能力;而函数之间如何传递参数、哪些寄存器需要保留、栈应当如何布局等规则,则属于 ABI。编译器会在生成指令时遵守这些约定。
汇编的设计受到多方制约。
- 硬件能力:硬件由逻辑门、寄存器、总线、缓存、执行单元等组成。某些操作容易做得很快,例如整数加法、移位和位运算;某些操作成本更高,例如除法、复杂寻址、跨核同步。指令集需要在硬件实现复杂度与生产代码中的常见热点路径之间取得平衡,例如允许部分算术和位运算指令直接组合移位操作,也可以通过条件移动等指令降低部分复合操作的成本。
- 实际需求:程序需要计算、访存、跳转、函数调用、系统调用、原子操作、异常处理等能力,指令集必须为这些需求提供表达方式。同时,指令集设计也会考虑真实工况中的热点路径。例如普通“比较两个数字后跳转”通常需要
cmp和b.cond两条指令,而“比较寄存器是否为 0 后跳转”在 ARM64 中可以用cbz/cbnz一条指令表达,因为判 0 是循环、空指针检查、计数器判断中非常常见的负载模式。这类设计和真实负载统计、编译器优化能力密切相关:硬件提供常见模式的短路径,编译器则负责把高级语言中的热点代码映射到这些短路径上。 - 历史限制:已有软件、操作系统、ABI、编译器和旧处理器兼容性会反过来约束新指令集的演进。x86 尤其明显,而 ARM64、RISC-V 等较新的设计则更容易把历史控制在可继承的范围内,而不是把它们累积成明显的负担。好的历史应该被继承,例如 ABI、异常模型和工具链约束;真正需要警惕的是那些会妨碍现代实现的累积复杂度。而像 CPython 3.13 bytecode 这样和解释器版本耦合极强的字节码,则可以在每次版本迭代时大刀阔斧地优化改进。
从程序员视角看,机器指令通常会被理解为一次固定规模的状态转移。这个假设并不等于每条指令的实际延迟都相同,而是说 ISA 会限制操作数宽度、立即数范围、寻址形式和一次指令可处理的数据规模,让人们通常不必担心单条指令会因为操作数规模不同而出现可感知的复杂度差异。一旦操作规模不可控,例如任意长度大整数、字符串搜索、文件 IO、网络收发,通常就不会再伪装成普通算术指令,而会交给库、运行时或系统调用处理。
程序中状态的分布
数学上,一个状态可以看作一个巨大的状态向量或映射。对于真实程序来说,这个映射非常复杂:寄存器、内存、栈、堆、文件描述符、线程状态、异常状态、操作系统资源等,都可能影响程序之后的执行结果。
为了便于学习汇编,本文先把程序直接操作的状态粗略分成两类:
- 处理器状态:包括通用寄存器、浮点/SIMD 寄存器、状态标志位,以及虚拟机中的操作数栈、局部变量表等。
- 内存状态:包括普通内存、栈、堆、全局变量、映射文件、共享内存等。
在真实系统中,状态远不止这两类。程序还会通过系统调用、异常、内存屏障、运行时检查等机制与更大的运行环境交互;外部中断、设备输入等异步事件则可以看作来自环境的外部输入。
四种影响维度
为了分析指令的作用,我们可以把指令的能力分成四种影响维度。
- 计算:主要在处理器状态之间进行转换,通常不显式访问普通内存。例如整数加法、位运算、比较、寄存器移动等。
- 访存:在处理器状态和内存状态之间搬运数据,或者直接读取、修改内存状态。例如加载、存储、入栈、出栈等。
- 控制流:改变程序计数器,使程序不再简单地顺序执行。例如条件跳转、无条件跳转、函数调用、函数返回等。
- 环境交互:与当前程序抽象之外的运行环境交互,或者影响多核、异常、权限、运行时系统等更大的状态。例如系统调用、同步异常、内存屏障、原子操作、运行时 safepoint 检查等。
这四个维度并不是互斥的,一条指令往往会同时触及多个维度。
C
int addPositive(int a, int *b) {
if (a < 0) {
return *b;
}
return a + *b;
}将这段代码不优化编译后,会产生如下汇编。
IMPORTANT
不同编译器、不同版本、不同编译参数产生的汇编不尽相同。这里展示的只是一种可能结果。若无特殊说明,此后的汇编、字节码也均为一种可能结果。
由于没有开启优化,编译器会把参数先保存到栈上,再从栈中读回,因此代码看起来比源代码冗长。
asm
addPositive: // 汇编标签,表示函数入口地址
sub sp, sp, #16 // sp = sp - 16
// [sp .. sp + 15] 被划出为当前栈帧
str w0, [sp, 12] // [sp + 12] = w0
// w0 中的值被写入栈帧中的一个位置
str x1, [sp] // [sp] = x1
// x1 中的值被写入栈帧中的一个位置
ldr w0, [sp, 12] // w0 = [sp + 12]
// 从栈帧中取回之前保存的值
cmp w0, 0 // 计算:比较 w0 和 0
// cmp 不保存计算结果,但隐式更新 NZCV 标志位
bge .L2 // 控制流:如果 a >= 0,跳转到 .L2
// bge 读取上一条 cmp 设置的标志位,决定是否修改 PC
ldr x0, [sp] // x0 = [sp]
// 从栈帧中取回另一个保存的值
ldr w0, [x0] // w0 = [x0]
// 继续沿着数据流向下读取
b .L3 // 控制流:无条件跳转到 .L3
// 跳过下面的 a + *b 分支
.L2: // 以.L开头的标签不会进入符号表,下同
ldr x0, [sp] // x0 = [sp]
// 从栈帧中取回另一个保存的值
ldr w1, [x0] // w1 = [x0]
// 沿着数据流继续读取
ldr w0, [sp, 12] // w0 = [sp + 12]
// 从栈帧中取回之前保存的值
add w0, w1, w0 // w0 = w1 + w0
// 结果放入返回值寄存器
.L3:
add sp, sp, 16 // sp = sp + 16
// 栈指针回到进入函数之前的位置
ret // 控制流:返回调用者
// ret 会跳转到链接寄存器 LR/x30 保存的返回地址这段代码虽然只对应一个很简单的 C 函数,但已经包含了三类影响维度:
- 计算:例如
sub sp, sp, #16、cmp w0, 0、add w0, w1, w0。 - 访存:例如
str w0, [sp, 12]、ldr w0, [sp, 12]、ldr w0, [x0]。 - 控制流:例如
bge .L2、b .L3、ret。
其中最值得注意的是 cmp 和 bge 的配合。cmp w0, 0 本身并不跳转,它只是更新 CPU 中的状态标志位;bge .L2 本身也不重新比较 w0 和 0,它只是读取前一条比较指令留下的标志位,并根据这些隐式状态决定是否改变 PC。
这正体现了状态机视角下的汇编理解方式:一条指令执行后,不只是产生表面上的结果,还可能改变寄存器、内存、标志位、栈指针、程序计数器等状态,而后一条指令又会基于这些状态继续推进。
三种影响结果
学习一条指令时,关键在于弄清楚它会读取什么状态、修改什么状态,以及它是否可能改变正常的执行路径。
从这个角度看,一条指令的影响可以分为三类:
- 显式影响:直接写在操作数中的输入与输出,例如
add w1, w1, w2会读取w1和w2,再覆盖w1。 - 隐式影响:没有完整写在操作数中,但由指令语义约定的状态变化,例如
cmp会修改NZCV标志位,bl会修改链接寄存器LR/x30。 - 同步异常路径:指令无法按普通方式完成,或主动请求更高权限环境介入时,CPU 可能暂停当前控制流,进入异常处理流程。例如访问无效地址、执行无效指令或执行系统调用指令。
这三类影响都是状态转移的一部分。只看汇编代码表面上的操作数,仍然不足以完整理解一条指令。
显式影响
最容易理解的是显式写出的输入和输出。
asm
add w1, w1, w2 // w1 = w1 + w2
sub sp, sp, #16 // sp = sp - 16
ldr w0, [x1] // w0 = [x1]
str w0, [x1] // [x1] = w0add w1, w1, w2 读取两个寄存器,并把计算结果写回 w1。ldr 和 str 则更复杂一些:它们除了读取寄存器,还会读取或写入寄存器所指向的内存。
对 CPU 来说,栈上的局部变量、堆上的对象、全局变量和数组元素并没有本质区别。它们最终都表现为某个地址上的内存。所谓“访问数组下标”或“读取结构体字段”,在 CPU 上通常只是先计算地址,再进行加载或存储。
例如:
C
int getElement(int *arr, long index) {
return arr[index];
}可以被编译为:
asm
getElement:
ldr w0, [x0, x1, lsl #2]
retx0 是数组首地址,x1 是下标。由于一个 int 占 4 字节,x1, lsl #2 表示将下标左移两位,也就是乘以 4。ARM64 把常见的地址计算方式直接放进了访存指令中。
隐式影响
有些状态变化不会完整出现在操作数中。理解这些隐式状态,是阅读汇编代码的重要一步。
状态标志位
ARM64 中常见的状态标志位合称为 NZCV:
N:结果的最高位是否为 1,通常用于判断有符号数是否为负。Z:结果是否为 0。C:加法时表示是否产生无符号进位;减法和比较时表示是否没有发生无符号借位。V:是否发生有符号溢出。
普通的 add 不会更新 NZCV,而 adds 会更新:
asm
add w0, w1, w2 // w0 = w1 + w2,不修改 NZCV
adds w0, w1, w2 // w0 = w1 + w2,同时修改 NZCVcmp 也会修改 NZCV。它相当于一条不保存普通计算结果的减法:
asm
cmp w0, #0 // 实际上是 subs wzr, w0, #0
bge .L_not_negative // 根据 NZCV 判断 w0 是否大于等于 0cmp 和 bge 之间通过隐式状态发生联系。bge 不会再次读取并比较 w0,它只读取已经存在的 NZCV。
链接寄存器
函数调用也包含隐式状态变化:
asm
bl doSomethingbl 一方面会修改 PC,跳转到 doSomething;另一方面会把下一条指令的地址保存到链接寄存器 LR/x30。函数执行完成后,可以使用:
asm
ret返回调用者。没有显式操作数时,ret 默认读取 x30,并把它写入 PC。所以一次最简单的函数调用,本质上就是“保存返回地址、修改 PC、执行函数、从返回地址恢复 PC”。
如果函数在调用其他函数后还需要继续返回到自己的调用者,那么编译器生成代码时通常会额外将 lr 寄存器保存到栈上,以便后续恢复返回地址。
同步异常路径
一条指令除了正常完成,还可能触发同步异常。同步异常不是脱离状态机的意外,而是另一条受规则约束的状态转移路径。外部中断、设备输入这类异步事件不由当前指令直接触发,更适合看作运行环境输入的一部分。
例如:
asm
ldr w0, [x1]从表面上看,它只是读取 x1 指向的内存并写入 w0。但如果 x1 指向当前进程无法访问的地址,CPU 就无法正常完成这次加载。此时 CPU 会保存必要的异常现场,并跳转到操作系统预先注册的异常处理入口。
在开启虚拟内存的操作系统中,一次内存访问可能出现不同结果:
- 地址有效,并且页表映射已经存在:正常读取内存。
- 地址有效,但对应页面暂时没有装入物理内存:触发缺页异常,操作系统补全映射后重新执行这条指令。
- 地址无效或当前进程没有访问权限:操作系统通常向进程发送信号,例如 Linux 中的
SIGSEGV。
因此,在常见用户态环境中,空指针访问通常会导致段错误;更准确地说,是访问了未映射或无权限访问的地址。回到前面的 addPositive(int a, int *b),如果调用方传入的 b 是空指针,那么执行到 return *b 或 return a + *b 对应的 ldr 时,就可能沿这条同步异常路径离开普通控制流。C 语言层面本身并不保证空指针解引用一定以某种固定方式失败。
类似的同步异常路径还有:
- 执行未定义指令编码或无效指令,进入无效指令异常。
- 执行
svc,主动进入内核以发起系统调用。 - 访问需要特殊处理的内存映射区域,与设备或操作系统约定的处理流程交互。
把同步异常路径纳入视野后,程序就不再是只由当前函数控制的封闭状态机。CPU、操作系统、设备和运行时环境共同参与了状态推进。
ABI:基于约定的指令生成
CPU 提供了寄存器、内存和跳转指令,但它并不知道 C 语言中的函数是什么,也不会强制规定函数参数必须放在哪个寄存器中。
例如,下面两段汇编都可以表达 add(1, 2):
asm
// 方案一
mov w0, #1
mov w1, #2
bl add
// 方案二
mov w8, #1
mov w9, #2
bl add只要调用方和被调用方使用同一种规则,两种方案都能正常运行。但如果调用方把参数放在 w0 和 w1,被调用方却从 w8 和 w9 读取参数,程序就无法得到正确结果。
这就是 ABI(Application Binary Interface,应用二进制接口)需要解决的问题。编译器、汇编代码和运行时系统在生成或组织指令时,会遵守 ABI 约定,使不同代码片段能够在二进制层面正确配合。它通常包括:
- 参数和返回值放在哪里。
- 哪些寄存器可以被被调用函数随意修改,哪些寄存器必须在返回前恢复。
- 栈如何增长、如何对齐,以及栈帧如何组织。
- 基本类型的大小、对齐方式和内存布局。
- 系统调用、目标文件、动态链接等边界如何表示。
ABI 的范围比函数调用约定更大,但调用约定是理解汇编代码时最常遇到的一部分。
ARM64 中的常见调用约定
在常见的 ARM64 C ABI 中,函数通常遵守 AAPCS64(Procedure Call Standard for the Arm 64-bit Architecture)。其中部分规则如下:
x0到x7用于传递整数或指针参数,其中前几个寄存器也用于返回结果。x16和x17是过程调用临时寄存器,常用于 PLT 跳转、动态链接器跳板或其他中间调用代码。x19到x29和sp需要由被调用函数保留。如果函数需要修改它们,应当先保存旧值,并在返回前恢复。x30是链接寄存器,也就是LR。执行bl时,返回地址会写入x30。v0到v7可以用于传递浮点数和 SIMD 参数,其中前几个寄存器也用于返回结果;v8到v15的低 64 位需要由被调用函数保留。
因此,前面的 addPositive 中,参数 a 默认放在 w0,指针 b 默认放在 x1,返回值也放在 w0。这不是 addPositive 的 C 代码直接要求的,也不是 CPU 硬件强制规定的,而是编译器默认遵守 ABI 的结果。
编译器、汇编代码、静态库和动态库只有遵守共同约定,才能分别编写、分别编译,最后仍然正确地互相调用。
约定可以不遵守吗
既然 ABI 是约定,而不是硬件限制,那么当然可以不遵守。
如果一组函数只在程序内部使用,并且调用方与被调用方都由同一个编译器控制,那么编译器完全可以设计私有调用约定。例如使用更多寄存器传参、把某些隐式状态固定在特殊寄存器中,或者针对某一类函数采用特殊的数据布局。
但这种自由只存在于约定可控的边界之内。一旦需要调用系统库、链接外部目标文件、调用另一种语言生成的代码,或者暴露可以被外部代码调用的函数,就必须遵守双方认可的 ABI,或者在边界处增加转换代码。
Go 的内部 ABI
Go 是一个很典型的例子。Go 源码编译出的函数使用 Go 自己的内部 ABI,而不是简单地把平台 C ABI 当作所有 Go 函数之间的默认调用约定。
这样做的核心原因在于:Go 编译器和运行时基本由同一套工具链掌控,因此它可以围绕自己的需求: goroutine、可增长栈、GC、统一的函数、闭包和方法调用方式去设计一套更适合自身的调用约定。
不过,自定义约定并不意味着 Go 可以忽略外部世界。它仍然需要在与C外部库或系统调用交互时,转换到对方认可的 ABI。内部 ABI 解决的是 Go 自己这套代码如何高效协作,边界 ABI 解决的则是如何与外部世界保持兼容。
这再次体现了状态机视角:函数调用不只是执行一条 bl。调用前如何组织寄存器和栈,调用后哪些状态必须恢复,运行时是否可能介入,都是完整调用语义的一部分。
从优化后的代码理解指令选择
前面的 addPositive 使用未优化的汇编,适合展示栈和基础指令。编译器开启优化后,通常会尝试减少不必要的状态变化。
再看看当初的代码:
C
int addPositive(int a, int *b) {
if (a < 0) {
return *b;
}
return a + *b;
}它可以被优化成类似下面的 ARM64 汇编:
asm
addPositive:
ldr w1, [x1] // 提前读取 *b
bic w0, w0, w0, asr #31 // w0 = a & ~(a >> 31),也就是 max(a, 0)
add w0, w1, w0 // w0 = *b + max(a, 0)
ret减少栈的使用
未优化版本先把 a 和 b 保存到栈上,再从栈中读取:
asm
str w0, [sp, 12]
str x1, [sp]
ldr w0, [sp, 12]
ldr x0, [sp]优化后,参数可以一直保存在寄存器中。这样不仅减少了指令数量,也减少了内存访问。
寄存器通常比内存快得多。现代 CPU 虽然有多级缓存,但一次访存仍然需要经过地址计算、缓存查询、权限检查等步骤。如果数据没有命中缓存,还可能需要等待更低层缓存甚至内存。相比之下,寄存器之间的计算更容易被流水线快速处理。
用位运算消除跳转
未优化版本使用 bge 跳转到不同分支。优化版本则利用有符号整数的补码表示,把条件判断转换成位运算:
asm
bic w0, w0, w0, asr #31asr #31 表示进行算术右移。算术右移会使用符号位补齐高位:
- 如果
a >= 0,a >> 31的结果是全 0。 - 如果
a < 0,a >> 31的结果是全 1。
bic 表示 bit clear,其效果为第一个操作数和第二个操作数按位取反后的结果进行按位与:
C
w0 = w0 & ~(w0 >> 31);当 a >= 0 时,掩码为全 0,a 保持不变;当 a < 0 时,掩码为全 1,结果被清零。因此,这条指令计算出了 max(a, 0)。再加上 *b,就得到了原函数的返回值。
整个过程没有使用 cmp、没有修改 NZCV,也没有发生条件跳转。现代 CPU 虽然会使用分支预测提前猜测程序接下来要走哪条路径,但预测失败时,已经提前取出和执行的部分指令需要被丢弃,流水线会出现空泡。而对于是否大于0这种特殊的判断,可以使用一些位运算技巧把控制依赖改写成数据依赖,从而消除条件分支。
使用 csel 进行通用的条件选择
bic 版本非常紧凑,但它依赖一个特殊条件:这里判断的是一个 32 位有符号整数是否小于 0,因此可以使用符号位构造掩码。如果条件更复杂,仍然可以使用 ARM64 的条件选择指令 csel 消除跳转。
例如,addPositive 也可以被编译成:
asm
addPositive:
ldr w1, [x1] // 提前读取 *b
cmp w0, #0 // 判断 w0(a) 和 0 的关系,并根据结果更新 NZCV
add w2, w1, w0 // w2 = *b + a
csel w0, w1, w2, lt // N xor V (a < 0) 时选择 *b,否则选择 *b + a
ret这里的 cmp w0, #0 实际上是 subs wzr, w0, #0 的别名,在计算后会根据结果更新 NZCV,但由于是写入到零寄存器,所以结果被丢弃。
csel 会读取 NZCV,根据条件从两个寄存器中选择一个值写入目标寄存器。它比 bic 更通用:只要前面的比较可以通过条件码表达,就不需要依赖某种特殊的补码技巧。
TIP
得益于补码的特性,数学计算时 CPU 并不关心寄存器内的数据是有符号还是无符号,只需要直接计算,然后检查结果是否为0,最高位是否为1,和是否有溢出即可。a - b 可以看作a + (~b) + 1。
为结果,为结果最高位,即结果符号位。为结果最高位进位标志,为结果次高位进位标志。
| 标志位 | 说明 | 硬件表示 |
|---|---|---|
| N | 结果有符号表示为负数,即最高位为1。 | |
| Z | 结果为0。 | |
| C | 无符号加法进位,或无符号减法没有借位。 | |
| V | 有符号加减法溢出。 |
两种方式的差异如下:
| 方式 | 是否依赖 NZCV | 适用范围 | 特点 |
|---|---|---|---|
bic w0, w0, w0, asr #31 | 否 | 特定的有符号整数表达式 | 利用符号位构造掩码,一条指令完成 max(a, 0) |
cmp + csel | 是 | 可以通过条件码表达的通用选择 | 更容易生成和理解,但需要额外的比较与候选值计算 |
| 条件跳转 | 通常是 | 任意复杂分支 | 预测正确时成本较低,预测失败时可能产生流水线空泡 |
csel 与 bic 都避免了控制流分叉,但代价并不相同。csel 通常需要提前计算两个候选值,其中一个结果最终不会被采用; bic 则直接通过掩码变换得到结果,不需要分别计算两个分支,但依赖特定的判断条件。如果分支内部工作很多,或者某条路径几乎总能被准确预测,条件跳转仍然可能更高效。
提前访存与指令调度
优化后的代码把:
asm
ldr w1, [x1]放在了位运算之前。这是因为原始代码无论进入哪个分支,最终都需要读取 *b。提前加载可以让后续的 bic 等指令与访存过程部分重叠,减少等待数据的时间。
现代 CPU 通常采用流水线执行。一条指令需要经历取指、译码、执行、访存、提交等阶段。多条互不依赖的指令可以同时处于不同阶段中,提高硬件利用率。
但流水线并不能消除依赖关系。例如:
asm
ldr w1, [x1]
add w0, w1, w0add 必须等待 ldr 得到结果。如果 x1 指向的数据没有命中缓存,后续依赖 w1 的指令就可能等待很久,形成流水线空泡。优化后的 bic 不依赖加载结果,可以在等待期间执行。编译器和 CPU 都会尝试把这种互不依赖的工作安排到一起。
在硬件复杂度与常见路径之间平衡
ARM64 为一些常见计算提供了组合能力。例如:
C
unsigned int clearBits(unsigned int value, unsigned int mask) {
return value & ~mask;
}可以编译为:
asm
clearBits:
bic w0, w0, w1 // w0 = w0 & ~w1
retbic 可以看作“先按位取反,再按位与”的组合。相比先使用一条指令计算 ~mask,再使用另一条指令执行按位与,bic 用一条指令就表达了完整语义。
而 addPositive 使用的版本更进一步:
asm
bic w0, w0, w0, asr #31ARM64 的部分逻辑运算允许第二个操作数先进行移位,再参与运算。这样,算术右移和 bit clear 就可以合并到一条指令中。
这体现了 ARM64 指令设计中的一种取舍。CPU 不会为每一种高级语言表达式提供专用指令,否则解码逻辑、执行单元和指令编码都会变得更加复杂。但移位、加法、按位取反、按位与等操作本身容易使用硬件实现,而且在数组寻址、掩码计算、整数范围处理等热点路径中非常常见。因此,ARM64 允许它们以有限而规则的形式组合。
类似的设计还有带移位的加减法、带缩放的寻址方式、条件选择等。AArch64 的普通指令采用定长 32 位编码,CPU 前端更容易识别指令边界并进行解码;同时,通过这些有限而规则的组合能力,它仍然可以覆盖生产代码中的常见热点路径。这样的设计在不无限扩张硬件复杂度的前提下,减少了指令数量、中间寄存器和依赖链长度。
ARM64:用规则化组合平衡硬件与需求
ARM64 的先进性,很大一部分就体现在“让指令密度足够高,但又不把硬件做得过于臃肿”。相比x86,ARM64出现时,编译器技术已经相当成熟,所以它不再需要迁就汇编程序员提供实现复杂功能的单条指令(典型的如enter),而是用少量规则化组合覆盖更广的常见需求。
一个很典型的做法,是结合后缀标记、零寄存器与别名机制:
adds、subs这类带s的后缀会更新NZCV,所以既能完成计算,也能顺手为后续条件分支提供状态。而add、sub这类没有s后缀的指令则不会更新NZCV,这使得编译器可以做更激进的重排优化,而无需担心无关计算的副作用影响条件跳转的目标。xzr/wzr是零寄存器,既可以作为源,也可以作为目标,因此mov、cmp、tst之类的效果可以通过更基础的指令配合零寄存器表达出来。例如mov x0, x1实际上是orr x0, xzr, x1的别名,cmp x0, #0是subs xzr, x0, #0的别名,tst w0, w1则是ands wzr, w0, w1的别名 。- 这些别名是同一条底层编码的不同名字,汇编器和反汇编器会选择一个更适合人读的表面形式呈现出来。配合带后缀的规则化指令和零寄存器,ARM大大减少了需要实际电路支持的指令数量。
TIP
ARM不仅复用指令编码,寄存器编号也会复用。11111 在很多算术运算、基址寻址编码里会被解释为 sp,而在很多逻辑运算、零寄存器相关编码里会被解释为 xzr/wzr。这是一个大体成立的规律,但具体是否是 sp 还是 xzr,仍然取决于指令编码上下文。
而与上面的别名相对的,ret 指令是另一个值得注意的例子。它看起来像是 br x30 的别名,但实际上却完全是另一个指令,因为 ret 明确带有“函数返回”的语义,硬件可以据此使用更有针对性的返回地址预测机制。也就是说,ret 不只是跳转到 x30,它还向 CPU 的分支预测单元传达了更强的意图(hint),这会直接影响分支预测质量和流水线恢复效率。函数返回的语义,使用极为广泛且频繁,值得ARM64单独为它拆出专门的指令做优化。
TIP
有意思的是,ret 除了默认使用 x30 外,也可以显式指定其他通用寄存器,例如 ret x8。这说明它虽然常用于返回链接寄存器中的地址,但语义上仍然是“带返回意图的间接跳转”。
这里顺便为大家提供一些 benchmark 数据,仅供参考。它的作用只是说明 ret 和 br x30 的性能可能存在明显差异,具体数值不必过度解读。
cntfrq_el0 = 100000000 Hz
ITERS = 20000000
WARMUP = 1000000
ROUNDS = 10
NSITES = 64
single callsite:
br_x30 total= 16075134 ticks, per-call= 0.8038 ticks
ret total= 4057548 ticks, per-call= 0.2029 ticks
random callsites:
br_x30 total= 39221284 ticks, per-call= 1.9611 ticks
ret total= 19751319 ticks, per-call= 0.9876 ticks和 ARM64 这种规则化设计相比,x86 的历史更复杂。早期 x86 面对的是内存昂贵、代码密度重要、编译器技术尚未成熟、手写汇编常见的时代,所以在 ISA 设计上很多地方都倾向于让单条指令表达更多工作:部分指令默认更新状态标志位,部分操作数形式非常宽松,甚至允许一些本来可以拆成多步的工作尽量塞进一条指令里(甚至有专门的内存复制指令)。这样做确实让代码更紧凑,也让编程和早期编译器实现更方便,但代价是指令语义更难统一,前端解码更复杂,后端优化也更难做。
后来为了解决兼容性和寻址空间扩展问题,x86 又引入了 REX 前缀等扩展机制。结果是指令长度进一步增加,编码格式也更复杂。x86 的变长编码本来有机会在表达密度和可扩展性上取得更大优势,但历史包袱让它不得不持续迁就旧格式,最终形成了一套“能做很多事,但做起来不够干净”的体系。ARM64 则通过更统一的编码和更规则的指令别名,在硬件复杂度、编译器可优化性和程序员可用性之间取得了更现代的平衡。
TIP
也许有同学学组成原理会遇到这么个问题:哪里有段式、段页式内存管理,又哪里有基址和变址的区别?
这其实是16位8086留下的老黄历了,段式内存是因为16位寄存器只能访问64KB的地址空间,只能通过段+寄存器偏移的方式访问全部1MB空间。而基址与变址则是因为16位8086的寄存器并非通用寄存器,只有特定的几个寄存器才能用于访问内存(比如CX就不能用来访存)。现代 x86-64 仍然有 base/index/scale/disp、RIP 相对寻址以及 FS/GS 这类特殊机制,但已经不需要按 8086 那套寄存器限制去背“基址、变址”。
早期的一些C语言实现甚至专门做了 near、far 这种关键字用于表明指针是否是跨段的,但随着平坦地址空间的回归,这些设计也就都留在历史中了。如今主流用户态程序基本不再把段式内存当作主要编程模型,常见寻址也主要围绕通用寄存器+立即数、通用寄存器+通用寄存器、PC+立即数这些形式展开。
看完这一节,我们可以得到四个很直观的对应关系:
- 条件选择代表热模式与硬件的平衡,
csel、cbz、cbnz这类指令把高频分支和常见判零模式收束成更适合硬件执行的路径。 - 别名代表人类易读性与硬件的平衡,
mov、cmp、ret这些表面上更接近程序员习惯的写法,背后对应的是少量更基础的硬件语义。 ret代表性能与硬件的平衡,它保留“返回”这一高层意图,使用额外的指令编码为分支预测器提供更准确的信息。- x86 代表历史与硬件的平衡,它把长期兼容、旧工具链和早期指令习惯都保留了下来,因此硬件前端必须替历史承担额外复杂度。
从语义到性能
状态机视角可以帮助我们理解指令语义,但真实 CPU 并不是一次只处理一条指令的简单解释器。流水线、缓存、分支预测、乱序执行和多核一致性都会影响程序的实际性能。
理解低级指令时,可以分成两个层次:
- 架构语义:一条指令对外保证产生什么结果。例如
add修改目标寄存器,adds还会修改NZCV,bl还会写入x30。 - 微架构实现:CPU 内部如何高效实现这些结果。例如一条指令经过多少流水线阶段、是否命中缓存、是否能与其他指令并行、分支是否预测正确。
前者决定程序是否正确,后者决定程序是否高效。
因此,阅读一段汇编时,可以依次提出几个问题:
- 每条指令显式读取和写入了哪些寄存器或内存?
- 它是否修改了
NZCV、LR/x30、PC等隐式状态? - 它是否可能进入同步异常路径,或者与操作系统、设备和其他 CPU 核心交互?
- 是否存在不必要的访存?
- 是否存在难以预测的分支?
- 指令之间是否有很长的依赖链,导致流水线等待?
沿着这些问题分析,就不需要把汇编看成孤立的助记符列表。无论是 ARM64、x86,还是虚拟机字节码,其核心仍然是一组状态转移规则;不同层次的复杂性,只是在不断扩大我们观察状态的范围。
其他指令系统如何表达类似能力
ARM64 并不是表达这些能力的唯一方式。不同指令系统面对的目标不同:CPU 指令集需要考虑硬件实现与历史兼容,虚拟机字节码更重视可移植性、验证能力或动态语言语义。
ARM64、x86-64、LLVM IR、WASM 和 Python bytecode 的差异,并不是简单的“谁的指令更多”或者“谁的性能更好”。它们服务于不同目标,因此选择了不同的状态表示和指令编码方式:
- ARM64 相较 x86-64 历史负担更轻,它保留了更多有价值的历史,同时尽量避免让这些历史累积成新的包袱;它采用定长指令编码,尽量简化 CPU 前端的取指和解码。
- x86-64 需要兼容长期积累的软件,保留了变长指令编码和复杂的操作数形式,CPU 前端需要承担更复杂的解码工作。
- LLVM IR 面向编译器内部优化,核心单位是过程和 SSA 值,可以近似看成“无限虚拟寄存器”的中间表示。它让优化器、分析器和后端代码生成有了统一的格式,解耦了编译器的前后端。
- WASM 面向网络分发、快速流式编译、紧凑体积和跨平台确定性,采用了基于操作数栈的虚拟机设计。
- Python bytecode 服务于特定版本的 Python 运行时,不需要长期保持二进制兼容,因此可以随着解释器需求频繁调整。
下面仍然围绕 addPositive 的核心逻辑进行对比:
C
int addPositive(int a, int *b) {
if (a < 0) {
return *b;
}
return a + *b;
}示例用于展示有代表性的表达方式。具体编译器、编译参数和运行时版本可能生成不同结果。
x86-64:更灵活的操作数组合
x86 架构需要兼容数十年间积累的软件。现代 x86-64 CPU 仍然需要处理长度不同、编码复杂、操作数形式灵活的指令。这样可以保留向后兼容,也可以让单条指令表达更多工作,但代价是 CPU 前端的取指、边界识别和解码更加复杂。现代 x86-64 CPU 通常会先把复杂指令解码成更规则的内部微操作,再交给后端执行。
x86-64 也可以使用条件选择消除跳转。一种可能的实现如下:
asm
addPositive:
xor eax, eax // eax = 0
test edi, edi // 根据 a 更新状态标志位
cmovns eax, edi // 如果 a >= 0,eax = a
add eax, DWORD PTR [rsi] // eax += *b
ret在常见的 System V AMD64 ABI 中,edi 保存第一个 int 参数,rsi 保存第二个指针参数,eax 保存返回值。
cmovns 与 ARM64 的 csel 思路相似:它会读取状态标志位,在条件成立时进行寄存器移动,而不会改变控制流。不同的是,x86-64 的部分算术指令可以直接使用内存操作数:
asm
add eax, DWORD PTR [rsi]这条指令同时表达了内存读取和整数加法。ARM64 则是典型的 load/store 架构,需要先使用 ldr 把内存内容加载到寄存器,再执行 add。
x86-64 同样可以使用位运算构造掩码,但具体写法取决于可用扩展指令。例如 BMI1 提供了 andn。与 ARM64 的规则化组合相比,x86-64 背负了更长的兼容历史。它给予单条指令更灵活的操作数形式,但也增加了解码和优化工作的复杂度。
WASM:基于栈的可移植字节码
WebAssembly 不是 CPU 直接执行的原生指令集。它是一种面向虚拟机的可移植字节码,通常由浏览器或运行时解释、JIT 编译或者提前编译为本地机器码。
WASM 需要适合网络传输,并且希望运行时可以在下载模块的同时进行验证和编译。它还需要在不同平台上提供相对确定的行为。因此,WASM 采用基于操作数栈的指令设计:指令通常不需要反复编码输入和输出寄存器,字节码更加紧凑;编译器也可以沿着字节流逐步验证和生成目标代码。
下面是 emcc 下的一种编译结果:
wat
(func $addPositive (type 3) (param i32 i32) (result i32)
local.get 1
i32.load
local.get 0
i32.const 0
local.get 0
i32.const 0
i32.gt_s
select
i32.add)WASM 使用操作数栈传递中间结果。i32.gt_s 比较两个有符号整数,并把条件结果压入操作数栈;select 根据条件从两个候选值中选择一个;i32.add 再完成加法。这里使用 a > 0 而不是 a >= 0,但当 a == 0 时两种路径的结果都是 0,因此与原始逻辑等价。
这里的第二个参数,也就是 local.get 1 取出的值是线性内存中的动态地址,它与普通指针类似,但访问受到WASM运行时的严格管控。i32.load 会从操作数栈取出这个动态地址,再加上指令自身携带的静态 offset,读取 WASM 线性内存,并在越界时触发 trap。
与 ARM64 相比,WASM 不急于把 max(a, 0) 压缩成一条接近硬件的数据处理指令。它首先提供规则简单、体积紧凑、易于流式验证和编译、跨平台一致的抽象,再由 JIT 或提前编译器根据目标 CPU 选择 bic、csel、cmov 或其他本地指令。
Python bytecode:保留动态语言语义
Python bytecode 同样由虚拟机执行,但它比 WASM 更接近动态语言运行时。它主要服务于当前版本的 Python 解释器,而不是作为需要长期兼容的二进制分发格式。不同 Python 版本可以增加、删除、合并或者重新解释字节码,以适应解释器当前的优化需求。考虑下面的 Python 代码:
python
def add_positive(a, b):
if a < 0:
return b[0]
return a + b[0]不同 Python 版本会生成不同字节码。下面展示 CPython 3.13 的一种实际反汇编结果。这段输出只代表某个 CPython 3.13 构建和反汇编格式,不应当被当成 Python 语言层面的稳定接口。
text
1 RESUME 0
2 LOAD_FAST 0 (a)
LOAD_CONST 1 (0)
COMPARE_OP 18 (bool(<))
POP_JUMP_IF_FALSE 5 (to L1)
3 LOAD_FAST 1 (b)
LOAD_CONST 1 (0)
BINARY_SUBSCR
RETURN_VALUE
4 L1: LOAD_FAST_LOAD_FAST 1 (a, b)
LOAD_CONST 1 (0)
BINARY_SUBSCR
BINARY_OP 0 (+)
RETURN_VALUE这里的 LOAD_FAST、LOAD_CONST 和 BINARY_OP 操作的是 Python 虚拟机状态,而不是 CPU 通用寄存器。COMPARE_OP、BINARY_SUBSCR 和 BINARY_OP 也不能简单地等同于一条整数比较、内存加载或整数加法:
a可能是任意精度整数或自定义对象。a < 0可能调用对象自定义的比较逻辑。b[0]可能调用自定义下标访问逻辑,也可能抛出IndexError、KeyError或其他异常。a + b[0]可能执行整数加法,也可能调用对象重载的方法。
较新版本中出现的 LOAD_FAST_LOAD_FAST 会一次读取两个局部变量。它说明 Python bytecode 可以根据当前解释器的热点需求增加融合指令,而不需要像原生 CPU 指令集那样长期维护机器码兼容性。
因此,Python 解释器通常不能直接使用 bic 技巧替换这段控制流。在较新的版本中,CPython 的特化机制可以在多次运行后通过额外信息减少通用分派,但由于Python复杂的语义特性,很难做到这种类似C的激进优化。
LLVM IR:面向优化的过程级中间表示
LLVM IR 处于更靠近编译器内部的位置,是编译器在前端和后端之间使用的中间表示。它更接近底层,但又不会特化在某种架构上,而像是一个数学建模。
它的典型特点是:
- 以过程为中心,一段函数对应一段 IR。
- 以 SSA 形式组织数据流,值一旦定义通常不会被原地覆盖。
- 可以近似看作拥有很多“虚拟寄存器”,因此特别适合做全局优化和数据流分析。
- 目标是让优化器更容易做内联、常量传播、死代码删除、循环优化和指令选择,所以会附带大量额外信息。
例如,前面的 addPositive 在LLVM的-O2优化下被编译为了下面的形式:
llvm
define dso_local i32 @addPositive(i32 noundef %0, ptr noundef readonly captures(none) %1) local_unnamed_addr #0 !dbg !13 {
#dbg_value(i32 %0, !20, !DIExpression(), !22)
#dbg_value(ptr %1, !21, !DIExpression(), !22)
%3 = load i32, ptr %1, align 4, !dbg !22
%4 = tail call i32 @llvm.smax.i32(i32 %0, i32 0), !dbg !23
%5 = add nsw i32 %3, %4, !dbg !23
ret i32 %5, !dbg !25
}
declare i32 @llvm.smax.i32(i32, i32) #1
attributes #0 = { mustprogress nofree norecurse nosync nounwind willreturn memory(argmem: read) uwtable "min-legal-vector-width"="0" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="x86-64" "target-features"="+cmov,+cx8,+fxsr,+mmx,+sse,+sse2,+x87" "tune-cpu"="generic" }
attributes #1 = { nocallback nocreateundeforpoison nofree nosync nounwind speculatable willreturn memory(none) }LLVM IR 本身就是面向编译器优化和分析的中间表示,因此会保留函数边界、SSA 值、属性和调试元数据。这里看起来像顺序代码,是因为优化后控制流已经被消除,只剩下一个基本块。可以看到,LLVM 已经识别出了与 0 比较的特殊性,并把它提升为一个机器无关的 smax 操作;add nsw 中的 nsw 表示优化器可以假定有符号溢出不会发生,这来自 C 语言中有符号整数溢出未定义的语义。至于最终用 bic、csel、cmov 还是其他指令实现,则留给目标后端决定。
因此,LLVM IR 可以看作一种非常典型的“为了优化而存在的过程级状态转移模型”:它让编译器先在更抽象的层面看清楚程序,再决定最终要生成什么样的低级指令。
不同层次的共同模型
| 指令系统 | 主要状态 | 典型选择方式 | 设计重点 |
|---|---|---|---|
| ARM64 | 寄存器、内存、NZCV、PC | bic、csel、条件跳转 | 定长编码,规则化组合,简化前端设计 |
| x86-64 | 寄存器、内存、状态标志位、RIP | cmov、位运算、条件跳转 | 变长编码,灵活操作数形式,保持历史兼容 |
| WASM | 操作数栈、局部变量、线性内存 | select、结构化控制流 | 紧凑体积、流式编译、跨平台确定性 |
| Python bytecode | 操作数栈、局部变量、对象与解释器状态 | 条件跳转、运行时分派 | 贴合当前解释器需求,随 Python 版本演进 |
| LLVM IR | SSA 值、基本块、控制流图 | 内联、常量传播、死代码删除、指令选择 | 面向优化和分析,抽象出过程级数据流 |
它们在抽象层级和设计目标上差异很大,但仍然可以用同一种方法理解:每条指令读取什么状态、写入什么状态、是否改变控制流,以及是否可能进入同步异常路径。
不同层次的模型差异
原生 CPU 指令和虚拟机字节码都需要读取数据,但它们面对的问题不同,因此采用了不同的访存模型。
CPU:地址优先,硬件友好
CPU 访存通常以地址为中心:
asm
ldr w0, [x1, x2, lsl #2]CPU 并不知道 x1 指向的是数组、结构体、对象还是栈帧,也不知道 x2 在高级语言中是不是数组下标。它只负责计算地址,检查地址转换和访问权限,再读取对应位置的数据。
这种设计接近硬件:
- 地址可以统一描述栈、堆、全局变量、共享内存和内存映射设备。
- MMU、TLB、缓存和内存控制器都可以围绕地址工作。
- 高级语言中的变量名、字段名和类型信息可以在编译期转换为偏移量,不需要保留到机器码中。
x86-64 和 ARM64 的寻址方式有所差异,但核心仍然是地址优先。ARM64 倾向于先使用 ldr、str 在寄存器和内存之间搬运数据;x86-64 则允许部分算术指令直接使用内存操作数。
虚拟机:符号和语义优先
虚拟机字节码通常更接近语言或中间表示中的概念。例如 Python bytecode 可以使用:
text
LOAD_FAST 0 (a)
LOAD_CONST 1 (0)
BINARY_SUBSCRLOAD_FAST 读取的是当前栈帧中的局部变量槽位,反汇编工具还可以把槽位显示为变量名 a。BINARY_SUBSCR 表达的也不是“读取某个地址”,而是“执行下标访问”这一语言语义。对于列表、字典和自定义对象,它可能走向不同的运行时实现。
WASM 处于两者之间:
wat
local.get 0
i32.loadlocal.get 0 使用局部变量编号,具有虚拟机字节码的符号化特征;i32.load 则会从操作数栈取出动态地址,再加上指令自身携带的静态 offset,访问 WASM 线性内存,保留了接近底层地址访问的模型。这样既便于验证和跨平台执行,也方便 JIT 编译器映射到原生访存指令。
LLVM 则更倾向于类型化和 SSA 化:
llvm
%3 = load i32, ptr %1, align 4, !dbg !22load i32, ptr %1 保留了从 %1 指向的内存中加载一个 i32 的语义,同时显式携带类型、对齐和调试元数据,更符合其机器无关中间表示的特点,这使得其可以支持更多的后端。
因此,可以粗略地说:
- CPU 指令倾向于地址优先,便于硬件直接执行。
- 虚拟机字节码倾向于符号和语义优先,便于解释、验证、优化和保留语言行为。
这不是绝对边界。虚拟机会在内部把符号解析成槽位、对象指针或地址;CPU 也会通过调试信息、运行时元数据等机制重新关联到高级语言概念。但在各自对外暴露的指令模型中,两者的设计重心确实不同。
哈佛架构与冯诺依曼架构
访存模型还涉及另一个经典区别:指令和数据是否使用同一套存储与访问路径。
- 冯诺依曼架构:指令和数据共享统一的存储空间。代码本身也可以被视为数据,因此在权限允许时,程序可以加载、修改或生成机器码。
- 哈佛架构:指令和数据使用分离的存储空间或访问路径。普通数据访问不能直接读取或修改指令。
很多虚拟机在概念上更接近哈佛架构。字节码由解释器或 JIT 编译器读取,虚拟机程序通常不能把普通对象访问当作字节码读取,也不能随意覆盖下一条待执行指令。Python bytecode 与 Python 对象、WASM 指令流与 WASM 线性内存,在抽象模型中都是分离的。
现代通用 CPU 则通常采用一种混合设计。从架构语义看,代码和数据往往处于统一地址空间中,接近冯诺依曼架构;但在微架构实现中,L1 指令缓存(I-cache)和 L1 数据缓存(D-cache)通常分离,前端取指和后端访存可以并行工作。更低层缓存则可能重新统一。这种设计又具有哈佛架构的特征,常被称为改进型哈佛架构。
I-cache 和 D-cache 的分离也带来一个现实问题:程序生成或修改机器码后,新的数据不一定立刻对取指路径可见。在部分架构上,JIT 编译器需要显式清理数据缓存、刷新指令缓存并执行必要的同步指令。也就是说,即使架构层面允许把代码当作数据修改,微架构层面仍然需要维护两条缓存路径的一致性。
理解抽象,也理解抽象泄漏
现代程序建立在层层抽象之上。业务代码依赖语言语义,语言运行时依赖字节码或机器码,机器码依赖 ISA 与 ABI,CPU 又依赖缓存、MMU、流水线和 DRAM。每一层都会向上一层提供更简单的模型,让大多数开发工作不必反复考虑底层细节。
抽象的价值正在于此:遇到问题时,应当优先在当前层检查当前层承诺的语义。
- C 代码发生越界访问时,首先检查指针、生命周期和数组边界,而不是先怀疑 CPU 缓存。
- Python 代码触发异常时,首先检查对象类型和运行时语义,而不是直接阅读机器码。
- 函数调用结果错误时,手写汇编应先检查 ABI、栈布局和寄存器保存规则。
- 性能下降时,先确认算法复杂度、分配次数、I/O 和锁竞争,再逐步进入更低层分析。
抽象与实现并不相同
抽象描述的是对外保证,具体实现描述的是系统如何兑现保证。
例如:
- 从 ISA 语义看,
ldr只是读取指定地址的数据;从具体实现看,它可能命中 L1 缓存,也可能经历 TLB miss、页表遍历、缓存缺失,最终等待 DRAM。极端情况下,延迟还会受到 DRAM 刷新等硬件行为影响。 - 从字节码语义看,一条普通的对象分配指令只是申请对象;从运行时实现看,它可能消耗当前分配区,也可能触发 GC,进而扫描对象、移动内存或暂停部分线程。
- 从源代码调试体验看,程序似乎逐行执行;从编译器实现看,优化会内联函数、删除变量、移动指令、合并基本块。调试信息只能尽量把机器码或字节码映射回源代码,无法保证每条低级指令都完美对应某一行 C 代码。
区分抽象与实现,可以避免两种相反的误区:
- 把实现细节当作永远成立的接口,写出依赖偶然行为的代码。
- 把抽象模型当作底层现实的完整描述,在性能问题或边界问题出现时无从下手。
什么时候考虑抽象泄漏
抽象泄漏是指底层实现细节开始显著影响上层行为。它并意味着当前问题已经接近抽象边界。
通常在以下场景中,需要主动向下观察:
- 性能与尾延迟:平均路径看起来相同,但少量请求明显变慢。此时可能需要检查缓存 miss、TLB miss、缺页、NUMA、DRAM 行为、GC 暂停或调度抖动。
- 并发与内存可见性:单线程逻辑正确,但多核环境中偶发失败。此时需要考虑原子操作、内存序、编译器重排和 CPU 重排。
- FFI、系统调用与手写汇编:代码跨越 ABI、权限级别或语言运行时边界。此时必须确认参数布局、寄存器保存、栈对齐和对象生命周期。
- JIT、自修改代码与设备交互:普通内存访问模型已经不足以描述行为。此时可能需要处理 I-cache/D-cache 一致性、内存屏障、DMA 或 MMIO。
- 优化后的调试:源代码、字节码和机器码无法逐行对应。此时需要接受调试信息是近似映射,并结合反汇编、调用栈和运行时元数据判断实际执行路径。
谨慎使用依赖实现的技巧
有些底层技巧确实有价值,例如针对缓存行布局数据、利用特定指令消除热点分支、读取运行时内部结构,或者使用未公开接口绕开额外开销。但这类技巧必须明确标注适用边界。
在使用依赖实现的技巧前,至少应当问几个问题:
- 依赖的是语言规范、ABI、ISA 手册,还是某个版本中的偶然实现?
- 更换编译器版本、解释器版本、操作系统或 CPU 架构后,行为是否仍然成立?
- 失败时是性能退化,还是会产生错误结果、数据损坏或安全问题?
- 是否有测试、断言、版本检查或回退路径保护这个假设?
面向专用硬件的指令系统
通用 CPU 需要运行操作系统、数据库、编译器和浏览器,因此指令系统必须覆盖大量不同场景。但如果硬件只服务于某一类计算,就可以围绕高频路径进行更激进的特化。
GPU、AI 加速器和量子计算机看起来与 ARM64 相距很远,但仍然可以放入广义状态转移模型中:
text
新状态 = 状态转移规则(旧状态, 输入)区别在于,它们选择了不同的状态表示、基本操作和并行粒度。
SIMD:一次处理多个数据
通用 CPU 本身也提供 SIMD(Single Instruction, Multiple Data)指令。SIMD 会让一条指令同时处理向量中的多个元素。
例如,普通加法每次只处理一个整数:
text
c0 = a0 + b0向量加法则可以一次处理多个整数:
text
[c0, c1, c2, c3] = [a0, a1, a2, a3] + [b0, b1, b2, b3]在状态机视角下,这并没有改变计算的本质。只是单条指令读取和写入的状态从一个标量寄存器变成了一个向量寄存器。
SIMD 适合图像处理、音视频编解码、科学计算、加密和机器学习等场景。这些任务往往需要对大量数据执行相同操作。硬件可以复用取指和解码结果,把更多晶体管投入并行算术单元,提高单位能耗下的吞吐量。
GPU:大量线程与掩码执行
GPU 最初主要服务于图形渲染。屏幕上的大量像素、顶点和纹理数据通常需要执行相似计算,因此 GPU 会放置大量相对简单的算术单元,并提高并行吞吐量。
GPU 的编程模型常被称为 SIMT(Single Instruction, Multiple Threads)。程序员看到的是许多线程,每个线程拥有自己的寄存器和线程编号;硬件则会把一组线程组织起来,让它们在一段时间内执行相同指令。NVIDIA 通常把这样的线程组称为 warp,AMD 常称为 wavefront。
当线程走向不同分支时,GPU 常使用掩码控制哪些线程槽位在当前阶段生效:
text
active_mask = [1, 1, 0, 0]只有掩码为 1 的线程提交计算结果。之后硬件再切换掩码,执行另一条分支路径。这种方式避免了为每个线程配置完整而昂贵的独立控制流硬件,但如果同组线程频繁走向不同分支,就会出现分支发散,吞吐量下降。
GPU 的访存也围绕并行吞吐设计。相邻线程如果访问相邻地址,硬件可以把多次访存合并为较少的内存事务。反之,离散访问会浪费带宽。GPU 还常提供共享内存、纹理缓存等针对特定访问模式优化的存储层级。
因此,GPU 指令语义可以理解为:对一组线程状态进行批量转换,并由执行掩码决定哪些状态更新真正生效。
AI 加速器:矩阵计算与算子融合
神经网络训练和推理中,大量时间消耗在矩阵乘法、卷积、归一化和激活函数等操作上。AI 加速器会围绕这些热点设计专用数据通路,例如矩阵乘加阵列、tensor core、脉动阵列(systolic array)以及高带宽片上存储。
通用 CPU 可能需要用许多标量或 SIMD 指令完成矩阵乘法,而 AI 加速器可以提供更粗粒度的操作:
text
C = A * B + C硬件一次读取一块矩阵数据,在阵列中重复进行乘加,并尽量让中间结果留在寄存器或片上缓冲区中。这样可以减少昂贵的外部内存访问。
很多 AI 加速器还支持算子融合。例如:
text
y = relu(A * B + bias)如果拆成多个算子,每一步都可能把中间结果写回内存,再由下一步重新读取。融合后,中间结果可以直接沿着片上数据通路流向下一阶段。它减少的不只是指令数量,更重要的是访存和数据搬运。
AI 加速器的“指令”不一定像 CPU 指令那样细粒度。有些设备暴露矩阵乘加等硬件指令,有些设备通过命令队列提交张量算子或计算图,再由驱动、编译器和固件拆解执行。
在状态机视角下,AI 算子仍然是状态转移规则,只是它一次读取和写入的状态从几个寄存器扩展成了张量、片上缓冲区和命令队列。
量子计算机:量子门与测量
量子计算机使用量子比特(qubit)描述状态。经典比特只能处于 0 或 1,而量子比特可以处于叠加态。多个量子比特还可能形成纠缠状态,因此系统状态不能简单理解为一组彼此独立的布尔变量。
量子程序通常通过量子门推进状态,例如:
X门:类似经典逻辑中的取反。H门:把量子比特转换到叠加态。CNOT门:对两个量子比特执行受控操作,也可用于构造纠缠。
量子门可以视为对量子状态进行变换的规则。与经典计算不同,量子程序还需要执行测量。测量会把量子状态转换为经典结果,并把随机性引入状态机:单次执行结果不确定,但大量重复实验会呈现稳定的概率分布。
很多传统计算机上时间复杂度很高的操作,在量子计算机上之所以值得暴露为量子门或量子操作原语,是因为硬件本身提供了叠加、干涉和测量这些不同于经典电路的基本机制。Shor 算法就是典型例子:它借助量子门、量子傅里叶变换和测量去寻找周期结构,以一种与经典算法完全不同的方式实现了大质因数分解。
因此,量子指令也可以归入广义状态转移模型,但这里的状态不再只是寄存器和内存,状态转移也不再只产生确定性的经典结果。测量本身也是一种会改变后续状态分布的环境交互。
广义状态转移语义
| 指令系统 | 主要状态 | 特化方式 | 主要目标 |
|---|---|---|---|
| 通用 CPU | 标量寄存器、向量寄存器、内存、控制流状态 | SIMD、分支预测、缓存、乱序执行 | 低延迟与通用性 |
| GPU | 大量线程寄存器、执行掩码、显存与共享内存 | SIMT、掩码执行、访存合并 | 高并行吞吐 |
| AI 加速器 | 张量、片上缓冲区、命令队列 | 矩阵乘加阵列、算子融合、低精度计算 | 提高张量计算吞吐并减少数据搬运 |
| 量子计算机 | 量子比特、量子态、经典测量结果 | 量子门、纠缠、测量、经典反馈 | 操作量子态并利用特定量子算法 |
从 ARM64 的 add 到 GPU 的掩码执行,从张量算子融合到量子测量,指令系统始终在回答同一类问题:系统当前有哪些状态,硬件擅长进行哪些状态转移,哪些高频路径值得用更专门的规则表达。