C Basics & the Translation Unit
From source text to a linked image: declarations against definitions, the translation unit the compiler actually sees, the preprocessor and what a macro can and cannot do, and the four build stages with the error vocabulary each one produces.
Embedded C Workbench: From Source Text to Physical Behavior
Embedded C is not a different C language. It is ISO C used where every abstraction eventually becomes a fixed address, instruction sequence, bus transaction, interrupt boundary, deadline, or recovery behavior. A correct firmware engineer must reason across several layers at once: what the C abstract machine promises, what the compiler may transform, what the ABI and linker place, what startup initializes, what the processor and peripherals observe, and what tests or measurements prove on the target. The workbench organizes those layers into a dependency-correct path so a learner never memorizes a keyword without seeing the physical contract it creates.
How it is built
- The language layer defines objects, types, values, lifetimes, expressions, functions, and undefined behavior. This is the optimizer's legal contract. If a program has a data race, signed overflow, invalid pointer, out-of-bounds access, or lifetime violation, the compiler is not required to preserve the behavior the programmer imagined.
- The translation layer preprocesses each source file, compiles it independently, and emits an object file containing machine code, data, symbols, relocations, and debug information. Headers share declarations; they do not merge source files. A declaration introduces a contract, while exactly one compatible definition supplies storage or executable code.
- The link and startup layer resolves symbols, places sections into the target memory map, emits the image, installs the vector table, copies initialized data from flash to RAM, clears zero-initialized storage, initializes the runtime, and only then calls main. A section attribute is merely a request until the linker script and startup code implement it.
- The hardware boundary maps volatile objects onto peripheral addresses, uses masks for register fields, moves data through buses and DMA, and receives asynchronous events as interrupts. Datasheet access semantics such as read-only, write-one-to-clear, read-to-clear, and atomic set/clear registers matter more than the C lvalue syntax used to reach them.
- The runtime layer coordinates the main loop, state machines, timers, ISRs, RTOS tasks, DMA engines, and caches. Every shared buffer needs one current owner, a bounded capacity, a publication action, completion evidence, and an overload policy. Volatile alone supplies none of those properties.
- The evidence layer combines warning-clean builds, static analysis, unit and property tests, sanitizers and fuzzing on a host, fault injection, map and stack reports, debugger or trace evidence, and timing measurements on the real target. No single tool proves production firmware correct.
Design procedure
- Begin with the product contract: input rates, bursts, deadlines, reset behavior, power states, RAM and flash budgets, safety response, and required observability. These facts determine capacities and architecture before code style matters.
- Learn the C object model and integer rules before touching registers. Practice exact-width boundaries, checked conversions, arrays with explicit counts, pointer lifetime, const placement, and status-plus-output APIs under strict warnings.
- Trace a tiny program through preprocessing, compilation, assembly, linking, reset, startup, and main. Read its object symbols, disassembly, section table, map file, and reset handler until every byte has an origin and destination.
- Add the hardware boundary through a fake MMIO backend first. Encode register masks and side effects, then run the same driver logic against a target adapter. This separates testable policy from untestable hard-coded addresses.
- Add asynchronous execution deliberately. Draw ownership for each shared object across main, ISR, task, and DMA contexts. Select atomics, critical sections, queues, barriers, and cache maintenance from the target contract rather than folklore.
- Close with evidence. Inject every bounded failure, corrupt and truncate inputs, reset at persistence boundaries, measure stack and timing on target, and defend what remains implementation-defined or platform-specific.
Key terms
- abstract machine
- The behavior ISO C defines independently of any particular processor; the optimizer must preserve only observable behavior of valid programs.
- translation unit
- One preprocessed source file compiled independently. Internal and external linkage are defined at this boundary.
- ABI
- The target contract for calling convention, data layout, register use, stack alignment, object format, and binary interoperability.
- memory map
- The address ranges assigned to flash, RAM, peripherals, external memory, and special regions by the processor and board design.
- MMIO
- Memory-mapped input/output: peripheral registers accessed through addresses in the processor's load/store space.
- proof obligation
- A specific claim the module must establish, such as no partial write, bounded execution, ownership conservation, or recovery after a torn update.
Worked example
// One tiny line crosses every layer of the workbench.
#define GPIO_OUT (*(volatile uint32_t *)0x40020014u)
void led_on(void)
{
GPIO_OUT |= UINT32_C(1) << 5;
}
// Language: volatile requires the access; uint32_t fixes the width.
// Compiler: emits a load, OR, and store unless a target intrinsic replaces it.
// Linker: places led_on in executable flash.
// Startup: configures stack/data before this function can run.
// Bus: the address decoder selects the GPIO peripheral.
// Peripheral: register access semantics decide the electrical result.
// Evidence: disassembly, fake-MMIO test, and a target trace prove the chain.Common pitfalls
Toolchain, Linker, Reset, and Startup: How main Actually Begins
A C compiler does not turn a whole project directly into firmware. Each translation unit passes through preprocessing and compilation to produce an object file with code, data, symbols, relocations, and debug records. The linker resolves references across object files and libraries, applies a linker script to place input sections into the target memory map, and emits an executable image. After reset, the processor does not magically call main: architecture-specific reset logic establishes the stack, copies initialized objects from their load address in nonvolatile memory to their execution address in RAM, clears zero-initialized storage, initializes runtime facilities, and transfers control to main.
How it is built
- Preprocessing handles includes, macro expansion, conditional compilation, line control, and pragmas. The compiler parses the resulting translation unit, checks types, optimizes valid C semantics, and emits assembly or machine code. Inspecting preprocessed output explains macro bugs that are invisible in the original source.
- The assembler produces relocatable object sections such as text, read-only data, initialized data, zero-initialized data, unwind/debug records, and target-specific metadata. A symbol may be local, global, weak, defined, or undefined. Relocations mark addresses that cannot be finalized until layout is known.
- The linker script describes memory regions and output sections. It collects input sections, aligns them, chooses virtual and load addresses, exports boundary symbols, discards or retains sections, checks region overflow, and selects the entry point. Garbage collection removes unreferenced sections unless a root or KEEP rule preserves them.
- On Cortex-M, reset loads the initial stack pointer and reset-handler address from the vector table. The reset handler may configure clocks and memory, copy .data, clear .bss, initialize C/C++ runtime arrays, and call main. The exact order is supplied by the runtime and board support package, not ISO C.
- Initialized writable objects have two addresses in the image model: bytes stored in flash and a runtime address in RAM. Zero-initialized objects need no payload bytes in flash; the image records only size and location, and startup clears them. noinit storage deliberately skips initialization and must validate retained content before use.
- The map file and disassembly are primary evidence. The map accounts for section sizes, symbol addresses, archive selection, padding, and region usage; disassembly shows instruction choice, inlining, volatile accesses, stack adjustment, and whether expensive library helpers were linked.
Design procedure
- Compile a two-file program with preprocessing, assembly, and object output preserved. Use symbol and section tools to identify each definition, unresolved reference, input section, relocation, and source line mapping.
- Introduce one static function, one external function, initialized and zero-initialized globals, const data, a weak handler, and a custom section. Predict their linkage and section before inspecting the object file.
- Read the linker script from MEMORY through SECTIONS. Trace flash and RAM regions, vector placement, text/rodata, data load and run addresses, bss boundaries, stack/heap reservations, alignment, and overflow assertions.
- Single-step reset on a target or simulator. Observe initial stack load, reset handler, data copy, bss clear, clock setup, runtime hooks, and main. Confirm interrupt vectors point at the intended handlers.
- Generate and review the map on every release. Set flash and RAM budgets in the build, report largest symbols, track unexplained growth, and fail when regions overlap or cross reserved boundaries.
- Verify optimization-sensitive code in disassembly only when source-level rules are insufficient: MMIO sequences, barriers, naked handlers, boot handoff, exact instruction timing, and cryptographic constant-time claims.
Key terms
- relocation
- A record asking the linker to patch an address or offset once final symbol locations are known.
- VMA
- Virtual or execution address where a section is accessed at runtime.
- LMA
- Load address where a section's initial bytes are stored in the image, often flash for data that runs in RAM.
- weak symbol
- A fallback definition replaced when the linker finds a compatible strong definition.
- linker garbage collection
- Removal of unreferenced input sections, usually enabled with per-function/data sections and --gc-sections.
- vector table
- Architecture-defined table of initial stack and exception/interrupt handler addresses used at reset and exception entry.
Worked example
/* source */
uint32_t boot_count = 1U; /* payload in flash, runtime in RAM */
uint32_t samples[64]; /* .bss: size only, cleared by startup */
const char build_id[] = "A7"; /* .rodata in flash */
/* linker-script sketch */
MEMORY { FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K }
SECTIONS {
.isr_vector : { KEEP(*(.isr_vector)) } > FLASH
.text : { *(.text*) *(.rodata*) } > FLASH
.data : { __data_start__ = .; *(.data*) } > RAM AT> FLASH
.bss (NOLOAD) : { __bss_start__ = .; *(.bss*) *(COMMON)
__bss_end__ = .; } > RAM
}Common pitfalls
Objects, Values, and the Abstract Machine
C is defined in terms of an abstract machine, not the processor in front of you. The standard says what a program means in terms of objects with lifetimes, values with types, and side effects sequenced against each other; it deliberately says nothing about registers, instruction order, or how long anything takes. A compiler is free to do anything at all provided the observable behaviour of the abstract machine is preserved - and 'observable' is a much smaller set than most people assume. It covers volatile accesses, input and output, and the final state of the program. It does not cover the order of ordinary stores, whether a variable lives in memory at all, or whether a loop the compiler can prove has no effect exists in the binary. Understanding embedded C begins with taking that contract literally, because every surprising optimisation is the compiler taking it more literally than the programmer did.
How it is built
- An object is a region of storage with a type, a lifetime, and a value. Lifetime is the property that matters most in firmware: automatic objects live until their block exits, static objects live for the whole program, and using an object outside its lifetime is undefined rather than merely wrong. A pointer to a local returned from a function is the classic case, and it usually appears to work, which is what makes it dangerous.
- Undefined behaviour is not a runtime error and is not a crash. It is a licence: the standard removes all requirements, so the compiler may assume it never happens and optimise accordingly. A null check placed after a dereference can be deleted entirely, because the dereference already promised the pointer was not null. This is why 'it worked at -O0' proves nothing about the release build.
- Sequence points and, since C11, sequenced-before relationships define when side effects must have taken place. Between two sequence points, modifying the same object twice, or modifying it and reading it for any purpose other than computing the new value, is undefined. Expressions like i = i++ are not implementation-defined curiosities; they have no meaning at all.
- The as-if rule is the compiler's whole freedom. It may reorder, merge, duplicate, or delete any computation whose absence cannot be observed. In hosted code this is invisible. In firmware, where a spin on a hardware flag or a delay loop has no observable effect in the abstract machine's terms, it is the difference between working code and a hang - which is exactly what volatile exists to prevent.
- Integer types have implementation-defined widths, and only the exact-width types from stdint.h pin them down. int is at least sixteen bits, and on an eight-bit part it usually is exactly that, which turns a shift by twenty into undefined behaviour on one target and a working expression on another.
- Declarations and definitions are distinct. A declaration introduces a name and its contract; a definition allocates storage or emits code. Headers should carry declarations, because a definition in a header becomes a definition in every translation unit that includes it, and the linker will either reject the duplicates or silently merge them depending on the type.
Design procedure
- Compile with warnings as errors from the first line, and add the ones that are not on by default: shadowed variables, implicit conversions, missing prototypes, and unused results. Most undefined behaviour has a warning attached if you ask for it.
- Use the exact-width types from stdint.h for anything that touches hardware, a wire format, or a size calculation. Reserve plain int for loop counters over small ranges where promotion is harmless.
- Give every object the narrowest lifetime that works: block scope over file scope, and static only when the value genuinely has to outlive the call. A smaller lifetime is a smaller surface for aliasing and reentrancy bugs.
- Never write an expression that modifies an object twice, or modifies and reads it, between sequence points. If an expression needs a comment to explain its evaluation order, split it into statements.
- Treat every compiler warning about a comparison, a conversion, or an uninitialised value as a report of undefined behaviour until proven otherwise. The compiler is telling you where its assumptions and yours diverge.
- Run the same code on a host with the undefined-behaviour and address sanitizers before trusting it on target. They catch at runtime what the target will simply mis-execute.
Key terms
- Abstract machine
- The model C is defined against. The compiler must preserve its observable behaviour and nothing else.
- Observable behaviour
- Volatile accesses, I/O, and the program's final state. Ordinary stores are not observable and may be reordered or removed.
- Undefined behaviour
- Not an error - a licence for the compiler to assume the case never arises and optimise on that basis.
- As-if rule
- Any transformation is legal if its effect cannot be observed. The reason a delay loop can vanish.
- Lifetime
- The interval an object's storage is valid. Using it outside that interval is undefined and usually appears to work.
- Sequence point
- A place where all prior side effects have completed. Modifying an object twice between two of them has no meaning.
- Declaration vs definition
- A declaration names a contract; a definition allocates storage or emits code. Headers carry the former.
Worked example
/* Four expressions the abstract machine does not define. */
int i = 0;
i = i++; /* modified twice between sequence points */
a[i] = i++; /* read for a purpose other than the store */
int x = INT_MAX;
x = x + 1; /* signed overflow: UB, not wraparound */
uint8_t b = 1;
uint32_t v = b << 20; /* b promotes to int; on a 16-bit int
target this shift is UB */
/* Why -O0 proves nothing: */
void f(int *p) {
int v = *p; /* promises p is non-null ... */
if (p == NULL) return; /* ... so the compiler may DELETE this */
use(v);
}
# The abstract machine's observable set, in full:
#
# volatile accesses <- why MMIO needs it
# input and output
# the program's end state
#
# Not observable, therefore removable or reorderable:
# ordinary loads and stores
# an empty delay loop
# a computation whose result is unusedCommon pitfalls
The Preprocessor, Attributes, and Static Assertions
The preprocessor is a text transformer that runs before compilation and knows nothing about C. It does not understand types, scope, or precedence; it pastes tokens. That is why a macro can do things no function can - construct identifiers, capture the source line, compile a block out entirely - and why it fails in ways no function can, evaluating an argument twice or binding an operator more tightly than intended. Attributes are the opposite: compiler-specific annotations that give the optimiser and the linker information the language has no syntax for, such as packing a struct, placing an object in a section, or promising a function never returns. Between them sits _Static_assert, which moves a whole class of assumption from a comment into a build failure.
How it is built
- A function-like macro substitutes its arguments as token sequences, so each argument is evaluated wherever it appears in the body. A macro that uses its argument twice evaluates it twice, and MAX(i++, j) increments i once or twice depending on which branch wins. Wrapping the body and every parameter in parentheses fixes precedence but not multiple evaluation - only restraint does.
- Multi-statement macros need a do { ... } while (0) wrapper so they behave as a single statement in an unbraced if. Without it, an else attaches to the wrong if and the compiler accepts the result silently.
- The stringify and paste operators are what functions genuinely cannot do. Stringify turns an argument into a literal for logging; paste builds an identifier, which is how register-access and unit-test frameworks generate names. Both operate on tokens, so an argument that is itself a macro needs an extra expansion layer to be expanded first.
- Conditional compilation should test defined-ness of feature flags rather than compiler or vendor names wherever possible, and every branch should be built by something in CI. Code inside a never-selected #if is not compiled at all, so it rots without any warning until the day that branch is selected.
- Attributes are compiler extensions with no portable syntax before C23. packed removes padding and thereby creates potentially misaligned members, which on some cores makes ordinary member access a fault. section places an object for the linker script to find. noreturn, unused and fallthrough exist to suppress or enable specific diagnostics rather than to change code generation.
- _Static_assert is the cheapest verification in the language. It costs nothing at runtime and turns an assumption about a type width, a struct size, a buffer capacity or a power-of-two constraint into a compile error at the exact line that made the assumption.
Design procedure
- Prefer a static inline function to a function-like macro. It gets type checking, evaluates arguments once, and is equally fast at any optimisation level worth shipping.
- When a macro is genuinely necessary, parenthesise the whole body and every parameter use, and wrap multi-statement bodies in do { } while (0).
- Never pass an expression with side effects to a macro, and name macros in a way that signals they are macros so a caller can see the risk.
- Assert every structural assumption with _Static_assert: type widths, struct sizes, buffer capacities, and power-of-two requirements.
- Keep conditional compilation shallow and test it in CI. A configuration nobody builds is a configuration that does not compile.
- Isolate compiler attributes behind a single header of project macros so a toolchain change is one file rather than a search across the tree.
Key terms
- Function-like macro
- Token substitution with no type checking. Evaluates each argument wherever it appears.
- Multiple evaluation
- The defect a macro creates when it uses an argument more than once.
- do { } while (0)
- The wrapper that makes a multi-statement macro behave as one statement.
- Stringify and paste
- The two things a macro can do that a function cannot: make a literal, build an identifier.
- packed
- Removes padding and creates possibly-misaligned members. A fault on some cores.
- section
- Names the section for an object so the linker script can place it.
- _Static_assert
- A compile-time check with no runtime cost. Turns an assumption into a build error.
Worked example
/* The classic macro defects, and the fix. */
#define SQ_BAD(x) x * x
SQ_BAD(a + b) /* expands to a + b * a + b */
#define SQ(x) ((x) * (x))
SQ(a + b) /* correct precedence ... */
SQ(i++) /* ... but i increments twice */
static inline int sq(int x) { return x * x; } /* just use this */
/* Multi-statement macros need the wrapper: */
#define LOG2(a, b) do { log(a); log(b); } while (0)
if (x) LOG2(1, 2); else other(); /* correct only with do/while */
/* Assumptions that should fail the build, not the field: */
_Static_assert(sizeof(frame_t) == 16, "frame layout changed");
_Static_assert((RING_CAP & (RING_CAP - 1)) == 0, "capacity must be 2^n");
_Static_assert(sizeof(int) >= 4, "assumes 32-bit int");
# Macro vs inline function, on every axis that matters:
#
# macro static inline
# type checked no yes
# args evaluated once no yes
# debuggable no yes
# can build identifiers YES no
# can capture __LINE__ YES no
#
# Use a macro only for the two rows where it actually wins.Common pitfalls
More in Embedded C
- Interrupts, Rings & ConcurrencyThe handler is a second thread you did not declare. What is and is not atomic on a single core, the lock-free ring buffer and the conditions it requires, DMA and cache coherency, low-power modes and their wake sources, priority inversion, and debugging a race you cannot reproduce.
- Volatile RegistersMaster volatile keyword usage for memory-mapped registers in embedded C. Interactive simulator shows compiler optimization effects on register reads.
- User-Defined TypesDeclare your own types in embedded C: struct, union, enum, typedef, bitfields, designated initialisers, flexible array members and opaque handles.
- Embedded C + DSA 0 → 100One self-sufficient course connecting beginner C, Embedded C, hardware-facing APIs, bounded data structures, Embedded DSA practice, compiled code, diagnostics and production capstones.
- Bits, Fields & Fixed PointRegister fields and the mask conventions that silently disagree, Gray code and where one-bit-at-a-time matters, fixed-point arithmetic and the intermediate width a multiply needs, the undefined-behaviour traps in ordinary bit idioms, wire-format packing, and what each checksum detects.
- Types / PromotionUnderstand integer promotion and type conversion in embedded C. Interactive lab demonstrates implicit and explicit casting with signed/unsigned types.
- Compiler Workbench & TestingCompile real C for an embedded target and inspect what the compiler produced, then the discipline around it: where to draw the host-testable boundary, reading the generated assembly, undefined behaviour and the sanitizers, the warnings worth enabling, and measuring size and stack.
- Functions & ContractsDesign production C functions and modules with explicit preconditions, postconditions, ownership, status codes, opaque types, reentrancy, HAL seams and failure-state behavior.
- Arrays, Strings & BuffersArrays decay and the length does not travel; strings are a convention, not a type; and every length in a received packet is data rather than fact. Spans, the three string copies, framing and resynchronisation, serialisation, and parsing untrusted input safely.
- Object Layout & StorageWhere an object lives and what it costs: storage duration and linkage, the sections a declaration lands in, struct layout and the padding that makes a struct larger than its members, allocation without a heap, and integrity checks over stored data.
- FSM / DispatchDesign finite state machines with dispatch tables in embedded C. Interactive lab demonstrates state transitions and event handling for firmware.
- Embedded CA complete source-to-silicon workbench: C semantics, arrays and APIs, compiler and startup, MMIO, interrupts, DMA and caches, bounded systems, testing, safety evidence and production defense.