0%

摘要

这篇报告整理的是我在 StarryOS Linux 兼容性方向做的一组工作。前半段主要是方案一的 syscall 测例,补了 eventfd2signalfd4utimensat;后半段转到方案二,用 Alpine Git 作为测试对象,把 Git 从本地操作一路测到 git://、HTTPS 和 SSH remote。

我选 Git 的原因比较直接:它本身会碰到很多系统行为。本地仓库会频繁改 .git/refs.git/logs,remote 会走 socket 和 TCP loopback,HTTPS 会带出 OpenSSL 和 Python ssl,SSH 又会碰到 OpenSSH 对 socket option 的使用。沿着这条线做下来,除了补测试,也修到了 loongarch64 LASX 状态保存、socket QoS option、recvmsg cmsg 写回这些比较具体的兼容性问题。

Read more »

摘要

本次训练营我的工作主线是:围绕 StarryOS 的 Linux 语义兼容与 systemd 启动链路,持续做「问题复现 -> 根因定位 -> 最小修复 -> 回归测试固化」。

阶段性成果可以概括为三点:

  1. 完成 System V 共享内存一致性测试与内核语义修复,补齐 shmget/shmat/shmdt/shmctl 关键行为。
  2. 推动 systemd 分阶段支持,连续修复 execveatPID 1、mount API 回退、/sys/fs/cgroup 挂载点、/dev/kmsg 写侧等阻塞问题。
  3. 启动共享内核日志环建设(dmesg / /dev/kmsg / journald 共用后端),为后续日志子系统统一打基础。

一、训练营阶段工作

1.1 第一阶段:System V 共享内存语义完善

我在第一阶段以 Linux man-pages 与内核行为为基准,系统梳理了 System V 共享内存接口语义,并围绕 StarryOS 编写了一致性测试。

具体工作

  1. 建立语义基线
  • 对照 Linux 文档逐条梳理 shmget/shmat/shmdt/shmctl 的行为与错误码。
  • 把“正常路径”和“错误路径”都转为可断言的检查项,避免只验证 happy path。
  1. 落地 conformance 测试 test-shm-family
  • 组织 11 段测试、70 条断言,覆盖:
    • shmgetIPC_CREAT/IPC_EXCL/ENOENT/EEXIST/EINVAL
    • shmat 的只读附加、跨进程共享、错误 shmid
    • shmdt 的“必须精确传回原始附加地址”语义。
    • shmctlIPC_STAT/IPC_SET/IPC_RMID 与延迟销毁。
  • 增加 fork 读写验证,确保共享段在进程间真正共享,而非单进程假通过。
  1. 修复内核实现偏差(以测试失败驱动修复)
  • 修复前问题:
    • 未正确处理 IPC_CREAT/IPC_EXCL 组合。
    • 对已有段 size 检查过严,偏离 Linux 语义。
  • 修复后行为:
    • key 不存在且无 IPC_CREAT 返回 ENOENT
    • IPC_CREAT|IPC_EXCL 命中已有 key 返回 EEXIST
    • 仅在请求 size 大于现有段时返回 EINVAL
  1. 多架构验证
  • 在 x86_64/riscv64/aarch64/loongarch64 QEMU 跑通回归。
  • 将“测试可发现、可自动执行”作为合入前门槛,而不是手工验证截图。

对应 issue / PR

关键结果

  • 测例形成了可复用的 conformance 思路:先在 Linux 验证测试,再用同一测试验证 StarryOS。
  • IPC_CREAT/IPC_EXCL/size 等关键语义对齐,避免「功能可用但行为不兼容」的问题。

1.2 第二阶段:systemd 分阶段支持

在这一阶段,我围绕「让 Debian trixie 的 systemd 作为 PID 1 启动」持续打通阻塞链路。工作方式是:每发现一个失败点,就把它拆成独立、可回归验证的小修复。

主线 issue

关键修复与重构

  1. execveat(2) 支持与语义落地
  • 现象:systemd 相关路径需要 execveat,缺失会直接阻塞启动流程。
  • 第一阶段实现:补齐 syscall 分发、dirfd + path + flags 解析、错误码返回与回归测试。
  • 评审中暴露的深层问题:
    • 主可执行文件在已解析后又被“按路径二次解析”,导致 AT_SYMLINK_NOFOLLOW 语义被覆盖。
  • 关键重构:
    • 把 loader 改为“解析一次、按 Location 对象加载”,对齐 Linux 的对象加载模型。
    • 保留 shebang 与 .sh 解释器路径的按路径解析(这部分应当 follow 链接)。
  • 结果:execveat 不只是“能调用”,而是关键 flag 语义可正确端到端生效。

相关 PR:

  1. 让 init 成为真实 PID 1
  • 现象:/proc/1 看起来是 1,但 getpid() 不是 1,systemd 会按“非 system manager”路径退出。
  • 根因:用户可见 pid/tid 与调度器内部 id 混用,procfs 只是“显示层伪装”。
  • 修复:
    • init 进程固定用户可见 pid/tid 为 1。
    • TASK_TABLEkillcloneset_child_tid、未实现 syscall 日志等路径统一使用用户可见 tid。
  • 回归:新增 PID 1 语义测试(如 kill(1,0)getpgid(1)getsid(1))。
  • 结果:PID 1 从“显示为 1”变成“语义上真实为 1”。

相关 PR:

  1. 修复新 mount API 误导探测
  • 现象:fsopen 返回假成功 fd,systemd 误判内核支持新 mount API,然后在后续未实现接口失败,且不回退。
  • 修复策略:
    • 对未实现入口 fsopen/fspick/open_tree 明确返回 ENOSYS(而非 dummy fd)。
    • fsconfig/fsmount/move_mount 的未实现语义保持一致。
  • 回归:新增用例直接断言三个调用返回 -1errno=ENOSYS
  • 结果:systemd 能走回退路径,复用已可用的 mount(2) 链路。

相关 PR:

  1. 补齐 /sys/fs/cgroup 挂载点
  • 现象:即使回退到经典 mount,仍因 /sys/fs/cgroup 不存在导致 ENOENT
  • 根因:sysfs 挂载后会覆盖 rootfs 的同名目录,而自建 sysfs 树缺少 fs/cgroup 节点。
  • 修复:在 sysfs 树显式补齐 fs/cgroup 空目录。
  • 回归:扩展 sysfs 测试,校验目录存在性与 tmpfs 挂载/卸载成功。
  • 结果:cgroup 层级挂载链路被完整打通。

相关 PR:

  1. 新增 /dev/kmsg 写侧设备
  • 背景:journald 在早期启动阶段依赖 kmsg 写入通道。
  • 实现:
    • 新增 char 1:11 设备节点。
    • 每次 write() 视作一条记录,解析可选 <priority> 前缀并映射到日志级别。
    • 读侧暂按 EOF 语义返回,先保证写侧可用。
  • 回归:新增测试覆盖设备号、带/不带前缀写入、无换行写入与读侧 EOF。
  • 结果:systemd/journald 早期日志有了可用落点。

相关 PR:

1.3 第三阶段:共享内核日志环(进行中)

在前面 systemd 启动链路基本可用后,我开始把日志子系统从「多处各自缓存」收敛到「统一日志环」。目标是让 dmesg、/dev/kmsg 读端、journald 共用一套后端。

当前进展

  • 在 axlog 的统一落点捕获每条记录(级别、时间、消息体),避免多条日志链路各自格式化与缓存。
  • 新增 ring.rs 作为当前可用后端,提供 push/read_all/read_record/latest_seq 等基础能力,优先保证接口稳定与可接入。
  • 并行引入 prb.rs(printk ringbuffer 风格)作为下一阶段后端,当前完成主干结构与核心路径,但尚未切换为默认实现。
  • 已明确后续里程碑:
    • 完成 prb 接线与替换。
    • 做 SMP 场景并发压测,验证一致性与开销。
    • 再把 StarryOS 侧 /dev/kmsg 读端、syslog(2) 接到共享环上,形成完整闭环。

相关 PR:


二、方法与经验

2.1 我在本次训练营中形成的方法

  1. 先语义后实现:先明确 Linux 基线行为,再写代码。
  2. 先测试后修复:先用最小失败样例钉住问题,再做实现。
  3. 小步合并:每个 PR 只解决一个明确问题,避免多变量叠加。
  4. 回归前置:每修一个 bug 都补可自动执行的回归用例,防止未来重现。

2.2 典型教训

  1. 「看起来能跑」不等于兼容:像 mount API 假成功这类问题,短期可通过,长期会误导上层。
  2. 系统调用标志位语义不能只做“接受参数”:必须确保 end-to-end 语义真正生效。
  3. 路径层重构往往是功能正确性的前提:execveat 问题本质上是 loader 模型偏差。

三、产出索引

3.1 方案 issue

3.2 PR 列表


四、后续计划

  1. 继续推进共享日志环方案:补齐 prb 接线、并发压测与切换验证。
  2. 补齐 /dev/kmsg 读侧与 syslog(2) 路径对接,形成完整日志闭环。
  3. 继续以回归测试驱动 systemd 兼容缺口,逐步提升发行版可启动、可运行范围。

结营总结

这次训练营对我最大的提升,不只是“写了多少代码”,而是工程方法上的变化:我更重视语义对齐、证据链和回归护栏,也更能把一个复杂问题拆成可验证的小问题逐步推进。

从共享内存一致性到 systemd 启动链路,再到日志子系统统一,我逐步体会到操作系统开发里「小差异会放大成大故障」的现实,也因此建立了更稳健的开发节奏。后续我会继续沿着这条路径,把已完成的阶段成果打磨成可持续迭代的基础能力。

一、项目背景

2026 春季开源操作系统训练营的项目阶段提供了多个实践方向。我参加的是项目一:宏内核 StarryOS。这个方向的核心任务是在已有 StarryOS / tgoskits 代码基础上,围绕 Linux 兼容性、真实应用运行和系统调用语义完善做增量改进。

从项目页给出的任务组织方式来看,StarryOS 的改进可以从单个 syscall 或 bugfix 入手,也可以从真实 Linux app 的运行路径入手,再逐步扩展到更完整的系统能力。我的工作基本涉及到了这两类:

  1. 从 syscall 测例出发修复 futex、robust-list 等基础 ABI 语义;
  2. 从 nginx smoke 测试和后续 Docker 前置能力出发,修复 socket ioctl 和 cgroup2 的早期支持问题。

这些工作由若干个相对独立的小 PR 组成,它们都遵循同一个流程:先用用户态可观察行为暴露 StarryOS 与 Linux ABI 的差异,再通过较小范围的内核修改和测试用例把差异收敛下来。

Read more »

StarryOS 修复与 cgroup v2 模块化的设计、实现与工程复盘

作者:宋红 | 上游:rcore-os/tgoskits | fork:SongShiQ/tgoskits
训练营:2026 春季开源操作系统训练营(项目阶段八周:2026-05-13 ~ 2026-06-28)


一、摘要 / 项目背景与目标

我在 2026 春季 OS 训练营的项目阶段里,在 StarryOS上做了一些真实的开源贡献,也谨以此文记录我在本次训练营中的工作与感悟。

项目主体沿导师”项目总览”给出的三方向递进:

  • 方案一(打基础):选 syscall,走”读 man 手册 → 写用户态测例 → 在 StarryOS 跑、定位 assert 失败 → 修内核”的全流程;
  • 方案二(主线):通过添加配置让真实 Linux 应用(sqlite3 / busybox / llama.cpp / 动态链接程序)在 StarryOS 上跑起来,用真实应用反推 OS 缺陷;
  • 方案三(扩展):cgroup v2 资源控制子系统——这是压轴,计划设计一个可扩展的控制器框架,让 pids/cpu/memory/cpuset/io 五个控制器各自独立、可配置、可插拔。

二、分阶段工作脉络

本次训练营的工作任务分八周逐级展开,每一阶段都为下一阶段打地基:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
基础阶段(前置铺垫,简述)
rCore 闯关 / ArceOS 先导 / Rust / Docker —— 建立"什么是 syscall/进程/文件系统"的基本认知


方案一:syscall 兼容(第一~二周)
#656 dup/fcntl/flock 修 5 个内核 bug —— "读 man→写测例→定位→修内核"全流程跑通
#778 dup2/close_range/ioctl 测例重构 —— 测例本身是最经济的回归保护


方案二:真实应用兼容(第三~四周)
#895 sqlite3 多架构压力测试 / #993 busybox 高副作用 applet
#1006 llama.cpp Alpine/musl / #1041 musl 动态链接 / #1048 glibc 动态链接
#1033 riscv64 static-pie ELF loader 段错误修复(从 llama.cpp 暴露的深层 bug)


方案三:cgroup v2 模块化(第四~八周,压轴)
#1107 写死 match → #1156 迭代仍耦合 →(读 Asterinas SysTree)→
#1234 工厂注册表独立 crate → #1379 L4 资源管控 + manager 接口

递进关系也展现了我能力成长的路径:syscall的修复教会了我读内核调用链,测例体系让我组建了”先 Linux 基线再 StarryOS”的工作流,处理sqlite这类真实应用让我理解了ELF loader 这样的深层 bug,而 cgroup 使我完成了从”会改代码”到”会设计架构”的跃迁。


三、主要工作的详细展开

每个 PR 按闭环结构展开:任务目标 → 实现描述 → 测试方法与环境 → 测试结果与数据 → 结论与链接

3.1 方案一:syscall 兼容性

PR #656 — dup/fcntl/flock 兼容性修复(首个 merged)

  • 任务目标:test_dup_v2.c 的 78 个断言里有 5 个 FAIL,修掉这组内核 bug。
  • 实现描述:
    • fcntl 未识别 cmd 原本返 Ok(0) 兜底 → 改返 EINVAL(错误码即契约);
    • F_DUPFD 忽略 arg → 新增 dup_fd_min(arg),从 arg 起在 fd 表线性搜空位;
    • O_APPEND 不生效 → 在 ax-fs Fileappend: AtomicBool,write 路径检查并主动 seek-EOF;
    • flock/F_SETLK 空存根 → 接上 dev 已合入的 lock.rs dispatch。
  • 测试方法与环境:aarch64 QEMU,先在 glibc Docker 跑 Linux 基线确认规范理解正确,再上 StarryOS。
  • 结果与数据:dup-fcntl 106 个断言,Linux 基线 100% PASS,StarryOS aarch64 75/78(3 个为 ax-fs 设计层限制,诚实标注不在本 PR 范围)。修复 syscall 5 个。
  • 结论与链接:+671/-49,12 文件,7 commits,4 reviews,CI 19✓。https://github.com/rcore-os/tgoskits/pull/656
  • 代码锚点:kernel/src/syscall/fs/fd_ops.rskernel/src/file/fs.rs

PR #778 — dup2/close_range/ioctl 测例重构(纯测试 PR)

  • 任务目标:把 #656 的 800 行单文件 test_dup_v2.c 拆成可维护的 parts 化骨架,并为新 syscall 补测例。
  • 实现描述:test_framework.hCHECK_RET/CHECK_ERR_SAVED 宏;800 行拆成 Part 01–26;新写 test-dup2(7 parts×36 断言)、test-close-range(5 parts×49 断言)、test-ioctl(4 parts×11 断言)。
  • 测试结果:Linux 基线 202 PASS / 0 FAIL / 8 observe / 1 skip(observe = 规范允许的合理变体,不当 FAIL)。StarryOS QEMU 因 tokio pipe 已知问题阻塞,诚实标注非测试逻辑问题、不伪造通过率。
  • 结论:+2347/-419,31 文件,6 commits,4 reviews,CI 20✓,1 天合并。4 套测例 / 42 parts / 202 断言https://github.com/rcore-os/tgoskits/pull/778

3.2 方案二:真实应用兼容与测试体系

PR #895 — sqlite3 CLI 多架构压力测试

  • 任务目标:让 Alpine 发行版的 sqlite3 CLI(musl 完整栈)在 StarryOS 四架构跑通。
  • 实现描述:smoke 组 S0–S4(进程能起、IO 通路在)+ deep 组 S5–S8(DELETE journal/COMMIT/ROLLBACK、WAL 持久化、500 行 + integrity_check、64KiB BLOB 重开);apk add --no-cache sqlite 触发 Alpine 包安装。
  • 测试结果:aarch64/x86_64/riscv64/loongarch64 smoke+deep 全 PASS;SQLite 3.51.2,QEMU 10.2.1。2 组 / 9 测试点 / 4 架构 ×2 = 8 toml
  • 关键调试故事:loongarch64 我一开始把内存提到 2G,reviewer 质疑”其他架构 512M 你为啥 2G”,定位后发现是 apk-curl 既有问题,不是 sqlite3 需要——改回 512M、timeout 提到 600s 解决。这是被 review 教会”先定位问题再调参”的典型。
  • 结论:+592/-0,16 文件,11 commits(4 个 Merge dev),10 reviews,CI 23✓。https://github.com/rcore-os/tgoskits/pull/895

PR #993 — busybox 7 高副作用 applet 安全失败测试

  • 任务目标:测 insmod/fdflush/raidautorun/killall5/rdev/setlogcons/resize 这类”真做会破坏环境”的 applet。
  • 实现描述:不测真实行为,测错误处理路径——给不存在的设备/模块,验返回非零 + 合理错误信息(rc≠124 非 timeout,进一步 rc≠0 且输出含 “No such”/“not found” 等)。resize 输出终端转义序列污染 EXIT 解析 → 重定向 /dev/null 只取 RC;为防 util-linux CI timeout 误判,单独提 commit 调高 timeout + 加 completion marker(先观测再调参)。
  • 结论:+103/-2,4 文件,3 commits,3 reviews 全批准,CI 23✓,1 天合并。新增 7 applet 测试。crontab/crond 因 daemon hang 诚实留 follow-up。https://github.com/rcore-os/tgoskits/pull/993

