Volume 07 Intermediate 5 sub-modules ~15 min read

Modern C++ for Firmware

C++11 and later added features that suit firmware well: enums that do not leak, small functions written where they are used, a way to return "no value", a safe view of a buffer, and attributes that turn silent mistakes into errors. This volume shows each one working, and measures the one popular feature small firmware should skip.

You will learn
  • enum class: scoped names, a fixed size, and no quiet conversions
  • Lambdas as C callbacks and as template arguments, and what std::function costs
  • std::optional for a value that may be missing, and std::span for any buffer
  • Move semantics: handing a resource on without copying it
  • [[nodiscard]], [[fallthrough]], [[maybe_unused]] and [[noreturn]]
You need
  • Volumes 02 to 06 of this course, especially RAII and the rule of five in Volume 03.

7.1 enum class

An enum class keeps its names inside it, never turns into a number without being asked, and can be stored in exactly the size you choose. It turns a whole family of mix-ups into build errors.

A plain C enum is just a set of named integers. Its names spill into the surrounding code, and its values slide into any integer parameter:


// plain_enum.cpp - a plain C-style enum turns into a number without being asked
#include <cstdint>
#include <cstdio>

enum Colour { red, green, blue };           // the names red, green and blue are now global

static void set_pin(uint8_t pin) { std::printf("set_pin(%u)\n", static_cast<unsigned>(pin)); }

int main() {
    Colour c = blue;
    set_pin(c);                             // meant a pin number; got a colour; compiles anyway
    int total = red + blue;                 // arithmetic on colours: also compiles
    std::printf("red + blue = %d\n", total);
    return 0;
}

set_pin(2)
red + blue = 2

Both lines compiled, even with the course's strict warnings. Now the same ideas with enum class:


// enum_class.cpp - enum class: names that stay inside, and no quiet conversions
#include <cstdint>
#include <cstdio>

enum class Colour : uint8_t { red, green, blue };       // stored in one byte
enum class Alarm : uint8_t { off, red, amber };         // a second 'red', and no clash

static const char *name(Colour c) {
    switch (c) {
    case Colour::red:
        return "red";
    case Colour::green:
        return "green";
    case Colour::blue:
        return "blue";
    }
    return "?";
}

int main() {
    Colour c = Colour::blue;
    Alarm a = Alarm::red;
    std::printf("colour %s, stored as %u, in %zu byte\n", name(c), static_cast<unsigned>(c), sizeof c);
    std::printf("alarm level %u\n", static_cast<unsigned>(a));
    return 0;
}

colour blue, stored as 2, in 1 byte
alarm level 1

Three things changed:


// enum_convert.cpp - an enum class will not pass as a pin number
#include <cstdint>

enum class Colour : uint8_t { red, green, blue };

void set_pin(uint8_t pin);

void mistake() {
    set_pin(Colour::blue);
}

enum_convert.cpp: In function 'void mistake()':
enum_convert.cpp:9:21: error: cannot convert 'Colour' to 'uint8_t' {aka 'unsigned char'}

The switch that reminds you

State machines in firmware are usually an enum and a switch. When a new state is added later, every switch must learn about it. With -Wall, the compiler checks that for you:


// switch_missing.cpp - a new state added, and one switch not updated
#include <cstdint>

enum class State : uint8_t { idle, running, stopped, fault };      // fault was added later

const char *describe(State s) {
    switch (s) {
    case State::idle:
        return "idle";
    case State::running:
        return "running";
    case State::stopped:
        return "stopped";
    }
    return "?";
}

switch_missing.cpp: In function 'const char* describe(State)':
switch_missing.cpp:7:12: error: enumeration value 'fault' not handled in switch [-Werror=switch]
Common mistake

Adding default: to every switch over an enum "to be safe". A default case handles every value, so the compiler stops warning about the ones you forgot. List every enumerator instead, and let the build tell you when a new one arrives.

Quick check

Why does set_pin(Colour::blue) fail to compile when Colour is an enum class?

Show the answer

Answer: C. An enum class has no quiet conversion to an integer, so a Colour cannot be passed where a uint8_t is wanted. The plain enum in plain_enum.cpp slid straight in and printed set_pin(2). When you really want the number, static_cast says so.

7.2 Lambdas as callbacks

