1. 为什么要在单片机上折腾C++
很多人第一次接触单片机编程,学的都是C语言。51单片机、STM32、GD32,翻开任何一本教材,清一色是C。C语言在单片机领域的统治地位毋庸置疑——编译器成熟、代码体积可控、运行时开销透明,这些都是嵌入式开发最看重的品质。但项目做到一定复杂度之后,你会发现纯C开始变得吃力:驱动层和应用层搅在一起,状态机用switch-case写得又臭又长,模块之间的接口靠一堆全局变量和extern函数来维系,改一处牵动全身。
这时候C++的价值就体现出来了。我说的不是在单片机上跑STL、跑异常处理、跑RTTI那一套——那确实不现实。我说的是C++的一个子集:类、封装、继承、模板、命名空间、构造函数/析构函数、运算符重载。这些东西在编译后产生的代码体积和运行时开销,在大多数场景下和等效的C代码相差无几,但代码的组织性和可维护性完全不是一个量级。
我拿STM32F103C8T6做过对比测试:同一个功能模块(一个带状态机的传感器采集任务),用C写大约占用4.2KB Flash,用C++类封装后是4.5KB,多了不到8%。RAM占用几乎一样。这8%换来的可读性和可扩展性,在项目后期维护时能省下大量时间。当然,如果你用的是STC51这种资源极其紧张的芯片,那就得掂量一下了——后面我会具体讲51上怎么取舍。
这篇文章适合谁看?如果你已经会用C写单片机程序,点过灯、读过DHT11、驱动过LCD1602,但觉得代码越写越乱、想找个更好的组织方式,那这篇内容就是给你准备的。如果你连GPIO配置都还没搞明白,建议先把C的基础打牢再来看。
2. 单片机C++的核心取舍:哪些能用,哪些别碰
2.1 编译器对C++的支持现状
在动手之前,先确认你手上的工具链能不能编C++。这是最基础的一步,但很多人会在这里卡住。
Keil MDK(ARMCC/ARMCLANG):从MDK 5开始,ARMCLANG编译器对C++14的支持已经相当完整。你只需要在工程选项的C/C++选项卡里把源文件后缀改成.cpp,编译器会自动按C++模式处理。注意ARMCC(MDK 4时代的编译器)对C++11的支持有限,如果你还在用老版本,建议升级。
STM32CubeIDE(GCC):arm-none-eabi-gcc对C++的支持非常好,C++17甚至C++20的大部分特性都能用。在CubeIDE里创建工程时选择C++,或者在现有C工程里把文件后缀改为.cpp,然后在工程属性里加上-lstdc++链接选项即可。
PlatformIO + VSCode:这是我目前最推荐的组合。在platformio.ini里加上build_flags = -std=gnu++17,然后把源文件后缀改成.cpp就行。VSCode的C/C++插件配合PlatformIO的IntelliSense,代码补全和跳转体验比Keil好太多。配置过程中如果遇到头文件路径报红,在c_cpp_properties.json里把includePath指向.pio/libdeps目录即可。
SDCC(用于51单片机):SDCC从3.x版本开始支持C++,但支持程度有限——不支持异常、不支持STL、模板支持也不完整。如果你要在STC51上用C++,只能用最基础的类封装和命名空间,别指望模板元编程。
2.2 必须关闭的C++特性
在单片机上用C++,有几样东西是必须关掉的,否则代码体积会爆炸,或者运行时直接崩:
| 特性 | 状态 | 原因 | 关闭方式 |
|---|---|---|---|
| 异常处理 | 禁用 | 每个try-catch块都会生成大量栈展开代码,Flash开销极大 | -fno-exceptions |
| RTTI | 禁用 | 运行时类型信息需要额外的类型表,浪费Flash | -fno-rtti |
| STL容器 | 禁用 | vector/string等会引入大量模板实例化和动态内存分配 | 不包含头文件即可 |
| iostream | 禁用 | 光是包含iostream就能增加几十KB | 不包含 |
| 动态内存分配 | 慎用 | new/delete在碎片化内存上容易出问题 | 用静态分配替代 |
| 虚函数 | 慎用 | 每个虚函数调用需要查虚表,且每个含虚函数的类会多一个vptr | 能用模板静态多态就用模板 |
这些限制听起来很多,但实际上你日常写单片机代码根本用不到它们。你需要的是类、封装、构造函数、模板——这些都不受影响。
2.3 51单片机的特殊考量
51单片机的资源极其有限:STC89C52只有8KB Flash、512B RAM。在这种环境下用C++,必须极其克制。
我的建议是:51上只用C++的类封装和命名空间,不用模板,不用继承,不用虚函数。一个类只封装数据和对数据的操作,编译后和等效的C结构体+函数几乎没有区别。比如把LCD1602的驱动封装成一个类,把DHT11的读取封装成一个类,代码组织会清晰很多,但不会增加多少开销。
SDCC编译C++时,构造函数如果是空的,编译器会优化掉。但如果有成员初始化列表,会生成相应的初始化代码。析构函数在51上基本用不到——因为不会动态创建对象,所有对象都是全局或静态的。
3. 从C到C++的实操改造:以DHT11+LCD1602为例
3.1 项目结构设计
假设我们要做一个温湿度显示的项目:DHT11读取温湿度,LCD1602显示。用C写的话,大概是这样的结构:
// dht11.h void DHT11_Init(void); uint8_t DHT11_ReadData(uint8_t *temp, uint8_t *humi); // lcd1602.h void LCD1602_Init(void); void LCD1602_ShowString(uint8_t x, uint8_t y, char *str); void LCD1602_ShowNum(uint8_t x, uint8_t y, uint16_t num, uint8_t len); // main.c void main(void) { uint8_t temp, humi; LCD1602_Init(); DHT11_Init(); while(1) { if(DHT11_ReadData(&temp, &humi) == 0) { LCD1602_ShowString(0, 0, "Temp:"); LCD1602_ShowNum(5, 0, temp, 2); LCD1602_ShowString(0, 1, "Humi:"); LCD1602_ShowNum(5, 1, humi, 2); } Delay_ms(2000); } }这段代码能跑,但问题很明显:DHT11和LCD1602的接口是散落的函数,数据通过指针传递,调用者需要自己管理缓冲区。如果项目里再加一个OLED显示、一个串口上报,main函数会变得越来越臃肿。
用C++改造后,结构变成这样:
// dht11.hpp class DHT11 { public: DHT11(GPIO_TypeDef* port, uint16_t pin); bool init(); bool read(uint8_t& temp, uint8_t& humi); private: GPIO_TypeDef* m_port; uint16_t m_pin; bool waitForLevel(GPIO_PinState level, uint32_t timeout); }; // lcd1602.hpp class LCD1602 { public: LCD1602(I2C_HandleTypeDef* i2c, uint8_t addr); void init(); void showString(uint8_t x, uint8_t y, const char* str); void showNum(uint8_t x, uint8_t y, uint16_t num, uint8_t len); void clear(); private: I2C_HandleTypeDef* m_i2c; uint8_t m_addr; void writeCmd(uint8_t cmd); void writeData(uint8_t data); }; // main.cpp int main() { HAL_Init(); SystemClock_Config(); LCD1602 lcd(&hi2c1, 0x27); DHT11 sensor(GPIOA, GPIO_PIN_1); lcd.init(); sensor.init(); while(1) { uint8_t temp, humi; if(sensor.read(temp, humi)) { lcd.showString(0, 0, "Temp:"); lcd.showNum(5, 0, temp, 2); lcd.showString(0, 1, "Humi:"); lcd.showNum(5, 1, humi, 2); } HAL_Delay(2000); } }改造后的代码,每个模块的职责非常清晰。LCD1602对象知道自己挂在哪个I2C上、地址是多少,调用者不需要关心底层是并口还是I2C。DHT11对象知道自己接在哪个引脚上,读取失败时返回false而不是靠全局错误码。
3.2 构造函数中的初始化逻辑
C++类封装的一个核心优势是构造函数自动初始化。在C里,你必须在main函数开头手动调用一堆Init函数,顺序还不能错。用C++的话,对象在定义时自动调用构造函数,初始化逻辑内聚在类内部。
但这里有个坑:全局对象的构造函数调用时机。在单片机上,全局对象的构造函数会在main函数之前被调用(由启动文件中的__libc_init_array触发)。如果你在构造函数里调用了HAL库的函数(比如HAL_GPIO_Init),而此时HAL_Init还没执行,就会出问题。
我的做法是:构造函数只做简单的成员赋值,真正的硬件初始化放在单独的init()方法里。这样你可以精确控制初始化顺序,避免依赖问题。
DHT11::DHT11(GPIO_TypeDef* port, uint16_t pin) : m_port(port), m_pin(pin) { // 只赋值,不操作硬件 } bool DHT11::init() { // 在这里配置GPIO GPIO_InitTypeDef gpio = {0}; gpio.Pin = m_pin; gpio.Mode = GPIO_MODE_OUTPUT_OD; gpio.Pull = GPIO_PULLUP; gpio.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(m_port, &gpio); return true; }3.3 模板在单片机上的实战用法
模板是C++里对单片机最有价值也最容易被滥用的特性。用得好,可以做到零开销抽象;用不好,代码体积翻倍。
一个典型的正面案例是GPIO引脚模板。在C里,操作一个引脚需要传端口和引脚号两个参数。用模板可以把引脚号变成编译期常量,生成的代码和直接操作寄存器一样高效:
template<GPIO_TypeDef* Port, uint16_t Pin> class GpioPin { public: static void set() { Port->BSRR = Pin; } static void reset() { Port->BSRR = (uint32_t)Pin << 16; } static bool read() { return (Port->IDR & Pin) != 0; } static void toggle() { Port->ODR ^= Pin; } }; // 使用 using Led = GpioPin<GPIOA, GPIO_PIN_5>; using Button = GpioPin<GPIOC, GPIO_PIN_13>; Led::set(); if(Button::read()) { Led::toggle(); }这段代码编译后,Led::set()就是一条STR指令,和直接写GPIOA->BSRR = GPIO_PIN_5完全一样。但可读性好了很多——你一眼就能看出Led接在PA5上,Button接在PC13上。
反面案例是过度泛化的模板。我见过有人在STM32F103上写了一个通用的RingBuffer<T, N>模板,支持任意类型和任意大小。结果每个不同的T和N组合都会生成一份完整的代码,Flash里塞了好几份几乎一样的环形缓冲区实现。正确的做法是:如果只需要uint8_t的缓冲区,就写一个非模板的UartRingBuffer类,别搞泛型。
3.4 命名空间隔离模块
命名空间在单片机项目里特别有用,因为C语言的全局符号很容易冲突。比如你用了某个厂商的LCD驱动库,里面有个init()函数,你自己也写了个init(),链接时就会报重复定义。
用命名空间可以彻底解决这个问题:
namespace bsp { namespace lcd { void init(); void showString(uint8_t x, uint8_t y, const char* str); } namespace dht11 { void init(); bool read(uint8_t& temp, uint8_t& humi); } } // 调用时 bsp::lcd::init(); bsp::dht11::init();命名空间不产生任何运行时开销,纯粹是编译期的符号修饰。在51这种资源紧张的平台上,用命名空间来组织代码是零成本的。
4. 实操中踩过的坑与排查方法
4.1 链接错误:undefined reference to__gxx_personality_v0
这是用GCC编译C++时最常见的错误。原因是链接器找不到C++运行时库。解决方法是在链接选项里加上-lstdc++。在PlatformIO里,可以在platformio.ini中加:
build_flags = -lstdc++在STM32CubeIDE里,右键工程 -> Properties -> C/C++ Build -> Settings -> Tool Settings -> MCU G++ Linker -> Libraries,添加stdc++。
如果你已经加了-fno-exceptions和-fno-rtti,这个错误通常不会出现。但如果出现了,加上-lstdc++就能解决。
4.2 代码体积突然增大
如果你发现改成C++后Flash占用暴涨,按以下顺序排查:
检查是否包含了不该包含的头文件。
#include <iostream>、#include <string>、#include <vector>这些都会引入大量代码。用arm-none-eabi-nm或arm-none-eabi-size查看符号表,找出体积最大的符号。检查是否有虚函数。每个含虚函数的类都会生成虚表,每个对象会多一个vptr(4字节)。如果虚函数很多,虚表会占用不少Flash。在单片机项目里,能用模板静态多态就用模板,实在需要运行时多态再用虚函数。
检查模板实例化。同一个模板用不同的参数实例化了多次,每次都会生成一份代码。用
nm命令查看是否有重复的模板实例。检查全局对象的构造函数。如果有很多全局对象,它们的构造函数会在启动时依次执行,占用Flash和启动时间。把全局对象改成局部对象(在main里定义),可以避免这个问题。
4.3 51单片机上的C++编译问题
SDCC编译C++时,有几个特有的坑:
不支持//注释:SDCC的C++模式默认使用C89标准,不支持//行注释。需要在编译选项里加--std-c++11,或者在代码里统一用/* */。
不支持nullptr:用NULL或0代替。
类成员函数不能是inline:SDCC对inline的支持有限,建议把成员函数的实现放在类内部(隐式inline),或者放在同一个.cpp文件里。
构造函数不能有默认参数:SDCC对默认参数的支持有问题,建议显式传参。
中断函数不能用类成员:51的中断服务函数必须是全局函数,不能用类成员函数。如果需要在中断里操作某个对象,可以用全局指针或全局对象。
4.4 常见问题速查表
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
编译报错undefined reference to __gxx_personality_v0 | 缺少C++运行时库 | 链接选项加-lstdc++ |
| Flash占用暴涨 | 包含了STL或iostream | 移除相关头文件,用nm排查 |
| 程序启动就HardFault | 全局对象构造函数中调用了未初始化的硬件 | 把硬件初始化移到init()方法 |
51上编译报错inline function not supported | SDCC不支持inline | 去掉inline关键字 |
| 链接报重复定义 | 不同模块有同名全局函数 | 用命名空间隔离 |
| 虚函数调用后跑飞 | 对象被销毁后调用了虚函数 | 检查对象生命周期 |
| 模板代码体积翻倍 | 同一模板多次实例化 | 合并参数类型,或改用非模板实现 |
| 中断里调用类成员函数崩溃 | 中断上下文和主循环共享对象状态 | 用volatile保护共享数据,或用全局函数封装 |
4.5 一个真实的排查案例
我之前用STM32F103做一个Modbus RTU从机项目,用C++封装了串口和Modbus协议栈。调试时发现:单独测试串口收发正常,单独测试Modbus解析也正常,但合在一起跑几分钟后就会死机。
排查过程:先用逻辑分析仪抓串口波形,确认数据正常。然后在HardFault_Handler里加断点,发现是访问了非法地址。查看调用栈,发现是Modbus类的成员函数里访问了一个已经析构的对象的成员变量。
原因是我在中断服务函数里创建了一个临时的Modbus帧对象,把它传给了主循环里的解析函数。中断结束后临时对象析构,但主循环还在用它的指针。改成在主循环里创建对象、中断只负责填充缓冲区后,问题解决。
这个坑的教训是:在中断和主循环之间传递对象时,绝对不要传递临时对象的指针或引用。要么用全局对象,要么用静态分配的对象,要么用值传递(拷贝)。
5. 进阶技巧:让C++代码更嵌入式友好
5.1 用placement new替代动态分配
单片机项目里应该尽量避免new/delete,因为堆碎片化是个定时炸弹。但有时候你确实需要在运行时创建对象——比如根据配置创建不同的传感器驱动。
这时候可以用placement new:在一块静态分配的内存上构造对象,不涉及堆分配。
// 静态缓冲区 alignas(SensorDriver) uint8_t sensorBuf[sizeof(SensorDriver)]; // 在缓冲区上构造对象 SensorDriver* sensor = new (sensorBuf) SensorDriver(args); // 使用 sensor->read(); // 手动析构(如果需要) sensor->~SensorDriver();这样既享受了C++的对象语义,又避免了动态内存分配的风险。代价是你需要自己管理缓冲区的生命周期。
5.2 constexpr让配置在编译期完成
constexpr是C++11引入的利器,可以让计算在编译期完成,运行时零开销。在单片机项目里,我经常用它来定义配置参数:
constexpr uint32_t baudrate = 115200; constexpr uint32_t timerFreq = 72000000; constexpr uint32_t prescaler = timerFreq / baudrate - 1; // prescaler在编译期就算好了,运行时直接用 TIM_HandleTypeDef htim; htim.Init.Prescaler = prescaler;如果配置参数之间有依赖关系,constexpr函数可以在编译期完成计算:
constexpr uint32_t calcPrescaler(uint32_t freq, uint32_t baud) { return freq / baud - 1; } constexpr uint32_t ps = calcPrescaler(72000000, 115200);5.3 用static_assert做编译期检查
static_assert可以在编译期检查条件,不满足就报错。这在嵌入式开发里特别有用,因为很多错误(比如缓冲区大小不匹配)如果能在编译期发现,就不用等到运行时才调试。
template<size_t N> class RingBuffer { static_assert(N > 0 && (N & (N-1)) == 0, "Buffer size must be power of 2"); // ... };这样如果你写了RingBuffer<100>,编译器会直接报错,因为100不是2的幂。比运行时才发现缓冲区溢出要好得多。
5.4 中断服务函数的C++封装
前面说过,51的中断函数必须是全局函数。但在STM32上,可以用C++的静态成员函数作为中断处理函数:
class UartDriver { public: static void IRQHandler() { // 中断处理逻辑 } }; // 在启动文件或中断向量表中注册 extern "C" void USART1_IRQHandler() { UartDriver::IRQHandler(); }注意extern "C"是必须的,因为中断向量表是C链接的,需要C风格的函数名。这个技巧在STM32和GD32上都适用。
6. 工具链配置实战:VSCode + PlatformIO
6.1 环境搭建步骤
VSCode + PlatformIO是我目前最推荐的STM32 C++开发环境。配置步骤如下:
- 在VSCode中安装PlatformIO IDE插件。
- 新建工程,Board选择
STM32F103C8T6,Framework选择STM32Cube。 - 在
platformio.ini中添加C++配置:
[env:bluepill_f103c8] platform = ststm32 board = bluepill_f103c8 framework = stm32cube build_flags = -std=gnu++17 -fno-exceptions -fno-rtti -lstdc++ build_unflags = -std=gnu11- 把
src/main.c改名为src/main.cpp。 - 在
src目录下创建dht11.hpp、lcd1602.hpp等头文件。
6.2 IntelliSense配置
PlatformIO默认的IntelliSense可能找不到C++标准库头文件。在.vscode/c_cpp_properties.json中配置:
{ "configurations": [ { "name": "PlatformIO", "includePath": [ "${workspaceFolder}/**", "${workspaceFolder}/.pio/libdeps/bluepill_f103c8/**" ], "defines": [], "compilerPath": "~/.platformio/packages/toolchain-gccarmnoneeabi/bin/arm-none-eabi-gcc", "cStandard": "c11", "cppStandard": "c++17", "intelliSenseMode": "gcc-arm" } ] }6.3 编译和烧录
PlatformIO的编译命令是pio run,烧录是pio run -t upload。如果用的是ST-Link,需要在platformio.ini中指定:
upload_protocol = stlink debug_tool = stlink如果烧录失败,检查以下几点:ST-Link驱动是否安装、SWD线是否接对(SWCLK、SWDIO、GND、3.3V)、目标板是否供电。我遇到过最常见的问题是SWD线太长导致信号质量差,缩短到10cm以内就好了。
7. 什么时候不该用C++
说了这么多C++的好处,也得说说什么时候不该用。
资源极度受限的51单片机:如果你用的是STC89C52这种8KB Flash、512B RAM的芯片,而且项目已经用了大半资源,那就别折腾C++了。C语言虽然组织性差,但至少你知道每一字节花在哪里。
对启动时间有严格要求的场景:C++的全局对象构造函数会在main之前执行,增加启动时间。如果你的设备要求上电后几毫秒内就要响应,那就得小心使用全局对象。
团队里没人懂C++:如果团队里所有人都是纯C背景,引入C++会增加沟通成本和维护风险。代码是给人看的,如果没人看得懂,再优雅的封装也没意义。
认证要求严格的场景:某些行业(如汽车、医疗)的认证流程对C++的支持不如C成熟。如果项目需要过认证,用C可能更稳妥。
我的建议是:新项目可以从C++开始,老项目逐步迁移。先把驱动层用C++类封装起来,应用层保持C风格,等团队适应了再逐步扩大C++的使用范围。
8. 从C到C++的渐进式迁移策略
如果你手上有一个已经用C写好的项目,想迁移到C++,不要一次性重写。我的经验是按以下顺序逐步迁移:
第一步:把文件后缀改成.cpp。这一步不需要改任何代码,C代码在C++编译器下99%都能直接编译。如果报错,通常是类型转换问题(C允许隐式void*转换,C++不允许),加上显式转换即可。
第二步:用命名空间隔离模块。把每个模块的函数和变量包进命名空间,解决符号冲突问题。这一步也是零风险的。
第三步:把驱动层封装成类。从最简单的模块开始,比如LED、按键、串口。每个类只封装数据和对数据的操作,不涉及继承和多态。
第四步:用模板替代宏。把#define MAX(a,b)这种宏替换成template<typename T> T max(T a, T b),获得类型安全。
第五步:引入构造/析构函数管理资源。让对象在构造时初始化、析构时清理,减少手动Init/Deinit的遗漏。
每一步都可以独立编译和测试,确保不破坏现有功能。整个过程可能需要几周时间,但迁移完成后代码质量会有质的提升。
9. 一个完整的C++单片机项目模板
最后分享一个我常用的项目结构,适合中小型STM32项目:
project/ ├── platformio.ini ├── src/ │ ├── main.cpp │ ├── bsp/ # 板级支持包 │ │ ├── gpio.hpp │ │ ├── uart.hpp │ │ ├── i2c.hpp │ │ └── timer.hpp │ ├── drivers/ # 外设驱动 │ │ ├── dht11.hpp │ │ ├── lcd1602.hpp │ │ └── oled.hpp │ ├── app/ # 应用逻辑 │ │ ├── monitor.hpp │ │ └── menu.hpp │ └── utils/ # 工具类 │ ├── ring_buffer.hpp │ └── crc.hpp └── lib/ └── CMSIS/每个目录的职责:bsp放芯片外设的封装,drivers放具体器件的驱动,app放业务逻辑,utils放通用工具。头文件用.hpp后缀,源文件用.cpp后缀。所有类放在命名空间里,命名空间名和目录名对应。
这个结构在STM32F103、GD32F103、STM32F407上都用过,项目规模从几千行到几万行都能hold住。关键是保持模块之间的依赖关系清晰:app依赖drivers,drivers依赖bsp,bsp依赖CMSIS/HAL。反向依赖绝对不允许出现。
我在实际使用中发现,这套结构最大的好处是换芯片时只需要改bsp层。之前有个项目从STM32F103换到GD32F103,只改了bsp里的几个寄存器定义和时钟配置,drivers和app层一行没动,半天就完成了移植。如果用纯C写,这种移植至少得花两三天。