HAL vs BSP vs Driver vs Middleware: Where Should Firmware Code Actually Belong?

You can define all four terms correctly and still not know where to put the function you’re writing right now.

That’s the actual problem. Nobody gets stuck on “what is a BSP.” They get stuck in a pull request where one engineer says the chip-select pin belongs in the driver, another says it’s board-specific so it goes in the BSP, and both are making a reasonable argument that the definitions don’t settle.

Article 03 ended on code living at the wrong layer. This article is about how to decide the layer — starting with a compressed version of the definitions, because they’re table stakes, then spending the rest of the time on the eight cases where good engineers actually disagree.


The Layers, Compressed

One table. If you want more than this on definitions, every silicon vendor has a page on it.

LayerKnows aboutChanges whenExample
BSPThis physical boardBoard revision, pin change, clock tree changeCS_SENSOR_0 is on PB12; SPI2 runs at 10 MHz
HALThis MCU family’s peripheralsYou change MCUspi_transfer(inst, tx, rx, len)
Peripheral driverOne MCU peripheral, its DMA, its ISRPeripheral behaviour or its use model changesSPI2 bus manager, transaction queue, timeouts
Device driverOne external partYou change the partMAX31865 register map, RTD conversion, CRC
MiddlewareA standard or a reusable mechanismThe standard changes, or you replace the stacklwIP, mbedTLS, Modbus framing, MQTT
ServiceA product capability, hardware-independentProduct capability changesConfig service, diagnostics, telemetry
ApplicationWhat the product doesProduct behaviour changesControl law, thermal policy, state machine

Two clarifications, because both cause more confusion than the definitions do.

“HAL” is the most abused word in embedded. It means at least three different things depending on who’s talking. ST’s HAL_SPI_Transmit is a vendor peripheral driver library with a marketing name on it — it is not an abstraction layer in the architectural sense, because it abstracts nothing above STM32. CMSIS device headers are register definitions, a layer below that. And your HAL, if you have one, is the interface you defined so your upper layers don’t know which MCU they’re on. Three different things. When someone says “put it in the HAL,” ask which one they mean.

The RTOS isn’t a layer. Article 02’s diagram drew it between middleware and HAL, which is the conventional picture and also wrong. Drivers call xQueueSendFromISR. Application code calls xTaskDelay. Middleware takes mutexes. The RTOS is orthogonal to the stack — every layer touches it, which is exactly what a layer is not. Drawing it as a horizontal band suggests middleware reaches the RTOS by going through it, and nobody does that. It’s a service that cuts across, like the fault sink from article 03.


The Test That Actually Decides

Definitions describe. This decides:

> Why would this code change?

Two things belong in the same module when they change for the same reason, and in different modules when they don’t. That’s it. Every boundary question below is an application of it.

Take temperature_sensor.c. It changes if the sensor IC changes — that’s device driver. It changes if you move from I2C to SPI — that’s the peripheral below it, a different reason, so a different module. It changes if the over-temperature limit moves from 80 °C to 75 °C — that’s product policy, a third reason, and it belongs above the driver entirely.

Three reasons to change, three homes. When you can’t state a distinct reason for a module to change, it doesn’t need to exist.

The corollary is the one that gets ignored: shared reason for change beats layer purity. If a chunk of code only ever changes when the sensor part number changes, it belongs with the sensor driver even if it looks like it’s doing something a service should do. Boundaries follow variation, not diagrams.


Peripheral Driver vs Device Driver

The single most useful distinction in this article, and it’s missing from most codebases.

A peripheral driver owns an MCU peripheral. SPI2, its DMA channels, its interrupt, the chip selects wired to it. Article 02’s spi_bus.c is exactly this: it serialises transactions, drives CS, applies timeouts, and owns the recovery path when a transfer hangs.

A device driver owns an external part. The MAX31865 knows its register map, its 62.5 ms conversion time, its fault register bits, and how to turn a 15-bit ratio into ohms and then into degrees.

Thermal Service          "should we shut down?"        ← policy
      ↓
MAX31865 device driver   "these bytes are 84.2 °C"     ← device knowledge
      ↓
SPI2 bus manager         "these bytes went out and came back"  ← peripheral
      ↓
