指针是什么:一个变量,装着另一个变量的地址
引言:阶段2 的门槛
阶段1 我们写了 12 章,刻意绕开了指针——只在第 10 章数组那里瞥过一眼「数组名退化成指针」,在字符串、IO 里也时不时要写 &a[i]、char*。可指针这东西,你越绕它、它越吓人。阶段2 我们正面攻它,而攻下它的钥匙只有一句话:指针不神秘,它就是一个「装着地址」的变量。这一章先不碰指针算术、不碰堆内存、不碰多级指针,就把「指针 = 地址」这一个事实钉死——把 &(取地址)和 *(解引用)这两个运算符、指针的类型和大小、NULL 和野指针这几个根基概念真跑通。后面 11 章全建立在这个根基上。
从机器说起:变量有地址,指针装地址
要理解指针,得先在脑子里画一幅内存的图。计算机的内存(RAM)被分成一个个字节,每个字节能存 8 个比特,而且每个字节都有一个独一无二的编号、叫它的地址(从 0 开始往后数)。你声明一个变量,比如 int x = 42;,编译器就在内存里给它分配若干字节(int 通常 4 字节),这 4 个字节里第一个的地址,就叫**「x 的地址」**(§6.2.5 说变量的存储是有地址的)。
那「指针」是什么?ISO/IEC 9899:2011 §6.2.5p1 规定,指针类型是一种派生类型——「一个指针类型可以从一个对象类型派生,它指向那个类型的对象」。翻译成人话:指针变量就是一个专门用来「存地址」的变量。普通变量 int x 存的是一个整数 42;指针变量 int* p 存的是「某个 int 变量的地址」。如果 p 里存的是 x 的地址,我们就说「p 指向 x」。仅此而已——指针就是地址,没有别的玄机。int* 里的那个 * 是「这是个指针」的标记,int 是「它指向的东西是 int 类型」。
& 取地址,* 解引用
C 给指针配了一对运算符(§6.5.3.2):&(取地址,address-of)拿一个变量的地址、*(解引用,indirection)拿一个指针、顺着它存的地址去访问那个对象。一对真跑就懂:
#include <stdio.h>
int main(void) {
int x = 42;
int* p = &x; /* p 存 x 的地址,「指向」x */
printf("x = %d\n", x);
printf("*p = %d\n", *p); /* 通过地址读 x */
printf("&x = %p\n", (void*)&x); /* x 的地址 */
printf("p = %p\n", (void*)p); /* p 里存的地址,和 &x 一样 */
*p = 100; /* 通过地址改 x */
printf("改 *p 后: x = %d\n", x);
return 0;
}$ gcc -std=c11 -Wall basic.c -o b && ./b
x = 42
*p = 42
&x = 0x7ffe588a669c
p = 0x7ffe588a669c
改 *p 后: x = 100int* p = &x; 把 x 的地址存进 p,于是 p 指向 x。读 *p 就是「顺着 p 里那个地址、把那块内存当 int 读出来」,得 42(和 x 一样)。&x 和 p 用 %p 打印出来是同一个地址 0x7ffe588a669c——这正是「p 存的就是 x 的地址」的实锤(%p 是专门打印指针/地址的转换说明符,§7.21.6.1p8,注意它要求传 void*,所以要 (void*) 转一下;那个地址值每次运行都不一样,这是操作系统的 ASLR 地址随机化在做事,但 &x 和 p 一定相等)。
最关键的一行是 *p = 100;——它通过指针去改了 x,所以最后一行打印 x 已经是 100 了。一旦 p 指向 x,*p 就是 x 的别名:读 *p 等于读 x、写 *p 等于写 x。这就是指针最根本的能力——隔着地址去动另一个变量,第 3 章「用指针改调用者的变量」全靠这一招。
上面是书里的真跑。想自己改指针、看
x怎么变?试试这个(点「运行」就行;把*p = 100改成别的值、或让p指向另一个变量y,看x和*p的关系——这就是「*p是x的别名」):
Compiler Explorer
亲手玩:*p 是 x 的别名
int* p = &n 之后,*p 就是 n 的别名:读 *p 得 n 的值、改 *p 就是改 n。改改 *p 的值、或让 p 指向另一个变量,看 n 怎么跟着变。
* 和 & 是互逆的一对:& 把变量变成指向它的指针、* 把指针变回它指的变量。所以 *&x 这种怪写法,就是「取 x 的地址、再顺着地址取回那个对象」,等于 x 本身:
#include <stdio.h>
int main(void) {
int x = 7;
int y = *&x; /* * 是 & 的逆:*&x 就是 x */
printf("x = %d, *&x = %d\n", x, y);
return 0;
}$ gcc -std=c11 -Wall inverse.c -o inv && ./inv
x = 7, *&x = 7运行结果是 *&x = 7——因为 *&x 就是 x 本身,求出来的值自然和 x 一样、也是 7。工程里没人真写 *&x(多此一举),这里只是用它印证「* 是 & 的逆」这件事。理解了这一点,你看到 *p 就能立刻反应过来:它是「p 指向的那个对象」、是个左值(能被赋值),和直接写那个对象是一回事。
指针的类型与大小
声明指针时必须写明「指向什么类型」:int* p(指向 int)、char* s(指向 char)、double* q(指向 double)。这个「指向类型」有意义吗?有,而且很关键——它决定解引用时读几个字节:*p 当 p 是 int* 时读 4 字节、当 char* 时只读 1 字节(指针算术的步长也由它定,下一章细讲)。但有趣的是,指针自己的大小和指向什么类型无关:
#include <stdio.h>
int main(void) {
printf("sizeof(int*) = %zu\n", sizeof(int*));
printf("sizeof(char*) = %zu\n", sizeof(char*));
printf("sizeof(double*) = %zu\n", sizeof(double*));
printf("sizeof(void*) = %zu\n", sizeof(void*));
/* 64 位机上,指针大小和它指向什么类型无关——存的都是地址 */
return 0;
}$ gcc -std=c11 -Wall size.c -o sz && ./sz
sizeof(int*) = 8
sizeof(char*) = 8
sizeof(double*) = 8
sizeof(void*) = 8四个全 8。因为不管 int* 还是 char*,指针变量里装的都只是一份地址(一个编号),地址的宽度由机器的地址空间决定、和「指向的东西多大」无关。这台机器是 64 位的(地址总线 64 位),所以任何指针都是 8 字节(64 位);在 32 位机上所有指针都是 4 字节。这一点常被新手搞混——sizeof(int) 是 4、sizeof(int*) 是 8,因为前者是「一个 int 多大」、后者是「一个地址多大」,两码事。「指向类型」影响的是「解引用读几字节」「算术走几字节」,不影响指针本身的占地大小。
指针自己也是个变量,有自己的地址
这一步是很多人卡住的地方:指针变量自己也在内存里、它自己也有地址。p 存着 x 的地址,但 p 这个变量本身存在内存某处,那块内存的地址是 &p——它和 p 里存的值(x 的地址)是两回事。真跑给你看:
#include <stdio.h>
int main(void) {
int x = 42;
int* p = &x; /* p 指向 x */
int** pp = &p; /* pp 指向 p(指针的指针,多级指针的引子) */
printf("&x = %p\n", (void*)&x);
printf("p = %p (p 的值 = x 的地址)\n", (void*)p);
printf("&p = %p (p 自己的地址,和 p 的值不一样)\n", (void*)&p);
printf("**pp = %d (顺着 pp -> p -> x)\n", **pp);
return 0;
}$ gcc -std=c11 -Wall ptr_of_ptr.c -o pp && ./pp
&x = 0x7ffe9f86df44
p = 0x7ffe9f86df44 (p 的值 = x 的地址)
&p = 0x7ffe9f86df48 (p 自己的地址,和 p 的值不一样)
**pp = 42 (顺着 pp -> p -> x)看仔细这三行地址:&x 是 x 的地址(df44),p 的值也是 df44(因为 p 存的就是 x 的地址),可 &p 是 df48——这是 p 这个变量自己在内存里的位置,和 p 里存的值(df44)完全不是一回事。栈上 x 和 p 这两个变量被安排在了相邻的位置(df44 和 df48,差 4 字节,因为 x 是 int 占 4 字节),但谁挨着谁、隔多远是实现细节,重点是「p 有自己的地址 &p」。
既然 p 自己有地址,那就可以再声明一个指针来存它的地址——这就是 int** pp = &p;(一个「指向 int* 的指针」,即指针的指针)。顺着它走两步:*pp 得到 p(一个 int*)、**pp 再解一层得到 x(得 42)。多级指针听着吓人,但本质就是「指针装地址、装的是另一个指针的地址」这同一件事的套娃,第 8 章会专门展开。这里先记住:指针是变量,变量就有地址——能想通这一句,多级指针、&p 这些就不晕了。
两个必崩的坑:NULL 与野指针
指针的能力(隔着地址动内存)也是它的危险——指错了地方,轻则乱写别人内存、重则当场段错误。两个最经典的坑,都源于「指针还没指向一个合法对象就被用了」。
第一个是 NULL 空指针。NULL(在 <stddef.h>/<stdio.h> 里定义,§7.19)是一个特殊的指针值,表示「明确不指向任何对象」。它常用来给指针一个「还没指向有效目标」的初始值、或表示「查无结果」(比如 strchr 找不到字符就返回 NULL,第 11 章见过)。但解引用 NULL 是未定义行为(§6.3.2.3p3,空指针不指向任何对象,当然不能解引用),真跑必崩:
#include <stdio.h>
int main(void) {
int* p = NULL; /* 空指针:明确不指向任何对象 */
*p = 42; /* UB:解引用空指针 */
return 0;
}$ gcc -std=c11 -Wall null.c -o nl && ./nl; echo $?
139没有任何输出,退出码 139(128 + 11,SIGSEGV 段错误)。地址 0 那一页被操作系统刻意映射成不可访问,CPU 一执行写就触发段错误。开 ASan 看得更直白:
$ gcc -std=c11 -Wall -fsanitize=address null.c -o nl_asan && ./nl_asan
==55412==ERROR: AddressSanitizer: SEGV on unknown address 0x000000000000
SUMMARY: AddressSanitizer: SEGV (.../nl_asan+0x11a4) in main
==55412==ABORTINGSEGV on unknown address 0x000000000000——地址 0 就是 NULL,ASan 直接告诉你「你在解引用地址 0」。所以拿到一个可能是 NULL 的指针(比如函数返回值),先判空再用是基本素养:if (p != NULL) { *p = ...; }。
第二个坑更阴险——未初始化的野指针。声明 int* p; 却没给它赋值,p 里的内容是内存里残留的垃圾,是个随机地址。这时去 *p 就是「往一个随机地址写」,是 UB:
#include <stdio.h>
int main(void) {
int* p; /* 未初始化:野指针,p 的值是内存里的垃圾地址 */
*p = 42; /* UB:解引用野指针 */
return 0;
}$ gcc -std=c11 -Wall uninit.c -o un
uninit.c:5:8: warning: 'p' is used uninitialized [-Wuninitialized]
5 | *p = 42; /* UB:解引用野指针 */
| ~~~^~~~gcc 的 -Wall 能在编译期就警告 'p' is used uninitialized——又一个 -Wall 救命的例子。这次裸跑它恰好也段错误了(垃圾地址落在不可写页),但请记住 UB 的本质:它可能崩、可能不崩、可能「成功」地把 42 写进了别人那块内存里、引发完全无关的诡异 bug——哪种结果都不保证,这才是 UB 最可怕的地方。野指针比 NULL 更难查,因为 NULL 至少会很快崩在地址 0、栈里能看到,野指针可能「静悄悄作恶」很久。所以指针的生命从「初始化」开始:要么 int* p = &x; 指向一个已知对象、要么 int* p = NULL; 明确置空、要么(第 6 章)int* p = malloc(...) 从堆要一块——绝不留一个未初始化的指针悬着。
小结
指针不是神秘事物,它就是一个「装着地址」的变量(§6.2.5p1,从对象类型派生的派生类型):内存分字节、每字节有地址,变量占若干字节、其首字节地址就是「变量的地址」,指针变量专门存这种地址。&(取地址,§6.5.3.2p3)拿一个变量的地址、*(解引用,§6.5.3.2p4)顺着指针里存的地址去访问那个对象——两者互逆(*&x 就是 x);一旦 int* p = &x,*p 就是 x 的别名(真跑读 *p 得 42、改 *p 让 x 变 100),这是「隔着地址动另一个变量」的全部魔力,也是第 3 章用指针改调用者变量的根基。指针必须写明指向类型(int*/char*),它决定解引用读几字节、算术走几字节,但指针自己的大小和指向类型无关——64 位机上 int*/char*/double*/void* 的 sizeof 全是 8(存的都是同样宽的地址)。要破除「多级指针」的迷雾,记住一句:指针自己也是变量、也有自己的地址(真跑 &p 和 p 的值不是一回事),于是可以有「指向指针的指针」int** pp = &p、**pp 顺着走两层(第 8 章展开)。两个必崩的坑:解引用 NULL 是 UB(地址 0 不可访问,真跑段错误 139、ASan 报 SEGV on address 0x000000000000),未初始化的野指针更阴险(gcc -Wuninitialized 能抓,但 UB 可能静悄悄乱写别人内存而不崩)——所以指针的生命从初始化开始:指向已知对象、或置 NULL、或从 malloc 要,绝不悬空。%p 是打印地址的转换说明符(要 (void*) 转一下,地址值随 ASLR 每次不同)。下一章我们把指针和第 10 章的数组接上——指针算术,看 p + n 怎么「跳 n 个元素」而不是 n 个字节。
参考资源
- ISO/IEC 9899:2011 §6.2.5p1(指针类型派生自对象类型)、§6.5.3.2(
&取地址 p3、*解引用 p4)、§6.3.2.3(指针转换:p2 整型 0 转空指针、p3 空指针不可解引用为 UB)、§7.19(<stddef.h>的NULL宏)、§7.21.6.1p8(%p转换说明符、要求void*) - K. N. King《C Programming: A Modern Approach》第 11 章 Pointers(11.1 指针变量的机器级含义、11.2
&与*运算符、指针必须初始化) - Robert C. Seacord《Effective C》第 2 章·Pointer Types 小节(指针类型、对象与指针的关系)
- 第 1 章:程序结构与编译四阶段(变量在内存里)、第 2 章:整型与
sizeof、第 10 章:数组(数组名退化为指针、a[i]≡*(a+i))、第 12 章:基础 IO(%p、&取地址给scanf、类型不匹配 UB) - 阶段 0·第 5 章:编译阶段看汇编(内存段布局)、第 11 章:Sanitizer 门禁(本章 NULL/野指针验证用 ASan)、阶段2·第 2 章:指针算术、第 8 章:多级指针