C Programming Language

Expertise #02: Undefined Behavior and Unspecified Behavior

# Basic Tutorial,  # Programming Language,  # C Programming Language

Last Updated: 2020-07-01


前置知识

在阅读本文档前,请确保你已经掌握指针的基础知识,详见 Basics #02: Pointer


概述

C 语言标准明确规定了编写 C 语言程序时应当遵循的规范,包括规定了各类语法错误等等。然而不幸的是,根据 C 语言标准,有些语句的行为是不受国际标准控制的,换句话说,如果你在你的程序中做出了这类行为,那么你的程序在不同的环境下会运行出不同的结果。这类行为当中有些是正常的,但大多数是灾难的源头,你需要小心谨慎地对待它们。

许多劣质教材并没有对此进行介绍,甚至非常愚蠢地去研究这类行为的运行结果(比如谭浩强的教材)。但是学习 C 语言或是 C++ 语言的人都应该至少对此有一个基本的了解。

前文所述的这类行为主要包括未定义行为和未指定行为,本文将对此进行一些简单的介绍。

本文中有大量零碎的小节,介绍了各种常见的未定义行为和未指定行为,其中部分小节的难度较大,初学者可酌情进行选择性的学习,但至少应该对未定义行为和未指定行为的概念和优缺点有所了解。


为什么初学者需要掌握此内容

理由很简单:如果初学者了解未定义行为和未指定行为的概念,就能解除很多困惑,比如为什么数组越界有时会出乱数有时会程序崩溃,否则就很容易陷入误区。

比如,第一次数组越界的时候程序崩溃,一些人可能就因此有了“数组越界程序就会崩溃”的认识,而另一些人还可能会有了“数组越界会出乱数”的认识,然而这都是错的,将来的某一天,这些不幸的人一定会在一个程序上 DEBUG 很长时间,才发现自己原先的认知是错误的。如果你了解了这些现象背后的真理,那么你就不会因此而陷入误区,能够提高编写程序的效率,并且有利于自己未来的学习路程。


未定义行为

基本概念

未定义行为 (undefined behavior, UB) 是指 C 标准完全没有规定和限制运行结果的行为。使用了未定义行为的程序的运行结果将是完全不可预测的。

未定义行为源于编译器不能检测到的程序错误或太麻烦以至无法检测的错误。使用了未定义行为的程序都是错误的,即使程序能够运行,甚至能够返回正确的结果,也只是巧合罢了。

未定义行为最可怕的地方在于,编译器不仅不会对这些行为报告错误,而且不会对这些行为的运行结果作任何保证。

含有未定义行为的程序在有些环境或编译器中可以正确执行,但并不能保证同一程序在不同编译器中甚至在当前编译器的后继版本中会继续正确运行,也不能保证程序在一组输入上可以正确运行且在另一组输入上也能正确运行。因此不小心写出的未定义行为往往很难被检查出来。

其实未定义行为并不罕见,比如数组越界其实就是一种未定义行为,这也是初学者的噩梦之一。

例如数组越界时,程序有可能直接崩溃,但也有可能不会崩溃,而是给出一个莫名其妙的值,可能是 00 ,也可能是什么 81726358172635 或者 19283010-19283010 之类的。更糟的是,你还可能会恰好访问到其他变量的内存地址,你对该内存的写操作不会产生任何错误,而是会作用到一个你永远猜不到的变量上,导致你的程序的其他地方出现莫名其妙的错误。

正是因为数组越界的结果的不确定性,当你不小心写出越界的代码时,各种奇奇怪怪的错误都有可能发生,而你却难以定位问题的所在,直到你用肉眼发现,或是经过长时间的 DEBUG 最终发现问题。在短的代码文件中还好说,这在大型程序中更加糟糕。

其他的未定义行为也是类似的,包括有符号整数溢出、局部变量使用前未初始化、对空指针解引用等等。下文会对部分常见的未定义行为进行详细的介绍。

基于上述原因, C 语言的学习者应当对未定义行为有所了解,并在编写 C 程序时谨慎地避免触发任何未定义行为,更不能试图利用未定义行为去做任何事情。

下面是 C99 标准中对未定义行为的原文定义:

3.4.3 undefined behavior behavior, upon use of a nonportable or erroneous program construct or of erroneous data, for which this International Standard imposes no requirements

事实上,未定义行为实在是太多了,在 C99 标准中仅仅是简短地列举这些行为就用了十几页纸,因此我不打算在面向初学者的文章中逐一介绍,这里只会给出一些常见的未定义行为。

非法内存访问

任何非法的内存访问操作都是未定义行为,包括数组越界、对空指针或野指针解引用。

数组越界的示例:

void example1() {
    int a[10];
    for (int i = 0; i <= 10; i ++) {
        scanf("%d", &a[i]);
    }
}
void example2() {
    int a[10];
    for (int i = 0; i < 10; i ++) {
        if (a[i] > a[i + 1]) {
            swap(a[i], a[i + 1]);
        }
        if (2 + a[i] > 3 * a[i - 1]) {
            swap(a[i], a[i - 1]);
        }
    }
}

对空指针或野指针解引用的示例:

void example1() {
    int * p = NULL;
    *p = 1;
}
void example2() {
    int * p;
    *p = 1;
}
void example3() {
    int * p = (int *)malloc(sizeof(int));
    free(p);
    *p = 1;
}

请读者谨记,非法内存访问是一种未定义行为, 不一定会导致程序崩溃 ,而有可能产生一些未知的效果。

前文已经提到过,当数组越界时,如果你不幸恰好访问到其他正在被使用的内存地址,那么这并不是一个非法的地址,你对该内存的写操作不会产生任何错误,而是会作用到一个你永远猜不到的变量上,导致你的程序的其他地方出现莫名其妙的错误。

此外,对野指针解引用也不一定会导致程序直接崩溃,而可能得到一个乱数,这取决于编译和运行环境等等。

重复释放内存

我们知道,在 C 语言中动态开辟的内存都需要释放,但如果对同一块堆内存进行多次释放,将触发未定义行为。

程序可能会崩溃,也可能会导致其他任何不可预料的结果,例如下面的代码:

int * p = (int *)malloc(5 * sizeof(int));
free(p);
/* many codes here... */
int * q = (int *)malloc(5 * sizeof(int));
free(p);

如果运气非常不好,你为 q 分配的内存恰好落在之前为 p 分配的那块地址,那么在第二次 free(p) 时,指针 q 所指向的内存将被意外地释放掉,并且程序不会报告任何错误,甚至当你试图访问 q 时,将会导致非法内存访问,进而程序可能崩溃,也可能导致其他后果。

局部变量使用前未初始化

我们知道,未指定初始值的全局变量会被初始化为零值,但是局部变量却并非如此。读取一个未被初始化的局部变量的值是一种未定义行为:

void example1() {
    int x;
    printf("%d\n", x);
}
void example2() {
    int a[10];
    printf("%d\n", a[3]);
}

这可能导致程序崩溃,也可能返回 0 或随机乱数。因此在任何情况下都要保证变量在被读取前先初始化。

有符号整数溢出

当有符号整数类型(例如 intshort 类型)发生溢出时,其结果也是不确定的。例如:

int x = 2147483647; // MAX value of int type
int y = x + 1;

虽然在多数情况下 y 的值会变成 -2147483648 ,即 int 类型的最小值,但这事实上是建立在硬件 CPU 的实现基础之上的,并非每个 CPU 在发生这种运算溢出时都会得到这个结果。

此外,有符号整数类型在强制类型转换时发生溢出也是未定义行为,例如:

void example1() {
    int x = 10000000;
    int y = (short)x;
}
void example2() {
    double x = 1e12;
    int y = x;
}

需要强调的是,有符号整数溢出属于未定义行为而非未指定行为,任何情况下我们都应该避免此类溢出情况。

除以零或对零取模

对于 x = y / zx = y % z 而言,如果 z == 0 则会触发未定义行为, x 的值将是不确定的。当然,程序也可能会直接被终止。

使用指针时违反类型规则

指针类型的强制转换需要小心,例如把一个 int * 类型的指针变量强制转换为 float * 类型并解引用它是一个未定义行为:

int x = 1;
float y = *((float *)(&x));

这为什么是一个未定义行为的原因涉及到了浮点数底层实现的知识,因此不对初学者展开介绍。

试图修改字符串字面量