HAL                      "SPI2->DR"

The SPI driver must not know the bytes represent temperature. The temperature driver must not know what 84.2 °C means for the product.

Collapse those two and you get the thing where you can’t add a second sensor on the same bus without touching the first sensor’s driver — because the CS handling, the DMA, and the timeout policy all live inside a file named after one part.


Mechanism Below, Policy Above

The other rule worth memorising, and it resolves more arguments than the layer diagram does.

SPI driver        "I can move bytes."                    mechanism
Flash driver      "I can erase and program a sector."    mechanism
Storage service   "I can persist a config with a CRC."   mechanism
Application       "Save config when the operator commits."  policy

Lower layers say can. Upper layers say should. The moment a driver contains the word “should,” it has policy in it and the policy is now stuck at the wrong layer, where a product decision requires editing hardware code.


The Eight Cases That Actually Get Argued

Everything above is agreed. Here’s where it stops being agreed.

1. Where does the chip-select pin live?

The one that starts the most PR arguments, because three answers are defensible.

Split it three ways:

  • The BSP knows that logical CS ID 2 is PB12 on this board. That’s wiring, it changes with a board revision, and it’s the only thing here that does.
  • The peripheral driver — the SPI bus manager — drives the pin. It has to, because CS timing is coupled to the transfer, and setup/hold windows around the clock aren’t something a device driver can get right from outside.
  • The device driver knows only its own logical ID and passes it in.

Which is why article 02’s transaction struct carries cs_id rather than a port and pin:

typedef struct {
    uint8_t        cs_id;      /* logical. BSP maps it to a pin. */
    const uint8_t *tx;
    uint8_t       *rx;
    size_t         len;
    uint32_t       timeout_ms;
} spi_txn_t;

Move the sensor from PB12 to PC7 in rev C and one BSP table entry changes. Nothing else. That’s the whole payoff.

The wrong answer that looks right: let each device driver own its own CS GPIO. Now four device drivers each toggle a pin around a shared bus, nobody owns the sequencing, and you have article 03’s anti-pattern #9 with extra steps.

2. Where does the conversion delay live?

The MAX31865 needs about 62.5 ms after you trigger a one-shot conversion at 50 Hz filtering. Where does that number go?

The value is device knowledge. It’s in the datasheet, it changes if you change the part, so it lives in the device driver — as a named constant, exposed:

#define MAX31865_CONV_TIME_MS   66u   /* 62.5 ms typ + margin, DS Table 3 */

But the waiting is not the driver’s decision. Whether to block a task for 66 ms, run a one-shot timer and post an event, or poll on the next 100 ms tick is a concurrency decision, and the driver doesn’t know the caller’s deadline. So the driver offers both:

result_t max31865_start_conversion(dev_t *d);
result_t max31865_read_result(dev_t *d, float *ohms);  /* RES_BUSY if not ready */

and the service picks. Article 03’s magic delay anti-pattern is what happens when a driver decides to HAL_Delay(70) on the caller’s behalf — the delay is invisible in the caller’s timing analysis, and now a device datasheet controls your task scheduling.

The rule this generalises to: device timing constants belong to the device. Blocking decisions belong to whoever owns the deadline.

3. Where does calibration data live?

Three different things get called calibration, and they have three different homes. Conflating them is why calibration code ends up everywhere.

WhatHomeWhy
The conversion maths (Callendar–Van Dusen for an RTD)Device driverChanges only if the sensor type changes
Factory per-unit coefficientsStorage / config servicePer-unit data, written in production, must survive update
Board-level offset (reference resistor tolerance)BSPBoard property, same for every unit of a revision
Field trim the operator can setConfig service, exposed by applicationProduct behaviour

The device driver takes coefficients as input. It does not go looking for them. That’s what keeps it testable on a host with a table of known inputs and expected outputs, which is most of what article 09 wants from it.

4. Where is DMA buffer placement decided?

The most interesting one on STM32H7, and the one where the layer model genuinely strains.

Article 02 established that the Ethernet DMA can’t reach DTCM, so descriptors and buffers must live in D2 SRAM and be marked non-cacheable in the MPU. That’s a hard constraint. So who owns it — the driver that needs the buffer, or the BSP that owns the memory map?

