9
$\begingroup$

In C, calling a variadic functions are 'special' and not to be treated as regular functions or vice versa. Calling a variadic function through a regular function pointer, or vice-versa invokes undefined behavior, even if the arguments passed would otherwise be valid.

((int (*)(const char *))printf)("Hello world!"); // Undefined behavior

((void (*)(const char *, ...))memcpy)(ptr1, ptr2, 10); // Assuming ptr1 and ptr2 are valid, still invokes undefined behavior

Is this restriction necessary? Are variadic functions inherently different from regular functions in terms of how they are called, or could a language trivially permit the above constructs?

$\endgroup$

6 Answers 6

10
$\begingroup$

It depends on how the ABI defines how function calls are implemented on the machine code level.

If you have a calling convention in which:

  • Function arguments are passed at consecutive locations in memory (typically on the stack).
  • The calling code, rather than the called function, is responsible for stack cleanup.

Then varargs are nothing special. For a concrete example, suppose you have the two functions:

int fixedarg(int a, int b, int c, int d);
int vararg(int a, ...);

Then a call to fixedarg(a, b, c, d) looks like (in pseudo-assembly):

push d
push c
push b
push a
call fixedarg
clear_stack 4 * sizeof(int)

And a call to vararg(a, b, c, d) looks like:

push d
push c
push b
push a
call vararg
clear_stack 4 * sizeof(int)

Note that these are exactly the same except for the function name. That's really convenient for the compiler writers. The only real complexity comes within the vararg function, since you don't have names for the parameters hidden by .... But since standard C varargs functions require at least one named argument, you can find the addresses of b, c, and d by knowing the layout of parameters in memory and calculating the appropriate number of bytes from &a. You can abstract the pointer arithmetic behind macros (named va_start, va_arg, etc.) to make this less implementation-dependent.

As a bonus, this calling convention lets you have optional arguments even for functions with a fixed argument list. Let's say that you know that fixedarg happens to be implemented such that if a == 0, then b, c, and d are never used. Then a lenient compiler would let you write fixedarg(0), and this would compile into:

push 0
call fixedarg
clear_stack sizeof(int)

This is why, in my previous examples, I had the compiled code push the parameters onto the stack in reverse order (d, c, b, a). This puts the first parameter (a) on top of the stack, allowing subsequent parameters to be optional.

It's also why the clear_stack call is done after the call statement instead of in the function itself. The function doesn't know how many parameters it was called with, so it doesn't know how much it needs to adjust the stack pointer. Only the calling code knows that.

I believe that the PDP-11 systems on which C was originally developed used this all-parameters-in-memory convention, allowing the C compiler to play loose with parameter lists like this. Hence, early versions of C not requiring function prototypes.


A problem with passing function arguments in memory like that is that memory access tends to be extremely slow compared to CPU registers. Thus, it's popular for ABIs to define calling conventions that pass arguments in registers instead of memory. Of course, since a processor will have a finite number of available registers, there will be a limit to how much data can be passed in registers. For the sake of example, let's assume a processor that has three int-sized registers dedicated to function arguments, with assembly mnemonics $arg1, $arg2, and $arg3.

Then the call to fixedarg(a, b, c, d) becomes:

load $arg1, a
load $arg2, b
load $arg3, c
push d
call fixedarg
clear_stack sizeof(int)

And a call to vararg(a, b, c, d) becomes...well, that's tricky. If you use the usual convention that the first three int arguments are passed in registers, then you have to define your va_ macros to handle some arguments being in registers and some being in memory. And that would be really complicated.

Potential solutions to this problem are:

  • Require all arguments to be passed in-memory even for fixed-argument-list functions, thus slowing down the program.
  • Have separate calling conventions for fixed-args (register-based) and varargs functions. This necessitates explicitly marking which functions have fixed versus variable argument lists. So no more K&R-style int f() function declarations; you have to distinguish int f(int, int, int, int) versus int f(int, ...). But hey, that's better for static type-checking anyway.
  • Automatically transforming int varargs(int a, ...) to int varargs(int a, va_list args), where va_list is a pointer to the remaining parameters stored in a temporary in-memory array. This is a special case of the previous approach.
$\endgroup$
1
  • $\begingroup$ Hey dan, what is implemented in practice for the problem of calling convention of using registers, plus the stack? I understand different implementations can have varying ways of handling, but does the C standard impose anything on this? $\endgroup$
    – Floatoss
    Commented Apr 10 at 19:38
8
$\begingroup$

There are multiple questions into one here:

Casting from Non-Variadic to Variadic

((void (*)(const char *, ...))memcpy)(ptr1, ptr2, 10); // Assuming ptr1 and ptr2 are valid, still invokes undefined behavior

