学习汇编之前必须掌握的硬件知识

摘要

English Version

离开高级编程语言的抽象世界之后,你打算从汇编开始。

在汇编里,你需要直接操作寄存器和内存。不仅如此,还要面对栈对齐、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 -Sreadelf -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 r9rdi rsi rdx r10 r8 r9
返回值raxrax(负数表示 -errno)
被破坏按调用者/被调用者保存规则rcx 和 r11 总是被破坏

syscall 用 rcx 存放返回地址、用 r11 存放 rflags,所以第四个参数必须从 rcx 挪到 r10。忘了这一点是经典的新手 bug:前三个参数都正常,第四个传过去是垃圾值。

总结

几个思想贯穿全文:

  • ELF 是对同一堆字节的两套索引。节服务于链接器,段服务于内核。
  • 调用栈只是一段内存,call/ret 是硬件替你做的唯一簿记工作。
  • 调用约定是编译器之间的契约,不是 CPU 强制执行的。
  • 栈对齐是编译器默默假定、违反时会大声报错的规则。

当你在 gdb 或反汇编器中打开一道 pwn 题时,这些就是反汇编代码在告诉你的东西。顶部的 push rbp 是 ABI。那个奇怪的 sub rsp, 0x18 是对齐的账。让你泄露的那个地址,就住在上面布局图的 mmap 区域里。

汇编的语法仍然是最不重要的部分。

recent-work

ELF格式解析

TL;DR 一个 ELF 文件同时被四个角色读写:编译器产出它、链接器改造它、内核加载器消费它、工具与攻击者审视它。这四个读者看的是同一坨字节,但需求完全不同。ELF 的解法是把文件切成两套正交的索引表: Program Header Table(段视图)——给加载器看,描述“运 …

Read more →

NEMU RISC-V32 指令模式匹配与汇编级优化

TL;DR 本文沿着一条「先问为什么,再看怎么做」的线索,拆开 NEMU 的 RISC-V32 指令解码:从「怎么知道当前是哪条指令」这个问题出发,引出模式字符串,再手推 pattern_decode 如何把它变成 key/mask/shift,最后贴出宿主机 Intel 汇编, …

Read more →