x86_64汇编基础¶
linux内核1-GNU汇编入门_X86-64&ARM | TUPELO Blog
X86-64 Assembly常用知识点整理 | 知乎@鲤鱼儿乐
X86_64/X86 GNU汇编、寄存器、内嵌汇编 | 博客园
GNU Assembler: Using as: GNU 汇编器手册, 包含 AT&T / Intel 语法、内存引用、节区和伪指令.
System V AMD64 ABI (x86-64 psABI): Linux / Unix x86-64 调用约定和目标文件 ABI 的规范来源.
Debugging with GDB: GDB 官方手册, 可查反汇编、寄存器和内存检查命令.
Abstract
-
汇编语言是机器指令的可读写法. ISA规定 CPU 能执行什么, 汇编器语法规定文本怎么写, ABI规定函数之间怎么传参和保存状态.
-
本文以 Linux x86-64、ELF、System V AMD64 ABI 和 GNU
as的 AT&T 语法为主. -
单条指令的要点: 操作数是什么宽度? 数据从哪里来、写到哪里? 是否访问内存? 是否改变标志位或控制流?
从C到机器指令¶
以 C 程序为例, 编译和运行大致经过:
C 源码 (.c)
| 预处理、编译
v
汇编文本 (.s)
| 汇编器 as
v
目标文件 (.o, 机器码 + 符号/重定位等信息)
| 链接器 ld (通常由 gcc 驱动)
v
可执行文件
| CPU 取指、译码、执行
部分核心概念
-
指令集架构 (ISA): 定义寄存器、指令语义、寻址方式等软件可见规则. x86-64 是一套 ISA.
-
汇编语言: 用
movq、addl等助记符表达机器指令, 并用标签和汇编器指令组织程序. 汇编文本不是机器码. -
汇编器 (assembler): 把汇编文本编码为目标文件. GNU 工具链的汇编器是
as, 通常直接通过gcc调用. -
ABI (Application Binary Interface): 规定二进制组件如何协作, 包括函数参数、返回值、寄存器保存规则、栈对齐和目标文件约定. 同一 ISA 可以有不同 ABI.
这里的 .s 是 GCC 输出的 GNU 汇编文本. 其中既有 CPU 指令, 也有 .text、.globl、.cfi_startproc 等伪指令 / 元数据指令. 伪指令用于指导汇编器或调试器, 通常不会作为一条 CPU 指令执行.
AT&T 语法¶
GNU as 支持 AT&T 与 Intel 两种 x86 写法. GCC 在 Linux 上常见的输出是 AT&T 语法.
| AT&T 写法 | 含义 |
|---|---|
%rax | 寄存器名前加 % |
$5 | 立即数前加 $ |
movl %edi, %eax | 源操作数在前, 目标操作数在后, 即 eax = edi 的低 32 位 |
8(%rbp) | 访问内存地址 rbp + 8 处的数据 |
movl 8(%rbp), %eax | 从内存读 4 字节到 eax |
同一条指令在 Intel 语法中操作数顺序相反, 且通常不写 %、$:
# AT&T
movl %edi, %eax
; Intel 等价写法
mov eax, edi
数据宽度后缀¶
AT&T 常在助记符末尾用后缀标明操作数宽度:
| 后缀 | 宽度 | 常见用途 |
|---|---|---|
b | 8 bit | 字节 |
w | 16 bit | word |
l | 32 bit | long |
q | 64 bit | quadword |
例如 movb 搬 1 字节, movl 搬 4 字节, movq 搬 8 字节. 后缀表示这条操作的数据宽度, 不等于程序一定是 32 位或 64 位. x86-64 程序里常见 int 仍是 32 位, 指针和 long 通常是 64 位.
寄存器¶
寄存器是 CPU 可直接操作的高速存储位置. x86-64 的通用寄存器有 16 个, 常见名字及其低位别名如下:
| 64 位 | 32 位 | 16 位 | 低 8 位 |
|---|---|---|---|
rax | eax | ax | al |
rbx | ebx | bx | bl |
rcx | ecx | cx | cl |
rdx | edx | dx | dl |
rsi | esi | si | sil |
rdi | edi | di | dil |
rbp | ebp | bp | bpl |
rsp | esp | sp | spl |
r8-r15 | r8d-r15d | r8w-r15w | r8b-r15b |
这些名字不是完全独立的寄存器, 而是同一个寄存器的不同宽度视图:
63 32 31 16 15 8 7 0
+----------------------+------------------+----------+-----------+
| | | | |
+----------------------+------------------+----------+-----------+
|<--------------------------- %rax ----------------------------->|
|<----------------- %eax ---------------->|
|<------- %ah -------->|
|<-- %al -->|
严格地说, eax 覆盖 rax 的低 32 位, 而向 32 位寄存器写入时会把 rax 高 32 位清零. 写入 ax 或 al 则只改相应低位, 高位保持不变. 这也是为什么 movl $0, %eax 会把整个 rax 清零.
八个传统命名的通用寄存器保留了 16 位 x86 时代的名称. 这些名称提示其历史用途, 但在 x86-64 中多数时候仍可作为通用寄存器使用:
| 64 位寄存器 | 传统名称 | 常见用途与注意事项 |
|---|---|---|
rax | accumulator, 累加器 | 常作算术累加器; mul / div 等指令会隐式使用它; System V ABI 用它返回整数或指针. |
rbx | base, 基址 | 历史上用于基址寻址; 现在可保存一般数据. System V ABI 中属于被调用者保存寄存器. |
rcx | counter, 计数器 | 常作循环计数, 移位指令也会用其低位 cl 保存移位量; System V ABI 中是第 4 个整数 / 指针参数寄存器. |
rdx | data, 数据 | mul / div 等指令会与 rax 配合保存高位或余数; System V ABI 中是第 3 个整数 / 指针参数寄存器. |
rsi | source index, 源变址 | 字符串操作的传统源地址寄存器; System V ABI 中是第 2 个整数 / 指针参数寄存器. |
rdi | destination index, 目的变址 | 字符串操作的传统目标地址寄存器; System V ABI 中是第 1 个整数 / 指针参数寄存器. |
rbp | base pointer, 基址指针 | 常用作栈帧指针, 便于用固定偏移访问局部变量和参数; 省略帧指针后也可作普通寄存器. System V ABI 中属于被调用者保存寄存器. |
rsp | stack pointer, 栈指针 | 指向当前栈顶; push、pop、call、ret 会隐式使用它. 函数返回前必须恢复栈状态, 调用其他函数时还要满足 ABI 的栈对齐要求. |
rax、rbx、rcx、rdx 的名称常见于历史助记; rsi、rdi 中的 si / di 指源 / 目的变址; rbp、rsp 则直接对应基址指针和栈指针. 在 64 位模式下, rsi、rdi 等不再只能用于字符串操作.
在 System V AMD64 ABI 下, rbx、rbp、r12-r15 是被调用者保存寄存器; rax、rcx、rdx、rsi、rdi、r8-r11 是调用者保存寄存器. rsp 需要按调用约定保持平衡. 这里描述的是 ABI 约定, 不是这些寄存器在硬件层面只能承担的工作.
此外还有几个常见的特殊状态寄存器:
-
rip: 指令指针, 表示当前执行位置. 普通代码一般不会直接把它当通用寄存器改写. -
rflags: 条件标志位集合. 加减、比较等指令会设置其中的标志, 条件跳转据此决定去向. -
xmm0-xmm15等: 常用于浮点和 SIMD 向量数据. 浮点参数通常不走整数参数寄存器.
寄存器的用途不是 ISA 的绝对限制, 而是部分指令和 ABI 约定共同决定. 例如 rax 常放整数返回值, 是 ABI 约定, 并非 add 指令只能写入 rax.
指令与操作数¶
可以先用以下模式读一条常见指令:
指令助记符 源操作数, 目标操作数
例如:
movl $7, %eax # eax = 7
addl %edx, %eax # eax = eax + edx
subl $1, %eax # eax = eax - 1
AT&T 常见算术指令中, 源在左、目标在右. 但并非所有指令都只有两个操作数, 应查具体指令的文档.
数据搬运与地址计算¶
-
mov: 在寄存器、内存与立即数之间搬运数据. 一般不能同时把内存当作源和目标, 需要先经过寄存器. -
lea: 计算有效地址并把地址数值放入寄存器, 不读取该地址里的数据. 例如leaq 8(%rbp), %rax的语义是计算rbp + 8, 并将其写入rax.
movl 8(%rbp), %eax # 读 rbp + 8 处的 4 字节
leaq 8(%rbp), %rax # 计算 rbp + 8, 不访问该地址指向的内容
lea 名称里的 load 容易让人以为它会读内存. 记住它把地址表达式当作整数算术来计算即可.
算术、比较与标志位¶
-
add/sub: 加减并写回目标操作数. -
cmp src, dst: 计算dst - src以更新标志位, 丢弃计算结果. 它本身不保存差值. -
test a, b: 对两个操作数按位 AND 以更新标志位, 丢弃结果. 常用test %eax, %eax检查是否为零. -
inc/dec: 加一 / 减一. 初学时更建议先看懂add/sub.
标志位
标志位可以理解为上一条运算留下的“比较结果摘要”. 下面的条件跳转会读取这些标志位, 所以通常要把 cmp 和紧随其后的 jcc 一起阅读.
跳转与控制流¶
-
jmp label: 无条件跳转. -
je/jne: 相等 / 不相等时跳转. -
jl/jle/jg/jge: 有符号比较的小于、小于等于、大于、大于等于. -
jb/jbe/ja/jae: 无符号比较的小于、小于等于、大于、大于等于.
同一组位模式若解释为有符号整数或无符号整数, 大小关系可能不同, 因而条件跳转也不同. 看见 cmp 后要继续看跳转指令选择的是 jl 还是 jb 等.
下面是 C 的 if (a < b) x = 1; else x = 0; 的一种示意翻译:
cmpl %esi, %edi # 比较 edi - esi, 即 a - b
jge .Lelse # a >= b 时跳到 else, 这里按有符号数比较
movl $1, %eax
jmp .Ldone
.Lelse:
movl $0, %eax
.Ldone:
标签 .Lelse、.Ldone 是汇编器和跳转使用的代码位置名称, 不是 CPU 寄存器.
内存寻址¶
寄存器保存的是值; 内存操作数则描述“去哪个地址读或写”. GNU AT&T 的常见寻址形式是:
位移(基址寄存器, 变址寄存器, 比例因子)
有效地址按下面的式子计算:
地址 = 位移 + 基址 + 变址 * 比例因子
例如:
movl 8(%rdi,%rcx,4), %eax
它从地址 rdi + rcx * 4 + 8 读取 4 字节到 eax. 比例因子只能是 1、2、4 或 8, 常对应数组元素大小. 基址或变址可以省略.
数组 int a[] 的 a[i] 地址可写成 a + i * 4, 因为常见 x86-64 Linux ABI 中 int 是 4 字节. 汇编器不会知道这是 C 数组, 它只按地址表达式计算.
机器按字节寻址. x86-64 使用小端序, 一个多字节整数的最低有效字节放在较低地址:
数值: 0x12345678
-------- 高地址
| 0x12 |
--------
| 0x34 |
--------
| 0x56 |
--------
| 0x78 |
-------- 低地址
常见节区¶
| 节区 | 典型内容 |
|---|---|
.text | 指令代码 |
.rodata | 字符串常量等只读数据 |
.data | 有初始值的全局 / 静态数据 |
.bss | 未显式初始化或初始化为零的全局 / 静态数据 |
节区是目标文件和链接器组织内容的方式, 不是 C 语言变量作用域. 程序装载后, 链接器和操作系统共同决定这些内容在进程地址空间中的映射.
函数调用与栈¶
函数调用涉及两个层次:
-
call、ret是 x86 指令, 负责转移控制流并保存 / 恢复返回地址. -
调用约定规定参数放在哪里、返回值放在哪里、哪些寄存器必须保留. 这些是 ABI 规则, 不是
call指令自动完成的工作.
在 x86-64 中, call target 会把下一条指令的地址压入栈, 再跳到 target. ret 从栈顶取回该地址并继续执行.
编译器可以省略帧指针、把变量留在寄存器里、重排栈空间, 优化后甚至完全没有显式栈帧.
典型序言和尾声:
pushq %rbp # 保存调用者的 rbp, rsp 减少 8
movq %rsp, %rbp # 建立当前帧的参考位置
subq $16, %rsp # 给局部变量预留空间
...
leave # 等价于 mov %rbp, %rsp; pop %rbp
ret # 返回到 call 保存的地址
pushq / popq 会调整 rsp. 栈是内存, 并非一块独立的硬件存储; rsp 只是指向栈顶的寄存器.
System V AMD64 调用约定¶
以 Linux / 常见 Unix ELF 平台上的 System V AMD64 ABI 为例 (Windows x64 使用不同的参数寄存器规则).
整数和指针参数的前六个通常依次放在:
第 1 个: rdi 第 2 个: rsi 第 3 个: rdx
第 4 个: rcx 第 5 个: r8 第 6 个: r9
更多整数 / 指针参数通过栈传递. 浮点参数通常使用 xmm0-xmm7 等向量寄存器, 不能把浮点和整数参数简单看成同一串寄存器.
| 用途 | 常见规则 |
|---|---|
| 整数 / 指针返回值 | rax (较窄结果使用其低位) |
| 调用者保存 (caller-saved) | rax, rcx, rdx, rsi, rdi, r8-r11 等; 调用后不能假设值仍保留 |
| 被调用者保存 (callee-saved) | rbx, rbp, r12-r15; 函数若改写, 返回前要恢复 |
| 栈指针 | rsp 必须按 ABI 要求恢复, 不能让调用者的栈状态失衡 |
System V AMD64 要求普通函数调用点满足 16 字节栈对齐. call 压入 8 字节返回地址后, 被调用函数入口处通常有 rsp % 16 == 8. 因此函数序言可能需要调整栈空间, 以便再次调用其他函数时恢复调用点对齐.
ABI 还定义了 red zone: 在用户态 System V AMD64 中, rsp 以下 128 字节区域可由叶子函数临时使用, 不必调整 rsp; 该区域不能指望跨越另一次函数调用继续保留. 这是 ABI 的便利约定, 不要套用到 Windows ABI 或内核代码.
函数调用不等于系统调用
C 函数调用遵守平台 ABI. Linux x86-64 的 syscall 使用另一套约定: 系统调用号通常放在 rax, 参数寄存器顺序为 rdi, rsi, rdx, r10, r8, r9; syscall 还会覆盖 rcx 和 r11. 不要把 C 函数的第四个参数寄存器 rcx 直接照搬到系统调用.
汇编示例¶
假设有如下C代码:
int add(int a, int b)
{
return a + b;
}
int main()
{
int res = add(2, 3);
}
将其编译成汇编形式:
gcc -S add.c -o add.s
这里的 add.s 是 GCC 未优化输出的一种形式. 其中 add 函数的主体可概括为:
pushq %rbp
movq %rsp, %rbp
movl %edi, -4(%rbp)
movl %esi, -8(%rbp)
movl -4(%rbp), %edx
movl -8(%rbp), %eax
addl %edx, %eax
popq %rbp
ret
-
按 System V AMD64, 参数
a、b进入函数时分别在edi、esi的低 32 位. -
pushq %rbp和movq %rsp, %rbp建立一个常见栈帧.-4(%rbp)、-8(%rbp)分别是帧指针以下的局部存储位置. -
未优化版本把参数写入栈, 再读回
edx、eax. 这便于调试器对应源码变量, 但不是高效写法. -
addl %edx, %eax把两个 32 位数相加并将结果留在eax. 整数返回值按 ABI 放在rax的低位. -
恢复
rbp,ret回到调用点.
在 main 中, 你会看到类似:
movl $3, %esi
movl $2, %edi
call add
movl %eax, -4(%rbp)
这表示调用者先把 2、3 放进参数寄存器, 再执行 call add; 返回后从 eax 取回结果. 后续的 movl $0, %eax 则为 main 设置返回值 0.
生成的完整文件还有 .cfi_startproc、.cfi_offset 等行. 它们描述如何在栈展开时恢复寄存器, 供调试器、异常处理和回溯使用, 不是 CPU 会逐条执行的机器指令. .globl、.type、.size 等则帮助汇编器和链接器记录符号信息.
不要把此输出当作唯一的 add 实现. 改变 GCC 版本、优化级别、调试选项或目标平台后, 指令和栈布局都可能不同. -O2 下 add 可能只有一条 leal 或 addl, 甚至被内联而不再作为独立函数出现.
常用调试命令¶
可以用下面的命令观察编译过程:
# 生成 AT&T 汇编文本
gcc -S -O0 -fno-omit-frame-pointer add.c -o add.s
# 生成优化后的版本, 对比指令与栈帧
gcc -S -O2 add.c -o addopt.s
# 编译并查看目标文件中的反汇编
gcc -c add.c -o add.o
objdump -drwC -Mintel add.o
# 带调试信息运行 GDB
gcc -g -O0 -fno-omit-frame-pointer add.c -o add
gdb ./add
在 GDB 中可以尝试:
break add
run
disassemble /m add
info registers rdi rsi rax rbp rsp
display/i $pc
si
ni
x/4gx $rsp
-
disassemble /m add: 查看函数指令, 并尽可能与源码对应. -
info registers: 查看寄存器当前值. -
display/i $pc: 每次停下时显示当前指令. -
si: 单步执行一条机器指令, 遇到call会进入被调用函数. -
ni: 单步执行但越过函数调用. -
x/4gx $rsp: 从栈指针开始按 8 字节查看 4 个内存单元. GDB 的x格式可按需调整.
阅读时尽量用同一份源码重复实验: 先看 C, 再看 .s, 接着用 objdump 看实际编码, 最后在 GDB 中观察寄存器变化. 这样能区分编译器输出、汇编器文本与 CPU 状态.
常见误区¶
-
把 AT&T 操作数顺序看反:
addl %edx, %eax是eax += edx, 不是反过来. -
把立即数和内存混为一谈:
$8是数值 8;8(%rbp)是地址rbp + 8指向的内存. -
认为
lea会读取内存: 它计算地址, 不读取地址中的值. -
把寄存器宽度当作程序位数:
movl搬 32 位数据, 不意味着正在运行 32 位程序. -
认为每个 C 变量必然在栈上: 优化器可用寄存器、消除变量或改写控制流.
-
认为每个函数都有标准栈帧: 帧指针可以省略, 叶子函数甚至可能不调整栈指针.
-
混淆有符号与无符号比较:
jl和jb使用不同的标志位组合, 需要结合 C 类型理解. -
认为
call自动传递参数:call只保存返回地址并跳转; 参数由调用者按 ABI 放到寄存器或栈中.
阅读汇编的速查顺序¶
遇到不熟悉的汇编片段时, 可按以下顺序缩小问题:
-
确认目标架构、汇编语法、操作系统和 ABI.
-
找函数入口、标签、跳转和
ret, 先画出控制流. -
根据后缀确认数据宽度, 标出寄存器和内存操作数.
-
跟踪每条指令的数据流: 哪些寄存器被读写, 哪些地址被访问.
-
遇到
cmp/test时继续看后续条件跳转, 判断有符号还是无符号. -
遇到
call时按 ABI 找参数和返回值, 并留意 caller-saved 寄存器会被覆盖. -
把
.cfi、.globl、.section等元数据 / 汇编器指令与 CPU 指令分开.