Control Flow, Functions, and the Stack

C's control flow is the ancestor of the syntax you already know — `if`, `while`, `for`, `switch`. Its functions look familiar too, but they hide a defining C fact: arguments are passed by value, always copied. Understanding pass-by-value, and the call stack that makes function calls work, is the bridge to the hardest and most important topic in C — pointers.

We can compile a program and manipulate typed data. Now we structure it: branching, looping, and organizing code into functions. Most of this will feel familiar from other languages — C is where much of that syntax originated. But two things are distinctly C and set up everything ahead: functions copy their arguments, and every call lives on a stack. This post covers both.

Control flow

C’s control-flow constructs are the originals that Go, Java, and JavaScript inherited:

if (x > 0) {
    puts("positive");
} else if (x == 0) {
    puts("zero");
} else {
    puts("negative");
}

for (int i = 0; i < n; i++) { /* ... */ }
while (condition) { /* ... */ }
do { /* runs at least once */ } while (condition);

switch (code) {
    case 200: puts("ok");    break;
    case 404: puts("gone");  break;
    default:  puts("other");
}

Two C-specific gotchas worth flagging. First, switch falls through: without a break, execution continues into the next case. This is occasionally useful (grouping cases) but a frequent bug — forget a break and you run code you didn’t mean to. Second, C treats any nonzero value as true and zero as false; there was no bool until C99 (<stdbool.h>). This is why if (x) means “if x is nonzero,” and why the classic typo if (x = 5) (assignment, not comparison) compiles and is always true — it assigns 5 to x, which is nonzero. Enable -Wall and the compiler warns you.

Functions: declaration and definition

A C function has a return type, a name, typed parameters, and a body:

int add(int a, int b) {   // definition
    return a + b;
}

Recall the declaration/definition split from post 1. Because C compiles top to bottom in one pass, a function must be declared before it’s called. If main calls add before add appears in the file, you need a function prototype (a declaration) earlier — typically in a header:

int add(int a, int b);    // prototype: declared, defined elsewhere/later

This is exactly the mechanism that lets C split a program across files: .h files hold prototypes so any .c can call functions defined in another .c, resolved at link time (post 1). void as a return type means “returns nothing”; void as the parameter list (int main(void)) means “takes no arguments” — and in C you should write (void), not empty (), to mean no arguments explicitly.

Pass-by-value: the crucial fact

Here is the single most important thing about C functions: arguments are passed by value. When you call a function, C copies each argument into the function’s own parameters. The function operates on its copies; changes to them do not affect the caller’s variables.

void tryToModify(int x) {
    x = 99;              // modifies the LOCAL copy only
}

int main(void) {
    int n = 5;
    tryToModify(n);
    printf("%d\n", n);   // prints 5 — n is unchanged
    return 0;
}

tryToModify received a copy of n. Setting x = 99 changed the copy, which vanishes when the function returns. This surprises newcomers who expect the caller’s n to change. It doesn’t, and it can’t — not directly.

So how do C functions modify their caller’s data (which they constantly need to do)? By passing the address of the variable — a pointer — so the function can reach back and modify the original. Pass-by-value is why pointers are essential in C: they’re the mechanism for a function to affect something outside itself. That’s the entire motivation for the next post. Everything about C’s “why pointers?” starts here: because arguments are copied, you pass an address when you need to reach the original.

Scope and lifetime

Where a variable is visible (scope) and how long it lives (lifetime) matter more in C than in garbage-collected languages, because C makes you manage them:

The uninitialized-local trap is worth repeating because it’s so common: int x; then reading x gives you whatever bits were on the stack — a real bug that may appear to work by luck. Make initialization a reflex.

The call stack

Function calls in C are powered by the call stack — a region of memory that grows and shrinks as functions are called and return. Understanding it demystifies local variables, recursion, and a class of crashes.

When you call a function, C pushes a stack frame onto the stack: a block holding that call’s parameters, its local variables, and the return address (where to resume in the caller). When the function returns, its frame is popped — instantly freeing all its locals. This is why local variables vanish on return, why they’re fast (allocation is just moving a pointer), and why each recursive call gets its own copies of the locals (a fresh frame per call):

long factorial(int n) {
    if (n <= 1) return 1;         // base case
    return n * factorial(n - 1);  // each call gets its own frame with its own n
}

Two consequences to carry forward. Stack overflow: because the stack is finite, too-deep recursion (or an accidental infinite recursion) exhausts it and crashes — the literal origin of the term. And a critical rule for the pointer posts ahead: never return the address of a local variable. Since its frame is popped on return, that address points to memory that’s been reclaimed — a dangling pointer to a dead stack frame. Data that must outlive a function goes on the heap (post 6), not the stack. The stack is for the transient; the heap is for the lasting.

Key takeaways

Further reading

Sources & References

Functions, declarations, and calls