Volume 08 Intermediate 4 sub-modules ~15 min read

Designing a Driver Layer (HAL)

This volume puts the course together. It organises firmware into layers so that only one of them knows the registers, hands classes their hardware so they can be tested, and ends with a complete interrupt-driven UART driver that uses nearly every feature from the earlier volumes - run against a simulated UART on a PC.

You will learn
  • How to split firmware into layers, and what the board file is for
  • Run-time and compile-time hardware interfaces, and their measured costs
  • Dependency injection, and testing logic on a PC with fakes
  • A complete interrupt-driven UART driver, and the bug its test found
You need
  • Volumes 02 to 07 of this course.
  • How a UART works, from Embedded C from Zero's drivers volume.

8.1 Layered firmware architecture

Firmware stays manageable when it is built in layers. Each layer knows only the layer below it, and only the lowest one knows about registers - so the rest can be read, changed and tested without the chip.

In a small program, main() can write to registers directly. By the time there are ten peripherals and a few thousand lines, that becomes code nobody dares to touch. A change to a UART register can break a function that was only meant to print a temperature. The cure is to decide who is allowed to know what.

The layers of a firmware project Application Services Drivers Hardware abstraction layer what to do: report the temperature how to say it: a logger, a protocol which bytes to move: UART, SPI which registers: UartRegs, bits the hardware: the chip's peripherals the board file makes each object and hands it to the layer above that uses it
Figure 8.1 - Each layer uses only the one below it. The application knows what to say, the services know how to say it, the drivers know which bytes to move, and only the hardware abstraction layer knows the registers. The board file is the one place that wires them together.

Figure 8.1 shows the usual layers. Each one offers something to the layer above, and hides how it does it:

Layer Knows about Offers the layer above Example
Application What the product must do Nothing: it is the top "Report the temperature every second"
Services Formats and protocols Text, messages, commands A logger, a command parser
Drivers One peripheral's behaviour Bytes in and out, pins on and off A UART driver
Hardware abstraction layer Addresses, registers and bits Named registers UartRegs and its bit masks

Two rules keep the layers honest. A layer calls only downwards, never up. And nothing above the hardware abstraction layer mentions a register or an address. The one exception is the board file: a short piece of code that makes each object and connects it to the next. This is its version in the UART driver at the end of this volume:


static UartRegs fake_usart1;                             // on the chip: the registers at their address
constinit Uart console(fake_usart1, 16'000'000);         // ready before start-up code runs

extern "C" void USART1_IRQHandler() { console.on_interrupt(); }

Three lines, and they use three earlier volumes. There is a reference to the registers, a constinit object that is ready before start-up code runs, and an extern "C" handler that passes the interrupt to the driver.

Common mistake

Letting one quick register write leak upwards "just this once". The first USART1->DR = 'x' in application code means the application can no longer run without that exact chip - on a new board, or in a test on a PC. Keep the register struct where only the driver can reach it, as Embedded C's registers volume advised.

Quick check

In a layered firmware, which layer is allowed to know the address of the UART's registers?

Show the answer

Answer: B. Only the lowest layer knows registers and addresses. Everything above works through the layer below, which is what lets the upper layers move to a new chip, or run in a test on a PC, unchanged.

8.2 Hardware abstraction interfaces

A hardware abstraction can be a run-time interface, with virtual functions, or a compile-time interface, with a template. Both hide the hardware; they differ in cost and in when the choice is made.

Volume 04 met both styles. Here they are side by side, driving the same blinker, with a fake pin standing in for the hardware:


// hal_styles.cpp - the same blinker, written against a HAL two ways
#include <cstdint>
#include <cstdio>

// Style 1: a run-time interface, as in Volume 04.
class OutputPinIf {
public:
    virtual ~OutputPinIf() = default;
    virtual void toggle() = 0;
};

class Blinker {
public:
    explicit Blinker(OutputPinIf &pin) : pin_(pin) {}
    void tick() { pin_.toggle(); }

private:
    OutputPinIf &pin_;
};

// Style 2: a compile-time interface - any Pin type with a toggle() will do.
template <typename Pin>
class BlinkerT {
public:
    explicit BlinkerT(Pin &pin) : pin_(pin) {}
    void tick() { pin_.toggle(); }

private:
    Pin &pin_;
};

// Fake pins for the PC: they count toggles instead of driving a wire.
class FakePinVirtual : public OutputPinIf {
public:
    void toggle() override { toggles_++; }
    uint32_t toggles() const { return toggles_; }

private:
    uint32_t toggles_ = 0;
};

