11
$\begingroup$

I'm reading Appel's book Modern Compiler Implementation in C. I just read the following statement in chapter 6, and I wonder if it is accurate. It says that on modern machines, the calling conventions are to pass parameters in registers.

[in older systems], function arguments were passed on the stack. But this causes needless memory traffic. Studies of actual programs have shown that very few functions have more than four arguments, and almost none have more than six. Therefore, parameter-passing conventions for modern machines specify that the first k arguments (for k = 4 or k = 6, typically) of a function are passed in registers rp,...,rp+k−1, and the rest of the arguments are passed in memory.

But I thought C and C++ still used the "C calling convention", where arguments were passed on the stack, before the call instruction put the return address on the stack and jumped to the function implementation. Does it depend on the machine architecture? Maybe I think that because I've only been looking at X86 so far? Or maybe the compilers can choose other calling conventions for private functions that aren't exposed in shared libraries. Is this statement from the book accurate?

$\endgroup$
10
  • 16
    $\begingroup$ A calling convention is only really necessary when code from one compilation calls a function defined in a separate compilation. When the same compilation job includes both the caller and the callee, the compiler can do whatever behaviour-preserving transformations it wants, including inlining the function or even eliminating the call entirely if it determines that the call is unnecessary. $\endgroup$
    – kaya3
    Jul 27, 2023 at 16:03
  • 5
    $\begingroup$ The move from 32- to 64-bit x86 eliminated most of the calling conventions, so cdecl and fastcall are the same thing now. Of course, this never applied to other architectures in the first place. $\endgroup$
    – Bbrk24
    Jul 27, 2023 at 16:07
  • 9
    $\begingroup$ No version of the C or C++ language specification defines calling conventions, and nothing in either language depends on there even being a stack at all. Both languages bend over backwards to avoid provisions that could stand in the way of efficient implementation on diverse architectures, including esoteric ones. That implementations should pass arguments in registers when that is feasible and efficient is perfectly in line with C philosophy, and allowed -- but not required. $\endgroup$ Jul 28, 2023 at 2:28
  • 1
    $\begingroup$ @MatthieuM.: The publication date of the book is 1998, when it was a reasonable assumption that everyone writing a compiler would care enough about performance to not intentionally pick an inefficient calling convention like Go did, except when required for compatibility with crusty old ABI standards, like i386 System V on Linux, and cdecl or stdcall conventions on x86 Windows. Using a custom calling convention that's worse than the standard one for the target platform is not normal, but most rules have exceptions. $\endgroup$ Jul 28, 2023 at 16:59
  • 1
    $\begingroup$ Here's a quick counter example of a C function compiling to ARM with register parameters godbolt.org/z/xhvdx5v58 $\endgroup$ Jul 28, 2023 at 19:23

6 Answers 6

25
$\begingroup$

There are many calling conventions, and most pass at least some arguments in the register

The comment in your book is basically accurate. There is no fully "standard" C calling convention, and each combination of language, machine architecture and compiler can choose the ones that it prefers. Functions with multiple calling conventions can exist inside of the same binary as well as shown by the MSVC x86 calling convention docs.

Almost all calling conventions try to pass integer and pointer arguments in normal registers and floating point arguments in floating pointer registers for the performance benefits mentioned, as reading data from the stack can be less efficient. You might be thinking of the cdecl calling convention, an X86 calling convention that is one of the few that does require all arguments to be passed on the stack.

You are correct regarding private functions -- if a function can only be interacted with from inside code totally under the control of one compiler, you can create and use custom calling conventions. A trivial example of this are trivial functions that don't call other functions or allocate stack space called leaf functions; because they don't require all of the same machinery that normal function calls might, they might be compiled to not have the standard calling convention in use.

Some other programming languages utilize a custom calling optimized for their own internal use but still utilize the conventional architecture/platform ABI for exporting functions that can be called from C code (and thus most languages that can create bindings to C code, as it is commonly used as a common FFI interface). Since most operating interfaces and system calls are exposed as C functions, these compiler normally have to be able to correctly compile calls to the appropriate platform/architecture convention in use to access them as well.

$\endgroup$
1
  • 1
    $\begingroup$ Agreed, and some language implementations have pragmata specifying e.g. which registers may be used for integer parameters, which registers must be preserved and so on. $\endgroup$ Jul 28, 2023 at 6:34
9
$\begingroup$

It's true that some of the most common calling conventions for 32-bit x86 code require all parameters to be passed on the stack. One of these conventions is called "cdecl", but that's really just a name and isn't inherently tied to the C or C++ language in any way.

