Functions & Contracts
Design production C functions and modules with explicit preconditions, postconditions, ownership, status codes, opaque types, reentrancy, HAL seams and failure-state behavior.
Functions, Module Boundaries, and Error Contracts
A function is more than a reusable block of statements. At the language level it establishes a call boundary with typed parameters and a return value; at the ABI level it becomes register transfers, stack frames, saved state, and control flow; at the architecture level it is the smallest practical unit for enforcing module invariants. Embedded APIs must make failure ordinary because hardware can be absent, queues can be full, buffers can be short, deadlines can expire, and flash can reject a write. The most reliable shape is often status plus output parameter, with a documented rule that output remains unchanged on failure.
How it is built
- A header exports declarations, opaque types, constants, and documented contracts. A source file owns definitions and private static helpers. Include guards prevent repeated declarations; they do not solve multiple external definitions accidentally placed in a header.
- The calling convention assigns arguments and return values to registers or stack slots, defines which registers the caller or callee saves, and requires stack alignment. Large structures may be returned through a hidden pointer. Recursion and large automatic objects consume stack according to the generated call graph, not source indentation.
- An API contract names preconditions, postconditions, invariants, side effects, ownership, valid aliasing, units, concurrency context, and worst expected cost. const on a pointed-to input lets the compiler check one part of that promise but does not express lifetime or thread safety.
- Status values should be enumerable and actionable: invalid argument, no space, busy, timeout, hardware fault, integrity failure. Boolean success loses diagnostic information; errno-style global state is awkward in interrupts and multi-context firmware.
- Opaque handles hide representation and preserve invariants. The header forward-declares a structure and exposes functions operating on its pointer, or the caller provides an explicitly sized context buffer when dynamic allocation is forbidden.
- Dependency inversion makes drivers testable. Business logic calls an interface of function pointers or thin adapter functions; production binds them to MMIO while host tests bind them to a deterministic fake that records transactions and injects failures.
Design procedure
- Write the contract in plain language before the prototype. State who owns each pointer, how many elements are accessible, whether pointers may alias, which contexts may call it, and what is true after every return code.
- Choose types that carry the boundary: exact-width integers for registers and wires, size_t for object sizes, enum for finite states, pointer-to-const for read-only input, and explicit structures for related parameters and units.
- Validate at the public boundary and keep internal helpers operating on already-proven invariants. Avoid defensive checks scattered everywhere; they obscure which layer owns validation and can create inconsistent failure states.
- Order effects so failure is transactional. Validate ranges and capacity, prepare new state, perform the fallible hardware operation, then publish the state transition. If rollback is impossible, expose the intermediate state and recovery action.
- Keep ISR APIs separate when blocking, allocation, locking, or non-lock-free atomics are possible. Name them explicitly and document their bounded execution and interrupt-priority assumptions.
- Test the contract, not just implementation lines. For every status, force the cause and verify return code, outputs, state, side effects, call order, and whether retry is legal.
Key terms
- precondition
- A fact the caller must establish before the call, such as a valid pointer and accessible element count.
- postcondition
- A fact the function establishes on return, often conditional on the returned status.
- invariant
- A property that remains true at every public module boundary.
- reentrant
- Safe for overlapping calls because it has no unprotected shared mutable state.
- opaque type
- A type whose representation is hidden from clients so only the owning module can mutate its invariants.
- HAL
- A hardware abstraction boundary that separates portable policy from target-specific register and timing operations.
Worked example
typedef enum {
UART_OK = 0,
UART_INVALID,
UART_BUSY,
UART_TIMEOUT,
UART_HW_FAULT
} uart_status_t;
/* Contract:
* - src points to count readable bytes for the duration of this call.
* - count == 0 is valid; src may then be NULL.
* - never blocks longer than timeout_ticks.
* - *sent is set only on UART_OK; no partial success is hidden.
* - task context only; not ISR-safe.
*/
uart_status_t uart_write(uart_t *uart,
const uint8_t *src,
size_t count,
uint32_t timeout_ticks,
size_t *sent);Common pitfalls
Declaration and definition: two different things
A declaration tells the compiler a function's name, parameter types and return type - enough to check and generate a call. A definition additionally supplies the body, which is what the linker needs. A program may declare a function many times and must define it exactly once. Almost every confusing linker error in C comes from getting this pair wrong: a declaration with no definition, two definitions, or a call with no declaration at all.
How it is built
- A declaration ends at the semicolon: int add(int a, int b); It generates no code.
- A definition has a body and produces a symbol the linker can resolve.
- The declaration must be visible at the point of call, or the compiler cannot check the arguments.
- In C23, and in C99 onward as a constraint violation, calling an undeclared function is an error rather than an implicit int assumption.
- An empty parameter list means something specific: f(void) takes no arguments, while old-style f() means unspecified, which disables checking.
Design procedure
- Put declarations in a header, define in exactly one source file, and include the header in that file so the two are checked against each other.
- Always write (void) for a function taking nothing; an empty list historically means unchecked, not empty.
- Include the module's own header first in its .c file, so a mismatch between declaration and definition fails there rather than at a call site.
- Read undefined reference as declared but never defined, and multiple definition as defined in more than one translation unit.
- Mark helpers static so they are neither exported nor able to collide with a name in another file.
Key terms
- Declaration
- Name, parameters and return type. No body, no code, no symbol.
- Definition
- A declaration plus a body. Emits a symbol for the linker.
- Prototype
- A declaration that specifies parameter types, enabling argument checking.
- Undefined reference
- A linker error: something called a function that no translation unit defined.
- One definition
- The rule that a function may be declared repeatedly but defined once across the program.
Worked example
The header/source split, and the trap in the empty list:
// math.h
int add(int a, int b); // declaration
// math.c
#include "math.h" // include your OWN header first:
int add(int a, int b) { // a mismatch is caught right here
return a + b;
}
Why (void) matters:
void reset(void); // takes NOTHING. reset(1) is an error.
void reset(); // UNSPECIFIED arguments (pre-C23).
// reset(1, 2, 3) compiles silently.
The two linker errors, and what each one means:
undefined reference to `add'
declared somewhere, defined nowhere. Missing .c in the build,
or a typo, or C++ name mangling on a C symbol.
multiple definition of `add'
defined in two translation units. Usually a function body in a
header without static or inline.Common pitfalls
Parameters, arguments, and why C only passes by value
C has exactly one argument-passing mechanism: by value. Every argument is copied into the parameter, and the function operates on the copy. Passing a pointer does not change this - the pointer itself is copied, and it is the fact that both copies hold the same address that lets the function reach the caller's object. Understanding it this way removes almost every confusion about when a function can change what it was given.
How it is built
- Parameters are local objects initialised from the arguments. Modifying a parameter never affects the caller.
- Passing &x copies the address. The function can write through it and the caller sees the change, because both point at the same object.
- An array argument decays to a pointer to its first element, so array parameters are pointers and sizeof on them gives a pointer's size.
- A struct passed by value is copied in full, including padding, which for a large struct is a memcpy per call.
- The calling convention decides which arguments arrive in registers and which on the stack. On Arm AAPCS the first four words are in r0-r3.
Design procedure
- Pass small scalars by value; passing a pointer to an int is usually slower and always more to read.
- Pass structs larger than two or three words by pointer, and add const if the function does not modify them.
- Pass an explicit length alongside every array parameter, because the array's size is not recoverable from the pointer.
- Return small structs by value where it is clearer; the ABI often handles two-word returns in registers.
- Use a pointer parameter for an output, and document whether the function may leave it untouched on failure.
Key terms
- Pass by value
- The argument is copied into the parameter. C's only mechanism.
- Array decay
- An array argument becomes a pointer to its first element at a call.
- Out parameter
- A pointer parameter the function writes through to return an additional result.
- AAPCS
- The Arm procedure call standard: r0-r3 for the first four words, the rest on the stack.
- Const pointer parameter
- const T *p - a promise not to modify what p points at.
Worked example
The classic swap, wrong and right, and why:
void swap_broken(int a, int b) { int t = a; a = b; b = t; }
a and b are COPIES. The caller's variables are untouched.
void swap(int *a, int *b) { int t = *a; *a = *b; *b = t; }
the POINTERS are copies, but they hold the caller's addresses,
so writing through them reaches the caller's objects.
swap(&x, &y);
The decay trap, which produces a wrong size with no warning:
void clear(uint8_t buf[64]) {
memset(buf, 0, sizeof buf); // sizeof is 4: buf is a POINTER
} // clears four bytes, not 64
void clear(uint8_t *buf, size_t n) {
memset(buf, 0, n); // the length must be passed
}
The 64 in the first version is documentation. The compiler ignores it.Common pitfalls
Storage class and linkage: static, extern, and inline
The same three keywords that control variables also control functions, and for functions the rules are simpler but their consequences are larger. static gives a function internal linkage, hiding it from every other translation unit and giving the optimiser freedom it otherwise lacks. extern is the default and rarely needs writing. inline is not an optimisation request - it is a linkage rule about where the definition may live.
How it is built
- Functions have external linkage by default: the symbol is visible to the whole program.
- static gives internal linkage. The function is invisible outside its file, which lets the compiler inline it freely and drop it entirely if unused.
- inline permits multiple definitions across translation units, which is why the keyword exists; whether the compiler actually inlines is unrelated.
- C's inline rules need exactly one external definition somewhere, usually written as extern inline in one .c file, or the linker may not find a callable copy.
- static inline in a header is the common embedded idiom: every file gets its own copy, none conflict, and unused ones are discarded.
Design procedure
- Mark every function not declared in a header static. It is the single most effective habit for both code size and clarity.
- Use static inline in headers for small helpers, which sidesteps C's external-definition rule entirely.
- Do not write inline expecting speed; measure, because a large inlined function can be slower through instruction-cache pressure.
- Use __attribute__((always_inline)) or noinline only where you have measured a reason, and comment the reason.
- Let link-time optimisation handle cross-file inlining rather than restructuring code to enable it manually.
Key terms
- Internal linkage
- Visible only within one translation unit. What static gives a function.
- External linkage
- Visible program-wide. The default for functions.
- inline
- A linkage permission allowing a definition in several translation units. Not a speed request.
- static inline
- The header idiom: private to each file, so no external definition is needed.
- LTO
- Link-time optimisation, which lets the compiler inline across translation units.
Worked example
Why static matters more than it looks:
// driver.c
static void configure_pins(void) { ... } // private
void driver_init(void) { configure_pins(); ... }
configure_pins cannot be called from elsewhere, so the compiler
knows every call site. It may inline it and emit no separate
function at all, and if driver_init is never called the whole
thing is discarded. A non-static version must always be emitted,
because some other file might call it.
The three ways to put a helper in a header:
static inline int clamp(int v, int lo, int hi) { ... }
each including file gets a private copy. Simple, always works,
and the usual embedded choice.
inline int clamp(...) { ... } in the header
extern inline int clamp(int, int, int); in exactly ONE .c
one shared external definition. Correct C99, and easy to get
wrong - the symptom is undefined reference at -O0, where the
compiler declined to inline and needed a real function.
int clamp(...); header
int clamp(...) {} one .c file
an ordinary function. No inlining across files without LTO.Common pitfalls
Function pointers, callbacks, and dispatch tables
A function pointer holds the address of code rather than data, and it is what lets C do polymorphism, callbacks, and table-driven dispatch. The declaration syntax is genuinely awkward, which is why a typedef is nearly always used, but the concept is simple: a variable holding which function to call, decided at runtime rather than at the call site.
How it is built
- int (*op)(int, int); declares a pointer to a function taking two ints and returning int. The parentheses are required, or it declares a function returning a pointer.
- A function name used without parentheses converts to a pointer to it, so op = add and op = &add mean the same thing.
- A call through the pointer may be written op(1, 2) or (*op)(1, 2); the first is idiomatic.
- An array of function pointers indexed by an enum turns a switch into a table lookup, which is constant time and easy to extend.
- A callback usually pairs a function pointer with a void * context, since the callee needs somewhere to keep its own state.
Design procedure
- Typedef the pointer type once and use the alias everywhere; the raw syntax is a genuine readability cost.
- Always check a function pointer against null before calling it, since an uninitialised one jumps to an arbitrary address.
- Pass a void * context alongside every callback, or the callee is forced to use globals.
- Bounds-check the index into any dispatch table before indexing, and keep a COUNT enumerator so the table sizes itself.
- Place const tables in flash, which on a microcontroller is the difference between an 8-byte RAM cost per entry and none.
Key terms
- Function pointer
- A pointer holding a code address rather than a data address.
- Callback
- A function pointer passed to another function so it can call back into the caller's code.
- Dispatch table
- An array of function pointers indexed by a tag, replacing a switch.
- Context pointer
- A void * carried alongside a callback so it can find its own state.
- Vector table
- The hardware's own dispatch table: an array of handler addresses the CPU indexes on an interrupt.
Worked example
Declaration, typedef, and the table:
int (*op)(int, int); // raw. Note the parens:
int *op2(int, int); // a FUNCTION returning int*
typedef int (*BinOp)(int, int); // much better
BinOp op = add; // no & needed
int r = op(2, 3);
typedef enum { CMD_READ, CMD_WRITE, CMD_COUNT } Cmd;
static const BinOp handlers[CMD_COUNT] = {
[CMD_READ] = do_read,
[CMD_WRITE] = do_write,
}; // const: lives in flash
if (cmd < CMD_COUNT && handlers[cmd]) // bounds AND null check
handlers[cmd](a, b);
The callback-with-context pattern, which is what makes callbacks reusable:
typedef void (*RxCallback)(const uint8_t *data, size_t n, void *ctx);
void uart_on_rx(Uart *u, RxCallback cb, void *ctx) {
u->cb = cb; u->ctx = ctx;
}
Without ctx, every callback has to reach a global to find its
own state, and the same callback cannot serve two instances.Common pitfalls
Variadic functions, and why embedded code avoids them
A variadic function takes a variable number of arguments, declared with an ellipsis and read with the va_list macros from stdarg.h. printf is the familiar example. The mechanism has no type information at all - the callee is told nothing about what was passed and must infer it from a format string or a sentinel - which makes variadic functions both useful and the least type-safe construct in the language.
How it is built
- The declaration needs at least one named parameter before the ellipsis, because va_start needs something to start from.
- va_start, va_arg, va_end walk the argument list; va_arg must be told each argument's type, and getting it wrong is undefined behaviour.
- Default argument promotions apply to every variadic argument: narrow integers become int, and float becomes double.
- There is no way for the callee to know how many arguments arrived; it must be told by a format string, a count, or a terminating sentinel.
- printf-family implementations are large, and full float support can add several kilobytes of flash to a small firmware image.
Design procedure
- Prefer a struct or an array parameter to a variadic function whenever the argument list is a fixed shape.
- If you must write one, add __attribute__((format(printf, n, m))) so the compiler checks the format string against the arguments.
- Never pass a user-controlled string as the format argument; that is a format-string vulnerability and it can write memory.
- Remember the promotions: reading a va_arg as char or float is always wrong, since neither can ever be passed.
- Use a size-limited variant such as snprintf, and check the return value, which is the length that WOULD have been written.
Key terms
- Ellipsis
- The ... in a parameter list, marking the function as variadic.
- va_list
- The opaque type holding the traversal state over the variable arguments.
- Default argument promotion
- Narrow integers become int and float becomes double when passed through ...
- Sentinel
- A terminating value such as NULL marking the end of the argument list.
- format attribute
- A GCC/Clang attribute enabling printf-style checking on your own function.
Worked example
Writing one, and getting it checked:
#include <stdarg.h>
__attribute__((format(printf, 2, 3)))
void log_msg(LogLevel lvl, const char *fmt, ...) {
if (lvl < threshold) return;
va_list ap;
va_start(ap, fmt);
vsnprintf(buf, sizeof buf, fmt, ap); // pass the va_list on
va_end(ap);
emit(buf);
}
The attribute means log_msg(LOG_INFO, "%d", "text") is now a
compile-time warning rather than a runtime surprise.
The promotions, which make two va_arg types impossible:
va_arg(ap, char) ALWAYS WRONG - a char was promoted to int
va_arg(ap, float) ALWAYS WRONG - a float was promoted to double
va_arg(ap, int) correct for char, short, int
va_arg(ap, double) correct for float and double
And the vulnerability:
printf(user_input); // if user_input contains %n, this
// WRITES to memory
printf("%s", user_input); // correctCommon pitfalls
Recursion, stack depth, and the embedded objection
A recursive function calls itself, directly or through a cycle. It is often the clearest expression of an algorithm over a tree or a nested structure, and it is viewed with suspicion in embedded code for one concrete reason: each call consumes stack, the depth is frequently data-dependent, and a microcontroller's stack is a few kilobytes with nothing between it and whatever sits below.
How it is built
- Each call allocates a stack frame for its locals, saved registers and return address. Depth times frame size is the total cost.
- The depth is often determined by input rather than by code, so the worst case cannot be read off the source.
- Tail recursion, where the recursive call is the last action, can be turned into a loop by the compiler - but only at optimisation levels that enable it, so it is not a guarantee.
- Stack overflow on a microcontroller usually has no guard page: the stack simply grows into .bss and corrupts variables silently.
- -fstack-usage reports each function's frame size, which is the only reliable way to compute a worst case.
Design procedure
- Prefer an explicit loop with an explicit stack array where the depth is data-dependent; the bound then lives in your code.
- If you use recursion, bound the depth explicitly and return an error when the bound is reached, rather than trusting the input.
- Measure frame size with -fstack-usage and multiply by the maximum depth, rather than estimating.
- Enable the MPU stack guard or fill the stack with a pattern at startup and check the high-water mark, so overflow is detected.
- Remember that an ISR can fire at maximum recursion depth; the interrupt frame is added on top of the deepest path.
Key terms
- Stack frame
- The per-call storage: locals, saved registers, return address.
- Tail call
- A recursive call that is the last operation, convertible to a jump.
- -fstack-usage
- The compiler flag emitting each function's frame size to a .su file.
- High-water mark
- The deepest the stack has ever reached, found by filling it with a pattern and looking for surviving bytes.
- Stack guard
- An MPU region below the stack that faults on overflow instead of corrupting .bss.
Worked example
The same traversal, recursive and iterative:
void walk(Node *n) { // depth = tree depth
if (!n) return;
visit(n);
walk(n->left);
walk(n->right);
}
A degenerate tree of 1000 nodes is 1000 frames deep. At 32 bytes
a frame that is 32 kB of stack for a structure that fits in RAM.
void walk(Node *root) { // depth is now MINE to set
Node *stack[MAX_DEPTH];
int top = 0;
if (root) stack[top++] = root;
while (top) {
Node *n = stack[--top];
visit(n);
if (n->right && top < MAX_DEPTH) stack[top++] = n->right;
if (n->left && top < MAX_DEPTH) stack[top++] = n->left;
}
}
The bound is explicit, it lives in RAM I declared, and exceeding
it drops a node instead of corrupting memory.
And why tail recursion is not a promise:
int sum(int n, int acc) {
return n == 0 ? acc : sum(n - 1, acc + n); // tail call
}
-O2: becomes a loop. -O0: 'n' frames deep and overflows.Common pitfalls
Interrupt handlers and other functions the hardware calls
An interrupt service routine is a function the hardware calls, not your code. That single difference changes everything about it: it must have a specific name or be placed in the vector table, it takes no arguments and returns nothing, it may run at any point between any two instructions of the main program, and it must return quickly because everything of lower priority is waiting behind it.
How it is built
- On Cortex-M the vector table is an array of function addresses; the CPU loads the entry and branches, so the handler is found by position, not by name.
- The hardware stacks the caller-saved registers automatically, so a plain C function can serve as a handler without special attributes on this architecture.
- Other architectures need __attribute__((interrupt)) so the compiler saves every register and emits the correct return instruction.
- A handler shares data with the main program at arbitrary instruction boundaries, so anything shared must be volatile and accessed atomically.
- Handler names are typically weak symbols in the startup file, so defining your own with the same name silently replaces the default.
Design procedure
- Keep the handler short: set a flag, push to a queue, clear the hardware source, and return. Do the work in the main loop.
- Mark every variable shared with a handler volatile, and use an atomic type or a critical section for anything wider than one word.
- Never call printf, malloc, or a blocking function from a handler; most are not reentrant and none are bounded.
- Clear the interrupt source before returning, and on some cores read it back, or the handler re-enters immediately.
- Check the exact handler name against the startup file; a typo produces no error, because the weak default is still there.
Key terms
- Vector table
- An array of handler addresses the CPU indexes by exception number.
- Weak symbol
- A definition a strong one overrides silently. How default handlers are provided.
- Reentrant
- Safe to call again while an earlier call is still in progress.
- Critical section
- A region with interrupts disabled, making a multi-step access indivisible.
- Latency
- The time from the hardware event to the first instruction of the handler.
Worked example
The shape of a correct handler:
static volatile uint16_t rx_head, rx_tail;
static uint8_t rx_buf[256];
void USART1_IRQHandler(void) { // name must match startup
if (USART1->SR & USART_SR_RXNE) {
uint8_t b = USART1->DR; // reading DR clears the flag
uint16_t next = (rx_head + 1) % sizeof rx_buf;
if (next != rx_tail) { // drop rather than block
rx_buf[rx_head] = b;
rx_head = next;
}
}
}
No printf, no malloc, no waiting. It moves one byte and returns.
The silent failure that costs an afternoon:
void USART1_IRQHander(void) { ... } // typo: 'Hander'
This compiles and links with no error at all. The startup file's
weak default handler is still in the vector table, so the
interrupt fires and goes to an infinite loop. Nothing reports it.
And why volatile alone is not enough:
rx_head = next; // one 16-bit store, atomic on this core: OK
counter++; // load, add, store: an ISR between them
// loses the increment even with volatileCommon pitfalls
Return values, error contracts, and function attributes
A function's return value is its contract with the caller, and C gives no help enforcing it: a result can be ignored, an error code discarded, an output parameter left unread. Compiler attributes exist to close some of that gap - warn_unused_result, noreturn, pure, const, weak - and choosing a consistent error convention is what makes a codebase's failure handling reviewable at all.
How it is built
- There are three common conventions: return a status code and write results through pointers; return the value and use a sentinel for failure; or return a struct holding both.
- warn_unused_result makes ignoring a return value a warning, which is the only way C will insist an error is looked at.
- noreturn tells the compiler a function never comes back, which improves both codegen and the warnings around it.
- pure and const declare that a function has no side effects, letting the compiler eliminate repeated calls.
- weak lets a definition be overridden at link time, which is how default handlers and optional hooks are provided.
Design procedure
- Pick one error convention per module and apply it without exception; mixed conventions are why error handling gets skipped.
- Mark any function whose failure matters with warn_unused_result, particularly initialisation and write paths.
- Mark a fault handler or an assert failure noreturn, so the compiler knows the code after it is unreachable.
- Leave output parameters untouched on failure, and say so in the header, so the caller can rely on it.
- Return a small struct where a value and a status genuinely belong together; the ABI usually passes two words in registers.
Key terms
- Sentinel return
- A distinguished value such as -1 or NULL meaning failure.
- warn_unused_result
- An attribute making a discarded return value a warning.
- noreturn
- Declares the function never returns. _Noreturn in C11, [[noreturn]] in C23.
- pure / const
- Attributes declaring no side effects, enabling common-subexpression elimination of calls.
- weak
- A definition a strong one silently replaces at link time.
Worked example
The three conventions, and when each fits:
// 1. status out, result through a pointer - the usual choice
__attribute__((warn_unused_result))
Status adc_read(uint8_t ch, uint16_t *out);
uint16_t v;
if (adc_read(3, &v) != STATUS_OK) return handle();
// ignoring the result is now a WARNING
// 2. sentinel - fine when one value is impossible
int find(const int *a, size_t n, int key); // -1 = not found
// 3. struct - when both always matter
typedef struct { Status s; uint16_t value; } AdcResult;
AdcResult adc_read(uint8_t ch); // two words: often in registers
Attributes that change what the compiler knows:
_Noreturn void panic(const char *msg);
code after a call is unreachable; no "missing return" warning
__attribute__((pure)) int crc(const uint8_t *d, size_t n);
no side effects, so two identical calls collapse into one
__attribute__((weak)) void on_error(void) { }
a default; any strong definition replaces it at link timeCommon 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.
- 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.
- C Basics & the Translation UnitFrom 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.
- 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.