Volume 00 Beginner 4 sub-modules ~15 min read

Start Here: C++ on a Microcontroller

Many engineers were told that C++ is too big and too slow for small chips. Some of it is. This volume shows which parts of the language cost nothing at run time, which parts cost a lot, and how to switch the expensive ones off - with every cost measured on a real compiler, not repeated from rumour.

You will learn
  • Why firmware teams choose C++, and where you already meet it
  • Which common beliefs about C++ are myths, and which costs are real
  • The subset of C++ that suits a small microcontroller
  • What -fno-exceptions and -fno-rtti do, and the errors they give
  • How to compile and run every example in this course
You need
  • Some C: variables, functions, pointers and structs. Embedded C from Zero, Volumes 00 to 08, covers all of it.

0.1 Why C++ on a microcontroller

C++ keeps almost everything you know from C and adds ways for the compiler to check your code and do work for you. Used with care, that help costs nothing on the chip.

You may have written C++ already without knowing it. Every Arduino sketch is a C++ program: the setup() and loop() functions you write are compiled by a C++ compiler. Firmware teams in cars, medical devices and consumer products use it too, often under coding rules written for safety-critical C++, such as MISRA C++.

The easiest way to see what C++ adds is to write the same thing twice. Here is an LED, first the C way and then as a C++ class:


// same_led.cpp - the same LED, written the C way and the C++ way
#include <cstdint>
#include <cstdio>

// The C way: the data lives in a struct, and the functions that work on it
// take a pointer to it.
struct led_c {
    uint8_t pin;
    bool    on;
};

static void led_c_toggle(led_c *l) { l->on = !l->on; }

// The C++ way: the same data, with the function inside the type.
class Led {
public:
    explicit Led(uint8_t pin) : pin_(pin), on_(false) {}
    void toggle() { on_ = !on_; }
    bool is_on() const { return on_; }
    uint8_t pin() const { return pin_; }

private:
    uint8_t pin_;
    bool    on_;
};

int main() {
    led_c a = {5, false};
    Led   b(5);

    led_c_toggle(&a);
    b.toggle();

    std::printf("C struct:  %zu bytes, pin %u is %s\n", sizeof a,
                static_cast<unsigned>(a.pin), a.on ? "on" : "off");
    std::printf("C++ class: %zu bytes, pin %u is %s\n", sizeof b,
                static_cast<unsigned>(b.pin()), b.is_on() ? "on" : "off");
    return 0;
}

C struct:  2 bytes, pin 5 is on
C++ class: 2 bytes, pin 5 is on

Both take 2 bytes. The class is the struct with its functions moved inside it - nothing is added to the object in memory. What the class does add is a promise the compiler enforces: pin_ and on_ are private, so no other part of the program can change them by mistake. Every change has to go through toggle().

What C++ gives firmware

Feature What it does for firmware Volume
References Pass a value without copying it, and without a pointer that might be null 01
Namespaces Two drivers can both have a function called init() without clashing 01
Classes Keep data with the code that uses it, and hide what callers must not touch 02
RAII Cleanup that cannot be forgotten - a lock released on every path out of a function 03
Templates and constexpr The compiler works values out, so the chip does not have to 05
enum class A colour can never be passed where a pin number is expected 07
Remember

C++ is not all or nothing. You can start with a program that is mostly C, and add one feature at a time where it makes the code safer or clearer.

Quick check

In same_led.cpp, how much memory does one Led object take, compared with the led_c struct?

Show the answer

Answer: C. Both print 2 bytes. Member functions are compiled once, like ordinary functions; they are not stored in each object. Only virtual functions add a hidden pointer, and this class has none.

0.2 Myths and real costs

Most fears about C++ on small chips are myths. A few costs are real, and each one comes from a specific feature that you can avoid or switch off.

Four myths

What people say What is true
C++ is slower than C The same work compiles to the same instructions. A member function is an ordinary function that is handed the object.
C++ needs a heap Only new, delete and some library containers use the heap. Everything in this course works without one.
Every class hides extra data The Led class above is 2 bytes, exactly like the struct.
Templates always bloat the code The compiler writes one copy for each type you actually use. Used with care, they often make code smaller.

The real costs, measured

These numbers were measured for this course with GCC 15.2 on a PC. A microcontroller build gives different numbers - its pointers are 4 bytes, not 8 - but the comparisons hold.

