Skip to content
BoKSA

Firmware Architecture

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
main()
  ├── System and clock init (vendor startup code)
  ├── Configure GPIO, ADC, I²C, SPI, UART
  ├── Initialize sensor and actuator drivers
  ├── Initialize communication stack (if used)
  ├── Load configuration from non-volatile memory
  └── Set safe initial actuator states

while (1)   /* main loop — runs forever */
  ├── Read and filter sensor inputs
  ├── Run application logic (state machine)
  ├── Update actuators and displays
  ├── Handle communication (send / receive)
  └── Yield or sleep until next period (non-blocking timers)

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
typedef enum { STATE_IDLE, STATE_SAMPLING, STATE_ALARM } app_state_t;

static app_state_t state = STATE_IDLE;

void app_tick(void) {
    switch (state) {
    case STATE_IDLE:
        if (should_sample()) state = STATE_SAMPLING;
        break;
    case STATE_SAMPLING:
        read_sensors();
        state = threshold_exceeded() ? STATE_ALARM : STATE_IDLE;
        break;
    case STATE_ALARM:
        status_led_on();
        if (alarm_acknowledged()) state = STATE_IDLE;
        break;
    }
}

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
#define SAMPLE_PERIOD_MS  1000

static uint32_t last_sample_ms = 0;

void main_loop_body(void) {
    uint32_t now = system_time_ms();

    if (now - last_sample_ms >= SAMPLE_PERIOD_MS) {
        last_sample_ms = now;
        read_sensors();
    }
    handle_buttons();
    communication_poll();
}

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


Starting points

  1. Sketch the state machine on paper before coding — list states and transitions.
  2. Extract one module per sensor and one per actuator from your current main loop.
  3. Replace blocking delays with a periodic timer or tick counter for at least one task.
  4. Add debug logging (UART, SWO, or semihosting) at state transitions so you can trace behaviour.
  5. 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.