class FakePin {
public:
    void toggle() { toggles_++; }
    uint32_t toggles() const { return toggles_; }

private:
    uint32_t toggles_ = 0;
};

int main() {
    FakePinVirtual pin_a;
    Blinker a(pin_a);
    FakePin pin_b;
    BlinkerT<FakePin> b(pin_b);
    for (int i = 0; i < 5; i++) {
        a.tick();
        b.tick();
    }
    std::printf("run-time interface:     %u toggles, pin object %zu bytes\n", static_cast<unsigned>(pin_a.toggles()),
                sizeof pin_a);
    std::printf("compile-time interface: %u toggles, pin object %zu bytes\n", static_cast<unsigned>(pin_b.toggles()),
                sizeof pin_b);
    return 0;
}

run-time interface:     5 toggles, pin object 16 bytes
compile-time interface: 5 toggles, pin object 4 bytes

Both blinkers did the same job. The run-time pin is 12 bytes bigger, for the hidden vtable pointer and its padding, and every toggle() through it is an indirect call, as Volume 04 measured. The compile-time version has neither: BlinkerT<FakePin> calls FakePin::toggle directly, and on the chip BlinkerT<GpioPin> would call the real pin just as directly.

Run-time interface (virtual) Compile-time interface (template)
When the hardware is chosen While the program runs While compiling
Cost per call An indirect call, not inlined None: a direct call, often inlined
Cost per object A hidden pointer Nothing
Code must be in a header No Yes, because templates are
Error messages Short Longer, as Volume 05 showed
Good for Swapping at run time; plug-in boards One hardware per build; fast paths

Many projects mix the two. Templates suit the fast, low-level parts such as pins. Virtual interfaces suit the slower, bigger parts such as a storage device, where one indirect call is lost in the noise.

Quick check

In hal_styles.cpp, why is the run-time pin object 16 bytes when the compile-time one is 4?

Show the answer

Answer: C. Both hold one 4-byte count. The class with virtual functions also holds an 8-byte hidden pointer on this PC, and padding rounds the object up to 16. Code is never stored in objects.

8.3 Dependency injection

Dependency injection means a class is handed the things it depends on, through its constructor, instead of finding them itself. That one habit is what makes a class testable without hardware.

A thermostat reads a temperature and switches a heater. Written the quick way, it would read the ADC's register and write the heater's GPIO itself. It could then only ever be tested on the real board, with a real room getting warmer and colder. Written with injection, it asks for a sensor and a heater, and does not care what they are:


// di.cpp - dependency injection: the thermostat is handed its sensor and its heater
#include <cstdint>
#include <cstdio>

// What the thermostat needs, written as interfaces.
class TemperatureSensor {
public:
    virtual ~TemperatureSensor() = default;
    virtual int16_t read_tenths() = 0;      // tenths of a degree
};

class Heater {
public:
    virtual ~Heater() = default;
    virtual void set(bool on) = 0;
};

// The logic: heat below 19.5 C, stop above 20.5 C, and in between leave it as it is.
class Thermostat {
public:
    Thermostat(TemperatureSensor &sensor, Heater &heater) : sensor_(sensor), heater_(heater) {}
    void update() {
        int16_t t = sensor_.read_tenths();
        if (t < 195) {
            on_ = true;
        } else if (t > 205) {
            on_ = false;
        }
        heater_.set(on_);
    }

private:
    TemperatureSensor &sensor_;
    Heater &heater_;
    bool on_ = false;
};

// Fakes for a test on the PC: a sensor that reads whatever the test says, a heater that remembers.
class FakeSensor : public TemperatureSensor {
public:
    int16_t read_tenths() override { return tenths_; }
    void set_tenths(int16_t t) { tenths_ = t; }

private:
    int16_t tenths_ = 0;
};

class FakeHeater : public Heater {
public:
    void set(bool value) override { on_ = value; }
    bool on() const { return on_; }

private:
    bool on_ = false;
};

int main() {
    FakeSensor sensor;
    FakeHeater heater;
    Thermostat thermostat(sensor, heater);          // the dependencies are handed in
    const int16_t readings[] = {180, 200, 210, 200, 190};
    for (int16_t t : readings) {
        sensor.set_tenths(t);
        thermostat.update();
        std::printf("%d.%d C: heater %s\n", t / 10, t % 10, heater.on() ? "on" : "off");
    }
    return 0;
}

