4. Functions and Program Organization
A function is the unit of abstraction in C: a named block of code with a declared interface, its own local storage, and a single way in and (logically) a single way out. This chapter covers how functions are declared, called, and returned from; how the compiler and linker decide which names are visible where and how long each variable lives; and how those rules scale up to programs split across many .c files with headers, build systems, and linkage rules. It closes with variadic functions — the mechanism behind printf — and the type-safety price they charge.
<a id="41-function-basics"></a>
4.1 Function Basics
A function packages a computation behind a name and a type signature. This section covers what the compiler needs to know at each call site, how arguments travel from caller to callee, how results travel back, and how to decide where one function should end and the next begin.
Definitions and Prototypes
Theory
To generate code for a call, the compiler needs two things at the point of the call: how many arguments to pass and what type each one is, and what type comes back. It does not need the function's body — that can live in another file, or in a library shipped as machine code. C therefore separates two ideas:
- A definition provides the body. It says what the function does. Each function must be defined exactly once in the whole program.
- A declaration provides only the interface — return type, name, parameter types. It says what the function looks like. It may appear many times, in many files.
A declaration that includes parameter types is called a prototype. Think of the prototype as the label on a shipping container and the definition as the contents: the loading dock only reads the label.
double area(double r); /* declaration (prototype) -- note the ';' */
double area(double r) /* definition -- note the '{' */
{
return 3.14159265358979 * r * r;
}
Why does this matter so much in C? Because the compiler processes a translation unit top to bottom in one pass. When it reaches a call, it uses whatever it knows about that name at that moment. If it has never seen the name, older C (C89) let it guess: the function was assumed to return int and its arguments were passed with default promotions, unchecked. That "implicit declaration" rule silently produced garbage whenever the guess was wrong — the classic case being sqrt(2), where the compiler assumes an int return and reads the wrong register.
The rule tightened over time, and knowing which revision you compile under matters:
| Form | Meaning in C89 | Meaning in C99/C11/C17 | Meaning in C23 |
|---|---|---|---|
| No declaration before use | Implicit int function, arguments unchecked | Constraint violation (diagnostic required); many compilers still only warn | Removed entirely; error |
int f(); | Old-style: takes unspecified arguments, no checking | Same — unspecified arguments | Same as int f(void); — takes no arguments |
int f(void); | Prototype: takes no arguments | Same | Same |
int f(int, char *); | Prototype: checked | Same | Same |
Two practical consequences follow. First, always write (void), not (), for a function that takes no parameters — up to C23, () disables argument checking rather than forbidding arguments. Second, compile with -Wall -Wextra -Werror (Chapter 1.3) so a missing prototype stops the build instead of producing a program that "mostly" works.
Where do prototypes live? For a function used only inside one .c file, declare it static near the top of that file (Chapter 4.3). For a function other files call, put the prototype in a header and include that header in the defining file too — that way the compiler compares the declaration against the definition and catches a mismatch immediately, rather than letting the linker join two incompatible views of the same name.
C99 also removed implicit int for return types: main() { } and static x; are no longer valid; write int main(void) and static int x;.
Examples
#include <stdio.h>
/* --- Declarations (prototypes). No bodies; the compiler now knows the shapes. --- */
double area(double r);
int max3(int a, int b, int c);
static void banner(const char *title); /* static: visible only in this file */
void no_args(void); /* '(void)' -- takes NOTHING */
int main(void)
{
banner("geometry");
/* Legal only because 'area' was declared above: the compiler knows the
argument is a double and the result is a double, so it converts the
int literal 2 to 2.0 and reads the result from the FP register. */
printf("area(2) = %.4f\n", area(2));
printf("max3 = %d\n", max3(3, 9, 4));
no_args();
return 0;
}
/* --- Definitions. Order among themselves does not matter now. --- */
double area(double r)
{
return 3.14159265358979 * r * r;
}
int max3(int a, int b, int c)
{
int m = (a > b) ? a : b;
return (m > c) ? m : c;
}
static void banner(const char *title)
{
printf("== %s ==\n", title);
}
void no_args(void)
{
puts("called with no arguments");
}
What goes wrong without a prototype:
/* BAD: no #include <math.h>, so sqrt is undeclared. */
int main(void)
{
double x = sqrt(2.0); /* C89: assumed 'int sqrt()' -> reads the wrong
register, gets garbage. C99+: diagnostic.
C23: hard error. */
printf("%f\n", x);
return 0;
}
Prototype visible at the call site?
----------------------------------
NO YES
| |
v v
compiler guesses (C89) / arguments converted to the
errors out (C23) declared parameter types,
| return value read correctly
v |
wrong registers, wrong sizes, v
silent nonsense at run time correct call, mismatches
diagnosed at compile time
Key Takeaways
- A declaration (prototype) gives the interface and ends in
;; a definition gives the body. One definition per program, many declarations allowed. - The compiler is single-pass: a function must be declared before the first call in the file, or defined above it.
- Write
f(void), neverf(), for zero-parameter functions — before C23,()means "unspecified arguments, unchecked". - Include the header that declares a function inside the file that defines it, so declaration/definition mismatches are caught at compile time.
- Implicit function declarations and implicit
intare gone in modern C; enable-Wall -Wextra -Werrorso the compiler enforces this for you.
🧪 Practice
- Write a program that calls
sqrtwithout including<math.h>. Compile it with-std=c89, then-std=c17 -Wall, then-std=c23, and record how each diagnoses it.- Declare
void f();in one file and define it asvoid f(int a) { ... }in another; callf()with no arguments. Observe that it compiles and links, and explain why.- Split
areaandmax3intogeom.cwith prototypes ingeom.h, includegeom.hfromgeom.c, then deliberately change one parameter type in the header and see which tool complains.- Interview-style: "Why does C need forward declarations at all, when Java and Python do not?" Hint: think about how many passes each language's compiler makes over a source file, and what a linker is given to work with.
Parameters and Arguments
Theory
The two words name two sides of the same handshake, and keeping them apart makes every later rule easier:
- A parameter is the variable named in the function's definition. It exists inside the function.
- An argument is the expression written at the call site. Its value is what gets sent.
Calling a function is, formally, an assignment: each argument is converted to the corresponding parameter's type exactly as if by assignment, and the parameter starts life holding that value. f(3) where f takes a double passes 3.0; f(3.7) where f takes an int passes 3 (truncated, not rounded).
Parameters are ordinary local variables. They live in the function's own frame, they can be modified freely, and modifying them has no effect on the caller. That is the whole of C's argument model, and Chapter 4.1's "Pass by Value Semantics" topic develops the consequences.
Several rules deserve attention:
Argument count and type are checked against the prototype. Too few, too many, or an incompatible type is a compile error — this is precisely what prototypes buy you.
Evaluation order of arguments is unspecified. In f(g(), h()), the compiler may call h first. Worse, if two arguments modify the same object — f(i++, i++) — the behavior is undefined (Chapter 12.2). Never let one argument depend on a side effect in another.
Arrays decay. A parameter declared int a[] or int a[10] is silently adjusted to int *a. The array itself is never copied, sizeof a inside the function gives the size of a pointer, and the 10 is documentation only. Because of this, any function taking an array needs a separate length parameter. C99 lets you write void f(int n, int a[static n]) to document a minimum length and let the compiler warn.
Function parameters decay too. A parameter declared as a function type becomes a pointer to function (Chapter 5.3).
const on a parameter is a promise. void print(const char *s) promises not to write through s. This documents intent, lets callers pass string literals safely, and enables optimizations. Note that top-level const on a by-value parameter (void f(const int n)) only prevents the function from reassigning its own copy — it means nothing to the caller and is usually omitted from the prototype.
C has no default arguments and no overloading. Two functions cannot share a name; a name means exactly one function. Variadic functions (Chapter 4.4) and _Generic (Chapter 10.3) are the workarounds.
Examples
#include <stdio.h>
#include <string.h>
/* 'n' is a parameter; the caller's expression is the argument. */
long sum_ints(const int *a, size_t n) /* const: we promise not to write through a */
{
long total = 0;
for (size_t i = 0; i < n; i++)
total += a[i];
return total;
}
/* Array parameter: 'int a[10]' is a lie -- it is really 'int *a'. */
void show_size(int a[10])
{
printf("sizeof inside callee = %zu\n", sizeof a); /* 8 on a 64-bit machine */
}
/* Implicit conversion happens as if by assignment. */
void takes_double(double d) { printf("got %.2f\n", d); }
void takes_int(int i) { printf("got %d\n", i); }
int main(void)
{
int data[10] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
printf("sizeof in caller = %zu\n", sizeof data); /* 40: real array */
show_size(data); /* decays to int * */
/* The length must be passed explicitly -- the callee cannot recover it. */
printf("sum = %ld\n", sum_ints(data, sizeof data / sizeof data[0]));
takes_double(3); /* int 3 converted to 3.00 */
takes_int(3.7); /* double truncated to 3, not rounded */
/* Unspecified evaluation order: do not write code that depends on it. */
int i = 0;
/* printf("%d %d\n", i++, i++); <-- UNDEFINED BEHAVIOR. Never do this. */
printf("%d\n", i);
return 0;
}
Call: sum_ints(data, 10)
| |
argument --+ +-- argument
| |
(assignment-like conversion)
v v
Definition: sum_ints(const int *a, size_t n)
parameter parameter
(a copy of the pointer, and a copy of 10,
both living in the callee's own frame)
Key Takeaways
- Parameter = the callee's variable; argument = the caller's expression. Passing is an implicit assignment, including conversions.
- Prototypes make argument count and types checkable; without one, nothing is checked.
- Array parameters decay to pointers —
sizeofinside the callee measures the pointer, so always pass a length alongside. - Argument evaluation order is unspecified; two side effects on the same object in one call is undefined behavior.
- Use
conston pointer parameters you do not write through; C has no overloading and no default arguments.
🧪 Practice
- Write
double average(const double *a, size_t n)and call it with an array; printsizeoffor the array inmainand for the parameter inside the function, and explain the difference.- Define
void f(int x)and call it asf(2.99),f('A'), andf(70000L). Predict each printed result before running, then check.- Write a function
void swap_wrong(int a, int b)that tries to swap its parameters, call it, and show that the caller's variables are unchanged.- Interview-style: "What does
void f(int a[static 5])mean, and what can the compiler do with it?" Hint: it is a promise from the caller, not a constraint on the callee's type.
Return Values
Theory
A function sends a result back with return expression;. As with arguments, the value is converted to the declared return type as if by assignment, and a copy is what the caller receives.
The mechanics matter for one reason above all: the callee's local storage disappears when it returns. Its frame is popped, and any pointer into it becomes dangling. So return &local; is a bug — one that often "works" in testing because the memory has not yet been overwritten, and then fails catastrophically later. Returning a value is always safe; returning a pointer is safe only when the pointee outlives the call (it is static, heap-allocated, or owned by the caller).
void means "no value". A void function may use bare return; to exit early, but return expr; is an error. Conversely, in a non-void function, falling off the closing brace without returning is legal syntax, but using the resulting value is undefined behavior. Compilers catch this with -Wreturn-type, which should always be on.
main is the special case: reaching the end of main behaves as return 0;. Return EXIT_SUCCESS or EXIT_FAILURE from <stdlib.h> for portability; only the low 8 bits reach the shell on POSIX systems, so never encode meaningful data in an exit status.
C can only return one value, which shapes idiomatic C error handling. Four patterns cover almost everything:
| Pattern | Shape | When to use |
|---|---|---|
| Value only | int add(int, int) | Cannot fail |
| Sentinel | char *strchr(...) returns NULL | One obviously invalid value exists |
| Status + out-parameter | int parse(const char *s, long *out) | Every value is valid; failure must be distinguishable |
| Struct return | struct result f(void) | Two or more related values; copies are cheap |
The status + out-parameter shape is the workhorse of robust C: the return value says whether it worked, and the caller supplies storage for the answer. Returning a struct is fully legal (the whole struct is copied, and the ABI may use registers or a hidden pointer), and modern compilers handle it efficiently — but note that a struct containing a pointer to a local is still a dangling pointer.
Examples
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <limits.h>
/* 1. Plain value. */
int add(int a, int b) { return a + b; }
/* 2. Sentinel: NULL means "not found". */
const char *find_char(const char *s, char c)
{
for (; *s; s++)
if (*s == c) return s;
return NULL; /* the sentinel */
}
/* 3. Status + out-parameter: every long is a valid result, so the
success/failure signal must travel separately. */
int parse_long(const char *s, long *out) /* returns 0 on success, -1 on failure */
{
char *end;
errno = 0;
long v = strtol(s, &end, 10);
if (end == s || *end != '\0') return -1; /* not a number */
if (errno == ERANGE) return -1; /* out of range */
*out = v; /* write through the out-param */
return 0;
}
/* 4. Struct return: two values that belong together. */
struct divmod { int quot, rem; };
struct divmod divide(int a, int b)
{
struct divmod r = { a / b, a % b };
return r; /* the whole struct is copied out */
}
/* DANGER: returning the address of a local. */
int *broken(void)
{
int local = 42;
return &local; /* the frame dies here; the pointer is dangling */
}
int main(void)
{
printf("add : %d\n", add(2, 3));
const char *p = find_char("hello", 'l');
printf("find_char : %s\n", p ? p : "(not found)");
long v;
if (parse_long("12345", &v) == 0) printf("parse_long : %ld\n", v);
else puts("parse_long : failed");
struct divmod d = divide(17, 5);
printf("divide : %d rem %d\n", d.quot, d.rem);
/* int *bad = broken(); printf("%d", *bad); <-- undefined behavior */
return EXIT_SUCCESS;
}
Caller frame Callee frame (alive only during the call)
+-------------+ +-----------------------+
| int r; | <--copy---- | return a + b; |
+-------------+ | int local = 42; |
| int *bad; | <--addr---- | return &local; <-- points HERE
+-------------+ +-----------------------+
^
frame popped on return:
the pointer now aims at reusable memory
Key Takeaways
returncopies a value out and converts it to the declared return type; the callee's locals are destroyed immediately after.- Never return the address of an automatic local — return by value, or return memory that outlives the call.
- Falling off the end of a non-
voidfunction and using the result is undefined behavior; keep-Wreturn-typeenabled. mainimplicitly returns 0; useEXIT_SUCCESS/EXIT_FAILUREand remember only the low 8 bits are visible to the shell.- Only one value comes back: use a sentinel when one exists, a status code plus out-parameter when every value is valid, and a struct for related values.
🧪 Practice
- Write
int safe_div(int a, int b, int *out)returning-1whenb == 0and0otherwise. Call it with both cases.- Write the
broken()function above, compile with-Wall, and note the warning. Then run it and observe that the wrong value only sometimes appears — explain why the bug is nondeterministic.- Rewrite
divideto use two out-parameters instead of a struct. Compare the call sites for readability.- Interview-style: "Your function must return a string. What are your options in C, and what does each imply about ownership?" Hint: enumerate who calls
free, and what happens withstaticbuffers when two threads call at once.
Pass by Value Semantics
Theory
C has exactly one argument-passing mechanism: pass by value. Every argument is copied into the parameter, and the callee works on the copy. There is no pass-by-reference in the language — no & in a parameter list like C++, no implicit aliasing.
This is why the beginner's swap fails:
void swap(int a, int b) { int t = a; a = b; b = t; } /* swaps two copies */
The fix is not a different calling convention; it is passing a different value — the address:
void swap(int *a, int *b) { int t = *a; *a = *b; *b = t; }
swap(&x, &y);
Note what actually happened: the pointer itself is still passed by value (the callee gets its own copy of the address). But a copy of an address still points at the original object, so writing through it reaches the caller's variable. "Pass by reference" in C is simply "pass a pointer by value" — the idiom people call pass by address.
Three consequences follow.
Arrays are the apparent exception, and are not. Because an array expression decays to a pointer to its first element, f(arr) passes an address, so the callee can modify the caller's elements. No array was copied; a pointer was.
Big structs cost a copy. Passing a 4 KB struct by value copies 4 KB on every call. Pass const struct big * instead when the struct is large, and pass small structs (a point, a complex number) by value for clarity and speed.
Copies are shallow. Copying a struct that contains a pointer copies the pointer, not the pointee. Both copies then alias the same buffer — fine if you intend sharing, a double-free waiting to happen if you do not.
| Want | Pass | Callee sees | Can modify caller's object? |
|---|---|---|---|
| Read a scalar | int n | copy of the value | No |
| Modify a scalar | int *n | copy of the address | Yes, via *n |
| Read a large struct | const struct S *s | copy of the address, read-only | No (compiler enforced) |
| Modify a large struct | struct S *s | copy of the address | Yes |
| Read an array | const int *a, size_t n | copy of the address | No |
Examples
#include <stdio.h>
#include <string.h>
struct big { char data[4096]; int id; };
void by_value(struct big b) { b.id = 99; } /* modifies a 4 KB copy */
void by_pointer(struct big *b) { b->id = 99; } /* modifies the original */
void read_only(const struct big *b) { printf("id=%d\n", b->id); /* b->id = 1; would not compile */ }
void bad_swap(int a, int b) { int t = a; a = b; b = t; }
void good_swap(int *a, int *b){ int t = *a; *a = *b; *b = t; }
void fill(int a[], size_t n) { for (size_t i = 0; i < n; i++) a[i] = (int)i; } /* really int * */
int main(void)
{
int x = 1, y = 2;
bad_swap(x, y); printf("bad_swap : x=%d y=%d\n", x, y); /* 1 2 -- unchanged */
good_swap(&x, &y);printf("good_swap: x=%d y=%d\n", x, y); /* 2 1 -- swapped */
struct big s;
memset(&s, 0, sizeof s);
s.id = 7;
by_value(s); /* copies the whole 4 KB struct onto the stack */
printf("after by_value : id=%d\n", s.id); /* still 7 */
by_pointer(&s);
printf("after by_pointer: id=%d\n", s.id); /* now 99 */
read_only(&s);
int arr[5] = { 0 };
fill(arr, 5); /* array decayed to a pointer */
printf("arr[3] = %d\n", arr[3]); /* 3 -- caller's array was modified */
return 0;
}
good_swap(&x, &y)
caller frame callee frame
+----------+ +----------+
| x = 1 |<-------------| a (copy of &x)
+----------+ +----------+
| y = 2 |<-------------| b (copy of &y)
+----------+ +----------+
The POINTERS are copies. What they point AT is shared.
Key Takeaways
- C passes everything by value; the callee always works on a copy of the argument's value.
- To modify a caller's object, pass its address — the pointer is copied, but the pointee is shared.
- Arrays are not an exception: the array expression decays to a pointer, so the address is what gets copied.
- Struct arguments are copied in full; pass large structs as
const struct S *to avoid the cost and document read-only intent. - Struct copies are shallow — a copied pointer member aliases the same memory.
🧪 Practice
- Implement
bad_swapandgood_swap, print addresses of the caller's variables and of the parameters inside each, and confirm they differ.- Write a function that takes a 1 MB struct by value in a loop of a million calls, time it, then change to
const struct *and time it again.- Write
void zero(int *a, size_t n)and call it with a stack array and with amalloced buffer; verify both are modified.- Interview-style: "C is pass-by-value only — so how does
scanfchange my variables?" Hint: look at what&xactually evaluates to, and whatscanfdoes with the copy it receives.
Recursion
Theory
A function may call itself. That sounds circular, but it works because each call gets its own frame: its own parameters, its own locals, its own return address. Ten nested calls means ten independent copies of the locals stacked on top of each other.
Recursion is the natural shape for problems that are self-similar — where the answer for size n is built from the answer for a smaller input. Every correct recursive function has two parts:
- A base case that returns without recursing. Without it, the recursion never stops.
- A recursive case that calls itself on strictly smaller input, guaranteeing the base case is eventually reached.
The cost is the stack. Each frame consumes memory (typically tens of bytes, more with big locals), and the stack is a fixed, modest size — commonly 8 MB on Linux, 1 MB on Windows. Exceed it and the program dies with a stack overflow (segmentation fault), with no exception to catch. Depth proportional to n is fine for n in the thousands; depth proportional to n for a million-element list is not.
Two shapes are worth naming:
- Tail recursion: the recursive call is the last thing the function does, with nothing left to compute afterwards. A compiler at
-O2can turn this into a jump, reusing the frame — the recursion becomes a loop and the stack stops growing. This is an optimization, not a language guarantee; never rely on it for correctness. - Tree recursion: the function calls itself more than once (naive Fibonacci). Beware exponential blow-up — naive
fib(50)makes about 2.7 billion calls.
When should you use recursion in C? When the data is recursive: trees, directory hierarchies, nested expressions, divide-and-conquer sorts. For simple linear iteration, a loop is clearer, faster, and cannot overflow the stack. Any recursion can be rewritten iteratively (with an explicit stack if necessary), and for depth-unbounded input you should.
Examples
#include <stdio.h>
/* Classic: factorial. Base case n <= 1, recursive case n * fact(n-1). */
unsigned long long fact(unsigned n)
{
if (n <= 1) return 1; /* base case -- stops the recursion */
return n * fact(n - 1); /* NOT tail recursive: the multiply
happens after the call returns */
}
/* Tail-recursive version: nothing to do after the call, so -O2 turns it
into a loop and the stack stops growing. */
unsigned long long fact_tail(unsigned n, unsigned long long acc)
{
if (n <= 1) return acc;
return fact_tail(n - 1, acc * n); /* tail call */
}
/* Tree recursion: two calls per level -> exponential time. */
unsigned long long fib_slow(unsigned n)
{
if (n < 2) return n;
return fib_slow(n - 1) + fib_slow(n - 2);
}
/* The iterative equivalent: O(n) time, O(1) space. */
unsigned long long fib_fast(unsigned n)
{
unsigned long long a = 0, b = 1;
for (unsigned i = 0; i < n; i++) { unsigned long long t = a + b; a = b; b = t; }
return a;
}
/* Recursion where it genuinely fits: a self-similar structure. */
struct node { int value; struct node *left, *right; };
int tree_sum(const struct node *n)
{
if (n == NULL) return 0; /* base case */
return n->value + tree_sum(n->left) + tree_sum(n->right); /* self-similar */
}
int main(void)
{
printf("fact(10) = %llu\n", fact(10));
printf("fact_tail(10) = %llu\n", fact_tail(10, 1));
printf("fib_slow(30) = %llu\n", fib_slow(30)); /* noticeably slow */
printf("fib_fast(90) = %llu\n", fib_fast(90)); /* instant */
struct node l = { 1, NULL, NULL }, r = { 3, NULL, NULL }, root = { 2, &l, &r };
printf("tree_sum = %d\n", tree_sum(&root));
return 0;
}
Frame growth for fact(4):
call stack (grows downward)
fact(4) --> [ n=4 ]
fact(3) --> [ n=4 ][ n=3 ]
fact(2) --> [ n=4 ][ n=3 ][ n=2 ]
fact(1) --> [ n=4 ][ n=3 ][ n=2 ][ n=1 ] <- base case, returns 1
unwinding:
1*2 = 2 -> 2*3 = 6 -> 6*4 = 24
Key Takeaways
- Every recursive function needs a base case and a recursive case that strictly shrinks the input.
- Each call allocates a new frame; depth is bounded by the stack (about 8 MB on Linux) and overflow is a hard crash.
- Tail recursion can be optimized into a loop at
-O2, but C does not guarantee it — never depend on it for correctness. - Tree recursion without memoization can be exponential; naive
fibis the standard cautionary example. - Prefer recursion for recursive data (trees, nested structures) and loops for linear traversal.
🧪 Practice
- Write a recursive
size_t str_len(const char *s)and a recursivevoid reverse_print(const char *s). Test both on a short string.- Write a recursive function with no base case, run it, and observe the crash. Then use
ulimit -sto check your stack size and estimate the depth reached.- Time
fib_slow(35)versusfib_fast(35). Then add memoization (astaticarray of cached results) tofib_slowand time it again.- Interview-style: "Rewrite a recursive binary-tree traversal iteratively." Hint: whatever the call stack was storing for you, you must now store yourself.
Function Design and Cohesion
Theory
The compiler accepts any function you can type. What separates maintainable C from a swamp is a set of design habits, and the central one is cohesion: a function should do one conceptual thing, and its name should say what that thing is.
A useful test: describe the function in one sentence without using "and" or "then". If you cannot, it is doing too much.
Practical guidelines, roughly in order of impact:
One job per function. read_config_and_connect_and_log() has three reasons to change and cannot be tested or reused piecemeal. Splitting it makes each part independently testable.
Short enough to see at once. There is no magic number, but a function that does not fit on a screen is usually hiding two functions. Chapter 3.3 covered cyclomatic complexity: above roughly 10-15 decision points, split it.
Few parameters. Beyond four or five, callers start passing things in the wrong order — especially with same-typed parameters where the compiler cannot help. Group related parameters into a struct.
Minimize hidden state. A function that reads only its parameters and writes only its outputs (a pure function) is trivially testable and thread-safe. Global state and static locals make behavior depend on history. Push state to the edges of your program.
Consistent error signaling. Pick one convention per module — 0/-1, NULL, or an enum status — and apply it everywhere. Mixed conventions are a permanent source of unchecked failures.
Const-correctness in the signature. size_t count(const char *s, char c) tells the reader and the compiler that the string is not modified. Signatures are documentation the compiler checks.
Name for the caller's viewpoint. Verb phrases for actions (parse_line, flush_buffer), noun or predicate phrases for queries (buffer_size, is_valid). A function named process says nothing.
Guard clauses over deep nesting. Handling invalid input first and returning early keeps the main path at one indentation level.
Examples
#include <stdio.h>
#include <string.h>
#include <ctype.h>
/* ---------- BEFORE: one function doing four jobs ---------- */
int handle(char *line) /* modifies input, parses, validates, prints */
{
if (line != NULL) {
if (strlen(line) > 0) {
for (char *p = line; *p; p++)
*p = (char)toupper((unsigned char)*p);
if (strncmp(line, "SET ", 4) == 0) {
char *value = line + 4;
if (strlen(value) > 0) {
printf("setting to %s\n", value);
return 0;
} else return -1;
} else return -2;
} else return -3;
}
return -4;
}
/* ---------- AFTER: four cohesive functions ---------- */
/* One job: uppercase in place. Pure with respect to everything but its argument. */
static void upcase(char *s)
{
for (; *s; s++) *s = (char)toupper((unsigned char)*s);
}
/* One job: recognize the command and hand back the argument. No I/O, no mutation. */
static const char *parse_set(const char *line)
{
if (strncmp(line, "SET ", 4) != 0) return NULL; /* guard clause */
const char *value = line + 4;
return (*value != '\0') ? value : NULL;
}
/* One job: apply the effect. */
static void apply_set(const char *value)
{
printf("setting to %s\n", value);
}
/* Thin coordinator: reads top to bottom, guard clauses first, one level deep. */
int handle_line(char *line)
{
if (line == NULL || *line == '\0') return -1; /* guard */
upcase(line);
const char *value = parse_set(line);
if (value == NULL) return -2; /* guard */
apply_set(value);
return 0;
}
int main(void)
{
char a[] = "set brightness";
char b[] = "get brightness";
printf("a -> %d\n", handle_line(a));
printf("b -> %d\n", handle_line(b));
return 0;
}
Where each design axis pushes:
| Symptom | Likely cause | Fix |
|---|---|---|
| Name contains "and" | Low cohesion | Split into two functions |
7 parameters, several int | Ungrouped data | Pass a struct |
| Nesting 5 levels deep | Missing guard clauses | Return early on invalid input |
| Test needs elaborate setup | Hidden global state | Pass state in as a parameter |
| Same value recomputed everywhere | Missing helper | Extract a named function |
Key Takeaways
- A function should do one thing; if its one-sentence description needs "and", split it.
- Keep parameter lists short and group related arguments into structs; same-typed parameters invite ordering bugs.
- Prefer pure functions — inputs in, value out — and push global and
staticstate to the program's edges. - Use guard clauses to handle errors first and keep the success path shallow.
- Choose one error-signaling convention per module and use
constin signatures to document and enforce read-only access.
🧪 Practice
- Take the
handlefunction above and refactor it yourself before reading the "AFTER" version; compare the two decompositions.- Find a function in your own code with more than five parameters and refactor it to take a configuration struct.
- Rewrite a deeply nested validation function using guard clauses and count the indentation levels before and after.
- Interview-style: "How do you decide whether a block of code deserves to be its own function?" Hint: think about naming, reuse, and what you would need to mock to test it.
<a id="42-scope-and-lifetime"></a>
4.2 Scope and Lifetime
Two independent questions govern every variable in C: where in the source is this name visible (scope), and when during execution does this object exist (storage duration). Confusing them is the source of most puzzling behavior around static, extern, and dangling pointers, so this section separates them carefully.
Block, File, and Function Scope
Theory
Scope is a purely compile-time, textual property: the region of source code in which an identifier refers to a particular declaration. It says nothing about memory. C defines four scopes:
| Scope | Where it starts | Where it ends | Typical declaration |
|---|---|---|---|
| Block | At the declarator, inside { } | At the closing } | Locals, parameters |
| File | At the declarator, outside any block | End of the translation unit | Globals, static globals |
| Function | Anywhere in the function | End of the function | Labels only (goto targets) |
| Function prototype | Inside the parameter list | End of the prototype | void f(int n, int a[n]); |
Two details in that table repay attention.
Scope begins at the declarator, not at the enclosing brace. So this is legal and surprising:
int x = 10;
void f(void) {
int x = x; /* the inner x is already in scope: it initializes itself
with its own indeterminate value, not with 10 */
}
Function scope applies only to labels. A label written anywhere in a function is visible everywhere in it, which is why goto can jump forward over declarations to a cleanup label at the bottom (Chapter 3.3).
Blocks nest, and each { } opens a new block scope — including the bodies of if, for, and while, and even bare braces you write for the purpose. A for loop's initializer is itself in a scope that encloses the loop body:
for (int i = 0; i < n; i++) { /* i is visible here */ }
/* i is NOT visible here (C99 and later) */
Declaring variables in the smallest possible scope is the single cheapest reliability habit in C: it shortens the distance a reader must track, prevents accidental reuse, and lets the compiler reuse the storage.
Note that parameters are in the block scope of the function body, which is why a parameter and a top-level local cannot share a name.
Examples
#include <stdio.h>
int g = 100; /* file scope: visible from here to end of file */
void demo(int p) /* p: block scope of the function body */
{
int a = 1; /* block scope: the whole function body */
{ /* a nested block -- a new scope */
int a = 2; /* shadows the outer a (see "Name Shadowing") */
int b = 3; /* exists only inside these braces */
printf("inner: a=%d b=%d p=%d g=%d\n", a, b, p, g);
}
/* printf("%d", b); */ /* ERROR: b is out of scope here */
printf("outer: a=%d\n", a); /* 1 -- the inner a was a different object */
for (int i = 0; i < 2; i++) /* i: scope is the for statement */
printf("loop i=%d\n", i);
/* printf("%d", i); */ /* ERROR: i is out of scope */
goto done; /* labels have FUNCTION scope: visible everywhere */
done:
puts("done");
}
int main(void) { demo(7); return 0; }
file scope
+-----------------------------------------------+
| int g; |
| |
| demo() body -- block scope |
| +------------------------------------------+ |
| | int p (parameter), int a | |
| | | |
| | nested block | |
| | +------------------------+ | |
| | | int a (shadows), int b | | |
| | +------------------------+ | |
| | | |
| | label 'done': visible in ALL of demo | |
| +------------------------------------------+ |
+-----------------------------------------------+
Key Takeaways
- Scope is textual visibility, decided at compile time; it is independent of lifetime.
- C has block, file, function (labels only), and function-prototype scope.
- A name's scope starts at its declarator, not at the opening brace —
int x = x;sees the innerx. - Every
{ }opens a new scope, including loop andifbodies; C99for (int i = ...)confinesito the loop. - Declare variables in the narrowest scope that works; it reduces bugs and helps the optimizer.
🧪 Practice
- Write a function with three nested blocks, each declaring
int vwith a different value, and printvat each level.- Try to use a
for-loop variable after the loop under-std=c99and under-std=c89. Explain the different results.- Write
int x = 10;at file scope andint x = x + 1;inside a function; compile with-Wall -Winit-selfand explain the warning.- Interview-style: "What is the scope of a label, and why is it different from everything else?" Hint: consider what
gotomust be able to do inside a function with many nested blocks.
Automatic Storage Duration
Theory
Storage duration answers the other question: how long does the object live? The default for anything declared inside a block is automatic storage duration — hence the (essentially never-written) keyword auto.
An automatic object is created when execution enters its block and destroyed when execution leaves that block, by any route: falling off the end, break, continue, return, or goto. In practice the compiler allocates all of a function's automatic objects in one stack frame on function entry, and pops the whole frame on return — which is why creating them costs essentially nothing (adjusting a stack pointer) and why there is no cleanup to run.
Three properties matter:
Uninitialized automatics hold indeterminate values. Unlike static objects, they are not zeroed. Reading one before writing it is undefined behavior — and often looks like it "works" because the stack happened to contain zeros. Always initialize at the point of declaration.
Their addresses are only valid during their lifetime. Once the block exits, any pointer to them dangles. This is the mechanism behind the return &local; bug from Chapter 4.1.
Each call gets a fresh copy, which is exactly what makes recursion and multithreading work: two simultaneous calls do not share locals. Automatic objects are inherently thread-safe.
The stack is finite (often 8 MB). A large automatic array — char buf[10 * 1024 * 1024]; — can overflow it with no diagnostic at compile time and no recoverable error at run time. Big buffers belong on the heap (Chapter 7.2).
C99's variable-length arrays (VLAs) are automatic objects whose size is computed at run time. They are convenient but risky: a size derived from untrusted input is a stack-overflow vulnerability, and VLAs are optional in C11 and later (__STDC_NO_VLA__).
Examples
#include <stdio.h>
void counter_broken(void)
{
int count = 0; /* created fresh on EVERY call */
count++;
printf("count = %d\n", count); /* always 1 */
}
void indeterminate(void)
{
int uninitialized; /* NOT zeroed -- holds whatever was there */
int initialized = 0; /* always do this instead */
printf("initialized = %d\n", initialized);
/* printf("%d", uninitialized); <-- undefined behavior */
(void)uninitialized;
}
void scoped_lifetime(void)
{
for (int i = 0; i < 3; i++) {
int inner = i * 10; /* created and destroyed on every iteration */
printf("i=%d inner=%d (addr %p)\n", i, inner, (void *)&inner);
/* The address is typically identical each iteration: the same stack
slot is reused. Same storage, but a new object each time. */
}
}
int *dangling(void)
{
int local = 5;
return &local; /* lifetime ends at the closing brace */
}
int main(void)
{
counter_broken();
counter_broken(); /* still prints 1: the local was recreated */
indeterminate();
scoped_lifetime();
(void)dangling(); /* using the result would be undefined behavior */
return 0;
}
Stack during main() -> scoped_lifetime()
high addresses
+---------------------+
| main's frame | lives until main returns
+---------------------+
| scoped_lifetime's | created on entry, popped on return
| frame: i, inner |
+---------------------+
low addresses <-- stack pointer moves down on call, up on return
Key Takeaways
- Block-scope variables have automatic storage duration by default: created on block entry, destroyed on block exit.
- Automatic objects are not zero-initialized; reading an uninitialized one is undefined behavior.
- Pointers to automatic objects dangle once the block is left — never return them or store them beyond the block.
- Each call and each recursion level gets its own copy, which makes locals inherently thread-safe.
- The stack is small; put large buffers on the heap and treat run-time-sized VLAs as a security risk.
🧪 Practice
- Write a function with an uninitialized
intand print it, compiled with-O0and then-O2 -Wall. Compare the values and the warnings.- Print the address of a local inside a loop for several iterations, then inside a recursive function for several depths. Explain why one repeats and the other does not.
- Declare
char buf[64 * 1024 * 1024]as a local and run the program; then move it tomallocand run again.- Interview-style: "Why is a local variable thread-safe but a
staticlocal is not?" Hint: count how many copies of each exist when two threads call the same function.
static Variables
Theory
static is the most overloaded keyword in C. It does two entirely different jobs depending on where it appears, and the only reliable way to keep them apart is to ask: is this inside a block or outside one?
1. static on a block-scope variable changes its storage duration. The variable keeps block scope (still only visible inside that function), but it now has static storage duration: it is created once, before main runs, and lives until the program exits. It retains its value between calls. Static objects are also zero-initialized by default, and their initializer must be a constant expression evaluated at compile time (C99 relaxes this only for VLAs, which cannot be static at all).
2. static on a file-scope declaration changes its linkage. The variable or function still has static storage duration (globals always do), but it becomes internal linkage: invisible to other translation units. That is a Chapter 4.3 topic, and it is the C equivalent of "private to this file".
| Declaration | Scope | Storage duration | Linkage |
|---|---|---|---|
int x; inside a function | block | automatic | none |
static int x; inside a function | block | static | none |
int x; at file scope | file | static | external |
static int x; at file scope | file | static | internal |
Static locals are useful for a call counter, a lazily initialized table, or a cached result. They come with a real cost: the function is no longer pure, its behavior depends on call history, and it is not thread-safe — every thread shares the one object. The classic example is strtok, whose static state is exactly why it cannot be used from two threads.
The initializer of a static local runs once, not on every call — a point that surprises people coming from languages with different semantics:
void f(void) { static int n = 5; n++; } /* n is set to 5 once, before main */
Examples
#include <stdio.h>
/* 1. Static local: one object, shared by all calls, remembers its value. */
int next_id(void)
{
static int id = 0; /* initialized ONCE, before main runs */
return ++id;
}
/* 2. Lazy initialization: build an expensive table on first use. */
const int *squares(void)
{
static int table[10];
static int built = 0; /* zero-initialized by default */
if (!built) {
for (int i = 0; i < 10; i++) table[i] = i * i;
built = 1;
puts("(table built)");
}
return table; /* SAFE to return: static storage outlives the call */
}
/* 3. Contrast with automatic. */
void automatic_counter(void)
{
int count = 0; /* recreated every call */
printf("automatic: %d\n", ++count);
}
int main(void)
{
printf("ids: %d %d %d\n", next_id(), next_id(), next_id()); /* order of
evaluation is unspecified, but all three differ -- state persists */
const int *t = squares();
const int *u = squares(); /* no rebuild message the second time */
printf("squares[7] = %d, same pointer: %s\n", t[7], t == u ? "yes" : "no");
automatic_counter();
automatic_counter(); /* prints 1 both times */
return 0;
}
Memory regions
+----------------+ .data / .bss : static storage duration
| static int id | created before main, destroyed at exit
| static table[] | ONE copy, shared by every call and every thread
+----------------+
| heap | malloc/free
+----------------+
| stack | automatic locals: one copy PER CALL
+----------------+
Key Takeaways
- Inside a block,
staticchanges storage duration: one object, created beforemain, retaining its value across calls. - At file scope,
staticchanges linkage instead: the name becomes private to the translation unit. - Static objects are zero-initialized and their initializers must be compile-time constants, evaluated once.
- Returning a pointer to a static local is safe (unlike an automatic), but every caller shares one buffer.
- Static state destroys purity and thread safety —
strtokis the standard cautionary example.
🧪 Practice
- Write
int call_count(void)using a static local and call it in a loop; then removestaticand explain the change.- Write a function returning a pointer to a static
charbuffer that formats a number as a string. Call it twice in a singleprintfand explain the surprising output.- Print the address of a static local and of an automatic local in the same function across several calls; compare stability.
- Interview-style: "When is a static local the right tool, and when is it a landmine?" Hint: consider two threads, and consider what a unit test needs to reset between cases.
extern Declarations
Theory
By default, a variable declared at file scope has external linkage: the name is exported and any translation unit in the program can refer to the same object. extern is how another file says "this name exists somewhere else — trust me, the linker will find it."
The crucial distinction is definition versus declaration, exactly as with functions:
int counter = 0; /* DEFINITION: allocates storage, in exactly one .c file */
extern int counter; /* DECLARATION: no storage; refers to the definition */
The rule that follows is the standard C project layout, and it is worth memorizing:
- Put
extern int counter;in the header. - Put
int counter = 0;in exactly one.cfile. - Every file that uses
counterincludes the header.
If you put the definition in the header instead, every file that includes it defines the variable, and the linker reports a duplicate symbol. (Historically some linkers merged tentative definitions — the "common symbol" behavior — which is why the mistake sometimes went unnoticed. GCC 10 and later default to -fno-common, so it is now an error.)
Functions are simpler: a function declaration is extern by default, so extern on a prototype is redundant and normally omitted.
A subtlety worth knowing: at file scope, int counter; with no initializer is a tentative definition. If the translation unit never provides a real definition, it becomes a definition initialized to zero. This is why int x; in two headers used to link "by accident."
Globals themselves deserve a warning independent of the mechanics. They create invisible coupling between modules, make functions non-reentrant, and complicate testing. Prefer passing state explicitly; when a global is genuinely justified (a program-wide configuration, a logging handle), expose it through accessor functions rather than raw extern, so you can add validation or locking later. Mark read-only globals const and, if shared across threads, use _Atomic or a mutex — volatile is not a threading tool (Chapter 11.4).
Examples
/* ---------- config.h ---------- */
#ifndef CONFIG_H
#define CONFIG_H
extern int verbosity; /* DECLARATION -- no storage allocated */
extern const char *program_name;
void set_verbosity(int level); /* 'extern' is implicit on functions */
#endif
/* ---------- config.c ---------- */
#include "config.h" /* include your own header: catches mismatches */
int verbosity = 0; /* THE definition -- exactly one in the program */
const char *program_name = "demo";
void set_verbosity(int level)
{
verbosity = (level < 0) ? 0 : level;
}
/* ---------- main.c ---------- */
#include <stdio.h>
#include "config.h" /* brings in the extern declarations */
int main(void)
{
printf("%s: verbosity = %d\n", program_name, verbosity);
set_verbosity(3);
printf("%s: verbosity = %d\n", program_name, verbosity); /* same object */
return 0;
}
config.h : extern int verbosity; <- promise, no storage
| included by
+---------------------+
v v
config.c main.c
int verbosity = 0; (uses verbosity)
| |
| compiled | compiled
v v
config.o main.o
[defines verbosity] [undefined ref: verbosity]
\ /
+----- linker ------+
|
v
one object, one address
Key Takeaways
externdeclares; a file-scope definition allocates. Declare in the header, define in exactly one.cfile.- Defining a variable in a header causes duplicate-symbol errors in every file that includes it (and GCC 10+ no longer merges them).
externon function prototypes is redundant — functions have external linkage by default.int x;at file scope is a tentative definition that becomes a zero-initialized definition if nothing else defines it.- Globals couple modules and break reentrancy; prefer explicit parameters or accessor functions, and mark read-only globals
const.
🧪 Practice
- Build the three-file example above. Then move
int verbosity = 0;intoconfig.hand read the linker error carefully.- Declare
extern int missing;and use it without defining it anywhere. Compare the error message to the previous one and identify which tool produced each.- Add a second
.cfile that also modifiesverbosity, and confirm all files see the same object by printing&verbosityfrom each.- Interview-style: "How would you expose a global counter to other modules while keeping the ability to add a mutex later?" Hint: what does an accessor function let you change that an
externvariable does not?
register and inline
Theory
These two keywords are both hints about performance, and both are widely misunderstood — one is obsolete, the other is subtle.
register asked the compiler to keep a variable in a CPU register instead of memory. In 1972, with primitive compilers, this genuinely helped. Today every optimizing compiler does register allocation far better than a human can annotate, and the hint is ignored. One real effect remains: you cannot take the address of a register variable — &x is a compile error. That is occasionally used to enforce "this must never escape", but the keyword is otherwise dead code. C++17 removed it; C23 keeps it only for compatibility. Do not write it in new code.
inline (C99) is the interesting one. Function calls have overhead — arguments set up, a jump, a frame, a return — and for a tiny function that overhead can dominate. Inlining substitutes the body at the call site, eliminating the call and, more importantly, exposing the body to optimization in the caller's context.
The critical point: inline is not a command, and it is primarily a linkage rule, not an optimization directive. Compilers inline aggressively at -O2 without any keyword, and they refuse to inline when they judge it unprofitable (large bodies, recursion, address-taken functions) even when you write it. What inline actually changes is what the linker sees, and C's rule here is genuinely tricky:
inline void f(void) { ... }in a header provides an inline definition, which does not create an external symbol. If the compiler decides not to inline a call, the linker needs a real function somewhere — and finds none. The result is the classic "undefined reference tof" at-O0.- Exactly one translation unit must provide an external definition, written either as
extern inline void f(void);(a declaration withexternin a.cfile, which emits the symbol) or by defining it withoutinline. static inlinein a header sidesteps all of this: each translation unit gets its own private copy, no external symbol is needed, and there is no linker puzzle. This is what nearly all real C code uses.
C++ has different rules here; do not carry the intuition across.
Examples
/* ---------- vec.h : the idiomatic pattern ---------- */
#ifndef VEC_H
#define VEC_H
/* static inline: safe in a header, no linker rules to remember,
each translation unit gets its own copy that the compiler may inline. */
static inline int imax(int a, int b)
{
return a > b ? a : b;
}
static inline int clamp(int v, int lo, int hi)
{
return imax(lo, v < hi ? v : hi);
}
#endif
/* ---------- The C99 'inline' rule, demonstrated ---------- */
/* fast.h */
inline int square(int x) { return x * x; } /* inline definition: no symbol emitted */
/* fast.c */
#include "fast.h"
extern int square(int x); /* THIS line emits the external definition.
Without it, a non-inlined call fails at link time. */
/* main.c */
#include <stdio.h>
#include "fast.h"
int main(void) { printf("%d\n", square(7)); return 0; }
/* ---------- register: what it still does ---------- */
#include <stdio.h>
int main(void)
{
register int fast = 42; /* the hint is ignored by modern compilers */
printf("%d\n", fast);
/* int *p = &fast; */ /* ERROR: cannot take the address of a register variable */
return 0;
}
| Form | Where to write it | Emits a symbol? | Use it? |
|---|---|---|---|
static inline f() {...} | Header | Per-TU, internal | Yes — the default choice |
inline f() {...} | Header | No | Only with a matching extern declaration |
extern inline f(); | Exactly one .c | Yes | Pair with the above |
register int x; | Anywhere | n/a | No — obsolete |
Key Takeaways
registeris obsolete; its only surviving effect is forbidding&x. Modern compilers allocate registers better than annotations can.inlineis a hint the compiler may ignore, and at-O2it inlines plenty without the keyword.- C99
inlineis chiefly a linkage rule: a bareinlinedefinition emits no symbol, causing link errors when a call is not inlined. static inlinein a header is the practical idiom — no external symbol, no linker subtleties.- Do not apply C++
inlineintuition to C; the rules genuinely differ.
🧪 Practice
- Write
inline int square(int x) { return x * x; }in a header, call it from two.cfiles, and build at-O0. Read the link error, then fix it withextern inlinein one file.- Change the same function to
static inline, rebuild at-O0and-O2, and compare object-file symbols withnm.- Try to take the address of a
registervariable and record the exact error message from your compiler.- Interview-style: "Does marking a function
inlinemake it faster?" Hint: separate what the keyword promises the linker from what the optimizer decides, and consider instruction-cache pressure.
Name Shadowing
Theory
When an inner scope declares a name that already exists in an enclosing scope, the inner declaration shadows the outer one: within the inner scope, the name refers to the new object, and the outer one is temporarily unreachable. It is not an error; C is designed this way so that an inner block never needs to know what names its surroundings use.
Shadowing is what makes locals safe. Without it, adding a global named count to a program could break every function that happened to use count as a local. With it, the innermost declaration always wins.
The danger is not the rule but the confusion it enables. Three patterns cause real bugs:
Accidental shadowing during refactoring. You add a local n to a function that already reads a global n, and the rest of the function silently switches meaning.
Shadowing a parameter in a nested block. The inner assignment appears to modify the argument but modifies a different object.
Shadowing standard library names. A local named index, time, read, link, or remove compiles (POSIX declares these functions), and then a call to the real function fails obscurely.
-Wshadow reports all of it. Many projects enable it; a few find it noisy because inner loops legitimately reuse i. A good compromise is to enable it and rename rather than suppress.
Related, and distinct: redeclaration in the same scope is an error, not shadowing (int x; int x; inside one block). And a shadowed variable is only hidden, not destroyed — the outer object still exists and keeps its value.
Examples
#include <stdio.h>
int value = 100; /* file scope */
void shadow_demo(int value) /* parameter shadows the global */
{
printf("parameter shadows global: %d\n", value); /* the parameter */
{
int value = 20; /* shadows the parameter */
printf("block shadows parameter: %d\n", value);
value = 30; /* modifies the innermost object ONLY */
}
printf("parameter unchanged: %d\n", value); /* still the argument */
}
/* A realistic bug: the inner 'total' looks like an accumulator but is not. */
int sum_broken(const int *a, size_t n)
{
int total = 0;
for (size_t i = 0; i < n; i++) {
int total = a[i]; /* SHADOWS -- reported by -Wshadow */
(void)total; /* the outer total never changes */
}
return total; /* always 0 */
}
int sum_fixed(const int *a, size_t n)
{
int total = 0;
for (size_t i = 0; i < n; i++)
total += a[i]; /* one 'total', the outer one */
return total;
}
int main(void)
{
shadow_demo(7);
printf("global still: %d\n", value); /* 100 -- never touched */
int data[] = { 1, 2, 3, 4 };
printf("broken=%d fixed=%d\n",
sum_broken(data, 4), sum_fixed(data, 4)); /* 0 and 10 */
return 0;
}
Name lookup for 'value' proceeds outward until a match is found:
innermost block --> int value = 20; <-- found, stop
|
function body --> int value (parameter)
|
file scope --> int value = 100;
|
(not found) --> compile error
Key Takeaways
- An inner declaration hides an outer one of the same name; lookup always resolves to the innermost declaration in scope.
- Shadowing is legal and useful — it decouples inner blocks from the names used around them.
- Accidental shadowing silently changes meaning, especially when refactoring or when the shadowed name is a global or a library function.
- Compile with
-Wshadowand fix by renaming rather than suppressing. - Redeclaring a name in the same scope is an error; shadowing requires a nested scope.
🧪 Practice
- Reproduce
sum_broken, compile with-Wall -Wextra(note it may not warn) and then with-Wshadow, and compare.- Declare a local variable named
indexortime, then call the standard function of that name in the same scope, and read the resulting error.- Write a function where a parameter is shadowed inside an
ifblock; print addresses to prove there are two distinct objects.- Interview-style: "Is shadowing a language flaw or a feature?" Hint: imagine adding one new global to a million-line codebase without it.
<a id="43-multi-file-programs"></a>
4.3 Multi-File Programs
Beyond a few hundred lines, a C program lives in many files. This section covers the machinery that makes that work: headers and the guards that keep them safe, the interface/implementation split, the linkage rules that decide which names cross file boundaries, how to untangle circular dependencies, and how a build system turns a directory of sources into a binary.
Header Files and Include Guards
Theory
The preprocessor's #include is textual: it replaces the directive with the entire contents of the named file, before the compiler sees anything (Chapter 1.3, Chapter 8.1). Nothing more sophisticated happens — no module system, no import resolution, no deduplication.
That simplicity creates one immediate hazard. If a.h includes common.h and b.h includes common.h, then a file including both gets common.h twice. Duplicated struct definitions, typedefs, and enums are errors; the translation unit will not compile.
The fix is the include guard: a preprocessor conditional that makes the second and later inclusions expand to nothing.
#ifndef PROJECT_MODULE_H /* first time: not defined, so we proceed */
#define PROJECT_MODULE_H /* now it is defined */
... contents ...
#endif
The macro name must be unique across the whole program — including every library you link against. Use a project prefix and mirror the path: MYLIB_NET_SOCKET_H for mylib/net/socket.h. A bare UTIL_H collides sooner than you expect, and the symptom is bizarre: a header silently vanishes.
#pragma once does the same job in one line and is supported by GCC, Clang, and MSVC, but it is not in the C standard and can misbehave when the same file is reachable by two paths (symlinks, hard links, bind mounts) — implementations compare files, not names, but the heuristics differ. Guards are the portable choice; many projects use both.
What belongs in a header, and what does not:
Put in the header (.h) | Keep in the source (.c) |
|---|---|
| Function prototypes | Function definitions |
extern variable declarations | Variable definitions |
struct/union/enum definitions the caller needs | Types private to the module |
typedefs | Static helper functions |
| Macros that are part of the interface | Implementation-only macros |
static inline small functions | Everything else |
Two further rules keep headers healthy. Headers should be self-contained: including a header first in an otherwise empty file must compile, meaning it includes what it needs. And include what you use: do not rely on a header transitively pulling in <stddef.h> for you.
Finally, note the two include forms. #include "local.h" searches the current directory first, then the system paths; #include <system.h> searches only the configured include paths (-I adds to them). Use quotes for your project's headers and angle brackets for the standard library and installed dependencies.
Examples
/* ---------- stack.h : a complete, well-formed header ---------- */
#ifndef MYPROJ_STACK_H /* unique, path-mirroring guard name */
#define MYPROJ_STACK_H
#include <stddef.h> /* self-contained: we use size_t below */
typedef struct { /* the caller needs the layout to declare one */
int *data;
size_t len;
size_t cap;
} Stack;
int stack_init(Stack *s, size_t capacity); /* prototypes only */
void stack_free(Stack *s);
int stack_push(Stack *s, int value);
int stack_pop(Stack *s, int *out);
/* Small enough to inline; 'static inline' avoids all linker subtleties. */
static inline size_t stack_len(const Stack *s) { return s->len; }
#endif /* MYPROJ_STACK_H */
/* ---------- Why the guard matters ---------- */
/* a.h */
#ifndef A_H
#define A_H
#include "stack.h"
#endif
/* b.h */
#ifndef B_H
#define B_H
#include "stack.h" /* second inclusion of stack.h ... */
#endif
/* main.c */
#include "a.h"
#include "b.h" /* ... but the guard makes it expand to nothing */
int main(void) { Stack s; (void)s; return 0; }
/* Without the guard in stack.h: "error: redefinition of 'Stack'" */
Preprocessing main.c
#include "a.h" --> #include "stack.h"
MYPROJ_STACK_H not defined -> define it, emit contents
#include "b.h" --> #include "stack.h"
MYPROJ_STACK_H IS defined -> emit nothing
^
the guard turned an error into a no-op
Key Takeaways
#includeis plain text substitution; without protection, a header included twice defines its types twice.- Wrap every header in
#ifndef/#define/#endifwith a project-unique, path-mirroring macro name. #pragma onceis shorter and widely supported but non-standard; guards are the portable baseline.- Headers hold declarations, type definitions, and
static inlinehelpers — never definitions of functions or variables. - Make every header self-contained and include what you use rather than relying on transitive includes.
🧪 Practice
- Create a header defining a
structwithout a guard, include it twice from one.c, and read the error. Add the guard and rebuild.- Give two different headers the same guard macro (
UTIL_H) and include both. Explain why the second header's contents disappear.- Write a header that uses
size_twithout including<stddef.h>; compile it first in an empty.cand observe. Then make it self-contained.- Interview-style: "What are the trade-offs between
#pragma onceand include guards?" Hint: think about symlinks, build-system copies, and what the standard actually specifies.
Separating Interface from Implementation
Theory
A header is a contract. It states what a module offers; the .c file states how. Callers compile against the contract and never see the implementation, so the implementation can change freely as long as the contract holds. This is C's version of encapsulation, enforced by convention and by linkage rather than by an private keyword.
Getting the boundary right buys three things: faster builds (changing a .c file recompiles one object; changing a header recompiles every file that includes it), safer refactoring (nothing outside can depend on what it cannot see), and readable modules (the header is the documentation).
The strongest tool for hiding implementation is the opaque pointer (also called a handle, or the "pimpl" idiom in C++). The header declares a struct tag without defining it:
typedef struct Database Database; /* incomplete type: size unknown */
Database *db_open(const char *path);
Callers can hold a Database * but cannot dereference it, cannot declare a Database by value, and cannot depend on its fields — because they do not know them. The full struct Database { ... }; lives in database.c. The cost is that every instance must be heap-allocated (the caller cannot know the size), and every access goes through a function call. In exchange, you can add, remove, or reorder fields without recompiling — or even relinking — anything else.
When the type must be transparent (a small Point, a Vec3 you want on the stack and inlined), define it in the header and accept the coupling. The choice is a real trade-off:
| Approach | Caller can | Changing the struct requires | Use when |
|---|---|---|---|
| Full struct in header | Stack-allocate, access fields, inline | Recompiling all users | Small, stable value types |
| Opaque pointer | Only pass the pointer to your functions | Recompiling only the module | Resource handles, evolving types |
Conventions that keep interfaces coherent: prefix every exported name with the module name (stack_push, not push), so the global namespace stays navigable; mark everything not in the header static; document ownership in the header (who frees what); and keep the header free of implementation-only includes so it does not leak dependencies to every caller.
Examples
/* ---------- counter.h : the entire public contract ---------- */
#ifndef MYPROJ_COUNTER_H
#define MYPROJ_COUNTER_H
/* Opaque type: callers get a handle, not a layout. */
typedef struct Counter Counter;
Counter *counter_create(const char *name); /* returns NULL on failure;
caller owns the result */
void counter_destroy(Counter *c); /* safe on NULL */
void counter_increment(Counter *c);
long counter_value(const Counter *c);
#endif
/* ---------- counter.c : everything private lives here ---------- */
#include <stdlib.h>
#include <string.h>
#include "counter.h" /* include our own header: the compiler now checks
that our definitions match our declarations */
struct Counter { /* the real layout -- invisible to callers */
char *name;
long value;
long overflow_count; /* added later; NO caller needs recompiling */
};
static char *dup_string(const char *s) /* static: private helper, no symbol */
{
size_t n = strlen(s) + 1;
char *p = malloc(n);
return p ? memcpy(p, s, n) : NULL;
}
Counter *counter_create(const char *name)
{
Counter *c = malloc(sizeof *c);
if (!c) return NULL;
c->name = dup_string(name);
if (!c->name) { free(c); return NULL; }
c->value = c->overflow_count = 0;
return c;
}
void counter_destroy(Counter *c)
{
if (!c) return; /* free(NULL)-style tolerance */
free(c->name);
free(c);
}
void counter_increment(Counter *c) { c->value++; }
long counter_value(const Counter *c) { return c->value; }
/* ---------- main.c : sees the contract only ---------- */
#include <stdio.h>
#include "counter.h"
int main(void)
{
Counter *c = counter_create("hits");
if (!c) return 1;
/* Counter local; <-- ERROR: incomplete type, size unknown */
/* c->value = 99; <-- ERROR: dereferencing an incomplete type */
counter_increment(c);
counter_increment(c);
printf("value = %ld\n", counter_value(c));
counter_destroy(c);
return 0;
}
main.c knows: counter.c knows:
+---------------------+ +--------------------------+
| typedef struct | | struct Counter { |
| Counter Counter; | | char *name; |
| counter_create() | | long value; |
| counter_increment() | | long overflow_count; |
| counter_value() | | }; |
| counter_destroy() | | static char *dup_string()|
+---------------------+ +--------------------------+
the contract the implementation
(stable) (free to change)
Key Takeaways
- The header is a contract; the
.cfile is an implementation detail that callers must not be able to depend on. - Opaque pointers (
typedef struct T T;with the definition in the.c) give real encapsulation at the cost of heap allocation and function-call access. - Expose full struct definitions only for small, stable value types you want stack-allocated and inlined.
- Always include a module's own header in its
.cso declaration/definition mismatches fail at compile time. - Prefix exported names by module, mark everything else
static, and document ownership in the header.
🧪 Practice
- Build the
Countermodule above, then add a field tostruct Counterand confirm that onlycounter.cneeds recompiling.- Try to declare
Counter c;on the stack inmain.cand explain the "incomplete type" error precisely.- Convert a transparent struct module you have written into an opaque-pointer module; list what capabilities the caller lost.
- Interview-style: "How do you achieve information hiding in C, which has no
private?" Hint: two mechanisms — one for types, one for names — and both are compile/link-time, not run-time.
Internal vs External Linkage
Theory
Linkage determines whether declarations of the same name in different scopes or different translation units refer to the same entity. It is the third property of every identifier, alongside scope and storage duration, and it is the one the linker cares about.
C defines three kinds:
| Linkage | Meaning | How to get it |
|---|---|---|
| External | One entity shared across the whole program | File-scope functions and variables, by default |
| Internal | One entity per translation unit, private to it | static at file scope |
| None | The entity is unique to its declaration | Block-scope variables, parameters, type names |
The practical model: after compiling, each .o file carries a symbol table listing names it defines and names it needs. External-linkage names appear there and are matched up by the linker. Internal-linkage names do not — they are local details, invisible outside the object file.
This gives you C's access control:
- Public: declared in the header, defined non-
staticin the.c. - Private: declared and defined
staticin the.c, never in the header.
Making helpers static is not merely tidiness. It prevents name collisions across the program (two modules can each have a static int helper(void)), it lets the compiler inline and optimize more aggressively because it knows every call site, it lets the linker discard unused code, and it shrinks the exported symbol table.
Two rules govern definitions:
One Definition Rule (linker view): each external-linkage entity must be defined exactly once in the whole program. Zero definitions gives "undefined reference"; two gives "multiple definition".
Consistency: all declarations of the same external name must have compatible types. The compiler cannot check across files — it only sees one at a time — so a mismatch produces silent, undefined behavior. This is exactly why the shared header exists: it forces every file, including the defining one, to see the same declaration. Link-time optimization (-flto) can detect some of these mismatches.
A confusing corner: static on a block-scope variable does not affect linkage at all (block-scope variables have no linkage) — it changes storage duration, as covered in Chapter 4.2. Same keyword, unrelated meaning.
Examples
/* ---------- module.c ---------- */
#include <stdio.h>
#include "module.h"
int shared_total = 0; /* EXTERNAL linkage: other files can see it */
static int call_count = 0; /* INTERNAL: private to module.c */
static void log_call(const char *who) /* INTERNAL: no symbol exported */
{
call_count++;
printf("[%d] %s\n", call_count, who);
}
void module_add(int n) /* EXTERNAL: part of the public interface */
{
log_call("module_add");
shared_total += n;
}
/* ---------- other.c : a second module with its OWN log_call ---------- */
#include <stdio.h>
static void log_call(const char *who) /* No conflict: internal linkage means
this is a different function entirely */
{
printf("other: %s\n", who);
}
void other_work(void) { log_call("other_work"); }
# Inspect the symbol tables to see linkage made concrete.
gcc -c module.c other.c
nm module.o
# 0000000000000000 T module_add <- 'T': external, defined here
# U printf <- 'U': external, needed from elsewhere
# 0000000000000000 B shared_total <- 'B': external, uninitialized data
# 0000000000000004 b call_count <- lowercase 'b': INTERNAL (static)
# 0000000000000000 t log_call <- lowercase 't': INTERNAL (static)
#
# Lowercase = local to this object file. Uppercase = visible to the linker.
module.o other.o
+--------------------+ +--------------------+
| T module_add | | T other_work |
| B shared_total | | t log_call (own) |
| t log_call (own) | +--------------------+
| U printf |
+--------------------+
\ /
+------ linker -------+
|
matches U (needed) against T/B (defined).
The two 't log_call' entries never meet: internal linkage.
Key Takeaways
- Linkage decides whether two declarations of a name refer to the same entity; external crosses files, internal does not, block-scope has none.
- File-scope functions and variables are external by default;
staticat file scope makes them internal. - Mark every helper not in the header
static: it prevents collisions, enables optimization, and keeps the symbol table small. - Each external entity needs exactly one definition program-wide — zero gives "undefined reference", two gives "multiple definition".
- Cross-file type mismatches are undefined behavior the compiler cannot catch; a shared header included everywhere is the defense.
🧪 Practice
- Define a non-
staticfunction with the same name in two.cfiles and link them; read the error. Addstaticto both and confirm it links.- Run
nmon an object file from your own project and classify five symbols as external or internal by their letter case.- Declare
extern int x;in one file and definelong x = 5;in another, then printxfrom the first. Explain the garbage, and see whether-fltocatches it.- Interview-style: "What does
staticmean in C?" Hint: the correct answer is a question back — at file scope or at block scope? — and two different explanations.
Circular Dependencies
Theory
A circular dependency exists when module A needs something from B and B needs something from A. The textual #include model handles this badly: with guards in place, the second inclusion is empty, so one of the two files sees an incomplete picture and the compile fails with errors like "unknown type name" that point at a perfectly reasonable line.
Trace it: a.h includes b.h, which includes a.h. The guard for a.h is already defined, so the inner include yields nothing, and b.h continues without a.h's types. Whether it compiles then depends on which file was included first — a genuinely order-dependent build.
Three techniques resolve it, in order of preference.
1. Forward-declare instead of including. If a header only needs a pointer to a type, it does not need the type's definition — an incomplete type suffices:
struct Employee; /* forward declaration: no #include needed */
struct Department { struct Employee **staff; size_t n; };
Pointers to incomplete types are legal because their size is known. The full definition is only required where the pointer is dereferenced or the struct declared by value — which is in the .c, not the header. This eliminates most cycles and speeds up builds by removing header edges.
2. Extract the shared part into a third header. If A and B both need common types, move them to common.h and have both include it. The cycle A -> B -> A becomes A -> C, B -> C: a tree.
3. Reconsider the design. A genuine cycle often means the two modules are really one, or that the dependency should be inverted with a callback. If logger needs config and config needs logger, pass a log function pointer into config instead of having it reach back.
Note that circular dependencies between .c files are perfectly fine — a.c may call functions in b.c and vice versa, because the linker resolves all symbols simultaneously. The problem is specifically about headers, which are processed sequentially.
Examples
/* ---------- BROKEN: a real cycle ---------- */
/* employee.h */
#ifndef EMPLOYEE_H
#define EMPLOYEE_H
#include "department.h" /* needs Department */
struct Employee { char name[64]; struct Department *dept; };
#endif
/* department.h */
#ifndef DEPARTMENT_H
#define DEPARTMENT_H
#include "employee.h" /* needs Employee -> CYCLE */
struct Department { char name[64]; struct Employee *head; };
#endif
/* Compiling a file that includes employee.h first:
employee.h -> department.h -> employee.h (guard already set: EMPTY)
-> department.h continues, 'struct Employee' unknown -> error */
/* ---------- FIXED: forward declarations break the cycle ---------- */
/* employee.h */
#ifndef EMPLOYEE_H
#define EMPLOYEE_H
struct Department; /* incomplete type; a pointer to it is fine */
struct Employee {
char name[64];
struct Department *dept; /* pointer: size known without the definition */
};
void employee_print(const struct Employee *e);
#endif
/* department.h */
#ifndef DEPARTMENT_H
#define DEPARTMENT_H
struct Employee; /* the mirror image; still no #include */
struct Department {
char name[64];
struct Employee *head;
};
void department_print(const struct Department *d);
#endif
/* department.c : the ONLY place that needs both full definitions */
#include <stdio.h>
#include "department.h"
#include "employee.h" /* now the complete type is available */
void department_print(const struct Department *d)
{
printf("%s, head: %s\n", d->name, d->head ? d->head->name : "(vacant)");
/* d->head->name requires the COMPLETE struct Employee -- hence the include */
}
Before After
a.h <---> b.h a.h b.h
(cycle: neither | |
can be first) +---> a.c/b.c include both
(headers depend on nothing)
Rule of thumb:
need only 'struct T *' -> forward declare
need sizeof, members,
or a by-value T -> #include
Key Takeaways
- Header cycles break because include guards make the second inclusion empty, leaving one file with missing types.
- Forward-declare (
struct T;) whenever a header needs only a pointer — this removes most cycles and speeds up builds. - A complete definition is needed only for by-value use, member access, or
sizeof, and that belongs in the.c. - If both headers need shared types, factor them into a third header both include.
- Cycles between
.cfiles are fine; the linker resolves all symbols at once. Only header cycles are a problem.
🧪 Practice
- Build the broken employee/department pair, read the error, then fix it with forward declarations.
- Take a header in your project and replace every
#includeused only for pointer types with a forward declaration; measure the change in build time.- Construct a case where forward declaration is not enough (a struct containing another struct by value) and explain why.
- Interview-style: "Two modules genuinely need each other. What do you do?" Hint: forward declarations first; if that fails, ask whether the dependency can be inverted with a callback or whether it is really one module.
Build Systems and Makefiles
Theory
Once a project has more than a handful of files, rebuilding everything on every change wastes minutes and typing gcc by hand invites mistakes. A build system solves two problems: correctness (rebuild exactly what the change affected — no more, no less) and automation (one command produces the artifact).
make is the classic answer, and understanding it makes every other build tool legible. Its model is a dependency graph of rules:
target: prerequisites
recipe # MUST be indented with a TAB, not spaces
make compares timestamps: if any prerequisite is newer than the target, it runs the recipe. Applied recursively, this rebuilds only what is stale. foo.o depends on foo.c; the program depends on all the .o files. Change one .c and exactly one .o is rebuilt, then the link runs.
There is a trap here that bites every C project. foo.o also depends on every header foo.c includes — but make cannot know that unless you tell it. Edit a header, and make sees no change to foo.c and skips the rebuild, leaving object files compiled against different versions of a struct. The result is memory corruption at run time, and the classic fix is make clean (which admits defeat).
The real fix is automatic dependency generation. GCC and Clang's -MMD -MP flags emit a .d file per object listing its header dependencies in make syntax; -include $(DEPS) pulls them in. This is the single most important thing to get right in a hand-written Makefile.
Useful mechanics, compactly:
| Feature | Meaning |
|---|---|
$@ | The target |
$< | The first prerequisite |
$^ | All prerequisites, deduplicated |
%.o: %.c | Pattern rule: how to make any .o from its .c |
:= vs = | Immediate vs lazily-expanded assignment (prefer :=) |
.PHONY | Targets that are commands, not files (clean, all) |
make -j8 | Run 8 recipes in parallel |
CC, CFLAGS, LDFLAGS | Conventional variables users expect to override |
Beyond make: CMake generates build files for many platforms and toolchains and is the de-facto standard for portable C/C++ projects; Ninja is a fast low-level executor that CMake usually targets; Meson is a modern, more opinionated front end. All of them solve the same dependency-graph problem — make just makes the graph visible.
Examples
# ---------- A small but correct Makefile ----------
CC := gcc
CFLAGS := -std=c17 -Wall -Wextra -Wpedantic -Wshadow -g -MMD -MP
LDFLAGS :=
LDLIBS := -lm
TARGET := app
SRCS := $(wildcard src/*.c) # every .c under src/
OBJS := $(SRCS:.c=.o) # same names with .o
DEPS := $(OBJS:.o=.d) # the generated dependency files
.PHONY: all clean run
all: $(TARGET)
# Link: $@ is 'app', $^ is every object file.
$(TARGET): $(OBJS)
$(CC) $(LDFLAGS) -o $@ $^ $(LDLIBS)
# Pattern rule -- compile any .c into the matching .o.
# $< is the .c file; -MMD -MP (in CFLAGS) also writes src/foo.d
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
# Pull in the generated header dependencies. The leading '-' means
# "do not fail on the first build, when no .d files exist yet".
-include $(DEPS)
clean:
rm -f $(TARGET) $(OBJS) $(DEPS)
run: $(TARGET)
./$(TARGET)
What -MMD generates (src/main.d), in make's own syntax:
src/main.o: src/main.c src/stack.h src/config.h
src/stack.h: <- the -MP part: empty rules so a DELETED header
src/config.h: does not break the build with "no rule to make"
Result: touching src/stack.h now correctly rebuilds src/main.o.
# Typical usage
make # build only what changed
make -j$(nproc) # parallel build
make clean all # from scratch
make CC=clang CFLAGS="-O2 -DNDEBUG" # override conventional variables
The equivalent in CMake, for comparison:
cmake_minimum_required(VERSION 3.16)
project(app C)
set(CMAKE_C_STANDARD 17)
add_executable(app src/main.c src/stack.c) # dependencies tracked automatically
target_compile_options(app PRIVATE -Wall -Wextra)
target_link_libraries(app PRIVATE m)
Key Takeaways
- A build system exists to rebuild exactly what a change affected and to make the build one reproducible command.
makerules aretarget: prerequisitesplus a TAB-indented recipe, and rebuilds are driven by file timestamps.- Object files depend on headers too; without generated dependencies, editing a header silently produces a stale, corrupt build.
- Always compile with
-MMD -MPand-includethe resulting.dfiles — this is the fix, notmake clean. - Use
$@,$<,$^, pattern rules, and.PHONY; respectCC/CFLAGS/LDFLAGSso users can override them. - For portable or larger projects, CMake (usually generating Ninja) is the practical standard.
🧪 Practice
- Write the Makefile above for a three-file project. Touch one
.cand confirm only one.orebuilds.- Remove
-MMD -MPand the-includeline, edit a header that changes a struct's size, rebuild, and observe the stale-object bug. Restore them and confirm it is fixed.- Add a
debugtarget using-fsanitize=address,undefined -gand areleasetarget using-O2 -DNDEBUG, and compare binary sizes.- Interview-style: "Why does
makeneed a dependency graph at all, instead of just recompiling everything?" Hint: think about a project with 10,000 files and a one-line change, and then about what makes the graph hard to get right.
<a id="44-variadic-functions"></a>
4.4 Variadic Functions
printf accepts any number of arguments of any type — something no ordinary C prototype can express. This section covers the mechanism that makes it possible, how to write your own variadic functions correctly, and the type-safety hole the mechanism leaves open.
Ellipsis Parameters
Theory
Every function so far has had a fixed parameter list. A variadic function does not: it takes some fixed parameters followed by an unknown number of additional arguments, written as ... (the ellipsis).
int printf(const char *format, ...);
The rules are narrow and non-negotiable:
At least one named parameter is required before the ellipsis (in C17 and earlier; C23 relaxes this, but portable code should not rely on it). This is not arbitrary: the standard mechanism for walking the extra arguments starts from the last named one, so there must be one.
The ellipsis comes last, and there is exactly one.
The callee cannot discover how many arguments were passed, or their types. The language provides no count, no type tags, nothing. Every variadic function must derive this from the fixed parameters. There are only two workable conventions:
- A count parameter:
int sum(int n, ...)— the caller says how many follow. - A sentinel:
void print_all(const char *first, ...)terminated byNULL, asexecldoes. - A format string:
printf's approach — the format describes both count and types.
Arguments after the ellipsis undergo default argument promotions, because there is no prototype to convert them against:
| Passed type | Actually received as |
|---|---|
char, signed char, unsigned char, short, _Bool | int |
float | double |
everything else (int, long, double, pointers, structs) | unchanged |
This is why va_arg(ap, char) is always wrong — you must ask for int. And it is why %f in printf works for both float and double arguments: by the time printf sees it, a float has already become a double.
One more consequence: because there is no prototype for the variable part, no conversion happens at the call site. printf("%ld\n", 5) passes an int where printf will read a long — undefined behavior, not a silent conversion. Getting the types exactly right at the call site is the caller's job.
Examples
#include <stdio.h>
#include <stdarg.h>
/* Convention 1: an explicit count in a fixed parameter. */
int sum(int count, ...) /* 'count' is named; '...' is the rest */
{
va_list ap;
va_start(ap, count); /* start after the LAST NAMED parameter */
int total = 0;
for (int i = 0; i < count; i++)
total += va_arg(ap, int); /* 'int', because of default promotions */
va_end(ap);
return total;
}
/* Convention 2: a NULL sentinel terminates the list. */
void print_words(const char *first, ...)
{
va_list ap;
va_start(ap, first);
for (const char *s = first; s != NULL; s = va_arg(ap, const char *))
printf("%s ", s);
va_end(ap);
putchar('\n');
}
/* Demonstrating default argument promotions. */
void show_promotions(int n, ...)
{
va_list ap;
va_start(ap, n);
/* A char argument arrives as int; a float arrives as double. */
int c = va_arg(ap, int); /* NOT va_arg(ap, char) -- that is UB */
double f = va_arg(ap, double); /* NOT float */
va_end(ap);
printf("char-as-int = %d ('%c'), float-as-double = %.2f\n", c, c, f);
}
int main(void)
{
printf("sum = %d\n", sum(4, 10, 20, 30, 40));
print_words("variadic", "functions", "in", "C", NULL); /* sentinel required */
show_promotions(2, 'A', 3.5f); /* 'A' promotes to int, 3.5f to double */
return 0;
}
Call: sum(4, 10, 20, 30, 40)
+-------+ +----+----+----+----+
| count | | 10 | 20 | 30 | 40 | <- no type or count information
+-------+ +----+----+----+----+ travels with these
named the "..." part
|
+--> the ONLY thing telling the callee there are four ints.
Lie about it and you read past the arguments -> UB.
Key Takeaways
...declares a variadic function; it must come last and (before C23) must follow at least one named parameter.- The callee gets no count and no type information — it must be conveyed by a count parameter, a sentinel, or a format string.
- Arguments in the variable part undergo default promotions:
char/short/_Boolbecomeint,floatbecomesdouble. - No conversion happens at the call site for variadic arguments, so the caller must pass exactly the type the callee will read.
- Never retrieve a promoted type with its original name (
va_arg(ap, char)andva_arg(ap, float)are always wrong).
🧪 Practice
- Write
double average(int count, ...)and call it with three and then fivedoublearguments. Then pass integer literals and explain the wrong result.- Write a sentinel-terminated
int max_of(int first, ...)ending at a negative value, and document the convention in a comment.- Call
print_wordswithout the terminatingNULLand observe the crash or garbage. Explain what the loop read.- Interview-style: "Why must a variadic function have at least one named parameter?" Hint: consider what
va_startneeds as its second argument, and how the traditional implementation located the next argument.
va_list, va_start, va_arg, va_end
Theory
<stdarg.h> provides the portable interface for walking the anonymous arguments. It is four pieces (five in C99), and the discipline is rigid.
va_list is an opaque cursor object holding the state of the walk. On some ABIs it is a pointer; on x86-64 System V it is a small struct tracking register-save areas and an overflow pointer. Never assume a representation, never copy it with =, never inspect it.
va_start(ap, last_named) initializes the cursor. The second argument must be the name of the last named parameter — this is exactly why one is required. (C23 allows va_start(ap) with the parameter omitted.)
va_arg(ap, type) returns the next argument, interpreted as type, and advances the cursor. This is the dangerous operation: it does not verify anything. If the actual argument was a double and you ask for int, you get garbage and the cursor advances by the wrong amount, corrupting every subsequent read. Remember the promotions: ask for int, not char; double, not float.
va_end(ap) performs any cleanup the implementation needs. It is a no-op on many platforms and mandatory on all of them — omitting it is undefined behavior, and it must be called on every path out of the function, including early returns and error paths.
va_copy(dst, src) (C99) duplicates a cursor mid-walk. You need it whenever the list must be traversed twice — the classic case being a formatting function that first measures the required buffer size and then formats into it, because the first pass consumes the list. The copy must itself be released with va_end.
Two more rules that catch people:
- A
va_listcannot be re-walked. Afterva_end, or after reaching the end, you mustva_startagain (or have made ava_copyfirst). - You cannot pass a
va_listto another variadic function, only to a function that takes ava_list— which is why the standard library pairs everyprintfwith avprintf. This is the whole reason thev-prefixed family exists.
Every walk follows exactly this shape:
va_list ap;
va_start(ap, last_named); <- initialize
va_arg(ap, T); ... <- consume, in order, with the right types
va_end(ap); <- release, on EVERY exit path
Examples
#include <stdio.h>
#include <stdarg.h>
#include <stdlib.h>
#include <string.h>
/* Basic walk with a count. */
int sum(int count, ...)
{
va_list ap;
va_start(ap, count);
int total = 0;
for (int i = 0; i < count; i++)
total += va_arg(ap, int);
va_end(ap); /* mandatory */
return total;
}
/* Early exit: va_end must still run on every path. */
int sum_capped(int count, int cap, ...)
{
va_list ap;
va_start(ap, cap);
int total = 0;
for (int i = 0; i < count; i++) {
total += va_arg(ap, int);
if (total > cap) {
va_end(ap); /* do NOT return without this */
return cap;
}
}
va_end(ap);
return total;
}
/* va_copy: the list must be walked twice -- once to measure, once to build. */
char *join(const char *sep, int count, ...)
{
va_list ap, ap2;
va_start(ap, count);
va_copy(ap2, ap); /* snapshot BEFORE consuming ap */
size_t need = 1; /* the terminating '\0' */
for (int i = 0; i < count; i++) {
need += strlen(va_arg(ap, const char *));
if (i + 1 < count) need += strlen(sep);
}
va_end(ap); /* first walk is finished */
char *out = malloc(need);
if (!out) { va_end(ap2); return NULL; } /* release the copy too */
out[0] = '\0';
for (int i = 0; i < count; i++) { /* second walk uses the copy */
strcat(out, va_arg(ap2, const char *));
if (i + 1 < count) strcat(out, sep);
}
va_end(ap2);
return out;
}
int main(void)
{
printf("sum = %d\n", sum(3, 1, 2, 3));
printf("sum_capped = %d\n", sum_capped(5, 10, 4, 4, 4, 4, 4));
char *s = join(", ", 3, "alpha", "beta", "gamma");
if (s) { printf("join = %s\n", s); free(s); }
return 0;
}
x86-64 System V: what va_list is actually tracking
+----------------------+
| gp_offset | next integer register slot
| fp_offset | next floating-point register slot
| overflow_arg_area --+--> arguments that did not fit in registers (stack)
| reg_save_area --+--> where the prologue spilled the registers
+----------------------+
This is why va_list is opaque and why 'va_list b = a;' is not a copy:
on some ABIs it is an array type, and on all of them the state is shared.
Use va_copy.
Key Takeaways
- The sequence is fixed:
va_start, zero or moreva_arg, thenva_end— on every exit path, including errors. va_argperforms no checking; the wrong type both returns garbage and misaligns every later read.- Ask for promoted types:
intforchar/short/_Bool,doubleforfloat. - A
va_listis consumed by walking it; useva_copy(paired with its ownva_end) when you need a second pass. - You cannot forward a
va_listto a variadic function — that is whyvprintf,vsnprintf, and friends exist.
🧪 Practice
- Write
int count_positive(int n, ...)that walksnintegers and counts the positive ones. Add an earlyreturnand make sureva_endruns on that path.- Deliberately call
va_arg(ap, double)on an argument that was anint, print the result, and then print the next argument to see the cursor damage.- Implement
joinabove withoutva_copyby callingva_starta second time; explain when each approach is valid.- Interview-style: "Why does the standard library provide both
printfandvprintf?" Hint: try to write a logging wrapper that adds a prefix and then forwards its arguments toprintf.
Writing printf-Style Wrappers
Theory
The most common real use of variadics is not inventing a new calling convention — it is wrapping an existing one. You want log_error("disk %s full at %d%%", name, pct) to add a timestamp, a severity tag, and a newline, then hand everything to fprintf.
The obstacle is the rule from the previous topic: a va_list cannot be passed to a variadic function. fprintf(stderr, fmt, ap) does not forward the arguments; it passes the va_list object itself as the first conversion's argument, producing garbage.
The solution is the v-family. For nearly every variadic function in the standard library there is a counterpart taking a va_list instead of ...:
| Variadic | va_list counterpart | Purpose |
|---|---|---|
printf | vprintf | Format to stdout |
fprintf | vfprintf | Format to a stream |
sprintf | vsprintf | Format to a buffer (unbounded — avoid) |
snprintf | vsnprintf | Format to a bounded buffer (use this) |
So the wrapper pattern is always the same shape: take ..., start a va_list, call the v-version, end the list.
Two refinements make the wrapper production-quality.
Compiler-checked format strings. GCC and Clang support __attribute__((format(printf, m, n))), where m is the 1-based index of the format string parameter and n is the index of the first variadic argument. With it, the compiler applies full -Wformat checking to your function — catching %d with a char * at compile time. This single annotation recovers most of the type safety variadics give up, and it is the reason your wrapper should exist rather than a macro. Wrap it in a macro so it degrades to nothing on compilers that lack it.
Correct sizing with vsnprintf. vsnprintf returns the number of characters it would have written, excluding the terminator, regardless of the buffer size. That makes the two-pass idiom possible: call it with a size of 0 to measure, malloc that many bytes plus one, then call it again to format. The second call needs a va_copy, because the first consumed the list.
Never use sprintf or vsprintf in a wrapper. They have no bound and are a buffer overflow waiting for a long argument.
Examples
#include <stdio.h>
#include <stdarg.h>
#include <stdlib.h>
#include <time.h>
/* Portable attribute wrapper: real checking on GCC/Clang, nothing elsewhere. */
#if defined(__GNUC__) || defined(__clang__)
# define PRINTF_LIKE(fmt_idx, first_arg) \
__attribute__((format(printf, fmt_idx, first_arg)))
#else
# define PRINTF_LIKE(fmt_idx, first_arg)
#endif
/* 1: 'fmt' is parameter 2, the variadic part starts at parameter 3. */
void log_msg(const char *level, const char *fmt, ...) PRINTF_LIKE(2, 3);
void log_msg(const char *level, const char *fmt, ...)
{
time_t now = time(NULL);
char stamp[32];
strftime(stamp, sizeof stamp, "%H:%M:%S", localtime(&now));
fprintf(stderr, "[%s] %s: ", stamp, level);
va_list ap;
va_start(ap, fmt);
vfprintf(stderr, fmt, ap); /* the v-version accepts a va_list */
va_end(ap);
fputc('\n', stderr);
}
/* 2: allocate exactly the right buffer, using vsnprintf's return value. */
char *format_alloc(const char *fmt, ...) PRINTF_LIKE(1, 2);
char *format_alloc(const char *fmt, ...)
{
va_list ap, ap2;
va_start(ap, fmt);
va_copy(ap2, ap); /* the measuring pass consumes ap */
int n = vsnprintf(NULL, 0, fmt, ap); /* size 0: writes nothing, returns
the length it WOULD have needed */
va_end(ap);
if (n < 0) { va_end(ap2); return NULL; }
char *buf = malloc((size_t)n + 1); /* +1 for the terminator */
if (!buf) { va_end(ap2); return NULL; }
vsnprintf(buf, (size_t)n + 1, fmt, ap2); /* the real pass, bounded */
va_end(ap2);
return buf;
}
int main(void)
{
log_msg("INFO", "starting, pid placeholder %d", 1234);
log_msg("ERROR", "disk %s is %d%% full", "/dev/sda1", 97);
/* log_msg("ERROR", "%d", "not an int"); <- caught at COMPILE time
thanks to PRINTF_LIKE */
char *s = format_alloc("%s scored %.1f in %d rounds", "player", 9.25, 3);
if (s) { puts(s); free(s); }
return 0;
}
Why forwarding needs the v-family
log_msg("INFO", "x=%d", 42)
|
va_start(ap, fmt) ap now points at { 42 }
|
+--> fprintf(stderr, fmt, ap) WRONG: passes the va_list OBJECT,
| %d reads its first machine word
|
+--> vfprintf(stderr, fmt, ap) RIGHT: vfprintf knows ap is a cursor
Key Takeaways
- A
va_listcannot be forwarded to a variadic function; call thev-prefixed counterpart (vfprintf,vsnprintf) instead. - The wrapper shape is always
va_start->v...call ->va_end. - Annotate wrappers with
__attribute__((format(printf, m, n)))to get full-Wformatchecking on your own function. vsnprintfreturns the length it would have written, enabling measure-then-allocate; useva_copyfor the second pass.- Never use
sprintf/vsprintfin a wrapper — they are unbounded.
🧪 Practice
- Write
void debug_log(const char *fmt, ...)that prefixes"[debug] "and appends a newline, usingvfprintf.- Add the
formatattribute to it, then call it with a mismatched specifier and confirm the compiler catches it. Remove the attribute and see that the error becomes silent.- Implement
char *str_format(const char *fmt, ...)with the two-passvsnprintfidiom; test it with a string long enough to need a large buffer.- Interview-style: "Your logging wrapper crashes only when a caller passes a
%s. What is the most likely cause?" Hint: think about what the wrapper forwards, and what happens when ava_listis walked twice or passed to the wrong function.
Type-Safety Limitations
Theory
Variadic functions punch a hole in C's type system. For the fixed parameters, the prototype lets the compiler check and convert; for everything after the ..., there is nothing to check against. The callee's claims about the types are simply believed.
Four concrete failure modes follow, and all of them are undefined behavior rather than diagnosable errors:
1. Format/argument mismatch. printf("%d", 3.14) reads an integer from where a double was passed. On x86-64 the two even travel in different register files, so the value printed is unrelated garbage. Worse, printf("%s", 42) treats 42 as an address and dereferences it — a crash at best.
2. Wrong count. printf("%d %d\n", 1) reads a second argument that was never passed, taking whatever happens to be in the next register or stack slot. This is an information leak, not merely a wrong number: the classic format-string vulnerability uses %x repeatedly (and %n, which writes) to read and corrupt the stack. Never pass user-controlled data as a format string — use printf("%s", user_input), never printf(user_input).
3. Missing promotions. va_arg(ap, char) or va_arg(ap, float) requests a type that can never have been passed.
4. Size mismatches on integer types. printf("%d", some_long) or printf("%zu", some_int) misreads the argument size. Use the <inttypes.h> macros (PRId64, PRIu32) for fixed-width types, %zu for size_t, %td for ptrdiff_t, and cast to void * for %p.
The defenses, in order of strength:
| Defense | What it catches | Cost |
|---|---|---|
-Wformat -Wformat-security (in -Wall) | Mismatches in calls to known format functions | None; enable always |
__attribute__((format(...))) | The same, for your own wrappers | One annotation |
_Generic (C11) | Type dispatch with full checking | More code, no varargs at all |
| Fixed-arity or struct-array APIs | Everything | A different interface design |
| Compound literals with an array | Variable count, one checked type | Slightly unusual call syntax |
The design lesson is that variadics should be a last resort. If the arguments are all the same type, take an array (a compound literal makes the call site pleasant). If you are dispatching on type, _Generic gives compile-time selection with real checking (Chapter 10.3). Reserve ... for genuine formatting interfaces, and annotate them.
Examples
#include <stdio.h>
#include <stdarg.h>
#include <inttypes.h>
#include <stddef.h>
int main(void)
{
/* --- The four failure modes (all undefined behavior) --- */
/* printf("%d\n", 3.14); type mismatch: reads an int from an FP register */
/* printf("%s\n", 42); 42 treated as an address -> segfault */
/* printf("%d %d\n", 1); reads an argument that was never passed */
/* printf("%d\n", (long)1); size mismatch on LP64 */
/* --- Doing it correctly --- */
size_t n = 42;
long l = 1234567890L;
int64_t i8 = 9007199254740993;
void *p = &n;
ptrdiff_t d = 16;
printf("size_t : %zu\n", n);
printf("long : %ld\n", l);
printf("int64_t : %" PRId64 "\n", i8); /* portable fixed-width */
printf("pointer : %p\n", (void *)p); /* cast is required */
printf("ptrdiff : %td\n", d);
/* --- NEVER do this: format-string vulnerability --- */
const char *user_input = "%x %x %x %n";
/* printf(user_input); reads the stack; %n WRITES to memory */
printf("%s\n", user_input); /* correct: the data is an argument */
return 0;
}
/* --- A type-safe alternative when the arguments share a type --- */
#include <stdio.h>
#include <stddef.h>
static int sum_array(const int *a, size_t n)
{
int total = 0;
for (size_t i = 0; i < n; i++) total += a[i];
return total;
}
/* A compound literal (C99) gives a variadic-looking call site with FULL checking:
the wrong type here is a compile error, not run-time garbage. */
#define SUM(...) sum_array((int[]){ __VA_ARGS__ }, \
sizeof((int[]){ __VA_ARGS__ }) / sizeof(int))
int main(void)
{
printf("%d\n", SUM(1, 2, 3, 4, 5)); /* 15, fully type-checked */
/* printf("%d\n", SUM(1, "two")); compile ERROR -- exactly what we want */
return 0;
}
Where the checking happens
fixed parameters | variadic arguments
------------------------+--------------------------
prototype known | no prototype
converted as if by | default promotions only
assignment |
count checked | count unknown to the callee
type checked | type asserted by va_arg, never verified
|
+--> every guarantee is the programmer's,
except what -Wformat can infer from
a literal format string
Key Takeaways
- Variadic arguments are unchecked: the callee's
va_argtype and the format string are assertions the compiler cannot verify. - Mismatched type, wrong count, missing promotion, and wrong integer size are all undefined behavior, often silent.
- Never pass user-controlled data as a format string;
%nmakes it a memory-write primitive. - Use
%zu,%td,PRId64, and(void *)casts for%pto keep formats and argument sizes aligned. - Keep
-Wformaton, annotate your wrappers with theformatattribute, and prefer arrays, structs, or_Genericwhen the interface does not truly need formatting.
🧪 Practice
- Write
printf("%d\n", 3.14)andprintf("%s\n", 42), compile with-Wall, and record both the warning and the runtime behavior.- Write a program that prints a
size_twith%don a 64-bit system and explain when the output happens to look correct and when it does not.- Implement the
SUMmacro above, then trySUM(1, 2.5)and explain the diagnostic.- Interview-style: "Explain the format-string vulnerability and how to prevent it." Hint: describe what
%xreads and what%nwrites, then give the one-line fix at the call site.