Why this ambiguous behaviour of "printf()"?

Viewed 805

I have intentionally forced printf() to print celsius as an int (Using %8d format specifier for it). I know that this is the reason of printing 0 (under the heading Celsius Scale).

But I just want to know why fahr is going to be printing 0.0 in the whole table.

I have used GCC compiler.

This is the code for Celsius to Fahrenheit Conversion :

#define LOWER 0.0F
#define UPPER 300.0F
#define STEP 20.0F

#include <stdio.h>

void main() {
    float celsius, fahr;
    printf("*Conversion from Celsius to Fahrenheit*\n");
    printf("Celsius Scale \t   Fahrenheit Scale\n");
    for (celsius = LOWER; celsius <= UPPER; celsius = celsius + STEP) {
        fahr = (9.0f * celsius / 5.0f) + 32.0f; 
        printf("%8d\t\t%5.1f\n", celsius, fahr);
    }
}

This following table is the output of the above code :

*Conversion from Celsius to Fahrenheit*
Celsius Scale      Fahrenheit Scale
       0                  0.0
       0                  0.0
       0                  0.0
       0                  0.0
       0                  0.0
       0                  0.0
       0                  0.0
       0                  0.0
       0                  0.0
       0                  0.0
       0                  0.0
       0                  0.0
       0                  0.0
       0                  0.0
       0                  0.0
       0                  0.0
3 Answers

You have undefined behaviour (which means it is not 'predictable' what the results of an operation will be), as you are passing a float argument to printf when it expects an int argument (for the %8d) format.

From this C11 Draft Standard:

7.21.6 Formatted input/output functions
...
9 If a conversion specification is invalid, the behavior is undefined. If any argument is not the correct type for the corresponding conversion specification, the behavior is undefined.

Exactly why the fahr values are shown incorrectly is difficult to say with any certainty, but it's possibly connected to the fact the the float arguments are promoted to double, which has a different size than the expected int type, and thus, the call stack (or call 'frame' if arguments are passed in registers) is corrupted.

It is perhaps worth noting that, on my system (MSVC, 64-bit), running your code displays zeros for the celsius fields but the correct values for fahr. (But compiling for a 32-bit target does reproduce your problem!)

To fix the problem, explicitly cast the celsius argument to an int:

void main()
{
    float celsius, fahr;
    printf("*Conversion from Celsius to Fahrenheit*\n");
    printf("Celsius Scale \t   Fahrenheit Scale\n");
    for (celsius = LOWER; celsius <= UPPER; celsius = celsius + STEP) {
        fahr = (9.0f * celsius / 5.0f) + 32.0f;
        printf("%8d\t\t%5.1f\n", (int)celsius, fahr);
    }
}

As for the issue with fahr I tried to reproduce in several versions of GCC, but I was not able to, which is not unexpected since undefined behavior is exactly that, undefined, one cannot expect consistent results. I can try with the version and the exact compilation command you used, but I don't expect it to make a difference.

For instance in this sample code the results are totally different from yours.

For a double or float variable, as you know, you need "%f" specifier, you can cast celsius to int as was already mentioned or remove the 0s with the specifier itself:

printf("%8.0f\t\t%5.1f\n", celsius, fahr);
         ^^^

Demo

Note that using this method will round the values instead of truncating them.

Output:

*Conversion from Celsius to Fahrenheit*
Celsius Scale      Fahrenheit Scale
       0                 32.0
      20                 68.0
      40                104.0
      60                140.0
      80                176.0
     100                212.0
     120                248.0
     140                284.0
     160                320.0
     180                356.0
     200                392.0
     220                428.0
     240                464.0
     260                500.0
     280                536.0
     300                572.0

Your question is precise: I just want to know why fahr is going to be printing 0.0 in the whole table.

As you already understand, you have undefined behavior because you pass a float, which is actually converted to a double, to printf as an argument for which printf expects an int. Anything can happen, you get 0 and 0.0 as output for all lines, you could have gotten pretty much anything else or a crash...

To try and explain your observations, you must look into what is actually happening on your very system for this code. Such an analysis requires in depth knowledge on your system, ABI, compiler, compiler options, etc.

I modified your code and compiled it with Godbolt's Compiler Explorer and here are my observations for 2 configurations:

gcc version 9.3 for intel 64-bits, with optimisations disabled.

