从汇编的角度理解指针
跟着同一个 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 软件开发手册。
交流与讨论