目标读者是会 C、懂二进制和十六进制、知道指针和栈是什么的人。读完这篇,你应该能看懂 x64 汇编在做什么,并在需要时自己写上几行。

只讲核心机制和按需取用的进阶主题,不列指令全集。语法使用 GAS(GNU Assembler)的 Intel 语法,即 .intel_syntax noprefix 风格:目标操作数在前,源操作数在后,寄存器不带 % 前缀。文中的代码是概念片段,用来说明机制,不保证能直接编译运行;入口点、链接参数这类工程问题不在讨论范围。

从 C 到汇编

先看一段谁都写得出来的 C 代码:一个固定答案的猜数字循环。

int secret = 42;
int guess = 17;
 
while (guess != secret) {
    if (guess < secret) {
        guess += 10;
    } else {
        guess -= 5;
    }
}

对应的汇编(逻辑等价,不关心具体放在哪个文件段里):

    mov rax, 42         ; secret
    mov rbx, 17         ; guess
 
guess_loop:
    cmp rbx, rax        ; 比较 guess 和 secret
    je done             ; 相等则结束
    jl too_small        ; guess < secret
    sub rbx, 5          ; 否则 guess -= 5
    jmp guess_loop
too_small:
    add rbx, 10         ; guess += 10
    jmp guess_loop
done:

注意三件事。

第一,汇编里没有 while 和 if。只有比较(cmp)、条件跳转(je、jl)和无条件跳转(jmp)。高级语言的控制流结构,在汇编层面全部塌缩成”跳到哪一行去”。

第二,变量不存在了。secret 和 guess 只是恰好住在 rax 和 rbx 两个寄存器里的值,没有类型,没有作用域,你弄丢它没人负责。

第三,标签(guess_loop: 这类)就是跳转的目的地。函数、循环、分支,最后都靠它。

整篇文章剩下的内容,就是把这三件事背后的机制逐一拆开。

汇编代码的基本构成

一份 GAS 汇编源文件大致长这样:

.intel_syntax noprefix
.global _start
 
.data
msg:    .asciz "hello"      # 一个字符串
num:    .quad 42            # 一个 8 字节整数
 
.text
_start:
    mov rax, 1
    lea rsi, [rip + msg]

几个构成要素:

  • .text 段放指令,.data 段放数据。CPU 执行的是 .text 里的字节序列。
  • 标签(msg:、_start:)是符号,汇编器会把它们解析成地址。msg 就是那个字符串的地址。
  • .asciz、.quad 是汇编器指令(directive),负责在数据段里铺字节。它们不是 CPU 指令,只是告诉汇编器”在这里放点什么”。
  • 立即数就是字面量,如 1、42。
  • 操作数有三种来源:立即数、寄存器、内存。

看懂这个骨架,后面所有片段里出现的标签和数据定义就都不是黑魔法了。

寄存器与数据宽度

x64 的通用寄存器有 16 个:rax、rbx、rcx、rdx、rsi、rdi、rbp、rsp、r8 到 r15。它们大多是通用的,但有约定俗成的分工,例如 rax 常用来放返回值,rsp 是栈指针。

每个 64 位寄存器都能按不同宽度访问:

  • rax:完整 8 字节
  • eax:低 4 字节
  • ax:低 2 字节
  • al:低 1 字节(ah 是 ax 的高 1 字节)

这里有一个必须记住的规则:写入 32 位形式(如 eax)会把高 32 位清零。mov eax, 5 之后,rax 整体是 5,而不是”低 32 位是 5、高 32 位保持原样”。但写 ax 或 al 不会清零高位。这个不对称是真实代码里 bug 的高发区。

两个特殊的:

  • rip:存放下一条要执行的指令地址。不能直接写它,想改程序走向就用跳转。
  • rsp:栈指针,指向栈顶。可以改,但改错了栈就毁了。

数据传送与内存访问

mov 是赋值:

mov rax, 5          # rax = 5
mov rbx, rax        # rbx = rax

中括号表示内存访问:

mov rax, [num]      # 把 num 地址处的值读进 rax

读多少字节由目标寄存器决定。rax 是 8 字节,就从 num 开始读 8 字节;eax 读 4 字节。

最容易混的是 lea:

mov rax, [num]      # rax = *num,取内存里的值
lea rax, [num]      # rax = &num,取地址本身

mov [x] 是解引用,lea [x] 是取地址。对应到 C 就是 * 和 & 的区别。C 程序员看到 lea 常被拿去算地址加法而不实际访问内存,比如 lea rax, [rbx + 8] 就是 rax = rbx + 8,一次寻址计算白嫖一次加法。

基本运算与位运算

算术指令没什么好说的:

add rbx, rax        # rbx += rax
sub rbx, rax        # rbx -= rax
inc rbx             # rbx++
dec rbx             # rbx--

位运算同样直白,但有几个高频套路值得单独记住:

xor rax, rax        # 清零,比 mov rax, 0 更短更快
and rax, 0xff       # 掩码:只留低 8 位
or  rax, 0x80       # 置位:把 bit 7 设为 1

移位:

shl rax, 3          # 左移 3 位,等价于乘 8
shr rax, 1          # 无符号右移 1 位,等价于除以 2
sar rax, 1          # 有符号右移,保留符号位

左移是乘 2 的幂,右移是除 2 的幂——但有符号数右移要用 sar,shr 会把符号位也当普通位移掉。

标志位与条件控制流

算术和比较指令会悄悄修改一个隐藏的寄存器:rflags。你只需要知道其中四个标志位:

  • ZF(Zero Flag):结果为零时置 1
  • SF(Sign Flag):结果为负(最高位为 1)时置 1
  • CF(Carry Flag):无符号运算溢出时置 1
  • OF(Overflow Flag):有符号运算溢出时置 1

两个”比较”指令都不产出结果,只设置标志位:

cmp rax, 5          # 计算 rax - 5,丢弃结果,只留标志位
test rax, rax       # 计算 rax & rax,用来判断 rax 是否为 0

test a, a 是”判断 a 是否为零”的惯用法:结果为零则 ZF 置 1。

条件跳转读取标志位决定跳不跳。通用的:

  • je / jz:相等(或结果为零)时跳
  • jne / jnz:不等(或非零)时跳

有符号比较:

  • jl:小于跳
  • jle:小于等于跳
  • jg:大于跳
  • jge:大于等于跳

无符号比较:

  • jb:低于跳(below)
  • ja:高于跳(above)

同一个值,用有符号还是无符号跳转,结果可能完全不同。 cmp rax, rbx 之后,CPU 不知道你心里想的是 int64_t 还是 uint64_t,它只摆好所有标志位,选哪组跳转指令是你的事。这就是汇编里”类型”的真相:类型不在数据上,在你选的指令上。

回到开头的猜数字:cmp rbx, rax 设好标志位,je 看 ZF,jl 看 SF 和 OF 的组合,循环就靠 jmp 跳回标签。while 和 if 的全部秘密就这么多。

栈与函数调用

栈是一块内存,rsp 指向栈顶。x64 的栈向低地址增长:压栈时 rsp 减小,弹栈时增大。早压入的在高地址,晚压入的在低地址。

push rax            # rsp -= 8; [rsp] = rax
pop rax             # rax = [rsp]; rsp += 8

call 和 ret 是函数调用的全部机制:

call my_func        # 把下一条指令的地址压栈,然后跳到 my_func
my_func:
    ...
    ret             # 弹出栈顶地址,跳回去

call 压返回地址,ret 弹返回地址,仅此而已。汇编里没有”函数”这个概念——没有参数列表,没有局部变量声明,没有返回值类型。函数只是一种代码复用的约定:大家说好参数放哪、返回值放哪、谁负责清理栈,然后各自遵守。

传参完全靠约定。为了演示,我们人为约定:参数放 rdi 和 rsi,返回值放 rax:

    mov rdi, 3
    mov rsi, 4
    call add_two        # rax 现在是 7
 