But x86-32 is not a modern architecture these days, and is the exception here rather than the rule. Compilers for most other architectures mostly use calling conventions where some parameters are passed in registers. This includes x86-64, where the two most popular calling conventions are Microsoft x64, which uses four registers, and System V, which uses six (see the Wikipedia article linked above, further down). For ARM, the Arm company publishes recommended calling conventions (the Procedure Call Standard); the 32-bit version has four parameters passed in registers, and the 64-bit version uses eight.

$\endgroup$
7
$\begingroup$

In early C, it was required that code which called functions that would today have prototypes:

int test1(int,int,int);
int test2(int, ...);

process the calls test1(1,2,3); and test2(1,2,3); identically, save only for the address of the target function. A convention that passes all arguments on the stack works better for the second style of function than one which tries to put some on the stack and some in registers, and putting arguments on the stack when calling the second style of function meant it was necessary to put arguments on the stack when calling all functions.

$\endgroup$
9
  • 1
    $\begingroup$ Passing parameters in general purpose registers is not too bad. A variadic function can just push them to the stack in the function prologue. What does ramp up the complexity is using more than one type of register for parameter passing. $\endgroup$ Jul 28, 2023 at 21:18
  • 2
    $\begingroup$ @PeterGreen: Having the function prologue stack registers works nicely on the ARM, which can stack them ahead of the return address. On most other platforms, it would be necessary to either shuffle the return address around the parameters or skip over the return address when retrieving the arguments. $\endgroup$
    – supercat
    Jul 29, 2023 at 2:13
  • 1
    $\begingroup$ x86-64 System V aggressively uses registers for parameter passing, but still satisfies this requirement that an unprototyped call can safely be made to a function that's declared normally (assuming it uses types compatible with the default argument promotions), even for variadic functions. Yes, @PeterGreen, code-gen for variadic functions is inconvenient, having to keep track of whether you've used up the 6 GPRs or the 8 XMM registers, depending on whether the arg type for va_arg is int or FP (or __m128i). $\endgroup$ Jul 29, 2023 at 3:41
  • 1
    $\begingroup$ (cont.) ... since you can't conveniently make one contiguous array of args due to the return address being in the way. (It would be UB for va_arg with an integer parameter to read an FP arg, so the calling convention assumes that doesn't happen). Unless callers can see a non-variadic prototype, they set AL = # of XMM regs used for FP args. $\endgroup$ Jul 29, 2023 at 3:41
  • 1
    $\begingroup$ @PeterGreen: Windows x64 makes an interesting tradeoff to make variadic functions efficient, only allowing 4 total args to be passed in registers (unlike x86-64 which allows up to 6 integer and up to 8 FP). For variadic functions, FP args need to be duplicated to the corresponding integer reg, so callees can simply dump the four integer arg-passing regs to the shadow space (32B above the return address, which the caller reserved for the callee), forming an array contiguous with the stack args. godbolt.org/z/eh47enYdn $\endgroup$ Jul 29, 2023 at 3:44
6
$\begingroup$

In general the calling convention used by default for C/C++ code mostly depends on the CPU architecture and the operating system more than the specific compiler. Unix-like systems in particular use C function calls as the interface between application and platform. So alternative compilers generally had to follow the same calling convention as the system's primary compiler.

You could say "why not use the system calling convention when calling system libraries and use a different calling convention for your own functions", but there are two problems with that.

  • It's not obvious to the C compiler what is a function in a system library verses what is a function in your own program. That determination is not made until link time.
  • Some functions in the platform libraries take function pointers to callback functions. The compiler again doesn't know whether a function will be used as such a callback when compiling it.

Early C compilers, building code for what we would now call CISC CPUs passed all parameters on the stack.

However when RISC CPUs came along, compilers for those CPUs tended to pass parameters in registers. Given your book was written in 1998 I suspect these RISC CPUs are what the author meant when he talked about "modern computers".

Most of the old-school CISC architectures have died off or at least been relegated to embedded uses over the years. x86 being the notable exception.

When x86-64 came along, new calling conventions were devised. These new conventions followed the model used by RISC systems and passed parameters in registers.

This has essentially left 32-bit x86 code as the last bastion of old-school C calling conventions.

$\endgroup$
4
$\begingroup$

on modern machines, the calling conventions are to pass parameters in registers

It has nothing to do with modern machines.

In 1970s B (before C) on the Honeywell GCOS-3 system for example, the first two arguments to each function were passed in the A and Q 36-bit registers.

The called function could either save them on the stack or use them directly. Many simple functions could execute very quickly, because they did everything in registers and unless they called other functions didn't even need to adjust the stack pointer.

