Analysis of Undefined Behaviors from an Examination

本文中对 C 语言标准章节的引用以 N1570 为准。

某笔试题中的未定义行为

昨天看到一道笔试题,要求找出以下程序中的所有错误(代码已经手动格式化)。 该程序的用途是对标准输入中的所有整数(保证是 $[1,size]$ 之间)计数:

#include <stdlib.h>

int *frequency(int size)
{
	int *array;
	int i;
	array = (int *)malloc(size * 2);
	array -= 1;
	for (i = 0; i < size; i++)
		array[i] = 0;
	while (scanf("%d", &i) == 1)
		array[i] += 1;
	free(array);
	return array;
}

呃,这题的难度在于,这程序的槽点多得都不知道从哪开始吐比较好, 因此只能说是一道坑爹题。然而另一方面,作为一个教学实例, 它很“好”地展示了常见的若干与未定义行为高度相关的编程错误。 让我们一点一点地看。

首先什么是 size * 2?什么是 2?语言标准并没有规定 int 的大小, 它只是说 int 至少要能表示 $[-2^{16}, 2^{16}-1]$ 之间的所有整数 (5.2.4.2.1p1)。所以这里唯一可移植的写法是 sizeof(int), 否则要么会分配过多内存,要么会分配过少内存。

然而这么改就够了吗?当然不是!分析一下 size * sizeof(int) 这个东西, 它是一个整数乘法表达式,右边的子表达式 sizeof(int) 根据语言标准具有 size_t 类型,而且它是无符号类型(6.5.3.4p5, 7.19p2)。而 int 是带符号类型(6.7.2p2, p5),把它们两个乘起来的时候… … 根据标准, 会执行常规算术运算自动类型转换(usual arithmetic conversions) (6.5.5p3),对于整数运算来说,它的规则如下(6.3.1.8p1):

在两个操作数上进行整数提升转换,然后应用下列规则:

  • 提升后,如果两个操作数类型相同,不需要再进行转换。
  • 否则,如果两个操作数都是无符号的,或都是带符号的, 则低等级操作数被转换为高等级操作数的类型。
  • 否则,如果无符号操作数的等级大于等于带符号操作数的等级, 则将带符号操作数转换为无符号操作数的类型。
  • 否则,如果带符号操作数的类型能够表示无符号操作数类型能表示的所有值, 则将无符号操作数转换为带符号操作数的类型。
  • 否则,将两个操作数都转换成带符号操作数类型对应的无符号类型。

这里又出来两个概念,什么是“等级”?标准说(6.3.1.1p1):

每个整数类型都有一个 整数转换等级 ,定义为:

  • 任意两个带符号整数类型都不会有相同等级,即使它们表示的范围相同。
  • 一个带符号整数类型的等级大于所有精度更低的带符号整数类型。
  • long long int 的等级大于 long int 的等级,long int 的等级大于 int 的等级, int 的等级大于 short int 的等级, short int 的等级大于 signed char 的等级。
  • 如果无符号整数类型存在对应的带符号整数类型,则它们的阶相同。
  • 标准整数类型的等级大于任何相同位宽的扩展整数类型。
  • charsigned charunsigned char 的等级相同。
  • _Bool 的等级低于所有其他标准整数类型。
  • 任何枚举类型的等级等于与其相容的整数类型的等级(详见 6.7.2.2)。
  • 任何精度相同的扩展带符号整数类型的等级次序由实现决定, 但不能违反其他规则。
  • 对于整数类型 T1T2T3,若 T1 的等级大于 T2T2 的等级又大于 T3 ,则 T1 的等级大于 T3

什么又是“整数提升转换”?定义为(6.3.1.1p2,3):

以下规则适用于任何可以使用 intunsigned int 的表达式:

  • 如果对象或表达式具有整数类型,且其等级小于等于 intunsigned int, 且本身又不是 intunsigned int
  • 如果是具有 _Boolintsinged intunsigned int 的位段。

