
第五篇了。评论区从第三篇就开始有人喊“看了三篇了一行都没让我写呢”说实话这种催促是对的前面连续铺垫环境、工具链、C语法移植确实吊足了胃口。但嵌入式C这趟水比你想象得要深直接打开一个MCU工程改成.cpp然后开写大概率连main都进不去。延迟到今天主要是因为有些坑必须提前说清不然你写第一行代码就会卡到怀疑人生。这一篇我不是来给理论补课的是来带你把第一段能跑的C代码写出来。目标很具体用一只STM32F103C8T6最小系统板完成GPIO点灯、串口打印、中断回调这三件事全程用C风格的类来封装。涉及的坑包括启动文件的C初始化、extern C混编、printf重定向、中断函数与成员函数的桥接每一个都是我实际编译、烧录、看波形之后才敢写的结论。文章尽量保持代码完整你照着敲一遍板子亮了就算这次“编程之旅”正式开始。1. 先别急着写C上MCU前这三件事必须知道1.1 这不是桌面CC库启动流程要先接上桌面环境下C程序里那些全局对象、静态对象是在main之前由C运行时库负责构造的。编译器会往可执行文件里插入crtbegin、crtend这样的初始化片段操作系统加载完程序后动态链接器或者启动代码会调用__libc_init_array把全局构造器一个个执行完然后才进main。MCU上呢复位向量一触发CPU直接跑Reset_Handler。以STM32F103常用的GCC启动文件startup_stm32f103xb.s为例它做的事情大体是设置栈指针、调SystemInit然后bl main。很多模板里并没有显式调用__libc_init_array。这意味着你在文件作用域写的Gpio led;这样的全局对象构造函数可能根本不会执行对象里的成员变量全是未知值程序一跑起来就是乱摸寄存器轻则LED不亮重则HardFault。这是个典型的“为什么前几篇这么啰嗦”的原因。你不会先遇到语法问题而是会遇到一个看起来跟C完全无关的链接和启动问题。后面第五部分我会给你看具体症状和解法。1.2 从C入口调用C函数混编的第一个知识点STM32CubeMX生成的是纯C工程main.c里已经有一个main函数。你完全没必要跟它较劲非要删掉再用C重写入口。更简单的过渡方式是保留main.c在main函数末尾调用一个你写在C文件里的函数比如cpp_main()。这样C语言负责一切硬件初始化C代码从你的世界开始接管。/* main.c 中原本的 main 函数只加两行 */ int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART1_UART_Init(); cpp_main(); // 加这一行进入 C 世界 while (1) { } }// main.cpp #include main.h extern C void cpp_main() { // 你的 C 代码从这一行开始 }注意extern C。main.cpp会按C编译C编译器会对函数名做name mangling而main.c里链接器查找的是未修饰的cpp_main符号。所以C侧定义时得用extern C把符号名导出为普通C函数名两边才能顺利对上。同理CubeMX生成的外设全局变量比如huart1是C文件里的符号你在C里引用时要么包含对应头文件要么自己声明extern C UART_HandleTypeDef huart1;。1.3 需要的编译选项减少C运行时对MCU的负担在Makefile或CubeIDE的编译器设置里有几个选项几乎是必须加的。我自己的工程配置大概是这样CXX arm-none-eabi-g CXXFLAGS -mcpucortex-m3 -mthumb -Os -stdc17 \ -fno-exceptions -fno-rtti -fno-threadsafe-statics LDFLAGS -mcpucortex-m3 -mthumb -T stm32f103x8.ld \ --specsnano.specs --specsnosys.specs-fno-exceptions关掉异常。裸机上异常没有对应运行时支持一旦抛异常链接阶段会报缺少__cxa_throw你以为写得很C结果连烧录都做不到。-fno-rtti关掉运行时类型识别dynamic_cast、typeid都不用了省下不少flash和RAM。-fno-threadsafe-statics是因为裸机单线程根本不需要为局部静态变量的初始化加锁C11默认是线程安全的那套guard逻辑在MCU上纯属浪费。还有一点链接一定要用arm-none-eabi-g不要拿gcc来链接。C标准库、异常处理段这些只有C编译器驱动才会正确链接进去。2. 第一行有意义的C代码用寄存器封装一个GPIO类2.1 先理解地址再写类STM32F103的GPIO外设基地址是一组非常规整的地址GPIOA从0x40010800开始GPIOB是0x40010C00GPIOC是0x40011000。CMSIS头文件已经帮你把这些地址定义成了GPIOA、GPIOB、GPIOC这样的指针所以我们直接用。我用的板子LED接在PC13所以打开GPIOC的时钟把13脚配成推挽输出。时钟寄存器是RCC-APB2ENR的IOPCEN位这个在stm32f1xx.h里已经给了位定义。这些基础如果还不熟悉建议先把手册翻一遍但你不必把所有寄存器都背下来理解BSRR、ODR、CRH这几个关键的即可。GPIO模式配置需要根据引脚号来选择。0到7号引脚用CRL8到15号用CRH。每个引脚占用4位MODE位控制速率CNF位控制模式。LED作为推挽输出MODE给102MHzCNF给00即可。封装成类时要处理pin在低八位和高八位的分支否则会踩到寄存器移位错位的坑。2.2 完整代码一个极简GPIO类#include stm32f1xx.h class Gpio { public: enum class Mode { Input, Output50MHz }; enum class Level { Low 0, High 1 }; void init(GPIO_TypeDef* port, uint16_t pin, Mode mode) { port_ port; pin_ pin; volatile uint32_t* configReg; uint32_t bitPosition; if (pin 8) { configReg port_-CRL; bitPosition pin * 4; } else { configReg port_-CRH; bitPosition (pin - 8) * 4; } uint32_t current *configReg; current ~(0xFUL bitPosition); uint32_t modeValue; if (mode Mode::Input) { modeValue 0x4; // CNF10上拉/下拉? 这里先按输入浮空CNF01 } else { modeValue 0x3; // MODE11(50MHz)CNF00推挽输出 } current | (modeValue bitPosition); *configReg current; } void set(Level level) { if (level Level::High) { port_-BSRR (1UL pin_); } else { port_-BSRR (1UL (pin_ 16)); } } void toggle() { if (port_-ODR (1UL pin_)) { port_-BSRR (1UL (pin_ 16)); } else { port_-BSRR (1UL pin_); } } private: GPIO_TypeDef* port_; uint16_t pin_; };这段代码看着简单但里面有几个嵌入式专用细节pin 8判断CRL和CRH0xFUL bitPosition一次清掉4位配置输出时通过BSRR而不是ODR来赋值BSRR寄存器写1有效、写0无效可以用一条语句安全地改变单个引脚而不影响其他引脚。CMSIS头文件里这些寄存器都是volatile修饰的所以编译优化不会把寄存器读写折叠掉。2.3 实例化并让它闪起来有了类cpp_main里就是干净的用户代码extern C void cpp_main() { Gpio led; led.init(GPIOC, 13, Gpio::Mode::Output50MHz); for (;;) { led.toggle(); delay_ms(500); } }这里需要delay_ms。如果用了HAL库可以直接调HAL_Delay但如果想自己控制我建议用SysTick做时基。简单做法如下static volatile uint32_t ms_ticks 0; extern C void SysTick_Handler(void) { ms_ticks; } void delay_ms(uint32_t ms) { uint32_t start ms_ticks; while ((ms_ticks - start) ms) { } }ms_ticks必须是volatile因为它在中断里被修改主循环里读取时如果不加volatile编译器可能把读取优化到循环外最终delay毫秒级变成0毫秒程序闪烁快得像抽搐。这已经是我反复遇到的经典坑了。在cpp_main的一开始需要初始化SysTickSysTick_Config(SystemCoreClock / 1000); // 1ms 中断一次SysTick_Config是CMSIS提供的函数它本身是C可调用的从C调用也没有问题。3. 串口输出让板子说话顺便搞懂printf重定向3.1 不管用HAL还是寄存器先有一个发送通道CubeMX生成工程时如果开了USART1那么MX_USART1_UART_Init()已经在main.c里执行过了全局变量huart1也已经在C文件里定义。在C代码里我们要用这个现成的句柄需要先声明外部符号extern C UART_HandleTypeDef huart1;然后封装一个简单的Serial类。为了减少依赖我这里直接操作寄存器发送速度更快也更透明。USART1的寄存器基地址是USART1发送一个字节的流程是等待SR寄存器TXE标志位置1再把数据写入DR寄存器。class Serial { public: void init(USART_TypeDef* usart) { usart_ usart; } void write(const char* str) { while (*str) { while (!(usart_-SR USART_SR_TXE)) { } usart_-DR static_castuint8_t(*str); } } private: USART_TypeDef* usart_; };在cpp_main里这样用Serial uart; uart.init(USART1); uart.write(Hello from C\r\n);这段代码的前提是串口已经被CubeMX初始化成USART1并且波特率等配置正确。如果你的工程没开在CubeMX里把USART1打开生成代码后会有一个huart1可供使用。3.2 printf重定向为什么_old_write的签名那么像C很多时候你想用printf这种格式化输出而不是一行行写字符串。在ARM GCC的newlib环境下标准输出最终会调用一个名为_write的底层函数。你可以自己实现它覆盖弱符号把字符一个个塞进串口#include cstdio extern C int _write(int fd, char* ptr, int len) { if (fd 1) { for (int i 0; i len; i) { while (!(USART1-SR USART_SR_TXE)) { } USART1-DR static_castuint8_t(ptr[i]); } } return len; }然后直接在C代码里printf(Pulse count: %d\r\n, count);就能在串口终端看到输出。这里有个非常容易踩的坑_write的符号来自C库C编译器看到的是经过name mangling的名字。所以定义这个函数时必须包在extern C里否则你写了一个_write库不会认它printf最终输出不知道去了哪里。我在实际工程里见过不止一次链接成功但串口没任何输出的情况一查全是少了extern C。另外链接时建议加--specsnosys.specs。这套specs提供了一些系统的弱定义比如_sbrk、_close避免链接阶段报缺符号。如果你要自己管理堆栈也可以不放nosys但新手先用默认配置能少碰很多壁。3.3 为什么我不建议你用iostream很多从桌面端转过来的朋友会想我写C不就是要用std::cout吗很遗憾在STM32这种几十KB内存的板子上用完整的iostream移植成本很高。std::cout背后是一大坨全局对象、虚继承、环境缓冲在MCU上虽然能编过但flash消耗会明显膨胀而且这些全局对象还依赖前面说的__libc_init_array初始化机制你还没享受到流式语法的好先被启动问题折磨一顿。更可控的方案是用snprintf把数据格式化到缓冲区再调用Serial的write方法。比如char buf[64]; snprintf(buf, sizeof(buf), Value: %d, value); uart.write(buf);这样既保留了格式化能力又不会引入iostream那套重型机制。nano.specs会把printf的实现裁剪成轻量级代价是浮点格式化稍微弱一些但对于串口调试信息完全够用。4. 中断回调把C语言函数“翻译”成C方法4.1 中断向量只认普通函数不认成员函数GPIO控制、串口打印都是轮询式的接下来看看嵌入式C最别扭的部分中断。中断向量表里的EXTI0_IRQHandler、SysTick_Handler这些名字是链接器在向量表里直接填地址的。它们必须是普通C函数符号不能是C成员函数。你没法用MyClass::onIrq直接填向量表因为成员函数隐式带this参数函数类型也不一致。所以“C封装中断”不是让中断向量指向对象方法而是要在中断函数和一个类实例之间建立桥接。最常见的做法是在类内部维护一个指向自身实例的静态指针中断函数里通过这个指针调用成员函数。4.2 一个按键中断的封装示例假设PA0接按键CubeMX已经开启了EXTI0中断。按键按下会产生中断我们需要识别并消除抖动这里只处理置标志读取和消抖放在主循环里。class Button { public: void init() { instance_ this; pressed_ false; } bool isPressed() { bool ret pressed_; pressed_ false; // 读取后清除避免反复触发 return ret; } private: void onIrq() { pressed_ true; // 中断里只置标志不要做耗时操作 } static Button* instance_; volatile bool pressed_; }; Button* Button::instance_ nullptr;然后在C源文件里定义中断处理函数extern C void EXTI0_IRQHandler(void) { if (EXTI-PR EXTI_PR_PR0) { EXTI-PR EXTI_PR_PR0; // 清中断挂起位 if (Button::instance_) { Button::instance_-onIrq(); } } }注意EXTI-PR必须写1清除中断标志。这块忘了写中断会无限触发按键状态永远读不对。另外pressed_是volatile的因为它在中断上下文里被改主循环的isPressed()里读它时不能让编译器缓存在寄存器里。4.3 增加一点通用性用函数指针和上下文参数注册回调每次写一个外部中断就手动改EXTI0_IRQHandler类稍微多几个就很烦。可以做一个简单的中断分发器用函数指针加void*上下文来复用一个中断函数using IrqCallback void (*)(void* context); struct IrqEntry { IrqCallback callback; void* context; }; static IrqEntry exti0_entry; extern C void EXTI0_IRQHandler(void) { if (EXTI-PR EXTI_PR_PR0) { EXTI-PR EXTI_PR_PR0; if (exti0_entry.callback) { exti0_entry.callback(exti0_entry.context); } } } void register_exti0_callback(IrqCallback cb, void* ctx) { exti0_entry.callback cb; exti0_entry.context ctx; }封装类时把类的静态方法作为桥接函数传进去Button button; static void button_irq_bridge(void* ctx) { auto* b static_castButton*(ctx); b-onIrq(); } // 初始化时 register_exti0_callback(button_irq_bridge, button);这样中断函数不依赖具体类而桥接函数仍然可以在C文件里访问对象的私有成员。在实际项目中这套模型已经够用没必要引入std::function那种动态分配。5. 启动文件里的C初始化代码为什么还没写main就HardFault5.1 全局对象构造函数去哪儿了回到第一部分说的__libc_init_array。如果你按我前面的建议把Gpio led定义在cpp_main函数内部它是一个局部对象构造发生在进入函数的时候不需要全局构造器先跑所以你很晚才会踩到那个坑。但一旦有人习惯性地把对象移到文件作用域Gpio led; // 全局对象构造函数应该在main之前执行 extern C void cpp_main() { led.init(GPIOC, 13, Gpio::Mode::Output50MHz); for (;;) { led.toggle(); delay_ms(500); } }烧进去之后可能LED完全不动。用调试器看发现led的port_成员还是0pin_还是0构造函数根本没跑。这时候你第一反应会去查CubeMX配置查GPIO地址查时钟折腾半天才意识到全局构造器没有被启动代码调用。5.2 检查并修复startup文件打开工程里的startup_stm32f103xb.s定位Reset_Handler常见代码是Reset_Handler: ldr sp, _estack bl SystemInit bl main如果bl main之前没有bl __libc_init_array那全局构造器就没人管。自己加一行注意顺序应该在SystemInit之后、main之前Reset_Handler: ldr sp, _estack bl SystemInit bl __libc_init_array bl main改完后重新编译链接。此时链接器会从libc里找到并调用__libc_init_array它会遍历.init_array段里记录的构造器指针逐个调用。一般编译器生成的全局对象构造器就放在这个段里。如果你的启动文件里出现了多个bl main注意它们通常在一个b .死循环附近别改错位置。5.3 除了全局构造器还有一堆C运行时你敢不敢用即便修好了启动文件也不能把桌面C的坏习惯带进嵌入式。比如new和delete。GCC的C运行库默认不提供声明式的堆对象你需要自己实现操作符或者依赖malloc。如果你只写一个int* p new int(42);链接器会报“未定义的operator new”。在没有操作系统、没有内存管理机制的MCU上我建议初始阶段根本不要用new。GPIO、UART、Button这些对象在栈上创建完全够用它们生命周期跟整个程序一样长。异常和RTTI这两个特性如果忘了关会拉进来一个特别肥的运行时。我在1.3节已经给了编译选项这里再强调一遍-fno-exceptions -fno-rtti不是可选项是裸机C的默认应该这么开。如果哪天你确实需要异常先想想MCU上谁能捕获它catch块里的输出怎么发出去这复杂度不是新手阶段该碰的。5.4 我建议的入口写法对象放在cpp_main里用全局对象必须要管__libc_init_array用局部静态对象也需要编译器生成的guard变量至少链接时得保证__cxa_guard_acquire这些符号可用而把对象当成cpp_main里的普通局部变量构造时机明确不依赖任何段初始化最省心。我自己现在的习惯是应用级对象都放在cpp_main函数内或用一个简单单例的get函数返回局部静态引用。因为STM32的应用通常就是一个超级大循环一眼望到头函数栈上对象根本不存在“提前销毁”的问题。等以后工程复杂到某个对象真的需要跨文件全局共享时再回头把启动文件的初始化补全也不迟。最后再说几句这篇信息量已经不小从编译选项、GPIO寄存器封装、串口printf重定向到中断对象桥接每个点都够写一整篇。实际操作时我的建议是先把第一块LED代码编译下载跑通板子亮了再往里面加串口、加中断。一次加一个功能出了问题也容易定位。我踩过最大的坑就是启动文件的__libc_init_array当时排查了整整一个晚上。症状是一堆C对象初始化顺序莫名其妙后来翻启动文件才发现根本没调用。那次之后我在看任何新增板子的时候第一件事就是确认启动代码支不支持C初始化而不是急着复制一堆漂亮代码。希望你这篇学完能绕开这些最折磨人的底层问题真的开始用C写自己的STM32应用。下一期我会拿一个具体的传感器驱动来做范例继续把C和嵌入式外设的结合往深了写。