Feature What it cost in the measurement
Exceptions One small function grew from 99 to 111 bytes of code, and gained 140 bytes of tables: 152 bytes in all
RTTI Three small classes needed 6 type-information objects, 97 bytes; none with it switched off
iostream Printing "hello" took 1,877,656 bytes of code through std::cout, against 699,251 through printf: 2.7 times as much
Virtual functions Two classes needed two vtables, 80 bytes together, plus a hidden pointer in every object

The exception numbers need a word of explanation. When an exception passes through a function, the program must still run the destructors of that function's local objects. To do that, the compiler stores unwind tables that describe every function's stack. Those tables are there whether or not anything is ever thrown.

Common mistake

Blaming "C++" for a large program. Measure first. Most of the size usually comes from one feature, such as std::cout or the heap, and removing that feature fixes it.

Quick check

A program never actually throws an exception, but it is compiled with exceptions switched on. Which cost does it still pay?

Show the answer

Answer: B. The tables are built at compile time and shipped with the program, just in case. In the measurement they were 140 bytes for one small function, even though nothing was thrown.

0.3 The embedded C++ subset

The embedded C++ subset is C++ without the features that need a heap, exceptions or type information while the program runs. What is left is most of the language.

Use freely Use with care Avoid in small firmware
Classes, references, namespaces Virtual functions: a hidden pointer per object, and an indirect call Exceptions: throw, try, catch
enum class, constexpr, static_assert Templates used with many types: one copy of the code each RTTI: dynamic_cast, typeid
Templates, RAII, lambdas Global objects with constructors: they run before main() std::cout and the rest of iostream
std::array, std::optional, std::span Inline functions: faster, but they can grow the code new and delete while the program runs
Most of <algorithm> std::vector, std::string, std::map, std::function - they can allocate

Here is a short program using several of the "use freely" features. It was built with exceptions and RTTI switched off, like every example in this course:


// subset.cpp - everyday embedded C++, built with -fno-exceptions -fno-rtti
#include <array>
#include <cstdint>
#include <cstdio>

namespace board {

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

// Worked out by the compiler, not by the chip at run time.
constexpr uint32_t clock_hz = 16'000'000;
constexpr uint32_t uart_divisor(uint32_t baud) { return clock_hz / (16u * baud); }

}  // namespace board

static_assert(board::uart_divisor(9600) == 104, "check the divisor sum");

int main() {
    std::array<uint16_t, 4> readings = {512, 530, 498, 505};

    uint32_t total = 0;
    for (uint16_t r : readings) {
        total += r;
    }

    board::Colour c = board::Colour::green;

    std::printf("%zu readings, average %u\n", readings.size(),
                static_cast<unsigned>(total / readings.size()));
    std::printf("colour code %u\n", static_cast<unsigned>(c));
    std::printf("UART divisor for 9600 baud: %u\n", static_cast<unsigned>(board::uart_divisor(9600)));
    return 0;
}

4 readings, average 511
colour code 1
UART divisor for 9600 baud: 104

Four things are worth noticing:

Common mistake

Thinking that "no heap" means "no standard library". std::array, std::optional, std::span and most of <algorithm> never touch the heap. It is the growing containers - std::vector, std::string, std::map - that allocate.

Quick check

Which of these can allocate memory on the heap while the program runs?

Show the answer

Answer: D. A vector grows by allocating new memory, so it uses the heap. By contrast, the array has a fixed size known at compile time. The constexpr function and the enum class are worked out by the compiler.

0.4 Compiler flags: -fno-exceptions and -fno-rtti

Two compiler flags switch off the expensive features, and after that the compiler refuses any code that uses them - so the build itself proves they are gone.

-fno-exceptions

Compile this with -fno-exceptions:


// throw.cpp - throw, in a build made with -fno-exceptions
int read_sensor(int channel) {
    if (channel > 3) {
        throw channel;
    }
    return channel * 100;
}

throw.cpp: In function 'int read_sensor(int)':
throw.cpp:4:15: error: exception handling disabled, use '-fexceptions' to enable

The code does not compile, and that is the point. Nobody can add a throw to the firmware by accident, because the build fails the moment they try. Errors are reported in other ways instead - a return value, or an error code - which Volume 06 covers.