18.0 C: heater on
20.0 C: heater on
21.0 C: heater off
20.0 C: heater off
19.0 C: heater on

The test walked the temperature up and down in a fraction of a second. It checked the part of the logic that is easiest to get wrong: at 20.0 C the heater stayed on while warming and stayed off while cooling. That gap is the hysteresis, and it is what stops a heater clicking on and off every second.

On the board, the same Thermostat is handed an ADC sensor driver and a heater driver instead, in the board file. Its own code does not change at all.

Remember

If a class makes its own hardware objects, or reaches for globals, it can only be tested where that hardware exists. If it is handed them in its constructor, a test can hand it fakes. The two cost the same on the chip.

Quick check

What makes Thermostat testable on a PC?

Show the answer

Answer: A. Thermostat never makes or finds its hardware; it uses whatever it was given. The test gave it a FakeSensor and a FakeHeater, set the temperature itself, and checked the heater. The same injection could equally use templates instead of virtual functions.

8.4 A complete UART driver in C++

A complete interrupt-driven UART driver uses almost everything in this course. It has a register block checked by static_assert, ring buffers from templates and an interrupt lock. It takes a std::span, returns a std::optional and a [[nodiscard]] status, and has an extern "C" handler.

This driver sends and receives without ever waiting. Its write() puts bytes in a transmit queue and asks for transmit interrupts. The interrupt handler then moves them to the data register, one at a time. When a byte arrives, the handler puts it in a receive queue, and read() takes it out later. Figure 8.2 shows both directions.

How bytes move through the UART driver sending write() transmit queue handler, TXE DR, the wire switches TXEIE on queue empty: TXEIE off receiving the wire, DR handler, RXNE receive queue read() reading DR clears RXNE optional: a byte, or none Neither write() nor read() ever waits: the interrupt does the waiting.
Figure 8.2 - Sending: write() fills the transmit queue and switches the TXE interrupt on. Each interrupt moves one byte to the data register, and the last one switches the interrupt off. Receiving: each byte that arrives raises an interrupt, which moves it to the receive queue, and read() collects it later.

The registers

The register block is the hardware abstraction layer: plain data, laid out as a datasheet would show it, with a static_assert so a missing register cannot slip through. The bit names live in namespaces:


struct UartRegs {
    volatile uint32_t SR;       // 0x00 status
    volatile uint32_t DR;       // 0x04 data: write to send, read to receive
    volatile uint32_t BRR;      // 0x08 baud rate divisor: clock / baud
    volatile uint32_t CR1;      // 0x0C control
};
static_assert(sizeof(UartRegs) == 16, "UartRegs does not match the datasheet");

namespace sr {
constexpr uint32_t TXE = 1u << 7;       // the data register is free for the next byte to send
constexpr uint32_t RXNE = 1u << 5;      // a received byte is waiting in the data register
}  // namespace sr
namespace cr1 {
constexpr uint32_t UE = 1u << 13;       // UART on
constexpr uint32_t TXEIE = 1u << 7;     // interrupt when TXE is set
constexpr uint32_t RXNEIE = 1u << 5;    // interrupt when RXNE is set
constexpr uint32_t TE = 1u << 3;        // transmitter on
constexpr uint32_t RE = 1u << 2;        // receiver on
}  // namespace cr1

The driver


class Uart {
public:
    constexpr Uart(UartRegs &regs, uint32_t clock_hz) : regs_(regs), clock_hz_(clock_hz) {}

    void start(uint32_t baud) {
        regs_.BRR = (clock_hz_ + baud / 2) / baud;          // rounded, as in Embedded C
        regs_.CR1 = cr1::UE | cr1::TE | cr1::RE | cr1::RXNEIE;
    }

    // Queue bytes to send, and let the interrupt send them. Never waits.
    Status write(std::span<const uint8_t> bytes) {
        InterruptLock lock;
        Status result = Status::ok;
        for (uint8_t b : bytes) {
            if (!tx_.push(b)) {
                result = Status::queue_full;                // the rest will not fit
                break;
            }
        }
        regs_.CR1 = regs_.CR1 | cr1::TXEIE;                  // there is work: ask for TXE interrupts
        return result;
    }

    // A received byte, if one has arrived. Never waits.
    std::optional<uint8_t> read() {
        InterruptLock lock;
        uint8_t b = 0;
        if (rx_.pop(b)) {
            return b;
        }
        return std::nullopt;
    }

