​ 未定义的行为(Undefined Behavior),指程序不可预测的执行效果,一般由错误的代码实现引起。出于效率、兼容性等多方面原因,语言标准不便于定义错误程序的明确行为,而是将其统称为“未定义”的行为,可以是崩溃,也可以是非预期的任意表现,有些问题在编译器和执行环境的特殊处理下也可能不会造成实质性的危害,但不具备可移植性。代码质量管理的一个重要目标就是消除会导致未定义行为的代码。

大全:附录:C++ 未定义行为成因列表

常见

数组越界

数组下标越界是指访问数组中不存在的元素,即访问的下标超出了数组的边界。比如对于一个包含5个元素的数组,合法的下标是0到4,访问下标为5的元素就是一种越界行为。这种行为在C/C++中是未定义行为(UB)。

示例代码:

1
2
3
4
void func() {
int array[5] = {0};
printf("%d", array[5]); // 越界访问
}

未定义行为的影响:

  • 不同平台和编译器的行为可能不同:如在Windows上的MSVC编译器中,调试模式会用特定的值填充未初始化的内存,这可能帮助你识别越界访问,但在Linux的GCC编译器中,你可能只会访问到未初始化的内存,导致不可预知的值。
  • 可能引发严重错误:如果访问的内存地址超出程序的合法范围,可能会导致段错误(segmentation fault)。

段错误 (Segmentation Fault)

段错误是指程序试图访问一个无效的内存地址,通常是超出程序的合法内存范围或者访问没有权限的内存区域。现代操作系统通过内存保护机制阻止程序访问不合法的内存区域,从而触发段错误。

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <stdio.h>
#include <signal.h>
#include <stdlib.h>

void signalHandler(int signalNumber) {
printf("Signal number: %d\n", signalNumber);
exit(0);
}
int main() {
signal(SIGSEGV, signalHandler); // 捕获SIGSEGV信号

int var = *(int *)0x0; // 试图访问空指针,触发段错误
printf("%d", var);
return 0;
}

段错误的特征:

  • SIGSEGV信号:当程序触发段错误时,操作系统会向程序发送SIGSEGV信号,通常会导致程序异常终止。
  • 访问权限冲突:现代操作系统不仅检测物理内存范围,还会检测程序是否有权限访问某个内存地址,从而防止程序访问操作系统或其他程序的内存。

捕获SIGSEGV信号

尽管可以通过捕获SIGSEGV信号来处理段错误,如上例所示,但这通常不是一个实用的解决方案。捕获SIGSEGV信号后,程序无法从导致段错误的操作中恢复,通常会导致信号处理函数进入无限循环。因此,更好的做法是避免程序中出现导致段错误的代码。

这里为什么会无限循环?

理由:捕获SIGSEGV信号后,程序通常会陷入一个无限循环的陷阱。这是因为当程序遇到段错误时,执行流被中断并转移到信号处理程序中,而信号处理程序执行完后,程序会尝试返回到原来的位置继续执行。这时,程序会重新执行导致段错误的那一行代码,再次触发段错误,信号处理程序又会被调用。这种行为就会导致无限循环的发生。

除零

除零错误在很多语言都有, 除零错误其实是指 除以零(division by zero).

除以零之所以是未定义行为是因为在数学上除以零的结果就是没有定义的. 其实数学上还有好几个没有定义的值, 一些语言会用 NaN(not a number) 来表示这种值. 在 c 语言中这样的表达式是不允许编译的

1
2
3
4
5
6
7
8
9
10
#include <stdio.h>

int main() {
int result = 1 / 0;
printf("%d", result);
return 0;
}
main.c:4:20: warning: division by zero [-Wdiv-by-zero]
int result = 1 / 0;
^

虽然可以通过以变量相除的方式避开编译器检查

1
2
3
4
5
6
7
8
#include <stdio.h>

int main() {
int a = 1, b = 0;
int result = a / b;
printf("%d", result);
return 0;
}

但是并没有什么用, 这样的程序一运行就会产生

1
Signal: SIGFPE (Arithmetic exception)

FPE 即 floating-point exception, 表示程序执行了一个错误的算术操作. 与 SIGSEGV 类似, 不能通过捕获信号来强行使程序运行下去(handler 会被反复运行).

修改字符串字面量

字面量(literal)是写在源代码中的表示固定值的符号(token). 平时俗称的硬编码(hard coding)大多数时候就是在说字面量(有时指常量). 举个例子

1
2
3
const int var1 = 1;
int var2 = 1;
var2 = 2;

其中 var1 是常量, var2 是变量, 1 是字面量.

基本类型的字面量在机器码里也是字面量. 修改字符串字面量说的是这种情况

1
2
char *string = "Hello";
string[0] = 'h';

上述代码会在第二行产生段错误.

在了解只读数据之前, 先尝试以下代码

1
2
3
4
5
6
7
8
#include <stdio.h>

int main() {
char *string1 = "Hello";
char *string2 = "Hello";
printf("%ld %ld", (long) string1, (long) string2);
return 0;
}

以 linux 平台为例, 这段代码将打印出两个相同的内存地址, 由此可以证明 "Hello" 这个字面量是全 ELF 共享的, 无论有多少个使用了 “Hello” 的地方, 它们都会指向同一个地址.

*nix 平台所用的二进制文件(包括共享库与可执行文件)被称为 ELF, 其典型结构如下

一个 ELF 包含以下几个部分

  • ELF 头(ELF header)
  • 程序头(program headers)
  • 节头(section headers)
  • sections

