Rebuild embedded C from first principles. Layered architecture, HAL design,
state machines, memory management, and unit testing. Closes the gap between
"C that works" and "C you'd show in a code review."
Duration: 6 weeksHours: ~240 hrs (40/week)Schedule: Mon–Sat full timeCapstone: STM32 CLI system
// books for this phase
Modern C — Jens GustedtLanguage depth: type system, memory model, UB, idioms. Chapters 1–20 in order.
Embedded Software Design — Jacob BeningoArchitecture: HAL design, modularity, state machines, testing. The "professional habits" book.
Understanding and Using C Pointers — Richard Reese (finishing)Complete any remaining chapters on function pointers and pointer arithmetic.
// daily schedule template (mon–fri)
08:00 – 10:00 Book reading (Modern C or Beningo chapter)Take notes on paper. Draw diagrams. Do every worked example.
10:00 – 13:00 Coding exercises — implement chapter concepts on STM32Type code by hand. No copy-paste from book examples.
14:00 – 16:00 Weekly project work — layered driver or architecture taskThis is where the week's reading becomes a real artifact.
16:00 – 17:30 Review + documentationWrite up what you built, why it's designed that way, what you'd change.
Saturday Weekly review: fix what's broken, push to GitHub, write a short technical summary
Modern C Ch. 1–6: types, operators, control flow, functionsFocus: integer promotion rules, implicit conversions, signed overflow UB
Finish Understanding C Pointers: function pointers, pointer arithmeticThese appear constantly in driver dispatch tables and HAL code
Environment setup (do first)
Build from CLI: CMake + arm-none-eabi-gcc, no IDEKnowing how to build without an IDE forces real understanding of the toolchain
Set up clangd LSP for code navigationgenerate_compile_commands = ON in CMakeLists.txt
Configure OpenOCD for flash + debug via GDBNo GUI debugger — raw GDB commands this week
Daily coding exercises
MonRewrite memcpy, memset, strcpy from scratch without stdlib. Verify against originals.Forces correct pointer arithmetic and off-by-one awareness
TuePractice volatile and const correctness: write a GPIO register access layer using volatile uint32_t pointers. No HAL library.Every peripheral register pointer should be volatile. Understand why.
WedWrite a function pointer dispatch table: define 5 command handler functions, store in an array, call by index.This exact pattern is used in protocol parsers and bootloaders
ThuInteger promotion traps: write 3 examples where implicit conversion causes a bug. Fix each one with explicit casts.Classic: uint8_t a=200, b=200; uint8_t sum = a+b; — what is sum?
FriUndefined behaviour exploration: signed overflow, null pointer deref, out-of-bounds. Compile at -O0 then -O2. Document the difference.This will surprise you. UB that "works" at -O0 can silently break at -O2.
SatSet up Git repo. Push all week's code with a structured README. Document what each exercise taught you.
Week 2 — Structs, alignment, bit manipulation
Data Structures
struct paddingpacked attributeregister mapsbit manipulationcircular bufferunion type punning
Reading
Modern C Ch. 7–10: structs, unions, typedef, memory layoutFocus: struct padding, alignment requirements, __attribute__((packed))
Beningo Ch. 1–3: software engineering principles for embeddedSOLID applied to C, cohesion, coupling, single responsibility
Key concepts
Struct padding: compiler inserts padding to align members to their natural alignment. sizeof(struct) may surprise you.Use __attribute__((packed)) only when absolutely necessary — creates unaligned access faults on strict-alignment architectures
Register map as packed struct: maps C struct fields directly to hardware register bits.Verify with objdump that the struct sits at the correct base address
Daily coding exercises
MonImplement a register map as a packed struct for UART. Compare with raw pointer offset access. Verify both produce identical machine code with objdump.Use the STM32 reference manual to map every field
TueBit manipulation drills. Implement SET_BIT, CLEAR_BIT, TOGGLE_BIT, READ_BIT macros. No magic numbers anywhere. Apply to GPIO register control.These macros go in every professional embedded codebase
Wed–ThuBuild a circular (ring) buffer: fixed size (power of two), push, pop, is_full, is_empty. Lock-free for single producer / single consumer.You will reuse this in Phase 2 (UART DMA) and Phase 3 (telemetry)
FriUART interrupt-driven driver using your ring buffer for both TX and RX. No polling.TX: task writes to ring buffer, TXEIE interrupt drains it. RX: RXNEIE fills ring buffer, task reads it.
SatVerify UART driver with a Python script (pyserial): send known patterns, verify echo. Fix any corruption.
Circular buffer — reference implementation
/* Power-of-two size for bitmask wrap (no division) */
#define RING_SIZE 256u /* must be power of 2 */
#define RING_MASK (RING_SIZE - 1u)
typedef struct {
uint8_t buf[RING_SIZE];
volatile uint32_t head; /* written by producer (ISR) */
volatile uint32_t tail; /* written by consumer (task) */
} RingBuf_t;
/* Single producer / single consumer — lock-free on Cortex-M */
static inline bool rb_push(RingBuf_t *rb, uint8_t byte) {
uint32_t next = (rb->head + 1u) & RING_MASK;
if (next == rb->tail) return false; /* full */
rb->buf[rb->head] = byte;
rb->head = next;
return true;
}
static inline bool rb_pop(RingBuf_t *rb, uint8_t *out) {
if (rb->head == rb->tail) return false; /* empty */
*out = rb->buf[rb->tail];
rb->tail = (rb->tail + 1u) & RING_MASK;
return true;
}
static inline uint32_t rb_count(const RingBuf_t *rb) {
return (rb->head - rb->tail) & RING_MASK;
}
Week 3 — Layered architecture and HAL design
Architecture
HAL patternabstraction layersportabilityerror handlingAPI designdriver architecture
Reading
Beningo Ch. 4–6: HAL design, abstraction layers, portability patterns
Modern C Ch. 11–13: pointers to functions, standard library internals
The three-layer driver model
Layer 1 — Hardware Abstraction (HAL): direct register access, MCU-specific.Changing MCU = rewrite only this layer
Layer 3 — Application API: the public interface your application calls.UART_Write(buf, len). Never exposes registers.
Daily coding exercises
Mon–TueRefactor your Week 2 UART driver into 3 explicit layers. The HAL layer is the only file that touches registers. Verify by compiling driver logic tests on your host (no MCU).
WedWrite a GPIO HAL that could be ported to a different MCU by changing only one file. Test by swapping a stub implementation.GPIO_SetPin(port, pin), GPIO_ClearPin, GPIO_ReadPin — no register names leak upward
ThuDesign and implement a proper error handling system. Define error_t typedef (int32_t or enum), error code list, propagation pattern (return error, never ignore).MISRA Rule 17.7: the return value of a non-void function shall be used. Enforce this now.
FriAdd logging abstraction: LOG_DEBUG, LOG_INFO, LOG_ERROR macros that compile away in RELEASE builds. Implement over UART.
SatGitHub milestone: push UART driver with full 3-layer architecture and a README architecture diagram. This is Portfolio Project #1 start.
Error handling pattern
typedef int32_t error_t;
#define ERR_OK (0)
#define ERR_INVALID_ARG (-1)
#define ERR_TIMEOUT (-2)
#define ERR_BUSY (-3)
#define ERR_OVERFLOW (-4)
/* Every function that can fail returns error_t.
Callers must check the return value — always. */
error_t UART_Write(const uint8_t *buf, uint16_t len) {
if (buf == NULL || len == 0u) {
return ERR_INVALID_ARG;
}
/* ... implementation ... */
return ERR_OK;
}
/* Usage — never ignore: */
error_t err = UART_Write(data, len);
if (err != ERR_OK) {
LOG_ERROR("UART write failed: %d", err);
return err; /* propagate up */
}
Week 4 — State machines and design patterns
Patterns
finite state machinetable-driven FSMfunction-pointer FSMentry/exit actionsprotocol parserdefensive coding
Reading
Beningo Ch. 7–9: state machines, design patterns in embedded C
Modern C Ch. 14–16: defensive coding, assertions, static analysis mindset
Two FSM implementations to master
Table-driven: 2D array of [state][event] → next_state + action. O(1) lookup.Best for simple FSMs. Easy to visualise. Hard to add complex guard conditions.
Function-pointer: each state is a function. Events are parameters.Best for complex FSMs with guards. More flexible. More code.
Mon–TueImplement both FSM variants for a simple packet protocol: IDLE → RECEIVING → PROCESSING → IDLE. States driven by UART bytes.
WedBuild a UART command-line interface: receive ASCII commands, parse with your FSM, dispatch to handlers via function pointer table.Commands: "led on", "led off", "status". Parser handles unknown commands gracefully.
ThuAdd assertions everywhere: ASSERT(ptr != NULL), ASSERT(len <= MAX). Configure assert to log + halt in DEBUG, compile away in RELEASE.
FriAdd defensive input validation to every public API function. Test with malformed inputs: NULL pointers, zero lengths, out-of-range values.
SatPush CLI system to GitHub with a README that includes the FSM state diagram (draw it in ASCII art or draw.io).
.rodata — read-only data (const globals), stored in flash
.data — initialised globals, copied from flash to RAM at startup
.bss — zero-initialised globals, zeroed at startup
.stack — grows downward, size set in linker script
Static memory pool — reference implementation
#define POOL_BLOCK_SIZE 64u
#define POOL_BLOCK_COUNT 16u
typedef struct {
uint8_t data[POOL_BLOCK_SIZE];
bool in_use;
} Block_t;
static Block_t s_pool[POOL_BLOCK_COUNT]; /* lives in .bss */
void* Pool_Alloc(void) {
for (uint32_t i = 0; i < POOL_BLOCK_COUNT; i++) {
if (!s_pool[i].in_use) {
s_pool[i].in_use = true;
return s_pool[i].data;
}
}
return NULL; /* pool exhausted — never hang, return NULL */
}
void Pool_Free(void *ptr) {
if (ptr == NULL) return;
Block_t *b = (Block_t *)((uint8_t *)ptr - offsetof(Block_t, data));
ASSERT(b >= s_pool && b < s_pool + POOL_BLOCK_COUNT);
b->in_use = false;
}
Daily coding exercises
MonRead and annotate your STM32 linker script. Locate every section. Understand why .data initialisation happens in the startup assembly.
TueUse objdump -h to print section sizes. Use nm to list all symbols with sizes. Find where your largest variables live. Find any unexpected bloat.
Wed–ThuImplement the static memory pool above. Add to your CLI project: command receive buffers come from the pool, released after processing.
FriAdd memory usage reporting to the CLI: a "mem" command that prints pool usage (blocks used/total) and stack watermark (fill stack with known pattern at startup, scan for pattern at runtime).
SatVerify that your entire project compiles with zero dynamic memory calls: grep for malloc, calloc, realloc, free. There should be none.
Week 6 — Unit testing and Phase 1 capstone
Capstone
Unity test frameworkCMockCeedlingmocking hardwarehost-side testingbranch coverage
Reading
Beningo Ch. 13–15: unit testing in embedded C, mocking hardware, testable design
Ceedling + Unity documentation: ceedling.ioRead the getting-started guide before touching any test code
What to test this week
Ring buffer: push/pop, full/empty edge cases, wrap-aroundTarget: 100% branch coverage. This is pure algorithmic — no excuses.
FSM: every state/event transition. Invalid transitions handled.Create a test for each cell in your transition table.