1. 从裸机到C为什么要在单片机上折腾这门语言很多人第一次接触单片机编程都是从C语言开始的。51单片机、STM32、GD32、ESP32这些芯片的官方例程、教学视频、开源项目清一色都是C语言。江科大的51和32笔记在网上流传甚广蓝桥杯单片机组也是以C语言为绝对主力。所以当有人提出“在单片机上用C”的时候第一反应往往是有必要吗资源那么紧张C会不会太臃肿我自己最早也是这个想法。直到有一次做一个带菜单系统的项目用C语言写了一堆状态机函数指针数组、结构体嵌套、全局变量满天飞代码写到后面自己都理不清哪个函数改了哪个标志位。那时候我才认真去了解C在嵌入式里的实际用法发现事情和想象中完全不一样。C在单片机上的应用核心价值不在于那些“高级特性”而在于封装和类型安全。你完全可以在单片机上只用C的一个子集类、构造函数、引用、模板的简单用法、命名空间。这些特性带来的代码组织能力是纯C很难做到的。而且现代编译器对C的优化已经非常成熟只要你不碰异常、不碰RTTI、不碰动态多态生成的机器码和C几乎一样紧凑。这篇文章适合谁看如果你已经会用C语言点灯、写串口、调定时器但觉得代码越写越乱想找一个更好的组织方式那C在单片机上的应用就是为你准备的。如果你还在学C语言基础建议先把指针、结构体、函数指针这三样吃透再来看C会顺畅很多。注意这里说的C不是让你在单片机上跑STL容器、跑iostream、跑异常处理。那些东西在资源受限环境里是灾难。我们要用的是C的“零开销抽象”部分。2. 核心思路拆解C在单片机上到底能用什么、不能用什么2.1 零开销抽象原则不为没用的东西买单C有一个非常重要的设计哲学叫“零开销抽象”。意思是你用了一个高级特性如果这个特性在运行时不需要额外开销编译器就不会生成额外代码。比如你定义一个类里面只有成员变量和普通成员函数那这个类的大小和C语言的结构体完全一样成员函数的调用也和普通函数一样不会多一层间接。但有些特性是有开销的比如虚函数需要虚表指针每个对象多一个指针的大小异常处理需要额外的栈展开信息RTTI需要类型信息表。这些在单片机上要慎用尤其是RAM只有几KB的51单片机一个虚表指针可能就占掉你十分之一的RAM。所以我的选型原则很简单用C的语法糖和封装能力不用C的运行时机制。具体来说类、构造函数、析构函数、引用、命名空间、模板简单用法、内联函数、constexpr这些都可以放心用。虚函数在STM32级别可以酌情用在51级别基本不考虑。异常和RTTI直接关掉。2.2 编译器配置怎么让C代码不臃肿以STM32为例用STM32CubeIDE或者Keil MDK都可以直接建C工程。关键配置有几个关闭异常-fno-exceptions关闭RTTI-fno-rtti优化等级-Os优化体积链接时优化-flto在Keil里这些选项在Target设置里勾选。在GCC工具链里直接加到编译参数里。实测下来一个用C写的GPIO封装类和直接用寄存器操作的C代码编译出来的二进制大小差异在几十字节以内基本可以忽略。提示如果你用的是51单片机比如STC系列Keil C51编译器对C的支持非常有限基本只能当C用。所以51单片机不建议上C老老实实用C。C在单片机上的主战场是ARM Cortex-M系列比如STM32F103C8T6这种。2.3 内存管理不用new/delete用静态分配单片机上的RAM非常宝贵。STM32F103C8T6只有20KB RAM51单片机可能只有128字节到1KB。在这种环境下动态内存分配是禁忌。new和delete会引入堆管理代码而且容易产生碎片。C的构造函数可以在静态对象上调用不需要动态分配。你可以这样写class Led { public: Led(GPIO_TypeDef* port, uint16_t pin) : port_(port), pin_(pin) { // 初始化GPIO } void toggle() { HAL_GPIO_TogglePin(port_, pin_); } private: GPIO_TypeDef* port_; uint16_t pin_; }; // 静态分配全局对象 Led led1(GPIOA, GPIO_PIN_5);这个led1对象在编译期就分配好了空间构造函数在启动时调用没有任何堆操作。这就是C在单片机上最典型的用法。2.4 和C语言的互操作extern C的用法单片机开发离不开厂商的C语言库比如HAL库、标准外设库。C调用C函数需要加extern C否则链接时会找不到符号。通常的做法是在C头文件里加#ifdef __cplusplus extern C { #endif void HAL_GPIO_Init(...); #ifdef __cplusplus } #endif这样C和C都能用。如果你自己写C模块想被C代码调用也要在头文件里做类似处理。3. 实操过程从零搭建一个C单片机工程3.1 环境准备VSCode ARM GCC OpenOCD我平时用VSCode配C/C环境这套组合在Windows和Linux上都能跑。需要装的东西ARM GNU Toolchainarm-none-eabi-gccOpenOCD用于下载和调试VSCode插件Cortex-Debug、C/C工程目录结构大概是这样project/ ├── src/ │ ├── main.cpp │ ├── led.cpp │ └── led.hpp ├── startup/ │ └── startup_stm32f103.s ├── linker/ │ └── stm32f103c8t6.ld ├── Makefile └── .vscode/ ├── launch.json └── tasks.jsonMakefile里关键编译参数CXXFLAGS -mcpucortex-m3 -mthumb -Os -fno-exceptions -fno-rtti -fno-use-cxa-atexit -Wall LDFLAGS -T$(LDSCRIPT) -Wl,--gc-sections -flto-fno-use-cxa-atexit这个参数很多人不知道它是用来关掉全局对象析构注册的。单片机程序通常不会正常退出所以不需要在退出时调用全局对象的析构函数关掉可以省一点空间。3.2 第一个C类封装一个LED先看C语言版本#define LED1_PORT GPIOA #define LED1_PIN GPIO_PIN_5 void led1_init(void) { GPIO_InitTypeDef gpio {0}; gpio.Pin LED1_PIN; gpio.Mode GPIO_MODE_OUTPUT_PP; gpio.Speed GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(LED1_PORT, gpio); } void led1_on(void) { HAL_GPIO_WritePin(LED1_PORT, LED1_PIN, GPIO_PIN_RESET); } void led1_off(void) { HAL_GPIO_WritePin(LED1_PORT, LED1_PIN, GPIO_PIN_SET); } void led1_toggle(void) { HAL_GPIO_TogglePin(LED1_PORT, LED1_PIN); }这是典型的C写法每个LED都要写一套函数或者用宏来简化。如果有8个LED代码会非常冗长。C版本// led.hpp #pragma once #include stm32f1xx_hal.h class Led { public: Led(GPIO_TypeDef* port, uint16_t pin) : port_(port), pin_(pin) { GPIO_InitTypeDef gpio {0}; gpio.Pin pin_; gpio.Mode GPIO_MODE_OUTPUT_PP; gpio.Speed GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(port_, gpio); } void on() { HAL_GPIO_WritePin(port_, pin_, GPIO_PIN_RESET); } void off() { HAL_GPIO_WritePin(port_, pin_, GPIO_PIN_SET); } void toggle() { HAL_GPIO_TogglePin(port_, pin_); } private: GPIO_TypeDef* port_; uint16_t pin_; };用的时候// main.cpp #include led.hpp Led led1(GPIOA, GPIO_PIN_5); Led led2(GPIOB, GPIO_PIN_0); int main() { while (1) { led1.toggle(); led2.toggle(); HAL_Delay(500); } }代码量少了而且每个LED是一个独立对象不会互相干扰。这就是封装带来的好处。3.3 构造函数时机全局对象什么时候初始化C全局对象的构造函数在main函数之前调用。在单片机启动流程里startup_stm32f103.s里的复位处理程序会先调用SystemInit然后调用__libc_init_array这个函数会遍历.init_array段调用所有全局对象的构造函数。所以如果你在全局对象的构造函数里用了HAL库比如HAL_GPIO_Init需要确保HAL库已经初始化。通常HAL_Init是在main里调用的但全局对象构造在main之前这时候HAL还没初始化直接调用HAL_GPIO_Init可能会出问题。解决办法有两个一是把外设初始化放在main里全局对象只保存参数不调用HAL二是用“懒初始化”模式第一次使用时才初始化。我一般用第一种简单可靠。class Led { public: Led(GPIO_TypeDef* port, uint16_t pin) : port_(port), pin_(pin), initialized_(false) {} void init() { if (!initialized_) { GPIO_InitTypeDef gpio {0}; gpio.Pin pin_; gpio.Mode GPIO_MODE_OUTPUT_PP; gpio.Speed GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(port_, gpio); initialized_ true; } } // ... };然后在main里先HAL_Init()再调用每个对象的init()。3.4 中断处理C里怎么写中断服务函数中断服务函数在C里需要用extern C因为中断向量表是C链接的。比如extern C void TIM2_IRQHandler(void) { if (__HAL_TIM_GET_FLAG(htim2, TIM_FLAG_UPDATE)) { __HAL_TIM_CLEAR_FLAG(htim2, TIM_FLAG_UPDATE); // 你的代码 } }如果你想在中断里调用C对象的方法要注意这个对象必须是全局的或者静态的不能是局部的。而且中断里调用的方法最好是inline的或者简单的避免函数调用开销。4. 常见问题与排查技巧实录4.1 链接错误undefined reference to__cxa_guard_acquire这个错误通常出现在你用了局部静态对象的时候。C11之后局部静态对象的初始化是线程安全的编译器会生成__cxa_guard_acquire和__cxa_guard_release来保护初始化过程。但在单片机上通常没有多线程这个保护是多余的。解决办法加编译参数-fno-threadsafe-statics告诉编译器不需要线程安全保护。这样就不会生成那些函数调用了。4.2 代码体积突然变大检查是否引入了异常和RTTI如果你发现加了C代码后二进制体积突然大了几KB大概率是异常或RTTI没关。检查编译参数里有没有-fno-exceptions和-fno-rtti。另外dynamic_cast和typeid也会引入RTTI尽量避免使用。4.3 全局对象构造函数没被调用如果你发现全局对象的构造函数没执行检查链接脚本里.init_array段有没有被正确放置。有些自定义链接脚本可能漏掉了这个段。标准做法是在.text段后面加.init_array : { . ALIGN(4); __init_array_start .; KEEP(*(.init_array*)) __init_array_end .; } FLASH4.4 常见问题速查表问题现象可能原因解决办法链接错误undefined referenceC调用C函数没加extern C在C头文件加extern C包裹体积突然增大异常/RTTI没关加-fno-exceptions -fno-rtti全局对象构造没执行.init_array段丢失检查链接脚本中断里调用对象方法崩溃对象是局部的改用全局或静态对象局部静态对象链接错误线程安全保护加-fno-threadsafe-statics提示如果你用的是Keil MDK在Options for Target - C/C里Misc Controls里填--cpp11 --no_rtti --no_exceptions。不同版本参数可能略有差异以实际为准。5. 进阶用法模板和constexpr在单片机上的实际价值5.1 用模板做寄存器封装模板在单片机上最大的价值是“编译期计算”和“类型安全”。比如你可以用模板参数来指定GPIO端口和引脚这样编译器可以在编译期确定所有地址生成和直接写寄存器一样的代码。templateGPIO_TypeDef* Port, uint16_t Pin class Gpio { public: static void init() { GPIO_InitTypeDef gpio {0}; gpio.Pin Pin; gpio.Mode GPIO_MODE_OUTPUT_PP; gpio.Speed GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(Port, gpio); } static void set() { HAL_GPIO_WritePin(Port, Pin, GPIO_PIN_SET); } static void reset() { HAL_GPIO_WritePin(Port, Pin, GPIO_PIN_RESET); } static void toggle() { HAL_GPIO_TogglePin(Port, Pin); } }; using Led1 GpioGPIOA, GPIO_PIN_5; using Led2 GpioGPIOB, GPIO_PIN_0;用的时候Led1::init(); Led1::toggle();这种写法没有任何运行时开销所有参数都是编译期常量生成的代码和直接调用HAL库一样。而且类型安全不会把端口和引脚搞混。5.2 constexpr做编译期计算constexpr可以用来做编译期计算比如计算定时器的分频值、波特率寄存器的值。这样这些计算不会占用运行时时间。constexpr uint32_t calc_prescaler(uint32_t clk, uint32_t freq) { return clk / freq - 1; } constexpr uint32_t prescaler calc_prescaler(72000000, 1000);这个prescaler在编译期就计算好了直接作为常量嵌入代码。5.3 模板的坑代码膨胀模板用多了会导致代码膨胀因为每个不同的模板参数组合都会生成一份独立的代码。如果你用模板生成了几十个不同的GPIO类每个类都有独立的init、set、reset、toggle函数代码体积会明显增大。解决办法把不依赖模板参数的代码提取到基类或普通函数里。比如HAL_GPIO_Init的调用可以统一到一个非模板函数里模板类只负责传递参数。6. 从C到C的迁移策略不要一次性重写如果你有一个现成的C语言项目想迁移到C不要一次性全部重写。我的建议是先把编译器切换到C模式把.c文件改成.cpp确保能编译通过。C语言大部分代码在C里也能编译除了少数隐式类型转换的地方需要加显式转换。然后从最乱的那个模块开始用类重新组织。比如LED模块、按键模块、串口模块。新写的代码直接用C风格老代码保持不动通过extern C互相调用。逐步替换每次替换一个模块测试通过后再替换下一个。这样风险可控不会因为一次大改导致项目崩溃。注意C语言里的malloc和free在C里尽量不要用用静态分配或者内存池代替。C的new和delete在单片机上也要慎用原因和malloc一样。7. 我个人在实际操作中的体会我最早在STM32F103C8T6上跑C的时候心里也没底怕代码臃肿、怕出奇怪的问题。实际用下来只要把异常和RTTI关掉不用动态内存代码体积和C版本几乎没差别。但代码的可读性和可维护性提升非常明显。以前一个状态机要写几百行C代码用C的类封装后可能就几十行而且逻辑清晰。踩过的坑主要是两个一个是全局对象构造函数在HAL初始化之前执行导致GPIO配置失败另一个是局部静态对象引入了线程安全保护链接时报错。这两个问题在文章里都写了解决办法希望对你有帮助。最后再分享一个小技巧如果你用VSCode写C单片机代码可以在.vscode/c_cpp_properties.json里配置includePath把HAL库的头文件路径加进去这样代码补全和跳转都会很顺畅。另外compileCommands指向compile_commands.json可以让VSCode准确理解你的编译参数避免误报错误。
