未定义行为
内容简介
未定义行为指语言规范没有规定结果的行为。程序可能崩溃、看似正常、输出奇怪结果,甚至在优化后表现完全不同。
这个概念在 C/C++ 中最典型,但它背后的思想更通用:语言规范、编译器、运行时和硬件之间需要边界。越接近底层,越需要程序员自己遵守这些边界。
通用视角
不同语言对“错误行为”的处理方式不同:
| 类型 | 常见语言/机制 | 表现 |
|---|---|---|
| 未定义行为 | C / C++ | 标准不规定结果,编译器可基于“不发生”来优化 |
| 运行时异常 | Java / Python / JavaScript | 抛异常或报错,运行时接管 |
| 编译期拒绝 | Rust / Swift 的部分规则 | 编译器禁止危险代码 |
| 安全检查失败 | Go、C#、Java 数组越界 | 运行时报错,而不是任意内存访问 |
C++ 的历史包袱在于:为了性能、兼容 C、贴近硬件,它保留了大量“不替你检查”的能力。
C/C++ 常见来源
- 数组越界。
- 解引用空指针或野指针。
- 返回局部变量地址或引用。
- 有符号整数溢出。
- 使用未初始化变量。
- 通过错误类型解释对象内存。
- 修改字符串字面量。
- 多线程数据竞争。
- 栈溢出或栈内存被破坏。
示例
int* bad()
{
int value = 10;
return &value; // 返回局部变量地址
}value 在函数返回后已经销毁,返回的指针悬挂。
数组越界
数组越界是最常见的未定义行为之一。C/C++ 不会在普通数组访问时自动检查下标是否合法。
int arr[3] = {1, 2, 3};
printf("%d\n", arr[3]); // 未定义行为:合法下标只有 0、1、2
arr[-1] = 10; // 未定义行为:写到数组前面越界读和越界写都危险:
- 越界读可能读到相邻对象、填充字节、旧栈数据,或者直接崩溃。
- 越界写可能破坏相邻变量、函数返回地址、堆管理信息或对象内部状态。
- 程序看起来“正常运行”不代表行为合法,只代表这次运行没有暴露问题。
为什么不是固定报错
C/C++ 的内置数组本质上是连续内存对象,arr[i] 等价于 *(arr + i)。编译器通常不会为每次访问插入边界检查。
int arr[3] = {1, 2, 3};
int *p = arr + 3; // 合法:尾后指针,可用于比较
printf("%d\n", *p); // 未定义行为:尾后指针不能解引用尾后指针可以存在,但不能被解引用。它主要用于 [begin, end) 这种半开区间模型。
越界写的典型后果
int a[3] = {1, 2, 3};
int guard = 100;
a[3] = 999; // 可能覆盖 guard,也可能破坏别的内容,也可能暂时看不出问题不能依赖 guard 一定被覆盖。局部变量布局、优化级别、ABI、栈保护、调试/发布模式都会改变实际表现。
堆数组越界也一样危险:
int *data = malloc(sizeof(int) * 3);
if (data == NULL) {
return;
}
data[3] = 10; // 未定义行为:越过 malloc 得到的对象范围
free(data);这类错误可能直到 free、下一次 malloc 或很久之后才表现出来。
编译器优化的影响
因为标准规定越界访问是未定义行为,编译器可以假设它不会发生,并据此优化代码。
int get_value(int *arr, int i)
{
if (i >= 0 && i < 3) {
return arr[i];
}
return -1;
}这种显式边界检查是有意义的。但如果代码先越界访问、后检查,检查就太晚了:
int bad_get(int *arr, int i)
{
int value = arr[i]; // 如果 i 越界,UB 已经发生
if (i < 0 || i >= 3) {
return -1;
}
return value;
}边界检查必须发生在访问之前。
和数组传参退化的关系
函数参数里的数组形参会退化为指针,形参中写的长度不提供真实边界保护。
void fill10(int arr[10])
{
for (int i = 0; i < 10; ++i) {
arr[i] = i;
}
}
int small[3];
fill10(small); // 可能编译通过,但 fill10 内部会越界写因此 C 接口通常要把指针和长度一起传入:
int fill(int *data, size_t count)
{
if (data == NULL) {
return 0;
}
for (size_t i = 0; i < count; ++i) {
data[i] = (int)i;
}
return 1;
}最佳实践
- 所有数组访问前先确认下标范围,检查必须早于访问。
- 循环遍历使用
i < count,不要写成i <= count。 - C 接口使用
data + count、buffer + size这种成对参数表达边界。 count表示元素个数,size表示字节数,命名保持一致。- 对字符串缓冲区始终预留
'\0'空间。 - C++ 中优先使用
std::array、std::vector、std::string;需要检查时用.at()。 - 调试 C/C++ 越界问题时使用 AddressSanitizer、UndefinedBehaviorSanitizer、Valgrind 或编译器栈保护选项。
栈溢出
栈溢出通常来自过深递归或过大的局部对象。它不只是“函数调用太多报个错”,而是程序越过当前线程可用栈空间后的严重内存错误。
void recurse(int n)
{
int local = n;
recurse(local + 1); // 没有终止条件,最终耗尽栈空间
}void large_stack_object(void)
{
char buffer[1024 * 1024 * 64]; // 大局部数组,可能直接栈溢出
buffer[0] = 0;
}为什么表现不稳定
栈大小由操作系统、线程配置、编译器、链接选项和运行环境共同决定。栈溢出后可能出现:
- 程序立刻崩溃。
- 覆盖其他栈帧或局部变量。
- 破坏返回地址或栈保护信息。
- 在嵌入式/裸机环境中覆盖全局区、堆或其他任务栈。
- 调试版和发布版表现不同。
因此栈溢出不能依赖固定表现,也不能用“当前机器没崩”证明安全。
和未定义行为的关系
C/C++ 标准不规定“栈”这个运行时结构的所有实现细节。栈耗尽本身通常由运行环境处理,但一旦程序继续在无效栈空间上读写,结果就进入未定义或平台定义边界。对可移植 C/C++ 代码来说,应把栈溢出当作必须主动避免的严重错误。
最佳实践
- 递归必须有终止条件,并评估最大递归深度。
- 外部输入控制递归深度时,要设置深度上限。
- 深度可能很大时,把递归改成循环或显式栈数据结构。
- 大数组、大结构体、大缓冲区不要放在局部自动变量里,优先使用堆、静态存储或调用者提供的缓冲区。
- 多线程程序要关注线程栈大小,线程函数里尤其避免大局部对象。
- 不依赖尾调用优化来保证不溢出,除非编译器和平台已经被严格约束。
- 调试时可使用 AddressSanitizer、栈保护选项、静态分析和压力测试覆盖极端输入。
为什么危险
编译器可以假设未定义行为不会发生,并基于这个假设做优化。因此 UB 不只是“运行时报错”,它会影响编译器如何生成代码。
这也是很多新手觉得 C++ “玄学”的来源:代码看起来只是越界一次,但编译器可能已经在优化阶段假设这种情况永远不存在。
对学习其他语言的帮助
- 学过 C++ 后,更容易理解为什么 Rust 要严格限制借用和生命周期。
- 更容易理解为什么 Java、Go、Python 愿意牺牲一些性能换取运行时检查。
- 更容易理解“安全语言”并不是没有成本,而是把一部分风险转移给编译器或运行时。
注意事项
- 不要用“我这里运行没问题”证明 UB 是安全的。
- 打开编译器警告、使用 Sanitizer、写小测试都能帮助发现 UB。
- C++ 中很多安全实践本质上是在减少 UB 的入口。