指针能做算术,这是 C 最像汇编的地方,也是 bug 最密集的地方。先看一个我真实踩过的坑:函数收到 void *data,里面是 int 数组,我想跳过第 3 个元素,写了 data + 3 然后强转读出来,结果永远是第 0 个元素后面第 3 个字节。原因:void* 和 char* 的步长是 1 字节,int* 的步长是 4。指针 +1 加的不是 1,是「一个元素」,这个缩放规则是本章的地基,本章也顺手把原文小节编号错乱(2.x)修正回来。
指针加减整数,编译器自动乘上指向类型的大小:
int a[5] = {10, 20, 30, 40, 50};
int *p = a;
p + 1; // 地址 +4 字节(int 占 4),指向 a[1]
char *c = (char *)a;
c + 1; // 地址 +1 字节,只前进一个字节
double *d = (double *)a;
d + 1; // 地址 +8 字节,越过了两个 int!
同一个地址,三种类型三种步长。p + n 的严格语义是「从 p 起第 n 个元素的位置」,等价于按字节算的 (char*)p + n * sizeof(*p)。想按字节走,显式转 char*;想按元素走,保持原类型。2.3 章那个坑的修法:(int *)data + 3。
C 标准规定下标运算符定义为 a[i] ≡ *(a + i),于是:
p[i] ≡ *(p + i) ≡ *(i + p) ≡ i[p] // 加法交换律,后者也是合法 C
i[p] 能编译能运行,这个冷知识的意义在于揭示本质:下标只是指针加法的糖,而加法可交换。顺带解开一个千古问题:数组下标为什么从 0 开始?a[0] = *(a + 0) = 首元素本身,若从 1 开始,a[1] = *(a+1) 就要跳过第一个元素,需要额外的 −1 补偿。0 起步是「地址直接可用」的自然结果,不是习惯问题。
同一数组内的两个指针相减,结果是元素个数(类型 ptrdiff_t):
int a[5] = {10, 20, 30, 40, 50};
int *p = &a[1], *q = &a[4];
printf("%td\n", q - p); // 3,不是 12
相减自动除以步长,这个特性让「距离」的计算和类型无关地自然。两个红线:相减只在同一数组(或同一对象)内有定义,不同数组的指针相减是未定义行为(第 14 章立案);比较同理,< > 只对同数组指针有意义,==/!= 则任意指针都比得了。
有了加减和比较,数组的指针版遍历就齐了:
for (int *p = a; p < a + 5; p++) // p 是「游标」,a+5 是终点
printf("%d ", *p);
它和 for (i = 0; i < 5; i++) a[i] 生成几乎相同的汇编(p++ 就是地址加 4),选哪种纯看可读性。
指针运算最锋利的边界:算出一个越界指针本身,就已经是未定义行为(不用等解引用)。唯一的合法例外是 one-past-the-end:
int a[5];
int *end = a + 5; // 合法:末尾后一格,可比较、可作循环终点
int *bad = a + 6; // UB:多走了一步
int *also = a - 1; // UB:往前越界同样不行
*end; // UB:合法的地址,不合法的解引用
one-past-the-end 的存在是为了让 p != end 的循环终止条件成为可能(C++ 迭代器的 end() 继承了这个设计)。越界 UB 的阴险在于「不一定当场崩」:越界读个相邻变量,程序看起来一切正常,雷埋下了;更狠的是编译器按「越界不会发生」做优化(计组课第 2 章讲过 UB 的本质),循环边界检查被删掉,防护代码形同虚设。
标准 C 里 void* 不能加减(不知道步长),GCC/Clang 当扩展允许它按 1 字节走,可移植代码别赌这个。要按字节操作,先转 char*:
void *mem = malloc(64);
char *c = mem; // 按字节处理的正确姿势
c + 1; // 合法
<= 写循环终点。for (p = a; p <= a + 5; p++) 解引用到 a[5],恰好踩进 one-past-the-end 的雷区,数组后若紧邻其他变量,悄悄污染它们。int a[4][3]; int *p = &a[0][0]; 要访问 a[2][1],指针写法是什么(算清偏移 2*3+1)?再用 *(a[2] + 1) 验证两条路等价。