Skip to content

指针算术:p+1 不是走 1 个字节,是走 1 个元素 ​

引言:把指针和数组接上 ​

第 1 章咱们把指针立成了“装地址的变量”,但刻意没碰“指针加减”“指针比较”这些运算,因为它们最容易让人栽的坑,全在“步长”上。这一章就把指针算术讲清楚,并把它和第 10 章的数组正式缝起来。您会发现,第 10 章那个“a[i] === *(a+i)”根本不是巧合、它是 C 语言设计的地基;而“用指针遍历数组”“两个指针求距离”都是这个地基上自然长出来的写法。指针算术是 C 区别于很多语言的特色(多数高级语言根本不让咱们对“地址”做加减),也是后面字符串遍历、动态数组、内存拷贝的通用工具。

指针加减整数:步长是“一个元素”,不是“一个字节” ​

指针能加减整数,但加的不是“字节数”、而是“元素个数”,具体跨多少字节,由指针的指向类型决定。ISO §6.5.6p8 说:指针加 n,等于把地址往后挪 n × sizeof(指向类型) 个字节。咱们真跑看最直观:

C
#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) 完全等价。理解了这一点,“指针遍历数组”就是顺水推舟的事。

咱们把这步“跨过一个元素”动画化——下面的演示可以播放、暂停、单步,您一步步看 int* p 在 int numbers[4] 上怎么走:

size = 0 capacity = 0
开场 · 0.0s/65.6s

用指针遍历数组 ​

既然 p+1 指向下一个元素、指针之间还能比较大小,那咱们遍历数组根本不用下标,直接拿指针走:

C
#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 = 150

for (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,是个有符号整型):

C
#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 安全:

C
#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 3

end = 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 小节
  • C 语言基底·第 10 章:数组(退化、a[i]≡*(a+i)、越界 UB)、C 语言基底·第 2 章:整型与 sizeof(size_t/%zu)、第 1 章:指针是什么(指针类型决定走几字节)
  • 开发环境·第 5 章:编译看汇编(数组连续存储、地址布局);第 5 章:指针/数组/字符串统一视角;第 11 章:void* 与字节操作

87bb5f2 · 87bb5f2 · 2026-09-21