前段时间,公司内有过一次关于未定义行为的讨论,我认为很有价值,也从中学到了东西,整理成文。

我简单介绍下几个相关的概念,并着重探讨一些未定义行为的程序用法。

什么是未定义行为

未定义行为,Undefined Behavior(UB),并不是一个软件工程行业专有的话题,而是在各个领域都存在的普遍概念。

比如,开挖掘机可以挖沙子,也可以用来捞鱼,甚至,用它来挖鼻孔也可以,只要驾驶员有足够的能力驾驭它。而挖掘机说明书中,大概率不会提到用它捞鱼或挖鼻孔,虽然理论上有操作的可能性。

这些行为就可以称为使用挖掘机的未定义行为,又或者用手机捣蒜、用验尿机验茶水,都是 UB 行为。

未定义行为发生时,无法预测也无法保证它会产生什么后果,但一定是不推荐的行为。在软件上,通常指可以通过编译的编程用法,但不保证它实际会产生什么运行期效果。

程序存在未定义行为,就意味着它的执行结果有可能正确,也有可能错误,它可能会导致程序崩溃,但也可能看起来一切正常。而决定最终结果的因素,可能是编译器、操作系统、处理器或者运行时物理环境等。

不过需要明确的一点是,未定义行为应当被认为是一种 Bug。虽然很多系统编程中(尤其是使用 ASM、C 和 C++ 编程),程序员会主动利用未定义行为来达到一些目的,但这依然应该被认为是 “利用 Bug 实现特性” 的行为。

对于编译器来说,它永远假设程序中没有未定义行为。换句话说,编译器的编译行为,并不会考虑或处理未定义行为程序的输入。如果程序员意外写出了未定义行为,那编译器可能会忽略,也可能会因此生成错误的目标代码。

一个看似现在运行良好的程序,如果它带有未定义行为,很可能在将来更新编译器版本、或迁移运行平台、或升级其中某个依赖库时,将未定义行为的隐患激活。所以才说,它依然是一种 Bug。

一个注重安全的软件,应该避免出现未定义行为。

几个相关的概念

除了未定义行为,还有几个类似的名词,有些人可能会搞混。

未定义行为(Undefined Behavior)

程序不一定正确或错误,无法预测,但有潜在隐患。

编译器默认忽略并假设输入不含未定义行为。

比如说,内存越界访问是一种未定义行为,它可能不会异常,也可能某次运行时出现奇怪的现象。

未指定行为(Unspecified Behavior)

同一个程序,不同编译器的实现不固定。或者说,一种实现可能有多种不同的行为,这些行为都是符合标准的。

编译器会选择其中某一种实现来处理未指定行为。

如果程序员忽略了未指定行为,却写出了触发它的代码,那可能会带来意外的结果。

比如说,C++ 中函数参数的求值顺序是一种未指定行为,所以如果代码行为依赖参数求值顺序,可能出现不符合设计的结果。

实现定义的行为(Implementation-Defined Behavior)

不常被提到。和未指定行为类似,不同编译器的实现可能不同,但区别在于,它不是标准定义的。

对于这种编译器自行决定的编译行为差异,编译器需要给出使用说明。

C++ 中,明确允许编译器实现自行决定如何操作的一些行为,而不强制统一,比如说,char 是有符号还是无符号,整形数据溢出时的行为等。

这么做是允许编译器针对不同硬件架构做优化,以及兼容历史遗留代码。和未定义行为的区别是,它和未指定行为,不被认为是 Bug,而是编程语言的一部分,由语言标准或编译器说明来规范。

本文先讨论未定义行为。

几种常见的未定义行为

有符号整数溢出

在 C++ 中,无符号整数溢出不是未定义行为,它的溢出行为由编译器自行决定;但有符号整数溢出,是未定义行为。

比如:

1
2
3
4
5
int32_t val = INT_MAX + 1;
std::cout << val << std::endl; // print: -2147483648

uint32_t val = UINT_MAX + 1;
std::cout << val << std::endl; // print: 0

注释中是实际运行(Linux 5.15 64-bit, gcc 11) 打印的结果。

