← 所有文章

从汇编的角度理解指针

跟着同一个 C 程序观察 a、p 和 *p:从 LEA 与 MOV 开始,理解取地址、解引用、函数传参和指针步长。

本文目录

下面两行代码看起来只差一个星号,做的事却完全不同:

p = &b;
*p = 200;

第一行改变 p 保存的地址,第二行修改那个地址所指向的对象。如果这句话还是有点绕,我们就把 C 翻译成汇编,看数据到底去了哪里。

本文用常见的 x86-64 环境作示例,假设 int 占 4 字节、对象指针占 8 字节。汇编片段采用 Intel 风格,栈偏移是便于讲解的示意值,不是所有编译器都会生成的固定布局。

一、先认清三个不同的东西

#include <stdio.h>

int main(void) {
    int a = 100;
    int b = 300;
    int *p = &a;

    printf("%d %d\n", a, *p);
    *p = 200;
    printf("%d %d\n", a, b);
    p = &b;
    *p = 400;
    printf("%d %d\n", a, b);
    return 0;
}

输出是:

100 100
200 300
200 400

把第一段初始化后的状态画出来:

p 保存的值 ───→ a 所在的位置:[100]
                b 所在的位置:[300]

这里有三个概念:p 是一个指针变量;p 保存的是 &a;*p 指定的是 a 这个对象。指针变量本身和它指向的对象,是两块不同的存储。

所以 *p = 200 修改 a,p = &b 改变箭头方向,之后的 *p = 400 才会修改 b。

二、取地址:LEA 为什么没有读出 100?

假设编译器把 a 放在 [rbp-4],把 p 放在 [rbp-16],初始化可以示意为:

mov DWORD PTR [rbp-4], 100
lea rax, [rbp-4]
mov QWORD PTR [rbp-16], rax

第一条 mov 写入整数 100。第二条 lea 计算 rbp-4 这个地址,放到 rax;第三条把地址写进 p 的存储位置。

把中间一条换一下,区别就出来了:

lea rax, [rbp-4]           ; 得到位置:&a
mov eax, DWORD PTR [rbp-4] ; 读取内容:a

虽然都写了方括号,lea 只计算有效地址,不读取该地址里的数据。mov 在这里才会访问内存。LEA 也因此可以被编译器用来做某些整数运算。

在通常的用户态进程中,这里处理的是虚拟地址,不能把它直接称为内存条上的“物理坐标”。

三、解引用:沿着地址找到对象

如果 p 此时保存在栈上,读取 *p 可以是:

mov rax, QWORD PTR [rbp-16] ; 读取 p 保存的地址
mov eax, DWORD PTR [rax]   ; 读取该地址上的 int

这段示意代码有两次内存读取,但不意味着每次解引用都必须如此。如果 p 已经在 rax 中,第二条指令就够了;如果编译器知道 p 始终指向 a,还可能直接使用 a 的值。

写入也遵循同一条路径:

mov rax, QWORD PTR [rbp-16]
mov DWORD PTR [rax], 200

解引用的关键是通过地址访问对象。具体需要多少条指令、多少次内存访问,要看地址在哪里,以及编译器知道多少信息。 它不是控制流意义上的“跳转”。

四、传指针时,究竟复制了什么?

看两个函数:

void set_value(int value) {
    value = 200;
}

void set_target(int *pointer) {
    *pointer = 200;
}

set_value(a) 把 a 的数值复制给形参,修改形参不会修改 a。set_target(&a) 把 a 的地址复制给形参,函数通过这个地址修改 a。

C 的这两种调用都是值传递,只是第二种复制的值是指针。

在 x86-64 的 System V ABI 下,第一个整数或指针参数通常通过 rdi/edi 传递。于是第二个函数可以很短:

set_target:
    mov DWORD PTR [rdi], 200
    ret

Windows x64 的第一个这类参数使用 rcx/ecx,相应的内存操作会是 [rcx]。寄存器不同,复制地址并访问对象的关系相同。Microsoft x64 调用约定

再看一个常见疑问:

void redirect(int *pointer, int *other) {
    pointer = other;
}

这个函数不会改变调用方的 p,因为它只改了形参里那份地址副本。想修改调用方的指针变量,可以把上面的函数改为接收 p 自己的地址:

void redirect(int **pointer, int *other) {
    *pointer = other;
}

/* 在 main 中:redirect(&p, &b); */

二级指针并没有增加新的规则:先找到 p 这个对象,再把它保存的地址换掉。

五、类型决定读多宽、走多远

CPU 的内存读写指令需要知道操作宽度。编译器根据 C 类型选择合适的指令:在本文环境中,读取 char 通常涉及 1 字节,读取 int 涉及 4 字节。

指针加法也依赖类型:

int values[3] = {10, 20, 30};
int *p = values;
int next = *(p + 1);

p + 1 指向下一个 int,因此地址增加 4 字节,next 得到 20。若 p 已经在 rax 中,访问可以示意为:

mov eax, DWORD PTR [rax + 4]

结构体成员也是相似的过程:

struct Point { int x; int y; };

在本文常见布局下,y 位于结构体起始位置之后的 4 字节处,point->y 就可以使用基址加偏移量访问。其他结构体可能有填充,实际偏移应由 offsetof 或编译器布局确定。

类型不只影响指令宽度,也约束什么访问是合法的。指针运算通常必须留在同一个数组对象及其尾后位置的范围内;尾后指针可以用于比较,不能解引用。对象生命周期结束后,原来的地址值也不会替你延长对象的寿命。

六、亲手看看编译器生成了什么

把开头的程序保存为 pointer.c。在已安装 GCC 的环境中,可以运行:

gcc -O0 -fno-omit-frame-pointer -S -masm=intel pointer.c -o pointer-O0.s
gcc -O2 -S -masm=intel pointer.c -o pointer-O2.s

先找 lea 与通过寄存器间接访问内存的 mov,再比较两个文件。你可能发现,优化后的代码里,有些局部变量和指针操作完全消失了:编译器已经知道最终要打印哪些值。

这不是例子失效,而是另一个值得观察的事实:C 代码描述的是程序行为,汇编是编译器在目标平台上实现这种行为的一种结果。

指针在 C 中有自己的类型规则,到了常见的机器代码里,则表现为地址计算和内存访问。把这两个层次连起来,p、&p 和 *p 就有了各自明确的位置。

语言层面的细节可对照 C11 委员会草案 N1570 的 6.2.5、6.5.2.2、6.5.6 节;指令层面可查阅 Intel 软件开发手册。

— 本文完 —

有不同的想法?欢迎在下方交流,或写封邮件。

继续翻阅其他笔记 ↗

交流与讨论