试图修改字符串字面量的值会触发未定义行为:

char * a = "abcde";
a[1] = 'x';

访问生命周期已结束的变量

我们知道每个变量都有其生命周期,当其生命周期结束之后即会失效。然而不恰当的程序可能会不慎通过指针访问到已经失效的变量,例如;

int * f() {
    int x = 1;
    return &x;
}
void example() {
    int * p = f();
    int y = 2 + *p;
}

我们通过函数 f 的返回值得到了变量 x 的地址,但是 x 的生命周期事实上已经结束了,这将触发未定义行为。编译器可能会将 x 释放,你对这个返回来的地址取值可能会得到 0 或是乱数,但是编译器也有可能会因为你写出这样的代码而帮你悄悄地延长 x 的生命周期。

有些编译器会对这样的行为提出警告:

|3| warning: function returns address of local variable [-Wreturn-local-addr]

但是这并没有用,一些编译器很容易就会被骗过去,例如一些版本的 gcc 编译器会对上文中的程序提出警告,却不会对下面的程序提出警告:

int * f() {
    int x = 1;
    int * p = &x;
    return p;
}

在表达式内对某个变量的冲突性读写

对于那些不知情的程序员而言,使用此类未定义行为导致的错误是最难以被发现的。特别地,广为流传的谭浩强教材中存在对此类行为的完全错误的教导,初学者需要特别注意不要被坑害了。

你可能曾经见过类似下面的代码,并且感觉判断这些表达式的运行结果是一件十分困难的事情:

int i = 1;
int x = i++ + ++i;

你也可能见过一些垃圾教学者夸夸其谈地分析其运行结果,并且看起来十分有道理。如果是这样的话,那么很不幸,你被坑了。事实上,这段看起来似乎“很正常只是代码风格很糟糕”的代码属于未定义行为。

想要深入理解需要具备一定的 C 语言基础,因此我不在本文中对其原因进行详细介绍。初学者只需要记住:

  1. 不要在一个表达式内对任何一个变量 ii 修改超过一次。例如:
int i = 1;
int x = i++ + ++i;
int y = i++ + i++;

再例如:

void f(int * p) {
    *p ++;
}
int i = 1;
int x = f(&i) + f(&i);
  1. 不要在一个表达式内在修改任何一个变量 ii 的同时在别处读取 ii 的值。例如:
int i = 1;
i = i++ + 1;
int x = i + i++;

再例如:

int a[10];
a[i++] = i;
a[i] = i++;

记住,这些行为在 C 语言标准中被明确规定属于未定义行为,在任何情况下你都不应该使用这类行为。至于去专门研究这些行为在不同编译环境下的运行结果,更是愚蠢至极。

当然,下面的代码是没有问题的:

int i = 1;
int j = 5;
int x = i++ + ++j;
int y = i++ + j++;

不过,考虑到代码风格和可读性的问题,即便没有错误,仍然强烈建议读者尽量不要在一个表达式内混用自增自减运算符。

至于此类行为属于未定义行为的内在原因,是较为复杂且需要一定基础的,因此我不在本文中对其原因进行详细介绍,而是专门另起一篇文章,详见 C 语言中的 06 - Do not Abuse Side Effects in Subexpressions 文档。

在移位操作中使用不恰当的偏移量

例如,在 32 位机器上对一个 int 类型的变量左移或右移大于等于 32 位是未定义的:

int x = 233;
int y = (x << 32);
int z = (x >> 40);

因为移位操作将会调用所在机器的 CPU 的硬件移位指令,当移位超限时运算结果将会取决于硬件实现。

在移位操作中使用负数作为偏移量也是未定义行为。

指针的非法使用

;(unfinished)

In C and C++, the comparison of pointers to objects is only strictly defined if the pointers point to members of the same object, or elements of the same array.[13] Example:

int f() {
    int a = 0;
    int b = 0;
    return &a < &b; /* undefined behavior */
}

未指定行为

未指定行为 (unspecified behavior) 容许不同的编译器对这样的行为提供多种可能的实现,容许在同一程序中重复时产生不同结果,并且在任何情况下都不指定编译器选择何种实现。

