目标读者是会 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):结果为零时置 1SF(Sign Flag):结果为负(最高位为 1)时置 1CF(Carry Flag):无符号运算溢出时置 1OF(Overflow Flag):有符号运算溢出时置 1
两个”比较”指令都不产出结果,只设置标志位:
cmp rax, 5 # 计算 rax - 5,丢弃结果,只留标志位
test rax, rax # 计算 rax & rax,用来判断 rax 是否为 0test 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 += 8call 和 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 * 10div 是无符号除法,被除数是 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 手册。它才是完整的参考;这篇的目的,是让你翻得动它。