Skip to content

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.

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.

Here is the first consequence going wrong. The header says one thing, the source file another:

led.h
#ifndef LED_H
#define LED_H
#include <stdint.h>
void led_set_brightness(uint8_t percent);
#endif
led.c
#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).

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 headerBelongs in the .c file
function prototypes of the public APIfunction 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 everywherestatic helper functions
extern declarations of the rare public variablethe variable definitions themselves
the #includes the header itself needsthe #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;
}

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 __cplusplus
extern "C" {
#endif
/* declarations */
#ifdef __cplusplus
}
#endif

Keep 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.

You do not need a struct’s full definition to declare a pointer to it. A forward declaration (an incomplete type) is enough:

logger.h
#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);
#endif

logger.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.

A global variable shared across files needs a declaration in a header and exactly one definition in a source file:

systick.h
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_ms jumps 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:

systick.c
#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.

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.

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 struct inside the .c file.
  • 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.

heartbeat.h
#ifndef HEARTBEAT_H
#define HEARTBEAT_H
#include <stdbool.h>
#include <stdint.h>
#ifdef __cplusplus
extern "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
heartbeat.c
#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 struct named s_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_config fills 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_init validates and can fail. A module that silently accepts on_ms > period_ms produces a bug report weeks later; one that returns false at boot produces a fix in minutes.
  • heartbeat_process never 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 a delay_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 writes BSRR; 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:

main.c
#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); ... */
}
}

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.

uart.h
#ifndef UART_H
#define UART_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#ifdef __cplusplus
extern "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
#endif

Callers 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:

uart.c
#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:

CostConsequenceCommon workaround
callers cannot allocate the objectthe module must own storage (static array or pool)fixed instance array, as above
accessors cannot be inlined across filesa function call for every field readaccept it, or use -flto
no compile-time size for the callercannot embed a uart_t inside another structexpose 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.

C has one flat namespace, so names carry the module structure. A consistent scheme used by most firmware codebases:

KindConventionExample
public functionmodule_verb_nounuart_write, heartbeat_set_fault
public typemodule_noun_tuart_config_t, uart_port_t
public constant or macroMODULE_NOUNUART_PORT_COUNT, HEARTBEAT_MAX_RATE
enum valuesMODULE_ENUM_VALUEUART_PORT_CONSOLE
private functionno prefix needed, but statichw_configure
file-scope static variables_ prefixs_hb, s_uarts
global variable (rare)g_ prefixg_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.

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.

bsp/stm32f4disco/board.h
#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);
#endif

Select 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:

include/config.h
#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
#endif

A 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.

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:

Terminal window
gcc -std=c11 -Wall -Wextra -Wpedantic -O2 -c heartbeat.c -o heartbeat.o
gcc -std=c11 -Wall -Wextra -Wpedantic -O2 -c ringbuf.c -o ringbuf.o
ar rcs libfw.a heartbeat.o ringbuf.o
ar t libfw.a
nm libfw.a
heartbeat.o
ringbuf.o
heartbeat.o:
0000000000000000 T heartbeat_default_config
0000000000000000 T heartbeat_init
0000000000000000 T heartbeat_process
0000000000000000 T heartbeat_set_fault
0000000000000000 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:

Terminal window
gcc main.o -L. -lfw -o app # works
gcc -L. -lfw main.o -o app # fails

At -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 status

The 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.

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.

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.

  • Source file not including its own header. Declaration and definition drift apart silently. Fix: include it first in every .c file, and build with -Wmissing-prototypes.
  • Variable defined in a header. Multiple definition link errors (or silent merging on old compilers). Fix: extern declaration in the header, one definition in a .c file, or better, a static variable with accessors.
  • Non-static helper functions. They pollute the global namespace and block optimization. Fix: static by default; remove it only when a function joins the public API.
  • Headers that depend on include order. A header that compiles only after stdint.h breaks 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_X scattered through drivers. Fix: one board.h per board, selected by include path.
  • Library before objects on the link line. undefined reference for 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.
  1. Reproduce the mismatched declaration bug: write led.h and led.c with different parameter types, call the function from main.c, and confirm it builds without warnings. Then add #include "led.h" as the first line of led.c and record the error message.
  2. Write a module counter with counter_init, counter_increment, counter_get and its state in a static struct. Build it with -Wmissing-prototypes, then add a helper function without static and observe the warning. Use nm counter.o to show that the state variable is a lowercase b symbol.
  3. Package the ring buffer from Memory: where everything lives and your counter module into libfw.a. Link a test program against it with the library before and after main.o and explain the failure using the linker’s left-to-right algorithm.
  4. Make the heartbeat module multi-instance: replace the single static struct with an opaque heartbeat_t handle 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.
  5. Reproduce the weak-handler trap on the target: put a strong SysTick_Handler that increments a counter in its own file, archive it into a library, link the firmware against the library, and use arm-none-eabi-nm firmware.elf and the map file to show which definition of SysTick_Handler was linked. Then fix it with -Wl,--whole-archive and show the difference.

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?

Question 2:Which of these is safe to put in a header included by many .c files?

Question 3:uart.h contains typedef struct uart uart_t; and no struct body. In main.c, which line compiles?

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?

Question 5:Why does heartbeat_process take now_ms as a parameter instead of reading the SysTick counter itself?

  • The compiler sees one translation unit at a time and trusts declarations completely; include each module’s header first in its own .c file so mismatches become errors.
  • Headers hold the contract: prototypes, public types, API macros and static inline helpers. Never put variable definitions or ordinary function definitions in a header.
  • static is C’s only encapsulation. Make every file-scope function and variable static unless it is deliberately part of the module’s interface.
  • The firmware module pattern is init with a validated config struct, a non-blocking process, private state in one static 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.h per board, selected by include path, not in #ifdef chains 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.