看起来,这个执行结果是正确的,有符号从最大值溢出 1 后,得到了最小值。但这依然是未定义行为。

因为从数学上,有符号的整数溢出是没有被准确定义的,它可以是自然环绕(如上例),也可以是饱和、抛出错误或 0。

这可能带来意外的编译器优化,比如:

1
2
3
for (int i = 0; i <= INT_MAX; i++) {
// loop body
}

编译器可能认为 i 永远不会达到比 INT_MAX 更大的数,从而认为循环体永远会执行下去。所以我们建议,这种代码中,将 i 的类型改为 unsigned int 等无符号数。

虽然在标准中,有符号整型溢出是未定义行为,不过大多数编译器,在未开启激进优化时,会选择用自然环绕实现溢出。

如果不想刻意处理有符号数溢出的行为,使用数学库的 API 是一个推荐的实践。

类似的,整数位移溢出可以看作是对整数乘 2 或除 2 的结果溢出。它也是未定义行为。

访问未初始化内存

访问一个未赋初值的内存处的数据,是未定义行为,这个显而易见。

不过,有一个 C++ 初始化内存的坑,容易踩到这个 UB:

1
2
3
4
5
6
7
8
9
struct Custom { int val; };

int main() {
std::unique_ptr<Custom> obj1(new Custom());
std::unique_ptr<Custom> obj2(new Custom);

obj1->val; // fine
obj2->val; // undefined behavior
}

类型 Custom 只有一个 POD 成员,没有其他任何成员函数和复杂的成员。对于第二个对象 obj2,构造后的 val 值不会被初始化,而对于 obj1val 值会被初始化为 0。

对于只带有 POD 成员的类型,使用不带括号的 new 关键字创建的对象,其成员不会被默认初始化,从而访问时可能导致未定义行为。

严格别名

这个概念(Strict Aliasing),是一个 C++ 标准中的规则,它规定了哪些类型的指针可以合法的指向某个内存位置,而不会导致未定义行为。

编译器实现中有一个过程叫别名分析,它的一个作用就是对这些指针类型所指向的实际数据做分析,从而方便后续的优化和变换。多个不同的指针可能指向同一个内存位置,但意外的使用也可能导致未定义行为。

常见的合法别名用法有:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// access member
struct Custom { int x; };
struct Custom* ptr;
int* p = &ptr->x; // fine

// char-like pointer
char* c_ptr = reinterpret_cast<char*>(p); // fine

// cv type
const int* const_ptr = p; // fine
volatile int* volatile_ptr = p; // fine

// symbol value
unsigned int* ui_ptr = p; // fine

也有一些是未定义行为,比如:

1
2
3
int i = 42;
float* fptr = reinterpret_cast<float*>(&i); // undefined behavior
float f = *fptr;

编译器不会假设 fptr 和 i 有什么直接关系,很可能会产生非预期的结果。

比如说:

1
2
3
4
5
6
7
8
9
10
void transform(int* arr, float* output, size_t n) {
for (size_t i = 0; i < n-1; i++) {
output[i] = static_cast<float>(arr[i+1]) + 1;
}
}

int main() {
int data[100];
transform(data, reinterpret_cast<float*>(data), 100);
}

当发生激进优化时,可能做向量化处理或整体预取指令,导致最终结果错误。

唯一定义原则

先介绍下这个概念(One Definition Rule)。C++ 中明确约定,对于一个符号,必须有唯一的一个定义。

如果同一个编译单元中,有多个同名的定义,那么编译会报错。

如果不同编译单元中,有多个同名的定义,那么链接时会报告符号重复定义。

这些都可以由编译器检查出来,但有一个例外。对于模板函数,C++ 允许同名模板函数,有多份定义。

编译器处理这种情况的思路是,在编译阶段,对由模板函数生成的符号增加 .weak 标记,也就是弱符号。在链接阶段,对于同名的弱符号,随机选择一个(通常是第一个遇到的),保留到目标文件,而不会报错。

如果多个定义的模板同名函数,他们的实现是一样的,那不会有什么问题,但如果不一样,则大概率遇到意外情况。比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// file1.cpp
template <typename T>
T process() {
return 2;
}

int foo() {
std::cout << process<int>() << std::endl;
}