add_two:
    mov rax, rdi
    add rax, rsi
    ret

如果函数内部要调用别的函数,而它自己还要用到的寄存器可能被别人踩掉,就先 push 保存、用完 pop 恢复:

my_func:
    push rbx            # 保护现场
    ...
    call something_else # 天知道它会不会改 rbx
    ...
    pop rbx             # 恢复现场
    ret

“谁保存哪些寄存器、参数按什么顺序放、栈怎么对齐”,这套完整约定叫调用约定(ABI)。它是平台级的合同,本文把它放到进阶部分——在只和自己写的汇编打交道时,人为约定就够了。

Linux 系统调用

程序想做事(输出、读写文件、退出)就得找内核。Linux x64 的入口是 syscall 指令:

  • rax 放系统调用号
  • 参数依次放 rdi、rsi、rdx、r10、r8、r9
  • 返回值在 rax

比如退出:

mov rax, 60         # exit
mov rdi, 0          # 退出码
syscall

写字符串到标准输出:

mov rax, 1          # write
mov rdi, 1          # fd = stdout
lea rsi, [msg]      # 缓冲区地址
mov rdx, 5          # 长度
syscall

注意一个细节:系统调用的第 4 个参数用 r10,而普通函数调用约定用 rcx。这是两套不同的约定,syscall 指令本身会踩掉 rcx。混用是经典错误。另外和函数调用一样,调用之后别指望寄存器还保持原值。

进阶:复杂运算

乘除法和加减法不一样,它们被硬编码使用 rax(和 rdx),用起来束手束脚。

mul 是无符号乘法,单操作数:

mov rax, 3
mov rbx, 4
mul rbx             # rdx:rax = rax * rbx

结果是 128 位:rax 放低 64 位,rdx 放高 64 位。结果装得进 rax 时 rdx 为零。

imul 是有符号乘法,形式更灵活,可以双操作数甚至三操作数:

imul rax, rbx       # rax *= rbx
imul rax, rbx, 10   # rax = rbx * 10

div 是无符号除法,被除数是 rdx:rax 拼起来的 128 位数:

mov rdx, 0          # 高位清零,忘了这步就是未定义行为
mov rax, 7
mov rbx, 2
div rbx             # rax = 商(3),rdx = 余数(1)

商进 rax,余数进 rdx。除以零直接触发硬件异常。

有符号除法 idiv 有个坑:rdx 必须是 rax 的符号扩展,不能简单清零。cqo 指令专门干这件事——把 rax 的符号位铺满 rdx:

mov rax, -7
mov rbx, 2
cqo                 # rdx = rax 的符号扩展
idiv rbx            # rax = -3,rdx = -1

进阶:调用约定

当你的汇编要和 C 代码互调,或者要调用系统库时,人为约定就不够用了,得遵守平台 ABI。

System V ABI(Linux、macOS):

  • 参数依次放 rdi、rsi、rdx、rcx、r8、r9,多余的压栈
  • 返回值在 rax
  • rbx、rbp、r12–r15 是 callee-saved:函数要用它们就得先保存、返回前恢复;其余寄存器 caller 自己看着办
  • call 之前 rsp 要 16 字节对齐

Windows x64 ABI:

  • 参数依次放 rcx、rdx、r8、r9,多余的压栈
  • 返回值在 rax
  • 调用者必须为被调函数预留 32 字节的 shadow space(阴影空间),哪怕参数只用了寄存器

两个 ABI 的参数寄存器顺序不同,跨平台写汇编时第一件事就是确认自己在哪套约定下。还要注意 System V 函数调用的第 4 个参数是 rcx,而 syscall 的第 4 个参数是 r10——三种约定三套顺序,极易记混,动手时务必对着手册核对。

进阶:复杂寻址

x64 的内存操作数支持一个强大的寻址公式,俗称 SIB:

地址 = 基址 + 变址 × 缩放倍数 + 偏移

这一个公式同时覆盖了数组和结构体:

mov eax, [rbx + rcx*4]      # 数组:rbx 是首地址,rcx 是下标,4 是 sizeof(int)
mov eax, [rbx + rcx*4 + 8]  # 结构体数组:再加 8 字节的字段偏移
  • 基址:数组首地址或结构体起始地址
  • 变址:数组下标
  • 缩放倍数:元素大小,只能是 1、2、4、8
  • 偏移:常量位移,比如结构体字段的位置

C 里的 arr[i] 和 s->field,编译之后基本就是这个公式的实例。看懂它,反汇编里一大半内存访问就都读懂了。

进阶:PIC 与 Windows Shellcode

位置无关代码(PIC) 要解决的问题是:代码不知道自己会被加载到哪个地址。比如 Shellcode 被注入进某个进程,落点是随机的,代码里没法写死任何绝对地址。

经典技巧是利用 call 压返回地址的特性偷出当前位置:

    call next
next:
    pop rbx             # rbx = 当前地址

call next 把 next 的地址(也就是下一条指令的地址)压栈,紧接着 pop 把它取进 rbx。有了当前地址,后续所有数据访问都用 rbx + 偏移 的相对方式计算,代码就能在任意地址运行。(x64 也直接支持 [rip + 偏移] 寻址,这是更干净的做法,但 call/pop 这个把戏值得知道,它在老代码和 Shellcode 里随处可见。)

Windows Shellcode 的栈对齐 是一个具体的 ABI 应用。规则有两条:调用 Windows API 需要 32 字节 shadow space;call 执行时 rsp 必须 16 字节对齐。问题在于 call 会压入 8 字节返回地址——如果你在进入 Shellcode 时栈是对齐的,那么你自己 call 一个 API 之前,rsp 因为上一层 call 压了 8 字节而偏离对齐。所以常见写法是:

sub rsp, 0x28       # 32 字节 shadow space + 8 字节对齐补偿
call SomeApi
add rsp, 0x28       # 用完归还

0x20 + 0x8 = 0x28,多出来的 8 字节就是为了凑齐 16 字节对齐。漏掉它,很多 API 会莫名其妙地崩——崩在对齐检查上,而不是你的逻辑上。

本文只覆盖写 Shellcode 需要的这三块基石:拿到当前地址、遵守 ABI、对齐栈。至于遍历 PEB 找模块、解析导出表找函数、注入流程,那是另一个故事。

进阶:GCC 内联汇编

最后澄清一个名词。本文通篇讲的是独立汇编文件,由 GAS 处理。还有另一种东西叫 GCC 内联汇编,写在 C 代码里:

int a = 10, b = 20, result;
__asm__ (
    "add %1, %2\n\t"
    "mov %2, %0"
    : "=r"(result)
    : "r"(a), "r"(b)
);

内联汇编的重点不是指令本身,而是约束(constraint):用 "=r"、"r" 这类声明告诉编译器”这个操作数放哪个寄存器、是输入还是输出”,让 C 变量和汇编指令能接上头。适合的场景是:只需要几条指令(读特殊寄存器、用特定 CPU 特性),不想为这点事维护一整个汇编文件。

它在外观上很像”在 C 里写汇编”,但心智模型完全不同:独立汇编是你掌控一切,内联汇编是你和编译器协商——编译器负责分配寄存器和搬运数据,你只提供指令片段。把它当成”带汇编语句的 C”会少踩很多坑。

收个尾

读到这里,你拥有的不是完整的 x64 知识,而是一张够用的地图。

核心部分给了你读代码的能力:寄存器和数据宽度、mov 与 lea、标志位和条件跳转、栈和 call/ret、系统调用入口。拿着它去看反汇编,控制流不再神秘。

进阶部分按需取用:算术密集去看乘除法,与 C 互调去对 ABI,读数组和结构体的反汇编去看 SIB,写 Shellcode 去看 PIC 和栈对齐。

剩下的——没讲到的指令、抠不清的细节——交给 Intel 手册。它才是完整的参考;这篇的目的,是让你翻得动它。