2. 指针与数组

数组和指针,这俩东西在C/C++里就像一对双胞胎。长得像,但性格完全不同。很多新手甚至老手都在这上面栽过跟头。我刚开始工作那会儿,就因为在数组名上理解不到位,线上排查了一整天才找到bug——嗯,说起来都是泪。

数组名到底是什么?

先问个问题:int arr[5] 里的 arr 是什么?

很多人脱口而出:「数组名就是首地址啊!」

对,但不全对。准确地说,数组名在大多数表达式中会被隐式转换为指向首元素的指针。但有两个例外:

  • sizeof(arr) —— 返回整个数组的大小,不是指针大小
  • &arr —— 取的是整个数组的地址,类型是 int (*)[5]

核心区别:

  • arr 等价于 &arr[0],类型是 int*
  • &arr 类型是 int (*)[5],指向整个数组
  • 虽然数值相同,但指针类型不同,加减运算的结果也不同
#include <stdio.h>

int main() {
    int arr[5] = {1, 2, 3, 4, 5};
    
    printf("arr      = %p\n", arr);      // 首元素地址
    printf("&arr[0]  = %p\n", &arr[0]);  // 同上
    printf("&arr     = %p\n", &arr);     // 整个数组地址
    
    printf("arr + 1  = %p\n", arr + 1);   // 偏移4字节
    printf("&arr + 1 = %p\n", &arr + 1); // 偏移20字节
    
    return 0;
}

你看,arr + 1 跳过一个int,&arr + 1 跳过整个数组。数值上差了多少?16字节。这就是类型带来的差异。

我的习惯:写代码时尽量用 arr + i 或 &arr[i] 来取元素地址,少用 &arr + i。后者太容易搞混了。

指针运算与数组遍历

数组遍历,说白了就是「从首地址开始,按步长移动指针」。C/C++的指针运算天生就是为数组服务的。

三种常见的遍历方式:

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

// 方式1:下标法
for (int i = 0; i < 5; i++) {
    printf("%d ", arr[i]);
}

// 方式2:指针偏移
for (int i = 0; i < 5; i++) {
    printf("%d ", *(p + i));
}

// 方式3:指针递增(我最常用的)
for (int *q = arr; q < arr + 5; q++) {
    printf("%d ", *q);
}

我个人偏爱第三种。为什么?因为 q++ 直接移动指针,不需要每次计算偏移量。编译器优化后效率也更高。不过要注意——循环条件一定要用 q < arr + 5,而不是 q <= &arr[4]。后者虽然也能跑,但语义上不够清晰。

避坑指南:我曾经在嵌入式项目里用 while (p <= &arr[4]) 遍历数组,结果数组长度改成动态分配后,&arr[4] 变成了野地址。从那以后,我统一用 起始指针 + 长度 的方式写循环条件。

数组越界的典型场景

数组越界,C/C++程序员的噩梦。它不像Java会抛异常,而是静悄悄地破坏内存。你想想看,一个越界写操作,可能把函数返回地址、其他变量的值、甚至堆管理结构都给覆盖了。

我总结了几种最常见的越界场景:

场景 代码示例 后果
循环边界错误 for (i = 0; i <= 5; i++) arr[i] = 0; 写arr[5],越界1个元素
字符串操作 strcpy(buf, long_string) 缓冲区溢出
指针运算失误 *(p + offset) 中offset过大 访问未知内存
多维数组遍历 内层循环列数写错 跨行越界

来看一个典型的「差一错误」:

#include <stdio.h>

int main() {
    int arr[5] = {0};
    int i;
    
    // 错误:i <= 5 导致越界
    for (i = 0; i <= 5; i++) {
        arr[i] = i * 10;
    }
    
    // 打印看看
    for (i = 0; i < 5; i++) {
        printf("%d ", arr[i]);
    }
    
    return 0;
}

这段代码在大部分机器上能「正常」运行,但arr[5]写的是栈上的其他数据。你想想看,如果arr[5]恰好是某个函数指针或者循环变量i本身——那调试起来就酸爽了。

越界的隐蔽性:

  • 越界1个字节,可能毫无症状
  • 越界覆盖了关键数据,程序在别处崩溃
  • 越界写堆内存,释放时crash
  • 越界读,可能读到敏感数据

如何定位越界问题?

说实话,越界bug是最难调试的一类。我分享几个实战经验:

  1. AddressSanitizer(ASan) —— 编译时加 -fsanitize=address,运行时自动检测越界。我项目里默认开启。
  2. Valgrind —— 适合Linux环境,能检测越界和内存泄漏。
  3. 断言检查 —— 在关键数组操作前后加assert,比如 assert(index >= 0 && index < size)。
  4. 日志打印 —— 在怀疑的越界点打印指针值和索引,对比预期范围。

我的小技巧:在调试阶段,我会在数组前后各加一个「哨兵」变量,初始化为固定魔数(比如0xDEADBEEF)。如果程序崩溃时发现哨兵被改了,那100%是越界了。

知识体系图

下面这张图帮你理清本章的核心逻辑:

指针与数组 数组名的指针含义 arr == &arr[0] &arr 类型不同 指针运算与遍历 p + i 偏移 p++ 递增 数组越界场景 循环边界错 字符串溢出 定位方法 ASan / Valgrind / 断言 / 哨兵

这张图把本章内容串起来了。从数组名的本质出发,到指针运算的三种遍历方式,再到越界的典型场景,最后落到定位方法上。你写代码时脑子里过一遍这个图,很多坑就能提前避开。

最后提醒一句:别以为越界只发生在数组上。指针运算、字符串操作、memcpy/memset这些函数,只要长度参数算错了,都是越界的重灾区。写代码时多问自己一句:「这个指针指向的内存,真的够用吗?」

可可精品资料库