PR #1006 — llama.cpp Alpine/musl 兼容性迁移

  • 任务目标:让大模型推理运行时 llama.cpp 在三架构跑通(mmap/浮点/内存压力更大)。
  • 实现描述:CMAKE_CROSSCOMPILING=ON+CMAKE_C_COMPILER_WORKS=ON 跳过交叉编译 try_run;QEMU TCG 不支持 RVV → -DGGML_RVV=OFF;x86_64 加 -cpu max;riscv64 static-pie 段错误 → 当时用非 PIE 静态链接规避(这正是 #1033 的根因首次暴露)。
  • 测试结果:三架构 tok/s 基线(aarch64 1.39 / x86_64 0.60 / riscv64 0.72),SmolLM2-135M Q4_0。loongarch64 工具链 404、mmap 未验证、aarch64 L5 kretprobe panic 全部诚实写入 PR body。
  • 结论:+240/-2,12 文件,9 commits,2 reviews,CI 23✓。https://github.com/rcore-os/tgoskits/pull/1006

PR #1041 — musl 动态链接 smoke test(3 架构)

  • 任务目标:验证 ELF loader 的 INTERP 段加载(ld-musl-*.so.1)与 NEED 依赖解析——动态链接是”像 Linux 一样”的关键。
  • 实现描述:最小程序 dynamic-test.c(跑不起来就说明 INTERP 加载本身有问题);readelf -l 提取 INTERP 按 basename 安装 ld-musl;riscv64 加 -Wl,--strip-debug(lld 不支持 RISC-V debug relocation),x86_64 加 -cpu max
  • 结论:+309/-0,10 文件,7 commits(最多 review 的 PR 之一,7 轮,主要命名规范),CI 24✓。https://github.com/rcore-os/tgoskits/pull/1041

PR #1048 — glibc 动态链接测试(3 架构)

  • 任务目标:glibc 比 musl 严苛得多(ld-linux 复杂、pthread 依赖 NPTL、regex 依赖 locale),跑通说明 ELF loader 与 syscall 兼容已相当成熟。
  • 实现描述:4 子测试逐步深入——glibc-smoke(基础)/proc-self-exe(readlink /proc/self/exe)/pthread(clone/tls/futex)/regex(POSIX 正则);额外加 Debian rootfs 变体(glibc INTERP 与 Alpine musl 不同,toml 分开)。
  • 结论:+437/-1,16 文件,9 commits,4 reviews,CI 24✓。https://github.com/rcore-os/tgoskits/pull/1048

3.3 方案二深层:OS bug 根因修复

PR #1033 — riscv64 static-pie ELF loader 段错误修复

  • 任务目标:#1006 暴露的 riscv64 static-pie(Type=DYN)二进制直接段错误,这是 ELF loader 根因 bug——修好它意味着 StarryOS 能正确加载现代 Linux 发行版默认的 PIE 二进制。
  • 调试与实现(7 commits 逐层递进):
    1. QEMU 捕获 segfault 地址 → 反查 ELF 程序头 → 发现 relocation 目标在未映射区;
    2. 新增 vaddr_to_file_offset()(搜 PT_LOAD 段算 segment_offset+(vaddr-segment_vaddr))替代错误的 checked_sub(base);
    3. populate_area() 确保 relocation 写入前页面已映射;
    4. R_RISCV_64/R_RISCV_JUMP_SLOT,st_value==0 未定义符号跳过不覆写 GOT,R_RISCV_COPY 计数跳过;
    5. static-pie-test 回归测试 + busybox 回归确认不影响非 PIE 路径。
  • 结论:+402/-0,6 文件,7 commits,4 reviews,CI 25✓。修复 1 个 OS bug,影响 riscv64 所有 PIE 二进制加载https://github.com/rcore-os/tgoskits/pull/1033

3.4 方案三:cgroup v2 模块化

cgroup 是技术深度最高、最能体现”学习→构建→反思→重做”的工作,在第五章与第六章详写:

  • #1107:cgroup v2 核心框架 + pids/cpu 控制器,但写死在 CgroupNode 的 match 分支——加控制器要改核心结构。
  • #1156:在用户已有基础上迭代(try_fork CAS、bandwidth throttling 框架),10 轮 review、7 次 CHANGES_REQUESTED,仍非模块化,主动关闭。
  • #1234:读 Asterinas SysTree 后重构为 ax-cgroup 独立 crate(工厂注册表 + CgroupController trait),5 控制器、~18 属性、6 设计巧思。当前 OPEN,CHANGES_REQUESTED。
  • #1379:在 #1234 框架上把控制器从”状态正确”推进到”逻辑闭环 + 可测 + systemd/docker 可探测”,10 commits,Draft,base=dev 独立(与 #1234 平行,非 stack)。

四、量化成果与效果

4.1 总览量化表

维度 数据
PR 总数 12(8 merged + 2 主动关闭 +#1234 OPEN + #1379 Draft)
代码贡献(merged 合计) +5101 / -473,107 文件,59 commits
review 往返(merged 合计) 38 reviews
CI(merged 合计) 181 ✓ + 193 跳过(架构无关)
测试断言(syscall/测试类) 202 个(Linux 基线全 PASS)
架构覆盖 aarch64 / riscv64 / x86_64 / loongarch64
修复 syscall 7(dup/dup2/dup3/fcntl/flock/close_range/ioctl)
OS bug 根因修复 1(riscv64 static-pie ELF relocation)
Linux app 适配 5(sqlite3/busybox/llama.cpp/musl-dynamic/glibc-dynamic)
cgroup 控制器 5(pids/cpu/memory/cpuset/io)
cgroup 属性 ~18 个
cgroup 设计巧思 6 个
cgroup host 单测 8 模块 / 41 测试函数(全部 cargo test -p ax-cgroup 通过)
cgroup 新增代码(goal 分支) +1401 行,22 文件,10 commits(vs feat/cgroup-unified-rebased)

4.2 cgroup 三次尝试的架构演进

维度 #1107 #1156 #1234
架构 内嵌 match 分支 内嵌,迭代改进 独立 crate + 工厂注册表
控制器数 2(pids/cpu) 2(pids/cpu) 5(pids/cpu/memory/cpuset/io)
可扩展性 加控制器改核心 加控制器改核心 实现 trait + 注册 1 行
与内核耦合 强耦合 强耦合 依赖倒置(CgroupProvider)
Review 轮次 1 10(7 改) 38(20 改)
状态 主动关闭 主动关闭 OPEN(进行中)

4.3 数据的可信边界

  • 可信指标:断言数、PASS/FAIL 数、CI ✓ 数、代码行数/文件数/commit 数——均可溯源到 PR 或 git/gh 实测。
  • 不完全可信:llama.cpp 的 tok/s 在 QEMU TCG 上是数量级参考,不代表真实硬件性能。
  • 未验证项:cgroup 端到端 QEMU 四架构未全绿;L4 真实资源管控(cpu.max 调度跳过、memory 页分配器 charge、io 块层限流)未端到端验证——详见第六章诚实缺口。

五、cgroup构建详述

5.1 工厂注册表统一框架(整个项目的技术核心)

解决的耦合:#1107/#1156 把每个控制器的逻辑写死在 CgroupNodematch controller_name 分支里,加一个控制器就要改核心数据结构,违反开闭原则,我参考了Linux和星绽os的实现,对starryos的cgroup部分做了框内核风格的设计,以追求cgroup与内核的解耦,也能更符合starryos的模块化设计理念。

框架设计:

1
2
3
4
5
6
ax-cgroup(#![no_std],OS 无关独立 crate)
├── controller.rs: CgroupController trait + CgroupControllerFactory trait + FACTORY_REGISTRY
├── core.rs: CgroupNode 层次树
├── pids/cpu/memory/cpuset/io.rs: 5 控制器各实现同一 trait
├── provider.rs: CgroupProvider trait(依赖倒置)
└── lib.rs: membership + fork/exit/migrate 事务 + 属性统一分发

扩展成本:全局 FACTORY_REGISTRYArc<dyn CgroupControllerFactory>,启动时 5 行注册;新增一个控制器 = 实现 CgroupController trait + 加 1 行注册。VFS 层不认识任何具体控制器,只按 "控制器.属性" 命名约定统一分发。

5.2 六个设计巧思

  1. 依赖倒置(CgroupProvider trait):ax-cgroup 是 no_std OS 无关组件,但 migrate/exit 需要查”进程是不是僵尸”。定义 CgroupProvider trait(is_zombie/get_cgroup/set_cgroup/current_uid),内核实现 KernelCgroupProvider,组件反过来调内核。意义:组件可独立发布、可单测、可跨 OS 复用、对于多人并行开发友好。
  2. RAII 两阶段提交:fork 时先扣 pids 配额,但子进程 spawn 可能失败 → begin_fork() 返回 CgroupForkGuard,spawn 成功才 commit(),否则 Drop 自动 uncharge_path 回滚。编译器保证任何提前退出路径都不泄漏。
  3. 最近公共祖先(LCA)扣费:migrate 时只对两条 path-to-root 各自独有的前缀段扣/退费,公共段不动。避免”迁移到兄弟 cgroup 时父节点配额瞬时翻倍”的假超限。
  4. CAS 无锁计数:try_charge_localcompare_exchange 循环——先读 current,>=max 则拒,否则 CAS+1。消除 TOCTOU 窗口,多核同时 fork 不会突破上限。
  5. 属性名一次解析:parse_attr_name("pids.max") → ("pids","max"),只切第一个 .,一处定义三处复用;cpuset.cpus.effective 也正确(suffix 保留 cpus.effective)。
  6. domain 控制器与”无内部进程”规则:cgroup v2 约束”内部节点不能既有子 cgroup 又有进程”(针对 domain 控制器)。用 is_domain() 布尔位 + write_subtree_control 启用前检查实现。

5.3 host 侧测试体系(测试驱动架构验证)

8 个测试模块 / 41 个测试函数,全部在开发机经 cargo test -p ax-cgroup 运行,不依赖 QEMU。MockProvider 替代内核 provider:

模块 覆盖
roundtrip(297 行) 5 控制器全部属性 write→read-back,含 EINVAL/EPERM 错误码语义
cpu_logic weight→nice 映射单调性、BandwidthState 状态机(quota/consume/reset)
cpuset_logic effective_intersect 纯函数 + 层次 recompute
memory_charge charge/uncharge 对称、超限拒绝+events++、migrate 搬运、层次扣费
delegation 权限规则(root/uid/delegation)、subtree_control 门控、pids.events 计数
manager_iface cgroup.events populated 跟踪子树、cgroup.stat 后代计数、memory.stat key
parse 属性名解析、内存值解析(“1K”/“512M”)
mock MockProvider(per-pid 分配/zombie/UID 模拟)

5.4 ELF loader 修复

见 3.3。vaddr_to_file_offset() 正确处理 PIE 虚拟地址到文件偏移转换,populate_area() 确保 relocation 写入前页面已映射,影响 riscv64 所有 PIE 二进制。


六、出错调试与根因复盘

6.1 cgroup 三次尝试:最有价值的试错

#1107/#1156 为何关闭:把控制器写死在 match 分支里,加一个控制器就要改核心结构。#1156 经 10 轮 review、7 次 CHANGES_REQUESTED,reviewer 的反馈越来越集中在”架构不对”。同时我也意识到老师更想要的是组件化/可配置的 cgroup,而不是”能跑就行”,再回看这两个实现都停留在深耦合的简单cgroup,这种架构非常不利于后续controller开发和优化。

转折点——读 Asterinas(星绽)SysTree:#1156 关闭后我借助ai的帮助花两天快速学习 Asterinas 的 SysTree 框架以及框内核的设计思想,学到:SysTree 是 Model(数据)、VFS 是 View、Controller 是逻辑的 MVC 模式;每个控制器实现同一 SubControl trait,通过工厂注册;全局 registry 存工厂(而不是实例)。这是整个训练营最大的”学习→构建”转折——从”写代码能跑”到”设计架构可扩展”。

教训:这次的开发经历也让我感慨良多,系统设计,架构先行。在系统开发中应该先想清楚”加一个新控制器要改几处代码”,再动手写。代码能跑不等于架构对,同时也开阔了我的眼界,以前操作系统书上只学过宏内核与微内核,这次领略到了框内核的魅力。

6.2 典型并发 bug 的根因

bug 现象 根因 修复
TOCTOU 竞态 can_fork()+fork() 两步,多核同时通过检查后都递增,current 超 max 检查与递增非原子 try_fork() CAS 单原子操作
exit 下溢 current 为 0 时 fetch_sub(1) 下溢成负 无下界保护 fetch_update 处理 current==0 不再减
procs 写入污染 PID 不存在时仍 push 进 procs 列表 缺存在性检查 返回ESRCH
config 破 CI .cargo/config.toml include 从可选改必选,缺文件 CI 失败 include 写法错误 恢复optional=true

6.3 host 段错误根因

cgroup host 单测一启动就 SIGSEGV。我没有盲目加 #[allow] 绕过,而是定位到 SpinNoIrq 依赖 kernel_guard 的特权 IRQ 指令(cli/sti)在 host 上非法。按仓库已有范式(仿 starry-process)在 dev-dependency 启用 ax-kspin/host-test feature 解决。根因定位优于绕过。

6.4 CI 慢 → 多分支开发

CI 特别慢(每次 push 等很久),因而我学会了同时维护 feat/cgroup-unified(cpuset+registry)和 pr/cgroup-modularize(PR 提交)两个分支,用 git stash 在分支间切换。确实提高了效率。

6.5 被判定为反优化的方向

  • 不上半成品 L4 强制逻辑:cpu.max 若让调度器跳过 throttled 任务但没处理 busy-spin,会引入忙等;memory 若 charge 失败直接杀进程,会误杀。所以宁可停在”扣费 + 置标志”,把”调度器消费标志”留作清晰接入点。
  • 不为测试改可见性:parse/format 是模块私有函数,我没有为了测试改成 pub(污染公共 API),而是通过 factory new_instance()→write_attr/read_attr 的真实公共路径做端到端 round-trip。

七、可复用设计模式与后续方向

7.1 可复用经验

  • 跨实现测例纪律:先 Linux 基线 100% PASS 再上 StarryOS,才能区分”规范理解错”还是”内核没实现”;observe 标合理变体不当 FAIL。
  • 先观测再调参:#895 loongarch 内存、#993 util-linux timeout 都是”先加 timing/completion marker 观测,再定位根因,最后才调参”。
  • 工厂注册表 + trait 解耦:加扩展项 = 实现 trait + 注册 1 行,VFS/调用方不认识具体实现。
  • 依赖倒置守 no_std 边界:OS 无关组件经 provider trait 回调内核,而非直接依赖内核。

7.2 L1–L4 分层诚实评估

含义 状态
L1 属性 I/O 闭环 每属性能读/写/校验 ✅ 5/5 控制器
L2 统一注册框架 trait + factory + registry ✅ 完全达成
L3 内核集成接线 mount/fork/exit/migrate 事务 ✅ 框架达成,pids 真正生效
L4 真实资源管控 限制作用于调度/内存/IO pids 全闭环;cpu半闭环;memory/cpuset/io 留接入点

cpu.max 半闭环的准确状态(不夸大):goal 分支自建 ax_task::set_tick_hook/throttled API,tick hook 已注册(cgroup/mod.rs::init),每 tick 按 cpu.max 配额扣费、耗尽即置 throttled 标志、跨周期复位——扣费与标志已生效;仅差调度器 pick_next_taskis_throttled() 跳过任务这一环。所以是”准闭环”,不是 pids 那种完全闭环。

7.3 后续方向(开放式)

  • 接通 cpu L4 最后一环:调度器消费节流标志;
  • memory charge 接页分配器 alloc_frame;
  • io 接块层 QoS(等 rdif-block queue layer);
  • cgroup 端到端 QEMU 四架构跑出控制台 PASS;
  • goal 分支 10 个 commit 经 #1379 择机转正式 PR;ax-cgroup 合入 dev。

八、仓库与文档链接清单

链接/位置 状态
上游主仓库 https://github.com/rcore-os/tgoskits
我的 fork https://github.com/SongShiQ/tgoskits
PR#656 syscall dup/fcntl/flock /pull/656 merged
PR#778 dup2/close_range/ioctl 测例 /pull/778 merged
PR#895 sqlite3 多架构 /pull/895 merged
PR#993 busybox applet /pull/993 merged
PR#1006 llama.cpp /pull/1006 merged
PR#1033 riscv64 static-pie 修复 /pull/1033 merged
PR#1041 musl 动态链接 /pull/1041 merged
PR#1048 glibc 动态链接 /pull/1048 merged
PR#1107 cgroup 首次尝试 /pull/1107 CLOSED(主动关闭)
PR#1156 cgroup 迭代 /pull/1156 CLOSED(主动关闭)
PR#1234 ax-cgroup 独立 crate /pull/1234 OPEN
PR#1379 cgroup L4 + manager 接口 /pull/1379 Draft
ax-cgroup 组件源码 components/ax-cgroup/

九、个人心得体会

9.1 VibeCoding拙见

时至今日,大语言模型已经深度进入代码生成、问题求解、系统搭建和交互设计的各个环节。我认为,用codex、claude code cli 等 coding agent 进行软件开发的本质是多人接力协作开发 。实际上与LLM的每轮对话就是与一个全新的”人”在对话,而与coding agent的每轮对话就是与一个 能看到历史接力情况的 “人”对话。

一、概念完整性,新增的代码能不能与代码库中已有代码相洽。

“概念的完整性要求设计必须由一个人,或者非常少数互有默契的人员来实现。”——《人月神话》。

在我看来,coding agent本质上可以看成多人接力协作,而如何让人们劲往一处使用,历史早就已经给出答案:纲领。在开发初始可以首先与agent协同探讨出整体的开发”纲领”,简洁,凝练的阐述设计思路,同时最重要的是开发过程中这个纲领性文档的维护一定不能放任Agent自由修改,如果交给agent去做,与它不曾存在过别无二致。

实际践行 :对于阶段性目标规划、实现方案等文档在产出时一定要对文档通读并内化,即便是由ai执笔,也应该要看明白整个文档思路。利用xhigh或更高思考等级的coding agent review 出建议修改点之后,利用轻量模型快速应答的特性厘清每一个建议点和设计思路,避免跑偏和过度设计,这样在以后自然而然就能培养起Technical Taste。纲领性文档确定后,在开发过程中尽可能避免二次修改了,仅允许人工增补。

二、vibe coding 时项目随着开发进行复杂度不断上升,逐渐失控的问题。

在大型项目开发过程中,随着人手的增加,项目的开发效率并非一直提高,甚至过多的人手投入会导致项目进度滞后。我认为这也正是大家都深有体会的vibe coding的痛点,本质上还是因为coding agent是多人接力协作,这导致vibe coding时,当前窗口发了几轮提示词,约等于让几个”人”投入了当前项目,所以项目的复杂度会越来越大逐渐不可控。

**实际践行:** 重视提示词工程。比如说现在要让ai帮我做一件事,这件事又涉及a、b、c三个步骤,不要先让ai做a,做完了跟ai说继续做b。而是,一段提示词里交代明白。前者相当于三个人协作完成了一件事。重视任务拆解。有了上述直觉,任务拆解显得尤为重要,让ai完成的这一件事的粒度大小要把控好,当粒度过大时,就需要具体的施行文档对agent做进一步约束。

三、coding agent能不能代码写好,核心在于”单一职责”。

在实际开发中,面对任务给的一份初始需求,通常的做法就是先转成研发可用、ai可读的prd。接着,将prd转成trd。最后,按照trd实现。那么将prd转trd的环节是有多种做法的:1. 一份prd直接转一份trd,这一份trd中拆多个task。2. 一份prd转多个trd切片,每个切片是按照”单一职责”从prd中拆解出来的,单个切片内的task数恰到好处。

**实际践行:** 我在开发中多次采用了第二种做法,尤其是在构建cgroup时,不仅隔绝上下文,同时也能提高开发效果,prd按照单一职责去拆成多个trd切片,开发时一个session完成一个切片的实现,下一个切片开新的session实现。

四、方向可以让coding agent定,但掌舵的一定是人。

coding agent本质上是多人接力协作开发,项目主线一旦交给了coding agent,等于说交给了一群”人”,走偏是必然的。 随着coding agent与LLM越来越强大,代码能力相对来说变得不那么重要了,软件工程直觉才是做好vibe coing的关键。如何培养软件工程直觉、工程思维?这里也向大家抛出一个问题。

五、ai写的代码人一定要看。

这点大家都清楚,但痛点在于人怎么去review coding agent写的代码?

**实际践行:** 从系统视角和用户视角去入手解释新增代码做了什么?让agent基于新增代码说一说具体给系统带来了什么新的能力?对系统有什么增益?如果从双视角看都没有太大问题,就可以进入代码走读。代码走读一定要基于起始基点出发,基点的数据流串起了代码的生命线,看看数据最初从哪入?长什么样?顺着数据流去走读代码。

六、很多时候会发现coding agent给的方案是脱离代码库实际实现风格的、甚至是脱离架构风格的、是不符合概念完整性的。

我认为根本原因在于coding agent在写方案时没有彻底领悟已有代码风格,这一步要在构建之初就要严格约束,不然后期构建会逐渐放大实现代码时的问题,实现时轻易就写出了破坏系统概念完整性的代码。

**实际践行 :** 开发者应该总结出一套通用的skill用于构建codegraph,开发风格的构建是因人而异且自我迭代的,核心点就是利用prompt约束ai,让它首先利用codegraph充分获取代码库已有代码事实,确定已经熟悉并立下不得破坏的约束后再进行后续的trd详细撰写。

9.2 致谢

寒来暑往,三个月的时光转瞬即逝,还记得初入训练营时的兴奋和忐忑,彼时我还没想到我能学习并实践出这么多的知识与能力。深深感谢陈老师在项目阶段对我的指导,陈老师对我的鼓励给了我莫大的动力,让我继续钻研os组件化的构建。也感谢操作系统训练营,让我从只懂原理纸上谈兵的学生,变成了真正能够驾驭ai去解决实际问题的学生,这段历程将会是我求学生涯上最宝贵的经历之一。

长风破浪会有时,我的求学之路还在继续,这段经历带给我的远不止是工程上的实践,与ai协作的能力、开发思维的培养更是我在本次训练营收获到的硕果,真心祝愿操作系统训练营越办越好,再次感谢陈渝老师的引路与教诲。桃李不言,下自成蹊。祝陈老师科研顺遂,岁岁常安,在操作系统与开源教育的探索中再谱华章。

摘要

  1. 爬虫程序开发与对比

本实验开发了四个Rust爬虫程序,从三个方面(多进程、多线程、异步协程)对网络爬虫进行了对比:

crawler_process‌:采用多进程模式,使用外部命令。特点是进程隔离性好,但内存开销极大,QPS和平均响应时间在三者中波动较大,整体性能相对较低。
crawler_thread‌:采用多线程模式。性能介于进程和协程之间,内存使用稳定在一个较低的水平。
crawler_async‌:采用基于Tokio的异步协程模式。内存使用虽起点略高,但整体增长平缓,占用最少,资源复用和调度效率最高。

结论‌:对于I/O密集型的网络爬虫应用,异步协程(crawler_async)在内存效率和资源开销方面具有显著优势。

  1. 为异步协程增加优先级调度

在用户态异步协程运行时中,扩展实现了‌完全公平调度算法(CFS)‌ 来支持优先级调度。通过修改执行器代码(参考两个版本的priority_fut和priority100loc程序),使用BTreeMap模拟内核红黑树来维护任务的虚拟运行时间(vruntime),实现了优先级权重(nice值)的差异化调度。

验证测试‌:进行了多组不同优先级场景的长时间压力测试。结果证明,在运行时内不同任务的执行次数与其设置的nice值完全匹配,成功实现了优先级调度。测试也揭示,权重计算本身的复杂度会对全局任务执行总次数产生轻微影响,这是一个有趣的额外发现。

  1. 异步协程状态机分析

本任务深入研究了Rust异步/await语法糖背后的实现原理。通过生成和分析编译器MIR中间表示(特别是*.coroutine_post-transform.0.mir文件),直观地剖析了异步函数如何被编译器转换为一个‌基于枚举(enum)的有限状态机‌。该状态机精确管理了协程在挂起(Suspend)、运行中(包含Unresumed)以及最终完成/恐慌(Returned/Panicked)等不同状态,并智能地在各状态间复用和存储变量的内存,以实现高效、零成本的异步抽象。
总体结论

此次实践系统性地覆盖了异步编程的三个核心层面:

实际应用性能对比‌:确认了异步模型在高并发网络I/O任务中的性能和资源优势。
调度机制扩展‌:成功在用户态实现了基于CFS算法的优先级调度,加深了对操作系统调度原理在异步模型中的融合理解。
底层机制洞察‌:通过分析编译器生成的MIR,揭示了Rust异步协程的状态机本质,将高层的async/await映射为确定的、可分析的底层执行流。

本次实验不仅提升了异步编程技巧,更重要的是建立了从应用性能、运行时调度到底层实现机制的全链路知识体系。

周报链接

异步操作系统训练营开营与目标对齐交流会

异步项目第一周周报(2026-05-11~2026-05-17

异步项目第二周周报(2026-05-18~2026-05-24)

异步项目第三周周报(2026-05-25~2025-05-31)

异步项目第四周周报(2026-06-01~2026-06-07)

爬虫程序执行状态分析

一. 爬虫程序开发与对比

1.1 内容与目标

分别使用进程,线程,协程编写网络爬虫程序,对列表文件中的各个高校网站的首页进行爬取,然后比较它们之间的差异。

1.2 程序描述

项目包括4个rust程序,如下:

  1. crawler_async:这是一个基于Rust异步运行时Tokio开发的‌高性能并发网页爬虫程序‌。它实现了并行抓取多个网站HTML页面,并将其保存到本地文件系统的核心功能。使用Tokio作为异步运行时。通过在main函数上标注#[tokio::main],程序启动了一个Tokio运行时,使得所有异步任务(async)能够高效执行。通过tokio::sync::mpsc(多生产者,单消费者)‌通道(channel)‌ 实现跨任务的解耦式通信。爬虫任务将抓取到的网页内容(名称, HTML)发送到通道,由另一个异步任务接收并写入文件。
  2. crawler_thread:它是一个‌基于 Rust 标准库多线程(std::thread)实现的同步阻塞式网页爬虫‌。程序通过创建多个工作线程并行抓取网页,并利用生产者-消费者模型(使用 std::sync::mpsc 通道)将抓取到的网页内容与耗时统计分析逻辑解耦,最终将抓取的 HTML 内容保存至本地文件系统。同时,该程序在关键执行点集成了对进程内存使用的精细监控。
  3. curl_one:它是将爬虫每个请求的处理,作为一个‌独立的、支持命令行调用的单次网页抓取工具‌,由主进程按需启动,完成“发送请求-保存文件-返回指标”这一原子操作。编译成二进制后由父进程crawler_process并发调用。
  4. crawler_process:这是一个‌基于多进程(子进程)并发机制的网页爬虫程序‌,核心逻辑是通过生成多个系统进程(调用外部命令 curl_one)来实现并行网页抓取,然后汇总统计性能数据。使用 std::thread::spawn 为标准库的多线程,每个线程负责调用一个外部进程(./curl_one)执行单次爬取任务,实现并发。并发度由网站列表长度决定。线程作为任务的发起者与守护者,负责创建子进程、监控其状态。实际工作由子进程执行的外部可执行文件 curl_one 承担。

1.3 测试内容

1.3.1 测试方法

在网络连通的情况下执行3个测试程序(crawler_async、crawler_thread、crawler_process),收集每个对外请求处理的延时,每个请求执行时的内存使用,请求处理的QPS,进行分析。
3个程序的请求都是并发进行,在请求成功返回并处理完毕后进行统计,所以下面测试结果中的请求标号是请求返回顺序的序号,而不是地址文件university-websites.txt中网站排序号。

1.3.2 测试环境

  • 硬件平台:笔记本电脑(CPU:Intel(R) Core(TM) i7-4810MQ CPU @ 2.80GHz, MEM:16GB)上的WSL虚拟机。
  • 操作系统:WSL虚拟机内安装ubuntu24.04
  • 测量轮次:每种情况测试5轮。

1.4 测试结果

1.4.1 多进程爬虫crawler_process

成功返回的结果计数 第一次测试延时(毫秒) 第一次测试内存使用(MB) 第二次测试延时(毫秒) 第二次测试内存使用(MB) 第三次测试延时(毫秒) 第三次测试内存使用(MB) 第四次测试延时(毫秒) 第四次测试内存使用(MB) 第五次测试延时(毫秒) 第五次测试内存使用(MB)
1 709 1269 548 1232 1092 1228 1209 1229 1209 1230
2 784 1239 795 1159 1202 1196 1303 1196 1227 1195
3 888 1200 979 1125 1364 1162 1474 1161 1429 1124
4 1005 1158 1274 1055 1664 1055 1537 1124 1625 1053
5 1120 1086 1404 1045 1818 1028 1709 1051 1833 1023
6 1365 1015 1441 1045 2082 1006 2027 987 2091 977
7 1347 978 1514 980 2129 977 2362 1029 2138 935
8 1419 940 1695 965 2148 941 2373 989 2254 917
9 1544 919 1716 922 2246 912 2383 948 2299 880
10 1653 887 1754 885 2246 912 2404 901 2304 880
11 1694 846 1774 846 2287 828 2428 858 2340 798
12 1843 777 1704 804 2320 790 2440 814 2503 784
13 1960 750 1762 778 2334 747 2472 739 2539 742
14 1998 714 1804 718 2376 710 2857 706 2748 707
15 2116 670 1823 675 2385 667 2868 662 2792 665
16 2126 632 1839 632 2408 623 2879 620 2957 626
17 2163 589 1811 590 2448 580 2892 577 2980 583
18 2312 547 1857 544 2700 544 3310 538 3282 544
19 2611 506 2012 502 2768 499 3393 496 3388 503
20 2772 465 2532 461 2832 457 3503 459 3502 463
21 2909 425 3275 422 3090 419 3508 417 3523 420
22 3136 380 3671 380 3097 378 4492 373 3690 379
23 6253 343 4273 337 3661 335 6063 333 4698 338
24 6289 299 6014 294 4138 293 6903 289 7118 305
25 7200 262 6855 252 4243 252 9664 249 7126 263
26 7252 218 7712 210 6439 208 11631 208 7147 220
27 7316 175 11784 118 7421 167 11671 166 7150 177
28 7390 134 12923 91 11402 126 13605 88 7158 130
29 9178 88 19858 63 11403 127 13758 61 8362 88
30 24726 32 21575 32 15157 34 14773 34
process 统计 QPS 最高内存使用(MB) 平均延时(秒)
第一次测试 0.95 1269 3.12
第二次测试 1.21 1232 4.44
第三次测试 1.38 1228 3.98
第四次测试 1.97 1229 4.81
第五次测试 2.02 1230 3.94
平均值 1.51 1238 4.06

1.4.2 多线程爬虫crawler_thread

成功返回的结果计数 第一次测试延时(毫秒) 第一次测试内存使用(MB) 第二次测试延时(毫秒) 第二次测试内存使用(MB) 第三次测试延时(毫秒) 第三次测试内存使用(MB) 第四次测试延时(毫秒) 第四次测试内存使用(MB) 第五次测试延时(毫秒) 第五次测试内存使用(MB)
1 767 13 425 13 210 13 267 13 385 13
2 1562 14 753 14 463 14 573 14 841 14
3 1619 14 866 15 524 15 826 17 1103 15
4 2421 15 974 17 768 17 826 17 1491 15
5 2608 16 974 17 772 17 873 17 1511 15
6 2651 16 977 17 832 18 936 19 1533 16
7 3012 18 1061 18 878 18 948 19 1830 19
8 3181 19 1069 19 916 19 956 20 1841 19
9 3181 19 1083 19 952 20 977 20 1857 20
10 3195 19 1209 20 955 20 1060 21 1857 20
11 3214 20 1227 20 972 21 1159 22 1976 21
12 3253 21 1276 21 1027 22 1316 23 2003 22
13 3258 21 1416 22 1098 24 1353 23 2003 22
14 3446 21 1416 22 1116 24 1443 24 2238 22
15 3457 22 1513 22 1158 25 1609 24 2576 23
16 3664 23 1786 23 1254 27 1636 25 2693 24
17 3786 24 2011 24 1269 27 1699 25 2715 24
18 4339 25 3142 25 1271 27 1907 26 2735 25
19 4340 25 3481 26 1358 28 2259 27 2830 25
20 4569 26 3644 26 1360 28 2586 28 3060 26
21 4958 26 5661 28 1742 28 2588 28 3113 27
22 5255 27 5661 28 1760 29 5153 28 3203 27
23 5508 27 5663 28 2299 30 6141 29 3387 28
24 5564 28 5685 28 2303 30 6161 29 4287 29
25 7604 29 5833 29 2635 30 7153 30 4820 29
26 9667 30 6691 30 3779 31 7168 30 5121 30
27 9672 30 6697 30 6101 31 7701 31 5128 30
28 11705 30 8720 31 7227 32 8270 31 7159 31
29 16045 30 9791 31 9443 32 9341 32 8062 31
30 11839 31 10418 33 20912 31 8418 32
thread channel统计 QPS 最高内存使用(MB) 平均延时(秒)
第一次测试 1.80 30 4.74
第二次测试 2.53 31 3.42
第三次测试 2.88 33 2.23
第四次测试 1.43 32 3.53
第五次测试 3.56 32 3.06
平均值 2.44 31.60 3.39

1.4.3 异步协程爬虫crawler_async

成功返回的结果计数 第一次测试延时(毫秒) 第一次测试内存使用(MB) 第二次测试延时(毫秒) 第二次测试内存使用(MB) 第三次测试延时(毫秒) 第三次测试内存使用(MB) 第四次测试延时(毫秒) 第四次测试内存使用(MB) 第五次测试延时(毫秒) 第五次测试内存使用(MB)
1 672 19 503 19 310 20 327 19 590 19
2 1382 20 575 20 357 20 451 20 639 20
3 3409 20 674 21 483 21 663 20 1136 22
4 4681 21 867 21 524 21 682 21 1174 23
5 4930 21 869 21 599 21 842 22 1217 24
6 5152 21 1015 22 711 23 920 23 1223 24
7 5730 22 1105 23 825 24 948 24 1226 24
8 5789 23 1176 23 831 24 953 24 1267 25
9 5804 23 1186 24 844 24 956 24 1275 25
10 6361 23 1189 24 910 25 1012 25 1276 25
11 6398 24 1194 24 950 26 1013 25 1398 25
12 6517 24 1395 25 998 26 1041 26 1535 26
13 6674 25 1403 26 1084 26 1072 26 1539 26
14 6969 25 1430 26 1125 27 1176 27 1549 26
15 6974 25 1467 26 1195 27 1212 27 2241 27
16 7190 26 1490 26 1742 27 1240 27 2609 27
17 7201 26 1580 27 1868 27 1257 27 2634 28
18 7451 26 1619 27 1915 27 1308 27 2634 28
19 7521 27 1672 27 1925 27 1858 28 2680 28
20 7727 27 1845 27 1964 27 1886 28 2993 28
21 7754 27 1979 28 2589 28 2059 28 3019 28
22 7768 27 2231 28 2992 28 2097 28 3349 29
23 7895 27 2947 28 3148 28 2451 28 3354 29
24 7898 27 2971 28 3290 28 2782 28 5922 29
25 8567 28 3360 28 5916 29 2923 28 5933 29
26 10666 28 6140 28 5917 29 4379 28 7036 29
27 11692 28 6327 29 5917 29 5332 28 7212 29
28 11860 28 6711 29 7987 29 6383 29 9319 29
29 11869 28 7170 29 9010 29 10620 29 10164 29
30 12838 28 7198 29 10027 30 11600 29 11243 29
31 14126 29 37596 29 15391 29 14742 29 15404 29
async channel统计 QPS 最高内存使用(MB) 平均延时(秒)
第一次测试 2.19 29 7.34
第二次测试 0.82 29 3.51
第三次测试 2.01 30 3.01
第四次测试 2.10 29 2.78
第五次测试 2.01 29 3.70
平均值 1.83 29.20 4.07

1.5 结论

从上面测试结果可以清楚看到3个测试程序在内存使用方面的差异:

  1. 多进程爬虫程序在孵化多进程时内存使用暴增,然后随着子进程退出,内存使用等比下降。
  2. 多线程和异步协程爬虫的内存使用是逐渐增长的。
  3. 异步协程与多线程爬虫相比,初始内存使用较多,反应了tokio异步协程库代码本身占用的内存。但异步协程内存使用增长比多线程平缓,这反应了无栈协程比多线程节省了栈的内存使用。

1.5.1 多进程爬虫crawler_process

优点:
高并发潜力‌:由于每个下载任务都在独立的系统进程中运行,相互隔离,稳定性较好。尤其当部分任务异常(如网络超时)时,对其他任务影响非常小。
简洁可靠‌:程序框架与标准库依赖,无需复杂异步编程。
I/O隔离‌:网络请求与可能的写入操作分散在不同子进程,减少了主进程中大内存占用的风险。
计算与I/O分离‌:主线程仅承担任务分发和进程管理,计算密集型的工作由外部进程处理。

缺点:
资源开销大‌:每次请求启动一个新进程的消耗远大于在进程内复用网络连接或线程执行异步I/O操作,特别是当任务量巨大(如上千级网站)时,可能会占用大量内存,甚至导致系统性能下降。
性能瓶颈‌:进程间切换与内核态调度的开销可能比用户态的异步并发高,尤其频繁启动、结束进程可能成为瓶颈。
外部依赖‌:程序功能依赖于 ./curl_one 命令的外部文件。该程序需要事先编译好并确保在主程序的同级目录下。

1.5.2 多线程爬虫crawler_thread

并发能力‌: 性能上应该介于进程模型(开销最大)和异步模型(开销最小)之间。

I/O密集型任务的效率‌:与异步/并发框架(例如 ‌Tokio‌)相比,thread::spawn 对于 I/O 密集型任务仍然存在显著的开销。每个线程都会阻塞等待网络响应,当并发线程数(网站数目)非常大时(成百上千级),将会因线程切换和上下文开销而增加 CPU 负载,可能降低整体性能。

线程数量的可控性‌:程序没有采用‌线程池‌模式,而是为每个网站创建一个线程。当网站数量很多时,创建的大量线程可能导致系统内核调度上的瓶颈。合理的线程池模式(如使用 rayon 库)能够更好地控制系统开销。

1.5.3 异步协程爬虫crawler_async

与多进程爬虫和多线程爬虫相比,使用内存最少,切换开销最小。爬虫程序主要受网络抖动影响,性能差异并不明显,但在内存开销方面可以看到异步协程有明显优势。

1.6 代码链接

crawler_async代码

crawler_thread代码

crawler_process代码

curl_one代码

二. 用户态协程增加优先级调度

2.1 内容与目标

  1. 目标:选择一种执行流机制,在其中扩展优先级支持。
  2. 首先为《rust语言圣经》中异步章节的异步执行器增加优先级调度机制。训练营上一阶段对CFS算法有详细讲解,初步将其移植到异步执行器中,代替原有的通道,形成对异步协程的优先级调度机制。
  3. 为《100行无栈协程》中的异步执行器增加优先级调度机制。将CFS算法移植到《100行无栈协程》中的异步执行器中,实现对异步协程的的优先级调度。

2.2 程序描述

2.2.1 priority_fut

这是一个‌基于自定义完全公平调度器(CFS)实现的可设置优先级的 Rust 异步任务执行器程序‌。程序的核心目标不是执行具体的网络爬虫或计算任务,而是‌研究、演示和验证自定义调度策略在异步编程中的应用‌,特别是在多任务场景下,通过完全公平调度算法(CFS)和优先级控制,观察不同优先级任务的执行顺序如何被影响。

本程序调度的核心算法——‌完全公平调度(Completely Fair Scheduler, CFS)‌ 的 Rust 实现。它通常通过记录和比较任务的虚拟运行时间(vruntime)来决定下一个调度的任务,优先级会影响 vruntime 的增长速度,使得高优先级任务获得更多执行时间。
这里的 CFScheduler 负责管理一个 CFSTask 的任务队列,提供如 pick_next_task、put_prev_task、set_priority、task_tick 等方法,来控制任务的出入队列、优先级设置和时间片调整。

理解算法的生效方式‌:为了使 CFS 的优先级效果在短期测试(8秒定时)中肉眼可见,程序需要大量快速的 “poll + 返回 Pending” 循环来快速消耗 CPU 时间并推进行调度器的虚拟运行时间计算(例如 spawn 方法后 1024 次调用 task_tick)。

模拟长期调度环境‌:CFS 的设计初衷是在较长时间尺度(比如秒级、毫秒级的真实调度)上维护公平性,但简短的纯软件测试难以体现该算法的权重累积效果。因此开发者通过手动干预让 CPU 快速地进行计算与调度决策,以便在一个演示程序中直观地看到优先级差异。

然而,值得注意的是,由于大量人工干预调度计算,这不属于可直接生产环境使用的调度库,而是‌自定义异步调度机制的原型研究与演示‌。

2.2.2 priority100loc

代码实现了一个基于完全公平调度器(CFS)算法的异步任务运行时。该程序主要用于演示如何在用户态通过命令行参数控制多个并发任务的执行优先级,并统计不同优先级下任务的执行次数。

ArceOS官方的CFS算法地址链接

priority100loc程序是直接将arceos的CFS算法代码直接复制过来,放在项目main.rs的尾部,并做了一些修改,以适合用户态使用,以及调用CFS的私有方法:

priority100loc的CFS算法链接

程序构建了一个简易的操作系统级调度器模型,主要包含以下核心组件:

  • CFS 调度器:模拟了 Linux 内核的完全公平调度器(CFS)逻辑。它使用 BTreeMap 作为红黑树的替代,根据任务的虚拟运行时间(vruntime)来决定下一个执行的任务,确保高优先级(权重高)的任务获得更多的 CPU 时间。
  • 异步运行时:实现了一个简易的 Executor,能够接收异步闭包(Future)并执行。它通过一个空的轮询循环(run 方法)来驱动任务。
  • 优先级控制:支持通过命令行参数设置 6 个任务的优先级(nice 值),范围为 -20 到 19。

程序使用 clap 库解析输入:

  • --prio:接受 6 个空格分隔的整数,分别对应 6 个任务的优先级。
  • -d / --delay_task_one:一个布尔标志,用于决定是否让任务 1 引入微小的延迟(50 微秒),这会显著影响其调度频率。

程序运行 30 秒后停止,并输出每个任务内部原子计数器的值,用于直观展示优先级对任务执行频率的影响。

2.3 测试内容

2.3.1 测试方法

由于priority_fut进行较多人工干预,可以熟悉异步协程及CFS算法原理,但人工干预较多,说明效果相对低,这里主要针对priority100loc进行测试。
运行priority100loc, 通过使用不同的命令行参数为6个任务设置各种优先级,或让任务1睡眠50微秒,通过30秒内各个任务运行的次数观察优先级的执行情况。因为优先级越高(–prio的值越小),任务得到运行的机会越高,运行的次数也就应该比低优先级的任务高。也就是高优先级执行次数多,低优先级执行次数少
每一场景重复进行5次测试,各个场景的测试交错进行,收集6个任务的执行次数进行分析。
任务号从1开始。
所有任务都是同质的,只有当启用--delay-task-one时,任务1会每次执行会增加50微秒的睡眠。

2.3.2 测试环境

  • 硬件平台:笔记本电脑(CPU:Intel(R) Core(TM) i7-4810MQ CPU @ 2.80GHz, MEM:16GB)上的WSL虚拟机。
  • 操作系统:WSL虚拟机内安装的ubuntu24.04
  • 测量轮次:每一场景重复测试5次。

    2.3.3 测试场景

  1. 测试1:任务3优先级为-18,其它任务优先级为0,优先级列表为: [0, 0, -18, 0,0, 0]
  2. 测试2:任务3优先级为+18,其它任务优先级为0,优先级列表为: [0, 0, 18, 0,0, 0]
  3. 测试3:所有任务优先级都为0,优先级列表为: [0, 0, 0, 0,0, 0]
  4. 测试4:任务的优先级逐渐下降,优先级列表为: [-15, -10, -5, 0,5, 10]
  5. 测试5:任务在每次执行时睡眠50微秒,所有任务优先级都为0,任务优先级列表为: [0, 0, 0, 0,0, 0]
  6. 测试6:所有任务优先级都为-19,任务优先级列表为: [-19, -19, -19, -19,-19, -19]
  7. 测试7:任务的优先级交错设置,优先级列表为: [3, -2, 16, -8,0, 17]
  8. 测试8:任务的优先级从19逐渐上升,优先级列表为: [19, 18, 17, 16, 15, 14]
  9. 测试9:任务的优先级从-19逐渐下降,优先级列表为: [-19, -18, -17, -16, -15, -14]
  10. 测试10:任务的优先级从-19和+19之间来回切换,优先级列表为: [-19, 19, -19, 19, -19, 19]
  11. 测试11:所有任务优先级都为-19,任务优先级列表为: [-19, -19, -19, -19,-19, -19]
  12. 测试12:任务1优先级为-16,其它为-5,任务优先级列表为: [-16, -5, -5, -5,-5, -5]

    2.4 测试结果

    2.4.1 测试1

任务号 第1次执行 第2次执行 第3次执行 第4次执行 第5次执行 平均值
1 524426 495667 563711 523726 510096 523525
2 537517 527176 582233 512081 539515 539704
3 32874928 33498971 34807078 33877244 33740747 33759794
4 495608 490358 552831 499765 519064 511525
5 492120 512088 552502 514952 542661 522865
6 542960 479696 576577 479768 528296 521459
总执行次数 35467559 36003956 37634932 36407536 36380379 36378872
运行命令 ./priority100loc –prio 0 0 -18 0 0 0

2.4.2 测试2

任务号 第1次执行 第2次执行 第3次执行 第4次执行 第5次执行 平均值
1 7495654 7411949 7505272 7654233 7713202 7556062
2 6887367 7503910 7539407 7849526 7652740 7486590
3 117721 111570 119324 131796 125602 121203
4 7438860 7550351 7531448 7614096 7545250 7536001
5 7287715 7626784 7609210 7530652 7549708 7520814
6 7507020 7528093 7046191 7794306 7676440 7510410
总执行次数 36734337 37732657 37350852 38574609 38262942 37731079
运行命令 ./priority100loc –prio 0 0 18 0 0 0

2.4.3 测试3

任务号 第1次执行 第2次执行 第3次执行 第4次执行 第5次执行 平均值
1 6338273 6346417 6161578 6220578 6317908 6276951
2 6182495 6352568 6307718 6257312 6214059 6262830
3 6352438 6258802 6192278 6452642 6322180 6315668
4 6466035 6351864 6227901 6403985 6463463 6382650
5 6505075 6235031 6337990 6558159 6439099 6415071
6 6309518 6350785 6306983 6451381 6277962 6339326
总执行次数 38153834 37895467 37534448 38344057 38034671 37992495
运行命令 ./priority100loc

2.4.2 测试4

任务号 第1次执行 第2次执行 第3次执行 第4次执行 第5次执行 平均值
1 24807902 24519290 24504660 25056134 24887297 24755057
2 7273175 7378454 7346925 7252905 7352835 7320859
3 2320327 2319150 2254512 2407879 2295268 2319427
4 766136 731679 760759 762991 735433 751400
5 234500 215286 233259 236636 233538 230644
6 71329 71775 64473 68911 57431 66784
总执行次数 35473369 35235634 35164588 35785456 35561802 35444170
运行命令 ./priority100loc –prio -15 -10 -5 0 5 10

2.4.5 测试5

任务号 第1次执行 第2次执行 第3次执行 第4次执行 第5次执行 平均值
1 796 792 871 789 819 813
2 7625964 7676110 7663107 7824023 7676540 7693149
3 7675830 7708657 7534059 7618943 7608375 7629173
4 7706226 7744701 7694302 7751725 7610052 7701401
5 7720577 7476638 7683668 7737662 7555287 7634766
6 7785577 7759579 7637143 7771900 7619092 7714658
总执行次数 38514970 38366477 38213150 38705042 38070165 38373961
运行命令 ./priority100loc –delay-task-one

2.4.6 测试6

任务号 第1次执行 第2次执行 第3次执行 第4次执行 第5次执行 平均值
1 6128323 5986477 6065969 6198079 5886064 6052982
2 6124712 6052704 6070059 6115089 5967384 6065990
3 6122994 5972958 6039993 6123538 6023912 6056679
4 6309840 6123930 6021390 6070900 5879856 6081183
5 6313224 6132050 6189299 6082905 6055913 6154678
6 5640508 6071286 6043216 6013185 5931930 5940025
总执行次数 36639601 36339405 36429926 36603696 35745059 36351537
运行命令 ./priority100loc –prio -19 -19 -19 -19 -19 -19

2.4.7 测试7

任务号 第1次执行 第2次执行 第3次执行 第4次执行 第5次执行 平均值
1 2044583 1984462 1886239 1991459 1982130 1977775
2 6379222 6315280 6416028 6468360 6473837 6410545
3 91546 118726 86618 113121 95582 101119
4 26041482 25786572 26292617 26132147 26741641 26198892
5 4203781 3959375 4221686 4321252 4200423 4181303
6 74461 78767 75413 95555 85478 81935
总执行次数 38835075 38243182 38978601 39121894 39579091 38951568
运行命令 ./priority100loc –prio 3 -2 16 -8 0 17

2.4.8 测试8

任务号 第1次执行 第2次执行 第3次执行 第4次执行 第5次执行 平均值
1 3411393 3413169 3350819 3349387 3300040 3364962
2 4005013 4071409 4066249 4077452 4024824 4048989
3 5191674 5263767 5174058 5092429 5125709 5169527
4 6742951 6450670 6645637 6647389 6549705 6607270
5 8026476 8102475 8209471 8276922 8331433 8189355
6 10254394 10356769 10284942 10311101 10363921 10314225
总执行次数 37631901 37658259 37731176 37754680 37695632 37694330
运行命令 ./priority100loc –prio 19 18 17 16 15 14

2.4.9 测试9

任务号 第1次执行 第2次执行 第3次执行 第4次执行 第5次执行 平均值
1 9737352 9714999 9839490 9714043 9522830 9705743
2 7417436 7505936 7550560 7537489 7488182 7499921
3 6172818 6153440 6290409 6122554 6088414 6165527
4 4851346 4799726 4874082 4671378 4802577 4799822
5 3740114 3849336 3743193 3740868 3700960 3754894
6 3006319 3166688 3100174 3002087 2998361 3054726
总执行次数 34925385 35190125 35397908 34788419 34601324 34980632
运行命令 ./priority100loc –prio -19 -18 -17 -16 -15 -14

2.4.10 测试10

任务号 第1次执行 第2次执行 第3次执行 第4次执行 第5次执行 平均值
1 11794818 11925685 11940234 11994454 11948733 11920785
2 4426 1455 6290 3057 2411 3528
3 11974606 12054148 11919065 11933364 12103960 11997029
4 1561 2198 873 1632 244 1302
5 11758884 12215031 11971916 12077625 11840660 11972823
6 2447 2691 1710 4726 4405 3196
总执行次数 35536742 36201208 35840088 36014858 35900413 35898662
运行命令 ./priority100loc –prio -19 19 -19 19 -19 19

2.4.11 测试11

任务号 第1次执行 第2次执行 第3次执行 第4次执行 第5次执行 平均值
1 6548657 6600643 6538788 6469139 6454396 6522325
2 6674471 6517480 6516750 6489857 6593603 6558432
3 6454296 6596074 6525975 6495802 6574747 6529379
4 6522890 6597248 6493671 6455683 6570316 6527962
5 6636044 6538444 6567737 6514242 6516768 6554647
6 6445091 6454171 6526455 6399061 6579185 6480793
总执行次数 39281449 39304060 39169376 38823784 39289015 39173536
运行命令 ./priority100loc –prio 19 19 19 19 19 19

2.4.12 测试12

任务号 第1次执行 第2次执行 第3次执行 第4次执行 第5次执行 平均值
1 27397743 27526233 27636269 27586946 27891687 27607776
2 2147835 2112519 2163235 2084309 2117882 2125156
3 2184851 2217172 2155626 2103135 2191366 2170430
4 2086911 2100050 2161729 2139182 2186779 2134930
5 2124831 2149577 2053953 2183482 2134972 2129363
6 2106303 2221812 2159033 2126517 2128585 2148450
总执行次数 38048474 38327363 38329845 38223571 38651271 38316105
运行命令 ./priority100loc –prio -16 -5 -5 -5 -5 -5

2.4.13 每次测试执行的任务总数统计

测试条件(30秒6个任务运行总次数) 结果为5次统计平均值
任务3优先级为-18,其它为0 36378872
任务3优先级为18,其它为0。 37731079
所有任务优先级为0。 37992495
任务优先级为[-15,-10,-5,0,5,10]。 35444170
所有任务优先级为0,任务1每次睡眠50微秒。 38373961
所有任务优先级为-19。 36351537
任务优先级为[3,-2,16,-8,0,17] 38951569
所有任务优先级为+19 39173537
任务1优先级为-16,其它为-5 38316105
任务优先级为[-19,19,-19,19,-19,19] 35898662
任务优先级为[-19,-18,-17,-16,-15,-14] 34980632.2
任务优先级为[19,18,17,16,15,14] 37694329.6

2.5 结论

  1. 各个测试结果说明实际运行结果符合优先级的设定。
  2. 测试5说明:单个任务睡眠,但在调度器中仍计数运行时间,所以即便优先级相同,这一任务运行次数仍会下降。
  3. 从2.4.13节《每次测试执行的任务总数统计》中可以看出,各种场景的任务总数是有稍许差异,这与CFS算法的机制有关。在CFS算法中,优先级数字(-20~+19)每个都对应一个权重值,0对应权重1024,优先级数值越大权重值越小,优先级数值越小权重值越大。-20的权重是88761,-19是71755;而+19的权重值是15,+18的权重值是18。CFS算法在每次计算权重时,需要计算以权重值为除数的除法,CPU计算5位数的除法和2位数的除法是有细微差异的,5位数除法的计算复杂度稍高计算耗时也稍长。在几千万次的循环过程中,这一细微差异被放大。这就造成,如果测试优先级包含较大负数(例如:-19)时,执行任务的总数会下降,因为优先级运算消耗了更多CPU时间

2.6 代码链接

priority_fut代码地址

priority100loc代码地址

三. 异步协程状态机分析

3.1 内容与目标

分析异步协程状态机的内部机制和执行流转换。

3.2 Rust程序MIR文件介绍

MIR‌是‌Mid-level Intermediate Representation‌的缩写,翻译为‌中级中间表示‌,是Rust编译器内部用于表示程序代码的一种中间格式,是Rust编译流程里非常关键的一步。

Rust编译源代码到最终机器码的大致流程是:
源代码(Rust语法) → 抽象语法树(AST) → HIR(高级中间表示) → MIR(中级中间表示) → LLVM IR/机器码

Rust异步状态机,本质就是在MIR阶段生成的:async fn,源代码会被编译器拆分为:一个状态枚举(每个await点对应一个状态)+ 一个实现了Future的结构体。 这个拆分和生成过程,就是在MIR阶段完成的,因此我们能通过查看MIR,直接看到编译器生成的异步状态机结构。

3.3 获得MIR文件方法

  1. 在项目Cargo.toml的[profile.dev]节中增加调试选项
1
2
3
4
[profile.dev]
debug = 2          # 对应 -g (生成完整调试信息)
opt-level = 0      # 关闭优化,防止变量被优化掉
lto = false        # 关闭链接时优化,保留更多符号信息
  1. 构建时增加dump-mir选项,生成的mir文件在目录mir_dump下
1
2
rustup install nightly
RUSTFLAGS="-Z dump-mir=all" cargo +nightly build

3.4 异步协程需要关注的文件

dump到的.mir文件非常多,与异步协程相关的主要是几个以下面字符串结尾的MIR文件:

  1. ‌.coroutine_before.0.mir‌(通常完整文件名类似 crate_name::module::function::{closure#0}.coroutine_before.0.mir)代表了异步函数(或协程)在‌转换为状态机结构之前、但在初步 lowering 之后‌的中间表示。
  2. ‌.coroutine_async_drop_expand.0.mir‌(或类似名称)的文件,代表了编译器在处理异步代码时一个非常特定且关键的阶段:‌异步 Drop 展开(Async Drop Expansion)‌。
  3. coroutine_drop_proxy_async.0.mir‌(或类似名称如 coroutine_drop_glue)代表了编译器为异步协程(Async Coroutine)生成的‌析构代理(Drop Proxy/Glue)‌的 MIR。
  4. coroutine_drop.0.mir‌(通常完整路径包含函数名,如 fn_name::{closure#0}.coroutine_drop.0.mir)是 Rust 编译器为异步协程(Async Coroutine)生成的‌ 标准析构函数(Drop Implementation) 的 MIR 表示。异步 Future 的“清理脚本”。
  5. coroutine_post-transform.0.mir(或类似名称,如 .coroutine.after_lowering)代表了异步协程(Async Coroutine)在完成‌ 核心状态机转换Coroutine Lowering‌ 之后、但在进行大规模优化之前的 MIR 状态。这是你看到的‌最终形态的异步状态机代码。在此之前,代码还是一个包含 yield 的普通函数;在此之后,它变成了一个真正的、基于枚举的状态机结构。
  6. coroutine_pre-elab.0.mir‌(通常完整名称包含 elaborate_drops 或类似字样,如 fn_name.coroutine_pre-elaborate-drops.0.mir)代表了异步协程在‌ 析构细节展开Drop Elaboration‌ 之前的 MIR 状态。
  7. coroutine_resume.0.mir‌(或类似名称,如 fn_name::{closure#0}.coroutine_resume.0.mir)代表了异步协程(Async Coroutine)中专门用于‌处理“从挂起状态恢复执行”逻辑‌的 MIR 片段。

3.5 *.coroutine_post-transform.0.mir文件分析

分析核心状态机转换,主要在这一文件中。从field_tys和variant_fields可以静态获得状态机状态并大体构建执行流。

  1. CoroutineLayout::field_tys 包含所有跨.await生存的变量,即状态机中存储的变量。
  2. CoroutineLayout::variant_fields 是状态机转换的各个阶段需要存储field_tys中的哪些变量,例如:
    • Unresumed(0): [], - 初始状态,尚未开始执行
    • Returned (1): [], - 协程已正常完成
    • Panicked (2): [], - 协程因 panic 终止
    • Suspend0 (3): [_s0, _s1, _s2, _s3], - 第一个挂起点,存储s0, s1, s2, s3。
    • Suspend1 (4): [_s0, _s1, _s5, _s8], - 第二个挂起点,存储s0, s1, s5, s8。
    • Suspend2 (5): [_s0, _s1, _s4, _s6, _s8], - 第三个挂起点,存储s0, s1, s4, s6, s8。
    • Suspend3 (6): [_s0, _s1, _s4, _s7, _s8], - 第四个挂起点,存储s0, s1, s4, s7, s8。

3.6 使用大模型分析.mir文件

.mir文件很繁琐,通读需用很多时间。在了解其结构的基础上,可以将.mir文件直接上传到大模型,AI可以很快梳理出状态机状态变化和协程执行流。

下面是大模型对爬虫的异步函数save_response的分析结果:

该异步函数被编译成一个包含 ‌7 个变体(Variants)‌ 的状态机枚举,对应不同的执行阶段: 状态变体定义

1
2
3
4
5
6
7
‌Unresumed(0)‌ - 初始状态,尚未开始执行
‌Returned(1)‌ - 协程已正常完成
‌Panicked(2)‌ - 协程因 panic 终止
‌Suspend0(3)‌ - 第一个挂起点(等待目录创建)
‌Suspend1(4)‌ - 第二个挂起点(等待文件创建)
‌Suspend2(5)‌ - 第三个挂起点(等待数据写入)
‌Suspend3(6)‌ - 第四个挂起点(等待缓冲区刷新)

状态机字段分析 跨状态存活字段

1
2
3
‌_s0‌: std::string::String - 函数参数 name
‌_s1‌: std::string::String - 函数参数 body
‌_s8‌: bool - 内部标志位,用于跟踪某些状态

各状态特有字段 Suspend0 (状态3)

1
2
‌_s2‌: &str - 目录路径字符串 "websiteshtml/"
‌_s3‌: tokio::fs::create_dir_all 的 Future 对象

Suspend1 (状态4)

1
‌_s5‌: tokio::fs::File::create 的 Future 对象

Suspend2 (状态5)

1
2
‌_s4‌: tokio::fs::File - 已打开的文件句柄
‌_s6‌: tokio::io::util::write_all::WriteAll - 写入操作的 Future

Suspend3 (状态6)

1
2
‌_s4‌: tokio::fs::File - 文件句柄(继续使用)
‌_s7‌: tokio::io::util::flush::Flush - 刷新操作的 Future

执行流程分析

  1. 初始化和日志输出

    函数开始时输出日志:”开始进入函数save_response, 名称:” 设置目录路径为 “websiteshtml/“

  2. 状态转换流程 阶段1:创建目录 (Suspend0 → Suspend1)

    调用 tokio::fs::create_dir_all(“websiteshtml/“) 输出日志:”函数save_response, 名称: 第一次.await之前” 等待目录创建完成 输出日志:”函数save_response, 名称: 第一次.await之后”

阶段2:创建文件 (Suspend1 → Suspend2)

1
2
3
4
5
构建文件名:name + ".html"
调用 tokio::fs::File::create(file_name)
输出日志:"函数save_response, 名称: 第二次.await之前"
等待文件创建完成
输出日志:"函数save_response, 名称: 第二次.await之后,第三次之前"

阶段3:写入数据 (Suspend2 → Suspend3)

1
2
3
调用 file.write_all(body.as_bytes())
输出日志:"函数save_response, 名称: 第三次.await之后,第四次之前"
等待数据写入完成

阶段4:刷新缓冲区 (Suspend3 → Returned)

1
2
3
4
调用 file.flush()
输出日志:"函数save_response, 名称: 第四次.await之后"
等待缓冲区刷新完成
清理资源并返回结果

内存布局优化 存储冲突分析

从 storage_conflicts 可以看出编译器进行了内存优化:

1
2
3
4
5
6
7
_s0 和 _s1 与所有其他字段冲突,说明它们在整个生命周期中都存在
_s2(目录路径)只在 Suspend0 阶段使用
_s3(目录创建 Future)只在 Suspend0 阶段使用
_s4(文件句柄)在 Suspend2 和 Suspend3 阶段使用
_s5(文件创建 Future)只在 Suspend1 阶段使用
_s6(写入 Future)只在 Suspend2 阶段使用
_s7(刷新 Future)只在 Suspend3 阶段使用

字段生命周期管理

编译器通过 variant_fields 精确控制每个状态需要保存的字段:

1
2
3
4
‌Suspend0‌: 保存 _s0, _s1, _s2, _s3
‌Suspend1‌: 保存 _s0, _s1, _s5, _s8
‌Suspend2‌: 保存 _s0, _s1, _s4, _s6, _s8
‌Suspend3‌: 保存 _s0, _s1, _s4, _s7, _s8

错误处理机制

状态机包含完整的错误处理路径:

1
2
3
4
每个异步操作都有对应的 Result 类型返回
使用 Try trait 的 branch 方法进行错误传播
通过 FromResidual trait 将 std::io::Error 转换为 anyhow::Error
每个挂起点都有对应的清理代码(cleanup basic blocks)

性能特点

1
2
3
4
‌零成本抽象‌:状态机大小由最大变体决定(Suspend2 和 Suspend3 最大)
‌内存复用‌:编译器通过存储冲突分析优化内存使用
‌最小化移动‌:字符串等数据只在必要时移动
‌精确的析构‌:每个状态结束时正确清理资源

调试信息

MIR 中包含了完整的调试信息:

1
2
3
每个字段都有对应的源码位置(span)
变量名映射(如 debug name => ((_1 as variant#6).0: std::string::String))
作用域信息(scope[0], scope[1] 等)

这个状态机展示了 Rust 异步编程的核心优势:将高级的 async/await 语法转换为高效、确定性的状态机,同时保持内存安全和零成本抽象。

3.7 结论

通过分析MIR文件,可以静态获得异步协程函数状态机状态及状态改变的完整过程,并能从中构建出异步协程的执行流。

可以用CoroutineLayout::field_tysCoroutineLayout::variant_fields来复核AI分析的输出结果是否正确。

3.8 代码链接

crawler_async代码

PPT链接

2026春训练营总结报告PPT

我参加的 “基于协程异步机制的操作系统/驱动” 项目分为 3 个阶段, 在一阶段, 我主要阅读了 tokio-tutorial, 了解了 Rust 中的异步编程常见结构和异步执行方式, 产出了一个爬虫程序, 该爬虫主要对 进程, 线程, 协程 三种实现的并发程序进行相互比较, 进一步让我理解了协程的作用以及和进程/线程的区别。

在第二阶段, 我主要阅读了 embassy 的源码和文档, embassy 有点类似于 tokio, 区别是 embassy 是一个运行在嵌入式裸机上的异步运行时, 而 tokio 则大量使用了标准库(即操作系统)提供的一些方法。我的产出是对 mini-tokio - 一个 tokio-tutorial 里教学用的最小化异步运行时进行了优先级支持, 即每个任务拥有各自的优先级, 优先级高的任务在调度时会被优先执行。

来到了第三阶段, 我主要阅读了 embassy_preempt 的源码, 了解其执行流。 embassy_preempt 实现了协程之间的抢占, 即在定时器发出中断后保存其上下文并切换任务, 由于抢占这件事在现实中发生的次数一般较少, 我们认为这样操作带来的性能损失是可以接受的; 同时, 这也在某种程度上实现了进程和协程的统一: 协程在被抢占时退化为线程, 在再次执行并让权时自然的恢复为协程。我主要的贡献为将 embassy_preempt 从 stm32f401re 移植到 stm32f103c8 开发板上, 并为 embassy_preempt 实现了 Future 的 FFI 化, 即我们可以在手动构造一个 Future 并通过 FFI 传给 embassy_preempt 执行, 给异步任务的构造提供了新的选择。


在从 stm32f401re 到 stm32f103c8 的移植过程中, 我主要做了以下改动:

F103C8 仅有 20KB SRAM 和 64KB Flash,而 F401RE 拥有 96KB SRAM 和 512KB Flash。

1
2
3
4
5
MEMORY
{
RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 20K
FLASH (rx) : ORIGIN = 0x8000000, LENGTH = 64K
}

OS_ARENA_SIZE 定义任务控制块分配器的容量。F401 设置为 10240 字节,可容纳约 80 个任务;F103 的 20KB 总 RAM 使其必须缩小至 4096 字节(约 30 个任务)。APB_HZ 是定时器的输入时钟频率。STM32F103 的最高系统时钟为 72MHz(F401 为 84MHz),该值直接用于计算 TIM3 的预分频器: psc = APB_HZ / TICK_HZ - 1

1
2
3
4
5
6
7
8
9
#[cfg(feature = "stm32f401re")]
pub const OS_ARENA_SIZE: USIZE = 10240;
#[cfg(feature = "stm32f103c8")]
pub const OS_ARENA_SIZE: USIZE = 4096;

#[cfg(feature = "stm32f401re")]
pub const APB_HZ: INT64U = 84000000;
#[cfg(feature = "stm32f103c8")]
pub const APB_HZ: INT64U = 72000000;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
#[cfg(feature = "stm32f103c8")]
pub const STACK_START: *mut u8 = 0x20001400 as *mut u8;
#[cfg(feature = "stm32f103c8")]
pub const STACK_SIZE: usize = 6 * 1024;

#[cfg(feature = "stm32f103c8")]
pub const PROGRAM_STACK_SIZE: usize = 1024;
#[cfg(feature = "stm32f103c8")]
pub const INTERRUPT_STACK_SIZE: usize = 1024;

#[cfg(feature = "stm32f103c8")]
pub const HEAP_START: *mut u8 = 0x20002C00 as *mut u8;
#[cfg(feature = "stm32f103c8")]
pub const HEAP_SIZE: usize = 2 * 1024;

一开始我编译运行后 BSS 段实际占用 0x20000038-0x200011EC(约 4.5KB), 而 STACK_START 设于 0x20001000,正处于 BSS 范围内。init_stack_allocator() 初始化时通过 FixedSizeBlockAllocator 在 0x20001000 处建立链表元数据, 随后 __aeabi_memclr8 运行 BSS 清零,将已分配的元数据结构覆盖为全零, 导致后续 alloc_stack() 返回空指针或访问损坏的链表, 触发 BusFault → HardFault。移至 0x20001400 后解决。

STACK_SIZE 影响系统可支持的最大并发抢占数, PROGRAM_STACK(1KB) + INTERRUPT_STACK(1KB) 固定消耗 2KB, 剩余 (STACK_SIZE - 2KB) / TASK_STACK_SIZE 为最大同时被抢占的任务数。以 6KB 栈池和 1KB 每任务计算,可支持 4 个任务同时处于被抢占状态。preempt_test 需要 3 个抢占栈,最初的 4KB 设置仅余 2KB(2 个栈), 在分配第 3 个时栈池返回空指针导致 panic, 需扩容至 6KB。

HEAP_START 和 HEAP_SIZE 需紧随 STACK 末尾,避免中间产生间隙浪费 SRAM。2KB 足以满足 lazy_static 和 arena 之外的零星 alloc 需求。

F103 与 F401 的 RCC 外设属于完全不同的两代设计, F401 将 PLL 配置置于独立寄存器 RCC.pllcfgr(),包含 PLLM/PLLN/PLLP/PLLQ 四个分频系数,源自 F4 系列为 USB/SDIO/I2S 等多种外设提供独立时钟的需求。F103 的 PLL 参数直接集成在 RCC.cfgr() 中,仅有一个倍频系数 PLLMUL, 因为 F1 系列只需为 CPU 和简单外设提供时钟。此外 F401 拥有 PLLI2S 和 PLLSAI 且需在启动时显式禁用, F103 则完全没有这些外设。Flash 等待周期也不同: F401 使用 set_prften() 使能预取缓冲, F103 对应方法名为 set_prftbe()

TIM3 定时器在两种芯片上使用了相同的 timer_v1.rs PAC 模块和 TIM_GP16 类型,因此 enable_Timer() 及所有定时器操作代码无需修改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#[cfg(feature = "stm32f103c8")]
fn rcc_init() {
use stm32_metapac::rcc::vals::{Pllmul, Pllsrc, Pllxtpre};

// 启用 HSE (8MHz 外部晶振)
RCC.cr().modify(|v| { v.set_hseon(true); });
while !RCC.cr().read().hserdy() {}

// 配置 PLL, 源=HSE, 倍频=9 -> 72MHz
RCC.cfgr().modify(|v| {
v.set_pllsrc(Pllsrc::HSE_DIV_PREDIV);
v.set_pllxtpre(Pllxtpre::DIV1);
v.set_pllmul(Pllmul::MUL9);
});

// 设置 Flash 等待周期, 72MHz 需要 2 个等待周期
FLASH.acr().modify(|v| {
v.set_latency(Latency::WS2);
v.set_prftbe(true);
});

// 启用 PLL
RCC.cr().modify(|v| { v.set_pllon(true); });
while !RCC.cr().read().pllrdy() {}

// 配置总线分频, AHB=72MHz, APB1=36MHz, APB2=72MHz
RCC.cfgr().modify(|v| {
v.set_hpre(Hpre::DIV1);
v.set_ppre1(Ppre::DIV2);
v.set_ppre2(Ppre::DIV1);
});

// 切换系统时钟到 PLL
RCC.cfgr().modify(|v| v.set_sw(Sw::PLL1_P));
while RCC.cfgr().read().sws() != Sw::PLL1_P {}
}

F401 和 F103 的 GPIO 是两代完全不同的外设设计。F401 使用 4 个独立寄存器分别配置每个引脚的模式 MODER、输出类型 OTYPER、速度 OSPEEDR和上下拉 PUPDR, 每个寄存器为 16 个引脚各分配 2 位。F103 将所有配置压缩在 CRL/CRH 两个寄存器中: 每个引脚占 4 位, 低 2 位为 MODE(输入/2MHz/10MHz/50MHz), 高 2 位为 CNF(推挽/开漏/复用功能)。CRL 管理引脚 0-7, CRH 管理引脚 8-15。

此外, 时钟总线也不同: F401 的 GPIO 挂在 AHB1 总线上(RCC.ahb1enr()), F103 的 GPIO 挂在 APB2 上(RCC.apb2enr())。

F103 实现使用 core::ptr::write_volatile 直接操作硬件地址而非 PAC 抽象, 原因之一是调试 LED 问题时需要绕过 PAC 逐层验证寄存器写入是否生效, 二是在两个芯片的 PAC 类型系统不兼容的情况下, 直接内存访问反而更清晰。

LED 极性也值得注意: Blue Pill 板载 LED 连接 PC13 且为低电平有效(输出 LOW=灯亮,HIGH=灯灭)。Embassy 官方 blinky 示例使用 Output::new(p.PC13, Level::High, Speed::Low) 将初始电平设为 HIGH(灯灭),与我们的 LED_OFF() 逻辑一致。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// F401
#[cfg(feature = "stm32f401re")]
pub fn LED_Init() {
RCC.ahb1enr().modify(|v| v.set_gpioaen(true)); // AHB1 总线
GPIOA.moder().modify(|v| v.set_moder(5, vals::Moder::OUTPUT));
GPIOA.otyper().modify(|v| v.set_ot(5, vals::Ot::PUSHPULL));
GPIOA.ospeedr().modify(|v| v.set_ospeedr(5, vals::Ospeedr::HIGHSPEED));
GPIOA.pupdr().modify(|v| v.set_pupdr(5, vals::Pupdr::FLOATING));
}

// F103
#[cfg(feature = "stm32f103c8")]
pub fn LED_Init() {
unsafe {
// GPIO 时钟在 APB2 上(F401 在 AHB1)
let r = core::ptr::read_volatile(0x40021018 as *const u32);
core::ptr::write_volatile(0x40021018 as *mut u32, r | (1 << 4));
asm!("dsb"); asm!("isb");

// F103 使用 CRH 寄存器,每 4 位编码一个引脚的全部属性
let crh = core::ptr::read_volatile(0x40011004 as *const u32);
core::ptr::write_volatile(0x40011004 as *mut u32,
(crh & !(0xF << 20)) | (0x2 << 20));
// bits[23:20] = 0b0010 → CNF=00(推挽), MODE=10(2MHz)
}
}

bottom_driver 模块是一个按键中断驱动,用于测试外部中断触发的任务抢占。它操作 F401 的 SYSCFG 外设来选择 EXTI 线的映射引脚。F103 没有 SYSCFG 外设,替代方案是 AFIO(Alternate Function I/O), 但两者寄存器布局、字段名称、使能位全部不同。该驱动不是 RTOS 核心调度功能的前提——任务的创建、定时器、优先级调度、PendSV 上下文切换均不依赖它, 因此直接对 F103 禁用。测试中使用 bottom_driver 的 bin(如 bottom_test, time_performance 等)在 F103 上无法编译, 这是预期行为。

1
2
3
4
5
6
7
8
9
10
// port/mod.rs
#[cfg(feature = "stm32f401re")]
pub mod bottom_driver; // F401-only,使用 SYSCFG 外设

// os_core.rs
#[cfg(feature = "stm32f401re")]
use bottom_driver::BOT_DRIVER;

#[cfg(feature = "stm32f401re")]
BOT_DRIVER.init();

而对于第二个任务, 一个 dyn Trait 显然无法通过 FFI 传递, 需要做以下修改:

FfiWaker 用 C 函数指针作为 vtable,把 Rust 的 Waker 包装成 C 可以克隆/唤醒/销毁的指针。FfiContext 把这个指针重新包装回 RustContext,让 C 侧能通过它唤醒任务。

标准库的 Poll 没有 #[repr(C)],不能跨 FFI 边界传递。所以我们需要另一个 Poll

1
2
3
4
5
#[repr(C, u8)]
pub enum FfiPoll<T> {
Ready(T),
Pending,
}

C 侧的对应类型

1
2
3
4
5
typedef struct {
uint8_t _tag; // 0=Ready, 1=Pending
uint8_t _pad[3]; // 对齐到 4 字节
uint32_t value; // 仅 Ready 时有效
} FfiPoll_u32;

内存布局

1
2
3
4
5
Ready(42):   00 00 00 00  2a 00 00 00
^tag=0 ^value=42

Pending: 01 00 00 00 00 00 00 00
^tag=1 ^未使用

同时, 我们将 Waker 视为一个不透明的结构体提供给 C, 我在 Task2 里面描述过 Waker 的内部结构(https://gitlab.eduxiji.net/T2026106149910763/project3136859-388282/-/blob/task2/docs/waker.md), 这里不再赘述。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#[repr(C)]
struct FfiWakerBase {
vtable: *const FfiWakerVTable, // 指向函数指针表
}

#[repr(C)]
struct FfiWaker {
base: FfiWakerBase,
waker: ManuallyDrop<Waker>,
}

#[repr(C)]
struct FfiWakerVTable {
clone: unsafe extern "C" fn(*const FfiWakerBase) -> *const FfiWakerBase,
wake: unsafe extern "C" fn(*const FfiWakerBase),
wake_by_ref: unsafe extern "C" fn(*const FfiWakerBase),
drop: unsafe extern "C" fn(*const FfiWakerBase),
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
static REF_VTABLE: FfiWakerVTable = {
unsafe extern "C" fn clone(data) -> *const FfiWakerBase {
// C 调 clone → 分配新的 FfiWaker (Owned)
let cloned = Waker::clone(&(*w).waker); // 从原始 Waker clone
Box::into_raw(Box::new(FfiWaker {
base: FfiWakerBase { vtable: &OWNED_VTABLE }, // 新的 vtable!
waker: ManuallyDrop::new(cloned),
}))
}

unsafe extern "C" fn wake(data) { unreachable!(); }
// ↑ wake 不应该被调用——FfiWaker 是借来的,
// 不拥有 Waker, 不能消费它

unsafe extern "C" fn wake_by_ref(data) {
w.waker.wake_by_ref(); // 委托给 Rust Waker
}

unsafe extern "C" fn drop(data) { unreachable!(); }
// ↑ drop 也不允许——借来的不能释放

FfiWakerVTable { clone, wake: unreachable, wake_by_ref, drop: unreachable }
};

C 端不能 wake()drop()。这两个操作会消费所有权,但 borrowed waker 没有所有权。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
static OWNED_VTABLE: FfiWakerVTable = {
unsafe extern "C" fn clone(data) -> *const FfiWakerBase {
let cloned = Waker::clone(&(*w).waker);
Box::into_raw(Box::new(FfiWaker { /* ... */ }))
}

unsafe extern "C" fn wake(data) {
let b = Box::from_raw(data as *mut FfiWaker);
ManuallyDrop::into_inner(b.waker).wake(); // 消费 Waker
// b 被 drop, FfiWaker 内存释放
}

unsafe extern "C" fn drop(data) {
let mut b = Box::from_raw(data as *mut FfiWaker);
ManuallyDrop::drop(&mut b.waker); // 安静释放, 不唤醒
// b 被 drop, FfiWaker 内存释放
}

FfiWakerVTable { clone, wake, wake_by_ref, drop }
};

C 端通过 clone() 得到的 waker 是完全拥有的, 可以 wake()drop()、或 clone()

Waker 的上层 Context 同样需要处理。

1
2
3
4
5
6
#[repr(C)]
pub struct FfiContext<'a> {
task: OS_TCB_REF, // TCB 指针,用于 ffi_task_delay
waker: *const FfiWakerBase, // waker 引用,用于 ffi_wake_by_ref
_marker: PhantomData<&'a FfiWakerBase>,
}

C 的 poll 函数接收 *mut FfiContext,通过 with_context() 方法获取标准 &mut Context

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
pub fn with_context<T>(&mut self, f: impl FnOnce(&mut Context) -> T) -> T {
// RUST_VTABLE: 把 FfiWaker 包装成 RawWaker
static RUST_VTABLE: RawWakerVTable = {
// clone → 调用 C vtable 的 clone
// wake → 调用 C vtable 的 wake
// wake_by_ref → 调用 C vtable 的 wake_by_ref
// drop → 调用 C vtable 的 drop
};

let raw = RawWaker::new(self.waker.cast(), &RUST_VTABLE);
let waker = unsafe { Waker::from_raw(raw) };
let waker = ManuallyDrop::new(waker);

let mut cx = Context::from_waker(&waker);
f(&mut cx)
}

Future 同理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#[repr(C)]
pub struct FfiFuture<T> {
pub fut_ptr: *mut (),
pub poll_fn: unsafe extern "C" fn(*mut (), *mut FfiContext) -> FfiPoll<T>,
pub drop_fn: unsafe extern "C" fn(*mut ()),
}


impl<T> Future for FfiFuture<T> {
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<T> {
let task_ref = executor::task_from_waker(cx.waker()); // 获取 TCB
let rust_waker = cx.waker().clone();
let ffi_waker = ManuallyDrop::new(FfiWaker { // 包装 waker
base: FfiWakerBase { vtable: &REF_VTABLE },
waker: ManuallyDrop::new(rust_waker),
});
let mut ffi_ctx = FfiContext::new(task_ref, &*ffi_waker);
let ret = (self.poll_fn)(self.fut_ptr, &mut ffi_ctx); // 调用 C
Poll::from(ret)
}
}

async-ffi 的主要区别是, async-ffi 基于 std 环境, 做了一些额外的包装, 包括捕获 unwind panic 等问题。我在嵌入式领域做了相应的改造, 去除了一些不必要的功能, 同时可以与 no-std 环境适配。


训练营到这里就结束了, 我接下来的想法是继续沿着 “同步与异步统一” 这个问题继续走下去, 先前的 FFIFuture 也是我在这个问题上的初步尝试, 最终形成成果。

SDMMC 驱动异步化技术总结

本报告主要记录了我在2026春夏季开源操作系统训练营在 StarryOS 上开发 SDMMC 块设备驱动以及实现异步读写的全过程,包括最终完整的调用链路、同步与异步双通道设计思路以及遇到的相关技术细节。


一、 项目当前整体内容及当前完整调用链路

1.1 项目当前整体内容

当前项目的开发分为两个流程,首先是原代码的复现工作,在复现过程中进行了部分的小段更改,其次是异步化改造工作,异步化改造主要对axtasksimple-sdmmc两个模块进行更改,同步的流程保持不变,异步的流程基于同步流程进行改造,最终改造后的调用链路如下:

1.2 驱动完整调用链路

支持两种调用链路,分别适配之前的同步调用与现代的异步协程调度:

1.2.1 同步通道完整调用链路 (Synchronous Flow)

同步通道核心是通过阻塞当前 OS 线程来等待硬件中断。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
sequenceDiagram
autonumber
participant VFS as 虚拟文件系统 / 块设备层
participant SdMmc as SdMmc 驱动 (同步接口)
participant HW as SDMMC DMA 硬件
participant WaitQueue as OS 任务等待队列 (WaitQueue)
participant ISR as 中断服务程序 (ISR)

VFS ->> SdMmc: read_block(block_id, buf)
SdMmc ->> HW: 配置 DMA 描述符,启动 DMA 传输
SdMmc ->> WaitQueue: wait_timeout_until() 阻塞当前线程
Note over WaitQueue: 线程状态设为 Blocked<br/>让出 CPU,被移出就绪队列
HW -->> ISR: 传输完成,触发硬件中断
ISR ->> SdMmc: 捕获中断,更新原子标志
ISR ->> WaitQueue: notify_one() 唤醒阻塞线程
WaitQueue ->> SdMmc: 线程恢复运行,执行数据拷贝与清理
SdMmc ->> VFS: 返回读取到的数据

详细调用步骤分解:

  1. 入口发起:上层文件系统/内核调用 SdMmc::read_block(block_id, buf)write_block
  2. DMA 缓冲区准备:驱动配置传输大小,并获取物理上连续的一致性 DMA 缓存区。如果是写操作,将用户 buf 中的数据拷贝进 DMA 缓存。
  3. 进入 send_cmd_idmac
    • 创建描述符:分配一个 DMA 描述符 IdmacDescriptor,配置缓冲区物理地址与大小,并将描述符地址写入 dbaddr 寄存器。
    • 重置标志位:将全局原子变量 IDMAC_DONE_FLAGIDMAC_ERROR_FLAG 置为 false
    • 启动硬件:向 cmdargcmd 寄存器写入指令,并写入 pldmnd 寄存器,启动 SDMMC 硬件及内部 DMA 传输。
  4. 线程阻塞等待
    • 驱动调用
      1
      2
      3
      4
      IDMAC_WAIT_QUEUE.wait_timeout_until(
      Duration::from_secs(1),
      || IDMAC_DONE_FLAG.load(Ordering::Acquire) || IDMAC_ERROR_FLAG.load(Ordering::Acquire)
      )
    • 底层动作:当前线程被放入等待队列,状态修改为 Blocked(阻塞态),当前 CPU 切换上下文去运行其他就绪线程。此时 CPU 没有任何忙等轮询。
  5. 硬件中断触发与唤醒
    • 当 DMA 数据传输完毕,SD/MMC 控制器触发物理中断。
    • CPU 跳转至中断服务程序(ISR):SdMmc::handle_interrupt()
    • ISR 中更新状态:IDMAC_DONE_FLAG.store(true) 并调用 IDMAC_WAIT_QUEUE.notify_one()
    • 调度器收到唤醒通知,将原阻塞线程放回就绪队列,并恢复其执行。
  6. 收尾与返回
    • 线程恢复执行后,从 wait_timeout_until 返回,读取响应寄存器 resp 验证传输正确性。
    • 释放描述符内存,清理中断标志。
    • 如果是读操作,把数据从一致性 DMA 缓存拷贝回用户 buf,返回上层。

1.2.2 异步通道完整调用链路 (Asynchronous Flow)

异步通道专门为协程并发设计,其核心是通过 Rust Future 挂起协程,释放 OS 线程去运行其他任务,等待硬件中断精准唤醒。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
sequenceDiagram
autonumber
participant App as 异步应用 / 协程执行器 (Executor)
participant SdMmc as SdMmc 驱动 (异步接口)
participant HW as SDMMC DMA 硬件
participant WaitQueue as 任务等待队列 (WaitQueue)
participant ISR as 中断服务程序 (ISR)

App ->> SdMmc: read_block_async(block_id, buf).await
SdMmc ->> HW: 配置 DMA 描述符,启动 DMA 传输
SdMmc ->> WaitQueue: wait_timeout_until_async().await 注册挂起
Note over WaitQueue: 创建 EventListener 并绑定 Waker<br/>协程返回 Pending,让出执行权
Note over App: 协程挂起,Executor 调度同一线程下的其他协程
HW -->> ISR: 传输完成,触发硬件中断
ISR ->> SdMmc: 捕获中断,更新原子标志
ISR ->> WaitQueue: notify_one() 唤醒监听者
WaitQueue ->> App: 触发 waker.wake(),协程重新进入就绪队列
App ->> SdMmc: 执行器再次 poll 推进协程
SdMmc ->> SdMmc: 拷贝 DMA 缓冲区数据
SdMmc ->> App: 异步读取完成,返回数据

详细调用步骤分解:

  1. 入口发起:上层异步调用器 .await 执行 SdMmc::read_block_async(block_id, buf)write_block_async
  2. DMA 缓冲区准备:与同步相同,配置大小,如果是写操作则将数据拷入 DMA 一致性内存中。
  3. 调用并 .await 执行 send_cmd_idmac_async
    • 描述符配置:构建 IdmacDescriptor 描述符,关联缓冲区物理地址并写入 dbaddr
    • 重置原子标志:重置 IDMAC_DONE_FLAGIDMAC_ERROR_FLAG
    • 启动硬件:写入 cmdargcmd 寄存器启动命令,并写入 pldmnd 唤醒 DMA。
  4. 协程挂起 (Yield)
    • 驱动执行到异步等待处
      1
      2
      3
      4
      IDMAC_WAIT_QUEUE.wait_timeout_until_async(
      Duration::from_secs(1),
      || IDMAC_DONE_FLAG.load(Ordering::Acquire) || IDMAC_ERROR_FLAG.load(Ordering::Acquire)
      ).await
    • 底层动作
      • 该异步等待返回一个 Future 状态机。
      • 当执行器第一次 poll 该 Future 时,通过 event-listener 在栈上创建一个监听节点,将当前协程的 Waker 登记并挂载到 IDMAC_WAIT_QUEUE 中。
      • 检查条件不满足,返回 Poll::Pending
      • 执行器(Executor)将此协程移出活跃调度链表,底层的 OS 线程立刻去执行其他协程。
  5. 中断精准唤醒
    • 硬件传输完毕触发物理中断,CPU 进入 SdMmc::handle_interrupt()
    • 中断程序将 IDMAC_DONE_FLAG 原子设为 true
    • 调用 IDMAC_WAIT_QUEUE.notify_one() 找到链表里登记的协程 Waker,调用 waker.wake()
    • 执行器把该协程重新放入就绪队列。
  6. 协程恢复与完成
    • 执行器再次 poll 该协程,此时条件满足,wait_timeout_until_async 返回 Poll::Ready
    • 协程退出 .await,读取并验证寄存器,释放描述符。
    • 读操作将数据从 DMA 缓冲搬运至用户 buf。异步读取结束。

二、 初次复现代码的具体改动

2.1 整体代码结构分析 (src 目录)

以下是原代码 src 目录下的核心文件职责与作用分工:

文件名称 核心职责 具体作用描述
[lib.rs] 驱动模块入口 统一导出子模块,向外部公开核心驱动结构体 SdMmc
[sdmmc.rs] 传输与控制逻辑 实现 SdMmc 生命周期与初始化配置流程(时钟配置、卡检测、OCR 协商、CID/CSD 获取等);提供块读写接口 read_blockwrite_block(支持自动判定 DMA/PIO 模式);实现具体命令发送函数 send_cmdsend_cmd_idmac;提供硬件中断处理函数 handle_interrupt 用于接收控制器和内部 DMA 的状态并反馈给等待任务。
[regs.rs] 底类寄存器定义 映射并定义控制器的寄存器块(RegisterBlock)布局,并利用 bitfield-struct 宏安全地建模各个寄存器的位域字段(如 CtrlBModIdIntEnIdStsRIntSts 等),保证寄存器读写的类型安全。
[cmd.rs] 命令与数据传输构建 定义 SD/MMC 命令枚举 Command(如 GoIdleStateAppCmdReadSingleBlock 等),构建出寄存器所需的命令控制字和参数值,并定义数据传输方向结构 DataXfer
[dma.rs] DMA 内存与描述符管理 定义符合 IDMAC 硬件接口要求的 DMA 描述符 IdmacDescriptor 结构;提供物理连续且对齐 of DMA 一致性内存的申请(alloc_coherent)与释放(dealloc_coherent)接口。
[utils.rs] 元数据解析辅助工具 定义并解析 CID(卡识别寄存器)与 CSD(卡特定数据寄存器),用于计算块设备的总容量、卡片块数量以及获取生产元数据。


2.3 复现代码的具体改动

通过对旧版本 (root-simple-sdmmc-extended) 与新版本 (retry-simple-sdmmc-extended-dma) 进行深度代码比对,新版本在保持与旧版本时序对齐的同时,在以下几个关键维度进行了深入的修复与改善:

[!WARNING]

1. 内存泄漏与异常安全 (Critical)

  • 旧版本 (root):如果在“第二阶段(命令响应等待)”中发生了超时,函数会直接返回 None 退出,而未能释放通过 alloc_coherent 申请的 dma_desc_info 物理连续内存。这在硬件超时或出错时会导致严重的系统内存泄漏。
  • 新版本 (retry):在此类所有异常分支(如超时、命令错误)中均补齐了 dealloc_coherent 释放操作,彻底消除了内存泄漏隐患,保证了底层的异常安全。

[!IMPORTANT]

2. PIO 模式下 FIFO 地址访问 Bug 修复 (Correctness)

  • 旧版本 (root):在数据传输循环中,使用 fifo_base.byte_add(offset).read_volatile()。这错误地将数据读写的字节偏移量(offset)加在了寄存器地址上。由于 FIFO 寄存器属于单一端口物理映射,偏移会导致读取非法的内存空间。
  • 新版本 (retry):完全修复了此 Bug。数据传输始终对固定的 fifo_base 地址执行 read_volatile()/write_volatile(),完全符合硬件规范。

[!IMPORTANT]

3. 跨上下文错误传递机制 IDMAC_ERROR_FLAG (Robustness)

  • 旧版本 (root):未定义任何全局错误同步标志位。当中断服务程序(ISR)捕获了致命总线错误(FBE)、描述符不可用(DU)或卡错误(CES),也无法安全地向主线程传递该错误信号,导致主线程在发生硬件故障时只能“假死”直至超时。
  • 新版本 (retry):引入了全局原子变量 pub static IDMAC_ERROR_FLAG: AtomicBool = AtomicBool::new(false);
    • 职责:在 handle_interrupt (中断侧) 捕获硬件异常后,通过 store(true, Release) 原子地设置标志。
    • 优势:在 send_cmd_idmac (主线程侧) 配合 load(Acquire) 进行检查。该机制利用底层 CPU 的缓存一致性协议,在无锁(Lock-free)且不引起死锁的前提下,将硬件级的故障快速、安全地通知给主线程,构成了驱动安全降级与错误恢复的通信基石。

[!TIP]

4. 防御性状态轮询 (Efficiency)

  • 旧版本 (root):数据传输等待缺乏灵敏的中断级错误响应,在出错时只能依靠强行等待 5 秒超时来退出。
  • 新版本 (retry):在数据传输的第三阶段,引入了相较于初始基准状态的防御性错误校验(实时检测新发生的 new_fbe/new_ces/new_du 等),一旦检测到错误即刻跳出循环并释放资源,将故障恢复延迟缩短到微秒级。

5. 数据结构与注释清理 (Code Health)

  • 旧版本 (root)SdMmc 结构体内部保留了大量前期调试残留的、被注释掉的未实现字段(如 support_dma_64bit_addresssg_cpu 等),代码冗余严重。
  • 新版本 (retry):彻底清理了这些冗余字段,使核心数据结构非常精简。同时,补充了详实的中文注释,解释了并发原子操作以及休眠唤醒逻辑。

6. 调试日志降噪 (Code Health)

  • 旧版本 (root):在卡片初始化和时钟配置阶段,大量使用 warn! 级别输出过程日志,在操作系统正常启动时会产生大量无用日志垃圾。
  • 新版本 (retry):将正常的硬件状态快照和初始化过程日志降级为 debug! / trace!,仅在真正发生严重异常时才输出警告,使得内核终端输出更加整洁。

三、 完善异步机制的代码改动

我的这次改造涉及模块 axtasksimple-sdmmc 两个组件,采用了同步与异步双独立通道的设计模式:

3.1 具体的改造内容

3.1.1 axtask 模块:WaitQueue 异步等待扩展

wait_queue.rs 中,为底层基于 event-listener 的挂起队列 WaitQueue 扩展了 4 个非阻塞的异步等待方法,允许协程进行 .await 挂起而不阻塞底层 OS 线程:

  • wait_async():异步等待事件通知。
  • wait_until_async(condition):异步等待直到满足特定条件。
  • wait_timeout_async(dur):异步等待带超时机制。
  • wait_timeout_until_async(dur, condition):异步等待直到满足条件或超时(本项目核心依赖项)。

3.1.2 simple-sdmmc 驱动:非轮询化同步通道

在同步 DMA 发送接口 send_cmd_idmac 中,彻底移除了原有的 CPU 忙轮询循环(原循环通过不断调用 axtask::yield_now() 轮询原子标志,浪费大量 CPU 周期并且无法真正让线程进入 Blocked 状态):

修改前:忙轮询 (CPU 忙等待)
1
2
3
4
while !IDMAC_DONE_FLAG.load(Ordering::Acquire) {
if axhal::time::wall_time() >= deadline { ... }
axtask::yield_now(); // 忙等待,任务仍留在就绪队列
}
修改后:挂起阻塞 (让出 CPU)
1
2
3
4
let dma_irq_timed_out = IDMAC_WAIT_QUEUE.wait_timeout_until(
Duration::from_secs(1),
|| IDMAC_DONE_FLAG.load(Ordering::Acquire) || IDMAC_ERROR_FLAG.load(Ordering::Acquire),
); // 真正的线程阻塞,被移出就绪队列
  • 同步读写映射read_blockwrite_block 保持同步签名,并在内部直接调用该同步的 send_cmd_idmac 接口。当调用发生时,当前 OS 线程会被挂起进入睡眠状态,直到 DMA 中断到来触发 IDMAC_WAIT_QUEUE.notify_one() 或超时。

3.1.3 simple-sdmmc 驱动:原生异步协程通道

在 [sdmmc.rs] 中全面引入了原生的异步协程读写逻辑:

  • send_cmd_idmac_async (异步 DMA 发送核心):新建异步 DMA 命令发送接口。在等待 DMA 中断时,调用 .wait_timeout_until_async(...).await 将当前协程注册挂起。此过程完全不阻塞底层的 OS 线程,线程可以立刻去运行同一个线程内的其他协程。
  • read_block_asyncwrite_block_async (异步读写接口):新增两个公共异步方法。它们通过 .await 挂起等待 send_cmd_idmac_async 执行完成。一旦中断触发唤醒,协程继续在用户态被 poll 推进,完成后续 of DMA 缓冲区数据搬运工作。

3.2 架构模式对比与优劣分析

在开发过程中,我对两种主流的“同步/异步共存”设计模式进行了对比,最终选择了性能最优的独立双通道设计

维度 方案 A:统一异步内核 + block_on 包装(已废弃) 方案 B:独立双通道设计(当前采用)
具体实现 同步的 read_block 内部直接调用 block_on(read_block_async) 同步与异步各走一套底层的 send_cmd_idmac 接口。
代码复用性 。DMA 的设置和拷贝逻辑只写了一遍,没有冗余。 较低。同步路径和异步路径 of DMA 配置结构存在重复。
同步路径性能 略低。每次同步调用都需要额外付出创建协程 Waker、Pin 状态机以及运行 block_on 循环的 CPU 额外开销。 极致(最高)。同步读写直接调用 send_cmd_idmac,为纯同步函数调用,没有任何运行时的额外损耗。
异步路径并发 。协程非阻塞挂起。 。协程非阻塞挂起。
错误调试难度 。同步调用栈中会混入异步状态机的匿名类型与 runtime 调用栈。 极低。同步调用栈就是普通的底层寄存器和锁操作,易于定位。

结论:在操作系统内核和设备驱动开发中,性能和低确定性延迟是第一优先级。因此,方案 B 虽然带来了少量的 DMA 配置逻辑重复,但保证了同步路径的极限吞吐量,是更为妥当的系统级驱动重构设计。


四、 完整的异步读/写执行流

以异步读取 read_block_async 为例,下面详细展示其整体的事件传播与状态转换链路:

4.1 协程 Waker 唤醒机制与 event_listener 的绑定

当底层没有数据到来或 DMA 未完成时,协程必须挂起。唤醒机制的底层运转如下:

  • 挂起与登记
    1. 协程在 wait_timeout_until_async.await 处被执行器 poll 时,会在栈上构造一个 EventListener
    2. EventListener 将自身以链表节点的形式注册到全局 IDMAC_WAIT_QUEUE 中。
    3. event-listener 会在 poll 过程中获取当前协程上下文的 Waker(由上层执行器传入),并将其克隆并绑定到该监听器节点上。
    4. 如果等待的条件(例如 IDMAC_DONE_FLAG)尚未满足,则 poll 返回 Poll::Pending。执行器接收到 Pending 后,会把当前协程移出活跃就绪链表,暂停对其调度。
  • 中断精准唤醒
    1. DMA 数据传输完成,硬件触发物理中断,CPU 强行中断当前线程并跳转到 SdMmc::handle_interrupt() 中断服务程序(ISR)。
    2. ISR 中,驱动清除硬件中断状态并原子 store 将 IDMAC_DONE_FLAG 设为 true
    3. 接着调用 IDMAC_WAIT_QUEUE.notify_one(true)
    4. notify_one 会在等待队列链表中找到该协程对应的 EventListener,提取出其中的 Waker,并调用 waker.wake()
    5. 此时,该协程状态被标记为就绪,重新放回执行器的就绪调度队列中。

4.2 原子变量的协作

在多核或中断可能抢占的环境下,使用原子变量来进行状态标志通信至关重要:

  • IDMAC_DONE_FLAG: AtomicBool:标明 DMA 传输是否成功结束。在触发 DMA 传输前置为 false,在硬件中断成功接收到完成信号时置为 true
  • IDMAC_ERROR_FLAG: AtomicBool:标明硬件(如控制器或 DMA)是否发生了致命错误。在中断处理函数检测到错误位(如 FBE、DU、CES)时置为 true
  • 内存顺序(Ordering):使用 Ordering::Acquire and Ordering::Release 保证多核/中断上下文之间的数据可见性与指令重排安全。

4.3 完整的调用与运转链路 (Execution Flow)

以异步读取 read_block_async 为例,完整的调用与运转链路如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
sequenceDiagram
participant App as 上层应用/执行器
participant Drv as SDMMC 驱动 (SdMmc)
participant WQ as 任务等待队列 (WaitQueue)
participant HW as SDMMC DMA 硬件
participant ISR as 中断服务程序 (ISR)

App->>Drv: 1. read_block_async().await 发起读取
Drv->>Drv: 2. 映射配置一致性 DMA 描述符与缓冲区
Drv->>HW: 3. 写寄存器,触发 DMA 传输
Drv->>WQ: 4. wait_timeout_until_async() 异步等待
Note over WQ: 创建 EventListener<br/>将 Waker 注册到等待队列
WQ-->>App: 5. 返回 Poll::Pending 挂起协程
Note over App: 执行器切走,CPU 运行其它任务
Note over HW: DMA 硬件自动搬运数据到内存...
HW-->>ISR: 6. 传输完成触发物理中断
ISR->>ISR: 7. 清理中断位,设置原子标志 DONE=true
ISR->>WQ: 8. notify_one() 唤醒监听者
Note over WQ: 获取对应的 Waker,调用 waker.wake()
WQ-->>App: 9. 协程重新放入就绪队列
App->>Drv: 10. 执行器再次 poll 协程
Drv->>WQ: 11. 重新检测 DONE 状态,发现满足条件
WQ-->>Drv: 12. 退出异步等待,返回
Drv->>Drv: 13. 从 DMA 缓冲区拷贝数据至用户缓冲区
Drv-->>App: 14. 异步读取圆满完成,返回数据

五、 当前存在的问题

由于我最终采用了物理隔离的同步和异步双通道设计(即独立的同步与异步底层接口),目前导致了以下两组函数在底层的 DMA 物理缓冲区分配IDMAC 描述符链装配、以及读写的前后置数据同步搬运上存在约 80% 的代码逻辑重复:

  • send_cmd_idmac (同步) 与 send_cmd_idmac_async (异步)
  • read_block (同步) 与 read_block_async (异步)
  • write_block (同步) 与 write_block_async (异步)

后续如果我要继续冗余代码的重构,大概就是把三个部分改造为辅助函数:

DMA 配置助手化

将当前冗余的一致性内存分配、IdmacDescriptor 结构体物理填充(包含控制字、缓冲区大小、缓冲区地址配置等)抽离为独立的公共辅助函数,例如:

1
fn prepare_dma_desc(...) -> Result<DmaDescInfo, DmaError>

读写前后置操作抽象

将读操作的后置数据搬运拷贝(从物理连续的一致性 DMA 缓存拷贝至用户 buffer)、写操作的前置数据填充(从用户 buffer 拷贝至一致性 DMA 缓存)以及 DMA 一致性内存的释放生命周期管理,抽象出统一的辅助清理函数。

保持通道隔离并调用辅助函数

重构后,同步读写和异步读写只需在入口与出口分别调用上述公共抽象逻辑,而其核心等待与挂起机制(即同步直接阻塞 VS 异步协程挂起)依然维持完全隔离,在彻底消灭冗余代码的同时,完美保留同步路径的极限吞吐量性能。


六、对于向老师所说的中断处理过程中的同步异步分别如何处理的问题的回答

首先其实无论时同步还是异步,他们的底层都是一样的中断处理函数,都是一样设置状态标记(IDMAC_DONE_FLAG/IDMAC_ERROR_FLAG),然后唤醒等待的任务/协程
只是在硬件读写处理前,我们必须使用互斥锁(Mutex)来进行并发保护,且唤醒后在系统调度上存在核心差异:

1. 硬件互斥锁定与防死锁设计

由于 SDMMC 硬件是单通道独占设备,我们在发起 DMA 读写前必须获取驱动锁:

  • 同步端:在获取互斥锁(如 SpinNoIrq 或操作系统互斥量)后,如果等待中断,会挂起当前线程。此时其他尝试获取锁的线程会被排队,直至前一个线程在中断中被唤醒并释放锁。
  • 异步端:为了防止协程挂起时导致底层线程“死锁”,我们不能在 .await 期间持有物理自旋锁。上层对接时使用异步互斥锁(Async Mutex),当一个协程挂起时,它会主动把锁让出来,允许同一物理线程内的其他协程正常运行。

2. 中断唤醒的多态分发(Waker 多态)

在中断触发并调用 notify_one 时,底层通过 Rust 标准库 Waker 的虚函数表实现多态性,自动完成同步与异步的区分唤醒:

  • 同步的 Waker:对应 AxWaker。其 wake() 实现会通知操作系统的调度器,直接将对应的物理线程状态改为 Runnable,从而在中断返回后可以通过内核调度器触发抢占式切换,物理线程恢复运行并拷贝数据。
  • 异步的 Waker:对应协程执行器的 Waker。其 wake() 实现仅仅是将协程状态机标为 Ready 放入执行器的就绪链表。中断返回后物理线程不阻塞,由执行器随后 poll 推进协程状态。

相关的仓库地址

一、项目背景

StarryOS 是一个面向 RISC-V 的 Rust 内核项目,构建在 ArceOS 组件化框架之上。串口(UART)是内核与外界交互最重要的通道之一,承担三类任务:内核启动日志、Shell 终端交互、用户态高性能数据传输。

项目原本使用同步阻塞式的串口实现。这种方式有两个问题:第一,用户态 Shell 的输入/输出会因串口速率阻塞调用方;第二,写多字节时 CPU 持续轮询硬件 FIFO,效率低下。我们的训练营阶段初步目标,就是在保留同步内核日志通道的同时,从零为用户态 Shell 提供一条非阻塞、可中断驱动、CPU 高效的异步串口通路。

约束条件贯穿整个阶段:不修改任何上游框架、内核抽象层、硬件抽象层、底层驱动库;异步机制只引入社区主流的轻量级原子唤醒原语(embassy_sync::AtomicWaker),禁止引入完整的异步入库。


二、训练营其他阶段工作

训练营分阶段推进,前三个任务为后续主线工作奠定了协程与调度器的实践基础。

进程、线程、协程的性能对比。在统一数据量下对比三种并发模型,通过预取缓存、纯文件读取、可配置负载四步控制变量法排除了网络和解析的混淆。核心结论:I/O 密集型场景下协程较线程快 48% 至 82%,CPU 密集型场景下三者差异在 6% 以内,进程内存开销比线程和协程高 100 至 400 倍,结论经两轮独立运行验证零反转。

用户态线程与协程的深入实践。实现了一个支持优先级调度的用户态绿色线程库,通过裸函数栈布局手工实现上下文切换,支持 x86-64 Linux、Windows、RISC-V 64 三平台,全部代码可在 stable Rust 上编译运行。配套整理了无栈协程原理、绿色线程实现、执行流状态变迁三篇分析文档。

调度器对比与异步运行时优化。构建爬虫框架接入自研绿色线程和 Tokio 异步运行时,量化了四组调度配置在满载 CPU 与中量 I/O 场景下的差异。优化了 Tokio 优先级分发器:将基于二叉堆的逐任务分发改造为基于三队列的批量分发,使优先级模式与默认模式的实际耗时差距从 1.83 秒缩小至 0.11 秒,差距缩小 94%。


三、主要工作

本阶段工作按三条主线展开。

主线一:从零实现 kernel 层异步串口驱动

3.1 寄存器层验证

NS16550 UART 芯片只有 8 字节寄存器空间(偏移 0 到 7)。在早期验证中,由于对寄存器布局理解不到位,我们曾误用一个过大的寄存器步长,导致访问越界、触发总线错误。问题排查一度被误判为内存映射权限问题,使整体进度阻塞了将近一周。最终通过在同一页表映射内对比不同步长的读取结果,确认了是寄存器步长配置错误而非系统级权限问题,从而扫清了整个方向的最大障碍。这一教训让我们确立了「先排查代码 bug、再考虑系统级问题」的调试原则。

根因定位uart_init.rs:27-30UART_STRIDE = 1 及 LoadFault 注释

寄存器操作通过本地 fork 的 uart_16550 驱动库完成(kernel/Cargo.toml:106 path = "../../uart_16550"),而非直接依赖 crates.io 版本。该库封装了 NS16550 的 MMIO 后端寄存器定义(IER/ISR/LSR 等)和中断类型枚举,为上层提供安全的寄存器读写 API。我们在本地 fork 上的主要改动是为其新增了 set_ier() 公开写方法,使 IER 操作统一走安全 API 而非裸 write_volatile(详见 §3.6 修复三),后续 Q12 阶段还为其补充了 embedded_io_async trait 实现

3.2 驱动三件套

我们用三件套构成了异步串口的核心机制:

组件 文件 说明
环形缓冲区 ring_buffer.rs SPSC 无锁结构,64 KiB 每方向,使用 atomic_ring_buffer + PollSet 唤醒
中断服务程序(ISR) isr.rs 25 行极简实现:读 ISR → 禁中断 → AtomicWaker::wake → 返回
后台搬运任务 async_driver.rs RX/TX copier loop,NAPI 风格轮询/中断动态切换(阈值 16)

关键设计是 ISR 只做唤醒而不做数据搬运,搬运工作交给后台任务,保持 ISR 极简。整套三件套约 500 行代码,全部位于内核自身模块。

完整实现kernel/src/drivers/

3.3 虚拟文件系统集成

我们将异步串口封装为标准设备节点,对接现有的设备注册框架,使异步串口与项目内其他设备采用完全一致的接入方式。

  • 设备注册:dev/mod.rs:222-230/dev/console (5,1) 绑定 ASYNC_TTY
  • 设备操作:device_ops.rsTtyRead/TtyWrite + embedded_io_async trait 实现
  • 终端绑定:ntty_async.rs — 泛型 Tty<AsyncUartReader, AsyncUartWriter>ProcessMode::External 消除 yield storm

终端层通过泛型绑定替换了 reader 和 writer 两个 trait,整个终端栈因此无须修改即可切换到异步通路。

TTY 层实现ldisc.rs 行规则模块 | tty/mod.rs Tty 泛型结构

3.4 Shell 双向异步打通

在接收方向上,中断唤醒后台搬运任务,搬运任务填充环形缓冲区,用户态 Shell 从缓冲区读取,整条链路无阻塞。在发送方向上,用户态写入环形缓冲区,后台搬运任务将其送入硬件 FIFO,内核日志通道与异步通道共享同一个 UART 硬件但互不冲突,实现了「内核日志同步输出、用户态 Shell 异步交互」的共存架构。

共存实现dev/mod.rs:222-230 设备注册 | entry.rs:23-28 启动初始化 | async_driver.rs copier 与 Console 互斥读 FIFO


主线二:性能与正确性的多轮打磨

异步通路打通后,我们用了八轮迭代打磨性能和正确性。

性能对比报告benchmark-report-async.md | 手动测试报告manual-qa-report.md | Console vs Async 对比uart-performance-comparison.md

3.5 热路径优化(Q5 阶段)

对中断处理路径做了五项核心优化:

# 优化 代码位置 效果
1 IER 值缓存为原子变量,读改写→单次写入 uart_init.rs:99-109 消除 MMIO RMW 开销
2 RX/TX 独立锁,消除读写伪竞争 ring_buffer.rs 分设 RingBufRx/RingBufTx 解除读写互斥
3 批量读写接口替代逐字节操作 uart_init.rs:32-35 NAPI_BATCH_SIZE=64 减少锁获取次数
4 唤醒去重(will_wake 检查),避免无意义唤醒 async_driver.rs:41,61-65 减少 CPU 空转
5 NAPI 中断合并:连续成功≥16次切轮询,高吞吐减 90%+ IRQ async_driver.rs:46-60 大幅降低中断频率

以上 5 项优化的代码位置见表格中各行的链接,对应优化编号 O2/O25~O34。

3.6 三轮正确性修复(Q7~Q8 阶段)

# 缺陷 修复 代码位置
1 NAPI 退出缺陷:轮询模式计数器只增不减、空数据时无法重置 → CPU 空转 总量为 0 时重置 consecutive 计数器 async_driver.rs
2 ISR 持锁:中断处理路径仍持有 SpinNoIrq,违反 ISR 极简原则 移除 ISR 内所有锁,改用 read_isr_unlocked/read_lsr_unlocked isr.rs + uart_init.rs:72-96
3 IER 裸写绕过安全 APIwrite_volatile 裸地址写 IER,违反 MMIO 封装规则 给底层驱动库新增 set_ier() 公开方法,统一所有写入路径 uart_init.rs:102-109 + uart_16550/src/lib.rs

以上 3 项修复的代码位置见表格中各行的链接。

3.7 多轮迭代优化(Q9~Q12 阶段)

在三轮正确性修复之上,我们又进行了五轮优化迭代:

阶段 优化内容 关键指标变化 变更记录
Q9 超时机制:VTIME 读超时复用 axtask::future::timeout ldisc.rs VTIME 处理
Q10 数据路径优化:批量写入改造、缓冲容量扩充 3 倍、ldisc 锁粒度细化 1B 延迟 145→122 µs(↓16%) ldisc.rs + ring_buffer.rs
Q11 终端与内存子系统一般性优化(mm/access、close_range、sendfile、tty unwrap) 1B 延迟 122→118 µs;软件开销峰值↓40% tty/mod.rs + mm/
Q12 Embassy 路径 A:atomic_ring_buffer 无锁化、embedded_io_async 标准化、TC 硬件寄存器 tcdrain 1B 延迟 P50=115.7 µs;软件开销↓31%(53.9→37.1 µs);唤醒延迟 200→50 ns(8 点应用) ring_buffer.rs + device_ops.rs + uart_16550
Q6 ⏳ 真板验证(VisionFive2)— 等待硬件 Makefile vf2 目标

Q12 性能回归测试tests/benchmark.c 用户态 benchmark 源码

3.8 已识别的反优化(排除的错误方向)

我们对五类主流异步入库原语替换提案进行了评估,最终全部判定为反优化:

提案 排除原因
MPMC Channel 替换 SPSC Ring Buffer 丢失无锁特性、引入不必要的间接层
异步 Mutex 替换同步 SpinLock 反而更慢且无法跨等待点持有
Watch 包装单个 AtomicBool 对单一标志而言过度设计
Semaphore 做事件计数 用错了工具
select! 宏替换手动 poll 与 axtask 协程调度器不兼容
通用分发结构替代静态 AtomicWaker 唤醒目标固定且极少时,静态唤醒已是 O(1),通用结构反引入开销

上述反优化判定的对照实现:ring_buffer.rs SPSC 无锁方案(对应 OE1 Channel)、isr.rs AtomicWaker 静态唤醒(对应 OE2/OE3/通用分发结构)


主线三:方法论与经验沉淀

核心原则:「先用轻量原语、复杂方案需评估改动范围与性能基准」。在评估五类异步入库原语替换提案后全部判定为反优化(详见 §3.8),避免后续在错误抽象上投入精力。整个阶段的工程决策与踩坑经验均已记录在项目文档体系中,此处不再展开。


四、量化成果与达到的效果

4.1 功能交付

完成了从概念到内核虚拟机完整验证的异步串口栈:用户态 Shell 双向异步输入输出、标准事件轮询接口、非阻塞读取与 tcdrain 真异步、内核日志与用户态通道共存共享硬件。Shell 基本交互均工作正常。

手动 QA 报告(12 项测试全部 PASS,含真实终端输出证据):manual-qa-report.md

4.2 性能数据

完整报告benchmark-report-async.md | 同步 vs 异步对比uart-performance-comparison.md

CPU 占用效率(统一数据量 10 万字节,端到端对比):

指标 同步实现(Console) 异步实现 提升
CPU 周期 / 字节 3,835 268(Q7)/ ~13(Q12) 14.3× → 295×
延迟中位数 17.5 µs 6.5 µs(Q7)/ 7.9 µs(Q12 无 tcdrain) 2.2~2.7×
P99 延迟 324.5 µs 43.1 µs 7.5×

说明:上表中「268 cycles/byte」为 Q5/Q7 阶段数据(IER 缓存 + ISR 合并 + 批量 I/O 后),「~13 cycles/byte」为 Q12 阶段数据(atomic_ring_buffer 无锁化 + embedded_io_async 标准化后)。两个数据均来自 QEMU 实测,详见 uart-performance-comparison.md

环形缓冲区直测(绕过终端层,测量纯内核态通路):每秒可搬运近 6 亿字节(RX 588,776 KB/s,TX 196,850 KB/s),延迟中位数 600 纳秒。

唤醒延迟:将异步唤醒从通用 PollSet 事件分发机制替换为静态 AtomicWaker 原子唤醒原语后,单次唤醒延迟从约 200 纳秒降至约 50 纳秒,提升约 4 倍。共有 8 处唤醒点应用了该优化(isr.rs RX_WAKER/TX_WAKER/DRAIN_WAKER + async_driver.rs copier 注册点)。

端到端预测(按 4096 字节数据量在真实硬件上估算):吞吐量效率可达理论线速的 97.7%~97.9%,软件开销占比不足 2.3%;单字节平均延迟中,软件部分的开销约占三分之一(Q12 实测 37.1 µs)。

需要说明的是,所有吞吐量数据在 QEMU 模拟器上不完全可信:QEMU 的 NS16550 设备模型不仿真真实的串口线延迟,因此真实硬件上的吞吐量会受 115200 波特率对应的每秒 11.5 KB 上限制约。可信的性能指标包括 CPU 周期占用、单字节延迟、唤醒延迟,这些在 QEMU 上也能准确反映。

4.3 代码与质量

  • 新增代码约 500 行,全部位于 kernel/src/drivers/
  • 项目整体净增约 450 行(涉及 14 个内核侧文件 + 1 个底层驱动库文件)
  • 静态检查零错误零警告:编译器检查、代码风格检查、编译警告均清零
  • 死代码清理:移除 8 处确认无用的方法(共 76 行),保留 5 处预留接口
  • 多轮迭代全部通过完整的功能与性能回归测试

五、经验与教训

5.1 根因复盘

根因一:寄存器步长误用。NS16550 寄存器物理布局仅 8 字节,使用大于 1 的步长会导致寄存器访问超出硬件定义的范围。QEMU 模拟器对越界访问返回总线错误,在 RISC-V 上被解释为取指故障。在排查过程中,我们一度将故障归因于「内存映射权限不足」,并据此否决了多个上游修改方案。最终通过在同一页表映射内对比两种步长的访问结果,确认了真实根因是步长配置而非系统权限。

教训:硬件访问异常时,应优先排查寄存器布局与地址计算,再考虑页表权限等系统级问题。

代码证据uart_init.rs:27-30

根因二:非阻塞标志三入口未穷举。非阻塞读取标志需要从用户态一路传播到行规则模块。我们最初只在 ioctl 这一个入口做了处理,遗漏了文件打开和 fcntl 两个入口,导致用户在某些调用路径下设置了非阻塞标志却仍然阻塞等待。

教训:跨层状态传播必须穷举所有可能的入口路径,一个遗漏就意味着功能不完整。

三入口代码位置fd_ops.rs:105-107 open | fd_ops.rs:280-283 fcntl | tty/mod.rs:177-179 ioctl

5.2 可复用的设计模式

以下九条设计模式在本项目中经过验证,未来可复用:

  1. 环形缓冲区 + 中断处理 + 后台搬运任务三件套:ISR 只唤醒、后台做数据搬运(isr.rs + async_driver.rs
  2. 标准化设备注册框架:所有设备走同一接入路径(dev/mod.rs
  3. 终端层通过读写 trait 泛型绑定:实现终端栈与具体驱动的解耦(ntty_async.rs + ldisc.rs
  4. 中断合并与轮询模式动态切换:高吞吐场景自动减少中断次数(async_driver.rs:32-35,46-60
  5. 批量读写接口替代逐字节操作:减少系统调用开销(uart_init.rs:32-35
  6. 本地游标追踪已发送位置:避免内核日志与用户态输出交错(async_driver.rs TX copier 游标逻辑)
  7. 静态原子唤醒替代通用事件分发:固定唤醒目标场景下的最优选择(isr.rs AtomicWaker)
  8. 中断处理极简原则:全程只读标志寄存器、关闭中断、唤醒、返回(isr.rs 25 行实现)
  9. 寄存器值缓存为原子变量:读改写转化为单次写入(uart_init.rs:99-109 CACHED_IER

5.3 后续方向

后续可能会参加实习继续完成向老师指出的后续方向:在真开发板上尝试(VisionFive2)、独立成 crate 用于多个 OS、完善驱动的文档;如果可能,对 I2C、SPI 驱动进行异步改进,看看尤予阳的工作并进行分析;尝试基于当前uart的异步实现总结异步开发范式(如果能做到)


六、工作仓库

仓库 链接
StarryOS(主仓库) https://github.com/daivy2333/StarryOS
uart_16550(串口驱动库) https://github.com/daivy2333/uart_16550
embassy(fork) https://github.com/daivy2333/embassy
学习开发仓库 https://github.com/daivy2333/2026sOsReport

各阶段学习产出文档位于 2026sOsReport 仓库下:

文档 链接
爬虫任务(任务一) 产出/任务一/docs/爬虫任务.md
绿色线程实验(任务二) 产出/任务二/green-thread/priority-extension.md
优先级爬虫实验(任务三) 产出/任务三/docs/优先级调度在满负载下的效果分析.md
异步数据库练习 产出/一些别的/异步数据库练习.md
六月第一周(周报) reports/六月第一周.md
weekly-2026-W20(周报) reports/weekly-2026-W20.md
weekly-2026-W21(周报) reports/weekly-2026-W21.md
weekly-2026-W22(周报) reports/weekly-2026-W22.md
weekly-2026-W23(周报) reports/weekly-2026-W23.md

总结

转眼训练营就到结束的时候,还记得我参加这个完全就是为了提升自己的能力,我觉得算是初步达到了目的,不管是搜寻资料、总结信息的能力,还是编程开发的分支、工具管理能力,还是形而上的抽象知识实际运用起来的动手能力,甚至是紧跟时代的 AI 工具使用的能力,这些都在解决训练营难题的过程中得到了有效的提升,就有种从只会读书的学生转变成了能解决问题的学生的感觉,参加训练营真是受益匪浅。
我个人对于ai工具的想法,是挺乐观的,因为这是大趋势,我也是某种程度上的受益者。总的而言,ai是一个放大器、加速器,能在具体任务里面帮我加倍、加速的完成任务,这本身就需要我去掌握一些深度学习、提示词工程和理解运用专业知识(比如操作系统那些知识)的能力。所以,我认为是因为我本来就有点基础有点能力,在加上训练营的训练培养,才能用好ai,才能在老师的指导下完成当前这些工作。
感觉参与训练营之后,学校的课程作业都变得异常简单。感谢操作系统训练营,感谢向老师在项目阶段给的指导。