0/3 已展开

LLM 分析

Linux Scheduler 讨论分析:scx_flatcg 未初始化统计修复

系列基线信息

  • 标题: [PATCH] tools/sched_ext: scx_flatcg: Fix uninitialized stats on allocation failure
  • 作者: Liang Luo luoliang@kylinos.cn
  • 版本: 单 patch,无版本号(v1 直接提交)
  • 规模: 1 file changed, 2 insertions(+), 2 deletions(-)
  • Message-ID: 20260713071808.89847-1-luoliang@kylinos.cn
  • 来源频道: sched-ext
  • 关联 commit: Fixes: cabd76bbc036 ("tools/sched_ext: scx_flatcg: fix potential stack overflow from VLA in fcg_read_stats")
  • 流向: 提交 → Andrea Righi 评审 → Tejun Heo 应用到 sched_ext/for-7.3

明确目的

这个 patch 修复了 scx_flatcg 用户态工具里 fcg_read_stats() 函数的一个回归 bug。

具体要解决的问题:

  1. 未初始化内存读取:当 calloc() 分配 cnts 失败时,fcg_read_stats() 会提前 return,导致调用方传入的 stats 数组永远不会被清零
  2. UB(未定义行为):调用方 main()acc_stats 数组是栈上声明但未初始化,调用 fcg_read_stats() 后又无条件读取,于是读到了栈上残留的垃圾数据。
  3. 基线污染:垃圾数据通过 memcpy() 被拷贝进 last_stats,让下一个采样周期的差分基准被污染,统计结果从此一直错下去。
  4. 回归来源:之前的 commit cabd76bbc036 把 VLA 换成 calloc() 时,把 memset() 放到了失败检查之后,造成了这个新 bug。

遍历代码

修复前(有 bug 的代码)

static void fcg_read_stats(struct scx_flatcg *skel, __u64 *stats)
{
    __u64 *cnts;
    __u32 idx;

    cnts = calloc(skel->rodata->nr_cpus, sizeof(__u64));
    if (!cnts)
        return;                          // ← 提前 return,stats 没被清零
    for (idx = 0; idx < FCG_NR_STATS; idx++) {
        ...
    }
    memset(stats, 0, sizeof(stats[0]) * FCG_NR_STATS);  // ← 这句永远到不了(失败路径下)
}

### 修复后