A lambda is a small unnamed function written right where it is used. One with no captures can be a plain C callback. One that captures can be handed to a template, but not to a plain function pointer, and not cheaply to std::function.


// lambda.cpp - lambdas: small functions written where they are used
#include <cstdint>
#include <cstdio>

// A C-style timer driver: it calls back one plain function.
using callback_t = void (*)();
static callback_t timer_callback = nullptr;
static void timer_set_callback(callback_t cb) { timer_callback = cb; }

class Blinker {
public:
    void toggle() { on_ = !on_; }
    bool on() const { return on_; }

private:
    bool on_ = false;
};

static Blinker status_led;

// Calls f once for each sample. F can be any callable, and the compiler can see inside it.
template <typename F>
static void for_each_sample(const uint16_t *samples, int count, F f) {
    for (int i = 0; i < count; i++) {
        f(samples[i]);
    }
}

int main() {
    // 1. No captures: the lambda turns into a plain function pointer, so a C driver can take it.
    timer_set_callback([] { status_led.toggle(); });
    timer_callback();
    std::printf("after one tick: LED %s\n", status_led.on() ? "on" : "off");

    // 2. Captures: the lambda uses local variables - threshold by value, over by reference.
    const uint16_t samples[] = {510, 530, 890, 505, 920};
    uint16_t threshold = 800;
    int over = 0;
    for_each_sample(samples, 5, [threshold, &over](uint16_t s) {
        if (s > threshold) {
            over++;
        }
    });
    std::printf("%d samples over %u\n", over, static_cast<unsigned>(threshold));
    return 0;
}

after one tick: LED on
2 samples over 800

A lambda is written [captures](parameters) { body }. The first one captures nothing, so it is really just a plain function, and it fits the C driver's callback_t. That is the neater answer promised in Volume 02's practice: no separate toggle_status_led function needed.

The second one uses two local variables. The square brackets list them: threshold is copied in, and &over is a reference, so the lambda can add to the caller's count. Passed to a template such as for_each_sample, the lambda's code is visible to the compiler, which can put it straight into the loop.

Why a capturing lambda will not fit a C callback

A lambda that captures carries data - the captured variables - and a plain function pointer has nowhere to put it:


// capture_callback.cpp - a lambda that captures cannot be a plain function pointer
using callback_t = void (*)();
void timer_set_callback(callback_t cb);

void setup(int &ticks) {
    timer_set_callback([&ticks] { ticks = ticks + 1; });
}

capture_callback.cpp: In function 'void setup(int&)':
capture_callback.cpp:6:24: error: cannot convert 'setup(int&)::<lambda()>' to 'callback_t' {aka 'void (*)()'}

C drivers solve this with a second parameter, a void * "context" passed back to the callback, and many vendor drivers use exactly that shape.

What std::function costs

The standard library's std::function can hold any callable, captures and all. The size harness measured what that takes:


the program printed: sum 15, sizeof(std::function<int()>) = 32
the program uses: operator new(unsigned long), operator delete(void*, unsigned long)

Each std::function object is 32 bytes on this PC, and when its lambda's captures do not fit inside, it asks the heap for room with operator new. In firmware that avoids the heap, prefer a template parameter, as for_each_sample does, or the C-style pointer-and-context pair.

Quick check

Which of these lambdas can be passed to a C driver that takes void (*)()?

Show the answer

Answer: B. Only a lambda that captures nothing turns into a plain function pointer. The first and third capture variables, which a plain function pointer has no room for, as capture_callback.cpp shows. The second uses a global, which needs no capture.

7.3 std::optional and std::span

The type std::optional holds a value that may be missing, so a function can say "no reading" without a status code or a magic number. The type std::span keeps a pointer and a length together, so one function can take any buffer. Neither touches the heap.

std::optional

Volume 06 returned a status and passed the reading out through a reference. When "no value" is the only thing that can go wrong, std::optional says it more directly:


// optional.cpp - a value that may not be there, with no status code and no magic number
#include <cstdint>
#include <cstdio>
#include <optional>

static std::optional<int16_t> read_temperature(bool sensor_answers) {
    if (!sensor_answers) {
        return std::nullopt;                // "no reading"
    }
    return 25;
}

