指针的运算:算术、比较与数组名的秘密

指针的运算,是C语言里最让人又爱又恨的部分。爱它,是因为灵活;恨它,是因为太容易踩坑。今天我们就来彻底聊透这个话题。

一、指针的算术运算

指针的加减运算,不是简单的地址加减。它遵循一个核心规则:移动的单位是指向类型的大小

举个例子。一个 int* 指针,加1,地址增加4(假设int占4字节)。一个 char* 指针,加1,地址增加1。这背后的逻辑,说白了就是让指针能正确跳到下一个元素。

核心公式:ptr + n 的实际地址 = 当前地址 + n × sizeof(指向的类型)

int arr[] = {10, 20, 30, 40, 50};
int *p = arr;          // p指向arr[0]

p++;                   // 现在p指向arr[1]
printf("%d\n", *p);    // 输出20

p += 2;                // 现在p指向arr[3]
printf("%d\n", *p);    // 输出40

p--;                   // 现在p指向arr[2]
printf("%d\n", *p);    // 输出30

我在项目中遇到过一个问题:有人用 void* 指针做算术运算,结果编译报错。为什么?因为 void 类型的大小是未知的,编译器不知道移动多少字节。嗯,这里要注意,void* 不能直接做加减运算,除非先强制转换成具体类型。

二、指针的自增与自减

++-- 操作符,在指针语境下,行为很微妙。尤其是和 * 操作符组合时,优先级问题经常让人头疼。

int arr[] = {1, 2, 3, 4, 5};
int *p = arr;

int a = *p++;   // 等价于 *(p++),先取*p的值,然后p自增
// a = 1, p现在指向arr[1]

int b = (*p)++; // 先取*p的值,然后让这个值自增
// b = 2, arr[1]变成3

int c = *++p;   // 等价于 *(++p),先p自增,再取*p的值
// c = 3, p现在指向arr[2]

我个人习惯,在复杂表达式中,宁愿加括号也不去记优先级。你想想看,代码是给人读的,清晰比炫技重要得多。

我曾经在维护一段老代码时,看到一行 while (*dst++ = *src++),当时觉得写得很帅。后来调试一个内存越界问题,发现就是这种紧凑写法导致边界条件没处理好。从那以后,我写指针操作时,宁可多写几行,也要保证逻辑一目了然。

三、指针的比较

指针之间可以用关系运算符比较:==!=<><=>=。但有个前提:只有指向同一个数组(或同一块连续内存)的指针,比较才有意义

int arr[5] = {0};
int *p1 = &arr[1];
int *p2 = &arr[3];

if (p1 < p2) {
    printf("p1在p2前面\n");  // 这个输出
}

// 下面这种比较,行为是未定义的
int a, b;
int *pa = &a;
int *pb = &b;
if (pa < pb) {  // 未定义行为!
    // 不要这样做
}

为什么不同对象的指针比较是未定义行为?因为内存布局是编译器决定的,不同变量可能在栈上相邻,也可能相隔很远。比较结果没有实际意义。

指针和 NULL 的比较是安全的,也是常用的。我习惯在释放内存后,把指针置为 NULL,这样后续判断就方便了。

int *p = malloc(sizeof(int) * 10);
if (p == NULL) {
    // 处理分配失败
}
// ... 使用 ...
free(p);
p = NULL;  // 好习惯

四、指针与数组名的关系

这是C语言里最容易混淆的地方。数组名,到底是不是指针?

答案是:数组名不是指针,但在大多数表达式中,它会退化为指向首元素的指针

int arr[5] = {1, 2, 3, 4, 5};

printf("%p\n", arr);   // 输出数组首地址
printf("%p\n", &arr[0]); // 和上面一样

printf("%zu\n", sizeof(arr));   // 输出20(5个int)
printf("%zu\n", sizeof(&arr[0])); // 输出8(指针大小,64位系统)

看到了吗?sizeof(arr) 得到的是整个数组的大小,而不是指针的大小。这说明数组名本身不是指针。但当你把 arr 赋值给指针时,它退化为指针。

一个小技巧:用 &arr 得到的是指向整个数组的指针,类型是 int (*)[5]。而 arr 退化为 int*。两者指向的地址相同,但类型不同,做加减运算时步长不同。

int arr[5] = {0};
int *p = arr;       // p是int*,步长4字节
int (*pa)[5] = &arr; // pa是int(*)[5],步长20字节

p++;   // 地址增加4
pa++;  // 地址增加20

我在项目中见过一个bug:有人用 sizeof(arr) / sizeof(int) 计算数组长度,但 arr 已经作为函数参数传进去了。函数参数里的数组声明,实际上是个指针。结果算出来的长度永远是1或2(指针大小除以元素大小)。

我曾经在一个通信协议解析模块里,看到同事用 sizeof(buf) 来获取缓冲区大小,但 buf 是函数参数。结果缓冲区溢出,导致堆栈被破坏,查了两天才定位到问题。教训是:数组作为函数参数时,一定要把长度也传进去。

五、知识体系总览

下面这张图,把指针运算的核心逻辑串起来了。我建议你仔细看看,尤其是箭头指向的关系。

指针运算 算术运算 p + n → 地址 + n * sizeof(类型) p++ / p-- → 移动一个元素 指针相减 → 元素个数差 比较运算 == / != → 是否指向同一位置 < / > → 同一数组内比较 与NULL比较 → 判空 数组名关系 数组名 ≠ 指针 大多数情况退化为指针 &arr 得到数组指针 核心原则 指针运算的步长 = 指向类型的大小 不同对象的指针比较是未定义行为

六、避坑总结

指针运算的坑,我踩过不少。这里列几个最常见的,你写代码时多留个心眼。

  • 越界访问:指针加减后,一定要确保还在合法范围内。C语言不检查边界,越界了也不会报错,但后果很严重。
  • 野指针:指针没有初始化,或者指向的内存已经释放。这种指针做运算,结果不可预测。
  • 类型不匹配int*char* 混用,步长不同,容易算错地址。
  • 数组名作为函数参数:记住,传进去的是指针,不是数组。用 sizeof 会得到指针大小。

我个人习惯:每次做指针运算前,先问自己三个问题:指针指向哪里?移动后指向哪里?那个位置合法吗?养成这个习惯,能避免90%的指针相关bug。

指针运算,说白了就是地址的算术。但加上类型信息后,它变得既强大又危险。理解清楚每一步的语义,写出来的代码才能既高效又安全。

公众号:蓝海资料掘金营,微信deep3321