手头没有逻辑分析仪、示波器这类外部仪器,这篇的数全靠软件测:任务切换用 DWT 周期计数器数周期,中断延迟拿 TIM2 的硬件计数当参照。测的是这块 NUCLEO-H5E5ZJ-Q 上 FreeRTOS 的任务切换周期、任务通知切换开销,和中断从发生到任务跑起来的延迟。
1.1 测试模式和测法
在工程里加了个测试模式:一个编译开关 RTOS_BENCH,打开之后固件只跑测试任务。FreeRTOS 的任务切换钩子 traceTASK_SWITCHED_IN 同时换成一个最小版本,只在需要的时候记一个 DWT 时间戳——钩子自己的开销也会算进切换时间,能省就省。
DWT 在 250 MHz 下一个周期 4 ns。正式测之前先量了读它本身的开销:连着读两次,差 1 个周期。这 1 个周期包含在后面的数里,没有单独扣掉。
中断延迟不能照这个办法测。DWT 要等代码跑到读它的那一行才有值,从中断发生到 ISR 第一行代码之间那一段,它读不到。所以中断这边用 TIM2:250 MHz 自由运行,CC1 比较事件当事件源,事件发生的硬件时刻留在 CCR1 里;ISR 进来第一件事读 TIM2->CNT,两者相减就是延迟,编译器压栈和这次寄存器读的时间都包含在内。
所有测量窗口里都不打串口,样本先存数组,阶段结束才统一打印;原始样本(RAW 行)排在统计结果前面打,后面要用脚本拿原始数据再算一遍核对。
编译是 Release,GCC 14.2.1。测试固件占用:
Memory region Used Size Region Size %age Used
RAM: 177624 B 1536 KB 11.29%
FLASH: 49572 B 4 MB 1.18%
烧录带校验,过了之后复位,测试自己从头跑到尾,串口走 ST-Link 虚拟串口,115200 全程抓下来。