-fno-rtti


// dynamic_cast.cpp - dynamic_cast, in a build made with -fno-rtti
struct Sensor {
    virtual ~Sensor() = default;
    virtual int read() = 0;
};

struct Thermometer : Sensor {
    int read() override { return 25; }
};

int celsius(Sensor *s) {
    Thermometer *t = dynamic_cast<Thermometer *>(s);
    return t != nullptr ? t->read() : -1;
}

dynamic_cast.cpp: In function 'int celsius(Sensor*)':
dynamic_cast.cpp:12:22: error: 'dynamic_cast' not permitted with '-fno-rtti'

dynamic_cast asks, while the program runs, "is this sensor really a thermometer?" To answer, it needs the type information that -fno-rtti removes. Well-designed firmware rarely needs to ask: Volume 04 shows how to give the base class the functions callers need instead.

Other flags worth knowing

Flag What it does
-fno-exceptions No exceptions, and no unwind tables for them
-fno-rtti No type information; dynamic_cast and typeid refuse to compile
-ffunction-sections -fdata-sections with -Wl,--gc-sections Lets the linker throw away code nobody calls. In the course's test it removed an unused function and its 512-byte table: 586 bytes in all
-Os Optimise for size rather than speed
-Wall -Wextra -Werror Turn on the useful warnings, and treat every warning as an error

The third row is called garbage collection of sections. Without it, the linker keeps every function in a file if any one of them is used.

How to run the examples

Every example in this course was built with this command:


g++ -std=c++20 -Wall -Wextra -Werror -Wpedantic -Wshadow -Wconversion -Wsign-conversion \
    -Wold-style-cast -Wnon-virtual-dtor -Woverloaded-virtual \
    -fno-exceptions -fno-rtti -O1 -g example.cpp -o example

You do not need to install anything to follow along. Compiler Explorer, a free website, compiles C++ in your browser. It can also compile for ARM microcontrollers, so you can see the instructions a Cortex-M chip would run. If you prefer to work offline, g++ is free on Linux and macOS, and on Windows through WSL.

Quick check

What happens when code containing throw is compiled with -fno-exceptions?

Show the answer

Answer: A. g++ refuses to compile it: "exception handling disabled, use '-fexceptions' to enable". That is what makes the flag useful - it proves no exceptions are used anywhere in the build.

What you learned

Key words from this volume

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

Practice

Practice 1

Spot the expensive feature

A colleague's firmware for a chip with 32 KB of flash contains these four lines. Which one would you question first, and why?

std::array<uint8_t, 64> rx_buffer; · constexpr uint32_t timeout_ms = 500; · std::string device_name; · enum class Mode : uint8_t { idle, run };

Show the solution

The std::string. It can grow, so it allocates memory from the heap. On a small chip a heap brings fragmentation, and the risk of running out of memory at an awkward moment.

The other three cost nothing at run time. The array has a fixed size, and the constexpr value is worked out by the compiler. The enum class is just a byte with rules about how it may be used.

Practice 2

Read the measurement

In the exceptions measurement, the function grew from 99 to 111 bytes of code and gained 140 bytes of tables. The program never throws. Why are the tables there at all?

Show the solution

The compiler cannot know that nothing will ever throw: read_register() is defined in another file and might. If an exception did pass through, the destructor of the local lock object would still have to run. The unwind tables describe how to do that, so they are built into the program just in case - which is why switching exceptions off removes them.

Interview corner

Interview question 1

Why disable exceptions?

"Why do most embedded C++ projects build with -fno-exceptions?"

Show the solution

"Exceptions cost code and data even when nothing is thrown, because the compiler has to store unwind tables so that destructors still run as an exception passes. On a small chip that space matters, and the time taken to unwind is hard to predict, which is a problem for real-time code. Switching them off also makes any use of throw a compile error, so the build proves they are not used. Errors are reported through return values instead."

Interview question 2

Is C++ slower than C?

"Is C++ slower than C on a microcontroller?"

Show the solution

"Not for the same work. A class with member functions compiles to the same instructions as a struct with ordinary functions, and constexpr and templates can make code faster by moving work to compile time. The costs are specific features - exceptions, RTTI, iostream, the heap, and virtual calls where they are not needed. An embedded project uses the subset that avoids them, and measures the size of what it builds."