文章目录
-
- 摘要
- [一、为什么要把"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 偏差分析:为什么实测总比理论低
误差方向高度一致------实测全部偏低。三个原因叠加:
- 切换开销不进任务账 :任务从"被切入"到"真正开始跑 busy_delay_us"之间,有任务切换代码、
vTaskDelayUntil返回后的调度判断,这段开销被内核算进了任务时间吗?恰恰相反,切换本身发生在临界区里,不计入任何任务。每 10ms 周期发生 2 次切换(进 SensorTask、出 SensorTask),每次切换约几十个周期,累计约 0.2%~0.5% 的系统时间"消失"了; - 理论值本身是理想值:10ms 周期里除了 2ms 忙等,还有任务激活、tick 处理等固定开销,这些属于 SensorTask 的运行时间但没被算进"理论占用";
- 统计窗口边缘截断:任务切换与 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 修复与调优后验证
修复三板斧,每步都看统计表验证:
- BusyTask 里加 vTaskDelay(1)(把忙等改轮询为节拍让步),SensorTask 恢复 19.6%;
- 反思优先级设计:BusyTask 的真实职责如果是周期性的,应把优先级降到与 SensorTask 相同或更低,靠 vTaskDelayUntil 保证自己的节拍,而不是靠高优先级抢;
- 给 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 核心要点
- FreeRTOS 运行时统计 = 高频自由计数器 + 任务切换时采样,两个宏(
configGENERATE_RUN_TIME_STATS/configUSE_STATS_FORMATTING_FUNCTIONS)打开后,vTaskGetRunTimeStats直接给出任务级 CPU 占用率; - 计时源选型优先级:32 位计数器(TIM2/TIM5)直读 > DWT > SysTick 复用 > 中断累加------直读零中断开销,TIM2@10kHz 把溢出周期推到 119 小时,实测统计误差 <±1.2%;
- 计数器溢出是隐藏炸弹:DWT@72MHz 理论 59.6s 溢出,实测 60s 时统计表出现 9632% 的突变,与理论计算吻合;
- 统计表是优先级设计的"照妖镜":高优先级忙等任务导致低优先级占用率归零 + IDLE 归零,从猜变成可量化证据;
- 栈配置用高水位实测回填(峰值 ×1.5),堆余量作为工程必检项,两者与统计表一起构成系统体检三件套。
11.2 适用边界
本文方法适用于中小型 FreeRTOS 工程(任务数 < 20、RAM ≥ 20KB)。任务数超过 30 或需要微秒级精确 profiling 时,建议上 SEGGER SystemView 等跟踪工具;对统计实时性要求高的场景,注意 vTaskGetRunTimeStats 本身有毫秒级执行时间,调用它的任务优先级不宜过高。
11.3 局限性与已知问题
统计是全生命周期累计值,短时负载毛刺会被平均掉,看"瞬时峰值"需要定期清零重算;百分比按整数显示,亚 1% 的任务会显示 0%;溢出补偿(7.2 节)未做通用处理,超长运行(>119h@10kHz)场景需换 1kHz 计数或加回绕处理。
11.4 扩展方向
- 把统计表接到上位机做远程负载监控,或按 IDLE 占比反推系统余量做动态调频;
- 配合
vTaskList看任务状态机(阻塞/就绪/运行),结合本文占用率做完整的任务健康画像; - 任务间通信与同步的更多实测(队列/信号量/任务通知的内存与延迟对比)可参考同系列前作:《STM32 FreeRTOS 多任务实战:信号量同步、互斥量保护与优先级反转排查》。
如需获取本文完整代码和更多实战项目,可开通 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 计数器位宽与预分频说明)