Proven C Book←↑→

62 Declarations and call expressions — inside the act of calling

What to know first

chapter 25, Declaring and defining functions · prototypes, definitions, inline
chapter 34, What a function means · copying values, evaluation order
chapter 57, Linking and the ABI · the machine-level promise that is not in the source — short for application binary interface

Looking 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

chapter 22 taught how to call a function, chapter 25 separated declaration from definition, and chapter 57 established that a calling convention exists. What has never been gathered in one place is what happens during a single call. That is this chapter. And its position is deliberate: the very next chapter, on variadic functions, is where the rules of this one break down. To see what breaks, you must first see it standing.

By the end of this chapter

Declaration, definition and call expression are separated, and the call expression is examined as the operator it is. Arguments meet the prototype and are adjusted; evaluation order is measured and found to differ between compilers. A single call is then broken into five steps, and where its arguments ride — register or stack — is compared across twelve machines, which is where ARM and the embedded world get strange. Structures by value, functions that return function pointers, and the accidents that happen around all of this, close the chapter.

The questions this chapter answers

  1. 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 writtendoes it occupy memory?when does it matter?
declarationa name and a type — int add(int, int);nowhen the compiler checks a call
definitionthe declaration plus a body { … }yes — machine code is laid downwhen the linker joins names
call expressionadd(2, 3) — the calling expressionnowhen 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.

slotwhat may appearexample
function designatorany expression denoting a function — a name, a pointer, something fetched from an arrayadd, tbl[i], pick('+')
argument listzero or more expressions, separated by commas2, 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 writtenthrough C17C23
int f(); — empty parentheses“says nothing about the parameters”means int f(void)
calling f(1) with no declarationimplicit declaration — taken as int f()an error
int f(a, b) int a; int b; { … }the old definition form — allowedremoved 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.

placewhat happenswhat it relies on
a parameter covered by a prototypeconverted as if by assignmentthe type in the prototype
an argument crossing ...default argument promotions — float→double, small integers→intthe callee knows nothing (chapters 63)
the expression in a returnconverted to the return typethe function’s return type
an array name as an argumentdecays to a pointer to its first element—
a function name as an argumentdecays 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.

compilerthree argumentsresult of show(i++, i++)changing optimisation
gcc 14 (x86-64)third → second → firsta=1 b=0same at -O0, -O2, -Os
clang 22.1 (x86-64)first → second → thirda=0 b=1same 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 whatrelationmeaning
the arguments themselvesunspecified orderwhich goes first is unknown
the designator and the argumentsunspecified orderwhen tbl[i] is read is unknown too
all arguments and the bodysequencedthe body starts after every argument is evaluated
two function callsnever interleaveone 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?

stepwho does itwhat happens
① placing the argumentsthe callerloads the agreed registers, and pushes what does not fit
② the call instructionthe callerleaves the address to come back to and jumps
③ the prologuethe calleeclaims a frame and saves registers it will damage
④ the bodythe calleeworks — and may call others from inside
⑤ epilogue and returnthe calleeputs 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.

kindmeaningexample on x86-64 System V
caller-savedif the value is still needed after the call, stash it first — the callee may use it freelyrax, rcx, rdx, rsi, rdi, r8–r11
callee-savedto use it, save it first and restore it before returningrbx, 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.

conventioninteger argumentsfloating-point argumentsoverflowwho cleans up
System V x86-64 — Linux, macOSrdi rsi rdx rcx r8 r9 (6)xmm0–xmm7 (8)pushed on the stackthe caller
Microsoft x64 — Windowsrcx rdx r8 r9 (4)xmm0–xmm3 (4)stack, past 32 bytes of shadow spacethe caller
AAPCS64 — AArch64x0–x7 (8)d0–d7 (8)the stackthe caller
cdecl — 32-bit x86none — all on the stackall 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.

conventionargumentswho clears the stacksymbol name
cdeclall on the stackthe caller (add $28, %esp)_f
stdcallall on the stackthe callee (ret $8)_f@8 — the byte count is in the name
fastcallfirst two in ecx, edxthe 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) */
targetwhere a, b, c ridewhat stands out
ARM (AAPCS)r0 / r2:r3 / the stacka 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 32a0 / a1:a2 / a3no register is left empty in the same place — everything fits in registers
AVR (8-bit)r25:r24 / r23:r16 / r15:r14on an 8-bit machine one 16-bit int takes two registers and one 64-bit value takes eight — twelve registers for three arguments
MSP430r12 / the stack / r13registers are not filled in source order — only the large value spills, and the later argument takes the earlier register
SPARC%o0 / %o1:%o2 / %o3sent in the %o registers, received as %i — register windows
m68kall on the stackthe 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 passedx86-64 System VAArch64how to read it
struct { int x, y; } (8 bytes)both packed into rdiboth into x0small structures are packed into registers
struct { double a, b; } (16 bytes)xmm0, xmm1d0, d1floating-point parts go to floating-point registers — the two budgets are counted separately
struct { long v[5]; } (40 bytes)copied onto the stackthe address of a copy in x0large ones go through memory — but by different means
returning those 40 bytesthe caller reserves room and passes it in rdithe same room passed in x8a 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 writtenthe shapewhen
the raw declaratorint (*pick(char))(int, int);when one header line must do
a name for the function typetypedef int binop(int,int); → binop *pick(char);when “the function type” is spoken of in several places
a name for the pointer typetypedef 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.

accidentsymptomwho catches itthe cure
modifying one value twice in a calla different answer per compiler-Wall, usuallysplit the line
relying on evaluation orderbreaks when the compiler or the optimisation changesnobodypin the order down with a temporary
two files declaring one function differentlyquietly wrong values — or accidentally right onesnobody — the compiler, if a header is useddeclare once, in a header both files include
calling through a pointer of the wrong typereads registers nobody wrote-fsanitize=function (clang)make the pointer types match — do not paper over it with a cast
returning the address of a localafter the return that slot belongs to somebody else-Wreturn-local-addrlet the caller supply the room, or allocate (chapters 45)
promising a value and not returning onethe caller reads rubbish-Wreturn-typereturn on every path
sizeof on an array parameteralways the size of a pointer-Wsizeof-array-argumenttake the length as a separate argument
a wrongly sized value across ...the callee fetches something else-Wformat, for printf onlyfollow 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.