1. Today's topic

How to think about embedded firmware correctly: layers, events, drivers and services. The main idea is that firmware for an ESP32 or STM32 is not one large file containing GPIO, UART and timers. It is a small system in which each part has its own responsibility. A real project typically contains these subsystems:

  • traffic-light inputs through optocouplers, an ADC or GPIO expanders;
  • an ESP32 running ESP-IDF;
  • an EC25 modem using UART and AT commands;
  • MQTT/TCP/UDP;
  • logging;
  • a watchdog;
  • power control for a camera, radar or Jetson;
  • HIL tests using a Raspberry Pi;
  • possibly moving some of the logic to an STM32.

2. Why this matters

Problems such as delays, watchdog resets, UART stalls, false events, difficulty disabling flashing and unclear states often arise because responsibilities are mixed together, rather than because one if statement was written badly. The project mixes:

  • hardware sampling;
  • filtering;
  • business logic;
  • transport;
  • logging;
  • diagnostics;
  • timing;
  • recovery after errors.

An unsuitable design:

c
while (1) {
    read_adc();
    filter_ema();
    detect_phase();
    if (changed) {
        send_tcp();
        log_everything();
    }
    check_modem();
    vTaskDelay(pdMS_TO_TICKS(10));
}

At first this works. Later the application acquires different input types, UDP acknowledgements, timestamps, a watchdog, HIL, a CLI, EC25 URCs and connection recovery. The single loop becomes tangled. A better way to think about it: the input driver only measures the signal; the phase detector only consumes cleaned events; the transport only delivers an event; the modem only maintains connectivity; the CLI only controls and diagnoses the system.

3. Theory

A useful division of firmware into layers:

text
Application layer
  device logic: a phase changed, an event must be sent,
  diagnostic mode, fault-handling logic
Service layer
  modem_service, phase_service, transport_service,
  health_service, cli_service
Driver layer
  adc_driver, gpio_expander_driver, uart_driver,
  opto_input_driver, timer_driver
Hardware / BSP layer
  GPIO numbers, UART pins, frequencies, board-specific details

Rule: an upper layer may know about the layer below it, but a lower layer should not know about the layer above it. Examples:

  • adc_driver does not know what red/yellow/green means;
  • phase_detector does not know whether the signal came through an ADS1115, MCP23017 or GPIO;
  • transport_service does not know how the optocoupler works;
  • modem_service does not know that the event belongs to a traffic light;
  • cli_service does not manipulate GPIO directly; it calls the public APIs of services.

4. An architecture example for a traffic light

An unsuitable path:

text
Read ADS1115 -> filter -> determine the phase -> send over TCP -> log

A better path:

text
input_driver
  reads physical channels
signal_filter
  turns a noisy signal into a stable level
phase_detector
  recognizes RED/YELLOW/GREEN/OFF/UNKNOWN
event_bus
  distributes a "phase changed" event
transport_service
  sends the event over UDP/TCP/MQTT
health_service
  monitors errors and delays
cli_service
  makes the state observable

5. An event-driven approach

A polling approach:

c
while (1) {
    check_inputs();
    check_modem();
    check_uart();
    check_timeout();
}

An event-driven approach:

text
INPUT_LEVEL_CHANGED
PHASE_CHANGED
MODEM_CONNECTED
MODEM_LOST
UDP_ACK_TIMEOUT
WATCHDOG_WARNING

A minimal event structure:

c
typedef enum {
    APP_EVENT_PHASE_CHANGED,
    APP_EVENT_MODEM_READY,
    APP_EVENT_MODEM_LOST,
    APP_EVENT_UDP_ACK_TIMEOUT,
    APP_EVENT_INPUT_FAULT,
} app_event_type_t;
typedef struct {
    app_event_type_t type;
    int64_t timestamp_us;
    union {
        struct {
            uint8_t channel;
            uint8_t old_phase;
            uint8_t new_phase;
        } phase;
        struct {
            int error_code;
        } fault;
    } data;
} app_event_t;

6. Fast and slow paths

