为什么printf(“%f”,0); 给未定义的行为?


87

该声明

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

打印0。

但是,声明

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

打印随机值。

我意识到我表现出某种不确定的行为,但我不知道具体为什么。

所有位均为0的浮点值仍然是有效的float,值为0。
float并且int在我的计算机上具有相同的大小(如果相关)。

为什么使用整数文字而不是浮点文字printf会导致此行为?

PS如果我使用可以看到相同的行为

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

37
printf期待一个double,而你给它一个intfloat并且int可能是你机器上的大小相同,但0.0f实际上是转换为double当推入可变参数的参数列表(和printf期望)。简而言之,printf根据您使用的说明符和所提供的参数,您无法实现讨价还价的目的。
WhozCraig '16

22
Varargs函数不会自动将函数参数转换为相应参数的类型,因为它们不能。必需的信息对编译器不可用,这与带有原型的非varargs函数不同。
EOF

3
哦,“杂技”。我刚刚学到一个新词……
Mike Robinson


3
下一个要尝试的方法是传递a (uint64_t)0而不是,以0查看是否仍然出现随机行为(假设doubleuint64_t具有相同的大小和对齐方式)。由于在不同的寄存器中传递不同的类型,因此在某些平台(例如x86_64)上,输出仍然可能是随机的。
伊恩·雅培

Answers:


121

"%f"格式需要类型的参数double。您正在给它一个类型为的参数int。这就是行为未定义的原因。

该标准并不能保证所有位归零是一个有效的表示0.0(尽管它经常是),或任何的double价值,或者说intdouble具有相同的尺寸(记住它的double,不是float),或者,即使是相同的大小,以相同的方式将它们作为参数传递给可变参数函数。

它可能会在您的系统上“工作”。这是未定义行为的最严重症状,因为这使诊断错误变得很困难。

N1570 7.21.6.1第9段:

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

类型的参数float被提升为double,这就是为什么printf("%f\n",0.0f)起作用的原因。整数类型的参数比int提升为int或窄unsigned int。在的情况下,这些促销规则(由N1570 6.5.2.2第6段指定)无济于事printf("%f\n", 0)

请注意,如果将常量传递给0需要double参数的非变量函数,则假定函数的原型是可见的,则行为定义良好。例如,sqrt(0)(after #include <math.h>)隐式地将参数0从转换intdouble-,因为编译器可以从声明中sqrt看到它需要一个double参数。它没有的此类信息printf。可变参数函数之类printf的函数很特殊,在编写对其的调用时需要格外小心。


13
这里有几个出色的核心要点。首先,double并不是float这样,因此OP的宽度假设可能不成立(可能不成立)。第二,整数零和浮点零具有相同位模式的假设也不成立。做得好
轨道轻轨赛

2
@LucasTrzesniewski:好的,但是我不知道我的回答是如何提出这个问题的。我确实声明了这float一点double,但没有说明原因,但是那不是重点。
基思·汤普森

2
@ robertbristow-johnson:编译器不需要具有特殊的钩子printf,例如,例如gcc确实有一些钩子,因此它可以诊断错误(如果格式字符串是文字)。编译器可以看到printffrom 的声明,该声明<stdio.h>告诉它第一个参数是a const char*,其余参数用表示, ...。不,%f是的doublefloat是,被提升为double),%lf是的long double。C标准对堆栈一无所知。它指定printf仅在正确调用时的行为。
基思·汤普森

2
@ robertbristow-johnson:在过去的发呆中,“ lint”经常执行gcc现在执行的一些额外检查。一个float传递到printf被提升到double; 没有什么神奇的,这只是调用可变参数函数的语言规则。printf本身通过格式字符串知道调用方声称要传递给它的内容;如果该声明不正确,则行为是不确定的。
基思·汤普森

2
小的修正:该l长度调节剂“对一个下面没有影响aAeEfFg,或G转换说明”,对于一个长度调节long double转换L。(@ robertbristow-johnson可能也会有兴趣)
Daniel Fischer

58

