Skip to content

指针访存的汇编描述

Computing 101. - Computer Memory | pwn.college

GNU Assembler: Using as

Intel® 64 and IA-32 Architectures Software Developer's Manual

在最简汇编中, 我们设置的数据只在立即数和寄存器之间流动. 但寄存器数量有限, 我们显然无法把所有数据都放在寄存器里, 程序的大部分数据其实存放在内存里.

本文以 Linux x86-64 和 Intel 语法为例, 说明汇编层面如何访问内存, 并据此观察 C 语言中的指针和二级指针在汇编的视角是如何运作的.

内存与地址

寄存器"小而快", 内存"大而慢", 二者是存储层次中相邻的两级.

从程序的视角看, 内存是一个按字节编号的大数组, 每个字节有一个数值地址. 现代程序看到的是虚拟地址空间, 其中有大片地址并没有映射, 访问它们会触发段错误.

CPU 本身不区分一个数是"数据"还是"地址", 区别只在于指令如何使用它. 这是理解指针的关键.

立即数, 寄存器与内存

在 Intel 语法中, [...]表示"以括号内的值为地址访问内存":

写法 含义 C 语言类比
mov rdi, 31337 把数值 31337 放入 rdi rdi = 31337;
mov rdi, rax 把 rax 的值复制到 rdi rdi = rax;
mov rdi, [31337] 读取地址 31337 处的 8 字节, 放入 rdi rdi = *(long *)31337;
mov rdi, [rax] 读取以 rax 的值为地址的 8 字节, 放入 rdi rdi = *(long *)rax;

若 rax 为 133700, 则 mov rdi, rax 得到 133700, 而 mov rdi, [rax] 得到地址 133700 处存放的数据. 此时 rax 就是一个指针, 通过它读写所指数据的过程称为解引用.

Register │ Contents             Address │ Contents
+─────────────────+            +─────────────────+
│  rax   │ 133700 │  ───────▸  │ 133700 │ 42     │
+─────────────────+            +─────────────────+

mov rdi, [rax]   ; rdi = 42

Tip

寄存器是通用的. rax 在系统调用中充当调用号, 在这里也可以充当指针, 用途取决于指令如何使用它. 解引用甚至可以覆盖寄存器自身: mov rax, [rax] 之后, rax 由地址变成了数据.

x86-64汇编基础中的 AT&T 写法与之对应: mov (%rax), %rdi 的源操作数与 mov rdi, [rax] 等价, 只是操作数顺序相反.

有效地址与偏移

方括号内不只可以是单个寄存器, 而是一个有效地址表达式:

\[ \text{address} = \text{base} + \text{index} * \text{scale} + \text{disp} \]

即 [base + index*scale + disp], 比例因子只能是 1, 2, 4, 8. 表达式在 CPU 内部由地址生成单元一次算出, 不需要额外的加法指令.

设 rdi 指向一段连续字节:

    Address │ Contents
  +────────────────────+
┌▸│ 133700  │ 50       │
│ │ 133701  │ 42       │
│ │ 133702  │ 99       │
│ │ 133703  │ 14       │
│ +────────────────────+
└─ rdi = 133700
movzx rax, byte ptr [rdi+1]    # 偏移 1 字节, 取到 42
movzx rax, byte ptr [rdi+rcx]  # 变址: 等价于 C 的 p[i]
mov   eax, [rdi+rcx*4]         # 比例因子 4: 等价于 int 数组的 a[i]

偏移量以字节为单位. 因此 C 中的 a[i] 在汇编里要由编译器根据元素大小换算成 i * sizeof(*a), 这正是指针与数组中"指针加减按所指类型大小缩放"的来源. 汇编器视角下并不知道什么是数组, 只按地址表达式计算.

访问宽度

mov 一次搬运多少字节, 由寄存器宽度或显式的宽度标注决定, 而不由地址决定:

写法 宽度
mov rax, [rdi] 8 字节 (rax 为 64 位)
mov eax, [rdi] 4 字节
mov al, [rdi] 1 字节
mov qword ptr [rdi], 42 8 字节; 目的操作数是内存且源是立即数时, 无法由寄存器推断宽度, 必须用 byte / word / dword / qword ptr 标注
movzx rax, byte ptr [rdi] 读 1 字节并零扩展到 64 位

读宽度大于对象本身时, 会把相邻数据一起读进来. 例如字符串 "Hello, world!\n" 起始处用 mov rdi, [rip+msg] 读 8 字节, 得到的是 "Hello, w" 的 8 个字符, 而不是一个字符串指针.

