LearnLearn CC Foundations

Functions and the stack

gdb - a hands-on Linux lab on a real virtual machine.

Package code into functions: definition vs call, parameters vs arguments, the return value, and scope. C passes copies, so changing a parameter never changes the caller's variable; the return value is how results come home. Freeze a real call in gdb, read the backtrace of main calling add(a=2, b=40), and see the stack frames that hold each call's locals. Includes the two classic traps: swap(x, y) that compiles clean and changes nothing, and returning the address of a local, which gcc flags and the run punishes with a segfault.

You can declare variables, print them, and steer a program with if and for. Today you learn to package code into a reusable unit called a function. And the very first function most people write contains a trap that has confused beginners for fifty years.

Here it is. The function is supposed to double a number:

The black boxes on this page are a practice terminal: a safe sandbox that checks the one command each step teaches. Every output shown was captured from the real Learn C lab machine (Debian 12, gcc 12.2.0, gdb 13.1). One kind of value is per-machine: the 0x... addresses the debugger prints. A note flags those when they appear.

#include <stdio.h>

void double_it(int n) { n = n * 2; printf("inside double_it: n = %d\n", n); }

int main(void) { int x = 21; double_it(x); printf("after the call: x = %d\n", x); return 0; }

Two functions live in this file. You know main from Hello, machine: it is where every program starts. double_it is new. The void in front of it means it hands nothing back; more on that in a moment. Read the flow: main sets x to 21, hands x to double_it, and double_it doubles its n and prints it. Then main prints x.

prompt: operator@camp:~$ answer: gcc -Wall double.c -o double && ./double output: inside double_it: n = 42 after the call: x = 21 hint: The same loop as every lesson: gcc -Wall double.c -o double && ./double

n became 42. x stayed 21. Nothing is broken: this is the single most important rule about functions in C. A function receives copies of the values you pass it. The copy lived, changed, and died inside double_it, and x never heard about any of it. By the end of this lesson you will watch that copy sitting in memory under a debugger, and you will fix this program the way C engineers actually fix it.

A function is a named block of code that you write once and run whenever you want, by name. Running it is called calling the function. You have been calling functions since lesson one: printf is a function somebody else wrote.

The whole idea in one sentence: a function takes copies of the values you hand it, works in its own private workspace, and hands back one result.

The real-world picture: you walk up to a clerk with a form. The clerk photocopies your form, works on the photocopy at their own desk, writes one answer on a slip, and hands you the slip. Your original form never left your hand. The photocopy is thrown away with everything else on the desk.

Four terms you now own:

Functions exist because code repeats. The moment you catch yourself about to copy and paste a block, that block wants to be a function. Three practical wins follow. Reuse: write it once, call it anywhere. Naming: a function called max_of_three tells the reader what the block does before they read a line of it. Isolation: a block you can call by itself is a block you can test by itself.

Every real C program is functions calling functions calling functions. The Linux kernel you are running is, at bottom, a very large pile of C functions calling each other. Learning to read C is mostly learning to follow those calls.

And here is a callback you have earned: you have been using return values since Hello, machine. A program's exit code is whatever its main returns. main is just a function, and its caller is the C runtime that starts your program. When you type echo $?, you are reading main's return value.

Now a function that does it right: it takes two values in and delivers one back. This file is deliberately compressed, whole functions on single lines. C does not care about line breaks. We do it so the line numbers stay tiny: add lives on line 2 and main on line 3. You will meet those two numbers again shortly.

#include <stdio.h> int add(int a, int b) { int sum = a + b; return sum; } int main(void) { printf("%d\n", add(2, 40)); return 0; }

Read the call as a story. printf cannot print add(2, 40) until that call produces a value. So main pauses mid-line, the values 2 and 40 are copied into a and b, add computes sum, and return sum sends the result back. The call expression itself becomes that value: to the printf line, add(2, 40) IS the number that came back.

One new flag this time: -g. It packs a map into the binary, line numbers and variable names, so a debugger can show you the program's insides. It changes nothing about how the program runs.

prompt: operator@camp:~$ answer: gcc -Wall -g frames.c -o frames && ./frames output: 42 hint: Add -g after -Wall this time: gcc -Wall -g frames.c -o frames && ./frames

