前言

最近发现一个STM32上设备重启后,反复崩溃的问题,最终排查下来发现和FreeRTOS的定时器没有进行规范使用有关系。这里和大家分享下排查过程以及后续的FreeRTOS定时器使用规范。

1.问题描述

碰到了一个增加支持设备的数量后,重启设备反复不停重启的问题,起初我以为是我所设想的栈空间不足等原因,后来经过仔细排查发现实际上和定时器内回调函数执行时间过久有关系,那么执行过久为啥会导致崩溃呢?接下来就开始进行分析了,最终发现是和FreeRTOS的定时器的使用方法有关系。

2 问题排查过程

2.1 栈回溯初步定位问题出现的地方(prvTimerTask)

对PC和LR指针进行分析

在对LR指针进行分析前,我们先了解下对LR指针进行分析的原理。

PC和LR指针的作用

PC指针(程序计数器): 就像你读书时用的手指头👆——它永远指着你当前读到哪一行代码了。CPU执行指令时,PC会自动+1(指向下一条指令),告诉CPU下一步该执行哪里。

LR指针(链接寄存器): 相当于一个"书签🔖"。当程序需要跳去执行子函数时,LR会临时保存"回家的地址"。比如:

  1. 主程序执行到第5行时调用函数A
  2. LR就会记下第6行(返回地址)
  3. 等函数A执行完,CPU就查看LR里的地址跳回来继续执行
  4. 跳转到多级函数时,也是按照123步骤进行的,所以我们可以通过LR指针不断的找到上一级函数,从而打印出整个函数的调用栈信息(例如A->B->C->D)。

栈帧的组成
在STM32(基于ARM Cortex-M内核)中,栈帧(Stack Frame)是函数调用时在内存中形成的结构化数据块,用来保存关键信息,在异常/中断发生时,硬件会自动进行压栈(压到栈指针SP),用于保存上下文。

栈帧偏移 寄存器 位宽 说明
+0x1C R0 32-bit 通用寄存器R0
+0x18 R1 32-bit 通用寄存器R1
+0x14 R2 32-bit 通用寄存器R2
+0x10 R3 32-bit 通用寄存器R3
+0x0C R12 32-bit 通用寄存器R12
+0x08 LR 32-bit 链接寄存器LR(保存异常返回地址)
+0x04 PC 32-bit 程序计数器PC(异常返回后的下一条指令地址)
+0x00 xPSR 32-bit 程序状态寄存器(包含标志位、异常号等)

STM采用的是双栈,对于非操作系统的而言SP指的是MSP,对于操作系统而言SP指的是PSP。

  • MSP(主栈指针):系统默认使用
  • PSP(进程栈指针):RTOS中用户任务使用

问题出现后我通过打印出来的LR指针和xxx.map文件进行了对比,发现崩溃时LR指向的函数为prvTimerTask,但是prvTimerTask本身产生了崩溃还是prvTimerTask调用了定时器回调在定时器回调内产生了崩溃,这点当时不是很确定。
在这里插入图片描述
在这里插入图片描述

对os_back进行分析

除了对LR进行分析,我们还能通过打印出来的os_back(崩溃前的调用栈信息)进行回溯。
在这里插入图片描述

首先将地址信息放到一个addr.txt中
在这里插入图片描述

然后将编译时生成的xxx.axf文件和addr.txt放到一个路径下
在这里插入图片描述

之后在Linux服务器上,使用addr2line进行栈信息回溯(需要提取安装arm的编译工具链),我这里是执行

./toolchain/arm-gnu-toolchain-13.2.Rel1-x86_64-arm-none-eabi/bin/arm-none-eabi-addr2line -e *.axf -a -f -p < addr.txt

在这里插入图片描述

2.2 思考问题出现的原因

1. 是不是和定时器的栈空间不足有关系
毕竟遥控器从4个增加到了8个,那么很有可能定时器内的栈空间不足也会不停的产生崩溃,于是增加了定时器栈空间的大小发现还是崩溃。