串口开头三行:
BEGIN,RTOS_BENCH_V1
CONFIG,core=250000000,tick=1000,preempt=1,slicing=1,fpu=0,compiler=14.2.1 20241119,ICACHE=1
DWT_READ_PAIR,1
1.2 时间片轮换
建两个任务 A、B,优先级都是 10,函数体就是个空循环,谁也不阻塞,两个任务始终就绪,靠 tick 做时间片轮换。切换钩子在每次切入时记 DWT 时间戳,A、B 交替切入的间隔就是轮换周期。第一个间隔只有半截,扔掉,取后面 2047 个:
RESULT,slice_A_B,n=2047,hz=250000000,min_ticks=249998,avg_ticks=250000,p99_ticks=250002,max_ticks=250002,min_ns=999992,avg_ns=1000000,p99_ns=1000008,max_ns=1000008
CONFIG 行里 tick=1000,tick 是 1000 Hz,理论间隔 1 ms。实测平均正好 250000 个周期,1000.000 µs;最小 999992 ns,最大 1000008 ns,2047 个样本全落在 ±8 ns 里,也就是 ±2 个周期。时间片轮换测下来就是 1 ms,没看出偏差。
1.3 任务通知切换
两个任务 ping、pong 用任务通知打乒乓,pong 优先级 42,ping 41。先热身 32 轮不计,再量 1000 轮,分两种量法。
抢占式:ping 记下 DWT 时刻,把通知发给 pong;pong 优先级更高,被唤醒后直接抢占,醒来第一时间读 DWT,这个差值就是抢占式切换。
阻塞式:ping 发完通知就把自己阻塞住,pong 收到后回一个通知,一直量到 ping 被重新调度运行,是一个来回。
RESULT,notify_preempt,n=1000,hz=250000000,min_ticks=373,avg_ticks=373,p99_ticks=373,max_ticks=508,min_ns=1492,avg_ns=1493,p99_ns=1492,max_ns=2032
RESULT,notify_block,n=1000,hz=250000000,min_ticks=685,avg_ticks=685,p99_ticks=685,max_ticks=834,min_ns=2740,avg_ns=2742,p99_ns=2740,max_ns=3336
抢占式平均 373 个周期,1.493 µs;阻塞式平均 685 个周期,2.742 µs,多出来的 1.249 µs,主要是 ping 把自己挂起、之后再被调度回来那一段。两个数都包含通知 API、PendSV、上下文保存恢复和最小钩子的开销。
还有 CONFIG 行里的 fpu=0:这个工程没开 FPU,这里保存恢复的是整数上下文,浮点上下文的切换开销不在这篇的数里。
1.4 中断响应
TIM2 挂在 APB1 上,1.1 的测法要求 APB1 和内核同频,都是 250 MHz,代码里用 configASSERT 卡了这一条,不满足就停在那。TIM2 不分频自由运行,CC1 每 252250 个计数出一个事件,1009 µs 一次。中断优先级设成 5,正好是这个工程 configMAX_SYSCALL_INTERRUPT_PRIORITY 的值——在这个优先级上,ISR 里调 vTaskNotifyGiveFromISR 是合法的。
ISR 本体:
void TIM2_IRQHandler(void)
{
/* Compiler prologue and bus read are included in the measured latency. */
uint32_t now = TIM2->CNT;
uint32_t ref = TIM2->CCR1;
TIM2->SR = ~TIM_SR_CC1IF;
if (irq_n >= N) return;
uint32_t i = irq_n;
uint32_t latency = now - ref; /* unsigned subtraction also handles timer wrap */
if (latency >= timer_period || task_n != i) bad++;
irq_latency[i] = latency;
event_ref = ref;
irq_n = i + 1U;
TIM2->CCR1 = (latency < timer_period ? ref : now) + timer_period;
if (irq_n == N) TIM2->DIER = 0;
BaseType_t wake = pdFALSE;
vTaskNotifyGiveFromISR(receiver, &wake);
portYIELD_FROM_ISR(wake);
}
ISR 记完延迟就用通知唤醒接收任务,接收任务优先级 42,醒来读 TIM2->CNT,减同一个 CCR1,得到从事件发生到任务真正跑起来的全程时间,就是 RESULT 里的 event_to_task。每一对样本,ISR 侧计数和任务侧计数都要对上,对不上记一个 bad。每阶段采 2048 对,阶段末尾打一行 PHASE,irq 和 task 都是 2048、bad=0,这一组才算数。
先是系统空闲的一组,接收任务在等通知,别的什么都没跑:
PHASE,idle,irq=2048,task=2048,bad=0,status=OK
RESULT,irq_entry,n=2048,hz=250000000,min_ticks=26,avg_ticks=26,p99_ticks=26,max_ticks=45,min_ns=104,avg_ns=104,p99_ns=104,max_ns=180
RESULT,event_to_task,n=2048,hz=250000000,min_ticks=466,avg_ticks=466,p99_ticks=466,max_ticks=632,min_ns=1864,avg_ns=1866,p99_ns=1864,max_ns=2528
ISR 入口平均 26 个周期,104 ns;事件到任务运行平均 1.866 µs,最大 2.528 µs。
第二组加一个优先级 10 的后台任务,死循环跑整数乘加:
PHASE,busy,irq=2048,task=2048,bad=0,status=OK
RESULT,irq_entry,n=2048,hz=250000000,min_ticks=21,avg_ticks=25,p99_ticks=26,max_ticks=33,min_ns=84,avg_ns=103,p99_ns=104,max_ns=132
RESULT,event_to_task,n=2048,hz=250000000,min_ticks=461,avg_ticks=466,p99_ticks=466,max_ticks=602,min_ns=1844,avg_ns=1866,p99_ns=1864,max_ns=2408
和空闲那组基本一样:irq_entry 平均 103 ns,event_to_task 平均还是 1.866 µs。后台任务优先级只有 10,中断进来直接抢占,它再忙也不往中断响应里掺东西。
第三组是故意压最坏情况:后台任务每个 tick 进一次临界区,在里面空转 5000 个周期,约 20 µs,这段时间优先级 5 的 TIM2 中断被挡在外面,进不来:
PHASE,critical20us,irq=2048,task=2048,bad=0,status=OK
RESULT,irq_entry,n=2048,hz=250000000,min_ticks=26,avg_ticks=78,p99_ticks=2519,max_ticks=5019,min_ns=104,avg_ns=313,p99_ns=10076,max_ns=20076
RESULT,event_to_task,n=2048,hz=250000000,min_ticks=463,avg_ticks=517,p99_ticks=2956,max_ticks=5456,min_ns=1852,avg_ns=2068,p99_ns=11824,max_ns=21824
END,RTOS_BENCH_V1,heap_free=122760,min_heap=118440
这组就变了。irq_entry 平均被拉到 313 ns,最大 20.076 µs,只比 20 µs 的临界区多 76 ns;event_to_task 最大 21.824 µs,构成上就是那 20 µs 临界区,再加上中断能进来之后走到任务运行的那一段。临界区里把中断关多久,最坏的中断响应就得多等多久,这个工程里再写临界区,长度得先按这个账算一遍。
END 行的 min_heap=118440,是整个测试过程中堆剩余的历史最低点。
1.5 复测和复算
同一个固件重新烧录,跑了第二轮;第三轮只复位重跑。三轮打出来的统计数逐项相同,一个数都不差。
另外写了个脚本,把三轮日志里的 RAW 样本重新算了一遍:每阶段 2048 对,min、avg、p99、max 和固件自己打的 RESULT 逐项对得上,p99 是按 nearest-rank 取的;样本序号是否连续、2048 对是否一一配上,也顺带查了,都没问题。
下一篇跑 CoreMark 和 Dhrystone,测 250 MHz 的算力,跑分再换算到单位主频。
上面所有时间都是拿周期数按 250 MHz 配置主频换算的,时钟没有用外部仪器校准过,4 ns 只是 DWT 的计数分辨率。