尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

STM32 嵌入式 C++ 编程:零成本抽象与 RAII 工程实践

STM32 嵌入式 C++ 编程:零成本抽象与 RAII 工程实践 STM32 上用 C 写嵌入式程序这件事我三年前的立场和现在完全相反。那会儿我手里是一个 860 行的main.c跑在一块 STM32F407 的 CAN 采集板上三路串口、两个定时器、一个状态机、一堆全局标志位全挤在一个文件里。改一次波特率我需要在五个地方找同名变量新增一种报文格式我得先翻十分钟才敢下笔。后来我咬着牙用 C 把整套东西重写了一遍编译体积只涨了不到 3%代码量掉了四分之一最要命的是半年后回来改需求我居然还能看懂自己当初写了什么。这篇是我在 STM32 上做嵌入式 C 编程的第一篇记录只回答一个最根本的问题为什么是 C凭什么。它适合已经能用 C 点灯、跑通 HAL 库但对 C 还停留在学过、不敢用阶段的同学也适合那些写了五六年 C、被大工程维护折磨过的老手。我不打算把 C 当信仰去推只讲能落到 STM32 上的东西开销到底多少、哪些特性值得用、哪些必须关掉、工程怎么改、踩过哪些坑。1. 为什么我会在STM32上掏出C从一个点灯工程的失控说起1.1 从一份860行的main.c说起那份 CAN 采集板的固件最初只是一个点灯加串口打印的小工程后来需求一层层往上加加一路 RS485、加一个 OLED、加一个按键菜单、加两路 ADC 采样、加一个基于 SysTick 的软定时器任务表。每次加功能我都往main.c里塞一段塞到后来文件头部光 extern 声明就有四十多行。真正让我决定重构的是三个具体场景我到现在还记得很清楚第一个是串口重初始化因为某个工况下需要切换波特率结果发现三个串口句柄散落在三个不同函数里谁在什么时候初始化过、谁被关过全靠记忆第二个是引脚号传递uint16_t类型的引脚掩码、通道号、超时时间在函数间互相传有一次我把GPIO_PIN_5和GPIO_PIN_6写反了板子上电后蜂鸣器一直响查了两个小时第三个是换板子移植从 F407 换到 F103因为引脚全是硬编码的宏我几乎是复制了整个文件再改一遍。这三个场景指向的其实是同一件事C 语言把所有的上下文都留给了程序员的脑子去记。编译器能帮你的只有类型检查而一旦所有东西都是uint16_t、uint8_t*、全局变量类型检查基本就形同虚设。C 不是什么魔法它只是把这个句柄代表什么这个资源的生命周期归谁管这个数字是地址还是长度这些信息从注释里搬进了类型系统让编译器替你记。这就是我掏 C 的第一个理由不是为了炫技是记忆力真的不够用了。1.2 凭什么问的其实是两件事性能不退化复杂度不上升每次我跟同行说我在 STM32 上用 C对方的第一反应几乎都一样那体积不得炸了跑得动吗这个问题问得没错因为早期嵌入式圈子里流传的C 会生成一堆看不见的代码并不是空穴来风——默认开启异常、RTTI、动态内存和 STL 容器确实会让一个点灯工程从 12KB 变成 40KB 以上。但关键在于这些开销不是 C 语言本身的是那几个特定特性的。把异常和 RTTI 关掉、不用new/delete、不用std::vector你写的 C 本质上就是C 加上类、模板和编译期计算编译器生成的机器码和手写 C 几乎一模一样。第二件事是复杂度。很多人担心引入 C 之后新人看不懂、团队不统一、调试变困难。我的实际经验恰恰相反一个用class Uart封装好的串口驱动比一个带三个全局变量和两个初始化函数的 C 模块更好懂因为接口就那么几个方法状态全藏在对象里不会出现这个全局变量什么时候被谁改的这种问题。当然复杂度也不是白降的代价是你得先把封装想清楚得知道哪些东西可以放在构造函数、哪些必须留到init()。这些决策一旦做对后面每一个新功能都是在已有的积木上搭而不是在main.c里再塞一段。所以C 让嵌入式变复杂这个判断只在一种情况下成立你没有约束地使用它。2. 先算代价再谈收益C在STM32上的真实开销2.1 实测对照体积、RAM、编译时间怎么变空口说C 不会变大没有说服力我把自己那个 F407 的工程做了一次对照编译。测试条件是STM32F407VGT6HAL 库GCC 10.3 工具链优化等级-Og工程功能包含系统时钟配置、一个 GPIO 心跳灯、一路 UART 收发、一个 SysTick 任务调度、两路 ADC 采样。C 版本和 C 版本的功能完全一致C 版本额外加了若干个类封装但没有引入任何 STL 容器、没有开异常和 RTTI。指标C 实现C 实现变化.text代码段11384 B11720 B2.9%.data已初始化数据108 B112 B3.7%.bss未初始化数据1620 B1636 B1.0%栈使用峰值约 620 B约 640 B基本持平源码总行数约 860 行约 640 行-25%全量编译时间3.1 s4.2 s35%这组数字是我自己工程上的结果不同工程、不同优化等级、不同编译器版本都会有出入别当成普适结论。但趋势很明确只要不打开那几个重特性运行时代价几乎可以忽略代价主要转移到了编译时间上——因为模板实例化和头文件展开确实更费时间。这点在大型工程上会更明显一个几百个源文件的项目C 的增量编译时间可能比 C 慢一半以上这是实打实的成本需要提前接受。另外要说的是优化等级的影响。上面这组数据用的是-Og是为了方便调试。切到-O2之后两边都会变小差距也会进一步收窄我实测过某次-O2下 C 版本甚至比 C 版本小了 40 字节——原因很简单类内联函数把一些 C 版本里靠函数指针回调的逻辑直接内联展开了。所以优化等级这件事的权重比用 C 还是 C大得多。2.2 被误解的三件事异常、RTTI、动态内存把这几个东西说清楚基本就解开了九成人的顾虑。异常是最大的一块throw/catch在底层依赖展开表unwind table和运行时库GCC 生成的.eh_frame段不是按需的而是整个编译单元都会带上一个中等规模的工程可能因此多出十几 KB 的 Flash。而嵌入式代码里throw本身就不常用中断上下文里根本不能抛异常任务里用错误码返回反而更清晰所以-fno-exceptions基本是标配。RTTI 是第二块它撑起了dynamic_cast和typeid。这两个特性在单片机上的用途几乎为零——你没有复杂继承体系、没有运行时类型分发需求-fno-rtti关掉之后编译器和链接器都不会再生成类型信息表。第三块是动态内存这是最容易被误伤的C 不等于newnew只是 C 的一部分。你不用std::vector、不用std::string、不用shared_ptr代码里就一个new都不会出现堆也就不用配。真的需要动态创建对象用 placement new 在一块静态数组上构造就够了alignas(bsp::Uart) static uint8_t uart_storage[sizeof(bsp::Uart)]; bsp::Uart* g_uart new (uart_storage) bsp::Uart(USART1, 115200);这样一来内存是编译期确定的没有碎片风险也没有_sbrk的依赖。这套写法我用了两年多比裸指针加手工初始化清爽很多。2.3 为什么不是MicroPython、不是Rust也不是纯C把这两个方案拉出来比一下能更清楚地看出 C 的位置。MicroPython 的好处是开发极快一个machine.Pin就能点亮灯适合教学、原型验证和小批量非实时产品。但它的内存占用通常在几十 KB 起步垃圾回收带来的不确定延迟在硬实时场合很难接受而且驱动生态相对薄遇到一个冷门外设往往要自己写 C 模块绕了一圈又回到 C。更现实的问题是量产固件体积、授权合规、升级方案都要额外考虑。Rust 这几年在嵌入式领域进步很快编译期的内存安全确实是它的强项但你要面对的现实是现有的大量 HAL 代码、驱动库、示例文档都是 C 的复用要过 FFI 这一关团队里没人写过 Rust学习曲线要按季度算一些芯片厂商的官方支持还停留在早期阶段。这不是说 Rust 不好而是说在一个已经有成熟 C 代码库的项目上切过去成本很难在短期回收。纯 C 则是最稳的选择问题在于它把所有的正确性责任都推给了程序员的纪律工程一大就靠评审和规范硬撑编译器帮不上忙。C 的位置在中间能直接include厂商的 C 头文件、能复用全部现有 HAL 代码同时在编译期帮你抓住一部分错误——这就是它的性价比所在。3. C真正值钱的四个能力3.1 零成本抽象constexpr 与编译期计算零成本抽象这句话被说烂了但落到 STM32 上是什么样很多人没见过。举一个我常用的例子串口波特率寄存器的计算。C 语言里这通常是运行期算的或者干脆写个宏在编译期乱算一通。C 的constexpr可以让你写一个正常的函数同时保证它在编译期算完constexpr uint32_t uart_brr(uint32_t pclk, uint32_t baud) noexcept { return (pclk baud / 2u) / baud; } static_assert(uart_brr(84000000u, 115200u) 729u, 波特率分频值不符合预期);static_assert是在编译期执行的一旦时钟树配置改了、PCLK 变了这个断言会立刻失败而不是等到板子跑起来发现串口出乱码。类似的还有外设基地址的编译期计算用模板参数传地址、传偏移量最后展开成一条LDR指令没有任何多余代码template uint32_t Base, uint32_t Offset struct Reg { static volatile uint32_t ref() noexcept { return *reinterpret_castvolatile uint32_t*(Base Offset); } }; using GPIOA_MODER Reg0x40020000u, 0x00u; // 使用GPIOA_MODER::ref() | (1u 10);这里的关键在于Base Offset是编译期常量编译器直接把它折叠成一个立即数地址和你在 C 里写#define GPIOA_MODER (*(volatile uint32_t*)0x40020000)生成的代码完全一致。区别只在于前者有类型、有命名空间、能被static_assert校验后者是一串裸地址。3.2 RAII串口和定时器的生命周期不靠人肉记账RAII 这个词听起来玄乎其实就是资源在构造时获得、在析构时释放。C 语言里做临界区保护通常是这样进临界区关中断出来之前开中断中间如果有return或者break你得小心别漏掉那行开中断的代码。我踩过这个坑一个 ADC 采样的临界区里因为提前return忘了恢复PRIMASK导致整个系统中断再也进不去现象是板子看起来还在跑但所有按键失灵查了一下午。用 C 写就简单了class IrqLock { public: IrqLock() noexcept { primask_ __get_PRIMASK(); __disable_irq(); } ~IrqLock() noexcept { __set_PRIMASK(primask_); } IrqLock(const IrqLock) delete; IrqLock operator(const IrqLock) delete; private: uint32_t primask_; };用的时候直接IrqLock lock;放在函数开头不管函数从哪个分支返回析构函数都会恢复中断状态。__get_PRIMASK和__set_PRIMASK是 CMSIS 提供的保存和恢复而不是无脑开中断这样嵌套使用也不会出错。同样的套路可以用在 SPI 片选、DMA 通道占用、Flash 解锁上——只要是一个成对出现、容易漏掉的操作都适合包成一个类。注意RAII 对象不要在中断服务函数里做大范围使用也不要在构造函数里做耗时的外设操作。构造和析构本身应该是轻量的、确定性的否则会把中断响应时间拉长。3.3 强类型把 uint16_t 的混乱掐死在编译期这一条是我认为 C 对嵌入式最直接的贡献。看下面这个函数调用HAL_UART_Transmit(huart1, buf, timeout, len);参数是超时时间和长度两个都是整数顺序写反了编译器一声不吭运行起来就是发了 100 字节等 200 毫秒还是发了 200 字节等 100 毫秒你得靠现象去猜。用强类型包一层就完全不一样struct Length { uint16_t value; explicit Length(uint16_t v) : value(v) {} }; struct Timeout { uint32_t value; explicit Timeout(uint32_t v) : value(v) {} };explicit关键字要求调用方必须显式构造Length和Timeout之间不能互相赋值写反了直接编译失败。同样的思路可以用在引脚、通道、句柄、地址偏移上。我现在的bsp层里几乎所有的参数都是强类型或者enum classenum class默认不隐式转换成整数需要转换时必须写static_cast这个必须显式写的动作本身就让人多想一秒。另一种常见做法是把外设句柄包成不可拷贝的类型class Uart { public: Uart(const Uart) delete; Uart operator(const Uart) delete; // ... };这样不小心按值传参、或者把它放进一个会拷贝的结构里编译期就会被拦住避免出现两个对象操作同一个硬件句柄的情况。3.4 命名空间与模板把寄存器封装成可读的类型命名空间解决的是名字污染。C 语言里所有函数都是全局符号init()、send()、reset()这种名字只要两个模块都用就会冲突最后只能靠长前缀can_driver_init、uart_driver_init来区分。C 里按层次划分一下就很干净namespace bsp { namespace can { void init() noexcept; bool send(const Frame f) noexcept; } namespace uart { class Port { /* ... */ }; } }调用的时候写bsp::can::send(frame)一眼就知道这是哪个子系统的。更重要的是命名空间让内部实现和对外接口有了物理边界头文件里只暴露该暴露的东西剩下的全放.cpp的匿名命名空间里链接器不会再看到它们也不会出现跨模块的意外引用。至于模板除了上面说的寄存器地址封装还有一个很实用的用法是写通用的缓冲区视图template typename T, size_t N class Buffer { public: constexpr size_t capacity() const noexcept { return N; } T* data() noexcept { return data_; } const T* data() const noexcept { return data_; } size_t size() const noexcept { return size_; } bool push(const T v) noexcept { if (size_ N) return false; data_[size_] v; return true; } private: T data_[N]{}; size_t size_ 0; };Bufferuint8_t, 128这种写法容量是编译期常量数组直接分配在栈或者静态区没有堆、没有虚函数、没有额外开销。它比裸数组多的是边界检查和语义少的是越界写内存的风险。4. 动手改造从CubeMX的C工程到可用的C工程4.1 工具链与编译选项的具体设置以 STM32CubeIDE 为例改造的第一件事是把main.c重命名为main.cppIDE 会识别扩展名并自动切换到arm-none-eabi-g编译。如果你在用 CMake 管理工程需要在CMakeLists.txt里把.cpp文件对应到ASM、C、CXX三种语言并确保project(... LANGUAGES C CXX ASM)里带上了CXX。Keil MDK 的话在Options for Target里确认使用了 Arm Compiler 6AC6然后在源文件的属性里把语言改成 CAC6 对 C14 支持完整C17 只支持一部分所以工程里统一用-stdc14比较稳妥GCC 工具链则可以放心用-stdgnu17。编译选项是我最想强调的部分下面这套是我目前默认的配置# 关掉重量级特性 -fno-exceptions -fno-rtti -fno-threadsafe-statics -fno-use-cxa-atexit -fno-unwind-tables -fno-asynchronous-unwind-tables # 体积优化 -ffunction-sections -fdata-sections -Wl,--gc-sections # 精简库与浮点打印 --specsnano.specs -u _printf_float # 警告全开 -Wall -Wextra -Wshadow逐条解释一下原因。-fno-exceptions和-fno-rtti前面讲过了省的是 Flash 和运行时库。-fno-threadsafe-statics针对的是函数内静态变量的线程安全初始化GCC 会为它加一层__cxa_guard_acquire保护单核裸机上这纯属浪费关掉之后局部静态对象直接正常初始化。-fno-use-cxa-atexit影响的是析构注册裸机上根本不会有exit()留着只会多一个注册表。-fno-unwind-tables和-fno-asynchronous-unwind-tables是进一步的压缩既然没有异常展开表就是死重量。-ffunction-sections配合--gc-sections是嵌入式的老朋友但没有-fdata-sections配合的话只能回收函数、回收不了数据容易漏。--specsnano.specs把标准库换成 newlib-nanoprintf的体积能小一大截代价是默认不带浮点格式化需要打印浮点时加上-u _printf_float或者干脆在业务代码里自己格式化。这里有个坑我踩过加了-u _printf_float之后 Flash 会立刻涨 3KB 左右如果你的工程里只有一处打印浮点值得考虑改用手动除法拼字符串。4.2 main.cpp、启动文件与全局对象的构造时机CubeMX 为 GCC 生成的启动文件里Reset_Handler大概是这个结构Reset_Handler: ldr sp, _estack bl SystemInit bl __libc_init_array bl main bx lr中间那句bl __libc_init_array是关键它负责遍历.init_array段把所有全局对象的构造函数跑一遍。也就是说在 GCC 工具链下C 全局对象天生就是可用的不需要额外做什么。但反过来说这也意味着全局对象的构造函数跑在main()之前更跑在HAL_Init()之前。如果你在构造函数里访问了任何外设寄存器——哪怕只是调用HAL_GPIO_WritePin——此时 GPIO 时钟还没使能直接进 HardFault。这个坑我第一次遇到时百思不得其解单步调试断在了构造函数里看寄存器全是 0。所以我的做法是全局对象只在构造函数里保存端口号和引脚号这类纯数据真正的外设初始化统一放到init()方法里在main()里显式调用。这叫两段式初始化看起来啰嗦一点但时序完全可控。另外如果用的是 Keil MDK 加 Microlib要特别留意全局对象的构造是否被执行——不同版本的表现不完全一致保险的做法是编译一份最小测试在构造函数里翻转一个 GPIO上电看灯亮不亮确认之后再大规模使用。还有一个细节是链接脚本--gc-sections开启之后要确认.init_array段被KEEP保住.init_array : { . ALIGN(4); __init_array_start .; KEEP (*(.init_array*)) __init_array_end .; } FLASHCubeMX 生成的.ld文件里通常已经有这段但如果你自己改过链接脚本或者从别处拷了一份模板一定要检查。4.3 实操一个GPIO与UART封装可直接抄下面这段是我现在工程里用的 GPIO 封装删掉了一些项目相关的部分可以直接拿去改。第一个版本用指针保存端口#pragma once #include cstdint extern C { #include stm32f4xx_hal.h } namespace bsp { class OutputPin { public: OutputPin(GPIO_TypeDef* port, uint16_t pin) noexcept : port_(port), pin_(pin) {} void init() const noexcept { GPIO_InitTypeDef cfg{}; cfg.Pin pin_; cfg.Mode GPIO_MODE_OUTPUT_PP; cfg.Pull GPIO_NOPULL; cfg.Speed GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(port_, cfg); } void set(bool on) const noexcept { HAL_GPIO_WritePin(port_, pin_, on ? GPIO_PIN_SET : GPIO_PIN_RESET); } void toggle() const noexcept { HAL_GPIO_TogglePin(port_, pin_); } private: GPIO_TypeDef* port_; uint16_t pin_; }; } // namespace bsp这里有个细节值得说HAL_GPIO_Init的第一个参数是非 const 指针而我在成员函数末尾加了const看起来矛盾其实不矛盾——const修饰的是对象本身port_和pin_不能改指针指向的外设寄存器照样可以写。第二个版本是真编译期常量版本把地址存成uintptr_tclass OutputPin { public: constexpr OutputPin(uintptr_t port_addr, uint16_t pin) noexcept : addr_(port_addr), pin_(pin) {} GPIO_TypeDef* port() const noexcept { return reinterpret_castGPIO_TypeDef*(addr_); } // init / set / toggle 同上 private: uintptr_t addr_; uint16_t pin_; }; // 全局使用完全在编译期确定不产生任何动态初始化 constexpr bsp::OutputPin kLed(bsp::port_addr::GPIOA, GPIO_PIN_5);为什么第一版不能加constexpr因为GPIOA这个宏展开后是((GPIO_TypeDef*)GPIOA_BASE)里面含一个指针强制转换而reinterpret_cast不允许出现在常量表达式中编译器会直接报错。所以要么用指针版本但不加constexpr会触发动态初始化跑在main之前要么用整数地址版本加constexpr完全静态甚至可以放到 Flash 里。选哪个取决于你的场景如果只是几个灯两个都行如果要建一张几十条记录的外设表我强烈建议第二个。UART 封装稍微长一点重点是它自己管理UART_HandleTypeDef不让外部看见#pragma once #include cstdint #include cstring namespace bsp { class Uart { public: Uart(USART_TypeDef* inst, uint32_t baud) noexcept : inst_(inst), baud_(baud) {} bool init() noexcept { handle_ {}; handle_.Instance inst_; handle_.Init.BaudRate baud_; handle_.Init.WordLength UART_WORDLENGTH_8B; handle_.Init.StopBits UART_STOPBITS_1; handle_.Init.Parity UART_PARITY_NONE; handle_.Init.Mode UART_MODE_TX_RX; handle_.Init.HwFlowCtl UART_HWCONTROL_NONE; handle_.Init.OverSampling UART_OVERSAMPLING_16; return HAL_UART_Init(handle_) HAL_OK; } bool write(const uint8_t* data, uint16_t len, uint32_t timeout 100) noexcept { return HAL_UART_Transmit(handle_, const_castuint8_t*(data), len, timeout) HAL_OK; } bool write(const char* text, uint32_t timeout 100) noexcept { return write(reinterpret_castconst uint8_t*(text), static_castuint16_t(std::strlen(text)), timeout); } private: USART_TypeDef* inst_; uint32_t baud_; UART_HandleTypeDef handle_{}; }; } // namespace bspconst_cast那一处不是偷懒是 HAL 的历史包袱HAL_UART_Transmit的第二个参数类型是非 const 的uint8_t*但从语义上讲发送操作不会改缓冲区内容所以把这层转换收敛在封装内部、不泄漏给上层是最干净的处理方式。std::strlen需要包含cstring这类标准头文件是零运行时代价的可以放心用真正要躲开的是vector、string、iostream这些会拖进堆和大量模板实例的头文件。4.4 中断服务函数与extern C的正确用法中断服务函数是 C 工程里最容易出问题的地方原因是启动文件里的向量表是汇编写的g_pfnVectors: .word _estack .word Reset_Handler .word NMI_Handler .word HardFault_Handler ... .word EXTI0_IRQHandler汇编器引用符号时不做名字修饰找的就是字面上的EXTI0_IRQHandler。而 C 编译的函数会被加上名字修饰mangling变成_Z16EXTI0_IRQHandlerv之类的链接器自然找不到表现通常是中断进不去或者链接期直接报undefined reference。解决方法很简单给所有在向量表里出现的函数加extern Cextern C void SysTick_Handler(void) { HAL_IncTick(); } extern C void EXTI0_IRQHandler(void) { HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_0); }我更喜欢另一种组织方式中断服务函数只保留最短的一层转发实际逻辑交给 C 对象去处理这样中断部分保持纯粹的 C 风格业务逻辑享受 C 的封装。比如extern C void USART1_IRQHandler(void) { g_uart1.onIrq(); // 内部调用 HAL_UART_IRQHandler 并分发事件 }g_uart1是一个全局的bsp::Uart对象或者它的派生类onIrq()里判断中断标志、处理收发。要注意的是中断上下文和主循环共享的变量必须加volatile否则编译器可能把它优化进寄存器导致主循环永远读不到新值。另外全局对象的构造时机问题在这里又出现一次如果g_uart1是全局对象它的构造函数会在__libc_init_array阶段执行那时候时钟还没配。所以我还是老办法——全局声明、main()里init()。中断在init()完成之前不会真的触发这个顺序天然安全。5. 踩坑实录与排查速查表5.1 编译链接阶段的六个高频报错改造过程中我整理了一份报错对照表基本上第一次做 C/C 混合工程的人都会碰到其中三四条。报错信息根本原因处理方式undefined reference to HAL_GPIO_InitC 编译的目标文件符号未被修饰C 侧按修饰名查找在.cpp中用extern C { }包住厂商头文件undefined reference to _sbrk/_write使用了printf或new但缺少系统调用桩加--specsnosys.specs或自己实现_write、_sbrkregion RAM overflowed by N bytes误引入 STL 容器或异常.bss/堆暴涨检查头文件包含确认-fno-exceptions已生效invalid conversion from void* to uint8_t*C 代码里malloc返回值隐式转换在 C 中不合法加上显式static_cast或把该文件保留为.cSymbol xxx multiply defined头文件里定义了非 inline 的全局函数或变量加inline或把定义挪到.cpp上电后中断完全无响应中断函数被 C 修饰向量表找不到符号给中断函数加extern C第四条特别值得展开。把一大堆现成的 C 代码直接改后缀名成.cpp是最快的迁移路径但 C 和 C 之间有几个不兼容点会跳出来void*的隐式转换、旧式枚举到整数的隐式转换、字符串字面量赋给char*C11 起不合法、以及 KR 风格的函数声明。我的做法不是一次性全改而是分层迁移底层驱动和 HAL 配置留在.c里只把应用逻辑用.cpp重写两边通过带extern C的头文件对接。这样编译器报错的范围可控出问题也容易定位。提示迁移前先做一次全量备份并且保证每一步都能编译通过、能跑。一次改二十个文件再看编译结果是给自己找麻烦。5.2 运行期的三个隐形陷阱编译过了不代表跑得对运行期有三个坑我印象最深。第一个是全局对象构造函数里访问硬件前面讲过现象是上电直接 HardFault堆栈里能看到调用出现在.init_array阶段处理方式是两段式初始化。第二个是缺volatile我在一个 DMA 传输完成标志上踩过主循环里while (!dma_done);等待因为标志是普通变量且循环体内没别的操作编译器把读操作提到了循环外面直接变成死循环。加上volatile之后正常但更好的写法是配合 CMSIS 的内存屏障指令比如__DMB()尤其是涉及外设寄存器和共享缓冲区的地方。第三个是中断和主循环之间的临界区。我在一个环形缓冲区上加写了一版主循环读、中断写head和tail两个索引看起来各自独立实际上存在一个读到一半被中断打断的窗口偶尔丢一帧数据概率大概是几千分之一查了整整两天。最后用前面说的IrqLock把索引更新包了起来问题消失。这里的心得是只要数据同时出现在中断和主循环里别管它看起来多简单先想清楚需不需要保护再决定用volatile还是临界区还是两者都要。5.3 给自己定下的几条工程纪律用了两年多我给自己列了一份清单贴在工程目录的 README 里新同事进来先看这个不用new/delete确需动态构造就用 placement new 配静态存储不用 STL 容器array、algorithm、cstdint、type_traits、cstring这些零开销头文件随便用不开异常、不开 RTTI错误一律用返回值或错误码表达所有全局对象只做纯数据初始化外设操作统一走init()中断服务函数一律extern C函数体不超过五行跨中断共享的数据要么volatile要么进临界区不存在应该没事这种情况头文件里不放非inline的函数定义和变量定义。这些纪律里前三条是限制后四条是习惯。真正让我觉得 C 在嵌入式里值得用的不是某一条语法特性而是它们共同构成了一种让编译器替我值班的环境类型写错了编译不过、参数顺序反了编译不过、资源忘了释放会在析构里自动处理、寄存器地址算错了会被static_assert拦住。最后分享一个我自己用着挺顺手的小技巧在工程的conf.hpp里放一组static_assert把整块板子的关键约束写死——时钟频率、缓冲区大小是不是 2 的幂、结构体的对齐和大小、sizeof(void*)是不是 4。这些东西平时靠注释和文档维持一旦有人改了时钟树或者某个宏编译期立刻炸掉比运行期出玄学问题省事太多。我那个 CAN 采集板的结构体就是从static_assert(sizeof(CanFrame) 16, 帧格式变了别忘了同步上位机)这句话开始稳定下来的。后面我打算把报文解析那部分模板化的设计单独拆一篇写那才是 C 在这类工程里最出彩的地方。
返回列表