又因为 x86-64 是小端序, 这 8 字节中最低有效字节 'H' (0x48) 在最低地址, 因而放入 rdi 后, 低 8 位恰好是 0x48. 若把它当作退出码传给 exit, 实际生效的只有低 8 位, 即 72.

Review

大小端的定义也可参考数据的大小端和对齐存储.

取地址指令

mov reg, [expr] 会读内存, 而 lea reg, [expr] 只计算 expr 的值并写入寄存器, 不访问内存:

lea rsi, [rip+msg]   # rsi = &msg, 对应 C 的 &msg
mov rsi, [rip+msg]   # rsi = *(long *)&msg, 读出了 msg 处的 8 字节内容

两者的方括号写法相同, 因此最容易混淆. 记忆方法: lea 对应 &, 方括号形式的 mov 对应 *. 详见x86-64汇编基础对 lea 的说明.

另外, [rip+label] 是RIP 相对寻址: 地址 = 下一条指令地址 + 到 label 的偏移. 该偏移在汇编和链接时确定, 不依赖绝对地址, 因此是 x86-64 访问全局符号的常用写法, 也便于生成位置无关代码.

在内存中存放地址

地址本质上也是数值, 所以它可以像普通数据一样存进内存, 再被读出来作为新的指针使用. 设地址 123400 处存放着 133700, 地址 133700 处存放着 42:

mov rdi, 123400    # rdi = 123400
mov rdi, [rdi]     # 第 1 次解引用: rdi = 133700, 内存里存的是地址
mov rax, [rdi]     # 第 2 次解引用: rax = 42

上面的代码的访存模型可以简化成这样:

    Address │ Contents
  +────────────────────+
┌▸│ 123400  │ 133700   │─┐      2) mov rdi, [rdi]   rdi = 133700 (读出的是地址)
│ +────────────────────+ │
│ │ 133700  │ 42       │◂┘      3) mov rax, [rdi]   rax = 42     (读出的是数据)
│ +────────────────────+
│
│  Register │ Contents
│ +────────────────────+
└─│ rdi     │ 123400   │        1) mov rdi, 123400  rdi 指向 123400
  +────────────────────+

每执行一次 mov reg, [reg], 指针就沿箭头前进一级: 第一次读出的内容恰好是下一级的地址, 第二次才读到真正的数据.

这与 C 的二级指针完全对应:

int  v   = 42;
int *p   = &v;    /* 内存里存放 v 的地址 */
int **pp = &p;    /* 内存里存放 p 的地址 */
int  x   = **pp;  /* 两次解引用 */

三个变量在内存中的关系如下, 每个箭头对应一次解引用:

     pp             p              v
+──────────+ * +──────────+ * +──────────+
│    &p    │──▸│    &v    │──▸│    42    │
+──────────+   +──────────+   +──────────+
   *pp == p     **pp == v

与汇编对照, 寄存器 rdi 的初值 123400 相当于 pp 的值 (即 &p), 123400 处存放 133700 的单元相当于 p, 133700 处的 42 相当于 v.

pwn.college 中 Computer Memory 的最后一关即基于这一模型: rax 保存 SECRET_LOCATION_2, 该处保存 SECRET_LOCATION_1, 再次解引用才能得到 SECRET_VALUE, 需要依次执行self-deferencing mov rax, [rax] 与single-level dereferencing mov rdi, [rax].

二级指针模型的安全风险

数据与地址在程序里被反复存取, 一旦二者被混用 (例如把数据当作地址解引用, 或让攻击者控制了指针的值), 就会引发安全问题. 缓冲区溢出覆盖返回地址就是典型例子.

综合示例

下面的程序在不依赖 C 运行时的前提下, 把前面的概念串在一起.

其在 .data 中构造 pp -> p -> msg 的指针链, 通过双重解引用得到字符串地址, 用 write 系统调用输出, 并用第三次解引用读出首字符作为退出码:

.intel_syntax noprefix

.global _start

# 内存布局, 等价于 C:
#   char  msg[] = "Hello, world!\n";
#   char *p     = msg;    // 一级指针
#   char **pp   = &p;     // 二级指针
#
#   pp --> p --> msg --> "Hello, world!\n"
#   (8B)  (8B)   (14B)

.section .data

msg:
    .ascii "Hello, world!\n"
