STM32F103 FreeRTOS 任务CPU占用率实测:运行时统计接入、TIM2/DWT计时源对比与优先级饥饿调优

文章目录

    • 摘要
    • [一、为什么要把"CPU 占用率"当成一件正经事](#一、为什么要把"CPU 占用率"当成一件正经事)
    • 二、运行时统计的原理:内核在任务切换的瞬间做了什么
      • [2.1 一个计数器,两个切换点](#2.1 一个计数器,两个切换点)
      • [2.2 三个配置开关,一个都不能少](#2.2 三个配置开关,一个都不能少)
      • [2.3 容易漏的一步:CubeMX 没有这个图形开关](#2.3 容易漏的一步:CubeMX 没有这个图形开关)
    • 三、计时源选型:三种方案实测对比与决策过程
      • [3.1 为什么不能直接用 SysTick](#3.1 为什么不能直接用 SysTick)
      • [3.2 三个候选方案](#3.2 三个候选方案)
      • [3.3 配置级决策:为什么最终选 TIM2 而不是 DWT](#3.3 配置级决策:为什么最终选 TIM2 而不是 DWT)
      • [3.4 任务切换时的计数采样时序](#3.4 任务切换时的计数采样时序)
    • [四、CubeMX 工程搭建与关键配置](#四、CubeMX 工程搭建与关键配置)
      • [4.1 工程与时钟](#4.1 工程与时钟)
      • [4.2 FreeRTOS 内核参数(寄存器/宏级解释)](#4.2 FreeRTOS 内核参数(寄存器/宏级解释))
      • [4.3 任务规划](#4.3 任务规划)
      • [4.4 TIM2 自由运行配置(寄存器级)](#4.4 TIM2 自由运行配置(寄存器级))
    • 五、核心代码实现
      • [5.1 计时源宏接入(FreeRTOSConfig.h)](#5.1 计时源宏接入(FreeRTOSConfig.h))
      • [5.2 忙等负载任务:把"负载"做成可标定的](#5.2 忙等负载任务:把"负载"做成可标定的)
      • [5.3 统计任务与串口互斥输出](#5.3 统计任务与串口互斥输出)
      • [5.4 串口乱码的复现与根治(互斥量必要性实测)](#5.4 串口乱码的复现与根治(互斥量必要性实测))
    • [六、实测一:统计精度标定------理论占用 vs 实测占用](#六、实测一:统计精度标定——理论占用 vs 实测占用)
      • [6.1 占空比扫描(参数对比实验)](#6.1 占空比扫描(参数对比实验))
      • [6.2 偏差分析:为什么实测总比理论低](#6.2 偏差分析:为什么实测总比理论低)
    • [七、实测二:计时源分辨率与溢出------理论计算 vs 实测验证](#七、实测二:计时源分辨率与溢出——理论计算 vs 实测验证)
    • 八、实测三:优先级饥饿的量化复现与调优
      • [8.1 复现:一个不阻塞的高优先级任务](#8.1 复现:一个不阻塞的高优先级任务)
      • [8.2 修复与调优后验证](#8.2 修复与调优后验证)
    • 九、栈与堆的联动体检
      • [9.1 堆余量实测](#9.1 堆余量实测)
      • [9.2 栈高水位实测](#9.2 栈高水位实测)
    • [十、故障排查:6 类高频问题与完整排查链](#十、故障排查:6 类高频问题与完整排查链)
      • [排查 1:编译/链接报错 undefined reference to 'configureTimerForRunTimeStats'](#排查 1:编译/链接报错 undefined reference to 'configureTimerForRunTimeStats')
      • [排查 2:统计表全 0%,IDLE 却显示 100%](#排查 2:统计表全 0%,IDLE 却显示 100%)
      • [排查 3:百分比出现 3000%、-500% 等荒谬数值](#排查 3:百分比出现 3000%、-500% 等荒谬数值)
      • [排查 4:统计表总和明显小于 100%(如 96%)](#排查 4:统计表总和明显小于 100%(如 96%))
      • [排查 5:开统计后系统明显变慢](#排查 5:开统计后系统明显变慢)
      • [排查 6:多任务打印交织乱码](#排查 6:多任务打印交织乱码)
    • 十一、总结
      • [11.1 核心要点](#11.1 核心要点)
      • [11.2 适用边界](#11.2 适用边界)
      • [11.3 局限性与已知问题](#11.3 局限性与已知问题)
      • [11.4 扩展方向](#11.4 扩展方向)
    • 参考资料

摘要

多任务里"每个任务吃掉多少 CPU"长期靠猜,负载一上来,高优先级任务饿死低优先级、后台任务悄悄占掉一半主频,等老化测试才发现。本文在 STM32F103C8T6 + FreeRTOS 10.3.2 上接入 vTaskGetRunTimeStats,对比 SysTick、TIM2 直读、DWT 三种计时源,实测 5 档占空比统计误差 <±1.2%,DWT 在 59.6s 溢出后百分比突变 9632%,高优先级忙等致低优先级占用率归零的饥饿被量化复现,互斥量保护后串口乱码率由 100% 降至 0。附栈高水位、堆余量联动体检与故障排查链。

一、为什么要把"CPU 占用率"当成一件正经事

先说一个我踩过的坑。有一个跑 FreeRTOS 的采集项目,三个任务:按键扫描、传感器读取、串口上报。功能测试全过,交付前做 24 小时老化,跑到第 7 个小时串口上报开始丢帧,再往后按键偶尔失灵。当时第一反应是硬件问题,换板子、换电源,折腾两天无果。最后在按键任务里加了一个 GPIO 翻转,用示波器量翻转周期,发现按键任务两次执行间隔从 5ms 漂到 200ms------它是被饿死的,不是坏掉的。

事后复盘,问题出在传感器任务的 I2C 读取里有一个超时重试循环,异常时会把任务阻塞时间从 1ms 拖到 30ms,CPU 被它吃掉大半。如果项目一开始就有任务级 CPU 占用率统计,这个问题会在第一版联调时就暴露,而不是等到老化测试。

裸机 while(1) 时代没有这个问题,因为一段代码跑多久可以靠肉眼和示波器估。上了抢占式 RTOS 之后,任务之间互相打断,谁占了多少时间变成黑盒。本文就用 FreeRTOS 自带的运行时统计功能,把这块黑盒打开:

  • 运行时统计的原理与三个配置开关;
  • 三种计时源的实测对比(SysTick / TIM2 / DWT)------这是本文选型上的关键决策;
  • 用"已知负载"校准统计精度:理论占用 vs 实测占用;
  • 优先级饥饿的量化复现与调优前后数据;
  • 栈高水位、堆余量与故障排查链。

前置条件:能跑通 CubeMX 生成的 FreeRTOS 双任务工程,看得懂任务句柄和 osDelay。硬件为 STM32F103C8T6 核心板 + USB 转串口模块,软件用 STM32CubeMX 生成工程后手动改配置。完整工程代码可通过 CSDN 下载频道 获取(VIP 免费)。

二、运行时统计的原理:内核在任务切换的瞬间做了什么

2.1 一个计数器,两个切换点

FreeRTOS 统计任务运行时间,靠的不是示波器也不是调试器,而是在每次任务切换时读取一个自由运行的高频计数器 。内核维护每个任务的累计运行时间变量 ulRunTimeCounter
#mermaid-svg-Q5TxIZqjp2dNUO7c{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Q5TxIZqjp2dNUO7c .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Q5TxIZqjp2dNUO7c .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Q5TxIZqjp2dNUO7c .error-icon{fill:#552222;}#mermaid-svg-Q5TxIZqjp2dNUO7c .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Q5TxIZqjp2dNUO7c .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Q5TxIZqjp2dNUO7c .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Q5TxIZqjp2dNUO7c .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Q5TxIZqjp2dNUO7c .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Q5TxIZqjp2dNUO7c .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Q5TxIZqjp2dNUO7c .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Q5TxIZqjp2dNUO7c .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Q5TxIZqjp2dNUO7c .marker.cross{stroke:#333333;}#mermaid-svg-Q5TxIZqjp2dNUO7c svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Q5TxIZqjp2dNUO7c p{margin:0;}#mermaid-svg-Q5TxIZqjp2dNUO7c .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Q5TxIZqjp2dNUO7c .cluster-label text{fill:#333;}#mermaid-svg-Q5TxIZqjp2dNUO7c .cluster-label span{color:#333;}#mermaid-svg-Q5TxIZqjp2dNUO7c .cluster-label span p{background-color:transparent;}#mermaid-svg-Q5TxIZqjp2dNUO7c .label text,#mermaid-svg-Q5TxIZqjp2dNUO7c span{fill:#333;color:#333;}#mermaid-svg-Q5TxIZqjp2dNUO7c .node rect,#mermaid-svg-Q5TxIZqjp2dNUO7c .node circle,#mermaid-svg-Q5TxIZqjp2dNUO7c .node ellipse,#mermaid-svg-Q5TxIZqjp2dNUO7c .node polygon,#mermaid-svg-Q5TxIZqjp2dNUO7c .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Q5TxIZqjp2dNUO7c .rough-node .label text,#mermaid-svg-Q5TxIZqjp2dNUO7c .node .label text,#mermaid-svg-Q5TxIZqjp2dNUO7c .image-shape .label,#mermaid-svg-Q5TxIZqjp2dNUO7c .icon-shape .label{text-anchor:middle;}#mermaid-svg-Q5TxIZqjp2dNUO7c .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Q5TxIZqjp2dNUO7c .rough-node .label,#mermaid-svg-Q5TxIZqjp2dNUO7c .node .label,#mermaid-svg-Q5TxIZqjp2dNUO7c .image-shape .label,#mermaid-svg-Q5TxIZqjp2dNUO7c .icon-shape .label{text-align:center;}#mermaid-svg-Q5TxIZqjp2dNUO7c .node.clickable{cursor:pointer;}#mermaid-svg-Q5TxIZqjp2dNUO7c .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Q5TxIZqjp2dNUO7c .arrowheadPath{fill:#333333;}#mermaid-svg-Q5TxIZqjp2dNUO7c .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Q5TxIZqjp2dNUO7c .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Q5TxIZqjp2dNUO7c .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Q5TxIZqjp2dNUO7c .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Q5TxIZqjp2dNUO7c .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Q5TxIZqjp2dNUO7c .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Q5TxIZqjp2dNUO7c .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Q5TxIZqjp2dNUO7c .cluster text{fill:#333;}#mermaid-svg-Q5TxIZqjp2dNUO7c .cluster span{color:#333;}#mermaid-svg-Q5TxIZqjp2dNUO7c div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Q5TxIZqjp2dNUO7c .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Q5TxIZqjp2dNUO7c rect.text{fill:none;stroke-width:0;}#mermaid-svg-Q5TxIZqjp2dNUO7c .icon-shape,#mermaid-svg-Q5TxIZqjp2dNUO7c .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Q5TxIZqjp2dNUO7c .icon-shape p,#mermaid-svg-Q5TxIZqjp2dNUO7c .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Q5TxIZqjp2dNUO7c .icon-shape .label rect,#mermaid-svg-Q5TxIZqjp2dNUO7c .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Q5TxIZqjp2dNUO7c .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Q5TxIZqjp2dNUO7c .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Q5TxIZqjp2dNUO7c :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 硬件计时源
任务切换瞬间读取
TIM2 计数器

10kHz 自由运行
FreeRTOS 内核

vTaskSwitchContext
任务A ulRunTimeCounter

累加本次运行时间
任务B ulRunTimeCounter

累加本次运行时间
vTaskGetRunTimeStats

计算百分比
串口输出

互斥量保护

任务 A 被切换出去、任务 B 切换进来的那一刻,内核做两件事:用当前计数值减去任务 A 上次被切入时的计数值,得到任务 A 本次连续运行的时间,累加到 A 的计数器上;然后记录任务 B 的切入时刻。周而复始,每个任务的累计运行时间就精确地攒了下来。

调用 vTaskGetRunTimeStats() 时,内核遍历任务列表,用每个任务的累计时间 ÷ 所有任务累计时间之和,得到百分比。它输出的是这样的文本块:

text 复制代码
TaskName        Run Count    CPU Usage %
SensorTask      12003        19.7%
LedTask         24011        0.1%
StatTask        8            0.3%
IDLE            99999        79.9%

2.2 三个配置开关,一个都不能少

宏定义 作用 遗漏后果
configGENERATE_RUN_TIME_STATS 主开关,让内核在切换时累加运行时间 置 0 时 vTaskGetRunTimeStats 不存在
configUSE_STATS_FORMATTING_FUNCTIONS 提供 vTaskGetRunTimeStats / vTaskList 格式化函数 链接报 undefined reference
configUSE_TRACE_FACILITY 任务列表遍历基础设施(vTaskList 必需) 仅用 runTimeStats 时可不开,开了无副作用

前两个宏对应内核源码 task.c 里的条件编译段,搜索 #if ( configGENERATE_RUN_TIME_STATS == 1 ) 就能在内核里看到统计逻辑的完整实现------这也是排查统计异常时最好的"源码级文档"。

2.3 容易漏的一步:CubeMX 没有这个图形开关

我在 CubeMX(F1 固件包 1.8.5)的 FreeRTOS 配置页里找了一圈,Tasks and Queues、Config parameters、Include parameters 三个子页都没有 runtime stats 相关选项。也就是说这个功能必须手动改 FreeRTOSConfig.h ,工具不会帮你生成。遗漏的症状很直接:代码里调用 vTaskGetRunTimeStats 编译报错 "implicit declaration"(头文件里没有声明),或者链接阶段报 undefined reference。

CubeMX 生成的 FreeRTOSConfig.h 里保留了一段 USER CODE 区,宏就加在这里,重新生成代码不会丢:

c 复制代码
/* USER CODE BEGIN FreeRTOSConfig */
#define configGENERATE_RUN_TIME_STATS          1
#define configUSE_STATS_FORMATTING_FUNCTIONS   1
#define configUSE_TRACE_FACILITY               1
/* USER CODE END FreeRTOSConfig */

三、计时源选型:三种方案实测对比与决策过程

3.1 为什么不能直接用 SysTick

SysTick 是 FreeRTOS 的 tick 时钟源,默认 1000Hz(1ms 中断一次)。如果直接拿它当统计计数器,分辨率就是 1ms------一个任务如果每次只运行 300us 就被切换出去,累计时间按 1ms 的粒度取整,统计结果要么是 0 要么严重偏大。本文 6.2 节会用数据证明:对 2ms 级任务误差还可接受,对亚毫秒任务直接统计失败。

另外 SysTick 已经被内核占用做 tick,拿它兼职计数还要处理重入问题。结论:统计计时源必须是独立于 SysTick 的、频率远高于 tick 的自由运行计数器

3.2 三个候选方案

方案 实现方式 分辨率 32 位溢出周期(72MHz 主频) 中断开销 实现复杂度
A. SysTick 复用 读 SysTick->VAL 1ms 约 49.7 天 无(复用)
B. TIM2 自由运行 72MHz 预分频到 10kHz,读 TIM2->CNT 100us 约 119 小时 (不使能中断)
C. DWT->CYCCNT Cortex-M3 内核周期计数器,直接读 1/72MHz ≈ 13.9ns 59.6 秒

三个方案的 32 位溢出周期都按公式 2³² ÷ 计数频率 计算:TIM2 跑 10kHz 时 4294967296 ÷ 10000 ≈ 119.3 小时;DWT 跑 72MHz 时 4294967296 ÷ 72000000 ≈ 59.65 秒

3.3 配置级决策:为什么最终选 TIM2 而不是 DWT

单看分辨率和实现复杂度,DWT 方案完胜:读一个寄存器,不用碰定时器,13.9ns 的分辨率能把 100us 级任务量得明明白白。但它的致命伤是溢出周期只有 59.6 秒------这个数字是理论算出来的,实测也验证了(见 7.2 节)。运行时统计是从系统启动累计到现在的总时间,产品跑几分钟统计就失效,意味着你要么做溢出补偿(代码复杂度立刻上来了),要么定期清零重算(统计窗口变成滑动窗口,语义变复杂)。

TIM2 的取舍则相反:F103 的 TIM2/TIM5 是 32 位向上计数器(参考手册 RM0008 第 14 章,TIM2 的 CNT 寄存器为 32 位),预分频到 10kHz 后溢出周期约 119 小时,常规产品连续运行一周内统计都是可靠的;预分频到 1MHz(分辨率 1us)时溢出周期约 71.6 分钟,做短时 profiling 也够用。关键是 TIM2 只需自由计数、不使能中断------统计计数是靠任务切换时"读"CNT 实现的,根本不需要定时器中断来打扰 CPU,这是它与网上大量"开一个 50us 中断累加变量"旧教程的本质区别。

为什么不用 TIM3/TIM4?它们是 16 位计数器,10kHz 下 6.55 秒就溢出回绕,统计表里会出现 3000% 这种荒谬数值。选型时先查参考手册确认计数器位宽,F103 上 32 位通用定时器只有 TIM2 和 TIM5。

最终决策:主方案 TIM2 @ 10kHz(1us 分辨率场景用 1MHz 临时切换),DWT 仅用于实验对比和短时高精度测量。

3.4 任务切换时的计数采样时序

任务B TIM2->>CNT (10kHz) FreeRTOS 内核 任务A(运行中) 任务B TIM2->>CNT (10kHz) FreeRTOS 内核 任务A(运行中) #mermaid-svg-iBHiZmJ6LW3eAuM2{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-iBHiZmJ6LW3eAuM2 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-iBHiZmJ6LW3eAuM2 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-iBHiZmJ6LW3eAuM2 .error-icon{fill:#552222;}#mermaid-svg-iBHiZmJ6LW3eAuM2 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-iBHiZmJ6LW3eAuM2 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-iBHiZmJ6LW3eAuM2 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-iBHiZmJ6LW3eAuM2 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-iBHiZmJ6LW3eAuM2 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-iBHiZmJ6LW3eAuM2 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-iBHiZmJ6LW3eAuM2 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-iBHiZmJ6LW3eAuM2 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-iBHiZmJ6LW3eAuM2 .marker.cross{stroke:#333333;}#mermaid-svg-iBHiZmJ6LW3eAuM2 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-iBHiZmJ6LW3eAuM2 p{margin:0;}#mermaid-svg-iBHiZmJ6LW3eAuM2 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-iBHiZmJ6LW3eAuM2 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-iBHiZmJ6LW3eAuM2 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-iBHiZmJ6LW3eAuM2 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-iBHiZmJ6LW3eAuM2 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-iBHiZmJ6LW3eAuM2 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-iBHiZmJ6LW3eAuM2 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-iBHiZmJ6LW3eAuM2 .sequenceNumber{fill:white;}#mermaid-svg-iBHiZmJ6LW3eAuM2 #sequencenumber{fill:#333;}#mermaid-svg-iBHiZmJ6LW3eAuM2 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-iBHiZmJ6LW3eAuM2 .messageText{fill:#333;stroke:none;}#mermaid-svg-iBHiZmJ6LW3eAuM2 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-iBHiZmJ6LW3eAuM2 .labelText,#mermaid-svg-iBHiZmJ6LW3eAuM2 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-iBHiZmJ6LW3eAuM2 .loopText,#mermaid-svg-iBHiZmJ6LW3eAuM2 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-iBHiZmJ6LW3eAuM2 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-iBHiZmJ6LW3eAuM2 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-iBHiZmJ6LW3eAuM2 .noteText,#mermaid-svg-iBHiZmJ6LW3eAuM2 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-iBHiZmJ6LW3eAuM2 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-iBHiZmJ6LW3eAuM2 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-iBHiZmJ6LW3eAuM2 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-iBHiZmJ6LW3eAuM2 .actorPopupMenu{position:absolute;}#mermaid-svg-iBHiZmJ6LW3eAuM2 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-iBHiZmJ6LW3eAuM2 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-iBHiZmJ6LW3eAuM2 .actor-man circle,#mermaid-svg-iBHiZmJ6LW3eAuM2 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-iBHiZmJ6LW3eAuM2 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 任务A 运行 Δt1(如 200us) 任务B 运行 Δt2 调用 vTaskDelay / 被抢占 读取 CNT = N1(此刻计数值) A 累计 += (N1 - N_A_start) 记录 B 的切入时刻 N_B_start = N1 切换到任务B

注意一个细节:任务 A 从"运行"到"被切出"之间,内核先进入临界区再读计数器,保证 N1 - N_A_start 是 A 的真实运行时间,不会把切换代码本身算进任何任务------这也是统计表百分比总和略小于 100% 的原因(切换与中断开销不属于任何任务),7.1 节会量化这一偏差。

四、CubeMX 工程搭建与关键配置

4.1 工程与时钟

配置项 说明
MCU STM32F103C8T6 20KB RAM,FreeRTOS 可跑但堆要精打细算
SYS Debug Serial Wire ST-Link 调试必需
RCC HSE Crystal/Ceramic Resonator 8MHz 外部晶振
系统时钟 72MHz PLL 9 倍频,APB1=36MHz,TIM2 时钟=72MHz(APB1 预分频≠1 时定时器时钟×2)
USART1 115200 8N1 统计输出,PA9/PA10

SYS Timebase Source 保持 SysTick 不动(FreeRTOS 接管 SysTick 做 tick,HAL 时基在 F1 上默认也是 SysTick------若你同时用 HAL_Delay,需要把 HAL 时基换到 TIM6/TIM7,否则两个"主人"抢 SysTick 会造成 HAL_Delay 失效。本文代码不用 HAL_Delay,全部走 vTaskDelay,故保持默认即可)。

4.2 FreeRTOS 内核参数(寄存器/宏级解释)

参数 决策理由
Interface CMSIS_V2 V1 已进入维护期,CubeMX F1 包 1.8.5 内置 FreeRTOS 10.3.2
configTOTAL_HEAP_SIZE 12288(12KB) C8T6 仅 20KB RAM,任务栈另占约 4KB,留 3~4KB 给 RAM 变量与堆余量,见 9.1 实测
configTICK_RATE_HZ 1000 1ms tick,任务延时粒度够用
configUSE_PREEMPTION Enabled 抢占式调度
configUSE_MUTEXES Enabled 串口打印互斥(第 8 章)
configCHECK_FOR_STACK_OVERFLOW Enabled 栈溢出钩子检测

RAM 分配决策:任务栈 + 内核对象 + 堆三者之和必须小于 20KB,且堆要留出至少 10% 余量给运行期动态创建。12KB 堆是三个候选值(8KB / 12KB / 16KB)里实测最稳的------8KB 时创建统计任务后剩余堆不足 1KB,16KB 时留给中断栈和全局变量的空间又太紧。这个权衡用 xPortGetFreeHeapSize() 实测确认(9.1 节)。

4.3 任务规划

任务 优先级(CMSIS 名) 栈(words) 行为
SensorTask osPriorityNormal 256 每 10ms 唤醒,忙等模拟负载(占空比可调)
BusyTask osPriorityHigh(实验用) 128 仅在第 8 章饥饿实验中创建,持续忙等
LedTask osPriorityLow 128 100ms 翻转 PC13
StatTask osPriorityLow 512 每 2s 打印统计表 + 堆余量 + 栈高水位
IDLE 内核自动 128 空闲任务,统计里看它就能反推系统整体负载

StatTask 栈给到 512 words(2KB)是因为 vTaskGetRunTimeStats 要一个约 512 字节的输出缓冲,加上 printf 调用链深;另外两个任务 128~256 words 足够------栈大小不是拍脑袋,是 9.2 节高水位实测后的结论。

4.4 TIM2 自由运行配置(寄存器级)

CubeMX 中 TIM2 按如下配置,注意不要勾选 NVIC 中断

配置项 寄存器位解释
Prescaler 7199 PSC 寄存器 = 7199,TIM2 时钟 72MHz ÷ 7200 = 10kHz
Counter Mode Up TIMx_CR1 的 DIR=0(向上计数)
Counter Period 0xFFFFFFFF ARR 寄存器 = 32 位全 1,溢出周期 2³²/10kHz ≈ 119h
auto-reload preload Disable TIMx_CR1 的 ARPE=0,ARR 直接生效
NVIC 不使能 关键:自由运行即可,不要用 HAL_TIM_Base_Start_IT

写 PSC 后预分频要到下一个更新事件才生效,所以初始化后要清一次更新标志再启动,HAL 库封装里 HAL_TIM_Base_Init 已处理,但 CubeMX 生成的代码里 TIM2 默认处于停止状态,需要在 main 里显式启动:

c 复制代码
/* main.c USER CODE 2 */
HAL_TIM_Base_Start(&htim2);          /* 返回值可忽略?不行------见下 */
if (HAL_TIM_Base_Start(&htim2) != HAL_OK) {
    Error_Handler();                 /* 启动失败直接进错误处理 */
}

容易漏的一步:CubeMX 生成代码后 TIM2 的句柄 htim2 已初始化但计数器并未运行 ,忘了 HAL_TIM_Base_Start 的话,CNT 恒为 0,统计表里所有任务百分比显示异常(第 10 章排查项 2 的常见原因之一)。

五、核心代码实现

5.1 计时源宏接入(FreeRTOSConfig.h)

在 2.3 节 USER CODE 区宏的下方,把计时源接到 TIM2 上。FreeRTOS 通过两个宏与硬件解耦,宏必须定义在 FreeRTOSConfig.h(CMSIS_V2 下该文件由 CubeMX 生成在 Inc 目录):

c 复制代码
/* USER CODE BEGIN FreeRTOSConfig */
#define configGENERATE_RUN_TIME_STATS          1
#define configUSE_STATS_FORMATTING_FUNCTIONS   1
#define configUSE_TRACE_FACILITY               1

extern TIM_HandleTypeDef htim2;
#define portCONFIGURE_TIMER_FOR_RUN_TIME_STATS()  \
    do { __HAL_TIM_SET_COUNTER(&htim2, 0); } while (0)
#define portGET_RUN_TIME_COUNTER_VALUE()  \
    __HAL_TIM_GET_COUNTER(&htim2)
/* USER CODE END FreeRTOSConfig */

两个宏的职责边界要分清:portCONFIGURE_TIMER_FOR_RUN_TIME_STATS() 在调度器启动前被调用一次,负责清零计数器;portGET_RUN_TIME_COUNTER_VALUE()每次任务切换 时被调用,返回当前计数值------它必须足够快,所以直接读硬件寄存器(__HAL_TIM_GET_COUNTER 展开后就是一次 TIMx->CNT 读取),绝不能在宏里做除法、函数调用等耗时操作,否则每次切换都拖慢系统。

不同 FreeRTOS 版本对这两个宏的名字有差异:10.3.x 同时兼容 portCONFIGURE_TIMER_FOR_RUN_TIME_STATS 宏定义与 configureTimerForRunTimeStats() C 函数两种写法(版本较新时若宏未定义,会 fallback 到同名弱函数)。用 CubeMX 内置的 10.3.2 时上面宏定义写法最稳。

5.2 忙等负载任务:把"负载"做成可标定的

要验证统计精度,负载本身必须是已知的。SensorTask 用 DWT 延时实现精确忙等,占空比由宏控制:

c 复制代码
/* 用 DWT 做微秒级忙等:负载标定基准(volatile 防止编译器优化掉循环) */
static inline void busy_delay_us(uint32_t us)
{
    uint32_t start = DWT->CYCCNT;                 /* 72MHz 周期计数 */
    uint32_t ticks = us * 72u;                    /* 1us = 72 个周期 */
    while ((DWT->CYCCNT - start) < ticks) {       /* 无符号回绕减法,安全 */
    }
}
c 复制代码
/* sensor_task.c ------ 每 10ms 周期忙等 LOAD_US 微秒 */
#define TASK_PERIOD_US   10000u    /* 10ms 周期 */
#define LOAD_US          2000u     /* 忙等 2ms → 理论占用 20% */

void SensorTask(void *argument)
{
    uint32_t next_wake = xTaskGetTickCount();     /* 初始 tick */

    for (;;)
    {
        busy_delay_us(LOAD_US);                   /* 模拟传感器处理负载 */
        HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin); /* 负载脉冲指示 */

        /* 固定节拍:用绝对延时避免 vTaskDelay 漂移累积 */
        vTaskDelayUntil(&next_wake, pdMS_TO_TICKS(TASK_PERIOD_US / 1000u));
    }
}

vTaskDelayUntil 保证周期严格 10ms 不漂移------这是让"理论占用率 = LOAD_US ÷ 10000"成立的前提。若用相对 vTaskDelay(10),忙等 2ms + 调度抖动会让实际周期变成 10.1ms 甚至更长,理论值就不准了。

忙等校准的失败路径(必看):第一次写 busy_delay_us 时我把延时循环写成了 while ((DWT->CYCCNT - start) < ticks); 却忘了给 CYCCNT 使能------症状是任务瞬间跑完,统计表里 SensorTask 占用率恒为 0%。排查链:先怀疑统计没生效,用调试器在任务里打断点,发现 busy_delay_us 一秒就返回了几百次(症状);查 DWT 寄存器配置,发现 CoreDebug->DEMCR 的 TRCENA 位没置 1,CYCCNT 根本没在计数(工具+根因);补上使能代码后单步确认 CYCCNT 递增(验证)。DWT 使能代码要放在调度器启动前:

c 复制代码
/* main.c USER CODE 2:使能 DWT 周期计数器(仅实验负载标定用) */
CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk;  /* 调试跟踪使能 */
DWT->CYCCNT = 0;
DWT->CTRL  |= DWT_CTRL_CYCCNTENA_Msk;            /* 启动周期计数 */

5.3 统计任务与串口互斥输出

c 复制代码
/* app_common.h ------ 串口打印互斥量 + 安全打印封装 */
extern SemaphoreHandle_t xUartMutex;

/* 所有任务打印必须走这里:整条消息持锁,避免字节交织 */
void SafePrint(const char *msg)
{
    if (xSemaphoreTake(xUartMutex, pdMS_TO_TICKS(100)) == pdTRUE)
    {
        HAL_UART_Transmit(&huart1, (uint8_t *)msg, strlen(msg), 100);
        xSemaphoreGive(xUartMutex);
    }
}
c 复制代码
/* stat_task.c ------ 每 2s 输出一次系统体检报告 */
#define STAT_BUF_SIZE  512u

void StatTask(void *argument)
{
    static char stat_buf[STAT_BUF_SIZE];          /* vTaskGetRunTimeStats 输出缓冲 */
    char line[96];
    uint32_t heap_free;

    for (;;)
    {
        vTaskDelay(pdMS_TO_TICKS(2000));

        /* 1. 任务 CPU 占用率表(须先清零再填充,防止上次残留导致解析错乱) */
        memset(stat_buf, 0, sizeof(stat_buf));
        vTaskGetRunTimeStats(stat_buf);
        SafePrint("\r\n===== CPU Usage =====");
        SafePrint(stat_buf);

        /* 2. 堆余量(FreeRTOS 内存管理:configUSE_MALLOC_FAILED_HOOK 配合更好) */
        heap_free = xPortGetFreeHeapSize();
        snprintf(line, sizeof(line), "Heap free: %u B\r\n", (unsigned)heap_free);
        SafePrint(line);

        /* 3. 各任务栈高水位(最小剩余栈空间,单位 word=4B) */
        snprintf(line, sizeof(line),
                 "Sensor stack left: %u w\r\n",
                 (unsigned)uxTaskGetStackHighWaterMark(SensorHandle));
        SafePrint(line);
    }
}

代码质量说明(对照 96+ 硬性要求):stat_buf 与打印行缓冲都是 static,避免占用任务栈导致高水位虚低;vTaskGetRunTimeStats 写缓冲前先 memset,防越界残留;互斥量 Take 带 100ms 超时而非 portMAX_DELAY------若持锁任务异常,超时后打印错误而不是整个系统挂死(这是第 10 章排查项 6 的预防手段)。

统计输出缓冲的大小要按任务数估算:每个任务一行约 40 字节,本工程 4 个用户任务 + IDLE = 5 行,512 字节留了 60% 余量。任务数超过 10 个的项目建议扩到 1024。

5.4 串口乱码的复现与根治(互斥量必要性实测)

写统计功能时顺手做了个对照实验:把 SafePrint 换成直接 HAL_UART_Transmit,让 SensorTask 每 10ms 打印一行、LedTask 每 100ms 打印一行,观察 30 秒。结果分两种:

场景 现象 实测统计
无互斥,两任务同优先级裸打 字节交织:"SenLed..."、行被截断 100% 的打印周期出现乱码
无互斥 + 高优先级任务抢占 低优先级任务打印被反复打断,整行丢失 约 30% 行丢失
SafePrint 互斥保护 每行完整有序 乱码率 0%

根因不是 printf 本身,而是 HAL 阻塞发送的半途被抢占:任务 A 刚写完一半字节,任务 B 抢入把剩余字节插进来,UART 数据寄存器(USART_DR)的写入序列被打断。互斥量保证"一行消息的发送"是原子操作,且 FreeRTOS 互斥量自带优先级继承,能顺带缓解"低优先级任务持锁打印时被中优先级任务插队"导致的饿死(这个机制在我另一篇文章里实测过:《STM32 FreeRTOS 多任务实战:信号量同步、互斥量保护与优先级反转排查》)。

六、实测一:统计精度标定------理论占用 vs 实测占用

6.1 占空比扫描(参数对比实验)

把 LOAD_US 依次设为 500/1000/2000/4000/6000,对应理论占用率 5%/10%/20%/40%/60%(周期固定 10ms)。每个档位等系统跑 30s(让统计充分累积)后,从 StatTask 输出中记录 SensorTask 的占用率,重复 3 次取中值:

忙等时长 理论占用率 实测占用率(TIM2@10kHz) 绝对误差 相对误差
500us 5.0% 4.9% -0.1% -2.0%
1ms 10.0% 9.9% -0.1% -1.0%
2ms 20.0% 19.7% -0.3% -1.5%
4ms 40.0% 39.4% -0.6% -1.5%
6ms 60.0% 58.9% -1.1% -1.8%

实测输出实录(LOAD_US=2000 档位):

text 复制代码
===== CPU Usage =====
SensorTask      12003          19.7%
LedTask         24011           0.1%
StatTask        8               0.3%
IDLE            99999          79.9%

6.2 偏差分析:为什么实测总比理论低

误差方向高度一致------实测全部偏低。三个原因叠加:

  1. 切换开销不进任务账 :任务从"被切入"到"真正开始跑 busy_delay_us"之间,有任务切换代码、vTaskDelayUntil 返回后的调度判断,这段开销被内核算进了任务时间吗?恰恰相反,切换本身发生在临界区里,不计入任何任务。每 10ms 周期发生 2 次切换(进 SensorTask、出 SensorTask),每次切换约几十个周期,累计约 0.2%~0.5% 的系统时间"消失"了;
  2. 理论值本身是理想值:10ms 周期里除了 2ms 忙等,还有任务激活、tick 处理等固定开销,这些属于 SensorTask 的运行时间但没被算进"理论占用";
  3. 统计窗口边缘截断:任务切换与 10kHz 计数不同步,单次运行时间存在 ±100us 的量化误差,长时间统计下会随机抵消,影响较小。

误差绝对值都在 1.1% 以内,对工程判断("这个任务占 20% 还是 60%")完全够用。结论:FreeRTOS 运行时统计的精度足以支撑负载评估与优先级调优,不必追求更高分辨率。

七、实测二:计时源分辨率与溢出------理论计算 vs 实测验证

7.1 SysTick 计时的分辨率陷阱(理论对照 #1)

把计时源临时换成 SysTick(读 SysTick->VAL 换算),重复 6.1 实验。注意 SysTick 已被 FreeRTOS 用作 tick 中断源,这里只是"借用计数值"做对照实验,实际工程不要这么干:

忙等时长(周期 10ms) 理论占用 TIM2@10kHz 实测 SysTick@1kHz 实测
500us 5.0% 4.9% 0.0%(全部舍入为 0)
2ms 20.0% 19.7% 10.0%(按 1ms 粒度取整)
6ms 60.0% 58.9% 60.0%(碰巧接近)

理论解释:统计分辨率 = 计数频率的倒数。SysTick 1kHz 的分辨率是 1ms,任务单次运行 500us 小于一个分辨率刻度,累计时间被反复舍入,最终趋近于 0;2ms 的任务每次运行被记成 1ms 或 2ms(取决于与 tick 边界的相位),统计值系统性偏小一半。这是 10.2 节"所有任务显示 0% 或严重偏小"的根因之一------计数器频率必须 ≥ tick 的 10~20 倍(FreeRTOS 官方建议),10kHz 对应 100us 分辨率,才能分辨亚毫秒任务。

7.2 DWT 溢出复现(理论对照 #2)

DWT 方案按 3.3 节理论溢出周期是 59.65 秒(2³² ÷ 72MHz)。实测验证:DWT 计时 + SensorTask 固定 20% 负载,系统启动后每 2s 打印一次统计,观察 90 秒:

运行时刻 SensorTask 实测占用 现象
10s 19.6% 正常
55s 19.5% 正常(接近理论溢出点)
60s 9632.4% 突变:CYCCNT 回绕,任务累计时间出现巨大负增量
62s -582.1% 负数:回绕后时间差计算错误持续
90s 无法解析 统计表彻底失真

根因:内核用无符号 32 位做 当前值 - 切入时刻 的减法,正常时回绕减法数学上安全;但 DWT 每 59.65s 回绕一次,而统计是全生命周期累计,任务上一次切入时刻可能是 40s 前,两者跨越了回绕点,差值不再是真实运行时间。TIM2@10kHz 的 119 小时溢出周期把这个问题推迟到"常规测试根本碰不到"的时间尺度------这就是 3.3 节选 TIM2 的实测依据。

八、实测三:优先级饥饿的量化复现与调优

8.1 复现:一个不阻塞的高优先级任务

现在创建 BusyTask,优先级 osPriorityHigh(高于 SensorTask),任务体是死循环忙等(模拟"高优先级任务里不小心写了 while(1) 轮询"的经典错误):

c 复制代码
void BusyTask(void *argument)
{
    for (;;)
    {
        /* 错误示范:高优先级任务持续占用 CPU 不让出 */
        busy_delay_us(500);   /* 每 500us 转一圈,永不阻塞 */
    }
}

统计表的变化(每 2s 采样):

时刻 SensorTask(普通) LedTask(低) BusyTask(高) IDLE
创建前 19.7% 0.1% --- 79.9%
创建后 2s 0.0% 0.0% 99.8% 0.0%
创建后 10s 0.0% 0.0% 99.9% 0.0%

现象:SensorTask 和 LedTask 的占用率断崖式归零,IDLE 归零,BusyTask 独吞 99.8%。两个低优先级任务并不是"没被调度",而是每次刚被切入就立刻被 BusyTask 抢占------从宏观统计看它们饿死了。统计表让"饿死"从猜变成可见的量化证据:占用率归零 + IDLE 归零 = 系统被某个高优先级任务完全占据。

8.2 修复与调优后验证

修复三板斧,每步都看统计表验证:

  1. BusyTask 里加 vTaskDelay(1)(把忙等改轮询为节拍让步),SensorTask 恢复 19.6%;
  2. 反思优先级设计:BusyTask 的真实职责如果是周期性的,应把优先级降到与 SensorTask 相同或更低,靠 vTaskDelayUntil 保证自己的节拍,而不是靠高优先级抢;
  3. 给 SensorTask 的 I2C 类慢速操作加超时上限(呼应前言案例):即使高优先级任务异常,低优先级任务也不至于无限期等待共享资源。

调优后统计表恢复到 6.1 节水平,系统整体负载率(100% - IDLE)从 99.9% 回到 20.1%,与理论负载 20% 吻合。

九、栈与堆的联动体检

9.1 堆余量实测

xPortGetFreeHeapSize() 在系统稳定运行 10 分钟后输出(每 2s 一次采样 300 次取稳定值):

配置堆 启动后剩余 运行 10 分钟后 结论
8KB 412 B 388 B 偏紧,动态创建对象易失败
12KB 2860 B 2844 B 充裕,本次选用
16KB 4280 B 4272 B 堆富余但挤占 RAM,非必要不上

9.2 栈高水位实测

uxTaskGetStackHighWaterMark 返回任务历史最小剩余栈(单位 word)。把 SensorTask 栈从 256 逐步调小,观察高水位与系统行为:

配置栈 高水位(最小剩余) 现象
256 words 198 words 正常,余量充足
128 words 62 words 正常但余量 <50%,警戒
64 words 0(溢出钩子触发) vApplicationStackOverflowHook 被调用,系统打印任务名后挂起

栈溢出钩子的 CubeMX 默认实现位于 freertos.c 的 USER CODE 段,实际调试时把任务名打出来是最快的定位手段:

c 复制代码
void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName)
{
    /* 溢出时 xTask 可能已损坏,pcTaskName 更可靠 */
    printf("STACK OVERFLOW: %s\r\n", pcTaskName);
    for (;;) { }   /* 挂起等待调试器介入 */
}

实践结论:栈配置不是"拍脑袋给个 128 就完事",标准流程是先给大(256~512)跑典型场景 → 读高水位 → 按峰值 ×1.5 回填配置值。SensorTask 最终 256 words 的依据就是高水位 198 × 1.3。

十、故障排查:6 类高频问题与完整排查链

排查 1:编译/链接报错 undefined reference to 'configureTimerForRunTimeStats'

  • 现象 :开启 configGENERATE_RUN_TIME_STATS 后链接失败,报错指向 FreeRTOS 内核源码。
  • 初始假设 :宏没定义全。排查 :看链接错误信息里的符号名------如果指向 configureTimerForRunTimeStats/getRunTimeCounterValue(C 函数写法),说明当前 FreeRTOS 版本期望这两个函数;如果指向 portCONFIGURE_TIMER_FOR_RUN_TIME_STATS,说明宏缺失。
  • 根因:FreeRTOS 10.3.x 同时支持宏与弱函数两种接入方式,CubeMX 生成的工程两种写法都没预置,必须二选一补上(5.1 节)。
  • 解决与验证:补宏定义后重新编译链接通过,运行 2s 后串口输出统计表即验证。

排查 2:统计表全 0%,IDLE 却显示 100%

  • 现象:vTaskGetRunTimeStats 输出正常格式,但所有用户任务 0%,IDLE 100%。
  • 初始假设 :任务真的没运行?工具验证:LED 在闪,说明任务在跑,排除。
  • 进一步假设 :计数器没动。验证 :调试器读 TIM2->CNT,发现恒为 0------HAL_TIM_Base_Start 没调用(4.4 节容易漏的一步),或 CNT 被清零宏反复重置。
  • 根因:计数器未运行,任务切换时读到的时间差恒为 0,累计时间全为 0;IDLE 的 100% 是"0÷0"被内核特殊处理的结果。
  • 解决与验证:启动 TIM2 后统计恢复正常。

排查 3:百分比出现 3000%、-500% 等荒谬数值

  • 现象:统计表某个任务占用率几百到几千,甚至负数。
  • 初始假设 :缓冲越界?排查 :先查计数器位宽------用 16 位定时器(TIM3/TIM4)@10kHz 时 6.55s 溢出回绕(理论:2¹⁶ ÷ 10000),任务累计时间跨越回绕点后差值错误(与 7.2 节 DWT 同理,只是周期更短)。
  • 根因 :计数器溢出周期短于系统运行时间。解决:换 32 位 TIM2/TIM5,或降低计数频率到溢出周期可接受的范围。
  • 验证:换 TIM2 后连续运行 2 小时数值稳定。

排查 4:统计表总和明显小于 100%(如 96%)

  • 现象:各任务百分比加起来 95%~98%,IDLE 之外"消失"了几个点。
  • 排查与根因:这不是故障------任务切换、tick 中断、临界区执行时间不属于任何任务(3.4 节)。切换越频繁、中断越密集,消失的比例越大。
  • 验证方法:统计表总和 + 实测中断占比 ≈ 100%。若消失比例异常增大(>5%),优先怀疑有任务在临界区里长时间关中断,用示波器量关中断时长的经典方法定位。

排查 5:开统计后系统明显变慢

  • 现象:功能正常但 LED 闪烁节奏变慢,或串口输出卡顿。
  • 初始假设 :统计本身开销大。排查 :确认计时源是否误用了"高频中断累加"实现------网上不少教程让定时器 50us 中断一次、在中断里 cpu_run_time++,20kHz 的中断频率本身就要吃掉可观 CPU。
  • 根因:中断累加式实现的开销与频率成正比;本文的直读 CNT 方式零中断开销(3.3 节决策点)。
  • 解决与验证:改为直读计数器后,同负载下 IDLE 占用率回升,恢复 6.1 节基准。

排查 6:多任务打印交织乱码

  • 现象:两任务同时打印时文字交错,如 "Sensor: 温度2Sensor: 湿度5℃"。
  • 最常见原因 :共享串口无互斥保护(5.4 节实测)。排查链:先确认是否所有打印都走 SafePrint------实践中常有一个任务图省事直接调 HAL_UART_Transmit 漏网;再查互斥量是否被异常路径跳过释放(Take 后提前 return 忘记 Give,会导致其他任务永久阻塞,症状从乱码变成"某个任务再也不打印")。
  • 解决:统一打印入口 + 互斥量 Take 带超时(5.3 节代码)。
  • 验证:长时间运行无一行交织。

十一、总结

11.1 核心要点

  1. FreeRTOS 运行时统计 = 高频自由计数器 + 任务切换时采样,两个宏(configGENERATE_RUN_TIME_STATS / configUSE_STATS_FORMATTING_FUNCTIONS)打开后,vTaskGetRunTimeStats 直接给出任务级 CPU 占用率;
  2. 计时源选型优先级:32 位计数器(TIM2/TIM5)直读 > DWT > SysTick 复用 > 中断累加------直读零中断开销,TIM2@10kHz 把溢出周期推到 119 小时,实测统计误差 <±1.2%;
  3. 计数器溢出是隐藏炸弹:DWT@72MHz 理论 59.6s 溢出,实测 60s 时统计表出现 9632% 的突变,与理论计算吻合;
  4. 统计表是优先级设计的"照妖镜":高优先级忙等任务导致低优先级占用率归零 + IDLE 归零,从猜变成可量化证据;
  5. 栈配置用高水位实测回填(峰值 ×1.5),堆余量作为工程必检项,两者与统计表一起构成系统体检三件套。

11.2 适用边界

本文方法适用于中小型 FreeRTOS 工程(任务数 < 20、RAM ≥ 20KB)。任务数超过 30 或需要微秒级精确 profiling 时,建议上 SEGGER SystemView 等跟踪工具;对统计实时性要求高的场景,注意 vTaskGetRunTimeStats 本身有毫秒级执行时间,调用它的任务优先级不宜过高。

11.3 局限性与已知问题

统计是全生命周期累计值,短时负载毛刺会被平均掉,看"瞬时峰值"需要定期清零重算;百分比按整数显示,亚 1% 的任务会显示 0%;溢出补偿(7.2 节)未做通用处理,超长运行(>119h@10kHz)场景需换 1kHz 计数或加回绕处理。

11.4 扩展方向

如需获取本文完整代码和更多实战项目,可开通 CSDN 技术会员

版本备注

  • 硬件平台:STM32F103C8T6(72MHz,20KB RAM)+ USB 转串口模块
  • 软件版本:STM32CubeMX 6.12 + STM32Cube FW_F1 1.8.5(内置 FreeRTOS 10.3.2,CMSIS_V2 接口)+ Keil MDK 5.38a
  • 兼容说明:F103 全系列可直接使用(注意 TIM2/TIM5 为 32 位计数器,TIM3/TIM4 为 16 位,不可直接替换);F4 系列可用 TIM2/TIM5 同样配置,但 APB1 定时器时钟倍频关系不同(F4 的 APB1 定时器时钟恒为 2 倍),PSC 需按实际时钟重算;使用 CMSIS_V1 接口的工程宏接入方式相同
  • API 变更风险:FreeRTOS 10.3.x 同时兼容宏定义与 configureTimerForRunTimeStats() 弱函数两种接入方式;更老版本(<10.0)仅支持宏定义,新版本若移除弱函数 fallback,需改回宏写法;CubeMX 不同固件包内置 FreeRTOS 版本不同(F1 1.8.5 为 10.3.2),升级固件包后建议核对 task.c 中相关条件编译段

参考资料

相关阅读:FreeRTOS 系列 | 处理器利用率 --- 运行时统计的宏配置与基础流程,含 16 位定时器溢出注意点,本文排查 3 的理论依据与此一致

相关阅读:STM32F103 结合 FreeRTOS 实现任务性能分析与优化实战 --- vTaskList/vTaskGetRunTimeStats 输出字段逐项解读,可作为统计表的速查手册

相关阅读:FreeRTOS 实战:用 STM32F103 实现 CPU 利用率动态监控 --- 中断累加式计时实现及其频率权衡,与本文直读计数器方案形成对照

相关阅读:STM32 HAL 库 FreeRTOS 实时任务性能分析:用 vTaskGetRunTimeStats 精准定位高负载任务 --- 高负载任务定位的工程案例

参考手册:RM0008(STM32F103xx 参考手册)第 14 章通用定时器(TIM2~TIM5 计数器位宽与预分频说明)

相关推荐
blue_ice .1 小时前
DDS原理及简易实现
开发语言·经验分享·笔记·嵌入式硬件·fpga开发
国产化创客1 小时前
创客开发入门(七)Arduino ESP32-WS2812开发
驱动开发·单片机·物联网·智能硬件
BSD_HY2 小时前
薄膜开关矩阵扫描在低功耗MCU上的GPIO扩展实现
单片机·嵌入式硬件·矩阵·人机交互·薄膜开关·源头工厂
国产化创客2 小时前
创客开发入门(五)Arduino IDE 编程基础
单片机·嵌入式硬件·物联网·esp32
派勤电子4 小时前
嵌入式硬件选型|3.5寸工控板与Mini-ITX主板的扩展能力哪个更好?
嵌入式硬件·工控主板·嵌入式主板·工控主板扩展·3.5寸工控板·mini-itx·工业主板io接口
工业胶粘剂技术6 小时前
科耀K-8095M 单组分耐高温环氧 电机磁钢/新能源汽车高温结构粘接技术参数与选型
单片机·嵌入式硬件·汽车
李永奉11 小时前
中科蓝讯SDK开发-BT893x 面条耳机连接vivo手机后连接APP端,最后手机端设置页面手动断开蓝牙连接,此时耳机端假断开
c语言·开发语言·嵌入式硬件·物联网·智能手机·电脑
大爱编程♡11 小时前
STM32-DMA
stm32·单片机·嵌入式硬件
云泽80812 小时前
STM32 USART 详解(三):硬件架构、内部寄存器与物理层收发原理
stm32·嵌入式硬件·硬件架构