Skip to content

x86_64汇编基础

x86汇编程序基础 | Linux C编程一站式学习

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 语言变量作用域. 程序装载后, 链接器和操作系统共同决定这些内容在进程地址空间中的映射.

函数调用与栈

函数调用涉及两个层次:

  1. call、ret 是 x86 指令, 负责转移控制流并保存 / 恢复返回地址.

  2. 调用约定规定参数放在哪里、返回值放在哪里、哪些寄存器必须保留. 这些是 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 直接照搬到系统调用.

汇编示例

lang/asm/add | virtualguard101/vglab

假设有如下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
  1. 按 System V AMD64, 参数 a、b 进入函数时分别在 edi、esi 的低 32 位.

  2. pushq %rbp 和 movq %rsp, %rbp 建立一个常见栈帧. -4(%rbp)、-8(%rbp) 分别是帧指针以下的局部存储位置.

  3. 未优化版本把参数写入栈, 再读回 edx、eax. 这便于调试器对应源码变量, 但不是高效写法.

  4. addl %edx, %eax 把两个 32 位数相加并将结果留在 eax. 整数返回值按 ABI 放在 rax 的低位.

  5. 恢复 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 放到寄存器或栈中.

阅读汇编的速查顺序

遇到不熟悉的汇编片段时, 可按以下顺序缩小问题:

  1. 确认目标架构、汇编语法、操作系统和 ABI.

  2. 找函数入口、标签、跳转和 ret, 先画出控制流.

  3. 根据后缀确认数据宽度, 标出寄存器和内存操作数.

  4. 跟踪每条指令的数据流: 哪些寄存器被读写, 哪些地址被访问.

  5. 遇到 cmp / test 时继续看后续条件跳转, 判断有符号还是无符号.

  6. 遇到 call 时按 ABI 找参数和返回值, 并留意 caller-saved 寄存器会被覆盖.

  7. 把 .cfi、.globl、.section 等元数据 / 汇编器指令与 CPU 指令分开.

延伸资料