如果 int 能表示原始值的所有类型,则将其值转换为 int;否则, 转换为 unsigned int 。这称为 整数提升转换 。它不改变其他类型。

整数提升转换一定保留原始值不变,包括符号。正如之前章节已经讨论的, char 默认是否带符号由实现决定。

分析我们的例子,如果 size_t 的等级小于等于 int 的等级,则它会被提升成 unsigned int ,而它的等级等于 int 的等级,于是 int 被转换成 unsigned int ,结果就是两个 unsigned int 相乘。如果 size_t 的等级大于 int 的等级,则 int 会被转换成 size_t,就是两个 size_t 相乘,总之都是无符号乘无符号。很“好”,不会触发未定义行为!

然而我之前翻译的文章中早就说过,不触发未定义行为不代表没 bug 。 例如,在 32 位 x86 平台上,作为扩展整数类型 size_t 的等级小于 int 的等级,结果是两个 unsigned int 相乘,且 sizeof(int) 的值是 4。 如果恶意用户传递了一个 1073741825 作为 size ,结果就是 1073741825u * 4u ,按 32 位补码算术规则就会得到 4u。 于是你就只分配了能放 1 个整数的空间, 之后恶意用户就可以随便输入几个数触发越界访问了,越界访问可是未定义行为!

所以在这里需要把这种情况处理掉:

if (size * sizeof(int) / sizeof(int) != size)
	return NULL;

然后编译器会抱怨:

warning: comparison of integer expressions of different signedness:
‘long unsigned int’ and ‘int’ [-Wsign-compare]
  if (size * sizeof(int) / sizeof(int) != size)
                                       ^~

因为这里在比较无符号类型(左边)和带符号类型(右边), 结果右边的数会被转换成无符号类型(仍然是按照算术运算自动转换规则,见 6.5.9p4)进行比较。这样,如果用户输入了一个负数,就可能发生奇怪的现象。 再加一些防御:

unsigned u_size = (unsigned) size;
if (size <= 0 || u_size * sizeof(int) / sizeof(int) != u_size)
	return NULL;

这还没完,不能排除 unsigned 的表示范围比 size_t 大的情况, 这种情况下将一个 unsigned 传递给接受 size_tmalloc 就会被截断, 从而再次分配过小的内存块。于是,我们强制转换一下以上判据中的内存块大小:

if (size <= 0 || (size_t) (u_size * sizeof(int)) / sizeof(int) != u_size)
	return NULL;

这样,如果内存块大小会被截断,该条件表达式就不会再成立。

下一行 array -= 1绝对错误 的。 许多人喜欢玩弄这种地址代数技巧,觉得这样很牛逼,但其实标准规定得很清楚, 这是一个未定义行为(6.5.6p8):

当某表达式在指针上加或减一个整数时,结果的类型与指针操作数一致。 如果指针操作数指向数组中的一个元素,且数组足够大,则结果指向另一元素, 该元素与指针操作数指向的元素在数组中的下标之差等于整数操作数。换句话说, 如果 P 指向数组中的第 $i$ 个元素,则表达式 (P)+N (等价于 N+(P)) 和 (P)-N (这里 N 的值是 $n$)分别指向数组的第 $i+n$ 和 $i-n$ 个元素,前提是它们存在。另外,如果表达式 P 指向数组的最后一个元素,则 (P)+1 指向数组的最后一个元素之后的元素, 且如果表达式 Q 指向数组的最后一个元素之后的元素,则 (Q)-1 指向数组的最后一个元素。 如果指针操作数和运算得到的指针都指向同一数组中的元素, 或该数组最后一个元素之后的元素,则运算不会发生溢出。否则,行为是未定义的。 如果结果指向数组最后一个元素之后的元素,则不得将单目 * 运算符作用于它。

注意标准只允许指向数组最后一个元素之后的元素的指针, 可没有允许指向第一个元素之前的指针。你可以写出

/* pretend "var a: array[-5..4] of integer" in Pascal */
int a[10], i;
int *p = a + 5;
for (i = -5; i < 5; i++)
	p[i] = foo(i);