ELF header 存储在 ELF 文件的起始位置, 包含一个用于表示文件格式的魔数(magic number)以及一系列程序基本信息, 使用 readelf 可以读取出其中的内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
$ readelf -h c_sample
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: DYN (Shared object file)
Machine: Advanced Micro Devices X86-64
Version: 0x1
Entry point address: 0x4f0
Start of program headers: 64 (bytes into file)
Start of section headers: 8648 (bytes into file)
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 56 (bytes)
Number of program headers: 9
Size of section headers: 64 (bytes)
Number of section headers: 33
Section header string table index: 32

一个程序的各个部分会被拆分为很多个 sections 存储在 ELF 中, sections 的数量根据操作系统和程序的不同会有不固定的个数, 每个 section 包含的数据多少也是不固定的. ELF header 可以让操作系统到正确的位置寻找 program headers 与 section headers.

section headers 包含了每个 section 的名字与起始位置, 使用 readelf -S 来查看.

program headers 告诉操作系统为了执行此 ELF 需要到哪些地址(文件偏移量)加载数据到内存, 这些地址也在 section 区域中, 通常是多个 sections 的集合. 使用 readelf -l 来查看.

sections 组成了 ELF 的本体, 而 program headers 与 section headers 是如何使用这些 sections 的元信息. 一般情况下共享库(shared object, 即 .so 文件)是不包含 program headers 区域的, 但是如果有合法的 program headers 并且确实有正确的 main 函数, 共享库也是可以当做可执行程序运行的. 所以共享库和可执行程序并没有本体上的区别.

在那么多的 sections 中, 我们只关心其中的 .rodata 节. rodata 就是 read only data, 它存储着这个 ELF 中的全局共享的数据. 使用命令来读取这一节

1
2
3
$ readelf -x .rodata c_sample
Hex dump of section '.rodata':
0x00000690 01000200 48656c6c 6f00 ....Hello.

0x00000690 是之前在 readelf -S 命令中可以看到的 .rodata 节的起始位置, 01000200 是与操作系统有关的魔数(用来表示内存分配的一些 flag), 在 linux x86_64 平台固定为这个值, 之后就是用户代码里写的字符串字面量(因为是字符串, 最后还有个 \0).

如果使用多个字符串字面量

1
2
3
4
5
int main() {
char *string1 = "Hello";
char *string2 = "World";
return 0;
}

那么就可以在 .rodata 里找到多个字符串

1
2
Hex dump of section '.rodata':
0x000006a0 01000200 48656c6c 6f00576f 726c6400 ....Hello.World.

现在我们知道程序是怎么运行的了, 下面来练一练

1
2
3
4
5
6
7
8
#include <stdio.h>

int main() {
char *string1 = "Hello";
char *string2 = "World";
printf("%s", string1 + 6);
return 0;
}

这段代码在 linux 平台会打印出 “World”

回到最初的问题, 由于字符串字面量一定会被编译到 .rodata 节, 而此节从 ELF 加载到内存后是被操作系统保护的, 是只读的. 之前提到过, 现代操作系统会在程序企图对一个地址进行操作前检查权限, 如果权限不正确也会产生段错误(被中断).

有返回值的函数没有 return

先看以下例子

1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>

int func() {

}

int main() {
printf("Hello\n");
printf("%d\n", func());
return 0;
}

类似 func 这样声明了有返回值却没有 return 的函数, 并非在所有编译器都能编译. 而且会有类似如下的警告

1
2
3
4
5
$ clang main.c
main.c:5:1: warning: control reaches end of non-void function [-Wreturn-type]
}
^
1 warning generated.

而即使能编译, 最终的运行结果也取决于编译器和 cpu 架构.

linux x86_64 平台 gcc 7.5.0(c89, c90, c99, c11) 的结果是第二行输出 0

linux x86_64 平台 g++ 7.5.0(c++98, c++11, c++14) 第二行输出 6

linux x86_64 平台 clang 6.0.0(c89, c90, c99, c11) 第二行输出随机数字

如果对此感兴趣可以找一些在线的 playground 把所有编译器都试一遍.

实际上讨论到底会有什么输出是没有意义的, 因为未定义行为的结果会因为编译器, 操作系统, cpu 架构不同而不同. 下面略微讲解一下为什么 g++ 输出 6

首先, 函数退栈的时候需要把返回值交给其调用者, 而函数自身已经退栈, 所以为了传递返回值必须将其存入某个事先约定的寄存器(与 cpu 架构有关), 函数退栈后调用者再到约定的寄存器去获取返回值. 将返回值保存到目标寄存器的操作就是 return.

所以非常显而易见的是, 如果编译器没有做额外的处理(故意刷新 return register), 那么取返回值时, 总能取到上一个调用 return 的函数存入的值.

非常凑巧的是, printf 函数是有返回值的

1
extern int printf (const char *__restrict __format, ...);

printf 的返回值是成功输出的字符数量. 因此 return register 会被 printf 置为 6, 而 func 没有调用 return. 取返回值时就会取到实际上是上一个退栈的函数的前一个退栈的函数的返回值.

在 x86 架构 cpu 中, 保存小于 32 位的整型返回值的 return register 是 EAX , 这可以用 objdump 来得知, 此处不再赘述. 在 c 语言中能够通过 register 关键字操作寄存器.

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>

register int eax asm("eax");

int func() {

}

int main() {
printf("Hello\n");
printf("%d\n", eax);
return 0;
}

(第二行输出 6)

x86 平台的 64 位整型返回值会使用 RAX , 而浮点数更是有 XXM0XXM7 (Streaming SIMD Extensions)那么多寄存器联合使用. 所以如果上上个退栈的函数的返回类型和上一个退栈的函数类型不同, 此时甚至会取到"未初始化"的寄存器值.