
搞嵌入式的人应该都有过这种体验一个用裸机开发跑得稳稳当当的功能一上 FreeRTOS 就开始偶尔卡顿、响应超时甚至死机后复位。最头疼的是你用调试器打断点看变量一切正常把 printf 加进去问题反而消失了。这类问题之所以难查是因为我们把 RTOS 当成了“黑盒”只知道任务在跑却不知道 CPU 时间到底花在了哪里。CPU 使用率统计就是给系统装上的一块“仪表盘”让隐藏的调度问题、忙等问题和中断风暴暴露出来。这篇文章我会从原理到实现把 FreeRTOS 系统 CPU 使用率统计的完整思路讲透。包括三种由浅入深的统计方案、任务级 CPU 拆分方法、统计数字不准确时的排查方向以及一个我在 STM32 平台上完整定位 CPU 过载的实战案例。适合刚学完 FreeRTOS 基础、又不知道下一步该做点的开发者也适合在项目里被任务调度问题折腾过、想建立一套观测手段的工程师。1. 为什么CPU使用率是嵌入式系统的硬指标很多刚从裸机转过来的开发者有个误区觉得 CPU 使用率是 PC 端或者 Linux 服务器的概念小单片机跑个 RTOS 根本不需要关心。这个观点在简单项目里勉强成立但一旦任务数量超过三五个实时性要求一上来CPU 使用率就会成为决定系统能不能稳定运行的硬指标。1.1 实时系统要的不是“快”是“确定性”裸机开发里主循环跑完一圈算一圈CPU 忙一点也就慢一点用户体验差但系统通常不会崩。FreeRTOS 这类抢占式实时系统不一样每个任务都有明确的周期和截止时间。一个 1ms 周期的采集任务如果在某段时间内连续性错过了唤醒窗口采集数据就会出现间隙下游控制算法拿到的是不连续的数据整个系统行为就变得不可预测。CPU 使用率高并不一定代表系统有问题但 CPU 使用率长时间接近 100% 往往意味着某个任务在饿着肚子等 CPU。这种时候如果没有量化指标你根本不知道是任务设计不合理、中断太频繁还是某个任务里悄悄出现了忙等。1.2 CPU使用率是各项优化的基础数据不管你接下来要做什么优化第一件事都是先拿到数据。任务周期调整知道每个任务的真实消耗才能判断这个任务该跑 1ms 还是 10ms。选型评估同一个业务逻辑跑在不同主频的芯片上CPU 占用差多少直接用统计数字说话。功耗优化MCU 的功耗和 CPU 活跃时间强相关统计出空闲占比就清楚有多少时间能进低功耗模式。1.3 传统调试手段回答不了“CPU时间去哪了”printf 和断点能告诉你“某个函数被执行了”但回答不了“系统有 70% 的时间在哪里空转”。堆栈溢出检测能告诉你任务栈爆没爆但发现时系统通常已经挂了。而 CPU 使用率统计提供的是一个连续、量化的视图可以在问题发生之前就发现异常趋势。2. 原理拆解让空闲任务替你记账在动手写代码之前先讲明白 FreeRTOS 调度的基本逻辑这是所有统计方案的地基。2.1 FreeRTOS 的调度模型FreeRTOS 是优先级抢占式调度器规则可以浓缩成一句话系统永远运行当前就绪任务里优先级最高的那个。那问题来了如果所有任务都在等待事件没有任务可跑怎么办调度器不能闲着它会让一个特殊任务跑起来——空闲任务Idle Task。空闲任务是系统自动创建的优先级最低只有其他所有任务都进入阻塞态或挂起态时它才有机会执行。这个机制就是 CPU 使用率统计的突破口。2.2 核心公式空闲占比反推 CPU 使用率CPU 的忙碌程度本质上就是“有多少时间没有执行空闲任务”。假设统计周期为 T其中空闲任务运行了 T_idle那么CPU 使用率 (1 - T_idle / T) × 100%公式虽然简单但真正的技术活在于“怎么精准测量空闲任务运行了多久”。下一节我会给出三种具体实现从最朴素的计数法到基于硬件周期计数器的方案精度逐步提升。3. 从零实现三种CPU使用率统计方案3.1 方案一空闲任务钩子计数法最简单FreeRTOS 提供了钩子函数机制其中vApplicationIdleHook()会在空闲任务每次循环时被调用。在这个钩子里对一个计数器加 1就能得到一个反映“空闲量”的数值。/* FreeRTOSConfig.h 中需要开启 */ #define configUSE_IDLE_HOOK 1 /* 统计文件 */ static volatile uint32_t s_idle_count 0; void vApplicationIdleHook(void) { s_idle_count; }读取的时候间隔一段时间采样两次计数计算差值即可uint32_t idle_count_old 0; uint32_t get_cpu_usage(void) { uint32_t delta s_idle_count - idle_count_old; idle_count_old s_idle_count; /* 这个 delta 怎么映射到 CPU 使用率需要先标定“满载参考值” */ return delta; }这个方案看起来直观但有个坑s_idle_count统计的不是时间而是“空闲任务循环迭代次数”。空闲任务的运行速度受编译器优化、中断频率、低功耗模式影响100% 空闲时一秒钟跑多少圈是不固定的。所以你需要先标定一个“满载”基准值工作量不小而且不通用。结论这个方案适合快速粗估系统是否“忙得离谱”不适合做精确统计。我不建议项目正式采用。3.2 方案二节拍中断采样法推荐这是我在实际项目里用得最多的方案简单、精确、不依赖外部硬件。核心思路FreeRTOS 的时基中断通常 1ms 一次每次都会调用vApplicationTickHook()。在这个钩子里检查“当前是否运行在空闲任务”如果是就把“空闲节拍数”加 1。一个节拍就是一个最小时间单位统计窗口内有多少个节拍是空闲的空闲占比就出来了。怎么判断当前是否在空闲任务里我用一个标志位而不是去比较任务句柄/* FreeRTOSConfig.h */ #define configUSE_IDLE_HOOK 1 #define configUSE_TICK_HOOK 1 /* 统计源文件 */ static volatile uint32_t s_idle_flag 0; static volatile uint32_t s_idle_ticks 0; static volatile uint32_t s_total_ticks 0; /* 空闲任务钩子一旦执行说明当前在空闲任务中 */ void vApplicationIdleHook(void) { s_idle_flag 1; } /* 时基中断钩子每个 tick 采样一次 */ void vApplicationTickHook(void) { if (s_idle_flag ! 0u) { s_idle_ticks; s_idle_flag 0; } s_total_ticks; }这里的关键逻辑是某个 tick 到来时如果上一个 tick 周期里空闲任务执行过那么s_idle_flag就会被 idle hook 置 1说明这个 tick 的时间片属于空闲任务如果系统一直在跑业务任务flag 就是 0。这样每个节拍最多统计一次计数和系统节拍天然对齐。统计结果可以做一个滑动窗口计算推荐每 1000 个节拍输出一次平均使用率#define STATS_WINDOW_TICKS 200u /* 200个节拍即200ms */ static uint8_t s_window_count STATS_WINDOW_TICKS; static uint32_t s_idle_snapshot 0; static uint32_t s_total_snapshot 0; static volatile uint32_t s_cpu_usage_percent 0; void vApplicationTickHook(void) { if (s_idle_flag ! 0u) { s_idle_ticks; s_idle_flag 0; } s_total_ticks; /* 递减到0时计算一次平均CPU使用率 */ if (--s_window_count 0u) { uint32_t idle_delta s_idle_ticks - s_idle_snapshot; uint32_t total_delta s_total_ticks - s_total_snapshot; if (total_delta 0u) { s_cpu_usage_percent 100u - (idle_delta * 100u / total_delta); } s_idle_snapshot s_idle_ticks; s_total_snapshot s_total_ticks; s_window_count STATS_WINDOW_TICKS; } }为什么要用递减变量而不是取模运算因为在时基中断里执行s_total_ticks % STATS_WINDOW_TICKS虽然也能用但取模在部分 Cortex-M 核上会生成除法指令几百个周期的开销在 1ms 中断里不致命能省则省。嵌入式开发的一个好习惯就是把周期性的判断从“取余”改成“递减到零”肉眼可见地降低中断负担。注意vApplicationTickHook是在中断上下文里执行的不要在里边调用printf、vTaskDelay或任何可能阻塞的 API。上面的代码只做简单整数累加完全安全。3.3 方案三基于硬件周期计数器高精度节拍采样法的精度是 1 个 tick通常 1ms。如果任务切换频率很高或者你需要分析亚毫秒级的 CPU 占用波动可以用 Cortex-M 内核自带的 DWT 数据观察点与跟踪单元中的周期计数器CYCCNT它按 CPU 时钟周期计数精度可以达到纳秒级。/* 初始化 DWT-CYCCNT */ static void dwt_counter_init(void) { CoreDebug-DEMCR | CoreDebug_DEMCR_TRCENA_Msk; DWT-CYCCNT 0u; DWT-CTRL | DWT_CTRL_CYCCNTENA_Msk; }在空闲任务钩子里读当前计数值累计空闲时间static volatile uint32_t s_idle_start 0; static volatile uint64_t s_idle_cycles 0; void vApplicationIdleHook(void) { if (s_idle_start 0u) { s_idle_start DWT-CYCCNT; /* 进入空闲记录起点 */ } } void vApplicationTickHook(void) { if (s_idle_start ! 0u) { s_idle_cycles (uint32_t)(DWT-CYCCNT - s_idle_start); s_idle_start 0u; } }计算时用s_idle_cycles除以 CPU 主频得到秒数再除以统计窗口时长就是空闲占比。它的优势是能捕捉单个 tick 内的碎片化空闲时间劣势是实现稍复杂而且在低功耗模式下 DWT 计数器可能停走需要验证。3.4 三种方案怎么选方案精度实现复杂度推荐场景空闲钩子计数低需标定极低临时粗估节拍中断采样1 个 tick低绝大多数项目首选DWT 周期计数CPU 周期级中高频任务分析、性能调优我的建议日常监控全部用方案二它已经能覆盖 99% 的排查需求了。只有当你需要分析“两个 tick 之间 CPU 到底空转了多久”这种级别的问题时再上方案三。4. 任务级CPU拆分与统计谁吃掉了CPU整体 CPU 使用率告诉你系统负载高但没告诉你谁干的。真实项目中我更关心每个任务各占了多少 CPU这样才能定位到具体任务。4.1 开启 FreeRTOS 原生运行时间统计FreeRTOS 自带一套任务运行时间统计机制核心原理是在每个 tick 中断里记录当前任务的运行时间然后通过vTaskGetRunTimeStats()输出所有任务的 CPU 占用率。开启方式如下/* FreeRTOSConfig.h */ #define configGENERATE_RUN_TIME_STATS 1 #define configUSE_STATS_FORMATTING_FUNCTIONS 1 /* 提供一个高精度时基供运行时间统计使用 */ #define portCONFIGURE_TIMER_FOR_RUN_TIME_STATS() dwt_counter_init() #define portGET_RUN_TIME_COUNTER_VALUE() DWT-CYCCNT然后在需要输出统计的位置调用void print_task_stats(void) { char buf[512]; vTaskGetRunTimeStats(buf); printf(%s\n, buf); }输出示例Task AbsTime Percentage ------------------------------------- COM_Task 1500000 75% Sensor_Task 320000 16% UI_Task 180000 9% IDLE 5200000 85% - 注这里的百分比算法有讲究每个任务一行的AbsTime是运行时间计数器的累计值Percentage是该任务占总计的比例。注意这里每个任务的百分比是独立对总时间算的所有百分比加起来接近 100%。4.2 为什么建议用独立定时器而不是系统节拍运行时间统计需要一个比系统节拍通常 1ms精度高得多的计时源。如果直接复用 SysTick精度只能到 1ms对于几个 ms 就切换一次的任务统计结果会非常粗糙。而且portGET_RUN_TIME_COUNTER_VALUE()需要返回一个持续递增的计数器SysTick 的重装载值通常不大容易溢出。我在 STM32 上优先用 DWT-CYCCNT原因很简单不需要额外占用一个定时器外设初始化三行代码。如果内核时钟是 72MHzCYCCNT 大约 59.6 秒才溢出一次配合 32 位无符号数差值计算完全够用。如果芯片不支持 DWT或者低功耗模式下 DWT 会停可以改用定时器输入捕获通道。比如把 TIM2 配成 1MHz 递增计数portGET_RUN_TIME_COUNTER_VALUE()直接返回TIM2-CNT效果一样。4.3vTaskGetRunTimeStats的三个不足这套官方方案用起来很方便但要心里有数它的短板输出是累计值不是某段时间内的瞬时占用率。统计任务跑得越久历史累计数据占比越大最新的抖动就被平滑掉了。想要“最近 5 秒”的 CPU 构成你得不到。格式化依赖snprintf系列会吃掉不少任务栈。默认每个任务栈 128 字的话很容易爆我一般把统计任务栈配到 1024 字以上。输出字符串是一次性生成的如果任务数很多缓冲区可能不够。任务名加百分比的文本量要提前估算。4.4 自己实现按任务累计运行时间如果你不想每次都格式化字符串想拿到原始数据自己分析可以参考这个思路在 tick hook 里检测当前任务句柄是否发生变化如果变了就把上一段时间累加到对应任务上。static TaskHandle_t s_last_task NULL; static uint32_t s_last_time 0; void vApplicationTickHook(void) { TaskHandle_t cur xTaskGetCurrentTaskHandle(); if (cur ! s_last_task) { uint32_t now DWT-CYCCNT; uint32_t elapsed now - s_last_time; if (s_last_task ! NULL) { task_time_add(s_last_task, elapsed); } s_last_task cur; s_last_time now; } }这个方案更灵活你可以把累计时间按自己的数据结构存起来在任意时刻做差值计算得到“最近 N 秒”的真实瞬时占用率。代价是xTaskGetCurrentTaskHandle()在 tick 中断里频繁调用有少量开销实测在 72MHz 下几乎可忽略。5. 统计数字不准时的排查方向我见过太多人照着网上的教程把统计代码敲进去结果数值要么恒为 0%要么疯狂抖动要么直接死机。这里列几个我自己踩过或帮别人排查过的坑。5.1 空闲任务钩子没开统计恒为 0 或恒为 100%configUSE_IDLE_HOOK没置 1 时内核根本不会调用vApplicationIdleHook()s_idle_flag永远为 0所有 tick 都算作“忙”CPU 使用率恒为 100%。同样如果configUSE_TICK_HOOK没开vApplicationTickHook()不会被执行统计任务只能读到全 0 的全局变量。这类问题好排查但容易被忽略。写代码前先把 FreeRTOSConfig.h 里的宏列成一个清单逐个核对。5.2 中断占用时间没有被统计一个常被误解的地方ISR 里执行的时间既不算任何任务的运行时间也不算空闲时间。也就是说如果你在中断里做了大量重活比如一个 2ms 的 SPI Flash 擦写循环调度器根本感知不到——在这 2ms 里tick 可能都没来得及触发s_total_ticks没有增长。最终表现是CPU 使用率统计显示系统很空闲但业务任务响应很慢。这种“隐形的开销”用任务维度的统计看不到需要配合示波器、逻辑分析仪抓中断引脚或者用定时器测量中断服务程序的执行时长。5.3 低功耗模式Tickless干扰统计开启configUSE_TICKLESS_IDLE之后系统在空闲时会停止 SysTick 以省电。空闲任务的执行模式变了vApplicationTickHook()可能很久才执行一次s_total_ticks与实际经过时间严重脱节。如果你既想用 Tickless 又想统计 CPU 使用率建议改用 DWT 方案并且以真实时间如 RTC 或低功耗定时器作为窗口基准否则统计结果没有任何意义。5.4 统计窗口太短导致抖动把窗口设成 10 个 tick10msCPU 使用率会在 0% 和 100% 之间来回跳。这个不是代码 bug而是窗口内样本量太少。我通常把窗口设为 1000 个 tick1 秒这样既能反映稳态负载又不会太迟钝。如果需要观察短期波动三个窗口同时跑100ms 用于快速感知1s 用于趋势判断10s 用于稳定性评估。窗口也不是越长越好。太久远的历史会把刚才改动的效果稀释掉不利于验证优化是否生效所以做性能调优时用短窗口做长期监控时用长窗口。5.5 任务栈溢出导致统计函数异常vTaskGetRunTimeStats()内部使用格式化函数对栈的要求比普通函数高很多。如果任务栈配得刚刚好一调用这个函数就触发栈溢出检查然后掉进vApplicationStackOverflowHook()系统直接复位。排查办法统计任务单独建一个任务栈给到 1024 字以上如果还不够把输出的字符串缓冲区放到函数外面用静态数组存储。6. 实战复盘一次CPU过载定位的完整过程最后分享一个真实场景正好能把前面所有内容串起来。这套排查方法我一直沿用任何 FreeRTOS 项目都能直接套用。6.1 现象与初步判断一块 STM32F103 的板子负责一个电机控制加通信的小系统。三个任务控制任务周期 1ms实时性要求最高优先级 7通信任务周期 10ms负责串口收发与协议解析优先级 4显示任务周期 500ms刷新 OLED优先级 2软件版本迭代后客户反馈通信时不时超时OLED 刷新有明显卡顿。我第一反应就是 CPU 可能被谁吃了于是把第二章节拍采样法的统计代码加进去。6.2 整体数据出来了确实过载上线跑了一段时间后统计任务打印出来的整体 CPU 使用率稳定在 93% 左右。这意味着所有任务之外留给空闲任务的时间不足 7%。系统长期在这种负载下任何一个任务偶发抖动都会造成雪崩式延迟。拿到这个数字方向就明确了不是某一个 bug 导致崩溃而是整体负载过高需要拆解到任务级看谁在消耗。6.3 任务级统计暴露问题接着开启configGENERATE_RUN_TIME_STATS用vTaskGetRunTimeStats()拉出任务分布结果让我有点意外。Task Percentage ------------------------------------- Control_Task 18% COM_Task 71% Display_Task 4% IDLE 7%通信任务的优先级只有 4按理说它不该吃这么多 CPU。71% 意味着它几乎在持续占用处理器。打开代码定位发现通信任务里有一个这样的循环for (uint32_t i 0; i 10000; i) { if (rx_flag) { break; } /* 空转等标志位 */ }这是在等一个串口接收完成标志本意是“最多等一小段时间”但因为串口中断在某种情况下没及时触发这个循环变成了纯粹的 CPU 空转而且它在这个状态下频繁进出还占着通信任务的优先级把显示任务也饿着了。6.4 优化与优化后的数字对比把忙等循环改成阻塞式信号量等待if (xSemaphoreTake(rx_sem, pdMS_TO_TICKS(2)) pdTRUE) { /* 收到数据开始解析 */ } else { /* 超时按未收到处理 */ }通信任务在等待期间进入阻塞态CPU 让给其他任务。改完再跑统计整体 CPU 使用率从 93% 掉到 12%通信超时和 OLED 卡顿都消失了。这组对比就能说明统计功能的价值不是靠猜而是用数据佐证问题改完之后再验证。整个过程半小时内完成远比对着代码一行行瞪眼效率高。7. 落地这套统计功能时的一些建议结合这些年做项目的经验最后分享几个实操层面的建议能帮你少走弯路。首先统计功能一开始就要内置而不是上了量之后才补。嵌入式项目一旦跑起来想回头加一个 hook 函数并不复杂但统计代码本身需要排查历史数据做对比没有基线数据你拿到一个 90% 的数字都不知道它是好是坏。我习惯从项目框架搭建的第一天就把统计代码挂上去哪怕前期只在调试串口打印。其次统计代码要做到可以随时编译开关。用条件编译把整套逻辑包起来生产版本直接关掉零额外开销。类似这样#if (CPU_STATS_ENABLE 1) void vApplicationTickHook(void) { ... } #endif输出通道上优先选择 DMA 串口打印避免统计任务在 printf 里阻塞过久影响被统计对象。数据多了之后可以改成周期性累积到一个环形缓冲区由上位机统一拉取分析。最后提醒一点CPU 使用率统计是“诊断工具”不是“治疗工具”。它告诉你哪里出了问题却不会自动修复问题。看到异常数据后还得靠你对 FreeRTOS 调度机制的理解去推导根因。我见过的多数调度问题根子都出在任务内部逻辑上——忙等、长时间关中断、用高优先级任务做大量低优先级的事。统计功能把这些问题暴露出来剩下的功课恰恰是 RTOS 真正值钱的部分。