3. 指针运算与数组:指针加减、数组名退化、下标访问的底层原理

指针和数组,这对冤家在C/C++里纠缠了几十年。我见过太多人在它们身上栽跟头,包括我自己。刚入行那会儿,我写了个网络缓冲区,数组名当指针传来传去,结果内存越界查了整整两天。嗯,今天咱们就把这层窗户纸捅破。

3.1 数组名到底是个啥?

说白了,数组名就是数组首元素的地址。但这里有个坑——它不完全是指针。

核心区别:

  • 数组名是常量,不能自增自减
  • 指针是变量,可以随便改
  • 对数组名取sizeof,得到的是整个数组的大小
  • 对指针取sizeof,得到的是指针本身的大小(32位下4字节,64位下8字节)
int arr[5] = {1, 2, 3, 4, 5};
int *p = arr;

printf("sizeof(arr) = %zu\n", sizeof(arr));  // 20 (5 * 4)
printf("sizeof(p)  = %zu\n", sizeof(p));     // 8 (64位系统)

// arr++  // 编译错误!数组名不是左值
p++;       // 合法,p指向arr[1]

为什么会这样?因为数组名在大多数表达式中会「退化」成指向首元素的指针。但注意,sizeof和&操作符是例外。

3.2 指针加减的底层逻辑

指针加减,不是简单的整数加减。它是以「指向类型的大小」为步长的。我习惯叫它「类型感知的算术」。

int arr[5] = {10, 20, 30, 40, 50};
int *p = arr;

printf("p   = %p\n", p);     // 假设 0x1000
printf("p+1 = %p\n", p+1);   // 0x1004 (int占4字节)
printf("p+2 = %p\n", p+2);   // 0x1008

你想想看,如果指针指向的是结构体呢?

struct Data {
    int id;
    char name[32];
    double score;
};

struct Data arr[3];
struct Data *p = arr;

// p+1 的偏移量是 sizeof(struct Data),不是1个字节!
printf("sizeof(struct Data) = %zu\n", sizeof(struct Data));
// 在我的机器上是 48 字节(考虑对齐)

我曾经踩过的坑: 写网络协议解析时,把一个char*强制转换成struct header*,然后做指针加减。结果因为结构体对齐,算出来的偏移量全错了。从那以后,我处理二进制协议一定用memcpy或者union,绝不用指针强转。

3.3 下标访问的真相

很多人以为arr[i]是数组特有的语法。其实它就是个语法糖。

底层原理: arr[i] 等价于 *(arr + i)

这就解释了为什么你可以写i[arr]——虽然没人这么干,但语法上完全合法。

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

// 下面三种写法完全等价
printf("%d\n", arr[2]);     // 3
printf("%d\n", *(arr + 2)); // 3
printf("%d\n", 2[arr]);     // 3  (别这么写,会被同事打的)

我的建议: 永远用arr[i]这种标准写法。虽然i[arr]能证明你懂底层原理,但代码是给人看的,不是用来炫技的。

3.4 二维数组的指针运算

二维数组的指针运算,很多人觉得头疼。其实记住一句话就行:逐层解引用。

int matrix[3][4] = {
    {1, 2, 3, 4},
    {5, 6, 7, 8},
    {9, 10, 11, 12}
};

// matrix 的类型是 int (*)[4],指向一行(4个int)
// matrix[0] 的类型是 int*,指向第一个元素

printf("matrix    = %p\n", matrix);     // 首行地址
printf("matrix+1  = %p\n", matrix+1);   // 第二行地址,偏移 4*4=16字节
printf("*(matrix+1) = %p\n", *(matrix+1)); // 第二行首元素地址
printf("*(*(matrix+1)+2) = %d\n", *(*(matrix+1)+2)); // 7

我画了个图,帮你理清这个关系:

二维数组指针运算示意图 matrix[0][0] 1 matrix[0][1] 2 matrix[0][2] 3 matrix[0][3] 4 matrix[1][0] 5 matrix[1][1] 6 matrix[1][2] 7 matrix[1][3] 8 matrix[2][0] 9 matrix[2][1] 10 matrix[2][2] 11 matrix[2][3] 12 matrix → 首行地址 matrix+1 → 第二行 matrix+2 → 第三行

3.5 指针越界的典型场景

指针运算最危险的地方,就是越界。我总结了几种常见情况:

场景 代码示例 风险等级
数组下标越界 arr[10] 但数组只有5个元素 高
指针加减超出范围 p += 100 但只分配了10个元素 高
空指针运算 NULL + 5 极高
类型转换后指针运算 (int*)char_arr + 1 中

我曾经犯过的错: 写一个字符串处理函数,用指针遍历字符数组。循环条件写成了 while (*p++),结果字符串没有'\0'结尾,指针一路狂奔,把栈上的返回地址都给改了。程序崩溃时,我盯着core dump看了半小时才反应过来。

3.6 安全编码建议

说了这么多,最后给几条实用的建议:

  1. 能用下标就别用指针——下标至少能让你一眼看出访问的是第几个元素
  2. 指针运算一定要有边界检查——我习惯在循环里保留一个end指针
  3. 数组传参时,把长度一起传过去——别指望函数内部能知道数组大小
  4. 警惕「差一错误」——arr + len指向的是越界位置,不是最后一个元素
// 安全遍历的写法
int arr[10];
int *end = arr + 10;  // 指向越界位置,用于比较

for (int *p = arr; p < end; p++) {
    // 处理 *p
}

// 而不是
for (int i = 0; i <= 10; i++) {  // 越界!
    // arr[i]
}

我的习惯: 写指针运算时,我总会在注释里标出当前指针指向的位置和边界。比如 // p指向buf[3],buf大小16。这习惯救过我很多次。

指针运算和数组,说白了就是地址的算术。但地址错了,程序就崩了。理解底层原理,养成安全编码的习惯,你就能少踩很多坑。

可可精品资料库