Skip to content
Courses

C Programs and Compilation

Programming FoundationsLesson 5 of 8120 minutesLessonBuilds on: Python Behavior LabNo lab next

You’ve written Python programs that print a message, call a function, and return a result. Let’s start C with the smallest of those jobs: print one line. The intended behavior is familiar, but neither the notation nor the way we’ll run it is the same.

With our Python scripts, we passed a source file to python3. In this C workflow, we’ll give the source to another program first, and it will produce a separate executable. Running that executable doesn’t reread the source. That distinction will matter as soon as we change a message and the program stubbornly prints the old one.

We’ll work through the new syntax before asking the tools to translate it. You need the Linux habits and Python function ideas from the preceding lessons, but no previous C experience. Allow about two hours, and take a break after the first working program if the notation still needs time to settle.

A toolchain is a set of programs, and between them they turn source into something runnable. Here it’s the compiler, the assembler, and the linker. They get installed together and they have to agree with each other, so the whole set gets one name.

Terminal window
gcc --version

If it’s missing:

Terminal window
sudo apt update
sudo apt install build-essential

The supported Ubuntu 24.04.4 course environment should report GCC 13.x. If the major version differs, stop and restore the pinned guest before you go any further, since later lessons assume this toolchain.

Create a separate directory for the C examples. These commands go in the Ubuntu terminal, not a Python interpreter:

Terminal window
mkdir -p ~/underdunn-course/exercises/c-build
cd ~/underdunn-course/exercises/c-build

Use your text editor to create hello.c in that directory with this complete content. The .c suffix identifies a C source file; it doesn’t make the file executable.

#include <stdio.h>
int main(void) {
puts("hello from C");
return 0;
}

There are only two statements doing work inside the function, but a fair amount of punctuation has arrived with them. Let’s account for it rather than treat the surrounding lines as something you copy because the compiler enjoys decoration.

int main(void) defines the function we’ll start in. Like a Python def, a C function definition gives a name to a body of work. C writes the result type first: int says this function returns an integer. main is the name used for the starting function of this kind of C program. The startup code calls it when we run the executable. The void between these parentheses says this version of main takes no arguments; it does not say that the function returns nothing.

The braces enclose the function’s body. { begins that body and } ends it. Python used indentation to mark a block. C uses braces here, although we still indent the contents so people can read them. Moving a line to the left doesn’t move it out of the function if it’s still between the braces.

puts("hello from C"); calls a library function. The parentheses contain its argument, a string in double quotes. puts writes that string to standard output and adds a newline, so this call should display one line in the terminal. The semicolon ends the statement. It isn’t part of the message, and a newline in the source isn’t a substitute for it.

#include <stdio.h> makes the relevant library declarations available. stdio.h is a header, a file containing information the compiler needs about standard input/output functions such as puts. This directive does not call puts or print anything. It prepares the compiler to understand the later call. Unlike the two statements inside main, this directive doesn’t end in a semicolon.

return 0; finishes main and reports success. You already know the idea of returning a value to a caller. Here the caller is the program’s startup machinery, and returning zero gives the shell a successful exit status. It does not print a zero. The message and the exit status are two different results travelling by different routes.

The C library’s simple output reference specifies what puts writes, including its added newline. Use it when comparing the visible message with the function call, not as a list of functions to memorize.

For now, int only needs to mean the integer type used by this function. We don’t need its storage limits, pointers, or all the possible forms of main to understand this program. Those details will get their own examples when we need them.

Predict both the printed message and exit status before running these shell commands:

Terminal window
gcc -std=c11 -Wall -Wextra -Wpedantic -o hello hello.c
./hello
echo $?
file hello.c hello

gcc is the program driving the build. hello.c is its input, and -o hello names the output file. -std=c11 selects the C language version used for these examples. The remaining options enable useful warnings: -Wall selects a common set, -Wextra adds more, and -Wpedantic asks for diagnostics about departures from the selected language standard. They do not prove the program correct, but I’d leave them enabled while learning to read what the compiler tells you.

The build should finish without diagnostics. If it reports an error, stop there and compare your source with the complete listing rather than running whatever file happens to be left behind.

The next command, ./hello, runs the executable in the current directory. You should see hello from C, followed by 0 from echo $?. That zero is the status of the immediately preceding command, which is why we check it before running file.

file should identify hello.c as text and hello as an ELF executable, the native file format we’ll inspect later. We have two files with different jobs. Don’t open hello in the editor and try to repair the message there; the human-readable program is still in hello.c.

Change only the text inside the quotes to hello again, save hello.c, and predict what ./hello will print without another build. Run it and check the observation. Then repeat the GCC command and run the executable again. The old message should survive the source edit until the rebuild replaces the executable.

