Skip to content

Part 1 · The C Language27 min read

Hello, C — The Compilation Pipeline

Every firmware image you will ever ship starts as C source text and ends as bytes in flash. In between sit four distinct tools: the preprocessor, the compiler, the assembler and the linker. When something goes wrong (a symbol that will not resolve, a variable that ends up in the wrong memory, a function that vanished after optimization) you can only fix it if you know which stage produced the problem and how to look at that stage’s output. In this lesson you write your first C programs, run each stage by hand, open every intermediate file, and set up the warning flags and Makefile you will use for the rest of the course.

hello.c
#include <stdio.h>
int main(void)
{
printf("Hello, firmware!\n");
return 0;
}

Build and run it on your host:

Terminal window
gcc -std=c11 -Wall -Wextra -Wpedantic hello.c -o hello
./hello
echo $?
Hello, firmware!
0

Every line here matters:

  • #include <stdio.h> is not C code. It is an instruction to the preprocessor to paste the contents of the system header stdio.h at this point. That header declares printf so the compiler knows its parameter and return types. It does not contain the code of printf; that lives in the C library and arrives at link time.
  • int main(void) defines a function called main that takes no arguments and returns an int. On a hosted system (a PC with an operating system) the C runtime calls main after setting up the process. The (void) means “exactly zero parameters”. Writing int main() means something weaker in C11 (unspecified parameters), so always write void.
  • The braces delimit the function body, a block. Statements inside end with semicolons.
  • printf writes formatted text to standard output. The \n is a newline escape. We use printf freely for now; on a microcontroller there is no terminal, and making printf work there is a topic of its own in The C standard library on a microcontroller.
  • return 0; ends main and hands the value 0 back to whoever started the program. By convention 0 means success; stdlib.h also provides EXIT_SUCCESS and EXIT_FAILURE. The shell stores it in $? (in PowerShell, $LASTEXITCODE). Since C99, falling off the closing brace of main is treated as return 0;, but only for main. Be explicit anyway.

