Skip to content

学习C语言后,一个进阶的方向就是学习汇编。学习汇编对逆向分析、程序调试及系统编程都有帮助,然而很多人对汇编望而却步,认为其指令繁多、功能复杂,把汇编学习看做背诵指令表,导致无法真正理解其设计。本文将会以ARM64汇编为主线,并通过对照x86汇编、解释型语言字节码等帮助读者理解计算机的核心工作原理。

程序的本质是状态机

在外部看,一个程序运行时,是在执行代码。但回到数学抽象上说,它是一个状态机在不断推进。

从3n+1猜想认识状态机

状态机包括两个部分:可变的状态和固定的状态转移规则。为了不这么抽象,我们来介绍一个非常简单的状态机:基于考拉兹猜想的状态机。

考拉兹猜想的状态机非常简单,我们给定任意一个正整数。

  1. 如果这个数字是偶数,将其除以2
  2. 如果是这个数字是奇数,将其乘以3再加1
  3. 如果这个数字是1,停机

在这里,上面的三条规则就是状态转移规则,而这里给定的数字就是初始状态。按照这个规则操作数字就是推进状态机,推进会改变状态,但不会改变转移规则,随着推进,状态机最终会停机或陷入循环。

例如 10 -> 5 -> 16 -> 8 -> 4 -> 2 -> 1 -> 停机。

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 改变内存状态,ADD 改变寄存器状态,B.NE 改变控制流状态。CPU 当前拥有的寄存器、内存、状态标志位、PC 等共同构成了可变状态;而指令集手册描述的每条指令语义,则构成了固定的状态转移规则。

对于真实 CPU 来说,这些规则最终落实在硬件电路中;对于 JVM、WASM、Python bytecode 这样的虚拟机或解释器来说,这些规则则落实在解释器、JIT 或运行时系统的实现中。它们的形式不同,但抽象上都可以看成“读取一条指令,根据规则更新状态,然后进入下一步”。

[!information]

b.vs 并不知道刚才执行的是加法、减法还是比较;它只关心当前状态中的 V 标志位是否为 1。也就是说,条件跳转依赖的不是“语义记忆”,而是 CPU 状态机中已经存在的状态。

汇编的设计思想

CPU 或虚拟机提供了一套固定的状态转移规则,并通过指令集手册告诉程序员:每条指令会读取什么状态、写入什么状态、可能改变哪些隐含状态,以及在什么条件下进入异常或跳转。

而程序本身,则提供了状态机运行所需的一部分初始状态:代码、数据、符号、重定位信息等。操作系统、加载器、ABI 和运行时环境会进一步补全这个初始状态,例如建立进程地址空间、设置栈、初始化寄存器、跳转到入口函数。

机器码是 CPU 真正执行的二进制编码;汇编则是机器码的人类可读形式。一套机器码编码、寄存器规则、寻址方式、异常行为、内存模型和调用约定相关约束,共同构成了我们通常所说的指令集架构,也就是 ISA。

汇编的设计受到多方制约。

  • 硬件能力:硬件由逻辑门、寄存器、总线、缓存、执行单元等组成。某些操作容易做得很快,例如整数加法、位运算;某些操作成本更高,例如除法、复杂寻址、跨核同步。而一些高成本的复合操作,也可以通过指令设计降低其成本,例如条件移动。
  • 实际需求:程序需要计算、访存、跳转、函数调用、系统调用、原子操作、异常处理等能力,指令集必须为这些需求提供表达方式。
  • 历史限制:已有软件、操作系统、ABI、编译器和旧处理器兼容性会反过来约束新指令集的演进。x86 尤其明显,而 ARM64、RISC-V 等较新的设计则在一定程度上减少了历史包袱。而像Python bytecode这样和版本耦合极强的字节码,则可以在每次版本迭代时大刀阔斧地优化改进。

程序中状态的分布

数学上,一个状态可以看作一个巨大的有序集合。对于真实程序来说,这个集合非常复杂:寄存器、内存、栈、堆、文件描述符、线程状态、异常状态、操作系统资源等,都可能影响程序之后的执行结果。

为了便于学习汇编,本文先把程序直接操作的状态粗略分成两类:

  1. 运算器状态:包括通用寄存器、浮点/SIMD 寄存器、状态标志位,以及虚拟机中的操作数栈、局部变量表等。
  2. 内存状态:包括普通内存、栈、堆、全局变量、映射文件、共享内存等。