int main() {
    std::optional<int16_t> t = read_temperature(true);
    if (t) {                                // true only if there is a value
        std::printf("got %d C\n", *t);      // * reads the value: only after checking
    }

    std::optional<int16_t> missing = read_temperature(false);
    std::printf("missing has a value: %s\n", missing.has_value() ? "yes" : "no");
    std::printf("missing.value_or(-999) gives %d\n", missing.value_or(-999));
    std::printf("sizeof(std::optional<int16_t>) = %zu: the value and a flag\n", sizeof t);
    return 0;
}

got 25 C
missing has a value: no
missing.value_or(-999) gives -999
sizeof(std::optional<int16_t>) = 4: the value and a flag

An optional is the value plus a flag, stored side by side: 4 bytes here, and no heap. Read it with * only after checking, or use value_or(), which supplies a fallback. There is also a value() function, the checked way to read it. Like at() in Volume 06, it throws when there is nothing there, so in a build without exceptions, check first instead.

std::span

C passes a buffer as two separate parameters, a pointer and a length, and nothing stops the two disagreeing. A std::span keeps them together:


// span.cpp - one function for every kind of buffer: std::span
#include <array>
#include <cstdint>
#include <cstdio>
#include <span>

// A span is a view of someone else's bytes: a pointer and a length, kept together.
static uint32_t checksum(std::span<const uint8_t> data) {
    uint32_t sum = 0;
    for (uint8_t b : data) {
        sum += b;
    }
    return sum;
}

int main() {
    uint8_t c_array[4] = {1, 2, 3, 4};
    std::array<uint8_t, 6> frame = {10, 20, 30, 40, 50, 60};

    std::printf("a C array:          %u\n", static_cast<unsigned>(checksum(c_array)));
    std::printf("a std::array:       %u\n", static_cast<unsigned>(checksum(frame)));
    std::printf("first 3 of frame:   %u\n", static_cast<unsigned>(checksum(std::span<const uint8_t>(frame).first(3))));
    std::printf("sizeof(std::span<const uint8_t>) = %zu: a pointer and a length\n", sizeof(std::span<const uint8_t>));
    std::printf("sizeof(std::span<const uint8_t, 4>) = %zu: the length is in the type\n",
                sizeof(std::span<const uint8_t, 4>));
    return 0;
}

a C array:          10
a std::array:       210
first 3 of frame:   60
sizeof(std::span<const uint8_t>) = 16: a pointer and a length
sizeof(std::span<const uint8_t, 4>) = 8: the length is in the type

One checksum served a C array, a std::array, and the first three bytes of the array, with the length worked out each time rather than typed. A span is 16 bytes on this PC - the pointer and the length, the same as the two C parameters it replaces. A span whose length is part of its type needs only the pointer.

Common mistake

Keeping a span after the buffer it looks at has gone. A span owns nothing: it is a view, like a reference. Returning a span of a function's local array gives the caller a view of memory that no longer exists. Pass spans into functions; think hard before storing or returning one.

Quick check

What is inside a std::span<const uint8_t>?

Show the answer

Answer: A. The program printed 16 bytes: a pointer and a length. The span looks at the caller's buffer and copies nothing, which is why it must not outlive that buffer.

7.4 Move semantics simply

A move hands an object's resource to another object, and leaves the first one owning nothing. It is how a class that must not be copied - like Volume 03's DmaChannel - can still be passed on.

Volume 03 deleted DmaChannel's copy constructor, because two owners of one channel would release it twice. But sometimes the channel must change hands - claimed at start-up, then given to the UART driver that will use it. A copy makes two owners. A move makes one owner, and swaps who it is:


// move.cpp - handing a DMA channel on, without copying it
#include <cstdio>
#include <utility>

static bool channel_in_use[4] = {false, false, false, false};

static int dma_claim() {
    for (int i = 0; i < 4; i++) {
        if (!channel_in_use[i]) {
            channel_in_use[i] = true;
            return i;
        }
    }
    return -1;
}

static void dma_release(int ch) {
    channel_in_use[ch] = false;
    std::printf("channel %d released\n", ch);
}

class DmaChannel {
public:
    DmaChannel() : ch_(dma_claim()) { std::printf("channel %d claimed\n", ch_); }
    ~DmaChannel() {
        if (ch_ >= 0) {
            dma_release(ch_);
        }
    }
    DmaChannel(const DmaChannel &) = delete;                // no copies, as in Volume 03
    DmaChannel &operator=(const DmaChannel &) = delete;

