Part 1 · The C Language28 min read
Organizing a Real C Codebase
A blinky fits in one file. A product does not: a typical firmware image has 50 to 500 source files written by several people over several years. C gives you very few tools for organizing that code (no namespaces, no classes, no modules in the language sense) but the few it has are enough if you use them with discipline. This lesson shows how translation units, headers, static and incomplete types combine into modules with real encapsulation, how to keep board-specific details in one place, how to build a static library and why link order matters, and the directory layout this course uses from here on.
Translation units
Section titled “Translation units”The compiler never sees your project. It sees one translation unit at a time: a single .c file after the preprocessor has pasted in every #include and expanded every macro. It compiles that unit to an object file and forgets it. Only the linker sees all the object files together.
heartbeat.c ──┐ ┌── heartbeat.o (defines heartbeat_init, ...) heartbeat.h ──┤ preprocess+compile │ │ ───────────────────>│ main.c ───────┤ (one TU at a time) ├── main.o (calls heartbeat_init: undefined) board.h ──────┘ │ └──> linker ──> firmware.elf (references resolved)Two consequences shape everything else in this lesson.
First, when main.c calls heartbeat_init, the compiler has only the declaration in the header to go on. It trusts that declaration completely. If it disagrees with the real definition in another file, nothing checks it.
Second, every function and variable with external linkage shares one global namespace across the whole program, including the C library and every third-party library you link. Two files that both define a non-static function called init cannot be linked together.
The mismatched declaration bug
Section titled “The mismatched declaration bug”Here is the first consequence going wrong. The header says one thing, the source file another:
#ifndef LED_H#define LED_H#include <stdint.h>void led_set_brightness(uint8_t percent);#endif#include <stdint.h>/* forgot: #include "led.h" */
void led_set_brightness(uint32_t permille){ /* ... expects 0..1000 ... */ (void)permille;}Both files compile without a warning. main.c includes led.h and passes a uint8_t; led.c reads a uint32_t it believes is in tenths of a percent. With the Arm calling convention the value happens to arrive in r0 either way, so this might even appear to work, until someone changes a parameter to a struct or adds a second argument. Formally, calling a function through an incompatible declaration is undefined behavior.
The fix is a rule with no exceptions: every .c file includes its own header, first. Then the compiler sees the declaration and the definition in the same translation unit and reports the conflict:
led.c:4:6: error: conflicting types for 'led_set_brightness'; have 'void(uint32_t)'led.h:4:6: note: previous declaration of 'led_set_brightness' with type 'void(uint8_t)'Including it first has a second benefit: it proves the header is self-contained, because nothing included earlier can hide a missing #include inside it.
The warning -Wmissing-prototypes enforces the other half of the rule. It warns about any non-static function defined without a previous prototype. Either the function is part of the module’s interface (so declare it in the header) or it is private (so make it static).
What goes in a header
Section titled “What goes in a header”A header is the module’s contract with the rest of the program. Put in it exactly what callers need and nothing else.
| Belongs in the header | Belongs in the .c file |
|---|---|
| function prototypes of the public API | function definitions |
| types callers pass or receive (config structs, enums, handles) | private types and the full definition of opaque structs |
macros that are part of the API (UART_MAX_PORTS) | private constants and helper macros |
static inline functions small enough to inline everywhere | static helper functions |
extern declarations of the rare public variable | the variable definitions themselves |
the #includes the header itself needs | the #includes only the implementation needs |
Two things must never appear in a header. A variable definition (uint32_t g_ticks; or uint32_t g_ticks = 0;) creates a separate definition in every file that includes it, which is a “multiple definition” link error since GCC 10 and was silently merged before it. A non-static, non-inline function definition has the same problem.
static inline in a header is the exception that works: every translation unit gets its own private copy, and since the compiler can see the body, it inlines the call and usually emits no copy at all. Use it for tiny accessors and bit helpers where a function call would cost more than the work.
static inline bool bit_is_set(uint32_t value, uint32_t bit){ return (value & (1u << bit)) != 0u;}Header hygiene
Section titled “Header hygiene”A good header has three properties.
Self-contained. It compiles when included alone, as the first line of an otherwise empty file. If it uses uint32_t, it includes stdint.h itself instead of hoping the includer did. The “own header first” rule tests this automatically.
Idempotent. Including it twice in one translation unit is harmless. That is what the include guard does. The preprocessor, used well covers guards versus #pragma once; the one naming rule worth repeating is that identifiers starting with an underscore and a capital letter, or containing a double underscore, are reserved for the implementation. _UART_H_ is technically illegal; UART_H is fine.
Usable from C++. Many projects mix C drivers with C++ application code. C++ mangles function names to encode their parameter types, so a C++ caller looks for a symbol like _Z10uart_writeP4uartPKhj while your C object file defines plain uart_write. The guard below tells a C++ compiler to use C linkage for everything inside, and is invisible to a C compiler:
#ifdef __cplusplusextern "C" {#endif
/* declarations */
#ifdef __cplusplus}#endifKeep C-only syntax such as compound literals and designated initializers in macros out of headers meant for C++ callers; older C++ standards reject them.
Finally, include what you use, and nothing more. Each extra #include in a header is forced on every file that includes it, which slows builds, makes a change to one header rebuild half the project, and invites circular dependencies.
Forward declarations
Section titled “Forward declarations”You do not need a struct’s full definition to declare a pointer to it. A forward declaration (an incomplete type) is enough:
#ifndef LOGGER_H#define LOGGER_H
#include <stddef.h>#include <stdint.h>
struct uart; /* forward declaration: no need to include uart.h here */
void logger_init(struct uart *out);void logger_write(const char *msg, size_t len);
#endiflogger.h now compiles without uart.h. Files that include logger.h but never touch the UART are not rebuilt when uart.h changes. And if uart.h ever needs something from logger.h, there is no circular include to untangle: the cycle that would have formed is broken at the forward declaration.
The rule: if a header only passes pointers to a type around, forward-declare it. Include the full header in the .c file, where the members are actually used.
extern variables, and why to avoid them
Section titled “extern variables, and why to avoid them”A global variable shared across files needs a declaration in a header and exactly one definition in a source file:
extern volatile uint32_t g_systick_ms; /* declaration: no storage */
/* systick.c */volatile uint32_t g_systick_ms; /* definition: storage in .bss */This works, and it is the beginner’s way of sharing state. It scales badly:
- Any file can write it. When
g_systick_msjumps backwards, every file in the project is a suspect. - The module cannot maintain invariants. If two variables must change together, nothing stops a caller from changing one.
- Callers depend on representation. Changing the tick from milliseconds to a 64-bit microsecond counter breaks every user.
- Shared mutable state between an ISR and the main loop needs careful access rules (atomic reads, critical sections). With an accessor function, the rule lives in one place.
The alternative is a static variable with accessor functions:
#include "systick.h"
static volatile uint32_t s_ms; /* only this file can touch it */
void SysTick_Handler(void){ s_ms++;}
uint32_t systick_now_ms(void){ return s_ms; /* aligned 32-bit read: atomic on Cortex-M */}On Cortex-M the accessor compiles to a load and a return; with link-time optimization (-flto) it often disappears entirely. Legitimate uses of extern remain: const lookup tables, linker-script symbols like _estack, and the occasional variable where profiling proved the call matters. Treat them as exceptions you justify, not the default.
static is your encapsulation
Section titled “static is your encapsulation”C has exactly one encapsulation mechanism, and it is file-level: a function or file-scope variable marked static has internal linkage, invisible outside its translation unit. A module therefore is a .c file whose public surface is its header and whose everything else is static.
The payoff is not only safety. When a function is static, the compiler knows every caller, so it can inline it, specialise it, or drop it entirely if nothing calls it. The linker also has fewer global symbols to collide.
The module pattern
Section titled “The module pattern”Almost every firmware module, from a heartbeat LED to a protocol stack, has the same shape:
module_init(const module_config_t *cfg): validate the configuration, set up state, return success or failure.module_process(...): called repeatedly from the main loop (or a task) to do incremental work. Never blocks.- A handful of query and command functions.
- All state in one
static structinside the.cfile. - Hardware access either through a board-provided function or through function pointers in the config, so the module can be tested on a host.
Here is a complete small module: a heartbeat LED that blinks slowly when all is well and fast when a fault is flagged.
#ifndef HEARTBEAT_H#define HEARTBEAT_H
#include <stdbool.h>#include <stdint.h>
#ifdef __cplusplusextern "C" {#endif
typedef void (*heartbeat_led_fn)(bool on);
typedef struct { uint32_t period_ms; /* full blink cycle in normal mode */ uint32_t on_ms; /* LED on-time within each cycle */ heartbeat_led_fn led; /* drives the LED; supplied by the board */} heartbeat_config_t;
void heartbeat_default_config(heartbeat_config_t *cfg);bool heartbeat_init(const heartbeat_config_t *cfg);void heartbeat_process(uint32_t now_ms);void heartbeat_set_fault(bool fault);
#ifdef __cplusplus}#endif
#endif#include "heartbeat.h"
#include <stddef.h>
#define FAULT_PERIOD_MS 200u#define FAULT_ON_MS 100u
static struct { heartbeat_config_t cfg; uint32_t cycle_start_ms; bool led_on; bool fault; bool initialized;} s_hb;
void heartbeat_default_config(heartbeat_config_t *cfg){ cfg->period_ms = 1000u; cfg->on_ms = 100u; cfg->led = NULL;}
bool heartbeat_init(const heartbeat_config_t *cfg){ if (cfg == NULL || cfg->led == NULL) { return false; } if (cfg->period_ms == 0u || cfg->on_ms > cfg->period_ms) { return false; } s_hb.cfg = *cfg; s_hb.cycle_start_ms = 0u; s_hb.led_on = false; s_hb.fault = false; s_hb.initialized = true; s_hb.cfg.led(false); return true;}
void heartbeat_set_fault(bool fault){ s_hb.fault = fault;}
void heartbeat_process(uint32_t now_ms){ if (!s_hb.initialized) { return; } uint32_t period = s_hb.fault ? FAULT_PERIOD_MS : s_hb.cfg.period_ms; uint32_t on = s_hb.fault ? FAULT_ON_MS : s_hb.cfg.on_ms;
uint32_t elapsed = now_ms - s_hb.cycle_start_ms; /* wrap-safe */ if (elapsed >= period) { s_hb.cycle_start_ms = now_ms; elapsed = 0u; }
bool want_on = elapsed < on; if (want_on != s_hb.led_on) { s_hb.led_on = want_on; s_hb.cfg.led(want_on); }}What to notice:
- The state is one
static structnameds_hb. Grouping it makes the module’s whole state visible at a glance, lets a debugger print it with one command (p s_hb), and makes it obvious which data an ISR might share. heartbeat_default_configfills defaults. The caller starts from defaults and overrides fields, so adding a new field later does not break existing callers. A config struct is also much easier to read at the call site than an init function with seven positional arguments.heartbeat_initvalidates and can fail. A module that silently acceptson_ms > period_msproduces a bug report weeks later; one that returnsfalseat boot produces a fix in minutes.heartbeat_processnever blocks. It looks at the time, does at most one small thing, and returns. The main loop calls it as often as it likes. Compare that to adelay_ms(100)inside the module, which would stall every other module.- The module has no idea how an LED is driven. The board supplies
led. On the target that writesBSRR; in a host test it records calls in an array. Testing and debugging C on the host uses exactly this seam. - The time is a parameter.
heartbeat_process(now_ms)takes the time instead of reading a global clock, which makes it deterministic and trivial to test.
The main loop then reads like a table of contents:
#include <stdbool.h>#include <stdint.h>
#include "board.h"#include "heartbeat.h"#include "systick.h"
int main(void){ board_init();
heartbeat_config_t hb_cfg; heartbeat_default_config(&hb_cfg); hb_cfg.led = board_led_green; if (!heartbeat_init(&hb_cfg)) { board_panic(); }
for (;;) { uint32_t now = systick_now_ms(); heartbeat_process(now); /* other_module_process(now); ... */ }}Opaque types and handles
Section titled “Opaque types and handles”The heartbeat module has one instance, so a single static struct is enough. A UART driver has several instances, and callers need a way to say which one. The cleanest way in C is an opaque handle: the header declares the struct type but never defines it.
#ifndef UART_H#define UART_H
#include <stdbool.h>#include <stddef.h>#include <stdint.h>
#ifdef __cplusplusextern "C" {#endif
typedef struct uart uart_t; /* incomplete: members are private */
typedef enum { UART_PORT_CONSOLE, /* USART2 on PA2/PA3 on the Discovery */ UART_PORT_AUX, UART_PORT_COUNT} uart_port_t;
typedef struct { uint32_t baud;} uart_config_t;
uart_t *uart_open(uart_port_t port, const uart_config_t *cfg);void uart_close(uart_t *u);size_t uart_write(uart_t *u, const uint8_t *data, size_t len);size_t uart_read(uart_t *u, uint8_t *data, size_t len);uint32_t uart_rx_overruns(const uart_t *u);
#ifdef __cplusplus}#endif
#endifCallers can hold and pass uart_t * but cannot dereference it, take sizeof(uart_t), or declare a uart_t variable. Any attempt is a compile error. The struct’s layout is genuinely private, so the driver can add fields, reorder them or change the buffer size without recompiling a single caller.
The definition lives in uart.c. Firmware does not malloc the instances; it keeps a static array, one per port, and hands out pointers into it:
#include "uart.h"
#include "ringbuf.h"
#define UART_RX_BUF_SIZE 128u
struct uart { uart_port_t port; bool in_use; uint32_t baud; uint32_t overruns; ringbuf_t rx; uint8_t rx_storage[UART_RX_BUF_SIZE];};
static struct uart s_uarts[UART_PORT_COUNT];
/* Register-level code (clock enable, GPIO AF7, BRR, CR1) is the subject of * the UART lesson; it is reduced to stubs here to show the module structure. */static void hw_configure(uart_port_t port, uint32_t baud){ (void)port; (void)baud;}
static size_t hw_send(uart_port_t port, const uint8_t *data, size_t len){ (void)port; (void)data; return len;}
uart_t *uart_open(uart_port_t port, const uart_config_t *cfg){ if ((unsigned)port >= UART_PORT_COUNT || cfg == NULL || cfg->baud == 0u) { return NULL; } struct uart *u = &s_uarts[port]; if (u->in_use) { return NULL; /* already open: one owner per port */ } u->port = port; u->baud = cfg->baud; u->overruns = 0u; (void)rb_init(&u->rx, u->rx_storage, sizeof u->rx_storage); hw_configure(port, cfg->baud); u->in_use = true; return u;}
void uart_close(uart_t *u){ if (u != NULL) { u->in_use = false; }}
size_t uart_write(uart_t *u, const uint8_t *data, size_t len){ if (u == NULL || !u->in_use || data == NULL) { return 0u; } return hw_send(u->port, data, len);}
size_t uart_read(uart_t *u, uint8_t *data, size_t len){ if (u == NULL || !u->in_use || data == NULL) { return 0u; } return rb_read(&u->rx, data, len);}
uint32_t uart_rx_overruns(const uart_t *u){ return (u != NULL) ? u->overruns : 0u;}The typedef struct uart uart_t; in the header and struct uart { ... }; in the source refer to the same type: the tag uart names it, and the type stays incomplete everywhere except after the definition in uart.c.
Opaque handles have costs, and you should know them before you make every type opaque:
| Cost | Consequence | Common workaround |
|---|---|---|
| callers cannot allocate the object | the module must own storage (static array or pool) | fixed instance array, as above |
| accessors cannot be inlined across files | a function call for every field read | accept it, or use -flto |
| no compile-time size for the caller | cannot embed a uart_t inside another struct | expose the struct with a “do not touch” comment instead |
Some libraries offer a middle path: the header defines an opaque storage type of the right size, such as a struct holding an array of uint32_t words, and the .c file _Static_asserts that the real struct fits and has no stricter alignment. Callers can then allocate instances statically while the members stay hidden. It works, but it reintroduces a size dependency and type punning between two struct types, so use it only when you need caller-owned instances.
Naming conventions
Section titled “Naming conventions”C has one flat namespace, so names carry the module structure. A consistent scheme used by most firmware codebases:
| Kind | Convention | Example |
|---|---|---|
| public function | module_verb_noun | uart_write, heartbeat_set_fault |
| public type | module_noun_t | uart_config_t, uart_port_t |
| public constant or macro | MODULE_NOUN | UART_PORT_COUNT, HEARTBEAT_MAX_RATE |
| enum values | MODULE_ENUM_VALUE | UART_PORT_CONSOLE |
| private function | no prefix needed, but static | hw_configure |
| file-scope static variable | s_ prefix | s_hb, s_uarts |
| global variable (rare) | g_ prefix | g_systick_ms |
The prefix is your namespace: uart_init cannot collide with spi_init, and anyone reading heartbeat_process(now) in main knows which file to open. The s_ and g_ prefixes make shared state stand out in code review. One caveat: POSIX reserves names ending in _t. Embedded C uses the suffix everywhere anyway and collisions are rare, but now you know why some style guides avoid it.
Board files and config.h
Section titled “Board files and config.h”Firmware runs on more than one board over its life: the Discovery kit, the first prototype, revision B with the LED moved to another pin. Keep everything that changes between boards in one place, the board support files, and keep it out of the modules.
#ifndef BOARD_H#define BOARD_H
#include <stdbool.h>#include <stdint.h>
#define BOARD_NAME "STM32F4-Discovery"#define BOARD_HSE_HZ 8000000u#define BOARD_LED_PORT_IDX 3u /* GPIOD */#define BOARD_LED_GREEN_PIN 12u#define BOARD_LED_RED_PIN 14u#define BOARD_BUTTON_PIN 0u /* PA0, active high */
void board_init(void);void board_led_green(bool on);void board_led_red(bool on);_Noreturn void board_panic(void);
#endifSelect the board with the include path, not with #ifdef inside every file. Each board directory has its own board.h and board.c with the same function names, and the build adds -Ibsp/stm32f4disco or -Ibsp/proto_revb. The application code says #include "board.h" and never changes.
The alternative, a chain of #if defined(BOARD_DISCOVERY) ... #elif defined(BOARD_PROTO) ... spread across many files, works for two boards and becomes unreadable at five. If you do need it, confine it to one header and end the chain with #error "no board selected" so a missing -D flag fails loudly.
Compile-time feature configuration goes in a config.h with overridable defaults:
#ifndef CONFIG_H#define CONFIG_H
#ifndef CONFIG_LOG_LEVEL#define CONFIG_LOG_LEVEL 2 /* 0 off, 1 error, 2 info, 3 debug */#endif
#ifndef CONFIG_UART_CONSOLE_BAUD#define CONFIG_UART_CONSOLE_BAUD 115200u#endif
#if CONFIG_LOG_LEVEL < 0 || CONFIG_LOG_LEVEL > 3#error "CONFIG_LOG_LEVEL must be 0..3"#endif
#endifA release build passes -DCONFIG_LOG_LEVEL=1; a developer leaves the default. The #error check turns a typo into a build failure instead of a silent misconfiguration. Larger projects (Zephyr, ESP-IDF) generate this file from a Kconfig menu, which is the same idea with a user interface.
Static libraries and link order
Section titled “Static libraries and link order”A static library is an archive of object files with an index of the symbols they define. Third-party code (a crypto library, a vendor HAL) usually arrives this way, and you may want to package your own drivers as one. Using host gcc for the demonstration:
gcc -std=c11 -Wall -Wextra -Wpedantic -O2 -c heartbeat.c -o heartbeat.ogcc -std=c11 -Wall -Wextra -Wpedantic -O2 -c ringbuf.c -o ringbuf.oar rcs libfw.a heartbeat.o ringbuf.oar t libfw.anm libfw.aheartbeat.oringbuf.o
heartbeat.o:0000000000000000 T heartbeat_default_config0000000000000000 T heartbeat_init0000000000000000 T heartbeat_process0000000000000000 T heartbeat_set_fault0000000000000000 b s_hb...ar rcs means: replace or insert members (r), create the archive without complaining (c), and write the symbol index (s, what ranlib does). The addresses in a real nm listing differ per symbol; the point is the T (defined, global) and b (defined, local) letters.
Link against it with -L for the directory and -l for the name without the lib prefix and .a suffix:
gcc main.o -L. -lfw -o app # worksgcc -L. -lfw main.o -o app # failsAt -O2 GCC places main in a section called .text.startup, which is why it appears in the error:
/usr/bin/ld: main.o: in function `main':main.c:(.text.startup+0x1d): undefined reference to `heartbeat_init'collect2: error: ld returned 1 exit statusThe order matters because of how the GNU linker treats archives. It processes inputs left to right, keeping a list of symbols that are still undefined. Object files are always linked in whole. An archive is scanned once, at the moment it appears, and only the members that define a currently undefined symbol are pulled in. In the failing command, the linker reaches libfw.a when nothing is undefined yet, takes nothing from it, then meets main.o and its new undefined reference with no archive left to search.
The rules that follow:
- Put libraries after the objects that use them.
- If library A uses library B, write
-lA -lB. - If two libraries need each other, wrap them as
-Wl,--start-group -lA -lB -Wl,--end-group, which makes the linker rescan the group until nothing new resolves.
Object-oriented C, briefly
Section titled “Object-oriented C, briefly”The UART handle above is already halfway to object-oriented C: a struct holding an object’s state, and functions that take a pointer to it as their first argument, like self or this. Add a struct of function pointers (an “ops table”) that every implementation fills in, and code can call dev->ops->write(dev, buf, len) without knowing whether the device is a UART, a USB CDC port or a fake in a unit test. That is how Linux drivers, Zephyr and most serious firmware achieve polymorphism without C++. The full pattern, including embedding a base struct and container_of, is in Design patterns for embedded C; for now, recognise the shape when you see it in vendor code.
A directory layout for a firmware project
Section titled “A directory layout for a firmware project”The rest of this course uses this layout:
firmware/├── Makefile build entry point├── include/ project-wide headers (config.h, common types)├── src/ application modules (main.c, heartbeat.c, shell.c)├── drivers/ MCU peripheral drivers (gpio.c/.h, uart.c/.h, spi.c/.h)├── bsp/│ ├── stm32f4disco/ board.h, board.c for the Discovery kit│ └── proto_revb/ the same API for your own hardware├── platform/ startup.c, link.ld, stm32f407_regs.h├── lib/ third-party code, unmodified, one directory per library├── tests/ host unit tests and fakes└── build/ generated: objects, .elf, .map (never committed)The include path for a target build is -Iinclude -Isrc -Idrivers -Ibsp/stm32f4disco -Iplatform. Host tests use -Iinclude -Isrc -Itests/fakes and never see drivers/ or platform/, which is how you guarantee that application logic does not reach into registers.
Dependencies point one way: src/ may include drivers/ and bsp/ headers; drivers/ include platform/; nothing in drivers/ includes anything from src/. If a driver needs to notify the application, it takes a callback, as the heartbeat module took led. Driver design and hardware abstraction turns this into the layering rule for whole systems.
Common mistakes
Section titled “Common mistakes”- Source file not including its own header. Declaration and definition drift apart silently. Fix: include it first in every
.cfile, and build with-Wmissing-prototypes. - Variable defined in a header. Multiple definition link errors (or silent merging on old compilers). Fix:
externdeclaration in the header, one definition in a.cfile, or better, astaticvariable with accessors. - Non-static helper functions. They pollute the global namespace and block optimization. Fix:
staticby default; remove it only when a function joins the public API. - Headers that depend on include order. A header that compiles only after
stdint.hbreaks the next person. Fix: every header includes what it uses. - Including a full header just to name a pointer type. Rebuild cascades and include cycles. Fix: forward-declare the struct.
#ifdef BOARD_Xscattered through drivers. Fix: oneboard.hper board, selected by include path.- Library before objects on the link line.
undefined referencefor functions that clearly exist. Fix: objects first, then libraries in dependency order. - ISR in its own file inside a static library. The weak default handler wins. Fix: link the object directly or force it in.
Exercises
Section titled “Exercises”- Reproduce the mismatched declaration bug: write
led.handled.cwith different parameter types, call the function frommain.c, and confirm it builds without warnings. Then add#include "led.h"as the first line ofled.cand record the error message. - Write a module
counterwithcounter_init,counter_increment,counter_getand its state in astatic struct. Build it with-Wmissing-prototypes, then add a helper function withoutstaticand observe the warning. Usenm counter.oto show that the state variable is a lowercasebsymbol. - Package the ring buffer from Memory: where everything lives and your
countermodule intolibfw.a. Link a test program against it with the library before and aftermain.oand explain the failure using the linker’s left-to-right algorithm. - Make the heartbeat module multi-instance: replace the single
static structwith an opaqueheartbeat_thandle and a static array of four instances, keeping the same behavior. Drive two instances with different periods from one main loop on your host, printing LED transitions with timestamps. - Reproduce the weak-handler trap on the target: put a strong
SysTick_Handlerthat increments a counter in its own file, archive it into a library, link the firmware against the library, and usearm-none-eabi-nm firmware.elfand the map file to show which definition ofSysTick_Handlerwas linked. Then fix it with-Wl,--whole-archiveand show the difference.
Check yourself
Section titled “Check yourself”Question 1:main.c calls uart_write, defined in libdrivers.a. The link command is arm-none-eabi-gcc -ldrivers main.o -o fw.elf. What happens?
GNU ld scans each archive once, when it reaches it on the command line, and only pulls members that resolve symbols undefined at that moment. Libraries must come after the objects that use them.
Question 2:Which of these is safe to put in a header included by many .c files?
A static inline function gives each translation unit its own private copy, so there is no duplicate symbol. The other three are definitions with external linkage and would be defined once per including file.
Question 3:uart.h contains typedef struct uart uart_t; and no struct body. In main.c, which line compiles?
uart_t is an incomplete type outside uart.c. You can declare and pass pointers to it, but you cannot create an instance, take its size or access its members.
Question 4:A driver's USART2_IRQHandler is in uart_isr.c, built into libdrivers.a. The startup file defines USART2_IRQHandler as a weak alias. The application calls functions in uart.c from the same library, but received bytes never arrive. What is the most likely cause?
The linker only extracts archive members to resolve undefined symbols, and a weak definition counts as defined. Nothing else references uart_isr.o, so the default handler stays in the vector table.
Question 5:Why does heartbeat_process take now_ms as a parameter instead of reading the SysTick counter itself?
Passing time in decouples the module from the clock source. A host test can call it with 0, 50, 100, 1000 and check the LED callback, with no timer and no waiting.
Key takeaways
Section titled “Key takeaways”- The compiler sees one translation unit at a time and trusts declarations completely; include each module’s header first in its own
.cfile so mismatches become errors. - Headers hold the contract: prototypes, public types, API macros and
static inlinehelpers. Never put variable definitions or ordinary function definitions in a header. staticis C’s only encapsulation. Make every file-scope function and variablestaticunless it is deliberately part of the module’s interface.- The firmware module pattern is
initwith a validated config struct, a non-blockingprocess, private state in onestatic struct, and hardware reached through the board or injected function pointers. - Opaque handles (
typedef struct uart uart_t;) give real information hiding; the module owns the instances in a static array. - Board differences belong in one
board.hper board, selected by include path, not in#ifdefchains spread through the code. - GNU ld scans archives once, left to right, pulling only members that resolve undefined symbols: objects first, libraries after, and beware of ISRs hidden in archives behind weak defaults.
Next: Undefined behavior, volatile, and what the optimizer assumes, where you learn why correct-looking C can compile into something you did not write.