C++在单片机这条路上,我一直是坚定的实践派。做嵌入式这些年,最常被问的问题就是:单片机资源这么紧,C语言已经是事实标准,为什么还要折腾C++?尤其是当我用C++去写STM32工程的时候,总有朋友觉得这是在炫技。其实真不是。去年我接手一个两年前用C写的温控器项目,两千多行代码里堆了四十多个全局状态变量,每加一个功能就要翻半天代码找影响面,改一个bug能带出两个新bug。后来我用C++重写核心逻辑,代码量几乎砍半,新同事接手时理解的效率高了很多。这篇是C++在单片机应用系列的第二篇,上一篇聊过的环境准备就不重复了,这篇直接上硬货:怎么用面向对象去封装寄存器驱动,怎么用轻量状态机管理外设逻辑,以及一个完整跑通的DHT11加LCD1602小案例。如果你正在受C语言的全局变量、标志位撕扯之苦,这篇应该能给你打开一扇新门。
1. 为什么在单片机上用C++
1.1 不是炫技:C++解决的是工程痛点
很多单片机工程师对C++的第一反应是“用不上”“太高级”。但真正在项目里被C语言折磨过的人会明白,C++在单片机上的价值从来不是语法新潮,而是把“一团乱麻”的工程变得有边界、有层次、可维护。
传统C语言写单片机程序,最常见的形态是一个while(1)大循环,里面塞了按键扫描、数码管刷新、传感器读取、串口处理、报警输出。各个模块之间的通信完全靠全局变量和标志位。功能少的时候这套勉强能用,功能一多就成了意大利面条代码。一个温控器哪怕只有三个按键、一路温度采集、一个继电器输出,状态标志位一多,逻辑就开始互相牵扯。你改了一处标志位,可能影响三处读取它的地方,而这三处在不同的源文件里,靠肉眼很难全部找到。
C++带来的第一个改变是“分组明确”。
比如温度传感器,你可以把它封装成一个对象,这个对象的接口只有read、calibrate、shutdown,内部有哪些寄存器、多少中间状态,外面一概不关心。调用方不再需要知道传感器用的是I2C还是单总线,也不需要在主循环里到处维护传感器的状态标志位。这个思路并不只是“面向对象”的学术概念,它直接影响了代码该怎么组织、bug会出现在哪里、改功能时要去改哪个文件。
还有一个被低估的好处是模板。传统C代码想要实现“通用”,多半靠函数指针或者一堆宏,但宏一旦复杂起来,调试的时候你会怀疑人生。C++的模板是在编译期展开的,没有运行时代价,却比宏安全得多。这一点后面细聊。
我并不是说C++就比C性能好。如果用虚函数滥用、用动态内存、用异常,C++甚至会比C更慢更占空间。但在嵌入式工程里,真正耗时间的往往是外设等待、协议解析和循环算法,函数调用方式那点差异在一个72MHz主频的芯片上根本感知不到。工程上的收益,远比微观性能重要。
1.2 哪些C++特性在单片机上真正有用,哪些别碰
在单片机这种资源受限环境里用C++,不能把桌面端的开发习惯全搬过来。我在项目里总结了一张“白名单/黑名单”,分享给大家做个参考。
先看白名单:
- 类与封装:把GPIO、UART、传感器封装成类。这是C++在单片机上的核心价值。不需要虚函数,就是简单的数据成员加成员函数。
- 枚举类(enum class):比#define或者裸enum安全得多,命名冲突少,switch的时候不会被编译器容忍漏掉分支。
- 命名空间:不同模块之间的全局名不会互相覆盖,比如两个模块都有TxBuffer,谁污染谁一目了然。
- 模板:模板是编译期多态,没有运行时代价。比如一个环形缓冲区,用模板定义容量,每个模块需要的深度不同,编译器会生成对应版本,性能和手写的一样好。
- constexpr与static_assert:把能放到编译期计算的放到编译期,把约束条件直接写死在代码里,不满足就编译失败。这类代码运行时不会有任何代价。
- 引用:传入大型结构体时比指针更安全,至少在语义上表达“这个参数我要用但不打算改它”,用const引用又能防手滑改坏。
再看黑名单:
- 异常:几乎所有的嵌入式编译器默认都关闭异常支持,栈展开的成本和代码膨胀在MCU上吃不消。遇到错误就用错误码或枚举返回值。
- RTTI:dynamic_cast、typeid在MCU上不实用,RAM和代码段都有额外开销。C++标准里这些默认也该关掉。
- new/delete随意用:堆碎片化在长时间运行的产品里是噩梦。稍微跑几天,明明单片机的RAM还有几百字节,new却拿不到一块连续内存。能不用就不用,后面我会讲替代方案。
- 虚函数滥用:虚表指针每个对象多4字节,调用变成间接跳转。一个系统里用一两个虚函数问题不大,满屏都是virtual,调试起来真的酸爽。
- 大型STL容器:std::vector、std::string这种会动态分配内存的容器不要在MCU上用。但std::array、std::span这类零开销轻量模板可以用得很舒服。
这套规则我用了三年,一直很稳。核心原则就一句话:能用编译期解决的,绝不拖到运行时;能用静态内存解决的,绝不用堆。
2. 用面向对象写寄存器驱动:从GPIO到外设类
2.1 先写一个可复用的GPIO类
搞单片机的人都知道,GPIO是几乎所有外设的地基。按键要靠它、LED要靠它、模拟单总线要靠它、LCD也要靠它。以前写C,每次初始化都要对着参考手册翻寄存器,开了时钟开引脚,又要配模式又要配速度,遇到PIN7还可能是在CRH寄存器上,一不留神写错偏移量,外设就是不工作。
用C++做GPIO封装,第一个目标就是让这些配置一次性、无歧义地完成。我在STM32 F1系列上写过一个简单的版本:
#include <cstdint> class GpioPin { public: enum class Mode : uint8_t { InputFloating = 0x04, // 输入浮空 InputPullUpDown = 0x08, // 输入上拉/下拉 OutputPushPull = 0x02, // 推挽输出 AlternatePushPull = 0x0A, // 复用推挽 Analog = 0x00 }; enum class Speed : uint8_t { Low = 0x02, Medium = 0x01, High = 0x03 }; GpioPin(GPIO_TypeDef* port, uint16_t pin, Mode mode, Speed speed = Speed::Low) : port_(port), pin_(pin) { configure(mode, speed); } void setHigh() const { port_->BSRR = pin_; } void setLow() const { port_->BRR = pin_; } void toggle() const { port_->ODR ^= pin_; } bool read() const { return (port_->IDR & pin_) != 0; } // 动态切换模式,DHT11这类单总线设备会用到 void setMode(Mode mode, Speed speed = Speed::Low) { configure(mode, speed); } private: GPIO_TypeDef* port_; uint16_t pin_; void configure(Mode mode, Speed speed) { // 这里假定RCC时钟已经在外部统一打开 uint8_t index = 0; uint16_t temp = pin_; while ((temp & 0x01u) == 0u) { temp >>= 1u; ++index; } volatile uint32_t* configReg = (index < 8) ? &port_->CRL : &port_->CRH; uint8_t offset = (index & 0x07u) * 4u; uint32_t config = static_cast<uint32_t>(mode) | (static_cast<uint32_t>(speed) << 2u); *configReg &= ~(0x0Fu << offset); *configReg |= (config << offset); } };这段代码的价值在哪?第一,Mode和Speed用枚举类定义,写错了编译器会直接报错,不会像数字常量那样错得无声无息。第二,构造函数里一次性完成配置,后面要用的只是一个清晰的对象。代码里动态计算引脚序号,会自动区分CRL和CRH,不需要你自己判断是PIN_0还是PIN_7。
使用的时候就是这样:
GpioPin ledPin(GPIOB, GPIO_PIN_1, GpioPin::Mode::OutputPushPull, GpioPin::Speed::Low); GpioPin btnPin(GPIOA, GPIO_PIN_0, GpioPin::Mode::InputPullUpDown); ledPin.setHigh(); if (btnPin.read()) { ... }和C语言相比,函数名变成统一风格,端口、引脚、模式都在构造函数里一眼看全。更关键的是,一个GpioPin对象是带状态的,把它传给DHT11类、LCD类时,外设就知道自己操作的是哪个引脚,不再需要一个全局Pin变量到处exter。
如果还想再省一点RAM,可以上模板版本。模板GPIO把端口和引脚号变成编译期参数,对象本身甚至不占用RAM:
template <auto Port, uint16_t Pin, GpioPin::Mode Mode> class StaticGpio { public: static void setHigh() { Port->BSRR = Pin; } static void setLow() { Port->BRR = Pin; } static bool read() { return (Port->IDR & Pin) != 0; } };这个版本的核心思想是:这些外设地址本来就是编译期常量,何必在运行期用一个成员变量存着?写成模板后,每次调用都是直接寄存器访问,编译器会全部内联,性能和写裸寄存器没有区别。代价是Pin、Port、Mode成了类型的一部分,不再支持运行时动态配置,但对大多数产品来说,引脚在硬件设计阶段就定死了,运行时根本不需要变。
2.2 外设组合:把DHT11和LCD1602封装成对象
GPIO类的价值,要组合起来才真正体现。一个DHT11温湿度传感器,一根数据线,靠单总线协议通信;一个LCD1602屏幕,至少需要RS、EN、D4~D7六根线,如果用8位模式还要再加四根。用C语言写,这些引脚的初始化散落在不同函数里,每次用的时候都要回忆哪个引脚在哪个端口上。用C++,把它们组合成类,这个痛苦就消失了。
先看DHT11的类骨架:
class Dht11 { public: enum class Error : uint8_t { Ok, NoResponse, Timeout, ChecksumFail }; explicit Dht11(GpioPin* pin) : pin_(pin) {} Error read(float& humidity, float& temperature); private: GpioPin* pin_; bool waitLevel(bool level, uint32_t timeoutUs); };再看LCD1602的类骨架:
class Lcd1602 { public: Lcd1602(GpioPin* rs, GpioPin* en, GpioPin* d4, GpioPin* d5, GpioPin* d6, GpioPin* d7) : rs_(rs), en_(en), d4_(d4), d5_(d5), d6_(d6), d7_(d7) { initialize(); } void show(uint8_t row, uint8_t col, const char* fmt, ...); void clear(); private: GpioPin *rs_, *en_, *d4_, *d5_, *d6_, *d7_; void writeNibble(uint8_t nibble); void writeCommand(uint8_t cmd); void writeData(uint8_t data); void pulseEnable(); void initialize(); };这样一组合,main函数里的全局状态就变得非常干净:
GpioPin dhtDataPin(GPIOC, GPIO_PIN_3, GpioPin::Mode::OutputPushPull, GpioPin::Speed::High); GpioPin lcdRsPin(GPIOB, GPIO_PIN_0, GpioPin::Mode::OutputPushPull); GpioPin lcdEnPin(GPIOB, GPIO_PIN_1, GpioPin::Mode::OutputPushPull); GpioPin lcdD4Pin(GPIOB, GPIO_PIN_2, GpioPin::Mode::OutputPushPull); GpioPin lcdD5Pin(GPIOB, GPIO_PIN_3, GpioPin::Mode::OutputPushPull); GpioPin lcdD6Pin(GPIOB, GPIO_PIN_4, GpioPin::Mode::OutputPushPull); GpioPin lcdD7Pin(GPIOB, GPIO_PIN_5, GpioPin::Mode::OutputPushPull); Dht11 dht(&dhtDataPin); Lcd1602 lcd(&lcdRsPin, &lcdEnPin, &lcdD4Pin, &lcdD5Pin, &lcdD6Pin, &lcdD7Pin);对象之间用指针组合,是嵌入式里最常见的模式,因为它天然不涉及生命周期问题。这里有个隐藏的坑:这些全局对象的构造函数会在main之前执行。如果你在构造函数里访问了还没开时钟的寄存器,初始化就可能失败甚至触发HardFault。解决办法我放到第5节讲,这里先记住这个结论。
3. 事件驱动与可扩展架构:任务调度和状态机的C++化
3.1 写一个轻量协作式调度器
单片机程序离不开“定期干活”这个需求:每1ms扫描一次按键,每10ms刷新一次数码管,每100ms读一次温度,每500ms把状态上报到串口。用C语言的话,最原始的办法是在大循环里做时间差判断,写多了就变成一堆散落的if (now - lastMs >= xxx)判断,时间常量还各写各的,维护起来想哭。
与其让每个模块自己做时间管理,不如写一个轻量调度器,把周期任务统一管理起来。我常用的是协作式调度器,代码量不大,功能在多数产品里完全够用:
#include <cstdint> using TaskHandler = bool (*)(void*); struct Task { TaskHandler handler = nullptr; void* param = nullptr; uint32_t intervalMs = 0; uint32_t lastRunMs = 0; bool active = false; }; class Scheduler { public: static constexpr uint8_t kMaxTasks = 8; bool addTask(TaskHandler handler, void* param, uint32_t intervalMs) { for (uint8_t i = 0; i < kMaxTasks; ++i) { if (tasks_[i].handler == nullptr) { tasks_[i].handler = handler; tasks_[i].param = param; tasks_[i].intervalMs = intervalMs; tasks_[i].lastRunMs = 0; tasks_[i].active = true; return true; } } return false; } void run() { while (true) { uint32_t now = getMillis(); for (uint8_t i = 0; i < kMaxTasks; ++i) { Task& task = tasks_[i]; if (!task.active || task.handler == nullptr) continue; if (now - task.lastRunMs >= task.intervalMs) { task.lastRunMs = now; // handler 返回 false 表示该任务执行完毕,自动注销 if (!task.handler(task.param)) { task.active = false; } } } } } private: Task tasks_[kMaxTasks] = {}; static uint32_t getMillis(); // 通常用SysTick实现 };这里几个细节是实战经验:
为什么用函数指针而不是std::function?因为std::function在MCU上可能引入堆分配或者巨大的栈开销。函数指针虽然不支持捕获lambda,但嵌入式场景下配合一个全局或静态上下文指针就足够了。
为什么用now - task.lastRunMs >= task.intervalMs而不是now >= task.lastRunMs + task.intervalMs?因为32位无符号数在溢出时,减法依然能给出正确的时间差,这是嵌入式时间判断的标准写法。
为什么任务数组大小固定在8?因为协作式调度器本身不应该变成万能调度器,超过8个周期任务时,多半说明你这个系统的任务粒度划得太细了,该考虑合并,或者该上RTOS了。这个上限其实是我故意加的约束,它逼着你把任务合并成更合理的大块。
任务函数长这样:
static bool scanKeyTask(void* ctx) { auto* s = static_cast<KeyScanner*>(ctx); s->scan(); return true; // true表示任务继续运行 }这个方案在STM32上跑得很稳,RAM消耗就一个数组,代码量极小。如果你跑的是C51那种RAM只有256字节的老芯片,也可以用类似思路,只是把数组缩小,周期常量用uint16_t就够。
3.2 状态机:让逻辑可读可测
单片机程序里最绕的逻辑,往往是按键操作、菜单跳转、协议解析、复合外设的时序流程。这类逻辑有一个共同特点:它们的执行顺序取决于“当前处于什么状态”和“发生了什么事件”。用C语言写,最容易堆成嵌套if,层层套下去,代码超过三层,人脑基本就转不动了。
状态机的价值在于它逼你把所有可能的状态列出来,把所有可能的事件列出来,然后逐个决定状态转移。这样写出来的程序,逻辑可读性极强,也方便做单元测试。
C++的enum class在这里可以派上大用场。我以一个温湿度采集流程为例:按键按下后,从空闲态进入采集等待态,DHT11读取成功后跳到显示态,显示完回到空闲态;读超时进入错误态,错误态下再按一次按键重新尝试。
class HumiStateMachine { public: enum class State : uint8_t { Idle, Waiting, Showing, Error }; enum class Event : uint8_t { KeyPressed, ReadDone, ReadTimeout, DisplayDone }; void dispatch(Event event) { State next = current_; switch (current_) { case State::Idle: if (event == Event::KeyPressed) next = State::Waiting; break; case State::Waiting: if (event == Event::ReadDone) next = State::Showing; if (event == Event::ReadTimeout) next = State::Error; break; case State::Error: if (event == Event::KeyPressed) next = State::Waiting; break; case State::Showing: if (event == Event::DisplayDone) next = State::Idle; break; } if (next != current_) { exitState(current_); current_ = next; enterState(current_); } } State getState() const { return current_; } private: State current_ = State::Idle; void enterState(State state) { // 进入Waiting时启动DHT11异步读取 // 进入Showing时刷新LCD // 进入Error时在LCD显示错误码 } void exitState(State state) { // 离开State::Showing时清屏 } };有人可能会问,为什么不用函数指针的状态转移表?那确实更灵活,但代价是表格数据放在RAM里(或者要仔细放Flash里),阅读起来不如switch直观。在MCU这种小项目里,switch版本不仅代码量小,编译器还能优化成跳转表,而且调试时单步看状态流转非常清楚。过度工程化在单片机上是种灾难,够用就好。
状态机事件从哪里来?可以配合上一节的调度器。比如调度器每50ms扫描一次按键,ScanKeyTask里检测到按下降沿就调用humiSm.dispatch(Event::KeyPressed)。这样按键检测、状态逻辑、外设驱动三者完全解耦,改任何一个都不影响另外两个。
4. 实战:DHT11温湿度采集加LCD1602显示的完整C++实现
4.1 DHT11时序的C++实现细节
DHT11是一个单总线温湿度传感器,数据格式固定:湿度整数、湿度小数、温度整数、温度小数、校验和,五个字节按顺序送出来。这块芯片的时序不复杂,但对时序细节比较敏感,用C++封装时好多人栽在同一处:方向切换。
DHT11通信大概是这样的:主机先把总线拉低至少18ms,然后拉高,从机检测到上升沿后会先拉低80us再拉高80us作为响应,之后每一位数据以50us低电平作为前导,高电平26~28us表示0,70us表示1。
主机拉低18ms之后,必须立刻把引脚从输出模式切回输入模式,否则从机的响应拉低会和你自己的输出冲突,根本读不到数据。这就是为什么我在前面的GpioPin类里专门留了一个setMode方法。再看DHT11类的完整read实现:
#include <cstdint> #include "GpioPin.h" class Dht11 { public: enum class Error : uint8_t { Ok, NoResponse, Timeout, ChecksumFail }; explicit Dht11(GpioPin* pin) : pin_(pin) {} Error read(float& humidity, float& temperature) { // 1. 主机触发:拉低至少18ms,然后拉高 pin_->setMode(GpioPin::Mode::OutputPushPull, GpioPin::Speed::High); pin_->setLow(); delayMicroseconds(18000); pin_->setHigh(); // 关键一步:释放总线,切回输入模式 pin_->setMode(GpioPin::Mode::InputFloating); delayMicroseconds(30); // 2. 等待从机响应:先80us低电平,再80us高电平 if (!waitLevel(false, 100)) return Error::NoResponse; if (!waitLevel(true, 100)) return Error::NoResponse; // 3. 读取40位数据 uint8_t data[5] = {0}; for (int i = 0; i < 40; ++i) { if (!waitLevel(false, 100)) return Error::Timeout; uint32_t width = 0; uint32_t timeout = 100; while (pin_->read() && timeout--) { delayMicroseconds(1); ++width; } if (width == 0) return Error::Timeout; data[i / 8] <<= 1; // 高电平宽度超过40us按1处理,否则按0处理 if (width > 40) data[i / 8] |= 1u; } // 4. 校验:前四个字节的和低8位应等于第五个字节 if (static_cast<uint8_t>(data[0] + data[1] + data[2] + data[3]) != data[4]) { return Error::ChecksumFail; } humidity = static_cast<float>(data[0]) + data[1] / 10.0f; temperature = static_cast<float>(data[2]) + data[3] / 10.0f; return Error::Ok; } private: GpioPin* pin_; bool waitLevel(bool level, uint32_t timeoutUs) { uint32_t count = 0; while (pin_->read() != level) { if (++count >= timeoutUs) return false; delayMicroseconds(1); } return true; } };这里有个很微妙的地方:DHT11的时序窗口是以微秒为单位的,而MCU的SystemCoreClock是72MHz,一次while判断加一次delayMicroseconds的延时代价并不固定,所以实际读取高电平宽度时存在一定误差。不过DHT11的1和0差别还算明显,40us的判定阈值留出了比较宽裕的余量,实测稳定。
踩过的坑要提醒一下:
- 不要在DHT11读取期间关中断或者做长时间阻塞。有的同事喜欢在printf到串口或堵塞式EEPROM操作前调用DHT11.read,结果在一半的机子上读到校验错误。原因很简单:时序被打断,从机把0位当1位或者反过来。
- 延时函数一定要校准。如果你用的是裸循环那类的粗糙延时,建议先用逻辑分析仪看看18ms和30us的实际长度是否准确,否则一切免谈。
- 如果线路较长,数据线最好接一个4.7k到10k的上拉电阻,并且把20MHz的GPIO速度改为High甚至VeryHigh,否则上升沿太慢,时序会被拉歪。
4.2 LCD1602的类封装与显示逻辑
LCD1602老归老,项目里出镜率依然很高。它支持8位和4位两种并行模式,为了省GPIO,我一般用4位模式,只接RS、EN、D4~D7六根线。4位模式下,每个字节被拆成高4位和低4位分别发送,初始化时序和命令字都要特别注意。
LCD1602的类实现里,最核心的是几个私有方法:
void Lcd1602::writeNibble(uint8_t nibble) { d4_->setHigh((nibble & 0x01) != 0); d5_->setHigh((nibble & 0x02) != 0); d6_->setHigh((nibble & 0x04) != 0); d7_->setHigh((nibble & 0x08) != 0); pulseEnable(); } void Lcd1602::pulseEnable() { en_->setHigh(); delayMicroseconds(2); en_->setLow(); delayMicroseconds(2); } void Lcd1602::writeCommand(uint8_t cmd) { rs_->setLow(); // 命令模式 writeNibble(cmd >> 4); // 发高4位 writeNibble(cmd & 0x0F); // 发低4位 delayMicroseconds(80); // LCD到命令执行需要时间 } void Lcd1602::writeData(uint8_t data) { rs_->setHigh(); // 数据模式 writeNibble(data >> 4); writeNibble(data & 0x0F); delayMicroseconds(80); }初始化时序在4位模式下有固定套路,顺序不能乱:
void Lcd1602::initialize() { delayMilliseconds(50); // 上电等待 // 三条0x03指令让LCD进入8位模式并稳定 writeNibble(0x03); delayMilliseconds(5); writeNibble(0x03); delayMilliseconds(5); writeNibble(0x03); delayMicroseconds(150); writeNibble(0x02); // 切换到4位模式 writeCommand(0x28); // 4位总线、2行、5x7字体 writeCommand(0x0C); // 显示开、光标关、闪烁关 writeCommand(0x06); // 写入后地址自动加一,画面不移动 writeCommand(0x01); // 清屏 }为什么初始化时先发三条0x03而不是直接发0x28?因为上电瞬间LCD可能还处在8位模式的某个中间状态,只有连续发三条0x03才能把它稳定地带到8位模式入口,然后通过0x02切到4位模式。这个顺序是HD44780控制器手册明确规定的,少一步或者顺序错一步,LCD就会白屏或者乱码。
显示函数我习惯做成可变参数的格式化接口,工程里写起来很方便:
void Lcd1602::show(uint8_t row, uint8_t col, const char* fmt, ...) { char buffer[32]; va_list args; va_start(args, fmt); vsnprintf(buffer, sizeof(buffer), fmt, args); va_end(args); // 设置DDRAM地址:第一行0x00,第二行0x40 uint8_t addr = (row == 0) ? (0x00 + col) : (0x40 + col); writeCommand(0x80 | addr); while (*buffer) { writeData(static_cast<uint8_t>(*buffer++)); } }这个实现有个小陷阱:vsnprintf在标准库中体积不小,如果你对Flash空间敏感,不要轻易引入完整printf系列函数。STM32F103C8的64KB Flash通常够用,但在一些Flash只有8KB的老型号上,就要再三斟酌。我自己在资源紧张的板子上会换用一种极简的整数格式化函数,手写转字符串,体积能省下好几K。
到这里,把Dht11和Lcd1602两个类放在一起,在main里配合上一节的状态机,一个完整的小气象站就能跑起来了。每50ms扫描一次按键,按下就触发一次读取,读成功后在LCD上同时显示温度和湿度。
5. 资源受限下的优化与避坑指南
5.1 编译器开关与语言子集
在单片机上用C++,第一件事不是写代码,而是设置好编译器选项,把不需要的语言特性关掉。我习惯在CMake里这样配置:
target_compile_options(firmware PRIVATE -std=c++17 -fno-exceptions -fno-rtti -fno-threadsafe-statics -ffunction-sections -fdata-sections -Wall -Wextra )逐条说明:
-std=c++17:现在arm-none-eabi-gcc和Keil AC6(armclang)都支持C++17,足够用了。C++20的一些特性在MCU编译器上支持还不完整,不建议强行追新。-fno-exceptions:关闭异常。嵌入式环境通常没有足够的栈和代码空间支持它。-fno-rtti:关闭运行时类型识别。省掉typeid和dynamic_cast背后那张类型信息表。-fno-threadsafe-statics:关闭局部静态变量初始化的线程安全保护。MCU上即使跑RTOS,也不是所有地方都需要这种保护,关掉后局部静态变量初始化代码变薄,还能减少代码体积。这个选项很多人不知道,但对嵌入式C++很有价值。-ffunction-sections -fdata-sections:配合链接器的--gc-sections,把没用到的函数和数据段从最终固件里剔除,特别能压体积。
有了这套基础,再用前面讲的白名单写代码,基本不会踩到什么全局性的坑。
5.2 内存、代码膨胀与链接脚本
C++在单片机上的一个隐性成本是全局对象的构造机制。C++标准规定,非局部static对象的构造函数在main之前执行,这是由startup代码里的__libc_init_array函数调用的。问题来了:如果你把一个GpioPin对象定义为全局变量,它的构造函数会在main之前执行,而那一刻RCC外设时钟可能还没打开,你写GPIO的CRL/CRH寄存器完全是白写,甚至会访问无效地址导致HardFault。
这是我见过的C++写单片机项目最典型的翻车现场。解决办法有三个,按推荐程度排序:
第一,全局对象尽量用函数内的局部静态变量替代。C++11以后局部静态变量的初始化是线程安全的(在-fno-threadsafe-statics下仍然保证只初始化一次),并且是第一次执行到那条语句时才构造。换句话说,把对象放到调用点处再创建,而不是在main之前创建,时序就完全可控。
第二,如果必须用全局对象,就在构造函数里先判断时钟是否已使能,没使能就先开启。这种做法会引入跨模块依赖,我不太推荐。
第三,干脆所有外设对象都在main函数内部创建,通过引用或指针传递给使用方。大家都是在main里按顺序初始化时钟、初始化GPIO、再构造具体外设对象,就不会有时序问题。
内存布局方面,C++编译出来的固件段和C几乎没有区别,.text是代码,.rodata是只读常量,.data是已初始化全局数据,.bss是零初始化数据。可以用命令直接看:
arm-none-eabi-size build/app.elf输出会告诉你每个段占多少字节。如果发现.rodata异常膨胀,多半是某个库函数被带进来了,比如printf系、浮点格式化,或者是你代码里隐藏着大量编译器生成的常量表。这时候用下面这行命令排序看符号,找出大头:
arm-none-eabi-nm -S -n build/app.elf | sort -k2 -r | head -30另一个实用技巧是在链接脚本里关闭动态内存:
.bss (NOLOAD) : { ... __heap_start = .; __heap_end = .; }其实更好的做法是:链接脚本里不分配堆,或者只给极小的堆。一旦堆被掐掉,new会直接失败,你就不得不采用静态对象池方案。比如前面调度器的Task数组、环形缓冲区的存储空间,都用静态数组预分配。这种强制约束在工程上是好事,它逼着你提前把每个模块需要多少内存规划清楚,而不是指望运行时的迷你malloc。
5.3 调试工具链:用VSCode打造现代C++单片机开发环境
很多老工程师还停留在Keil里点点鼠标的年代,但对于C++工程,Keil MDK的编辑体验确实差了些。AC5编译器对C++的支持停在C++03的程度,很多模板和constexpr写法都无法用;AC6编译器(armclang)支持C++17,但Keil的编辑器对现代C++的语法高亮和智能补全仍然一般。我现在的主力环境是VSCode加CMake加arm-none-eabi-gcc,配合J-Link的SWD调试。
VSCode里最需要注意的是c_cpp_properties.json的配置,用对之后,Ctrl+点击跳转、智能补全、错误波浪线都非常流畅:
{ "configurations": [ { "name": "STM32F103", "includePath": [ "${workspaceFolder}/Core/Inc", "${workspaceFolder}/Drivers/STM32F1xx_HAL_Driver/Inc", "${workspaceFolder}/Drivers/CMSIS/Device/ST/STM32F1xx/Include", "${workspaceFolder}/Drivers/CMSIS/Include" ], "defines": [ "STM32F103x6", "USE_HAL_DRIVER" ], "compilerPath": "/usr/bin/arm-none-eabi-g++", "cppStandard": "c++17" } ], "version": 4 }注意一个细节:编译器路径要指向arm-none-eabi-g++而不是gcc,否则VSCode的C++插件会把C++代码当成C来解析,宏和语法高亮全乱套。
调试方面,我用J-Link加Ozone比较多。Ozone能直接加载ELF文件里的符号表,反汇编窗口配合寄存器和内存视图,分析HardFault很方便。另一个轻量方案是SEGGER RTT,在代码里放一个RTT通道,用J-Link的RTT Viewer实时打印日志。RTT的好处是几乎不占UART资源,打印速度极快,而且不会像串口printf那样长时间阻塞影响时序。
如果你手头没有J-Link,只有ST-Link,可以直接用STM32CubeProgrammer生成代码后,在VSCode里配合Cortex-Debug插件调试。CoreSight SWD调试协议是标准的,ST-Link和J-Link都能用,只是调试性能和附加功能差一些。注意下载程序时如果多次失败,优先检查BOOT0引脚电平和供电稳定性,这是ST芯片下载失败最常见的原因,和调试器本身反而关系不大。
模板和constexpr在VSCode环境里编译和调试都比Keil顺畅不少,这也是我坚持用这套工具链的原因之一。很多在Keil AC5下只能用宏模拟的通用逻辑,用C++模板写出来之后,代码可读性立马上一个台阶。
一个真实项目的改造体会
前阵子帮朋友看一个温湿度控制器,功能很简单:定时采集DHT11数据、LCD1602显示、一个继电器控制加热、两个按键设阈值。他用C语言写了两千多行,全篇都是flag变量和delay嵌套,改一个按键去抖逻辑要翻三个文件。我帮他把核心逻辑按C++重写,外设封装成类、按键检测放到调度器、主逻辑改成状态机,最终代码不到八百行,功能完全一致,但新逻辑的可读性不可同日而语。最直观的变化是,他把板子拿回来继续加功能时,不再需要问“这个全局变量现在在哪里改”了。
如果你打算在自己项目里试C++,建议别一次推倒重来。可以先从一个外设封装开始,比如用类封装一个I2C传感器,把原先散落的寄存器操作收拢起来。跑通之后,再加一个调度器,把周期任务梳理清楚。最后再上状态机。每走一步都能看到代码结构的变化,也能逐渐积累适合自己芯片和工具链的C++经验。
后续如果大家有兴趣,我可以接着写第三篇,聊聊C++和RTOS结合时要注意的线程安全问题,以及怎么用模板实现零拷贝的串口收发缓冲。单片机上的C++还有不少好玩的点,慢慢来。