sched discussion
[PATCH v3 0/4] sched/debug: Introduce per-CPU debugfs files
LLM 分析
sched/debug:引入 per-CPU debugfs 文件并修复 lockless UAF / RCU 遍历违规
系列概况
- 标题:
[PATCH v3 0/4] sched/debug: Introduce per-CPU debugfs files - 作者:Aaron Tomlin atomlin@atomlin.com
- 版本:v3(2026-08-08)
- 规模:4 个 patch,覆盖 2 个文件(
kernel/sched/debug.c、kernel/sched/fair.c) - 代码统计:+65 / -6 行(cover letter 中给出的 stat)
- Message-ID:20260808235522.380038-1-atomlin@atomlin.com 起 5 封 patch 邮件(第 0/4 到第 4/4),另加 1 封作者本人回复请求 ignore
- 完整性:patch 文本完整给出,diffstat 给齐;最后一封由作者本人在 2026-08-10 撤回整个 v3,要求 ignore,因为 Sashiko 又报告了更多 pre-existing 问题
补丁目的
系列同时做两件事:
- 修复三处已经在 sched debugfs handler 中存在的内存安全问题:分别针对
rq->curr、rq->rd的无锁解引用,以及rq->leaf_cfs_rq_list上的非 RCU 安全遍历。Reported-by都是sashiko-bot,说明是机器扫描出来的潜在 UAF/RCU 违例。 - 引入 per-CPU debugfs 文件
/sys/kernel/debug/sched/cpu/cpu<N>/debug,在大 SMP 上不必再读全量/sys/kernel/debug/sched/debug,可直接拿到目标 CPU 的 runqueue 视图。
旧流程的问题
print_cpu()直接以task_pid_nr(rq->curr)解引用rq->curr,未持 RCU 读锁。任务并发退出时,put_task_struct()通过call_rcu()调度__put_task_struct_rcu_cb();宽限期可能在 reader 仍在解引用期间完成,free_task()释放task_struct-> UAF。print_dl_rq()直接读cpu_rq(cpu)->rd。CPU 热拔除或 cpuset 重新划分时,partition_sched_domains() -> rq_attach_root() -> call_rcu(free_rootdomain),reader 未持 RCU 读锁即可能观察到kfree(old_rd)后的dl_bw->bw-> UAF。print_cfs_stats()使用for_each_leaf_cfs_rq_safe()(即list_for_each_entry_safe())遍历 RCU 保护的leaf_cfs_rq_list。_safe变体不强制READ_ONCE(),编译器/CPU 可能重排或重复读取,使 reader 在 writersmp_store_release()发布之前就"看到"新插入节点的指针,导致读到未初始化字段。- 全局
/sys/kernel/debug/sched/debug把全部 online CPU 信息汇总输出,做针对性诊断既慢又容易在大输出里翻页出错。
新流程
- 三处关键访问全部包入
rcu_read_lock()/rcu_read_unlock()之间,并以READ_ONCE()重新取值,使 reader 处于 RCU 读端临界区,阻挡并发的call_rcu()回调执行。 - 新增
for_each_leaf_cfs_rq_rcu()宏(基于list_for_each_entry_rcu()),配合已有的rcu_read_lock()安全遍历 leaf CFS runqueue 列表。 - 新增
/sys/kernel/debug/sched/cpu/cpu<N>/debug,由debugfs_cpu_init()在sched_init_debug()阶段创建cpu/cpu<N>/debug;sched_debug_cpu_show()内通过cpu_online(cpu)决定输出或返回-ENODEV,复用现有print_cpu()避免重复逻辑。
BEFORE (lockless reader) AFTER (rcu_read_lock + READ_ONCE)
---------------------------- ------------------------------------------
print_cpu() task_pid_nr(rq->curr) rcu_read_lock()
| | SEQ_printf(... READ_ONCE(rq->curr) ...)
| | put_task_struct() rcu_read_unlock()
| v
| call_rcu(free_task)
| |
| +--> grace period elapses concurrently
| -> free_task() -> UAF on rq->curr
v
UAF risk
print_dl_rq() cpu_rq(cpu)->rd->dl_bw rcu_read_lock()
| | rd = READ_ONCE(cpu_rq(cpu)->rd)
| | partition_sched_domains if (rd) { ...use rd->dl_bw... }
| v rcu_read_unlock()
| rq_attach_root()
| |
| v
| call_rcu(free_rootdomain)
| |
| +--> kfree(old_rd) concurrent -> UAF on dl_bw
v
UAF risk
print_cfs_stats() for_each_leaf_cfs_rq_safe() rcu_read_lock()
(= list_for_each_entry_safe) for_each_leaf_cfs_rq_rcu(...)
| no READ_ONCE | uses READ_ONCE via list_for_each_entry_rcu
v v
sees new node before fields published waits for smp_store_release() publish
-> uninitialised read safe iteration
/sys/kernel/debug/sched/debug /sys/kernel/debug/sched/cpu/cpu<N>/debug
: all CPUs, big dump : single CPU, print_cpu(m, cpu), -ENODEV if offline
Patch 概览
| Patch | 文件 | 关键改动 |
|---|---|---|
| 1/4 | kernel/sched/debug.c | print_cpu() 用 rcu_read_lock() + READ_ONCE(rq->curr) 保护 rq->curr 解引用 |
| 2/4 | kernel/sched/debug.c | print_dl_rq() 用 rcu_read_lock() + READ_ONCE(cpu_rq(cpu)->rd) 保护 rq->rd;引入 rd 局部变量与 NULL 检查 |
| 3/4 | kernel/sched/fair.c | 新增 for_each_leaf_cfs_rq_rcu() 宏(list_for_each_entry_rcu()),并在 print_cfs_stats() 中替换原 for_each_leaf_cfs_rq_safe() |
| 4/4 | kernel/sched/debug.c | 新增 debugfs_cpu_init() 与 sched_debug_cpu_show(),在 sched_init_debug() 中创建 /sys/kernel/debug/sched/cpu/cpu<N>/debug,offline CPU 返回 -ENODEV |
关键实现
Patch 1/4 -- print_cpu()
rcu_read_lock();
SEQ_printf(m, " .%-30s: %ld\n", "curr->pid",
(long)(task_pid_nr(READ_ONCE(rq->curr))));
rcu_read_unlock();
READ_ONCE() 阻止编译器/CPU 把 rq->curr 缓存到寄存器;rcu_read_lock() 让 reader 处于 RCU 宽限期,因此 __put_task_struct_rcu_cb() 不会在这一段执行,task_struct 在解引用期间始终有效。Fixes: 指向最初引入 debug 文件的 commit 02968ccf7b80。
Patch 2/4 -- print_dl_rq()
struct root_domain *rd;
...
rcu_read_lock();
rd = READ_ONCE(cpu_rq(cpu)->rd);
if (rd) {
dl_bw = &rd->dl_bw;
SEQ_printf(m, " .%-30s: %lld\n", "dl_bw->bw", dl_bw->bw);
SEQ_printf(m, " .%-30s: %lld\n", "dl_bw->total_bw", dl_bw->total_bw);
}
rcu_read_unlock();
相比之前的版本,新版把 rd 提到外层并加 NULL 检查:在 rq_attach_root() 已经把 rd 摘下但 call_rcu() 还没真正释放的窗口里,rd 可能为 NULL,直接读 dl_bw->bw 就会崩;同时显式 rcu_read_lock() 让 free_rootdomain() 推迟到 reader 退出临界区。
Patch 3/4 -- for_each_leaf_cfs_rq_rcu()
#define for_each_leaf_cfs_rq_rcu(rq, cfs_rq) \
list_for_each_entry_rcu(cfs_rq, &(rq)->leaf_cfs_rq_list, \
leaf_cfs_rq_list)
for_each_leaf_cfs_rq_rcu(cpu_rq(cpu), cfs_rq)
宏展开基于 list_for_each_entry_rcu(),每次 next 指针的读取都过 READ_ONCE(),与 writer 侧的 smp_store_release() 形成 happens-before,杜绝看到未发布字段。_safe 变体允许并发删除/再插入,本场景 leaf_cfs_rq_list 只在 rq->lock 下增删,RCU 读端临界区足够保护。
Patch 4/4 -- per-CPU debugfs
static int sched_debug_cpu_show(struct seq_file *m, void *v)
{
unsigned long cpu = (unsigned long) m->private;
if (!cpu_online(cpu))
return -ENODEV;
print_cpu(m, cpu);
return 0;
}
m->private 携带 CPU 编号,复用既有 print_cpu(),避免逻辑分叉。debugfs_cpu_init() 用 for_each_possible_cpu() 一次性创建 cpu/cpu<N>/ 目录与 debug 文件,让 possible 但当前 offline 的 CPU 也保留入口,由 read 时再校验。
类比
把 debugfs 想成医院的病历室:
- 旧版
/sys/kernel/debug/sched/debug是一本大部头合订病历,要查某个病人(CPU)必须整本翻出来 -- 既慢,又容易在翻页过程中某页被回收站收走(UAF)。 - 新版的
/sys/kernel/debug/sched/cpu/cpu<N>/debug像是按病人拆出来的小册子,要看谁直接抽谁。 rcu_read_lock()像图书馆前台在你查资料时挂出一块"请勿收架"牌子;call_rcu()的回收动作要等你把这块牌子摘下来(rcu_read_unlock())才会执行。list_for_each_entry_safe()的问题则像用一把没有护栏的扫描枪读实时更新的登记表 -- 前一行刚填到一半,你就能扫到名字。list_for_each_entry_rcu()给扫描枪装上同步护栏:作者把整行(含tg、load)写完、按下发布键(smp_store_release())之前,你是看不到这一行的。
Highlight:风险与注意点
- 作者已正式撤回 v3:最后一封回复里 Aaron 自己请求 ignore,并指向 Sashiko 报告的另一些问题。这意味着即便 v3 方向正确,仍需在 v4 把那些新报告的 pre-existing 问题一起修;任何 maintainer 不应在 v3 上直接 AC。
rcu_read_lock()范围必须覆盖到所有解引用:patch 1/2/3 都在同一函数内闭环,OK;但调用链若上层(如sched_debug_next/seq_read)在已经rcu_read_unlock()之后继续用cfs_rq指针,就会再次把临界区打开,需要在 review 中确认print_cfs_rq不缓存cfs_rq。ENODEV是 best-effort 信号:cpu_online(cpu)与for_each_possible_cpu()创建目录不同步,可能出现"目录已存在但目标 CPU 立刻下线"的极短窗口;脚本读到-ENODEV不应等同于"CPU 配置错误",而应理解为"此刻 offline"。- 单文件视图仍是
/proc/sched_debug的子集:per-CPU 文件只走print_cpu(),未覆盖 leaf_cfs_rq/rt_rq/dl_rq 的完整视图;文档或 commit message 应明确"targeted inspection"定位,避免用户期待等价于全量 debug。 Fixes:标签面广:02968ccf7b80自最初引入 debug 文件就有问题,可能涉及 stable 回溯;review 时要确认 RCU 注解是否在所有受影响内核版本都成立。print_dl_rq()的rdNULL 检查的可见性:rq_attach_root()把rq->rd置 NULL 与call_rcu(free_rootdomain)之间存在窗口;旧代码只取一次值,新代码取完再 NULL 检查,更稳,但理论上仍可能恰好在READ_ONCE之后、if (rd)之前被释放 -- 这是rcu_read_lock()在起兜底作用,review 时要确认它确实在整个if块内仍然持有。
版本变化
- v1 -> v2:把 commit message 动机改写为"在大型 SMP 拓扑上做有针对性的交互式调试"(来自 Peter Zijlstra 与 Zhan Xusheng 的反馈);
sched_debug_cpu_show()增加cpu_online(cpu)守卫,离线 CPU 返回-ENODEV(Zhan Xusheng)。 - v2 -> v3:① 在
print_cpu()中加入rcu_read_lock()+READ_ONCE(rq->curr);② 在print_dl_rq()中加入rcu_read_lock()+READ_ONCE(cpu_rq(cpu)->rd)并对 CPU 热拔除/cpuset 重划分做防护;③ 新增for_each_leaf_cfs_rq_rcu()宏(基于list_for_each_entry_rcu()),并把 RCU 遍历问题单独抽成 patch 3/4。 - v3 -> 撤回:作者在 2026-08-10 因 Sashiko 报告了更多 pre-existing 问题而请求 ignore 当前 v3,预计出 v4 时一并修复。
一句话总结
v3 系列同时修掉 sched debugfs 里三处 lockless UAF / RCU 遍历违规,并补上一个 per-CPU debugfs 入口,但作者本人在最后一封邮件里已经要求 ignore、准备出 v4 处理 Sashiko 报告的更多问题,因此评审应当暂停,等 v4 再继续。