memcpy's signature is void *memcpy(void*, const void*, size_t). This means that as first and second arguments are only valid pointers and as third a size_t.
Meanwhile the type void (*)(const char*, ...) allows to pass any kind of objects past the first argument.
No matter how you look at it, this should not work. What is variadic_memcpy(p1, p2, 42.5, bigStruct, gmtime(&spec.tv_sec)); supposed to do?

And indeed, the System V ABI for AMD64 processors specify that different object types are passed in different locations:

  • INTEGER objects are allocated in, in order, RDI, RSI, RDX, RCX, R8, and R9,
  • SSE objects (such as floats) are allocated from XMM0 to XMM7
  • MEMORY and X87 objects, as well as all others when no more of the previously mentioned registers are available, are allocated on the stack.

Those are essentially three different stacks, so you cannot determine the arguments order just from looking at them. (printf's format argument itself specifies the arguments' types, so the routine actually iterates through registers and memory independently).
Furthermore, AL is reserved by routines calling variadic routines to specify an upper bound of vector registers used by passed SSE arguments.

Implementation-wise, on SysV-compliant systems, the target routine will look for arguments where you did not put them. And the ABI says it's your fault.
And theory-wise, such a use of variadic functions is exactly the same as passing the wrong argument type to parameters with compiler checks disabled - also called Dynamic Typing - and in the case of C, with dynamic checks disabled too, for added fun.

Casting from Variadic to Non-Variadic

A variadic function is not statically checked, which allows you to cast it to a non-variadic one by simply translating from a checked ABI to an unchecked one.
Eg. Casting printf to int (*)(const char*) can be achieved by generating a routine that forwards all arguments, doing an ABI translation if necessary (eg. register to stack). If the translation is wrong, C says that it's on you anyway.

Such translation in the opposite direction is not possible. For all you know, a variadic call will pass less arguments than a target routine expects, and a compiler would be supposed to accept such nonsensical code.

C Variadic ABIs

i386

Compilers for i386 essentially do whatever they want, and most follow a pretty simple pattern: everything passed to ..., and the argument preceding it, are allocated on the stack.
This means finding the first variadic argument is a matter of &param + sizeof(param).
Fun fact: this design is why C requires variadic routines to have at least one non-variadic parameter, and why the va_start macro takes that parameter as second argument.

And how to know how many arguments are passed you ask? Ha! As if compilers care. lol you're the programmer, you're supposed to know how computers work, so use your brain and figure it out yourself.

This is not a joke. This is what C compilers tell you to do.
And remember the C Standard says you are in charge and responsible of everything.