42, as predicted. Now the scope rule from earlier, made concrete: sum was declared inside add, so it exists only inside add. If you edited main to print sum directly, gcc would refuse to compile: inside main, that name simply does not exist. a, b, and sum were born when the call started and destroyed when return fired. The only survivor was the returned value, 42. Where did those three short-lived variables live? That place is the next step.

Every running program owns a region of memory called the stack. Picture a spring-loaded pile of cafeteria trays. Every function call places a new tray on top. That tray is called a stack frame, and it holds the call's parameters, its local variables, and a note saying where in the caller to resume when the function returns. When the function returns, its whole tray is thrown away.

So while add(2, 40) runs, the pile is two trays high: main's frame on the bottom, add's frame on top holding a, b, and sum. You do not have to imagine it. A debugger is a program that runs your program and can freeze it mid-flight, and gdb, the GNU debugger, is the one every Linux engineer uses. Debugging with gdb gets its own full lesson in module 2; today we borrow exactly one move.

The move is one long command. gdb --batch runs gdb non-interactively, and each -ex hands it one order, in sequence. break add freezes the program whenever add starts. run starts the program. backtrace lists every frame on the stack. info locals shows the frozen function's local variables. continue lets the program finish. Orders that contain a space must be quoted so the shell delivers them as one word.

#include <stdio.h> int add(int a, int b) { int sum = a + b; return sum; } int main(void) { printf("%d\n", add(2, 40)); return 0; }

prompt: operator@camp:~$ answer: gdb --batch -ex 'break add' -ex run -ex backtrace -ex 'info locals' -ex continue ./frames output: Breakpoint 1 at 0x1143: file frames.c, line 2. [Thread debugging using libthread_db enabled] Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".

Breakpoint 1, add (a=2, b=40) at frames.c:2 2 int add(int a, int b) { int sum = a + b; return sum; } #0 add (a=2, b=40) at frames.c:2 #1 0x000055555555516a in main () at frames.c:3 sum = 0 42 [Inferior 1 (process 894) exited normally] hint: gdb --batch -ex 'break add' -ex run -ex backtrace -ex 'info locals' -ex continue ./frames (quote the two-word orders)

Read it line by line. Breakpoint 1 at 0x1143 says gdb marked the first line of add. The two libthread_db lines are startup noise; ignore them. Breakpoint 1, add (a=2, b=40) is the freeze itself, and gdb shows you the copies arrived: a=2, b=40. Then the backtrace: the two # lines are the stack in text form. Frame #0 is the newest tray, add, exactly where we stopped. Frame #1 is its caller, main, frozen mid-line at frames.c:3. The long 0x5555... number is the return address: the exact spot in main where execution resumes when add returns. sum = 0 is the unassigned slot you predicted. The 42 is the program finishing normally after continue, and the Inferior 1 line is gdb reporting that the process it was watching exited cleanly.

{ "kind": "stack", "frames": [ { "fn": "add", "locals": [ { "name": "a", "value": "2" }, { "name": "b", "value": "40" }, { "name": "sum", "value": "0 (line 2 not run yet)" } ], "ret": "main () at frames.c:3" }, { "fn": "main", "locals": [], "ret": "the C runtime" } ], "caption": "Newest frame on top. a and b are copies made at the call. When add returns, this whole top frame is thrown away and only the returned 42 survives." }

The numbers 0x1143 and 0x000055555555516a are addresses on this machine, and process 894 is one run's process number. gdb turns address randomization off while it runs your program, so the addresses repeat run after run here, but your machine can print different ones. The process number changes every run, everywhere. Read them as labels, not values to memorize.

Back to the broken doubler. You now know why it failed: double_it changed its private copy, and the copy died with the frame. So how does a function ever get a result OUT? You watched the answer happen inside add: the one thing that survives the frame is the return value. The fix is three highlighted lines:

#include <stdio.h>

int double_it(int n) { return n * 2; }

int main(void) { int x = 21; x = double_it(x); printf("after the call: x = %d\n", x); return 0; }

The diff: void became int (the function now promises an int back), the body became a single return, and the call site became x = double_it(x);. That last line is the idiom to burn in: the call becomes the returned value, and main stores it into its own x itself.

prompt: operator@camp:~$ answer: gcc -Wall double.c -o double && ./double output: after the call: x = 42 hint: Nothing new in the command, the fix was in the file: gcc -Wall double.c -o double && ./double

x is finally 42, and notice who changed it: main did, with its own assignment. The function never touched main's variable; it computed a value and handed it back. That shape, take copies in, return a result, let the caller decide where to store it, is most of real C. There is a second way, where the caller hands over the variable's address so the function can reach it directly. That is the pointer lesson, and it is next.

return delivers exactly one value. So what about a job that needs to change two variables at once? The famous example is swapping: make x hold y's value and y hold x's. Fifty years of beginners have written this exact function, and it is the most famous wrong function in C:

#include <stdio.h>

void swap(int a, int b) { int tmp = a; a = b; b = tmp; }

int main(void) { int x = 1; int y = 2; swap(x, y); printf("x = %d, y = %d\n", x, y); return 0; }

Read the body carefully, because the logic is correct. tmp saves a, a takes b's value, b takes the saved one. Trace it on paper and a and b really do trade places. That is exactly what makes this trap so durable: the swapping is right, and the function is still useless.

prompt: operator@camp:~$ answer: gcc -Wall swap.c -o swap && ./swap output: x = 1, y = 2 hint: The same shape as every compile today: gcc -Wall swap.c -o swap && ./swap

Unchanged, and not one word from gcc. This is the pass-by-value trap wearing its most famous costume, and the silence is the lesson: the compiler cannot save you here, because shuffling your own copies is valid C that simply does nothing useful. One sentence resolves it for good: swap received the VALUES 1 and 2, not the variables x and y, so no amount of work inside its own frame can reach back into main's. To truly swap, the function would need to know WHERE x and y live. Hold that word, where.

Here is the next idea a sharp beginner has, sometimes within the hour: if my copy dies with the frame, maybe I can smuggle out the LOCATION of my local variable instead, and let the caller go read the result there after the call. C even has the symbols for it. &ans reads as "the address of ans", the spot in memory where that variable lives. int * before a function name declares that it returns an address where an int lives. And *p means "the value at the address stored in p". All three get their full lesson next; today you only need to judge the smuggling plan:

#include <stdio.h>

int *make_answer(void) { int ans = 42; return &ans; }

int main(void) { int *p = make_answer(); printf("%d\n", *p); return 0; }

prompt: operator@camp:~$ answer: gcc -Wall dangling.c -o dangling output: dangling.c: In function 'make_answer': dangling.c:5:12: warning: function returns address of local variable [-Wreturn-local-addr] 5 | return &ans; | ^~~~ hint: Just the compile this time, no && and no run: gcc -Wall dangling.c -o dangling

gcc names the exact disease: function returns address of local variable. But remember the rule from Hello, machine: a warning is not an error, so a binary appeared anyway. Run it.

prompt: operator@camp:~$ answer: ./dangling output: Segmentation fault hint: The binary was built despite the warning: ./dangling

echo $? would report 139, and the next step decodes that number. Why it died, in one sentence: a local variable's address only means something while its frame is on the stack, and return threw the frame away, so p held directions to a tray that no longer exists, and following them touched memory the program does not own. The warning was the machine telling you all of this in advance; treat -Wreturn-local-addr as an error every single time. The honest way out of a function is the one you already proved: return the VALUE, because the value is copied out to the caller before the frame dies. The other honest way, where the CALLER hands in an address that outlives the call, is the next lesson.

Two errors account for most of the pain while learning to write functions. Here is the literal text of each, what it means, and what to check first.

1. The missing semicolon

broken.c: In function 'main':
broken.c:5:13: error: expected ';' before '}' token
    5 |     return 7
      |             ^
      |             ;
    6 | }
      | ~

What it means: a statement was never finished. gcc only notices when the NEXT token makes the sentence impossible, so the caret often points just past the real mistake. Here gcc even prints the repair on the line under the caret: the lone ; is its suggested fix, right where one belongs. What to check first: the end of the line the caret sits on, then the line above it. Note the first line of the message: gcc names the function it was reading, In function 'main'. As your files grow to many functions, that name is your first clue about where to look.

2. The stack that hit the ceiling

Segmentation fault

And in the shell, echo $? reports exit code 139. What it means: the program touched memory it does not own and the kernel killed it with signal 11 (139 is 128 + 11). With functions, the classic cause is a function tha

Practice Functions and the stack in a real Linux terminal at The Linux Camp. Progress is verified automatically as you type commands on the machine.