In embedded firmware systems, dynamic memory allocation is often forbidden due to fragmentation risks and nondeterministic behavior.
Despite this restriction, firmware frequently requires runtime polymorphism to select and execute different driver implementations through a common interface.
You are given a small firmware-style driver framework with a base driver interface and two concrete drivers.
Your task is to implement runtime polymorphism without using heap allocation, ensuring safe and deterministic object lifetime.
You must select the correct driver at runtime and invoke its behavior through a base-class interface, while keeping all objects stack-allocated.
Input / Program Flow
Driver selection rules:
0 → select the SPI driver1 → select the I2C driverProgram flow:
Output
If input is 0, output:
SPI transfer started
If input is 1, output:
I2C transfer started
Output Requirements
Constraints
new, delete, malloc, or free
Polymorphism ("Many Forms") allows you to treat different objects (like TempSensor, PressureSensor) as a single generic type (Sensor). It decouples the Interface (what a function does) from the Implementation (how it does it).
In Embedded C++, it comes in two flavors:
virtual functions. Flexible, but has slight overhead.1. Virtual Functions
The virtual keyword tells the compiler: "Don't bind this function call yet. Wait until the program runs to see what type of object this actually is."
class Protocol {
public:
// Virtual: Can be overridden by children
virtual void send(uint8_t data) {
printf("Generic Send\n");
}
};
class UART : public Protocol {
public:
// Override: Replace generic logic with UART logic
void send(uint8_t data) override {
UART_HW->DR = data;
}
};
class SPI : public Protocol {
public:
void send(uint8_t data) override {
SPI_HW->DR = data;
}
};
// The Magic: This function works for UART, SPI, I2C, etc.
void transmit_data(Protocol* p, uint8_t byte) {
p->send(byte); // Calls the specific version automatically
}
2. Abstract Classes (Interfaces)
In HAL design, the Base class often has no logic—it just defines the API. We use Pure Virtual Functions (= 0) to enforce this.
A class with pure virtual functions cannot be instantiated.
// Interface (Contract)
class IDigitalOut {
public:
virtual void write(bool state) = 0; // Pure Virtual
virtual ~IDigitalOut() {} // Virtual Destructor (Required)
};
// Implementation
class GpioPin : public IDigitalOut {
public:
void write(bool state) override {
// Hardware specific code
}
};Dynamic polymorphism isn't magic; it uses a lookup table.
vptr) pointing to its class's V-Table.| Structure | Cost (Overhead) |
|---|---|
| RAM | 4 bytes per object (for the vptr). |
| Flash | One table per Class (not per object). |
| CPU | 2-3 extra cycles (Fetch pointer -> Jump). |
1. Function Overloading
Same function name, different arguments. Resolved at compile time.
void log(int i);
void log(char* s);
// Compiler picks the right one. Zero overhead.
2. Templates (CRTP)
Used when you want polymorphic behavior (generic drivers) but cannot afford the RAM cost of V-Tables.
template <typename T>
void send_byte(T& driver, uint8_t b) {
driver.send(b); // Resolved at compile time!
}
1. Hardware Abstraction Layer (HAL)
This is the standard use case. You define an ISerial interface. Your application uses ISerial.
STM32UART.NordicUART.2. Heterogeneous Collections
You can iterate over a list of different devices.
// Array of pointers to the Base class
Sensor* sensors[] = { &temp, &imu, &battery };
for (auto* s : sensors) {
s->read(); // Calls Temp::read(), then IMU::read()...
}3. Testability (Mocking)
Polymorphism is essential for Unit Testing on a PC.
You pass a MockGPIO (which just prints to screen) into your driver instead of a real RealGPIO (which touches registers).
| Pitfall | Details |
|---|---|
| ❌ Missing Virtual Destructor | CRITICAL: If a Base class has virtual functions, the Destructor must be virtual. If not, delete base_ptr will not call the Derived destructor, causing resource leaks. |
| ❌ Object Slicing | Polymorphism only works with Pointers (
|
❌ override Keyword | Always use override in derived classes. If you accidentally change the signature (send(int) vs send(uint8_t)), override triggers a compile error. Without it, you create a generic bug. |
| ❌ Calling Virtual in Constructor | Never call a virtual function inside a Constructor or Destructor. The object is not fully formed yet, so it will call the Base version, not the Derived version. |