Memory Without the Heap
Firmware that never touches the heap knows its memory use before it runs. This volume shows where C++ objects live and when they are made, replaces the growing containers with fixed ones, builds objects later with placement new, and handles errors without exceptions. It ends with a start-up bug that two files can cause between them, and the two ways to prevent it.
- Where each kind of object lives, when it is made, and what a static local costs
- std::array, and a fixed-capacity container that never allocates
- Placement new: building an object later, in memory reserved while compiling
- Status values instead of exceptions, and what the library does when it would throw
- The static initialisation order problem, and constinit and first-use as cures
- Volumes 02 to 05 of this course.
- Memory sections and start-up, from Embedded C from Zero's memory map volume.
6.1 Where objects live
Every object lives somewhere: in static storage, fixed when the program is linked, on the stack, for the life of a block, or on the heap. Firmware keeps almost everything in the first two, so its memory use is known before it ever runs.
Embedded C from Zero, Volume 09 showed where C puts its variables. C++ uses the same places. The new question is when an object's constructor runs, because that decides how much work start-up has to do.
| How the object is written | Where it lives | When it is made | When it is gone |
|---|---|---|---|
| Global, with a value known while compiling | .data or .rodata | Before any code runs | Never |
| Global, with a constructor that runs code | .bss, filled in by its constructor | By the start-up code, before main() |
Never, on a chip |
static inside a function |
.bss | The first time the function is called | Never, on a chip |
| Local variable | The stack | At its line | At the closing brace |
Made with new |
The heap | At the new |
At the delete |
The last row is the one firmware avoids. A heap can run out while the program runs, or become too fragmented to use, long after testing. Everything in this volume is about getting what the heap would give without using one.
A static local is made on the first call
// static_local.cpp - a static local object is made on the first call, and kept
#include <cstdint>
#include <cstdio>
class Calibration {
public:
Calibration() { std::printf(" Calibration constructed: reading the values from flash\n"); }
int16_t offset() const { return -12; }
};
static const Calibration &calibration() {
static Calibration c; // made on the first call only
return c;
}
int main() {
std::printf("main starts\n");
std::printf("first call:\n");
int16_t a = calibration().offset();
std::printf("second call:\n");
int16_t b = calibration().offset();
std::printf("offsets %d and %d\n", a, b);
return 0;
}
main starts
first call:
Calibration constructed: reading the values from flash
second call:
offsets -12 and -12
The object c was made during the first call, after main had started, and the second call found it
already there. To know whether it has been made yet, the compiler keeps a hidden flag beside it, called a
guard variable. The size harness looked at what that costs:
default build: text 131 bytes, calls __cxa_guard_acquire and __cxa_guard_release
with -fno-threadsafe-statics: text 90 bytes, calls neither
both builds keep: guard variable for config()::c
By default, the compiler makes the first call safe even if two threads make it at once. It does that by
calling two library functions around the constructor. A bare-metal program with no threads often switches
that off with -fno-threadsafe-statics, which saved 41 bytes here. Be careful, though: an interrupt that
calls the same function during the first call would then see a half-built object.
Putting a big array in a local variable. A 4 KB buffer inside a function lives on the stack, and a chip with a small stack will overflow it without any warning. Make big buffers static, or members of a static object, so the linker counts them and the map file shows them.
In static_local.cpp, when is the Calibration object constructed?
Show the answer
Answer: B. The output shows "main starts" and "first call:" before the constructor's message, and nothing more on the second call. A static local is made when the function first runs, and kept from then on.
6.2 std::array and fixed-size containers
std::array is a C array that knows its own size, can be copied, and never touches the heap. With it and a few fixed-capacity containers, firmware gets most of what the standard containers offer.
// array.cpp - std::array: a C array that knows its size and can be copied
#include <array>
#include <cstddef>
#include <cstdint>
#include <cstdio>
// A C array parameter is really a pointer: the size is lost on the way in.
static size_t count_c(const uint8_t *buffer, size_t length) {
size_t zeros = 0;
for (size_t i = 0; i < length; i++) {
zeros += buffer[i] == 0 ? 1u : 0u;
}
return zeros;
}
// A std::array keeps its size as part of its type.
template <size_t N>
static size_t count(const std::array<uint8_t, N> &buffer) {
size_t zeros = 0;
for (uint8_t b : buffer) {
zeros += b == 0 ? 1u : 0u;
}
return zeros;
}
int main() {
uint8_t c_frame[8] = {1, 0, 2, 0, 3, 0, 4, 5};
std::array<uint8_t, 8> frame = {1, 0, 2, 0, 3, 0, 4, 5};
std::printf("C array: %zu zeros, and the length had to be passed separately\n", count_c(c_frame, 8));
std::printf("std::array: %zu zeros, and frame.size() is %zu\n", count(frame), frame.size());
std::printf("sizeof frame = %zu: no hidden extra data\n", sizeof frame);
std::array<uint8_t, 8> copy = frame; // a std::array can be copied with =
copy[0] = 99;
std::printf("after changing the copy: frame[0] = %u, copy[0] = %u\n", static_cast<unsigned>(frame[0]),
static_cast<unsigned>(copy[0]));
return 0;
}
C array: 3 zeros, and the length had to be passed separately
std::array: 3 zeros, and frame.size() is 8
sizeof frame = 8: no hidden extra data
after changing the copy: frame[0] = 1, copy[0] = 99
A std::array<uint8_t, 8> is exactly 8 bytes: the size lives in the type, not in the object. It can be
passed by reference with its size intact, and copied with =, which a C array cannot do.
The C habit it replaces is a real source of bugs. An array parameter in C is quietly a pointer, so
sizeof inside the function measures the pointer. The course's flags catch it:
// sizeof_param.cpp - the C mistake: sizeof on an array parameter
#include <cstddef>
#include <cstdint>
size_t frame_length(const uint8_t frame[8]) {
return sizeof frame;
}
sizeof_param.cpp: In function 'size_t frame_length(const uint8_t*)':
sizeof_param.cpp:6:19: error: 'sizeof' on array function parameter 'frame' will return size of 'const uint8_t*' {aka 'const unsigned char*'} [-Werror=sizeof-array-argument]
A std::array can also check an index while compiling. The call std::get<4>(frame) asks for element 4
of a 4-element array, and the library's own static_assert refuses it:
// get_bounds.cpp - std::get checks the index while compiling
#include <array>
#include <cstdint>
uint8_t fifth(const std::array<uint8_t, 4> &frame) {
return std::get<4>(frame);
}
get_bounds.cpp:6:23: required from here
/usr/include/c++/15/array:412:26: error: static assertion failed: array index is within bounds
The error is reported from inside the library's own header, array, and the "required from here" line
points back at the call that caused it.
A container that grows, with no heap
A std::vector grows by asking the heap for more room. A fixed-capacity vector gives the same "add to the end"
idea inside storage whose size is fixed while compiling:
// fixed_vector.cpp - a vector with a fixed capacity: grows and shrinks, never allocates
#include <array>
#include <cstddef>
#include <cstdint>
#include <cstdio>
template <typename T, size_t Capacity>
class FixedVector {
public:
bool push_back(const T &item) {
if (count_ == Capacity) {
return false; // full: the caller must deal with it
}
items_[count_] = item;
count_++;
return true;
}
void clear() { count_ = 0; }
size_t size() const { return count_; }
static constexpr size_t capacity() { return Capacity; }
const T *begin() const { return items_.data(); }
const T *end() const { return items_.data() + count_; }
private:
std::array<T, Capacity> items_{};
size_t count_ = 0;
};
struct Event {
uint32_t time_ms;
uint8_t code;
};
int main() {
FixedVector<Event, 3> log;
for (uint8_t code = 1; code <= 4; code++) {
bool ok = log.push_back(Event{code * 100u, code});
std::printf("push event %u: %s\n", static_cast<unsigned>(code), ok ? "stored" : "no room");
}
for (const Event &e : log) {
std::printf("event %u at %u ms\n", static_cast<unsigned>(e.code), static_cast<unsigned>(e.time_ms));
}
std::printf("%zu of %zu used, and the whole log is %zu bytes, fixed while compiling\n", log.size(),
log.capacity(), sizeof log);
return 0;
}
push event 1: stored
push event 2: stored
push event 3: stored
push event 4: no room
event 1 at 100 ms
event 2 at 200 ms
event 3 at 300 ms
3 of 3 used, and the whole log is 32 bytes, fixed while compiling
The fourth push was refused, and push_back said so by returning false, rather than asking for memory
that might not be there. Because the class has begin() and end(), the range-for loop works on it just as
it does on a std::array. You rarely need to write containers like this yourself: the Embedded Template
Library, a free open-source library, provides fixed-capacity versions of vector, deque, map and string.
What does sizeof give for a std::array<uint8_t, 8>?
Show the answer
Answer: A. The program printed "sizeof frame = 8". The size 8 is part of the type, known while compiling, so the object stores only the eight bytes. A C array parameter, by contrast, is really a pointer.
6.3 Placement new
Placement new builds an object in memory you have already set aside. It lets firmware reserve room for an object while compiling, but run its constructor later, when the hardware or settings it needs are ready.
A global object's constructor runs before main. Sometimes that is too early: the UART's baud rate may
come from settings in flash that are only read part-way through start-up. Placement new separates the two
steps - the memory is reserved while compiling, and the object is made when you choose:
// placement_new.cpp - making an object later, in memory set aside at compile time
#include <cstdint>
#include <cstdio>
#include <new>
class Uart {
public:
explicit Uart(uint32_t baud) : baud_(baud) { std::printf("Uart constructed at %u baud\n", static_cast<unsigned>(baud_)); }
~Uart() { std::printf("Uart destroyed\n"); }
uint32_t baud() const { return baud_; }
private:
uint32_t baud_;
};
// Room for one Uart, correctly aligned, reserved while compiling. Nothing is constructed yet.
alignas(Uart) static unsigned char uart_storage[sizeof(Uart)];
static Uart *console = nullptr;
static uint32_t read_baud_from_settings() { return 57600; } // standing in for flash or EEPROM
int main() {
std::printf("start-up: clocks and settings are not ready yet\n");
uint32_t baud = read_baud_from_settings();
console = new (uart_storage) Uart(baud); // placement new: construct it here, now
std::printf("console runs at %u baud, stored inside uart_storage: %s\n",
static_cast<unsigned>(console->baud()),
static_cast<void *>(console) == static_cast<void *>(uart_storage) ? "yes" : "no");
console->~Uart(); // the destructor must be called by hand
console = nullptr;
return 0;
}
start-up: clocks and settings are not ready yet
Uart constructed at 57600 baud
console runs at 57600 baud, stored inside uart_storage: yes
Uart destroyed
Three details make it work:
alignas(Uart)lines the buffer up on the same boundary aUartneeds. Embedded C's alignment rules apply to objects built by hand too.sizeof(Uart)makes the buffer exactly big enough, worked out by the compiler.new (uart_storage) Uart(baud)runs the constructor on that memory. No heap is involved: this form ofnewonly builds, it never allocates.
The object is not destroyed by itself, because the compiler does not know it exists. The destructor has to
be called by hand, as the last lines do. Volume 07's std::optional does all of this for you, safely, and
is usually the better choice; placement new is what it uses underneath.
Building a second object in the same storage without destroying the first, or forgetting alignas. The
first skips a destructor, and the second can put a 4-byte value on an odd address, which some chips cannot
read at all. If placement new appears more than once or twice in a project, wrap it in a class, or use
std::optional.
What does new (uart_storage) Uart(baud) do?
Show the answer
Answer: C. Placement new only constructs. The memory was reserved while compiling, and the output shows the object sits inside uart_storage. Because nothing was allocated, nothing is freed; the destructor is called by hand.
6.4 Living without exceptions and RTTI
With exceptions switched off, a function reports a problem in its return value. Library code that would have thrown does not quietly carry on: it ends the program. And without RTTI, a class answers "what kind are you?" through its own virtual function.
Reporting errors with values
// status.cpp - reporting errors without exceptions
#include <cstdint>
#include <cstdio>
// Every way a reading can go wrong, as a named value. (enum class is covered in Volume 07.)
enum class Status : uint8_t { ok, timeout, bad_checksum };
static const char *text(Status s) {
switch (s) {
case Status::ok:
return "ok";
case Status::timeout:
return "timeout";
case Status::bad_checksum:
return "bad checksum";
}
return "unknown";
}
// The answer goes out through a reference; the return value says whether it can be trusted.
static Status read_temperature(bool sensor_answers, bool checksum_good, int16_t &celsius) {
if (!sensor_answers) {
return Status::timeout;
}
if (!checksum_good) {
return Status::bad_checksum;
}
celsius = 25;
return Status::ok;
}
int main() {
int16_t celsius = 0;
Status s = read_temperature(true, true, celsius);
std::printf("good sensor: %s, %d C\n", text(s), celsius);
s = read_temperature(false, true, celsius);
std::printf("no answer: %s\n", text(s));
s = read_temperature(true, false, celsius);
std::printf("damaged frame: %s\n", text(s));
return 0;
}
good sensor: ok, 25 C
no answer: timeout
damaged frame: bad checksum
This is the C way of reporting an error code, made safer: the codes have names
and their own type, so a Status cannot be confused with a temperature. Volume 07 adds two refinements:
[[nodiscard]], which makes ignoring the status a warning, and std::optional, which returns a value that
may be missing.
What happens when the library would have thrown
The flag -fno-exceptions switches exceptions off in your code. The standard library was built separately, and some
of its functions still throw - for example at(), the checked way to index a std::array. With nothing to
catch the exception, the program ends. The size harness ran exactly that case:
the program printed: asking for element 10 of 4
then the library printed:
terminate called after throwing an instance of 'std::out_of_range'
what(): array::at: __n (which is 10) >= _Nm (which is 4)
exit status 134: stopped by SIGABRT
The library called std::terminate, which aborted the program. On a
microcontroller that usually ends in a fault handler or an endless loop. So in a build without exceptions,
check an index yourself before using [], or use std::get when the index is known while compiling - do not
rely on at().
Asking "what kind?" without RTTI
A dynamic_cast needs RTTI, which Volume 00 switched off. When code really must know the kind of an object,
the class can say so itself:
// kind.cpp - asking "what kind of sensor?" without RTTI
#include <cstdint>
#include <cstdio>
enum class SensorKind : uint8_t { thermometer, barometer };
class Sensor {
public:
virtual ~Sensor() = default;
virtual SensorKind kind() const = 0; // the class answers for itself: no RTTI needed
virtual int read() const = 0;
};
class Thermometer : public Sensor {
public:
SensorKind kind() const override { return SensorKind::thermometer; }
int read() const override { return 25; }
};
class Barometer : public Sensor {
public:
SensorKind kind() const override { return SensorKind::barometer; }
int read() const override { return 1013; }
};
int main() {
Thermometer t;
Barometer b;
const Sensor *sensors[] = {&t, &b};
for (const Sensor *s : sensors) {
if (s->kind() == SensorKind::thermometer) {
std::printf("a thermometer, reading %d C\n", s->read());
} else {
std::printf("not a thermometer, reading %d\n", s->read());
}
}
return 0;
}
a thermometer, reading 25 C
not a thermometer, reading 1013
It costs one entry in each vtable, instead of RTTI's type information for every class. Often, though, the
better fix is to ask why the caller needs to know the kind at all. If every sensor had a report()
function, the loop would not need the if.
In a build with -fno-exceptions, what happens when std::array::at() is given an index that is too big?
Show the answer
Answer: D. The harness saw "terminate called after throwing an instance of 'std::out_of_range'" and exit status 134. The flag only affects your own code. The library's at() still throws, and with no handler the program is aborted.
6.5 Static initialisation order
Globals in different files are set up in an order the language does not fix. A global that uses another file's global may see it before it is ready. The cure is to set values up on first use, or while compiling.
Before main runs, the start-up code copies .data, zeroes .bss, and then runs the start-up code for each
global that needs it, one file at a time. Within one file the order is the order of the source. Between
files the C++ standard does not say which comes first. This is known as the
static initialisation order problem. Here it is, in two files:
// init_order.cpp - a global object that uses a global from another file
#include <cstdint>
#include <cstdio>
extern uint32_t configured_baud; // defined in init_order_part1.cpp
class Uart {
public:
explicit Uart(uint32_t baud) : baud_(baud) { std::printf("Uart constructed at %u baud\n", static_cast<unsigned>(baud_)); }
uint32_t baud() const { return baud_; }
private:
uint32_t baud_;
};
Uart console(configured_baud); // which is set up first: this, or configured_baud?
int main() {
std::printf("in main: configured_baud = %u, console runs at %u baud\n",
static_cast<unsigned>(configured_baud), static_cast<unsigned>(console.baud()));
return 0;
}
// init_order_part1.cpp - the other file: a global set up by calling a function
#include <cstdint>
#include <cstdio>
static uint32_t choose_baud() { // not constexpr, so it runs at start-up
std::printf("choose_baud() runs\n");
return 115200;
}
uint32_t configured_baud = choose_baud();
Uart constructed at 0 baud
choose_baud() runs
in main: configured_baud = 115200, console runs at 0 baud
In this build, the first file's globals were set up first. So console read configured_baud while it
still held 0, and kept that 0 for good. Every global starts as zero before its start-up code runs. By the time
main looked, configured_baud was 115200, which makes the bug confusing to find. Link the files in the
other order, and it may vanish, only to come back when someone reorders the build.
Figure 6.1 shows the start-up steps.
Fix 1: set it up on first use
Put the value inside a function, as a static local. The first call sets it up, whenever that call happens:
// first_use.cpp - the fix: set the value up the first time it is asked for
#include <cstdint>
#include <cstdio>
extern uint32_t &configured_baud(); // defined in first_use_part1.cpp
class Uart {
public:
explicit Uart(uint32_t baud) : baud_(baud) { std::printf("Uart constructed at %u baud\n", static_cast<unsigned>(baud_)); }
uint32_t baud() const { return baud_; }
private:
uint32_t baud_;
};
Uart console(configured_baud()); // asking for the value makes sure it is ready
int main() {
std::printf("in main: console runs at %u baud\n", static_cast<unsigned>(console.baud()));
return 0;
}
// first_use_part1.cpp - the value lives inside a function, and is set up on the first call
#include <cstdint>
#include <cstdio>
static uint32_t choose_baud() {
std::printf("choose_baud() runs\n");
return 115200;
}
uint32_t &configured_baud() {
static uint32_t baud = choose_baud(); // runs on the first call, and only then
return baud;
}
choose_baud() runs
Uart constructed at 115200 baud
in main: console runs at 115200 baud
Same file order, same start-up, right answer: console's constructor asked for the value, and asking made
it ready. The cost is the guard from the first sub-module.
Fix 2: set it up while compiling
When the value is known while compiling, there is a better fix. A value worked out by the compiler needs no start-up code at all, so it is ready before anything runs. The C++20 keyword constinit makes sure of that, and refuses anything that would need start-up code:
// constinit.cpp - constinit refuses a value that would need code at start-up
#include <cstdint>
uint32_t choose_baud();
constinit uint32_t fixed_baud = 115200; // fine: a constant, stored ready in the program
constinit uint32_t configured_baud = choose_baud(); // refused: this would run at start-up
constinit.cpp:7:20: error: 'constinit' variable 'configured_baud' does not have a constant initializer
constinit.cpp:7:49: error: call to non-'constexpr' function 'uint32_t choose_baud()'
A global that other files use should be constinit or constexpr, so it is ready before anything runs. If it really must be worked out at start-up, hide it in a function with a static local, and make everyone call the function.
Why did console in init_order.cpp end up at 0 baud?
Show the answer
Answer: B. The output shows "Uart constructed at 0 baud" before "choose_baud() runs". Every global starts as zero; the other file's start-up code had not run yet. The standard does not fix the order between files.
What you learned
- Firmware keeps objects in static storage and on the stack, so memory use is known before it runs.
- A static local is made on the first call, behind a guard; -fno-threadsafe-statics makes the guard cheaper.
std::arrayknows its size, copies with=, costs nothing extra, and never uses the heap.- A fixed-capacity container refuses to grow past its capacity instead of allocating.
- Placement new builds an object in reserved memory, later, and its destructor is called by hand.
- Without exceptions, return a status; library functions that would throw end the program instead.
- Globals in different files start up in no fixed order: use constinit, or a static local on first use.
Key words from this volume
Every word below has a plain-English entry in the glossary.
- .data
- .rodata
- .bss
- Heap
- Fragmentation
- Guard variable
- Stack overflow
- Placement new
- Alignment
- Error code
- std::terminate
- Static initialisation order
- constinit
Practice
How big is it?
An Event is a uint32_t time and a uint8_t code. How many bytes is a FixedVector<Event, 16>, built as
in fixed_vector.cpp, on the course's 64-bit PC? And a FixedVector<uint16_t, 10>?
Show the solution
Event: 8 bytes
FixedVector<Event, 16>: 136 bytes
FixedVector<uint16_t, 10>: 32 bytes
An Event is 5 bytes of data padded to 8, so 16 of them take 128 bytes, and the 8-byte count makes 136. Ten
uint16_t take 20 bytes; the count must start on an 8-byte boundary, so 4 bytes of padding come first,
then the count: 32 in all. The point is that each is known while compiling - sizeof tells you, and the
map file adds it up.
Fix the start-up order
A project has uint32_t sample_rate_hz = read_rate_setting(); in one file, and a global
Adc adc(sample_rate_hz); in another. On some builds the ADC runs at the wrong rate. Give two fixes, and say
when each is the right one.
Show the solution
The ADC's constructor may run before read_rate_setting() has, and then it reads 0, just as console did in
init_order.cpp.
If the rate is really fixed, make it constinit uint32_t sample_rate_hz = 48000; or constexpr. It is then
ready before any start-up code runs, and constinit refuses a value that would need start-up code.
If it must be read at start-up, move it into a function with a static local, as first_use.cpp does, and
have the ADC call that function. The value is then set up the first time anyone asks for it, whichever file
asks first.
Where should it live?
A driver needs a 2 KB receive buffer. Where should it go: a local array in the receive function, a global,
a static member, or new at start-up? Why?
Show the solution
A global, or a static member of the driver class - static storage either way. The linker then counts it,
the map file shows it, and the build fails if RAM runs out, instead of the chip failing later. A 2 KB local
array lives on the stack, which is often only a few KB on a small chip, and overflows silently. Using new
brings in the heap, which firmware avoids. If the buffer's size depends on a setting, use a class template
with the size as a parameter, so it is still fixed while compiling.
Interview corner
C++ without a heap
"How do you use C++ in firmware without a heap?"
Show the solution
"I keep objects in static storage or on the stack, so every byte is known at link time. I use std::array and fixed-capacity containers instead of vector and string, and static objects or placement new instead of new, with std::optional for objects built later. Errors come back as status values, because exceptions are off. And I check the map file, since the linker will tell me if RAM runs out."
The static initialisation order problem
"What is the static initialisation order problem, and how do you avoid it?"
Show the solution
"Globals whose values need start-up code are set up file by file, and the standard does not say which file goes first. So a global in one file can use another file's global while it still holds zero. I avoid it by making shared globals constexpr or constinit, so they need no start-up code. When a value must be worked out at start-up, I put it in a function with a static local, so it is ready the first time anyone asks."
at() without exceptions
"What does std::array::at() do with a bad index if the firmware is built with -fno-exceptions?"
Show the solution
"It still ends the program. The flag only affects my code; the library's at() throws std::out_of_range anyway, nothing can catch it, and std::terminate aborts. On a chip that usually means a fault handler or an endless loop. So I check indexes myself before using the square brackets, or use std::get when the index is known while compiling."