For a traffic light and a camera, the central principle is to capture the phase-change time as close to the input as possible. Slow work such as the modem, TCP, MQTT, logs and CLI should happen afterwards and should not delay the timestamp. An unsuitable sequence:

text
read the input
  -> spend time filtering
  -> spend time logging
  -> contact the modem
  -> record the timestamp afterwards

The intended sequence:

text
read the input
  -> promptly capture the timestamp
  -> put an event into a queue
  -> transport sends, retries, waits for ACK and logs separately

7. Common mistakes

2.7.1 Mistake 1. The driver knows the business logic

An unsuitable design:

c
if (adc_value > threshold) {
    send_red_phase_to_camera();
}

A better design:

c
input_level_t level = input_driver_get_level(CH_RED);
phase_detector_process(CH_RED, level, timestamp);

2.7.2 Mistake 2. One large task does everything

One task reads the ADC, parses UART, sends MQTT, prints logs and serves the CLI. This is a likely route to delays and watchdog problems.

2.7.3 Mistake 3. Logging inside the fast path

UART logs can have a substantial effect on timing. In the fast path, count events instead; print aggregated information once per second or when requested through the CLI.

2.7.4 Mistake 4. An event stores too little information

An unsuitable design:

c
phase = GREEN;

A better design:

c
phase_event = {
    .old_phase = RED,
    .new_phase = GREEN,
    .timestamp_us = esp_timer_get_time(),
    .source = INPUT_SOURCE_OPTO,
    .confidence = 95,
    .raw_mask = 0b0010,
};

2.7.5 Mistake 5. The logic depends directly on ESP-IDF/STM32 HAL

If phase_detector.c includes driver/gpio.h, esp_log.h or stm32_hal_gpio.h, it becomes difficult to test on a PC or port to another platform. Prefer a pure function:

c
phase_state_t phase_detector_update(
    phase_detector_t *det,
    input_snapshot_t input,
    int64_t timestamp_us
);

8. Practical assignment

Create an ARCHITECTURE.md file. A minimal structure:

markdown
# Firmware architecture
## Layers
### BSP
Pins, UART numbers, board-specific constants.
### Drivers
ADC, GPIO expander, UART, timers.
### Services
Phase detector, modem, transport, CLI, health monitor.
### Application
Starts services and connects events.
## Fast path
Input sample -> timestamp -> phase detector -> event queue.
## Slow path
Transport, logging, diagnostics, retries.
## Rules
1. Drivers do not know business logic.
2. Services communicate through events.
3. Timestamp is captured before network sending.
4. Logs must not block fast path.

Then find 3 architecture violations in your current project:

  • a driver calls the network sender;
  • phase logic lives inside ADC code;
  • transport reads global variables;
  • the CLI directly changes internal service fields;
  • the timestamp is captured too late.

9. What to explore next

  • Read the official ESP-IDF documentation on components and the build system.
  • Look at esp_event as a foundation for an event-driven approach.
  • Begin separating phase_detector from ESP-IDF so that it can be tested on a PC.

10. Brief recap

Good embedded firmware is organized around module responsibilities and events between modules, rather than around GPIO, UART and ADC. The intended fast path:

text
the input changed
  -> the timestamp was captured
-> the new state was determined
-> an event was put into the queue
-> network, modem and logging work run separately
In an illustrative trace, the input is sampled at 1000 us, UART logging finishes at 4000 us, and network sending finishes at 9000 us. Which timestamp describes the input sample?

Exercise

Assign input sampling, timestamp capture, phase detection, event enqueueing, AT commands, network retries and diagnostic logging to the fast or slow path. Name the owner of each operation and one condition without which a queue alone cannot guarantee a fast path.

Self-check criteria: Account for all seven operations, capture the timestamp before slow work, keep transport out of the driver, and explain the full-queue policy.

Show the supplied answer

Fast path: the driver samples the input, the timestamp is captured, the detector updates the phase, and the event is enqueued. Slow path: modem_service handles AT commands, transport_service sends and retries, and diagnostics aggregate logs. The fast path must not block indefinitely on a full queue; it needs an explicit overflow policy and a bounded wait.