换句话说,如果程序中使用了未指定行为,那么对于同样的代码,在不同的编译环境下,或是在同一编译环境的不同编译参数设置下,甚至是在同一编译环境同一设置下程序代码中的不同位置重复出现,程序都可能有给出不同的运行结果。

未指定行为的运行结果取决于编译器的具体实现,并且可能无法通过检查程序的源代码来完全确定。这看起来与未定义行为类似,虽然二者都具有一定的不确定性,但是却有本质上的区别。

不要将未指定行为与未定义行为混淆,二者并非同一概念。

未定义行为通常是错误的程序构造或数据的结果, C 标准没有规定如何处理这类行为,其不确定性的根源是“错误”。而未指定行为则是 C 标准允许不同的编译器提供多种实现,但是要求必须提供确切的实现。多数情况下,未指定行为带来的不确定性问题主要是可移植性的问题。

未指定行为并非不能使用,恰恰相反,有些未指定行为很有用。只要我们充分理解未指定行为,并小心谨慎地对待它,就能规避其结果的不确定性可能导致的一切危害。而未定义行为则无论如何应该避免使用。

下面是 C99 标准中对未指定行为的原文定义:

3.4.4 unspecified behavior use of an unspecified value, or other behavior where this International Standard provides two or more possibilities and imposes no further requirements on which is chosen in any instance

事实上,未指定行为实在是太多了(虽然只用了三页纸,比未定义行为少多了),因此我不打算在面向初学者的文章中逐一介绍,这里只会给出一些常见的未指定行为。

具有静态存储周期的变量的初始化顺序

C 标准规定,当程序启动时,具有静态存储周期的所有对象均应被初始化,这其中包括全局变量和 static 变量。但是标准并未规定这些变量以何种顺序初始化。

这对于 C 语言初学者而言不会有太大的问题,因为 C 标准规定只能使用字面值常量来初始化全局变量,但在 C++ 中需要小心对待这一问题。

union 中被替换掉的成员的值

我们知道 union 类型允许多个成员变量共用内存,提供了在多种数据类型之间“切换”的功能。在对一个 union 变量的某个成员变量赋值后,该 union 变量的其他成员的值是未指定的。例如:

union A {
    short x;
    char y;
} a;
a.x = 256;
a.y = 'a';
printf("%d\n", a.x);

输出的结果是不确定的,可能是 97 ,也可能是 353 ,前者是字符 'a' 在 ASCII 码中对应的整数值,后者是 97 + 256 的结果。

至于为什么会是 97 + 256 ,假设在当前编译环境下 short 类型的长度为 22 字节、 char 类型的长度为 11 字节,如果编译器仅仅将字符 'a' 的值按照 char 的长度写入,那么成员 x 的高八位的值不会被抹除,而 256 的二进制表示形式为 00000001,00000000 ,其低八位本就是全零的,因此最终的结果恰好为二者相加。换句话说,如果对 y 赋值前 x = 257 ,结果可能仍为 353

而如果编译器会彻底抹除其他成员的残留数据,结果就是 97 ,但 C 语言标准并未规定一定要这么做。

再例如:

union A {
    short x;
    char y;
} a;
a.y = 'a';
a.x = 65;
printf("%c\n", a.y);

输出的结果是不确定的,虽然很可能是 'A' ,即 65 对应的 ASCII 字符,但是你要谨记这是一种未指定行为。

再例如:

union A {
    int x;
    float y;
} a;
a.y = 12.56;
printf("%d\n", a.x);

正常情况下将 float 类型的值赋给 int 类型的变量(或是反过来)会发生隐式或显式的类型转换,而在 union 中覆盖其它成员时, C 标准并没有提供这种保证。

由于 int 类型和 float 类型的二进制表示形式不同,即便这两种类型的长度字节数相等,输出结果也可能会因环境而异,唯一能保证的就是程序不会崩溃,你总能得到一个合法的整数值,至于值是多少就不好说了。

struct 和 union 中的匿名填充内存中的值

结构体和联合体类型的内存对齐机制可能会产生一些空余区域,被称为匿名内存填充。

如果试图通过某种方法获取匿名填充内存中存储的值,将触发未指定行为。

字符串字面量的内存复用

对于不同的字符指针,如果它们指向的字符串字面量的内容相同,那么编译器可以选择将它们指向同一个字符串字面量,两个字符指针可能会指向常量区中的同一块内存。例如:

char * a = "aaa";
char * b = "a";
char * c = "aaa";
printf("%p %p %p\n", a, b, c);

你可能会发现 ac 的地址是相同的,编译器这么做是为了节约常量区的内存。但是编译器也可以选择不进行这样的优化,这是一个未指定行为,在有些编译环境下,你也可能会发现 ac 的地址并不同。

动态分配大小为零的空间

我们知道 malloc 等各类用于动态分配内存的函数,但如果你试图动态分配大小为零的空间,那么结果将是不确定的,例如:

int n;
scanf("%d\n", n);
int * p = (int *)malloc(n * sizeof(int));
if (p == NULL) {
    return;
}
*p = 1; // might be an undefined behavior
for (int i = 1; i < n; i ++) {
    *(p + i) = 2 * (*p);
}

如果输入 n = 0 ,那么 malloc 可能会真的返回一块内存为 0 的空间的地址,而不是你想象中的一个空指针,那么上述代码中的 if 表达式将不会起作用,接下来任何试图利用 p 访问内存的行为都将引起非法内存访问。

比如示例中试图先行初始化 p[0] ,然后依次计算 p[1], p[2], ... 。基于 p 不为空的假设,你会认为这不会引起任何问题,然而事实却未必如此。

对于此类代码,必须专门判断 n == 0 的情况。

子表达式及其副作用的执行顺序

在一个表达式中,其各个子表达式的执行顺序是未指定的,而不是什么自左向右或是自右向左。

这听起来可能有些抽象,那么我们不妨看一些示例:

int f(int i) {
    printf("%d", i);
    return i;
}
int x = f(1) + f(2) + f(3);

你认为输出的内容会是什么?事实上结果是不确定的,可能是 123 ,也可能是 321 ,但也未必不会是 213

再比如:

int x = f(g(1), h(2));

究竟是 g(1) 先执行还是 h(2) 先执行是不确定的,唯一确定的是,在函数 f 被真正调用之前 g(1)h(2) 一定会先计算完毕。所以我们必须保证函数 gh 之间不存在任何依赖关系,否则程序的运行结果将是不确定的。

当然,如果你在 gh 中对同一变量进行了写操作,那就不仅仅是未指定行为,而是前文已经介绍过的未定义行为了。


实现定义行为

实现定义行为 (implementation-defined behavior) 是一类特殊的未指定行为。其中的区别在于,对于一般的未指定行为,编译器可以随意选择如何实现,并且无需明确说明会如何实现。但是对于实现指定行为,编译器必须明确选择一种实现方式,并明确说明会如何实现。

实现定义行为比未指定行为还要更加安全。如果我们不需要考虑程序的可移植性,那么我们可以放心大胆地依赖实现定义行为,因为我们应当可以在编译器的官方文档中查询到实现定义行为的确切的运行结果。但如果需要考虑移植问题,那么我们仍然要避免触及实现定义行为。

和前两者一样,实现定义行为也太多了,因此我不打算在面向初学者的文章中逐一介绍,这里只会给出一些常见的实现定义行为。

在标识符名称中使用多字节字符

哪些多字节字符可以在标识符名称中使用属于实现定义行为。例如,有时中文字符可以作为变量名,但有时会报告编译错误:

int 变量 = 0;

整数类型和指针类型之间的转换

我们知道变量的内存地址本质上就是一种被赋予了特殊语义的整数,因此整数可以转换为任何指针类型。

然而,根据所处运行环境的不同,地址值的长度可能不同,例如可能是 32 位或 64 位。因此,在整数类型和指针类型之间进行的隐式或显式的类型转换是一个未指定行为,例如:

float x = 5;
float * p = &x;
int a = (int)p;
float * q = (float *)a;

如果在所处运行环境中,地址的长度小于等于 int 类型的长度,则上述代码不会有任何问题。但如果是在 64 位机上,在将 p 中的值赋给 a 时可能会发生溢出,这将触发危险的未定义行为。

指针相减的结果

我们知道,对于指向相同数组中的元素的两个指针,我们可以将二者相减得到其差值:

int a[5];
int * p = &a[1], * q = &a[3];
int delta = q - p;

