Embedded C Interview Preparation
Frequently tested concepts, Practical Embedded C programs

#Frequently tested concepts#
Quick revision notes. Each topic: what it is β what interviewers ask β the trap to avoid.
Table of Contents
- Pointer and Array Output Questions
constvsvolatilestaticvsextern- Macros vs Inline Functions
- Bit Manipulation Coding Problems
- Structure Padding and Alignment
- Endianness Detection
- Memory Allocation and Memory Leaks
- Stack vs Heap
- Interrupts vs Polling
- ISR Design and Shared Variables
- Race Conditions and Atomicity
- Timers, Counters and PWM Calculations
- UART, SPI and IΒ²C Comparisons
- RTOS Task Scheduling and Synchronization
- Debugging Unexpected Resets and Crashes
- Optimizing for Limited RAM and Flash
- Firmware Design Scenarios and Hardware Debugging
1. Pointer and Array Output Questions
Core rules
- Array name decays to a pointer to its first element in most expressions.
- Exceptions:
sizeof(arr)and&arrdo not decay. arr[i]is just*(arr + i). Pointer arithmetic scales by the type size.- Array parameters in functions are really pointers, so
sizeofinside gives pointer size.
int a[5] = {10, 20, 30, 40, 50};
int *p = a;
p + 1; // address of a[1] (moves by sizeof(int))
*(p + 2); // 30
&a + 1; // jumps over the WHOLE array (5 ints)
sizeof(a); // 20 (if int = 4)
sizeof(p); // 4 or 8 (pointer size)
Common tricky outputs
int a[] = {1, 2, 3};
int *p = a;
printf("%d", *p++); // prints 1, then p moves ahead (post-increment, * and ++ same precedence, right to left)
printf("%d", (*p)++); // prints a[0], then a[0] becomes a[0]+1
printf("%d", *++p); // moves p first, then prints
char *s1 = "hello"; // pointer to string literal in Flash/read-only, DON'T modify
char s2[] = "hello"; // copy lives in RAM, modifiable
Pointer types to recognise
| Declaration | Meaning |
|---|---|
int *p[5] | array of 5 int pointers |
int (*p)[5] | pointer to an array of 5 ints |
int *f() | function returning int pointer |
int (*f)() | pointer to function returning int |
const int *p | data is const, pointer can move |
int *const p | pointer is const, data can change |
Traps: sizeof on array parameter, modifying string literals, dereferencing uninitialised or NULL pointers, void * arithmetic (not standard C), comparing pointers of unrelated arrays.
2. const vs volatile
const | volatile | |
|---|---|---|
| Tells compiler | "Program won't modify this" | "Value may change anytime outside program's control" |
| Effect | Read-only enforcement, may go in Flash | Disables optimisations that cache the value in a register |
| Purpose | Safety, saving RAM | Correctness with hardware/ISR/other threads |
Use volatile for:
- Memory-mapped peripheral registers
- Variables modified inside an ISR and read in main
- Variables shared between tasks/threads
volatile uint8_t flag = 0; // ISR sets it
while (flag == 0) { } // without volatile, compiler may turn this into an infinite loop
#define STATUS_REG (*(volatile uint32_t *)0x40021000)
Can a variable be both? Yes: const volatile. Example: a read-only hardware status register. You can't write it, but it changes by itself.
Pointer combos
volatile int *p; // pointed data is volatile
int *volatile p; // the pointer itself is volatile
const volatile int *p; // read-only, hardware-changing data
Remember: volatile does NOT make operations atomic and does NOT replace locks/critical sections. It only stops the compiler from optimising reads/writes away.
3. static vs extern
static has three uses
| Where | Effect |
|---|---|
| Local variable | Keeps value between calls, lives in .data/.bss (not stack), initialised once |
| Global variable | Visible only inside that .c file (internal linkage) |
| Function | Visible only inside that .c file |
extern: declares a variable/function that is defined elsewhere (external linkage). No memory is allocated by extern itself.
// config.c
int baud = 9600; // definition (memory allocated)
static int counter = 0; // private to config.c
// main.c
extern int baud; // declaration, uses the one from config.c
extern int counter; // LINKER ERROR, counter is static
Quick points
- Definition = allocates memory. Declaration = just tells the compiler the name/type.
- Put
externdeclarations in headers, definitions in exactly one.cfile. - Use
staticby default for file-private helpers: avoids name clashes, helps the compiler optimise. - A
staticlocal is not thread/reentrant-safe, so be careful in ISRs and RTOS tasks.
4. Macros vs Inline Functions
Macro (#define) | inline function | |
|---|---|---|
| Processed by | Preprocessor (text substitution) | Compiler |
| Type checking | None | Yes |
| Debugging | Hard (no symbol) | Easier |
| Side effects | Arguments may be evaluated multiple times | Evaluated once |
| Code size | Expands every use | Compiler may inline (it's a hint) |
| Scope | No scope rules | Normal scope rules |
Classic trap
#define SQUARE(x) x * x
SQUARE(2 + 3); // becomes 2 + 3 * 2 + 3 = 11, not 25
#define SQUARE_OK(x) ((x) * (x))
SQUARE_OK(i++); // i++ evaluated twice, undefined behaviour
Fix: wrap every parameter and the whole expression in parentheses. Multi-statement macros use do { ... } while (0).
static inline int square(int x) { return x * x; } // safe, typed, no double evaluation
When to use macros: constants, register addresses, bit masks, conditional compilation (#ifdef), stringify/concatenate (#, ##).
Prefer inline for anything function-like.
5. Bit Manipulation Coding Problems
Essential operations
x |= (1U << n); // set bit n
x &= ~(1U << n); // clear bit n
x ^= (1U << n); // toggle bit n
(x >> n) & 1U; // read bit n
Set/clear a field (multiple bits)
x &= ~(MASK << POS); // clear field
x |= ((val & MASK) << POS); // write new value
Frequently asked problems
| Problem | Trick |
|---|---|
| Check power of 2 | x && !(x & (x - 1)) |
| Count set bits (Kernighan) | while (x) { x &= (x - 1); count++; } |
| Check if bit is set | if (x & (1U << n)) |
| Swap two numbers w/o temp | a ^= b; b ^= a; a ^= b; |
| Lowest set bit | x & -x |
| Clear lowest set bit | x & (x - 1) |
| Check odd/even | x & 1 |
| Reverse bits of a byte | loop shifting bit by bit, or lookup table |
| Swap nibbles | `(x << 4) |
| Swap bytes of 16-bit | `(x << 8) |
Gotchas
- Use
1U(unsigned), because1 << 31on signedintis undefined behaviour. - Right shift of a negative signed number is implementation-defined, so prefer unsigned types.
- Shifting by >= type width is undefined.
- Operator precedence:
x & 1 == 0is parsed asx & (1 == 0). Use brackets.
6. Structure Padding and Alignment
Why: CPUs access memory fastest (or only) at addresses aligned to the data size. The compiler inserts padding bytes to achieve this.
Rules
- Each member is placed at an offset that is a multiple of its own size/alignment.
- Total struct size is rounded up to a multiple of the largest member's alignment.
struct A { char c; int i; char d; }; // c(1) + pad(3) + i(4) + d(1) + pad(3) = 12 bytes
struct B { int i; char c; char d; }; // i(4) + c(1) + d(1) + pad(2) = 8 bytes
Tip: order members largest β smallest to cut padding.
Packing (removes padding)
struct __attribute__((packed)) P { char c; int i; }; // 5 bytes (GCC/ARM)
#pragma pack(1) // alternative
Trade-offs of packed structs
- Saves memory, matches protocol/register layouts.
- Unaligned access can be slow, or cause a hard fault on some cores (e.g., Cortex-M0).
- Taking pointers to packed members is risky.
Also remember
- Padding bytes have indeterminate values, so don't
memcmpstructs or send them raw over a link without packing/serialising. offsetof(type, member)gives the member offset.- Union size = largest member (plus padding).
- Bit-fields: layout/order is implementation-defined, so avoid them for portable register maps.
7. Endianness Detection
- Little-endian: least significant byte at lowest address (x86, most ARM Cortex-M).
- Big-endian: most significant byte at lowest address (network byte order, some PowerPC/older MCUs).
0x12345678 stored at address 0x100:
| Address | 0x100 | 0x101 | 0x102 | 0x103 |
|---|---|---|---|---|
| Little | 78 | 56 | 34 | 12 |
| Big | 12 | 34 | 56 | 78 |
Method 1: pointer cast
uint32_t x = 1;
if (*(uint8_t *)&x == 1) { /* little-endian */ }
else { /* big-endian */ }
Method 2: union
union { uint32_t i; uint8_t c[4]; } u = { .i = 1 };
if (u.c[0] == 1) { /* little-endian */ }
Converting
uint32_t swap32(uint32_t x) {
return ((x & 0x000000FF) << 24) | ((x & 0x0000FF00) << 8) |
((x & 0x00FF0000) >> 8) | ((x & 0xFF000000) >> 24);
}
htons/htonl/ntohs/ntohlconvert host β network (big-endian) order.- Matters when: sending multi-byte data over UART/SPI/CAN/network, parsing file formats, talking to sensors with a different byte order.
- Byte-by-byte transfer of a single
uint8_tarray has no endianness issue.
8. Memory Allocation and Memory Leaks
Functions
| Function | Behaviour |
|---|---|
malloc(n) | Allocates n bytes, uninitialised, returns NULL on failure |
calloc(c, n) | Allocates c*n bytes, zeroed |
realloc(p, n) | Resizes block, may move it (old pointer invalid), original is kept if it fails |
free(p) | Releases block. free(NULL) is safe |
Memory leak: allocated memory that is never freed and no longer reachable. On an MCU that runs for months, a small leak eventually leads to a crash.
Common bugs
p = malloc(10);
p = malloc(20); // leak: first block lost
free(p);
*p = 5; // use after free (dangling pointer)
free(p); free(p); // double free
p = realloc(p, 100); // if it fails, returns NULL and the original p is leaked
Safe realloc:
tmp = realloc(p, 100);
if (tmp) p = tmp;
Other bugs: buffer overflow, forgetting to check for NULL, freeing a stack/static pointer, freeing a pointer not at the block start.
Why dynamic allocation is often avoided in embedded
- Fragmentation (free memory exists but not contiguous)
- Non-deterministic allocation time (bad for real-time)
- Small heap, hard to prove it won't run out
- Leaks/failures over long uptime
Alternatives: static allocation, memory pools / fixed-size block allocators, allocate once at startup and never free. (MISRA C also discourages dynamic allocation.)
Detection: Valgrind/AddressSanitizer on host builds, heap-usage counters, RTOS heap stats, wrapping malloc/free to count.
9. Stack vs Heap
| Stack | Heap | |
|---|---|---|
| Stores | Local variables, function args, return addresses, saved registers | Dynamically allocated blocks |
| Managed by | Automatic (compiler, push/pop) | Programmer (malloc/free) |
| Speed | Very fast | Slower |
| Size | Small, fixed | Larger, whatever is left |
| Fragmentation | None | Possible |
| Lifetime | Until function returns | Until freed |
| Failure | Stack overflow | malloc returns NULL |
| Layout | Grows (usually) downward from top of RAM | Grows upward from after .bss |
Typical RAM layout: .data (initialised globals) β .bss (zero-initialised globals) β heap β β¦ free β¦ β stack.
Embedded cautions
- Avoid large local arrays and deep/unbounded recursion.
- Each RTOS task has its own stack, so size it properly.
- Never return a pointer to a local variable (it is gone after return).
- Stack overflow often shows up as random corruption or hard faults, not a clean error.
- Check usage via stack painting (fill with
0xAA, see how much was overwritten), a linker map, or RTOS high-water-mark APIs.
10. Interrupts vs Polling
| Polling | Interrupt | |
|---|---|---|
| How | CPU repeatedly checks a flag/status | Hardware signals the CPU, which jumps to an ISR |
| CPU usage | Wasted in the loop | Free to do other work/sleep |
| Latency | Depends on loop period | Low, fast response |
| Complexity | Simple, easy to debug | Harder (shared data, priorities, timing) |
| Determinism | Predictable | Jitter from nested/other interrupts |
| Power | Poor (CPU stays awake) | Good (can sleep until event) |
Use polling: very simple systems, very fast-changing events where interrupt overhead is larger than the loop, or when the event is expected immediately. Use interrupts: unpredictable/rare events, low-power designs, time-critical responses. Hybrid (common): the ISR sets a flag or pushes data into a buffer; the main loop does the heavy processing.
Interrupt flow: event β hardware flag β NVIC/controller β finish current instruction β push context β jump via vector table β ISR β clear flag β return β restore context.
Interrupt latency = time from event to first ISR instruction. Affected by: higher-priority ISRs, disabled interrupts (critical sections), instruction in progress, context-save time.
11. ISR Design and Shared Variables
Golden rules for an ISR
- Keep it short and fast. Set a flag, copy data, then leave.
- No blocking calls, no delays, no waiting loops.
- Avoid
printf,malloc/free, and non-reentrant functions. - Clear the interrupt flag (otherwise it re-fires endlessly).
- No return value, no arguments.
- Be careful with floating point (may need extra context saving) and long computations.
- In an RTOS, use only the
...FromISRAPI variants.
Shared variables between ISR and main
volatile uint8_t data_ready = 0;
volatile uint8_t rx_byte;
void UART_ISR(void) {
rx_byte = UART->DR;
data_ready = 1;
}
int main(void) {
while (1) {
if (data_ready) {
data_ready = 0;
process(rx_byte);
}
}
}
Checklist for shared data
- Mark it
volatile. - Make sure access is atomic (see next section), or protect multi-byte/multi-step access with a critical section.
- Prefer single writer per variable.
- For streams of data use a ring (circular) buffer: ISR writes
head, main readstail. This is lock-free if only one side modifies each index. - Pass events with flags/queues instead of sharing complex structures.
Other points: ISR priority and nesting, keeping the critical section as short as possible, and not calling functions that main also uses unless reentrant.
12. Race Conditions and Atomicity
Race condition: the result depends on the unpredictable timing/order of execution between main code and an ISR (or between tasks).
Why counter++ is not atomic. It is read β modify β write:
LDR r0, [counter]
ADD r0, r0, #1
STR r0, [counter]
If an ISR modifies counter between the load and the store, an update is lost.
Another classic: reading a multi-byte value (e.g., 32-bit on an 8/16-bit MCU, or a 64-bit timer) while an ISR updates it gives a "torn read" (half old, half new).
Atomic operation: cannot be interrupted midway. Single aligned reads/writes up to the native word size are usually atomic, but read-modify-write sequences are not.
Solutions
| Technique | Notes |
|---|---|
| Disable interrupts around the access (critical section) | Simple. Keep it very short; save/restore the previous state rather than blindly re-enabling |
Atomic instructions (LDREX/STREX, C11 <stdatomic.h>) | Lock-free |
| Mutex / semaphore (RTOS tasks) | Not usable in ISRs; handles task-to-task sharing |
| Double buffering / copy-then-read | Avoids long locks |
| Single-writer design, ring buffers | No lock needed |
| Read twice and compare | Fix for torn reads of a counter |
uint32_t primask = __get_PRIMASK();
__disable_irq();
shared_count++; // protected
__set_PRIMASK(primask); // restore previous state
Remember: volatile fixes visibility, not atomicity.
13. Timers, Counters and PWM Calculations
Timer tick and overflow
Timer clock = F_cpu / Prescaler
Tick time = 1 / Timer clock = Prescaler / F_cpu
Overflow/Period time = (ARR + 1) Γ Prescaler / F_cpu (for a timer with auto-reload value ARR)
Overflow frequency = F_cpu / (Prescaler Γ (ARR + 1))
Example: F_cpu = 16 MHz, prescaler = 64, want 1 ms interrupt:
- Timer clock = 16 MHz / 64 = 250 kHz β tick = 4 Β΅s
- Counts needed = 1 ms / 4 Β΅s = 250 β ARR = 250 β 1 = 249
(Many MCUs: counts = value + 1, so double-check the "Nβ1" in the datasheet.)
Choosing prescaler/reload: the counter limit is 2^bits (e.g., 65536 for 16-bit). Pick the smallest prescaler that fits the period for the best resolution.
Frequency/period measurement (input capture)
Period = (capture2 β capture1) Γ tick time (handle overflow with unsigned subtraction)
Frequency = 1 / Period
PWM
PWM frequency = Timer clock / (ARR + 1)
Duty cycle (%) = (CCR / (ARR + 1)) Γ 100 // CCR = compare value
CCR = Duty Γ (ARR + 1) / 100
Resolution (bits) = log2(ARR + 1)
Average voltage = Duty Γ Vcc
Example: Timer clock 1 MHz, want 1 kHz PWM at 25% duty β ARR + 1 = 1 MHz / 1 kHz = 1000; CCR = 0.25 Γ 1000 = 250.
Counter modes: up, down, up/down (center-aligned PWM halves frequency for the same ARR: f = clk / (2 Γ ARR)).
Common uses: periodic tick/scheduler, delays, timeout, input capture (measure pulse/period), output compare, PWM for motors/LEDs/servos (servo: 50 Hz, 1β2 ms pulse), baud rate generation, watchdog.
Servo example: 50 Hz β 20 ms period; 1.5 ms pulse = 7.5% duty.
Watch out: overflow/rollover handling, ISR cost at high rates, dead-time for H-bridge PWM, and 32-bit millisecond tick rollover (use (now - start) >= timeout with unsigned math).
14. UART, SPI and IΒ²C Comparisons
| Feature | UART | SPI | IΒ²C |
|---|---|---|---|
| Type | Asynchronous, point-to-point | Synchronous, masterβslave | Synchronous, multi-master/multi-slave |
| Wires | 2 (TX, RX) + GND | 4+ (MOSI, MISO, SCK, CS per slave) | 2 (SDA, SCL) + pull-ups |
| Clock | None (agreed baud rate) | Master supplies SCK | Master supplies SCL |
| Speed | Up to ~1βfew Mbps (typical 9600β115200) | Fastest (10s of Mbps) | 100 kHz, 400 kHz, 1 MHz+ |
| Duplex | Full | Full | Half |
| Addressing | None | Chip Select line | 7/10-bit address |
| Acknowledge | No (parity optional) | No | Yes (ACK/NACK per byte) |
| Devices | 2 | Many (more CS pins) | Many (127 addresses) |
| Distance | Longer (with RS-232/RS-485) | Short, on-board | Short, on-board |
| Complexity | Simple | Simple, hardware-heavy | Protocol-heavy |
UART essentials
- Frame: start bit (0) + 5β9 data bits + optional parity + stop bit(s) (1). Common: 8N1.
- Both sides must use the same baud rate. A mismatch of more than ~2β3% gives garbage.
- Errors: framing, parity, overrun, noise.
- Useful: RS-232 / RS-485 level conversion, DMA/interrupt + ring buffer for reception.
- Throughput for 8N1: 10 bits per byte, so 115200 baud β 11,520 bytes/s.
SPI essentials
- Modes 0β3 = CPOL (idle clock level) and CPHA (sample edge). Master and slave must match.
- Data shifts out and in simultaneously. One CS per slave (or daisy chain).
- No flow control, no ACK, so you cannot detect an absent device.
IΒ²C essentials
- Open-drain lines + pull-up resistors (wired-AND).
- Sequence: START β address + R/W β ACK β data bytes with ACK β STOP.
- Features: clock stretching, repeated START, arbitration (multi-master), possible address conflicts.
- Common bugs: missing pull-ups, wrong address (7-bit vs 8-bit form), bus stuck low (recover by clocking SCL up to 9 times).
Choosing
- Fast data (display, flash, ADC) β SPI
- Many slow sensors, few pins β IΒ²C
- Simple debug/console/module link, long distance β UART
15. RTOS Task Scheduling and Synchronization
RTOS goal: deterministic, time-bounded response using multiple tasks.
Task states: Running β Ready β Blocked (waiting) β Suspended.
Scheduling
- Preemptive priority-based: the highest-priority ready task always runs; it can interrupt a lower one.
- Time slicing (round-robin): equal-priority tasks share the CPU.
- Cooperative: a task runs until it yields or blocks.
- Context switch: save the current task's registers, load the next task's. Costs time.
- Tick: periodic timer interrupt driving the scheduler/delays.
Synchronization tools
| Tool | Purpose |
|---|---|
| Binary semaphore | Signalling (ISR β task, task β task) |
| Counting semaphore | Counting events / managing a pool of resources |
| Mutex | Mutual exclusion for a shared resource (has an owner, supports priority inheritance) |
| Queue | Pass data between tasks/ISRs safely |
| Event flags/groups | Wait for one or several events |
| Task notifications | Lightweight signalling (e.g., FreeRTOS) |
| Software timers | Run callbacks after a delay/period |
Mutex vs semaphore: a mutex is owned (only the locker should release it) and provides priority inheritance; a semaphore is a signalling mechanism with no ownership.
Key problems
- Priority inversion: a high-priority task waits for a mutex held by a low-priority task, while a medium-priority task preempts the low one. The high task is effectively blocked by the medium task. (Mars Pathfinder.)
- Fix: priority inheritance (the holder temporarily gets the waiter's priority) or priority ceiling.
- Deadlock: tasks wait on each other forever. Prevent with consistent lock order, timeouts, fewer locks.
- Starvation: a low-priority task never runs because higher ones always do.
- Race conditions on shared data. Protect with mutex/queue.
Practical rules
- Use the
...FromISRfunctions inside ISRs. Never block in an ISR. - Do not use delay loops; use blocking
delay/queue timeouts so other tasks can run. - Keep critical sections short.
- Size each task's stack properly.
- Typical design: ISR gives a semaphore/posts to a queue β task wakes and does the work (deferred interrupt processing).
Rate Monotonic Scheduling (RMS): shorter period β higher priority. Schedulable if CPU utilisation U = Ξ£(Ci/Ti) β€ n(2^(1/n) β 1) (β 69% for large n).
16. Debugging Unexpected Resets and Crashes
Step 1: find out why it reset. Read the reset-cause register at startup (power-on, brown-out, watchdog, software, external pin, lockup). Log it to non-volatile or retained RAM.
Common causes
| Symptom/cause | Typical reason |
|---|---|
| Watchdog reset | Main loop blocked, deadlock, long delay, forgot to kick the watchdog |
| Brown-out/power | Voltage dip (motors, radio TX, relays), weak supply, missing decoupling caps |
| Hard fault | Null/invalid pointer, unaligned access, jumping to a bad address, stack overflow, executing data |
| Stack overflow | Big local arrays, deep recursion, small task stacks |
| Heap exhaustion/corruption | Leak, double free, buffer overflow |
| Uninitialised variable/pointer | Random behaviour that varies between runs |
| ISR issues | Flag not cleared (interrupt storm), ISR too long, wrong vector/handler name |
| Race condition | Intermittent, hard to reproduce |
| Clock/PLL misconfig | Fails on some boards/temperatures |
| Noise/ESD, floating reset pin | Spurious resets |
| Flash corruption/bad update | Wrong linker settings, vector table offset |
Debug approach
- Reproduce reliably. Note when it occurs (timing, load, temperature, a specific input).
- Check reset-cause flags and any fault-status registers.
- On a Cortex-M hard fault, inspect the stacked registers (PC, LR) to find the faulting instruction; check CFSR/BFAR/MMFAR; map the PC through the
.map/.elffile. - Check stack and heap usage (stack painting, high-water marks).
- Use a debugger, breakpoints, or a watchpoint on the corrupted variable.
- Add lightweight logging (GPIO toggles, ring-buffer trace); avoid heavy
printfin time-critical code. - Compile with
-Wall -Wextra, run static analysis, enable MPU/stack canaries if available. - Scope the supply rails/reset line for glitches.
- Bisect: disable features/modules until the crash stops.
Defensive measures: watchdog with sensible timeout, fault handlers that save context, assertions, input validation, NULL checks, bounds checking.
17. Optimizing for Limited RAM and Flash
Saving RAM
- Use the smallest sufficient types (
uint8_t,uint16_t); order struct members to cut padding; use bit-fields/packing carefully. - Mark constant data
constso it stays in Flash instead of being copied to RAM (tables, strings, lookup data). - Avoid large local arrays and deep recursion (stack); size task stacks accurately.
- Avoid
malloc; use static buffers or fixed-size pools. - Reuse buffers (e.g., a shared scratch buffer,
unions for mutually-exclusive data). - Use ring buffers of minimal safe size; process data in chunks/streams.
- Place rarely used big data in external memory or compress it.
- Check
.data+.bss+ stack + heap against the RAM size in the map file.
Saving Flash
- Compiler flags:
-Os(optimise for size),-ffunction-sections -fdata-sectionswith linker--gc-sectionsto drop unused code, LTO (-flto). - Avoid heavy library calls:
printf(especially with float) andscanfare large. Use lightweight alternatives ornewlib-nano. - Avoid floating point on MCUs without an FPU. Use fixed-point/integer math.
- Replace repeated code with functions; remove dead code and unused features.
- Use lookup tables vs computation only if the trade-off (Flash vs CPU time) fits.
- Use inline/macros wisely: inlining everything increases size.
- Compress strings/resources; shorten log messages; strip debug strings in release.
- Choose smaller HAL/driver options, or write direct register code for tiny parts.
Speed vs size trade-off: -O2/-O3 is faster but larger; -Os is smaller. Measure, don't guess.
Tools: the linker .map file, arm-none-eabi-size (text/data/bss), nm --size-sort, objdump.
Quick mental model: text = code + const (Flash), data = initialised globals (Flash copy + RAM), bss = zero-init globals (RAM only).
18. Firmware Design Scenarios and Hardware Debugging
A. Approaching a design question
Ask/state in this order:
- Requirements: inputs, outputs, timing/deadlines, power budget, memory limits, reliability.
- Architecture choice:
- Super-loop (bare-metal): simple, few tasks.
- Interrupt-driven + main loop: event-based.
- State machine: clear behaviour for protocols/UI/modes.
- RTOS: many concurrent tasks with deadlines.
- Hardware split: use timers, DMA, and peripherals to offload the CPU.
- Data flow: ISR β buffer/queue β processing task.
- Error handling: timeouts, retries, watchdog, safe state on fault.
- Power: sleep modes, wake on interrupt, lower clock.
- Testing/debugging: logging hooks, test points, unit tests on host.
B. Typical scenarios and design hints
| Scenario | Key design ideas |
|---|---|
| Read a sensor every X ms and send over UART | Timer interrupt sets flag β main reads sensor β UART with DMA/ring buffer |
| Button debounce | Sample on a timer (e.g., 5β20 ms) and require N stable reads, or an RC + interrupt with a lockout timer |
| UART command parser | ISR fills ring buffer β main parses lines β state machine for frames, checksum/CRC, timeout for partial frames |
| Low-power battery device | Sleep by default, wake via RTC/GPIO, process quickly, return to sleep; disable unused peripherals |
| Motor control | PWM + feedback (encoder/ADC), PID loop at a fixed timer rate, current limit, safe stop |
| Bootloader/firmware update | Separate flash region, validate image (CRC/signature), keep fallback, jump via vector table |
| Multi-sensor system | RTOS tasks or scheduler with fixed periods, mutex-protected shared bus (IΒ²C/SPI) |
| Watchdog strategy | Kick only when all critical tasks report healthy, not blindly in a timer ISR |
C. State machine pattern
typedef enum { IDLE, RUNNING, ERROR } state_t;
state_t state = IDLE;
switch (state) {
case IDLE: if (start) state = RUNNING; break;
case RUNNING: if (fault) state = ERROR; break;
case ERROR: /* handle, recover */ break;
}
D. Hardware debugging checklist
Tools: multimeter, oscilloscope, logic analyzer (decode UART/SPI/IΒ²C), debugger (SWD/JTAG), bench supply.
- Power first: correct voltages, stable under load, decoupling caps present, ground connected.
- Clock: crystal/oscillator running? Correct frequency configured?
- Reset and boot pins: floating or wrongly strapped?
- GPIO: pin muxed/alternate function correct, direction set, clock to the port enabled, pull-ups configured.
- Peripheral not working: peripheral clock enabled? Pins mapped? Registers configured in the right order? Interrupt enabled in both peripheral and NVIC?
- Communication bus: check the signal with a scope/logic analyzer: idle levels, baud/clock rate, polarity/mode, pull-ups, address, voltage-level mismatch (3.3 V vs 5 V), TX/RX swapped.
- Narrow the cause: hardware or software? Toggle a GPIO and watch it on the scope; try a minimal known-good test program; swap the board/part.
- Intermittent bugs: suspect timing, noise, power dips, or a race. Capture the failing event with a trigger.
E. Common "it doesn't work" causes (quick list)
- Forgot to enable the peripheral clock
- Wrong pin alternate function
- Interrupt not enabled / wrong priority / flag not cleared
- Missing
volatileon a shared or register variable - Wrong pull-up/pull-down, or missing pull-ups on IΒ²C
- Wrong baud rate or clock source
- Wrong SPI mode (CPOL/CPHA)
- Floating inputs
- Stack too small
Last-Minute Revision Cheat Sheet
volatile= don't cache it.const= don't write it. Not atomic, not thread-safe.static= private to file / persistent local.extern= defined elsewhere.- Macros: parenthesise everything. Prefer
static inline. - Bits:
|= 1U<<nset,&= ~(1U<<n)clear,^=toggle. Use unsigned. - Order struct members big β small to cut padding.
- Endianness: cast
&xtouint8_t*and see which byte comes first. - Embedded: avoid
malloc; if you use it, checkNULL, free once, never use after free. - Stack = fast and automatic, small. Heap = flexible, fragments.
- ISR: short, clear the flag,
volatileshared data, no blocking. counter++is not atomic, so use a critical section or an atomic.- Timer:
f_overflow = F_cpu / (Prescaler Γ (ARR+1)); duty =CCR / (ARR+1). - UART: async, 2 wires. SPI: fast, 4+ wires. IΒ²C: 2 wires, addressed, pull-ups.
- RTOS: mutex for exclusion (priority inheritance), semaphore for signalling, queue for data.
- Reset debugging: read the reset-cause flags first.
- Size optimisation:
-Os,gc-sections,constin Flash, no floatprintf. - Hardware debugging: power β clock β reset β pins β peripheral clock β signals on a scope.
#Practical Embedded C programs#
Hands-on notes for the 12 programs that show up again and again in embedded labs, projects and interviews. Each program follows the same pattern: goal β idea β code β how to test β mistakes to avoid β interview angle.
Table of Contents
- Before You Start (platform and shared helpers)
- Blink an LED Without a Blocking Delay
- Read a Push Button with Debouncing
- Set, Clear, Toggle and Test Individual Bits
- Generate a Periodic Timer Interrupt
- Configure PWM to Vary LED Brightness
- Implement a Circular Buffer
- Send and Receive Data Using UART
- Interface a Sensor Using IΒ²C or SPI
- Implement an Interrupt-Driven State Machine
- Create a FreeRTOS Producer-Consumer Application
- Implement Watchdog-Based Recovery
- Build a Small Sensor-Monitoring Firmware Project
- How the 12 Programs Fit Together
0. Before You Start
Platform assumed
The code is written register-level for an STM32F4 (Cortex-M4), specifically a Nucleo-F446RE, using only the CMSIS header stm32f4xx.h. Reasons: register code shows what is really happening, and the same ideas carry over to any MCU. Only register names change.
| Item | Value on this board |
|---|---|
| Core clock after reset | 16 MHz (HSI), so SystemCoreClock = 16000000 |
| User LED | PA5 (also TIM2_CH1, handy for PWM) |
| User button | PC13, active-low (reads 0 when pressed) |
| UART to PC (via ST-Link) | USART2: PA2 (TX), PA3 (RX), AF7 |
| Timers' input clock | 16 MHz (APB prescalers are 1 after reset) |
Porting tip: for any other MCU, keep the logic (state variables, buffers, timing math) and replace only the register lines (clock enable, pin mode, peripheral registers).
Shared helper: a 1 ms system tick
Almost every program below uses this. Define it once.
#include "stm32f4xx.h"
#include <stdint.h>
#include <stdbool.h>
static volatile uint32_t g_ms; // volatile: changed in ISR, read in main
void SysTick_Handler(void) { g_ms++; } // runs every 1 ms
uint32_t millis(void) { return g_ms; } // 32-bit read is atomic on Cortex-M
void tick_init(void) { SysTick_Config(SystemCoreClock / 1000U); }
Rollover-safe time check: always subtract, never compare absolute values:
if ((uint32_t)(millis() - start) >= period) { ... } // works even when g_ms wraps (~49.7 days)
Shared helper: LED on PA5
#define LED_PIN 5U
static void led_init(void) {
RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN; // 1. clock the port
GPIOA->MODER &= ~(3U << (LED_PIN * 2)); // 2. clear mode bits
GPIOA->MODER |= (1U << (LED_PIN * 2)); // 3. 01 = output
}
static inline void led_on(void) { GPIOA->BSRR = (1U << LED_PIN); }
static inline void led_off(void) { GPIOA->BSRR = (1U << (LED_PIN + 16U)); }
static inline void led_toggle(void) {
if (GPIOA->ODR & (1U << LED_PIN)) led_off(); else led_on();
}
BSRR is a write-only "set/reset" register: one write changes the bit with no read-modify-write, so it is safe even if an ISR touches the same port.
Golden rule for every peripheral: enable its clock first, then configure pins, then configure the peripheral, then enable it. Skipping the clock is the number one reason "nothing happens".
1. Blink an LED Without a Blocking Delay
Goal
Blink the LED while the CPU stays free for other work.
Why not delay_ms()?
| Blocking delay | Non-blocking (time-stamp) |
|---|---|
| CPU spins in a loop doing nothing | CPU keeps running other code |
| Button, UART and sensors are missed during the wait | Everything stays responsive |
| Impossible to run several timings at once | Any number of independent timers |
Idea
Remember when the last toggle happened. Each loop pass, check whether enough time has elapsed. If yes, toggle and update the time stamp.
Code
int main(void) {
tick_init();
led_init();
uint32_t last = 0;
const uint32_t PERIOD = 500; // ms
while (1) {
if ((uint32_t)(millis() - last) >= PERIOD) {
last += PERIOD; // += keeps the average rate exact (no drift)
led_toggle();
}
// ... other work runs here at full speed (read button, parse UART, etc.)
}
}
Reusable software-timer version
typedef struct { uint32_t start; uint32_t period; } swtimer_t;
static inline void swtimer_init(swtimer_t *t, uint32_t period_ms) {
t->start = millis(); t->period = period_ms;
}
static inline bool swtimer_expired(swtimer_t *t) {
if ((uint32_t)(millis() - t->start) >= t->period) {
t->start += t->period; // periodic, no drift
return true;
}
return false;
}
// usage
swtimer_t blink, report;
swtimer_init(&blink, 500);
swtimer_init(&report, 2000);
while (1) {
if (swtimer_expired(&blink)) led_toggle();
if (swtimer_expired(&report)) { /* send a status line */ }
}
Expected result
LED toggles every 500 ms (1 Hz blink). Adding more code to the loop does not change the blink rate unless that code itself takes longer than the period.
Common mistakes
if (millis() > last + 500)breaks at rollover. Use subtraction.last = millis()instead oflast += PERIODmakes the blink slowly drift.- Forgetting
volatileon the tick counter, so the loop never sees it change. - Putting a long function in the loop, which delays every toggle (the loop must stay fast).
Interview angle
Q: How do you do two things at different rates without an RTOS?
A: Time-stamp scheduling with millis(): each activity has its own last-run time and period; the main loop checks them all.
2. Read a Push Button with Debouncing
The problem
A mechanical switch does not change cleanly. For roughly 1β20 ms it bounces, producing many false edges. Without debouncing, one press can be counted as several.
Ideal: ____________βββββββββββββββ
Real: ____________ββββββββββββββββ β bounce
Idea (software debounce by sampling)
- Sample the pin at a fixed interval (e.g. every 10 ms), not as fast as possible.
- Only accept a new state after it has been stable for N consecutive samples.
- Report events (pressed / released), not raw levels.
Code
#define BTN_PORT GPIOC
#define BTN_PIN 13U
#define SAMPLE_MS 10U
#define STABLE_SAMPLES 5U // 5 Γ 10 ms = 50 ms of stability required
static void button_init(void) {
RCC->AHB1ENR |= RCC_AHB1ENR_GPIOCEN; // PC13 defaults to input
}
static inline bool button_raw(void) { // true = pressed (active low)
return (BTN_PORT->IDR & (1U << BTN_PIN)) == 0U;
}
typedef enum { BTN_NONE, BTN_PRESSED, BTN_RELEASED } btn_event_t;
typedef struct { bool stable; uint8_t count; } debounce_t;
btn_event_t debounce_update(debounce_t *d, bool raw) {
if (raw == d->stable) { // same as accepted state β nothing is changing
d->count = 0;
return BTN_NONE;
}
if (++d->count >= STABLE_SAMPLES) { // different for N samples in a row β accept it
d->stable = raw;
d->count = 0;
return raw ? BTN_PRESSED : BTN_RELEASED;
}
return BTN_NONE;
}
int main(void) {
tick_init(); led_init(); button_init();
debounce_t btn = { .stable = false, .count = 0 };
uint32_t last = 0;
while (1) {
if ((uint32_t)(millis() - last) >= SAMPLE_MS) {
last += SAMPLE_MS;
if (debounce_update(&btn, button_raw()) == BTN_PRESSED) {
led_toggle(); // exactly one toggle per press
}
}
}
}
Alternative: shift-register debounce
static uint8_t hist = 0;
hist = (uint8_t)((hist << 1) | button_raw()); // every 1β5 ms
if (hist == 0xFF) { /* stable pressed */ }
else if (hist == 0x00) { /* stable released */ }
Eight identical samples in a row means the state is stable. Very compact; the integrator above is easier to tune.
Add-on: long press
Count how long the stable state stays pressed; fire a different event once.
if (d->stable && ++d->hold == LONG_PRESS_SAMPLES) return BTN_LONG; // e.g. 100 samples = 1 s
if (!d->stable) d->hold = 0;
Expected result
One clean toggle per press, regardless of how noisy the contact is.
Common mistakes
- Using
HAL_Delay(20)after detecting an edge, which blocks the whole system. - Treating the level as the event (LED toggles continuously while held). You need edge/event detection.
- Debounce time too long (sluggish) or too short (still bounces). 20β50 ms is typical.
- Forgetting active-low:
pressed = !pin.
Hardware alternative
An RC filter + Schmitt trigger debounces in hardware. Software is cheaper and tunable.
Interview angle
Q: Hardware or software debounce? A: Software sampling costs no parts and is flexible; hardware helps when the pin drives an interrupt that must not see bounce. Often both: an interrupt starts a timer, and the state is read after the bounce time.
3. Set, Clear, Toggle and Test Individual Bits
The four operations
| Operation | C expression | Why it works |
|---|---|---|
| Set bit n | x |= (1U << n) | OR with 1 forces the bit to 1, others unchanged |
| Clear bit n | x &= ~(1U << n) | AND with 0 forces the bit to 0, others unchanged |
| Toggle bit n | x ^= (1U << n) | XOR with 1 flips the bit |
| Test bit n | (x >> n) & 1U | shift the bit to position 0 and mask |
Reusable macros
#define BIT(n) (1UL << (n))
#define BIT_SET(x, n) ((x) |= BIT(n))
#define BIT_CLR(x, n) ((x) &= ~BIT(n))
#define BIT_TGL(x, n) ((x) ^= BIT(n))
#define BIT_TEST(x, n) (((x) >> (n)) & 1UL) // returns 0 or 1
Or type-safe inline functions:
static inline uint32_t bit_set(uint32_t x, uint8_t n) { return x | (1UL << n); }
static inline uint32_t bit_clr(uint32_t x, uint8_t n) { return x & ~(1UL << n); }
static inline uint32_t bit_tgl(uint32_t x, uint8_t n) { return x ^ (1UL << n); }
static inline bool bit_test(uint32_t x, uint8_t n) { return (x >> n) & 1UL; }
Walk-through
uint8_t r = 0x00; // 0000 0000
BIT_SET(r, 3); // 0000 1000 = 0x08
BIT_SET(r, 0); // 0000 1001 = 0x09
BIT_TGL(r, 3); // 0000 0001 = 0x01
BIT_CLR(r, 0); // 0000 0000 = 0x00
bool b = BIT_TEST(r, 0); // 0
Multi-bit fields (write a value, not just a bit)
Clear the whole field first, then OR in the new value.
#define FIELD_WRITE(reg, mask, pos, val) \
((reg) = ((reg) & ~((uint32_t)(mask) << (pos))) | (((uint32_t)(val) & (mask)) << (pos)))
// Set pin PA5 mode to output (field = 2 bits at position 10, value 01)
FIELD_WRITE(GPIOA->MODER, 0x3U, 10U, 0x1U);
// Read a field
uint32_t mode = (GPIOA->MODER >> 10) & 0x3U;
On real registers
GPIOA->ODR |= (1U << 5); // set PA5 (read-modify-write, not interrupt-safe)
GPIOA->BSRR = (1U << 5); // set PA5 (single write, atomic)
GPIOA->BSRR = (1U << (5 + 16)); // clear PA5 (atomic)
if (GPIOC->IDR & (1U << 13)) { } // test PC13
Common mistakes
- Using
1instead of1U/1UL:1 << 31on a signedintis undefined behaviour. - Forgetting
~or parentheses in clear:x &= 1 << nclears everything else. - Precedence:
x & 1 == 0meansx & (1 == 0). Always bracket. - Shifting by more than the type width (undefined).
- Read-modify-write on a register shared with an ISR (use
BSRR-type registers or a critical section).
Quick test checklist
| Start | Operation | Expected |
|---|---|---|
0x00 | set bit 7 | 0x80 |
0xFF | clear bit 0 | 0xFE |
0xAA | toggle bit 1 | 0xA8 |
0x10 | test bit 4 | 1 |
Interview angle
Q: Why is x |= 1 << n risky on hardware registers?
A: It is a read-modify-write; if an interrupt changes the register between the read and the write, that change is lost. Use atomic set/clear registers when available.
4. Generate a Periodic Timer Interrupt
Goal
Get an interrupt at an exact, repeating interval without CPU involvement in between.
The math
Timer clock = F_clk / (PSC + 1)
Interrupt rate = Timer clock / (ARR + 1)
Period = (PSC + 1) Γ (ARR + 1) / F_clk
PSC = prescaler register, ARR = auto-reload (the count at which the counter wraps).
| Wanted period | PSC | ARR | Check (16 MHz) |
|---|---|---|---|
| 1 ms | 15 | 999 | 16 MHz / 16 / 1000 = 1 kHz |
| 10 ms | 15 | 9999 | 1 MHz / 10000 = 100 Hz |
| 1 s | 15999 | 999 | 1 kHz / 1000 = 1 Hz |
Choose values so ARR fits the counter width (16-bit β max 65535 for TIM6).
Code (TIM6, 1 Hz interrupt)
static volatile bool g_tick_1s = false;
void tim6_init_1hz(void) {
RCC->APB1ENR |= RCC_APB1ENR_TIM6EN; // 1. clock the timer
TIM6->PSC = 16000U - 1U; // 2. 16 MHz / 16000 = 1 kHz
TIM6->ARR = 1000U - 1U; // 1000 counts = 1 s
TIM6->EGR = TIM_EGR_UG; // 3. force-load PSC/ARR now
TIM6->SR = 0; // clear the flag that UG just raised
TIM6->DIER |= TIM_DIER_UIE; // 4. enable "update" interrupt in the timer
NVIC_SetPriority(TIM6_DAC_IRQn, 5U); // 5. enable it in the NVIC too
NVIC_EnableIRQ(TIM6_DAC_IRQn);
TIM6->CR1 |= TIM_CR1_CEN; // 6. start counting
}
void TIM6_DAC_IRQHandler(void) { // name must match the vector table
if (TIM6->SR & TIM_SR_UIF) {
TIM6->SR = ~TIM_SR_UIF; // clear flag first thing (otherwise it re-fires)
g_tick_1s = true; // keep ISR tiny: just signal main
}
}
int main(void) {
led_init();
tim6_init_1hz();
while (1) {
if (g_tick_1s) {
g_tick_1s = false;
led_toggle(); // real work happens in main, not in the ISR
}
}
}
Interrupt checklist (all three must be true)
- Interrupt enabled in the peripheral (
DIER.UIE). - Interrupt enabled in the NVIC (
NVIC_EnableIRQ). - Interrupt flag cleared inside the ISR.
Expected result
LED toggles every second, driven by hardware. The main loop can sleep (__WFI()) between events to save power.
Common mistakes
- Wrong handler name (it silently falls into the default infinite-loop handler).
- Not clearing
UIF, giving an interrupt storm and a frozen main loop. - Forgetting
EGR = UG, so the first period is wrong or the prescaler isn't applied yet. - Off-by-one: the counter counts
ARR + 1ticks, so writeN - 1. - Heavy work (printf, long loops) inside the ISR.
Interview angle
Q: How do you get a 1 ms tick from a 72 MHz clock? A: Pick PSC and ARR so (PSC+1)Γ(ARR+1) = 72000, e.g. PSC = 71, ARR = 999.
5. Configure PWM to Vary LED Brightness
Idea
PWM switches the LED on/off faster than the eye can see. The duty cycle (fraction of time ON) sets the average brightness.
Duty 25%: βββ βββ
β βββββββββ βββββββ
Duty 75%: βββββββββ βββββββββ
β βββ β
Formulas
PWM frequency = Timer clock / (ARR + 1)
Duty (%) = CCR / (ARR + 1) Γ 100
CCR = Duty% Γ (ARR + 1) / 100
Resolution = ARR + 1 steps
Choice here: timer clock = 16 MHz / 16 = 1 MHz; ARR = 999 β 1 kHz PWM, 1000 duty steps. 1 kHz is well above flicker visibility (use β₯ 200 Hz for LEDs).
Code (PA5 = TIM2_CH1, alternate function 1)
void pwm_init(void) {
RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN;
RCC->APB1ENR |= RCC_APB1ENR_TIM2EN;
// PA5 β alternate function mode (10), AF1 = TIM2
GPIOA->MODER &= ~(3U << (5U * 2U));
GPIOA->MODER |= (2U << (5U * 2U));
GPIOA->AFR[0] &= ~(0xFU << (5U * 4U));
GPIOA->AFR[0] |= (1U << (5U * 4U));
TIM2->PSC = 16U - 1U; // 1 MHz timer clock
TIM2->ARR = 1000U - 1U; // 1 kHz PWM
TIM2->CCR1 = 0U; // start at 0 %
// Channel 1: PWM mode 1 (110), preload enabled so duty changes apply cleanly
TIM2->CCMR1 &= ~(TIM_CCMR1_OC1M | TIM_CCMR1_CC1S);
TIM2->CCMR1 |= (6U << TIM_CCMR1_OC1M_Pos) | TIM_CCMR1_OC1PE;
TIM2->CCER |= TIM_CCER_CC1E; // output enable on channel 1
TIM2->CR1 |= TIM_CR1_ARPE; // buffer ARR as well
TIM2->EGR = TIM_EGR_UG; // load everything
TIM2->CR1 |= TIM_CR1_CEN; // run
}
void pwm_set_percent(uint8_t percent) {
if (percent > 100U) percent = 100U;
TIM2->CCR1 = ((TIM2->ARR + 1U) * percent) / 100U; // 100 % β CCR > ARR β always high
}
Breathing effect (smooth fade, non-blocking)
Eyes perceive brightness non-linearly, so a linear ramp looks "jumpy" at low levels. Squaring the level (a cheap gamma correction) looks smoother.
int main(void) {
tick_init();
pwm_init();
int8_t level = 0, dir = 1; // 0..100
uint32_t last = 0;
while (1) {
if ((uint32_t)(millis() - last) >= 10U) { // update every 10 ms
last += 10U;
level += dir;
if (level >= 100) { level = 100; dir = -1; }
if (level <= 0) { level = 0; dir = 1; }
TIM2->CCR1 = ((uint32_t)level * level * (TIM2->ARR + 1U)) / 10000U; // gamma β 2
}
}
}
Full fade up and down takes about 2 s (100 steps Γ 10 ms Γ 2).
Expected result
Smooth "breathing" LED on PA5, with no CPU work between the 10 ms updates.
Common mistakes
- Forgetting alternate-function mode or the AF number, so the pin stays a plain GPIO.
- Writing
CCR > ARR+1and expecting something special (it just means 100%). - Updating
CCRat a random time without preload may cause a glitch in the current cycle. - PWM frequency too low (visible flicker) or so high that resolution drops (
resolution = clock / freq). - Confusing duty cycle (%) with brightness (non-linear).
Other uses
Motor speed, servo pulses (50 Hz, 1β2 ms), buzzer tones, simple DAC (PWM + RC filter), power regulation.
Interview angle
Q: 10 kHz PWM with 8-bit resolution from a 16 MHz timer clock? A: ARR + 1 = 256 needs a 2.56 MHz timer clock, so prescaler β 6.25: not exact. Pick 10 kHz at 100 steps instead (1 MHz / 100), or accept ~9.8 kHz. Frequency and resolution trade off against the same clock.
6. Implement a Circular Buffer
Why
A circular (ring) buffer is a fixed-size FIFO. It is the standard way to move data between an ISR (fast, bursty) and the main loop (slower, steady): UART RX/TX, sensor samples, event queues.
index: 0 1 2 3 4 5 6 7
data: [ ] [ ] [D] [E] [F] [ ] [ ] [ ]
βtail βhead
tail = next to read head = next to write
Both indices wrap around with & (SIZE-1).
Design choices
| Approach | How full/empty is detected | Notes |
|---|---|---|
| One slot kept empty (used below) | empty: head == tail; full: next(head) == tail | No shared count, so safe for 1 producer + 1 consumer without locks. Capacity = SIZE β 1 |
Separate count | count == 0 / count == SIZE | Uses all slots, but count is modified by both sides, so needs protection |
| Free-running indices | full: head - tail == SIZE | Uses all slots, SIZE must be a power of 2 |
Code
#define RB_SIZE 64U // MUST be a power of two for the mask trick
typedef struct {
uint8_t data[RB_SIZE];
volatile uint16_t head; // written only by the producer
volatile uint16_t tail; // written only by the consumer
} ringbuf_t;
static inline void rb_init(ringbuf_t *rb) { rb->head = rb->tail = 0; }
static inline bool rb_is_empty(const ringbuf_t *rb) { return rb->head == rb->tail; }
static inline bool rb_is_full (const ringbuf_t *rb) {
return ((rb->head + 1U) & (RB_SIZE - 1U)) == rb->tail;
}
static inline uint16_t rb_count(const ringbuf_t *rb) {
return (uint16_t)((rb->head - rb->tail) & (RB_SIZE - 1U));
}
bool rb_put(ringbuf_t *rb, uint8_t byte) { // producer side
uint16_t next = (rb->head + 1U) & (RB_SIZE - 1U);
if (next == rb->tail) return false; // full β caller decides what to do
rb->data[rb->head] = byte; // write data FIRSTβ¦
rb->head = next; // β¦then publish it
return true;
}
bool rb_get(ringbuf_t *rb, uint8_t *byte) { // consumer side
if (rb->head == rb->tail) return false; // empty
*byte = rb->data[rb->tail]; // read data FIRSTβ¦
rb->tail = (rb->tail + 1U) & (RB_SIZE - 1U); // β¦then release the slot
return true;
}
Lock-free rule: with exactly one writer and one reader (e.g. ISR writes, main reads) and each index modified by only one side, no interrupt masking is needed. With multiple producers or consumers, add a critical section or a mutex.
Quick host-side test (compile on a PC)
#include <assert.h>
int main(void) {
ringbuf_t rb; rb_init(&rb);
uint8_t v;
assert(rb_is_empty(&rb));
for (uint8_t i = 0; i < RB_SIZE - 1U; i++) assert(rb_put(&rb, i));
assert(rb_is_full(&rb));
assert(!rb_put(&rb, 99)); // overflow rejected
for (uint8_t i = 0; i < RB_SIZE - 1U; i++) { assert(rb_get(&rb, &v)); assert(v == i); }
assert(!rb_get(&rb, &v)); // underflow rejected
// wrap-around: push/pop past the end many times
for (int i = 0; i < 1000; i++) { assert(rb_put(&rb, (uint8_t)i)); assert(rb_get(&rb, &v)); assert(v == (uint8_t)i); }
return 0;
}
Variants
- Overwrite-oldest (for "latest N samples" logs): if full, advance
tailinstead of failing. This needs care with concurrency. - Element type other than bytes: change
uint8_t data[]to a struct (e.g. a sample record). - Bulk read/write with
memcpysplit at the wrap point for DMA use.
Common mistakes
- SIZE not a power of two while using the mask
& (SIZE-1). - Moving the index before writing/reading the data (consumer sees garbage).
- Missing
volatileon indices shared with an ISR. - Ignoring the "full" return value, which silently loses data.
- Assuming capacity = SIZE (it is SIZEβ1 in this design).
Interview angle
Q: How do you tell full from empty when head == tail? A: Keep one slot unused, or keep a separate count, or use free-running indices and compare the difference.
7. Send and Receive Data Using UART
UART in one picture
Asynchronous, 2 wires (TX, RX) + GND, no clock line: both sides must agree on the baud rate.
idle(1) | start(0) | D0 D1 D2 D3 D4 D5 D6 D7 | stop(1) β "8N1" frame = 10 bits per byte
Throughput at 115200 baud β 115200 / 10 = 11,520 bytes/s.
Baud-rate register
BRR = F_pclk / baud (OVER8 = 0, so BRR already holds mantissa<<4 | fraction)
115200 @ 16 MHz β 16000000 / 115200 = 138.9 β 139 (error β 0.08 %, fine)
Keep the baud error below roughly 2β3 %.
Design
- TX: simple version waits on
TXE(polling). Better: put bytes into a TX ring buffer and let a TXE interrupt drain it. - RX: always interrupt-driven. The ISR drops each byte into an RX ring buffer, and the main loop reads it. This avoids losing bytes while main is busy.
Initialisation
static ringbuf_t rx_rb, tx_rb; // from Program 6 (RB_SIZE = 64)
volatile uint32_t g_rx_overflow;
void uart2_init(uint32_t baud) {
RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN;
RCC->APB1ENR |= RCC_APB1ENR_USART2EN;
// PA2 (TX), PA3 (RX) β alternate function, AF7
GPIOA->MODER &= ~((3U << 4) | (3U << 6));
GPIOA->MODER |= ((2U << 4) | (2U << 6));
GPIOA->AFR[0] &= ~((0xFU << 8) | (0xFU << 12));
GPIOA->AFR[0] |= ((7U << 8) | (7U << 12));
USART2->BRR = (SystemCoreClock + baud / 2U) / baud; // rounded
USART2->CR1 = USART_CR1_TE | USART_CR1_RE | USART_CR1_RXNEIE; // 8N1 default
USART2->CR1 |= USART_CR1_UE; // enable last
rb_init(&rx_rb); rb_init(&tx_rb);
NVIC_SetPriority(USART2_IRQn, 6U);
NVIC_EnableIRQ(USART2_IRQn);
}
Interrupt handler (RX and TX)
void USART2_IRQHandler(void) {
uint32_t sr = USART2->SR;
// ---- receive ----
if (sr & (USART_SR_RXNE | USART_SR_ORE)) {
uint8_t b = (uint8_t)USART2->DR; // reading SR then DR clears RXNE and ORE
if (!rb_put(&rx_rb, b)) g_rx_overflow++; // buffer full β count the loss
}
// ---- transmit (only when the TXE interrupt is enabled) ----
if ((sr & USART_SR_TXE) && (USART2->CR1 & USART_CR1_TXEIE)) {
uint8_t b;
if (rb_get(&tx_rb, &b)) USART2->DR = b;
else USART2->CR1 &= ~USART_CR1_TXEIE; // nothing left β stop TXE interrupts
}
}
Send functions
// Simple polling version (fine for debug, blocks until the shift register is free)
void uart_putc_poll(char c) {
while (!(USART2->SR & USART_SR_TXE)) { }
USART2->DR = (uint8_t)c;
}
// Interrupt-driven version (non-blocking unless the buffer is full)
void uart_putc(char c) {
while (!rb_put(&tx_rb, (uint8_t)c)) { } // wait for room
USART2->CR1 |= USART_CR1_TXEIE; // kick the ISR
}
void uart_puts(const char *s) { while (*s) uart_putc(*s++); }
Receive and a tiny command parser
bool uart_getc(uint8_t *c) { return rb_get(&rx_rb, c); } // non-blocking
#define LINE_MAX 32U
static char line[LINE_MAX];
static uint8_t len;
static void handle_command(const char *cmd) {
if (strcmp(cmd, "LED ON") == 0) { led_on(); uart_puts("OK\r\n"); }
else if (strcmp(cmd, "LED OFF") == 0) { led_off(); uart_puts("OK\r\n"); }
else { uart_puts("ERR: unknown command\r\n"); }
}
void cli_poll(void) { // call from the main loop
uint8_t c;
while (uart_getc(&c)) {
if (c == '\r' || c == '\n') {
if (len) { line[len] = '\0'; handle_command(line); len = 0; }
} else if (len < LINE_MAX - 1U) {
line[len++] = (char)c;
} else {
len = 0; // line too long β discard
}
}
}
int main(void) {
tick_init(); led_init(); uart2_init(115200);
uart_puts("Ready\r\n");
while (1) { cli_poll(); }
}
How to test
Open a serial terminal (115200, 8N1, no flow control) on the Nucleo's virtual COM port. Type LED ON + Enter β LED lights and you get OK.
Common mistakes
- Wrong baud rate or clock (garbage characters).
- TX/RX wires crossed (TX of one goes to RX of the other).
- Not clearing the overrun flag (RX locks up). Reading
SRthenDRclears it. - Blocking
printfinside an ISR. - Treating received data as a C string without a terminating
'\0'. - No line-length check, giving a buffer overflow from long input.
- Forgetting terminal line endings (
\r\n).
Error types to know
| Error | Meaning |
|---|---|
| Framing | stop bit not seen (usually wrong baud) |
| Parity | parity mismatch (if parity is enabled) |
| Overrun | new byte arrived before the old one was read |
| Noise | sampling disagreement on the line |
Interview angle
Q: Why use a ring buffer with UART RX? A: The ISR must return fast; the buffer decouples arrival speed from processing speed so no bytes are lost while main is busy.
8. Interface a Sensor Using IΒ²C or SPI
The key technique: split the code into transport (bus read/write) and driver (sensor-specific logic). Then the driver is portable and testable.
application βββΊ sensor driver βββΊ bus transport (IΒ²C / SPI) βββΊ hardware
8A. IΒ²C: TMP102 temperature sensor
Facts about the sensor
| Item | Value |
|---|---|
| 7-bit address | 0x48 (ADD0 pin tied to GND) |
| Temperature register | pointer 0x00, 2 bytes |
| Format | 12-bit two's complement, left-justified in 16 bits |
| Resolution | 0.0625 Β°C per LSB |
Transport layer (STM32 HAL shown; swap for any MCU)
extern I2C_HandleTypeDef hi2c1;
bool i2c_read_reg(uint8_t addr7, uint8_t reg, uint8_t *buf, uint16_t len) {
return HAL_I2C_Mem_Read(&hi2c1, (uint16_t)(addr7 << 1), reg,
I2C_MEMADD_SIZE_8BIT, buf, len, 50) == HAL_OK; // 50 ms timeout
}
bool i2c_write_reg(uint8_t addr7, uint8_t reg, const uint8_t *buf, uint16_t len) {
return HAL_I2C_Mem_Write(&hi2c1, (uint16_t)(addr7 << 1), reg,
I2C_MEMADD_SIZE_8BIT, (uint8_t *)buf, len, 50) == HAL_OK;
}
HAL expects the 8-bit form (address shifted left by 1); the shift is a classic source of bugs.
Driver
#define TMP102_ADDR 0x48U
#define TMP102_REG_TEMP 0x00U
// Returns temperature in milli-Β°C (25.0 Β°C β 25000). No floats needed.
bool tmp102_read_mC(int32_t *mC) {
uint8_t b[2];
if (!i2c_read_reg(TMP102_ADDR, TMP102_REG_TEMP, b, 2)) return false;
int16_t raw = (int16_t)(((uint16_t)b[0] << 4) | (b[1] >> 4)); // 12-bit value
if (raw & 0x800) raw -= 0x1000; // sign-extend negatives
*mC = ((int32_t)raw * 625) / 10; // Γ 0.0625 Β°C = Γ62.5 mΒ°C
return true;
}
Sanity check: 25 Β°C = 400 counts = 0x190 β bytes 0x19 0x00 β (0x19<<4)|0 = 0x190 = 400 β 400 Γ 62.5 = 25000 mΒ°C β.
Usage
int32_t t;
if (tmp102_read_mC(&t)) printf("T = %ld.%02ld C\r\n", (long)(t / 1000), (long)((t % 1000) / 10));
else printf("sensor error\r\n");
IΒ²C bus scan (first step when a sensor "doesn't respond")
for (uint8_t a = 1; a < 127; a++)
if (HAL_I2C_IsDeviceReady(&hi2c1, a << 1, 1, 10) == HAL_OK)
printf("found device at 0x%02X\r\n", a);
IΒ²C refresher
- Two open-drain wires SDA/SCL with pull-up resistors (typ. 4.7 kΞ© at 100 kHz, 2.2 kΞ© at 400 kHz).
- Transaction:
START β address+R/W β ACK β data bytes (ACK each) β STOP. A register read uses a repeated START between the pointer write and the data read. - Speeds: 100 kHz standard, 400 kHz fast.
8B. SPI: register read pattern (ADXL345 accelerometer as example)
| Item | Value |
|---|---|
| Mode | SPI mode 3 (CPOL = 1, CPHA = 1), up to 5 MHz |
| Read command | first byte = 0x80 | reg (bit 7 = read) |
| Multi-byte read | 0xC0 | reg (bit 6 = auto-increment) |
| Device ID | register 0x00 should read 0xE5 |
// Transport (implemented once per MCU)
uint8_t spi_txrx(uint8_t tx); // sends one byte, returns the byte received at the same time
void cs_low(void);
void cs_high(void);
// Driver
uint8_t adxl_read_reg(uint8_t reg) {
cs_low();
spi_txrx(0x80U | reg); // command
uint8_t v = spi_txrx(0xFF); // dummy byte clocks the data out
cs_high();
return v;
}
void adxl_read_burst(uint8_t reg, uint8_t *buf, uint8_t n) {
cs_low();
spi_txrx(0xC0U | reg);
for (uint8_t i = 0; i < n; i++) buf[i] = spi_txrx(0xFF);
cs_high();
}
// First test after wiring
if (adxl_read_reg(0x00) != 0xE5) { /* wiring, mode, or CS problem */ }
Which bus when?
| IΒ²C | SPI | |
|---|---|---|
| Wires | 2 (shared by all) | 3 + 1 CS per device |
| Speed | 100β400 kHz (up to ~1 MHz) | 1β50 MHz |
| Addressing | 7-bit address | chip-select line |
| Best for | many slow sensors | fast data, displays, flash |
Common mistakes
IΒ²C: missing pull-ups; 7-bit vs 8-bit address confusion; no timeout (bus hangs forever); not checking ACK; bus stuck low after a crash (recover by toggling SCL up to 9 times). SPI: wrong mode (CPOL/CPHA); CS not toggled per transaction; forgetting dummy bytes; MISO floating when CS is high. Both: reading the datasheet's byte order wrong (MSB first vs LSB first); 3.3 V vs 5 V mismatch; not sign-extending negative values.
Interview angle
Q: Your IΒ²C read returns 0xFF every time. What do you check? A: Pull-ups present? Correct address (and shift)? Sensor powered? Scan the bus with a scope or logic analyzer to see whether the address is ACKed.
9. Implement an Interrupt-Driven State Machine
Idea
Interrupts only post events. The main loop dispatches events to a state machine. This keeps ISRs tiny and makes behaviour easy to reason about.
[ISR] ββeventβββΊ [event queue] βββΊ [main loop] βββΊ state machine βββΊ actions
Example behaviour
Button presses cycle the LED mode: OFF β SLOW blink β FAST blink β OFF. If left in FAST for 10 s, it automatically returns to OFF.
BUTTON BUTTON
βββββββ ββββββΊ ββββββββ ββββββΊ ββββββββ
β OFF β β SLOW β β FAST β
βββββββ βββββββ ββββββββ ββββββββ
β² (not used) β
βββββ BUTTON, or TIMEOUT (10 s) ββ
(In SLOW, BUTTON goes to FAST. In FAST, BUTTON or TIMEOUT goes to OFF.)
Event source: EXTI interrupt on PC13 (falling edge)
static ringbuf_t evq; // reuse the ring buffer from Program 6
typedef enum { EVT_NONE = 0, EVT_BUTTON, EVT_TIMEOUT } event_t;
void button_irq_init(void) {
RCC->AHB1ENR |= RCC_AHB1ENR_GPIOCEN;
RCC->APB2ENR |= RCC_APB2ENR_SYSCFGEN;
// Route EXTI line 13 to port C (EXTICR[3] covers lines 12β15; line 13 = bits 7:4; 0x2 = port C)
SYSCFG->EXTICR[3] = (SYSCFG->EXTICR[3] & ~(0xFU << 4)) | (0x2U << 4);
EXTI->IMR |= (1U << 13); // unmask line 13
EXTI->FTSR |= (1U << 13); // trigger on falling edge (button press)
NVIC_SetPriority(EXTI15_10_IRQn, 6U);
NVIC_EnableIRQ(EXTI15_10_IRQn);
}
void EXTI15_10_IRQHandler(void) {
if (EXTI->PR & (1U << 13)) {
EXTI->PR = (1U << 13); // write 1 to clear the pending bit
static uint32_t last; // simple lock-out against contact bounce
uint32_t now = millis();
if ((uint32_t)(now - last) >= 50U) {
last = now;
rb_put(&evq, EVT_BUTTON); // just post the event, do nothing else
}
}
}
The state machine
typedef enum { ST_OFF, ST_SLOW, ST_FAST } state_t;
static state_t state = ST_OFF;
static uint32_t state_entered_ms;
static uint32_t last_toggle_ms;
static void enter_state(state_t s) { // entry actions live in one place
state = s;
state_entered_ms = millis();
last_toggle_ms = state_entered_ms;
if (s == ST_OFF) led_off();
}
static void fsm_handle(event_t e) { // transitions: (state, event) β next state
switch (state) {
case ST_OFF:
if (e == EVT_BUTTON) enter_state(ST_SLOW);
break;
case ST_SLOW:
if (e == EVT_BUTTON) enter_state(ST_FAST);
break;
case ST_FAST:
if (e == EVT_BUTTON || e == EVT_TIMEOUT) enter_state(ST_OFF);
break;
}
}
static void fsm_run(void) { // per-state "do" activity, called every loop
uint32_t period = 0;
if (state == ST_SLOW) period = 500;
else if (state == ST_FAST) period = 100;
if (period && (uint32_t)(millis() - last_toggle_ms) >= period) {
last_toggle_ms += period;
led_toggle();
}
if (state == ST_FAST && (uint32_t)(millis() - state_entered_ms) >= 10000U) {
rb_put(&evq, EVT_TIMEOUT); // time-based events use the same path
}
}
int main(void) {
tick_init(); led_init(); rb_init(&evq); button_irq_init();
enter_state(ST_OFF);
while (1) {
uint8_t e;
while (rb_get(&evq, &e)) fsm_handle((event_t)e); // drain all pending events
fsm_run();
// optional: __WFI(); // sleep until next interrupt (SysTick wakes it every 1 ms)
}
}
Table-driven alternative (scales better to many states)
typedef struct { state_t next; void (*action)(void); } transition_t;
static const transition_t table[3][2] = { // [state][event-1]
/* BUTTON TIMEOUT */
/* OFF */ { {ST_SLOW, NULL}, {ST_OFF, NULL} },
/* SLOW */ { {ST_FAST, NULL}, {ST_SLOW, NULL} },
/* FAST */ { {ST_OFF, led_off}, {ST_OFF, led_off} },
};
void fsm_dispatch(event_t e) {
const transition_t *t = &table[state][e - 1];
if (t->action) t->action();
state = t->next;
}
Why this design works
| Principle | Benefit |
|---|---|
| ISR only posts an event | Short ISR, low latency, no shared-data races on state |
Only main touches state | No locking needed |
Entry actions in enter_state() | Behaviour per state is in one place |
| Events from ISR, timers, UART all use one queue | Uniform handling |
Common mistakes
- Doing LED/UART/delay work inside the ISR.
- Not clearing the EXTI pending bit (interrupt re-fires forever).
- Missing debounce on the interrupt path (one press = many events).
- Changing
statefrom both ISR and main, which creates a race. - No default/invalid-state handling (an enum with an unexpected value).
Interview angle
Q: Why not put the whole state machine in the ISR? A: Long ISRs raise latency for other interrupts and are hard to debug. ISRs capture events; the main loop (or a task) does the processing.
10. Create a FreeRTOS Producer-Consumer Application
Idea
One task produces data, another consumes it. A queue sits between them and handles buffering, blocking and thread-safety for you.
[Producer task] ββxQueueSendβββΊ [ Queue (8 items) ] ββxQueueReceiveβββΊ [Consumer task]
(every 100 ms) FIFO, thread-safe (sleeps until data arrives)
Why a queue (not a global variable)?
- Copies data safely between tasks; no race conditions.
- The consumer blocks (uses zero CPU) while waiting.
- If the producer is faster for a moment, the queue absorbs the burst.
Code
#include "FreeRTOS.h"
#include "task.h"
#include "queue.h"
typedef struct {
uint32_t tick; // when the sample was made
uint16_t value; // the data
} sample_t;
static QueueHandle_t xSampleQueue;
static volatile uint32_t dropped; // samples lost because the queue was full
static void vProducerTask(void *arg) {
(void)arg;
TickType_t wake = xTaskGetTickCount();
uint16_t counter = 0;
for (;;) {
sample_t s = { .tick = xTaskGetTickCount(), .value = counter++ };
// wait up to 10 ms for space; if still full, count it and move on
if (xQueueSend(xSampleQueue, &s, pdMS_TO_TICKS(10)) != pdPASS) {
dropped++;
}
vTaskDelayUntil(&wake, pdMS_TO_TICKS(100)); // exact 100 ms period, no drift
}
}
static void vConsumerTask(void *arg) {
(void)arg;
sample_t s;
char msg[48];
for (;;) {
// block forever until an item arrives (needs INCLUDE_vTaskSuspend = 1)
if (xQueueReceive(xSampleQueue, &s, portMAX_DELAY) == pdPASS) {
snprintf(msg, sizeof msg, "t=%lu value=%u\r\n", (unsigned long)s.tick, s.value);
uart_puts(msg);
}
}
}
int main(void) {
uart2_init(115200); // from Program 7
xSampleQueue = xQueueCreate(8, sizeof(sample_t)); // 8 items, each a full sample_t
configASSERT(xSampleQueue != NULL);
xTaskCreate(vProducerTask, "prod", 256, NULL, 2, NULL); // stack in WORDS (256 = 1 KB)
xTaskCreate(vConsumerTask, "cons", 384, NULL, 3, NULL); // higher priority: drains quickly
vTaskStartScheduler(); // never returns if all is well
for (;;) { } // reached only if heap is too small
}
Note: FreeRTOS uses SysTick for its own tick, so don't also use the bare-metal
SysTick_Handler/millis()from the earlier programs. UsexTaskGetTickCount()instead.
Variant: ISR as the producer
extern QueueHandle_t xRxQueue;
void USART2_IRQHandler(void) {
BaseType_t woken = pdFALSE;
if (USART2->SR & USART_SR_RXNE) {
uint8_t b = (uint8_t)USART2->DR;
xQueueSendFromISR(xRxQueue, &b, &woken); // note the FromISR version
}
portYIELD_FROM_ISR(woken); // switch to the consumer immediately if it has higher priority
}
Required: this ISR priority must be numerically β₯ configMAX_SYSCALL_INTERRUPT_PRIORITY, i.e. logically no more urgent than the kernel's limit, otherwise FreeRTOS API calls are illegal in it.
Choosing priorities
| Situation | Typical choice |
|---|---|
| Consumer must keep up | Consumer priority β₯ producer |
| Producer is time-critical (sampling) | Producer higher, deep queue |
| Consumer slow (writes to flash/UART) | Bigger queue, or drop policy |
What to watch for
| Question | How to check |
|---|---|
| Queue ever full? | dropped counter, uxQueueSpacesAvailable() |
| Stack big enough? | uxTaskGetStackHighWaterMark(NULL) (smaller = closer to overflow) |
| Heap enough? | xPortGetFreeHeapSize() |
| Tasks starving? | Task stats / trace tools |
Common mistakes
- Using normal API calls (
xQueueSend) inside an ISR instead of...FromISR. - Task stack too small (
snprintf/printfneed a lot of stack). - Busy-waiting in a task instead of blocking on the queue or
vTaskDelay. - Sending pointers to a local variable through a queue (the data is gone when the sender moves on). Copy the value, or send a pointer to a static buffer or pool.
- Priorities inverted so the consumer never runs (starvation).
- Forgetting to check
xQueueCreate/xTaskCreatereturn values.
Interview angle
Q: Queue vs semaphore for ISR β task signalling? A: Semaphore if you only need to say "something happened"; queue if you also need to pass data.
11. Implement Watchdog-Based Recovery
Idea
A watchdog timer (WDT) resets the MCU if firmware fails to "kick" it in time. This turns a hang into an automatic recovery. A good design also detects why it reset and recovers gracefully, not blindly.
normal: ββkickββββkickββββkickββββkickβββΊ (timer keeps getting reloaded)
hang: ββkickββββkickββββ X ........ RESET (timer runs out β MCU restarts)
Two watchdogs on STM32 (concept)
| IWDG (independent) | WWDG (window) | |
|---|---|---|
| Clock | Own LSI oscillator, so it works even if the main clock fails | APB clock |
| Cannot be stopped once started | Yes | Yes |
| Rule | Must be kicked before timeout | Must be kicked inside a window (too early is also a reset) |
| Use | General hang recovery | Detect runaway fast loops too |
This program uses the IWDG.
Timeout math (LSI β 32 kHz, prescaler /64)
Counter clock = 32000 / 64 = 500 Hz β 2 ms per tick
Timeout = (RLR + 1) Γ 2 ms β RLR = timeout_ms / 2 β 1
Max (RLR = 4095, /64) β 8.2 s
Note that LSI is inaccurate (roughly 17β47 kHz on F4), so use generous timeouts and don't use the watchdog as a timer.
Code: set up and kick
#define IWDG_KEY_ENABLE 0xCCCCU
#define IWDG_KEY_RELOAD 0xAAAAU
#define IWDG_KEY_ACCESS 0x5555U
void iwdg_init(uint32_t timeout_ms) {
IWDG->KR = IWDG_KEY_ACCESS; // unlock PR/RLR
IWDG->PR = 4U; // divider /64
IWDG->RLR = (timeout_ms / 2U) - 1U;
while (IWDG->SR) { } // wait until PR/RLR updates finish
IWDG->KR = IWDG_KEY_RELOAD; // load counter
IWDG->KR = IWDG_KEY_ENABLE; // start. Cannot be stopped now (until reset)
}
static inline void iwdg_kick(void) { IWDG->KR = IWDG_KEY_RELOAD; }
Code: find out why we reset
typedef enum { RST_POWER, RST_PIN, RST_SOFT, RST_IWDG, RST_WWDG, RST_OTHER } rst_cause_t;
rst_cause_t reset_cause_get_and_clear(void) {
uint32_t csr = RCC->CSR;
RCC->CSR |= RCC_CSR_RMVF; // clear the flags (otherwise they stay set)
if (csr & RCC_CSR_IWDGRSTF) return RST_IWDG; // check watchdog first
if (csr & RCC_CSR_WWDGRSTF) return RST_WWDG;
if (csr & RCC_CSR_SFTRSTF) return RST_SOFT;
if (csr & (RCC_CSR_PORRSTF | RCC_CSR_BORRSTF)) return RST_POWER; // power-on also sets PIN, so test it before PIN
if (csr & RCC_CSR_PINRSTF) return RST_PIN;
return RST_OTHER;
}
Code: remember across the reset
Data in a .noinit RAM section survives a watchdog reset (not a power loss). Validate with a magic number.
typedef struct {
uint32_t magic;
uint32_t wdg_resets; // consecutive watchdog resets
uint32_t last_missing_mask; // which task did not check in
} reset_info_t;
#define INFO_MAGIC 0xC0FFEE42U
__attribute__((section(".noinit"))) static reset_info_t g_info;
Your linker script needs a
.noinit (NOLOAD)section (add one if the default doesn't have it). Alternative: store these in the RTC backup registers.
Code: supervisor pattern (don't kick blindly!)
Kicking the watchdog from a timer ISR defeats the purpose, since the ISR keeps running while the main program is dead. Instead, each critical activity checks in, and the watchdog is kicked only when all have done so.
#define TASK_SENSOR (1U << 0)
#define TASK_COMMS (1U << 1)
#define TASK_ALL (TASK_SENSOR | TASK_COMMS)
static uint32_t g_alive; // touched only from the main loop β no race
static inline void wd_checkin(uint32_t mask) { g_alive |= mask; }
static void wd_supervise(void) { // call once per main-loop pass
if ((g_alive & TASK_ALL) == TASK_ALL) {
g_alive = 0;
iwdg_kick();
} else {
g_info.last_missing_mask = TASK_ALL & ~g_alive; // remember who is silent
}
}
Code: recovery logic at boot
int main(void) {
rst_cause_t cause = reset_cause_get_and_clear();
if (cause != RST_IWDG || g_info.magic != INFO_MAGIC) {
g_info.magic = INFO_MAGIC; // fresh start β reset the counters
g_info.wdg_resets = 0;
g_info.last_missing_mask = 0;
} else {
g_info.wdg_resets++;
}
outputs_to_safe_state(); // motors off, heaters off, relays open, ASAP
tick_init(); uart2_init(115200);
if (cause == RST_IWDG) {
uart_puts("RECOVERED from watchdog reset\r\n");
// print g_info.wdg_resets and g_info.last_missing_mask
}
bool safe_mode = (g_info.wdg_resets >= 3U); // repeated failure β stop trying the same thing
if (safe_mode) uart_puts("SAFE MODE: minimal functionality\r\n");
iwdg_init(2000); // start AFTER initialisation is done (or use a longer first timeout)
while (1) {
if (!safe_mode) { sensor_task(); wd_checkin(TASK_SENSOR); }
comms_task(); wd_checkin(TASK_COMMS);
wd_supervise();
}
}
Test it
Add a command (e.g. HANG) that runs while (1) { }. After ~2 s the board should restart, print RECOVERED from watchdog reset, and increment the counter. After three consecutive hangs it should enter safe mode.
Debugging tip: the debugger halting the CPU would trigger resets. Freeze the IWDG while halted:
DBGMCU->APB1FZ |= DBGMCU_APB1_FZ_DBG_IWDG_STOP;
Same idea in an RTOS
Each task sets an event-group bit when healthy. One watchdog task waits for all bits with a timeout, then kicks.
// in each task: xEventGroupSetBits(wdg_group, TASK_X_BIT);
// in the watchdog task:
EventBits_t b = xEventGroupWaitBits(wdg_group, ALL_BITS, pdTRUE /*clear*/, pdTRUE /*all*/, pdMS_TO_TICKS(1500));
if ((b & ALL_BITS) == ALL_BITS) iwdg_kick(); // otherwise do NOT kick β reset
Common mistakes
- Kicking in an ISR or a dedicated timer (hides real hangs).
- Timeout shorter than the worst-case loop time (random resets). Measure first.
- Starting the watchdog before slow initialisation finishes.
- No record of why the system reset, which makes field bugs impossible to debug.
- Infinite reset loops with no safe mode or back-off.
- Leaving outputs in an unsafe state during the reset.
- Using the watchdog to hide a bug instead of fixing it.
Interview angle
Q: Where should you kick the watchdog? A: In one place, in the main flow, only after confirming all critical tasks made progress, never from a timer interrupt.
12. Build a Small Sensor-Monitoring Firmware Project
What it does
Reads a temperature sensor every 500 ms, smooths it, raises an alarm with hysteresis, reports over UART every 2 s, accepts commands, blinks an LED to show status, handles a missing sensor, and recovers from hangs with the watchdog.
This project uses the programs from earlier sections as modules.
Architecture
SysTick (1 ms) ββΊ millis() βββββββββββββββ
UART RX ISR βββΊ rx ring buffer βββΊ CLI β
βΌ
main loop (cooperative, time-stamped):
every 500 ms : read TMP102 ββΊ moving average ββΊ alarm state machine
every 2 s : print report
every loop : cli_poll(), led_update(), wd_supervise()
Folder layout
sensor-monitor/
βββ main.c # init + the cooperative loop
βββ config.h # all tunable numbers in ONE place
βββ app/
β βββ monitor.c/.h # filter + alarm state machine
β βββ cli.c/.h # command parser (Program 7)
βββ drivers/
β βββ tmp102.c/.h # sensor driver (Program 8)
β βββ uart.c/.h # UART + ring buffers (Programs 6, 7)
β βββ wdt.c/.h # IWDG + reset cause (Program 11)
βββ lib/
βββ ringbuf.c/.h # circular buffer (Program 6)
Separate drivers (hardware) from app (logic), so the app logic can be unit-tested on a PC.
config.h
#define SAMPLE_PERIOD_MS 500U
#define REPORT_PERIOD_MS 2000U
#define TEMP_HIGH_mC 30000 // alarm ON above 30.0 Β°C
#define TEMP_HYST_mC 1000 // alarm OFF below 29.0 Β°C
#define AVG_WINDOW 8U
#define SENSOR_FAIL_LIMIT 3U // consecutive failed reads before declaring a fault
#define WDG_TIMEOUT_MS 2000U
monitor.c: filter + alarm with hysteresis
typedef enum { MON_NORMAL, MON_ALARM, MON_SENSOR_FAULT } mon_state_t;
typedef struct { int32_t buf[AVG_WINDOW]; int32_t sum; uint8_t idx, filled; } mavg_t;
static mavg_t avg_f;
static mon_state_t state = MON_NORMAL;
static uint8_t fails;
static int32_t last_mC, avg_mC;
static int32_t high_mC = TEMP_HIGH_mC; // can be changed by a CLI command
static int32_t mavg_push(mavg_t *m, int32_t v) {
if (m->filled == AVG_WINDOW) m->sum -= m->buf[m->idx]; // drop the oldest
else m->filled++;
m->buf[m->idx] = v;
m->sum += v;
m->idx = (uint8_t)((m->idx + 1U) % AVG_WINDOW);
return m->sum / m->filled;
}
void monitor_on_sample(bool ok, int32_t mC) {
if (!ok) {
if (++fails >= SENSOR_FAIL_LIMIT) state = MON_SENSOR_FAULT;
return; // keep the last good values
}
fails = 0;
last_mC = mC;
avg_mC = mavg_push(&avg_f, mC);
if (state == MON_SENSOR_FAULT) state = MON_NORMAL; // sensor came back
if (state == MON_NORMAL && avg_mC > high_mC) state = MON_ALARM;
else if (state == MON_ALARM && avg_mC < high_mC - TEMP_HYST_mC) state = MON_NORMAL;
}
mon_state_t monitor_state(void) { return state; }
int32_t monitor_last_mC(void) { return last_mC; }
int32_t monitor_avg_mC(void) { return avg_mC; }
void monitor_set_high_c(int32_t c) { high_mC = c * 1000; }
Why hysteresis? Without it, a reading hovering near 30.0 Β°C would flip the alarm on/off constantly. With 1 Β°C hysteresis it turns on above 30 and only off below 29.
temp
30.0 β β β β β/\β β β β β β β β β β alarm ON threshold
/ \ /\
29.0 β β β β/β β \β β /β β\β β β β alarm OFF threshold
\/
Status LED and helpers
static const uint16_t led_period_ms[] = { 1000, 150, 400 }; // NORMAL, ALARM, SENSOR_FAULT
static void led_update(void) {
static uint32_t last;
uint16_t p = led_period_ms[monitor_state()];
if ((uint32_t)(millis() - last) >= p) { last += p; led_toggle(); }
}
static void print_mC(int32_t mC) { // 25310 β "25.31"
char b[16];
int32_t a = (mC < 0) ? -mC : mC;
snprintf(b, sizeof b, "%s%ld.%02ld", (mC < 0) ? "-" : "", (long)(a / 1000), (long)((a % 1000) / 10));
uart_puts(b);
}
static const char *state_name(mon_state_t s) {
static const char *n[] = { "NORMAL", "ALARM", "SENSOR_FAULT" };
return n[s];
}
CLI commands (extend Program 7's handle_command)
| Command | Action |
|---|---|
STATUS | print one report immediately |
HIGH 35 | change the alarm threshold to 35 Β°C |
HANG | deliberately freeze, to test watchdog recovery |
static void handle_command(const char *cmd) {
if (strcmp(cmd, "STATUS") == 0) report_now();
else if (strncmp(cmd, "HIGH ", 5) == 0) { monitor_set_high_c(atoi(cmd + 5)); uart_puts("OK\r\n"); }
else if (strcmp(cmd, "HANG") == 0) while (1) { } // test only
else uart_puts("ERR\r\n");
}
Validate
atoiresults (e.g. accept only β40β¦125 Β°C) in real code.
main.c: the cooperative loop
static void report_now(void) {
uart_puts("T="); print_mC(monitor_last_mC());
uart_puts(" AVG="); print_mC(monitor_avg_mC());
uart_puts(" STATE="); uart_puts(state_name(monitor_state()));
uart_puts("\r\n");
}
int main(void) {
rst_cause_t cause = reset_cause_get_and_clear();
tick_init(); led_init(); uart2_init(115200); i2c_init();
uart_puts("Sensor monitor v1.0\r\n");
if (cause == RST_IWDG) uart_puts("NOTE: recovered from watchdog reset\r\n");
iwdg_init(WDG_TIMEOUT_MS);
uint32_t t_sample = 0, t_report = 0;
for (;;) {
uint32_t now = millis();
if ((uint32_t)(now - t_sample) >= SAMPLE_PERIOD_MS) {
t_sample += SAMPLE_PERIOD_MS;
int32_t t;
bool ok = tmp102_read_mC(&t); // has a timeout, so it cannot block forever
monitor_on_sample(ok, t);
wd_checkin(TASK_SENSOR);
}
if ((uint32_t)(now - t_report) >= REPORT_PERIOD_MS) {
t_report += REPORT_PERIOD_MS;
report_now();
}
cli_poll(); wd_checkin(TASK_COMMS);
led_update();
wd_supervise(); // kicks the dog only if everyone checked in
}
}
Sample output
Sensor monitor v1.0
T=24.81 AVG=24.81 STATE=NORMAL
T=24.88 AVG=24.84 STATE=NORMAL
T=31.25 AVG=29.10 STATE=NORMAL β average still catching up
T=33.00 AVG=31.40 STATE=ALARM β LED blinks fast
T=27.00 AVG=28.80 STATE=NORMAL β only clears below 29.0 (hysteresis)
Test plan
| Test | Expected |
|---|---|
| Normal start | Slow 1 Hz blink, report every 2 s |
| Warm the sensor with a finger | Average rises, then ALARM + fast blink |
| Remove warmth | Clears only after dropping below threshold β 1 Β°C |
| Unplug the sensor | After 3 failed reads β SENSOR_FAULT, medium blink; replug β returns to NORMAL |
Send HIGH 28 | Alarm threshold updated |
Send HANG | Reset in ~2 s, "recovered" message |
| Send garbage / very long lines | No crash; ERR returned |
| Run for hours | No growth in g_rx_overflow, stable behaviour |
Design decisions worth explaining
- Cooperative loop vs RTOS: the work is light and periodic, so a time-stamped loop is simpler and cheaper. Move to FreeRTOS (Program 10) if timings get tighter or tasks multiply.
- Integer milli-degrees: no floating-point library, smaller Flash.
- Fail-safe handling: a sensor fault is an explicit state, not a silent wrong value.
- Config in one header: thresholds and timings are easy to change and review.
- Watchdog with check-ins: catches both a hung loop and a stuck sensor read.
Common mistakes
- Blocking IΒ²C calls without a timeout, which hangs the whole system.
- Reporting a stale value as if it were fresh when the sensor fails.
- Alarm threshold with no hysteresis (chattering).
printfwith%fon a small MCU (huge Flash and RAM cost).- Putting hardware register access directly in application logic (untestable).
Interview angle
Q: How would you extend this? A: Add EEPROM/flash storage for the threshold, timestamped logging into a ring buffer, a second sensor on the same bus, low-power sleep between samples, and move to RTOS tasks if timing requirements tighten.
13. How the 12 Programs Fit Together
| # | Program | Core skill | Reused later in |
|---|---|---|---|
| 1 | Non-blocking blink | Time-stamp scheduling, millis() | 2, 5, 9, 12 |
| 2 | Button debounce | Sampling, event detection | 9 |
| 3 | Bit operations | Register manipulation | Everywhere |
| 4 | Timer interrupt | Prescaler/ARR math, ISR flags | 5, 10 |
| 5 | PWM | Duty/frequency/resolution | Motor, LED, buzzer work |
| 6 | Circular buffer | ISR β main data exchange | 7, 9, 12 |
| 7 | UART | Baud, ISR RX/TX, parser | 10, 11, 12 |
| 8 | IΒ²C / SPI sensor | Transport vs driver split | 12 |
| 9 | Interrupt-driven FSM | ISR posts events, main decides | 12 |
| 10 | FreeRTOS producer-consumer | Queues, blocking, priorities | RTOS-based projects |
| 11 | Watchdog recovery | Fault detection, safe restart | 12 |
| 12 | Monitoring project | Integration of all the above | n/a |
Ten habits these programs teach
- Never block when you can time-stamp.
- Enable the peripheral clock first.
- Keep ISRs short: flag, buffer or event, then leave.
- Mark ISR-shared variables
volatileand protect multi-step access. - Use ring buffers to decouple fast producers from slow consumers.
- Separate hardware drivers from application logic.
- Always add timeouts to anything that waits on hardware.
- Treat errors (sensor missing, buffer full, reset cause) as normal states to handle.
- Put tunable numbers in one config header.
- Kick the watchdog only when the system is truly healthy.
π§ Continue Learning
Related technical blueprints and practical guides in Firmware & Embedded Systems.

Microcontroller-Specific Embedded C
Embedded C is not completely platform-independent. You should learn the concepts above and then implement them on a real microcontroller

Debugging, Testing and Production Firmware
Hardware debugging, Firmware testing, Reliability, Security, Performance,...

Advanced Firmware Development
ADC, DAC and Sensor Interfacing, DMA and Data Transfer, Embedded Software Architecture, RTOS β Real-Time Operating Systems, Embedded Build Process and Linker

Interrupts and Communication Protocols
Interrupts and Exception Handling, UART / USART Serial Communication, SPI β Serial Peripheral Interface, IΒ²C β Inter-Integrated Circuit, Other Communication Interfaces