Both, and the split is what matters. The driver declares its requirement; the BSP satisfies it.

/* driver: states what it needs, in its own file */
__attribute__((section(".eth_dma"), aligned(32)))
static eth_desc_t rx_desc[ETH_RX_DESC_COUNT];
/* BSP owns the linker script: .eth_dma → D2 SRAM  */
/* BSP owns the MPU table:     that region → non-cacheable */

The driver doesn’t name an address. The BSP doesn’t know what a descriptor is. And the linker fails loudly if the region doesn’t exist, which beats the alternative — a buffer that lands somewhere cacheable, works on the bench, and drops frames under load six months later.

Anti-pattern #15 from article 03 applies directly here: add a new DMA buffer without an MPU region and it may happen to work because it landed in an already-non-cacheable area. Then a linker change moves it. Keep the linker script and the MPU table in the same review.

5. Where does retry live?

Article 02’s rule: retry once, at the lowest layer that can distinguish transient from permanent. In layer terms:

  • Peripheral driver retries a bus-level glitch. It’s the only layer that can see a CRC error and know the bus is otherwise alive.
  • Device driver does not retry. It has no way to distinguish a busy sensor from a dead one better than the bus did.
  • Service doesn’t retry either. It applies policy: mark degraded, fall back to last-known-good, stop reporting.

Three layers, one retry. Retry in all three and you get 27 attempts and a nine-second stall inside what the diagram calls a sensor read.

6. Where does logging go?

Logging is the classic cross-cutting concern, and pretending it fits the layer model is how article 03’s dependency cycle gets built.

The answer that doesn’t create a cycle: drivers emit, services log.

/* drivers/fault_sink.h — no dependencies above drivers, ISR-safe */
void fault_emit(uint16_t source_id, uint16_t code, uint32_t detail);

Eight bytes into a lock-free ring. The logging service drains it, adds timestamps and context, and decides what’s worth persisting. The driver depends on nothing, and the ring survives if the log store is busy erasing a sector.

Above the driver layer, calling the logging service directly is fine — services and application code sit above it and the arrow points down.

7. Is the vendor HAL your HAL?

No, and treating it as one is why “we used the HAL, so we’re portable” is a claim that never survives contact with a second MCU.

ST’s HAL abstracts across STM32 families. That’s real value — it’s why moving F4 to H7 is a week rather than a month. But it doesn’t abstract across vendors, its handle structs leak into every signature that touches it, and its blocking APIs bring their own timing model.

Two workable positions, and both beat pretending:

Use it, and contain it. Vendor HAL calls live in peripheral drivers, nowhere else. Your portability boundary is the driver interface, not the HAL. This is the pragmatic default and what article 02’s KIC-400 does.

Wrap it, if you have a specific reason. A second MCU vendor on the roadmap, or a host-build requirement that reaches deeper than the driver interface. Wrapping costs you a layer and buys you portability you may never spend.

What doesn’t work is a thin wrapper that renames HAL_SPI_Transmit to spi_transmit and keeps the same handle type and the same blocking semantics. That’s a rename, not an abstraction, and it will be discovered to be a rename on the day you actually need to port.

8. Where does the RTOS-facing code live?

If the RTOS isn’t a layer, where do tasks get created?

Not in drivers. A driver that creates its own task has made a priority decision it has no standing to make — it doesn’t know what else is in the system or what the deadlines are. Article 02’s task table is a single, reviewable artifact precisely because priority assignment is a system-level decision.

The pattern that works: drivers and middleware expose blocking-capable APIs with timeouts and are agnostic about who calls them. Task creation, priority assignment and stack sizing live in one place — a system composition file at the application layer that reads like the task table.

/* application/system_init.c — the only file that creates tasks */
static StaticTask_t control_tcb;
static StackType_t  control_stack[CONTROL_STACK_WORDS];

xTaskCreateStatic(control_task, "ctrl", CONTROL_STACK_WORDS,
                  NULL, PRIO_CONTROL, control_stack, &control_tcb);

One file, one grep, and priority creep from article 03 becomes visible in a diff.


