尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

嵌入式 C++ 编程:为什么 STM32 项目值得从 C 切换到 C++?

嵌入式 C++ 编程:为什么 STM32 项目值得从 C 切换到 C++? 最近我把一个基于 STM32 的项目从纯 C 重构到了 C17身边不少做嵌入式的朋友听到第一反应都是“你疯了吧嵌入式不都用的 C 语言吗” 这个反应我太熟悉了因为就在几年前我自己也坚定地认为搞嵌入式就该老老实实写 C。但这两年芯片资源越来越宽裕、项目需求越来越复杂我开始认真在 STM32 上尝试 C结果一试就回不去了。这篇是“基于 STM32 的嵌入式 C 编程之旅”系列的第一篇先不聊具体的代码技巧而是把这个最关键、也最容易引战的问题说清楚同样点灯、同样操作寄存器凭什么要用 CC 到底比 C 强在哪这篇适合那些在 C 和 C 之间犹豫的嵌入式工程师也适合被“C 太慢、太耗资源”刻板印象劝退过的朋友。1. 先聊聊为什么嵌入式世界里 C 长期不受待见1.1 历史包袱C 语言是嵌入式的事实标准嵌入式这个领域有个很特殊的现象技术栈的更新速度远比互联网后端慢得多。很多产品经理可能不理解为什么一个单片机工程师还在用几十年前诞生的 C 语言。原因其实很实际STM32 的整个生态从寄存器定义、标准外设库到后来的 HAL 库、LL 库官方给的示例、培训文档、应用笔记几乎 90% 以上都是 C 代码写的。你打开 Keil 或者 STM32CubeMX 生成一个工程默认生成的就是 main.c你要加一个模块下意识就会新建一个 .c 和 .h 文件。这种惯性是整个行业花了快二十年沉淀下来的不是哪个人说一句“C 更好”就能扭转的。老一辈嵌入式工程师的知识结构也大多建立在 C 语言之上从指针到结构体从回调函数到状态机这些都是 C 的经典套路。公司里带新人老师傅大概是这么教先看懂数据手册里的寄存器然后把 HAL 库调用起来最后在 main 的 while 循环里写业务逻辑。这套路径非常成熟你会发现团队里的所有人都能互相看懂代码招聘也容易遇到问题去论坛上一搜就是一抓一大把的答案。在这种背景下C 进来反而显得像异类。关键是C 语言本身并没有做错什么。它简单、直接、贴近硬件生成的代码体积小运行效率高几千字节 Flash 的单片机也能跑得很欢。对一个只用 GPIO 点灯、读个按键、跑个串口的小项目来说C 确实够用甚至比 C 更省心。但问题在于当项目规模从“点灯”膨胀到“带 WiFi 联网、跑 RTOS、解析多种协议、还得支持远程升级”的时候纯 C 的结构组织能力就开始捉襟见肘了。这时候 C 的价值才真正体现出来它不是为了替代 C 而存在的而是为了解决 C 在大型项目中越来越难维护的问题。1.2 “C 等于慢、等于浪费”的刻板印象是从哪来的每次我一提嵌入式 C总有人跳出来说“C 太慢了”“代码体积翻倍”“中断里根本没法用”。这种说法有其历史原因今天的 C 已经不是二十年前的 C 了但很多人的认知还停留在当年。早期嵌入式用的编译器确实不怎么样不管是 Keil C51 时代的 C 支持还是早期 ARM GCC 对 C 的优化能力生成出来的代码质量参差不齐。如果在那个年代你用上了虚函数、异常、模板再不加优化选项那代码体积确实能把 Flash 撑爆。更要命的是C 的异常机制默认是开启的哪怕你从来不写 try/catch编译器也会为可能抛出异常的代码生成额外处理逻辑这对 MCU 来说就是纯浪费。RTTI运行时类型识别也一样你明明用不到 dynamic_cast编译器照样塞进一堆类型信息。所以很多人当年试验 C 之后得出的结论是这东西太肥了嵌入式碰不得。这个结论在当时的环境下没错但是放在 2025 年就过时了。现代 ARM GCC 对 C 的优化已经非常成熟C11 之后更是在语言层面强调了“零开销抽象”原则你用抽象封装功能编译后生成的机器码可以跟手写的 C 代码几乎一样。关键是你要用对编译选项、用对特性把异常关掉、把 RTTI 关掉然后克制地使用模板和虚函数。换句话说C 语言本身并不慢慢的是把 C 当 Java 写、把所有高级特性堆上去的做法。还有一个更隐蔽的原因大家把“学 C”和“用 C”混为一谈了。很多人一听到 C 就想到类继承、虚函数、lambda、模板元编程觉得学问太深不敢碰。但实际上在 STM32 这种裸机环境下你只需要用到 C 的一个小子集封装、命名空间、重载、模板的简单用法、RAII这些就足以让工程质量提升一大截完全不需要触碰那些晦涩的语法。1.3 C 是 C 的超集也是解决方案的扩充我特别喜欢把 C 和 C 的关系比喻成手动挡和手自一体变速箱。C 语言像一台纯粹的手动挡操作直接、传动效率高老司机开好了非常爽。C 则是一台手自一体你既可以手动换挡直接操作寄存器、写底层驱动也可以切到自动挡用类、模板、RAII 管理上层逻辑。问题是很多手动挡爱好者看到自动挡就说“那是给不会开车的人用的”这显然是一种偏见。在 STM32 开发里C 并不是让你丢掉对硬件的控制能力。寄存器你要操作照样可以操作位带操作、DMA、中断服务函数一个都不少。C 做的是把这些底层操作包装成更安全的接口在上层逻辑里你不需要到处看到 magic number 和裸的位运算看到的是语义明确的方法调用。这种包装不是逃逸而是抽象层次的自然分层。2. C 能替嵌入式项目解决的问题核心优势拆解2.1 封装从“看手册改寄存器”到“调用接口”我用 C 语言写 STM32 驱动的时候最头疼的不是功能难写而是代码的可读性和可复用性太差。比如控制一个 LED 翻转C 代码里你可能直接这么写GPIOA-ODR ^ GPIO_PIN_5;这句话本身没有什么问题但当你项目里有十几个这样的 GPIO 操作分散在业务代码的各个角落问题就来了。一周后你自己回来看这段代码如果不看原理图根本想不起来 PA5 接的是什么。如果哪天硬件改版LED 从 PA5 挪到了 PB3你要全局搜索替换漏改一处就等着现场事故。用 C 封装之后情况完全不同class Led { public: Led(GPIO_TypeDef* port, uint16_t pin) : port_(port), pin_(pin) {} void On() { port_-BSRR pin_; } void Off() { port_-BSRR static_castuint32_t(pin_) 16; } void Toggle() { port_-ODR ^ pin_; } private: GPIO_TypeDef* port_; uint16_t pin_; };使用的时候你只需关心这是一个 Led调用 On 或者 Off完全不用管底层寄存器是什么。硬件改版了你只需要改创建 Led 的那一行其他所有调用代码一行都不用动。这就是封装的威力它不仅让代码更容易读还把“变化”隔离在一个地方显著降低维护成本。封装还有一个容易被忽略的好处可以把不变量约束在类内部。比如一个传感器模块的上电顺序必须是“先拉高电源引脚延时 10ms再发送配置命令”在 C 语言里这套顺序散落在各处你很难保证每个调用者都遵守。封装成类之后把上电初始化放到构造函数或用 Init 方法把时序控制在类内部外部想用错都难。2.2 RAII让资源管理自动且可靠RAIIResource Acquisition Is Initialization资源获取即初始化可能是 C 对嵌入式开发最有价值、但最被忽视的特性。名字很拗口原理其实很简单用一个对象的构造函数去获取资源用析构函数去释放资源资源生命周期和对象生命周期绑定对象离开作用域资源自动释放。嵌入式里最常见的资源是什么寄存器、外设句柄、中断状态、互斥锁。C 语言里你经常会写这样的代码void EnterCriticalSection(void); void ExitCriticalSection(void); void SetValue(int32_t v) { EnterCriticalSection(); // ... 修改共享变量 ExitCriticalSection(); }一旦函数中间出现了提前 return或者在临界区里调用了可能触发断言的函数ExitCriticalSection 很可能会被跳过中断就一直关着系统直接死机。C 语言不是不能写对而是要一直保持高度自觉这对人的要求太高了。C 用 RAII 可以把这种事变成“不可能出错”class InterruptGuard { public: InterruptGuard() : primask_(__get_PRIMASK()) { __disable_irq(); } ~InterruptGuard() { __set_PRIMASK(primask_); } private: uint32_t primask_; }; void SetValue(int32_t v) { InterruptGuard guard; // 进入临界区 value_ v; // 即使中间有 return析构函数也会恢复中断 }这个模式在嵌入式中几乎是无价的。只要 guard 在栈上创建编译器就能保证函数无论从哪条路径退出析构函数一定会被调用中断状态一定恢复。你不需要记住哪里关了中断、哪里该打开这就是 RAII 通过编译器强制实现的可靠性。类似的用法还有 UART 发送锁、SPI 片选控制、任务调度器的调度器锁都可以用这同一个模式解决问题。2.3 模板与编译期多态把运行时开销变成编译期决策很多嵌入式工程师一听模板就害怕其实模板在 STM32 开发里用好了非常舒服。它的核心价值在于如果同类算法要支持多种数据类型或多种硬件外设C 语言通常两个选择一是写宏二是用 void* 指针加函数指针把类型信息抹掉然后在运行时做判断。前者没有类型检查宏错起来让人崩溃后者有运行时开销还可能因为类型转换错误导致踩内存。C 模板的思路完全不同让编译器在编译期根据你用的类型为你生成一份专门的代码。比如我需要一个环形缓冲区希望它既能存 uint8_t也能存 float用模板写是这样的template typename T, size_t N class RingBuffer { public: bool Push(const T item) { if (IsFull()) { return false; } buffer_[head_] item; head_ (head_ 1) % N; return true; } bool Pop(T item) { if (IsEmpty()) { return false; } item buffer_[tail_]; tail_ (tail_ 1) % N; return true; } bool IsEmpty() const { return head_ tail_; } bool IsFull() const { return (head_ 1) % N tail_; } private: T buffer_[N]; size_t head_ 0; size_t tail_ 0; };然后你可以直接用RingBufferuint8_t, 64 uart_rx_buf; RingBufferfloat, 8 adc_samples;生成的代码各自独立类型安全没有 void*没有运行时类型判断性能上跟写两份专用的 C 结构体没有任何差别。这就是模板的“零开销抽象”你用抽象的方式写逻辑编译器帮你把具体版本的代码生成好运行时不需要付出任何额外成本。更要强调的是模板让“易读性”和“复用性”不再矛盾。C 语言要复用代码经常得写各种宏拼接读起来像天书C 模板的语法虽然对初学者有点门槛但一旦熟悉了读起来比宏清晰太多了。2.4 命名空间与类型安全代码规范化的重要基础嵌入式项目发展到一定规模之后“命名冲突”会成为实实在在的痛。多个模块都要定义 Init、DeInit、Send、Receive 这类函数名C 语言常见的解决办法是加前缀比如 drv_uart_init、app_network_send前缀越加越长函数名越来越难以阅读。C 提供了命名空间这个更优雅的解决方式namespace driver { namespace uart { void Init(); void Send(const uint8_t* data, uint16_t len); } } namespace application { namespace network { void Send(const uint8_t* data, uint16_t len); } }调用的时候或者写全限定名或者用 using 引入代码的组织层次一目了然。命名空间不仅是防冲突的工具它还给代码划出了清晰的模块边界这种边界感对多人协作的项目尤其重要。C 的强类型枚举也比 C 语言安全得多enum class GpioState : uint8_t { Low 0, High 1 }; void SetPin(GpioState state); // 只能传 GpioState传一个整数编译不过C 语言里你写一个函数接收 uint8_t 参数调用者传 0 还是 100编译器根本不管直到运行时才炸。C 的 enum class 把所有合法值限制在明确的范围内从类型系统上杜绝了一大类低级错误。嵌入式很多 bug 本质上都是“把不该当成参数的整数值传了进去”C 让这类 bug 从“运行时报错”变成了“编译时报错”省下的调试时间可不是一星半点。3. 破除“C 太慢”的顾虑性能到底怎么样3.1 编译选项关闭异常和 RTTI 之后我理解大家真正担心的不是语言优劣而是代码跑在 MCU 上够不够快、占不占 Flash。这个问题的答案有一部分藏在编译选项里。同样的 C 代码开不开异常代码体积能差出一大截。在 STM32 工程中我推荐的 C 编译选项长这样arm-none-eabi-g -stdc17 -fno-exceptions -fno-rtti -fno-threadsafe-statics -Os拆开来看-fno-exceptions关闭异常机制编译器不会为可能抛异常的代码生成额外的栈展开处理逻辑。嵌入式环境下异常本身就是一个争议话题绝大多数团队根本不用直接关掉最省心。-fno-rtti关闭运行时类型识别省掉 typeinfo 和相关查询逻辑。你不做 dynamic_cast完全用不到。-fno-threadsafe-statics禁用静态局部变量初始化的线程安全锁。裸机环境没有多线程局部静态对象初始化不需要加锁这项能省一点代码。-Os优化代码体积对 MCU 来说通常比 -O2 更合适。关掉这三样之后C 的“重负担”基本就没了。剩下的类、模板、命名空间、重载等特性编译后和 C 代码一样直接映射到机器码几乎没有额外的运行时开销。很多人说 C 慢很可能就是没关这些开关白白扛了不必要的包袱。3.2 不用虚函数时C 和 C 的性能在同一水平“有类就有虚函数有虚函数就有查表开销”——这是对 C 的常见误解。实际上虚函数只有在用“多态”的场景才会产生运行时查找。绝大多数嵌入式驱动封装根本用不到虚函数类里的方法默认就是静态绑定和 C 语言里直接调用一个普通函数没有任何区别。还是拿前面的 Led 类举例On 方法里只有一行port_-BSRR pin_如果你还开了 LTO链接期优化编译器甚至可以把这个方法完全内联到调用处。最后生成的汇编和你在 C 里手写GPIOA-BSRR GPIO_PIN_5一模一样。你有封装的好处却付出了零成本。甚至模板的“多态”也不需要在运行时付出代价。模板的静态分派发生在编译期编译器根据类型参数生成具体的机器码没有 vtable没有间接跳转。你在 C 里用模板做“接口抽象”得到的其实是 C 语言里“用宏拼接代码”的性能但代码可读性和可维护性高多了。3.3 代码体积与内存会看 map 文件就不慌工程从 C 重构成 CFlash 占用增加是一定的但增加多少取决于你用了哪些特性。以我自己做的 STM32G4 项目为例全部用类封装外设驱动、业务模块用命名空间组织、用了少量模板实现 FIFO 和环形缓冲最终生成的固件比纯 C 版本增大约 3% 到 5%。换取来的是更清晰的分层、更快的新人上手速度以及改需求时更小的心理负担。这些隐性收益很难量化但对于长期维护的项目来说完全是值得的。如果你担心代码膨胀我建议养成看 map 文件或使用arm-none-eabi-size命令的习惯。编译完看一眼 text、data、bss 段的变化哪些代码是模板实例化带来的、哪些是静态对象构造函数引入的一目了然。模板用不好确实会爆炸比如在头文件里实例化了大量不同类型的容器每个类型生成一份代码Flash 就撑不住了。但这个问题不是 C 的锅是“不会用”的问题。C 语言里你用宏写同样的代码一样会膨胀只是编译器报错的位置不一样罢了。3.4 动态内存能不用就不用C 也一样有人一听说 C 就联想到 new/delete 满天飞然后担心堆碎片导致系统不稳定。这种担心有道理但在嵌入式 C 里new/delete 从来不是必选项。我自己的项目里基本上禁止动态分配内存所有对象要么是全局静态的要么在栈上创建。C 只提供了 new 这个语法用什么不用什么项目规范完全可以约束。如果你确实要在 STM32 上使用动态内存我建议只在初始化阶段分配一次之后不再释放避免产生堆碎片。中断服务函数里绝对不能做动态内存操作因为大部分堆实现不是可重入的这个问题在 C 语言里用 malloc 也一样存在并不是 C 的锅。C 的优势恰恰在于你可以用 RAII 把每次 new 和 delete 绑成一个管理对象比 C 语言的裸 malloc/free 安全得多。4. 在 STM32 上跑 C 的实操要点4.1 工具链与工程配置比想象中简单在 STM32 上使用 C 没有想象中那么复杂现在的工具链基本开箱即用。我这里以 STM32CubeIDE 为例因为它是目前免费且整合度最高的环境之一。第一步正常创建一个 STM32 工程用 CubeMX 把时钟、外设都配好。CubeMX 生成的代码是 C 语言的这个不用动保留它作为底层驱动即可。第二步在工程里新建一个 main.cpp 文件在文件里写你的 C 业务逻辑。第三步关键一步确保你有一个入口函数能从 C 的世界进入 C 的世界。最简单的方式是在 main.c 里新建一个外部函数声明然后在 main.cpp 里实现// main.c void AppMain(void); int main(void) { HAL_Init(); SystemClock_Config(); ... AppMain(); while (1); } // main.cpp #include stm32g4xx_hal.h void AppMain() { // 在这里就可以愉快地用 C 了 }这样 CubeMX 生成的 C 代码完全不动你只是在主循环里调了一个 C 函数进去。如果要让 C 代码调用 C 函数比如 HAL 库函数需要用 extern C 包裹一下后面的小节会讲。编译选项在工程属性里配置进入 Properties - C/C Build - Settings - Tool Settings在 GNU ARM Cross C Compiler 的 Miscellanous 里加上-fno-exceptions -fno-rtti -fno-threadsafe-statics。注意这里是改 C 编译器的选项C 编译器的选项不用动。Keil 用户也不用担心ARM Compiler 6 对 C17 的支持已经很成熟。在 Options for Target - C/C 里把源文件后缀改成 .cpp编译器会自动按 C 处理同样建议在 Misc Controls 里加-fno-exceptions -fno-rtti。我两种环境都实测过CubeIDE 的体验更顺手Keil 也完全没问题。4.2 C/C 混编时的链接问题extern C 的正确用法嵌入式 C 项目十有八九是 C 和 C 混合编译CubeMX 生成的 HAL 库是 C 写的你的新模块是 C 写的二者之间怎么无缝互调是绕不开的问题。先说理论C 语言编译后函数符号就是函数名本身比如HAL_UART_Transmit编译后符号就叫这个名字。C 编译器为了实现重载会对函数名进行 mangling也就是把参数类型编码进符号名里比如同名函数参数不同符号名就不同。直接在一个 .cpp 文件里调用 C 函数链接器按 C 的命名规则去找符号自然找不到。解决办法就是告诉 C 编译器这些函数是用 C 写的不要对它们做名字改编。#ifdef __cplusplus extern C { #endif #include stm32g4xx_hal.h #ifdef __cplusplus } #endif在 .cpp 文件里引入 C 头文件时把它们包在 extern C 里或者更好的是在头文件里就做好兼容处理// 某个 C 模块的头文件 my_driver.h #ifdef __cplusplus extern C { #endif void MyDriver_Init(void); void MyDriver_Process(void); #ifdef __cplusplus } #endif你要是忘记加 extern C现象很典型C 文件编译都正常C 文件也编译正常但链接的时候报一堆 undefined reference看着像函数没定义其实只是符号对不上。这个问题我当年排查了好几个小时现在已经成为肌肉记忆了。4.3 三个可直接抄的封装小例子这里给出三个我在项目里反复使用的封装模式代码量不大但能直观感受到 C 在嵌入式的价值。第一个是 GPIO 输出封装一个具有最高性价比的小类class GpioOutput { public: GpioOutput(GPIO_TypeDef* port, uint16_t pin) : port_(port), pin_(pin) {} void Set() { port_-BSRR pin_; } void Reset() { port_-BSRR static_castuint32_t(pin_) 16; } void Toggle() { port_-ODR ^ pin_; } private: GPIO_TypeDef* port_; uint16_t pin_; }; // 使用示例 GpioOutput led(GPIOA, GPIO_PIN_5); led.Set(); led.Toggle();这个类没有虚函数没有动态内存所有方法都是 inline 的编译后跟直接操作寄存器没区别。关键在于调用方再也不需要关心端口和引脚这些底层细节了。第二个是 UART 调试口的封装把 HAL 的 C 接口包了一层class Uart { public: explicit Uart(UART_HandleTypeDef* huart) : huart_(huart) {} bool Write(const uint8_t* data, uint16_t len) { return HAL_UART_Transmit(huart_, data, len, 1000) HAL_OK; } bool Read(uint8_t* data, uint16_t len) { return HAL_UART_Receive(huart_, data, len, 1000) HAL_OK; } private: UART_HandleTypeDef* huart_; }; // 使用示例 Uart debug(huart1); debug.Write((const uint8_t*)Hello\r\n, 7);封装之后如果你想加一个“发送前自动加 CRC”的扩展逻辑直接在 Write 函数内部修改所有调用方无感知。这在 C 语言里要么到处改要么再套一个函数最后又是一堆前缀层叠。第三个是前面提过的中断保护器一个能极大减少疏忽的 RAII 工具class InterruptGuard { public: InterruptGuard() : primask_(__get_PRIMASK()) { __disable_irq(); } ~InterruptGuard() { __set_PRIMASK(primask_); } private: uint32_t primask_; }; // 使用示例 void DataReady(int32_t sample) { InterruptGuard guard; latest_sample_ sample; // 即使这里提前 return中断状态也能正确恢复 }5. 选型建议什么时候该用 C什么时候别硬上5.1 适合 C 的 STM32 项目画像不是所有 STM32 项目都需要 C不要为了用而用。根据自己的实践我觉得以下几类项目很适合引入 C。第一类业务逻辑复杂的项目。比如带网络协议栈的联网设备协议解析、连接管理、状态机、数据缓存模块多、状态多纯 C 组织起来非常费力。C 的类、命名空间、强类型枚举能把复杂逻辑拆得清清楚楚维护成本显著下降。第二类需要长期演进的产品。一个产品要卖三五年期间不断加功能、改需求代码库越来越大。C 的封装让模块之间的耦合降低改一个模块时不会牵一发动全身。我用 C 写过两个版本的产品到了第三个迭代版基本上处于“改不起”的状态而用 C 重构之后新功能一个迭代就能稳定跑起来。第三类团队有一定 C 基础的多人协作项目。多人开发最怕的就是代码风格混乱、模块边界模糊。C 的访问权限控制public/private是编译器层面的模块边界比 C 语言靠命名约定强制多了。private 成员变量外部根本访问不到其他模块想绕过接口都做不到这种强制约束对团队协作帮助极大。5.2 不建议上 C 的场景反过来遇到下面这些情况我也劝你先别急。第一项目资源极其紧张。比如一个 STM32F030 的 16KB Flash、4KB RAM 的项目要跑很多东西每字节 Flash 都很宝贵。这种项目用 C 老老实实写就够了C 带来的抽象优势在资源压力面前不值一提。第二团队没有 C 经验且没人愿意学。技术选型要考虑团队现实如果团队全是 C 工程师、大家也没有热情学新东西强行上 C 只会制造内部矛盾。这种情况下不如先把 C 代码里的模块划分和命名规范理顺这也是有价值的重构。第三涉及某些安全认证或企业编码规范的项目。比如部分车规、医疗设备项目编码规范基于 MISRA-C对 C 的支持要么不完善、要么审核成本高。这类项目按规范办事最重要不需要冒技术风险。第四已经有了稳定可靠的 C 代码库。如果旧代码跑得好好的、没有重构诉求你就别为了“先进”去重写。C 的新代码可以通过 extern C 和旧 C 代码共存完全没必要推倒重来。5.3 混合使用策略不需要“全有或全无”我特别想强调这一点嵌入式 C 不是一场革命而是一场渐进式的演进。你不必把整个工程从 C 推倒重写完全可以只写一个新的 .cpp 模块把新增功能用 C 实现老模块继续用 C 不碰。两者用 extern C 搭桥各模块按自己的节奏演进。我实际经验中最顺滑的切入点是把设备状态机、协议解析、数据处理这类“纯逻辑、少硬件”的模块先切到 C。因为这些模块最容易从 C 结构体加函数的模式平滑迁移到 C 类加方法的模式而且它们不直接操作寄存器风险小。等状态机模块用 C 稳定跑起来了团队再逐步把驱动层也在新代码中封装成类一步步扩大 C 的使用范围。这种“边开车边换轮胎”的方式能让你在项目不中断的情况下逐步享受到 C 的好处也让团队有时间学习、适应不用把宝全押在一个大重构里。6. 实操心得踩过的坑与值得注意的细节6.1 我踩过的几个坑做嵌入式 C 这几年我踩过的坑不少几乎每一个都值得写进新手避坑指南。第一个坑是忘了关异常和 RTTI。早期一个项目我在 CubeIDE 里建好工程直接写 C跑了一段时间发现 Flash 占用比预期大了 10% 以上查了半天最后发现就是没加编译选项。默认的 arm-none-eabi-g 会启用异常支持哪怕你的代码一行 try/catch 都没有它在可能抛出异常的地方也会生成额外逻辑。加上-fno-exceptions -fno-rtti之后代码体积立刻降下来了。第二个坑是静态对象的初始化顺序问题。C 允许多个全局对象但全局对象的构造函数在进入 main 之前按链接顺序执行谁先谁后标准不保证。我在一个项目里定义了 A 对象和 B 对象A 的构造函数依赖于 B 已经初始化完成结果实际运行发现 A 先构造拿到的是还没有初始化的 B直接死机。解决办法很简单不要在全局对象构造函数里做复杂操作真正的初始化工作放到 main 里调用 Init 方法来做。全局对象只作为一个“空壳”等系统完全启动之后再填充内容。第三个坑是模板滥用导致的代码膨胀。有一次我为了图方便在一个头文件里用模板实现了一个支持任意类型数组的查找函数然后在十几个文件里对 uint8_t、uint16_t、float 等类型分别调用了一次最后 Flash 硬生生多出了好几 KB。后来我学乖了模板很好用但只在确实需要“同一算法多种类型”时才用而且实例化次数要控制。普通业务代码里能直接写类型就直接写别为了“通用”而模板化。6.2 中断函数与 C 的相处之道很多人担心中断服务函数里能不能用 C担心静态对象、成员函数会不会有什么隐藏开销。我的答案是能但要克制。中断服务函数本质上是普通函数C 编译器会把它编译成一段用于响应中断的代码。你在中断里调用一个类的成员函数只要这个函数不动态分配内存、不抛出异常、不调用可能阻塞的系统调用那就和 C 语言里调用一个普通函数没有区别。我用 C 封装过定时器中断里的采样逻辑成员函数直接被编译器内联进中断函数实测中断响应时间没有增加。但有几个禁区必须注意不要在中断里使用 C 的动态内存分配不要中断里调用可能触发系统的复杂功能。这些规则和 C 语言里“不要在中断里调用 malloc”是一样的C 并没有改变这个底层事实。另外如果中断里要唤醒主循环处理数据尽量只设置一个标志位让主循环空闲时去处理通过 RAII 管理临界区的保护确保数据一致。6.3 给新手的入坑建议如果你看完这篇想试试在水 STM32 上用 C我给你几条具体的建议。第一条不要一上来就看《Effective C》《C Primer》这类大部头。在嵌入式这个场景先学最基础的语法类、命名空间、重载、构造函数析构函数足够用了。C 走极端可以非常复杂但作为嵌入式工程师你只需要学那个与实际需求匹配的子集。第二条从“换皮”开始。把已有 C 代码里的函数原封不动地挪到类里变成成员函数比如把uart_send(struct uart_dev*, uint8_t* data)改成uart.Send(data)。这一步不需要任何深奥的 C 知识但已经会带来非常明显的代码组织改善。第三条逼自己掌握 RAII。嵌入式中 RAII 是 C 最保值、最实用的特性我前面举的 InterruptGuard 只是一个起点。试着用它管理片选引脚、管理外设时钟、管理调度器锁你会发现很多之前“总忘了关”的问题彻底消失了。第四条学会看反汇编。C 抽象是否真的零开销不用听别人说自己编译出来看一眼最实在。CubeIDE 里右键工程选 Show Disassembly搜索一下你封装的外设操作函数看生成的指令是否和你手写 C 时一致。看几次之后你就彻底不慌 C 的性能问题了。我个人的切身体会是用 C 做 STM32 之后我写的代码总量并没有变少但需要我操心的事情变少了。模块边界有编译器帮忙强制资源释放有析构函数兜底类型错误在编译期就暴露出来。调试时看到的不再是一堆裸寄存器和魔数而是一眼能读懂的语义化逻辑。这个系列后面我会接着聊 C 工程怎么搭、外设驱动怎么分层、中断和任务之间怎么安全地共享数据以及怎么用 C 给嵌入式代码做单元测试。至少从这几年 STM32 项目的实践来看C 这条路走对了。
返回列表