跳转至

现代嵌入式 C++ 教程:常见编译器参数指南

在实际的嵌入式开发中,每一字节的 Flash 和 RAM 真的都是开发者省出来的。C++ 虽然背负着“重型语言”的偏见,但通过合理配置编译器选项,我们可以精准地裁剪运行时开销,使其性能和体积甚至优于手工 C 代码。(这一点,我相信大家在Chapter0上已经看到了)


0 一些基础

语言标准控制:-std=

这是定义项目“现代性”的最直接方式。

  • 参数格式-std=c++11, -std=c++14, -std=c++17, -std=c++20
  • GNU 扩展版gnu++17。相比标准 c++17,它允许使用一些 GCC 特有的非标准扩展(如特殊的内联汇编语法)。在嵌入式底层开发中,有时不得不使用 gnu++ 版本。

为什么在嵌入式中选 -std=c++17 或以上?

  • constexpr 的威力:在 C++17 中,大量的逻辑可以被移至编译期计算,直接减少了运行时的 CPU 负载和 Flash 占用。
  • std::span (C++20):它是嵌入式开发中传递缓冲区(Buffer)的完美替代品,比传统的 uint8_t* ptr, size_t len 更安全且无额外开销。
  • 结构化绑定:让解析复杂的传感器数据结构变得极其优雅。

预处理器与宏定义:-D-U

在嵌入式中,由于硬件差异,我们经常需要“条件编译”。

  • -D<macro>=<value>:定义宏。
  • 例如:-DSTM32F407xx-DDEBUG_LEVEL=2
  • 现代做法:尽量在 CMake 中通过 target_compile_definitions(target PRIVATE STM32F407xx) 来控制,而不是在代码里写满 #define
  • -U<macro>:取消已定义的宏。

警告:过度依赖宏会导致代码路径难以测试(Code Coverage 无法覆盖未开启宏的分支)。在现代 C++ 中,建议优先考虑 if constexpr 配合常量对象。


路径搜索与库链接:-I, -isystem, -L, -l

这是初学者最容易在 CMake 里配置出错的地方。

  • -I <dir> (Include):指定头文件搜索路径。
  • -isystem <dir>:指定“系统”头文件路径。
  • 精妙之处:如果第三方库(如 ST 的 HAL 库)产生了大量无意义的警告,用 -isystem 引入它们,编译器会自动屏蔽该目录下的所有警告,让你的控制台保持清爽。
  • -L <dir>:指定静态库(.a)的搜索目录。
  • -l<name>:链接指定的库。
  • 注意:如果库名是 libmath.a,参数则是 -lmath(去掉 lib 前缀和扩展名)。

输出管理与调试信息:-o-g

  • -o <file>:指定输出文件名。在交叉编译中,我们通常生成 .elf 文件,然后再通过 objcopy 转换为 .bin.hex
  • -g-g3
  • -g 产生标准的调试符号,用于 GDB 调试。
  • -g3:甚至会包含宏定义的调试信息。如果你在调试时需要查看某个 #define 的值,请开启它。
  • 误区拨正:开启 -g 不会增加代码在板子上运行时的体积。调试信息只存在于电脑上的 .elf 文件中,并不会被烧录到单片机的 Flash 里。

警告治理:-W 系列 (Code Quality)

在嵌入式这种安全敏感的领域,警告就是隐匿的 Bug。

  • -Wall -Wextra:绝大多数开发者的标配,开启绝大部分有价值的警告。
  • -Werror将所有警告视为错误
  • 推荐实践:在 CI/CD(持续集成)环境中强制开启 -Werror,确保提交的代码没有任何隐患。
  • -Wshadow:当局部变量名覆盖了全局变量名时发出警告,这在嵌入式逻辑切换中极其有用。
  • -Wdouble-promotion嵌入式必选! 当你无意中将一个 float 提升为 double 时警告。在没有双精度硬件浮点单元的单片机上,这会导致性能暴跌。

依赖生成:-M, -MMD

你是否好奇 CMake 是如何知道“由于你改了某个头文件,所以这 10 个源文件需要重新编译”的?

  • -MMD:在编译的同时,生成一个 .d 后缀的依赖关系文件。
  • 自动化:现代构建系统(CMake/Ninja)会自动处理这些选项。理解它能帮你排查“为什么我改了代码但编译没反应”的增量编译问题。
# 编译参数
target_compile_options(${PROJECT_NAME} PRIVATE
    -std=c++17             # 核心:定义语言标准
    -g3                    # 调试:丰富的调试信息
    -Wall -Wextra          # 质量:严格警告
    -Werror                # 质量:零容忍警告
    -Wdouble-promotion     # 性能:防止隐式双精度运算
    -ffunction-sections    # 体积:函数分区
    -fdata-sections        # 体积:数据分区
    -fno-exceptions        # 裁剪:禁用异常
    -fno-rtti              # 裁剪:禁用 RTTI
)

