☰
单片机C++实战:从C迁移到C++的编译配置与特性取舍
2026/10/30 1:08:27 网站建设 项目流程

1. 为什么要在单片机上跑C++而不是老老实实写C

很多人第一次接触单片机开发,学的都是C语言。寄存器操作、位运算、指针满天飞,代码写起来确实"贴地气",但项目一旦超过两三千行,各种状态标志、缓冲区管理、模块之间的耦合就会让人头皮发麻。我在做一个多传感器采集板的时候就遇到过这种情况:DHT11温湿度、LCD1602显示、串口上报、按键菜单,四个模块的全局变量互相打架,改一处逻辑要翻遍五个文件。那时候我就开始认真考虑,能不能把C++搬到单片机上用。

先说结论:C++在单片机上完全可用,而且用得好能显著降低中大型项目的维护成本。但前提是你得搞清楚哪些特性能用、哪些特性会带来额外开销、编译器怎么配置。这不是把PC上的C++代码直接搬过来那么简单,单片机资源有限,Flash可能只有8KB到64KB,RAM可能只有512字节到几KB,每一个字节都要精打细算。

C++相比C在单片机场景下的核心优势,我总结下来主要是三点。第一是封装,把LCD1602的引脚操作、初始化时序、写命令写数据全部封在一个类里,外部只需要调lcd.print("hello"),不用关心RS、RW、EN怎么翻转。第二是命名空间,避免不同模块的函数名冲突,比如两个传感器都有init()函数,用命名空间隔开就干净了。第三是构造与析构,配合RAII思想管理资源,比如串口发送时的临界区保护,构造时关中断、析构时开中断,天然防止忘记恢复。

但要注意,单片机上的C++和PC上的C++是两回事。异常处理(try/catch)、RTTI(运行时类型识别)、标准模板库的大部分容器(std::vector、std::string)在单片机上要么不支持,要么开销巨大。我实测过在STM32F103上开异常处理,Flash直接多占了十几KB,对于只有64KB Flash的芯片来说这是不可接受的。所以单片机C++的核心原则是:用C++的语法糖和抽象能力,但保持C的运行效率。

还有一个常见误区是认为C++编译出来的代码一定比C大。实际上,只要你不用虚函数、不用异常、不用动态内存,C++编译出来的机器码和C几乎一样。我做过对比测试,同一个LCD1602驱动,用C写和用类封装写,编译后Flash占用分别是1.2KB和1.3KB,差距只有100字节左右,但代码可读性提升了一个档次。这100字节换来的是后续维护效率的成倍提升,非常划算。

适合读这篇内容的人,应该是有一定C语言基础、写过51或者STM32裸机程序、想进一步提升代码组织能力的开发者。如果你连寄存器操作都不熟,建议先把C语言和单片机基础打牢,再来看C++的应用。下面我会从工具链配置、核心特性取舍、实战代码结构、踩坑经验几个方面展开,都是我自己在项目里验证过的内容。

2. 工具链选择与编译配置:从Keil到GCC的取舍

2.1 不同单片机平台的C++支持现状

单片机世界里的编译器生态比较碎片化,不同平台对C++的支持程度差别很大。我按自己用过的几类平台来梳理一下。

51单片机(如STC89C52、STC8系列):这是最受限的平台。Keil C51编译器对C++的支持非常有限,基本上只能算"支持C++语法子集",类、继承、模板这些高级特性要么不支持,要么生成的代码效率极低。SDCC编译器虽然开源,但对C++的支持也不完整。所以我的建议是:51单片机上不要用C++,老老实实写C。51的RAM通常只有128到512字节,Flash也就几KB到几十KB,C++的抽象开销在这里得不偿失。网上那些"51单片机C++开发"的教程,大多是噱头,实际项目里没人这么干。

STM32(ARM Cortex-M系列):这是C++在单片机上最实用的平台。ARM GCC工具链(arm-none-eabi-gcc)对C++14甚至C++17都有不错的支持,Keil MDK的ARMCC/ARMCLANG也支持C++。STM32F103C8T6这种"最小系统板"有64KB Flash、20KB RAM,跑不带异常和RTTI的C++完全没问题。我现在的项目基本都用STM32配GCC,用Makefile或者CMake管理,开发体验比Keil舒服很多。

ESP32/ESP8266:这两个平台本身就支持C++,ESP-IDF和Arduino框架都是C++写的。ESP32资源丰富(几百KB RAM、几MB Flash),可以用更多C++特性,甚至能用部分STL。但要注意ESP32的Arduino框架里大量用了动态内存和String类,在实时性要求高的场景下要谨慎。

STC8系列:STC8G1K17这类增强型51,虽然性能比传统51强不少,但编译器支持还是短板。Keil C51对C++的支持依然有限,所以还是建议用C。

下面这张表是我对不同平台C++适用性的总结,供你选型参考:

平台编译器C++支持程度推荐使用主要限制
STC89/STC8Keil C51/SDCC极有限不推荐RAM/Flash太小,编译器支持差
STM32F0/F1ARM GCC/Keil MDK良好推荐需关闭异常和RTTI
STM32F4/F7ARM GCC/Keil MDK很好强烈推荐资源充足,可用更多特性
ESP32ESP-IDF/Arduino完整推荐注意动态内存使用
国产RISC-VGCC良好推荐生态相对较新

2.2 GCC工具链下的关键编译选项

如果你用STM32配GCC,编译选项直接决定了C++能不能在单片机上跑得动。我把自己项目里验证过的关键选项列出来,每一条都解释为什么这么设。

arm-none-eabi-g++ -mcpu=cortex-m3 -mthumb \ -fno-exceptions \ -fno-rtti \ -fno-threadsafe-statics \ -fno-use-cxa-atexit \ -nostdlib \ -ffunction-sections \ -fdata-sections \ -Os \ -std=c++17 \ -c main.cpp -o main.o

-fno-exceptions:关闭异常处理。这是单片机C++最重要的一条。开启异常会让编译器生成大量额外的栈展开代码,Flash占用可能增加20%到50%。单片机项目里我们本来就不该用异常,错误处理用返回值或者错误码就够了。

-fno-rtti:关闭运行时类型识别。RTTI主要用于dynamic_cast和typeid,单片机项目里几乎用不到,关掉能省不少空间。

-fno-threadsafe-statics:关闭局部静态变量的线程安全保护。C++11规定局部静态变量的初始化是线程安全的,编译器会生成额外的守卫代码。单片机裸机程序没有多线程,关掉这个能省空间。如果你用了RTOS,那要评估一下是否真的需要。

-fno-use-cxa-atexit:关闭全局对象析构注册。单片机程序通常不会正常退出,全局对象的析构函数基本不会执行,关掉能省掉atexit相关的代码。

-nostdlib:不链接标准库。单片机项目通常用自己的启动文件和链接脚本,不需要标准库。但要注意,关掉之后new、delete、__cxa_pure_virtual这些需要自己实现。

-ffunction-sections -fdata-sections:把每个函数和数据放到独立的段,配合链接器的--gc-sections选项,可以自动丢弃未使用的代码和数据。这对C++特别重要,因为类里定义了很多方法,实际用到的可能只有几个。

-Os:优化目标是代码大小。单片机Flash宝贵,优先选-Os而不是-O2。实测下来-Os比-O2能省10%到20%的Flash。

2.3 必须自己实现的几个底层函数

用-nostdlib之后,有几个C++运行时函数需要你自己提供,否则链接会报错。这些函数在PC上由标准库提供,单片机上得自己写。

// 纯虚函数调用处理,如果基类纯虚函数被意外调用会进这里 extern "C" void __cxa_pure_virtual(void) { while(1); // 死循环,方便调试时定位问题 } // 全局对象构造注册,单片机通常不需要,空实现即可 extern "C" void __cxa_atexit(void (*)(void*), void*, void*) { } // new和delete的实现,如果不用动态内存可以不实现 void* operator new(size_t size) { return malloc(size); } void operator delete(void* ptr) { free(ptr); } void operator delete(void* ptr, size_t) { free(ptr); }

这里有个坑我要特别提醒:如果你不用动态内存,就不要实现new和delete。因为一旦实现了,编译器就不会报错,团队成员可能随手就new一个对象出来,在只有几KB RAM的单片机上这是灾难。我的做法是故意不实现,这样谁写new谁就链接报错,强制大家用静态分配。

__cxa_pure_virtual这个函数一定要实现。我踩过一次坑:基类有个纯虚函数,派生类忘记实现了,运行时调到了纯虚函数,因为没有这个函数导致跳到了非法地址,程序直接跑飞,查了两天才定位到。实现成死循环之后,至少能用调试器看到停在哪里。

3. 单片机C++的核心特性取舍:哪些能用哪些别碰

3.1 放心用的特性:类、命名空间、引用、constexpr

在单片机上,C++有一批特性是"零开销"或者"接近零开销"的,可以放心用。

类与封装是最有价值的。把LCD1602的驱动封装成一个类,引脚定义、初始化时序、写命令、写数据、显示字符串全部内聚在一起。外部代码只需要Lcd1602 lcd; lcd.init(); lcd.print("temp: 25C");,完全不用关心底层怎么翻转引脚。这种封装带来的可维护性提升是巨大的。而且只要不用虚函数,类的方法调用和普通函数调用没有区别,没有额外开销。

命名空间解决命名冲突。比如你用了两个厂家的传感器驱动,都有init()和read()函数,用命名空间包起来就干净了。命名空间在编译期就解析完了,运行时零开销。

引用比指针更安全,而且和指针一样高效。函数参数用引用传递大对象,避免拷贝。比如void send(const char* data)和void send(const char*& data),后者在某些场景下能避免指针本身的拷贝。

constexpr是C++11引入的编译期计算,非常适合单片机。比如你要算一个定时器的重装载值,用constexpr在编译期算好,运行时直接用常量,不占运行时间。我经常用它来定义引脚配置、波特率分频值这些。