2. 是不是和串口打印了什么已经释放的野指针有关系
考虑到串口执行时间比较长,是不是存在一些定时器打印信息时,已经被异步调用的线程将资源释放了的情况,但是这种不好排查,而且觉得方向也不正确就没继续排查。

3. 确认下是否和定时器内回调执行耗时过久有关系
通过将日志屏蔽能够解决问题,让我意识到很有可能和定时器内的回调函数执行时间过长有一些关系,然后我把日志屏蔽,在定时器内增加延时,我发现也会崩溃,所以就初步确定了下和定时器的执行耗时有关系。

4. 确认下定时器执行耗时多久
于是我就在在定时器执行回调调用ApduInfo_MasterInit前后增加时间打印,我发现初始化8个遥控器耗时竟然达到了1200多ms,而将日志打印关闭这个时间来到约900ms,而定时器的执行周期只有50ms

5. 分析为啥定时器执行耗时久会引起崩溃
定时器执行周期50ms, 定时器耗时1200ms时会崩馈,但是900ms时不会崩溃,这让我意思到可能是和FreeRTOS的定时器调度策略有一定的关系,然后我就问了下GPT,给到的回复是定时器消息队列堆积可能会影响,这个时候就需要往FreeRTOS定时器的源码去进行分析了。
在这里插入图片描述

6. 增加定时器的队列大小
增加完队列大小后,发现还是会崩溃,我的队列是从10加到了35。当然现在回头看如果我最终把队列加到100 可能也就不崩溃了,因为确实是定时器累计了太多了,导致队列放不下了,但是最终原因还是定时器累计太多了。

2.3 FreeRTOS定时器源码分析

timers.c 是 FreeRTOS 的软件定时器实现文件,它的主要工作原理是

  • 使用一个专门的定时器服务任务(prvTimerTask)管理所有软件定时器
  • 通过消息队列(xTimerQueue)接收其他任务/中断发送的定时器命令
  • 使用两个链表(xActiveTimerList1/2)管理活动定时器,处理节拍计数器溢出
  • 定时器到期时调用注册的回调函数
  • 配置为自动重载的定时器到期后会重新加载定时值(这个需要关注)

2.3.1 timers.c所提供的功能(AI给出)

1. 主要数据结构:

- Timer_t:定时器控制块结构体,包含:  
* pcTimerName:定时器名称(调试用)  
* xTimerListItem:链表项(用于挂接到活动定时器链表)  
* xTimerPeriodInTicks:定时周期(节拍数)  
* pvTimerID:定时器ID  
* pxCallbackFunction:回调函数  
* ucStatus:状态标志(是否激活/是否静态分配/是否自动重载)

2. 关键链表:

  • xActiveTimerList1 和 xActiveTimerList2:两个活动定时器链表,用于处理节拍计数器溢出
    • pxCurrentTimerList:指向当前活动链表
    • pxOverflowTimerList:指向溢出链表

3. 主要函数功能:

静态函数(内部调用):

注意这里的节拍就是tickCnt
- prvCheckForValidListAndQueue:检查并初始化定时器服务所需的数据结构和队列  
- prvTimerTask:定时器服务任务(守护任务)  
- prvProcessReceivedCommands:处理从定时器队列收到的命令  
- prvInsertTimerInActiveList:将定时器插入活动链表  
- prvProcessExpiredTimer:处理已到期的定时器  
- prvSwitchTimerLists:切换定时器链表(处理节拍计数器溢出)  
- prvSampleTimeNow:获取当前节拍数并检查是否溢出  
- prvGetNextExpireTime:获取下一个到期时间  
- prvProcessTimerOrBlockTask:处理定时器或阻塞任务  
- prvInitialiseNewTimer:初始化新定时器

公共函数:

- xTimerCreateTimerTask:创建定时器服务任务  
- xTimerCreate/xTimerCreateStatic:创建定时器(动态/静态)  
- xTimerGenericCommand:通用定时器命令接口  
- xTimerIsTimerActive:检查定时器是否激活  
- pvTimerGetTimerID/vTimerSetTimerID:获取/设置定时器ID  
- xTimerPendFunctionCall(FromISR):延迟函数调用机制

