
学STM32有个特别有意思的现象刚入门那会儿每一步都小心翼翼翻数据手册、看参考例程反而极少出事反倒是玩了一两年、觉得自己什么都懂了之后三天两头把自己坑得怀疑人生。我这些年遇到最离谱的几个问题——中断里调用延时函数导致系统直接卡死、为了省引脚把调试口禁了结果下载器找不到芯片、Flash写数据写着写着整片数据全没——全都是典型的“学得越久越容易踩”的坑。这篇文章不聊怎么点亮LED不谈怎么跑个串口专门聊聊我在这三个方向上完整的踩坑过程、排查思路和最终解决方案。适合已经学过一段时间STM32、开始自己画板子做项目的朋友也适合正准备从标准库切到HAL库、或者从F103往F4/H7迁移的同学。以下内容全部来自实际项目不是抄手册。1. 中断里的HAL_Delay越觉得“没事”就越容易卡死1.1 一个电机项目的翻车现场去年帮朋友调一台两轮差速小车STM32F103控制两台直流减速电机编码器反馈测速。为了读编码器把A相信号接在外部中断上在中断服务函数里做了一点简单的消抖处理——进中断后先延时5ms再读一次电平确认。当时想得很简单不就加个延时嘛消抖嘛哪儿有那么娇气。结果电机一转系统就随机卡死。不是那种偶发性的小卡顿是彻底死机连看门狗都救不回来。看门狗在主循环里喂卡死的时候喂狗函数根本执行不到最后只能断电重启。一开始怀疑是电机干扰导致程序跑飞加了各种滤波电容、改了布局、换了光耦隔离问题依旧。折腾了两天最后用调试器暂停住卡死的现场才发现PC指针停在HAL_Delay的while循环里出不来调用栈往回翻是从外部中断服务函数进去的。那一刻我才意识到问题根本不在硬件干扰而在软件逻辑。1.2 为什么HAL_Delay会在中断里死循环要理解这个坑得先看HAL_Delay的实现。HAL库里的HAL_Delay本质上就是一个死等循环__weak void HAL_Delay(uint32_t Delay) { uint32_t tickstart HAL_GetTick(); uint32_t wait Delay; while ((HAL_GetTick() - tickstart) wait) { } }这里的HAL_GetTick()返回的是uwTick变量而这个uwTick是在SysTick中断服务函数里累加的。也就是说HAL_Delay的正常运行依赖SysTick中断周期性触发。问题就出在SysTick的优先级上。STM32的NVIC默认配置下SysTick中断优先级是15也就是所有可编程中断里最低的那一档。当你在外部中断比如EXTI里调用HAL_Delay时如果外部中断的优先级高于SysTickSysTick就永远抢不到CPUtick不再增长while循环里的条件永远不成立于是程序就死在那里。更隐蔽的是中断嵌套死锁。假设有两个中断一个优先级高一个优先级低两个中断服务函数里都调用了HAL_Delay。高优先级中断先抢进去开始等延时低优先级中断被抢占后它的HAL_Delay还没执行完tick不再增长高优先级中断里的while循环永远等不到tick更新于是两个中断互相等双双卡死系统完全瘫痪。提示这个问题的本质不是“延时函数不能用”而是“阻塞型延时占据了CPU时间片而它依赖的系统时基中断恰恰可能被自己阻塞”。1.3 为什么新手反而不容易踩这个坑说实话这个坑最大的特点是新手反而不容易踩。刚学的时候大家都不敢在中断里写复杂代码中断服务函数里就置个标志位具体逻辑全放主循环。反而学了一段时间之后胆子大了觉得“中断里加个延时不就这么点事”结果一踩一个准。还有一个原因很多教程为了演示方便直接在中断服务函数里调用HAL_Delay做按键消抖、做超声波测距的时序控制。看多了自然觉得“这么写应该没问题教程都这么写的”。但实际上这些教程很多只考虑了单一中断的场景根本没有覆盖多中断优先级嵌套的情况。再加上有些人从F103标准库切到F4/H7的HAL库SysTick优先级配置方式变了HAL库版本之间也有差异更容易在不经意间埋雷。1.4 中断里延时到底该怎么办我在这个项目之后彻底改掉了中断里直接调HAL_Delay的习惯。替代方案大概有四种按我的推荐程度排一下。方案一调高SysTick中断优先级。把SysTick的优先级设成0最高这样即使在高优先级中断里调用HAL_Delaytick也能正常增长。但副作用是会影响所有依赖HAL_GetTick的机制比如HAL库的各种超时判断、HAL_UART_Receive的超时退出优先级调得过高可能导致这些超时逻辑不准。方案二用基本定时器做一个独立的延时函数。TIM6/TIM7这种基本定时器不占用外部引脚专门做时基非常合适。写一个不依赖SysTick的延时函数在中断里调用短延时绰绰有余。方案三用DWT精确延时。这是我在F103和F407上都验证过的方案利用Cortex-M内核的DWT-CYCCNT寄存器每个CPU周期加1可以实现微秒级精确延时不依赖任何外设中断void DWT_Delay_Init(void) { CoreDebug-DEMCR | CoreDebug_DEMCR_TRCENA_Msk; DWT-CYCCNT 0; DWT-CTRL | DWT_CTRL_CYCCNTENA_Msk; } void DWT_Delay_us(uint32_t us) { uint32_t start DWT-CYCCNT; uint32_t ticks us * (SystemCoreClock / 1000000); while ((DWT-CYCCNT - start) ticks); }方案四彻底改成非阻塞逻辑。中断服务函数里只置标志位主循环或者状态机根据标志位做延时处理。这个方案治本但改造成本最高。我的建议是短延时用DWT或者基本定时器长延时和复杂时序逻辑尽量改成状态机别在中断里死等。2. 从引脚复用开始改一次配置下载器就找不到芯片了2.1 禁用JTAG的“真香”与“真险”STM32F103的PA15、PB3、PB4默认是JTAG调试引脚。很多人在项目里资源吃紧打着这几个引脚的主意反正我用SWD下载JTAG用不上不如把这些引脚释放出来当普通IO用白赚三个IO想想就很爽。标准库里的写法是这样的GPIO_PinRemapConfig(GPIO_Remap_SWJ_JTAGDisable, ENABLE);这个宏只禁用JTAG保留SWD。问题出在很多人记不住这个宏名或者图省事直接写了另一个GPIO_PinRemapConfig(GPIO_Remap_SWJ_Disable, ENABLE);GPIO_Remap_SWJ_Disable表示JTAG和SWD全部禁用。程序下载进去的瞬间板子上的SWD口就彻底废了。下次你用ST-Link连板子直接报出经典错误“Error: No STM32 target found! If your product embeds Debug Authentication, please...”——这就是热搜词里那个报错的来源。你得知道STM32F1的SWJ引脚配置有四种模式全功能SWJJTAGSWD、禁用JTAG保留SWD、禁用SWD保留JTAG、全部禁用。每个宏对应一种模式选错了代价就是下载器连不上芯片。2.2 为什么这是“越熟练越容易犯”的典型这个坑的讽刺之处在于新手基本不会踩。新手用开发板下载方式都是默认的引脚分配也是例程里的根本不会去动这些调试引脚。等你开始自己画板子、自己做项目IO资源紧张了才会想着释放调试引脚这时候才暴露问题。而且F1手册里“JTAG-DP”和“SW-DP”两个配置看着非常像一个不留神就写错了。用HAL库的朋友同样有这样的风险在GPIO_InitTypeDef里把PA15配置成复用推挽输出时如果不小心把SWDIO/SWCLK对应的引脚也加进了初始化列表一样可能导致调试口失效。2.3 引脚复用真正的坑外设互抢和AFIO时钟调试口只是引脚的坑之一。还有一个非常隐蔽的翻车点两个外设把一个引脚配置成了不同功能。举个例子USART1的TX在PA9TIM1的PWM输出通道也想用PA9。两个外设的初始化代码单独看都没问题但后初始化的外设会把前一个的引脚配置覆盖掉。从逻辑上完全看不出来哪里错了直到某个功能莫名其妙不工作。我现在的习惯是画原理图之前先把所有外设的引脚占用列一张表逐个核对有没有冲突。F1系列的重映射还有一个必须注意的点使用部分重映射或完全重映射功能时必须开启AFIO时钟否则配置不生效。标准库对应RCC_APB2PeriphClockCmd(RCC_APB2Periph_AFIO, ENABLE)HAL库对应__HAL_AFIO_REMAP_xxx这类宏很多从标准库转HAL库的朋友会漏掉这一步。2.4 IO驱动能力20mA不是拿来带继电器的热搜词里“stm32 io驱动能力”一直热度不减因为这个问题几乎人人都会遇到。GPIO在推挽输出模式下单个IO的驱动能力大概在十几到二十几毫安级别具体看数据手册。这个电流驱动个LED绰绰有余但直接驱动继电器线圈、大功率蜂鸣器、电机驱动芯片的使能脚就完全不够看了。我见过不少朋友自己画板子时为了省几个三极管直接把继电器接到IO口上。结果继电器吸合不稳定、IO口被拉低、芯片发烫甚至引脚直接烧掉。正确的做法是IO口只输出控制信号用三极管或者ULN2003做功率驱动感性负载必须并续流二极管。注意开发板上一般都有驱动电路直接照着开发板抄不容易发现问题。等你开始自己画板子时反而容易把驱动电路省掉这就是“学得越久越容易犯”的根源——你以为你懂了其实你只是看过。2.5 锁死自救指南万一你已经把程序烧进去了调试口被关了怎么办别急还有一条路ISP模式。把BOOT0引脚拉高重新上电。芯片进入系统存储器模式此时Flash里的用户程序不运行。用串口/USB方式连接芯片通过ISP工具把Flash整片擦除。BOOT0拉回低电平重新上电现在SWD口就恢复了。如果你用的是ST-Link还可以试试ST-Link Utility里的“Connect under reset”模式有时候能救回来。但最稳妥的办法是在硬件设计时就预留BOOT0跳线任何情况下都能靠ISP救回来。这个习惯救过我很多次。3. Flash掉电保存会读会写不算会擦除和寿命才是分水岭3.1 Flash的物理特性决定了“写”不是随便写做项目做到一定阶段都会想做参数掉电保存。STM32内部Flash本身就是非易失存储很多人第一反应就是数据直接存Flash里不就行了。但Flash的物理特性决定了它跟RAM完全是两回事有三条铁律必须刻在脑子里写操作只能把1写成0。擦除操作把整个扇区/页恢复成全1。所以在覆盖写“老数据”之前必须先擦除整个扇区/页。举个例子一个地址里存的是0x55你想改成0x33。如果你不擦除直接写结果不是0x33而是0x11。因为0x55是010101010x33是00110011按位与之后得到00010001也就是0x11。数据不对但程序不报错你读出来只会觉得莫名其妙。3.2 一个掉电保存的典型翻车现场我之前做一个设备需要在运行中修改参数并掉电保存。第一次写入完全正常但运行中改了参数再写读出来要么全是0xFF要么直接HardFault。排查链路很典型先怀疑地址问题检查了FLASH_BASE到FLASH_END的范围地址没错。然后看代码发现用的是标准库的FLASH_ProgramWord写完以后没有等待BSY标志就立刻读读到的还是旧值。继续查发现第二次写入之前根本没有做擦除操作数据被“写坏”了。最离谱的是有一次擦除时用错了扇区号直接擦到了代码区程序当场跑飞HardFault。正确的写入流程应该是解锁FlashFLASH_Unlock。检查目标地址是否已擦除读出来是否全0xFF。如果不是先擦除整个扇区。写入数据。等待BSY标志清空。读回校验。这一步一步都不能省每省一步后面都有一个坑等着你。3.3 掉电瞬间写入数据坏掉的真正元凶比忘记擦除更隐蔽的是掉电瞬间写Flash。做过带掉电保存功能的产品的人大概率都遇到过这样的需求设备掉电时要把当前状态保存下来。于是你在掉电检测中断里调用Flash写函数结果发现下次上电数据是乱的。原因在于Flash写入需要稳定的供电。STM32内部Flash写入时电荷泵需要一定的电压支撑电源在跌落过程中如果电压已经不足写一半就断电整个扇区的数据都可能损坏。你根本不知道它写到了哪一步也不知道哪些位写成功了、哪些位写失败了。解决思路是双备份加校验加状态标志我用的是这种结构typedef struct { uint32_t magic; // 固定魔数用于判断数据是否有效 uint8_t data[256]; // 业务数据区 uint32_t crc; // 数据区CRC校验 } StorageBlock;存储区划分成两个备份区分别放一个StorageBlock。写入时先擦除备份区写数据最后更新magic和crc。上电读取时检查两个备份区的magic是否有效、crc是否通过哪个区有效就用哪个区的数据。如果两个区都无效说明数据真的损坏了用默认配置启动。3.4 磨损均衡Flash寿命不是无限的还有一个很多人忽略的问题STM32内部Flash的擦写寿命典型值是10K次级别。如果你的设备每秒钟保存一次状态一个扇区几天之内就会报废。所以频繁写入的场景必须做磨损均衡。基本的思路是把存储区拆成N个块每个块带一个序号每次写入选择“序号最大且有效”的块的下一个块所有块写满后再擦除最旧的块循环使用。以N4为例每个块的擦写次数相当于被分散到4个块上整体寿命放大4倍。N越大放大倍数越大但管理逻辑也越复杂需要根据实际写入频率和寿命要求权衡。如果你的项目写入频率很高我的建议是直接外挂EEPROM或者铁电存储器不要在内部Flash里硬扛。EEPROM虽然容量小但擦写寿命是百万次级别而且按字节擦写管理逻辑简单得多。内部Flash适合存配置参数这类写入不频繁的数据不适合做高频数据记录。4. 把“防御性思维”变成习惯4.1 我现在的自检清单被这几个坑反复教育之后我现在每写完一个模块都会过一遍自检清单中断服务函数里有没有阻塞调用有没有HAL_Delay、自定义延时、串口发送等待如果中断里必须要延时用的是DWT还是定时器SysTick优先级配置是什么引脚复用表有没有冲突两个外设有没有占用同一个引脚调试口引脚有没有被误配置成普通IOSWD有没有被禁用Flash写入前有没有确认已擦除地址对齐了吗写完有没有等BSY掉电保存有没有做双备份和校验有没有考虑写一半断电的场景低功耗唤醒后时钟配置和外设状态有没有恢复这些问题看着琐碎但每一个都是我用实际项目换来的教训。4.2 调试三板斧踩坑多了我也总结了一套排查问题的三板斧分享给大家。第一板斧示波器看波形。现象永远比代码可靠。怀疑中断没进直接量引脚电平变化怀疑延时不准直接量PWM波形周期。示波器不会骗人。第二板斧串口打印关键变量。程序跑飞之前通过串口把关键状态、计数器、标志位打出来时序关系一目了然。特别是在查中断嵌套死锁这类问题的时候打印日志能帮你还原程序最后走过的路径。第三板斧仿真器单步跟踪配合反汇编。HAL库封装得很厚有时候你在C语言层面看不出问题但打开反汇编窗口一眼就能看到程序卡在哪个循环里。我之前查HAL_Delay卡死就是靠反汇编确认的。4.3 给学了一段时间的你一个建议说到底这三类坑有个共同点——它们都不是“不会”造成的而是“以为自己会了”造成的。每用一个库函数多问一句“它底层依赖什么中断、什么时钟、什么外设”比记住再多的API都有用。把每次踩坑的记录整理成自己的清单下次做项目之前翻一遍很多问题根本不会发生。我现在每写一段中断服务函数都会先问自己一句这里会不会阻塞