指针越界经典场景:数组越界、字符串操作越界、栈缓冲区溢出、堆内存越界

指针越界,说白了就是指针跑到了它不该去的地方。我调试过的崩溃问题里,至少有一半都能归到这类头上。你想想看,指针本来指向一块合法的内存,结果因为计算错误或者逻辑疏忽,它一脚踩到了隔壁的地盘——轻则数据错乱,重则直接段错误。

这一章,我把最常见的四种越界场景拆开来讲。每个场景我都亲手踩过坑,有些坑至今想起来还觉得后背发凉。

数组越界:最经典的“差一错误”

数组越界可能是新手最容易犯的错误。我见过太多人写循环时,条件判断里多了一个等号。

int arr[5] = {0, 1, 2, 3, 4};
for (int i = 0; i <= 5; i++) {  // 注意:i <= 5,越界了
    arr[i] = i * 2;
}

这段代码里,arr[5] 根本不存在。数组只有 5 个元素,索引范围是 0 到 4。当 i = 5 时,你写的是 arr[5],它实际上是紧挨着数组末尾的那块内存。

⚠️ 警告:数组越界写入不会立刻崩溃,它可能悄悄覆盖了另一个变量的值。这种 bug 最难排查——你看到的是变量 A 的值莫名其妙变了,但根本想不到是数组 B 越界写的锅。

我个人习惯在写循环时,一律用 i < n 而不是 i <= n-1。前者更直观,也更容易避免差一错误。

字符串操作越界:C 风格字符串的陷阱

C 风格字符串以 '\0' 结尾,这个细节经常被忽略。我记得有一次接手一个遗留项目,里面有一段代码看起来人畜无害:

char buf[10];
strcpy(buf, "Hello, World!");  // 源字符串长度 13,加上 '\0' 共 14 字节

buf 只有 10 个字节,但 strcpy 会一直复制直到遇到源字符串的 '\0'。结果就是 14 个字节被硬塞进 10 字节的空间里,后面 4 个字节直接覆盖了栈上相邻的数据。

💡 提示:我建议优先使用 strncpy 或 snprintf,它们可以指定最大写入长度。但要注意 strncpy 在源字符串过长时不会自动添加 '\0',所以最好手动在末尾置零。

还有一个常见场景是 sprintf 拼接字符串。我曾经见过一个日志函数,用 sprintf 把多个字段拼到一个固定大小的缓冲区里。某天线上日志突然乱码,查了半天才发现是某个字段长度超了,把后面的数据全冲乱了。

栈缓冲区溢出:局部变量的“隐形杀手”

栈缓冲区溢出和数组越界本质类似,但危害更大。因为栈上不仅存着局部变量,还存着函数的返回地址。

void vulnerable() {
    char buffer[64];
    gets(buffer);  // 危险!没有长度限制
    printf("Input: %s\n", buffer);
}

gets 函数不会检查输入长度。如果你输入超过 64 个字符,多出来的部分就会覆盖栈上的返回地址。攻击者可以精心构造输入,让函数返回到任意地址——这就是经典的栈溢出攻击。

🔑 关键点:现代编译器默认会开启栈保护(-fstack-protector),在栈上插入 canary 值。如果 canary 被覆盖,程序会主动终止。但这不是万能药,我建议永远不要依赖编译器的保护,而是从代码层面杜绝溢出。

嗯,这里要注意:递归函数也容易导致栈溢出。每次递归调用都会在栈上分配新的栈帧,如果递归深度太大,栈空间会被耗尽。我记得调试过一个图像处理算法,递归深度达到几万层,程序直接崩溃在 malloc 里——因为栈溢出让堆管理器的内部数据结构也遭了殃。

堆内存越界:动态分配的“暗雷”

堆内存越界比栈溢出更隐蔽。因为堆上的内存布局不像栈那样规整,越界写入可能不会立刻触发崩溃,而是等到 free 或 delete 时才炸。

int* p = (int*)malloc(5 * sizeof(int));
for (int i = 0; i <= 5; i++) {
    p[i] = i;  // 越界写入 p[5]
}
free(p);  // 可能在这里崩溃,或者更晚

为什么会这样?因为 malloc 分配的内存块前后都有元数据(比如块大小、空闲链表指针)。越界写入可能破坏这些元数据。当你调用 free 时,堆管理器尝试读取被破坏的元数据,结果就是段错误。

⚠️ 警告:我曾经遇到过一个线上服务,每隔几小时就随机崩溃一次。查了三天,最后发现是某个模块在 realloc 之后没有更新指针——原来的指针变成了悬空指针,后续写入全部越界。这种 bug 的复现概率很低,但一旦触发就是灾难。

堆内存越界的另一个常见场景是缓冲区溢出后覆盖了相邻堆块的数据。比如两个 malloc 分配的内存块紧挨着,你往第一个块里写多了,就把第二个块的内容给改了。这种问题在排查时特别头疼,因为出错的代码和崩溃的代码可能相隔十万八千里。

知识体系总览

下面这张图把四种越界场景的核心要素串了起来。你可以把它当作一张速查表,遇到崩溃时先对照一下。

指针越界经典场景知识体系 指针越界 数组越界 字符串操作越界 栈缓冲区溢出 堆内存越界 常见原因 • 循环条件多一个等号 • 索引计算错误 • 多维数组遍历顺序不对 • 缓冲区大小与元素个数混淆 常见原因 • 忘记预留 '\0' 空间 • strcpy/strcat 无长度限制 • sprintf 拼接超长字符串 • 字符串长度计算错误 常见原因 • gets/scanf 无长度限制 • 递归深度过大 • 局部数组越界写入 • 返回地址被覆盖 常见原因 • malloc 后忘记更新指针 • 写入超过分配大小 • 破坏堆元数据 • 释放后继续使用 核心防御手段 边界检查 | 安全函数 | 静态分析 | 地址消毒器 (ASan)

避坑指南

我曾经在排查一个堆内存越界问题时,花了整整两天才定位到根因。那是一个多线程程序,线程 A 往一个共享缓冲区里写数据,线程 B 读取。问题出在线程 A 计算写入长度时用了错误的公式,每次多写 4 个字节。因为堆内存分配有对齐机制,小规模越界不会立刻触发崩溃,直到某次分配的内存块刚好在边界上,才把相邻块的元数据给毁了。

从那以后,我养成了几个习惯:

  • 所有数组访问都加边界检查——哪怕是在性能敏感的代码里,至少要在调试版本里开启断言。
  • 字符串操作一律用安全版本——snprintf 代替 sprintf,strlcpy 代替 strcpy(如果平台支持)。
  • 堆内存分配后立即初始化——用 calloc 或者 memset 清零,这样越界读取时更容易发现异常。
  • 定期用 AddressSanitizer 跑测试——ASan 能在运行时检测出越界访问,虽然会拖慢速度,但比线上崩溃强一万倍。
💡 个人经验:如果你在调试一个随机崩溃的问题,而且怀疑是内存越界,试试在 malloc 和 free 前后加日志,记录每次分配的大小和地址。然后对比崩溃时的堆栈,往往能发现规律。我靠这招抓出过好几个隐藏很深的越界 bug。

嗯,这一章的内容就到这里。指针越界是个老生常谈的话题,但每次遇到新项目、新代码,总能看到有人重蹈覆辙。希望这些场景和案例能帮你少走一些弯路。