2.3.2 timers的调度策略

守护线程(portTASK_FUNCTION)
FreeRTOS定时器的调度主要是通过创建的一个守护线程去执行的。下方即为该守护线程所循环调用的几个函数。

该线程大部分时间处于阻塞状态(vQueueWaitForMessageRestricted阻塞),仅当以下情况时会被唤醒

  • 定时器到期
  • 收到新命令
  • 节拍计数器溢出
    在这里插入图片描述

守护线程内的几个重要函数介绍

xNextExpireTime = prvGetNextExpireTime( &xListWasEmpty );
获取下一个即将到期的定时器的到期时间,后续的函数处理会通过这个时间与当前时间来进行比对,判断定时器是否已经到期(Expire),如果到期了则执行该定时器。
在这里插入图片描述

上面英文注释的含义是

定时器按照到期时间顺序排列,链表头指向最先到期的任务。 获取下一个即将到期的定时器的到期时间。如果当前没有活跃的定时器,
则将下次到期时间设为0。这会导致该任务在tick计数器溢出时解除阻塞, 此时定时器列表会被切换,可以重新评估下一个到期时间

prvProcessTimerOrBlockTask( xNextExpireTime, xListWasEmpty );
处理等待阶段,如果有定时器到期则会立即处理,如果无定时器到期,则基于下一个到期时间与当前时间的差值使用vQueueWaitForMessageRestricted()有限等待。

在这里插入图片描述

prvProcessReceivedCommands();
FreeRTOS的定时器有个命令处理机制,我们平时调用的Start/Reset/Stop都会转换为Commander存放在一个叫做xTimerQueue的队列,等待统一进行处理。

prvProcessReceivedCommands该函数会处理所有积压的命令,支持的命令类型包括:

  • 启动/停止定时器
  • 修改定时周期
  • 删除定时器
  • 延迟函数调用

我们本次的问题也主要是出现在该函数中。
函数内首先会在一个while()中检查队列内是否有命令到来(例如启动定时器)
在这里插入图片描述

然后执行对应的指令,这里和START相关的都会先检查下定时器是否到期了,如果到期了则执行定时器回调,同时如果是Reload的情况,则会继续使用上次定时器的执行时间+定时周期时间,判断当下的定时器是否需要继续执行(问题也就出现在这里)
在这里插入图片描述

xTimerGenericCommand**会记录下下一次要执行定时器的时间,并放到队列里,而这个操作又会唤醒
while( xQueueReceive( xTimerQueue, &xMessage, tmrNO_DELAY ) != pdFAIL )

在这里插入图片描述

队列里面有了数据之后,while就又满足了,继续往下走,直到当前定时器的执行时间 > 当前时间
在这里插入图片描述

所以上述流程下,当定时器的回调执行时间过久卡住其它定时器以及该定时器自身时,就会触发反复将定时器加入到队列中进行处理,直到定时器的执行时间 > 当前时间。最终导致队列不足,触发assert。

2.4 增加打印确认问题原因

前面我们怀疑是定时器的回调执行时间过久卡住其它定时器以及该定时器自身时,导致定时函数执行任务反复被调用,这里我们加一些打印试一下。

  1. 在xTimerGenericCommand 中增加一些打印看下是不是由该函数添加命令到队列中的,以及添加的是哪个定时器。
    由于我们有3个执行时间间隔比较短的定时器,分别是50ms/50ms/100ms,为了区分我们将前面两个50ms的分别修改为51ms/52ms

在这里插入图片描述

在这里插入图片描述

  1. 在prvProcessReceivedCommands的while()前面加一段打印,用来确认prvProcessReceivedCommands函数是否有退出,是否是在while()内不停执行。
    在这里插入图片描述