A C file is a sequence of top-level items, read top to bottom:

  1. Preprocessor directives (lines starting with #): includes, macro definitions, conditional compilation.
  2. Declarations: types, global variables, function prototypes.
  3. Function definitions, each containing a block of declarations and statements.

Comments come in two forms: /* block */, which can span lines but cannot nest, and // line, which runs to the end of the line (standard since C99). The compiler reads strictly top to bottom, so a name must be declared before it is used. That rule is the reason headers exist.

On your STM32F407 there is no operating system to call main and nobody to receive its return value. Startup code (which you will write in Startup code and the vector table) initializes memory and calls main, and main must never return:

int main(void)
{
/* initialize clocks, GPIO, peripherals */
for (;;) {
/* the superloop: poll inputs, run tasks, sleep */
}
}

The C standard calls this a freestanding environment, as opposed to hosted. In a freestanding environment the name and type of the startup function are implementation-defined and only a handful of headers (such as stdint.h, stddef.h, stdbool.h, limits.h) are guaranteed to exist. In practice every embedded toolchain still calls main and ships a C library, but the distinction explains why some things you take for granted on a PC need work on a chip.

When you type gcc hello.c -o hello, gcc is a driver. It runs other programs in sequence and deletes the intermediate files. Add -v to see the actual commands (cc1, as, collect2/ld).

hello.c ──[preprocess]──> hello.i ──[compile]──> hello.s ──[assemble]──> hello.o ──[link]──> hello
C text cpp C text cc1 asm text as object ld executable
-E -S -c (or .elf)

You can stop the driver after any stage:

FlagStops afterOutputContents
-Epreprocessing.i (to stdout by default)Pure C, all includes pasted, macros expanded
-Scompilation.sAssembly text for the target CPU
-cassembly.oRelocatable object file (binary ELF)
nonelinkingexecutableFully linked program

-save-temps runs all stages and keeps every intermediate file, which is handy when you want to look at all of them at once.

Terminal window
gcc -std=c11 -E hello.c -o hello.i
wc -l hello.i

On a typical Linux machine hello.i is several hundred lines long, even though your file has seven. Open it and scroll to the bottom:

# 1 "hello.c"
# 1 "<built-in>"
# 1 "<command-line>"
# 1 "/usr/include/stdio.h" 1 3 4
...
extern int printf (const char *__restrict __format, ...);
...
# 3 "hello.c"
int main(void)
{
printf("Hello, firmware!\n");
return 0;
}

The preprocessor is a text processor. It knows nothing about C types or functions. It:

  • replaces each #include with the full text of the named file, recursively;
  • expands macros (#define);
  • evaluates #if/#ifdef and drops the inactive branches;
  • removes comments;
  • inserts linemarkers (# 3 "hello.c") so the compiler can report errors against your original file and line, not against line 812 of the merged text.

The single most important line in that output is the declaration of printf. That declaration is all the compiler needs to check your call. The ... means printf is variadic (accepts extra arguments); variadic functions are covered in Functions and the calling convention.

When a macro misbehaves or a header defines something unexpected, -E is the tool that shows you the truth. The preprocessor, used well goes deep on this.

The compiler proper (cc1) parses the preprocessed C, checks types, optimizes, and emits assembly for one specific CPU. On your PC that is x86-64 assembly; for the course target it is Arm Thumb-2. Let’s compile a tiny function for the Cortex-M4:

scale.c
int scale(int x)
{
return x * 5 + 3;
}
Terminal window
arm-none-eabi-gcc -std=c11 -O2 -mcpu=cortex-m4 -mthumb -S scale.c -o scale.s

The interesting part of scale.s (assembler directives trimmed; exact output varies a little between GCC versions):

scale:
add r0, r0, r0, lsl #2 @ r0 = x + (x << 2) = x * 5
adds r0, r0, #3 @ r0 = r0 + 3
bx lr @ return to caller

Three instructions. The argument x arrives in register r0, the result leaves in r0, and bx lr jumps back to the return address held in the link register. The compiler replaced the multiplication by 5 with a shift-and-add because that is a single instruction on Arm. You do not need to read assembly fluently yet; Reading what the compiler wrote teaches it properly. What you need now is the habit: when you wonder what your C turned into, run -S (or paste it into Compiler Explorer with -O2 -mcpu=cortex-m4 -mthumb) and look.

Terminal window
arm-none-eabi-gcc -std=c11 -O2 -mcpu=cortex-m4 -mthumb -c scale.c -o scale.o

The assembler turns the text mnemonics into machine code bytes and packages them into an object file. An object file is not runnable. It is a container (ELF format on both Linux and arm-none-eabi) holding:

  • sections: named blobs of bytes, such as machine code or initial values of variables;
  • a symbol table: the names this file defines and the names it uses but does not define;
  • relocations: notes saying “at offset X in section Y, patch in the final address of symbol Z once you know it”;
  • optionally, debug information (with -g) mapping instructions back to source lines.

Code in an object file is compiled as if every section started at address 0. Nothing has a final address yet, because the assembler cannot know where the linker will put things.

The linker takes one or more object files plus libraries and produces a single executable. It does three jobs:

  1. Symbol resolution. Every symbol that some object uses but does not define must be defined by exactly one other object or library. Zero definitions is an “undefined reference” error. Two strong definitions is a “multiple definition” error.
  2. Section placement. It concatenates all .text sections together, all .data sections together, and so on, and assigns each a real address. On a PC the operating system’s conventions decide the addresses. On the STM32F407 you decide, with a linker script: code goes to flash at 0x08000000, variables go to SRAM at 0x20000000. That is the subject of Linker scripts.
  3. Relocation. Now that every symbol has an address, it patches every placeholder recorded in the relocation tables.

One file is too simple to show linking, so split a program in two. This is the shape of every real project: a module with a header, and a main.c that uses it.

counter.h
#ifndef COUNTER_H
#define COUNTER_H
int counter_next(int v);
int counter_calls(void);
#endif
counter.c
#include "counter.h"
static int calls; /* zero-initialized, visible only in this file */
int counter_limit = 10; /* initialized global, visible to other files */
const int counter_step = 2; /* read-only global */
static int clamp(int v) /* helper visible only in this file */
{
return (v > counter_limit) ? counter_limit : v;
}
int counter_next(int v)
{
calls++;
return clamp(v + counter_step);
}
int counter_calls(void)
{
return calls;
}
main.c
#include <stdio.h>
#include "counter.h"
int main(void)
{
int v = 0;
for (int i = 0; i < 7; i++) {
v = counter_next(v);
}
printf("v = %d after %d calls\n", v, counter_calls());
return 0;
}

counter.h holds declarations (prototypes) so that main.c can call the functions with type checking. The #ifndef COUNTER_H lines are an include guard, which stops the header’s contents from being pasted twice. "counter.h" in quotes means “search the directory of the including file first”, while <stdio.h> skips that directory and searches only the include paths (-I directories and the system directories).

Compile each file separately, without linking. Leaving out -O2 means the default, -O0, which keeps every function so the symbols are easy to see:

Terminal window
gcc -std=c11 -Wall -Wextra -Wpedantic -c counter.c -o counter.o
gcc -std=c11 -Wall -Wextra -Wpedantic -c main.c -o main.o
nm counter.o
nm main.o
0000000000000000 b calls
0000000000000000 t clamp
000000000000003a T counter_calls
0000000000000000 D counter_limit
0000000000000020 T counter_next
0000000000000000 R counter_step
U counter_calls
U counter_next
0000000000000000 T main
U printf

Your offsets will differ (and on macOS every name gets a leading underscore), but the letters will match. nm lists symbols with their offset within their section and a type letter:

LetterMeaningSectionExample above
T / tcode.textcounter_next / clamp
D / dinitialized data.datacounter_limit
B / bzero-initialized data.bsscalls
R / rread-only data.rodatacounter_step
Uundefined: used here, defined elsewherenoneprintf, counter_next
W / wweak symbol (can be overridden)anydefault interrupt handlers

Uppercase means global (visible to the linker from other object files); lowercase means local (private to this object). The keyword static at file scope is what made calls and clamp lowercase. Several things are worth noticing:

  • Each section’s symbols start at offset 0. calls, clamp, counter_limit and counter_step all sit at 0 in different sections.
  • calls went to .bss because it has no initializer. .bss occupies no space in the file, only a size: the loader (or on an MCU, your startup code) fills it with zeros. counter_limit went to .data because its initial value 10 must be stored somewhere and copied into RAM.
  • counter_step is const, so it went to .rodata. On a microcontroller .rodata stays in flash and costs no RAM at all.
  • main.o has three U entries. The compiler accepted the calls because the prototypes in counter.h and stdio.h described them, but the code is somewhere else.

You can see the section sizes with size:

Terminal window
size counter.o
text data bss dec hex filename
188 4 4 196 c4 counter.o

data is 4 bytes (counter_limit) and bss is 4 bytes (calls). text counts code plus read-only data plus some unwind tables, so its exact value depends on your compiler; do not expect 188. arm-none-eabi-size prints the same columns for firmware, and you will read it after every build to see how much flash and RAM you are using.

Look at the call in main.o built for the Cortex-M4:

Terminal window
arm-none-eabi-gcc -std=c11 -mcpu=cortex-m4 -mthumb -c main.c -o main_arm.o
arm-none-eabi-objdump -dr main_arm.o

Among the output you will find something like:

12: f7ff fffe bl 0 <counter_next>
12: R_ARM_THM_CALL counter_next

The bl (branch with link, the Arm call instruction) is encoded with a dummy offset. The line below it is a relocation: “when you know where counter_next lives, fix up the branch at offset 0x12 using the Thumb call relocation formula”. After linking, objdump on the final executable shows a real target address.

Link main.o alone, forgetting counter.o:

Terminal window
gcc main.o -o app
/usr/bin/ld: main.o: in function `main':
main.c:(.text+0x22): undefined reference to `counter_next'
/usr/bin/ld: main.c:(.text+0x34): undefined reference to `counter_calls'
collect2: error: ld returned 1 exit status

The message comes from ld, not from the compiler. collect2 is a GCC wrapper around the linker; when you see its name, the problem is at link time. “Undefined reference” always means one of: you forgot to compile or link a file, you misspelled the name, the definition is static in its file (and therefore invisible), or a library is missing or listed in the wrong order.

Now define counter_limit again in main.c with int counter_limit = 5; and link both:

/usr/bin/ld: counter.o:(.data+0x0): multiple definition of `counter_limit'; main.o:(.data+0x0): first defined here

Each global object or function must have exactly one definition in the whole program. Headers carry declarations; source files carry definitions. Variables, scope, lifetime, and storage classes explains the difference precisely.

When it links correctly:

Terminal window
gcc main.o counter.o -o app
./app
v = 10 after 7 calls

Trace it: v goes 2, 4, 6, 8, 10, then clamp holds it at 10 for the last two calls.

On your PC, printf is normally not copied into app. The linker records “needs printf from the shared C library” and the operating system’s dynamic loader maps libc.so into the process at run time. That is dynamic linking: smaller executables, one shared copy of the library in memory, library updates without relinking. Static linking copies the needed library code into the executable itself. A microcontroller has no loader and no filesystem, so firmware is always statically linked: every byte that will ever run, including the parts of the C library you use, is resolved at link time and placed at a fixed address in flash. This is why the map file (which you will learn to read) can tell you exactly where every one of your 1 MB of flash went.

The compiler knows about many likely bugs but by default reports few of them. Turn them on:

Terminal window
gcc -std=c11 -Wall -Wextra -Wpedantic -Werror=implicit-function-declaration \
-Werror=return-type -Werror=incompatible-pointer-types -Werror=int-conversion \
-c main.c
FlagWhat it doesTypical catches
-std=c11Compile ISO C11 instead of the GNU dialect defaultMakes extensions visible, consistent behavior across compilers
-WallThe core set of high-value warnings (despite the name, not all)printf format mismatches, uninitialized variables, unused variables, missing return, if (x = 5), suspicious precedence, unsequenced i = i++
-WextraA second set that is slightly noisierUnused parameters, signed/unsigned comparisons, missing struct field initializers, unsigned < 0 comparisons, implicit switch fallthrough
-WpedanticWarn about anything that is not strict ISO CGNU extensions used by accident: zero-length arrays, binary literals before C23, statement expressions
-Werror=nameTurn one specific warning into an errorThe warnings that are always bugs
-WerrorTurn every warning into an errorUse in CI so warnings cannot accumulate

The four -Werror= flags above cover mistakes that compile to broken code: calling a function with no declaration in scope, falling off the end of a non-void function, passing the wrong pointer type, and silently converting between integers and pointers. GCC 14 and later already treat most of these as errors by default; older compilers and some vendor toolchains do not, so state them explicitly.

There are more useful warnings that -Wall -Wextra do not enable, such as -Wshadow, -Wconversion and -Wdouble-promotion. They appear in the lessons where their bugs are explained, and Testing and debugging C on the host assembles the full professional list.

Here is a program with one typo:

bad.c
#include <stdio.h>
int main(void)
{
int x = 5
printf("%d\n", x);
return 0;
}
bad.c: In function 'main':
bad.c:5:14: error: expected ',' or ';' before 'printf'
5 | int x = 5
| ^
| ;
6 | printf("%d\n", x);
| ~~~~~~

Read it in this order:

  1. Location: bad.c:5:14 is file, line, column. The compiler reports where it noticed the problem, which is often just after the real mistake. Here it reached printf on line 6, realized the declaration on line 5 never ended, and pointed at the end of line 5.
  2. Severity: error stops the build; warning does not; note adds context to the previous message (for example “previous declaration was here”).
  3. Message: say it out loud. “Expected a comma or semicolon before printf.”
  4. The fix-it hint: modern GCC and Clang often print the suggested fix under the caret (the ; here).

Two rules save hours. First, fix the first error first. One missing brace or semicolon can confuse the parser into producing fifty errors further down; most vanish when the first is fixed. Second, read the bracketed flag name on warnings:

printf("%d\n", 3.5);
warning: format '%d' expects argument of type 'int', but argument 2 has type 'double' [-Wformat=]

[-Wformat=] tells you which warning fired, so you can look it up, and it tells you this one came from -Wall. A forgotten include gives a message that names the problem exactly:

warning: implicit declaration of function 'printf' [-Wimplicit-function-declaration]
note: include '<stdio.h>' or provide a declaration of 'printf'

Without a declaration, pre-C99 rules let the compiler assume the function returns int and takes whatever you pass. That assumption silently breaks for functions returning pointers or taking float, which is why we promote this warning to an error.

Finally, if the error mentions ld, collect2 or “undefined reference”, stop looking at your syntax: the compiler was happy, the linker was not.

The same scale.c compiled at -O0 instead of -O2:

Terminal window
arm-none-eabi-gcc -std=c11 -O0 -mcpu=cortex-m4 -mthumb -S scale.c -o scale_O0.s
scale:
push {r7} @ save frame pointer
sub sp, sp, #12 @ make room on the stack
add r7, sp, #0 @ r7 = frame pointer
str r0, [r7, #4] @ store x to the stack
ldr r2, [r7, #4] @ load x back
mov r3, r2
lsls r3, r3, #2 @ r3 = x * 4
add r3, r3, r2 @ r3 = x * 5
adds r3, r3, #3 @ r3 = x * 5 + 3
mov r0, r3 @ result into r0
adds r7, r7, #12 @ tear down the frame
mov sp, r7
ldr r7, [sp], #4
bx lr

Fourteen instructions instead of three. At -O0 the compiler translates each statement literally: every variable lives in memory, every use reloads it, nothing is kept in registers across statements. That makes the code easy to step through in a debugger and the generated code easy to match against the source, but it is large and slow. At -O2 the compiler keeps values in registers, removes redundant work, inlines small functions and reorders code freely, as long as the program’s observable behavior (as the C standard defines it) is unchanged.

That last condition is the source of a whole class of firmware bugs: code that “works at -O0 and breaks at -O2” almost always relied on something the C standard does not promise, such as reading a hardware register without volatile or overflowing a signed integer. You will meet the tools to reason about this in Undefined behavior, volatile, and what the optimizer assumes. The course target flags use -O2 -g: optimized code with debug information.

Typing compiler commands does not scale past two files. make rebuilds only what changed, based on file timestamps and the dependencies you declare.

Makefile
CC := gcc
CFLAGS := -std=c11 -Wall -Wextra -Wpedantic -O2 -g
LDFLAGS :=
OBJS := main.o counter.o
app: $(OBJS)
$(CC) $(LDFLAGS) -o $@ $^
%.o: %.c counter.h
$(CC) $(CFLAGS) -c -o $@ $<
clean:
rm -f app $(OBJS)
.PHONY: clean

A rule has the form target: prerequisites followed by recipe lines, and each recipe line must start with a tab character, not spaces (the classic error is missing separator). The three rules:

  1. app: $(OBJS) links. It is the first rule, so plain make builds it. $@ is the target name (app) and $^ is all prerequisites (main.o counter.o).
  2. %.o: %.c counter.h is a pattern rule: any .o is built from the .c of the same name. $< is the first prerequisite (the .c file). Listing counter.h means editing the header recompiles both objects, which matters because a changed prototype must be seen by every caller.
  3. clean deletes build products. .PHONY tells make that clean is not a file, so it runs even if a file named clean exists.

Run make twice. The second time it prints that app is up to date. Touch counter.c and run it again: only counter.o is recompiled, then app is relinked. Hand-listing header dependencies does not scale either; Build systems, toolchain flags, and CI shows how the compiler generates them automatically with -MMD -MP, and how the firmware Makefile adds cross-compiler flags, objcopy and flashing.

  • Missing prototype for a function in another file: the compiler guesses wrong types or errors out. Put the prototype in a header and include it in both the caller and the defining file.
  • Defining a variable in a header (int counter = 0; in counter.h): every file that includes it defines it, giving “multiple definition”. Declare it in the header with extern, define it in one .c file.
  • Reading the fiftieth error first: errors cascade. Fix the first one, rebuild.
  • Thinking a link error is a syntax error: undefined reference comes from ld. Check that the file is compiled and linked, that the name matches, and that the definition is not static.
  • Building without warnings: use -std=c11 -Wall -Wextra -Wpedantic from the very first program, and -Werror in CI.
  • Spaces instead of a tab in a Makefile recipe: make reports missing separator. Configure your editor to keep tabs in Makefiles.
  • Expecting -O0 behavior from optimized builds: if code only works unoptimized, it depends on undefined behavior. Find it rather than shipping -O0.
  1. Build hello.c, run it, and print the exit status. Change return 0; to return 42;, rebuild, and confirm the shell reports 42. Then delete the return statement entirely: what does echo $? print now, and why?
  2. Run gcc -std=c11 -E hello.c | wc -l and then gcc -std=c11 -E hello.c | grep -n "printf". Find the declaration of printf. Add #define GREETING "Hi\n" and use printf(GREETING);, then confirm in the -E output that the macro name no longer exists, only its expansion.
  3. Before running nm, predict the type letter of each symbol in this file, then check: int a; int b = 1; static int c; const int d = 4; static void e(void) {} void f(void) { e(); }. Note what changes when you compile with -O2 (hint: look for e). Older GCC versions (before 10) show C, a “common” symbol, for a.
  4. Using the counter project, produce the “undefined reference” and the “multiple definition” errors on purpose. Then make counter_next static in counter.c and explain the new error you get, naming the stage that reports it.
  5. Compile scale.c for the Cortex-M4 at -O0, -O1, -O2 and -Os with -S. Count the instructions in each. Then change the body to return x * 7 + 3; and return x / 3; at -O2 and describe the instructions the compiler chose (the division case is worth puzzling over: there is no division instruction in the output).

Question 1:A build fails with main.c:(.text+0x1c): undefined reference to 'uart_send' followed by collect2: error: ld returned 1 exit status. Which stage failed?

Question 2:nm sensor.o shows U i2c_read. What does that tell you?

Question 3:At file scope you write static int retries; with no initializer. Which nm letter and section do you expect?

Question 4:Which command lets you see exactly what a macro expanded to?

Question 5:Why must firmware for a microcontroller be statically linked?

Question 6:GCC reports bad.c:12:5: error: expected ';' before 'return'. Where is the mistake most likely to be?

  • gcc is a driver for four stages: preprocess (-E), compile (-S), assemble (-c), link. Every problem belongs to exactly one stage, and each stage’s output can be inspected.
  • Headers carry declarations so the compiler can type-check calls; the code itself is found by the linker. Each global function and object has exactly one definition in the whole program.
  • Object files contain sections (.text, .data, .bss, .rodata), a symbol table and relocations. nm shows the symbols; uppercase letters are global, lowercase are local (static).
  • “Undefined reference” and “multiple definition” are linker errors, not syntax errors.
  • Firmware is always statically linked: every byte has a fixed address decided at link time.
  • Always compile with -std=c11 -Wall -Wextra -Wpedantic, promote the always-a-bug warnings with -Werror=, and fix the first error first.
  • -O0 translates literally; -O2 transforms aggressively within the rules of the standard. Code that only works at -O0 is broken.

Next: Types, sizes, and how numbers are stored, where you learn exactly what bits a uint8_t, an int and a float occupy and why that matters on a 32-bit microcontroller.