未定义行为:一个printf函数引发的思考


#

在开始之前,我们先看这样一段代码:

1
2
3
4
5
6
7
#include <stdio.h>
int main() {
double a = 10;
printf("%d\n", 8.0/5);
printf("%d\n", a);
return 0;
}

按照强制类型转换的原则,输出看似应当是1和10。然而事实要比这复杂的多。结下来我们将这段代码分别编译成32位和64位的可执行文件。

运行时我们发现,结果远远超乎我们的想象。

#

我们先看一下32位可执行文件的执行结果(本文灵感来自这里):

搞错了?多运行几次,仍然是同样的结果。这时事情变得严重起来了,我们先尝试能否逆推出第一个输出的来源:-1717986918对应的十六进制为0x9999999a。由此可知该数据在内存中的存放方式为9A 99 99 99,看起来并不是什么特殊的数字,因此我们考虑看看下面对原程序反汇编的结果。

首先我们应该先明确两个事实:第一,32位环境下,printf通过堆栈传参并且从右到左将参数依次放入栈中。如0x011d5处的指令将指向格式化字符串的指针入栈作为printf的第一个参数;第二,我们并不能在代码中看到8.0/5运算的过程,因为编译器在编译时已经为了优化程序计算出了算式的值作为一个常量。

程序第一次进行浮点数操作是在0x11b6处,通过附近的代码我们可以确定计算结果存储在ebx-0x1ff0的位置:

将该处的数据转为double类型,我们可以看到结果是10.0,可以判断此处是变量a入栈的操作。再看这一块内存,我们突然有了意外发现:ebx-0x1fe8处的数据正是我们刚刚探索的第一个输出的来源,将此处double类型的数据输入这里的小工具,可以看到结果正是8.0/5的计算结果:

结合0x11e1处的两条压栈指令,我们已经可以解释异常输出的原因了:printf的格式化字符串并没有造成数据本身类型的转化,它只规定了printf函数从内存中读取数据之后如何看待它。因此%d造成了对浮点数的截断,从而得到意料之外的结果。

#

32位的情况相对简单,我们在考虑一下64位的情况,运行结果如下:

64位程序的输出更加离谱,更离谱的是多次重复运行的结果几乎都不相同。笔者偶然间发现如果将格式化字符串改为%lx,输出结果会出现一定的规律:

这些数据怎么看怎么眼熟,数据可疑的长度(刚好占48位,x86-64结构下linux虚拟内存的大小一般是2^48)让人不禁联想到地址的可能性。这些先按下不表,我们仍然先看反汇编结果:

仍需明确一个事实:x86-64结构下printf函数的前7个参数从左到右放入rdi, rsi, rdx, rcx, r8, r9这几个寄存器,之后的参数从堆栈传递。

我们容易看到在每一次printf函数调用前,rdi中都存放着格式化字符串的地址,然而我们并没有看到rsi的出现。但我们可以观察到一个细节:eax在调用printf函数之前总是置为1。在 x86_64 System V ABI中可以看到如下描述:

Register Usage
%rax temporary register; with variable arguments
passes information about the number of vector
registers used; 1st return register

而所谓的vector registers目前可以简单的理解成浮点数运算相关的一系列寄存器,比如本例中的xmm0寄存器。这里出现了一个奇怪的现象:eax被设置意味着函数调用有浮点数的参与,然而printf函数最终奇怪的行为却好像并不支持这一点。线索在这里又断掉了。

为了解答这一问题,笔者查找了很久也没有查找出汇编代码中出现了什么问题,在C标准中似乎也没有提及。但在这里笔者最终找到了原因,原因很简单,代码本身的行为属于未定义行为,即其行为本身就是没有规定,也是不可预测的。其实正如上面所说,未定义行为正是代码在当前程序状态下的行为在其所使用的语言标准中没有规定时表现出来的行为,而这也是示例中程序错误的真正原因。

#

当这篇文章写到结尾时,笔者的感觉就像是看了一期《走近科学》。本以为是什么高深的错误,甚至用上了反编译,结果却是“未定义行为”。但既然如此,就应该思考一下未定义行为这种情况为何存在。

在大部分的语言中,类似于示例中的情况根本无发通过编译,或是运行时抛出异常。然而C和C++对未定义行为却显得极为大度。原因可以用一个例子说明:假设现在有一个四则运算的程序,程序的输入当然只能是数字(语言标准),那么当用户给出不符合标准的输入时,程序可能输出意想不到的结果或者直接崩溃(未定义行为),也可以报错(抛出异常,编译不通过)。我们可以看到,前者就是标准的允许未定义行为存在的情况,它假想用户的输入都是合法的。相比较于后者,它缺少了一些检查措施,但也因此代码更加简洁,执行速度更快。

C和C++便是如此,如果一种行为没有被标准规定过(如惹人生厌的(i++)+(++i)之类的代码)那么编译器就假设这种行为永远不可能发生,这带来了一些编译器进行优化提升的空间。

这里有一些常见的C语言中的未定义行为。


← Prev 64位格式化字符串漏洞 | 关于Cache存储器 Next →