但是这个运算结果的类型是实现定义的。 C 标准只规定结果是一个有符号整数类型,但是具体是 short int 类型还是 int 类型,亦或是其它类型,就是由编译环境决定的了。在头文件 <stddef.h> 中定义的类型 ptrdiff_t 说明了该结果的确切类型。

类型相关细节问题

  • char 类型的特征(包括数据范围、表示形式、行为等)是和 signed char 类型还是和 unsigned char 类型等价,标准规定必须明确指定其中之一;

  • 编译环境使用了哪些扩展整数类型,以及长度相同的各个扩展整数类型的整数转换排名之间的大小关系;

  • 有符号整数类型的符号位的具体实现方式,例如是单符号位还是双符号位;

  • 将整数类型的值转换为有符号整数类型时,如果原值不在新类型的数据范围之内,具体会发生什么;

  • 有符号整数类型的部分位运算规则,例如右移时符号位如何处理;

  • 枚举类型的整数转换排名。

其它细节问题

  • main 函数接受的 argv 参数的内容;

  • 调用 system(char *) 函数的相关规则;

  • 信号量相关的一切内容;

  • 对于大小为 00 的文件,是否判断为“文件不存在”;


未定义行为与编译优化

充分理解本节内容可能需要对编译有至少一点点了解。

一些编译器还可能在优化时自行处理未定义行为,再一步加剧了结果的不可预测性。

我们知道,编译器在编译程序时会进行一些优化,保证优化前后的程序是等价的,并提高程序的运行速度。然而,因为正确的 C 程序是没有未定义行为的,所以编译器可以在编译使用了未定义行为的程序时产生一些预料之外的结果。这并未违反 C 标准,但却可能为开发者带来严重的困扰。

例如:

int example(int x) {
    return x + 1 > x;
}

可能会被直接优化成 return true ,那么当 x 的值为 int 类型的最大值时,函数究竟会返回什么就是一件不确定的事情了。

再例如:

int f(int x) {
    int a;
    if (x != 0) {
        a = 1;
    }
    return a;
}

可能会被直接优化成 return 1 ,因为这段代码不能保证对 a 的调用不是一个未定义行为,有些编译器可能会为了将未定义行为消除而强行消除掉这个 if 语句。


为什么存在这样的行为

读者一定会感到很奇怪,为什么 C 标准容许未定义行为和未指定行为的存在,甚至专门规定了哪些行为是未定义行为和未指定行为,而不是用一些手段将这些容易出错的东西彻底消除?尤其是学习过其他高级语言的读者,这种规定似乎只在 C/C++ 中存在,而其他语言中鲜有见到。

其实理由很简单。虽然这些行为会给程序员带来各种麻烦,但是 C/C++ 语言的极高的运行效率很大一部分就是因为它们容许了这种行为的存在。

我们知道 C/C++ 语言是以其接近底层和极高的运行效率出名的。如果你学过一点汇编语言或是编译原理就会明白,越高级的语言往往执行效率越低,各种现代语言为你消除了各种未定义行为,却也极大地牺牲了程序的运行效率。

比如,你以为像 Java 语言一样在运行时检查出非法内存访问并抛出错误报告是一件很容易的事情吗?不,这会为程序带来额外的操作,看起来微不足道,但是存在于底层代码中会极大地影响整个程序的运行速度。

虽然 C/C++ 语言不保证在发生非法内存访问时抛出错误,但是这省去了许多合法性检查的开销。这样做的好处是很显著的,只要程序员能够写出正确的程序,就能享受极高运行效率所带来的种种好处。

对于未指定行为也是一样,例如不规定子表达式的求值顺序, C/C++ 语言完全是有意这么规定的,其目的就是允许编译器采用任何求值顺序,使编译器在优化中可以根据需要自由地调整实现表达式求值的指令序列,以得到效率更高的代码。

简而言之, C/C++ 语言为了运行效率而牺牲了对程序员的友好,所以 C/C++ 程序员必须学习并谨慎地对待其他语言中鲜有的未定义行为和未指定行为。你不应该像个喷子一样看待这些行为带来的麻烦,而应该客观地评判其优缺点。


文章更新记录

更新时间更新内容
2020.1.22追加了实现定义行为相关的内容