
看了三篇了一行都没让我写呢。这句话我在后台和评论区看到太多次了隔着屏幕都能感觉有人已经把键盘拍烂了。先别急这篇就是专门来开荤的。前面几篇我把STM32、嵌入式开发的整体地图、学习路线、C语言基础和开发环境都铺了一遍从这篇开始正式进入用C写STM32的实操环节。我们先干一件嵌入式领域最经典的事点亮一颗LED。这篇文章适合两类人一类是学过C但没碰过硬件、想试试嵌入式的人另一类是以前用C写过单片机、现在想知道C在嵌入式中到底能带来什么的人。我会带你把第一行代码写出来、编译过、烧进去、看到灯亮同时讲清楚每一行背后的硬道理。1. 先把目标定下来为什么第一行代码选“点灯”1.1 前几篇的铺垫不是废话很多初学者会不理解为什么前三篇讲了一堆概念还不让写代码。我的答案是嵌入式开发的代码不是孤立的代码操作的对象是寄存器寄存器连着外设外设又连着物理世界。如果一上来就给你一段点灯代码你照抄也能亮但一旦灯不亮或者换了一颗芯片就完全不会排查了。第一行代码选点灯是因为它在嵌入式界的地位等同于桌面编程里的Hello World。它麻雀虽小五脏俱全一个LED要亮起来你至少得搞定芯片时钟、GPIO配置、寄存器读写、编译烧录、运行验证。把这些环节走通一遍你就算是正式进入嵌入式这个大门了。很多跟我聊过的朋友都有一个共同感受点灯之前觉得嵌入式充满神秘感点灯之后发现它本质就是按照手册操作寄存器。这也是为什么系列前几篇坚持先讲学习路线和工具链。不然这篇的代码对你来说就是一堆魔法数字0x40010800是谁1U 2又是要干嘛你看得懂语法但看不懂它在硬件上干了什么。1.2 为什么坚持用C而不是传统C传统嵌入式开发里C语言是绝对主力。但近几年C在嵌入式中的比重明显上升ESP-IDF用了C接口RT-Thread的组件层也大量使用C很多开源项目像Mongoose、LVGL绑定层也都是C友好。原因不复杂C带来更强的类型检查、更清晰的抽象能力和更好的代码组织方式。以点灯为例C风格可能是这样的GPIOA-ODR | (1U 5);而C风格是led.on();单看一行还看不出多大差距但当一个工程里有按键、数码管、电机、传感器十几路外设都要操作时C风格会变成一大片难以阅读的位运算而C风格可以做到每个外设一个对象调用语义完全贴近真实世界。这篇的代码里会封装一个LED类这是嵌入式C的第一步也最能体现C的价值硬件操作对象化。1.3 这一次先不用HAL库直接操作寄存器STM32的开发方式大致有三种寄存器操作、标准外设库、HAL库。HAL库是ST官方主推的封装度高写起来省事但它的初始化流程很长一个GPIO开时钟、配置引脚、设置复用都能绕好几层函数。对第一次写代码的新手来说很容易照着抄能跑出问题就懵。所以我刻意让第一行代码走寄存器直操作路线。理由有三个直接操作寄存器能帮你建立外设就是内存地址的心智模型这是嵌入式最核心的思维点灯场景下寄存器操作代码量只有几十行完全在可控范围内寄存器操作无中间层每一行代码都对应一种硬件行为非常利于教学。当然代价也很明显需要自己查芯片参考手册具体寄存器地址要自己找。不过F1系列的手册资料满天飞查一次后面就熟练了。2. 动手之前这几个硬件底层概念必须吃透2.1 GPIO点灯的原理不是玄学是电路一颗LED要亮起来条件其实很简单LED两端要有足够的电压差并且有电流流过。单片机做的事情就是控制某个引脚输出高电平还是低电平。举一个最常见的接法LED的阳极通过限流电阻接到3.3V电源阴极接到单片机的某个GPIO引脚。在这个电路里如果GPIO输出低电平LED两端就有电势差灯亮如果输出高电平两边都是3.3V没有压差灯灭。这就是低电平点亮很多开发板板载LED都是这种设计。另一种接法是LED阴极接地阳极接GPIOGPIO输出高电平灯亮这叫高电平点亮。这两种方式在程序里就是on()和off()里寄存器操作互换一下的事对应关系必须搞明白不然后面灯不亮你都不知道是自己代码写错了还是电路设计不同。GPIO内部结构再往下拆你会发现引脚状态实际由一组寄存器控制。你不用把它们当成俯视封装图里那些复杂的内部电路只需要记住四类关键寄存器配置寄存器决定引脚是输入还是输出、输出模式是什么输出数据寄存器决定引脚输出高还是低置位/复位寄存器可以单独把某个引脚拉高或拉低输入数据寄存器用来读取外部电平。2.2 地址决定了你操作的“寄存器”是谁嵌入式开发里有个非常核心的观点外设就是一块内存区域。每个外设都占用一段地址空间里面每个寄存器都是一个32位的内存单元。你往某个地址写值就是告诉硬件你想让它干嘛。以STM32F103系列为例GPIOA外设的基地址是0x40010800RCC时钟控制外设的基地址是0x40021000。GPIOA内部各个寄存器的地址就是在基地址上加上对应的偏移寄存器偏移作用CRL0x00配置引脚0~7的输入输出模式CRH0x04配置引脚8~15的输入输出模式IDR0x08读取引脚电平ODR0x0C设置引脚输出电平BSRR0x10单独置位/复位引脚BRR0x14单独复位引脚而RCC的外设时钟使能寄存器APB2ENR偏移是0x18。为什么开外设前要先使能时钟你可以把STM32理解为一栋大楼每个外设是一间房间时钟就是房间的电闸。房间没通电你进去按开关什么都没反应。APB2ENR就是你手里的配电箱要把GPIOA对应的那个开关合上GPIOA才能工作。GPIOA的时钟使能位是APB2ENR的第2位写作1U 2。这也是新手最容易忘的一步后面常见问题里我会再提。2.3 C写嵌入式绕不开的几个关键字volatile这是嵌入式C里出现频率最高的关键字之一。它的含义是告诉编译器这个变量可能被编译器看不见的东西修改比如硬件、中断服务函数。哪怕代码里没有任何人对它赋值编译器也不能随便优化掉对它的读写。寄存器在CMSIS头文件里都被定义成了volatile类型的成员目的就是防止编译器把两次连续写合并成一次。constexpr编译期常量。嵌入式场景里很多引脚编号、延时上限都是写死的固定值用constexpr明确告诉编译器这些值不会变编译器能算的都在编译期算完运行期零开销。namespace随着工程变大全局符号冲突会变成一个很现实的问题。把延时函数放进匿名命名空间等于告诉链接器这个符号只在当前编译单元可见避免不小心污染全局命名空间。内联成员函数类里面直接写函数体默认就是 inline。对于on()、off()这种高频小函数内联可以完全消除函数调用开销让代码保持对象操作的写法性能上却不输给直接操作寄存器。3. 第一次真正写代码从空工程到LED闪烁3.1 环境极简复盘VS Code PlatformIO我推荐用 VS Code 加 PlatformIO 插件。理由很直接它跨平台、插件生态好、对语法高亮和C现代特性的支持完整而且工程配置非常简洁一个platformio.ini就能把编译、烧录、串口监视全部搞定。环境准备分三步在VS Code里安装PlatformIO插件用PlatformIO: New Project新建项目Board选择genericSTM32F103C8把生成好的platformio.ini改成下面这样[env:genericSTM32F103C8] platform ststm32 board genericSTM32F103C8 framework stm32cube如果你手里不是F103C8T6这颗芯片而是F103RCT6或者F103ZET6不要慌在PlatformIO选Board的时候搜对应的型号即可编译系统会自动匹配启动文件和链接脚本。至于还在用Keil的朋友我的建议是如果想体验C11以上特性一定用AC6编译器ARM Compiler 6AC5对C的支持非常老旧很多现代语法会报错。新建源文件时把后缀名改成.cpp编译器就会按C处理。思路和下面要写的代码完全一致只是工程配置的细节不同。3.2 完整代码第一份用C写的STM32工程核心文件下面这份代码就是这一篇的成品我尽量保持精简但也没砍掉C的味道。它在常见带8MHz晶振的STM32F103开发板上可以直接跑起来。#include stm32f1xx.h namespace { void delay_ms(volatile uint32_t ms) { while (ms--) { volatile uint32_t count 7200; while (count--) { __NOP(); } } } } // namespace class LED { public: LED(GPIO_TypeDef* port, uint8_t pin) : port_(port), pin_(pin) { pin_mask_ (1UL pin); uint32_t pos (pin 0x7U) * 4U; if (pin 8U) { port_-CRL (port_-CRL ~(0xFUL pos)) | (0x2UL pos); } else { port_-CRH (port_-CRH ~(0xFUL pos)) | (0x2UL pos); } } void on() const { port_-BSRR pin_mask_; } void off() const { port_-BRR pin_mask_; } void toggle() const { if (port_-ODR pin_mask_) { off(); } else { on(); } } private: GPIO_TypeDef* port_; uint8_t pin_; uint32_t pin_mask_; }; int main() { RCC-APB2ENR | (1U 2); // 使能 GPIOA 时钟 constexpr uint8_t kLedPin 5; LED led(GPIOA, kLedPin); while (1) { led.toggle(); delay_ms(500); } }很多人见到这份代码的感觉是咦居然没有看到一行像传统51单片机那种sbit LED P1^0;然后while(1)里翻转的写法。对这就是C和C在嵌入式中的第一个直观差异外设成了对象控制操作成了对象的方法调用。代码不长但每一段都需要理解到位我一个一个拆开讲。#include stm32f1xx.h是CMSIS头文件里面定义了芯片所有外设的基地址、寄存器结构体以及GPIOA、RCC这些外设指针。GPIO_TypeDef这个结构体的成员就对应上一节表格里的那些寄存器操作GPIOA-CRL本质上就是往0x40010800 0x00这个地址读写数据。匿名命名空间里的delay_ms是一个粗糙的软件延时。volatile uint32_t count是关键它保证编译器不会把整个空循环优化掉。__NOP()是ARM内核提供的空指令让CPU真正停顿一拍。这个延时并不精确和系统主频强相关但作为首次点灯完全够用。LED类里最值得研究的是构造函数。拿pin 5举例pin_mask_ 1UL 5就是二进制0x20对应ODR输出寄存器里的第5位。pos (5 0x7) * 4 20这是在CRL寄存器里定位PA5配置字段的起始位。CRL每个引脚占4位PA5的配置字段正好落在第20~23位。先用~0xFUL 20把这四位清零再用0x2UL 20写入配置值。0x2在4位字段里的含义是CNF为00对应通用推挽输出MODE为10对应2MHz输出速度。on()和off()用BSRR和BRR而不是直接操作ODR原因很关键BSRR和BRR是写1生效写0忽略一次写入只影响目标引脚不会干扰其他并行IO状态。而如果写ODR要经历读-改-写三步多引脚同时操作时容易产生意想不到的中间状态这也是嵌入式开发里的经典技巧。main()里第一件事打开GPIOA时钟然后LED led(GPIOA, kLedPin)在栈上构造对象while(1)循环里不断翻转电平中间用延时控制闪烁频率。LED对象放在main内部不是全局对象。这不是随手写的而是刻意避开一个C裸机大坑很多芯片的启动文件根本不处理C全局对象的构造函数调用全局对象可能压根不会执行构造函数。放在栈上构造是最稳妥的做法后面常见问题里我还会展开说。3.3 换一个引脚、换一种速度验证你是不是真懂了代码跑通了之后我强烈建议你立刻做三个小练习每个都只要改一两行代码但能验证理解程度。第一个练习把PA5改成PA1。你要做的就是把kLedPin从5改成1同时把外接LED的杜邦线从PA5改插到PA1。构造函数里的位运算会自动把配置字段定位到CRL的低4位不需要你手动调整任何地址。如果这个理所当然能用说明你理解了CRL的位段划分逻辑。第二个练习如果你的板载LED不是接在PA5比如某块开发板上LED接PC13且低电平点亮那你需要改三处把(1U 2)换成(1U 4)使能GPIOC时钟把LED led(GPIOA, 5)改成LED led(GPIOC, 13)再把on()和off()里的BSRR、BRR对调因为低电平点亮时亮对应的是拉低引脚。这三个修改点每一个都要能在原理图上找到依据。第三个练习把闪烁频率从500ms改成200ms顺便看看灯会不会闪得比之前快。这看似简单其实在逼你去感受延时函数里那个7200到底是什么——它和主频的关系是什么改主频之后要不要跟着改。能想明白这一步你的嵌入式时钟观念就建立起来了。4. 常见问题速查编译失败与灯不亮到底卡在哪4.1 编译烧录层面的几个高频问题我在带人入门的时候遇到过无数次同样的报错这里把最典型的几个列出来。第一找不到stm32f1xx.h头文件。通常是PlatformIO工程框架没选对或者没有完整下载。检查platformio.ini里的framework stm32cube是否正确重新执行pio run让它把依赖拉下来。第二__NOP()未声明。这说明CMSIS核心头文件没有被正确包含。__NOP()定义在core_cm3.h里而stm32f1xx.h本身会包含它所以正常情况下不会出现这个错。如果出现了大概率是你手里那个头文件版本太老或者路径有冲突优先确认是不是同时残留了多个不同版本的头文件。第三C全局对象的构造函数不执行。就像前面说的很多STM32启动文件默认只做C运行环境的初始化不处理C的.init_array段。所以不要图省事把LED led(...)写成全局对象。真有大工程必须用全局对象时得自己写启动代码里的静态初始化流程那已经超出这篇的范畴了。现阶段统一做法对象全部放在main函数栈上构造。第四C写中断服务函数要注意extern C。中断向量表里那些函数名比如SysTick_Handler、EXTI0_IRQHandler是给汇编启动文件定位用的。C编译器会对函数名做名字修饰生成的目标文件里符号名会变导致中断无法触发。如果你后续要用C写中断处理函数正确姿势是加上extern C包裹。这篇没有涉及中断但提前知道这个坑会让你后续少走弯路。4.2 硬件不工作的排查顺序灯不亮的排查顺序比闷头改代码重要得多。我习惯按下面这个顺序来每一层都有对应证据。先用示波器或者万用表量引脚电平。如果没有仪器就直接换一颗LED、换一个GPIO引脚试。很多人会忽略电源和下载器连接实际上点灯失败最朴素的原因是板子根本没跑起来。烧录后可以先用调试器读一下芯片ID确认连接正常。确认软件层面的第一件事永远是什么时钟使能位没有开。GPIOA对应(1U 2)GPIOB是(1U 3)GPIOC是(1U 4)接哪个端口就开哪个。忘了开时钟GPIO配置寄存器的写操作完全无效。然后是CRL/CRH的配置。配置字段里MODE位是00时引脚处于输入模式输出信号根本到不了引脚。很多新手代码写了一大堆唯独忘记了把MODE从00改成01/10/11那LED当然不亮。把0x2UL pos换成0x3UL pos可以提高输出速度也能排除速度配置过低的问题。还要警惕一个隐藏点PA13、PA14、PA15、PB3、PB4这几个引脚在复位后默认是调试接口作为普通IO用会非常别扭。这份示例代码用的是PA5不存在这个问题但如果你练习时改到了PA13、PA14、PA15上面灯不亮可能不是代码的问题而是这些引脚被调试功能占用了。最后再排查板上电路。LED是高电平点亮还是低电平点亮限流电阻是多少LED有没有接反这些硬件因素会直接决定on()里该写BSRR还是BRR。4.3 照抄代码前先确认你板子上的两个关键信息不同开发板LED接法差异很大这也是评论区经常出现照抄了你的代码灯为什么不亮的根本原因。我在带项目的过程中见过接PA5高电平点亮的也见过接PC13低电平点亮的还有接PB0、PD2的。不要背型号要看原理图。拿到一块新板子两个信息必须确认第一LED网络标号连到哪个GPIO也就是端口字母和引脚号第二它是高电平点亮还是低电平点亮。然后按下面这个思路修改开发板接法时钟使能修改端口对象修改输出逻辑修改PA5接LED高电平点亮1U 2GPIOAGPIOA不用改PC13接LED低电平点亮1U 4GPIOCGPIOCon/off里BSRR和BRR互换PB0接LED高电平点亮1U 3GPIOBGPIOB不用改这里有一个关键提醒上面所有地址和寄存器结构都是STM32F1系列专属。如果你用的是STM32F4或者STM32L4GPIO外设挂在AHB1总线上基地址完全不同寄存器也从CRL/CRH变成了MODER、OTYPER、OSPEEDR底层操作逻辑全变。这篇的代码只保证在F1系列上成立换芯片系列属于另一套玩法不要硬套。4.4 第一份代码的“避坑速查表”我把这篇涉及的所有坑整理成一张表你可以打印出来贴在屏幕上以后每次点灯失败都照着查一遍现象可能原因解决方向编译时报找不到头文件PlatformIO框架未安装完整重新执行pio run拉取依赖编译优化后灯乱闪或延时失效延时变量缺少volatile延时计数变量加volatile或使用__NOP()下载成功但灯不亮GPIOA时钟未使能检查RCC-APB2ENR对应位下载成功且电压正常但灯不亮MODE位为00输入模式检查CRL/CRH写入值是否为0x2灯常亮不闪烁延时函数执行时间异常检查系统时钟和HSE晶振确认延时参数引脚配置正确但个别引脚无法控制引脚被调试功能占用避开PA13/14/15、PB3/PB4或做调试口重映射代码全对但LED不亮LED电路是低电平点亮互换on()里的BSRR和BRR最后分享一点个人体会。第一行代码写完那盏灯亮起来的一瞬间也许比不上你第一次写出Hello World时那样兴奋因为它的输出太简单了只是物理世界里的一个光点。但就是这个小光点证明了你写的C真正控制了一块芯片、一个引脚、一个真实世界的设备。这就是嵌入式最让人上瘾的地方代码不再是屏幕上的字符而是看得见摸得着的动作。按我的经验灯亮之后大多数人会立刻开始改闪烁频率、加第二颗灯、再接一个按键去控制LED这就是你最自然的学习状态。下一篇我会用同样的C封装思路讲定时器与中断那一步过去之后你的嵌入式能力会有一个质的提升。这一篇先到这里代码还在手边的话现在就去动两行。