这一篇,我们终于动手写代码了
别急,老朋友。这句"看了三篇了,一行都没让我写呢",我从评论区看到的时候真的笑了——因为这正是我刻意安排的节奏。嵌入式C++和纯软件不一样:你在电脑上写个std::cout << "hello",点运行,结果就出来了。在STM32上,你的代码要经过编译、链接、烧录、复位,最后还要在硬件上验证,中间任何一环断了,看到的都是一块沉默的板子。前三篇不讲工具链、不管工程骨架、不碰烧录调试,上来就让你写"点灯"代码,那和让一个没通电的灯泡装上灯罩有啥区别?这一篇,就是你从"看客"转向"施工者"的分界线。
这篇的目标很明确:从空main函数开始,亲手画出第一份能跑的 C++ 工程,然后在寄存器层面点亮一个板载 LED,最后再用 C++ 封装成一个Led类。适合所有跟着本系列走到这里、手已经开始痒的朋友。
1. 为什么前三篇我忍着没让你写代码
1.1 "看完"和"写完"之间,隔着一整套工具链
先坦白一件事:我见过太多朋友,打开 Keil 或者 STM32CubeMX,鼠标点几下,工程生成了,然后复制一段网上的点灯代码,编译下载,灯亮了。他觉得自己会了,但一个月后换个芯片、换个编译器,整个人就傻了。因为他只是"完成了操作",并不理解"发生了什么"。
这个系列的定位从一开始就不是"帮你点亮一盏灯",而是"让你具备自己造灯的能力"。这需要三样东西:能看懂代码的编译工具链、能理解启动流程的工程骨架、能看到寄存器状态的调试通道。这三样东西,在 Keil 的图形界面里被隐藏得很好,你根本不会注意到它们存在。而在命令行这套方案里,它们全部裸露在你面前——所以我必须在前三篇先把它们铺好。
1.2 前几篇到底干了什么,一句话版
- 第 1 篇讲了路线选择:为什么 STM32 + C++ 而不是纯 C 或者 Arduino,结论是"掌控感 + 代码组织能力"。
- 第 2 篇把环境搭好了:VS Code 编辑,
arm-none-eabi-gcc编译,OpenOCD 烧录,ST-Link 是桥。 - 第 3 篇拆了工程:启动文件做什么、链接脚本管什么、
main函数到底是谁调起来的。 - 第 4 篇讲了 CMSIS 和寄存器:STM32F1 的寄存器不是魔法数字,就是芯片手册里一个个有地址的单元格。
第 3 篇和第 4 篇特别重要,因为它们回答了"代码从哪里开始执行"和"寄存器是什么"这两个灵魂问题。你如果跳过那两篇直接看这篇,遇到RCC->APB2ENR你会以为是某种咒语,实际上它就是一块内存地址的别名,仅此而已。
1.3 这一篇开始,你要把"看"变成"写"
我故意把动手环节压到第五篇,就是想逼你把前面的基础吃透。但吃透的验证方式只有一个:独立跑通第一个程序。这篇我会带着你建立一个最小但完整的 C++ 工程,然后逐行走读点灯代码。等跑完,你会意识到"看代码"和"写代码"之间那条线,其实比想象中要靠得近——跨过去的方法就是老老实实敲一遍,别复制。
2. 建立第一个可复现的工程:空main.cpp能编译过,才算起步
2.1 一个STM32 C++工程到底由什么组成
如果你被问"写一个STM32程序需要哪些文件",我希望你脑海中浮现的答案是下面这四个东西:
| 文件 | 作用 | 类比 |
|---|---|---|
startup_stm32f103xb.s | 芯片复位后的第一段汇编代码,设置栈指针、跳转main | 公寓的交房验收流程 |
STM32F103C8Tx_FLASH.ld | 链接脚本,告诉编译器 ROM/RAM 地址怎么安排 | 房子的户型图 |
syscalls.c/syscall.c | 给 C/C++ 标准库提供底层支撑(_sbrk等) | 水电燃气的基础管线 |
main.cpp | 你真正写逻辑的地方 | 你的装修设计 |
前三个文件没弄对,你的main.cpp写得再漂亮也没用。第 3 篇我们已经详谈过它们,所以这一篇我只强调一个观点:它们不是代码,是你工程的"地基"。地基不稳,后面每加一个外设都是在危房上装修。
2.2 动手:建目录、放文件、写空main
我建议你从第 3 篇的模板开始,别自己从零手搓。打开你之前建好的工程目录,确认里面已经有:
project/ ├── Core/Inc/ # 头文件目录 ├── Core/Src/ # 源码目录 ├── Drivers/ # CMSIS 头文件目录 ├── STM32F103C8Tx_FLASH.ld # 链接脚本 ├── startup_stm32f103xb.s # 启动文件 ├── syscalls.c # 系统调用桩 └── Makefile 或 CMakeLists.txt然后将Core/Src/main.c(如果没有就新建)更名为Core/Src/main.cpp,内容先只写这个:
#include "stm32f1xx.h" int main(void) { while (1) { } }这段代码什么也不会做,但它是你整个嵌入式 C++ 生涯的起点。注意#include "stm32f1xx.h"来自 CMSIS 头文件目录,如果编译器找不到它,说明你的-I路径没配好——这正是去验证环境的好机会。
2.3 验证编译链路的技巧:先过编译,再过链接
很多新手第一次编译就同时翻三个错误,然后崩溃。我的做法是分两步走:先编译main.cpp生成目标文件,再把它和启动文件、链接脚本合到一起生成.elf。
# 第一步:只编译,不链接 arm-none-eabi-g++ -mcpu=cortex-m3 -mthumb \ -Os -ffunction-sections -fdata-sections \ -I Core/Inc -I Drivers/CMSIS/Device/ST/STM32F1xx/Include \ -c Core/Src/main.cpp -o build/main.o如果这步过了,说明头文件路径、语言设置都没问题。接下来走完整链接:
arm-none-eabi-g++ -mcpu=cortex-m3 -mthumb \ -Os -ffunction-sections -fdata-sections \ -fno-exceptions -fno-rtti \ -T STM32F103C8Tx_FLASH.ld \ -Wl,--gc-sections \ -o build/firmware.elf \ build/main.o startup_stm32f103xb.o syscalls.o注意我在编译选项里显式加了-fno-exceptions和-fno-rtti。C++ 的异常和 RTTI 在裸机嵌入式里基本用不上,关了能省一大块代码空间,还能避免运行时开销。这一行选项,就是"嵌入式 C++"和"桌面 C++"的第一个分水岭。
当你看到firmware.elf生成成功,你的"地基"算是真正夯实了。这时候,我们才开始谈点灯。
3. 第一行真代码:用寄存器点亮板载LED
3.1 为什么第一行必须是点灯
嵌入式领域的 Hello World 就是点灯。你用一个人类肉眼可见的物理现象——发光,来证明整条链路是通的。如果 LED 亮了,说明芯片供电正常、复位正常、时钟正常、GPIO 配置正确、代码烧录成功。这几个环节任何一个有问题,灯都不会亮。所以点灯不是幼稚,它是最科学的验收手段。
我见过不少想一步登天的朋友,上来就去搞 USB 设备或者以太网协议栈,结果卡在硬件异常里三天找不出原因。点灯至少能告诉你:问题出在软件逻辑还是硬件连接。
3.2 读原理图、翻手册:GPIO操作的完整逻辑
在写任何寄存器代码前,先做两件事。第一,打开你开发板的原理图,找到板载 LED 接在哪个引脚。我用的是 STM32F103C8T6 的蓝板,LED 接在 PC13 上,另一端接 3.3V,也就是说PC13 输出低电平时 LED 亮,输出高电平时灭。你的板子可能不一样,这一步千万别省——烧录完发现灯不亮,八成是引脚或极性搞错了。
第二,翻开 STM32F103 参考手册(RM0008),找到 GPIO 章节。点亮一个 LED 涉及三个核心操作:
- 打开时钟:GPIO 外设挂在 APB2 总线上,要先把它的时钟使能,否则写任何寄存器都是白写。对应
RCC->APB2ENR的IOPCEN位。 - 配置模式:PC13 是引脚 13,属于高八位,用
GPIOC->CRH寄存器控制。我们要把它配成"通用推挽输出",速度用 2MHz 就够——LED 不要求高速,选 2MHz 还能省点功耗。对应 MODE 位为10,CNF 位为00。 - 设置电平:输出低电平用
GPIOC->BSRR寄存器。BSRR 低 16 位是置 1,高 16 位是清零。把高 16 位的 bit13 写 1,PC13 就被拉低了。
这套流程背后的逻辑,一句话概括:先通电(时钟),再定岗位(模式),最后干活(输出电平)。记住这个顺序,后面操作任何外设都适用。
3.3 代码落地:时钟、模式、电平三步走
下面这段代码,就是完整实现上述三步的main.cpp:
#include "stm32f1xx.h" int main(void) { // 第 1 步:打开 GPIOC 外设时钟(挂在 APB2 总线上) RCC->APB2ENR |= RCC_APB2ENR_IOPCEN; // 第 2 步:PC13 配置为推挽输出,速度 2MHz // CRH 寄存器的 bit20~23 对应 PC13,MODE=10(2MHz),CNF=00(通用推挽) GPIOC->CRH &= ~(0xFUL << 20); GPIOC->CRH |= (0x2UL << 20); // 第 3 步:BSRR 高 16 位清零 PC13,输出低电平,LED 点亮 GPIOC->BSRR = (1UL << (13 + 16)); while (1) { } }读完这段代码,你可能会想:就这么简单?就这么简单。LED 亮的那一刻,你亲手控制了芯片的物理引脚,这种"我能让硅片听我话"的感觉,是所有花里胡哨框架给不了你的。
关于GPIO_TypeDef和RCC_TypeDef这些结构体类型,你不需要去背,CMSIS 头文件里已经帮我们把寄存器地址和名字都定义好了。你写GPIOC->CRH,编译器会把它翻译成对0x40011004这个地址的操作。这就是第 4 篇讲的 CMSIS 的威力。
4. 用C++封装一个Led类:让点灯这件事可以复用
4.1 嵌入式C++的约束:关了异常和RTTI以后还剩什么
有人问我:你都用 C++ 了,怎么还写寄存器操作?这不是倒退吗?这里有个关键认知:嵌入式 C++ 的价值不在语法糖,而在抽象能力。我关掉异常和 RTTI,是为了不引入运行时负担;我不用new,是因为裸机堆管理容易碎片化。但这些约束之内,C++ 还保留了三件利器:
- 类封装:把"某个引脚"的结构状态和行为绑定在一起。
- 枚举和类型安全:让"低电平点亮"和"高电平点亮"这种极性表达变得可读、可检查。
- namespace 和静态断言:把代码组织得井井有条,在编译期就能发现错误。
换句话说,C++ 在裸机上的定位是"有纪律的表达力",而不是"什么都塞进去的瑞士军刀"。
4.2 Led类设计与实现细节
还是以 PC13 为例,我们来写一个可复用的Led类。设计需求很简单:初始化引脚、开灯、关灯、翻转状态。但要注意,不同板子的 LED 极性不一样,所以我把极性做成了构造参数:
// led.hpp #pragma once #include "stm32f1xx.h" #include <cstdint> namespace dev { enum class LedActiveLevel : std::uint8_t { Low = 0, // 低电平点亮(板载 LED 常见) High = 1 // 高电平点亮(部分扩展模块) }; class Led { public: Led(GPIO_TypeDef* port, std::uint16_t pin, LedActiveLevel level) : port_(port), pin_(pin), active_(level) {} void init() { if (pin_ < 8) { std::uint32_t shift = pin_ * 4; port_->CRL &= ~(0xFUL << shift); port_->CRL |= (0x2UL << shift); } else { std::uint32_t shift = (pin_ - 8) * 4; port_->CRH &= ~(0xFUL << shift); port_->CRH |= (0x2UL << shift); } } void on() { if (active_ == LedActiveLevel::Low) { port_->BSRR = (std::uint32_t)pin_ << 16; // 清零 = 拉低 } else { port_->BSRR = pin_; // 置位 = 拉高 } } void off() { if (active_ == LedActiveLevel::Low) { port_->BSRR = pin_; } else { port_->BSRR = (std::uint32_t)pin_ << 16; } } void toggle() { port_->ODR ^= pin_; // 异或翻转 } private: GPIO_TypeDef* port_; std::uint16_t pin_; LedActiveLevel active_; }; } // namespace dev几个细节我展开说一下。init()里区分了 CRL 和 CRH,因为引脚 0~7 的配置位在 CRL,8~15 的配置位在 CRH;on()和off()用BSRR操作而不是直接读写ODR,是为了避免"读-改-写"过程中被中断打断导致误操作;toggle()用ODR异或,简洁且天然正确。
4.3 封装前后的对比:为什么说C++写嵌入式是"值"的
现在main.cpp变成这样:
#include "stm32f1xx.h" #include "led.hpp" dev::Led onBoardLed(GPIOC, 13, dev::LedActiveLevel::Low); int main(void) { RCC->APB2ENR |= RCC_APB2ENR_IOPCEN; // 时钟还是要自己开 onBoardLed.init(); onBoardLed.on(); while (1) { } }对比一遍纯 C 的写法,你发现什么了?代码量几乎一样,但语义完全不同。以后你要把板子换成 PA5 接 LED 的另一个型号,只需要改一行dev::Led的构造参数;要支持外部模块的高电平点亮,把LedActiveLevel::Low换成High就完事。而不管代码怎么变,它的调用方式始终是init()、on()、off()、toggle()这几个词。
这就是抽象的回报:你付出的成本是十几个class code,获得的回报是读代码的人(包括三个月后的自己)一眼就能看明白意图。C++ 在嵌入式里不是用来炫技的,是用来控制复杂度的。
5. 编译、烧录、调试:第一次跑通全链路与常见坑
5.1 一条命令从源码到固件
代码写完,接下来就是见证奇迹的时刻。我在第 2 章给过你分步编译的命令,实际开发时我会用顺手的方式把它们收拢到 Makefile 或 CMake 里。这里给出你手动复现的完整链路:
arm-none-eabi-g++ -mcpu=cortex-m3 -mthumb \ -Os -ffunction-sections -fdata-sections -fno-exceptions -fno-rtti \ -I Core/Inc -I Drivers/CMSIS/Device/ST/STM32F1xx/Include \ -T STM32F103C8Tx_FLASH.ld -Wl,--gc-sections \ -o build/firmware.elf \ build/main.o build/led.o startup_stm32f103xb.o syscalls.o # 生成可以烧录的 bin 文件 arm-none-eabi-objcopy -O binary build/firmware.elf build/firmware.bin如果你用的是 PlatformIO,下面两行就能顶替上面所有手动操作:
pio run # 编译 pio run -t upload # 编译并烧录但我不建议你现在就用它,因为自动化的代价是隐藏细节。手动跑过一次编译链接之后,再用任何集成工具心里都有底。
5.2 烧录与验证:怎么确认你的代码真的在跑
烧录我用 OpenOCD + ST-Link。一条命令搞定:
openocd -f interface/stlink-v2.cfg -f target/stm32f1x.cfg \ -c "program build/firmware.elf verify reset exit"烧录完成后,LED 应当常亮。如果你用的是 PC13 低电平点亮的板子,只是"常亮"还不够有意思,我们可以让交替亮灭。最简单的方式是加一个笨延时(不精确,但立刻能看出效果):
void delay(volatile std::uint32_t count) { while (count--) { __NOP(); } } int main(void) { RCC->APB2ENR |= RCC_APB2ENR_IOPCEN; onBoardLed.init(); while (1) { onBoardLed.toggle(); delay(800000); } }注意delay参数必须用volatile修饰。否则编译器在-Os优化下会认为这个循环"没有实际效果",直接把它整个删掉——到时候你会发现 LED 疯狂闪烁或者干脆不闪,这就是优化和预期打架的经典案例。
一个更正经的做法是用 SysTick 定时器做精准延时,这个我放到下一篇专门讲。现在用笨延时够用,但心里要清楚它不精确。
5.3 新手最容易翻车的四个问题排查表
我预判你会遇到以下四种状况,直接给排查思路:
| 现象 | 最可能原因 | 排查方向 |
|---|---|---|
| 编译报找不到头文件 | -I路径没包含 CMSIS 目录 | 检查stm32f1xx.h实际路径 |
| 烧录成功但灯不亮 | 时钟没开 / 引脚配置不对 / 极性反了 | 先核对原理图,再看 APB2ENR 和 CRH 寄存器值 |
| 烧录时报连接失败 | ST-Link 驱动没装 / 接线松了 / 板子没供电 | 检查设备管理器,确认调试器被识别 |
| 灯亮但不闪烁 | 延时被编译器优化删掉了 | 把延时参数加上volatile |
表格之外的忠告:每次只改一个变量。新手最常犯的错误是同时怀疑代码、硬件、接线、电源,然后四处乱试。正确姿势是:代码逻辑先用"灯常亮"验证,再谈闪烁;改代码后重新编译烧录,不要对着旧固件发呆;怀疑引脚极性时翻原理图,而不是猜。
6. 到这一篇为止,我建议你停下来做的事情
6.1 别急着学一堆新外设,先把点灯"玩出花"
我不打算在第五篇就把 UART、定时器、中断全灌给你。那样做只会让你回到"复制代码能跑,关掉工程全忘"的老路。我的建议是:拿着这个Led类,做下面三个小实验。
- 改极性参数,接一个外部高电平点亮的 LED 模块,确认你的类设计是通用的。
- 用
toggle()和笨延时,实现 SOS 求救信号(三短三长三短)。这会逼你思考delay的组合逻辑。 - 把 LED 换到另一个引脚,比如 PB0,重新调用
init(),验证类确实适应不同引脚。
这三个实验做下来,你对 GPIO、时钟、极性、延时、类封装的理解会比看十篇文章都深。
6.2 关于嵌入式C++的一个核心原则:不需要的抽象不加
我在这篇里给了你一个Led类,但我没给你 GPIO 抽象基类、没给你接口层、没给工厂模式。不是我不会,是这一步用不上。嵌入式 C++ 最忌讳的事情就是过度设计——你写个点灯程序,结果项目里有五个继承层级和六个虚函数,那纯粹是为难编译器也为难自己。
原则很简单:当抽象能明显降低重复或显著提升可读性时,才引入;否则,写朴素代码。Led类恰好到了"值得封装"的那条线,因为它要处理引脚号、极性、端口指针这些容易搞混的事实。你要是下一步要驱动 OLED,那也是一个独立类;但两个类之间,不急着搞共同基类。
6.3 我个人的一点体会
回到标题那句抱怨——"看了三篇了,一行都没让我写呢"。我当年学嵌入式的时候,恰恰是反过来的:教程让我写了太多的"一行",却没告诉我那行在整条链路里是什么位置。结果就是代码越写越心虚,出了问题完全不知道从哪里查起。
这个系列选择"先搭地基、再动工"的顺序,确实牺牲了前期的成就感,但换来的是后期的安全感。现在你亲手编译、烧录、点亮了第一盏 LED,你拥有的不只是一段能跑的代码,而是一条完整的"从想法到硬件"的流水线。这条流水线一旦建立,接下来加灯、加按键、加串口、加定时器,都只是在已有的稳固地基上添砖加瓦。
下一篇我们讲 SysTick 定时器,用精准的延时让那颗 LED 像呼吸一样闪烁——那时候你再回头看这篇的笨延时,会清晰地看到自己的成长曲线。