$\endgroup$
1
  • $\begingroup$ Way back in the dark ages when Lisp was first conceived (around 1960), two Lisp fundamentals were CAR and CDR (contents of the address register and contents of the data register) en.m.wikipedia.org/wiki/CAR_and_CDR#Etymology. Passing things around in registers has been around forever. $\endgroup$
    – Flydog57
    Jul 30, 2023 at 3:36
4
$\begingroup$

Compilers going for the most speed will inline functions aggressively, which involves bypassing the calling convention entirely. Also, when a function is strictly local, or when the compiler has access to the full program, it can safely ignore the official calling convention, and instead hardcode the function to take its arguments however is most effective. For the most part, older hardware didn’t have enough registers to use them for function arguments all the time, and newer hardware does.

Variadic functions in C, such as printf(format, ...), historically used the stack, on the DEC PDP-8, and the way they’re specified to work essentially assumes that the arguments will be pushed onto the stack in reverse order and cleaned up by the caller (although there are other possible ways to do it). Classic Fortran essentially assumes that function arguments are passed by reference, when they’re not global variables. A few older languages, such as COBOL, have a statement like PERFORM that doesn’t work the same way as function calls in Algol. Lisp machines are a particularly interesting example of an instruction set designed to accelerate the data structures of Lisp, which still uses the terms car and cdr after the format of the registers that were once used to pass arguments on a long-obsolete mainframe. But most new languages (and extensions to old ones) support the same kind of function calls that have become mainstream.

System APIs also define a particular convention for system calls and callbacks. On the x86, these almost all passed arguments on the stack, largely because the machine had so few general-purpose registers that any non-tail call would need to spill its local variables onto the stack anyway. (However, some compilers offered an optional fastcall calling convention that passed arguments in registers.) When AMD created 64-bit long mode, they added more registers, and all 64-bit x86 systems pass function arguments in registers. This included the x32 calling convention on Linux, which takes advantage of the extra registers in 32-bit mode, but by then the original was too established to change.

Most other general-purpose ISAs have at least 32 named registers (because they derive from 32-bit classic RISC designs, which all have 32-bit fixed-width instruction words that can be fetched in one bus cycle. which leave enough room for three register fields of five bits each), and therefore passed function arguments in registers from the beginning. SPARC was notable for having windows of input and output registers that shifted upon each function call.

$\endgroup$
20
  • $\begingroup$ largely because the machine had so few general-purpose registers that any non-tail call would need to spill its local variables onto the stack anyway. - It's not clear to me what point you're making here. Classic x86-32 calling conventions have three call-clobbered ("volatile") registers, EAX, ECX, and EDX. They're clobbered whether you pass args in them or not, and the other 4 registers (not including the stack pointer) can be used to hold locals across calls. Passing one (thiscall), two (32-bit fastcall), or three (GCC regparm=3) args in those regs doesn't change that. $\endgroup$ Jul 29, 2023 at 3:55
  • $\begingroup$ It does leave a callee with no scratch registers in a function with 3 args. The call-clobbered registers have "precious" values, and the call-preserved registers need to be saved before overwriting. But even in cases where a leaf function does need to reserve some stack space and spill something, that's more or less break-even with the caller pushing the arg and the callee reloading it. And that doesn't always happen. $\endgroup$ Jul 29, 2023 at 3:57
  • $\begingroup$ My guess has always been that classic calling conventions were designed more for simplicity than performance, especially for simplicity of variadic functions so all args could form one contiguous array without requiring shadow space (like Win x64) for the callee to dump reg args into. (The return address gets in the way). $\endgroup$ Jul 29, 2023 at 3:59
  • 1
    $\begingroup$ although there are other possible ways to do it [variadic functions] - Note that ISO C says it's ok to call printf("%d\n", 1, 2, 3);. A variadic function must be safely callable with more args than it can find out about (from earlier args such as the format string). If you wanted to make a callee-pops convention for variadic functions, you'd need to separately pass a size to pop. (And that's very inconvenient on x86; ISA support for callee-pops is limited to an immediate operand for ret im16, otherwise you have to copy the return address or something for runtime-variable callee pops.) $\endgroup$ Jul 29, 2023 at 4:02
  • $\begingroup$ See C calling convention: who cleans the stack in variadic functions vs normal functions? / Stack cleanup in stdcall (callee-pops) for variable arguments for discussion of some of the difficulty of callee-pops for variadic on x86. But yes, some systems have used totally different ways to pass args, like dynamically allocating space for an "activation record" (I think IBM did that, maybe on their mainframes, instead of having a stack at all.) $\endgroup$ Jul 29, 2023 at 4:09

You must log in to answer this question.

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