Menu

C 语言未定义行为:含义、成因和危害

未定义行为是 C 标准不作任何要求的代码,所以什么都可能发生,包括优化器删掉你写的检查。本文讲解它的成因,为什么“在我电脑上能跑”什么也证明不了,以及能捕获它的编译选项。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

“未定义行为”听起来像是“不可预测”的行话,但它的含义比这更强。当 C 标准说某个写法具有未定义行为时,意思是标准对程序的行为不作任何要求:不管是值、这条语句,还是整个程序。

最出人意料的是:未定义行为并不局限于出问题的那一行。编译器可以假定它永远不会发生,并基于这个假设改写周围的代码。结果可能是:你明明写了的检查,在编译出的二进制文件里根本不存在。

契约模型

把标准看作你和编译器之间的一份契约。你承诺不做某些事;作为交换,编译器承诺你的程序按你写的意思执行。

不要越界访问数组。不要让有符号整数溢出。不要读取未初始化的值。不要在释放指针后继续使用它。不要在一个表达式里两次修改同一个对象,而中间没有序列点。

违反任何一条,契约就失效了,而且是对整个程序失效,不只是那一行。没有“合理的兜底行为”,也不要求程序一定崩溃。

有三个相关的术语值得区分清楚:

  • 未定义行为(undefined behavior):什么都可能发生。越界访问、有符号溢出、释放后使用。
  • 未指定行为(unspecified behavior):几种合法结果中的一种,编译器不必告诉你是哪一种。例如函数参数的求值顺序。
  • 实现定义行为(implementation-defined behavior):由实现来选择,并且必须在文档中说明。比如 char 是否有符号、int 有多大。

只有第一种才会危险到“优化器把我的代码删了”的程度。

主要来源

有符号整数溢出

无符号运算会回绕,标准对此有明确规定。有符号运算则不会,超出范围就是未定义的。

检查 if (a > INT_MAX - b) 完全在合法范围内进行,所以它才是正确的溢出检测。写成 if (a + b < 0) 则会先执行溢出,再去判断结果;而编译器有权假定溢出从未发生,可能会把这个检查删掉。

越界访问

读写数组范围之外的内存是未定义的,不管它会不会崩溃:

int arr[5] = {1, 2, 3, 4, 5};
int x = arr[5];        /* UB: index 5 does not exist */
arr[-1] = 0;           /* UB */
int *p = arr + 10;     /* UB even to compute this pointer */

注意最后一行:构造一个超出末尾一个以上位置的指针就是未定义的,即使你从未解引用它。标准允许 arr + 5(末尾之后一位,用于终止循环),但不允许 arr + 6。

小幅度的越界才是最危险的。它们通常不会触发段错误,而是悄悄覆盖相邻的变量,错误的结果则出现在毫不相关的地方。

读取未初始化的值

int x;
printf("%d\n", x);     /* UB: reading an indeterminate value */

int *p;
*p = 42;               /* UB: dereferencing an indeterminate pointer */

人们很容易认为“它只是存着垃圾值”,但标准并不是这么说的,而编译器会利用这个区别。曾有 GCC 版本认定:一个在赋值前就被读取的变量可以是它想要的任何值,包括能让某个分支被折叠掉的值。

悬空指针

int *p = malloc(sizeof *p);
free(p);
*p = 42;               /* UB: use after free */
free(p);               /* UB: double free */

int *q;
{
    int local = 10;
    q = &local;
}
printf("%d\n", *q);    /* UB: the object's lifetime ended */

运行时的后果在段错误一文中有讲解;这里要说的是,崩溃反而是幸运的结果。

错误的 printf 格式说明符

printf("%d\n", 3.14);        /* UB: %d with a double */
printf("%s\n", 42);          /* UB: %s with an int - usually crashes */
printf("%d %d\n", 1);        /* UB: fewer arguments than specifiers */
long n = 5;
printf("%d\n", n);           /* UB on systems where long is wider than int */

printf 是可变参数函数:它按照格式字符串读取参数,无法检查它们。一旦不匹配,它就会从错误的位置读取错误数量的字节。用 -Wall 编译,编译器就会替你检查格式字符串,这是这组警告中最有价值的之一。

在一个表达式中两次修改同一个对象

int i = 0;
i = i++ + ++i;             /* UB */
arr[i] = i++;              /* UB */
printf("%d %d\n", i++, i); /* UB */

这些都是未定义的,而不只是“取决于编译器”。教科书上那些问 i = i++ + ++i 结果是多少的题目,根本没有正确答案。

严格别名

通过不兼容类型的指针访问一个对象是未定义的,这一条连有经验的程序员都会中招:

float f = 1.0f;
int *p = (int *)&f;
printf("%d\n", *p);        /* UB: reading a float through an int * */

编译器假定 int * 和 float * 永远不会指向同一块内存,并据此重新排列指令。重新解释字节的合法方式是 memcpy(优化后生成的指令完全一样),或者使用联合体:

char * 是个例外:你随时都可以通过 unsigned char * 查看任何对象的字节。

为什么“在我电脑上能跑”什么也证明不了

未定义行为常常看起来能正常工作,这正是它危险的地方。程序在开发和测试中一切正常,然后在某个完全不相关的东西改变后突然出错:

  • 新版本的编译器带来了更聪明的优化器。
  • 发布版本从 -O0 换成了 -O2。
  • 添加了一个无关的函数,栈布局随之改变,原本的越界写入现在落在了要紧的数据上。
  • 换了一台机器、换了一个 libc、换了一个操作系统。

看起来能运行并不能证明代码正确,因为标准从来没有承诺过任何东西。这是一个处于休眠状态的 bug,而触发它的通常就是发布版本。

优化器如何利用它

下面这个例子通常能让争论结束。一位程序员写了一个空指针检查:

void process(int *p) {
    int value = *p;              /* dereference */
    if (p == NULL) {             /* then check for null */
        return;
    }
    printf("%d\n", value * 2);
}

顺序写反了,检查在解引用之后,但检查总归还是会执行吧?

不一定。编译器的推理是这样的:*p 已经被解引用了,所以 p 不可能是 NULL(解引用 NULL 是未定义的,所以在任何行为有定义的程序里,它都不是 NULL);所以 p == NULL 永远为假;所以整个 if 语句体都是死代码,可以删掉。

编译后的函数里根本没有空指针检查。Linux 内核中就出现过这种模式的真实案例,编号 CVE-2009-1897:GCC 正是删掉了这样一个检查,把一个看似无害的顺序错误变成了可被利用的漏洞。

再看一个小一点的例子:

/* An overflow check that does not work */
int safe_add(int a, int b) {
    int sum = a + b;
    if (sum < a) {          /* "did it wrap?" */
        return -1;
    }
    return sum;
}

对于有符号类型,编译器可以假定 a + b 没有溢出;在这个假设下,只有当 b < 0 时 sum < a 才可能成立。于是这个检查测试的并不是作者想要的东西,而当 b >= 0 时,它可能被完全优化掉。正确的写法是事先检查:

两个判断都停留在可表示的范围内,所以永远不会发生溢出,优化器也就没有什么可以“假定不存在”的。(GCC 和 clang 还提供了 __builtin_add_overflow,用一条指令就能完成同样的事。)

检测未定义行为

先用静态警告,它们是免费的:

gcc -Wall -Wextra -Wpedantic program.c -o program

这能捕获格式字符串不匹配、部分未初始化读取、可疑的比较以及不可达的代码。

然后是 sanitizer,它会给程序插桩,在违规发生的那一刻报告:

gcc -g -fsanitize=undefined program.c -o program
./program
program.c:8:15: runtime error: signed integer overflow:
2147483647 + 1 cannot be represented in type 'int'

再配合 AddressSanitizer 处理内存方面的问题:

gcc -g -Wall -Wextra -fsanitize=address,undefined program.c -o program

两者结合,可以捕获越界访问、释放后使用、重复释放、有符号溢出、非法移位、未对齐的指针和空指针解引用,每一项都带有文件、行号和调用栈。程序大约会慢 2 倍,这在开发阶段完全不算什么。

valgrind ./program 不需要重新编译,能捕获未初始化读取和内存错误,但捕获不了算术方面的 UB。clang --analyze 和 gcc -fanalyzer 甚至不用运行程序就能找出其中一部分问题。

实用的规则是:在 CI 中用 sanitizer 运行你的测试。那些在本地看起来能正常工作的 UB,正是它们要揪出来的。

与它共处

光靠小心是避免不了未定义行为的,每个人迟早都会写出来。有效的做法是让它暴露出来:

  • 从第一天起就用 -Wall -Wextra 编译,并修复每一条警告。
  • 在 -fsanitize=address,undefined 下运行测试。
  • 每个变量在声明时就初始化,每个指针都初始化为 NULL。
  • 用数组长度检查下标,循环条件写成 i < n。
  • 在运算之前用 <limits.h> 检查是否会溢出。
  • 释放指针后把它设为 NULL。
  • 需要回绕行为时使用 unsigned 类型,回绕在那里是有定义的。
  • 重新解释字节时,优先使用 memcpy,而不是指针强制转换。

C 的速度来自编译器可以假定你遵守了契约。这是一种真实的取舍,而不是设计缺陷;上面这些工具能以几乎为零的开发成本,把大部分安全性还给你。

想了解这些规则在运行时的表现,请看段错误;想了解更早出现的编译期错误,请看常见错误。

常见问题

C 语言的未定义行为是什么?

指 C 标准对其完全不作要求的代码。编译器可以生成任何结果:崩溃、错误的答案、看起来能正常运行的代码,或者把出问题的分支整个删掉的代码。它不是“由实现定义”,也不是“随机”,而是你违反了契约,之后的一切都没有任何保证。

C 语言为什么要有未定义行为,而不是把所有情况都定义清楚?

为了速度和可移植性。要求每次访问数组都做边界检查,会带来 C 在设计上就不愿承担的性能开销;把有符号溢出定义为回绕,又会迫使那些溢出时会触发陷阱的硬件多执行指令。让这些情况保持未定义,编译器就可以假定它们永远不会发生,并据此进行优化。

C 语言的有符号整数溢出是未定义行为吗?

是的。INT_MAX + 1 是未定义的,它并不保证回绕到 INT_MIN。无符号溢出则不同:它有完整的定义,按 2^N 取模回绕。正因如此,对有符号的 x,编译器可以假定 x + 1 > x 永远成立,并删掉用这种写法实现的溢出检查。

怎么检测 C 程序中的未定义行为?

先用 -Wall -Wextra 编译,捕获编译器能静态发现的问题;再用 -fsanitize=address,undefined 运行测试,它会在越界访问、释放后使用、有符号溢出等问题发生的那一刻报告,并指出文件和行号。valgrind 无需重新编译,也能捕获类似的一批问题。

Coddy 编程语言插图

和 Coddy 一起学编程

开始