    // Called from the interrupt handler.
    void on_interrupt() {
        uint32_t status = regs_.SR;
        if (status & sr::RXNE) {
            uint8_t b = static_cast<uint8_t>(regs_.DR);     // reading DR clears RXNE
            if (!rx_.push(b)) {
                dropped_++;
            }
        }
        if ((status & sr::TXE) && (regs_.CR1 & cr1::TXEIE)) {
            uint8_t b = 0;
            if (tx_.pop(b)) {
                regs_.DR = b;
            } else {
                regs_.CR1 = regs_.CR1 & ~cr1::TXEIE;        // nothing left: stop asking
            }
        }
    }

    uint32_t dropped() const { return dropped_; }

private:
    UartRegs &regs_;
    uint32_t clock_hz_;
    RingBuffer<uint8_t, 8> tx_;
    RingBuffer<uint8_t, 8> rx_;
    uint32_t dropped_ = 0;
};

Here is where each idea from the course appears:

Running it

On a PC there is no UART, so the program ends with a small simulation of one. Its hw_transmit() plays the transmitter asking for bytes while its interrupt is on, and hw_receive() plays a byte arriving. The whole file is in the box below. Here is what it printed:


BRR = 139, CR1 = 0x202C
write: ok, the wire carried 6 bytes: hello
TX interrupt afterwards: off
received: 'O' 'K'
writing 12 bytes into an 8-byte queue: queue full
the bytes that fitted still went out: abcdefgh

Every line can be checked against something known:

Common mistake

The first version of this driver had a bug that the last line of the test caught. When the queue filled, write() returned straight away - before switching the transmit interrupt on. The eight queued bytes then sat there until some later write() happened to succeed. The fix was to switch the interrupt on whenever anything was queued. A test that fills the queue on purpose is worth writing for every driver with one.

The whole of uart_driver.cpp

// uart_driver.cpp - an interrupt-driven UART driver, run against a simulated UART
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <optional>
#include <span>

// ---- the register block, laid out as the datasheet shows it --------------------------------
struct UartRegs {
    volatile uint32_t SR;       // 0x00 status
    volatile uint32_t DR;       // 0x04 data: write to send, read to receive
    volatile uint32_t BRR;      // 0x08 baud rate divisor: clock / baud
    volatile uint32_t CR1;      // 0x0C control
};
static_assert(sizeof(UartRegs) == 16, "UartRegs does not match the datasheet");

namespace sr {
constexpr uint32_t TXE = 1u << 7;       // the data register is free for the next byte to send
constexpr uint32_t RXNE = 1u << 5;      // a received byte is waiting in the data register
}  // namespace sr
namespace cr1 {
constexpr uint32_t UE = 1u << 13;       // UART on
constexpr uint32_t TXEIE = 1u << 7;     // interrupt when TXE is set
constexpr uint32_t RXNEIE = 1u << 5;    // interrupt when RXNE is set
constexpr uint32_t TE = 1u << 3;        // transmitter on
constexpr uint32_t RE = 1u << 2;        // receiver on
}  // namespace cr1

// ---- pieces from earlier volumes ------------------------------------------------------------
static uint32_t primask = 0;            // the interrupt mask, faked for the PC, as in Volume 03

class InterruptLock {
public:
    InterruptLock() : saved_(primask) { primask = 1; }
    ~InterruptLock() { primask = saved_; }
    InterruptLock(const InterruptLock &) = delete;
    InterruptLock &operator=(const InterruptLock &) = delete;

private:
    uint32_t saved_;
};

template <typename T, size_t N>
class RingBuffer {                      // the ring buffer from Volume 05
public:
    bool push(T item) {
        if (count_ == N) {
            return false;
        }
        items_[head_] = item;
        head_ = (head_ + 1) % N;
        count_++;
        return true;
    }
    bool pop(T &item) {
        if (count_ == 0) {
            return false;
        }
        item = items_[tail_];
        tail_ = (tail_ + 1) % N;
        count_--;
        return true;
    }

private:
    T items_[N] = {};
    size_t head_ = 0;
    size_t tail_ = 0;
    size_t count_ = 0;
};

enum class [[nodiscard]] Status : uint8_t { ok, queue_full };

// ---- the driver -----------------------------------------------------------------------------
class Uart {
public:
    constexpr Uart(UartRegs &regs, uint32_t clock_hz) : regs_(regs), clock_hz_(clock_hz) {}

    void start(uint32_t baud) {
        regs_.BRR = (clock_hz_ + baud / 2) / baud;          // rounded, as in Embedded C
        regs_.CR1 = cr1::UE | cr1::TE | cr1::RE | cr1::RXNEIE;
    }

