第 14 章 · 未定义行为与陷阱

UB 不是「会报错」,而是「标准不再约束编译器,什么结果都可能」——C 指针最危险的地方。

13.1 什么是未定义行为

UB 意味着编译器可以假设它不会发生,并据此优化。后果包括:

  • 恰好「正常工作」(最坑,藏着隐患);
  • 读到脏数据;
  • 段错误 / 崩溃;
  • 编译器优化出反直觉的结果(它按 UB 不会发生的假设删代码、重排)。

UB 与「运行时报错」是两回事:Java 的越界抛异常是定义好的行为;C 的越界是 UB,连「抛异常」都不能保证。

13.2 指针相关的 UB 清单

行为说明
解引用空指针*NULL,通常段错误
解引用悬空指针free 后 / 返回局部变量后使用
越界读写数组越界、strcpy 溢出
越界指针运算超出 one-past-the-end 的 p+n
不同数组的指针相减/比较无定义(==/!= 除外)
丢弃 const 写只读对象写字符串字面量、写 const 全局
违反 strict aliasing用不兼容指针类型别名访问
违反 restrict别名了 restrict 指针
未对齐访问强转不对齐地址再解引用
double free / 释放非 malloc 内存堆损坏
使用未初始化指针垃圾值当地址用

13.3 悬空指针(dangling pointer)

指针指向的内存已经失效,指针本身还在:

int *p;
{
    int x = 10;
    p = &x;     // p 指向栈上局部变量
}               // 块结束,x 销毁
printf("%d\n", *p);   // UB:p 悬空

产生悬空的三种来源:返回局部变量地址(第 10 章)、free 后继续用(第 9 章)、realloc 后还用旧指针

13.4 缓冲区溢出(buffer overflow)

不检查边界就写,是安全漏洞的头号来源(栈溢出改返回地址、堆溢出改函数指针):

char buf[8];
strcpy(buf, "this is way too long");   // 写穿 8 字节,破坏相邻内存
gets(buf);                              // gets 无法限制长度,C11 已移除
  • snprintffgets、带长度上限的 API 替代。
  • 编译加 -fsanitize=address(ASan)能当场抓住越界写。

13.5 严格别名规则(strict aliasing)

C 规定:同一块内存,只能用「兼容类型」的指针访问(少数例外:char*unsigned char* 可看任何对象的字节)。用不兼容类型别名,是 UB:

float f = 1.0f;
int *p = (int*)&f;    // 用 int* 读 float,违反严格别名
int x = *p;           // UB(编译器可能已按 f 是 float 做了优化)

合法的「类型双关」做法:用 memcpyunion

float f = 1.0f;
uint32_t bits;
memcpy(&bits, &f, sizeof bits);   // 合法:逐字节拷贝,不违反别名

union { float f; uint32_t u; } u; // 合法:union 成员间转换有定义
u.f = 1.0f; u.u;                  // 读 u.u 是允许的(C 标准下部分实现定义)

13.6 对齐(alignment)违规

把不对齐的地址强转成 int*/double* 再解引用,在 x86 上可能只是慢,在 ARM/RISC-V 等架构上直接崩:

char buf[8];
double *d = (double*)(buf + 1);   // buf+1 不对齐 double(需 8 对齐)
*d = 3.14;
  • 需要「对齐的缓冲」用 _Alignas(C11)或 aligned_alloc
  • 访问任意字节用 char*(它对齐要求是 1)。

13.7 一个「反直觉优化」的例子

UB 之所以危险,是编译器会据此删代码。经典例子:

int x;                       // 未初始化
if (x) { /* A */ }           // 编译器可能假设 x 恒为 0,直接删掉 A

指针版本:

void f(int *p, int *q) {
    *p = 1;
    *q = 2;
    return *p;   // 若 p、q 别名则返回 2,否则 1
}

编译器可能把 return *p 优化成 return 1——它按「pq 不别名」的假设处理;违反 restrict/别名规则会出现「写了 2 却读到 1」。

13.8 防御清单

  1. 编译开警告-Wall -Wextra -Wpedantic,把警告当错误 -Werror
  2. 跑 sanitizer-fsanitize=address,undefined(开发期)。
  3. 静态分析clang --analyzecppcheck
  4. 初始化指针NULLfree 后置 NULL
  5. 边界用 const + 显式长度,别指望 '\0' 兜底。
  6. 类型双关走 memcpy/union,别硬转指针。

13.9 与汇编 / 其他语言对照

  • 汇编:UB 在汇编层没有意义——CPU 忠实执行指令,UB 是语言标准层面的约定,编译器的优化器才是 UB 的「执行者」。
  • Java:内存安全是语言保证,越界抛异常、null 抛 NPE,没有 UB——代价是每次访问的检查开销和 JVM 的复杂度。
  • Rust:用所有权 + 借用检查器在编译期消灭悬空、别名、数据竞争,unsafe 块之外没有 UB,这是 Rust 对 C 指针困境的正面回答。
  • C++:继承 C 的 UB,但 RAII、智能指针、std::span(带边界)大幅缩小了 UB 的暴露面。

UB = 「编译器可以假设它不会发生」。不确定是否合法时,查「标准是否定义」,别赌编译器怎么做。