指针算术:p+1 不是走 1 个字节,是走 1 个元素
引言:把指针和数组接上
第 1 章我们把指针钉成了「装地址的变量」,但刻意没碰「指针加减」「指针比较」这些运算——因为它们最容易让人栽的坑,全在「步长」上。这一章就把指针算术讲透,并把它和第 10 章的数组正式缝起来。你会发现,第 10 章那个「a[i] === *(a+i)」根本不是巧合、它是 C 语言设计的地基;而「用指针遍历数组」「两个指针求距离」都是这个地基上自然长出来的写法。指针算术是 C 区别于很多语言的特色(多数高级语言根本不让你对「地址」做加减),也是后面字符串遍历、动态数组、内存拷贝的通用工具。
指针加减整数:步长是「一个元素」,不是「一个字节」
指针能加减整数,但加的不是「字节数」、而是「元素个数」——具体跨多少字节,由指针的指向类型决定。ISO §6.5.6p8 说:指针加 n,等于把地址往后挪 n × sizeof(指向类型) 个字节。真跑看最直观:
#include <stdio.h>
int main(void) {
int a[5] = {10, 20, 30, 40, 50};
int* p = a; /* p 指向 a[0] */
printf("p = %p\n", (void*)p);
printf("p + 1 = %p (往后 1 个元素 = 4 字节,不是 1 字节)\n", (void*)(p + 1));
printf("*(p+1) = %d, p[1] = %d (二者等价:p[i] === *(p+i))\n", *(p + 1), p[1]);
return 0;
}$ gcc -std=c11 -Wall arithmetic.c -o ar && ./ar
p = 0x7ffee107aed0
p + 1 = 0x7ffee107aed4 (往后 1 个元素 = 4 字节,不是 1 字节)
*(p+1) = 20, p[1] = 20 (二者等价:p[i] === *(p+i))p 指向 a[0](地址 0x...aed0),p + 1 是 0x...aed4——加了 4(十六进制 aed0→aed4),正好是一个 int 的 4 字节,而不是 1 字节。这就是关键:int* 的指针加 1,跨过一个 int(4 字节);换成 char* 加 1 只跨 1 字节、double* 加 1 跨 8 字节。步长 = sizeof(指向类型),编译器根据指针类型自动算好——这正是第 1 章说的「指针类型决定走几字节」的兑现,也解释了 void* 为什么不能直接做算术(void 没大小、步长无从确定,必须先转成 char* 之类的具体类型)。
最后一行打印 *(p+1) 和 p[1] 都是 20。这其实就是第 10 章那条「a[i] === *(a+i)」——p 指向 a[0],p+1 指向 a[1],解引用得 20;而 p[1] 本来就定义为 *(p+1)。下标和指针加法是同一件事的两种写法,数组名 a 退化成指向首元素的指针后(第 10 章),a[i] 和指针版本的 *(a+i) 完全等价。理解了这一点,「指针遍历数组」就是顺水推舟的事。
用指针遍历数组
既然 p+1 指向下一个元素、指针之间还能比较大小,那遍历数组根本不用下标,直接拿指针走:
#include <stdio.h>
int main(void) {
int a[5] = {10, 20, 30, 40, 50};
int sum = 0;
/* 用指针遍历数组(不用下标),p < a+5 是「还没越过末尾」 */
for (int* p = a; p < a + 5; p++) {
sum += *p;
}
printf("sum = %d\n", sum);
return 0;
}$ gcc -std=c11 -Wall traverse.c -o tr && ./tr
sum = 150for (int* p = a; p < a + 5; p++) 是 C 里极经典的写法:p 从指向 a[0] 出发,每轮 p++(指针加 1、跨一个元素),只要 p < a+5(还没走到第 5 个的位置)就继续,把 *p(当前元素)累加进 sum。循环跑完 sum 是 150(10+20+30+40+50),和用下标 for (i=0; i<5; i++) sum+=a[i] 完全等价。这种写法在标准库(strcpy、memcpy 的实现)和很多 C 代码里到处都是,因为它直接、且不依赖下标计数器。指针比较(p < a+5)要求两个指针指向同一个数组里的元素(§6.5.8),这是前提,跨数组的指针比较是没意义的。
两个指针相减:得「元素差」,类型是 ptrdiff_t
指针能加整数,那两个指针能相减吗?能——p - q 得到的是它们之间相隔几个元素(不是几个字节),结果类型是 ptrdiff_t(「指针差」,在 <stddef.h> 里定义,§7.19,是个有符号整型):
#include <stddef.h>
#include <stdio.h>
int main(void) {
int a[5] = {10, 20, 30, 40, 50};
int* p1 = &a[1];
int* p2 = &a[4];
ptrdiff_t d = p2 - p1; /* 指针相减:得「元素个数差」(不是字节数) */
printf("p2 - p1 = %td (a[4] 与 a[1] 相差 3 个元素)\n", d);
return 0;
}$ gcc -std=c11 -Wall diff.c -o df && ./df
p2 - p1 = 3 (a[4] 与 a[1] 相差 3 个元素)p2 指向 a[4]、p1 指向 a[1],p2 - p1 是 3——它们之间隔了 3 个元素(a[2]、a[3]、a[4]),不是 12 字节。指针相减自动除掉了 sizeof(int) 那个因子,给的是「元素个数」。打印 ptrdiff_t 要用 %td(就像 size_t 用 %zu、第 2 章见过),用错说明符又是类型不匹配的 UB。同样,指针相减只在两个指针指向同一数组(或同一对象的内存范围)时才有定义——跨数组的指针相减是 UB,标准(§6.5.6p9)把这件事严格限制在同一段连续内存内。
越界与 past-the-last:可以「指向」末尾之后,但不能「解引用」
指针算术有个特殊边界规则(§6.5.6p8):一个指向数组元素的指针,加到「数组末尾之后第一个位置」(叫 past-the-last)是合法的——但这个 past-the-last 指针只能用来「比较」、不能用来「解引用」。这正是为什么遍历写 p < a+N 而不是 p < a+N-1 安全:
#include <stdio.h>
int main(void) {
int a[3] = {1, 2, 3};
int* end = a + 3; /* past-the-last:合法地指向「末尾之后一位」 */
int* p;
for (p = a; p < end; p++) {
printf("%d ", *p); /* p 取 a[0]、a[1]、a[2],永远到不了 end */
}
printf("\n");
/* *end 是 UB:end 指向数组之外,可以「指向」但不能解引用 */
return 0;
}$ gcc -std=c11 -Wall past_end.c -o pe && ./pe
1 2 3end = a + 3 这个指针「指向 a[3]」——可 a 只有 a[0]、a[1]、a[2],a[3] 不存在。但标准允许这个 past-the-last 指针存在、并允许它参与比较(p < end 当循环条件完全合法),循环里 p 走到 a[0]/a[1]/a[2]、解引用都没问题,当 p 变成 end(等于 a+3)时 p < end 不成立、循环退出,所以永远不会去解引用 *end。打印出来正好 1 2 3。但如果你手贱写一句 *end——解引用 past-the-last 指针是 UB(§6.5.6p8 明说「不能解引用」),越界读,又是第 10 章 a[5] 那种坑的指针版。所以指针算术的安全边界记住一条:可以算到 a+N(含)来当哨兵,但 * 只对 a[0] 到 a[N-1] 用。
再多说一句边界:指针算术(加减、比较、相减)只在「同一个数组(或同一块连续对象)内部」才有定义。两个毫无关系的变量 int x, y; int* px=&x, *py=&y;,px - py 是 UB(它们不在同一个数组里)。标准把这些运算严格限制在同一段连续内存内,违反了这个前提,行为就未定义——这也是为什么指针算术几乎总是和数组、malloc 出来的连续块、字符串字面量这些「连续存储」绑在一起用。
小结
指针算术(§6.5.6)是 C 把「指针」和「数组」缝在一起的工具。指针加减整数,步长是「一个元素」不是「一个字节」——int* p 加 1 跨 4 字节、char* 加 1 跨 1 字节、double* 加 1 跨 8 字节,步长 = sizeof(指向类型)(真跑 p=0x...aed0、p+1=0x...aed4,差 4);所以 p[i] === *(p+i) 顺理成章(呼应第 10 章 a[i]≡*(a+i)),下标和指针加法是一回事。用指针遍历数组就靠这套:for (int* p=a; p<a+N; p++) sum+=*p;(真跑得 150),指针比较 p < a+N 当循环条件(§6.5.8,要求指针指向同一数组)。两个指针相减得「元素个数差」(不是字节数),真跑 &a[4]-&a[1] 得 3,结果类型是 ptrdiff_t(<stddef.h>,用 %td 打印,§7.19);指针相减、比较都只在「同一数组/同一连续内存」内才有定义,跨数组是 UB。边界规则(§6.5.6p8):可以指向「末尾之后一位」a+N(past-the-last)、并参与比较,但绝不能解引用——*(a+N) 是越界 UB;所以遍历写 p < a+N 安全(p 永远到不了 a+N),写 *(a+N) 就炸。指针算术几乎是「数组、字符串、动态内存、memcpy」的通用语言,下一章我们用指针做一件更实际的事——把它传进函数、让函数能改调用者的变量。
参考资源
- ISO/IEC 9899:2011 §6.5.6(加减运算符:p8 指针±整数的步长规则与 past-the-last 边界、p9 指针相减得
ptrdiff_t)、§6.5.8(关系运算符:指针比较要求同数组)、§6.5.2.1(下标p[i]≡*(p+i))、§7.19(<stddef.h>的ptrdiff_t) - K. N. King《C Programming: A Modern Approach》第 12 章 Pointers and Arrays(指针算术、用指针处理数组、
a[i]与*(a+i))、第 11 章(指针遍历) - Robert C. Seacord《Effective C》第 5 章·Pointer Arithmetic 小节
- 第 10 章:数组(退化、
a[i]≡*(a+i)、越界 UB)、第 2 章:整型与sizeof(size_t/%zu)、阶段2·第1章:指针是什么(指针类型决定走几字节) - 阶段 0·第 5 章:编译看汇编(数组连续存储、地址布局)、阶段2·第5章:指针/数组/字符串统一视角、第 11 章:void* 与字节操作