跳转至

嵌入式现代 C++教程——constexpr:把计算推到编译期

在嵌入式开发中,我们习惯于用"时间"和"空间"来衡量一段代码的好坏。函数调用要不要内联,循环能不能展开,RAM 和 Flash 有没有被浪费。这些问题都很直观,也很工程。

constexpr这个是一个真正的好东西——它不优化运行时,而是干脆让运行时什么都不用做。这正是 constexpr 在嵌入式现代 C++ 中最有价值、也最容易被低估的地方。


constexpr 解决的不是"快不快",而是"还需不需要算"

很多人第一次接触 constexpr,是从这种例子开始的:

constexpr int square(int x) {
    return x * x;
}
查看完整可编译示例
// constexpr 基础示例:编译期计算

#include <iostream>
#include <array>
#include <cstdint>

// 简单的 constexpr 函数
constexpr int square(int x) {
    return x * x;
}

// 更复杂的 constexpr:编译期生成查找表
constexpr std::array<int, 5> generate_square_table() {
    std::array<int, 5> table{};
    for (int i = 0; i < 5; ++i) {
        table[i] = square(i);
    }
    return table;
}

// 编译期常量表
constexpr auto SQUARE_TABLE = generate_square_table();

// 寄存器位定义示例(嵌入式常见)
constexpr uint32_t GPIO_PIN_MASK(uint8_t pin) {
    return 1U << pin;
}

// 编译期计算波特率分频器
constexpr uint32_t calculate_baud_divisor(uint32_t cpu_freq, uint32_t baud, uint32_t oversample = 16) {
    return cpu_freq / (oversample * baud);
}

// 常用的 UART 配置
constexpr uint32_t UART_DIVISOR_115200 = calculate_baud_divisor(72000000, 115200);
constexpr uint32_t UART_DIVISOR_9600 = calculate_baud_divisor(72000000, 9600);

int main() {
    std::cout << "=== constexpr 示例 ===" << std::endl;

    // 编译期计算的值
    std::cout << "square(8) = " << square(8) << std::endl;
    std::cout << "SQUARE_TABLE[3] = " << SQUARE_TABLE[3] << std::endl;

    // GPIO 位掩码
    std::cout << "\n--- 寄存器位定义 ---" << std::endl;
    std::cout << "GPIO_PIN_5_MASK = 0x" << std::hex << GPIO_PIN_MASK(5) << std::dec << std::endl;

    // UART 波特率分频器
    std::cout << "\n--- UART 波特率分频器(编译期计算)---" << std::endl;
    std::cout << "115200 baud 分频器 = " << UART_DIVISOR_115200 << std::endl;
    std::cout << "9600 baud 分频器 = " << UART_DIVISOR_9600 << std::endl;

    // 验证编译期计算
    static_assert(UART_DIVISOR_115200 == 39, "波特率分频器应该在编译期计算");
    static_assert(SQUARE_TABLE[4] == 16, "查表应该在编译期生成");

    std::cout << "\n关键点:" << std::endl;
    std::cout << "1. constexpr 让计算在编译期完成,运行时无开销" << std::endl;
    std::cout << "2. 查找表可以在编译期生成,存储在 Flash 中" << std::endl;
    std::cout << "3. 寄存器位定义、波特率分频器等配置,编译期确定" << std::endl;
    std::cout << "4. static_assert 验证值确实在编译期确定" << std::endl;

    return 0;
}

然后编译器把 square(8) 直接替换成 64,看起来很神奇。

但如果只停留在这个层面,很容易把 constexpr 理解成一种"更激进的 inline"。这是一个常见但并不准确的认识。

inline 的核心问题是:要不要在运行时展开函数体。而 constexpr 关注的是:这个计算是否还有必要存在于运行时。一旦你意识到这个差异,就会发现它们解决的是完全不同的问题。


在嵌入式里,运行时并不是免费的

在 PC 上,多一次整数乘法、多一次分支判断,几乎可以忽略不计。但在嵌入式系统中,运行时往往意味着更多的东西——可能是 Flash 中的指令读取,可能是 Cache miss,可能是启动阶段的额外执行路径,甚至可能影响到中断响应的确定性。很多嵌入式代码中都存在这样一种"无意识的运行时计算":常量明明在编译期就已经确定,却依然被写成了运行时表达式。

例如外设寄存器位掩码、数组大小、查表索引、协议字段偏移。这些值在系统启动后永远不会改变,但却被一次次地计算、判断、组合。constexpr 的出现,正是为了让这些"本不该存在于运行时的计算",彻底消失。


constexpr 的本质,是一种"更强的常量语义"

在嵌入式 C 时代,我们已经非常熟悉 #defineenum。它们同样可以在编译期得到结果,也同样不会引入运行时开销。

但它们的问题在于:缺乏类型、缺乏作用域、缺乏表达能力

constexpr 并不是为了取代它们,而是提供了一种更现代、更安全的方式,把"这是一个编译期就能确定的事实"明确地告诉编译器。当你把一个函数、一个构造函数、一个变量声明为 constexpr,你其实是在表达一种强约束:如果它不能在编译期完成,那它就不该存在。这种约束一旦成立,编译器就会毫不犹豫地把结果直接写进目标文件。


嵌入式中,constexpr 最自然的落脚点

在真实工程中,constexpr 很少是"炫技式"的。它更多是悄悄地改善系统结构。

  • 例如寄存器地址和位定义。过去我们用宏,现在我们可以用 constexpr 常量,让类型系统参与进来。
  • 例如静态查找表的生成,编译期完成初始化,运行时只做索引。
  • 例如协议相关的固定字段、偏移量、长度计算,在链接前就已经完全确定。

这些地方使用 constexpr,几乎不会改变你代码的阅读方式,却能实实在在地减少运行时代码。


constexpr 和嵌入式启动阶段

在很多嵌入式系统中,启动阶段是一个被高度关注、但又容易被忽略细节的地方。初始化代码往往集中、复杂,而且执行一次就决定了系统之后的状态。如果这些初始化中包含大量本可以在编译期完成的计算,那么启动时间、代码体积和可验证性都会受到影响。constexpr 在这里的意义,并不只是"快一点",而是让启动路径更简单、更确定。当某些值已经被固化进二进制文件时,你甚至不需要再关心它们是怎么被算出来的。


constexpr 并不是"写了就一定发生"

和 inline 类似,constexpr 也经常被误解为一种"强制指令"。事实上,它更像是一种契约。

你告诉编译器:这段逻辑应该可以在编译期完成。如果做不到,编译器要么拒绝编译,要么退化为运行时求值(取决于上下文)。这反而是一件好事。在嵌入式开发中,编译期失败往往比运行时问题要友好得多。而且越高版本的C++,越能支持更多行为的编译期计算。