或者

/* pretend addressing array with negative offset in Python */
int a[10], i;
int *p = a + 10;
for (i = 1; i <= 10; i++)
	p[-i] = foo(i);

这都是既符合标准又逼格高的地址代数。然而一旦写出 array -= 1, 这就是一个未定义行为,实现可以做任何事。例如,在一台具有稀疏内存 (地址空间里面有很多空洞)和硬件预取机制的计算机上, CPU 可能会直接预取 array 减 1 后指向的那个地址, 然而这一段地址根本没有连接内存总线,于是 CPU 就跑飞了。 就算是在普通的笔记本上,编译器看到这句话也可以做任何事, 包括在你的屏幕上输出 I am angry!.

再强调一次,这不是说 array-1 的结果没有意义, 而是说表达式 array -= 1 本身 没有意义, 这导致 任何 执行它的程序流程 没有意义。 理论上它可能表现为 system("mkfs.ext4 /dev/sda1")raise(SIGBUS), 甚至 __builtin_unreachable()

所以说唯一可行的办法是多分配一个整数的内存空间:

u_size += 1;

这里不会溢出(因为它是一个正的 int 转换来的,不会等于 UINT_MAX)。

下面的循环范围应该变成 1u_size,为了防止带符号溢出,需要改为用 size_t 作为循环变量。或者,可以直接用 calloc 分配初始化好的数组:

unsigned u_size = (unsigned) size + 1;
if (size <= 0 || (size_t) u_size != u_size)
	return NULL;
array = calloc(u_size, sizeof(int));

这里连乘法溢出的判断都交给 calloc 进行了, 但是传递参数时仍要检查是否会发生截断。

据报道,某些老旧的 C 运行库没有在 calloc 中判断乘法溢出。 (这类运行库曾经引起一系列安全问题。) 如果你希望程序能在这些违反标准的环境下工作,需要自行判断乘法溢出。

然而,无论是 malloc 还是 calloc 分配失败的时候都会返回 NULL (7.22.3.2p3, 7.22.3.4p3),如果解引用空指针,又会产生未定义行为, 所以要把这种情况判断掉:

if (!array)
	return NULL;

