未定义行为

内容简介

未定义行为指语言规范没有规定结果的行为。程序可能崩溃、看似正常、输出奇怪结果,甚至在优化后表现完全不同。

这个概念在 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 + countbuffer + size 这种成对参数表达边界。
  • count 表示元素个数,size 表示字节数,命名保持一致。
  • 对字符串缓冲区始终预留 '\0' 空间。
  • C++ 中优先使用 std::arraystd::vectorstd::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 的入口。

相关:CandCpp指针CandCpp数组cppthis指针optional