Firmware Architecture, State Machines and RTOS
Super-loop, event-driven and RTOS designs, state machines, scheduling and deadlines, priority inversion and the watchdog.
On this page
Architectures compared
| Architecture | How it works | Strengths | Limits |
|---|---|---|---|
| Super-loop | One endless loop calls each job in turn; interrupts handle urgent events | Simple, small, easy to follow | Response time grows as jobs are added; one slow job delays all others |
| Timer-tick (time-triggered) | A timer interrupt sets flags; the loop runs each job on its schedule | Predictable timing, little code | Jobs must be short; no preemption |
| State machine | The program is a set of states with events that change them | Clear, testable, never blocks | Needs discipline; large machines grow complex |
| Event-driven / cooperative | Events go into a queue; handlers run one at a time to completion | Low RAM; easy reasoning, no locks | A handler that takes too long blocks all the others |
| Preemptive RTOS | Tasks with priorities; the kernel switches between them | Independent activities, guaranteed response for high priority, network stacks | RAM per task; shared-data bugs; learning curve |
Start with the simplest design that meets the timing, and move up only when needed. Most small devices work well as a timer-tick loop with state machines; an RTOS earns its place when there are several independent activities with different timing, or when a library (a network or USB stack) expects one.
The super-loop and timer ticks
The super-loop is the classic bare-metal design: initialize, then loop forever. It breaks down when one step takes longer than the period of another.
Worked example: why a long job ruins a loop
A loop must read a sensor every 10 ms but also writes to an SD card, which can block for 25 ms. Whenever the card write runs, the next sensor read is 15 ms late, and the readings are no longer evenly spaced: the control loop sees jitter. The cure is to split the slow job into small steps (a state machine that writes 512 bytes per pass), move it to a lower-priority task, or use DMA so that the CPU is free.
// Timer-tick scheduler: a 1 ms interrupt sets a counter; the main loop runs jobs on schedule
volatile uint32_t ticks = 0;
ISR(TIMER0_COMPA_vect) { ticks++; } // 1 kHz tick (see Timers, PWM and Timing)
void loop() {
static uint32_t next_fast = 0, next_slow = 0;
uint32_t now = ticks; // read once (a 32-bit read on an 8-bit chip should be atomic)
if ((int32_t)(now - next_fast) >= 0) { next_fast += 5; task_sensor(); } // every 5 ms
if ((int32_t)(now - next_slow) >= 0) { next_slow += 100; task_display(); } // every 100 ms
}
State machines
A finite state machine is a list of states, the events that move between them, and what each state does. It is the natural way to write firmware that never blocks: the program remembers where it is in a sequence instead of waiting. Machine sequences, communication protocols, user interfaces and fault handling all become clearer as state machines, and they are easy to test by feeding events and checking the state.
enum State { IDLE, FILLING, HOLD, FAULT };
State state = IDLE;
uint32_t state_since = 0;
void set_state(State s) { state = s; state_since = millis(); } // log the change here
void fsm_run(void) {
uint32_t t = millis() - state_since;
switch (state) {
case IDLE:
pump(false);
if (level_low()) set_state(FILLING);
break;
case FILLING:
pump(true);
if (level_high()) set_state(HOLD);
else if (t > 60000UL) set_state(FAULT); // no level rise in 60 s
break;
case HOLD:
pump(false);
if (t > 5000) set_state(IDLE);
break;
case FAULT:
pump(false); alarm(true);
if (reset_pressed()) { alarm(false); set_state(IDLE); }
break;
}
}
- Every state sets all its outputs, so no output is left over from the previous state; the fault state is always safe.
- Timeouts are events: compare the time in the state against a limit, as the filling state does.
- Draw it first. A diagram with all states and transitions exposes missing cases (what if the level sensor fails?).
- Table-driven machines store the transitions in an array: more compact for large machines and easier to check.
- Hierarchy: split big machines into a few small ones that talk by events (a communication machine and a control machine), rather than one with dozens of states.
- Never block inside a state; return and let the next pass continue.
Event-driven and cooperative designs
An event-driven design puts events (a button press, a received frame, a timer tick) into a queue, and one dispatcher takes them out in order and calls the handler for each, which runs to completion. Handlers must be short. Because only one handler runs at a time, shared data needs no locks, which removes a large class of bugs. Interrupts post events to the queue (through a ring buffer, as in UART, I²C, SPI and CAN in Firmware). Cooperative schedulers (protothreads, coroutines, async/await in Rust) give the feel of tasks without a preemptive kernel and without a separate stack for each.
What an RTOS gives you
A real-time operating system (FreeRTOS, Zephyr, ThreadX, RT-Thread, embOS) is a small kernel that runs several tasks (each with its own stack and priority), switches between them, and supplies queues, semaphores, mutexes and timers. Real-time means a bounded response time, not speed: the highest-priority ready task runs within a known, short time.
Worked example: what an RTOS costs
- RAM: each task needs its own stack and a control block. Three tasks of 512, 512, 1 024 bytes of stack, with about 100 bytes each for the control blocks and 200 bytes for queues and kernel data, use about 2 548 bytes (2.5 KB). That is most of an ATmega328P’s 2 KB, which is why RTOSes are used on 32-bit parts with 16 KB of RAM or more.
- Context switch: about 300 cycles on a Cortex-M: 300 ÷ 48 MHz = 6.3 µs. With 400 switches a second the cost is 400 × 300 ÷ 48 000 000 = 0.25 % of the CPU.
- Tick: a 1 000 Hz kernel tick of about 200 cycles uses 0.42 % of the CPU. Tickless modes remove it for low power.
- Stack size: measure it: fill the stack with a pattern, run the worst case and see how much is untouched (most RTOSes offer a “high-water mark”). Leave 25 to 50 % margin, and remember that a task that calls
printfneeds hundreds of bytes more.
// FreeRTOS: two tasks and a queue between them
QueueHandle_t q;
void task_sensor(void *p) {
TickType_t last = xTaskGetTickCount();
for (;;) {
int16_t v = read_sensor();
xQueueSend(q, &v, 0); // do not block if the queue is full
vTaskDelayUntil(&last, pdMS_TO_TICKS(10)); // exactly every 10 ms, no drift
}
}
void task_control(void *p) {
int16_t v;
for (;;) {
if (xQueueReceive(q, &v, portMAX_DELAY)) apply_control(v); // sleeps until data arrives
}
}
void setup() {
q = xQueueCreate(8, sizeof(int16_t));
xTaskCreate(task_sensor, "sensor", 256, NULL, 3, NULL); // priority 3 (higher number = higher)
xTaskCreate(task_control, "control", 256, NULL, 2, NULL);
vTaskStartScheduler();
}
Scheduling and deadlines
In a rate-monotonic schedule, tasks with shorter periods get higher priorities. Each task has a worst-case execution time C and a period T; the CPU utilization is U = Σ Ci ÷ Ti. A simple sufficient test says the set is schedulable if U ≤ n (21/n − 1), which falls to 69 % for many tasks. A more exact test is the response time of each task: the time from release to completion, including interference from higher-priority tasks, which must not exceed its deadline.
Worked example: will the three tasks meet their deadlines?
| Task | Job | C (ms) | T (ms) | C ÷ T |
|---|---|---|---|---|
| A | read sensor | 1 | 5 | 0.20 |
| B | control loop | 2 | 10 | 0.20 |
| C | log and display | 3 | 20 | 0.15 |
- Utilization: U = 1 ÷ 5 + 2 ÷ 10 + 3 ÷ 20 = 0.20 + 0.20 + 0.15 = 0.55. The rate-monotonic bound for 3 tasks is 3 × (21/3 − 1) = 0.780, and 0.55 ≤ 0.780, so the set is schedulable.
- Response times: A is never interrupted: RA = 1 ms. For B: R = 2 + ⌈R ÷ 5⌉ × 1, starting at R = 2: 3 ms, which is stable, so RB = 3 ms. For C: R = 3 + ⌈R ÷ 5⌉ × 1 + ⌈R ÷ 10⌉ × 2: starting at 3 gives 6, then 7, then 7, so RC = 7 ms, within its 20 ms period. The figure shows C finishing at 7 ms, preempted by A.
- Headroom: the CPU is 45 % idle, which can absorb jitter, interrupts and the kernel’s own overhead.
- The worst case matters. Use the longest measured execution time (with caches, interrupts and flash wait states), not the average, and include the time interrupts steal from every task.
Sharing data: queues, semaphores and mutexes
| Tool | What it does | Use |
|---|---|---|
| Queue | Copies messages from a producer to a consumer | The default way to pass data between tasks and from an ISR; no shared variable |
| Binary semaphore | A flag that a task waits on; an ISR “gives” it | Waking a task from an interrupt |
| Counting semaphore | Counts events or available resources | Several identical resources or pending events |
| Mutex | Locks a shared resource; only the owner can release it; usually with priority inheritance | Protecting a bus, a buffer or a global structure |
| Event group / notification | Bits that tasks wait on | Waiting for several conditions; the lightest way to signal a task |
| Critical section | Disables interrupts briefly | Very short updates of a shared variable |
- Prefer messages to shared variables: if only one task owns a resource and others ask it through a queue, there is nothing to lock.
- Keep locks short and never hold one while waiting for something else.
- Use the “FromISR” versions of kernel calls inside interrupts, and never block there.
- Check return values: a full queue or a timeout is an event your design must handle.
Priority inversion and deadlock
Priority inversion: a low-priority task takes a lock, a high-priority task needs it and waits, and a medium-priority task (which needs no lock) preempts the low one. The high-priority task is blocked for as long as medium runs, here 6 time units instead of the 1 unit the low task still needed to finish. (This happened on the Mars Pathfinder in 1997.) The cure is priority inheritance: while a task holds a mutex, it temporarily takes the priority of the highest task waiting for it. Use mutexes, not binary semaphores, to protect shared resources, and keep critical sections short.
Deadlock: task 1 holds lock A and waits for B, while task 2 holds B and waits for A: neither can ever continue. Avoid it by always taking locks in the same order, taking only one at a time, or using timeouts and reporting the failure.
The watchdog and fail-safe design
A watchdog timer is a hardware counter that resets the chip if the program does not “kick” (restart) it within a set time. It turns a hang into a reset, which is often the best recovery in a device nobody is watching. A watchdog that is kicked from a timer interrupt is useless (the interrupt keeps running when the main loop is stuck): kick it from the main loop, and only when every task has shown that it is alive.
Worked example: setting the watchdog timeout
- The longest legitimate pass of the main loop takes 120 ms. A watchdog timeout of 1 000 ms is 8.3 times that: long enough not to trip in normal use, short enough that a hang is repaired within a second.
- With an RTOS, each task sets a bit in a “checked in” word when it completes its work; a supervisor task kicks the watchdog only when all the bits are set, then clears them. If any task hangs, the watchdog expires and resets.
- Window watchdogs reject a kick that comes too early as well as too late, catching a runaway loop that kicks it continuously.
- After a reset, read the reset-cause register (power-on, brown-out, watchdog) and log it, so you know a watchdog reset occurred and why.
- Fail safe: on a fault, put the outputs in the state that is safe (pump off, heater off, valve closed), and design the hardware so that losing the signal (a stuck pin, a reset) does the same.
- Plausibility checks: reject sensor values outside the physical range or that change faster than possible; treat them as a fault.
- Assertions in development builds catch impossible states early; in production, handle them by a controlled reset or safe state.
- Brown-out detection keeps the chip from running on too low a supply (see Power and Low-Power Firmware).
- Independent hardware protection (thermal fuses, limit switches, overcurrent trips) for anything that could cause harm if the firmware fails.
Choosing an architecture
| If your project is… | Use… |
|---|---|
| A few jobs with relaxed timing (a thermostat, a data logger) | A timer-tick loop with state machines |
| A user interface plus control with tight timing on one loop | Interrupts for the fast part, a state machine in the loop for the rest |
| Several independent activities (control, communication, display, logging) | An RTOS with a task per activity and queues between them |
| A network stack, USB or Bluetooth | The vendor’s RTOS (FreeRTOS, Zephyr) that the stack expects |
| Hard real-time control at kilohertz rates | A timer interrupt for the control law, with everything else lower priority |
| Safety-critical | A certified RTOS and process, redundant hardware protection, and independent review |
Related
- Embedded Systems: the overview of this guide.
- GPIO, Interrupts and Debouncing and Timers, PWM and Timing: interrupts and ticks.
- Memory, Number Formats and Reliability: stacks, RAM and watchdogs.
- Debugging, Testing and Troubleshooting: tracing tasks and hard faults.
- PLC and Logic Control: the scan cycle and sequences, the PLC equivalent of these designs.
FAQ
Do I need an RTOS?
Not for most small projects. A timer-tick loop with state machines goes a long way. Move to an RTOS when you have several independent activities with different timing, or a library needs one.
What is the difference between a task and an interrupt?
An interrupt is run by the hardware in response to an event and must be very short. A task is a longer-running piece of code scheduled by the kernel; it can wait, sleep and be preempted by higher-priority tasks and interrupts.
How big should a task stack be?
Measure the high-water mark under worst-case load, and add 25 to 50 % margin. A few hundred bytes is common for a simple task on a 32-bit chip; tasks that print, use floating point or call deep libraries need more.
Why did my device hang once a month?
Typically a rare race condition, a stack overflow, a lock never released, or a communication timeout with no recovery. A watchdog resets it, and logging the reset cause and the last few states tells you where to look.
References
- Liu, C. L. and Layland, J. W., “Scheduling Algorithms for Multiprogramming in a Hard-Real-Time Environment”, Journal of the ACM, 1973 (the rate-monotonic bound).
- FreeRTOS and Zephyr documentation (tasks, queues, mutexes and priority inheritance); Barry, R., Mastering the FreeRTOS Real Time Kernel.
- Samek, M., Practical UML Statecharts in C/C++; Jones, M., “What really happened on Mars?” (priority inversion on Pathfinder).
Last reviewed: October 2026. Cycle counts and RAM costs are typical for a Cortex-M and FreeRTOS: measure on your own system.