The code for the erroneous printf in 64-bits is this:

    cvtss2sd        xmm1, DWORD PTR [rbp-8]
    cvtss2sd        xmm0, DWORD PTR [rbp-4]
    mov     edi, OFFSET FLAT:.LC6
    mov     eax, 2
    call    printf

The code for the a modified argument (int)celcius which is what printf expects:

    cvtss2sd  xmm0, DWORD PTR [rbp-8]
    movss     xmm1, DWORD PTR [rbp-4]
    cvttss2si eax, xmm1
    mov       esi, eax
    mov       edi, OFFSET FLAT:.LC6
    mov       eax, 1
    call      printf

In the 64-bit version, the erroneous code passes celcius and fahr as double values in floating point registers %xmm0 and %xmm1 respectively and passes the value 2 in %eax, whereas the correct code would pass fahr as a double in %xmm0 and celcius converted to an int in register %esi, and the value 1 in %eax.

The value in %eax, more precisely the contents of %al is the number of vector registers used to pass arguments. To implement the vararg api in printf, the compiler generates a prolog that uses this value to save the register arguments to the stack:

myprintf:
    push    rbp
    mov     rbp, rsp
    sub     rsp, 104
    mov     QWORD PTR [rbp-216], rdi
    mov     QWORD PTR [rbp-168], rsi
    mov     QWORD PTR [rbp-160], rdx
    mov     QWORD PTR [rbp-152], rcx
    mov     QWORD PTR [rbp-144], r8
    mov     QWORD PTR [rbp-136], r9
    test    al, al
    je      .L12
    movaps  XMMWORD PTR [rbp-128], xmm0
    movaps  XMMWORD PTR [rbp-112], xmm1
    movaps  XMMWORD PTR [rbp-96], xmm2
    movaps  XMMWORD PTR [rbp-80], xmm3
    movaps  XMMWORD PTR [rbp-64], xmm4
    movaps  XMMWORD PTR [rbp-48], xmm5
    movaps  XMMWORD PTR [rbp-32], xmm6
    movaps  XMMWORD PTR [rbp-16], xmm7
.L12:

So printf will read from [rbp-216] the int value expected for the %8d format and from [rbp-128] the double value for fahr. The int value will be whatever %rsi happened to contain when printf was called, 0 from your observations. The double value should be whatever was passed in xmm0, so you would expect to see the value of celcius, and that's indeed what I observe on my system. Since you observe something quite different, there is a good chance your system does not use this 64-bit ABI.

gcc version 9.3 for intel 32-bits, with optimisations disabled.

In 32 bits, the arguments are all passed on the stack. When passing 2 float values, we have:

    fld     DWORD PTR [ebp-12]
    fld     DWORD PTR [ebp-16]
    sub     esp, 12
    lea     esp, [esp-8]
    fstp    QWORD PTR [esp]
    lea     esp, [esp-8]
    fstp    QWORD PTR [esp]
    push    OFFSET FLAT:.LC6
    call    printf
    add     esp, 32

and when passing an int and a float:

    fld     DWORD PTR [ebp-16]
    movss   xmm0, DWORD PTR [ebp-12]
    cvttss2si       eax, xmm0
    lea     esp, [esp-8]
    fstp    QWORD PTR [esp]
    push    eax
    push    OFFSET FLAT:.LC6
    call    printf
    add     esp, 16

So printf expects the int at [ebp+12] and the double at [ebp+16] but instead celcius was pushed as a doouble at [ebp+12] and fahr at [ebp+20].

The int read from [ebp+12] are in fact the 4 low order bytes of the celcius value. Since the celcius values are small integers, the 32 low order bits of their 64-bit floating point representation are all zeroes, hence the integer read is 0. Conversely, the double value read for fahr is misaligned: the first 4 bytes are the high 32-bits of the double value of celcius and the last 4 bytes are the low 32 bits of the double value fahr, which are 0 because fahr is also a small integral value. Hence the exponent part of this double value has all bits zero, so it is either the value 0.0 or an extremely small denormal value that converts to 0.0 with the %5.1f conversion format. Indeed I get the same output as you do in 32-bit mode.

You can experiment with a different format such as %g for fahr and check if my prediction for a very small value is correct.

Of course this forensic study is only relevant to a specific architecture and by no means condoned by the C Standard.

Related