```c
static void fcg_read_stats(struct scx_flatcg *skel, __u64 *stats)
{
    __u64 *cnts;
    __u32 idx;

    memset(stats, 0, sizeof(stats[0]) * FCG_NR_STATS);  // ← 先清零
    cnts = calloc(skel->rodata->nr_cpus, sizeof(__u64));
    if (!cnts)
        return;                          // ← 失败时 stats 已经是 0
    for (idx = 0; idx < FCG_NR_STATS; idx++) {
        ...
    }
}

调用方(main 中相关片段)

__u64 acc_stats[FCG_NR_STATS];           // 栈上未初始化
fcg_read_stats(skel, acc_stats);
stats[i] = acc_stats[i] - last_stats[i]; // 失败时读 garbage → UB
// 接着 memcpy 到 last_stats,下一轮 baseline 被污染

关键改动

  • memset(stats, 0, sizeof(stats[0]) * FCG_NR_STATS) 从失败检查之后移到之前
  • calloc 失败路径依然能保证 stats 被填为 0,调用方减出来的差分会是 0 - last_stats[i],至少是一个可预测的合法值,不再是未定义行为。
  • 行为对称:fcg_read_stats() 无论走哪条路径,都至少把 stats 写一次。

ASCII 流程图

1. 修复前后的控制流对比

修复前(cabd76bbc036 之后)                修复后(本 patch)
===========================                ===========================
fcg_read_stats()                          fcg_read_stats()
  │                                         │
  ├─ calloc(cnts)                           ├─ memset(stats, 0, …)   ← 先清零
  │     │                                   │     │
  │     ├─ 成功 ─→ loop …                   ├─ calloc(cnts)
  │     │              │                    │     │
  │     │              └─ memset(stats,0)   │     ├─ 成功 ─→ loop …
  │     │                                     │     │
  │     └─ 失败 ─→ return  ← stats 未写!     │     └─ 失败 ─→ return
  │                                                                     ↑ stats=0, 可预测
  ▼                                         ▼
调用方读 stats[i]                          调用方读 stats[i] = 0 - last_stats[i]
  → UB(garbage)                            → 合法、可预测

2. 数据污染链路

栈分配 acc_stats[FCG_NR_STATS]      [  未初始化(栈残留)  ]
            │
            ▼  fcg_read_stats() 失败路径跳过 memset
acc_stats                             [  仍是栈 garbage  ]
            │
            ▼  stats[i] = acc_stats[i] - last_stats[i]
stats[i]                              [  错误差分  ]
            │
            ▼  memcpy 到 last_stats
last_stats                            [  baseline 被污染  ]
            │
            ▼  下一周期继续累加
后续所有差分                          [  全部带噪声/雪崩  ]

3. 评审 → 合并链路

Liang Luo (luoliang@kylinos.cn)
    │ 2026-07-13 07:18  提交 patch
    ▼
Andrea Righi (arighi@nvidia)
    │ 2026-07-13 07:53  回复(review/Ack)
    ▼
Tejun Heo (tj@kernel.org, sched_ext maintainer)
    │ 2026-07-13 18:35  Applied to sched_ext/for-7.3
    ▼
sched_ext/for-7.3 分支(目标 7.3 合并窗口)

概念类比

餐厅点餐场景

想象一个服务员流程:

  • fcg_read_stats() 是「厨房」,它的责任是把一桌菜(stats 数组)端给顾客。
  • 厨房需要临时借一个盘子(calloc 出来的 cnts)来摆菜。
  • 旧流程:厨房先去借盘子;如果借不到就直接回厨房不出来,饭桌上空空如也;然后顾客以为菜已经端上来,伸筷子去夹,结果夹到的全是上一桌客人吃剩的渣(栈 garbage)。
  • 新流程:厨房在借盘子之前先把桌面铺一层干净的桌布(memset(0));哪怕盘子借不到,桌面也是干净的,顾客看到的差值至少是「这桌没菜 - 上一桌的菜」,是一个合理、稳定的数字。

再换一种角度:这个 bug 像是「变量先使用、后赋值」的典型错误——memset 原本是「赋值」动作,但被错误地放到了「使用」之后;只要走到失败分支,stats 就从来没被赋值过,而调用方却当成已经赋值来读,触发 C 语言的经典 UB。


Highlight 突出问题

  1. UB 触发条件可能很罕见calloc 在普通压力下很难失败,但在内存紧张、容器 OOM、cgroup 限额触发时就会浮上来;上线前最好做一个「人为压低 memory+ 跑scx_flatcg` 的烟雾测试,确认差分曲线没有突刺。
  2. void 返回类型让错误无法传播fcg_read_stats() 返回 void,调用方没有任何途径感知失败,这是一个接口设计层面的小问题。本 patch 没改 API,但后续可以讨论:要么改成 bool/int 返回值让调用方决定 fallback,要么在文档里明确「失败时 stats 会被清零」的契约。
  3. memset(sizeof(stats[0]) * FCG_NR_STATS) 的字节数计算是否每次都正确:本 patch 维持原写法,意味着 stats 必须是指针而不是数组名——这一假设成立,但容易被未来重构者破坏;如果有人把 __u64 *stats 改成 __u64 stats[]sizeof(stats) 就会变成指针大小,bug 静默复发。建议加一行注释或 BUILD_BUG_ON 之类。
  4. 回归来源未被更早发现:commit cabd76bbc036 是为修 VLA 栈溢出引入的,本身是个正确的改动;但任何「在 if(!ptr) return; 之前/之后插入语句」的改动都应该 review 一下所有调用方对 out 参数的依赖。可考虑引入 __attribute__((nonnull))-Wmaybe-uninitialized 更激进地开启。
  5. 跟进观察:合入 sched_ext/for-7.3 后,需要关注 7.3 合并窗口期间是否有人基于这个修复做进一步清理(例如把 fcg_read_stats 改成 bool 返回,或在 main() 主动把 acc_stats 初始化为 0 防御)。

版本演进

本 patch 只有 v1,无 v2/v3:

  • v1:直接把 memset 上移到 calloc 失败检查之前。
  • Andrea Righi 在评审中未要求修改(从其简短回复推断为 Ack/Reviewed-by 类型)。
  • Tejun Heo 当天直接 pick 进了 sched_ext/for-7.3,说明补丁被认为足够干净。

与其他相关 patch 系列的关联

  • 直接前置:commit cabd76bbc036 ("tools/sched_ext: scx_flatcg: fix potential stack overflow from VLA in fcg_read_stats")——本 patch 是它的直接回归修复
  • 同文件 (scx_flatcg.c) 的演进:scx_flatcg 作为 sched_ext 示例 BPF 程序,长期处于小修小补状态,类似「out 参数未初始化」类问题在其他 *_read_stats 函数中也可能存在,值得一并审计。
  • sched_ext 仓库节奏:Tejun 在该 patch 当天 pick,说明 for-7.3 分支正处在 7.3 合并窗口前的快速收纳期,类似小修复都会快速入树。

一句话总结

这是一个典型的「把 memset 放错了位置」导致的未初始化内存读取 UB 修复:通过把 memset 上移到 calloc 失败检查之前,保证 stats 数组无论分配成败都被清零,堵住了回归 commit cabd76bbc036 引入的隐患,并被 Tejun Heo 当天 pick 进 sched_ext/for-7.3