为什么参数与printf未定义行为中的转换说明符不匹配

Why are arguments which do not match the conversion specifier in printf undefined behavior?

本文关键字:转换 说明符 不匹配 参数 printf 未定义 为什么      更新时间:2023-10-16

在C(n1570 7.21.6.1/10)和C++(通过包含C标准库)中,为类型与其转换规范不匹配的printf提供参数是未定义的行为。一个简单的例子:

printf("%d", 1.9)

格式字符串指定int,而参数是浮点类型。

这个问题的灵感来自于一个用户的问题,他遇到了大量转换不匹配的遗留代码,这些代码显然没有造成任何伤害,参见理论和实践中的未定义行为。

起初,声明UB只是格式不匹配似乎很激烈。很明显,输出可能是错误的,这取决于确切的不匹配、参数类型、端序、可能的堆栈布局和其他问题。正如一位评论员指出的那样,这也延伸到了随后(甚至是之前?)的争论。但这与UB的总体情况相去甚远。就我个人而言,除了预期的错误输出之外,我从未遇到过其他任何事情。

大胆猜测一下,我会排除对齐问题。我可以想象的是,提供一个格式字符串,使printf期望大数据和小的实际参数,可能会让printf读取堆栈之外的数据,但我对var args机制和特定的printf实现细节缺乏更深入的了解来验证这一点。

我快速查看了printf的来源,但对于普通读者来说,它们相当不透明。

因此,我的问题是:printf中错误匹配转换说明符和参数的具体危险是什么?

printf只有在正确使用的情况下才能按照标准所述工作。如果使用错误,则行为是未定义的。为什么标准应该定义当你使用错误时会发生什么?

具体地说,在一些体系结构上,浮点参数在不同的寄存器中传递给整数参数,因此在printf内部,当它试图找到与格式说明符匹配的int时,它会在相应的寄存器中找到垃圾。由于这些细节超出了标准的范围,除了说它没有定义之外,没有办法处理这种不当行为。

例如,使用"%p"的格式说明符但传递浮点类型可能意味着printf试图从尚未设置为有效值的寄存器或堆栈位置读取指针,并且可能包含陷阱表示,这将导致程序中止。

一些编译器可能以允许待验证的论据类型;因为有一个错误的程序陷阱使用它可能比输出看似有效但错误的要好信息,一些平台可能会选择这样做。

因为陷阱的行为超出了C标准的范围,所以任何操作该陷阱可能被归类为调用未定义行为。

注意,基于不正确格式的实现捕获的可能性意味着即使在期望类型和实际传递类型具有相同表示的情况下,行为也被认为是未定义的,不同的是,如果同一秩的有符号数和无符号数的值在两者通用的范围内,则它们是可互换的[即,如果"long"为23,则即使"int"answers"long"大小相同,它也可以用"%lX"输出,但不能用"%X"输出]。

还要注意的是,C89委员会通过法令引入了一项规则,该规则一直沿用至今,规定即使"int"answers"long"具有相同的格式,代码:

long foo=23;
int *u = &foo;
(*u)++;

调用Undefined Behavior,因为它会导致以"long"类型写入的信息以"int"类型读取(如果是"unsigned int"类型,则行为也将为Undefined)。由于"%X"格式说明符会导致数据以"unsigned int"类型读取,因此将数据以"long"类型传递几乎肯定会导致数据存储在"long(长)"类型的某个位置,但随后以"unssigned int(无符号int)"类型读取数据,因此这种行为几乎可能违反上述规则。

举个例子:假设您的体系结构的过程调用标准规定浮点参数在浮点寄存器中传递。但是由于%d格式说明符的原因,printf认为您正在传递一个整数。因此,它期望调用堆栈上有一个参数,但该参数并不存在。现在任何事情都有可能发生。

任何printf格式/参数不匹配都会导致错误输出,因此一旦这样做,就不能依赖任何东西。除了垃圾输出之外,很难判断哪一个会产生可怕的后果,因为它完全不取决于您正在编译的平台的具体情况和printf实现的实际细节。

向具有%s格式的printf实例传递无效参数可能会导致无效指针被取消引用。但是,intdouble更简单类型的无效参数可能会导致类似结果的对齐错误。

如果您还不知道的话,我首先要让您知道long是64位的,适用于64位版本的OS X、Linux、BSD克隆和各种Unix。然而,64位Windows将long保留为32位。

这与printf()和UB的转换规范有什么关系?

在内部,printf()将使用va_arg()宏。如果在64位Linux上使用%ld,并且只传递一个int,则其他32位将从相邻内存中检索。如果在64位Linux上使用%d并传递long,则其他32位仍将在参数堆栈中。换句话说,转换规范指示va_arg()的类型(intlong,不管怎样),并且相应类型的大小确定va_arg()调整其变元指针的字节数。尽管自sizeof(int)==sizeof(long)以来,它只能在Windows上工作,但将其移植到另一个64位平台可能会带来麻烦,尤其是当您有int *nptr;并尝试将%ld*nptr一起使用时。如果您无法访问相邻的内存,则可能会出现segfault。因此,可能的具体情况是:

  • 相邻的内存被读取,从那时起输出就被打乱了
  • 试图读取相邻的内存,并且由于保护机制而出现segfault
  • longint的大小相同,所以它只是工作
  • 提取的值被截断,从那时起输出就被打乱了

我不确定在某些平台上对齐是否是一个问题,但如果是,这将取决于传递函数参数的实现。一些具有短参数列表的"智能"编译器专用printf()可能会完全绕过va_arg(),并将传递的数据表示为字节串,而不是使用堆栈。如果发生这种情况,printf("%x %lxn", LONG_MAX, INT_MIN);有三种可能性:

  • longint的大小相同,所以它只是工作
  • 打印ffffffff ffffffff80000000
  • 由于对齐错误,程序崩溃

至于为什么C标准说它会导致未定义的行为,它没有具体说明va_arg()是如何工作的,函数参数是如何在内存中传递和表示的,或者intlong或其他基元数据类型的显式大小,因为它没有不必要地约束实现。因此,无论发生什么,C标准都无法预测。仅仅看上面的例子就应该表明这一事实,我无法想象还有什么其他实现可能会完全不同。