第 1 章 · 指针与内存模型

这门课的起点,是我大一被卡了一整周的一句话:「指针就是存地址的变量」。字面意思全都认识,连起来就是不明白,直到有天在调试器里第一次亲眼看了一眼内存,才豁然开朗。本章的任务就是把那次「看见」复刻给你:内存到底长什么样、地址是什么、指针存的是什么、* 和 & 各自在干什么。后面十三章的一切(栈帧、堆、野指针、函数指针)都建立在这一章的图景上,值得慢读。

1.1 内存是一条有门牌的街

把进程的内存想象成一条极长的街道,每个格子 1 字节,每格有唯一门牌号:

地址      内容
0x1000    [  ]
0x1001    [  ]
 ...            ← 每个格子 1 字节,门牌号就是地址
0x1008    [44 43 42 41]   ← 变量 x(int,4 字节)住在这
0x100C    [  ]

一个 int 要占 4 格,它住在哪?住首格,它的地址就是首格的门牌号 0x1008。「取地址」&x 问的就是这件事:

int x = 42;
printf("%p\n", (void*)&x);    // 输出类似 0x7ffd12345678

(地址每次运行都可能不同,那是操作系统的 ASLR 在随机化,计组课第 10 章提过。你自己的机器上跑,数字必然和我这里的例子不同,这正说明它是个真实的物理位置,不是编出来的概念。)

指针就是存这个门牌号的变量:

int  x = 42;
int *p = &x;      // p 里装着 x 的地址
*p = 10;          // 「顺着 p 里存的地址找过去,把那里改成 10」——x 变成 10
printf("%d\n", x);   // 10

*p = 10 和 x = 10 改的是同一块内存:*p 就是「x 的另一个名字」。初次理解这句话,建议用调试器(gdb 的 p &x、p p)亲眼看一次两个地址相等,胜过读十遍定义。

1.2 类型决定「读几格、怎么解释」

地址本身只是一串编号,「从这里开始读几个字节、当成什么读」全由指针的类型说了算:

int x = 0x41424344;
char *c = (char *)&x;
printf("%c\n", c[0]);     // 输出 'D'

x 在内存里的字节(小端机器)是 44 43 42 41,int* 读它解释成整数,char* 读同一个地方只取 1 字节解释成字符。内存里的位模式纹丝没动,变的只是解释方式。这个实验是「类型系统是内存的解释协议」的最短证明,也是后面所有强转、别名(第 14 章)话题的种子。

1.3 指针自己也要占地方

一个常被忽略的事实:指针变量本身也是个对象,要占内存。64 位平台上不管指向什么:

printf("%zu %zu %zu\n", sizeof(int*), sizeof(char*), sizeof(void*));
// 8 8 8

三种指针都是 8 字节(存 64 位地址),和指向的类型大小无关。「int* 比 char* 大」是初学者的高频误解,顺手破掉。数据结构课第 12 章算的「指针税」(每个链表节点多 8 字节)就是这笔账的应用。

指针还有对齐要求:int* 指向的对象应落在 4 字节对齐的地址上。把奇数地址强转成 int* 再解引用,在多数架构上直接 SIGBUS(总线错误),第 14 章会作为未定义行为正式立案。

1.4 空指针与「不指向」

指针可能不指向任何合法对象,这时赋 NULL(C23 起也有真正的 nullptr 关键字):

int *p = NULL;
if (p) { ... }        // 惯用法:NULL 的假值判断

两个必须分清的概念:空指针(明确的「不指向」,值确定)和未初始化指针(垃圾值,可能指向任何地方)。后者比前者危险得多,解引用 NULL 大概率干净利落地段错误,解引用野指针可能「看起来没事」(垃圾地址恰好在可读区域),把雷埋给未来(第 14 章)。所以 C 的纪律是:指针声明即初始化,暂时没有目标就先置 NULL,让「没指向」变成可检测的状态。

1.5 void*:类型擦除的通用插座

void* 只存地址、不带类型,任何对象指针都能隐式转成它,它也只负责「搬运地址」,不能解引用(编译器不知道该读几格)也不能做算术:

void *mem = malloc(64);   // malloc 返回 void*:「一块 64 字节的空地,你自己解释」
int *arr = mem;           // C 里隐式转回 int*,合法(C++ 必须显式强转)

它是 C 语言实现「泛型」的土办法:qsort 的比较函数收 void*、线程函数参数是 void*,都是同一招。代价是类型安全全靠程序员自觉,转错类型编译器不拦,这是第 14 章 UB 清单上的常客。

1.6 和汇编、其他语言对个账

最后把指针翻译回你已经认识的东西。计组课第 10 章的汇编里,mov eax, [rbx] 的方括号就是解引用,r 里放的就是指针;lea 取的是「算出来的地址」正是 &。Java 的对象引用其实也是指针,只是 JVM 把地址藏起来不让运算、配上 GC 管生死;Python 的变量是「名字到对象的绑定」,id() 看到的就是那个地址。所以「指针」从来没有离开过你,只是 C 把它交到你手里,别的语言把它锁在柜子里。这门课的任务就是练好「拿着裸指针不伤手」的基本功。

常见错误

  • * 的一词多义没分清。int *p = &x; 里的 * 是「声明 p 是指针」(类型的一部分),*p = 10 里的 * 是解引用(一个操作)。两个 * 语义不同,初读最绕,写的时候想着「int* 是类型」就顺了。
  • 解引用未初始化指针。int *p; *p = 1; p 里是垃圾,写进随机地址,轻则段错误,重则悄悄改了别的数据。声明即初始化。
  • 打印指针用 %d。指针要用 %p 且强转 (void*),用 %d 在 64 位机上截断地址,输出的「地址」是错的。

练习

  1. 动手:写程序声明 int、double、char 变量各一个,打印它们的地址(%p)和 sizeof,画一张「谁住哪」的示意图,验证同类型变量的地址间距。
  2. 动手:用 char * 逐字节打印一个 int 的 4 个字节(十六进制),判断你的机器是大端还是小端,和计组课第 2 章的理论对上。
  3. 手算:int *p = NULL; printf("%d", *p); 的运行结果是什么?哪一层(编译、链接、运行)出错?错误信息长什么样(跑一次记录原文)。
  4. 思考:Java 说「没有指针」,但它有 NullPointerException。用本章的语言解释:Java 引用和 C 指针的共同点、差异点各是什么?(提示:存地址这件事一样,能不能运算、有没有 GC 不同。)