学习汇编之前必须掌握的硬件知识
摘要
离开高级编程语言的抽象世界之后,你打算从汇编开始。
在汇编里,你需要直接操作寄存器和内存。不仅如此,还要面对栈对齐、System V、调用栈等等问题。
先修知识至关重要,否则你会错过真正有价值的思想。
最不重要的东西恰恰是语言本身
如果你打算从 Pwn 题目的逆向工程入手,这里有一份为你这样的初学者准备的指南
寄存器
最直观的概念是寄存器。
寄存器是 CPU 内部速度最快、体积最小的存储单元,用于保存内存地址、数据和指令。
根据体系结构的不同,寄存器可以按功能分成几类。
通用寄存器
开发者用这些寄存器来保存返回值、临时数据、函数参数或算术运算的操作数。
通用寄存器是可以由你直接操作的。
比如这些指令:
push rbp
mov rbp, rsp
pop rdi
你可以轻松修改这些寄存器中保存的值。
控制寄存器
x86 架构中的控制寄存器决定处理器的全局运行模式,并控制系统级特性,例如虚拟内存管理(分页)、处理器保护环、缓存策略和任务切换。
PC
最基本的控制寄存器是 PC(程序计数器)。它的名字有历史原因。了解更多:Wiki
RIP 是 x86_64 中实现的寄存器,它保存当前指令的地址。
另外,不同体系结构中 PC 寄存器的名称也不一样。
ARM: R15
RISC-V: PC
PowerPC: IAR/NIA
CR0-CR8 (x86)
CR0(系统状态与模式切换): 控制 CPU 的基础运行模式和内存策略。它管理从实模式到保护模式的转换(通过 PE 位),启用分页(通过 PG 位),并强制写保护(WP),防止 Ring 0 修改只读页。
CR3(页目录基址寄存器 - PDBR): 保存当前进程根页表的物理地址。它是内存管理单元(MMU)进行地址翻译时的锚点;向 CR3 装入新值,就相当于在进程上下文切换时切换了虚拟内存环境。
CR2(页错误线性地址): 作为一个诊断寄存器,它会自动保存触发页错误(#PF)的那个确切的线性(虚拟)地址。内核的异常处理器读取该寄存器,以确定需要处理或换入哪个内存地址。
CR4(架构扩展与安全): 用于开关高级硬件能力和关键的内核级安全缓解措施。包括启用物理地址扩展(PAE),以及激活 SMEP(管理器模式执行保护)和 SMAP(管理器模式访问保护)等硬件防御,以阻止本地提权漏洞利用。
CR8(任务优先级寄存器): 仅在 64 位长模式下可用,通过本地 APIC 管理中断优先级,允许内核将低于特定优先级阈值的外部中断屏蔽掉。
寄存器堆
在计算机体系结构中,**寄存器堆(Register File)**是直接构建在 CPU 处理器核心内部的一小块超高速存储单元阵列。它保存执行单元(如 ALU)在每个时钟周期内读写所需的操作数和结果。
实际上,寄存器堆实现了寄存器,当你用汇编操作 rbp 或 rax 时,是寄存器堆在底层电路上实现了它。
一开始你不需要搞清楚寄存器堆的实现原理。
ELF
ELF(可执行与可链接文件,Executable and Linkable File)是 GNU/Linux 中的默认可执行格式。
不同操作系统有不同的可执行格式。
GNU/Linux: ELF
Windows: PE
Darwin(Mac OS): Mach-O
ELF 是最流行的格式,所以我只讨论 ELF。
节(Section, ELF)
const char *str = "Hello World";
这行 C 代码声明了一个指向字符串的指针。该字符串存储在 .rodata 节中。
不要执着于死记各个节的名称和功能。
这里的核心思想是:“一切都存在于内存中,节只是一种组织数据的格式”
objdump 是一个可以轻松展示各个节的工具
objdump -s -j .rodata a.out
-s 转储原始字节,-j 选择要转储哪个节。
节是链接器对文件的视角。 当 gcc a.c b.c -o a.out 运行时,链接器会把两个目标文件中的 .text 合并为输出文件中的一个 .text。每个节都带有名称、类型、地址和一个标志,用来说明它保存的是什么类型的数据。
但链接器并不运行你的程序。运行它的是内核,而内核并不关心节名。内核只需要知道把文件的哪一段映射进内存、映射到哪个虚拟地址、赋予什么权限。这些信息存放在程序头表中。
段(Segment, ELF)
readelf -l a.out
Program Headers:
Type Offset VirtAddr FileSiz MemSiz Flg Align
LOAD 0x000000 0x0000000000400000 0x0001e4 0x0001e4 R 0x1000
LOAD 0x001000 0x0000000000401000 0x000135 0x000135 R E 0x1000
LOAD 0x002000 0x0000000000402000 0x000088 0x000130 RW 0x1000
Section to Segment mapping:
Segment Sections...
00 .interp .dynsym .dynstr .gnu.hash .rela.dyn
01 .init .plt .text .fini
02 .dynamic .got .got.plt .data .bss
.init、.plt、.text、.fini 是四个节,但它们都落在同一个段里,而这个段被标记为 R E。“文本段是可执行的"这句话的真正来源就在这里。
所以同一堆字节被索引了两次:
| 节(Section) | 段(Segment) | |
|---|---|---|
| 使用者 | 链接器 | 内核(加载器) |
| 表 | 节头表 | 程序头表 |
| 回答的问题 | 如何组织符号和重定位 | 如何映射内存,赋予什么权限 |
| 命令 | readelf -S | readelf -l |
第三行 LOAD 的 FileSiz 比 MemSiz 小。缺少的那些字节就是 .bss:在文件中声明了大小但没有内容,所以加载器在映射之后直接把这一段清零。
之前的建议依然适用。不要试图记住每个节和段的名字。ELF 并不神秘,它只是对同一堆字节建立的两套索引,之所以有两套,是因为有两个使用者。
进程内存布局
当你执行 ./a.out 时,内核会创建一个新的地址空间,遍历程序头,并把每个 PT_LOAD 段映射进去。然后构建栈,加载解释器(动态链接的二进制是 /lib64/ld-linux-x86-64.so.2),最后跳转到入口点。
最终得到的布局,从高地址到低地址:
+--------------------+
| stack | argv、envp、auxv 在顶部,向下增长
+--------------------+
| mmap region | libc.so、ld.so、大块 malloc,向下增长
+--------------------+
| ... |
+--------------------+
| heap (brk) | 向上增长
+--------------------+
| RW LOAD segment | .data .bss .got .dynamic
+--------------------+
| RE LOAD segment | .text .plt
+--------------------+
| R LOAD segment | .interp .dynsym .rodata
+--------------------+
低地址
两个增长方向:堆向上增长,栈向下增长,mmap 夹在中间。只有当地址空间耗尽时它们才会相遇。
运行 cat /proc/self/maps,你会在活着的进程上看到完全一样的布局,以及每个区域的地址。实际上每个区域在执行时都会得到一个随机基址,这就是为什么在 pwn 题目中你泄露的地址每次运行都会变化。
字节序
内存按字节编址,但寄存器保存 8 字节的值。字节序决定了这个值中的哪个字节放在最低地址。x86-64 是小端序:最低有效字节在前。
存储在 0x7ffd0000 处的 qword 0x0000000000401000:
0x7ffd0000: 00
0x7ffd0001: 10
0x7ffd0002: 40
0x7ffd0003: 00
0x7ffd0004: 00
0x7ffd0005: 00
0x7ffd0006: 00
0x7ffd0007: 00
这在你读十六进制转储时很重要:字节按地址顺序出现,所以要还原一个数字就得倒着读。第一次把寄存器和原始内存转储做对比时,值看起来会是乱的。
字符串不受影响,字节序列就是字节序列。混乱只在同样的字节被解释成整数时才开始。
ARM 和 RISC-V 的字节序可以由启动固件配置,所以必须逐平台确认。x86 永远是小端序。
栈与调用栈
寄存器给你快速的存储,内存给你大量的存储,但两者都不会记住该回到哪里。当 A 调用 B 时,B 需要知道自己是从哪个地址被调用的。这就是调用栈的工作。
栈是由 rsp 管理的一段内存,遵循 LIFO:
push rax ; rsp -= 8,然后 [rsp] = rax
pop rax ; rax = [rsp],然后 rsp += 8
两条指令定义了函数调用的一切:
call foo ; 把下一条指令的地址压栈,然后跳转到 foo
ret ; 把该地址弹出到 rip
call 把返回地址写到栈上,ret 再把它读回来。CPU 根本不知道什么是函数。
栈帧
每个函数都拥有栈上属于它的一片区域,叫做它的栈帧。标准序言:
push rbp ; 保存调用者的帧指针
mov rbp, rsp ; 当前栈顶成为帧基址
sub rsp, 0x20 ; 为局部变量分配空间
...
mov rsp, rbp ; 这两行通常写成一条
pop rbp ; leave 指令
ret
执行 mov rbp, rsp 之后,这个栈帧中的一切都以相对 rbp 的固定偏移来寻址:
mov [rbp-0x8], rdi ; 溢出保存第一个参数
mov rax, [rbp-0x8]
这就是 rbp 被称为帧指针的原因。函数运行期间它不移动,即使每次压栈 rsp 都会变。gdb 的 info frame 和 bt 就依赖这一点:每个栈帧都保存了调用者的 rbp,所以往回走就是沿着 rbp -> [rbp] -> [[rbp]] 一路跟下去。
这条链并不保证存在。gcc 在 -O2 下会用 -fomit-frame-pointer 编译,rbp 变成普通的被调用者保存寄存器,gdb 只能通过扫描返回地址来恢复栈帧。调试优化后的二进制更困难,原因正是这个。
返回地址所在的位置:
[rbp+0x10] 第一个栈参数(第 7 个参数)
[rbp+0x08] 返回地址 <- 由 call 压入
rbp -> [rbp+0x00] 保存的 rbp <- 由 push rbp 压入
[rbp-0x08] 局部变量
...
rsp ->
保存的 rbp 和返回地址之间的那 8 个字节,是 pwn 中最重要的布局。覆盖它就意味着决定 ret 跳到哪里。
调用约定(System V AMD64 ABI)
硬件只给你 call 和 ret,对参数该放在哪里只字不提。分别编译的两个函数必须在某些事上达成一致,这个一致就是调用约定。
对于 GNU/Linux x86-64,它就是 System V AMD64 ABI。
整数和指针参数
前六个放在 rdi, rsi, rdx, rcx, r8, r9。其余的放在栈上,从右向左压入,每个占 8 字节槽位。返回值通过 rax 返回,结果为 128 位时使用 rdx:rax。
long add(long a, long b, long c, long d, long e, long f, long g);
mov rdi, a
mov rsi, b
mov rdx, c
mov rcx, d
mov r8, e
mov r9, f
push g ; 第 7 个参数,在栈上
call add
; 结果在 rax
浮点参数使用 xmm0 到 xmm7,与整数寄存器分开计数。f(1, 2.0, 3) 最终变成 rdi=1, xmm0=2.0, rsi=3。
调用者保存与被调用者保存
执行 call 之后,你还能信任 rax 吗?ABI 给出两组答案:
调用者保存(volatile):
rax, rcx, rdx, rsi, rdi, r8-r11、xmm0-xmm15调用者必须假设任何调用都可能破坏它们。
被调用者保存(non-volatile):
rbx, rbp, r12-r15、rsp被调用者必须在
ret之前恢复它们。
这正是序言以 push rbp 开头的原因。rbp 属于调用者,所以如果你想拿它当自己的帧基址,就必须保存旧值并在返回前放回去。rbx 和 r12-r15 同理,这就是为什么你会在 libc 各处函数开头看到它们被压栈。
这只是一种约定,硬件里没有任何东西强制它。一个破坏了 rbx 的函数不会当场崩溃,它只是违反了契约,然后调用者在某个毫不相干的地方失败。
Windows x64 的做法不同:整数参数前四个放在 rcx, rdx, r8, r9,另外调用者还要预留 32 字节的影子空间。由于 ABI 是按操作系统而不是按体系结构划分的,同一颗 CPU 可能有两套不同的规则。
栈对齐
ABI 还多加了一条规则:rsp 在 call 之前必须 16 字节对齐。
一步步推:
call 之前: rsp = 0 (mod 16)
call 压入 8 字节
进入函数时: rsp = 8 (mod 16)
push rbp: rsp = 0 (mod 16)
sub rsp, N: N 必须让它保持 0 (mod 16)
所以在普通函数体内部,rsp 保持 16 字节对齐。编译器默默地假定这一点。像 movaps(移动对齐的打包单精度)这样的 SSE 指令,一旦操作数地址不是 16 字节对齐就会立刻出错。
手写一段调用 printf 的汇编例程,栈没对齐,你就会在 libc 内部某处得到一个 SIGSEGV,而且看不出到底哪里出了问题。这个 bug 就在你的序言里。
一个局部的修法:
push rbx ; 保存一个被调用者保存的寄存器
mov rbx, rsp ; 记住原始栈指针
and rsp, -16 ; 强制对齐
call puts
mov rsp, rbx ; 恢复它,反正下面的内容本来也不再有效
pop rbx
有一个值得一提的例外:红区(red zone),即 rsp 下方的 128 字节。叶子函数可以在完全不调整 rsp 的情况下把数据存在那里,因为信号处理器被要求不得碰它。这就是为什么优化后的叶子函数往往根本不对 rsp 做减法。
系统调用
到目前为止的一切都待在你自己的地址空间里。一旦你的汇编想打印点什么或退出,它就必须走出去。
在 x86-64 Linux 上,这条指令是 syscall:
mov rax, 1 ; 系统调用号:write
mov rdi, 1 ; fd:stdout
lea rsi, [rel msg]
mov rdx, 3 ; count
syscall
调用号表在内核头文件 unistd_64.h 中。
寄存器布局与函数调用约定接近,但并不相同:
| 函数调用 | 系统调用 | |
|---|---|---|
| 编号 | 隐含在调用中 | rax |
| 前 6 个参数 | rdi rsi rdx rcx r8 r9 | rdi rsi rdx r10 r8 r9 |
| 返回值 | rax | rax(负数表示 -errno) |
| 被破坏 | 按调用者/被调用者保存规则 | rcx 和 r11 总是被破坏 |
syscall 用 rcx 存放返回地址、用 r11 存放 rflags,所以第四个参数必须从 rcx 挪到 r10。忘了这一点是经典的新手 bug:前三个参数都正常,第四个传过去是垃圾值。
总结
几个思想贯穿全文:
- ELF 是对同一堆字节的两套索引。节服务于链接器,段服务于内核。
- 调用栈只是一段内存,
call/ret是硬件替你做的唯一簿记工作。 - 调用约定是编译器之间的契约,不是 CPU 强制执行的。
- 栈对齐是编译器默默假定、违反时会大声报错的规则。
当你在 gdb 或反汇编器中打开一道 pwn 题时,这些就是反汇编代码在告诉你的东西。顶部的 push rbp 是 ABI。那个奇怪的 sub rsp, 0x18 是对齐的账。让你泄露的那个地址,就住在上面布局图的 mmap 区域里。
汇编的语法仍然是最不重要的部分。
