STM32踩坑记:中断延时卡死、调试口锁死与Flash数据丢失的教训
2026/9/9 2:54:29 网站建设 项目流程

学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时,如果外部中断的优先级高于SysTick,SysTick就永远抢不到CPU,tick不再增长,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_Delay,tick也能正常增长。但副作用是会影响所有依赖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引脚配置有四种模式:全功能SWJ(JTAG+SWD)、禁用JTAG保留SWD、禁用SWD保留JTAG、全部禁用。每个宏对应一种模式,选错了代价就是下载器连不上芯片。

2.2 为什么这是“越熟练越容易犯”的典型

这个坑的讽刺之处在于,新手基本不会踩。新手用开发板,下载方式都是默认的,引脚分配也是例程里的,根本不会去动这些调试引脚。等你开始自己画板子、自己做项目,IO资源紧张了,才会想着释放调试引脚,这时候才暴露问题。

而且F1手册里“JTAG-DP”和“SW-DP”两个配置看着非常像,一个不留神就写错了。用HAL库的朋友同样有这样的风险:在GPIO_InitTypeDef里把PA15配置成复用推挽输出时,如果不小心把SWDIO/SWCLK对应的引脚也加进了初始化列表,一样可能导致调试口失效。

2.3 引脚复用真正的坑:外设互抢和AFIO时钟

调试口只是引脚的坑之一。还有一个非常隐蔽的翻车点:两个外设把一个引脚配置成了不同功能。举个例子,USART1的TX在PA9,TIM1的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模式。

  1. 把BOOT0引脚拉高,重新上电。
  2. 芯片进入系统存储器模式,此时Flash里的用户程序不运行。
  3. 用串口/USB方式连接芯片,通过ISP工具把Flash整片擦除。
  4. BOOT0拉回低电平,重新上电,现在SWD口就恢复了。

如果你用的是ST-Link,还可以试试ST-Link Utility里的“Connect under reset”模式,有时候能救回来。但最稳妥的办法是在硬件设计时就预留BOOT0跳线,任何情况下都能靠ISP救回来。这个习惯救过我很多次。

3. Flash掉电保存:会读会写不算会,擦除和寿命才是分水岭

3.1 Flash的物理特性决定了“写”不是随便写

做项目做到一定阶段,都会想做参数掉电保存。STM32内部Flash本身就是非易失存储,很多人第一反应就是:数据直接存Flash里不就行了。但Flash的物理特性决定了它跟RAM完全是两回事,有三条铁律必须刻在脑子里:

  1. 写操作只能把1写成0。
  2. 擦除操作把整个扇区/页恢复成全1。
  3. 所以在覆盖写“老数据”之前,必须先擦除整个扇区/页。

举个例子,一个地址里存的是0x55,你想改成0x33。如果你不擦除直接写,结果不是0x33,而是0x11。因为0x55是01010101,0x33是00110011,按位与之后得到00010001也就是0x11。数据不对,但程序不报错,你读出来只会觉得莫名其妙。

3.2 一个掉电保存的典型翻车现场

我之前做一个设备,需要在运行中修改参数并掉电保存。第一次写入完全正常,但运行中改了参数再写,读出来要么全是0xFF,要么直接HardFault。

排查链路很典型:

  1. 先怀疑地址问题,检查了FLASH_BASE到FLASH_END的范围,地址没错。
  2. 然后看代码,发现用的是标准库的FLASH_ProgramWord,写完以后没有等待BSY标志就立刻读,读到的还是旧值。
  3. 继续查,发现第二次写入之前根本没有做擦除操作,数据被“写坏”了。
  4. 最离谱的是有一次擦除时用错了扇区号,直接擦到了代码区,程序当场跑飞,HardFault。

正确的写入流程应该是:

  1. 解锁Flash(FLASH_Unlock)。
  2. 检查目标地址是否已擦除(读出来是否全0xFF)。
  3. 如果不是,先擦除整个扇区。
  4. 写入数据。
  5. 等待BSY标志清空。
  6. 读回校验。

这一步一步都不能省,每省一步,后面都有一个坑等着你。

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个块,每个块带一个序号,每次写入选择“序号最大且有效”的块的下一个块,所有块写满后再擦除最旧的块,循环使用。以N=4为例,每个块的擦写次数相当于被分散到4个块上,整体寿命放大4倍。N越大放大倍数越大,但管理逻辑也越复杂,需要根据实际写入频率和寿命要求权衡。

如果你的项目写入频率很高,我的建议是直接外挂EEPROM或者铁电存储器,不要在内部Flash里硬扛。EEPROM虽然容量小,但擦写寿命是百万次级别,而且按字节擦写,管理逻辑简单得多。内部Flash适合存配置参数这类写入不频繁的数据,不适合做高频数据记录。

4. 把“防御性思维”变成习惯

4.1 我现在的自检清单

被这几个坑反复教育之后,我现在每写完一个模块,都会过一遍自检清单:

  • 中断服务函数里有没有阻塞调用?有没有HAL_Delay、自定义延时、串口发送等待?
  • 如果中断里必须要延时,用的是DWT还是定时器?SysTick优先级配置是什么?
  • 引脚复用表有没有冲突?两个外设有没有占用同一个引脚?
  • 调试口引脚有没有被误配置成普通IO?SWD有没有被禁用?
  • Flash写入前有没有确认已擦除?地址对齐了吗?写完有没有等BSY?
  • 掉电保存有没有做双备份和校验?有没有考虑写一半断电的场景?
  • 低功耗唤醒后,时钟配置和外设状态有没有恢复?

这些问题看着琐碎,但每一个都是我用实际项目换来的教训。

4.2 调试三板斧

踩坑多了,我也总结了一套排查问题的三板斧,分享给大家。

第一板斧:示波器看波形。现象永远比代码可靠。怀疑中断没进,直接量引脚电平变化;怀疑延时不准,直接量PWM波形周期。示波器不会骗人。

第二板斧:串口打印关键变量。程序跑飞之前,通过串口把关键状态、计数器、标志位打出来,时序关系一目了然。特别是在查中断嵌套死锁这类问题的时候,打印日志能帮你还原程序最后走过的路径。

第三板斧:仿真器单步跟踪,配合反汇编。HAL库封装得很厚,有时候你在C语言层面看不出问题,但打开反汇编窗口,一眼就能看到程序卡在哪个循环里。我之前查HAL_Delay卡死,就是靠反汇编确认的。

4.3 给学了一段时间的你一个建议

说到底,这三类坑有个共同点——它们都不是“不会”造成的,而是“以为自己会了”造成的。每用一个库函数,多问一句“它底层依赖什么中断、什么时钟、什么外设”,比记住再多的API都有用。把每次踩坑的记录整理成自己的清单,下次做项目之前翻一遍,很多问题根本不会发生。我现在每写一段中断服务函数,都会先问自己一句:这里会不会阻塞?

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询