RAII: Cleanup You Cannot Forget
Most firmware engineers have met a function with an early return that forgot to give something back: a bus lock, a chip select, interrupts left off. RAII puts the giving back in a destructor, so the compiler runs it on every way out. This volume builds guards for interrupts, chip selects and clocks, measures what they cost, and ends with the rule that keeps them safe.
- Why cleanup is forgotten on early returns in C, and how a destructor ends that for good
- How to write an interrupt lock that saves and restores the old state, and nests safely
- Guard objects for chip selects and peripheral clocks
- Two ways a guard can silently do nothing, and how to spot them
- What a copy does to a class that gives something back, and the rule of zero, three and five
- Volume 02 of this course: constructors, destructors and static members.
- Critical sections, from Embedded C from Zero's interrupts volume.
3.1 Resources that clean up after themselves
Take a resource in a constructor and give it back in the destructor. Then it is given back on every way out of the scope, because the compiler puts the destructor call at each one. That is RAII.
Firmware is full of things that must be given back. A lock on a shared bus, interrupts that were switched
off, a chip-select line held low and a peripheral clock switched on are all examples. In C, every way out
of a function has to remember to give them back. A function with three return statements has three chances to forget.
// raii_basics.cpp - a cleanup that every path out of a function runs
#include <cstdio>
// A shared bus, and a count of who holds it: 0 means free.
static int bus_holders = 0;
static void bus_lock() { bus_holders++; }
static void bus_unlock() { bus_holders--; }
// The C way: every way out of the function must remember to unlock.
static int read_sensor_c(int channel) {
bus_lock();
if (channel > 3) {
return -1; // this path forgets bus_unlock()
}
int value = channel * 100;
bus_unlock();
return value;
}
// The C++ way: the unlock lives in a destructor, and every way out runs it.
class BusLock {
public:
BusLock() { bus_lock(); }
~BusLock() { bus_unlock(); }
};
static int read_sensor(int channel) {
BusLock lock;
if (channel > 3) {
return -1; // the destructor runs here too
}
return channel * 100;
}
// The call has finished before report() runs, so bus_holders is read afterwards.
static void report(const char *who, int channel, int result) {
std::printf("%-4s channel %d: returned %4d, bus holders afterwards %d\n", who, channel, result, bus_holders);
}
int main() {
report("C", 2, read_sensor_c(2));
report("C", 9, read_sensor_c(9));
bus_holders = 0;
report("C++", 2, read_sensor(2));
report("C++", 9, read_sensor(9));
return 0;
}
C channel 2: returned 200, bus holders afterwards 0
C channel 9: returned -1, bus holders afterwards 1
C++ channel 2: returned 200, bus holders afterwards 0
C++ channel 9: returned -1, bus holders afterwards 0
The C version works on the good channel and leaks on the bad one. After channel 9 the bus is still held, and the next piece of code that wants it will wait for ever. The C++ version gives the bus back both times.
The class BusLock is a guard object. Its constructor takes the lock and its
destructor gives it back. The function read_sensor never mentions unlocking at all. It makes a BusLock,
and the compiler places the destructor call at both return statements, as Volume 02 showed with Trace.
The name RAII stands for "resource acquisition is initialisation": getting the resource is part of making
the object.
Think of a door with a spring that closes it behind you. You cannot forget to shut it, however you leave the room - walking out calmly, or running out because something went wrong. A guard object is that spring.
What a guard costs
The size harness compiled a correct C version, with an unlock written on each path, and the RAII version of the same function. The RAII version came out smaller: 39 bytes against 44. The compiler saw that both ways out end with the same unlock, so it wrote one call and jumped to it from both paths. With exceptions switched off, nothing else is added.
The two functions, and their machine code
int read_locked_c(int channel) {
bus_lock();
if (channel > 3) {
bus_unlock();
return -1;
}
int value = read_register(channel);
bus_unlock();
return value;
}
class BusLock {
public:
BusLock() { bus_lock(); }
~BusLock() { bus_unlock(); }
};
int read_locked_raii(int channel) {
BusLock lock;
if (channel > 3) {
return -1;
}
return read_register(channel);
}
read_locked_c(int):
push %rbx
mov %edi,%ebx
call bus_lock()
cmp $0x3,%ebx
jg <+0x20>
mov %ebx,%edi
call read_register(int)
mov %eax,%ebx
call bus_unlock()
mov %ebx,%eax
pop %rbx
ret
nop
call bus_unlock()
mov $0xffffffff,%ebx
jmp <+0x1b>
read_locked_raii(int):
push %rbx
mov %edi,%ebx
call bus_lock()
cmp $0x3,%ebx
jg <+0x20>
mov %ebx,%edi
call read_register(int)
mov %eax,%ebx
call bus_unlock()
mov %ebx,%eax
pop %rbx
ret
nop
mov $0xffffffff,%ebx
jmp <+0x16>
-> not the same: 44 bytes and 39 bytes
Follow the bad channel in each. In read_locked_c, jg <+0x20> jumps to its own call bus_unlock(), then
jumps back to the shared return. In read_locked_raii, the jump lands on the one call bus_unlock() that
the good path uses too.
Giving the guard no name. The line BusLock(); makes a temporary
object, which is destroyed at the end of that very statement. That is before the work it was meant
to protect, and it compiles without a warning:
// temporary.cpp - a guard with no name lives for one line only
#include <cstdio>
class BusLock {
public:
BusLock() { std::printf(" lock taken\n"); }
~BusLock() { std::printf(" lock released\n"); }
};
static void with_a_name() {
BusLock lock;
std::printf(" using the bus\n");
}
static void without_a_name() {
BusLock(); // made, and destroyed again, on this one line
std::printf(" using the bus\n");
}
int main() {
std::printf("with a name:\n");
with_a_name();
std::printf("without a name:\n");
without_a_name();
return 0;
}
with a name:
lock taken
using the bus
lock released
without a name:
lock taken
lock released
using the bus
The opposite slip, empty brackets after the name, is caught. The line BusLock lock(); is read as the
declaration of a function called lock. This is the most vexing parse,
and g++ warns about it:
// vexing.cpp - empty brackets after the name
class BusLock {
public:
BusLock();
~BusLock();
};
void use_the_bus() {
BusLock lock();
}
vexing.cpp: In function 'void use_the_bus()':
vexing.cpp:9:17: error: empty parentheses were disambiguated as a function declaration [-Werror=vexing-parse]
vexing.cpp:9:17: note: remove parentheses to default-initialize a variable
In raii_basics.cpp, why is the bus free after read_sensor(9) returns early?
Show the answer
Answer: C. The guard object lock is destroyed on every way out of the function, and its destructor calls
bus_unlock(). The C version has no destructor, so its early return left the bus held: the output shows 1
holder afterwards.
3.2 Scoped interrupt locks
An interrupt lock is a guard object for a critical section. When it is made, it saves the interrupt state and switches interrupts off. When it is destroyed, it puts the saved state back, so locks can nest and no path can leave interrupts off.
Embedded C from Zero, Volume 11 wrote a critical section by hand: save the state and disable interrupts, do the work, restore the state. It also warned against ending with a blind "enable interrupts", which switches them on underneath a caller who had them off. Here is that rule, built into a class, with the blind version beside it for comparison.
// irq_lock.cpp - a critical section that cannot be left open, and nests safely
#include <cstdint>
#include <cstdio>
// The chip's interrupt mask, faked for the PC: 1 means interrupts are off.
// On an Arm Cortex-M chip these four are CMSIS's __get_PRIMASK(), __set_PRIMASK(),
// __disable_irq() and __enable_irq().
static uint32_t primask = 0;
static uint32_t get_primask() { return primask; }
static void set_primask(uint32_t value) { primask = value; }
static void disable_irq() { primask = 1; }
static void enable_irq() { primask = 0; }
// Save the old state, switch interrupts off, and put the old state back at the end.
class InterruptLock {
public:
InterruptLock() : saved_(get_primask()) { disable_irq(); }
~InterruptLock() { set_primask(saved_); }
InterruptLock(const InterruptLock &) = delete; // no copies: see 3.4
InterruptLock &operator=(const InterruptLock &) = delete;
private:
uint32_t saved_;
};
// The mistake: switch interrupts back on at the end, whatever they were before.
class BlindLock {
public:
BlindLock() { disable_irq(); }
~BlindLock() { enable_irq(); }
};
static uint32_t total = 0;
static void add_reading(uint32_t reading) {
InterruptLock lock;
total = total + reading;
}
static void add_reading_blind(uint32_t reading) {
BlindLock lock;
total = total + reading;
}
static const char *irq() { return get_primask() ? "off" : "on"; }
int main() {
std::printf("at the start: interrupts %s\n", irq());
add_reading(10);
std::printf("after add_reading: interrupts %s\n", irq());
{
InterruptLock outer;
std::printf("outer lock taken: interrupts %s\n", irq());
add_reading(20);
std::printf("after a nested InterruptLock: interrupts %s\n", irq());
add_reading_blind(30);
std::printf("after a nested BlindLock: interrupts %s\n", irq());
}
std::printf("after the outer lock: interrupts %s\n", irq());
std::printf("total = %u\n", static_cast<unsigned>(total));
return 0;
}
at the start: interrupts on
after add_reading: interrupts on
outer lock taken: interrupts off
after a nested InterruptLock: interrupts off
after a nested BlindLock: interrupts on
after the outer lock: interrupts on
total = 60
On an Arm Cortex-M chip, the interrupt switch is a one-bit register called PRIMASK. The fake functions here have the same jobs as the CMSIS ones named in the comment. The class keeps the rule from the C course in two lines:
- The constructor reads the old state into
saved_before switching interrupts off. - The destructor puts
saved_back. It does not simply switch interrupts on.
The fifth line of the output is the bug that rule prevents. Inside the outer lock, add_reading took and
released its own lock, and interrupts stayed off, because the old state was "off". Then add_reading_blind
switched them on at its end - while the outer block still believed it was protected.
Figure 3.1 shows the same run over time.
The two lines ending in = delete forbid copying an InterruptLock. A copy would carry its own saved_,
and put the state back a second time. The last sub-module explains the rule behind this; for now, notice
that the compiler enforces it:
// irq_copy.cpp - trying to copy an InterruptLock
#include <cstdint>
class InterruptLock {
public:
InterruptLock();
~InterruptLock();
InterruptLock(const InterruptLock &) = delete;
InterruptLock &operator=(const InterruptLock &) = delete;
private:
uint32_t saved_;
};
void update() {
InterruptLock lock;
InterruptLock second = lock;
}
irq_copy.cpp: In function 'void update()':
irq_copy.cpp:17:28: error: use of deleted function 'InterruptLock::InterruptLock(const InterruptLock&)'
irq_copy.cpp:8:5: note: declared here
Everything inside an interrupt lock adds to the time other interrupts must wait. Keep the lock in the smallest block that needs it: a pair of braces around three lines is often enough.
Why does InterruptLock put back the saved state instead of simply switching interrupts on?
Show the answer
Answer: A. Locks nest. In the output, the nested InterruptLock left interrupts off, as the outer block needed. The BlindLock switched them on inside the outer block, which is the bug the saved state prevents.
3.3 Scoped peripheral enables
The same pattern fits any "switch on, use, switch off" job. A chip select can be held low for one transfer, and a peripheral's clock kept on for exactly as long as someone needs it.
A chip select that is always released
An SPI device listens only while its chip select is held low. Forget to raise it after a transfer - on an error path, say - and the device keeps listening, and the next transfer to a different device confuses both.
// chip_select.cpp - an SPI chip select that is released on every path out of a transfer
#include <cstdint>
#include <cstdio>
// A cut-down output pin, standing in for the OutputPin class from Volume 02.
class OutputPin {
public:
void set() { high_ = true; }
void clear() { high_ = false; }
bool is_set() const { return high_; }
private:
bool high_ = true;
};
// Chip select is active low: pulled low for the whole transfer, high again after it.
class ChipSelect {
public:
explicit ChipSelect(OutputPin &cs) : cs_(cs) { cs_.clear(); }
~ChipSelect() { cs_.set(); }
ChipSelect(const ChipSelect &) = delete;
ChipSelect &operator=(const ChipSelect &) = delete;
private:
OutputPin &cs_;
};
static OutputPin sensor_cs;
static bool sensor_present = true;
// A fake SPI exchange: a missing device answers 0xFF, as a floating data line would.
static uint8_t spi_exchange(uint8_t out) {
if (!sensor_present) {
return 0xFF;
}
std::printf(" exchanged 0x%02X while CS is %s\n", static_cast<unsigned>(out),
sensor_cs.is_set() ? "high" : "low");
return static_cast<uint8_t>(out + 1);
}
static int read_register(uint8_t reg) {
ChipSelect select(sensor_cs);
if (spi_exchange(reg) == 0xFF) {
return -1; // no device: leave early, and CS still goes high
}
return spi_exchange(0x00);
}
int main() {
int value = read_register(0x0F);
std::printf("device present: read %d, CS is now %s\n", value, sensor_cs.is_set() ? "high" : "low");
sensor_present = false;
value = read_register(0x0F);
std::printf("device missing: read %d, CS is now %s\n", value, sensor_cs.is_set() ? "high" : "low");
return 0;
}
exchanged 0x0F while CS is low
exchanged 0x00 while CS is low
device present: read 1, CS is now high
device missing: read -1, CS is now high
Both bytes of the first transfer went out while CS was low, and CS was high again afterwards. When the
device was missing, read_register gave up after the first exchange and returned early, and CS still went
high. The guard holds a reference to the pin, a use of references from Volume 01 that fits well: a chip
select always belongs to one pin, and cannot be null.
A clock that stays on while anyone needs it
Many chips save power by switching off the clock to each peripheral that is not in use. Switching it off too early is a bug, and so is leaving it on for ever. When several parts of the program share one peripheral, the answer is a guard with a reference count, kept in a static member as in Volume 02:
// clock_enable.cpp - a peripheral clock that stays on while anyone is using it
#include <cstdint>
#include <cstdio>
static uint32_t fake_clock_enable_reg = 0; // one bit per peripheral clock, as on most chips
constexpr uint32_t ADC_CLOCK = 1u << 8; // the bit for the ADC, in this made-up chip
class AdcClock {
public:
AdcClock() {
if (users_ == 0) {
fake_clock_enable_reg = fake_clock_enable_reg | ADC_CLOCK; // first user: clock on
}
users_++;
}
~AdcClock() {
users_--;
if (users_ == 0) {
fake_clock_enable_reg = fake_clock_enable_reg & ~ADC_CLOCK; // last user gone: clock off
}
}
AdcClock(const AdcClock &) = delete;
AdcClock &operator=(const AdcClock &) = delete;
private:
static inline uint32_t users_ = 0;
};
static const char *adc_clock() { return (fake_clock_enable_reg & ADC_CLOCK) ? "on" : "off"; }
static void check_battery() {
AdcClock clock;
std::printf(" check_battery: ADC clock %s\n", adc_clock());
}
static void log_temperature() {
AdcClock clock;
std::printf(" log_temperature: ADC clock %s\n", adc_clock());
check_battery(); // a second user, nested inside the first
std::printf(" back in log_temperature: ADC clock %s\n", adc_clock());
}
int main() {
std::printf("at the start: ADC clock %s\n", adc_clock());
log_temperature();
std::printf("afterwards: ADC clock %s\n", adc_clock());
return 0;
}
at the start: ADC clock off
log_temperature: ADC clock on
check_battery: ADC clock on
back in log_temperature: ADC clock on
afterwards: ADC clock off
The third line is the one a simpler guard would get wrong. When check_battery finished, its guard was
destroyed, but the clock stayed on, because log_temperature still had one. Only when the last user's
guard went did the clock switch off.
Making the guard at the top of a long function, when only a few lines need it. The chip select, the lock or the clock is then held for the whole function. Put the guard in the smallest block that needs it, and add a pair of braces to make that block if necessary.
In clock_enable.cpp, why is the ADC clock still on after check_battery returns?
Show the answer
Answer: B. Each AdcClock adds one to users_ and takes one away when destroyed. After check_battery's guard is gone the count is back to one - log_temperature's guard - so the destructor leaves the clock on. The count reaches zero only when that last guard is destroyed.
3.4 Rule of zero, three and five, simply
A class that gives something back in its destructor must decide what copying it means. In firmware the usual answer is the simplest one: copying is not allowed.
When you do not write a copy constructor, the compiler writes one that copies each member. For most classes that is exactly right. For a class that owns something, it is a bug waiting to happen:
// double_release.cpp - a class that releases something in its destructor, copied by accident
#include <cstdio>
// A tiny pool of four DMA channels, which reports a channel released twice.
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) {
if (!channel_in_use[ch]) {
std::printf("ERROR: channel %d released twice\n", ch);
return;
}
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_);
}
}
int number() const { return ch_; }
private:
int ch_;
};
static void log_channel(DmaChannel ch) { // by value: the caller's object is copied
std::printf("logging channel %d\n", ch.number());
}
int main() {
DmaChannel tx;
log_channel(tx);
std::printf("back in main\n");
return 0;
}
channel 0 claimed
logging channel 0
channel 0 released
back in main
ERROR: channel 0 released twice
Passing tx by value made a copy. The copy held the same channel number, and when log_channel returned,
the copy's destructor released channel 0 - while tx still believed it owned it. When main ended, tx
released it again. This is the DMA version of a double free, and in a real
driver the second release could take a channel away from somebody else.
The fix is to say what copying means. For a class like this, the right meaning is "not allowed", written
with = delete. A deleted function cannot be called, so the mistake now
stops the build:
// no_copy.cpp - the same mistake, once copying has been deleted
class DmaChannel {
public:
DmaChannel();
~DmaChannel();
DmaChannel(const DmaChannel &) = delete;
DmaChannel &operator=(const DmaChannel &) = delete;
int number() const { return ch_; }
private:
int ch_;
};
void log_channel(DmaChannel ch);
void send() {
DmaChannel tx;
log_channel(tx);
}
no_copy.cpp: In function 'void send()':
no_copy.cpp:18:16: error: use of deleted function 'DmaChannel::DmaChannel(const DmaChannel&)'
no_copy.cpp:14:29: note: initializing argument 1 of 'void log_channel(DmaChannel)'
The second line deleted is the copy assignment, operator=, which copies
one existing object over another. Deleting both closes both doors. And log_channel only needs to look at
the channel, so it should take a const reference, as Volume 01 advised. Then nothing is copied at all:
// by_reference.cpp - copying deleted, and the function takes a reference instead
#include <cstdio>
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;
DmaChannel &operator=(const DmaChannel &) = delete;
int number() const { return ch_; }
private:
int ch_;
};
static void log_channel(const DmaChannel &ch) { // a reference: nothing is copied
std::printf("logging channel %d\n", ch.number());
}
int main() {
DmaChannel tx;
log_channel(tx);
std::printf("back in main\n");
return 0;
}
channel 0 claimed
logging channel 0
back in main
channel 0 released
Three rules, in one table
| Rule | What it says | In firmware |
|---|---|---|
| Rule of zero | If every member looks after itself, write none of the special functions | Most classes: pins, timeouts, buffers built from std::array |
| Rule of three | If you write a destructor, decide about the copy constructor and copy assignment too | Guards and owners: delete both copies |
| Rule of five | The rule of three, plus the two move functions C++11 added | Owners that must be handed on, covered in Volume 07 |
The rule of zero is the one to aim for, and it works because the compiler builds a class's copying out of
its members' copying. A class that holds a DmaChannel cannot be copied either, without writing a single
line about it:
// rule_of_zero.cpp - a class that writes none of the special functions
#include <array>
#include <cstdint>
class DmaChannel {
public:
DmaChannel();
~DmaChannel();
DmaChannel(const DmaChannel &) = delete;
DmaChannel &operator=(const DmaChannel &) = delete;
private:
int ch_;
};
// Rule of zero: its members look after themselves, so it declares no special functions.
class UartReceiver {
private:
DmaChannel dma_;
std::array<uint8_t, 64> buffer_;
};
void start() {
UartReceiver rx;
UartReceiver copy = rx;
}
rule_of_zero.cpp: In function 'void start()':
rule_of_zero.cpp:25:25: error: use of deleted function 'UartReceiver::UartReceiver(const UartReceiver&)'
rule_of_zero.cpp:17:7: note: 'UartReceiver::UartReceiver(const UartReceiver&)' is implicitly deleted because the default definition would be ill-formed:
The class UartReceiver says nothing about copying, yet copying it is refused, because one of its members
cannot be copied. Its std::array member, on the other hand, would copy correctly on its own. Build classes
from members that already behave correctly, and the whole class behaves correctly with no extra code.
A class releases a DMA channel in its destructor, and has no other special functions. What goes wrong if an object is passed by value?
Show the answer
Answer: D. The compiler-written copy constructor copies the channel number, so two objects believe they own channel 0. Each destructor releases it, and double_release.cpp printed "released twice". Delete the copy functions, or pass a reference.
What you learned
- A guard object takes a resource in its constructor and gives it back in its destructor, on every way out.
- In the measurement, the RAII version of a locked function was smaller than the C one: 39 bytes against 44.
- A guard needs a name. Without one it is destroyed at the end of the line; with empty brackets it is a function.
- An interrupt lock saves the old state and puts it back, so locks nest and no path leaves interrupts off.
- Guards work for chip selects and, with a reference count, for clocks that several users share.
- A class that gives something back must decide about copying, and usually deletes it.
- Aim for the rule of zero: build classes from members that look after themselves.
Key words from this volume
Every word below has a plain-English entry in the glossary.
- Resource
- RAII
- Guard object
- Temporary object
- Most vexing parse
- Critical section
- PRIMASK
- Chip select
- Reference count
- Copy constructor
- Double free
- Deleted function
- Copy assignment
- Rule of zero
- Rule of three
- Rule of five
Practice
Find the leak
This C function adds a value to a small queue with interrupts off. One way out forgets to put interrupts
back. Find it, then rewrite the function with an InterruptLock.
static bool push_c(uint32_t value) {
uint32_t saved = get_primask();
disable_irq();
if (value == 0) {
set_primask(saved);
return false;
}
if (count == 4) {
return false; // the queue is full - and interrupts stay off
}
queue[count] = value;
count = count + 1;
set_primask(saved);
return true;
}
Show the solution
The second early return, taken when the queue is full, never calls set_primask. With a guard, no way out
needs to remember:
static bool push(uint32_t value) {
InterruptLock lock;
if (value == 0 || count == 4) {
return false;
}
queue[count] = value;
count = count + 1;
return true;
}
The course's test pushed 5, 0, 7, 8, 9 and 10 through both versions:
push_c( 5): true interrupts on
push_c( 0): false interrupts on
push_c( 7): true interrupts on
push_c( 8): true interrupts on
push_c( 9): true interrupts on
push_c(10): false interrupts off
push( 5): true interrupts on
push( 0): false interrupts on
push( 7): true interrupts on
push( 8): true interrupts on
push( 9): true interrupts on
push(10): false interrupts on
The fifth push filled the queue, so the sixth took the forgotten path, and the C version returned with interrupts off. The C++ version never can.
The lock that did nothing
A colleague protects a shared counter with InterruptLock(); on the first line of a function, then updates
the counter. Testing on the board shows the update is still sometimes interrupted. Why?
Show the solution
The line makes a temporary object with no name. Its constructor switches interrupts off, and its destructor
puts them back at the end of that same statement. That happens before the counter is touched. The program
temporary.cpp showed the same order: "lock taken", "lock released", then the work. Give the guard a name:
InterruptLock lock;.
Copy or not?
For each class, say whether copying should be allowed, and which rule applies.
- A
Readingstruct holding a timestamp and a temperature. - An
InterruptLock. - A
RxBufferclass holding astd::arrayand two indexes. - A
DmaChannelthat releases its channel in its destructor.
Show the solution
- Allowed - copies of plain data are exactly what you want. Rule of zero: write nothing.
- Not allowed - a copy would put the interrupt state back twice. Delete both copy functions.
- Allowed, if a copied buffer makes sense - every member copies correctly. Rule of zero.
- Not allowed - two objects would release one channel. Delete both copy functions, and pass references. Handing the channel on is a move, which Volume 07 covers.
Interview corner
What is RAII?
"What is RAII, and why does it matter in firmware?"
Show the solution
"RAII ties a resource to an object's lifetime: the constructor takes it and the destructor gives it back. The compiler runs the destructor on every way out of the scope, so early returns cannot leak the resource. In firmware I use it for interrupt locks, chip selects, bus locks and peripheral clocks. With exceptions switched off it costs no more than the release calls I would write by hand, and it can cost less."
A critical-section guard
"How would you write a critical-section guard for a Cortex-M?"
Show the solution
"A class whose constructor saves PRIMASK and then disables interrupts, and whose destructor writes the saved value back. It must not simply enable interrupts, because critical sections nest and the caller may already have had them off. I would delete the copy constructor and copy assignment, so a copy cannot restore the state twice. And I would keep each guard in the smallest block that needs it, because everything inside adds to interrupt latency."
The rule of three
"Explain the rule of three."
Show the solution
"If a class needs a user-written destructor, it almost certainly needs its copy constructor and copy assignment dealt with too. The compiler's versions copy members one by one, so two objects end up owning the same resource, and both destructors release it. The fix is to write proper copies or, far more often in firmware, to delete them. The rule of five adds the move functions, and the rule of zero says: build classes from members that manage themselves, and write none of these."