    // Queue bytes to send, and let the interrupt send them. Never waits.
    Status write(std::span<const uint8_t> bytes) {
        InterruptLock lock;
        Status result = Status::ok;
        for (uint8_t b : bytes) {
            if (!tx_.push(b)) {
                result = Status::queue_full;                // the rest will not fit
                break;
            }
        }
        regs_.CR1 = regs_.CR1 | cr1::TXEIE;                  // there is work: ask for TXE interrupts
        return result;
    }

    // A received byte, if one has arrived. Never waits.
    std::optional<uint8_t> read() {
        InterruptLock lock;
        uint8_t b = 0;
        if (rx_.pop(b)) {
            return b;
        }
        return std::nullopt;
    }

    // Called from the interrupt handler.
    void on_interrupt() {
        uint32_t status = regs_.SR;
        if (status & sr::RXNE) {
            uint8_t b = static_cast<uint8_t>(regs_.DR);     // reading DR clears RXNE
            if (!rx_.push(b)) {
                dropped_++;
            }
        }
        if ((status & sr::TXE) && (regs_.CR1 & cr1::TXEIE)) {
            uint8_t b = 0;
            if (tx_.pop(b)) {
                regs_.DR = b;
            } else {
                regs_.CR1 = regs_.CR1 & ~cr1::TXEIE;        // nothing left: stop asking
            }
        }
    }

    uint32_t dropped() const { return dropped_; }

private:
    UartRegs &regs_;
    uint32_t clock_hz_;
    RingBuffer<uint8_t, 8> tx_;
    RingBuffer<uint8_t, 8> rx_;
    uint32_t dropped_ = 0;
};

// ---- the board: one UART, and its interrupt handler -------------------------------------------
static UartRegs fake_usart1;                             // on the chip: the registers at their address
constinit Uart console(fake_usart1, 16'000'000);         // ready before start-up code runs

extern "C" void USART1_IRQHandler() { console.on_interrupt(); }

// ---- the simulated UART: what the silicon does by itself --------------------------------------
static char wire[64];
static size_t wire_length = 0;

// While the TXE interrupt is enabled, the UART keeps asking for bytes, and sends each one.
static void hw_transmit() {
    constexpr uint32_t untouched = 0xFFFFFFFFu;
    while (fake_usart1.CR1 & cr1::TXEIE) {
        fake_usart1.SR = fake_usart1.SR | sr::TXE;
        fake_usart1.DR = untouched;
        USART1_IRQHandler();
        if (fake_usart1.DR != untouched && wire_length < sizeof wire) {
            wire[wire_length] = static_cast<char>(fake_usart1.DR);
            wire_length++;
        }
    }
}

// A byte arrives on the wire: the UART stores it, raises RXNE, and interrupts.
static void hw_receive(uint8_t b) {
    fake_usart1.DR = b;
    fake_usart1.SR = (fake_usart1.SR | sr::RXNE) & ~sr::TXE;
    USART1_IRQHandler();
    fake_usart1.SR = fake_usart1.SR & ~sr::RXNE;
}

int main() {
    console.start(115200);
    std::printf("BRR = %u, CR1 = 0x%04X\n", static_cast<unsigned>(fake_usart1.BRR),
                static_cast<unsigned>(fake_usart1.CR1));

    const uint8_t hello[] = {'h', 'e', 'l', 'l', 'o', '\n'};
    Status s = console.write(hello);
    hw_transmit();
    std::printf("write: %s, the wire carried %zu bytes: %.*s", s == Status::ok ? "ok" : "queue full",
                wire_length, static_cast<int>(wire_length), wire);
    std::printf("TX interrupt afterwards: %s\n", (fake_usart1.CR1 & cr1::TXEIE) ? "on" : "off");

    hw_receive('O');
    hw_receive('K');
    std::printf("received:");
    while (std::optional<uint8_t> b = console.read()) {
        std::printf(" '%c'", *b);
    }
    std::printf("\n");

    const uint8_t too_long[] = {'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l'};
    s = console.write(too_long);
    std::printf("writing 12 bytes into an 8-byte queue: %s\n", s == Status::ok ? "ok" : "queue full");
    size_t before = wire_length;
    hw_transmit();
    std::printf("the bytes that fitted still went out: %.*s\n", static_cast<int>(wire_length - before),
                wire + before);
    return 0;
}
Quick check

Why does on_interrupt() switch the TXE interrupt off when the transmit queue is empty?

