
前一阵子在技术群里跟人聊 STM32 的项目方案有位做了十几年单片机的老工程师随口甩出一句话“单片机资源就那么多老老实实用 C 就行C 跑不动的。”我当时没接话但心里很清楚这不是个例。嵌入式 C 这个事几乎每过一阵子就会被拿出来争论一轮支持的人用模板和 RAII 写得飞起反对的人连听都不愿意听。这个系列上一篇文章里我大概聊了聊为什么要开始用 C 写嵌入式以及 C 在单片机上的历史渊源。这一篇我想换个角度专门来拆一拆“C 跑不了单片机”这个刻板印象到底是怎么形成的哪些说法有道理哪些说法早就过时了。这个问题如果理不清团队里就很难真正把 C 用起来因为每次 review 代码都会有人跳出来拿“跑不动”说事。所以这篇文章不只是讲技术更想讲清楚偏见背后的来龙去脉。1. “C跑不了单片机”这句话是从哪个年代传下来的1.1 上世纪八九十年代8位单片机的客观限制如果你把时间拨回到二十多年前那个年代的单片机确实跟 C 八字不合。拿最经典的 8051 系列来说片内 RAM 常常只有 128 字节ROM 也就是 4KB 到 8KB主频 12MHz 算是主流。这种资源规格下连 C 语言编译器生成的代码都已经是开发者精打细算之后的结果更别提 C 了。当时也存在支持 8051 的 C 编译器比如 Keil 早期版本和一些专业的第三方工具链但实际用起来体验一言难尽。C 的核心机制比如类、虚函数、重载、模板每一个都可能引入额外的代码生成和运行时支持。一个空的 C 工程哪怕什么逻辑都不写链接出来的固件体积都可能让 8KB ROM 捉襟见肘。其实那个年代的硬件性能决定了编程语言的选型空间。51 单片机更常用的还是汇编C 语言已经算是“高级抽象”了。工程师花大量的精力去抠字节、省周期C 这种建立在更抽象层次上的语言在这种环境里确实不现实。所以“C 跑不了单片机”这句话在那个年代是相当真实的客观描述不是偏见。1.2 编译器生态的缺位让偏见扎了根除了硬件本身编译器生态的问题也很关键。上个世纪嵌入式 C 编译器已经相当成熟了各大芯片厂商都会提供自家芯片的 C 编译器生成的代码经过多年优化效率很接近汇编。各种库、例程、参考手册默认都是 C 语言的天下。而 C 编译器在嵌入式领域长期处于“能编译但没人给你做深度优化”的状态。很多第三方编译器只是把 C 源码勉强翻译成目标代码代码膨胀严重错误提示还不友好。再加上当时的行业标准 MISRA-C 只认 C很多车规、工控项目直接在规范层面把 C 排除了。开发工具和参考资料的缺失形成了一种自我强化的循环因为没人用 C 写单片机所以没有针对性的优化和支持因为没有支持所以更没人用。这种循环持续了很久导致一代工程师从入行到转行都没真正见过 C 在单片机上跑得好是什么样于是“C 跑不了单片机”就成了一条代代相传的经验。1.3 ARM Cortex-M 出现后信息为什么还是没更新2004 年前后ARM Cortex-M3 核心横空出世STM32 后来的火爆让单片机的主频和存储容量直接上了一个台阶。STM32F103 这种“上古神片”都有 64KB 甚至 512KB Flash20KB 到 64KB 的 RAM72MHz 的主频。这种资源量级从硬件上说已经完全养得起一套精简配置的 C 运行时了。但你不得不承认工具链的普及远远慢于硬件的发展。很长一段时间里ST 官方提供的标准外设库和后来的 HAL 库都只有 C 语言版本官方例程里清一色的 .c 文件。哪怕你写一个 .cpp 文件点编译之前还得在 Keil 里手动改一堆设置新手很容易卡在配置上。更麻烦的是C 要和 C 库函数交互需要处理 extern C 的问题。芯片启动文件是汇编写的初始化函数是 C 写的中断向量表注册的函数名也是按 C 符号规则编译的。这些细节虽然不是大问题但需要有人告诉你“原来要这样处理”。当年网上能找到的现成经验少之又少大多数人试了几次没跑通就扔下一句“C 不行”回到 C 的老路上。2. 逐条拆解“C太庞杂、太慢、太浪费”的说法到底哪些是真的2.1 全功能C确实不适合单片机但没人要求你全用反对 C 的人最常用的理由就是拿 C 的全家桶来说事异常处理、运行时类型识别、虚继承、iostream 流、标准模板库、动态内存分配……如果把这些东西全塞进一个 STM32 工程里我不仅不反对“跑不动”的说法我自己第一个跑路。先说异常。C 的异常处理机制在 GCC 工具链里默认是需要运行时类型信息和一套展开栈的库支持的。启用异常后即使代码里一个 throw 都没写链接出来的固件体积也会明显增加。而且单片机中断上下文里的异常处理牵扯到栈展开和当前执行状态的恢复语义十分微妙。所以嵌入式 C 圈子里有个共识关掉异常或者只在极少数高可靠场景下启用带严格约束的异常机制。再说运行时类型识别也就是 dynamic_cast 和 typeid 这套东西。它要求编译器为每个带虚函数的类维护额外的类型信息数据结构。这些信息平时躺在 Flash 里看似不占 RAM但会让代码段体积膨胀。如果代码里真正用到 dynamic_cast 的地方只有一两处为了这一两处把整个工程的类型信息全量打进去性价比非常低。iostream 更不用提输入输出流的实现臃肿程度在资源受限环境里是出了名的。一个 hello world 级别的 iostream 程序链接出来的固件可能比纯 C 的 printf 版本大上好几倍。实际嵌入式项目里谁会用 cout 去驱动串口都是自己封装寄存器级别的字符输出函数。2.2 模板和重载不是“隐藏的运行时炸弹”而是编译期决策反对 C 的人容易把所有特性混为一谈但模板和异常、RTTI 完全是两回事。模板最大的特点是所有实例化都发生在编译期运行时不产生任何额外开销。举个例子你想封装一个操作 GPIO 寄存器的功能。用 C 的方式通常是定义一堆宏#define GPIO_SET_BIT(PORT, PIN) ((PORT)-BSRR (1U (PIN))) #define GPIO_CLEAR_BIT(PORT, PIN) ((PORT)-BRR (1U (PIN)))用宏确实很直接但宏没有类型检查参数写错了编译器也发现不了。用 C 模板可以这么写templateuint32_t Pin inline void gpioSetBit(GPIO_TypeDef* port) { static_assert(Pin 16, Pin index out of range); port-BSRR (1U Pin); }这个模板在编译时就知道 Pin 是多少static_assert 在编译期做合法性检查展开后的机器码跟你手写寄存器操作一模一样。没有任何函数调用栈没有任何中间变量甚至连一个额外的跳转指令都不会产生。运算符重载也经常被误解。你重载一个运算符编译之后实际上就是一次普通函数调用能不能被内联取决于优化选项和函数复杂度。比如写一个封装寄存器的类重载 operator 来实现“写寄存器”的语义只要你把它定义成内联函数最终生成的指令跟直接操作寄存器就没有区别。重要的是你拿到的是可读性、可维护性而不是性能损失。所以当你听到有人说“C 模板运行慢”时他大概率是把编译期模板实例化和运行时多态调用搞混了。虚函数确实有 vtable 查表的开销但模板根本不是多态它是“在编译期把类型算清楚然后直接生成对应代码”的静态机制。2.3 用数据说话STM32F103 上 C 和 C 的实际差距我手头测试过不少 STM32 板子这里用一个非常典型的例子来说明 C 和 C 在真实固件上的差异。做一个简单的 GPIO 翻转测试在 STM32F103 上跑一个循环每次把一个引脚翻转然后测量方波频率。纯 C 的写法void toggle_c(void) { for (volatile uint32_t i 0; i 1000000; i) { GPIOB-ODR ^ GPIO_PIN_0; } }C 的写法用模板封装一下寄存器地址templateuint32_t BASE_ADDR struct GPIO { static inline void toggle(uint32_t pin) { reinterpret_castvolatile uint32_t*(BASE_ADDR)-ODR ^ pin; } }; void toggle_cpp(void) { for (volatile uint32_t i 0; i 1000000; i) { GPIOGPIOB_BASE::toggle(GPIO_PIN_0); } }开 -O2 优化之后两段代码生成的汇编几乎一模一样跑出来的方波频率差异在 1% 以内。这个差异主要来自编译器版本、对齐方式等随机因素根本不是 C 本身的“性能税”。如果做一个更贴近实际项目的对比比如一个包含 UART 初始化、定时器配置、中断处理、状态机调度的小系统C 版本和 C 版本在 Flash 占用上的差距通常在几百字节到几 KB 之间。原因很简单只要你关掉异常、关掉 RTTI、不用 iostreamC 其实只是“长得不一样”的 C它在底层仍然直接映射到寄存器和内存地址并不会生成什么魔法代码。3. STM32上真正把C跑起来的工程配置每一步都是什么逻辑3.1 工具链选择从 MDK 到 GCC 的取舍在 STM32 上跑 C第一步是选工具链。市面上主要有三套方案Keil MDK商业工具C 支持默认开启但它的编译优化能力和 GCC 长期有差异。不少老工程师对 C 的“坏印象”其实来自早期 MDK 的 C 前端代码生成质量确实一般。IAR EWARM同样商业C 支持完善代码体积控制得很好但价格不菲。arm-none-eabi-gcc开源工具链G 编译器社区活跃优化选项丰富。我个人最喜欢用这个。以 arm-none-eabi-gcc 为例关键的编译选项有这些arm-none-eabi-g -mcpucortex-m3 -mthumb -Os -ffunction-sections -fdata-sections \ -fno-exceptions -fno-rtti -Wl,--gc-sections -specsnano.specs -specsnosys.specs逐个解释一下-fno-exceptions和-fno-rtti把 C 的异常和运行时类型信息关掉这是嵌入式 C 的第一步也是最重要的一步。关掉之后编译器就不再为可能抛出异常的函数生成额外的栈展开表也不再为多态类生成 typeinfo 结构。-ffunction-sections -fdata-sections-Wl,--gc-sections让链接器把每个函数、每个变量放到独立的段里然后在链接阶段把没被引用的段整体扔掉。这对 C 尤其重要因为模板会生成大量实例化代码很多是永远用不到的。-specsnano.specs使用 newlib-nano 库这是专门为嵌入式优化过的 C 标准库精简版printf 等等功能的体积大幅缩小。-specsnosys.specs链接一个最简的系统调用 stub避免因为缺少底层 syscall 而链接失败。3.2 启动文件里那些必须处理的 C 初始化很多人第一次用 C 写 STM32会遇到一个非常诡异的“bug”全局对象的构造函数没有执行。定义一个全局类对象在构造函数里点个灯结果上电之后灯不亮。原因很简单单片机启动流程和 PC 程序不同。Cortex-M 芯片上电后先是复位向量跳转到 Reset_Handler然后由启动代码完成内存清零、数据段拷贝最后跳到 main 函数。PC 程序里那种“调用全局构造函数”的动作在单片机默认启动代码里根本不存在需要你手动把 C 运行时的初始化函数加进去。arm-none-eabi-g 链接 C 程序时会把所有全局对象的构造函数地址收集到一个叫.init_array的段里。完成初始化需要调用一个名为__libc_init_array的函数。所以你的启动代码里应该这样安排extern void __libc_init_array(void); void Reset_Handler(void) { // 1. 设置堆栈指针通常由硬件自动完成 // 2. 拷贝 .data 段 // 3. 清零 .bss 段 // 4. 调用 __libc_init_array(); // 新增的关键步骤 // 5. 调用 main() }如果你用的是 ST 官方的 startup 文件它默认只做了前三步不会自动调用__libc_init_array。所以你需要在 main 函数的开头手动调用或者修改启动文件。这个细节是很多人第一次踩坑的地方。3.3 中断服务和C代码的边界以及“周全的封装”中断服务函数在 C 工程里是一个容易出错的高危区。Cortex-M 的中断向量表期望的函数符号是 C 链接规则的比如USART1_IRQHandler。如果你把中断函数定义在 .cpp 文件里必须加上 extern C 修饰extern C void USART1_IRQHandler(void) { // 中断处理 }如果你不加 extern C函数名会被 C 编译器按命名空间、类名、参数列表进行 name mangling链接器就找不到匹配的中断向量符号于是中断函数永远不会被调用。这是第二个非常隐蔽的坑。更关键的是中断服务函数里能不能调用 C 成员函数。答案是可以但你要确保整个调用路径上没有任何异常抛出也没有动态内存分配失败的情况。因为中断上下文对时间和资源都非常敏感C 的抽象如果设计得不好很容易把几十个周期的事情拖到几百个周期。我的建议是在中断服务函数里只做最必要的寄存器操作比如清中断标志位、缓存数据然后把真正的业务逻辑放到主循环里。中断和业务模块之间的接口可以用一个环形缓冲区或者std::array做队列。这样做的好处是C 的复杂逻辑不进入中断上下文安全性提高而且中断延迟也变得更可控。3.4 链接脚本里预留足够的堆如果你决定在 C 代码里使用动态内存也就是 new/delete那么有一件事必须确认链接脚本中堆区域的大小。STM32 的 GCC 工程默认堆通常只有几百字节这对 malloc 或 new 来说是杯水车薪。推荐的做法是嵌入式 C 项目优先用静态对象、局部对象和std::array尽量避免动态分配。如果非要用那就把堆设到足够大并且在代码里做好分配失败检测。实际项目里我见过因为默认堆太小new 返回空指针程序跑飞的情况这种问题排查起来非常费劲。4. 嵌入式C的正确用法为什么有人写得好有人写成一坨4.1 没有“万能语言”C 也分面向对象的 C 和模板化的 C我这些年见过不少“用 C 写嵌入式”翻车的项目翻车的原因往往不是 C 不行而是用的人把 C 当 Java 使。他们把程序拆成一大堆继承层次每个外设都定义一个抽象基类接口层全部虚函数然后到处 new 对象。费了九牛二虎之力搭出来的框架Flash 占用直线上升运行效率却没啥提升最后只能回退到 C。嵌入式 C 的正确姿势其实更偏向“模板 值语义 静态派发”这一路。类当然可以定义但继承和虚函数要非常克制。大多数情况下用模板做编译期多态就够了不需要运行期多态。举一个例子。你想要一个控制 PWM 输出的接口硬件上有定时器 PWM、GPIO 模拟 PWM、还有带专用外设的 PWM。用虚函数做的话class Pwm { public: virtual void setDuty(uint16_t duty) 0; virtual ~Pwm() {} };用模板做的话templatetypename HardwarePwm class PwmController { public: void setDuty(uint16_t duty) { hardware_.setDuty(duty); } private: HardwarePwm hardware_; };第二种方案不产生 vtable不产生动态分配所有调用都是编译期确定的最终生成的代码和手写寄存器操作完全没有区别。而且它一样能做到“接口统一”因为只要HardwarePwm类型有setDuty方法编译期就会自动匹配上这叫做结构化类型约束。4.2 RAII 的价值用作用域管理资源单片机也适用C 程序员管理资源靠“先申请后释放”的心智纪律释放漏了就是 bug。C 里有一把好武器叫 RAII意思是用对象的构造函数和析构函数来管理资源的生命周期。可能有人会觉得这是桌面程序的概念单片机又没有资源需要释放。其实不然。你想想这些场景关闭一个外设的时钟使能、退出临界区时恢复中断状态、临时禁用某个中断源、获取一个互斥锁防止并发访问。以前用 C 写是这种风格uint32_t primask __get_PRIMASK(); __disable_irq(); // 临界区代码 __set_PRIMASK(primask);这段代码有个致命问题如果临界区中间某个分支提前 return 了中断恢复的语句就被跳过。你用静态分析工具查这种问题查一次悔一次。用 C RAII 写长这样struct CriticalSectionGuard { uint32_t primask; CriticalSectionGuard() : primask(__get_PRIMASK()) { __disable_irq(); } ~CriticalSectionGuard() { __set_PRIMASK(primask); } }; void someFunction() { CriticalSectionGuard guard; // 临界区代码 // 即使这里提前 return析构函数也会自动恢复中断状态 }编译器会在作用域退出时自动调用析构函数不管你是正常 return 还是提前 return。这种“让编译器帮你兜底”的思路对嵌入式开发的可靠性提升是非常明显的。很多老工程师坚持不用 C其实是没体验过 RAII 带来的安全感。4.3 区分“嵌入式 C”和“桌面 C”的边界我建议初学者在心里明确一个概念你能用到的 C 是一个经过裁剪的子集而不是教科书上的完整 C。以下这些东西在普通 STM32 项目里尽量别碰或者严格控制new/delete动态分配容易造成堆碎片且分配耗时不确定不满足实时性要求。std::vector、std::string这类依赖堆的容器除非你能保证内存静态预分配否则慎用。iostream体积极大替代方案是自封装的格式化输出函数。异常和 RTTI如前所述直接关掉。多重继承和虚继承会增加对象布局复杂度和代码体积非必要不用。可以放心大胆用的类、封装、命名空间用它们组织代码比散落的函数清楚得多。模板编译期多态、类型安全的寄存器操作、constexpr 计算。RAII作用域管理中断、锁、延时。std::array固定大小数组的现代封装没有堆开销。std::spanC20修改经典 C 数组接口时的友好类型。lambda 表达式配合回调函数用灵活且由编译器内联。这个子集不是随便定的而是经过实际项目验证的组合。它可以让你避免 C 所有“跑不动”的槽点同时拿到 C 语言给不了的工程组织能力和类型安全。5. 我在实物板上跑过的关键场景以及对应的一些经验教训5.1 一款STM32F407产品从纯C迁移到C的过程我手上有一个老项目原来是纯 C 写的跑在 STM32F407 上包含传感器采集、PID 控制、显示、通讯等功能代码量大概 1.5 万行。后来要加新功能C 语言的组织结构已经有点撑不住了全局变量满天飞模块间耦合严重。于是花了大概一个月时间把核心模块逐渐迁到 C。迁移过程没有搞“一天重写”的大革命而是采用了渐进式策略第一步把整个工程从 .c 文件迁移到 .cpp 文件。文件名改成 .cpp编译器改用 g但代码内容可以暂时不动。只要在 .h 文件里统一加 extern C 处理C 库函数和中断函数都不会有问题。这一步目的是让 C 编译器接管所有代码暴露出潜在的符号链接和类型问题。第二步关掉异常和 RTTI。这时编译器可能会报一些依赖这些特性库的错误逐个修掉。第三步选择核心模块比如通信协议解析和状态机管理重构成类和模板。第四步把常用的资源管理改造成 RAII 风格比如串口发送前禁止中断、发送完恢复中断就用了前面提到的 Guard 类。最终的效果代码量减少了很多因为很多重复的宏定义和 try-finally 风格的手工恢复逻辑被统一的模板和 RAII 替代了。Flash 占用略微增加具体的数字我很诚实地说增加了大概 2KB 左右本质原因是 C 标准库的初始化代码和模板实例化的少量开销。作为代价换来的是模块间接口更清晰原来那种“改一个全局变量导致另一个模块行为突变”的调试地狱明显少了很多。5.2 静态初始化顺序这个老坑你知道吗C 工程里跨模块使用全局对象时有一个著名的静态初始化顺序惨案。如果 A 模块和 B 模块都在文件作用域定义全局对象而且 A 的构造函数会访问 B 的对象那么 A 在 B 前面构造的话就会访问到一个未初始化的对象。这种行为标准没有规定顺序编译器也有自己的实现方式结果就是代码在不同优化级别下表现不同。我在 STM32 上就踩过一次一个日志模块的全局对象先构造另一个 StatusLED 模块的全局对象后构造结果日志构造时想点一下指示灯灯没亮因为 StatusLED 还没构建。后面排查了很久最后把全局对象都改成“单例模式 首次访问时初始化”的做法才算根治。所以我的经验是尽量不要在全局构造函数里依赖其他模块的全局对象一定要依赖的话就把依赖对象也做成局部 singleton或者在 main 函数里手动按顺序构造。5.3 一个非常“香”的组合模板constexpr做寄存器映射最后分享一个提升开发效率的组合技巧。在 STM32 的 C 项目里你可以用 constexpr 在编译期算寄存器地址和位掩码用模板约束外设类型让编译器帮忙查错。比如enum class PinMode { Input, Output, AlternateFunction, Analog }; templateuint32_t PortBase class Gpio { public: templateuint32_t Pin, PinMode Mode static void configure() { static_assert(Pin 16, Invalid pin); volatile uint32_t* moder reinterpret_castvolatile uint32_t*(PortBase 0x00); uint32_t shift Pin * 2; uint32_t modeBits static_castuint32_t(Mode); *moder (*moder ~(0x3UL shift)) | (modeBits shift); } }; using GPIOA_Type GpioGPIOA_BASE; using GPIOB_Type GpioGPIOB_BASE;用的时候GPIOA_Type::configure0, PinMode::Output(); GPIOB_Type::configure5, PinMode::Input();如果你把引脚号写错了比如写 Pin20static_assert 会在编译阶段直接拦住你。如果你在只支持输入的寄存器上调用了输出配置通过模板特化也可以在设计期给出提示。这一套组合下来编译通过基本等于寄存器操作正确很大程度上减少了运行时调试的时间。当然这种模板化不是为了炫技而是为了把“寄存器配置错误”这类低级错误从运行期搬到编译期。对于硬件开发来说多一道编译期的检查就是少一次示波器前的熬夜。留给还在观望的人一句话我自己从怀疑 C、到尝试 C、再到把 C 作为主力语言做 STM32 项目走了不短的路。回头再看“C 跑不了单片机”这个说法只能感慨一句它曾经是事实但现在已经变成了一个过时的标签。关键不是 C 能不能跑而是你选择的编译器版本、裁剪策略和设计风格有没有跟上硬件时代的节奏。如果你正在一个中大规模 STM32 项目里被 C 语言全局变量和函数指针折腾得难受不妨静下心来试一把 C从关掉异常开始从你最有把握的一个模块开始大概率会重新认识这门语言。