66 Declarations and call expressions — inside the act of calling
What to know first
inlineLooking back
chapter 34 said that the order in which arguments are evaluated is not fixed. But what exactly does not fixed mean — that the compiler may choose, or that anything at all may happen?
A. They are different, and that difference is this chapter’s first knot. The order of argument evaluation is unspecified: the compiler picks one of the permitted orders, and whichever it picks, the program runs properly. Modifying the same object twice within one call is undefined: there, anything at all may happen. This chapter shows the boundary in the flesh.
The need for this chapter, and its context
By the end of this chapter
The questions this chapter answers
- Casting a function pointer to another type and calling through it is something one really does see, especially where callbacks are registered. Is that dangerous too?
66.1 Three things kept apart — declaration, definition, call#
The same name appears in three places. The three do entirely different work.
| what is written | does it occupy memory? | when does it matter? | |
|---|---|---|---|
| declaration | a name and a type — int add(int, int); | no | when the compiler checks a call |
| definition | the declaration plus a body { … } | yes — machine code is laid down | when the linker joins names |
| call expression | add(2, 3) — the calling expression | no | when the program runs |
Table 66.1 — One name in three places
A declaration is a promise, a definition is a thing, and a call expression is an event. This chapter is mostly about the third, but for the event to go right the first two must not contradict each other — and there are places where they can contradict each other with the compiler saying nothing at all. Those places produce the worst accidents in this neighbourhood (chapter 61).
66.1.1 A call expression is an operator#
Reading add(2, 3) as “the syntax for calling a function” misses something. In C’s grammar this is the postfix operator (), and its operands fall into two groups.
| slot | what may appear | example |
|---|---|---|
| function designator | any expression denoting a function — a name, a pointer, something fetched from an array | add, tbl[i], pick('+') |
| argument list | zero or more expressions, separated by commas | 2, 3 |
Table 66.2 — The operands of a call expression
Two consequences follow at once. Since an expression may stand in the designator slot, that expression may have side effects of its own — tbl[i++](x). And since a function name decays to a pointer the moment it is used (chapter 68), add(2,3), (*add)(2,3) and (&add)(2,3) all mean the same thing.
★ The commas separating arguments are not the comma operator. f(a, b) passes two arguments; f((a, b)) passes one — inside the inner parentheses the comma becomes an operator, evaluates a, throws it away and yields b. The preprocessor counts macro arguments by the same distinction (chapter 65).
66.1.2 Functions without prototypes, tidied away by C23#
Functions without prototypes were a leftover from old C. C23 swept the leftovers out.
| what is written | through C17 | C23 |
|---|---|---|
int f(); — empty parentheses | “says nothing about the parameters” | means int f(void) |
calling f(1) with no declaration | implicit declaration — taken as int f() | an error |
int f(a, b) int a; int b; { … } | the old definition form — allowed | removed from the standard |
Table 66.3 — The rules around prototypes, before and after
The gcc on this machine shows the difference directly. Given static int f() { return 1; }, a call f(2) draws nothing under -std=c17 and is cut down under -std=c23 with too many arguments to function 'f'. Empty parentheses moved from “unknown” to “none”.
★ The standard and the compiler can also disagree. First, where the compiler is ahead of the standard. The table’s second row — a call with no declaration — is what C23 made an error, and gcc 14 on this machine already errors on it under -std=c17. The compiler shut the door before the standard did.
★ “Removed” and “rejected by the compiler” are not the same. The old definition form is gone from the standard, yet this machine’s gcc still accepts it under -std=c23 with a single warning (-Wold-style-definition) — a courtesy to old code. clang, in the same place, makes it an error. What the standard removed and what your compiler tolerates must be checked separately.
66.2 Arguments arrive as if by assignment#
With a prototype in scope the compiler converts each argument to the parameter’s type as if by assignment (chapter 30). The returned expression is likewise converted to the return type. It all happens silently, which is a good reason to watch it once.
examples-en/call-machinery/convert.c
// With a prototype in scope, arguments are converted "as if by assignment".
#include <stdio.h>
static void takes_int(int n)
{
printf(" the parameter holds %d\n", n);
}
static void takes_unsigned(unsigned n)
{
printf(" the parameter holds %u\n", n);
}
// The same holds for the result --- the returned expression is converted
// to the return type.
static int truncating_return(void)
{
return (int)3.99; // spelling it out tells the reader
}
static char narrowing_return(void)
{
int wide = 321;
return (char)wide; // 321 does not fit in a char
}
int main(void)
{
puts("a double handed to an int parameter:");
takes_int(3.7); // arrives cut down to 3
puts("a negative int handed to an unsigned parameter:");
takes_unsigned(-1); // wraps around
printf("returning 3.99 as int: %d\n", truncating_return());
printf("returning 321 as char: %d\n", narrowing_return());
// Without a prototype this adjustment does not happen. Since C23 a call
// with no prototype is an error, so the only unadjusted place left is ... .
return 0;
}
Output
a double handed to an int parameter:
the parameter holds 3
a negative int handed to an unsigned parameter:
the parameter holds 4294967295
returning 3.99 as int: 3
returning 321 as char: 65
3.7 arrives as 3, -1 wraps around in an unsigned slot, and 321 narrows into a char as 65. None of this is an error — like assignment, it simply converts.
| place | what happens | what it relies on |
|---|---|---|
| a parameter covered by a prototype | converted as if by assignment | the type in the prototype |
an argument crossing ... | default argument promotions — float→double, small integers→int | the callee knows nothing (chapter 67) |
the expression in a return | converted to the return type | the function’s return type |
| an array name as an argument | decays to a pointer to its first element | — |
| a function name as an argument | decays to a function pointer | — |
Table 66.4 — Adjustments applied to arguments and results
The decay of arrays is where people are burnt most often. Writing int a[10] as a parameter still leaves the function receiving a single pointer.
examples-en/call-machinery/params.c
// What is written as a parameter and what is received can differ.
#include <stdio.h>
// It says [10], but what arrives is a single pointer.
// (Writing sizeof a here earns -Wsizeof-array-argument from gcc.)
static void looks_like_array(int a[10])
{
int *p = a; // the same thing --- the assignment draws no warning
printf(" inside: sizeof(the parameter) = %zu\n", sizeof p);
}
// Written this way, "there are at least ten" becomes a contract (since C99).
static int sum_ten(int a[static 10])
{
int total = 0;
for (int i = 0; i < 10; i++)
total += a[i];
return total;
}
// Taking the length separately is the sound way.
static int sum_n(const int *a, size_t n)
{
int total = 0;
for (size_t i = 0; i < n; i++)
total += a[i];
return total;
}
int main(void)
{
int v[10] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
printf("outside: sizeof(v) = %zu\n", sizeof v);
looks_like_array(v);
printf(" the array itself never crossed the call --- only its address did\n");
printf("sum via [static 10]: %d\n", sum_ten(v));
printf("sum via pointer+length: %d\n", sum_n(v, sizeof v / sizeof v[0]));
return 0;
}
Output
outside: sizeof(v) = 40
inside: sizeof(the parameter) = 8
the array itself never crossed the call --- only its address did
sum via [static 10]: 55
sum via pointer+length: 55
Forty bytes outside, eight bytes inside — the array never crossed the call. Only its address did. Recovering the length inside with sizeof is therefore hopeless, and gcc says so outright: 'sizeof' on array function parameter 'a' will return size of 'int *'.
★ int a[static 10] is different. What arrives is still a pointer, but “there are at least ten” is now written down as a contract (chapter 55). The compiler may optimise on that promise, and breaking it is undefined behaviour — it is a place to write a promise, not a place to add a check.
66.3 The order of argument evaluation — what the standard declines to fix#
Here is the chapter’s first knot: the order in which arguments are evaluated. Within one call expression the arguments may be evaluated in any order. In the flesh:
examples-en/call-machinery/order.c
// The order in which arguments are evaluated is not fixed by the standard.
// The same source may run in a different order under a different compiler.
#include <stdio.h>
static int step = 0;
// Prints its own name and reports which step it was.
static int mark(const char *who)
{
step += 1;
printf(" evaluated %s (step %d)\n", who, step);
return step;
}
static void take(int a, int b, int c)
{
printf(" the callee got a=%d b=%d c=%d\n", a, b, c);
}
int main(void)
{
puts("call with three arguments:");
take(mark("the first argument"),
mark("the second argument"),
mark("the third argument"));
// The order *inside* one argument is another story again.
step = 0;
puts("one argument built from two calls:");
take(mark("left of +") + mark("right of +"), 0, 0);
return 0;
}
Output
call with three arguments:
evaluated the third argument (step 1)
evaluated the second argument (step 2)
evaluated the first argument (step 3)
the callee got a=3 b=2 c=1
one argument built from two calls:
evaluated left of + (step 1)
evaluated right of + (step 2)
the callee got a=3 b=0 c=0
The gcc on this machine evaluated the last argument first and worked backwards. The same source under clang evaluates the first argument first and works forwards. Changing the optimisation level between -O0, -O2 and -Os left each compiler’s habit intact.
| compiler | order of three arguments | result of show(i++, i++) | changing optimisation |
|---|---|---|---|
| gcc 14 (x86-64) | third → second → first | a=1 b=0 | same at -O0, -O2, -Os |
| clang 22.1 (x86-64) | first → second → third | a=0 b=1 | same throughout |
Table 66.5 — One source, two orders — measured on this machine
Two things must be read apart here: the orders differing and modifying a value twice.
The orders differing is not itself an accident. The order of argument evaluation is unspecified; either choice runs the program as defined — only code written in reliance on an order falls apart. Indeed, in the second half of the demo the outer arguments went backwards while the two calls inside one argument went in source order. Even the summary “this compiler goes right to left” is not accurate.
Modifying a value twice is an accident. show(i++, i++) modifies one object twice with no order between the modifications, which is undefined behaviour. The a=1 b=0 and a=0 b=1 above are not “two of the possible answers” but two samples from a place where any answer is permitted.
Counter-example. Touching the same thing twice in one call
printf("%d %d\n", i, i++); /* undefined behaviour */
put(buf[k], buf[k++]); /* undefined behaviour */The cure is always the same — split the line.
int before = i;
i += 1;
printf("%d %d\n", before, i);Both compilers point at this with -Wall alone — gcc says operation on 'i' may be undefined (-Wsequence-point), clang says unsequenced modifications to 'i' (-Wunsequenced). Keeping warnings on catches most of it.
66.3.1 Where there is an order#
Not everything is free. Between calls the discipline is quite definite.
| between what | relation | meaning |
|---|---|---|
| the arguments themselves | unspecified order | which goes first is unknown |
| the designator and the arguments | unspecified order | when tbl[i] is read is unknown too |
| all arguments and the body | sequenced | the body starts after every argument is evaluated |
| two function calls | never interleave | one finishes before the other starts |
Table 66.6 — Sequencing rules around a call
That last row matters. In f(g(), h()) it is unknown whether g or h runs first, but g will not run halfway, hand over to h, and resume. The order is unspecified; the interleaving is forbidden. The standard cares enough to name the relation — indeterminately sequenced — and without it, calling any non-reentrant function from an argument slot would be unsafe.
66.4 One call, five steps#
Now the inside of the event. What does the single line add(2, 3) set in motion at machine level?
| step | who does it | what happens |
|---|---|---|
| ① placing the arguments | the caller | loads the agreed registers, and pushes what does not fit |
| ② the call instruction | the caller | leaves the address to come back to and jumps |
| ③ the prologue | the callee | claims a frame and saves registers it will damage |
| ④ the body | the callee | works — and may call others from inside |
| ⑤ epilogue and return | the callee | puts the result in the agreed place, drops the frame, returns |
Table 66.7 — The five steps of one call
The return address of ② is the heart of the whole function mechanism. Because it is left on the stack, calls can nest, recursion works — and writing past that slot sends the program back to somebody else’s address (chapter 44).
Step ③ turns on one more agreement: who preserves what.
| kind | meaning | example on x86-64 System V |
|---|---|---|
| caller-saved | if the value is still needed after the call, stash it first — the callee may use the register freely | rax, rcx, rdx, rsi, rdi, r8–r11 |
| callee-saved | to use it, save it first and restore it before returning | rbx, rbp, r12–r15 |
Table 66.8 — Responsibility for preserving registers
This split is what makes calls cheap. Saving every register on every call would turn one call into dozens of memory accesses; splitting the duty means most calls save nothing at all.
66.5 Register or stack — there is a budget#
Sending arguments in registers is fast, and registers are finite. So every calling convention sets a budget: this many go in registers, the rest go on the stack.
C has no syntax for asking whether an argument arrived in a register. It can still be seen indirectly. With optimisation off, register-borne arguments are spilled side by side into the callee’s own frame, while stack-borne ones stay where the caller put them. The two groups sit far apart, so measuring the distance between neighbouring arguments reveals the seam.
examples-en/call-machinery/where.c
// Where do the arguments live --- the ones that came in registers, and the
// ones that came on the stack?
//
// C has no syntax for asking "did this argument arrive in a register?".
// But with optimisation off, the compiler spills register-borne arguments
// side by side into its own frame, while stack-borne ones stay where the
// caller put them. The two groups sit far apart, so *measuring the distance
// between neighbouring arguments* reveals the seam.
#include <stdio.h>
#include <stdint.h>
#include <stddef.h>
static void eight(int a, int b, int c, int d, int e, int f, int g, int h)
{
const int *arg[8] = { &a, &b, &c, &d, &e, &f, &g, &h };
ptrdiff_t widest = 0;
int at = 0;
for (int i = 0; i + 1 < 8; i++) {
uintptr_t p = (uintptr_t)arg[i], q = (uintptr_t)arg[i + 1];
ptrdiff_t gap = p > q ? (ptrdiff_t)(p - q) : (ptrdiff_t)(q - p);
printf(" argument %d to %d: %td bytes apart\n", i + 1, i + 2, gap);
if (gap > widest) { widest = gap; at = i + 1; }
}
printf("the widest jump is between argument %d and %d\n", at, at + 1);
}
int main(void)
{
puts("a call with eight arguments:");
eight(1, 2, 3, 4, 5, 6, 7, 8);
puts("that jump is the seam between the register group and the stack group.");
return 0;
}
Output
a call with eight arguments:
argument 1 to 2: 4 bytes apart
argument 2 to 3: 4 bytes apart
argument 3 to 4: 4 bytes apart
argument 4 to 5: 4 bytes apart
argument 5 to 6: 4 bytes apart
argument 6 to 7: 152 bytes apart
argument 7 to 8: 8 bytes apart
the widest jump is between argument 6 and 7
that jump is the seam between the register group and the stack group.
The addresses jump between the sixth and the seventh. That is where this ABI’s budget for integer arguments — six — runs out. Building the same program with clang gave a different jump width but the same jump position: it is not the compiler’s taste but the convention’s boundary.
| convention | integer arguments | floating-point arguments | overflow | who clears the stack |
|---|---|---|---|---|
| System V x86-64 — Linux, macOS | rdi rsi rdx rcx r8 r9 (6) | xmm0–xmm7 (8) | pushed on the stack | the caller |
| Microsoft x64 — Windows | rcx rdx r8 r9 (4) | xmm0–xmm3 (4) | stack, past 32 bytes of shadow space | the caller |
| AAPCS64 — AArch64 | x0–x7 (8) | d0–d7 (8) | the stack | the caller |
| cdecl — 32-bit x86 | none — all on the stack | all on the stack | — | the caller |
Table 66.9 — Four desktop calling conventions — as emitted by this machine’s compilers
Windows’ “shadow space” is an agreement that the caller leaves 32 bytes free for the callee to spill its register arguments into. The 32 bytes are left even when there are no arguments at all. Same CPU, same instructions, different promise (chapter 61).
66.5.1 When the side that clears the stack differs, so does the symbol name#
Several conventions coexisted on 32-bit Windows. They all pushed arguments on the stack; they differed in who cleaned up, so joining them wrongly left the stack quietly askew. The remedy of that era was to leave a trace in the name.
| convention | arguments | who clears the stack | symbol name |
|---|---|---|---|
cdecl | all on the stack | the caller (add $28, %esp) | _f |
stdcall | all on the stack | the callee (ret $8) | _f@8 — the byte count is in the name |
fastcall | first two in ecx, edx | the callee | @f@8 |
Table 66.10 — Three conventions on 32-bit Windows — int f(int,int) built with this machine’s mingw
The single instruction ret $8 means “return, and take 8 bytes of stack with you”. And because of the @8 on the name, calling through a declaration with the wrong number of arguments makes the linker fail to find the name — a link error instead of silent corruption. It is a rare case of name decoration acting as a safety device.
66.6 Strange cases — ARM and the embedded world#
Seen from the desktop, conventions look like variations on “how big is the budget”. Down among small machines the rules get much stranger. Everything below comes from building one and the same declaration for different targets.
long long mix(int a, long long b, int c); /* calling mix(1, 2, 3) */| target | where a, b, c ride | what stands out |
|---|---|---|
| ARM (AAPCS) | r0 / r2:r3 / the stack | a 64-bit value must start in an even register, so r1 is left empty — and the third argument is pushed out to the stack |
| RISC-V 32 | a0 / a1:a2 / a3 | unlike ARM, no register is left empty — everything fits in registers |
| AVR (8-bit) | r25:r24 / r23:r16 / r15:r14 | on an 8-bit machine one 16-bit int takes two registers and one 64-bit value takes eight — twelve registers for three arguments |
| MSP430 | r12 / the stack / r13 | registers are not filled in source order — only the large value spills, and the later argument takes the earlier register |
| SPARC | %o0 / %o1:%o2 / %o3 | sent in the %o registers, received as %i — register windows |
| m68k | all on the stack | the register budget is zero |
Table 66.11 — Where the arguments of mix(1, 2, 3) ride on six machines
In practice. One architecture, two ways to pass floating-point arguments --- soft-float and hard-float
There is more than one way to pass a double on ARM. Under the convention that avoids the floating-point unit (soft-float), two doubles ride in the integer registers r0:r1 and r2:r3. Under the convention that uses it (hard-float), the same two values go in d0 and d1. Same source, same CPU, even the same instructions — and the values ride in entirely different places.
Mix object files built under the two promises and the function reads registers it was never given: the program does not die, it merely computes with nonsense. Modern toolchains record the promise in the ELF (executable and linkable format) file and the linker refuses, but picking one wrong prebuilt binary library is still a common accident in embedded work. The hf in a distribution named armhf names exactly this promise.
Platform note. Stranger rules from smaller machines
The odd addresses of Thumb. In ARM’s Thumb mode the address of a function has its lowest bit set to 1. That bit is not part of the address but a marker saying “on arrival, execute as Thumb” — the tagged pointers of chapter 4 made real. Convert such a function pointer to an integer and print it and it is always odd, which is startling the first time.
Machines with no stack. Very small microcontrollers such as the PIC10/12/16 family have next to no data stack. Compilers for that world assign parameters and locals to addresses fixed at compile time (a compiled stack). The moment a function calls itself it overwrites those very slots — so recursion does not work. Compilers for the 8051 keep a separate reentrant keyword for the same reason. That such implementations exist even though the standard requires recursion tells its own story: on those machines, being useful mattered more than being conforming.
These two paragraphs are not measured on this machine but taken from what each toolchain’s documentation lays down — kept apart so that what was measured and what was read do not get mixed.
66.7 Passing and returning structures#
What happens when one value does not fit in a register? Structures sit right on that boundary.
| what is passed | x86-64 System V | AArch64 | how to read it |
|---|---|---|---|
struct { int x, y; } (8 bytes) | both packed into rdi | both into x0 | small structures are packed into registers |
struct { double a, b; } (16 bytes) | xmm0, xmm1 | d0, d1 | floating-point parts go to floating-point registers — the two budgets are counted separately |
struct { long v[5]; } (40 bytes) | copied onto the stack | the address of a copy in x0 | large ones go through memory — but by different means |
| returning those 40 bytes | the caller reserves room and passes it in rdi | the same room passed in x8 | a hidden argument rides along with the result |
Table 66.12 — Passing and returning one structure — as the compilers emitted it
Code that passes large structures by value gives no hint of it in the source, yet a copy and a hidden argument come along. Where performance matters, the idiom that took hold is to pass a pointer and mark it const (chapter 48).
★ Rules of thumb like “16 bytes or less goes in registers” differ per convention and have many exceptions — when integers and floating-point members are mixed, System V classifies the structure in eight-byte pieces and sends some pieces in integer registers and others in floating-point ones. There is no need to memorise this. There is one thing to remember: the size and composition of a value change how it travels.
66.8 Functions that return function pointers#
If an expression may stand in the designator slot, then a function may produce that expression. It is also a good place to practise reading declarators.
examples-en/call-machinery/retfp.c
// A function that returns a function pointer --- one thing, three spellings.
#include <stdio.h>
static int add(int a, int b) { return a + b; }
static int mul(int a, int b) { return a * b; }
// (1) The raw declarator. Read it from the inside out:
// pick is a function taking (char), and its result is a pointer to
// a function taking (int, int) and giving an int.
static int (*pick(char op))(int, int)
{
return op == '+' ? add : mul;
}
// (2) Name the function type and the same thing reads as one line.
typedef int binop(int, int); // a function type
static binop *pick_typedef(char op)
{
return op == '+' ? add : mul;
}
// (3) Name only the pointer type --- the common compromise.
typedef int (*binop_ptr)(int, int);
static binop_ptr pick_ptr(char op)
{
return op == '+' ? add : mul;
}
int main(void)
{
printf("raw declarator: %d\n", pick('+')(3, 4));
printf("function typedef: %d\n", pick_typedef('*')(3, 4));
printf("pointer typedef: %d\n", pick_ptr('+')(10, 32));
// What comes back is a value: keep it and call it later.
binop_ptr f = pick('*');
printf("stored and called later: %d\n", f(6, 7));
// (*f)(...) and f(...) mean the same --- the name decays to a pointer anyway.
printf("both spellings agree: %d %d\n", (*f)(2, 3), f(2, 3));
return 0;
}
Output
raw declarator: 7
function typedef: 12
pointer typedef: 42
stored and called later: 42
both spellings agree: 6 6
Read from the inside out. In int (*pick(char op))(int, int), pick is a function taking one char; its result is a pointer; and what that pointer points to is a function taking two ints and giving an int.
| how it is written | the shape | when |
|---|---|---|
| the raw declarator | int (*pick(char))(int, int); | when one header line must do |
| a name for the function type | typedef int binop(int,int); → binop *pick(char); | when “the function type” is spoken of in several places |
| a name for the pointer type | typedef int (*binop_ptr)(int,int); → binop_ptr pick(char); | the common compromise |
Table 66.13 — Three ways of declaring pick, a function returning a function pointer
A common misconception. The parentheses are not decoration
int *f(int); and int (*f)(int); are entirely different. The first is a function returning a pointer to int; the second is a pointer to a function. One pair of parentheses decides which thing is the function. When what is returned is itself a function pointer, both shapes appear in one declaration — int (*pick(char))(int,int) — which is what makes it hard to read. chapter 69 takes the rule to its end.66.9 The accidents that happen around a call#
The places where this chapter’s rules are broken, gathered in one table. Most are caught by the compiler; the few that are not are the dangerous ones.
| accident | symptom | who catches it | the cure |
|---|---|---|---|
| modifying one value twice in a call | a different answer per compiler | -Wall, usually | split the line |
| relying on evaluation order | breaks when the compiler or the optimisation changes | nobody | pin the order down with a temporary |
| two files declaring one function differently | quietly wrong values — or accidentally right ones | nobody — the compiler, if a header is used | declare once, in a header both files include |
| calling through a pointer of the wrong type | reads registers nobody wrote | -fsanitize=function (clang) | make the pointer types match — do not paper over it with a cast |
| returning the address of a local | after the return that slot belongs to somebody else | -Wreturn-local-addr | let the caller supply the room, or allocate (chapter 45) |
| promising a value and not returning one | the caller reads rubbish | -Wreturn-type | return on every path |
sizeof on an array parameter | always the size of a pointer | -Wsizeof-array-argument | take the length as a separate argument |
a wrongly sized value across ... | the callee fetches something else | -Wformat, for printf only | follow the rules of chapter 67 |
Table 66.14 — Accidents around a call
The third row is the most dangerous in the table, precisely because nobody catches it, and it can happen to look correct, so the accident surfaces late.
examples-en/call-machinery/mismatch/main.c
// Two files know the same function differently.
//
// Warning: this program is undefined behaviour. Neither the compiler nor the
// linker says a word --- each of them sees only its own file. The output
// below is not "the right answer": it is what this machine's calling
// convention happened to make of it.
#include <stdio.h>
// The definition takes two ints; this file declares two longs.
void report(long id, long value);
int main(void)
{
puts("calling a function this file has mis-declared:");
report(1, 2);
puts("it linked, it ran, and it even looks right --- that is the danger.");
return 0;
}
Output
calling a function this file has mis-declared:
report() as defined here: int id=1, int value=2
it linked, it ran, and it even looks right --- that is the danger.
The two files have never seen each other. Each compiler sees only its own file, and the linker sees only names — there is a report, somebody calls it, done. So the program builds without a single warning and on this book’s x86-64 machine even prints the right answer: a value sent as a long was received as an int, which under the System V x86-64 convention means reading the lower half of the same register, and it happened to fit.
The same reasoning explains where the luck runs out. Had the definition been void report(double) and the declaration void report(long), the caller would load an integer register while the callee reads a floating-point register — one nobody has written. The result is not slightly wrong but entirely unrelated.
Counter-example. Writing the declaration twice
/* a.c */ /* b.c */
void report(long, long); void report(int id, int value) { … }When a declaration lives in two places, one day only one of them is updated. There is one sound arrangement: declare it once, in a header, and have the defining file include that header too. Then the moment definition and declaration disagree, the compiler catches it inside that one file. chapter 60 called this discipline “write it once”.
Q. Casting a function pointer to another type and calling through it is something one really does see, especially where callbacks are registered. Is that dangerous too?
A. It is. Function pointers may be converted to one another and the value survives if converted back, but calling through a type other than the original is undefined behaviour. On this machine it usually appears to work — calling a one-argument function through a no-argument type leaves the registers just as they were — and that is the trap. Move to a machine with a different convention, or turn on control-flow integrity checking, and it goes off. Writing a qsort comparator as int cmp(const int *, const int *) and casting it into place is the textbook example. The sound way is to take const void * and convert back inside.
66.10 Recap#
Recap
- A declaration is a promise, a definition is a thing, a call is an event. They can contradict each other where the compiler cannot see it — across files.
- A call expression is an operator. The function designator and the argument list are its operands, and even their order relative to each other is unspecified.
- Arguments meet the prototype and convert as if by assignment. Array and function names decay to pointers on the way in.
- Differing evaluation order is unspecified; modifying one value twice is undefined. On this machine gcc goes right to left and clang left to right — and even that summary must not be relied on.
- One call is five steps: place the arguments, leave a return address and jump, prologue, body, result and return.
- A convention sets a register budget. Past the budget, arguments go on the stack, and the seam is visible from inside the program as a jump in addresses.
- The smaller the machine, the stranger the rules — even-register alignment, two rival ways to pass floating-point arguments (soft-float and hard-float), Thumb’s odd addresses, and a compiled stack with no recursion.
- Structures ride in registers or through memory according to size and composition, and a large result brings a hidden argument with it.
This chapter assumed calls where a prototype exists and the count and types match. The next chapter is where that assumption collapses — functions whose receiving side knows neither how many arguments arrived nor of what type. What must be promised instead, once this chapter’s adjustments are gone, is that chapter’s story.