UB 不是「会报错」,而是「标准不再约束编译器,什么结果都可能」——C 指针最危险的地方。
UB 意味着编译器可以假设它不会发生,并据此优化。后果包括:
UB 与「运行时报错」是两回事:Java 的越界抛异常是定义好的行为;C 的越界是 UB,连「抛异常」都不能保证。
| 行为 | 说明 |
|---|---|
| 解引用空指针 | *NULL,通常段错误 |
| 解引用悬空指针 | free 后 / 返回局部变量后使用 |
| 越界读写 | 数组越界、strcpy 溢出 |
| 越界指针运算 | 超出 one-past-the-end 的 p+n |
| 不同数组的指针相减/比较 | 无定义(==/!= 除外) |
| 丢弃 const 写只读对象 | 写字符串字面量、写 const 全局 |
| 违反 strict aliasing | 用不兼容指针类型别名访问 |
| 违反 restrict | 别名了 restrict 指针 |
| 未对齐访问 | 强转不对齐地址再解引用 |
| double free / 释放非 malloc 内存 | 堆损坏 |
| 使用未初始化指针 | 垃圾值当地址用 |
指针指向的内存已经失效,指针本身还在:
int *p;
{
int x = 10;
p = &x; // p 指向栈上局部变量
} // 块结束,x 销毁
printf("%d\n", *p); // UB:p 悬空
产生悬空的三种来源:返回局部变量地址(第 10 章)、free 后继续用(第 9 章)、realloc 后还用旧指针。
不检查边界就写,是安全漏洞的头号来源(栈溢出改返回地址、堆溢出改函数指针):
char buf[8];
strcpy(buf, "this is way too long"); // 写穿 8 字节,破坏相邻内存
gets(buf); // gets 无法限制长度,C11 已移除
snprintf、fgets、带长度上限的 API 替代。-fsanitize=address(ASan)能当场抓住越界写。C 规定:同一块内存,只能用「兼容类型」的指针访问(少数例外:char*、unsigned char* 可看任何对象的字节)。用不兼容类型别名,是 UB:
float f = 1.0f;
int *p = (int*)&f; // 用 int* 读 float,违反严格别名
int x = *p; // UB(编译器可能已按 f 是 float 做了优化)
合法的「类型双关」做法:用 memcpy 或 union:
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 标准下部分实现定义)
把不对齐的地址强转成 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)。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——它按「p、q 不别名」的假设处理;违反 restrict/别名规则会出现「写了 2 却读到 1」。
-Wall -Wextra -Wpedantic,把警告当错误 -Werror。-fsanitize=address,undefined(开发期)。clang --analyze、cppcheck。NULL,free 后置 NULL。const + 显式长度,别指望 '\0' 兜底。memcpy/union,别硬转指针。std::span(带边界)大幅缩小了 UB 的暴露面。UB = 「编译器可以假设它不会发生」。不确定是否合法时,查「标准是否定义」,别赌编译器怎么做。