再然后就是主循环,我第一眼没看出问题, 然而这是因为我习惯了 ACM 题目总是给出绝对准确的数据范围。 如果有个恶意用户偏要输入一个超过 size 的数,或者负数, 不就又触发未定义行为了 :( 。所以要给他判断掉:

while (scanf("%d", &i) == 1)
	if (i > 0 && i <= size)
		array[i] += 1;
	else
		/* handle error */

至于这个错误怎么处理我也不知道(没有接口文档)。然而这就够了吗? 如果用户干脆输入一个 int 无法表示的大数呢?语言标准规定 (7.21.6.2p10):

只要遇到了一个 % 限定符,输入项(或者对于 %n 限定符来说, 已经输入的字符个数)会被转换成与该限定符匹配的类型。 如果输入项与该限定符不匹配,则该限定符执行失败,称为匹配错误。 除非赋值操作被 * 字符禁止,转换结果被存储到参数列表中 format 参数之后的,第一个尚未获得转换结果的参数中。 如果该对象的类型与限定符不一致,或者转换结果不能被该对象表示, 则行为是未定义的。

再阅读一下 %d 限定符的说明(7.21.6.2p11):

d: 匹配一个或许带符号的十进制整数,其格式与 strtol 函数在 base 参数的值为 10 时对主体序列 (subject sequence) 的预期相同。 对应的参数应该是指向带符号整数的指针。

什么是“主体序列”?再次翻阅标准(7.22.1.4p3):

如果 base 在 2 到 36 之间(含),期望的主体序列是一个包含数字和字母, 表示一个基为 base 的整数的字符序列,它可以在开头带有一个正号或负号, 但不包含整数后缀(integer suffix)。字母 a (或 A)到 z (或 Z) 表示从 10 到 35 的值,序列中只允许小于 base 的值。

这里的坑在于,按照 strtol 的定义,17131212946 确实是一个主体序列, 但它却(在绝大多数实现中)超过了 int 的表示范围,因此在 scanf 试图写入它的时候就会触发未定义行为。为此写一个函数读取 int

/* return 0 if successful, 1 if fail, EOF if end of file. */
int read_int(int *x)
{
	char c[2] = {0, 0};
	unsigned abs = 0, b;
	int sym = 1, stat = 0;
	for (c[0] = getchar(); (signed char) c[0] != EOF; c[0] = getchar()) {
		if (stat == 0) {
			if (isdigit(c[0]))
				stat = 1;
			else if (c[0] == '+') {
				stat = 1;
				continue;
			} else if (c[0] == '-') {
				sym = -1;
				stat = 1;
				continue;
			} else if (isspace(c[0]))
				continue;
			else
				return 1;
		}
		if (stat == 1) {
			if (isspace(c[0]))
				break;
			if (!isdigit(c[0]))
				return 1;
			if (abs * 10u / 10u != abs)
				return 1;
			abs *= 10;
			b = (unsigned) atoi(c);
			if (abs + b < abs)
				return 1;
			abs += b;
		}
	}
	if ((signed char) c[0] == EOF)
		return EOF;
	if (abs == -1u * (unsigned)INT_MIN && sym == -1) {
		*x = INT_MIN;
		return 0;
	}
	if (abs > INT_MAX)
		return 1;
	*x = sym * (int)abs;
	return 0;
}

这里有人会问 (signed char) 是干什么的,这是由于标准规定 char 可能无符号,此时在比较 c[0]EOF 时,c[0] 会被提升为一个正的 int, 而标准却规定 EOF 是负数,判断就会失败。另外,为了解析每一位, 这里动用了 atoi 函数,而许多人会写出 c[0] - '0'。这是不可移植的, 因为未必所有字符集中 '0''9' 这 10 个字符都连续编码。

最后

free(array);
return array;

What the f**k? 返回一个野指针让调用者怎么用? 调用者几乎无论如何都会触发未定义行为。那么就得把 free 删掉。 当然这里肯定又有人说会泄露内存,那调用者去 free 内存就可以了, asprintfstrdup 等许多标准函数都是这么干的。 如果非要说这样做不好,需要改函数接口,这是 另一个问题 了。

综上,最后改完的程序就是

/* Read some integers in $[1, size]$ from stdin and calculate their
   frequency.  It returns a pointer to an array containing the frequency
   of $i$ in the $i$-th element.  If $size$ is negative or zero, or we
   can not allocate the memory, NULL will be returned.  If there is an
   integer outside the range $[1, size]$ from stdin, it will be silently
   ignored.  */
int *frequency(int size)
{
	int *array;
	int i;
	unsigned u_size = (unsigned) size + 1;
	if (size <= 0 || (size_t) u_size != u_size)
		return NULL;
	array = (int *)calloc(u_size, sizeof(int));
	if (!array)
		return NULL;
	while (read_int(&i) == 0)
		if (i > 0 && i <= size)
			array[i] += 1;
	return array;
}

总结

我不知道出题人有没有想到,为了使这段程序能够完全避免未定义行为, 需要引出大量的分析,并写出这么多代码(辅助函数 read_int 比这段程序还长)。 如果他想到了,还要让人在没有语言标准手册参考的情况下搞出完整的答案, 那是强人所难。如果他没想到,那说明这是个 sb 公司。

另外这个故事告诉我们,在关键程序中不应使用 scanf 函数读取整数, 因为嵌入在 scanf 库函数内部的未定义行为是我们完全没法消除的。 再次强调,这可不是读出来的值未定义,而是只要用户输入了超过 int 范围的数, 整个程序都 没有意义。

Xi Ruoyao
Xi Ruoyao
PhD Student

Retired from ICPC, now a PhD student and an assistant ICPC coach (with no salary).