Restore the original message and rebuild before going on. Next, make a separate status experiment:

Terminal window
cp hello.c hello-status.c

In hello-status.c only, change return 0; to return 7;. Predict the message and status separately, then test:

Terminal window
gcc -std=c11 -Wall -Wextra -Wpedantic hello-status.c -o hello-status
./hello-status
echo $?

The message stays hello from C, while the status becomes 7. That doesn’t diagnose a real failure by itself: we deliberately chose the number. It establishes that returning a status and printing text are separate operations.

#include <stdio.h> supplies declarations for the standard I/O functions, puts among them. A declaration tells the compiler a name’s type and how it’s allowed to be used, and nothing more (it isn’t the function itself). The actual implementation lives in the C library, and the linker connects it up much later.

Now we want to print the result of a calculation instead of a fixed message. We’ll put the calculation in its own function, and show the compiler how to call it before supplying its body.

Create a new file named double.c with this complete listing. Keep hello.c unchanged for the build-stage experiment afterward:

#include <stdio.h>
int double_value(int value);
int main(void) {
printf("%d\n", double_value(21));
return 0;
}
int double_value(int value) {
return value * 2;
}

Read int double_value(int value); as “a function named double_value receives one integer named value and returns an integer.” That declaration is a prototype. It ends with a semicolon because it doesn’t contain a body. The definition further down repeats the function’s signature and supplies the braces and calculation. return value * 2; returns the integer product to whoever called it.

In main, double_value(21) calls the helper with 21 and supplies its returned value to printf. Unlike puts, printf uses a format string: %d marks where to print an integer argument, and \n writes a newline. The comma separates the format string from the value to insert. In this example, the result is an int, matching %d; a format code is not a request to convert any arbitrary value into the right type.

Predict the result, then build and run:

Terminal window
gcc -std=c11 -Wall -Wextra -Wpedantic double.c -o double
./double

Expect 42. Trace that number from the argument, through the helper’s return, to the printed result. You don’t have to relearn multiplication, but the parameter declaration, function body, and formatted output each needed a C explanation before we could use them.

We’ve been asking one gcc command to produce a finished program. Now let’s stop that same build at its intermediate results. Use the unchanged hello.c, not the helper example. Predict which outputs will still be text before running:

Terminal window
gcc -E hello.c -o hello.i
gcc -S hello.i -o hello.s
gcc -c hello.s -o hello.o
gcc hello.o -o hello
file hello.i hello.s hello.o hello

Preprocessing handles the directives like #include, compilation translates C into assembly, assembly encodes that assembly into an object file, and finally linking combines those objects and any required libraries into a single executable.

C source passes through four build stages before it becomes an executable
.cSourceC text
.iPreprocessheaders expanded
.sCompileassembly text
.oAssemblecode + symbols
ELFLinkexecutable

The first command stops after preprocessing and writes hello.i: C text after directives such as the header inclusion have been handled. -S stops after translation to assembly and writes hello.s, a textual description of machine instructions. You don’t need to read those instructions yet. -c assembles them into hello.o, an object file containing encoded instructions and information needed to combine this piece with others. The last command links that object into hello.

An object file isn’t a smaller executable you can run on its own. Our object calls a function whose implementation comes from the C library, and the final program also needs startup support. The linker connects those pieces. For shared libraries, some address resolution happens later when the program loads or runs, so don’t assume every library instruction was copied into hello.

It’s worth checking one of those unfinished connections:

Terminal window
file hello.c hello.i hello.s hello.o hello
nm -u hello.o

nm inspects symbol names associated with an object. Its -u option lists unresolved names: functions or data this object refers to but doesn’t define. The significant line for this example is:

U puts

U means the name is undefined in this object. That’s expected here, not a failed compilation: the library provides puts. Contrast this with the next example, where we must supply a definition of our own. Detailed ELF headers and load addresses can wait until How Programs Work.

GCC’s compilation options documents where -E, -S, and -c stop the build. Check that stopping point before interpreting the output file.

So far our own function definition lived in the same source file as main. Let’s separate them while keeping the calculation unchanged. That will let us see why a declaration can be enough to compile a call, yet not enough to finish the program.

Create math_helpers.h:

#ifndef MATH_HELPERS_H
#define MATH_HELPERS_H
int double_value(int value);
#endif

This header shares the prototype between our two source files. The three lines beginning with # form an include guard: if MATH_HELPERS_H hasn’t been defined, define it and include the declaration up to #endif. If the header is encountered again during the same source file’s preprocessing, skip that guarded content. Keep the guard as supplied; it prevents repeated inclusion, but it does not implement the function.

Create math_helpers.c:

#include "math_helpers.h"
int double_value(int value) {
return value * 2;
}

Create main.c:

#include <stdio.h>
#include "math_helpers.h"
int main(void) {
printf("result=%d\n", double_value(21));
return 0;
}

#include "math_helpers.h" asks for our local header, while <stdio.h> names a standard library header. Both source files now agree on the helper’s interface. Only math_helpers.c supplies its body; including the header doesn’t secretly include that other .c file.

Predict the output before building each object separately and linking the two:

Terminal window
gcc -Wall -Wextra -Wpedantic -c main.c -o main.o
gcc -Wall -Wextra -Wpedantic -c math_helpers.c -o math_helpers.o
gcc main.o math_helpers.o -o calculate
./calculate

Expected output is result=42. Predict the failure with math_helpers.o left out, then test it using a different output name, leaving the working calculate untouched:

Terminal window
gcc main.o -o missing-helper
echo $?

Expect an undefined reference diagnostic naming double_value and a nonzero build status. main.o contains a call, but this link command supplies no object defining our helper. Check that ./calculate still works before the next experiment. It was produced by the earlier complete link, not by the failed command.

A GCC diagnostic normally identifies the file, line, column, severity, and message. This is an illustrative diagnostic shape, not a promise of an exact location or wording from your build:

main.c:5:27: error: expected ‘;’ before ‘return’

Start at the first error in your own source, as the later messages are usually just consequences of the parser having lost its footing back at that first one. A warning means translation was able to continue, and not that the code is correct.

Linker diagnostics use phrases like undefined reference to 'double_value' or multiple definition of 'double_value'. Ask yourself which object was supposed to define that symbol, and then ask which objects the link command actually received. Those two answers usually differ.

Copy hello.c to hello-broken.c. Remove the semicolon after puts in that copy, predict whether the failure happens before linking, then run gcc -Wall -Wextra -Wpedantic -c hello-broken.c -o hello-broken.o. Here -c stops after making an object from C source, so there is no link step to blame. Read the first diagnostic, restore the semicolon, and rerun the same command to verify recovery. The original source and executable remain your known baseline.

The greeting experiment already showed that editing source doesn’t update an executable. Now we have an intermediate object too. Change math_helpers.c to multiply by 3, leaving the function name unchanged for this experiment. Predict the output at each of these steps before running them:

Terminal window
./calculate
gcc -Wall -Wextra -Wpedantic -c math_helpers.c -o math_helpers.o
./calculate
gcc main.o math_helpers.o -o calculate
./calculate

Expect result=42, then result=42, then result=63. Recompiling the changed object didn’t update the already-linked executable. The last link incorporated the new implementation. Restore the multiplication to two, recompile the helper object, and relink before the next exercise.

Use timestamps as supporting evidence:

Terminal window
ls -l --time-style=long-iso main.c math_helpers.c main.o math_helpers.o calculate

The durable fix is a build system recording the dependencies for you, and for a lesson this small you can just deliberately rebuild the changed objects and relink them yourself. Never treat “the source says so” as evidence about an executable you didn’t rebuild.

Modification exercise: add a second helper

Section titled “Modification exercise: add a second helper”

Spend 10 to 20 minutes adding increment_value(int value) to the header and helper source. It should return its argument plus one, using the same declaration/body pattern as double_value. Call it with 21 from main.c and print the result with %d. Predict the result, compile warning-clean objects, and link them together. No conditions or loops are needed yet.

Then, in a separate copy of the header and sources, change only the helper definition to take no arguments, using (void), while the header still declares one int argument. Predict the conflict before compiling that helper. Read the first diagnostic, restore the matching definition, and rebuild. Finish by identifying the exact linker inputs that produced your working executable.

  • gcc: command not found: confirm the VM has internet access, then install build-essential as shown above. Check any package-manager errors rather than just repeating the install blindly.
  • undefined reference: inspect the nm output alongside the link command. Make sure exactly one linked object or library defines the name being asked for, and no more.
  • Warnings, but an executable appeared anyway: fix the first warning in your own code and rebuild. Deleting the warning flags conceals evidence, and it repairs nothing.
  • Runtime output ignores a source change: compare the timestamps, rebuild the changed object, and relink the executable. Confirm it with output or strings, rather than with hope.

After the failed build, an old executable can still run. Check that a successful rebuild is what changes its behavior. If you see the old output, confirm the build succeeded and that you ran the executable it produced.

You can now read the small C programs you’ve built, distinguish printed output from return status, and work out whether a build failed during translation or linking. Next we’ll give C declarations, conditions, and loops the same deliberate treatment. Recognizing a Python idea is useful, but it doesn’t require you to guess its C spelling.

Knowledge check

Question 1 of 2

Each C file compiles with -c, but the final command reports undefined reference to a helper. Which stage needs investigation?