为什么会出现整数溢出、缓冲区溢出和段错误?
这3个问题表面上看起来不同,但是本质上都是来源于同一个事实:
计算机资源都有明确边界,但是程序执行的操作可能会越过边界。
- 整数溢出:越过了数字类型的表示范围,是数值边界被突破。
- 缓冲区溢出:越过了某块内存的合法范围,是对象边界被突破。
- 段错误:访问了当前进程无权访问的内存,是地址权限边界被突破。
1. 整数溢出为什么会发生?
1.1 整数所能保存的范围是有限的
计算机不可能用无限多个二进制位保存一个整数。
例如,一个8位无符号整数只有8个二进制位:
00000000 ~ 11111111
它可以表示 0 ~ 255
如果执行:
unsigned char x = 255;
x = x + 1;
在数学上的结果是:256
但是8位空间是无法表示256的,因为256的二进制是 0001 0000 0000,需要9位。这个时候,变量只能保存低8位,于是就变成了:0000 0000,结果就变成了0。
1.2 无符号整数采用模运算
对于一个n位无符号整数,结果相当于:
实际结果 mod 2ⁿ
8位无符号整数:
256 mod 256 = 0
257 mod 256 = 1
例如:
unsigned char x = 250;
x += 10;
这个时候输出的结果为
260 mod 256 = 4
这不是CPU算错,而是该整数类型的规则。
1.3 有符号整数更危险
典型的32位int范围是:
~2147483648 ~ 2147483647
例如:
int x = 2147483647
x = x + 1;
这个时候输出的结果,在常见的机器上可能会观察到
-2147483648
但在C语言标准中,有符号整数溢出属于未定义行为。
这意味着编译器不需要保证它一定绕回负数。编译器甚至可以假定:
合法程序不会发生有符号整数溢出。
因此它可能会进行一些看似反直觉的优化。
例如:
int greater(int x) {
return x + 1 > x;
}
编译器可能直接优化成:
return 1;
因为在不发生溢出的前提下,x + 1 必然大于 x 。
1.4 整数溢出不一定立即崩溃
整数溢出通常只产生错误值:
int price = 100000;
int count = 100000;
int total = price * count;
数学上的结果是:10,000,000,000
但32位int无法表示这个结果,total可能得到完全错误的数。
更危险的是,错误的结果可能随后被用于内存分配:
unsigned int count = 1073741825;
unsigned int bytes = count * 4;
int *array = malloc(bytes);
程序原本想分配大约4GB的内存,但是乘法可能溢出,bytes 变成一个很小的数。
随后程序却按照原来的大数量写入:
for (unsigned int i = 0; i< count; i++){
array[i] = 0;
}
于是形成这种链条:
整数溢出
↓
分配空间过小
↓
写出缓冲区
↓
缓冲区溢出
↓
可能出现段错误或安全漏洞
2. 缓冲区溢出为什么会发生?
2.1 什么是缓冲区?
缓冲区就是一块有固定容量的连续内存。
例如:
char name[8];
它只有8字节:
name[0]
name[1]
name[2]
name[3]
name[4]
name[5]
name[6]
name[7]
合法的下标是:0~7
如果访问的是:name[8] = 'A';,那么已经越过了数组的边界。
2.2 C语言通常不会自动检查数组下标
这是产生缓冲区溢出的关键原因。
例如:
char buffer[8];
for (int i = 0; i < 20; i++){
buffer[i] = 'A';
}
以CPU的视角来看的话,CPU最终看到的不是“访问数组第i项”,而是更加接近:
计算 buffer起始地址 + i
向这个地址写入一个字节
CPU并不知道:
buffer是一个数组;- 数组只有8个字节;
buffer[8]已经越界。
对于CPU来说,它只是收到了一个地址和一次写操作。
因此,C语言中的数组边界主要靠程序员和编译器分析来保证。
2.3 溢出的数据会覆盖相邻内容
假设栈上有:
void test(void) {
char buffer[8];
int authorized = 0;
}
此时,内存的布局可能会类似:
低地址
┌──────────────────┐
│ buffer[0..7] │
├──────────────────┤
│ 对齐空间 │
├──────────────────┤
│ authorized │
├──────────────────┤
│ 保存的寄存器 │
├──────────────────┤
│ 返回地址 │
└──────────────────┘
高地址
具体的排列顺序根据实际的编译器和平台可能会有所不同,但是核心问题都是相同的,就是:
超出
buffer的数据可能覆盖附近的其他数据。
例如:
strcpy(buffer, "AAAAAAAAAAAAAAAAAAAA");
strcpy 不知道目标缓冲区有大,它会一直复制,知道遇到字符串结尾的 '\0'。
接下来可能会发生:
buffer被填满
↓
覆盖其他局部变量
↓
覆盖保存的寄存器
↓
覆盖返回地址
2.4 缓冲区溢出不只发生在栈上
2.4.1 栈缓冲区溢出
void f(const char *input) {
char buf[16];
strcpy(buf, input);
}
可能会破坏当前函数的栈帧。
2.4.2 堆缓冲区溢出
char *buf = malloc(16);
memcpy(buf, input, 100);
可能会覆盖其他堆对象或内存分配器的管理信息。
2.4.3 全局缓冲区溢出
char global_buf[16];
越界后可能会破坏相邻的全局数据。
2.5 缓冲区溢出不一定立即会产生段错误
缓冲区溢出和段错误有着重要的区别。
假设程序申请了8字节,但写了12字节:
合法区域:8字节
越界写入:4字节
如果后面4字节仍然处在进程拥有且可写的内存页内,CPU不会阻止。
结果可能会是:
- 某个变量被悄悄修改;
- 程序稍后才崩溃;
- 输出错误;
- 看起来暂时正常;
- 形成可被利用的安全漏洞。
所以:
缓冲区溢出描述的是越过对象边界;段错误描述的是违反内存访问权限。
对象边界和内存页权限边界不是一回事。
3. 段错误为什么发生?
段错误通常是指程序收到了类似:SIGSEGV的信号。
它表示:
CPU执行内存访问时,操作系统发现该虚拟地址不允许当前进程以这种方式访问。
3.1 每个进程看到的是虚拟地址空间
比如,程序可能认为自己在访问:0x00007fffffffe000
这个地址并不直接等于某个物理内存的位置。
CPU中的内存管理单元会结合页表,把虚拟地址翻译成物理地址,并且检查权限:
- 这个页面存在吗?
- 允许读取吗?
- 允许写入吗?
- 允许执行吗?
- 当前进程有权限吗?
如果检查失败,CPU会产生页故障异常。
操作系统检查后发现无法修复,那么就会向程序发送段错误信号。
3.2 解引用空指针
int *p = NULL;
*p = 10;
NULL 通常表示地址0或空地址。
操作系统一般故意不映射低地址页面,因此写入地址0会失败:
p = 0
*p = 10
↓
尝试写入虚拟地址0
↓
该地址未映射
↓
段错误
3.3 使用野指针
int *p;
*p = 10;
局部指针p未初始化,它可能包含任意二进制值。
例如:p = 0x7a31c05f ...
程序就会把这个随机值当作地址使用。
如果地址无效,就可能会产生段错误。
3.4 使用已释放的内存
int *p = malloc(sizeof(int));
free(p);
*p = 10;
free(p) 后:
- p中的地址值通常还在;
- 但是那块内存已经不再属于当前的对象;
- 它可能已经分配给了别的对象;
- 也可能被内存分配器回收或者取消映射。
这叫use-after-free,它可能会:
- 立即段错误
- 破坏其他对象
- 暂时正常运行
- 形成安全漏洞
3.5 写入只读内存
char *s = "hello";
s[0] = 'H';
字符串字面量通常位于只读区域。
程序尝试写入时:
地址存在
但页面没有写权限
↓
段错误
所以,地址存在但是权限不符,也可能会产生段错误。
3.6 栈溢出
void recurse(void) {
char data[1024];
recurse();
}
每次递归都会消耗新的栈空间。
最终栈指针可能越过操作系统为线程分配的栈区域,进入保护页:
不断建立栈帧
↓
栈空间耗尽
↓
访问未映射或受保护页面
↓
段错误
需要注意的是,栈溢出和栈缓冲区溢出,并不是一个概念,它们的区别如下:
| 问题 | 含义 |
|---|---|
| 栈溢出 | 整个调用栈空间用完 |
| 栈缓冲区溢出 | 栈上的某个数组被越界访问 |
3.7 访问越界数组
int array[10];
array[1000000] = 1;
地址计算相当于:
array起始地址 + 1000000 × sizeof(int)
如果计算出的地址进入未映射区域,程序会段错误。
但是如果只访问:
array[10] = 1;
它可能仍然位于当前可写内存页内,因此未必立刻段错误,指挥造成缓冲区溢出和未定义行为。
4. 简单总结三种溢出的关系
| 问题 | 越过的边界 | 谁通常能发现 | 是否一定崩溃 |
|---|---|---|---|
| 整数溢出 | 整数类型范围 | 程序、编译器或检测工具 | 否 |
| 缓冲区溢出 | 数组或对象边界 | 通常需要程序或检测工具 | 否 |
| 段错误 | 虚拟内存映射或权限 | CPU和操作系统 | 通常会终止程序 |
5. 为什么C语言特别容易出现这些问题?
因为C语言给程序员提供了非常直接的底层控制:
- 可以直接操作指针;
- 可以手动分配和释放内存;
- 数组下标通常不自动检查;
- 整数类型宽度固定;
- 指针和数组操作开销很低;
- 运行时安全检查较少。
代价是:
C语言默认相信程序员知道自己在访问什么。
高级语言往往会增加检查。
例如Python:
a = [1, 2, 3]
a[10]
通常抛出:
IndexError
而不是直接计算地址并访问。
Java也会检查数组边界:
int[] a = new int[3];
a[10] = 1;
通常抛出:
ArrayIndexOutOfBoundsException
C为了性能、可预测性和底层控制,没有默认提供同等级别的运行时保护。