ELE-315 · Lesson 12 of 12 · 22 min
Firmware architecture, testing and security: a capstone
The previous eleven lessons took the microcontroller apart: its core and memory, its toolchain, its pins, converters, timers, interrupts, buses and power modes. A product needs all of them working together for years, in the field, without anyone watching. This capstone is about the whole. It covers how firmware is structured, from a simple loop to state machines to a real-time operating system; how firmware is tested before it reaches hardware and on hardware; how it is debugged; how it recovers from faults with watchdogs and safe states; and how it is updated and protected against attackers. It ends with the NS-5 design review, where Jonah presents the firmware architecture that draws on every lesson in the course.
Superloops and cooperative scheduling
The simplest firmware architecture is the superloop: after initialization, a main loop calls each part of the application in turn, forever, and interrupt handlers do the urgent work and set flags for the loop. The NS-4 began as one. A superloop is easy to understand, needs no operating system and has no concurrency inside the loop, so tasks never preempt each other. Its weakness is response time. Every task waits for every other task in the loop, so one slow function, such as parsing a long modem response, delays everything; and long jobs must be split by hand into small steps that each return quickly.
A common refinement is a cooperative scheduler: tasks are functions that run to completion at set periods or when events arrive, driven by software timers from a tick or a low-power timer, as in lessons 6 and 11. Each task must be short, and long work is broken into a sequence of steps, usually written as a state machine. Cooperative designs are deterministic and simple to reason about, and they fit naturally with tickless sleep: when no task is ready, the loop sleeps until the next deadline.
State machines
Much embedded behavior is naturally a finite state machine: the device is in one of a small number of states, and events cause transitions with actions. A modem connection, an I²C transaction, a button press with debouncing, a charging cycle and a firmware update all fit this pattern. Writing them as explicit state machines, rather than as nests of flags and conditions, makes the behavior visible, testable and reviewable. Every combination of state and event has a defined outcome, including the ones that 'cannot happen', which in the field always eventually do.
typedef enum { ST_IDLE, ST_POWERING, ST_REGISTERING, ST_CONNECTED, ST_SENDING, ST_BACKOFF } up_state_t;
typedef enum { EV_TIMER, EV_MODEM_READY, EV_ATTACHED, EV_SESSION_OPEN, EV_ACKED, EV_TIMEOUT, EV_ERROR } up_event_t;
static up_state_t st = ST_IDLE;
static uint8_t failures;
void upload_handle(up_event_t ev)
{
switch (st) {
case ST_IDLE:
if (ev == EV_TIMER) { modem_power_on(); timeout_start(15000); st = ST_POWERING; }
break;
case ST_POWERING:
if (ev == EV_MODEM_READY) { modem_attach(); timeout_start(120000); st = ST_REGISTERING; }
else if (ev == EV_TIMEOUT) { goto fail; }
break;
case ST_REGISTERING:
if (ev == EV_ATTACHED) { modem_open(); timeout_start(30000); st = ST_CONNECTED; }
else if (ev == EV_TIMEOUT || ev == EV_ERROR) { goto fail; }
break;
case ST_CONNECTED:
if (ev == EV_SESSION_OPEN) { send_batch(); timeout_start(60000); st = ST_SENDING; }
else if (ev == EV_TIMEOUT || ev == EV_ERROR) { goto fail; }
break;
case ST_SENDING:
if (ev == EV_ACKED) { modem_power_off(); failures = 0; st = ST_IDLE; }
else if (ev == EV_TIMEOUT || ev == EV_ERROR) { goto fail; }
break;
case ST_BACKOFF:
if (ev == EV_TIMER) { modem_power_on(); timeout_start(15000); st = ST_POWERING; }
break;
}
return;
fail:
modem_power_off();
if (++failures >= 5) { failures = 0; st = ST_IDLE; log_event(LOG_UPLOAD_GAVE_UP); }
else { timer_start_backoff(60000u << failures); st = ST_BACKOFF; }
}# Test the upload state machine against event sequences, including the ones that "cannot happen"
T = { # (state, event) -> next state; anything missing is ignored in that state
("IDLE", "timer"): "POWERING", ("BACKOFF", "timer"): "POWERING",
("POWERING", "ready"): "REGISTERING", ("REGISTERING", "attached"): "CONNECTED",
("CONNECTED", "open"): "SENDING", ("SENDING", "acked"): "IDLE",
}
FAILS = {"POWERING", "REGISTERING", "CONNECTED", "SENDING"}
def run(events):
st, failures, modem_on = "IDLE", 0, False
for ev in events:
if st in FAILS and ev in ("timeout", "error"):
modem_on, failures = False, failures + 1
st = "IDLE" if failures >= 5 else "BACKOFF"
if failures >= 5: failures = 0
continue
nxt = T.get((st, ev), st)
if nxt == "POWERING": modem_on = True
if nxt == "IDLE" and ev == "acked": modem_on, failures = False, 0
st = nxt
return st, modem_on
tests = {
"normal upload": ["timer", "ready", "attached", "open", "acked"],
"network never attaches": ["timer", "ready", "timeout"],
"five failures then give up": ["timer", "timeout"] + ["timer", "timeout"] * 4,
"stray ack while idle": ["acked", "timer", "ready"],
"error while sending": ["timer", "ready", "attached", "open", "error"],
}
for name, evs in tests.items():
st, on = run(evs)
print(f"{name:28s} -> state {st:12s} modem {'ON' if on else 'off'}")normal upload -> state IDLE modem off network never attaches -> state BACKOFF modem off five failures then give up -> state IDLE modem off stray ack while idle -> state REGISTERING modem ON error while sending -> state BACKOFF modem off
Real-time operating systems
As firmware grows, a superloop becomes hard to keep responsive. A real-time operating system provides preemptive tasks, each with its own stack and priority, plus queues, semaphores, mutexes, event flags and timers. A high-priority task waiting for a radar frame runs as soon as the DMA interrupt posts to its queue, preempting a lower-priority task that is formatting a log record. That makes response times independent of the length of unrelated code, and makes it natural to structure a complex system as cooperating tasks with clear interfaces.
The cost is concurrency. Tasks share data, so every shared structure needs a mutex or a queue, and lesson 7's race conditions now occur between tasks as well as between tasks and interrupts. Mutexes can cause priority inversion, where a high-priority task waits for a mutex held by a low-priority task that is itself preempted by a medium-priority one; priority inheritance, offered by most real-time kernels, fixes this. Each task needs a stack sized for its worst case, and stack overflow is a classic source of memory corruption. Scheduling analysis can show whether a set of periodic tasks will meet their deadlines; under rate-monotonic priorities, a sufficient test is that total utilization stays under a bound that falls toward 69 percent as tasks are added, and exact response-time analysis gives tighter answers. CMP-440 Real-Time Systems covers these methods in depth.
# Rate-monotonic utilization check for the NS-5 tasks (sufficient test) plus exact response times
import math
tasks = [ # name, period ms, worst-case execution ms; shorter period = higher priority
("radar frame", 6.25, 1.6),
("classify", 50.0, 9.0),
("CAN service", 10.0, 0.8),
("log and store", 1000.0, 40.0),
]
tasks.sort(key=lambda t: t[1])
U = sum(c / p for _, p, c in tasks)
n = len(tasks)
bound = n * (2 ** (1 / n) - 1)
print(f"utilization {U:.3f}, bound for {n} tasks {bound:.3f} -> {'passes' if U <= bound else 'inconclusive'}")
for i, (name, p, c) in enumerate(tasks):
r = c
while True:
r_new = c + sum(math.ceil(r / pj) * cj for _, pj, cj in tasks[:i])
if r_new == r or r_new > p: break
r = r_new
print(f" {name:14s} period {p:7.2f} ms, response {r_new:6.2f} ms, {'meets' if r_new <= p else 'MISSES'} deadline")utilization 0.556, bound for 4 tasks 0.757 -> passes radar frame period 6.25 ms, response 1.60 ms, meets deadline CAN service period 10.00 ms, response 2.40 ms, meets deadline classify period 50.00 ms, response 15.40 ms, meets deadline log and store period 1000.00 ms, response 89.20 ms, meets deadline
The NS-5's utilization of about 0.56 passes the sufficient test, and the exact analysis confirms every task meets its deadline with room to spare. Jonah recommends a small real-time kernel for the NS-5 cabinet controller, where CAN, USB, classification and logging all compete, and keeps the pole-top node's cooperative scheduler, which is simpler, already proven and easier to put to sleep.
Testing embedded software
Testing firmware only on hardware is slow, and many faults never appear on a bench. Good embedded teams therefore test in layers. Unit tests run the application logic on a development computer, compiled for the host, with hardware access replaced by small fake drivers: the debouncer of lesson 4, the ring buffer of lesson 7, the CRC and message parser of lesson 8 and the state machine above can all be tested this way in milliseconds. Keeping register access in thin driver layers, as lesson 3 recommended, is what makes this possible. Integration tests run on the target, often hardware-in-the-loop, where a test computer drives the device's inputs and checks its outputs: a signal generator imitates the radar, a programmable supply imitates a dying battery, and a CAN interface plays the cabinet controller.
Some tests target the embedded environment specifically. Timing tests measure interrupt latency and task response times with a logic analyzer and spare pins. Power tests, as in lesson 11, run scripted days and check average current. Environmental tests run units in a chamber from −40 °C to +70 °C. Fault-injection tests cut power during flash writes, disconnect sensors, corrupt messages and hold buses low, and check that the firmware recovers. Static analysis tools catch undefined behavior, uninitialized variables and buffer overruns before any test runs, and coding standards such as MISRA C, used widely in automotive and industrial work, restrict the language to a safer subset: no dynamic memory after startup, no unchecked conversions, every switch with a default, and many more rules aimed at making C predictable.
Debugging
Most microcontrollers have an on-chip debug port, usually a two-wire serial wire interface or JTAG, through which a debug probe can halt the core, set breakpoints, read and write memory and registers, and program flash. Breakpoints are powerful but stop time, which a running motor or radio cannot tolerate, so embedded debugging also relies on non-intrusive methods: watchpoints that trigger on a memory access, trace output that streams program-counter samples or instrumented events through a dedicated pin, real-time variable monitoring through the debug port while the core runs, logging to a buffer drained in idle time, and toggling spare pins for a logic analyzer. When a field unit fails, the fault records of lesson 7, the error counters of lessons 8 to 10 and the reset-cause register are often the only evidence; designing them in from the start is part of the architecture.
Watchdogs and safe states
A watchdog timer resets the microcontroller unless firmware refreshes it periodically. It is the last line of defense against hangs, runaway code and corrupted state. A watchdog only helps if it is refreshed in the right place. Kicking it from a timer interrupt is useless, because the interrupt keeps running while the main loop is stuck. A better design refreshes it from one place in the main loop or a supervisor task, and only when every monitored task has checked in since the last refresh. Windowed watchdogs also reset if the refresh comes too early, which catches code that is looping too fast through a broken path. An independent watchdog with its own oscillator keeps working even if the main clock fails.
/* Supervisor: refresh the watchdog only when every task has reported progress. */
#define TASK_RADAR (1u << 0)
#define TASK_CLASSIFY (1u << 1)
#define TASK_UPLOAD (1u << 2)
#define TASK_ALL (TASK_RADAR | TASK_CLASSIFY | TASK_UPLOAD)
static volatile uint32_t alive;
void task_checkin(uint32_t bit) { __disable_irq(); alive |= bit; __enable_irq(); }
void supervisor_poll(void) /* called once per main-loop pass */
{
if ((alive & TASK_ALL) == TASK_ALL) {
alive = 0;
IWDG->KR = 0xAAAA; /* refresh the independent watchdog */
}
}After a reset, firmware should know why it reset. The reset-cause register distinguishes power-on, brownout, watchdog, software and pin resets, and firmware should log the cause and count watchdog resets. A device that resets repeatedly should fall back to a safe, minimal mode rather than looping forever: for the NS-5, that means keep counting vehicles with the simplest detection algorithm, stop classification, and report the problem at the next upload. Safety-critical systems go much further, with redundant processors, memory and register self-tests and formal safety standards, but the principle of failing to a defined safe state applies to every product.
Firmware updates
Field devices need updates to fix bugs, close security holes and add features, and an update is the riskiest operation a device performs. If power fails halfway through writing new firmware, the device must still boot. The standard design has a small, rarely changed bootloader at the start of flash and two application slots. The new image is downloaded into the inactive slot, checked, marked as pending, and the device reboots into it. The new firmware must confirm that it works, for example by completing a successful upload, within a set time; if it does not, or if the watchdog fires repeatedly, the bootloader reverts to the previous image. This A/B scheme with rollback is how the NS-5 will update over the cellular link, and Jonah's flash budget from lesson 3 reserves room for it.
Security
Connected embedded devices are targets. Attackers may want to read data, use the device as a foothold into a network, disrupt the service it provides, or simply add it to a botnet. Field devices are physically accessible too: anyone with a ladder can reach the NS-4. Security has to be designed in, and most of it is ordinary engineering discipline rather than exotic cryptography.
| Threat | Example on the NS-5 | Defense |
|---|---|---|
| Malicious firmware | A forged update over the cellular link | Secure boot: the bootloader verifies a digital signature on every image |
| Downgrade | Installing an old, vulnerable but genuinely signed image | Monotonic version counter checked by the bootloader |
| Eavesdropping and tampering in transit | Reading or altering uploaded counts | Authenticated encryption with standard protocols such as TLS |
| Unauthorized local access | A stranger's phone configuring the node by radio | Pairing with per-device keys; commands authenticated |
| Debug port access | Reading flash through the debug port | Lock or disable debug access in production; read-out protection |
| Shared secrets | One key extracted from one unit opens all units | Unique per-device keys, stored in protected memory or a secure element |
| Software flaws | A buffer overflow in the message parser | Input validation, bounds checks, static analysis, memory protection unit |
Secure boot is the foundation. The bootloader, stored in flash that cannot be rewritten in the field, holds a public key and checks the signature of the application before running it; only images signed with the company's private key, kept offline, will run. Hashing and signature verification are available as hardware accelerators on many microcontrollers. A version counter in one-time-programmable memory prevents rolling back to signed but vulnerable versions. Per-device keys mean that extracting a key from one unit compromises only that unit. Production units lock their debug ports. And because most attacks exploit ordinary bugs, the testing and coding practices above are security practices too. Regulations increasingly require these measures for connected products, including vulnerability handling and update support over a product's life, so they are now part of the specification rather than an optional extra.
The NS-5 design review
At the end of his term, Jonah presents the NS-5 firmware architecture to Maren, Ines and Dana Okoye's server team. The pole-top node runs a cooperative scheduler on a 1 ms low-power timer with tickless sleep. Radar frames arrive by SPI with DMA at 16 MHz; classification runs in a task between frames; detections are timestamped by input capture; the RS-485 port, the I²C sensor bus with recovery at boot, and the modem's upload state machine run as tasks; and a supervisor feeds the watchdog. The interrupt priorities follow the deadline table of lesson 7. The power budget shows 2.1 mA average at the battery in summer and about 126 days on a full charge in January. Updates use a signed A/B scheme with rollback. Unit tests run on every commit, and a hardware-in-the-loop rig with a radar simulator runs nightly.
| Lesson | Decision in the NS-5 design |
|---|---|
| 1 Constraints | Five days without sun, −40 °C to +70 °C, pin and cost budgets |
| 2 Architecture | RISC-V core at 48 MHz, 512 KB flash, 128 KB SRAM, 1 wait state, PLL from crystal |
| 3 Toolchain | Thin register drivers, linker map budget with A/B slots |
| 4 and 5 I/O | Debounced tamper input, switched battery divider, averaged ADC |
| 6 and 7 Timing | Input capture timestamps, deadline-based interrupt priorities |
| 8 to 10 Links | RS-485 with hardware driver enable, SPI DMA, I²C recovery, CAN and USB on the cabinet controller, short-range radio |
| 11 Power | Stop mode, tickless idle, radar presence mode, batched uploads |
| 12 Architecture | Cooperative scheduler on the node, kernel on the cabinet controller, watchdog supervisor, secure boot |
Maren's only question is the one every firmware lead asks: what happens when it fails? Jonah's answer, the fault records, error counters, reset-cause log, safe mode and rollback, is the last piece of the design, and the reason the review passes. Embedded systems are judged not by how they behave on the bench but by how they behave on a pole in January, years later, when no one is watching.
Common mistakes
MistakeLetting a superloop grow until one slow function delays everything.
InsteadBreak long work into state machines or move to preemptive tasks when response times demand it.
MistakeRefreshing the watchdog from a timer interrupt.
InsteadRefresh it from a supervisor only when all monitored tasks have reported progress.
MistakeTesting only on hardware.
InsteadUnit-test logic on the host with fake drivers; reserve hardware-in-the-loop for integration and timing.
MistakeShipping with the debug port open and a shared key in every unit.
InsteadLock debug access and use unique per-device keys.
MistakeWriting new firmware over the only copy.
InsteadUse a bootloader with A/B slots, signature checks and automatic rollback.
Summary and key terms
- Superloops and cooperative schedulers are simple and deterministic but need short tasks.
- Explicit state machines make event-driven behavior visible and testable.
- A real-time operating system adds preemption at the cost of concurrency hazards; CMP-440 covers scheduling analysis.
- Test in layers: host unit tests, hardware-in-the-loop, timing, power, environment and fault injection.
- Debugging uses breakpoints and non-intrusive tools such as trace, logging and pin toggling.
- Watchdogs must be refreshed only on evidence of progress; repeated resets should lead to a safe mode.
- Field updates need a bootloader, A/B slots and rollback; security rests on secure boot, unique keys and locked debug.
- Superloop
- A main loop that calls each part of the application in turn.
- Finite state machine
- A model of behavior as states and event-driven transitions.
- Real-time operating system
- A small kernel providing preemptive tasks and synchronization with predictable timing.
- Priority inversion
- A high-priority task blocked by a lower-priority one holding a shared resource.
- Hardware-in-the-loop
- Testing firmware on real hardware with simulated inputs and checked outputs.
- MISRA C
- A widely used set of C coding guidelines for critical embedded systems.
- Watchdog timer
- A timer that resets the device unless refreshed periodically.
- Secure boot
- Verifying the signature of firmware before running it.
- A/B update
- An update scheme with two firmware slots and rollback to the previous image.
Practice problems
Work each problem on paper first, then reveal the answer to check.
Three periodic tasks have utilizations 0.2, 0.25 and 0.3. Does the rate-monotonic sufficient test pass?
Show answer
U = 0.75; the bound for three tasks is 3(2^(1/3) − 1) ≈ 0.780, so it passes.
Why is refreshing a watchdog from a periodic timer interrupt ineffective?
Show answer
The interrupt keeps running even when the main loop or a task is stuck.
What does a monotonic version counter in the bootloader prevent?
Show answer
Installing an older, genuinely signed but vulnerable firmware image.
Give two events a state machine for an I²C transaction should handle that a simple driver might ignore.
Show answer
A not-acknowledge and a timeout, for example; also arbitration loss or a stuck bus.
Lesson quiz · 5 questions
Score 60% or more to complete this lesson.
Create a free account to save your progress, take quizzes and earn a certificate.