    // A move: take over the other object's channel, and leave it owning nothing.
    DmaChannel(DmaChannel &&other) : ch_(other.ch_) { other.ch_ = -1; }
    DmaChannel &operator=(DmaChannel &&other) {
        if (this != &other) {
            if (ch_ >= 0) {
                dma_release(ch_);
            }
            ch_ = other.ch_;
            other.ch_ = -1;
        }
        return *this;
    }
    int number() const { return ch_; }

private:
    int ch_;
};

class UartDriver {
public:
    explicit UartDriver(DmaChannel &&tx) : tx_(std::move(tx)) {}
    int tx_channel() const { return tx_.number(); }

private:
    DmaChannel tx_;
};

int main() {
    DmaChannel ch;
    std::printf("before the move: ch owns channel %d\n", ch.number());
    UartDriver uart(std::move(ch));
    std::printf("after the move: ch owns %d, the driver owns channel %d\n", ch.number(), uart.tx_channel());
    return 0;
}

channel 0 claimed
before the move: ch owns channel 0
after the move: ch owns -1, the driver owns channel 0
channel 0 released

The channel was claimed once and released once. Here is what made that happen:

With the copy functions deleted and the move functions written, DmaChannel follows the rule of five from Volume 03: all five decided on purpose.

Common mistake

Using an object after moving from it. This DmaChannel promises what it holds afterwards - nothing - but many classes promise only that the object can be destroyed or given a new value. Treat a moved-from object as empty, and do not read from it.

Quick check

In move.cpp, why is channel 0 released only once, although two objects held it at different times?

Show the answer

Answer: D. The move left ch owning -1, as the output shows. Both destructors ran, but only the driver's had a channel to release. That is what makes moving safe where copying was not.

7.5 Attributes and [[nodiscard]]

Attributes are notes to the compiler, written in double square brackets. The useful ones in firmware turn silent mistakes - an ignored error, a missing break, an unused parameter - into warnings, and with -Werror, into errors.

[[nodiscard]]: a result that must not be ignored

A status value only helps if someone looks at it. Mark the function [[nodiscard]], and throwing the result away becomes a warning:


// nodiscard.cpp - a status that is thrown away
#include <cstdint>

enum class Status : uint8_t { ok, timeout };

[[nodiscard]] Status uart_send(uint8_t byte);

void say_hello() {
    uart_send('H');
}

nodiscard.cpp: In function 'void say_hello()':
nodiscard.cpp:9:14: error: ignoring return value of 'Status uart_send(uint8_t)', declared with attribute 'nodiscard' [-Werror=unused-result]

Better still, put the attribute on the type. Then every function that returns a Status is covered, with nothing to forget:


// nodiscard_type.cpp - [[nodiscard]] on the type: every function returning it is covered
#include <cstdint>

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

Status uart_send(uint8_t byte);
Status spi_send(uint8_t byte);

void say_hello() {
    uart_send('H');
    spi_send('H');
}

nodiscard_type.cpp: In function 'void say_hello()':
nodiscard_type.cpp:10:14: error: ignoring returned value of type 'Status', declared with attribute 'nodiscard' [-Werror=unused-result]
nodiscard_type.cpp:11:13: error: ignoring returned value of type 'Status', declared with attribute 'nodiscard' [-Werror=unused-result]

Three more for everyday firmware


// attributes.cpp - [[fallthrough]], [[maybe_unused]] and [[noreturn]], with every warning on
#include <cstdio>
#include <cstdlib>

[[noreturn]] static void fault(const char *why) {
    std::printf("fault: %s - stopping\n", why);
    std::exit(0);                       // on a chip: an endless loop, or a reset
}

static int divisor_for(int mode) {
    if (mode == 1) {
        return 104;
    }
    if (mode == 2) {
        return 52;
    }
    fault("unknown mode");              // never returns, so nothing is needed after it
}

static int steps_for(int mode) {
    int steps = 0;
    switch (mode) {
    case 2:
        steps += 10;
        [[fallthrough]];                // on purpose: mode 2 does mode 1's work as well
    case 1:
        steps += 1;
        break;
    default:
        break;
    }
    return steps;
}

static void log_value([[maybe_unused]] int value) {
    // A debug build would print the value here; this build does nothing with it.
}