Show the answer

Answer: D. TXE means "ready for another byte", which stays true when there is nothing to send. Left on, the interrupt would run for ever and starve the rest of the program. The write function switches it back on when there is work. The test showed it off again once "hello" had gone.

What you learned

Key words from this volume

Every word below has a plain-English entry in the glossary.

Practice

Practice 1

Give the timeout a clock

The Timeout class from Volume 02 was handed the current time on every call. Change it so that it is handed a Clock object once, in its constructor, and asks the clock itself. Then test it with a fake clock: start it, move the clock on 499 ms, then 1 ms more.

Show the solution

// practice_clock.cpp - practice: give the timeout its clock, so a test can control time
#include <cstdint>
#include <cstdio>

class Clock {
public:
    virtual ~Clock() = default;
    virtual uint32_t now_ms() const = 0;
};

class Timeout {
public:
    Timeout(const Clock &clock, uint32_t limit_ms) : clock_(clock), limit_ms_(limit_ms) {}
    void start() { start_ms_ = clock_.now_ms(); }
    bool expired() const { return clock_.now_ms() - start_ms_ >= limit_ms_; }

private:
    const Clock &clock_;
    uint32_t limit_ms_;
    uint32_t start_ms_ = 0;
};

// On the chip, now_ms() would read a timer. In a test, the test decides what time it is.
class FakeClock : public Clock {
public:
    uint32_t now_ms() const override { return ms_; }
    void advance(uint32_t ms) { ms_ = ms_ + ms; }

private:
    uint32_t ms_ = 1000;
};

int main() {
    FakeClock clock;
    Timeout reply(clock, 500);
    reply.start();
    clock.advance(499);
    std::printf("after 499 ms: %s\n", reply.expired() ? "expired" : "waiting");
    clock.advance(1);
    std::printf("after 500 ms: %s\n", reply.expired() ? "expired" : "waiting");
    return 0;
}

after 499 ms: waiting
after 500 ms: expired

The test controls time exactly, so it can check the boundary - 499 ms and 500 ms - which is where timeout bugs live. On the board, the board file hands Timeout a clock that reads the real millisecond counter.

Practice 2

Which layer?

Put each piece in its layer.

  1. The UartRegs struct.
  2. A function that turns a temperature into the text "temperature 25 C".
  3. The Uart class.
  4. "Send a report every minute".
  5. The line that makes the Uart object for USART1.
Show the solution

(1) The hardware abstraction layer: it describes registers. (2) Services: it knows a format, not a peripheral. (3) Drivers: it knows how one peripheral behaves. (4) The application: it is what the product does. (5) The board file: it is the one place that knows which UART, at which address, is the console.

Practice 3

Report what fitted

The driver's write() returns Status::queue_full when some bytes do not fit, but not how many did. Why might a caller need to know, and how would you change the function?

Show the solution

A caller that wants every byte sent needs to know where to carry on from. Otherwise it must resend the whole message, and some bytes go out twice. Return the number queued as well: for example a small struct with the Status and a count, or return the count and treat "fewer than asked" as full. Keep [[nodiscard]] on it, so the caller cannot ignore it.

Interview corner

Interview question 1

Testable firmware

"How would you structure firmware so that most of it can be tested on a PC?"

Show the solution

"In layers: application, services, drivers, and a hardware abstraction layer that is the only code to touch registers. Classes are handed their dependencies through their constructors, and a board file wires the real objects together. A test wires fakes instead - a sensor that returns what the test says, a port that records bytes - so everything above the HAL runs on the PC. Even a driver can be tested against a simulated register block."

Interview question 2

Virtual or template HAL?

"Would you use virtual functions or templates for a hardware abstraction layer?"

Show the solution

"It depends on the part. Templates cost nothing per call and let the compiler inline, so I use them for fast low-level things like pins, where the hardware is fixed for each build. Virtual interfaces are simpler to write and read, and a pin object with one grows by a hidden pointer. I use them where the choice happens while running, or where the call is slow anyway, like storage."

Interview question 3

An interrupt-driven UART

"Walk me through an interrupt-driven UART transmit path."

Show the solution

"write() copies the bytes into a transmit ring buffer, inside a critical section because the handler shares the buffer, and enables the TXE interrupt. Each time TXE fires, the handler moves one byte into the data register. When the buffer is empty, the handler disables the TXE interrupt, or it would fire for ever. Two details matter: write() must enable the interrupt even when the buffer fills part-way, and the caller must be told when not everything fitted."