// file2.cpp
template <typename T>
T process() {
return 10;
}

int bar() {
std::cout << process<int>() << std::endl;
}

// main.cpp
int main() {
foo();
bar();
}

这个例子中,当使用 -O0 编译时,打印出两个 2 或两个 10,但我们预期的是一个 2 和一个 10。而使用 -O2 编译时,能打印出一个 2 和一个 10

这是因为,当开启优化时,编译器会对简单的函数做内联操作,从而在编译阶段,就消除了符号 process,自然能得到正确的结果。不开启优化时,链接时会仅保留其中一个 process 的实现。

线程模型的激进优化

并发编程中有很多未定义行为,其中,对程序重排序可能带来未定义行为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int x = 0, y = 0;

auto fun = [&] {
x = 1;
y = 2;
};

auto observer = [&] {
if (y == 2) {
assert(x == 1);
}
};

std::thread t1(fun);

std::thread t2(observer);

编译器可能假设,当 y 为 2 时,x 一定是 1,从而对 observer 中的代码做激进的优化。

然而,在没有一致性同步操作时,这个假设可能是错的。x = 1 和 y = 2 在编译器调度或硬件指令重排序时,完全可以交换顺序。

为什么允许未定义行为

原因是为了追求兼容性、灵活性和高性能实现。

以 C++ 为例。C++ 的核心目标是零成本抽象,是运行性能最优,它为了达到这个目标,选择信任程序员。从而,编译器也会信任程序员,无论程序员是意外使用了未定义行为,还是故意使用未定义行为来达到某些目的。

这些目的包括但不限于:

  • 向后兼容性:过去大量的代码已经这么用了,改变语言规则会破坏旧代码
  • 零成本抽象:任何实现都只生成达成该目的所必要的代码
  • 硬件灵活性:因为要运行在各种多样且复杂的底层硬件,只能通过未定义行为完成特定的目的

未定义行为就像是一种行规,一种默认的使用约定。“用户应当知道这些行为且明白它可能带来的后果”。

从 Rust 看未定义行为

不同于 C++,Rust 在设计之初,就确定它的核心目标是系统安全。所以 Rust 语言和编译器能够从语言层面避免不安全的编码。

然而,Rust 这种设计也就限制了语言操作底层硬件和系统的灵活性,所以它不得不额外支持 unsafe 语法,允许在一定程度上允许出现未定义行为。

从而,我们可以换个视角,通过 Rust 的 unsafe 语法规则,来窥探可能存在的未定义行为。

在 unsafe 代码块中,Rust 允许以下 5 类不安全用法(它并未完全禁用安全检查,只是放宽限制):

  1. 解引用裸指针:在 unsafe 中,不关心裸指针是否为空、悬垂、未对齐或指向非法内存,这是很多未定义行为产生的根源
  2. 调用不安全的函数:释放了调用函数中存在的未定义行为
  3. 访问或修改可变静态变量:在并发环境下,可变静态变量作为一种共享资源,可能产生竞争,多线程竞争也是一种未定义行为
  4. 实现不安全的 trait:也是并发数据竞争问题,对于一些如 Send 和 Sync 的特征,跨线程可能带来数据安全问题
  5. 访问 union 的字段:严格别名约束的一种体现。union 有不同的类型,访问不同字段也就意味着可能通过不同类型的指针访问到同一块内存,自然可能引入未定义行为

另外还有一些没有直接对应到规则的未定义行为被释放:

比如,内存模型违规:

1
2
3
4
5
unsafe {
use std::sync::atomic::{AtomicBool, Ordering};
let flag = AtomicBool::new(false);
flag.store(true, Ordering::Relaxed);
}

其中 Ordering::Relaxed 可能导致多个不同线程看到的 flag 状态不同。

总结

千万不要小看了每一个未定义行为,我上边列举的几个包括示例代码,都是简化后的结果。实际上,在工程实践中,这样的未定义行为藏在非常隐蔽的地方,调试排查很困难。

最好的策略就是始终保持对它的敏感性,并避免程序中出现任何未定义行为。


本文同步发布在知乎账号下:编译器视角下的未定义行为