FreeRTOS internals, IPC primitives, priority inversion, deadlock, DMA integration,
LwIP Ethernet, SystemView profiling, and production-grade fault handling.
Builds directly on your existing STM32 support experience.
Duration: 8 weeksHours: ~320 hrs (40/week)Schedule: Mon–Sat full timeCapstone: FreeRTOS sensor fusion node with Ethernet
// books for this phase
Hands-On RTOS with Microcontrollers — Brian AmosFreeRTOS API, tasks, queues, semaphores, mutexes, timers, event groups. Follow every code example on hardware.
Embedded and Real-Time Operating Systems — K C WangTheory underneath the API: scheduling algorithms, priority inversion theory, IPC, memory management.
Making Embedded Systems — Elicia WhiteArchitecture decisions, hardware/software interface, design for testability. Read in parallel — gives the "why".
// daily schedule template (mon–fri)
08:00 – 10:00 Theory reading — alternate K C Wang (theory) and Amos (FreeRTOS practice) by dayK C Wang gives you the internals. Amos gives you the API. Both matter.
10:00 – 13:00 FreeRTOS exercises on STM32 — one new RTOS primitive per session
14:00 – 16:30 Project work — sensor fusion node, adding one subsystem per week
16:30 – 17:30 Debug session — intentionally break things (stack overflow, deadlock, priority inversion) then fix themThis is where your support background becomes a superpower
Saturday Integration + profiling: use Segger SystemView or FreeRTOS trace to visualise task execution
// freertosconfig.h — understand every setting
configUSE_PREEMPTION = 1 /* preemptive vs cooperative */
configUSE_TIME_SLICING = 1 /* equal-priority tasks share CPU */
configTICK_RATE_HZ = 1000 /* 1ms tick — sets timer resolution */
configMAX_PRIORITIES = 7 /* don't set too high — wastes RAM */
configMINIMAL_STACK_SIZE = 128 /* words (not bytes) on ARM Cortex-M */
configTOTAL_HEAP_SIZE = 32768 /* bytes — or use static allocation */
configCHECK_FOR_STACK_OVERFLOW = 2 /* ALWAYS on during development */
configUSE_TRACE_FACILITY = 1 /* needed for SystemView */
configUSE_STATS_FORMATTING_FUNCS = 1 /* vTaskList(), vTaskGetRunTimeStats() */
configGENERATE_RUN_TIME_STATS = 1 /* requires a high-res timer */
configUSE_MUTEXES = 1
configUSE_RECURSIVE_MUTEXES = 1
configUSE_COUNTING_SEMAPHORES = 1
configUSE_TASK_NOTIFICATIONS = 1
configSUPPORT_STATIC_ALLOCATION = 1 /* set to 1 in Week 5 */
configSUPPORT_DYNAMIC_ALLOCATION = 1
// week-by-week
Week 1 — RTOS internals and task management
Foundation
TCB — Task Control Blockcontext switchingscheduler internalstask statesstack sizingpreemptiontick interrupt
Reading
K C Wang Ch. 1–3: what an OS kernel does, process model, context switchingUnderstand what xTaskCreate() actually allocates and where it lives in RAM
Amos Ch. 1–4: FreeRTOS setup, task creation, priorities, vTaskDelayType every example by hand. No copy-paste.
Read the FreeRTOS source this week
tasks.c — find xReadyTasksLists[], understand scheduler tickThe ready list is a sorted linked list. The scheduler always picks the head of the highest non-empty list.
list.c — the doubly-linked list used for all RTOS objectsEverything in FreeRTOS — tasks, queues, timers — lives on these lists
Daily exercises
MonCreate 3 tasks at different priorities. Toggle a different GPIO in each. Observe preemption on oscilloscope.Expected: highest priority task dominates. Lower priority tasks only run when higher yields.
TueRead FreeRTOS source: list.c and tasks.c. Find the TCB struct definition. Draw its memory layout on paper.
WedConfigure configCHECK_FOR_STACK_OVERFLOW = 2. Deliberately overflow a stack. Write vApplicationStackOverflowHook() to log task name over UART before halting.
ThuUse uxTaskGetStackHighWaterMark() on all tasks. Size stacks correctly with 20% headroom. Document in comments above each xTaskCreate().
FriCooperative scheduling experiment: set configUSE_PREEMPTION = 0. Repeat Monday's test. Observe why preemptive is almost always correct for embedded.
SatScaffold the sensor fusion project: create 5 task stubs (IMU, Temp, Aggregator, UART_Report, Watchdog). Each toggles an LED. Verify all run.
Week 2 — Queues and inter-task communication
IPC
queue internalsproducer-consumerblocking vs non-blockingISR-safe APIsqueue setsbackpressure
Reading
K C Wang Ch. 4–5: IPC theory, message passing, bounded buffer problem
White Ch. 1–3: system architecture — how data flows between layers
Critical API rules
Never call xQueueSend() from an ISR — use xQueueSendFromISR()Non-ISR versions acquire a scheduler lock — calling from ISR causes undefined behaviour
Always check return value: pdTRUE vs errQUEUE_FULLDropped data is silent. Silent data loss in embedded = hard-to-reproduce bugs
portMAX_DELAY blocks forever — only use when you can prove it won't deadlockIn most cases, use a finite timeout and handle the timeout case
Sensor data struct — use throughout the project
typedef struct {
uint32_t timestamp_ms; /* xTaskGetTickCount() at sample time */
float x, y, z; /* IMU axes or temperature (z unused) */
uint8_t sensor_id; /* 0=IMU, 1=Temp */
uint8_t valid; /* 1 if data is fresh */
} SensorReading_t;
/* Queue of structs — copy by value, not by pointer */
/* Queue depth: enough to absorb worst-case producer burst */
static QueueHandle_t g_imu_queue;
static QueueHandle_t g_temp_queue;
/* In IMU_Task: */
SensorReading_t reading = { .timestamp_ms = xTaskGetTickCount(),
.x = imu_x, .y = imu_y, .z = imu_z,
.sensor_id = 0, .valid = 1 };
xQueueSend(g_imu_queue, &reading, pdMS_TO_TICKS(10));
/* In Aggregator_Task: */
SensorReading_t rx;
if (xQueueReceive(g_imu_queue, &rx, pdMS_TO_TICKS(100)) == pdTRUE) {
/* process rx */
}
Daily exercises
MonProducer-consumer with queue depth 10. Produce faster than consume. Measure how long producer blocks when queue fills (DWT timestamp before/after xQueueSend).
TueSend data from UART RX ISR to task via xQueueSendFromISR(). Understand portYIELD_FROM_ISR() — why context switch may be needed immediately after ISR.
WedQueue of SensorReading_t structs. Connect IMU_Task → Aggregator_Task via queue.
ThuQueue set: Aggregator blocks on both IMU and Temp queues simultaneously with xQueueSelectFromSet().
FriStress test: produce at max rate. Add a drop counter to SensorReading_t. Log drop rate over UART.
SatMeasure latency: DWT timestamp in ISR, again when task receives. Document min/max/avg over 10,000 samples.
Priority inversion and deadlock are the two most dangerous bugs in RTOS development.
You must be able to reproduce them on demand, explain them precisely, and fix them
confidently. These come up in virtually every embedded systems interview.
Document everything — it becomes interview material.
binary semaphorecounting semaphoremutex vs semaphorepriority inheritancepriority inversiondeadlockCoffman conditions
Semaphore vs Mutex — know this cold
Semaphore: signalling. One task/ISR gives, another waits. No ownership concept.Use for: ISR→task sync, task→task sync, counting resources (N buffers available)
Mutex: ownership. Taker must be giver. Enables priority inheritance.Use for: shared resource protection between tasks. NEVER from ISR.
Reading
K C Wang Ch. 6: mutual exclusion theory, semaphore internals, deadlock (Coffman conditions)
Amos Ch. 8–10: FreeRTOS semaphores, mutexes, priority inheritanceRead the FreeRTOS source for xSemaphoreTake — compare with xQueueReceive
Debug scenario 1 — reproduce priority inversion
Setup: 3 tasks — Low (P1), Medium (P2), High (P3). Low takes a binary semaphore and holds it while doing "work" (vTaskDelay). High tries to take the same semaphore and blocks. Medium has no semaphore involvement but preempts Low.
Observe: High is blocked on semaphore held by Low. Medium preempts Low (Medium > Low priority). High starves. This is priority inversion — Medium effectively runs at higher priority than High.
Measure: Toggle GPIO in High task when it runs. Measure on oscilloscope. High LED should be dark while Medium runs.
Fix: Replace binary semaphore with xSemaphoreCreateMutex(). FreeRTOS priority inheritance temporarily raises Low to High's priority. Medium can no longer preempt it. Re-measure — High runs within microseconds of Low releasing.
Debug scenario 2 — reproduce deadlock
Setup: 2 tasks, 2 mutexes (A and B). Task1 takes A then B. Task2 takes B then A. Both tasks run.
Observe: Both tasks block forever. CPU is idle. Watchdog (if present) resets the system.
Fix option 1: Always acquire mutexes in the same global order (A then B) in all tasks.
Fix option 2: Use xSemaphoreTake() with a timeout. If second mutex not acquired within timeout, release first and retry.
Debug scenario 3 — ISR misuse
Call xSemaphoreTake() (non-ISR version) from inside an interrupt handler. Observe HardFault or assert fire. This is one of the most common RTOS bugs in real codebases. Know why it fails and what to use instead (xSemaphoreGiveFromISR).
Project — shared log buffer
Add a shared log buffer accessed by both Aggregator and UART_Report tasks. Without mutex: observe corrupt log entries on UART. With mutex: clean entries. Demonstrate both — take a screenshot of each. Document the difference.
White Ch. 4–6: interrupt design, latency budget, ISR lengthWhite's rule: ISRs should read hardware and signal a task. Nothing more.
Deferred ISR — the production pattern
ISR (minimal): read hardware register, post to queue or give semaphore, exit. Under 1 microsecond.
Worker task: blocks on queue/semaphore, does all processing at task context priority.
This is how every well-written production RTOS driver is structured.
Task notification — faster than semaphore
/* Giving from ISR — faster than xSemaphoreGiveFromISR */
void EXTI0_IRQHandler(void) {
BaseType_t xHigherPriority = pdFALSE;
vTaskNotifyGiveFromISR(g_worker_task_handle, &xHigherPriority);
portYIELD_FROM_ISR(xHigherPriority);
}
/* Taking in task — blocks until notification received */
void Worker_Task(void *pv) {
while (1) {
ulTaskNotifyTake(pdTRUE, portMAX_DELAY); /* block */
/* do deferred work here */
Process_Sensor_Data();
}
}
Daily exercises
MonCreate one-shot and periodic software timers. Verify callback runs in timer daemon context (print task name in callback). Measure accuracy vs hardware timer — understand the 1-tick resolution limit.
TueEvent groups: IMU, Temp, and Mag tasks each set a bit when data ready. Aggregator waits for ALL 3 bits (xEventGroupWaitBits with waitForAll=pdTRUE).
WedReplace a semaphore with task notifications. Measure CPU overhead difference using vTaskGetRunTimeStats().
ThuFull deferred ISR: UART RX interrupt posts raw bytes to queue. Parser task wakes and processes. Measure ISR exit time with GPIO toggle — target under 1 microsecond.
FriEnable tickless idle. Measure idle current with a multimeter. Understand the wake-up latency trade-off.
SatAdd multi-task watchdog: each task sets a heartbeat bit. Watchdog task checks all bits every 100ms, kicks IWDG only if all set, clears flags.
MonSwitch between heap_1, heap_4, heap_5. Measure fragmentation after 1000 alloc/free cycles with xPortGetFreeHeapSize() and xPortGetMinimumEverFreeHeapSize().
TueConvert all dynamic allocations to static. Verify xPortGetFreeHeapSize() equals configTOTAL_HEAP_SIZE at runtime — zero heap used.
ThuDouble-buffered DMA RX: buffer A fills while task processes buffer B. Swap on half-transfer and transfer-complete interrupts. No gaps in continuous reception.
FriProfile heap usage across all tasks. Add a telemetry command that reports stack high-water marks for every task.
Week 6 — Ethernet + LwIP: build from scratch
Milestone
No CubeMX templates — build it yourself
You have debugged Ethernet and LwIP from the support side. This week you design
and build the full stack from scratch, without generated code scaffolding.
The constraint forces you to understand every layer. Keep Wireshark running all week.
LwIP raw APInetconn APIsocket APIPBUF managementTCP serverUDP broadcastDHCP
LwIP API comparison
Raw API: callback-based, no blocking, no OS. Most efficient.Use for bare-metal or absolute minimum overhead
Netconn: blocking calls, OS-aware. Simpler than raw.Best choice when using FreeRTOS
Socket: POSIX-like, most overhead. Familiar from Linux.Use for porting from Linux or quick prototyping
Reading
White Ch. 8–10: networking in embedded systems, protocol layers
LwIP documentation: lwip.fandom.com/wikiRead the "PBUF" and "netconn API" pages carefully
Study: ethernetif.c from ST (even if you're not using it directly)Understanding the driver interface is how you debug it
Daily exercises
MonWrite the Ethernet DMA driver from scratch. Get ping working. Verify with Wireshark — you should see ARP requests and ICMP echo replies.
TueTCP echo server using netconn API. Python test: send 10,000 packets at full speed, verify zero corruption or drops.
WedAdd DHCP client (dhcp_start). Verify IP assignment with Wireshark. Add link-local fallback if no DHCP server.
ThuUDP broadcast: every 1 second broadcast a discovery packet {device_name, ip, firmware_version}. Python listener prints discovered devices. This is a real embedded product pattern.
FriBinary framed TCP protocol: [magic_byte][length_u16][payload][crc8]. Parser on STM32 side. Python sender. Replace JSON-over-UART sensor report with this protocol over TCP.
Sat24-hour stress test: continuous operation, log PBUF stats hourly. Fix any PBUF pool exhaustion (common LwIP memory leak).
Portfolio milestone
Sensor fusion node transmitting IMU + temp data over TCP at 100ms intervalsJSON or binary framed format — Python receiver logs to CSV
UDP broadcast discovery — Python finds device automatically on local network
24-hour stress test passed — no PBUF leaks, no task starvation, no resets
Week 7 — Real-time analysis and SystemView profiling
K C Wang Ch. 9–11: schedulability analysis, rate-monotonic scheduling, Liu & Layland theorem
White Ch. 7–9: performance analysis, profiling embedded systems
Rate-monotonic schedulability
Liu & Layland bound: for n tasks, RM schedulable if U ≤ n(2^(1/n) − 1)For large n this approaches ln(2) ≈ 69%. Above this bound: not guaranteed schedulable under RM.
U = sum of (WCET_i / Period_i) for all tasksMeasure WCET with DWT_CYCCNT — not with vTaskGetRunTimeStats (too coarse)
Schedulability analysis — example for sensor fusion project
MonRate-monotonic analysis on paper for your sensor fusion project. Assign periods and WCET to each task. Calculate U. Verify schedulability.
TueMeasure WCET for each task using DWT_CYCCNT. Compare measured vs assumed in your RM analysis. Update the analysis if assumptions were wrong.
WedInstall Segger SystemView (free). Configure in FreeRTOSConfig.h. Record a 10-second trace. Find: longest ISR, most context switches/sec, task with worst jitter.
ThuMeasure interrupt-to-task latency: timestamp at ISR entry and when task unblocks. Target under 50 microseconds. Document the distribution over 10,000 samples.
FriStress test: add a CPU-intensive medium-priority task. Observe starvation in SystemView. Fix with priority adjustment.
SatWrite a schedulability analysis document: task table, utilisation calculation, SystemView screenshot with annotations. Add to GitHub README.
/* ARM Cortex-M exception frame pushed to stack on fault:
R0, R1, R2, R3, R12, LR, PC, xPSR (in that order)
The PC value tells you exactly where the fault occurred. */
/* Fault status registers — read immediately in handler */
#define SCB_CFSR (*((volatile uint32_t *)0xE000ED28)) /* Combined */
#define SCB_HFSR (*((volatile uint32_t *)0xE000ED2C)) /* HardFault */
#define SCB_MMFAR (*((volatile uint32_t *)0xE000ED34)) /* MemManage addr */
#define SCB_BFAR (*((volatile uint32_t *)0xE000ED38)) /* BusFault addr */
typedef struct {
uint32_t r0, r1, r2, r3, r12;
uint32_t lr, pc, xpsr;
} ExcFrame_t;
void HardFault_Handler_C(ExcFrame_t *frame) {
/* Log to backup SRAM (survives reset) */
FAULT_LOG->pc = frame->pc;
FAULT_LOG->lr = frame->lr;
FAULT_LOG->cfsr = SCB_CFSR;
FAULT_LOG->hfsr = SCB_HFSR;
FAULT_LOG->valid = 0xDEADBEEF;
/* Log over UART if still functional */
LOG_ERROR("HardFault at PC=0x%08X LR=0x%08X CFSR=0x%08X",
frame->pc, frame->lr, SCB_CFSR);
/* Force reset via NVIC */
NVIC_SystemReset();
}
/* Assembly trampoline — switches to correct stack before calling C handler */
__attribute__((naked)) void HardFault_Handler(void) {
__asm volatile (
"tst lr, #4 \n"
"ite eq \n"
"mrseq r0, msp \n"
"mrsne r0, psp \n"
"b HardFault_Handler_C \n"
);
}
Daily exercises
MonWrite the HardFault handler above. Reproduce 3 faults deliberately: null pointer deref, stack overflow, divide by zero. Verify your handler catches and logs each one with correct PC value.
TueConfigure MPU: mark stack region as non-executable, add guard pages between task stacks. Verify MPU catches stack overflow before it corrupts adjacent memory.
WedOn next boot after fault: check FAULT_LOG->valid for 0xDEADBEEF. If present, log the saved fault info over UART, clear the log. Verify by deliberately faulting, observing reset, and reading the boot log.
ThuPython integration test suite: connect over TCP, send known command sequences, verify responses. At least 10 test cases covering normal operation and error conditions. Document pass/fail.
FriBuild in RELEASE (-O2). Fix any bugs that only appear with optimisation (missing volatile, UB). This is a real and important exercise.
SatRecord 2-minute demo video: SystemView trace alongside running hardware. Push final capstone to GitHub.