4、越界场景一:数组越界写——覆盖相邻变量

数组越界写,这大概是C/C++程序员最常踩的坑了。我入行头两年,至少有一半的诡异bug最后都追到了这里。说白了,就是你在数组的合法范围外写入了数据,结果把旁边变量的值给冲掉了。

为什么会这样?因为局部变量在栈上是挨着放的。你写数组的时候,编译器不会帮你检查边界——它信任你。但一旦越界,数据就会像洪水一样漫到邻居的地盘上。

4.1 一个活生生的例子

来看一段代码,我故意制造了一个越界写场景:

#include <stdio.h>

int main() {
    int arr[5] = {1, 2, 3, 4, 5};
    int guard = 0xDEADBEEF;  // 放在arr后面的哨兵变量
    
    printf("越界前:guard = 0x%x\n", guard);
    
    // 故意越界写
    for (int i = 0; i <= 5; i++) {  // 注意:i <= 5,多了一次
        arr[i] = i * 10;
    }
    
    printf("越界后:guard = 0x%x\n", guard);
    printf("arr[5] = %d\n", arr[5]);  // 这里其实已经越界了
    
    return 0;
}

运行结果:

越界前:guard = 0xDEADBEEF
越界后:guard = 0x32
arr[5] = 50

看到没?guard的值从0xDEADBEEF变成了0x32(也就是十进制的50)。arr[5]的赋值操作,直接覆盖了guard的内存。

4.2 用GDB抓现行

这种问题,肉眼很难看出来。尤其是代码量大的时候。我习惯用GDB来定位,步骤很简单:

  1. 编译时加-g选项,保留调试信息
  2. 在可疑位置打断点
  3. 观察变量地址和内存布局

实际操作一下:

$ gcc -g -o overflow overflow.c
$ gdb ./overflow

(gdb) break 9  // 在for循环处打断点
(gdb) run

(gdb) print &arr
$1 = (int (*)[5]) 0x7fffffffe4c0

(gdb) print &guard
$2 = (int *) 0x7fffffffe4dc

(gdb) print &arr[5]  // 越界位置
$3 = (int *) 0x7fffffffe4d4

嗯,这里要注意地址的计算。arr[5]的地址是arr起始地址 + 5 * sizeof(int)。在我的机器上,arr在0x7fffffffe4c0,arr[5]就在0x7fffffffe4d4。而guard在0x7fffffffe4dc,两者差了8个字节——正好是两个int的距离。

关键发现:arr[5]和guard之间差了8个字节,说明编译器在arr和guard之间插入了填充(padding),或者有其他局部变量。但arr[6](0x7fffffffe4d8)和arr[7](0x7fffffffe4dc)就会直接命中guard。

我们单步执行看看:

(gdb) watch guard  // 监视guard变量
Hardware watchpoint 2: guard

(gdb) continue
Continuing.

Hardware watchpoint 2: guard

Old value = -559038737  // 0xDEADBEEF的十进制
New value = 50
main () at overflow.c:12
12              arr[i] = i * 10;

看,当i=5时,arr[5] = 50,这个写入操作触发了watchpoint。guard的值被改写了。

4.3 内存布局可视化

为了让你更直观地理解,我画了一张图:

栈内存布局:数组越界写 高地址 guard = 0xDEADBEEF → 被改为 0x32 0x7fffffffe4dc 越界写入区域:arr[5], arr[6], arr[7] ... arr[0] arr[1] arr[2] arr[3] arr[4] 0x7fffffffe4c0 低地址 越界写方向 合法访问 栈生长方向:高地址 → 低地址(向下生长)

这张图很清楚地展示了:数组arr在低地址,guard在高地址。当你越界写arr[5]时,实际上是在往高地址方向写,正好覆盖了guard。

4.4 避坑指南

我曾经在一个嵌入式项目里,就因为数组越界写了一个字节,导致整个系统的任务调度器崩溃。排查了整整两天,最后发现是一个循环的边界条件多写了1个int。从那以后,我养成了几个习惯:

  • 循环边界用 < 而不是 <= ——这是最经典的越界来源
  • 敏感变量之间放哨兵——比如在数组后面放一个已知值,越界时能快速发现
  • 能用std::vector就别用裸数组——C++的at()方法会做边界检查
  • 编译时开启地址消毒(AddressSanitizer)——-fsanitize=address 能自动检测越界

小技巧:用GDB的watch命令监视关键变量,一旦被意外修改就会立即停下来。配合条件断点,效率更高。我个人习惯在调试阶段,给所有关键变量都加上watchpoint。

4.5 总结

数组越界写的本质,就是破坏了栈上变量的隔离性。你写的是arr[5],但受害的是guard。这种bug隐蔽性强,但用GDB的watchpoint和地址分析,基本上一抓一个准。

记住一句话:编译器信任你,但你不应该信任自己。每次写循环的时候,多看一眼边界条件——这能省下你后面好几个小时的调试时间。

可可精品资料库