.set msg_len, . - msg       # 汇编期常量: 当前位置减 msg 起点, 即 14, 不占内存

p:
    .quad msg               # 8 字节, 内容是 msg 的地址 (链接时重定位)

pp:
    .quad p                 # 8 字节, 内容是 p 的地址

.section .text

_start:
    lea rax, [rip+pp]       # rax = &pp, 取变量自身的地址, 不读内存
    mov rax, [rax]          # 第 1 次解引用: rax = pp = &p
    mov rsi, [rax]          # 第 2 次解引用: rsi = p  = &msg, 即 write 的 buf

    mov rax, 1              # SYS_write
    mov rdi, 1              # fd = stdout
                            # rsi = buf, 已由双重解引用得到
    mov rdx, msg_len        # count = 14
    syscall                 # 会破坏 rcx 和 r11, 并用 rax 返回结果; rsi 保持不变

    movzx rdi, byte ptr [rsi]   # 第 3 次解引用(按字节): rdi = 'H' = 72
    mov rax, 60             # SYS_exit
    syscall

汇编, 链接并运行:

> as -o mem.o mem.s && ld -o mem mem.o && ./mem
Hello, world!
> echo $?
72

数据与内存模型

.data 中三个符号按定义顺序紧挨着排列, 没有填充 (地址以 .data 起点 msg 的偏移表示, 实际地址由链接器决定):

  Address │ Contents                      Symbol
+──────────────────────────────────────+
│ msg+0   │ "Hello, world!\n"  (14B)   │  msg  ◂──────────┐
+──────────────────────────────────────+                  │  *p  = msg
│ msg+14  │ &msg               (8B)    │  p   ────────────┘
+──────────────────────────────────────+  ^                  *pp = p
│ msg+22  │ &p                 (8B)    │  pp
+──────────────────────────────────────+

Register │ Contents
+────────────────────────+
│ rax    │ &pp           │  lea rax, [rip+pp]            不读内存
│ rax    │ &p            │  mov rax, [rax]               第 1 次解引用
│ rsi    │ &msg          │  mov rsi, [rax]               第 2 次解引用, write 的 buf
│ rdi    │ 'H' = 72      │  movzx rdi, byte ptr [rsi]    第 3 次解引用, 仅读 1 字节
+────────────────────────+

指针链为 pp ──▸ p ──▸ msg ──▸ "Hello, world!\n": 前两级里存放的是地址 (各占 8 字节), 只有最后一级才是字符数据, 所以第三次解引用必须按字节读取.

反汇编分析

> objdump -d -Mintel mem.out

mem.out:     文件格式 elf64-x86-64


Disassembly of section .text:

0000000000401000 <_start>:
  401000:       48 8d 05 3f 20 00 00    lea    rax,[rip+0x203f]        # 403046 <pp>
  401007:       48 8b 00                mov    rax,QWORD PTR [rax]
  40100a:       48 8b 30                mov    rsi,QWORD PTR [rax]
  40100d:       48 c7 c0 01 00 00 00    mov    rax,0x1
  401014:       48 c7 c7 01 00 00 00    mov    rdi,0x1
  40101b:       48 c7 c2 0e 00 00 00    mov    rdx,0xe
  401022:       0f 05                   syscall
  401024:       48 0f b6 3e             movzx  rdi,BYTE PTR [rsi]
  401028:       48 c7 c0 3c 00 00 00    mov    rax,0x3c
  40102f:       0f 05                   syscall

反汇编的读法与最简汇编的反汇编相同, 这里重点看访存指令如何被编码, 以及上图中的各层关系如何落到具体地址上:

地址 机器码 指令 说明
0x401000 48 8d 05 3f 20 00 00 lea rax,[rip+0x203f] 48 是 REX.W 前缀 (64 位操作), 8d 是 lea 操作码, 05 是 ModRM 字节 (目的 rax, 内存操作数为 rip+disp32), 后 4 字节是小端 32 位位移 0x0000203f
0x401007 48 8b 00 mov rax,QWORD PTR [rax] 8b 是"内存读入寄存器"形式的 mov, ModRM 为 00: 目的 rax, 内存操作数为 [rax]. 没有位移字节
0x40100a 48 8b 30 mov rsi,QWORD PTR [rax] 与上一条只差 ModRM: 30 把目的寄存器改为 rsi
0x40100d 48 c7 c0 01 00 00 00 mov rax,0x1 c7 /0 是"立即数写入寄存器/内存"形式, c0 选中 rax, 后接 32 位立即数
0x401014 48 c7 c7 01 00 00 00 mov rdi,0x1 同上, c7 选中 rdi
0x40101b 48 c7 c2 0e 00 00 00 mov rdx,0xe 立即数 0xe 即汇编时由 .set msg_len 算出的 14
0x401022 0f 05 syscall 发起 write(1, rsi, 14)
0x401024 48 0f b6 3e movzx rdi,BYTE PTR [rsi] 0f b6 是 movzx r, r/m8, ModRM 3e: 目的 rdi, 内存操作数 [rsi]
0x401028 48 c7 c0 3c 00 00 00 mov rax,0x3c 0x3c 即 60, 对应 exit
0x40102f 0f 05 syscall 发起 exit(rdi)