在这里插入图片描述

  1. 增加打印定时器还剩下多少队列信息还有多少栈空间,判断下是不是因为队列和栈空间不足导致的
    在这里插入图片描述

崩溃的时候栈空间和队列的长度还很足,所以应该不是栈空间和队列不足导致的
在这里插入图片描述

  1. 排查是哪个定时器回调产生了崩溃(发现有个1000ms运行一次的定时器,被删除了之后,又重新运行了一次,就是最后的数字0 对应的是启动定时器)

在这里插入图片描述

  1. 查看下这个1000ms定时器内部的实现,其在定时器内部调用了 停止和删除定时器。我把这两部分给屏蔽调就不会崩溃了
    在这里插入图片描述

6. 那么这个1000ms定时器被删除后,为什么会又启动了呢?
前面我们说到过,FreeRTOS的定时器在运行结束后,如果发现配置了reload会检查上次运行的时间+周期时间是否小于当前时间,如果小于那么它会重新创建一个START命令将其加入到队列的尾部

例如以定时A为例子,上次运行时间 0 ,周期1000ms,但是当前有个定时B执行花费了超过2020ms
等到该定时器执行时,当前时间为2025ms. 那么 0 + 1000 < 2025 会执行一次回调(回调内把定时器删除、停止了),然后1000 + 1000 <2025 又会把定时器重新START,但是之前删除定时器时我们的资源已经被释放了。
在这里插入图片描述所以最后的Start后再去执行相当于调用了已经被释放的指针,就崩溃了。
在这里插入图片描述

4 结论

4.1 根本原因分析

本次崩溃的根本原因是定时器回调函数执行时间过长FreeRTOS定时器自动重载机制相互作用导致的异常情况。具体表现为:

  1. 定时器回调执行超时

    • 遥控器初始化定时器回调执行时间长达1200ms(周期仅50ms)
    • 远超定时器设计允许的执行时长(应远小于定时周期)
  2. 自动重载机制缺陷

    • 当存在多个定时器时,FreeRTOS会通过prvProcessReceivedCommands()处理积压的命令
    • 对于自动重载(auto-reload)定时器,系统会检查上次执行时间+周期 < 当前时间时重复触发
    • 在回调执行期间,系统时间仍在流逝,导致单次回调可能触发多次连锁反应
  3. 资源释放后重复调用

    • 特别危险的场景:在1000ms定时器回调中删除自身定时器
    • 由于执行延迟,系统仍会认为该定时器需要重载执行
    • 导致已删除的定时器被重新激活,访问已释放资源引发崩溃

4.2 问题复现流程

50ms定时器回调开始执行
执行时间1200ms?
阻塞定时器守护任务
系统时间持续流逝
其他定时器到期命令堆积
1000ms定时器到期
执行回调并删除自身
系统检查时间补偿
上次执行时间(0)+周期(1000) < 当前时间(2025ms)?
重新激活已删除定时器
访问已释放资源
内存错误崩溃
正常结束
  1. 定时器使用规范

    • 确保回调函数执行时间远小于定时周期(建议<10%周期)
    • 避免在定时器回调中执行耗时操作(如硬件初始化、网络通信等)
    • 需要长时间处理的任务应移交到独立任务处理
  2. 自动重载定时器注意事项

    • 避免在auto-reload定时器回调中删除自身
    • 需要自销毁的定时器应使用one-shot模式
    • 对可能超时的定时器增加执行时间检查
  3. 系统配置优化

    • 合理设置configTIMER_TASK_PRIORITY确保及时响应
    • 监控xTimerQueue的剩余空间作为健康指标
    • 对于高频定时器可考虑合并处理

4.4 经验总结

本次问题揭示了FreeRTOS定时器机制的三个重要特性:

  1. 定时器回调执行时间会直接影响系统稳定性
  2. 自动重载定时器在系统延迟时会产生"追赶效应"
  3. 定时器命令队列可能成为隐藏的瓶颈

建议开发者在设计定时器时始终遵循"短平快"原则,并将耗时操作转移到独立任务中处理。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