# 链接参数
target_link_options(${PROJECT_NAME} PRIVATE
    -Wl,--gc-sections      # 体积:垃圾回收死代码
    -Wl,-Map=${PROJECT_NAME}.map  # 诊断:生成内存映射文件
)

1. 优化等级:在速度、体积与调试之间平衡

GCC 和 Clang 提供了多级的优化开关。理解它们的差异是嵌入式开发者的基本功。

选项 名称 核心行为 适用场景
-O0 无优化 保持代码与汇编的一一对应。 仅限排查极难捕捉的逻辑 Bug。
-Og 调试优化 开启不影响调试观察的优化。 开发阶段的首选,兼顾性能与单步调试。
-O2 性能优化 几乎开启所有不涉及空间换时间的优化。 高性能计算、RTOS 任务逻辑。
-Os 尺寸优化 开启 -O2 中不增加代码体积的选项。 嵌入式发布的默认选择
-Ofast 极速优化 破坏 IEEE 754 标准(不保证浮点精度)。 纯数学计算且不介意精度微差。

💡 深度建议:为什么不要在嵌入式用 -O3

-O3 会进行大量的循环展开(Loop Unrolling)和函数内联。虽然速度可能提升,但在 Flash 空间捉襟见肘的单片机上,它会导致代码膨胀,甚至可能因为指令缓存(I-Cache)未命中反而降低性能。


2. 裁剪 C++ 运行时:卸下沉重的“盔甲”

现代 C++ 默认携带了一些在嵌入式中代价极高的特性。通过以下两个选项,我们可以把 C++ “瘦身”回类似 C 的开销。

2.1 -fno-exceptions (禁用异常)

  • 代价:C++ 异常需要庞大的“解开栈(Unwind Table)”支持,这会增加约 10%~20% 的 Flash 占用。
  • 后果:无法使用 try-catchthrow。如果程序出错,会直接调用 std::terminate
  • 嵌入式准则:在资源受限系统(如 Cortex-M)中,强烈建议禁用

2.2 -fno-rtti (禁用运行时类型识别)

  • 代价:为了支持 dynamic_casttypeid,编译器会为每个带虚函数的类生成额外的元数据(vtable 之外的信息)。
  • 后果:无法在运行时判断对象的真实类型。
  • 嵌入式准则:现代嵌入式设计更倾向于编译时多态(模板/CRTP),因此 RTTI 通常是多余的。

3. 垃圾回收不用的代码

默认情况下,编译器将整个源文件编译成一个巨大的二进制块。即便你只用了库里的一个函数,链接器也会把整个库的代码塞进 Flash。

3.1 编译器端:分区化

  • -ffunction-sections:将每个函数独立打包进一个段(Section)。
  • -fdata-sections:将每个全局变量/静态变量独立打包。

3.2 链接器端:垃圾回收

  • -Wl,--gc-sections:告知链接器(ld),扫描所有段,把那些没有被引用的“死代码”彻底从最终的 .elf 文件中剔除。

4. CMake 中的最佳实践配置

将上述理论转化为代码。在你的顶层 CMakeLists.txt 中,建议这样管理这些选项:

# 创建一个专门的编译选项接口库,方便所有 Target 复用
add_library(project_warnings INTERFACE)

target_compile_options(project_warnings INTERFACE
    $<$<CONFIG:Release>:-Os>                 # Release 模式优化尺寸
    $<$<CONFIG:Debug>:-Og -g3>               # Debug 模式方便调试
    -fno-exceptions                          # 禁用异常
    -fno-rtti                                # 禁用 RTTI
    -ffunction-sections                      # 函数分区
    -fdata-sections                          # 数据分区
    -Wall -Wextra -Wpedantic                 # 开启严格警告(防患未然)
)

# 链接器选项
target_link_options(project_warnings INTERFACE
    "-Wl,--gc-sections"                      # 链接时删除死代码
    "--specs=nano.specs"                     # 使用精简版 C 库 (Newlib-nano)
)

# 使用时只需要链接这个接口
target_link_libraries(my_firmware PRIVATE project_warnings)

5. 危险的 -Ofast 与浮点陷阱

在嵌入式中,-Ofast 会开启 -ffast-math。这可能导致:

  1. 精度丢失:编译器为了加速,可能会忽略极小的浮点数误差。
  2. NaN/Inf 失效:它假设你的程序永远不会产生非法浮点数。
  3. 重新排序运算:这可能导致在某些算法中出现不稳定的结果。

建议:除非你在做纯数字信号处理(DSP)且对精度有完全的掌控,否则始终坚持使用 -Os-O2