So how one typically writes a variadic routine is by having a parameter that specifies how many variadic arguments there are and what their types are, and by gently asking that routine's user to get everything right.
printf for instance deduces both arguments' count and types from the format string's specifiers.
[As mentioned by @TobySpeight's comment: Another approach is by requiring a terminating sentinel value, as is used by execl and XtVaSetValues.]

Naturally, this is incompatible with both UNIX's and Win32's ABIs (_cdecl, _stdcall and friends), which do pass things in registers.

System V for AMD64

SysV actually works similarly to the i386 approach described above, but with the added benefit of being one standardised ABI.
The main difference is that a variadic function sees some arguments passed in registers, which requires it to save all the relevant registers on the stack, with the following exceptions:

  • RAX should indicate how many XMM registers are used by the caller,
  • and only those registers should be saved (ie. if RAX is 2, then XMM0 and XMM1 are to be saved).

Note that RAX is reserved both for this use and to be used by returning routines, meaning it does not actually constitute a difference between variadic and non-variadic routines.

Then that function must iterate through each of the used stacks as described earlier. This means va_list is not just a pointer, but a struct containing multiple pointers which are independently incremented.
For example, va_arg(list, int) looks first in RDI's stack copy, then RSI's copy, etc.., while va_arg(list, float) looks first in XMM0's copy then XMM1's copy, etc..

This is actually trivial.
Here is va_list:

typedef struct {
    unsigned int gp_offset;
    ///The element holds the offset in bytes from reg_save_area
    ///to the place where the next available general purpose
    ///argument register is saved.
    ///In case all argument registers have been exhausted, it is
    ///set to the value 48 (6 ∗ 8).

    unsigned int fp_offset;
    ///The element holds the offset in bytes from reg_save_area
    ///to the place where the next available floating point
    ///argument register is saved.
    ///In case all argument registers have been exhausted, it is
    ///set to the value 304 (6 ∗ 8 + 16 ∗ 16).

    void *overflow_arg_area;
    ///This pointer is used to fetch arguments passed on the stack.
    ///It is initialized with the address of the first argument
    ///passed on the stack, if any, and then always updated to
    ///point to the start of the next argument on the stack.

    void *reg_save_area;
    ///The element points to the start of the register save area.
} va_list[1];

Where the register save area is the memory where the registers were saved.

Here is va_arg's algorithm:

  1. Determine whether type may be passed in the registers. If not go to step 7.
  2. Compute num_gp to hold the number of general purpose registers needed to pass type and num_fp to hold the number of floating point registers needed.
  3. Verify whether arguments fit into registers. In the case:
    l->gp_offset > 48 − num_gp ∗ 8
    or
    l->fp_offset > 304 − num_fp ∗ 16
    go to step 7.
  4. Fetch type from l->reg_save_area with an offset of l->gp_offset and/or l->fp_offset. This may require copying to a temporary loca- tion in case the parameter is passed in different register classes or requires an alignment greater than 8 for general purpose registers and 16 for XMM registers.
  5. Set:
    l->gp_offset = l->gp_offset + num_gp ∗ 8 l->fp_offset = l->fp_offset + num_fp ∗ 16.
  6. Return the fetched type.
  7. Align l->overflow_arg_area upwards to a 16 byte boundary if alignment needed by type exceeds 8 byte boundary.
  8. Fetch type from l->overflow_arg_area.
  9. Set l->overflow_arg_area to:
    l->overflow_arg_area + sizeof(type)
  10. Align l->overflow_arg_area upwards to an 8 byte boundary.
  11. Return the fetched type.

From §3.5.7.The va_arg macro.

What this all means is that System V guarantees variadic routines to be ABI-compatible with non-variadic routines on AMD64!
And this all happens without passing extra parameters! va_list is computed on demand after dumping registers on the stack.

Now however, how to figure out how many arguments there are and what their types are still is up to the user.

Win32 & Win64

TODO: Get interest in Windows.

D Variadic ABIs

D, unlike C, has two type-safe variadic calling conventions.

D-Style Variadic Functions

This is a simple dynamically-typed implementation:

  • the variadic arguments are allocated on the stack, and
  • a variadic function has as first parameter a compiler-supplied TypeInfo[] _arguments object allocated on the stack, which is a slice object with a .length property and containing pointers to the TypeInfo instances of each respective argument.

As implementations must supply _arguments, a variadic function then can take its address and compute the address of each argument, as well as dynamically typecheck each one.

Typesafe Variadic Functions

Another approach that D uses is that of requiring all variadic arguments to be of the same type, allowing implementations to pack them in stack-allocated arrays, and to just pass that.
In fact D goes further in that a void f(int[] xs...) function can be used both with n-arity (f(42, 1917, 1922)) or as expecting an array directly (f([42, 1917, 1922])), and then generalises the idea by allowing the n-arity use to pass arguments to class or struct constructors (eg. calling f(LinkedList!string xs...) as f("h", "Ü") is the same as f(new LinkedList!string("h", "Ü"))).

Cheating with Class Hierarchies

In fact we can also generalise such statically-type-safe variadic functions as described above by using class hierarchies.
If everything descends from Object, then a variadic function void f(Object[] xs...) accepts arguments of any type to be matched over by the callee, by simply passing an array, using the same ABI as non-variadic functions.

Add in either autoboxing or some way to dynamically and seamlessly differentiate reference from value types, such as pointer tagging, and all types are covered by the single ABI.

$\endgroup$
2
  • 2
    $\begingroup$ Win32 has approximately a billion calling conventions, which Win64 thankfully consolidated to just two (fastcall and vectorcall). $\endgroup$
    – Bbrk24
    Commented Jul 9, 2023 at 3:37
  • 2
    $\begingroup$ The other way to write a variadic function is to pass a sentinel value to mark the last argument, e.g. execl(), XtVaSetValues(), etc. $\endgroup$ Commented Jul 10, 2023 at 14:30
5
$\begingroup$

No, and Some Systems Used the Same Convention

K&R C had only a single calling convention, which is how printf() could be called without any special syntax or function prototype. In fact, since there were no function prototypes in C until ANSI C, there could not possibly have been different calling conventions for any implementation that also needed to compile K&R-style code, for example:

struct FILE;

struct FILE* fprintf();
int fopen();

int main() {
    struct FILE *outfile;
    outfile = fopen("hello.txt", 3);
    /* WONTFIX: Real code should check for errors. */
    fprintf(outfile, "%s\n", "hello, world!");
    return 0;
}

There is simply no way for the compiler to tell that fprintf is variadic and fopen is not (at least, not for code other than the standard library, without doing more whole-program analysis than compilers back then did).

On 16-bit x86, this led to C having a different calling convention from Pascal (with C passing its arguments in reverse order, so that the first argument of a variadic function, such as its format string, would always be at a known location, immediately before the return address on top of the stack). This is why ANSI C required varargs functions to have at least one non-variadic parameter, and for the variadic parameters to come last.

The va_start and va_arg macros are the way they are to allow them to be implemented by taking the address of the last non-variadic argument on the call stack and successively adding the size of each variadic argument to obtain a pointer to the next.

Most of the special rules for variadic functions, such as the default argument promotions, exist solely for the sake of backward-compatibility with K&R C. For example, arguments narrower than an int widen to int because an int represented the smallest object that could be passed on the stack on word-addressed machines, and because this saved an instruction on machines that had no byte or half-word instructions. Similarly, float widens to double partly to avoid rounding error, but mainly because that was how it worked on the DEC PDP-8. The rules for pointer conversions were intended to enable K&R-style cpde such as &dest == memcpy(&dest, &src, sizeof(dest)); that worked on K&R C (because there was no type-checking of function arguments, and all object pointers were cast to character pointers when passed as function arguments) to also compile on ANSI C, with the function prototype void* memcpy(const void*, void*, size_t);.

$\endgroup$
1
  • $\begingroup$ The promotion rules were a generalization of the fact that the only actions that needed to operate on numeric types other than int or double were format-converting loads and stores. One of the reasons char had to be signed on some systems but unsigned on others was to allow a simple compiler to process something like char1 = char2; to efficiently process the load of char2 without having to care about how the value would be used, both on systems that support only sign-extending byte loads, and on systems that support only zero-padding byte loads. $\endgroup$
    – supercat
    Commented Oct 15 at 20:02
3
$\begingroup$

This comes down to how function calls are implemented. In C variadic function calls are implemented by adding an extra parameter va_list

So the call to printf is wrong because no va_list parameter is provided. Likewise the call to memcpy is wrong because you have provided an extra parameter.

This badness is legal because you used a cast but its plain wrong semantically.

Function calls don't have to be implemented this way. You could have a language where they are always implemented as passing an array of parameters and the ellipsis (...) is just syntactic sugar for give me the rest of the list after extracting the arguments I asked for.

Some languages may work this way but most just give the impression of working this way while actually passing arguments a similar way to C under the hood. It tends to be less efficient to have to extract arguments from an array with each function call.

$\endgroup$
2
  • 2
    $\begingroup$ GCC implements varargs for x86 by passing all ... arguments on the stack, and va_list is a just a pointer initialised by va_start to the first argument. There is no "added extra parameter va_list." That's also why C requires vararg routines to have at least one parameter: because the expected ABI does not pass any parameter indicating where things are and the compiler must deduce it dynamically from something. $\endgroup$
    – Longinus
    Commented Jul 8, 2023 at 17:01
  • $\begingroup$ System V's ABI also requires the callee to compute va_list on demand, and the caller to allocate the arguments as if the function is not variadic (with using RAX to indicate used XMM registers as an optimisation, possible from it being written to anyway). $\endgroup$
    – Longinus
    Commented Jul 8, 2023 at 23:52
3
$\begingroup$

In C, if f() is a variadic function, but at the place of the call the compiler has only seen a non-variadic declaration (which is wrong), you have undefined behaviour. A call is not guaranteed to "work".

In practice, you want variadic arguments to be stored in consecutive memory locations so that the va_list macros can be implemented without compiler magic, and non-variadic arguments you want to be stored so they can be accessed as quickly as possible, for example integers in integer registers, floating-point parameters in floating-point registers. f(int, double) and calling f (3, 7.0) and f (double, int) and calling f (7.0, 3) could create exactly the same code, with 3 being passed in the first integer register and 7.0 in the first floating point register.

This would be a real problem if f was a variadic function. The compiler would have to analyse the types at runtime to see what values are passed where.

$\endgroup$
2
$\begingroup$

If variadic arguments use a different calling convention from standard functions, and are only used in situations that aren't speed-critical, then language implementations would often be able to process them in type-safe fashion for a code-space cost comparable to processing varidic functions without type safety. The key would be to have callers of variadic functions generate temporary objects for any non-trivial expressions in the argument list, and then pass the address of a blob containing information about the arguments and the address of a function to process information from the blob.

Note that this approach could facilitate nice constructs like Pascal's numeric formatting syntax, by allowing the called function to know which arguments were separated by commas and which were separated by colons, and could allow for ABI compatibility between code built using different tool sets. If code using the Acme toolset and code using Joe's toolset were combined into a single project, and both pieces of code invoked variadic functions, the project would have to include both Acme's variadic-parsing library and Joe's variadic-parsing library, which might waste some space, but if the behaivor of the parsing function was standardized, code generated using either toolset could perform variadic functionc calls to code generated using the other, without either toolset having to care about how the other one set up its stack frames.

$\endgroup$

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .