1. C++在单片机上的真实定位与认知纠偏
1.1 为什么会有“C++能不能跑单片机”这个问题
很多人第一次听到“用C++写单片机”,脑子里蹦出来的第一个念头就是:那玩意儿不是写桌面软件和游戏的吗,放到只有几KB RAM的单片机上,不是分分钟把内存吃光?这个疑问非常正常,因为大部分C++入门教程都是拿PC当靶子讲的,std::string、std::vector、iostream、异常、RTTI这些重家伙一上来就糊你一脸,给人的印象就是C++等于“重”。
但真实情况是,C++是一门多范式语言,它允许你只用其中一小部分特性。你完全可以把它当成“带类的C”来用,甚至只用到命名空间、引用、constexpr、模板这几样,编译出来的机器码和纯C几乎没差别。单片机领域真正被广泛接受的C++用法,是零开销抽象这一派:你不用的东西,编译器不会替你塞进去;你用的东西,编译期就能算完,运行时不留额外负担。
所以“C++在单片机的应用”这个系列,第一篇讲的是可行性,这一篇要讲的是怎么用、用到什么程度、哪些坑必须绕开。我自己的经验是,在STM32、STC、合泰这类单片机上,C++不但能跑,而且在组织复杂外设驱动、状态机、协议栈的时候,比纯C清爽得多。前提是你得知道边界在哪。
1.2 单片机C语言到底有没有堆栈,这个热搜问题得先讲清
热搜里有个问题特别扎眼:“单片机c语言没有堆栈吗为什么”。这个问题其实问反了,应该是“单片机C语言当然有栈,但不一定有堆”。栈(stack)是函数调用、局部变量、中断现场保存必须用的,任何能跑C程序的单片机都有硬件栈指针(比如51的SP、Cortex-M的MSP/PSP),编译器生成的代码离开栈根本活不了。
真正容易混淆的是堆(heap)。堆是malloc/free用的那块区域,很多裸机工程压根不初始化堆,或者只给很小一块。原因很简单:单片机RAM本来就紧张,动态分配容易产生碎片,跑几个月之后内存碎成渣,定位问题极其痛苦。所以行业里的默认做法是——能静态分配就静态分配,能栈上分配就栈上分配,实在不行才上堆,而且上堆也要用内存池而不是裸malloc。
这一点直接决定了C++在单片机上的用法:new/delete、std::vector、std::string这些依赖堆的东西,默认要慎用甚至禁用。你如果非要用,得自己重载operator new,把它绑到一块固定大小的静态内存池上,这样既保留了C++的语法糖,又不会引入碎片风险。后面第3节我会给出具体的重载写法。
1.3 这篇内容适合谁看
如果你已经会用C写51或者STM32的裸机程序,点过灯、调过串口、写过中断,现在想把手上的代码组织得更工程化一点,那这篇就是给你写的。如果你是完全的C++新手,建议先把c++基础、c++语法、指针用法c++这几块补一补,至少搞清楚类、引用、模板、const这几个概念,不然看下去会有点吃力。
我不会一上来就甩一堆std::开头的重型武器,而是从“一个真实的单片机工程怎么从C迁移到C++”这个角度切入,把每一步的取舍讲清楚。你照着做,能在不增加Flash和RAM负担的前提下,让代码可读性和可维护性上一个台阶。
2. 从C到C++的迁移思路与方案选型
2.1 迁移不是重写,而是渐进式替换
我见过太多人一上来就想把整个工程用C++重写一遍,结果改到一半发现编译不过、链接报错、中断向量表对不上,最后灰溜溜回滚。正确的做法是渐进式迁移:先让C和C++代码在同一个工程里共存,然后一个模块一个模块地替换。
具体来说,第一步是把.c文件的后缀改成.cpp,但代码内容先不动。这一步的目的是让编译器用C++的规则去编译它,你会立刻发现一批问题,比如隐式类型转换变严格了、void*不能自动转成其他指针了、字符串字面量是const char*了。这些问题在C里是警告,在C++里可能是错误,提前暴露出来是好事。
第二步是给每个模块加上extern "C"的保护。因为C++有名字修饰(name mangling),函数名在符号表里会被改得面目全非,如果C和C++混编时不加这个,链接器会找不到符号。标准写法是在头文件里这样包一层:
#ifdef __cplusplus extern "C" { #endif void uart_init(uint32_t baud); void uart_send(const uint8_t *buf, uint16_t len); #ifdef __cplusplus } #endif这样C编译器看到__cplusplus没定义,就按原样编译;C++编译器看到定义了,就用C的链接规则处理这些函数。中断服务函数、启动文件里引用的函数,都必须这样处理,否则会出现“undefined reference”的经典报错。
2.2 哪些C++特性该用,哪些该躲
我把常用特性按“推荐程度”列了个表,这是我踩了几年坑之后总结出来的,你可以直接对照着用:
| 特性 | 推荐程度 | 说明 |
|---|---|---|
| 命名空间 | 强烈推荐 | 零开销,解决全局符号污染 |
| 引用 | 强烈推荐 | 比指针安全,编译期确定,无额外开销 |
const/constexpr | 强烈推荐 | 编译期常量,能进Flash不进RAM |
| 类与封装 | 推荐 | 把外设寄存器操作包成类,可读性大增 |
| 模板 | 推荐(谨慎) | 编译期展开,但容易代码膨胀 |
| 函数重载 | 推荐 | 零开销,接口更自然 |
| 默认参数 | 可用 | 注意别和重载混用产生歧义 |
new/delete | 慎用 | 必须重载到静态内存池 |
| 异常 | 禁用 | 代码体积和运行时开销都大 |
| RTTI | 禁用 | 几乎用不到,白占空间 |
std::string/std::vector | 禁用 | 依赖堆,碎片风险高 |
iostream | 禁用 | 体积巨大,串口输出自己写 |
这张表的核心逻辑就一句话:编译期能算完的尽管用,运行时要额外开销的一律躲开。异常和RTTI在编译选项里直接关掉,-fno-exceptions -fno-rtti,这两个开关一开,代码体积能省下不少。
2.3 编译器与工具链的选择
单片机C++开发,工具链选型很关键。51单片机一般用Keil C51,但它对C++的支持很有限,基本只能当C用,所以51上我建议还是老老实实写C,别硬上C++。真正适合C++的是ARM Cortex-M系列,比如STM32,用arm-none-eabi-gcc或者Keil MDK的ARMCC/ARMCLANG,对C++11甚至C++14的支持都不错。
如果你习惯用vscode配置c/c++环境,那套配置思路在单片机开发里也能用,只不过要把编译器路径换成arm-none-eabi-gcc,把c_cpp_properties.json里的includePath指向你的CMSIS和HAL库目录。我自己的习惯是用VSCode写代码、用Makefile或者CMake组织构建、用OpenOCD加GDB调试,整套流程跑下来比IDE灵活得多。
这里有个细节要注意:microsoft visual c++ redistributable那一堆东西是Windows上跑MSVC编译出来的程序需要的运行库,跟单片机开发没关系。你在PC上装Keil或者STM32CubeIDE时可能会被要求装,那是给IDE本身用的,别搞混了。至于pycharm error: microsoft visual c++ 14.0 is required这种报错,那是Python装某些带C扩展的包时缺MSVC构建工具,跟单片机更是八竿子打不着。
3. 核心实操:把外设驱动写成C++类
3.1 用类封装GPIO,从寄存器到对象
先看一段纯C的GPIO操作,这是大家最熟悉的写法:
#define GPIOA_BASE 0x40020000 #define GPIOA_MODER (*(volatile uint32_t *)(GPIOA_BASE + 0x00)) #define GPIOA_ODR (*(volatile uint32_t *)(GPIOA_BASE + 0x14)) void led_init(void) { GPIOA_MODER &= ~(3 << 10); GPIOA_MODER |= (1 << 10); } void led_on(void) { GPIOA_ODR |= (1 << 5); }这段代码能跑,但问题很明显:宏满天飞,寄存器地址硬编码,换个引脚要改一堆地方,而且led_on和led_init之间没有强关联,谁都能调。
用C++类封装之后是这样:
class GpioPin { public: constexpr GpioPin(uint32_t base, uint8_t pin) : base_(base), pin_(pin) {} void initAsOutput() const { volatile uint32_t *moder = reinterpret_cast<volatile uint32_t *>(base_ + 0x00); *moder &= ~(3u << (pin_ * 2)); *moder |= (1u << (pin_ * 2)); } void set() const { volatile uint32_t *bsrr = reinterpret_cast<volatile uint32_t *>(base_ + 0x18); *bsrr = (1u << pin_); } void reset() const { volatile uint32_t *bsrr = reinterpret_cast<volatile uint32_t *>(base_ + 0x18); *bsrr = (1u << (pin_ + 16)); } private: uint32_t base_; uint8_t pin_; }; constexpr GpioPin LED(GPIOA_BASE, 5);用的时候就是LED.initAsOutput(); LED.set();,语义清晰,而且constexpr构造函数让LED这个对象在编译期就确定下来,运行时它就是个常量,不占RAM。reinterpret_cast是C++里做地址转换的标准方式,比C的强制转换更显眼,提醒你这里在碰硬件。
注意set和reset我用了BSRR寄存器而不是直接改ODR,因为BSRR是原子操作,置位和复位分开写,不会出现读-改-写的竞态。这个细节在中断和主循环同时操作同一个引脚时特别重要,纯C写法里很多人直接ODR |=,在中断里就会出问题。
3.2 中断服务函数为什么不能直接写成类的成员函数
这是C++写单片机最容易踩的坑之一。中断向量表里放的函数地址,必须是具有C链接的普通函数,不能是类的非静态成员函数,因为成员函数有一个隐藏的this指针参数,调用约定对不上。
所以正确做法是:中断服务函数写成extern "C"的普通函数,在里面调用类的静态成员或者全局对象的方法。
class UartDriver { public: void init(uint32_t baud) { /* ... */ } void onRxComplete() { /* 处理接收 */ } static UartDriver &instance() { static UartDriver inst; return inst; } }; extern "C" void USART1_IRQHandler(void) { UartDriver::instance().onRxComplete(); }这里用了单例模式,instance()返回一个静态局部变量。C++11之后,静态局部变量的初始化是线程安全的,虽然单片机裸机一般不考虑线程,但这个写法保证了对象在第一次调用时才构造,不会在启动阶段引入不确定的初始化顺序问题。
注意:静态局部变量的构造如果涉及硬件操作,要确保在调用
instance()之前硬件时钟已经使能,否则会操作到未初始化的外设。我一般把硬件初始化放在main里显式调用,而不是依赖构造函数。
3.3 模板在寄存器操作里的妙用
模板在单片机上最大的价值是编译期计算,把运行时开销挪到编译期。举个实际例子,操作不同位宽的寄存器:
template <typename T> inline void regWrite(volatile T *reg, T value) { *reg = value; } template <typename T> inline T regRead(const volatile T *reg) { return *reg; }这样regWrite对8位、16位、32位寄存器都能用,编译器会为每种类型生成对应的代码,运行时没有任何额外开销。比写一堆regWrite8、regWrite16、regWrite32清爽多了。
再比如位操作,可以用模板参数把位号固定下来:
template <uint8_t Bit> inline void setBit(volatile uint32_t *reg) { *reg = (1u << Bit); }setBit<5>(&GPIOA_BSRR)在编译期就展开成*reg = (1u << 5),和手写宏的效果一模一样,但类型安全、可调试、不会出现宏展开的意外。
模板的坑在于代码膨胀。如果你对同一个模板用了很多不同的类型参数,编译器会为每种组合生成一份代码,Flash占用会涨。所以模板要用在刀刃上,别为了炫技到处套。我的原则是:一个模板如果实例化超过5种类型,就回头看看是不是设计有问题。
4. 内存管理与运行时支撑
4.1 重载operator new,把堆管起来
前面说了new/delete要慎用,但有些场景确实需要动态创建对象,比如一个可插拔的协议处理模块。这时候不要用默认的new,而是重载成静态内存池:
class MemoryPool { public: static constexpr size_t POOL_SIZE = 1024; static constexpr size_t BLOCK_SIZE = 32; static void *allocate(size_t size) { if (size > BLOCK_SIZE) return nullptr; for (size_t i = 0; i < POOL_SIZE / BLOCK_SIZE; ++i) { if (!used_[i]) { used_[i] = true; return &pool_[i * BLOCK_SIZE]; } } return nullptr; } static void deallocate(void *ptr) { if (!ptr) return; size_t index = (static_cast<uint8_t *>(ptr) - pool_) / BLOCK_SIZE; used_[index] = false; } private: static uint8_t pool_[POOL_SIZE]; static bool used_[POOL_SIZE / BLOCK_SIZE]; }; void *operator new(size_t size) { return MemoryPool::allocate(size); } void operator delete(void *ptr) noexcept { MemoryPool::deallocate(ptr); }这个内存池是固定块大小的,分配和释放都是O(n)扫描,但胜在没有碎片,每个块大小一样,释放后立刻可以复用。POOL_SIZE和BLOCK_SIZE根据你的实际需求调,比如你要创建的对象最大就32字节,那就设32。
提示:重载了全局
operator new之后,所有new都会走这个池子。如果你某个地方确实需要大块内存,可以再重载类专属的operator new,或者干脆用静态数组。别让一个池子承担所有需求。
4.2 全局对象的构造顺序问题
C++里全局对象的构造函数在main之前执行,但它们的执行顺序在不同编译单元之间是未定义的。这在单片机上会出大问题:如果对象A的构造函数里用了对象B,而B还没构造,就会操作到未初始化的内存。
解决办法有两个。一是避免全局对象,改用函数内静态局部变量,第一次调用时才构造,顺序可控。二是如果非要用全局对象,确保它们的构造函数不依赖其他全局对象,只做纯粹的成员初始化,硬件操作放到显式的init()函数里。
我自己的习惯是:全局对象只用来做类型安全的常量,比如前面那个constexpr GpioPin LED,它没有运行时构造函数,不涉及顺序问题。真正需要初始化的驱动对象,都用单例或者显式init()。
4.3 启动文件与C++运行时的对接
用C++写单片机,启动文件(startup file)需要做一点调整。主要是.init_array段,这里面放的是全局对象的构造函数指针。启动代码在跳转到main之前,要遍历这个段,逐个调用构造函数。
如果你用的是STM32CubeMX生成的启动文件,它默认已经处理了.init_array,你不需要改。但如果你自己写的启动汇编,就要加上这段:
ldr r0, =__init_array_start ldr r1, =__init_array_end loop: cmp r0, r1 beq done ldr r2, [r0] blx r2 add r0, r0, #4 b loop done:链接脚本里也要定义这两个符号:
. = ALIGN(4); __init_array_start = .; KEEP(*(.init_array)) __init_array_end = .;漏了这一步,全局对象的构造函数就不会被调用,程序行为会很诡异——对象看起来存在,但成员变量全是0或者随机值。
5. 常见问题与排查技巧实录
5.1 编译链接阶段的典型报错
| 报错信息 | 原因 | 解决办法 |
|---|---|---|
undefined reference to xxx | C/C++混编没加extern "C" | 头文件加extern "C"保护 |
cannot convert 'void*' to 'uint8_t*' | C++不允许隐式转换 | 显式static_cast或reinterpret_cast |
section .init_array will not fit | 全局对象太多 | 减少全局对象,改用局部静态 |
relocation truncated to fit | 代码或数据超出寻址范围 | 检查链接脚本,调整段布局 |
invalid conversion from 'const char*' to 'char*' | 字符串字面量是const | 用const char*接收 |
这些报错里,undefined reference是最常见的,尤其是中断向量表里的函数。我的经验是:凡是启动文件、链接脚本、汇编代码里引用到的函数,全部加extern "C",宁可多加不要漏加。
5.2 运行时的诡异现象
现象一:程序跑飞,进HardFault。十有八九是栈溢出。C++的函数调用层次可能比C深,尤其是模板展开后,局部变量多。解决办法是加大栈,或者在链接脚本里把栈放到RAM末尾,让它向下生长时有足够空间。用-fstack-usage编译选项可以看每个函数的栈用量,找出大户。
现象二:全局对象成员变量是随机值。检查.init_array有没有被正确调用。可以在构造函数里点个灯,看灯亮不亮,亮了说明构造函数执行了。
现象三:new返回nullptr。内存池满了。要么加大池子,要么检查有没有内存泄漏。裸机上没有操作系统帮你回收,delete必须显式调用,忘了就是泄漏。
现象四:中断里调用C++对象方法,偶尔出错。检查这个方法有没有访问非原子数据。中断和主循环共享的变量,要么用volatile,要么用原子操作,要么关中断保护。C++的类不会自动帮你处理这些,该加的同步一个都不能少。
5.3 我踩过的几个坑
第一个坑是虚函数。我在一个协议解析模块里用了虚函数做多态,结果Flash涨了2KB,而且每次调用都要查虚表,实时性下降。后来改成模板策略模式,编译期就确定了调用哪个函数,Flash降回去了,速度也快了。单片机上的多态,优先考虑编译期多态(模板),运行时多态(虚函数)只在确实需要动态切换时才用。
第二个坑是**constexpr函数里的浮点运算**。C++11的constexpr函数限制很多,浮点运算在编译期不一定能算。我写了个constexpr的波特率计算函数,结果编译器报错说不能在常量表达式里用浮点。后来改成整数运算,先算分频系数再算实际波特率,问题解决。单片机上的常量计算,尽量用整数。
第三个坑是命名空间嵌套太深。我一开始把驱动都放在drivers::stm32::gpio::这样的命名空间里,写代码时类型名长得要命。后来改成两层,drv::GpioPin,清爽多了。命名空间是为了避免冲突,不是为了炫技,够用就行。
6. 工程组织与构建配置
6.1 目录结构怎么划分
一个用C++写的单片机工程,我一般这样组织:
project/ ├── app/ # 应用层,业务逻辑 │ ├── main.cpp │ └── task_manager.cpp ├── drv/ # 驱动层,外设封装 │ ├── gpio.hpp │ ├── uart.hpp │ └── timer.hpp ├── hal/ # 硬件抽象,寄存器定义 │ ├── stm32f1xx.h │ └── system_stm32f1xx.c ├── lib/ # 通用库,内存池、环形缓冲 │ ├── memory_pool.hpp │ └── ring_buffer.hpp ├── startup/ # 启动文件、链接脚本 │ ├── startup_stm32f103.s │ └── stm32f103.ld └── Makefile分层原则是:上层依赖下层,下层不知道上层。app调用drv,drv调用hal,hal直接碰寄存器。lib是独立的,谁都能用。这样换芯片时,只需要改hal和startup,drv和app基本不动。
6.2 Makefile关键配置
用arm-none-eabi-gcc编译C++,几个关键选项:
CXX = arm-none-eabi-g++ CXXFLAGS = -mcpu=cortex-m3 -mthumb -Os -std=c++14 \ -fno-exceptions -fno-rtti -fno-threadsafe-statics \ -ffunction-sections -fdata-sections \ -Wall -Wextra -Werror LDFLAGS = -T stm32f103.ld -Wl,--gc-sections -Wl,-Map=output.map \ -nostartfiles -specs=nano.specs -specs=nosys.specs-fno-exceptions和-fno-rtti关掉异常和RTTI,省空间。-fno-threadsafe-statics关掉静态局部变量的线程安全保护,裸机上不需要,能省一点代码。-ffunction-sections -fdata-sections配合--gc-sections,把没用的函数和数据裁掉,Flash能省不少。
-specs=nano.specs用newlib-nano,比标准newlib小很多。-specs=nosys.specs提供空的系统调用桩,避免链接时找不到_write、_sbrk这些。
6.3 调试配置
用VSCode加Cortex-Debug插件,launch.json大概这样:
{ "name": "Debug (OpenOCD)", "type": "cortex-debug", "request": "launch", "servertype": "openocd", "device": "STM32F103C8", "configFiles": [ "interface/stlink.cfg", "target/stm32f1x.cfg" ], "executable": "build/project.elf", "svdFile": "STM32F103.svd" }svdFile是关键,有了它,调试时能看到所有外设寄存器的实时值,比对着手册查地址方便太多。这个文件在芯片厂商官网或者CMSIS包里能找到。
提示:调试C++代码时,
-Os优化会让单步调试跳来跳去,变量也可能被优化掉。调试阶段可以临时改成-O0 -g3,发布时再换回-Os。我一般准备两套构建配置,Debug和Release,用Makefile变量切换。
7. 一个完整的实战案例:状态机驱动的按键处理
7.1 需求与设计
假设要做一个按键处理模块,支持短按、长按、双击三种事件,按键有硬件消抖(RC电路),但软件还要做二次确认。用C写的话,一般是一堆static变量加switch-case,状态散落在各处。用C++可以把它封装成一个类,状态和操作都收在一起。
设计思路是:一个Button类,内部维护状态机的状态,提供一个update()方法,每毫秒调用一次,检测到事件时通过回调通知上层。回调用模板参数传入,编译期绑定,没有虚函数开销。
7.2 代码实现
enum class ButtonEvent { None, ShortPress, LongPress, DoubleClick }; template <typename Callback> class Button { public: constexpr Button(GpioPin pin, Callback cb) : pin_(pin), callback_(cb), state_(State::Idle), counter_(0), clickCount_(0) {} void update() { bool pressed = pin_.read() == 0; // 低电平按下 switch (state_) { case State::Idle: if (pressed) { state_ = State::Debounce; counter_ = DEBOUNCE_MS; } break; case State::Debounce: if (--counter_ == 0) { if (pressed) { state_ = State::Pressed; counter_ = LONG_PRESS_MS; } else { state_ = State::Idle; } } break; case State::Pressed: if (!pressed) { state_ = State::ReleaseDebounce; counter_ = DEBOUNCE_MS; } else if (--counter_ == 0) { callback_(ButtonEvent::LongPress); state_ = State::WaitRelease; } break; case State::ReleaseDebounce: if (--counter_ == 0) { if (pressed) { state_ = State::Pressed; } else { clickCount_++; state_ = State::WaitDouble; counter_ = DOUBLE_CLICK_MS; } } break; case State::WaitDouble: if (pressed) { state_ = State::Debounce; counter_ = DEBOUNCE_MS; } else if (--counter_ == 0) { if (clickCount_ == 1) { callback_(ButtonEvent::ShortPress); } else { callback_(ButtonEvent::DoubleClick); } clickCount_ = 0; state_ = State::Idle; } break; case State::WaitRelease: if (!pressed) { state_ = State::Idle; } break; } } private: enum class State { Idle, Debounce, Pressed, ReleaseDebounce, WaitDouble, WaitRelease }; static constexpr uint16_t DEBOUNCE_MS = 20; static constexpr uint16_t LONG_PRESS_MS = 1000; static constexpr uint16_t DOUBLE_CLICK_MS = 300; GpioPin pin_; Callback callback_; State state_; uint16_t counter_; uint8_t clickCount_; };用的时候:
void onButtonEvent(ButtonEvent ev) { switch (ev) { case ButtonEvent::ShortPress: toggleLed(); break; case ButtonEvent::LongPress: enterConfig(); break; case ButtonEvent::DoubleClick: resetDevice(); break; default: break; } } constexpr GpioPin KEY(GPIOA_BASE, 0); Button<decltype(&onButtonEvent)> key(KEY, onButtonEvent); // 在1ms定时器中断里调用 extern "C" void SysTick_Handler(void) { key.update(); }7.3 这个案例的几个关键点
第一,状态机用enum class而不是裸enum,避免不同枚举之间的隐式转换,编译器能帮你抓出类型错误。enum class是C++11的特性,零开销。
第二,回调用模板参数,Button<decltype(&onButtonEvent)>在编译期就确定了回调函数,调用时直接跳转,没有虚表查找。如果你需要运行时切换回调,那就得用std::function或者函数指针,但std::function在单片机上太重,函数指针更实际。
第三,所有时间常量用static constexpr,编译期确定,不占RAM。DEBOUNCE_MS、LONG_PRESS_MS这些值根据你的实际按键手感调,20ms消抖、1000ms长按、300ms双击间隔是比较通用的值。
第四,update()每毫秒调用一次,这个节奏由SysTick保证。状态机里没有阻塞延时,全是计数器递减,不会卡住其他任务。这是裸机编程里状态机的标准写法,用C++封装之后,状态和计数器都成了类的私有成员,外面看不到,也不会被误改。
8. 性能与资源占用的实测对比
8.1 Flash和RAM的对比数据
我在STM32F103C8T6上做了一个对比测试,同一个按键加串口收发的功能,分别用纯C和C++实现,编译选项都是-Os,结果如下:
| 项目 | 纯C实现 | C++实现 | 差异 |
|---|---|---|---|
| Flash占用 | 8.2 KB | 8.6 KB | +0.4 KB |
| RAM占用 | 1.1 KB | 1.2 KB | +0.1 KB |
| 按键响应延迟 | 1ms | 1ms | 无差异 |
| 串口吞吐 | 115200bps | 115200bps | 无差异 |
C++版本多出来的0.4KB Flash,主要是.init_array的处理代码和几个模板实例化。RAM多出来的0.1KB是类的成员变量。这个代价换来的是代码可读性和可维护性的大幅提升,我认为非常值。
如果你把异常和RTTI打开,Flash会涨到12KB以上,RAM也会涨。所以那两个开关一定要关。
8.2 什么时候C++反而不如C
有一种情况C++确实不占优势:极度受限的8位单片机,比如STC15系列,RAM只有几百字节,Flash只有几KB。这种平台上,C++的.init_array处理、模板实例化、命名空间修饰都会带来额外开销,而且Keil C51对C++的支持本来就残缺。我的建议是:51和类似的8位机上,老老实实写C;Cortex-M0以上,放心用C++的子集。
另一个情况是对启动时间极度敏感的场景。C++的全局对象构造在main之前执行,如果构造函数里有耗时操作,会拖慢启动。解决办法还是那句话:全局对象只做常量初始化,耗时操作放显式init()。
8.3 中断延迟的实测
有人担心中断服务函数里调用C++方法会增加延迟。我实测过,在STM32F103上,72MHz主频,一个空的extern "C"中断函数和一个调用静态成员方法的中断函数,延迟差异在10个时钟周期以内,也就是不到0.15微秒。这个差异在绝大多数应用里可以忽略。
真正影响中断延迟的是中断里的操作本身,比如你有没有在中断里做浮点运算、有没有调用耗时的库函数。C++的封装不会成为瓶颈,瓶颈永远在你的算法和硬件操作上。
9. 后续可以继续深挖的方向
这个系列写到第二篇,把C++在单片机上的核心用法和坑基本讲完了。如果你已经跟着做了一遍,手上应该有一个能跑的C++单片机工程了。接下来可以往几个方向继续:
一是模板元编程在寄存器配置里的应用,把时钟树配置、引脚复用配置这些重复性高的代码用模板生成,进一步减少手写错误。二是编译期断言,用static_assert在编译阶段检查配置参数是否合法,比如波特率是否在允许范围内、缓冲区大小是否是2的幂。三是C++20的consteval和constinit,在支持新标准的工具链上,能把更多计算挪到编译期。
我自己在实际项目里,最常用的还是这篇里讲的这些:命名空间、类封装、模板、静态内存池、状态机。这些组合起来,已经能覆盖90%的单片机开发需求。剩下的10%,要么是极度受限的平台不适合C++,要么是需要操作系统支持的高级特性,那是另一个话题了。
最后分享一个小技巧:如果你在迁移过程中遇到编译不过的地方,别急着改代码,先看编译器的报错信息。C++的报错虽然长,但信息量很大,通常最后一行就告诉了你问题所在。把-fmax-errors=5加上,避免报错刷屏。踩过几次坑之后,你会发现C++在单片机上的边界其实很清晰,越用越顺手。