首先,正如我在其他几个答案中所提到的那样,但我认为还不够清楚:在图书馆函数采用or或参数的大多数情况下,它确实可以提供整数。编译器将自动插入一个转换。例如,是定义明确的,并且行为将与完全相同,并且在那里使用的任何其他整数类型表达式也是如此。doublefloatsqrt(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,这在大多数现代系统,是64个位宽。因此printf("%f", 0),仅将32个零位传递给期望有64个零位的被调用方。

同样重要的第二个原因是,浮点函数参数可能在与整数参数不同的地方传递。例如,大多数CPU对于整数和浮点值都有单独的寄存器文件,因此可能的规则是,参数0到4如果是整数,则输入到寄存器r0到r4;如果参数是浮点,则输入f0到f4。因此,printf("%f", 0)在寄存器f1中查找该零,但根本不存在。


1
是否有任何架构将寄存器用于可变函数,甚至在那些将其用于常规函数的架构中?我认为这是即使可以使用声明其他函数(带有float / short / char参数的函数除外)也需要正确声明可变参数函数的原因()
Random832 '16

3
@ Random832如今,可变参数和普通函数的调用约定之间的唯一区别是,可变参数可能会提供一些额外的数据,例如所提供参数的真实数量的计数。否则,所有内容都将进入与正常功能完全相同的位置。例如,请参阅x86-64.org/documentation/abi.pdf的 3.2 ,其中对variadics的唯一特殊处理是传入的提示AL。(是的,这意味着实现va_arg比以前复杂得多。)
zwol16年

@ Random832:我一直认为原因是在某些体系结构上,可以通过使用特殊指令更有效地实现具有已知数量和参数类型的函数。
celtschk

@celtschk您可能会想到SPARC和IA64上的“注册窗口”,它们应该使用少量参数来加速函数调用的常见情况(实际上,它们执行相反的操作)。它们不需要编译器专门处理可变参数函数调用,因为在任何一个调用位置上的参数数量始终是编译时常量,而不管被调用者是否可变参数。
zwol

@zwol:不,我在想ret n8086 的指令,那里n是一个硬编码的整数,因此不适用于可变函数。但是我不知道是否有任何C编译器实际上利用了它(非C编译器当然有)。
celtschk

13

通常,当您调用需要a的函数double但提供a时int,编译器会自动double为您转换为a 。不会发生这种情况printf,因为在函数原型中未指定参数的类型-编译器不知道应应用转换。


4
另外,printf() 尤其要进行设计,使其参数可以是任何类型。您必须知道format-string中每个元素期望的类型,并且必须正确提供。
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和其他参数可变功能的工作,它只是规定了他们的行为。特别是,没有提到堆栈帧。
基思·汤普森

一个小问题:printf确实有一个类型化参数,即格式字符串,类型为const char*。顺便说一句,这个问题被标记为C和C ++,而C确实更相关。我可能不会reinterpret_cast举个例子。
基思·汤普森

只是一个有趣的观察:未定义的行为相同,并且很可能是由于相同的机制,但是在细节上有很小的不同:如问题中那样传递int时,UB 在尝试将int解释为double 时出现 printf中。 ,当取消引用pf时它已经在外面发生了
阿空加瓜

@Aconcagua添加了说明。
πάνταῥεῖ

此代码示例是UB,用于严格的别名冲突,这与所要询问的问题完全不同。例如,您完全忽略了将浮点数在不同的寄存器中传递给整数的可能性。
MM

12

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

如果转换规范无效,则行为未定义。C11dr§7.21.6.19

UB的候选原因。

  1. 根据规范,它是UB,编译是精炼-'nuf说。

  2. double并且int大小不同。

  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

我不确定是什么令人困惑。

您的格式字符串应为double; 您改为提供int

两种类型是否具有相同的位宽完全无关紧要,除了它可以帮助您避免像这样的破损代码避免硬存储器冲突异常。


3
@Voo:不幸的,该格式字符串修饰符命名,但是我仍然不明白为什么您会认为int此处可以接受。
Lightness Races in Orbit

1
@Voo:“(也可以视为有效的浮动模式)”为什么要int限定为有效的浮动模式?二进制补码和各种浮点编码几乎没有共同之处。
Lightness Races in Orbit

2
这很令人困惑,因为对于大多数库函数而言,将整数文字提供0给键入的参数double将可以解决问题。对于初学者来说,编译器不会对printf寻址的参数槽进行相同的转换,这并不明显%[efg]
zwol

1
@Voo:如果您对这种错误的严重性感兴趣,请考虑在x86-64 SysV ABI上,将浮点参数传递给与整数参数不同的寄存器集。
EOF

1
@LightnessRacesinOrbit我认为讨论为什么使用 UB 总是很合适的,这通常涉及讨论允许的实现范围以及在常见情况下实际发生的情况。
zwol

4

"%f\n"仅当第二个printf()参数的类型为时,才保证可预测的结果double。接下来,可变参数函数的额外参数是默认参数提升的主题。整数参数属于整数提升,它永远不会导致浮点类型的值。并将float参数提升为double

最重要的是:standard允许第二个参数为or float或or double而没有其他值。


4

为什么正式使用UB现在已经在几个答案中进行了讨论。

明确获得此行为的原因取决于平台,但可能是以下原因:

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

0

造成此“不确定值”问题的主要原因在于,将指针转换为int传递给printf变量参数部分的值,并将其转换为宏执行的double类型的指针va_arg

这会导致引用未完全初始化的内存区域,而该内存区域未使用作为参数传递给printf的值进行初始化,因为doublesize内存缓冲区区域大于intsize。

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


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

的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..
.... 


双值参数代码案例管理在vprintf中的实际实现(考虑gnu impl。)是:

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项目的“ printf”(vprintf)的glibc实现
  2. printf的简化代码示例
  3. va_arg的简化代码示例
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.