访存指令的共同点

三条访存指令的机器码形式完全统一: REX.W + 操作码 + ModRM. 其中 ModRM 的低 3 位选择基址寄存器, 中间 3 位选择另一个寄存器; 只有当基址是 rip 时才多出一个 4 字节位移. 因此:

  • mov rax,[rax] 与 mov rsi,[rax] 在机器码层面只差一个字节, 区别仅是读出的值写入哪个寄存器.

  • 第 1, 2 次解引用的两条指令都是 QWORD PTR, 即读 8 字节, 因为这两级存放的是地址. 第 3 次是 BYTE PTR, 只读 1 字节, 对应访问宽度一节的结论.

  • lea 与 mov rax,[rax] 的操作码不同 (8d 与 8b), 这也印证了 lea 只计算有效地址而不访问内存.

RIP 相对寻址

lea 的位移是 0x203f, 而 CPU 执行该指令时 rip 已指向下一条指令, 即 0x401007:

有效地址 = 0x401007 + 0x203f = 0x403046

与 objdump 注释中的 # 403046 <pp> 一致. 这个位移是链接时算出的, 代码里并没有写入 pp 的绝对地址, 这就是取地址指令中所说的位置无关寻址.

据此还能反推出 .data 的布局: pp 位于 msg+22, 所以 msg 在 0x403030, p 在 0x40303e, 即:

符号 地址 内容
msg 0x403030 "Hello, world!\n"
p 0x40303e 0x403030 (即 &msg)
pp 0x403046 0x40303e (即 &p)

这些地址由链接器决定, 不同工具链下可能不同, 但三者之间的相对偏移 14 与 8 是固定的. 可以用 objdump -s -j .data mem.out 查看 .data 的原始字节来验证: p 与 pp 处应分别是 &msg 与 &p 的小端表示:

> objdump -s -j .data mem.out

mem.out:     文件格式 elf64-x86-64

Contents of section .data:
 403030 48656c6c 6f2c2077 6f726c64 210a3030  Hello, world!.00
 403040 40000000 00003e30 40000000 0000      @.....>0@.....

其他值得注意的点

  • .set msg_len, . - msg 中的 . 是汇编器的位置计数器, 两个同一节内标号之差在汇编期即可算出. 它是一个常量, 因此 mov rdx, msg_len 编码成立即数. 该行必须紧跟在 msg 后面.

  • .quad msg 在文件里存放的是 msg 的地址, 该值要等链接器确定了地址后才能填入, 属于重定位. 静态链接的 ld 下可直接使用; 若要生成位置无关可执行文件 (PIE), 则需要运行时重定位.

  • .data 与 .text 是常见节区之二. 本例的 .data 是可写的, 所以也可以用 mov qword ptr [rip+p], 0 修改指针.

  • 系统调用约定与最简汇编相同, 只是多了 write (调用号 1), 其参数依次放在 rdi, rsi, rdx. 相关背景见系统调用.

  • 可以用 gdb 观察指针链: 在 _start 处断点后, 依次 x/gx &pp, x/gx &p, x/s &msg, 即可逐级验证每一层存放的值.

常见误区

  • 把 mov reg, [label] 当作取地址: 它读出的是 label 处的内容. 取地址要用 lea reg, [rip+label].

  • 忽略访问宽度: 用 64 位寄存器读取单个字符, 会连同后面 7 个字节一起读入.

  • 对宽度不明的内存操作数不写 ptr 标注: 如 mov [rdi], 1 无法确定写 1, 2, 4 还是 8 字节, 汇编器会报错或产生歧义.

  • 把数据当作地址: CPU 不会检查, 只会照做并在非法地址处触发段错误.