int main() {
    std::printf("steps for mode 1: %d, for mode 2: %d\n", steps_for(1), steps_for(2));
    std::printf("divisor for mode 2: %d\n", divisor_for(2));
    log_value(5);
    std::printf("divisor for mode 3: %d\n", divisor_for(3));
    return 0;
}

steps for mode 1: 1, for mode 2: 11
divisor for mode 2: 52
fault: unknown mode - stopping

Each attribute answers a warning that the course's flags would otherwise turn into an error:

Attribute What it says Without it
[[fallthrough]] This case runs on into the next one on purpose "this statement may fall through"
[[maybe_unused]] This parameter may go unused in some builds "unused parameter"
[[noreturn]] This function never comes back "control reaches end of non-void function"

The last line of the program never printed: divisor_for(3) called fault(), which stopped the program. On a chip, a fault handler like this usually loops for ever or resets the chip, and [[noreturn]] tells the compiler so. C++20 adds [[likely]] and [[unlikely]] too, hints for which way a branch usually goes; they change speed, never behaviour.

Quick check

Why is [[nodiscard]] better on the Status type than on each function?

Show the answer

Answer: A. On the type, the attribute applies to every function that returns a Status - nodiscard_type.cpp caught two functions that were never marked themselves. On each function, one forgotten marking lets an ignored error through.

What you learned

Key words from this volume

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

Practice

Practice 1

Make it an enum class

A driver uses enum Mode { MODE_OFF, MODE_SLOW, MODE_FAST }; and a function void set_speed(uint8_t rpm);. Somebody writes set_speed(MODE_FAST); by mistake, and it compiles. Rewrite the enum so that the mistake cannot compile, and say how big a Mode will be.

Show the solution

Write enum class Mode : uint8_t { off, slow, fast };. The names now live inside it, as Mode::fast, so the MODE_ prefixes are no longer needed to keep them apart. An enum class never turns into a number without a cast, so set_speed(Mode::fast) fails just as enum_convert.cpp did. And with : uint8_t a Mode is 1 byte, as enum_class.cpp measured for Colour.

Practice 2

Optional instead of a status

Volume 06's read_temperature returned a Status and passed the reading out through a reference. The only thing that can go wrong is that the sensor does not answer. Rewrite it to return std::optional<int16_t>, and show a caller that prints the reading or "no reading".

Show the solution

The function is the one in optional.cpp: it returns std::nullopt when the sensor does not answer, and the reading otherwise. A caller checks it like a pointer:


std::optional<int16_t> t = read_temperature(true);
if (t) {                                // true only if there is a value
    std::printf("got %d C\n", *t);      // * reads the value: only after checking
}

When there are several different failures to tell apart - timeout, bad checksum - keep the Status, because an optional can only say "missing", not why.

Practice 3

Who owns the channel?

In move.cpp, what number does ch.number() give after UartDriver uart(std::move(ch));, and how many times is channel 0 released when the program ends?

Show the solution

It gives -1: the move constructor took the channel and left ch owning nothing. The program printed "after the move: ch owns -1, the driver owns channel 0". Channel 0 is released once, by the driver, when it is destroyed; ch's destructor sees -1 and does nothing.

Interview corner

Interview question 1

enum or enum class?

"Why prefer enum class to a plain enum?"

Show the solution

"A plain enum's names leak into the surrounding scope and its values convert to int silently, so a colour can be passed where a pin number is expected. An enum class keeps its names scoped, needs a static_cast to become a number, and lets me fix the underlying type - uint8_t for a one-byte field. With -Wall, a switch over it that misses a value is flagged, so adding a state finds every switch that needs updating."

Interview question 2

Callbacks in firmware

"How do you pass callbacks in embedded C++, and why not std::function?"

Show the solution

"A lambda with no captures converts to a plain function pointer, so it works with C drivers. When I need captures, I pass the callable as a template parameter, which costs nothing and can be inlined, or use a function pointer plus a context pointer. I avoid std::function in small firmware: I measured one at 32 bytes, and it called operator new when the captures did not fit inside it."

Interview question 3

What does std::move do?

"What does std::move actually do?"

Show the solution

"Nothing at run time: it is a cast to an rvalue reference, which says the object may be taken from. The move constructor or move assignment does the real work, taking the resource and leaving the source empty. I use it for move-only types, like a class owning a DMA channel, which must be handed on but never copied."