你们催更的留言我都看到了。“看了三篇了,一行都没让我写呢”,这话确实没冤枉我。前三篇一直在铺垫,我把它理解成“打地基”,但对急着想上手的兄弟来说,确实有点煎熬。这篇咱们换换节奏,直接开写,写真正能跑在STM32上的C++代码。不是那种抄来抄去的demo,而是从工程结构到类封装,从LED闪烁到按键状态机,一行一行让你知道自己在干什么。这一篇写完,你手里应该有一份自己敲出来、能编译、能烧录、能跑的嵌入式C++工程。
先说清楚这篇适合谁。如果你刚看完前三篇,对Cortex-M内核、寄存器、编译链接这些概念有了初步印象,但手还没痒够,那这篇正合适。如果你已经写了好几年代码,从C转C++,想知道在嵌入式里怎么落地面向对象,这篇也有参考价值。如果你连STM32是什么都还迷糊,建议先回看系列第一篇,或者把这篇文章当作一个带有详细注释的实战录,抄一遍也行,但抄的时候要动脑子。
1. 铺垫了三篇,为什么现在才开始写代码
1.1 前三篇到底在做什么
咱们这个系列的名字是“基于STM32的嵌入式C++编程之旅”,有人会觉得,既然是编程之旅,第一篇就该写一个LED闪烁,第二篇就该写串口打印,结果我连着讲了内核、寄存器映射、启动文件,当然,中间也穿插了C++的基本语法,但确实没有让你动手写完整程序。
回顾一下前三篇的内容:先讲了ARM Cortex-M3/M4的存储器映射,聊了为什么0x08000000是Flash,0x20000000是SRAM;然后讲了GPIO控制器内部结构,什么是MODER寄存器,什么是BSRR寄存器,为什么要用“读-改-写”的方式来修改ODR;接着讲了C++在嵌入式环境里和桌面环境的关键差异,比如没有异常处理、没有标准模板库,也没有让你随便new的内存池。这些知识在今天会全部派上用场。
为什么不在第一篇就直接给代码?因为嵌入式编程有个非常现实的门槛:你写的代码最终不是跑在有个操作系统的电脑上,而是直接跑在裸金属硬件上。你没有printf可用,没有动态内存管理器帮你擦屁股,没有crash时的堆栈打印。一旦工程配置不对、启动文件不全、链接脚本里Flash和RAM地址错了,程序烧进去是黑屏,你连个报错提示都看不到。前三篇做的,就是把这些“看不见的坑”提前扫一遍。现在开始写代码,你觉得只是在写几个函数,实际上你是在把你刚刚学过的内核知识全部调动起来。
1.2 直接上手写代码的代价
很多人买一块开发板,第一件事就是复制例程,点亮LED,然后感觉自己会了。但离开开发板、换一块最小系统板的时候,往往连时钟配置都不会改。为什么?因为例程太“完整”了,完整到把所有细节都藏起来了。
直接上手写代码也不是不行,但代价是你遇到一个报错,根本不知道报错在说谁。比如编译器告诉你undefined reference to SystemInit,你如果不清楚这是启动文件里引用的一个时钟初始化函数,你可能就卡住了。再比如你把main函数改成了int main(),工程却链接失败,因为你没有加上crt0的启动流程,也不知道main在嵌入式环境里其实是被启动文件调用的一个裸函数。
所以我宁可前三篇慢一点,也要先把“你写的代码最终是怎么变成二进制、怎么被烧录到Flash、怎么被CPU一条条执行的”这条链路讲透。现在到了动手环节,你会发现这些知识会反复出现,不是无用功。
2. 从零搭一个能编译C++的STM32工程
2.1 开发环境选型:CubeIDE还是VS Code
动手之前先定工具。支持STM32的C++开发环境,常见三套:Keil MDK,STM32CubeIDE,以及VS Code加GCC工具链。我个人的建议是,如果你不是公司项目强制要求用Keil,那就用STM32CubeIDE,或者用VS Code配置一套GCC工具链。
先说Keil MDK。Keil在中文社区占有率很高,教程多,遇到问题容易搜到答案,但它对C++的支持在某些场合会让你头疼:旧版编译器对C++11标准支持不够完整,工程配置界面也偏老旧。如果你手头只有Keil,也不是不能用,就是要确认你的版本编译器支持你写的C++特性。
STM32CubeIDE是ST官方基于Eclipse的IDE,集成了STM32CubeMX,可以图形化配置时钟树、引脚、外设,然后生成初始化代码。它对C++的支持很友好,默认启用arm-none-eabi-g++编译器,你只需要在工程设置里把编译器语言切到C++,或者直接把源文件改成.cpp后缀。缺点是Eclipse框架有点笨重,启动慢,但胜在“开箱即用”。
VS Code的方案是这三套里最灵活、也最“程序员味”的。你用VS Code当编辑器,装Cortex-Debug插件和Embedded Tools插件,后端用arm-none-eabi-gcc编译器,配合CMake或者Makefile管理构建。这套方案配置一次之后,编译、烧录、调试都能在VS Code里完成。热词里很多人搜“vscode配置stm32开发环境”,说明走这条路的人越来越多。我自己的主力开发环境就是VS Code加CMake,写代码的体验比Eclipse舒服太多。
2.2 一个最小工程的完整结构
不管用哪个IDE,一个能从源码变成hex文件的STM32 C++工程,必须包含下面几样东西:
- 链接脚本(.ld文件):定义Flash起始地址0x08000000和大小、RAM起始地址0x20000000和大小,以及堆栈尺寸。
- 启动文件(startup_stm32f1xx.s):初始化栈指针,跳转Reset_Handler,在C++环境下还要负责调用全局对象的构造函数,这个非常关键,后文细说。
- 系统初始化文件(system_stm32f1xx.c):设置时钟和总线频率。
- 你的C++源文件:至少包括一个main.cpp,以及你后续写的类定义和实现。
- 一个链接和编译脚本或IDE工程配置。
如果从STM32CubeMX生成基础工程,它默认生成的是C语言模板,但你完全可以继续在工程里添加.cpp文件。如果使用CMake,你可以在顶层CMakeLists.txt里把源文件扩展名改成.cpp,编译器会自动从gcc切到g++。
我这里列一个最小工程目录,方便你对着建:
project/ ├── Core/ │ ├── Inc/ │ │ └── main.h │ ├── Src/ │ │ ├── main.cpp │ │ └── system_stm32f1xx.c ├── Drivers/ │ ├── CMSIS/ │ └── STM32F1xx_HAL_Driver/ ├── startup_stm32f1xx.s ├── STM32F103C8Tx_FLASH.ld └── CMakeLists.txt别小看这个结构,它有很强的约束力。你写的类最好放到独立目录,不要在main.cpp里堆一两千行代码。嵌入式C++最容易犯的毛病不是内存泄漏,而是“一个main.cpp走天下”。
2.3 点亮第一颗LED背后的步骤
假设你已经用CubeMX或者自己搭好了工程,接下来点灯这件事,可以拆解成几个明确的步骤:
- 确认开发板上LED接的引脚。绝大多数板上LED接PC13(STM32F103C8T6最小系统板),也有接PA1或PB0的,要看原理图。
- 使能GPIO所在总线的时钟。GPIO外设挂在APB2总线上,RCC->APB2ENR里对应位置1。
- 配置引脚模式为输出:GPIOx->CRL或CRH寄存器里把引脚模式配成“通用推挽输出”,速度随便设个2MHz就够。
- 往BSRR寄存器写值:BSRR低16位对应引脚输出高电平,高16位对应输出低电平。BSRR的好处是写0不会影响其他引脚,直接按位写就行。
这些动作你当然可以用HAL库一句话完成:HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13, GPIO_PIN_RESET);但那样你其实不知道底层发生了什么。我建议起步阶段自己操作寄存器,体会一下存储器映射的感觉,然后慢慢地再抽象封装。
3. 第一段真正属于你的C++代码:GPIO类
3.1 为什么用类封装寄存器
搞嵌入式的人常年被人问:“我们是写C的,为什么要用C++?”我的回答是:你不需要为了面向对象而突然引入一大堆虚函数和继承,但在嵌入式里,类同样是组织代码的好工具。比如说GPIO操作,如果你有四个LED、三个按键,用C写,你可能会复制粘贴好几段初始化代码,或者用宏定义把每个引脚定义一堆#define。用类封装,一次定义好端口、引脚和模式,之后调用成员函数,代码的可读性能提高一个层次。
关键问题是,嵌入式C++里的类不能滥用。理论上类可以有构造函数、析构函数、虚函数,但这些特性会隐含地引入额外代码和运行时开销。写STM32代码时,我们的原则是:只要这个功能是编译期就能确定的,就不要让代码在运行期多花一个字节。比如GPIO类,我可以把引脚配置全部在构造函数里完成,构造函数内联展开后,实际生成的机器码和直接操作寄存器的C代码是一模一样的,但代码结构却清晰得多。
3.2 从底层寄存器实现GPIO类
下面这段代码可以直接抄进你的GPIO.h里,注意,我是基于STM32F103的寄存器定义来写的,核心原理对F4也适用,只不过寄存器名字不同。
class GPIO { public: enum class Mode { InputFloating, OutputPushPull, AlternatePushPull, }; GPIO(GPIO_TypeDef* port, uint16_t pin, Mode mode) : m_port(port), m_pin(pin) { // 使能GPIO时钟,简化处理,默认时钟已开启 // 这里需要根据端口选择对应的RCC使能位 if (port == GPIOA) { RCC->APB2ENR |= RCC_APB2ENR_IOPAEN; } else if (port == GPIOB) { RCC->APB2ENR |= RCC_APB2ENR_IOPBEN; } else if (port == GPIOC) { RCC->APB2ENR |= RCC_APB2ENR_IOPCEN; } uint32_t pinIndex = 0; while (!(m_pin & (1U << pinIndex))) { ++pinIndex; } // 配置CRL或CRH uint32_t regOffset = (pinIndex % 8) * 4; uint32_t* configReg = (pinIndex < 8) ? &port->CRL : &port->CRH; if (mode == Mode::OutputPushPull) { *configReg &= ~(0b11U << regOffset); // MODE[1:0] *configReg |= (0b01U << regOffset); // 输出模式,最大速度2MHz *configReg &= ~(0b11U << (regOffset + 2)); // CNF[1:0] *configReg &= ~(0b01U << (regOffset + 2)); // 推挽输出,CNF[1:0] = 00 } else if (mode == Mode::InputFloating) { *configReg &= ~(0b11U << (regOffset + 2)); // CNF[1:0] *configReg |= (0b01U << (regOffset + 2)); // 浮空输入,CNF[1:0] = 01 *configReg &= ~(0b11U << regOffset); // MODE = 00,输入模式 } // 更多模式可自行扩展 } void set() { m_port->BSRR = m_pin; } void reset(){ m_port->BSRR = m_pin << 16; } void toggle() { if (m_port->ODR & m_pin) { reset(); } else { set(); } } uint8_t read() const { return (m_port->IDR & m_pin) ? 1 : 0; } private: GPIO_TypeDef* m_port; uint16_t m_pin; };这段代码有几个细节值得你注意:
pinIndex用while循环找引脚掩码里第一个置位的位,这样就不需要为PA0~PA15各写一套逻辑。- 配置寄存器时,我同时对MODE和CNF字段做处理,顺序是:先清MODE,再置MODE,再清CNF。这个讲究,因为这两组bit互相独立,但寄存器一次读写不能只改一个字段。
toggle()的用法是读ODR。读ODR会有问题吗?在大多数场景下没问题,但如果引脚输出还要同时做复用功能,读回来的值未必等于引脚真实电平,这时用read()比较好。
这里先解释为什么要用BSRR而不是ODR来控制输出。BSRR寄存器是写1有效,置位和复位分成两个32位段,写0则不影响。而ODR是整体寄存器,要改某个位就得读-改-写,万一在改写过程中引脚电平变了,可能出错。BSRR写入后生效,是硬件级别的原子操作,不会被打断。嵌入式开发里,能用硬件原子指令完成的,就不要用软件去模仿原子。
3.3 加入构造析构与错误检查的完整代码
上面的GPIO类只用了构造函数和普通成员函数,没有析构函数,也没有虚函数,这是有意的。析构函数不好写,因为GPIO被销毁后,引脚到底应该回到什么状态?有一种选择是恢复为浮空输入,但那可能影响电路其他部分。更安全的做法是提供一个deinit()方法,显式关闭配置,析构时什么都不做,避免隐式副作用。
我们再看一个更稳妥的使用场景。假设板上有两个LED,一个在PC13,一个在PA1,你可以在main函数里这样写:
GPIO ledRed(GPIOC, GPIO_PIN_13, GPIO::Mode::OutputPushPull); GPIO ledGreen(GPIOA, GPIO_PIN_1, GPIO::Mode::OutputPushPull); int main() { while (1) { ledRed.toggle(); delay_ms(200); ledGreen.toggle(); delay_ms(200); } }delay_ms需要你自己实现。最简单的是用SysTick定时器,初始化为“每1ms产生一次中断”,在中断服务函数里递减一个全局计数,然后delay_ms里死等计数归零。也可以用while循环空转做粗略延时,但空转延时依赖编译优化,非常不推荐用于生产。后面我会给一个基于SysTick的非阻塞延时做法。
这段代码你编译、烧录后,如果LED交替闪烁,说明你的C++工程已经正常运作了。到这一步,你写的第一段已经不是“一行代码没写”,而是已经写了至少四十行,而且全部是你自己敲出来的。实际上,你还完成了寄存器配置、位操作、类封装这一整套流程。
4. 从闪烁到按键:第一次完整的C++状态机
4.1 用状态机代替delay的思路
点灯成功后,很多人会习惯性地用延时函数控制闪烁:亮500ms,灭500ms。这个写法在纯LED场景没毛病,但如果你加一个按键,你很快就发现,延时函数执行期间,程序什么都干不了。单片机是单线程的,你死等在delay里,按键扫描就停了,即使中断能打断延时,整个程序也显得很乱。
解决这个问题的主流思路,是把程序写成“状态机 + 轮询”的结构。代码每隔一毫秒跑一次“心跳”,心跳里判断当前时间,如果应该切换状态,就切换。这样程序永远不会阻塞,按键、通信、显示都能同时维护。
从C++的角度看,状态机非常适合用类来封装。把一个对象的状态hiding在private成员变量里,外部只需要调用update()方法,它会根据内部状态和当前时间决定行为。这个思想在大型嵌入式系统里同样适用,比如电梯控制、洗衣机流程,都是状态机的例子。
4.2 状态机实现与核心代码
我直接上一个按键控制LED的模式:短按按键一次,LED进入慢闪;再短按一次,进入快闪;第三次短按,关闭。这个需求实际上是一个三状态状态机。
先定义按键类。按键输入有一个老生常谈的问题:机械抖动。消抖最简单的办法是连续采样多次,或者用“状态到达后等待稳定时间”。我给出一个基于时间片的消抖实现:
class Button { public: enum class State { Idle, Pressed, Released, }; Button(GPIO_TypeDef* port, uint16_t pin, uint32_t debounceMs) : m_port(port), m_pin(pin), m_debounceMs(debounceMs) {} // 每次循环调用,传入当前毫秒计数 void update(uint32_t now) { bool level = (m_port->IDR & m_pin) ? true : false; switch (m_state) { case State::Idle: if (level) { m_lastTick = now; m_state = State::Pressed; } break; case State::Pressed: if (!level) { // 如果已经稳定了,表示一次完整的按下并松开 if (now - m_lastTick >= m_debounceMs) { m_pressed = true; } m_state = State::Idle; } break; } } bool wasPressed() { bool result = m_pressed; m_pressed = false; return result; } private: GPIO_TypeDef* m_port; uint16_t m_pin; uint32_t m_debounceMs; uint32_t m_lastTick = 0; bool m_pressed = false; State m_state = State::Idle; };其实这里我用了一个简化版状态机,只处理“按下后回到Idle”的事件。消抖的核心在于最后一次跳变到稳定的时间,如果时间太短,我们认为是一次抖动。这个方法比“每隔几毫秒采样多次”更节省时间,也更容易用C++的状态枚举表达。
然后是LED控制状态机:
class LedController { public: enum class Pattern { Off, SlowBlink, FastBlink, }; LedController(GPIO& led, uint32_t slowPeriod, uint32_t fastPeriod) : m_led(led), m_slowPeriod(slowPeriod), m_fastPeriod(fastPeriod) {} void update(uint32_t now) { switch (m_pattern) { case Pattern::Off: m_led.reset(); break; case Pattern::SlowBlink: case Pattern::FastBlink: { uint32_t period = (m_pattern == Pattern::SlowBlink) ? m_slowPeriod : m_fastPeriod; if (now - m_lastToggle >= period) { m_led.toggle(); m_lastToggle = now; } break; } } } void setPattern(Pattern p) { m_pattern = p; m_lastToggle = 0; } private: GPIO& m_led; uint32_t m_slowPeriod; uint32_t m_fastPeriod; Pattern m_pattern = Pattern::Off; uint32_t m_lastToggle = 0; };这段代码体现了几个重要的C++习惯:LedController持有GPIO&引用,而不是GPIO*指针,表示这个控制器离不开LED实例,而且在构造之后这个关系不会变。模式切换时m_lastToggle = 0,确保下次update立即翻转LED,而不是要等满一个周期。
最后在main.cpp里串起来:
GPIO ledRed(GPIOC, GPIO_PIN_13, GPIO::Mode::OutputPushPull); Button btn(GPIOA, GPIO_PIN_0, 20); // PA0连按键,按下为高 LedController controller(ledRed, 500, 200); extern "C" void SysTick_Handler(void) { g_tick++; } void delay_cycles() { volatile uint32_t i; for (i = 0; i < 400000; ++i) { } } int main() { SysTick_Config(72000); // 72MHz主频,每毫秒中断一次 while (1) { btn.update(g_tick); if (btn.wasPressed()) { static uint8_t cnt = 0; cnt = (cnt + 1) % 3; if (cnt == 0) controller.setPattern(LedController::Pattern::Off); else if (cnt == 1) controller.setPattern(LedController::Pattern::SlowBlink); else controller.setPattern(LedController::Pattern::FastBlink); } controller.update(g_tick); } }SysTick_Config(72000)这个值是怎么来的?STM32F103的最高主频是72MHz,SysTick定时器底层是一个24位递减计数器,每计一个数消耗一个时钟周期,把重装载值设为72000,也就是1ms递减计数一次。如果外部晶振是8MHz,系统时钟经过PLL倍频到72MHz,那这个值就是72M/1000 = 72000。如果你的板子主频改了,这个数字也要同步改。
到这一步,你写的C++代码行数已经接近两百行。你说你“一行都没写”,只是因为你总是等在屏幕前看别人写。现在你已经完成了按键消抖、非阻塞延时、状态机切换这一整套嵌入式开发中最常见的逻辑。
4.3 收获:你其实已经写了多少行
不算GPIO类底层封装,不算Button类,只算main.cpp里的逻辑,你已经手写了大概50行有效代码。加上类的定义,接近200行。这200行不是无意义的抄写,而是你自己能看懂的、有结构的代码。你觉得没写,可能是因为你还没有建立“写代码”的体感。
体感很重要。写C++和写嵌入式C相比,最大的体感差异在于组织方式:C代码习惯是文件+函数+全局变量,而C++代码习惯是把数据和相关操作绑定在一个类内部。同样的点灯+按键功能,C版本往往是一堆函数名加参数到处传,而C++版本,你在main函数里很清晰地看到三个对象互相协作。在工程慢慢变大时,这种清晰度就是生产力的保证。
5. 嵌入式C++实战中的高频坑与排查记录
5.1 编译链接阶段的问题
我第一次用C++写STM32时,踩的第一个坑是链接错误。错误信息类似这样:
undefined reference to '__aeabi_atexit'当时百思不得其解,后来一查才知道,C++的静态对象析构要用到__aeabi_atexit函数,但嵌入式环境里默认没有标准库支持。解决办法是在编译器参数里加-nostartfiles——不对,准确说是在链接阶段加上--specs=nano.specs,或者直接实现一个空函数void __aeabi_atexit(void*) {}。更麻烦的版本是“纯裸机”环境,你要确保全局对象构造不会引用到操作系统层面的初始化。
同理,undefined reference to '__gxx_personality_v0'也是常见问题。这是C++异常处理相关符号,嵌入式环境默认关掉异常,需要在编译选项里加-fno-exceptions,同时链接的时候告诉编译器不生成异常相关的内容。这个我建议从第一天就在编译器参数里加上:
-fno-exceptions -fno-rtti省得以后每次报错都来查一遍。RTTI(运行时类型识别)在嵌入式里几乎用不到,关闭它不仅能减小代码体积,还能避免依赖。
还有一个链接问题,经常被忽略:未声明的extern "C"导致中断函数链接不上。STM32的启动文件是汇编写的,里面声明Reset_Handler、SysTick_Handler等符号供你实现。如果你用C++写main.cpp,并且写了void SysTick_Handler(),这个函数名会被C++编译器进行名字修饰,变成一串带参数类型信息的新符号名,汇编启动文件就找不到了。解决办法是给中断函数显式加上extern "C"声明:
extern "C" void SysTick_Handler(void); void SysTick_Handler(void) { g_tick++; }这个坑非常隐蔽,因为编译不会报错,只是烧录后中断完全不起作用。很多人点了半天灯都不亮,其实不是引脚配错了,是这个原因。
5.2 运行期静默故障
编译链接通过之后,麻烦往往在运行期。嵌入式调试工具少,经常程序看起来没反应,你甚至不知道它跑到哪一行了。下面几个运行期问题是我个人碰到次数最多的。
首先是volatile关键字。用优化级别O2编译时,编译器会把不改变的变量优化掉。比如你写一个循环:
for (uint32_t i = 0; i < 100000; i++);如果i只在循环里使用,而且循环体为空,编译器在优化时可能直接把整个循环删掉,或者把i优化成一个死循环。为了让延时函数真正生效,你得把i声明为volatile uint32_t i,告诉编译器“不要对这个变量的读写做优化”。同理,在中断和main之间共享的所有变量,都应该加上volatile,或者使用原子操作。比如前面例子里的g_tick,在main的while循环里被读取,同时在SysTick中断中被增加,这两个地方共享,必须用volatile修饰,否则编译器可能只在进入循环前读一次,后面每次都使用缓存值,按键消抖和控制器时间判断全部失效。
第二个是全局对象的构造时机。在C++里,全局对象有一个构造阶段,发生在main函数之前。嵌入式工程里,这个构造由启动文件调用__libc_init_array来执行。很多从STM32CubeMX生成的工程,启动文件确实有这个调用,但如果你自己写了一个精简启动文件,很可能漏掉这一步。漏掉的结果是:你的GPIO类构造函数没被调用,但是全局对象的内存已经分配了,你调用set()时会往一个没有初始化的寄存器地址写值,自然没有任何反应。所以,如果你用了全局对象,必须确认启动文件里有构造段处理逻辑。如果没有,要么老老实实改启动文件,要么改为在main函数里用局部对象,或者用单例模式懒初始化。
第三个是中断上下文和普通函数的资源竞争。比如你在Button的update()里读GPIO,而稍后又在中断里写另一个GPIO,两者没有共享数据,还好。但如果中断里修改了某个标志位,而main里读取后清零,这个“读-改-写”过程可能被打断,导致丢失事件。解决方法是:要么在进入临界区前关中断,要么把事件处理设计成更安全的方式。普通嵌入式课本会教你用__disable_irq()和__enable_irq()包住临界区,但这个操作有代价,会引入不确定的中断延迟,能用read-modify-write原子指令解决的就优先用硬件方案。
5.3 调试手段与经验
嵌入式调试,别指望像桌面开发那样打断点看变量,真机现场往往没有串口打印,也不可能随时接J-Link。你有三样趁手的工具:调试器的断点与寄存器窗口、串口打印、LED指示灯。
用ST-Link/J-Link配合IDE调试时,你确实可以在源代码设断点、单步执行,但一旦涉及到多线程中断并发问题,调试器反而会干扰时序。我的经验是:能用一个二进制位的LED指示状态,就不要急着打印字符串。比如你怀疑按键消抖逻辑有没有进入Pressed状态,可以让LED在进入该状态时亮一下,然后观察现象。这比读串口日志直观得多。
串口打印也可以用,但要注意log函数本身不能阻塞太久。很多工程师在中断里打印日志,结果打印几条就把程序卡死了。正确做法是建一个FIFO缓冲区,中断只负责把数据写进缓冲区里,main函数循环负责像倒垃圾一样慢慢发送缓冲区数据。如果你不会实现FIFO,宁可在中断里设置一个标志位,main里看到标志位就打印,也别直接在中断里调printf。
另外,我建议每个模块开头的调试阶段都保留一个“自检模式”。比如GPIO类可以设计一个selfTest()方法,初始化后把所有引脚都翻一遍,观察LED有没有反应。自检模式看起来不起眼,但在排错时能帮你快速区分“引脚初始化配置错了”和“后续逻辑写错了”。
写在最后的一点体会
回到开头那位朋友的问题:看了三篇了,一行都没让我写呢。现在你回看这一篇,是不是已经写了不少行?其实关键不在行数,而在于你有没有在写代码的时候意识到自己在操作硬件。你写RCC->APB2ENR |= 1 << 4;的时候,脑子里有没有想到那一根使能总线的硬件线在某个时钟周期被拉起来?你写GPIOA->BSRR = 0x00000001;的时候,有没有想到前面那根引脚的电平在几纳秒后发生变化?这才是嵌入式编程有意思的地方,也是C++在这个领域里发挥价值的场景:你要在足够高的抽象层级上描述逻辑,同时不能丢掉对每一行机器指令背后硬件行为的感觉。
我自己早年习惯用纯C写STM32,后来在项目里逐渐引入C++,最开始的几十个类都是“寄存器操作的结构体套了一层方法”,说实话收益不大。真正的收益出现在写出了状态机、消息队列、任务调度这些结构之后,模块之间的耦合变弱了,测试也变得容易。我相信你继续写下去,也会遇到同样的问题;遇到的时候别急着写一大堆设计模式,先从最简单的封装开始,一点一点把C++的语法落地到寄存器之上。这条路,走到后面自然是水到渠成。