Firmware Architecture
Introduction
Firmware is the software that runs directly on your microcontroller — not on a laptop or server. It reads sensors, drives actuators, enforces timing, and (when needed) handles network traffic. Firmware architecture is how you organise that code so it stays readable, testable, and safe as your project grows.
Many student projects start as one long main loop copied from tutorials. That works for a blinking LED, but breaks down when you add debouncing, display updates, networking, and alarm logic in the same file. Structuring firmware into layers, modules, and state machines lets you change one part without breaking others.
This article explains typical program structure, how to split responsibilities, timing choices, and safe defaults. It applies whether you program in bare-metal C, use a vendor HAL (STM32 HAL, ESP-IDF, NXP SDK), or work through a higher-level framework. You need a hardware plan first.
Execution models
Embedded firmware usually follows one of two models:
| Model | Structure | Typical use |
|---|---|---|
| Bare-metal superloop | main() runs init once, then while (1) { ... } forever |
Small projects, learning, tight control |
| RTOS (real-time OS) | Multiple tasks scheduled by the kernel | Networking + sensors + UI in parallel |
Both models use the same logical modules — only scheduling changes. A sensor task and a network task on an RTOS are the same separation as two function calls in a well-structured superloop.
Typical program flow
A connected sensor node on a superloop might look like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | |
On an RTOS, the same steps appear in main() or a startup task, while read_sensors(), network_poll(), and update_actuators() run as separate tasks with defined priorities.
Layered structure
Organise firmware in layers from hardware upward:
| Layer | Responsibility | Example |
|---|---|---|
| Board / HAL | Register access, clocks, pin mux | gpio_set(), i2c_write() |
| Drivers | One peripheral or chip | dht22_read(), ssd1306_draw_text() |
| Services | Debouncing, filtering, calibration | button_pressed(), get_temperature_c() |
| Application | Rules: input → output | evaluate_alarm(), traffic_light_tick() |
| Communication | Protocol encode/decode, send, receive | telemetry_send(), command_dispatch() |
Rule: application code calls get_temperature_c(), not raw register reads scattered through the project. When wiring changes, you update the driver layer once.
As projects grow, put each layer in separate .c / .h files and link them as a normal embedded project (Makefile, CMake, or your IDE's build system).
Modules and responsibilities
Each module should have one main job:
| Module | Responsibility | Example API |
|---|---|---|
| HAL / drivers | Hardware access for one device or bus | led_set(), motor_stop() |
| Sensors | Validated readings | temp_read(), distance_mm() |
| Actuators | Safe output with limits | relay_off(), servo_set_angle() |
| Application logic | Behaviour and state | app_tick() |
| Communication | Network or bus protocol | mqtt_publish(), http_post() |
| Configuration | Stored settings | config_load(), config_save() |
Example: Do not read a humidity sensor inside your HTTP POST handler. The sensor service returns a value; the communication module sends it. Application logic decides when to send.
State machines
Use a state machine when the device has distinct modes:
- Traffic light: idle → vehicle detected → green → timeout → red
- Monitor: sleep → sample → display → alarm
- Provisioning: offline → config mode → connecting → online
A simple superloop pattern in C:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | |
Document states and transitions in your project docs — a table or diagram is enough. That makes timing bugs easier to reason about.
Timing and concurrency
Firmware is event-driven or periodic. Choose deliberately:
| Approach | When to use | Caution |
|---|---|---|
| Polling | Sample sensors every N ms in the main loop | Blocking sleeps stall everything else |
| Software timers | Non-blocking periodic work | Handle timer counter wrap-around |
| Hardware timers | Precise PWM, timeouts, scheduling | Configure prescalers correctly |
| Interrupts (ISR) | Fast edges, byte-received, tick | Keep ISR minimal — set a flag, process in main context |
Avoid blocking the main loop
Long busy-waits stall communication, debouncing, and display updates. Prefer deadline-based scheduling:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | |
system_time_ms() might come from a SysTick interrupt, an RTOS tick, or a hardware timer — the pattern is the same.
Document expected loop period and worst-case execution time when behaviour must be predictable (motor safety, alarm response).
Safe defaults and error handling
Firmware architecture includes what happens when things go wrong:
- Boot — actuators start in a safe state (motor off, heater off, valve closed).
- Sensor fault — out-of-range readings ignored or flagged; do not hang or reset blindly.
- Network down — retry with backoff; do not block sensor reading indefinitely.
- Watchdog — hardware timer resets the MCU if the main loop stops feeding it.
Define these rules before demo day, not after a stuck relay overheats a component.
Mapping firmware to hardware
Your driver layer should mirror the pin map:
- HAL knows port/pin or bus instance identifiers.
- Application code uses semantic names (
status_led_on()), not pin numbers in every file.
When you change wiring, update drivers in one place — not in twenty copies across the main loop.
Toolchains and frameworks
You may write firmware in several ways; architecture stays the same:
| Approach | What you get |
|---|---|
| Bare-metal C + vendor SDK | Full control; you manage init and the main loop |
| CMSIS / HAL | Standardised peripheral drivers (common on ARM MCUs) |
| RTOS (FreeRTOS, Zephyr, etc.) | Tasks, queues, mutexes for concurrency |
| Higher-level frameworks | Faster bring-up; still split logic into modules |
Regardless of toolchain, avoid putting all behaviour in one file. The layers above apply to all of them.
Relevant topics
- Embedded system architecture
- Hardware architecture
- Debouncing inputs
- Connectivity architecture
- Knowledge overview
Starting points
- Sketch the state machine on paper before coding — list states and transitions.
- Extract one module per sensor and one per actuator from your current main loop.
- Replace blocking delays with a periodic timer or tick counter for at least one task.
- Add debug logging (UART, SWO, or semihosting) at state transitions so you can trace behaviour.
- Set actuator safe states during init before any logic that might fail.
Focus points
- One concern per module — sensor reads, display updates, and protocol handling belong in separate units.
- Non-blocking main loop — communication and UI need CPU time between sensor samples.
- State machine for modes — if behaviour changes with mode, encode modes explicitly.
- Drivers hide hardware — application code should not hard-code pin numbers everywhere.
- Safe boot state — outputs off or known-safe until logic confirms it is OK to actuate.
- Test modules alone — feed mock sensor values to test alarm logic without hardware.
- Match pattern to project size — superloop is fine until concurrency forces RTOS or careful scheduling.
Key points
- Firmware architecture is how you organise MCU software: init, main loop or tasks, modules, states.
- Use layers: HAL → drivers → services → application → communication.
- State machines make multi-mode behaviour readable and debuggable.
- Use non-blocking timing when networking, inputs, and sensors must coexist.
- Safe defaults on boot and on failure are part of architecture, not optional extras.