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来定位,步骤很简单:
- 编译时加-g选项,保留调试信息
- 在可疑位置打断点
- 观察变量地址和内存布局
实际操作一下:
$ 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 内存布局可视化
为了让你更直观地理解,我画了一张图:
这张图很清楚地展示了:数组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和地址分析,基本上一抓一个准。
记住一句话:编译器信任你,但你不应该信任自己。每次写循环的时候,多看一眼边界条件——这能省下你后面好几个小时的调试时间。