为什么printf("%f",0)会产生未定义的行为?

89

这个声明

printf("%f\n",0.0f);

打印出0。

然而,这个语句

printf("%f\n",0);

打印随机值。

我意识到我正在表现出某种未定义的行为,但我无法确定具体原因。

所有位都是0的浮点值仍然是一个有效的值为0的float
floatint在我的机器上是相同大小的(如果这甚至相关的话)。

为什么在printf中使用整数字面值而不是浮点数字面值会导致这种行为?

P.S. 如果我使用相同的行为,则可以看到

int i = 0;
printf("%f\n", i);

38
printf 函数期望输入一个 double 数据类型,但你提供了一个 int 类型。在你的计算机上,floatint 可能大小相同,但是当将 0.0f 推入变参列表时,它实际上被转换为 double(而 printf 函数期望的就是这个)。简而言之,你没有根据你所使用的格式说明符和提供的参数履行 printf 函数的协议要求。 - WhozCraig
22
可变参数函数不会自动将函数参数转换为对应参数的类型,因为它们无法这样做。与具有原型的非可变参数函数不同,编译器无法获取必要信息。 - EOF
3
哦哦... "variadics." 我刚学了一个新词... - Mike Robinson
2
可能是重复问题:printf会导致未定义的行为吗? - Khalil Khalaf
3
尝试下一步是将(uint64_t)0传递而非0,看看是否仍然会出现随机行为(假设doubleuint64_t具有相同的大小和对齐方式)。在某些平台上(例如x86_64),由于不同类型被传递到不同的寄存器中,输出很可能仍然是随机的。 - Ian Abbott
显示剩余20条评论
10个回答

122
"%f" 格式需要一个 double 类型的参数。你提供了一个 int 类型的参数,这就是为什么行为是未定义的原因。
标准并不保证所有位都为零是 0.0(虽然通常是这样)或任何 double 值的有效表示,也不保证 intdouble 是相同的大小(记住它是 double 而不是 float),甚至如果它们是相同的大小,也不保证它们以相同的方式作为参数传递给可变参数函数。
在你的系统上可能会“工作”。这是未定义行为最糟糕的症状,因为它使得难以诊断错误。 N1570 7.21.6.1 第9段:

... 如果任何参数不是对应转换说明符的正确类型,则行为是未定义的。

类型为float的参数会被提升为double,这就是为什么printf("%f\n",0.0f)能够工作。比int窄的整数类型的参数会被提升为intunsigned int。这些提升规则(由N1570 6.5.2.2段落6指定)对于printf("%f\n", 0)的情况没有帮助。
请注意,如果将常量0传递给期望double参数的非变参函数,则行为是定义良好的,假设函数的原型可见。例如,在#include <math.h>之后,sqrt(0)会将参数0int隐式转换为double,因为编译器可以从sqrt的声明中看到它期望一个double参数。但对于printf来说,它没有这样的信息。变参函数(如printf)是特殊的,需要在调用它们时更加小心。

13
这里有几个很好的核心要点。首先,它是“double”而不是“float”,因此OP的宽度假设可能不成立(很可能不成立)。其次,整数零和浮点零具有相同的位模式的假设也不成立。干得好。 - Lightness Races in Orbit
2
@LucasTrzesniewski:好的,但我不知道我的回答如何引出这个问题。我确实说过float会被提升为double,但我并没有解释原因,因为那不是主要的重点。 - Keith Thompson
2
编译器不需要为 printf 设置特殊钩子,尽管例如 gcc 之类的编译器确实有一些这样的钩子,以便它可以诊断错误(如果格式字符串是文字)。编译器可以从 <stdio.h> 中看到 printf 的声明,这告诉它第一个参数是 const char*,其余的由 , ... 指示。不,%f 是用于 double(而 float 被提升为 double),而 %lf 是用于 long double。C 标准对栈没有任何规定。它仅在正确调用 printf 时指定其行为。 - Keith Thompson
2
@robertbristow-johnson:在过去,“lint”经常执行一些gcc现在执行的额外检查。传递给printffloat会被提升为double;这并没有什么神奇之处,只是一种调用可变参数函数的语言规则。printf本身通过格式字符串知道调用者声称传递给它的内容;如果声称不正确,行为将是未定义的。 - Keith Thompson
2
小修改:l长度修饰符“对接下来的aAeEfFgG转换说明符没有影响”,对于long double类型的转换,长度修饰符为L。(@robertbristow-johnson 也许会有兴趣) - Daniel Fischer
显示剩余9条评论

60

首先,正如其他答案中提到的但在我看来没有清楚地说明的那样: 在大多数情况下,将整数提供给库函数接受doublefloat参数的上下文是有效的。编译器将自动插入转换。例如,sqrt(0)是完全定义良好的,并且会像sqrt((double)0)一样运行,对于在该参数中使用的任何其他整数类型表达式也是如此。

printf不同。这是因为它接受可变数量的参数。其函数原型为

extern int printf(const char *fmt, ...);

因此,当你写作时

printf(message, 0);
编译器对于printf函数的第二个参数并没有任何类型信息。编译器只知道这个参数表达式的类型是int,因此不像绝大部分库函数那样,你需要亲自确保参数列表与格式字符串的期望匹配。现代编译器能够检测到类型不匹配的错误,但它们不会为了达成你本意而插入转换,因为如果代码出现问题,最好是在你会注意到的时候就报错,而不是几年后再使用一个不太有用的编译器重构代码。
接下来的问题是:既然在大多数现代系统上,(int)0和(float)0.0都是由32位表示的零,那么为什么不通过偶然的方式得出正确结果?C标准只是说“它不必工作,你要自己承担风险”,但我将列举两个最常见的原因,以帮助你理解为什么它不是必需的。
首先,由于历史原因,当你将float传递给可变参数列表时,它会被提升为double,在大多数现代系统上,double宽度为64位。所以,printf("%f", 0)只传递32个零位给期望接收64位的调用方。
第二个同等重要的原因是浮点函数参数可能会在与整数参数不同的位置传递。例如,大多数CPU都有专门的寄存器文件用于整数和浮点值,因此如果它们是整数,则规则可能是参数0到4放置在r0到r4中;如果它们是浮点数,则放置在f0到f4中。因此,printf("%f", 0)查找零时在寄存器f1中,但根本没有找到。

1
有没有使用寄存器来处理可变参数函数的架构,即使是那些用于普通函数的架构中也有?我认为这就是为什么虽然其他函数(除了具有浮点/短/字符参数的函数)可以使用“()”声明,但要求正确声明可变参数函数的原因。 - Random832
3
目前,可变参数函数和普通函数的调用约定之间唯一的区别是,对于可变参数函数可能会提供一些额外的数据,例如实际提供的参数数量的计数。否则,一切都在与普通函数完全相同的位置上执行。例如,请参见http://www.x86-64.org/documentation/abi.pdf的第3.2节,其中对于可变参数的唯一特殊处理是在`AL`中传递的提示。(是的,这意味着`va_arg`的实现要比以前复杂得多。) - zwol
@Random832:我一直认为这个原因是因为在某些架构上,使用特殊指令可以更有效地实现已知数量和类型参数的函数。 - celtschk
@zwol:不,我想的是8086的ret n指令,其中n是硬编码整数,因此不适用于可变参数函数。然而,我不知道任何C编译器是否真正利用了它(非C编译器肯定有)。 - celtschk
抱歉...s/hard-coded/compile-time/——常量是作为指令本身的一部分给出的。 - celtschk
显示剩余2条评论

13

通常情况下,当您调用期望 double 的函数时,但提供了一个 int,编译器会自动为您转换为 double。但是,这在 printf 中不会发生,因为函数原型中没有指定参数的类型 - 编译器不知道应该应用什么样的转换。


4
另外,printf() 特别是 被设计成参数可以是任何类型的。你需要知道每个格式字符串中的元素期望的类型,并正确地提供它。 - Mike Robinson
@MikeRobinson:嗯,任何基本的C类型。这只是所有可能类型中非常非常小的子集。 - MSalters

13
为什么使用整数字面量而不是浮点数字面量会导致这种行为? 因为printf()除了第一个参数(const char* formatstring)以外,没有类型化的参数,它对所有其他参数使用C风格的省略号(...)。 它根据格式字符串中给定的格式化类型来决定如何解释传递的值。 当尝试时,您将遇到与其相同类型的未定义行为。
 int i = 0;
 const double* pf = (const double*)(&i);
 printf("%f\n",*pf); // dereferencing the pointer is UB

3
某些特定的 printf 实现可能以这种方式工作(除了传递的项是值而不是地址)。C 标准并没有规定 printf 和其他可变参数函数的工作方式,它只规定它们的行为。特别地,没有提到堆栈帧。 - Keith Thompson
一个小问题:printf确实有一个类型参数,即格式字符串,其类型为const char*。顺便说一下,这个问题标记了C和C++,而C更相关;我可能不会将reinterpret_cast用作示例。 - Keith Thompson
只是一个有趣的观察:相同的未定义行为,很可能是由于相同的机制,但细节上有一点不同:在问题中传递int时,当尝试将int解释为double时,UB发生在printf内部-在您的示例中,它已经在解除引用pf时发生了。 - Aconcagua
@Aconcagua 添加了澄清。 - πάντα ῥεῖ
这个代码示例存在严格别名违规的UB问题,这是与问题所询问的完全不同的问题。例如,您完全忽略了浮点数和整数在传递到不同寄存器时可能会出现的情况。 - M.M

12

使用不匹配的 printf() 格式说明符 "%f"和类型 (int) 0 会导致未定义的行为。

如果转换说明符无效,则行为是未定义的。C11dr §7.21.6.1 9

导致 UB 的可能原因:

  1. 根据规范,它是 UB,编译器有点固执。

  2. doubleint 大小不同。

  3. doubleint 可能使用不同的堆栈传递其值(通用 vs FPU 堆栈)。

  4. double 0.0 可能没有被一个全零位模式定义。(罕见)


10

这是一个很好的机会,可以从编译器警告中学习。

$ gcc -Wall -Wextra -pedantic fnord.c 
fnord.c: In function ‘main’:
fnord.c:8:2: warning: format ‘%f’ expects argument of type ‘double’, but argument 2 has type ‘int’ [-Wformat=]
  printf("%f\n",0);
  ^
或者
$ clang -Weverything -pedantic fnord.c 
fnord.c:8:16: warning: format specifies type 'double' but the argument has type 'int' [-Wformat]
        printf("%f\n",0);
                ~~    ^
                %d
1 warning generated.
所以,printf产生未定义的行为,因为你向它传递了一个不兼容的类型参数。

9
我不确定哪里让您感到困惑了。
您的格式字符串需要一个双精度浮点数,而您提供的是整数。
这两种类型是否具有相同的位宽完全无关紧要,除非它可以帮助您避免由此类破碎代码引起的硬内存违规异常。

3
@Voo: 这个格式化字符串修饰符的命名确实不太合适,但我仍然不明白为什么你认为在这里使用 int 是可接受的。 - Lightness Races in Orbit
1
@Voo: “(这也可以作为有效的浮点模式)” 为什么一个 int 可以作为有效的浮点模式?二进制补码和各种浮点编码几乎没有任何共同之处。 - Lightness Races in Orbit
2
这很令人困惑,因为对于大多数库函数来说,将整数字面量“0”提供给类型为“double”的参数将会做正确的事情。对于初学者来说,编译器不会对由%[efg]地址引用的printf参数槽执行相同的转换并不明显。 - zwol
1
@Voo:如果你对这个问题有兴趣,考虑一下在x86-64 SysV ABI上,浮点参数和整数参数是通过不同的寄存器集传递的,这可能会出现严重错误。 - EOF
1
@LightnessRacesinOrbit 我认为讨论为什么会出现未定义行为总是合适的,这通常涉及到谈论实现允许的余地以及在常见情况下实际发生的事情。 - zwol
显示剩余15条评论

4
""%f\n" 仅在第二个 printf() 参数的类型为 double 时才能保证可预测的结果。接下来,变参函数的额外参数受默认参数提升的影响。整数参数属于整数提升,这永远不会导致浮点类型值。而 float 参数被提升为 double

更重要的是:标准允许第二个参数为 floatdouble,其他任何类型均无效。

"

4

为什么它形式上是未定义行为已经在多个答案中讨论过了。

之所以会出现这种特定的行为,是因为与平台相关,但可能是以下原因:

  • printf 根据标准 vararg 传播来期望其参数。这意味着一个 float 将成为一个 double,任何小于 int 的内容将成为一个 int
  • 您正在传递一个 int,而函数期望一个 double。您的 int 可能是32位,而您的 double 是64位。这意味着从参数应该放置的位置开始的四个堆栈字节是0,但后面的四个字节具有任意内容。这就是用于构造显示值的内容。

0

这个"未确定值"问题的主要原因在于将传递给printf变量参数部分的int值的指针强制转换为指向double类型的指针,而va_arg宏执行此操作。

这会导致引用未完全初始化为传递给printf的值的内存区域,因为double大小的内存缓冲区比int大小大。

因此,当解引用此指针时,将返回一个未确定的值,或者更好地说是一个包含部分作为printf参数传递的值的“值”,其余部分可能来自另一个堆栈缓冲区甚至是代码区(引发内存故障异常),真正的缓冲区溢出


可以考虑这些特定部分的“printf”和“va_arg”代码实现...

printf

va_list arg;
....
case('%f')
      va_arg ( arg, double ); //va_arg is a macro, and so you can pass it the "type" that will be used for casting the int pointer argument of printf..
.... 


the real implementation in vprintf (considering gnu impl.) of double value parameters code case management is:

if (__ldbl_is_dbl)
{
   args_value[cnt].pa_double = va_arg (ap_save, double);
   ...
}



va_arg

char *p = (double *) &arg + sizeof arg;  //printf parameters area pointer

double i2 = *((double *)p); //casting to double because va_arg(arg, double)
   p += sizeof (double);



参考资料

  1. GNU项目glibc实现的"printf"(vprintf))
  2. "printf"代码简化示例
  3. "va_arg"代码简化示例

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接