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.
The smallest useful program
Section titled “The smallest useful program”#include <stdio.h>
int main(void){ printf("Hello, firmware!\n"); return 0;}Build and run it on your host:
gcc -std=c11 -Wall -Wextra -Wpedantic hello.c -o hello./helloecho $?Hello, firmware!0Every line here matters:
#include <stdio.h>is not C code. It is an instruction to the preprocessor to paste the contents of the system headerstdio.hat this point. That header declaresprintfso the compiler knows its parameter and return types. It does not contain the code ofprintf; that lives in the C library and arrives at link time.int main(void)defines a function calledmainthat takes no arguments and returns anint. On a hosted system (a PC with an operating system) the C runtime callsmainafter setting up the process. The(void)means “exactly zero parameters”. Writingint main()means something weaker in C11 (unspecified parameters), so always writevoid.- The braces delimit the function body, a block. Statements inside end with semicolons.
printfwrites formatted text to standard output. The\nis a newline escape. We useprintffreely for now; on a microcontroller there is no terminal, and makingprintfwork there is a topic of its own in The C standard library on a microcontroller.return 0;endsmainand hands the value 0 back to whoever started the program. By convention 0 means success;stdlib.halso providesEXIT_SUCCESSandEXIT_FAILURE. The shell stores it in$?(in PowerShell,$LASTEXITCODE). Since C99, falling off the closing brace ofmainis treated asreturn 0;, but only formain. Be explicit anyway.
Structure of a C source file
Section titled “Structure of a C source file”A C file is a sequence of top-level items, read top to bottom:
- Preprocessor directives (lines starting with
#): includes, macro definitions, conditional compilation. - Declarations: types, global variables, function prototypes.
- 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.
What changes on a microcontroller
Section titled “What changes on a microcontroller”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.
The four stages
Section titled “The four stages”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:
| Flag | Stops after | Output | Contents |
|---|---|---|---|
-E | preprocessing | .i (to stdout by default) | Pure C, all includes pasted, macros expanded |
-S | compilation | .s | Assembly text for the target CPU |
-c | assembly | .o | Relocatable object file (binary ELF) |
| none | linking | executable | Fully 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.
Stage 1: preprocessing
Section titled “Stage 1: preprocessing”gcc -std=c11 -E hello.c -o hello.iwc -l hello.iOn 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
#includewith the full text of the named file, recursively; - expands macros (
#define); - evaluates
#if/#ifdefand 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.
Stage 2: compilation
Section titled “Stage 2: compilation”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:
int scale(int x){ return x * 5 + 3;}arm-none-eabi-gcc -std=c11 -O2 -mcpu=cortex-m4 -mthumb -S scale.c -o scale.sThe 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 callerThree 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.
Stage 3: assembly
Section titled “Stage 3: assembly”arm-none-eabi-gcc -std=c11 -O2 -mcpu=cortex-m4 -mthumb -c scale.c -o scale.oThe 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.
Stage 4: linking
Section titled “Stage 4: linking”The linker takes one or more object files plus libraries and produces a single executable. It does three jobs:
- 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.
- Section placement. It concatenates all
.textsections together, all.datasections 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 at0x08000000, variables go to SRAM at0x20000000. That is the subject of Linker scripts. - Relocation. Now that every symbol has an address, it patches every placeholder recorded in the relocation tables.
Inside object files: sections and symbols
Section titled “Inside object files: sections and symbols”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.
#ifndef COUNTER_H#define COUNTER_H
int counter_next(int v);int counter_calls(void);
#endif#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;}#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:
gcc -std=c11 -Wall -Wextra -Wpedantic -c counter.c -o counter.ogcc -std=c11 -Wall -Wextra -Wpedantic -c main.c -o main.onm counter.onm main.o0000000000000000 b calls0000000000000000 t clamp000000000000003a T counter_calls0000000000000000 D counter_limit0000000000000020 T counter_next0000000000000000 R counter_step
U counter_calls U counter_next0000000000000000 T main U printfYour 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:
| Letter | Meaning | Section | Example above |
|---|---|---|---|
T / t | code | .text | counter_next / clamp |
D / d | initialized data | .data | counter_limit |
B / b | zero-initialized data | .bss | calls |
R / r | read-only data | .rodata | counter_step |
U | undefined: used here, defined elsewhere | none | printf, counter_next |
W / w | weak symbol (can be overridden) | any | default 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_limitandcounter_stepall sit at 0 in different sections. callswent to.bssbecause it has no initializer..bssoccupies no space in the file, only a size: the loader (or on an MCU, your startup code) fills it with zeros.counter_limitwent to.databecause its initial value 10 must be stored somewhere and copied into RAM.counter_stepisconst, so it went to.rodata. On a microcontroller.rodatastays in flash and costs no RAM at all.main.ohas threeUentries. The compiler accepted the calls because the prototypes incounter.handstdio.hdescribed them, but the code is somewhere else.
You can see the section sizes with size:
size counter.o text data bss dec hex filename 188 4 4 196 c4 counter.odata 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.
Relocations
Section titled “Relocations”Look at the call in main.o built for the Cortex-M4:
arm-none-eabi-gcc -std=c11 -mcpu=cortex-m4 -mthumb -c main.c -o main_arm.oarm-none-eabi-objdump -dr main_arm.oAmong the output you will find something like:
12: f7ff fffe bl 0 <counter_next> 12: R_ARM_THM_CALL counter_nextThe 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 errors, decoded
Section titled “Link errors, decoded”Link main.o alone, forgetting counter.o:
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 statusThe 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 hereEach 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:
gcc main.o counter.o -o app./appv = 10 after 7 callsTrace it: v goes 2, 4, 6, 8, 10, then clamp holds it at 10 for the last two calls.
Static vs dynamic linking
Section titled “Static vs dynamic linking”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.
Warning flags you always use
Section titled “Warning flags you always use”The compiler knows about many likely bugs but by default reports few of them. Turn them on:
gcc -std=c11 -Wall -Wextra -Wpedantic -Werror=implicit-function-declaration \ -Werror=return-type -Werror=incompatible-pointer-types -Werror=int-conversion \ -c main.c| Flag | What it does | Typical catches |
|---|---|---|
-std=c11 | Compile ISO C11 instead of the GNU dialect default | Makes extensions visible, consistent behavior across compilers |
-Wall | The 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++ |
-Wextra | A second set that is slightly noisier | Unused parameters, signed/unsigned comparisons, missing struct field initializers, unsigned < 0 comparisons, implicit switch fallthrough |
-Wpedantic | Warn about anything that is not strict ISO C | GNU extensions used by accident: zero-length arrays, binary literals before C23, statement expressions |
-Werror=name | Turn one specific warning into an error | The warnings that are always bugs |
-Werror | Turn every warning into an error | Use 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.
How to read a compiler error
Section titled “How to read a compiler error”Here is a program with one typo:
#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:
- Location:
bad.c:5:14is file, line, column. The compiler reports where it noticed the problem, which is often just after the real mistake. Here it reachedprintfon line 6, realized the declaration on line 5 never ended, and pointed at the end of line 5. - Severity:
errorstops the build;warningdoes not;noteadds context to the previous message (for example “previous declaration was here”). - Message: say it out loud. “Expected a comma or semicolon before
printf.” - 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.
Optimization levels: a first look
Section titled “Optimization levels: a first look”The same scale.c compiled at -O0 instead of -O2:
arm-none-eabi-gcc -std=c11 -O0 -mcpu=cortex-m4 -mthumb -S scale.c -o scale_O0.sscale: 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 lrFourteen 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.
A Makefile with three rules
Section titled “A Makefile with three rules”Typing compiler commands does not scale past two files. make rebuilds only what changed, based on file timestamps and the dependencies you declare.
CC := gccCFLAGS := -std=c11 -Wall -Wextra -Wpedantic -O2 -gLDFLAGS :=OBJS := main.o counter.o
app: $(OBJS) $(CC) $(LDFLAGS) -o $@ $^
%.o: %.c counter.h $(CC) $(CFLAGS) -c -o $@ $<
clean: rm -f app $(OBJS)
.PHONY: cleanA 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:
app: $(OBJS)links. It is the first rule, so plainmakebuilds it.$@is the target name (app) and$^is all prerequisites (main.o counter.o).%.o: %.c counter.his a pattern rule: any.ois built from the.cof the same name.$<is the first prerequisite (the.cfile). Listingcounter.hmeans editing the header recompiles both objects, which matters because a changed prototype must be seen by every caller.cleandeletes build products..PHONYtellsmakethatcleanis not a file, so it runs even if a file namedcleanexists.
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.
Common mistakes
Section titled “Common mistakes”- 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;incounter.h): every file that includes it defines it, giving “multiple definition”. Declare it in the header withextern, define it in one.cfile. - Reading the fiftieth error first: errors cascade. Fix the first one, rebuild.
- Thinking a link error is a syntax error:
undefined referencecomes fromld. Check that the file is compiled and linked, that the name matches, and that the definition is notstatic. - Building without warnings: use
-std=c11 -Wall -Wextra -Wpedanticfrom the very first program, and-Werrorin CI. - Spaces instead of a tab in a Makefile recipe:
makereportsmissing separator. Configure your editor to keep tabs in Makefiles. - Expecting
-O0behavior from optimized builds: if code only works unoptimized, it depends on undefined behavior. Find it rather than shipping-O0.
Exercises
Section titled “Exercises”- Build
hello.c, run it, and print the exit status. Changereturn 0;toreturn 42;, rebuild, and confirm the shell reports 42. Then delete thereturnstatement entirely: what doesecho $?print now, and why? - Run
gcc -std=c11 -E hello.c | wc -land thengcc -std=c11 -E hello.c | grep -n "printf". Find the declaration ofprintf. Add#define GREETING "Hi\n"and useprintf(GREETING);, then confirm in the-Eoutput that the macro name no longer exists, only its expansion. - 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 fore). Older GCC versions (before 10) showC, a “common” symbol, fora. - Using the
counterproject, produce the “undefined reference” and the “multiple definition” errors on purpose. Then makecounter_nextstaticincounter.cand explain the new error you get, naming the stage that reports it. - Compile
scale.cfor the Cortex-M4 at-O0,-O1,-O2and-Oswith-S. Count the instructions in each. Then change the body toreturn x * 7 + 3;andreturn x / 3;at-O2and describe the instructions the compiler chose (the division case is worth puzzling over: there is no division instruction in the output).
Check yourself
Section titled “Check yourself”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?
Messages mentioning ld, collect2 or "undefined reference" come from the linker. The compiler already accepted the call; the definition is missing at link time.
Question 2:nm sensor.o shows U i2c_read. What does that tell you?
U means undefined in this object: a reference that the linker must resolve against exactly one definition elsewhere.
Question 3:At file scope you write static int retries; with no initializer. Which nm letter and section do you expect?
No initializer means zero-initialized storage in .bss, and static gives internal linkage, which nm shows as a lowercase letter.
Question 4:Which command lets you see exactly what a macro expanded to?
-E stops after preprocessing and prints the C text with all macros expanded and includes pasted in.
Question 5:Why must firmware for a microcontroller be statically linked?
Dynamic linking relies on a loader that resolves library symbols when the program starts. A bare-metal MCU has none, so every symbol must be resolved to a fixed flash address at link time.
Question 6:GCC reports bad.c:12:5: error: expected ';' before 'return'. Where is the mistake most likely to be?
The compiler reports where it noticed the problem. A missing semicolon is detected only when the next token (return) arrives, so the actual error is at the end of the previous statement.
Key takeaways
Section titled “Key takeaways”gccis 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.nmshows 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. -O0translates literally;-O2transforms aggressively within the rules of the standard. Code that only works at-O0is 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.