Modern C++ features like compile-time computations and conditional logic can achieve zero-overhead abstractions in embedded systems. While basic class encapsulation and inheritance introduce no extra cost, dynamic polymorphism (especially with RTTI) adds significant overhead. Leveraging consteval and if constexpr can even reduce binary size and improve performance.
文章核心来源于 CppOnSea 2025 大会上的一篇演讲, 题为 "Cost of Abstractions in Embedded C++". 这里作为这次演讲的一些内容总结.
嵌入式开发和PC上的软件开发有非常明显的不同, 很多时候嵌入式是在裸机 (Bare Metal) 环境下进行开发, 这意味着它没有操作系统的支持, 任何系统资源的管理都需要自己完成. 因此能选择的开发语言基本上就只有 C/C++, Rust 这类接近硬件层的语言了. 其次, 嵌入式系统的资源非常有限, 可能只有十几几十 KB 的Flash, 非常有限的 RAM, 以及相比 PC 端 CPU 频率慢得多的处理器. 所以代码除了编译出来的性能高低, 还要考虑程序的体积大小, 以及内存的使用情况.
一个比较符合惯性思维的是, 抽象层会引来开销 (Abstraction Overhead), 这在嵌入式开发中的影响程度会被放大, 所以一直以来 C++ 在嵌入式中大多也就是作为 C with Class 进行使用. 但随着 C++ 标准的提升, 一些模板元编程的特性允许 C++ 进行编译期计算, 同时保持相对较高的语义化程度, 让高层抽象能够做到零开销 (Zero Overhead), 甚至在演讲中的示例, 由于编译期计算, 甚至将性能和体积都进一步提升了.
总之, 这次演讲的核心内容是证明了新 C++ 标准内引入的一些特性 (主要是编译期计算特性) 确实能在嵌入式开发中带来零开销的高层抽象.
首先定义一个最小嵌入式工程:
实验的原始程序, 用 C 语言编写, 使用 -Os 针对大小进行优化, 构建后的二进制文件 text 段为 636 字节, bss 段为 1568 字节,
data 段为 0 字节, 总计 2204 字节.
HAL, Hardware Abstraction Layer, 硬件抽象层, 是嵌入式开发中常见的一种设计模式. 它通过定义一组接口来屏蔽底层硬件的差异, 让上层代码可以更方便地进行硬件操作. HAL 的设计目标是提高代码的可移植性和可维护性, 现在 HAL 大多用 C 语言来实现. 下面是一个示例, 初始化一个 GPIO 引脚:
typedef struct {
uint32_t pin;
GPIO_Modes mode;
} GPIO_InitStruct;
void GPIO_Init(GPIO_InitStruct *conf) {
uint32_t tmp;
/* check parameters */
if (!IS_GPIO_PIN(conf->pin)) { return; }
if (!IS_GPIO_MODE(conf->mode)) { return; }
/* configure mode */
if (conf->mode == GPIO_MODE_OUTPUT) {
tmp = OSPEEDR;
tmp &= ~(OSPEEDR_MASK << (conf->pin * 2u));
tmp |= (OSPEEDR_HIGH_SPEED << (conf->pin * 2u));
OSPEEDR = tmp;
/* ... */
}
/* ... */
}
int main(void) {
GPIO_InitStruct conf = { 0 };
conf.pin = GPIO_PIN_5;
conf.mode = GPIO_MODE_OUTPUT;
GPIO_Init(&conf);
while (1) {
/* ... */
}
}不难发现上面的 C 语言 HAL 代码仍然需要手动操作各种寄存器, 使用各种宏定义, 代码的语义化程度不高, 可读性和可维护性都不太好. 接着是一些用 C++ 进行封装的尝试.
首先可以将寄存器封装成一个类, 成员变量储存寄存器地址, 成员函数进行寄存器的读写操作:
class CRegister {
private:
const std::uint32_t m_address;
public:
CRegister(std::uint32_t address) : m_address(address) {}
void set(std::uint32_t value) {
*(reinterpret_cast<volatile std::uint32_t*>(m_address)) = value;
}
std::uint32_t get() const { /*... */ }
}这是一个简单的寄存器封装, 类比这个我们可以封装出模式寄存器等各种寄存器, 例如
class CModeRegister {
private:
const CRegister m_register { 0x48000000 };
[[always_inline]] std::uint32_t calculate_value(std::uint32_t pin, GPIO_Modes mode) const {
return (mode & GPIO_MODE) << (pin * 2u);
}
[[always_inline]] void calculate_bitmask(std::uint32_t pin) const {
return MODER_MASK << (pin * 2u);
}
public:
[[always_inline]] void set_mode(std::uint32_t pin, GPIO_Modes mode) {
m_register.set(calculate_value(pin, mode), calculate_bitmask(pin));
}
};需要注意的是, 这里并没有让 CModeRegister 直接继承 CRegister , 尽管这样似乎更符合 OOP 的设计思路, 但现在评估的是使用类进行封装的开销,
而继承可能又会引入额外的开销, 这会影响评估结果.
接着 GPIO 的初始化函数就可以变为
void GPIO_Init(GPIO_InitStruct *conf) {
/* ... */
if (conf->mode == GPIO_MODE_OUTPUT) {
COutSpeedRegister ospeedr;
ospeedr.set_speed(conf->pin, conf->speed);
COutputTypeRegister otyper;
otyper.set_type(conf->pin, conf->type);
}
CModeRegister moder;
moder.set_mode(conf->pin, conf->mode);
/* ... */
}接着进行测试. 直接在单片机上测试是没办法的, 裸机环境下要进行测量非常麻烦. 而嵌入式的代码又不能直接编译后在 PC 上运行, 因为 PC 上的操作系统不允许直接访问硬件寄存器, 更何况还有 MMU 的存在. 因此这里模拟了一些 Mock 寄存器, 模拟运行环境, 通过测量执行时间和二进制大小来评估开销.
最终的测试结果是, 编译器编译出了相同的二进制文件, 也就是说使用类进行封装并没有引入额外的开销.
接着尝试使用静态封装, 也就是将寄存器类的成员函数都改为静态成员函数, 这样就不需要创建类的实例了. 例如:
class CModeRegister {
private:
static inline const CRegister m_register { 0x48000000 };
static [[always_inline]] std::uint32_t calculate_value(std::uint32_t pin, GPIO_Modes mode) {
return (mode & GPIO_MODE) << (pin * 2u);
}
static [[always_inline]] void calculate_bitmask(std::uint32_t pin) {
return MODER_MASK << (pin * 2u);
}
public:
static [[always_inline]] void set_mode(std::uint32_t pin, GPIO_Modes mode) {
m_register.set(calculate_value(pin, mode), calculate_bitmask(pin));
}
};编译的结果是, 二进制文件大小增加了, text 段从 636 字节增加到 740 字节, 但 bss 段大小没有变化.
原因是在调用主函数之前的静态初始化阶段需要调用构造函数来初始化静态成员变量, 这引入了额外的储存开销.
为了能继续使用静态封装, 我们需要想办法避免静态成员变量的初始化, 既然寄存器的地址是固定的, 那么完全可以将寄存器地址作为模板参数作为编译期常量传入, 这样就不需要静态成员变量了. 例如:
template<std::uint32_t address>
class CRegister {
public:
void set(std::uint32_t value) {
*(reinterpret_cast<volatile std::uint32_t*>(address)) = value;
}
std::uint32_t get() const { /*... */ }
};现在编译器就能利用这些编译期常量进行更多的优化, 同时也不再需要静态成员变量的初始化了. 测试结果显示, 现在二进制文件大小变回了 636 字节, 和最初的 C 语言版本相同.
接着评估一下继承带来的开销, 将寄存器类改为继承关系:
class CModeRegister : public CRegister<0x48000000> {
private:
static [[always_inline]] std::uint32_t calculate_value(std::uint32_t pin, GPIO_Modes mode) {
return (mode & GPIO_MODE) << (pin * 2u);
}
static [[always_inline]] void calculate_bitmask(std::uint32_t pin) {
return MODER_MASK << (pin * 2u);
}
public:
static [[always_inline]] void set_mode(std::uint32_t pin, GPIO_Modes mode) {
set(calculate_value(pin, mode), calculate_bitmask(pin));
}
};测试结果显示, 继承并没有引入额外的开销, 二进制文件大小仍然是 636 字节. 简单的继承并不引入额外的开销. 但接下来会看到多态则不太一样.
如果要实现多种总线接口, 可能使用多态是最自然的一种抽象方法, 定义一种基类 interface 和一些要实现的虚函数,
然后派生出不同的子类来实现不同的总线接口. 这里用了一个比较简单的场景, 定义一个基类 IPin
class IPin {
public:
virtual void set() = 0;
virtual void reset() = 0;
};
class CPin : public IPin {
private:
std::uint8_t m_pin { 0 };
public:
CPin() = default;
CPin(std::uint8_t pin) : m_pin(pin) {}
void set() override {
CBitSetRegister::set_pin(m_pin);
}
void reset() override {
CBitResetRegister::reset_pin(m_pin);
}
};构建分为两种方式, 一种是 -fno-rtti 关闭 RTTI 支持, 另一种是开启 RTTI 支持. 结果显示, 关闭 RTTI 时 text 段从 716
字节增加到 776 字节, bss 段没有变化; 开启 RTTI 时 text 段增加到 2208 字节, bss 段增加到 1580 字节.
这说明多态确实引入了额外的开销, 特别是开启 RTTI 支持时开销非常大.
RTTI 为什么会引入这么大的开销? 开启 RTTI 后, 编译器会每个类型生成额外的信息, 放在类似 __typeinfo 这样的段中,
这些信息会被用来进行运行时类型识别, 例如 dynamic_cast 和 typeid 操作. 这些额外的信息会显著增加二进制文件的大小.
即使不开启 RTTI, 多态本身也需要通过虚函数表 (vtable) 来实现动态绑定, 尽管储存开销没有 RTTI 那么大, 但性能上的额外开销也不小.
尽管 HAL 是直接用 C 写的, 并不意味着它就是理论上的最优实现. 细看 HAL 的实现, 其实会发现它存在很多运行时计算和运行时分支选择,
但这些理论上都可以在编译期完成. 例如上面的 GPIO 初始化函数, 它能初始化多种不同的配置, 比如根据模式是 OUTPUT 还是
INPUT 来选择不同的寄存器进行配置, 但在嵌入式中要将 GPIO 初始化成什么样子, 并不需要到运行时才能决定, 自己写代码的时候就已经决定好了.
但在 C 里面, 为每一种模式都写一个单独的初始化函数是非常麻烦的, 以及, 例如在模式寄存器中, 要将某一位设置为 1, HAL
的典型做法是获取这一位的偏移量, 然后通过位运算来设置这一位. 但是例如这个移位操作, 在编译期间就能计算出来了, 没必要等到运行时再计算.
于是 C++ 的模板元编程特性就能派上用场了, 通过模板参数传入编译期常量, 让编译器在编译期间就能计算出最终的值, 同时不用编写那么多的代码. 下面是一个例子.
首先定义一些枚举类型来表示 GPIO 的模式和端口:
enum class modes : std::uint32_t {
input = 0b00,
output = 0b01,
alternate = 0b10,
analog = 0b11
};
enum class ports : std::uint8_t {
port_f,
port_d,
port_c,
port_b,
port_a
};接着利用 C++20 引入的 concepts 特性, 定义一些概念 (Concepts) 来约束模板参数, 做静态检查:
template<modes mode>
concept is_valid_mode = (
(mode == modes::input) ||
(mode == modes::output) ||
(mode == modes::alternate) ||
(mode == modes::analog)
);
template<pins pin>
concept is_valid_pin = (
is_valid_low_pin<pin> || is_valid_high_pin<pin>
);
template<pins... pin>
concept are_valid_pins = (is_valid_pin<pin> && ...);这样就能在编译期检查传入的模板参数是否合法了, 能省下很多运行时参数检查的工作.
接下来是关于运行期间的寄存器值计算, 利用 consteval 关键字定义编译期计算函数:
template <modes mode, pins... pin>
requires (are_valid_pins<pin...> && is_valid_mode<mode>)
consteval std::uint32_t moder_value() {
return (... | ((static_cast<std::uint32_t>(mode) << (static_cast<std::uint8_t>(pin) * 2u))));
}
template <pins... pin>
requires (are_valid_pins<pin...>)
consteval std::uint32_t moder_bitmask() {
return (... | (MODER_MASK << (static_cast<std::uint8_t>(pin) * 2u)));
}例如在运行时调用 moder_value<modes::output, pins::pin_5>() 时, 编译器就会直接返回 0b01 << (5 * 2u) 的结果,
甚至直接将结果内联到调用处, 这样就避免了运行时的计算开销. consteval 函数显然是非常适合掩码计算的.
接下来是编译期分支选择, 例如根据不同模式选择不同的初始化方法, 这通过 if constexpr 语句来实现:
template <GpioInitConfig conf, pins... pin>
requires (are_valid_pins<pin...> && is_valid_gpio_conf<conf>)
static [[always_inline]] void configure_pins() {
static_assert(sizeof...(pin) > 0, "At least one pin must be specified");
if constexpr (conf.mode == modes::output) {
/* ... */
}
if constexpr (conf.mode == modes::input) {
/* ... */
}
/* ... */
}使用 if constexpr 还有一个好处是, 它能让编译器在编译期间就剔除掉不满足条件的分支, 如果想用传统的方法,
你必须首先保证传入的参数能通过每一个分支的编译, 尽管你明知这个分支永远不会被执行. 但if constexpr 让编译器只编译满足条件的分支,
能省去很多麻烦.
这样在运行时调用的时候就会直接调用对应的特化版本, 省掉了很多运行时开销.
最终测试结果显示, 经过这些编译期计算和分支选择的优化后, 原始二进制为 9688字节的 text 段, 60 字节的 data 段, 1916 字节的
bss 段, 优化后变为 7660 字节的 text 段, 56 字节的 data 段, 1856 字节的 bss 段. 也就是说不仅没有引入额外的开销,
反而还减少了二进制文件的大小. 同时运行总时间从 2725 ticks 减少到 2179 ticks, 大概相当于每 4 个循环就多跑了 1 次循环.
当然可能有一些疑问是, 模板编程不是也可能导致二进制膨胀吗? 但实际上由于给编译器提供了更多的编译期信息, 让它能进行更多的优化, 至少在这里优化的程度是能够抵消二进制膨胀带来的影响的.
总结一下, 在 C++ 中, 封装并不一定会引入额外的开销, 取决于封装的方式. 零开销的封装方式有类封装, 继承, 静态多态,
而动态多态则会显著增加开销, 特别是开启 RTTI 支持时开销非常大. 而负开销, 也就是能带来性能提升的则是各种编译期计算和编译期分支选择,
比如 constexpr 和 consteval 函数.