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.
- 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
- 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 |
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.
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.
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.
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:
namespace boardkeepsclock_hzanduart_divisorout of the global names, so another file can have its ownclock_hzwithout a clash.std::arrayknows its own size, soreadings.size()is 4 without a separate constant to keep in step.uart_divisor(9600)isconstexpr, so the compiler works it out while compiling.- The static_assert checks that answer during the build. If someone changes the clock and the divisor stops being 104, the program will not compile at all.
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.
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.
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
- C++ keeps what you know from C and adds features the compiler checks and works out for you.
- A class with member functions takes the same memory as the equivalent struct.
- The real costs come from specific features: exceptions, RTTI, iostream, the heap and virtual functions.
- The embedded subset keeps most of the language and drops only what needs run-time support.
-fno-exceptionsand-fno-rttiswitch those features off, and make any use of them a compile error.- Unused code can be removed at link time with
-ffunction-sections -fdata-sections -Wl,--gc-sections.
Key words from this volume
Every word below has a plain-English entry in the glossary.
- C++
- Class
- Object
- Namespace
- RAII
- Template
- constexpr
- enum class
- Heap
- Exception
- RTTI (run-time type information)
- iostream
- vtable
- Unwind table
- static_assert
- Compiler flag
- --gc-sections
Practice
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.
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
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."
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."