62 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?
62.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 62.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 (chapters 57).
62.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 62.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 64), 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 61).
62.1.2 What C23 tidied away#
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 62.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 mismatch runs the other way too. 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.
62.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/ch62/convert.c
// 원형이 있으면 인자는 "대입하듯" 변환되어 들어간다.
#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);
}
// 반환값도 마찬가지다 --- 돌려주는 식은 반환 타입으로 변환된다.
static int truncating_return(void)
{
return (int)3.99; // 명시적으로 적어 두면 읽는 사람이 안다
}
static char narrowing_return(void)
{
int wide = 321;
return (char)wide; // 321 은 char 에 들어가지 않는다
}
int main(void)
{
puts("a double handed to an int parameter:");
takes_int(3.7); // 3 으로 잘려서 들어간다
puts("a negative int handed to an unsigned parameter:");
takes_unsigned(-1); // 감싸 올라간다
printf("returning 3.99 as int: %d\n", truncating_return());
printf("returning 321 as char: %d\n", narrowing_return());
// 원형이 없으면 이 조율이 일어나지 않는다. C23 부터는 원형 없는 호출
// 자체가 오류이므로, 남은 "조율 없는 자리"는 ... 뿐이다.
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 (chapters 63) |
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 62.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 receives a single pointer.
examples/ch62/params.c
// 매개변수에 적은 것과 실제로 받는 것은 다를 수 있다.
#include <stdio.h>
// [10] 이라고 적었지만 받는 것은 포인터 하나다.
// (여기서 sizeof a 를 쓰면 gcc 가 -Wsizeof-array-argument 로 나무란다.)
static void looks_like_array(int a[10])
{
int *p = a; // 같은 것이다 --- 대입에 경고가 없다
printf(" inside: sizeof(the parameter) = %zu\n", sizeof p);
}
// 이렇게 적어야 "적어도 10개는 있다"가 계약이 된다 (C99 부터).
static int sum_ten(int a[static 10])
{
int total = 0;
for (int i = 0; i < 10; i++)
total += a[i];
return total;
}
// 길이를 따로 받는 것이 정석이다.
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 53). 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.
62.3 Order — what the standard declines to fix#
Here is the first knot. Within one call expression the arguments may be evaluated in any order. In the flesh:
examples/ch62/order.c
// 인자가 평가되는 순서는 표준이 정해 두지 않았다.
// 같은 소스가 컴파일러마다 다른 순서로 돌 수 있다.
#include <stdio.h>
static int step = 0;
// 부르면 자기 이름을 찍고 몇 번째로 불렸는지를 돌려준다.
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"));
// 한 인자 *안에서*의 순서는 또 다른 이야기다.
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 | 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 62.5 — One source, two orders — measured on this machine
Two things must be read apart here.
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.
62.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 62.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.
62.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 62.7 — The five steps of one call
The return address of ② is the heart of the whole device. 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 it 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 62.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.
62.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/ch62/where.c
// 인자는 어디에 놓여 있는가 --- 레지스터로 온 것과 스택으로 온 것.
//
// C 에는 "이 인자는 레지스터로 왔다"를 묻는 문법이 없다. 그러나 최적화를
// 끄면 컴파일러는 레지스터로 받은 인자를 자기 프레임에 나란히 내려놓고,
// 스택으로 실려 온 인자는 호출한 쪽이 놓아 둔 자리를 그대로 쓴다. 두
// 무리는 서로 멀리 떨어져 있으므로, *이웃한 인자의 주소 차이*를 재면
// 경계가 어디인지 드러난다.
#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 cleans up |
|---|---|---|---|---|
| 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 62.9 — Four conventions from the desktop — 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 57).
62.5.1 When the cleaner differs, so does the 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 62.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.
62.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 | no register is left empty in the same place — 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 62.11 — One line, six machines
In practice. One architecture, two promises that cannot meet
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.
62.7 Passing 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 62.12 — Handing over 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 keep: the size and composition of a value change how it travels.
62.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/ch62/retfp.c
// 함수 포인터를 돌려주는 함수 --- 같은 것을 세 가지 표기로 적는다.
#include <stdio.h>
static int add(int a, int b) { return a + b; }
static int mul(int a, int b) { return a * b; }
// ① 날것의 선언자. 안쪽부터 읽는다:
// pick 은 (char) 를 받는 함수이고, 그 결과는
// (int, int) 를 받아 int 를 주는 함수를 가리키는 포인터다.
static int (*pick(char op))(int, int)
{
return op == '+' ? add : mul;
}
// ② 이름을 붙여 두면 같은 뜻이 한 줄로 읽힌다.
typedef int binop(int, int); // 함수 타입
static binop *pick_typedef(char op)
{
return op == '+' ? add : mul;
}
// ③ 반환 타입만 이름 붙이는 흔한 절충.
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));
// 돌려받은 포인터는 값이므로 변수에 담아 두었다가 나중에 불러도 된다.
binop_ptr f = pick('*');
printf("stored and called later: %d\n", f(6, 7));
// (*f)(...) 와 f(...) 는 같은 뜻이다 --- 이름은 어차피 포인터로 무너진다.
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 62.13 — Three ways of writing one thing
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 65 takes the rule to its end.62.9 The accidents that happen here#
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 (chapters 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 63 |
Table 62.14 — Accidents around a call
The third row is the most dangerous in the table, precisely because nobody catches it.
examples/ch62/mismatch/main.c
// 두 파일이 같은 함수를 서로 다르게 알고 있다.
//
// 경고: 이 프로그램은 미정의 동작이다. 컴파일러도 링커도 아무 말을 하지
// 않는다 --- 각자 자기 파일만 보기 때문이다. 아래 출력은 "옳은 결과"가
// 아니라 이 기계의 호출 규약에서 우연히 맞아떨어진 모습일 뿐이다.
#include <stdio.h>
// 정의는 int 둘인데 여기서는 long 둘이라고 선언했다.
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 machine even prints the right answer: a value sent as a long was received as an int, which under this 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 56 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.
62.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. 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 floating-point promises, 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 callee 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.