How does gcc (x64) deal with types/sizes in variadic functions?
Asked Answered
C

3

1

A variadic function and main()

#include <stdio.h>
#include <stdarg.h>
int f(long x,...)
{ va_list ap;
  int i=0;
  va_start(ap,x);
  while(x)
  { i++;
    printf("%ld ", x);
    x=va_arg(ap,long);
  }
  va_end(ap);
  printf("\n");
  return i;
}

int main()
{ return f(1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,1L<<63,0);
}

On gcc, linux and x64: even though f()'s arguments are not cast to a 64bit long, gcc seems to get it right.

$ gcc t.c && ./a.out
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 -9223372036854775808 

How?

Citric answered 13/4, 2018 at 11:13 Comment(0)
C
1

Arguments to a variadic function are "promoted" to 64 bit values on linux x64 so there's no need to explicitly cast up to a 64bit value on this platform.

Citric answered 14/4, 2018 at 0:36 Comment(3)
This is also the behavior I observed, but do you have a source for that?Ralina
@Chronial, see here https://mcmap.net/q/247232/-nulls-in-variadic-c-functionsCitric
Thx for the pointer. I actually found the full answer during my research a few days ago. I took the time to put my findings in an answer. Your assumption is not correct – the values are not promoted to 64bit. They are only promoted to 32bit and then aligned to 64bit.Ralina
R
2

Three things are happening here:

1. Default argument promotion

The C standard defines something called “default argument promotion” for variadic arguments. That means:

  • If the actual argument is of type float, it is promoted to type double prior to the function call.
  • Any signed char or unsigned char, signed short or unsigned short, enumerated type, or bit field is converted to either a signed int or an unsigned int using integral promotion.

But beware: This is int, which is 32bit on x64 linux. There is more happening:

2. The System V x64 calling convention

The SysV AMD64 ABI defines how the arguments are then passed to the function in section 3.2.3. What matters here is: The first 6 integers are passed in registers, the remaining ones are pushed to the stack. Also: “The size of each argument gets rounded up to eightbytes. [...] Therefore the stack will always be eightbyte aligned”. Note that floats are passed in the floating point registers, not the normal ones.

3. The va_... macros

Functions that contain va_start get a special prologue that also pushes all the argument registers to the stack. How this is done is not part of the calling convention. Since the compiler doesn't know the actual argument sizes at this point, you can expect it to push the whole registers making these values also 64bit aligned. va_arg then first iterates through these, then the rest of the arguments which were passed on the stack.

What this means for your code example

All of this means that all the arguments are 64bit aligned. But they are not actually 64bit wide. All integers are at least 32bit wide (_Bool/bool is 8bit wide), but nothing more. The value of the unused bits is unspecified. The caller is free to leave these bits uninitialized. Thus:

  • The code is broken for negative values (godbold)
  • The spec doesn't guarantee that garbage data won't just pop up in the higher bits at some point in the future. But this seems rather unlikely since a lot of code probably depends on this behavior (see comments).
Ralina answered 28/9, 2022 at 22:0 Comment(3)
The SYSV ABI you point at does indeed say that the 32 bit values will be passed in 32 bit register. However, on the x86_64 architecture, ANY write to a 32-bit register (in 64-bit mode) will clear the upper 32 bits of the corresponding 64-bit register. So it is actually impossible for the caller to leave those bits uninitialized. Note that this only applies to value passed in registers -- values passed on the stack (due to running out of registers) might have garbage in the upper bits.Tidewaiter
However, for historical reasons, it is likely that compilers will always 0-extend 0 constants to 64 bits even when passing them on the stack, as not doing so would break existing old code calling functions like execl that terminate their argument list with a (non-casted) 0.Tidewaiter
Thx, that's a very good point, changed the answer accordingly.Ralina
C
1

Arguments to a variadic function are "promoted" to 64 bit values on linux x64 so there's no need to explicitly cast up to a 64bit value on this platform.

Citric answered 14/4, 2018 at 0:36 Comment(3)
This is also the behavior I observed, but do you have a source for that?Ralina
@Chronial, see here https://mcmap.net/q/247232/-nulls-in-variadic-c-functionsCitric
Thx for the pointer. I actually found the full answer during my research a few days ago. I took the time to put my findings in an answer. Your assumption is not correct – the values are not promoted to 64bit. They are only promoted to 32bit and then aligned to 64bit.Ralina
D
1

The essential bit of code which makes it work is

x = va_arg(ap, long);

You could shoot yourself in the foot quite well by changing it to any other type.

char ch = va_arg(ap, char);

Depending on the rules of the target architecture, this might increment ap by one, two, four, or eight after each access.

Detention answered 14/4, 2018 at 0:46 Comment(2)
but f() is passed ints (32bit on x64). i just got caned for asking about this 32bit to 64bit "promotion" here #49814818Citric
logically, ap would be incremented by (sizeof long) or (sizeof char) - but i know this isn't the caseCitric

© 2022 - 2024 — McMap. All rights reserved.