看了三篇了一行都没让我写呢。——这条留言我刷到的时候第一反应是有点想笑第二反应是有点感动。想笑是因为这语气太真实了就是读者对作者最直接的吐槽感动是因为能把前三篇看完并且问出这句话说明你已经在认真等代码了不是那种随手划过就关掉的看。那这篇就专门回应这个需求我们正式开始写代码而且写的不只是代码是带C味道的嵌入式代码。先把话说清楚这是一篇嵌入式C系列的正文不是嵌入式C教程。我们用的是C的语法、C的封装方式跑在STM32这种资源受限的MCU上。你能用类把外设包装起来写起来像写桌面程序一样有结构编译出来的固件还能塞进F103这种只有几十KB RAM的芯片里。这篇适合两类人一类是跟着系列走到现在、心里有点小急的初学者另一类是会用STM32但一直写C、想看看C在嵌入式里到底怎么落地的工程师。看完你至少能干三件事搭一个最小的嵌入式C工程、用类封装GPIO点亮一颗LED、再用定时器中断让这颗灯自己闪。整个过程我不跳步每个环节你都能直接照做。好先聊一件最要紧的事为什么前面三篇我没让你写代码我不是吊胃口我在等你躲过几个大坑。1. 为什么前几篇不急着让你写代码1.1 前几篇到底讲了什么往回翻一翻这个系列前面三篇的内容其实分成两条线一条是工具线一条是认知线。工具线讲的是怎么把写C这件事在MCU上变成现实。交叉编译器arm-none-eabi-g怎么装、Makefile怎么组织、链接脚本是干嘛的、启动文件里那段汇编点灯之前做了什么。这些内容虽然不直接产生代码但没有它们你后面每一行代码都会卡在编译不过或者下载不进去这种问题上。认知线讲的是STM32的底层逻辑寄存器为什么是一段内存地址、时钟树是怎么把外部晶振变出72MHz主频、外设模块GPIO、定时器、串口在这个芯片内部挂在哪些总线上。这些知识单看很枯燥却是后面所有代码的骨架。你可以把前三篇想成驾校的交规课看着乏味但没它你不敢上路。1.2 盲目写代码的三个典型翻车现场我见过很多新手拿到板子就开始写结果翻车方式高度一致这里给你提前剧透一下。第一个坑不查时钟树上来就配GPIO寄存器。写完灯不亮就开始怀疑芯片坏了。其实多半是因为GPIO外设的时钟门压根没打开。STM32是一个大商场每个外设像一个小商铺进门刷卡开时钟才能用电。不刷RCC那一项寄存器写得再对外设也纹丝不动。第二个坑不知道寄存器底层是地址映射直接拿数字去算地址。算错一位写进了一个不存在的位段结果程序跑飞触发HardFault。STM32的寄存器地址是有规律的基地址加偏移但不是让你背地址而是要理解CMSIS头文件里的结构体指针是怎么把地址映射成GPIOA-ODR这种可读写形式的。第三个坑拿到一块板子不先验证能编译、能下载、调试器能连上就跑去找业务代码问题。最后分不清到底是代码逻辑错了、工具链坏了还是下载线接触不良。这就是为什么我坚持让你先搭一个能空转的工程确认链路通了再写真正的功能代码。1.3 到了第5篇你的储备够不够到这里我替你把关一下你能在终端里敲make构建工程知道交叉编译器和本地编译器不是一回事知道芯片上电先跑启动文件再做C/C运行时初始化知道外设操作本质是读写寄存器。够了可以开始写代码了。如果还不太确定也没关系后面的示例每一条都带注释。但我要提醒你代码能跑和懂了原理能改是两种完全不同的境界。这一篇的示例请你务必亲手敲一遍甚至两遍不要复制粘贴。敲的过程就是在强化你对寄存器、对类、对编译流程的肌肉记忆。2. 动手前最后一道准备工程骨架2.1 一个最小嵌入式C工程的目录结构我用的板子是经典的STM32F103C8T6也就是大家常说的蓝板子或者最小系统板。下面的工程结构不依赖STM32CubeMX只有一个Makefile、一个链接脚本、一个启动文件、一个CMSIS头文件目录和几个源文件足够说明问题。project/ ├── build/ ├── Makefile ├── linker.ld ├── startup_stm32f103xb.s ├── src/ │ ├── main.cpp │ └── system_stm32f1xx.c └── include/ ├── stm32f1xx.h └── core_cm3.h不要被这个结构吓到里面绝大多数东西是可以从STM32CubeF1固件包里直接拷贝的比如startup_stm32f103xb.s、stm32f1xx.h、system_stm32f1xx.c。你自己真正要写的只有main.cpp和Makefile外加一个linker.ld链接脚本。2.2 嵌入式C编译的几个关键开关同样是arm-none-eabi-g编译选项和写桌面C有很大区别这份建议你可以直接抄。-fno-exceptions -fno-rtti --specsnano.specs -Wl,--gc-sections -mcpucortex-m3 -mthumb-fno-exceptions关闭C异常处理。MCU上的异常展开需要大量栈空间和运行时支持在嵌入式场景里基本不用关掉能省不少代码体积。-fno-rtti关闭运行时类型识别dynamic_cast、typeid。RTTI会为每个有虚函数的类生成类型信息表在MCU上属于浪费。--specsnano.specs使用newlib-nano精简C标准库printf和各种标准函数的体积会大幅收缩。-Wl,--gc-sections配合-ffunction-sections -fdata-sections链接器会把没有被引用的函数和数据段丢弃这是嵌入式工程压缩固件的常规组合。有一点需要特别留意如果你在代码里用了printf并且要打印浮点数还需要在链接时加上-u _printf_float否则浮点格式化代码会被newlib精简掉输出结果是空的或者固定为垃圾值。调试阶段我建议先只打印整数等后面串口篇再有需要再开浮点。2.3 先验证链路再写业务代码写LED操作之前先做一件看起来有点傻的事烧录一个空转程序。就这么一段#include stm32f1xx.h int main() { while (1) { __asm volatile(nop); } return 0; }这个程序烧进芯片完全没有任何现象它的目的只有一个验证交叉编译、链接、烧录、调试这整条链路是通的。这一步通过了后面写的代码产生的问题才可能定位到代码本身。我把Makefile的核心部分给你你可以直接套用TARGET blink.elf CC arm-none-eabi-g CFLAGS -mcpucortex-m3 -mthumb -Os \ -ffunction-sections -fdata-sections \ -fno-exceptions -fno-rtti \ -MMD -Iinclude LDFLAGS -T linker.ld --specsnano.specs -Wl,--gc-sections SRCS src/main.cpp src/system_stm32f1xx.c OBJS $(SRCS:%.cppbuild/%.o) $(TARGET): $(OBJS) $(CC) $(OBJS) $(LDFLAGS) -o $ build/%.o: %.cpp mkdir -p build $(CC) $(CFLAGS) -c $ -o $ flash: st-flash write $(TARGET:.elf.bin) 0x08000000烧录用st-flash前提是板子上的ST-Link被系统识别。如果你用的是STM32CubeProgrammer命令行换成STM32_Programmer_CLI -c portSWD -w blink.bin 0x08000000效果一样。2.4 全局对象构造的隐藏依赖这一步多说几句。C全局对象的构造函数是在main之前由__libc_init_array调用完成的而__libc_init_array这个符号是在启动文件startup_stm32f103xb.s里被执行的。如果你用的是精简版或自己改过的启动文件没有调用__libc_init_array那么所有全局对象都不会初始化成员变量全是复位初值程序一进main就会表现得莫名其妙。这个问题非常阴险因为编译不报错、链接不报错、下载也正常但行为就是不对。所以从这一篇开始请记住一个检查点系统启动链路里必须包含__libc_init_array这一步。STM32CubeF1固件包自带的标准启动文件是包含的放心用但别自己乱动它。3. 第一行嵌入式C代码点亮一颗LED3.1 寄存器操作GPIO点亮LED的底层原理点灯在嵌入式里相当于编程界的Hello World但它并不是简单的一句输出而是对GPIO外设的完整配置过程。以F103C8T6为例板载LED通常接在PC13引脚高电平熄灭、低电平点亮也就是低有效。要让这颗LED亮起来需要做三件事第一件事给GPIOC这个外设打开时钟门第二件事把PC13配置为推挽输出模式第三件事让引脚输出低电平。看代码#include stm32f1xx.h int main() { // 1. 打开GPIOC外设时钟。GPIO在STM32F1系列挂在APB2总线上 RCC-APB2ENR | RCC_APB2ENR_IOPCEN; // 2. 配置PC13为通用推挽输出输出速度2MHz // F1系列40脚以上的芯片引脚8~15的模式由CRH寄存器控制 // PC13对应CRH中第(13-8)5个引脚位每引脚占4位 GPIOC-CRH ~(0xFUL (5 * 4)); GPIOC-CRH | (0x2UL (5 * 4)); // MODE10(2MHz)CNF00(推挽输出) // 3. 输出低电平点亮LED GPIOC-BRR (1UL 13); while (1) { // 保持亮 } return 0; }这里有一个很多新手容易混淆的点STM32F1系列的GPIO模式寄存器是CRL和CRH每4位描述一个引脚前两位CNF配置功能类型后两位MODE配置输入输出和速度而MODER这种按2位描述的寄存器结构是STM32F4系列才有的。如果你在网上看到一段F4代码直接拿去F1上跑寄存器位段完全对不上灯不亮还是小事写坏配置导致异常才是大麻烦。做嵌入式不但要会看代码还要会看是哪一款芯片、哪一个系列的参考手册。3.2 用C类封装GPIO从裸寄存器到工程化前面那一段代码能跑但只能算C语言风格的GPIO操作。既然题目是嵌入式C就要把这层寄存器操作包进类里让外部调用变得干净整洁。下面这个Gpio类是我在教学项目中常用的简化版#ifndef GPIO_HPP #define GPIO_HPP #include stm32f1xx.h namespace board { class Gpio { public: enum class Mode { Output, // 仅演示输出模式 Input }; Gpio(GPIO_TypeDef* port, uint8_t pin, Mode mode) : port_(port), pin_(pin) { // 打开对应端口的时钟 if (port_ GPIOA) RCC-APB2ENR | RCC_APB2ENR_IOPAEN; if (port_ GPIOB) RCC-APB2ENR | RCC_APB2ENR_IOPBEN; if (port_ GPIOC) RCC-APB2ENR | RCC_APB2ENR_IOPCEN; if (mode Mode::Output) { // F1系列CRL控制Pin0~7CRH控制Pin8~15 uint32_t shift (pin_ 8) ? (pin_ * 4) : ((pin_ - 8) * 4); volatile uint32_t* cr (pin_ 8) ? port_-CRL : port_-CRH; *cr ~(0xFUL shift); *cr | (0x2UL shift); // 推挽输出 2MHz } } void set() { port_-BSRR (1UL pin_); } void reset() { port_-BRR (1UL pin_); } void toggle() { if (port_-ODR (1UL pin_)) { reset(); } else { set(); } } private: GPIO_TypeDef* port_; uint8_t pin_; }; } // namespace board #endif // GPIO_HPP有了这个类主函数里点灯就变成这样#include stm32f1xx.h #include gpio.hpp using board::Gpio; int main() { Gpio led(GPIOC, 13, Gpio::Mode::Output); while (1) { led.set(); for (volatile uint32_t i 0; i 1000000; i); led.reset(); for (volatile uint32_t i 0; i 1000000; i); } return 0; }我来解释一下这个类设计的几个关键点。构造函数里自动完成了时钟使能和模式配置调用方不需要再关心寄存器细节。set、reset、toggle分别对应BSRR置位、BRR清零、ODR翻转三种硬件操作。BSRR是32位寄存器低16位写1就是置位对应引脚为高电平BRR低16位写1是清零这样操作不需要读-改-写避免了多线程或中断环境下的竞态。volatile关键字我觉得值得单独说一句。GPIO寄存器地址指向的是硬件外设CPU读之前和读之后硬件寄存器随时可能被外设自己改变。如果不加volatile编译器可能会认为某个寄存器值在循环里没变于是优化成只读一次。这是最典型的代码看起来对但是行为不对的坑之一。你可能会问这个类是不是太简单了生产环境够用吗说实话这只是教学版本。工程上的GPIO类通常会结合模板在编译期计算寄存器地址会有更细的读引脚功能会区分开漏、推挽、上拉、下拉等模式。但核心思想和这个一模一样把寄存器操作藏在类接口后面使用方只关心这个引脚干嘛的而不是这个寄存器的第几位。先把这个思想学会后面再往里面加东西很自然。3.3 编译烧录看到第一束光把代码抄进工程在项目根目录执行make如果一切顺利build/目录下会出现blink.elf和blink.bin。烧录到芯片里make flash或者用OpenOCDopenocd -f interface/stlink.cfg -f target/stm32f1x.cfg \ -c program build/blink.elf verify reset exit板上LED开始闪的时候你算是真正跨进了嵌入式C编程的门。我这里说的闪是大约1MHz的循环延时实际上这个软件延时极其不准确不同优化级别、不同编译器甚至不同温度下延时都能差不少。所以下一节我要换一种更可靠的方式让LED动起来。4. 从亮灯到动起来定时器与中断4.1 定时器在STM32里是什么角色软件延时让MCU空转数百万条指令这不是工程上该有的做法。要精确控制时间得用定时器外设。你可以把定时器想象成一个室内的秒表计数器每来一个时钟脉冲计数器的值加一当计数值等于你设定的自动重载值时清零并触发一次更新事件。如果使能了更新中断CPU就会停下来去执行一次中断服务函数然后再回来继续干活。还是以F103为例TIM2挂在APB1总线初始化它需要四步开时钟、配置预分频PSC、配置自动重载ARR、使能中断和启动定时器。我给一个精确到1ms中断一次的配置void timer2Init() { // 1. 打开TIM2外设时钟 RCC-APB1ENR | RCC_APB1ENR_TIM2EN; // 2. 预分频72MHz / (7200) 10kHz // 也就是说计数器每递增1需要0.1ms TIM2-PSC 7200 - 1; // 3. 自动重载10kHz / 10 1kHz即1ms中断一次 // 到这里溢出周期 PSC1 再乘 ARR1再除以定时器时钟 TIM2-ARR 10 - 1; // 4. 清中断标志、使能更新中断、启动定时器 TIM2-SR 0; TIM2-DIER | TIM_DIER_UIE; NVIC_EnableIRQ(TIM2_IRQn); TIM2-CR1 | TIM_CR1_CEN; }这里有个小学数学但非常重要的地方PSC 7200 - 1有些新手看到减一就困惑为什么不能直接写7200因为预分频器的实际分频系数等于寄存器值加一。PSC写7199实际是7200分频ARR写9实际是10个周期。所以定时器溢出频率的计算公式是溢出频率 定时器时钟 / ((PSC1) * (ARR1))72MHz除以7200得到10kHz再除以10得到1kHz也就是1ms。如果你的板子外部晶振不是8MHz或者锁相环倍频系数不同定时器时钟可能不是72MHz那就不能直接抄这个数字。这是嵌入式开发里最常见的照抄翻车点一定要自己去查对应芯片的时钟树。4.2 中断服务函数如何与C和睦相处C编译器会对函数名做名字修饰name mangling用于支持函数重载。但中断向量表里的函数符号是芯片厂商在链接层面写死的名字必须是TIM2_IRQHandler这样不带修饰的形式。这时候就需要extern C来告诉链接器这个函数按C语言规则链接。另外还有一个C特性需要绕开类成员函数有一个隐藏的this指针不能直接被中断向量表调用。所以通用套路是中断向量表对应的函数用extern C包装然后转调类的静态成员函数。看这个升级版定时器类#include stm32f1xx.h #include gpio.hpp using board::Gpio; // 全局LED对象PC13低有效 Gpio led(GPIOC, 13, Gpio::Mode::Output); // 中断标志与软时钟计数 volatile uint32_t tick_ms 0; extern C { void TIM2_IRQHandler(void) { if (TIM2-SR TIM_SR_UIF) { TIM2-SR 0; // 清更新标志否则会一直进中断 tick_ms; // 软时钟每次中断1代表过了1ms led.toggle(); // 1ms翻转一次输出500Hz方波 } } } int main() { RCC-APB1ENR | RCC_APB1ENR_TIM2EN; TIM2-PSC 7200 - 1; TIM2-ARR 10 - 1; TIM2-SR 0; TIM2-DIER | TIM_DIER_UIE; NVIC_EnableIRQ(TIM2_IRQn); TIM2-CR1 | TIM_CR1_CEN; while (1) { // 主循环在这里可以稍后基于tick_ms做调度 } }注意tick_ms和Gpio led都要用volatile吗tick_ms是中断和主循环共享的变量必须加volatile防止编译器把它的值缓存到寄存器里而不重新读内存。led对象本身在中断里调用toggle其内部的寄存器和成员变量访问都涉及硬件寄存器的volatile访问这一点类内部已经保证了。4.3 中断服务函数里能不能乱来很多初学者一进中断就放飞自我又是打印又是malloc。在8位单片机上也许还能忍在ARM Cortex-M3上中断里写一堆重代码会带来两个问题一是中断响应时间被拉长二是如果中断里调用了不可重入的函数会发生数据错乱。我的建议是先把这条规则焊死在脑子里中断服务函数只做最小工作比如清标志、累加计数、翻转GPIO、置个标志位。真正耗时的处理放到主循环里做。样定时器只负责到点了通知你主循环负责接到通知后慢慢处理。C里还有一个潜在坑如果你在中断里调用了new/delete而堆分配器不是线程安全的多个中断嵌套或者中断与主循环竞争同一把锁轻则内存碎片重则死锁。所以嵌入式C明确禁用动态内存分配的写法全局对象在启动阶段一次性构造好运行期不new不delete。下面给出一个更工程化的扩展用函数指针把定时器回调解耦出来这样同一个定时器类可以驱动不同模块。class Timer { public: using Callback void (*)(); static void init() { RCC-APB1ENR | RCC_APB1ENR_TIM2EN; TIM2-PSC 7200 - 1; TIM2-ARR 10 - 1; TIM2-SR 0; TIM2-DIER | TIM_DIER_UIE; NVIC_EnableIRQ(TIM2_IRQn); TIM2-CR1 | TIM_CR1_CEN; } static void setCallback(Callback cb) { callback_ cb; } static void isr() { if (TIM2-SR TIM_SR_UIF) { TIM2-SR 0; if (callback_) { callback_(); } } } private: static Callback callback_; }; Timer::Callback Timer::callback_ nullptr; extern C { void TIM2_IRQHandler(void) { Timer::isr(); } }使用方只需要void onTick() { led.toggle(); } int main() { Timer::init(); Timer::setCallback(onTick); while (1) {} }这种中断入口固定、业务回调可替换的模式在单片机裸机开发中非常实用代码复用性和可读性都会好很多。后面你写串口接收、ADC采样、PWM波形的时都可以沿用同样的思路。5. 常见问题与排查实录5.1 编译期报错undefined reference to__cxa_pure_virtual这个错误通常出现在你定义了抽象类并声明了纯虚函数但某个纯虚函数没有提供实现或者编译器需要为纯虚函数生成桩代码时运行库中缺少对应的符号。最常见的原因是在嵌入式C工程里使用了虚函数而你的链接参数没有把相应的运行时支持包含进来。排查方法先确认是否真的需要虚函数。如果你的类体系里只有一个实现、不存在运行时多态需求尽量避免虚函数这既是省空间也是省这类麻烦。如果确实需要给所有纯虚函数提供一个默认实现或者检查链接参数中是否混用了--specsnano.specs和标准libstdc导致符号解析不一致。5.2 编译下载都成功但LED怎么都不亮按优先级排查先量引脚电压。万用表测PC13对GND如果点亮逻辑是对的但电压一直是高很可能你板子的LED实际上是高电平点亮跟前文假设的低有效相反。把BRR换成BSRR再试。接着查时钟有没有开RCC-APB2ENR是否真的置位了IOPCEN。再查CRH的位段算得对不对PC13在CRH里的偏移是第5组也就是位20到23容易算错成第13组。还有一个隐蔽原因你用的是F103头文件和启动文件却拷的是F411的。芯片型号和头文件家族不对编译虽然能过但寄存器地址表完全不同写进物理寄存器的数就歪了。所以拿到任何示例第一件事核对头文件版本。5.3 全局对象不初始化函数跑得乱七八遭上电之后程序没有按预期工作点灯类对象里的port_明明是GPIOA配置却写到了错误的寄存器上。这种问题十有八九是启动文件缺失了对__libc_init_array的调用全局对象构造函数直接没执行。解法检查启动文件startup_*.s的Reset_Handler确认在跳转到main之前有这段代码。标准启动文件长这样bl SystemInit bl __libc_init_array bl main如果只有bl main需要补上__libc_init_array。如果你的工程是自己拼接的这也解释了为什么我前面强调不要轻易简化启动文件C运行时初始化是嵌入式C里最容易踩的地方。5.4 程序跑着跑着进HardFaultHardFault是所有STM32开发者绕不开的噩梦。引发原因太多了但在刚开始写代码这个阶段最常见的就是访问了未使能的外设时钟域、数组或指针越界、栈空间溢出。先看调试器里的Fault状态寄存器SCB-CFSR能区分是总线错误、存储管理错误还是用法错误。栈溢出这一项我要多说一句C临时对象、函数调用和中断嵌套都需要栈空间。F103默认启动文件给的栈大小可能是1KB如果你的C代码里创建了较大的局部对象很容易就爆了。建议第一件事把启动文件里的栈大小改成4KB或8KB反正这芯片有20KB RAM调试阶段富裕点没坏处。5.5 ST-Link连接失败这个不算代码问题但也让人头疼。现象是烧录工具报告Could not connect to target。原因无外乎三种ST-Link驱动没装好、SWDIO/SWCLK接线接触不良、目标板供电异常。另外如果你的程序一上电就把SWD引脚复用成了GPIO也会导致调试器连不上这时按住复位键再点连接或者用st-flash的--connect-under-reset参数一般能救回来。5.6 定时器中断频率不对中断频率不对首要检查的是系统时钟到底是不是72MHz。很多最小系统板的晶振是8MHz但也有贴了不同的晶振或者外部高速时钟没起振导致系统跑在HSI内部8MHz的情况。直接在调试器里看RCC-CFGR寄存器的PLL相关位确认系统时钟实际频率。这才是PSC和ARR计算的基础基础错了后面全错。再检查APB1分频器的设置。F103的定时器时钟不总是等于APB1总线时钟当APB1分频系数不等于1时定时器时钟是APB1的两倍。你要对着参考手册的时钟树一节确认一遍别拿着公式死算。用一口气写出来的代码才是真正学到的这篇终于让你动笔了而且是往一块真实的STM32芯片里写C代码。我个人带新人的习惯是第一节课就算环境再麻烦也要让他们亲手从寄存器点灯开始而不是直接丢一个CubeMX生成的工程给他们。看起来土但能把外设就是一段内存地址、编程就是操作寄存器这个底层肌肉记忆建立起来。有了这个底子后面上HAL库、上RTOS、上C的模板元编程都不会心虚。接下来的系列里还可以往这些方向扩展把串口封装成输出流用打印调试信息用SysTick做任务调度的时间片或者把LED改成PWM呼吸灯。你手头有了GPIO和定时器这两个地基后面每加一个外设无非就是重复两件事开时钟、操作寄存器然后用类把它包起来。祝你点灯顺利。
