RIOT 调度器空转基准测试(sched_nop)解析:thread_yield 性能测量原理与实现
【免费下载链接】RIOTRIOT - The friendly OS for IoT项目地址: https://gitcode.com/GitHub_Trending/riot/RIOT
本篇文章围绕 RIOT 操作系统中的调度器基准测试应用tests/bench/sched_nop展开,深入剖析它如何通过在一个无竞争线程的循环中反复调用thread_yield(),测量出"原始上下文保存/恢复性能 + 调度器空转判定耗时"的合成指标,并输出每秒thread_yield()调用次数作为结果。读者读完后,将理解该基准的测量模型、thread_yield()在内核中的调用链、如何编译运行与解读输出,以及它与其他tests/bench应用刻意重复代码的原因。
基准测试的设计目标与测量模型
tests/bench/sched_nop是 RIOT 仓库中用于量化"调度器空转成本"(scheduler no-op cost)的微基准测试。其核心思想非常直接,正如 README 所述:
该测试在一个循环中调用
thread_yield()。由于系统中不存在其他优先级更高或相同(且就绪)的线程,因此每次调用测得的实际上是原始上下文保存/恢复性能,加上调度器判断"当前没有其他活动线程"所需的短暂时间。
换句话说,这个基准刻意构造了一个"yield 后没有任何可切换对象"的最简场景,从而把以下两部分开销从复杂的多线程切换中剥离出来:
- 上下文保存与恢复:CPU 寄存器现场、栈指针等状态的保存与恢复;
- 调度器空转判定:
sched_run()在就绪队列中查找下一个线程,发现目标线程仍是当前线程时,跳过实际切换的开销。
最终结果以每秒能够完成的thread_yield()调用次数(calls per second)呈现。这一指标可以直接反映目标平台调度路径的"裸速度",是评估内核调度开销、比较不同 CPU 架构(如 Cortex-M 系列、RISC-V、native 模拟器)调度实现效率的重要参考。
值得说明的是,这个应用有意与一些类似的 benchmark 应用重复代码(README 原文:"This test application intentionally duplicates code with some similar benchmark applications"),其目的是便于直接比较各基准的代码大小(code size)。重复而非共享代码,意味着每个基准都可以独立编译、独立统计自身 ROM 占用,从而在不互相污染的情况下对比不同测量场景的开销差异。
主程序实现:定时驱动的 yield 计数循环
核心实现位于 main.c,完整逻辑只有约 60 行。其测量流程分为三个阶段。
阶段一:注册定时器回调
xtimer_t timer; timer.callback = _timer_callback; xtimer_set(&timer, TEST_DURATION);程序首先使用xtimer设置一个一次性定时器,时长由宏TEST_DURATION决定,默认值为1000000U,单位为微秒,即默认测量窗口为1 秒:
#ifndef TEST_DURATION #define TEST_DURATION (1000000U) #endif该宏可在编译时通过CFLAGS覆盖,例如将测量窗口缩短为 100ms 以加快验证。
定时器到期后触发回调函数_timer_callback,它所做的只是将一个全局 volatile 标志置位:
volatile unsigned _flag = 0; static void _timer_callback(void *arg) { (void)arg; _flag = 1; }_flag声明为volatile,确保编译器不会将循环中对它的读取优化掉——这是嵌入式基准测试中防止编译器"聪明过头"消除空循环的标准手段。
阶段二:无竞争 yield 计数循环
uint32_t n = 0; xtimer_set(&timer, TEST_DURATION); while (!_flag) { thread_yield(); n++; }循环体的每一次迭代执行一次thread_yield()并递增计数器n,直到定时器触发、_flag变为 1 才退出。由于该基准测试运行在 main 线程上下文中,且系统中不存在优先级更高或相同、且处于就绪状态的线程,因此每次thread_yield()都会经历完整的"保存上下文 → 进入调度器 → 发现无需切换 → 恢复上下文"路径,但永远不会真正切换到其他线程——这正是"空转(nop)"的含义。
阶段三:结果输出
退出循环后,程序打印 JSON 格式的结果:
printf("{ \"result\" : %"PRIu32, n); printf(", \"ticks\" : %"PRIu32, (uint32_t)((TEST_DURATION/US_PER_MS) * (coreclk()/KHZ(1)))/n); puts(" }");result:测量窗口内完成的thread_yield()调用次数,即每秒调用次数(默认窗口为 1 秒);ticks:平均每次thread_yield()调用消耗的 CPU 时钟周期数。其计算方式为:窗口时长换算为毫秒(TEST_DURATION/US_PER_MS)乘以 CPU 频率(千赫兹为单位,coreclk()/KHZ(1)),再除以调用次数n。
例如某平台coreclk()返回 64 MHz、默认 1 秒窗口内完成 100 万次调用,则每次调用约耗 64 个时钟周期。ticks字段让结果不再受 CPU 主频影响,便于跨平台对比调度路径的相对效率。PRIu32来自<inttypes.h>风格的格式化宏,用于可移植地打印 32 位无符号整数。
这里有两个值得注意的实现细节:
- 测量窗口的计时依赖
xtimer,而xtimer本身运行在定时器中断上下文中,因此基准期间系统必须保持中断使能,thread_yield()路径恰好不长期关闭中断(见下文源码分析),测量是有效的; ticks字段是可选的,测试脚本的正则允许其存在或缺失,兼容不同固件版本。
源码级的调用链:thread_yield → sched_run
要理解这个基准到底在"量"什么,需要进入内核源码追踪thread_yield()的完整路径。
thread_yield 的实现
core/thread.c 中thread_yield()的实现如下:
void thread_yield(void) { unsigned old_state = irq_disable(); thread_t *me = thread_get_active(); if (me->status >= STATUS_ON_RUNQUEUE) { sched_runq_advance(me->priority); } irq_restore(old_state); thread_yield_higher(); }其语义是:短暂关闭中断,将当前线程在其优先级对应的就绪队列中**轮转(advance)**到队尾,然后恢复中断并调用thread_yield_higher(),触发一次调度决策。
sched_run:发现"无事可做"的关键路径
thread_yield_higher()最终会调用 core/sched.c 中的核心调度函数sched_run()。在"无其他就绪线程"的场景下,sched_run()的关键行为如下:
thread_t *__attribute__((used)) sched_run(void) { thread_t *active_thread = thread_get_active(); thread_t *previous_thread = active_thread; /* ... */ sched_context_switch_request = 0; unsigned nextrq = _get_prio_queue_from_runqueue(); thread_t *next_thread = container_of(sched_runqueues[nextrq].next->next, thread_t, rq_entry); /* ... */ next_thread->status = STATUS_RUNNING; if (previous_thread == next_thread) { /* 当前线程即下一个线程:无需真正切换上下文 */ /* ... */ return active_thread; } /* 否则才执行真正的上下文切换(context switch) */ }从源码结构可以看出,sched_run()通过_get_prio_queue_from_runqueue()从就绪队列位图(runqueue bitcache)中取出当前最高优先级队列的队首线程。在本基准场景下,队列中只有 main 线程自己,因此previous_thread == next_thread成立,调度器跳过真正的上下文切换,直接返回当前线程。
这正是 README 所述"调度器意识到没有其他活动线程所需的(短)时间"的源码级印证:这一小段判定逻辑(位图查询、队首取出、相等比较、状态置位)就是基准要测量的调度器空转成本。而真正的上下文保存/恢复开销,则由thread_yield()的完整调用路径(进入调度器前的现场保护、退出调度器后的现场恢复)贡献。
此外,core/include/sched.h 中的注释也明确了 RIOT 内核的调度触发模型:sched_context_switch_request标志位一旦置位,调度器会在此处(或返回路径中)执行上下文切换;thread_yield()与thread_sleep()是两种典型的主动触发方式。sched_run()返回后,架构相关代码依据sched_context_switch_request决定是否真正进行上下文切换——在 sched_nop 场景下该标志在sched_run()开头即被清零且后续没有其他就绪线程置位,因此最终不切换。
构建与运行:从编译到解析输出
Makefile 与依赖
Makefile 内容极简:
include ../Makefile.bench_common USEMODULE += xtimer include $(RIOTBASE)/Makefile.include- 引入 Makefile.bench_common,后者设置了
RIOTBASE(指向仓库根目录)并引入通用测试构建框架Makefile.tests_common; - 显式声明
USEMODULE += xtimer,因为基准依赖xtimer提供定时测量窗口; - 其余模块(线程、调度器、时钟等)由 RIOT 的默认模块机制自动引入。
内存受限板的豁免
Makefile.ci 中声明了因内存不足而豁免的板卡:
BOARD_INSUFFICIENT_MEMORY := \ atmega8 \ #即atmega8由于 ROM/RAM 过小无法容纳该基准,CI 不会在其上构建。其他 AVR、Cortex-M、RISC-V 及 native 平台均可运行。
编译与烧录
在任意支持的目标板上编译并运行(以 native 模拟器为例,最方便在 PC 上体验):
# 在 tests/bench/sched_nop 目录下 make BOARD=native flash term或将测量窗口缩短为 100ms 快速验证:
make BOARD=native CFLAGS="-DTEST_DURATION=100000U" flash term也可以为真实板卡(如nucleo-f401re、samr21-xpro)指定BOARD交叉编译并烧录,输出通过串口终端查看。
输出解读
程序启动先打印main starting,测量结束后打印一行 JSON:
{ "result" : 1234567, "ticks" : 51 }result越大,说明调度路径每秒能承受的 yield 次数越多,调度空转开销越低;ticks越小,说明每次 yield 消耗的 CPU 周期越少。ticks是一个与主频无关的归一化指标,跨平台对比时更有意义。
自动化测试
tests/01-run.py 使用 RIOT 的 testrunner 框架自动验证输出格式:
def testfunc(child): child.expect(r"{ \"result\" : \d+(, \"ticks\" : \d+)? }")该正则要求输出严格匹配{ "result" : <数字> }或带"ticks"字段的完整形式,用于 CI 中自动校验基准应用能够正常完成测量并产生合法结果。
定位与延伸:sched_nop 在基准族中的角色
sched_nop属于 tests/bench 目录下的微基准应用族。它刻意与同族应用重复代码,正是为了在相同代码规模的前提下对比不同测量维度——例如专门测量上下文切换成本的基准(如thread_switch类)、测量线程创建/退出成本的基准等。通过sched_nop测得的"空转调度成本",可以与其他基准组合使用,帮助开发者分解出调度开销中的各项组成:
- 调度器就绪队列查询与判定的固定开销;
- 上下文保存/恢复的架构相关开销;
- 两者之和在无竞争场景下的下限值。
因此,sched_nop的数值可视为该平台调度路径开销的下界参考:任何真实的多线程切换场景,其单次切换成本都不会低于这里的ticks值。
小结
tests/bench/sched_nop用约 60 行代码实现了一个精准的调度器空转微基准:通过xtimer设定 1 秒测量窗口,在一个无竞争线程的循环中反复调用thread_yield()并计数,最终输出每秒调用次数与每调用时钟周期数。其测量模型、输出格式、Makefile 依赖与测试脚本均在仓库中有完整可验证的实现,任何 RIOT 开发者都可以在数分钟内编译运行它,得到本平台调度路径开销的第一手数据。
【免费下载链接】RIOTRIOT - The friendly OS for IoT项目地址: https://gitcode.com/GitHub_Trending/riot/RIOT
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考