1. 从裸机寄存器到C++抽象:为什么要在单片机上折腾C++
很多人第一次听到"用C++写单片机"时的反应都差不多:51那点RAM和Flash,跑C都紧巴巴的,还上C++?这不是自找麻烦吗。我刚开始也是这个想法,直到在一个STM32F103C8T6的项目里,用C写了一套状态机加环形缓冲区加命令解析,代码写到两千多行的时候,改一个功能要在五六个文件里来回跳,才真正动了用C++重构的念头。
先说清楚一个前提:单片机上的C++和PC上的C++完全是两码事。PC上你随手一个std::vector、一个std::string、一个异常抛出,背后是操作系统、堆分配器、异常展开表在撑着。单片机上这些东西要么没有,要么代价大到你不能接受。所以本文讨论的"C++在单片机上的应用",核心不是让你把PC那套STL搬过来,而是用C++的语法特性去组织裸机代码,让代码更清晰、更不容易出错,同时不引入额外的运行时开销。
具体来说,C++在单片机上真正有价值的东西是这几样:类与封装(把外设操作打包成对象)、模板(编译期多态,零运行时开销)、命名空间(避免全局符号冲突)、constexpr(编译期计算,不占运行时)、引用(比指针更安全的参数传递)、构造函数与析构函数(资源自动初始化与释放)。而需要谨慎使用的是:虚函数(有vtable开销)、异常(大多数嵌入式工具链默认关闭)、动态内存(new/delete、STL容器)、RTTI(运行时类型识别)。
我实测下来,在STM32F103C8T6(72MHz、64KB Flash、20KB RAM)上,用C++写的GPIO封装、UART驱动、状态机框架,编译出来的固件比等价的C代码大约多出3%到8%的Flash占用,RAM占用基本持平。这个代价换来的是代码可读性和可维护性的大幅提升,我认为是值得的。但如果你用的是STC89C52这种只有8KB Flash、512字节RAM的51单片机,那就得掂量一下了——不是不能写,而是每一点开销都要精打细算。
注意:本文所有代码示例基于ARM Cortex-M平台(以STM32F103C8T6为主),工具链为arm-none-eabi-gcc。51单片机的C++支持情况会在后面单独讨论。
2. 工具链配置:让g++认识你的单片机
2.1 编译器选型与关键编译选项
在单片机上用C++,第一步不是写代码,而是把工具链配好。很多人卡在这一步就放弃了,因为默认的编译选项会让你的固件体积爆炸。
我用的是arm-none-eabi-g++,这是GCC的ARM嵌入式版本,和arm-none-eabi-gcc是同一套工具链,只是前端换成了C++。关键编译选项如下:
arm-none-eabi-g++ -mcpu=cortex-m3 -mthumb \ -fno-exceptions -fno-rtti \ -fno-threadsafe-statics \ -fno-use-cxa-atexit \ -Os -ffunction-sections -fdata-sections \ -Wall -Wextra \ -c main.cpp -o main.o逐个解释这些选项为什么必须加:
-fno-exceptions:关闭异常。嵌入式环境下异常展开表会占用大量Flash,而且大多数RTOS和裸机环境根本没有异常处理机制。关掉之后try/catch/throw都不能用,编译会直接报错,这反而是好事——强制你不用异常。-fno-rtti:关闭运行时类型识别。dynamic_cast和typeid都不能用,省掉vtable里的类型信息。-fno-threadsafe-statics:关闭局部静态变量的线程安全保护。GCC默认会为函数内的static局部变量加锁保护,在裸机上这层保护是多余的,而且会引入__cxa_guard_acquire等符号,链接时可能报错。-fno-use-cxa-atexit:关闭全局对象的析构注册。默认情况下GCC会为全局对象的析构函数注册atexit调用,但单片机上程序永远不会"正常退出",这层注册纯属浪费。-Os:优化体积。单片机Flash通常比RAM宽裕,但也不富裕,-Os在大多数情况下比-O2更合适。-ffunction-sections -fdata-sections:每个函数和数据放到独立的段,配合链接器的--gc-sections可以剔除未使用的代码。
链接选项:
arm-none-eabi-g++ -mcpu=cortex-m3 -mthumb \ -Wl,--gc-sections \ -Wl,-Map=output.map \ -T stm32f103c8t6.ld \ -nostartfiles \ start.o main.o driver.o -o firmware.elf--gc-sections配合前面的-ffunction-sections -fdata-sections,能把没引用到的函数整个删掉。我实测过一个项目,加了这两个选项之后Flash占用从48KB降到了31KB,效果非常明显。
2.2 启动文件与C++运行时的对接
C语言的启动文件(startup_stm32f103xb.s)通常只调用SystemInit和main。C++需要额外的运行时初始化,主要是全局对象的构造函数调用。GCC的C++运行时会在.init_array段里存放全局构造函数指针,启动文件需要遍历这个段并逐个调用。
标准做法是在启动文件的复位处理函数里,在调用main之前加上:
ldr r0, =_sinit ldr r1, =_einit movs r3, #0 b LoopCopyInit CopyInit: ldr r2, [r0, r3] str r2, [r4, r3] adds r3, r3, #4 LoopCopyInit: adds r4, r0, r3 cmp r4, r1 bcc CopyInit FillZerobss: ; ... 清零bss段 ... CallInit: ldr r0, =_sinit ldr r1, =_einit cmp r0, r1 beq CallMain bl CallConstructors CallConstructors: ; 遍历.init_array,调用每个构造函数 ...如果你用的是STM32CubeMX生成的启动文件,它已经包含了.init_array的处理逻辑(搜索_init或__libc_init_array)。但如果你用的是自己写的或从别处抄的启动文件,一定要检查这一点,否则全局对象的构造函数不会被调用,程序行为会非常诡异。
实操心得:我遇到过一个坑,全局对象的构造函数没被调用,导致一个UART对象的波特率寄存器没初始化,串口输出全是乱码。查了半天以为是时钟配置问题,最后发现是启动文件少了
.init_array遍历。这个坑很隐蔽,因为编译链接都不会报错。
2.3 51单片机的C++支持现状
51单片机的C++支持是个老话题。Keil C51编译器对C++的支持非常有限,基本上只支持C++的一个子集,而且很多特性(如模板、命名空间)支持不完整。SDCC(Small Device C Compiler)对C++的支持也很有限。
我的建议是:51单片机上不要用C++。原因很简单:51的架构(哈佛结构、8位、寄存器窗口)本身就不适合C++的抽象模型,而且Keil C51的C++编译器生成的代码效率明显低于C。如果你在51上想获得类似C++的组织能力,用C的结构体加函数指针就够了。
STM32、GD32、ESP32这些32位平台才是C++的主场。特别是ESP32,它本身就用C++写的(Arduino框架),工具链对C++的支持非常完善。
3. 把GPIO封装成类:从寄存器操作到面向对象
3.1 裸机寄存器操作的痛点
先看一段典型的C语言GPIO操作代码:
// 初始化PA5为推挽输出 RCC->APB2ENR |= RCC_APB2ENR_IOPAEN; GPIOA->CRL &= ~(0xF << 20); GPIOA->CRL |= (0x1 << 20); // 点亮LED GPIOA->BSRR = GPIO_BSRR_BS5; // 熄灭LED GPIOA->BSRR = GPIO_BSRR_BR5;这段代码能跑,但有几个问题:第一,0xF << 20这种魔数,过两个月你自己都不记得是哪个引脚;第二,如果PA5被别的功能占用了,编译器不会提醒你;第三,初始化代码散落在各处,没有一个统一的地方管理引脚配置。
3.2 用类封装GPIO
C++的做法是把一个GPIO引脚封装成一个对象:
class GpioPin { public: enum class Mode : uint8_t { Input_Floating, Input_PullUp, Input_PullDown, Output_PushPull, Output_OpenDrain, Af_PushPull, Af_OpenDrain, Analog }; constexpr GpioPin(GPIO_TypeDef* port, uint8_t pin) : port_(port), pin_(pin) {} void init(Mode mode) const { // 使能时钟 if (port_ == GPIOA) RCC->APB2ENR |= RCC_APB2ENR_IOPAEN; else if (port_ == GPIOB) RCC->APB2ENR |= RCC_APB2ENR_IOPBEN; // ... 其他端口 // 配置CRL/CRH寄存器 volatile uint32_t* cr = (pin_ < 8) ? &port_->CRL : &port_->CRH; uint8_t shift = (pin_ % 8) * 4; uint32_t modeVal = modeToRegister(mode); *cr &= ~(0xF << shift); *cr |= (modeVal << shift); } void set() const { port_->BSRR = (1 << pin_); } void reset() const { port_->BSRR = (1 << (pin_ + 16)); } void toggle() const { port_->ODR ^= (1 << pin_); } bool read() const { return (port_->IDR & (1 << pin_)) != 0; } private: GPIO_TypeDef* const port_; const uint8_t pin_; static uint32_t modeToRegister(Mode mode) { switch (mode) { case Mode::Output_PushPull: return 0x1; case Mode::Input_Floating: return 0x4; // ... default: return 0x0; } } };用起来是这样的:
constexpr GpioPin led(GPIOA, 5); constexpr GpioPin button(GPIOB, 0); int main() { led.init(GpioPin::Mode::Output_PushPull); button.init(GpioPin::Mode::Input_PullUp); while (1) { if (button.read() == false) { led.toggle(); delay_ms(200); } } }3.3 为什么这样设计:constexpr与零开销
这里有几个关键设计决策值得解释。
为什么用constexpr构造函数?constexpr告诉编译器这个对象可以在编译期构造。对于GpioPin这种只包含两个成员(一个指针和一个uint8_t)的类,编译器会把它直接放到.rodata段或直接内联到代码里,运行时没有任何构造开销。你可以用static_assert验证:
static_assert(sizeof(GpioPin) == 8, "GpioPin should be 8 bytes");在32位ARM上,指针4字节,uint8_t加上对齐是4字节,总共8字节。这和直接用两个变量存端口地址和引脚号的开销完全一样。
为什么init()、set()这些方法用const?因为这些方法不修改对象自身的成员(port_和pin_),只修改它们指向的硬件寄存器。加const之后,你可以把GpioPin对象声明为constexpr,编译器会把它放到只读段。
为什么不用虚函数?虚函数会引入vtable指针(每个对象多4字节)和间接调用开销。对于GPIO这种高频操作,间接调用的开销是不可接受的。用模板或编译期多态替代。
实操心得:我一开始把
modeToRegister写成了非静态成员函数,结果每个GpioPin对象都隐含了一个this指针传递。改成static之后,编译器可以直接内联这个函数,生成的汇编代码和手写C完全一样。这种细节在PC上无所谓,在单片机上就是几个时钟周期的差别。
3.4 用模板做编译期引脚检查
更进一步,可以用模板参数把端口和引脚号编码到类型里:
template<GPIO_TypeDef* Port, uint8_t Pin> class Gpio { public: static void init(Mode mode) { /* ... */ } static void set() { Port->BSRR = (1 << Pin); } static void reset() { Port->BSRR = (1 << (Pin + 16)); } static bool read() { return (Port->IDR & (1 << Pin)) != 0; } }; using Led = Gpio<GPIOA, 5>; using Button = Gpio<GPIOB, 0>;这样Led::set()在编译期就确定了所有信息,生成的代码和直接写GPIOA->BSRR = (1 << 5)完全一样,没有任何运行时开销。而且如果你不小心把同一个引脚定义给了两个功能,链接时会报重复定义错误。
4. 中断处理与C++的对接:extern "C"与成员函数
4.1 中断向量表的C链接问题
C++编译器会对函数名进行名称修饰(name mangling),比如void uart_isr()在C++里可能被修饰成_Z8uart_isrv。但中断向量表是用汇编写的,里面引用的是C风格的符号名。如果你在C++文件里直接定义中断处理函数,链接时会报"undefined reference"。
解决办法是用extern "C":
extern "C" void USART1_IRQHandler(void) { // 中断处理代码 }这样编译器就不会对函数名进行修饰,链接器能找到它。
4.2 把中断处理委托给对象
但extern "C"函数不能是类的成员函数。如果你想把中断处理逻辑封装到UART类里,需要一个中间层:
class Uart { public: Uart(USART_TypeDef* usart, uint32_t baud) : usart_(usart) { // 初始化硬件 initHardware(baud); } void sendByte(uint8_t data) { while (!(usart_->SR & USART_SR_TXE)); usart_->DR = data; } void onRxInterrupt() { if (usart_->SR & USART_SR_RXNE) { uint8_t data = usart_->DR; if (rxCallback_) { rxCallback_(data); } } } void setRxCallback(void (*cb)(uint8_t)) { rxCallback_ = cb; } private: USART_TypeDef* const usart_; void (*rxCallback_)(uint8_t) = nullptr; void initHardware(uint32_t baud) { /* ... */ } }; // 全局实例 Uart uart1(USART1, 115200); // C链接的中断处理函数 extern "C" void USART1_IRQHandler(void) { uart1.onRxInterrupt(); }这个模式在嵌入式C++里非常常见:全局对象 + extern "C"中断处理函数 + 成员函数委托。全局对象放在.bss段,构造函数在启动时调用,中断处理函数通过全局对象名访问它。
4.3 中断安全的单例模式
如果你不想用全局变量,可以用单例模式:
class Uart { public: static Uart& instance() { static Uart inst(USART1, 115200); return inst; } // ... private: Uart(USART_TypeDef* usart, uint32_t baud) : usart_(usart) { /* ... */ } }; extern "C" void USART1_IRQHandler(void) { Uart::instance().onRxInterrupt(); }但这里有个坑:static局部变量的初始化在C++11之后是线程安全的,编译器会生成__cxa_guard_acquire和__cxa_guard_release调用。在裸机上这会导致链接错误(找不到这两个符号)。解决办法是加-fno-threadsafe-statics编译选项,或者用全局对象替代单例。
注意:如果你在中断处理函数里调用
Uart::instance(),而这是第一次调用,会触发构造函数的执行。在中断上下文里执行构造函数是非常危险的,可能破坏主程序的栈。所以要么在main里先调用一次instance()确保构造完成,要么直接用全局对象。
5. 状态机与模板:用编译期多态替代虚函数
5.1 虚函数的代价
在PC上,用虚函数实现状态机是很自然的:
class State { public: virtual void onEnter() = 0; virtual void onExit() = 0; virtual State* handleEvent(Event e) = 0; virtual ~State() = default; };但在单片机上,虚函数有三个代价:第一,每个对象多一个vtable指针(4字节);第二,每个类多一个vtable表(放在Flash里);第三,虚函数调用是间接跳转,无法内联,而且会破坏指令流水线。
对于一个只有几个状态的状态机,这些代价可能无所谓。但如果你有几十个状态,或者状态机在高速循环里被频繁调用,虚函数的开销就不可忽略了。
5.2 用模板实现编译期状态机
C++的模板可以在编译期完成状态分派,运行时没有任何间接调用:
template<typename Derived> class StateBase { public: void enter() { static_cast<Derived*>(this)->onEnter(); } void exit() { static_cast<Derived*>(this)->onExit(); } void handle(Event e) { static_cast<Derived*>(this)->onEvent(e); } }; class IdleState : public StateBase<IdleState> { public: void onEnter() { led.reset(); } void onExit() { } void onEvent(Event e) { if (e == Event::ButtonPress) { // 切换到RunningState } } };这种模式叫CRTP(Curiously Recurring Template Pattern,奇异递归模板模式)。StateBase<IdleState>::enter()在编译期就被解析为IdleState::onEnter(),编译器可以直接内联,生成的代码和直接调用IdleState::onEnter()完全一样。
5.3 状态机的实际组织方式
在实际项目里,我通常用一个StateMachine模板类来管理状态切换:
template<typename... States> class StateMachine { public: template<typename T> void transitionTo() { if (current_) current_->exit(); current_ = &getState<T>(); current_->enter(); } void handle(Event e) { if (current_) current_->handle(e); } private: StateBase<States>* current_ = nullptr; template<typename T> static StateBase<T>& getState() { static T instance; return instance; } };用起来:
using MyStateMachine = StateMachine<IdleState, RunningState, ErrorState>; MyStateMachine sm; int main() { sm.transitionTo<IdleState>(); while (1) { Event e = pollEvent(); sm.handle(e); } }这个状态机的所有状态在编译期就确定了,transitionTo<T>()在编译期解析为具体的状态类型,运行时只有一次指针赋值和两次函数调用(exit和enter),没有任何虚函数开销。
实操心得:CRTP的缺点是代码可读性下降,而且编译错误信息非常难懂。如果你团队里有新手,建议先用虚函数把逻辑跑通,等性能瓶颈出现了再考虑用CRTP优化。不要为了"零开销"而牺牲可维护性。
6. 内存管理:为什么我建议禁用new/delete
6.1 动态内存的陷阱
在单片机上用new/delete或malloc/free,最大的问题是内存碎片。假设你有20KB RAM,程序运行过程中反复分配和释放不同大小的内存块,跑几个小时之后,虽然总空闲内存还有10KB,但没有任何一块连续内存能满足一个1KB的分配请求。这就是碎片化。
在PC上,操作系统有虚拟内存和页表,碎片化问题被大大缓解。在单片机上,物理内存就是那么多,碎片化一旦发生就无法恢复,只能重启。
6.2 替代方案:静态分配与内存池
我的做法是:所有对象在编译期或启动时静态分配,运行时不进行任何动态内存分配。
对于需要动态创建的对象(比如命令解析器里的命令对象),用固定大小的内存池:
template<typename T, size_t N> class MemoryPool { public: T* allocate() { for (size_t i = 0; i < N; i++) { if (!used_[i]) { used_[i] = true; return &storage_[i]; } } return nullptr; } void deallocate(T* ptr) { size_t index = ptr - storage_; if (index < N) used_[index] = false; } private: alignas(T) uint8_t storage_[N * sizeof(T)]; bool used_[N] = {}; };这个内存池在编译期就确定了大小,运行时分配和释放都是O(N)的线性扫描(N通常很小,比如8或16),没有任何碎片问题。
6.3 重载operator new/delete
如果你无法完全避免new/delete(比如用了某个第三方库),可以重载全局的operator new/delete,把它们指向一个静态内存池:
void* operator new(size_t size) { void* ptr = myPool.allocate(size); if (!ptr) { // 内存耗尽,触发错误处理 while (1); } return ptr; } void operator delete(void* ptr) noexcept { myPool.deallocate(ptr); }这样即使代码里用了new,也不会真正调用malloc,而是从静态内存池里分配。代价是内存池大小固定,如果分配请求超过池大小,程序会卡死。
注意:重载
operator new之后,std::vector、std::string这些STL容器也能用了,但它们的扩容行为仍然会导致内存池快速耗尽。我的建议是:在单片机上不要用STL容器,用固定大小的数组或自定义的环形缓冲区。
7. 从C迁移到C++的实操路线
7.1 渐进式迁移策略
如果你有一个现成的C项目,想迁移到C++,不要一次性重写。我的建议是分三步走:
第一步:把文件后缀从.c改成.cpp,用C++编译器编译。这一步大多数C代码都能直接通过,因为C++是C的超集(除了少数不兼容的地方,比如void*不能隐式转换为其他指针类型)。这一步的目的是让工具链跑通,不改变任何逻辑。
第二步:把相关的全局变量和函数封装成类。比如把UART相关的全局变量和函数封装成一个Uart类,把GPIO相关的封装成GpioPin类。这一步是渐进的,一次封装一个模块,每封装完一个就测试一次。
第三步:引入模板和constexpr优化关键路径。比如把GPIO操作改成模板,把状态机改成CRTP,把配置参数改成constexpr。
7.2 常见兼容性问题
从C迁移到C++时,最容易遇到的几个问题:
| 问题 | 原因 | 解决办法 |
|---|---|---|
void*隐式转换报错 | C++不允许void*隐式转其他指针 | 加显式转换(uint8_t*)ptr |
| 枚举类型不兼容 | C++枚举是独立类型 | 用enum class或加显式转换 |
| 函数声明与定义不一致 | C++有函数重载 | 检查所有声明是否完全一致 |
| 全局变量重复定义 | C++的const默认是内部链接 | 用extern const或inline const |
| 中断处理函数找不到 | C++名称修饰 | 加extern "C" |
7.3 编译选项的逐步调整
迁移过程中,编译选项也要逐步调整。一开始可以保留异常和RTTI,等代码稳定后再关掉。但-fno-threadsafe-statics和-fno-use-cxa-atexit建议一开始就加上,因为这两个选项影响的是全局对象的构造行为,后期再改可能导致难以排查的初始化顺序问题。
8. 实战案例:用C++重写一个UART命令解析器
8.1 需求描述
假设我们要实现一个UART命令解析器,支持以下命令:
LED ON/LED OFF:控制LEDREAD ADC:读取ADC值并返回RESET:复位系统
命令以\r\n结尾,大小写不敏感。
8.2 C语言实现
先用C写一版:
#define CMD_BUF_SIZE 64 static char cmdBuf[CMD_BUF_SIZE]; static uint8_t cmdIndex = 0; void uart_rx_isr(uint8_t data) { if (data == '\n') { cmdBuf[cmdIndex] = '\0'; processCommand(cmdBuf); cmdIndex = 0; } else if (data != '\r' && cmdIndex < CMD_BUF_SIZE - 1) { cmdBuf[cmdIndex++] = data; } } void processCommand(const char* cmd) { if (strcasecmp(cmd, "LED ON") == 0) { GPIOA->BSRR = (1 << 5); } else if (strcasecmp(cmd, "LED OFF") == 0) { GPIOA->BSRR = (1 << (5 + 16)); } else if (strcasecmp(cmd, "READ ADC") == 0) { uint16_t val = adc_read(); printf("%d\r\n", val); } else if (strcasecmp(cmd, "RESET") == 0) { NVIC_SystemReset(); } else { printf("Unknown command\r\n"); } }这段代码能跑,但有几个问题:命令处理逻辑和UART中断耦合在一起;strcasecmp是线性比较,命令多了之后效率低;添加新命令要改processCommand函数。
8.3 C++实现
用C++重写:
class CommandParser { public: using Handler = void(*)(const char* args); void feed(uint8_t data) { if (data == '\n') { buffer_[index_] = '\0'; dispatch(buffer_); index_ = 0; } else if (data != '\r' && index_ < BufferSize - 1) { buffer_[index_++] = data; } } void registerCommand(const char* name, Handler handler) { if (count_ < MaxCommands) { commands_[count_++] = {name, handler}; } } private: static constexpr size_t BufferSize = 64; static constexpr size_t MaxCommands = 16; struct Command { const char* name; Handler handler; }; char buffer_[BufferSize]; size_t index_ = 0; Command commands_[MaxCommands]; size_t count_ = 0; void dispatch(const char* input) { for (size_t i = 0; i < count_; i++) { size_t len = strlen(commands_[i].name); if (strncasecmp(input, commands_[i].name, len) == 0) { const char* args = input + len; while (*args == ' ') args++; commands_[i].handler(args); return; } } printf("Unknown command\r\n"); } }; // 使用 CommandParser parser; void ledOn(const char*) { GPIOA->BSRR = (1 << 5); } void ledOff(const char*) { GPIOA->BSRR = (1 << (5 + 16)); } void readAdc(const char*) { printf("%d\r\n", adc_read()); } void reset(const char*) { NVIC_SystemReset(); } void initCommands() { parser.registerCommand("LED ON", ledOn); parser.registerCommand("LED OFF", ledOff); parser.registerCommand("READ ADC", readAdc); parser.registerCommand("RESET", reset); } extern "C" void USART1_IRQHandler(void) { if (USART1->SR & USART_SR_RXNE) { parser.feed(USART1->DR); } }8.4 对比分析
C++版本的优势:
- 解耦:命令解析逻辑和UART中断处理分离,
CommandParser可以复用到其他串口。 - 可扩展:添加新命令只需调用
registerCommand,不需要修改dispatch函数。 - 可测试:
CommandParser可以在PC上单独测试,不需要硬件。 - 类型安全:
Handler是函数指针类型,编译器会检查签名。
代价:
- Flash占用增加约200字节(主要是
strncasecmp和strlen的调用)。 - RAM占用增加约80字节(
commands_数组)。 - 代码行数从30行增加到70行。
对于这个规模的项目,我认为C++版本是值得的。但如果你的Flash只有8KB,这200字节可能就是压垮骆驼的最后一根稻草。
9. 踩坑记录:那些让我熬夜的C++单片机问题
9.1 全局对象构造顺序问题
C++标准规定,同一个编译单元内的全局对象按定义顺序构造,但不同编译单元之间的构造顺序是未定义的。这意味着如果你有两个全局对象,一个在a.cpp里,一个在b.cpp里,而且b的构造函数依赖a已经构造完成,那么程序可能在某些编译顺序下正常工作,在另一些顺序下崩溃。
我遇到过一个案例:一个全局的Logger对象在构造函数里调用了Uart::instance(),但Uart的全局对象还没构造,导致访问了未初始化的硬件寄存器,程序直接HardFault。
解决办法:避免全局对象的构造函数之间有依赖关系。如果无法避免,用"构造即初始化"(construct on first use)模式,或者把所有全局对象放到一个文件里,按依赖顺序定义。
9.2 中断里的虚函数调用
在中断处理函数里调用虚函数是合法的,但如果你在中断里创建或销毁对象,可能会触发operator new/delete,进而调用malloc/free,而malloc/free不是中断安全的(它们会修改堆管理数据结构,如果主程序正在malloc时被中断打断,堆会损坏)。
我的做法是:中断处理函数里只做最简单的事情——读取数据、设置标志位、写入缓冲区,然后把复杂处理放到主循环里。
9.3 模板代码膨胀
模板会在每个实例化点生成一份代码。如果你用Gpio<GPIOA, 0>到Gpio<GPIOA, 15>实例化了16个对象,编译器会生成16份init()、set()、reset()代码。虽然每份代码很小(可能就几条指令),但16份加起来也不容忽视。
解决办法:把不依赖模板参数的代码提取到非模板基类里,或者用constexpr函数替代模板。
9.4 链接脚本的段配置
C++会生成一些C没有的段,比如.init_array(全局构造函数)、.fini_array(全局析构函数)、.ARM.exidx(异常索引表,即使关了异常也可能生成)。如果你的链接脚本没有正确处理这些段,它们可能会被放到错误的位置,导致程序崩溃。
我的链接脚本里通常会加上:
.init_array : { . = ALIGN(4); __init_array_start = .; KEEP(*(.init_array)) __init_array_end = .; } >FLASH .fini_array : { . = ALIGN(4); KEEP(*(.fini_array)) } >FLASHKEEP是必须的,否则--gc-sections会把这些段删掉,全局对象的构造函数就不会被调用。
10. 工具与调试:让C++单片机开发更顺手
10.1 VSCode配置
VSCode是目前最顺手的单片机开发编辑器。配置C++ IntelliSense需要c_cpp_properties.json:
{ "configurations": [ { "name": "STM32", "includePath": [ "${workspaceFolder}/**", "${workspaceFolder}/Drivers/CMSIS/Include", "${workspaceFolder}/Drivers/CMSIS/Device/ST/STM32F1xx/Include" ], "defines": [ "STM32F103xB", "USE_HAL_DRIVER" ], "compilerPath": "/usr/bin/arm-none-eabi-g++", "cStandard": "c11", "cppStandard": "c++17", "intelliSenseMode": "gcc-arm" } ], "version": 4 }关键点:compilerPath要指向arm-none-eabi-g++而不是g++,否则IntelliSense会用PC的头文件,导致大量误报。cppStandard建议用c++17,因为constexpr和if constexpr在C++17里才完善。
10.2 调试技巧
用GDB调试C++单片机程序时,有几个技巧:
p *this:在成员函数里查看当前对象。info vtbl obj:查看虚函数表(如果用了虚函数)。set print demangle on:让GDB显示C++的原始函数名,而不是修饰后的名字。break ClassName::method:在成员函数上设断点。
如果程序在全局对象构造时崩溃,可以在_init函数或.init_array遍历处设断点,单步执行每个构造函数,找到崩溃的那个。
10.3 静态分析
cppcheck对C++的支持比C更好,可以检测出未初始化的成员变量、虚函数析构问题等。在CI里加上:
cppcheck --enable=all --std=c++17 --suppress=missingIncludeSystem src/clang-tidy也可以用于嵌入式C++,但需要生成compile_commands.json,配置稍麻烦。
11. 关于C++在单片机上的一些个人体会
写了几年C++单片机代码之后,我的体会是:C++在单片机上的价值不在于"用上C++",而在于"用对C++"。用错了,代码体积膨胀、性能下降、调试困难;用对了,代码清晰、可维护、零开销。
我现在的项目里,C++的使用原则是:
- 能用
constexpr就不用const,能用const就不用变量。 - 能用模板就不用虚函数,能用静态多态就不用动态多态。
- 能用栈就不用堆,能用静态分配就不用动态分配。
- 能用引用就不用指针,能用
const引用就不用非const引用。 - 中断处理函数保持简短,复杂逻辑放到主循环。
这套原则不是教条,而是从一次次踩坑里总结出来的。比如我一开始很喜欢用虚函数实现接口,觉得"面向对象"很优雅,直到有一次在一个1ms周期的控制循环里发现虚函数调用占了30%的CPU时间,才改成CRTP。
还有一次,我用std::function做回调,结果发现每个std::function对象占32字节,而且会触发堆分配。改成函数指针加void*上下文之后,占用降到8字节,而且没有堆分配。
C++给了你很多工具,但每个工具都有代价。在单片机上,你需要清楚地知道每个工具的代价,然后根据项目需求做取舍。这不是C++的问题,而是嵌入式开发的本质——资源永远不够,你永远在做取舍。
如果你刚开始在单片机上用C++,我的建议是:先从GPIO和UART的封装开始,用constexpr和static成员函数,不要碰虚函数和动态内存。等这套模式跑顺了,再逐步引入模板和CRTP。不要一上来就追求"零开销抽象",先把代码写清楚,性能问题等出现了再优化。
最后分享一个我常用的技巧:在main.cpp里定义一个constexpr bool开关,用来在编译期启用或禁用调试输出:
constexpr bool DebugEnabled = true; template<typename... Args> void debugPrint(const char* fmt, Args... args) { if constexpr (DebugEnabled) { printf(fmt, args...); } }if constexpr在C++17里是编译期求值的,当DebugEnabled为false时,printf调用会被完全消除,不生成任何代码。这比用#ifdef宏更类型安全,也比运行时if更高效。