class Lcd1602 { public: // 构造函数,初始化引脚 Lcd1602(GPIO_TypeDef* port, uint16_t rs, uint16_t rw, uint16_t en, uint16_t d4, uint16_t d5, uint16_t d6, uint16_t d7) : port_(port), rs_(rs), rw_(rw), en_(en) { data_pins_[0] = d4; data_pins_[1] = d5; data_pins_[2] = d6; data_pins_[3] = d7; } void init() { // 初始化时序,4位模式 delay_ms(15); writeCmd(0x33); writeCmd(0x32); writeCmd(0x28); // 4位模式,2行显示 writeCmd(0x0C); // 显示开,光标关 writeCmd(0x06); // 写入后地址自动加1 writeCmd(0x01); // 清屏 delay_ms(2); } void print(const char* str) { while (*str) { writeData(*str++); } } private: void writeCmd(uint8_t cmd) { rs_low(); rw_low(); writeNibble(cmd >> 4); writeNibble(cmd & 0x0F); } void writeData(uint8_t data) { rs_high(); rw_low(); writeNibble(data >> 4); writeNibble(data & 0x0F); } void writeNibble(uint8_t nibble) { for (int i = 0; i < 4; i++) { if (nibble & (1 << i)) { HAL_GPIO_WritePin(port_, data_pins_[i], GPIO_PIN_SET); } else { HAL_GPIO_WritePin(port_, data_pins_[i], GPIO_PIN_RESET); } } en_high(); delay_us(1); en_low(); delay_us(50); } void rs_high() { HAL_GPIO_WritePin(port_, rs_, GPIO_PIN_SET); } void rs_low() { HAL_GPIO_WritePin(port_, rs_, GPIO_PIN_RESET); } void rw_low() { HAL_GPIO_WritePin(port_, rw_, GPIO_PIN_RESET); } void en_high() { HAL_GPIO_WritePin(port_, en_, GPIO_PIN_SET); } void en_low() { HAL_GPIO_WritePin(port_, en_, GPIO_PIN_RESET); } GPIO_TypeDef* port_; uint16_t rs_, rw_, en_; uint16_t data_pins_[4]; };

这段代码就是典型的单片机C++写法。类封装了LCD1602的所有细节,外部用起来非常清爽。注意我用了初始化列表来初始化成员,这是C++的语法糖,编译后和C的赋值语句一样。

3.2 谨慎用的特性:虚函数、模板、运算符重载

虚函数要谨慎。虚函数会引入虚函数表(vtable),每个对象多一个指针(4字节),每次调用要通过vtable间接跳转,比普通函数调用慢一点。在资源紧张的单片机上,如果对象数量多,这4字节乘以对象数就是不小的开销。但虚函数带来的多态能力在某些场景下非常有用,比如你要写一个统一的传感器接口,DHT11、DS18B20、SHT30都实现同一个基类,上层代码统一调用read()。这种情况下用虚函数是值得的。

我的经验是:虚函数用在模块级别的抽象上,不要用在每个小对象上。比如传感器驱动这种"一个项目就那么几个实例"的场景,用虚函数完全没问题。但如果是"要创建几百个对象"的场景,就要考虑虚函数的开销了。

模板在单片机上要小心。模板本身是编译期的,不占运行时开销,但模板会导致代码膨胀。比如你写了一个template<typename T> T max(T a, T b),用int和float各实例化一次,就会生成两份代码。如果模板用得多,Flash占用会明显增加。我的建议是:模板用在真正需要泛型的地方,不要为了炫技而用。像std::vector这种容器,在单片机上基本不要用,因为它的模板实例化会带来大量代码。

运算符重载本身零开销,编译后就是普通函数调用。但要克制,不要重载得让人看不懂。比如你重载+来做字符串拼接,读代码的人可能以为是数值加法。我一般只重载=(赋值)、==(比较)、[](下标)这几个直观的运算符。

3.3 坚决不用的特性:异常、RTTI、动态内存、STL容器

异常处理在单片机上坚决不用。前面说过,开启异常会让Flash增加20%到50%,而且异常的栈展开机制在裸机上很难正确工作。错误处理用返回值、错误码、断言就够了。

RTTI同样不用。dynamic_cast和typeid在单片机项目里几乎用不到,关掉能省空间。

**动态内存(new/delete、malloc/free)**在单片机上要极度谨慎。原因有三:第一,堆和栈共享有限的RAM,动态分配容易导致栈溢出;第二,频繁分配释放会产生内存碎片,跑几天之后可能就分配不出连续内存了;第三,分配失败的处理很麻烦。我的原则是:单片机项目全部用静态分配或者栈分配,不用堆。所有对象在编译期就确定大小和数量。

STL容器(std::vector、std::string、std::map等)在单片机上基本不用。它们底层依赖动态内存,而且模板实例化会带来大量代码。如果你需要类似功能,自己写一个固定大小的环形缓冲区或者数组,几十行代码就够了,而且完全可控。

下面这张表总结了单片机C++特性的取舍:

特性是否推荐原因替代方案
类与封装强烈推荐零开销,提升可维护性无
命名空间强烈推荐编译期解析,零开销函数名前缀
引用推荐比指针安全,效率相同指针
constexpr推荐编译期计算,零运行时开销宏定义
虚函数谨慎使用有vtable开销,但多态有用函数指针
模板谨慎使用代码膨胀宏或重载
运算符重载谨慎使用零开销但影响可读性普通函数
异常禁用Flash开销大,裸机难支持错误码
RTTI禁用几乎用不到,占空间无
动态内存禁用碎片化,栈溢出风险静态分配
STL容器禁用依赖堆,代码膨胀自写固定缓冲区

4. 实战:用C++重构一个多传感器采集项目

4.1 项目背景与重构前的痛点

我拿一个真实项目来演示。需求是:STM32F103C8T6采集DHT11温湿度,通过LCD1602显示,同时通过串口每秒钟上报一次数据,按键可以切换显示页面。重构前是用C写的,大概800行代码,散落在5个文件里。

重构前的痛点很典型:main.c里有一个巨大的while(1)循环,里面混杂了DHT11读取、LCD刷新、串口发送、按键扫描的逻辑。全局变量有temp、humi、page、last_send_time等十几个,散落在各个文件,用extern声明来声明去。改一个显示逻辑,要同时改main.c、lcd.c、dht11.c三个文件。最要命的是,DHT11读取失败时的错误处理,在main.c里用了一个if (dht11_read() != 0)判断,但错误码定义在dht11.h里,改错误码要翻两个文件。

重构的目标很明确:用C++的类把每个模块封装起来,用命名空间隔离,用引用传递数据,消除全局变量。

4.2 模块划分与类设计

我把项目拆成四个类:Dht11、Lcd1602、Uart、KeyScan,再加一个App类作为应用层,负责协调这四个模块。

Dht11类封装温湿度读取,对外只暴露read(float& temp, float& humi)一个方法,返回错误码。Lcd1602类封装显示,对外暴露print()、clear()、setCursor()。Uart类封装串口发送,对外暴露send()。KeyScan类封装按键扫描,对外暴露scan()返回按键值。

App类持有这四个类的实例,在run()方法里组织业务逻辑。这样main.cpp就非常干净:

#include "app.hpp" int main() { // 硬件初始化(时钟、GPIO等) SystemInit(); // 创建应用实例 App app; app.init(); // 主循环 while (1) { app.run(); } }

main.cpp只有十几行,所有逻辑都在App类里。这就是C++封装带来的好处:关注点分离。

4.3 App类的实现与状态机设计

App类的核心是一个状态机。显示页面有"温度页"和"湿度页"两个状态,按键切换。串口上报每秒一次,用时间戳判断。

class App { public: App() : dht11_(GPIOA, GPIO_PIN_0), lcd_(GPIOB, GPIO_PIN_0, GPIO_PIN_1, GPIO_PIN_2, GPIO_PIN_4, GPIO_PIN_5, GPIO_PIN_6, GPIO_PIN_7), uart_(USART1), key_(GPIOC, GPIO_PIN_13), page_(Page::Temperature), last_send_tick_(0) {} void init() { lcd_.init(); uart_.init(115200); lcd_.print("System Ready"); delay_ms(1000); lcd_.clear(); } void run() { // 每2秒读一次传感器 if (HAL_GetTick() - last_read_tick_ >= 2000) { last_read_tick_ = HAL_GetTick(); if (dht11_.read(temp_, humi_) == Dht11::OK) { updateDisplay(); } else { lcd_.setCursor(0, 1); lcd_.print("Sensor Error "); } } // 每1秒串口上报一次 if (HAL_GetTick() - last_send_tick_ >= 1000) { last_send_tick_ = HAL_GetTick(); char buf[64]; snprintf(buf, sizeof(buf), "T=%.1f H=%.1f\r\n", temp_, humi_); uart_.send(buf); } // 按键扫描 KeyScan::Key k = key_.scan(); if (k == KeyScan::KEY_PRESS) { page_ = (page_ == Page::Temperature) ? Page::Humidity : Page::Temperature; updateDisplay(); } } private: enum class Page { Temperature, Humidity }; void updateDisplay() { lcd_.setCursor(0, 0); if (page_ == Page::Temperature) { char buf[16]; snprintf(buf, sizeof(buf), "Temp: %.1f C ", temp_); lcd_.print(buf); } else { char buf[16]; snprintf(buf, sizeof(buf), "Humi: %.1f %% ", humi_); lcd_.print(buf); } } Dht11 dht11_; Lcd1602 lcd_; Uart uart_; KeyScan key_; Page page_; float temp_ = 0.0f; float humi_ = 0.0f; uint32_t last_read_tick_ = 0; uint32_t last_send_tick_ = 0; };

注意几个细节。第一,所有成员都是值语义,没有指针,没有动态分配。第二,enum class Page是C++11的强类型枚举,比C的enum更安全,不会隐式转换成int。第三,snprintf是C标准库函数,在单片机上用没问题,但要注意栈空间,buf[64]会占64字节栈。第四,HAL_GetTick()返回毫秒时间戳,用它做非阻塞延时,避免delay_ms()阻塞主循环。

4.4 Dht11类的实现与错误处理

DHT11的时序比较特殊,单总线协议,需要精确的微秒级延时。用C++封装的时候,关键是把时序操作和错误处理分开。

class Dht11 { public: enum Status { OK = 0, ERR_TIMEOUT, ERR_CHECKSUM, ERR_NO_RESPONSE }; Dht11(GPIO_TypeDef* port, uint16_t pin) : port_(port), pin_(pin) {} Status read(float& temp, float& humi) { uint8_t data[5] = {0}; // 主机发送开始信号 setOutput(); pinLow(); delay_ms(18); pinHigh(); delay_us(30); setInput(); // 等待DHT11响应 if (waitLevel(0, 100) != OK) return ERR_NO_RESPONSE; if (waitLevel(1, 100) != OK) return ERR_NO_RESPONSE; if (waitLevel(0, 100) != OK) return ERR_NO_RESPONSE; // 读取40位数据 for (int i = 0; i < 40; i++) { if (waitLevel(1, 100) != OK) return ERR_TIMEOUT; delay_us(40); data[i / 8] <<= 1; if (readPin()) { data[i / 8] |= 1; } if (waitLevel(0, 100) != OK) return ERR_TIMEOUT; } // 校验 uint8_t sum = data[0] + data[1] + data[2] + data[3]; if (sum != data[4]) return ERR_CHECKSUM; humi = data[0] + data[1] * 0.1f; temp = data[2] + data[3] * 0.1f; return OK; } private: void setOutput() { /* 配置为推挽输出 */ } void setInput() { /* 配置为浮空输入 */ } void pinHigh() { HAL_GPIO_WritePin(port_, pin_, GPIO_PIN_SET); } void pinLow() { HAL_GPIO_WritePin(port_, pin_, GPIO_PIN_RESET); } bool readPin() { return HAL_GPIO_ReadPin(port_, pin_) == GPIO_PIN_SET; } Status waitLevel(bool level, uint32_t timeout_us) { while (readPin() != level) { if (--timeout_us == 0) return ERR_TIMEOUT; delay_us(1); } return OK; } GPIO_TypeDef* port_; uint16_t pin_; };

这个类的设计要点:错误码用枚举,不用异常。read()返回Status,通过引用参数输出温湿度值。调用方先判断返回值,再使用数据。这种模式在单片机里非常常见,比异常安全得多。

waitLevel()函数用超时机制防止死等。DHT11如果没接好或者坏了,while循环可能永远出不来,加上超时就能返回错误码,让上层处理。这个超时值我设的是100微秒,实测DHT11的响应时间在20到80微秒之间,100微秒足够。

5. 踩过的坑与实战经验

5.1 全局对象构造顺序问题

C++的全局对象在main()之前构造,但构造顺序在不同编译单元之间是不确定的。我踩过一次坑:App类里有一个Lcd1602成员,而Lcd1602的构造函数里调用了HAL_GPIO_WritePin,但此时GPIO时钟还没使能(时钟使能在SystemInit()里,而SystemInit()在main()里调用)。结果就是LCD初始化失败,屏幕一片空白。

解决办法有两个。第一,全局对象不要在构造函数里做硬件操作,把硬件初始化放到单独的init()方法里,在main()里显式调用。第二,如果一定要在构造函数里做,确保时钟使能等前置操作在全局对象构造之前完成,比如放在启动文件或者__attribute__((constructor))函数里。

我现在的做法是统一用第一种:所有硬件相关的类,构造函数只保存引脚配置,不做实际操作,init()方法里才真正配置硬件。这样构造顺序就不重要了。

5.2 虚函数表带来的Flash占用

我在一个项目里用了虚函数做传感器抽象,基类Sensor,派生类Dht11、Ds18b20、Sht30。编译后发现Flash比预期多了2KB。用arm-none-eabi-nm查看符号表,发现每个派生类都有一份vtable,而且vtable里还包含了RTTI信息(虽然我关了RTTI,但某些编译器还是会生成部分类型信息)。

解决办法:确认-fno-rtti生效,然后用-fno-vtable-gc相关的选项让链接器回收未使用的vtable。另外,如果派生类不多,可以考虑用函数指针代替虚函数,虽然代码丑一点,但Flash占用更可控。

5.3 静态局部变量的线程安全开销

C++11规定局部静态变量的初始化是线程安全的,编译器会生成一个守卫变量和原子操作。在单片机上,即使你没有用RTOS,编译器也会生成这些代码。我实测过一个函数里的static Lcd1602 lcd;,比全局对象多了几十字节的守卫代码。

解决办法:加-fno-threadsafe-statics编译选项。如果你确实用了RTOS,并且多个任务会同时首次访问这个静态变量,那要评估是否真的需要线程安全。大多数情况下,初始化放在任务启动前完成,就不需要运行时保护。

5.4 栈空间估算与溢出防范

C++的函数调用层次可能比C深,因为构造函数、析构函数、运算符重载都会增加调用层级。栈空间估算不准的话,容易溢出。我的经验是:在链接脚本里把栈大小设大一点,然后用调试器观察栈指针的最低位置。

具体做法:在main()开头把栈区域填充一个特殊值(比如0xDEADBEEF),跑一段时间后检查栈区域,看有多少被覆盖了,就知道实际栈使用量。STM32F103默认栈大小是1KB,我一般设成2KB,留足余量。

另外,snprintf、printf这类函数很吃栈,格式化字符串复杂的时候可能用几百字节栈。如果栈紧张,可以用自己写的轻量级格式化函数,或者把缓冲区改小。

5.5 中断服务函数里的C++注意事项

中断服务函数(ISR)里可以用C++,但要注意几点。第一,ISR里不要调用可能阻塞的函数,比如带delay的。第二,ISR里访问的共享变量要加volatile,防止编译器优化。第三,ISR里不要用动态内存。第四,ISR里调用类方法时,确保这个方法不会抛出异常(反正我们也关了异常)。

我一般把ISR写成extern "C"函数,在里面调用类的静态方法或者通过全局指针调用实例方法。比如:

extern "C" void USART1_IRQHandler(void) { if (USART1->SR & USART_SR_RXNE) { uint8_t data = USART1->DR; Uart::rxIsr(data); // 静态方法处理 } }

用静态方法是因为ISR里没有this指针,静态方法不需要实例。如果一定要用实例方法,可以定义一个全局的Uart* g_uart指针,在init()里赋值,ISR里通过g_uart->onRx(data)调用。但要注意g_uart要加volatile。

6. 从C到C++的迁移策略与团队协作建议

6.1 渐进式迁移而不是推倒重来

如果你手上有一个用C写的单片机项目,想迁移到C++,我的建议是渐进式迁移,不要推倒重来。推倒重来的风险太大,而且C和C++可以混编,没必要一次性全改。

具体策略:新写的模块用C++,老模块保持C不动。C++调用C函数用extern "C"声明,C调用C++函数需要提供extern "C"的包装函数。这样逐步替换,风险可控。

比如你有一个老的lcd.c,可以先不动它,新写的dht11.cpp用C++。等lcd.c需要大改的时候,再把它重写成lcd.cpp。这样迁移周期可能拉长到几个月,但每一步都是可验证的。

6.2 团队协作中的编码规范

C++的灵活性是把双刃剑,团队里每个人写法不一样,代码就会很乱。我建议在项目开始前定好几条硬性规范:

  • 禁用动态内存:代码审查时看到new、malloc直接打回。
  • 禁用异常和RTTI:编译选项强制关闭,谁用谁报错。
  • 虚函数要评审:用虚函数的地方要说明为什么需要多态,能不能用其他方式替代。
  • 类的大小要控制:单个类不超过200行,超过就拆分。
  • 成员变量加下划线后缀:比如temp_、humi_,和局部变量区分开。

这些规范看起来琐碎,但能避免很多问题。我带过一个三人小组,刚开始没定规范,结果一个人用m_前缀,一个人用_后缀,一个人啥都不用,代码合并的时候冲突不断。后来统一成下划线后缀,问题就解决了。

6.3 调试技巧:用GDB观察C++对象

用GDB调试C++程序比调试C程序稍微复杂一点,因为对象有内存布局。但GDB对C++的支持很好,可以直接print对象,看到成员变量的值。

比如print app会显示App对象的所有成员,包括嵌套的Lcd1602对象。print app.lcd_会显示LCD对象的状态。如果对象有虚函数,print还会显示vtable指针。

一个实用技巧:在GDB里用set print pretty on让输出格式化,用set print object on显示对象的实际类型。这样调试多态对象的时候,能看到派生类的成员。

另外,如果程序跑飞了,用bt(backtrace)看调用栈。C++的调用栈可能比C深,因为构造函数、析构函数都在栈上。看到一堆operator new、__cxa_atexit之类的符号不要慌,那是C++运行时。

6.4 性能对比:C与C++的实际差异

我在STM32F103上做过一个对比测试,同一个功能(DHT11采集+LCD显示+串口上报),用C写和用C++写,编译后对比:

指标C版本C++版本差异
Flash占用12.4KB13.1KB+5.6%
RAM占用1.8KB1.9KB+5.5%
主循环执行时间100%102%+2%
代码行数820行650行-20.7%
模块间耦合度高低显著改善

Flash和RAM多了5%左右,主要来自vtable和构造析构代码。但代码行数少了20%,而且模块间耦合度大幅降低。这个 trade-off 我认为非常划算。5%的资源换20%的代码量和更好的可维护性,在大多数项目里都是值得的。

如果你的项目对资源极度敏感,比如Flash只剩几百字节,那可能不适合用C++。但只要资源有10%以上的余量,用C++就是划算的。

6.5 什么情况下不要用C++

最后说一下反面情况。以下几种场景,我建议老老实实用C:

资源极度受限:Flash小于8KB,RAM小于512字节。这种平台连C都要省着用,C++的抽象开销承受不起。

编译器支持差:比如Keil C51,对C++的支持残缺不全,用了反而添乱。

团队不熟悉C++:如果团队成员都是C背景,强行上C++会导致代码质量下降,还不如统一用C。

项目周期极短:比如一周就要交付的demo,用C直接写最快,没必要引入C++的学习成本。

安全认证要求:某些行业的安全认证对C++有额外要求(比如MISRA C++),认证成本高,如果项目不需要C++,用C更省事。

我自己的判断标准是:项目代码量预计超过2000行,或者模块数超过5个,就用C++;否则用C。这个阈值不是绝对的,但可以作为一个参考。

7. 后续可以深入的方向

这篇主要讲了C++在单片机上应用的基础部分:工具链配置、特性取舍、实战代码结构、踩坑经验。如果你已经跑通了一个C++的单片机项目,接下来可以往几个方向深入。

第一个方向是模板元编程在寄存器操作上的应用。比如用模板封装GPIO操作,Gpio<PortA, Pin5>::set()在编译期就确定了寄存器和位掩码,生成的代码和直接操作寄存器一样高效,但可读性更好。这个技巧在STM32的HAL库之上做一层薄封装非常有用。

第二个方向是CRTP(奇异递归模板模式)实现静态多态。CRTP可以在编译期实现多态,避免虚函数的vtable开销。比如传感器驱动用CRTP,Dht11继承Sensor<Dht11>,read()调用在编译期解析,没有运行时开销。这个技巧适合对性能极度敏感的场景。

第三个方向是C++与RTOS的结合。如果你用FreeRTOS或者RT-Thread,C++的类可以封装任务、队列、信号量。比如Task类封装任务创建和删除,Queue<T>模板封装消息队列。这样RTOS的使用会更安全、更直观。

第四个方向是单元测试。单片机代码也可以做单元测试,把硬件相关的部分抽象成接口,在PC上跑测试。比如Lcd1602类依赖一个GpioInterface,在单片机上用真实GPIO实现,在PC上用mock实现,这样就可以在PC上测试LCD的显示逻辑,不用每次都烧录到板子上。这个做法能大幅提升开发效率。

这些方向我后续会逐个展开,每个都配可运行的代码。如果你现在就想动手,建议先从这篇的实战项目开始,把DHT11+LCD1602+串口的C++版本跑通,感受一下C++在单片机上的实际效果。跑通之后,你自然就知道下一步该学什么了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询