Interfaces, Without the Machinery

Article 01 introduced the pattern. Here’s the general form, and — more usefully — when not to reach for it.

typedef struct {
    result_t (*init)(void *ctx);
    result_t (*read)(void *ctx, sensor_sample_t *out);
    void     *ctx;
} sensor_if_t;

No heap, no exceptions, no RTTI. One indirect call. On target it binds to the real device driver; on the host test runner it binds to a table of canned samples, which is how the thermal policy gets tested without a sensor on a desk.

In C++ under -fno-exceptions -fno-rtti, a pure virtual interface is also fine and costs a vtable pointer per object plus the indirect call. Virtual functions don’t require RTTI or a heap — only dynamic_cast and typeid need RTTI, and objects can be statically allocated. Use whichever your codebase already uses. Don’t introduce C++ for this alone.

When not to use an interface. Indirection through a function pointer blocks inlining and costs a handful of cycles. Irrelevant for a 100 Hz sensor read. Not irrelevant inside an ISR running at 100 kHz, where you’ve just added five cycles to a path that executes 100,000 times a second and lost the compiler’s ability to inline a two-line accessor.

So: interfaces where implementations genuinely vary — a part that has a second source, anything you want to fake in tests, anything crossing a board revision. Direct calls on measured hot paths. That’s article 01’s rule about skipping layers deliberately, with a reason and a measurement attached.

An interface with exactly one implementation, forever, is a layer you’re paying for and not using.


The Cost of Over-Abstraction

There’s an opposite failure, and it’s less discussed because it looks like good engineering.

Application → Service → Manager → Controller → Adapter → Facade → Wrapper → HAL → Driver

to set a GPIO high.

The runtime cost is close to nothing on a Cortex-M7. The real costs are elsewhere: nobody can find where a value actually comes from, every stack frame in a fault dump is a wrapper, and the “reason to change” test fails at four of those layers because none of them has an independent reason to change.

The test is the same as everywhere else. If a layer has never changed independently of the layer below it, and you can’t name a plausible reason it would, it isn’t a boundary. Delete it.


Deciding, In Practice

Six questions, in order. First match wins.

  1. Does it touch MCU registers or peripheral state? → Peripheral driver (or HAL, if you have your own)
  2. Does it encode how this board is wired, clocked, or laid out in memory? → BSP
  3. Does it encode how one external part behaves? → Device driver
  4. Does it implement a standard or a reusable mechanism? → Middleware
  5. Does it provide a product capability with no hardware knowledge? → Service
  6. Does it decide what the product should do? → Application

Then the check that catches most mistakes: name the reason this code would change. If that reason is already the reason some other layer changes, you’ve picked wrong.

And the harder check, which is the one worth doing in a review: if you can’t say why the boundary exists, it doesn’t. Delete the layer or merge the modules. Boundaries you can’t defend are the ones that get violated first, because nobody defending them knows what they’re for.


Next

Layers tell you where code goes. They say nothing about when it runs.

Article 02’s task table assigned nine priorities and nine stack sizes, and I asserted the rules behind them without proving them. Priority as a function of deadline rather than importance. Never putting a blocking task above a non-blocking one. Those rules have derivations, and the failure modes when you get them wrong — priority inversion, unbounded blocking, stack overflow into a neighbour — are the most expensive bugs in RTOS firmware.

Article 05: RTOS Architecture — how to design tasks, queues, events, timers and synchronisation for production.


Series Index

Phase 1 — Architecture
01. Embedded Firmware Architecture Fundamentals
02. Production-Grade Firmware Architecture
03. Firmware Architecture Anti-Patterns

Phase 2 — Implementation
04. HAL vs BSP vs Drivers vs Middleware (you are here)
05. RTOS Architecture
06. State Machine Architecture

Phase 3 — Failure & Resilience
07. Error Handling & Recovery
08. Firmware Security Architecture

Phase 4 — Verification
09. Designing Firmware for Testability

Phase 5 — Product Lifecycle
10. Designing Firmware for 10-Year Products
11. Field Diagnostics & Observability
12. OTA & Safe Firmware Updates

Leave a Reply

Your email address will not be published. Required fields are marked *