在真实系统中,状态远不止这两类。程序还会通过系统调用、异常、中断、内存屏障、GC 等机制与更大的运行环境交互。

四种基本属性

为了分析指令的作用,我们可以把指令的能力分成四种基本属性。

  • 计算:主要在运算器状态之间进行转换,通常不显式访问普通内存。例如整数加法、位运算、比较、寄存器移动等。
  • 访存:在运算器状态和内存状态之间搬运数据,或者直接读取、修改内存状态。例如加载、存储、入栈、出栈等。
  • 控制流:改变程序计数器,使程序不再简单地顺序执行。例如条件跳转、无条件跳转、函数调用、函数返回等。
  • 环境能力:与当前程序抽象之外的运行环境交互,或者影响多核、异常、权限、运行时系统等更大的状态。例如系统调用、异常、中断、内存屏障、原子操作、GC safepoint 等。

IMPORTANT

这四种属性不是互斥分类,而是观察指令作用的四个角度。一条指令可能同时具有多种属性。

C
int addPositive(int a, int *b) {
	if (a < 0) {
		return *b;
	}
	return a + *b;
}

将这段代码不优化编译后,会产生如下汇编。

NOTE

不同编译器、不同版本、不同编译参数产生的汇编不尽相同。这里展示的只是一种可能结果。由于没有开启优化,编译器会把参数先保存到栈上,再从栈中读回,因此代码看起来比源代码冗长。

addPositive:                    // 汇编标签,表示函数入口地址

        sub     sp, sp, #16     // 计算:sp = sp - 16
                                // sp 是 ARM64 的栈指针。
                                // 这里为当前函数分配 16 字节栈空间
        str     w0, [sp, 12]    // 访存:*(int *)(sp + 12) = w0
                                // w0 保存第一个 int 参数 a,这里把 a 保存到栈上
        str     x1, [sp]        // 访存:*(int **)(sp) = x1
                                // x1 保存第二个参数 b,这里把指针 b 保存到栈上
        ldr     w0, [sp, 12]    // 访存:w0 = *(int *)(sp + 12)
                                // 从栈上重新读取参数 a
        cmp     w0, 0           // 计算:比较 w0 和 0
                                // cmp 不保存普通计算结果,而是隐式更新 NZCV 标志位
        bge     .L2             // 控制流:如果 a >= 0,跳转到 .L2
                                // bge 读取上一条 cmp 设置的标志位,决定是否修改 PC
        ldr     x0, [sp]        // 访存:x0 = *(int **)(sp)
                                // 从栈上读取指针 b
        ldr     w0, [x0]        // 访存:w0 = *(int *)x0
                                // 读取 *b。w0 也是 int 返回值寄存器
        b       .L3             // 控制流:无条件跳转到 .L3
                                // 跳过下面的 a + *b 分支
.L2:
        ldr     x0, [sp]        // 访存:x0 = *(int **)(sp)
                                // 从栈上读取指针 b
        ldr     w1, [x0]        // 访存:w1 = *(int *)x0
                                // 读取 *b,保存到 w1
        ldr     w0, [sp, 12]    // 访存:w0 = *(int *)(sp + 12)
                                // 从栈上读取参数 a
        add     w0, w1, w0      // 计算:w0 = w1 + w0
                                // 也就是 w0 = *b + a。结果放入 w0,作为返回值
.L3:
        add     sp, sp, 16      // 计算:sp = sp + 16
                                // 释放当前函数的 16 字节栈空间,恢复栈指针
        ret                     // 控制流:返回调用者
                                // ret 会跳转到链接寄存器 LR/x30 保存的返回地址

这段代码虽然只对应一个很简单的 C 函数,但已经包含了三类基本属性:

  • 计算:例如 sub sp, sp, #16cmp w0, 0add w0, w1, w0
  • 访存:例如 str w0, [sp, 12]ldr w0, [sp, 12]ldr w0, [x0]
  • 控制流:例如 bge .L2b .L3ret

其中最值得注意的是 cmpbge 的配合。cmp w0, 0 本身并不跳转,它只是更新 CPU 中的状态标志位;bge .L2 本身也不重新比较 w00,它只是读取前一条比较指令留下的标志位,并根据这些隐式状态决定是否改变 PC

这正体现了状态机视角下的汇编理解方式:一条指令执行后,不只是产生表面上的结果,还可能改变寄存器、内存、标志位、栈指针、程序计数器等状态,而后一条指令又会基于这些状态继续推进。

最后更新: