为什么会出现整数溢出、缓冲区溢出和段错误?

2 阅读9分钟

为什么会出现整数溢出、缓冲区溢出和段错误?

这3个问题表面上看起来不同,但是本质上都是来源于同一个事实:

计算机资源都有明确边界,但是程序执行的操作可能会越过边界。

  1. 整数溢出:越过了数字类型的表示范围,是数值边界被突破。
  2. 缓冲区溢出:越过了某块内存的合法范围,是对象边界被突破。
  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并不知道:

  1. buffer 是一个数组;
  2. 数组只有8个字节;
  3. 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不会阻止。

结果可能会是:

  1. 某个变量被悄悄修改;
  2. 程序稍后才崩溃;
  3. 输出错误;
  4. 看起来暂时正常;
  5. 形成可被利用的安全漏洞。

所以:

缓冲区溢出描述的是越过对象边界;段错误描述的是违反内存访问权限。

对象边界和内存页权限边界不是一回事。

3. 段错误为什么发生?

段错误通常是指程序收到了类似:SIGSEGV的信号。

它表示:

CPU执行内存访问时,操作系统发现该虚拟地址不允许当前进程以这种方式访问。

3.1 每个进程看到的是虚拟地址空间

比如,程序可能认为自己在访问:0x00007fffffffe000

这个地址并不直接等于某个物理内存的位置。

CPU中的内存管理单元会结合页表,把虚拟地址翻译成物理地址,并且检查权限:

  1. 这个页面存在吗?
  2. 允许读取吗?
  3. 允许写入吗?
  4. 允许执行吗?
  5. 当前进程有权限吗?

如果检查失败,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) 后:

  1. p中的地址值通常还在;
  2. 但是那块内存已经不再属于当前的对象;
  3. 它可能已经分配给了别的对象;
  4. 也可能被内存分配器回收或者取消映射。

这叫use-after-free,它可能会:

  1. 立即段错误
  2. 破坏其他对象
  3. 暂时正常运行
  4. 形成安全漏洞

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为了性能、可预测性和底层控制,没有默认提供同等级别的运行时保护。