看到这个标题,我自己先乐了一下。作为一个常年用C++折腾STM32的人,我太懂这种"看了三篇干货,结果一行代码都没见到"的焦灼感了。前几篇聊工具链、聊工程模板、聊编译流程,确实都是正事,但学习嵌入式这件事,光看不练是真的会看困的。
所以这篇不绕了,直接动手写代码。但有个前提我得先讲清楚:前面那些"没让写代码"的铺垫,恰恰决定了你接下来写的每一行代码会不会"白写"。很多人学单片机就是倒在第一步——拿了例程就编译、烧录、看现象,跑通了很开心,但换个芯片、换个引脚就完全懵了。这种"会抄不会写"的状态,本质上是因为脑子里只有代码,没有"代码怎么变成片上行为"的那条链路。
这篇我会先花几分钟把这条链路补上,然后从第一个真正能跑的C++程序开始,一路聊到类封装、模板、中断回调这些嵌入式C++的硬核内容。你跟着敲完,会发现前面三篇的铺垫全都串起来了。
1. 请先忍住代码冲动:工具链和编译烧录才是真正的门槛
很多初学者有个误解,觉得嵌入式开发难在"写代码"。实际上对STM32这种芯片来说,纯C/C++语法层面的难度很低,真正的门槛在工具链和工程结构。你在PC上写个Hello World,编译器帮你把一切后事都处理好了;但在单片机上,你得自己告诉编译器"我的代码放在哪、数据放在哪、栈开多大",然后还要通过烧录器把生成的二进制文件真正塞进芯片里。
1.1 代码从文本到芯片内部,到底经历了什么
我从头捋一遍这条链路,你对照着自己的工程看一遍就通了:
第一步:预处理和编译。你写的main.cpp会被arm-none-eabi-g++编译成一个.o目标文件。注意这里的"arm-none-eabi"不是普通的桌面编译器,它针对ARM Cortex-M内核生成指令,而且不带操作系统的标准库,所以很多桌面C++能用的功能在这里是没有的。
第二步:链接。.o文件加上启动文件、链接脚本(.ld文件)、HAL库的静态库,一起交给链接器,生成一个.elf文件。链接脚本很重要,它决定了你的代码段、只读数据段、数据段、堆栈分别被放到芯片内存(Flash和RAM)的哪个地址上。
第三步:格式转换与烧录。.elf文件被转换成.hex或.bin,通过ST-Link或J-Link烧录器,通过SWD接口写进芯片的Flash里。芯片上电后,从0x08000000地址开始执行启动文件里的复位向量,然后调用SystemInit、__libc_init_array,最后进到你的main函数。
你可能会问:这些细节我知道有什么用?举个真实例子。我在带人的时候,经常碰到有人把CubeMX生成的工程在Keil里能跑,换到VSCode + CMake工具链就黑屏。原因往往是链接脚本路径不对,或者启动文件没选对。你如果知道"启动文件负责建立C运行时环境,链接脚本负责内存布局"这件事,排查起来就是分分钟的事,而不是对着黑屏发懵。
1.2 这一套组合就是当前最省心的选择
我建议的工具链是:STM32CubeMX生成初始化代码 + HAL库 + arm-none-eabi-gcc(或clang)+ CMake + VSCode。这套组合的好处是——CubeMX管芯片外设初始化(时钟树、GPIO、串口等),你只需要在生成的工程里添加自己的业务逻辑;CMake管编译和链接;VSCode负责编辑和调试。
有人用Keil也跑得很顺,我没意见。但Keil的工程文件是uvprojx,CMake的构建系统更开放,也更符合C++项目的组织方式。尤其是后面要写类、模板这种稍微现代一点的C++代码,CMake + gcc的组合更顺手,调试信息、编译选项都透明可控。个人观点是尽量别再走老路了,2025年了,新项目没理由守着2005年的IDE。
提示:如果你用的是STM32F103C8T6这种入门芯片,记得先用CubeMX把System Core > SYS > Debug Serial Wire这一项打开,否则烧录一次之后,SWD引脚被复用成GPIO,第二次就烧不进程序了。这个坑我见得太多了,基本属于新手必踩。
2. 第一个程序:LED点亮的C++写法
铺垫完毕,开写。第一个程序不做花活,就是让板载LED闪起来。它虽然简单,但把"看门狗流程"跑完整:CubeMX初始化 -> 类封装 -> 主循环 -> 编译烧录 -> 观察现象。
2.1 先说不写代码的部分:CubeMX里必须做的三件事
打开STM32CubeMX,新建一个工程,选好你的芯片型号(比如STM32F103C8T6)。然后做三件事:
- 在System Core > SYS里,把Debug设为Serial Wire。
- 找到你的板载LED所连接的引脚。最常见的是PC13(很多小系统板上的LED都在这),也有一部分板子在PA1或PB0,具体看原理图。设为GPIO_Output。
- 在Project Manager里设置编译器为STM32CubeIDE或Makefile(如果你用CMake,就选Makefile,再用STM32CubeMX自带的Gnerate Code生成Makefile后转换,或者直接用CubeMX生成的工程配合CMake工具链)。
生成代码之后,打开工程,找到Core/Src/main.c。CubeMX生成的是C文件,但我们是C++之旅,所以第一步是把main.c改成main.cpp——需要注意同时改CMakeLists里对main的引用。如果你用STM32CubeIDE,直接把源文件后缀改成.cpp,IDE会自动重新扫描。
2.2 点灯代码:从C风格函数到C++类的第一步
先看CubeMX生成的核心代码长什么样。main函数里有一大段外设初始化,我们不用动,关键是这个部分:
int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); while (1) { HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin); HAL_Delay(500); } }这段代码能跑,但它是个"过程式"写法:LED引脚定义、翻转操作、延时逻辑全在一个循环里揉着。这在小项目里没问题,但如果你后面又加了按键、串口、传感器,这个while(1)会膨胀成一个几千行的"意大利面条"。所以从第一个程序开始,我就建议你养成"用类把外设封装成对象"的习惯。
// led.hpp #pragma once #include "stm32f1xx_hal.h" class Led { public: Led(GPIO_TypeDef* port, uint16_t pin, bool activeHigh = true) : port_(port), pin_(pin), activeHigh_(activeHigh) { HAL_GPIO_WritePin(port_, pin_, activeHigh_ ? GPIO_PIN_RESET : GPIO_PIN_SET); } void on() { HAL_GPIO_WritePin(port_, pin_, activeHigh_ ? GPIO_PIN_SET : GPIO_PIN_RESET); } void off() { HAL_GPIO_WritePin(port_, pin_, activeHigh_ ? GPIO_PIN_RESET : GPIO_PIN_SET); } void toggle() { HAL_GPIO_TogglePin(port_, pin_); } private: GPIO_TypeDef* port_; uint16_t pin_; bool activeHigh_; };然后在main.cpp里:
#include "led.hpp" Led led(LED_GPIO_Port, LED_Pin, false); // 第三个参数取决于你的LED是低电平点亮还是高电平点亮 int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); while (1) { led.toggle(); HAL_Delay(500); } }这个设计的好处是:以后程序里不管哪里想操作这个LED,不再需要知道它是哪个端口、哪个引脚、高有效还是低有效。你只需要一个led对象,调用on/off/toggle就行。换板子的时候,只改构造那一行,其他代码纹丝不动。这就是封装的意义,也跟我反复强调的"让代码服务于业务逻辑,而不是让业务逻辑迁就寄存器"是同一条思路。
2.3 为什么我建议你从"封装一个LED"开始,而不是直接背寄存器操作
我知道很多人学STM32是从寄存器操作学起的,比如GPIOA->ODR |= GPIO_PIN_13;这样写。这没错,甚至我建议你至少理解一遍寄存器操作——因为HAL库本质上就是寄存器的封装,理解底层能帮你以后排定时器、串口这类复杂外设的问题。
但"理解寄存器"和"所有代码都操作寄存器"是两码事。当你开始用C++写嵌入式的时候,类封装是你绕不开的核心能力。一个LED封装好了,后面超声波测距模块的数据封装、电机驱动的PWM封装、传感器状态机的封装,全是同一套思路。这也是为什么我建议你第一个C++程序就别用裸的HAL_GPIO写循环,而是直接用类。
3. C++到底比C多给了嵌入式什么
写到这儿,肯定有人心里嘀咕:我C也能写出同样的功能,为啥非要拿C++?这是个好问题,我的回答也直白:如果你的项目就几十行代码,C确实够用。但当你开始做稍微复杂一点的系统——多个外设协同、状态机、回调、数据解析——C的"过程式+全局变量"模式就会开始漏风。
3.1 封装与抽象:不是为了好看,是为了降低认知负担
人脑能同时跟踪的变量和状态是有限的,大概7个上下。一个包含UART采集、按键逻辑、OLED显示、电机控制的项目,全局状态量至少有几十个。C语言里你靠什么组织这些?靠命名前缀和头文件散落一地的extern变量。代码一膨胀,改一个引脚定义,全工程搜索引用点就能折腾半天。
C++的类解决的就是这件事:把相关的数据和操作绑在一起,对外只暴露接口。比如上面的Led类,外部代码根本不需要知道port_和pin_的存在。这种"需要改的东西只出现在一个地方"的设计,在项目维护期的价值是几何级的。
再补充一点HAL库本身的特点:STM32的HAL库分几层:LL库(接近寄存器的薄封装)、HAL库(带超时机制、状态机的一层)、以及CMSIS这种ARM内核抽象层。你写C++不是要和这几层对抗,而是在它们之上再建一层属于业务逻辑的抽象。
3.2 模板:让点灯程序从"一个LED"变"一组LED"
C++给嵌入式带来的第二大武器是模板。举个例子,如果你有3个LED,分别在不同端口,C风格写法是:
HAL_GPIO_TogglePin(LED1_GPIO_Port, LED1_Pin); HAL_GPIO_TogglePin(LED2_GPIO_Port, LED2_Pin); HAL_GPIO_TogglePin(LED3_GPIO_Port, LED3_Pin);C++模板可以做得更漂亮:把"引脚的GPIO端口、引脚号、有效电平"全部作为模板参数,在编译期就确定下来,运行时零开销。这才是模板在嵌入式里最迷人的地方——不牺牲性能,却换来类型安全和高度的复用性。
我先解释一下为什么很多嵌入式项目不建议用模板。一个重要原因是模板展开会让代码体积膨胀,Flash小的芯片(比如一些Cortex-M0只有16KB Flash)确实吃不消。但STM32F103这种128KB Flash起步的芯片,只要你不是病态地在每个循环里都实例化超大模板,完全没压力。真正要怕的不是模板,是"不会看编译产物体积"。
3.3 一个模板工程的完整示例:封装多个LED
我写一个用于扩展WPAN通信模块/采用GD32/STM32等主流芯片的板载三色LED的封装思路。核心思路是让模板代替"改构造函数传参"。
// 模板LED版本 template <typename T_GPIO_PORT, uint16_t T_PIN, bool T_ACTIVE_HIGH> class LedT { public: void on() { HAL_GPIO_WritePin(T_GPIO_PORT, T_PIN, T_ACTIVE_HIGH ? GPIO_PIN_SET : GPIO_PIN_RESET); } void off() { HAL_GPIO_WritePin(T_GPIO_PORT, T_PIN, T_ACTIVE_HIGH ? GPIO_PIN_RESET : GPIO_PIN_SET); } void toggle() { HAL_GPIO_TogglePin(T_GPIO_PORT, T_PIN); } }; // 定义三个LED类型 using LedRed = LedT<GPIOC, GPIO_PIN_13, false>; using LedGreen = LedT<GPIOA, GPIO_PIN_1, true>; using LedBlue = LedT<GPIOB, GPIO_PIN_0, true>;注意这里没有构造函数,没有成员变量。引脚信息全部编译期固定,最终生成的机器码跟直接调用HAL_GPIO_TogglePin是几乎一样的。这种零成本抽象,桌面开发里你很少会考虑,但在嵌入式里它就是"正常操作"。
3.4 必须主动放弃的东西:异常、RTTI、动态内存
给嵌入式C++泼三盆冷水,这三样在单片机上通常都是毒药:
异常(exception)。arm-none-eabi-gcc默认确实能开异常,但开了之后,代码体积、栈内存需求都会明显上涨,而且异常机制本身依赖运行时库的复杂支持。在资源受限的单片机上,用返回值或错误码就够了,大型工程基本会主动禁用异常。我建议的做法是:编译选项加-fno-exceptions,从根源上断掉这个念头。
RTTI(运行时类型识别)。typeid和dynamic_cast依赖类型信息表,同样会让代码体积变大,性能也有损耗。嵌入式C++项目基本都会在编译时加上-fno-rtti。
**动态内存(new/delete,以及malloc)。**嵌入式里的动态内存是个大坑。STM32的RAM本来就几十KB到几百KB,堆和栈必须有个总量控制。你new一块内存,忘记delete,过一会儿就堆溢出;内存碎片化之后,再大的堆也分配不出连续块。我见过太多"跑着跑着就HardFault"的新手项目,最后定位到就是滥用malloc。
不是说完全不能用动态内存,而是你得明确自己的策略。我在小项目里的习惯是:尽可能静态分配——在编译期就把所有缓冲区的大小定死。比如串口接收缓冲直接是uint8_t rx_buf[128],这件事在工程上比"灵活"更重要,因为嵌入式系统追求的是确定性和可预测性。
3.5 静态成员函数、命名空间和constexpr:让代码自带文档
C++在嵌人式里的价值还体现在这几个关键词上:
**命名空间(namespace)**可以防止外设驱动和业务代码的符号撞车。比如你同时用了两个传感器库,都定义了一个init()函数,如果没有命名空间,链接时可能直接报重名错误。如果项目里不同来源的代码比较多,还是建议从一开始就写上命名空间,别等到冲突了再改。
constexpr可以把一些"看起来像运行时的计算"挪到编译期。比如你把等待循环的延时参数定义为constexpr uint32_t kDelayMs = 200;,编译器就能在编译时算出相关的周期数,运行时就少一层间接性。这个习惯对嵌入式代码帮助很大,因为它把"魔法数字"消灭掉了,同时不损失一点点性能。
static成员函数则是一类"不需要对象实例就能调用"的函数。这点在嵌入式里特别重要,后面讲中断回调的时候会详细展开。
这里我也顺手回答一个很多人的疑问:用C++写STM32,和用C写STM32,代码能混着用吗?答案是能。STM32的HAL库本身就是C写的,你只要在C++文件里包含C的头文件时,用extern "C"包一下就行。CubeMX生成的很多文件也是C,你完全可以保留它们,只在自己的业务代码里用C++。实际工程中"C代码库+C++业务层"的混合模式其实非常常见,不用纠结谁取代谁的二元对立。
4. 中断与定时器:嵌入式的"事件驱动"在C++里怎么做
点灯只是开胃菜。如果你想让LED"按自己的节奏"闪烁,而不是在while循环里一遍遍延时,那就要接触嵌入式里最核心的家伙:中断,以及由中断驱动的定时器。这也是我特别想聊清楚的部分——很多学了C++语法的人卡在这一步,因为中断和C++的"对象模型"之间天然有一种微妙的关系。
4.1 为什么说中断是嵌入式的分水岭
一句话概括中断:CPU正在执行主循环,外部或内部事件触发了一个信号,CPU立刻停下当前工作,保存现场,跳到一个专门的函数里执行,处理完再回到原来的地方继续干。
那这跟"轮询"有什么区别?轮询是主循环一遍遍问"好了没有?";中断是"你不用问,好了我会主动告诉你"。对单片机来说,这直接决定了CPU的利用率和响应速度。比如一个串口接收任务,轮询模式下你一边干别的活一边隔几微秒就得查一次状态位,效率极低;中断模式下,数据一来,程序自动进回调函数,把数据收进缓冲区,主循环该干嘛干嘛。
STM32的定时器(Timer)就是这个"每隔一段时间产生一次中断"的外设。比如定时器配置成1秒溢出一次,那它每秒会触发一次更新中断,你可以在中断回调里翻转LED。这样主循环完全空出来了,可以做别的事——这就是所有"无延时闪烁"例程的原理。
4.2 HAL库的中断回调机制:C语言里的"虚函数"
HAL库处理中断的方式,是提供一个弱符号函数让你覆盖。以定时器为例,CubeMX里把TIM2配成500ms中断一次,然后在代码里写:
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM2) { HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin); } }这个HAL_TIM_PeriodElapsedCallback就是一个"空壳函数",你重写它,中断发生时HAL库就会调用你的版本。如果熟悉C++的面向对象,你会发现这非常像"虚函数覆盖"——基类留一个默认实现,派生类重写它。HAL库用C语言的弱符号机制模拟了这个效果。
但这里有个C++的坑:中断函数是C语言约定,而C++的成员函数默认带this指针,函数签名跟C的回调完全不同。换句话说,你不能直接写一个TimerCallback类的普通成员函数,指望HAL库去调用它。这就是我为什么要单独开一节来讲中断与C++的配合方式。
4.3 用类的静态成员函数包装中断回调
让类的成员函数成为中断回调,最标准也最简洁的做法是:用静态成员函数作为"桥梁"。静态成员函数不依赖对象实例,它的函数指针能和C回调完全兼容。做法如下:
class Blinker { public: explicit Blinker(TIM_HandleTypeDef* htim, Led& led) : htim_(htim), led_(led) {} void start() { HAL_TIM_Base_Start_IT(htim_); // 开启定时器中断 } // 这个静态函数会注册给HAL库调用 static void onTimerElapsed(TIM_HandleTypeDef* htim) { // 我们需要从htim推导出对应的Blinker实例 instance_->handleTick(htim); } static void setInstance(Blinker& inst) { instance_ = &inst; } private: void handleTick(TIM_HandleTypeDef* htim) { if (htim->Instance == htim_->Instance) { led_.toggle(); } } TIM_HandleTypeDef* htim_; Led& led_; static Blinker* instance_; // 类的静态指针,指向当前实例 };在使用时:
Led led(LED_GPIO_Port, LED_Pin, false); Blinker blinker(&htim2, led); Blinker::setInstance(blinker); extern "C" void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef* htim) { Blinker::onTimerElapsed(htim); } int main(void) { // 初始化外设... blinker.start(); while (1) { /* 主循环空出来做别的 */ } }这样做的好处是:中断触发后,最终会进到blinker这个对象的handleTick成员函数里,业务逻辑全部封装在类中,全局只保留一个必要的静态指针。如果你的工程里只有一个这种中断,一个静态指针足够;如果好几种中断都要往不同对象分发,可以建一个分发表(比如用数组把实例存起来),或者用"继承+纯虚函数"的方式写一个中断基类,上层类各自实现自己的handleTick版本。
我自己很推荐"**静态函数作为跳板 + 指向实例的空指针/数组"**这套模式,它是我在实际项目里用得最多的中断回调封装方案。它老派、直接、可靠,不比用标准库的std::function更花哨,但胜在简单可控——而简单可控在嵌入式调试里就是性价比最高的选择。
4.4 中断函数里用C++,要格外小心这几条
中断回调跟普通函数不一样,它对"确定性"要求极高。下面几条是我踩过坑后才总结出来的铁律,写进代码注释里都不为过:
**第一,不要用new/malloc。**中断里做内存分配,一旦堆状态不确定,轻则卡住,重则崩溃。同样的道理也适用于printf这类重活——如果非要打印调试信息,确保串口发送逻辑是DMA驱动的。
**第二,不要在中断里挂起等待。**HAL_Delay本质是个忙等循环,在中断里调用它,等于把整个系统的"及时响应能力"封死了。我在带项目的时候,花了很多时间纠正新人"出了中断不会干活"的习惯。中断里做的事情应当极短:置标志位、拷贝数据、调一个简单的回调,剩下的重活全部挪到主循环。
**第三,善用volatile。**在中断里被修改、又在主循环里被读取的变量,必须加上volatile修饰,否则编译器可能把这个变量优化到寄存器里,导致主循环永远看不到中断里的新值。当你发现"明明置了标志位,主循环就是没反应"的时候,先检查volatile。
第四,关中断的时候要有CTII临界区的概念。如果你的主循环和中断会同时操作同一个变量(比如一个缓冲区计数),你得用临界区保护。HAL库提供了__disable_irq()和__enable_irq()这对原语,但注意关中断的时间要极短,否则系统实时性就被破坏了。
这条就足以回答很多人的疑问:"嵌入式C++和桌面C++最大区别是什么?"——嵌入式编程中的代码不是跑在一个资源无限的抽象环境里,它跑在一个256字节的栈和128KB的Flash上,任何一行代码都要考虑时间与空间成本。
4.5 事件标志位模式:中断与主循环协作的经典套路
如果你的项目还比较小,我建议你先别急着搞复杂的线程或调度。最简单的协作模式就是"中断置标志,主循环查标志"。比如你要做"每500ms通过串口发送一次温湿度数据",可以用一个全局volatile uint32_t g_tick_flag = 0;。定时器中断里把它置1;主循环里发现它变成1,就清零并执行串口发送。这个模式写起来简单,调试也直观,我自己的不少项目都是这个底子。
volatile uint32_t g_tick_flag = 0; // 中断回调 extern "C" void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef* htim) { if (htim->Instance == TIM2) { g_tick_flag = 1; } } // 主循环 while (1) { if (g_tick_flag) { g_tick_flag = 0; // 执行逻辑 } }5. 实测中容易踩的坑和我的解决方式
最后这部分没有啥循序渐进的安排了,纯粹是我自己从"能编译"到"能稳定跑"之间撞过的墙。列出来,你在实战里撞见一个回来看一眼就行。
5.1 C和C++混合编译时的符号冲突
CubeMX生成的工程里,几乎所有外设初始化函数都用C写的。你把main.cpp加入工程之后,如果直接在C++文件里#include "stm32f1xx_hal.h",有些C库头文件会跟C++的语法起冲突——最常见的是volatile和struct混用的命名冲突,以及C语言的NULL宏跟C++的nullptr理念不一致。
解决方式其实不复杂:在C头文件的包含处包上extern "C"。CubeMX生成的main.h是C头文件,你在C++文件里这样写:
extern "C" { #include "main.h" #include "stm32f1xx_hal.h" }有些头文件会自动处理自己是不是C++环境(用#ifdef __cplusplus判断),那就更省事了。但封装好自己的习惯,永远没错。很多看起来"工程编译不过"的报错,其实都是这个细节。
5.2 烧录第二次就失败:SWD引脚被复用
前面第一节提了一嘴,这里再展开一次。CubeMX默认生成的工程,调试接口那个选项如果不设成Serial Wire,STM32F103的PA13/PA14这两个SWD引脚可能被初始化成GPIO。第一次烧录成功,是因为芯片出厂默认SWD是开启的;程序跑起来后,配置把引脚改了,第二次ST-Link就连不上了。
排查方法也很简单:长按复位让程序不跑,或者用ST-Link的connect under reset模式。但根治的办法就是在CubeMX里把SYS->Debug改为Serial Wire。这事我刚开始带项目时几乎每周都有人问一次。
5.3 点灯没反应:先别怀疑代码,先查这三件事
很多新手第一次点灯失败,代码本身往往是编译通过的,但板子就是不亮。我的排查顺序是:
- 供电:VCC和GND必须都接好,3.3V不是所有板子都能从USB口直接供出来的。用万用表量一下芯片的VDD引脚电压,至少要有3.2V以上。
- 芯片第一脚的方向/引脚跟原理图对不上:小系统板的LED不一定接PC13,很多STM32F103C8T6最小系统板的LED在PC13,但也有一些扩展板在PA1、PB0等位置。不确认的话,翻一下板子原理图,确认引脚号与CubeMX里的配置一致。如果你放大板子看芯片上的“第一脚”小圆点,顺着它数一数引脚编号,也能帮你建立基本的"引脚坐标系"概念。
- 用示波器或万用表测GPIO电平是否翻转:如果代码逻辑是翻转,电平却不动,那问题多半在CubeMX的引脚模式配置,或HAL库初始化时序。
这三点排查完,90%的新手点灯问题都能解决。剩下的概率是芯片本身坏了,或者你没选对芯片型号导致工程和外设地址不匹配。
5.4 HAL_Delay和SysTick的微妙关系
HAL库里最常用的HAL_Delay,底层依赖SysTick定时器。CubeMX默认会把SysTick配成1ms中断一次,HAL_Delay就是靠这个中断计数的。但这里有个容易困惑的点:如果你自己在某个中断里调用HAL_Delay,而那个中断的优先级比SysTick还高,那么SysTick中断会被卡住,HAL_Delay就永远不会返回。症状就是"程序卡死在中断里",而且很难查。
解决方式:要么别在中断里用HAL_Delay,要么重新设计中断优先级,保证SysTick的优先级比任何会调用延时的中断都要高。这也是为什么我在4.4里说"中断要短"的原因之一。
5.5 编译选项:从默认优化到-O2以及调试体验
很多人换了VSCode + GCC工具链之后,发现Debug断点不生效,或者变量看不了。这通常是因为没设置调试信息选项。建议在CMakeLists或者编译命令里加上-Og -g3。如果追求性能再切到-O2,但注意优化级别高了之后,一些变量会被优化掉,调试器里看到的"实时值"可能不是真实的。我一般调试阶段用-Og,确认没问题后切-O2跑性能测试。这也是嵌入式开发的常态——编译选项本身就是一次工程权衡。
5.6 一个更进阶的坑:浮点数和printf的重定向
如果你用的芯片不带FPU(比如F103C8T6的M3内核),那所有浮点运算都是用软件模拟的,速度慢,而且printf打印浮点数时,如果没有重定向_write函数,串口输出可能为空或乱码。我在项目里常用的做法是:自定义printf重定向,或用itoa方式先把浮点转成整数再拼字符串。在C++里,也可以封装一个FmtToBuffer的小函数,避免每次print浮点都要带一整套标准库开销。
这个坑不一定会踩,但迟早会碰上。留个印象就好:嵌入式里的IO重定向(printf的底层_write/_read),是C标准库和硬件串口之间的一块自留地,每换一个工具链就可能要重新实现一次。
最后再分享一个小技巧
我自己的学习习惯是:每实现一个功能,就顺手写一个"自问自答"清单。比如写完LED封装,我会问自己"如果换一块板子,我需要改几行代码?"如果答案是"只改一行构造参数",那说明抽象到位了;如果答案是"到处都要搜LED_GPIO_Port改",那说明封装还得重构。
我第一次用C++写STM32点灯的时候,其实也没有想明白"为什么一定要用类封装"。直到后来项目里同时接了4个超声波模块、2路电机、1块OLED、还有串口通信,我发现那些用过程式C写的部分改一个引脚就要全局搜索、心惊胆战,而用C++类封装的部分只需改一行构造参数时,才真正意识到了前几篇那些"看似磨叽的铺垫"的价值。
下一篇如果你还想继续往下走,我建议是把串口接收做成一个EventBus式的分发机制,或者用模板把多个引脚的GPIO操作再收敛一层。方向很多,但关键是先把你手里的板子玩熟——代码跑起来的那一刻,比看十篇教程都顶用。