0%

实习期自 2026-07 起。期间与 Agent 集群协作提交 88 个 PR,合入 36 个;其余多为计算与渲染用例,不验证内核语义,按收录范围撤下。本页只讲实习开始后提交的工作,分三段:用真实应用补/测内核,把浏览器跑起来并对照 Linux 提速,把系统调用拆成可替换的包。末尾是对全部内核补丁的溯源。

其中,主要分为三个主要的阶段:

7月:以各项 Linux App 地毯式覆盖测试为导向的对 StarryOS 的测试与修复。
8月:以火狐浏览器 CPU/GPU 两侧可在 StarryOS 上正确运行渲染与交互以及提升性能为导向的对 StarryOS 的测试与修复。
9月:主要为 win mac linux 三种 系统能力的支持 的解耦与实现,以及之前补丁的溯源任务。

本篇总结报告主要属于总结汇报相关成果,详细的周报以及相关内容请移步:wiki。对于方法论概要,请参考:对照 Linux 评估 StarryOS 子系统与浏览器启动,对于 Win/Mac 等系统的相关认知笔记请参考:三种 ABI 的系统调用与加载器。

Read more »

1. 摘要

2026 年 7 月到 10 月,我在 rcore-os/TGOSKits 社区实习,任务是给 StarryOS 实现 virgl 虚拟化 GPU(virtio-gpu 3D)加速,并让它的图形性能尽可能接近同机 Linux 虚拟机。实习结束时:

  • 功能:StarryOS 上 Weston + glmark2-es2-wayland 完整跑通 33 个场景,GL_RENDERER=virgl,宿主机真实 GPU(AMD Radeon 780M)完成渲染,用户态 Mesa 零改动;
  • 性能:glmark2 总分从第一次跑通的 69 分提升到 3000 分上下,约为同机 Linux 虚拟机(6100~7000 分)的一半;渲染循环帧时间从 2.17 ms 压到 0.33 ms;
  • 正确性:guest 侧三类会整机冻结的结构性缺陷被根除,重场景 10 轮浸泡测试 50/50 退出码全 0、零冻结;
  • 产出:向 rcore-os/virtio-drivers 与 rcore-os/tgoskits 提交 7 个拉取请求,6 个已合入主线,1 个开放(virtio-drivers 的异步控制队列 #265)。

本报告按三个阶段组织:功能打通(第 4 章)、性能优化战役(第 5 章)、工程化落地(第 6 章)。第 3 章先给出整体架构与”一帧的生命周期”,作为后文所有讨论的地图;第 7 章单独讨论贯穿三个阶段的组件化设计;第 8 章汇总成果与数据,第 9 章如实声明边界与后续工作。

2. 项目目标与起点

2.1 任务定义

StarryOS 是 TGOSKits 仓库中面向 Linux 用户态二进制接口的研究用宏内核,此前图形栈只有 2D dumb buffer 路径,应用没有 OpenGL 可用。任务目标:在组件化内核中实现 virgl 驱动——通过 PCI 枚举发现设备并初始化 virtqueue,实现 DRM 设备节点及约 10 个核心 ioctl,把 Mesa virgl 用户态封装的 Gallium 命令流经 virtqueue 转发到 QEMU 宿主机,由宿主机 GPU 完成渲染。验收口径从第一天起就是可量化的:Weston + glmark2 能否完整出分,以及分数与同机 Linux 虚拟机的比值。

2.2 基线:先造标尺

动手写代码之前,先做了同法对照实验:同一台宿主机、同一套 QEMU 参数,先跑 Alpine Linux 虚拟机(装 Weston + glmark2)作为参照系;再保持其他变量完全一致,只把内核换成 StarryOS。结果如下。

环境 内核 CPU 渲染(llvmpipe) GPU 渲染(virgl)
QEMU + KVM,4 vCPU Alpine Linux 约 700~800 FPS 约 9000 FPS(宿主核显直通)
同上,仅换内核 StarryOS 约 500 FPS 不支持

这组数字在动手前就确立了两件事:硬件加速对图形场景的收益是数量级的;而两侧用户态完全相同,差距全部来自内核侧。此后三个月,所有性能结论都遵守同一条纪律——同机、同用户态、唯一变量,背靠背 A/B。

3. 总体架构

3.1 软件栈分层

最终落地的图形栈自上而下分为 guest 侧五层与宿主机侧三段,每层只依赖下一层的接口。理解这张分层图是理解后文所有修改的前提:同一个问题可能出在任意一层,而修复必须落在语义上属于它的那一层。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
flowchart TB
subgraph guest["StarryOS guest(QEMU 虚拟机)"]
U["用户态<br/>Weston + Mesa virgl(libEGL/libGL)"]
K1["DRM 设备层<br/>card0.rs(DRM ioctl)+ drm.rs(UAPI 布局)<br/>sync_file.rs(fence 语义)"]
G1["axgpu 门面<br/>with_gpu / wait_gpu_condition / wait_completion"]
T1["rdif-gpu 接口层<br/>VirglOps trait:submit 返回 Completion"]
D1["ax-driver 适配层<br/>VirtIoGpu:时钟注入、IRQ endpoint"]
V1["virtio-gpu 驱动<br/>ctrl.rs 异步控制队列 + 真 fence"]
VQ["virtqueue(控制队列 64 深度 / 游标队列 16)"]
U -->|"libdrm ioctl"| K1
K1 --> G1
G1 --> T1
T1 --> D1
D1 --> V1
V1 --> VQ
end
subgraph host["QEMU 宿主机"]
Q1["virtio-gpu 设备模型"]
Q2["virglrenderer:翻译为宿主 GL 调用"]
GPU["物理 GPU(AMD Radeon 780M)"]
Q1 --> Q2 --> GPU
end
VQ -->|"kick(MMIO 写,触发 VM-exit)"| Q1
VQ -.->|"完成写 used ring → 中断 guest"| V1

各层的职责与关键代码锚点如下。card0.rs(约 4300 行,内核 DRM 层)实现 Linux 同款 VIRTGPU_* ioctl 与每上下文资源管理;sync_file.rs 实现 Linux sync_file 语义(SYNC_IOC_WAIT、poll、真 fence 翻转);axgpu 是 OS 侧 GPU 门面,对外提供 with_gpu 系列闭包、wait_gpu_condition/wait_completion 锁外等待原语与 set_completion_notifier 完成通知注册;rdif-gpu 以 VirglOps trait 定义驱动合同,submit 返回 Completion 完成令牌;drivers/gpu/virtio-gpu/src/ctrl.rs 是异步控制队列本体;ax-driver 负责把驱动接入平台(时钟注入、中断注册、Hal 地址翻译)。设备可见地址的最终设计很克制:异步热路径的命令字节全部拷进驱动自有、地址稳定的 DMA arena,翻译由 ax-driver 实现的 Hal::share 承担;仅存的零拷贝同步路径 request_sync 以文档契约要求调用方传入地址可忠实翻译的内存——这段设计的由来见 5.9 节,合同本身见 7.2 节。

3.2 一帧的生命周期

性能优化的对象是”一帧的时间都花在哪”。把渲染循环拆开,一帧的构成如下。

阶段 发生什么 耗时属性
① 应用渲染 glmark2 经 EGL 画一帧,提交给 Weston guest CPU
② Mesa 编码 Gallium virgl 驱动把 GL 状态变化编码为 virgl 命令流(每帧几十~几百条 GPU 命令) guest CPU
③ EXECBUFFER ioctl Mesa 携命令流指针 + BO 句柄进内核;内核分配 fence_id,调 submit,末尾统一 kick guest CPU + 1 次 MMIO
④ 队列传输 驱动把 CtrlHeader + 命令流挂上 virtqueue(indirect 描述符) µs 级
⑤ 宿主处理 QEMU 取命令 → virglrenderer 翻译成宿主 GL → 真实 GPU 渲染 宿主 CPU/GPU,帧的主要耗时
⑥ fence 完成 渲染完成后设备写 used ring → 注入中断 中断延迟
⑦ guest 收割 IRQ 推进 completed_fence_id;Mesa 的 WAIT/poll 看到完成 guest CPU,µs 级
⑧ present Weston atomic commit → set_scanout / resource_flush guest CPU + 队列传输

这张表有两个用途:它定义了”帧时间”的测量边界(从 ① 到 ⑧ 的墙钟),也预告了优化的两个方向——减少每个环节的固定开销(第 5 章),以及让 ③④ 与 ⑤ 重叠起来(异步化)。

3.3 同步模型与异步模型

实习开始时上游 virtio-drivers 的提交模型是全同步的:每条命令入队 → kick → 自旋等宿主应答 → 弹出,一条命令一次完整往返。改造目标是对齐 Linux drivers/gpu/drm/virtio/ 的模型:热路径命令 fire-and-forget 入队即返回,唯一保留的等待是 fence,因为它表达真实的 GPU 依赖。两种模型的时序对比如下。

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 M as Mesa(guest 用户态)
participant K as card0(内核)
participant D as virtio-gpu 驱动
participant Q as QEMU/virglrenderer(宿主)
rect rgb(235, 235, 235)
Note over M,Q: 改造前:同步模型,每命令一次阻塞往返
M->>K: RESOURCE_CREATE_3D / TRANSFER / EXECBUFFER
K->>D: 入队 + kick + 自旋等应答
D->>Q: MMIO kick(VM-exit)
Q-->>D: RESP_OK(宿主处理完才返回)
D-->>K: 返回
K-->>M: ioctl 返回
end
rect rgb(225, 240, 225)
Note over M,Q: 改造后:异步模型,fire-and-forget + 真 fence
M->>K: EXECBUFFER(命令流)
K->>D: submit(携带 FENCE 标志)
D-->>K: 立即返回 fence_id
K-->>M: ioctl 立即返回,附带 out-fence fd
M->>M: 继续编码下一帧(与宿主渲染重叠)
Q->>D: 完成写 used ring → 中断
D->>K: 推进 completed_fence_id 高水位
K-->>M: fence 翻转,poll/SYNC_IOC_WAIT 返回
end

两种模型的帧时间公式完全不同。同步模型下所有环节完全串行,一帧 3~5 个阻塞往返,其中 SUBMIT_3D 阻塞到渲染完成,guest 在宿主渲染期间完全空转:

T帧 ≈ [创建+RTT] + [挂载+RTT] + [上传+RTT] + [提交+RTT(含整个渲染等待)] + [present+RTT×2]

异步模型下 guest 编码第 N+1 帧与宿主渲染第 N 帧重叠:

T帧 ≈ max(guest 每帧编码, 宿主每帧渲染) + 一次批量 kick 延迟 + fence 唤醒延迟

这个对比是整个性能战役的纲。但 5.2 节会看到:只异步化”看得见”的两个同步点,实测反而变慢——真正的工作是把每帧隐藏的同步点全部数出来。

4. 功能打通(第 1~5 周)

4.1 协议调研

实现的参照物有三份:Linux 内核 drivers/gpu/drm/virtio/(最权威的驱动实现)、include/uapi/linux/virtio_gpu.h(全部命令定义)、Mesa 的 src/gallium/include/state_tracker/virgl.xml(命令流编码协议)。virtio-gpu 层面的关键事实:所有命令共享 24 字节的 virtio_gpu_ctrl_hdr(type、flags、fence_id、ctx_id、ring_idx),3D 能力由一组命令承载——CTX_CREATE、RESOURCE_CREATE_3D、TRANSFER_TO/FROM_HOST_3D、SUBMIT_3D 等;能力协商走 capset 机制,OpenGL 加速对应 VIRGL2(capset id=2)。

Mesa 侧的 virgl 命令流是另一层编码:每条命令的首个 dword 由 VIRGL_CMD0(cmd, obj, len) 打包(命令 ID、对象类型、命令长度),其后按命令携带状态对象——CREATE_OBJECT(BLEND/RASTERIZER/SHADER 等十一类)、SET_FRAMEBUFFER_STATE、CLEAR、DRAW_VBO 等。调研结论整理成驱动层需要实现的命令清单与字节级布局表,这成为第二周编码的直接输入。

4.2 驱动层:virtio-drivers 的 3D 扩展

fork rcore-os/virtio-drivers 后,在 device/gpu/mod.rs 中新增了完整的 3D 命令面:数据结构(CmdCtxCreate、CmdResourceCreate3d、CmdTransferToHost3d、CmdSubmit3d、GpuBox 等)、常量(VIRGL_CAPSET、3D opcode)、方法(ctx_create/destroy、ctx_attach/detach_resource、resource_create_3d、transfer_to/from_host_3d、submit_3d、get_capset(_info))。所有 3D 方法入口统一 require_virgl() 守卫:未协商 VIRGL feature 时返回错误而不是发出未定义命令。

测试先行写了 5 个单测,其中 command_opcodes_match_upstream_header 把所有 opcode 与 Linux virtio_gpu.h 逐一对表,submit_3d_reports_error / resource_create_3d_reports_error 对命令缓冲逐字节验证字段偏移。这批改动后来整理为 virtio-drivers #257(2026-08-14 合入)与 #258(2026-08-17 合入)。

4.3 从驱动到内核:三层封装与 DRM 设备层

驱动只是最底层。向上按仓库既有分层逐层封装:drivers/interface/rdif-display 定义纯 trait 接口(硬件无关的”合同”,含错误码 Gpu3dError 与 TransferBox/CapsetInfo 结构);drivers/ax-driver/src/virtio/display.rs 的 VirtIoDisplay<T> 是唯一生产实现,获取 virtio 驱动设备所有权后把调用逐个委托下去;os/arceos/modules/axdisplay 在全局设备上暴露 gpu3d_* 系列门面函数,作为内核 DRM 层的命令入口。

内核侧是重头:在 os/StarryOS/kernel/src/pseudofs/dev/drm.rs 定义 11 个 DRM_IOCTL_VIRTGPU_* 的编号、flag 与用户态结构体,再在 card0.rs 按 Linux 同名 handler 一一对应实现。

ioctl 编号 职责(对应 Linux 实现)
VIRTGPU_MAP 0x41 blob 资源 mmap 进用户空间
VIRTGPU_EXECBUFFER 0x42 提交 virgl 命令流(核心路径)
VIRTGPU_GETPARAM 0x43 能力查询(fence-fd 等参数)
VIRTGPU_RESOURCE_CREATE 0x44 创建 3D 资源
VIRTGPU_RESOURCE_INFO 0x45 查询资源信息(blob 类型等)
VIRTGPU_TRANSFER_FROM_HOST 0x46 宿主→guest 数据回读
VIRTGPU_TRANSFER_TO_HOST 0x47 guest→宿主数据上传
VIRTGPU_WAIT 0x48 等待 fence(渲染完成)
VIRTGPU_GET_CAPS 0x49 获取 capset 二进制(能力集)
VIRTGPU_RESOURCE_CREATE_BLOB 0x4a 创建 blob 资源(共享内存)
VIRTGPU_CONTEXT_INIT 0x4b 渲染上下文初始化(capset_id/num_rings)

每个 handler 只做三件事:按 UAPI 布局读用户参数、调用 gpu3d_* 门面把命令送到 virtqueue、按 Linux 语义回写结构与 errno。这层”与 Linux 一一对应”的纪律后来被证明极其重要——5.4 节的 fence 问题、6.4 节的释放语义问题,都是靠”对照 Linux 该是什么语义”来定位的。

4.4 让 Mesa 认出这块”显卡”

第一轮联调,glmark2 直接回退到 llvmpipe(CPU 软渲染)。把 glmark2/Mesa 的认卡逻辑逐条排查后找到四处协议失配:sysfs 中驱动名必须是 virtio_gpu(原实现叫 starry-drm-simple,用户态不认识);/dev/dri/ 下必须同时有 card0 和 renderD128(原来只有前者);DRM ioctl 号由结构体大小参与编码,DrmVirtgpuExecbuffer 缺 syncobj 尾字段导致 64 字节布局落空,DrmVirtgpuResourceCreateBlob 则必须精确 48 字节;最隐蔽的是 GETPARAM——它的 value 字段是指针,原实现把值写进指针字段本身,能力协商因此读到全 0。逐一修正后,GL_RENDERER 终于变成 virgl (AMD Radeon 780M Graphics ...)。

4.5 黑屏:把宿主纹理送上扫描口

认卡之后渲染已在宿主真实发生,但 spice 窗口黑屏——这恰好证明像素确实在宿主 GPU 上,只是回不到显示通路。根因:Weston 的 scanout buffer 是 RESOURCE_CREATE_3D 建的宿主 3D 资源,而原 present 路径只会把 guest RAM memcpy 到扫描口,宿主纹理里的像素根本拷不出来。修复是把 Framebuffer 的 backing 分成两类:FbBacking::Dumb(保留原 memcpy 路径)与 FbBacking::Gpu3d(记资源句柄,present 时走 set_scanout + resource_flush,由宿主零拷贝直接把纹理送上显示)。handle_addfb2 相应地先查 dumb 表、再查 3D 资源表,让宿主资源也能注册成 framebuffer。

spice 窗口里 weston 桌面与 glmark2 旋转立方体正常显示,串口横幅如下。

1
2
3
4
5
6
7
8
=======================================================
glmark2 2023.01
=======================================================
OpenGL Information
GL_VENDOR: Mesa
GL_RENDERER: virgl (AMD Radeon 780M Graphics (radeonsi, phoenix, ACO, DR...)
GL_VERSION: OpenGL ES 3.2 Mesa 25.2.7
=======================================================

至此功能里程碑达成:3D 硬件加速通路完整,glmark2 可以完整出分。这一步比计划表的预计提前了两周,余下的时间全部投入性能。

5. 性能优化战役(第 7~11 周)

5.1 方法论:帧时间为北极星

性能工作遵守固定的循环:提出猜想 → 设计能证伪它的实验 → 修改 → 同机 A/B 实测 → 用数据决定保留或回滚。归因不靠猜,靠一套逐步建起来的测量资产:QEMU tracepoint 数每帧命令构成;LD_PRELOAD 拦截 guest 侧 ioctl 前后共写了六版;QMP 的 x-query-virtio-queue-status 取证队列状态;HMP xp 直读宿主视角物理内存;自研”瀑布插桩”把一个渲染循环逐段拆开计时。还有一条昂贵换来的纪律:串口日志每行约等于 100 FPS 级开销,任何测量必须先关日志。

5.2 异步化的第一次失败

按 3.3 节的纲,第一刀砍向最显眼的同步点:EXECBUFFER 和 present 的阻塞等待。实测结果反直觉——只异步化 EXECBUFFER 得 362 分,再加 present 得 357 分,都低于同步基线的 405 分(FENCE 位关闭的对照基线)。这个失败是关键线索:异步机器本身没问题,是没打中。把每帧的同步点全部数了一遍,发现还有 47 个同步 RTT 藏在别处——29 个资源挂载 + 18 个资源创建/销毁/上传,EXECBUFFER 自己的等待本来就只有几十微秒。

用 QEMU tracepoint 统计命令构成,答案直接写在计数里。

tracepoint(≈ 命令) 总数(一个 run) 每帧 Linux 同项
ctx_res_attach(资源挂载到上下文) 828,394 36.5 0
ctx_submit(3D 提交) 136,942 6.03 ≈1
res_create_3d / res_back_attach / res_unref 各 ≈134,000 各 ≈5.9 启动期一次

每帧 36.5 次 attach 的根因在我自己的 DRM 层:EXECBUFFER 处理器对每个 BO 句柄每次提交都重新挂载一遍。讽刺的是代码里本来有个 attached: bool 字段想记录这件事,但它是个死字段——5 处写、0 处读。对照 Linux virtgpu_gem.c,正确语义是每个(资源, 打开它的上下文)只挂载一次;Weston 和 glmark2 是两个上下文共享同一批 buffer,所以按 (ctx, resource) 粒度用 attached_ctxs: BTreeSet<u32> 记录。修复后 373→406 分,重场景收益是轻场景的十倍——原因也清楚:重帧队列排得深,每个同步 RTT 都要等宿主排空积压,成本被放大。随后把剩下 18 个同步 RTT(创建/backing/销毁/上传)也全部 fire-and-forget,439 分。到这一步,每帧同步往返清零,收益与移除的 RTT 数严格线性。

轮次 改动 Score 验证对象
exp0 同步 + FENCE 位开(等宿主渲染完才返回) 367 基线 A
exp4 同步 + FENCE 位关 405 基线 B:分离”渲染等待”
exp5 仅 EXECBUFFER 异步 362 EXECBUFFER RTT 的贡献
exp6 EXECBUFFER + present 异步 357 present 2 RTT/帧的贡献
exp7 exp6 + smp4 373 “单核并行限制”假设
attachfix attach 每 (ctx,res) 一次 406 attach 风暴
full-async 全部生命周期命令 fire-and-forget 439 剩余 18 个同步 RTT

5.3 kick 合并:把 MMIO 税摊到整个事务

同步往返清零后,下一个靶子是 kick 本身——每次 kick 是一次 MMIO 写,在虚拟化里是一次 VM-exit。第一版按固定条数合并:命令入队后攒够 4 条才发一次 kick,立竿见影,ioctl 单价从 227353µs 降到 35.6µs,内核里 present 切片从每帧 1.82.0ms 掉到 0.26ms。第二版想改成更精细的”每条命令入队时自己判断要不要 kick”,结果反而回退:kick/帧从 1.42 涨到 5.16。重新对照 Linux virtgpu_vq.c 才发现关键:Linux 在 enqueue 时从来不 notify,只做一次计数,真正的 kick 统一放在 ioctl 收尾——合并的单位不是”命令条数”,而是”一次系统调用”。落地为边界 notify 后,kick/帧收敛到 3.23(Linux 为 1.01,剩差来自每帧命令量),FPS 539。

这里顺手证伪了一个猜想:我原本以为 Linux 靠 avail_event 机制把 kick 压到每批一次,宿主侧 trace 实测 Linux 的 kick:command = 0.33,约等于每条 submit 一次 kick,没有什么”抑制魔法”。

5.4 真 fence:每帧一个 8×1 像素的线索

kick 合并后,host 侧对比发现 Starry 每帧多发一个 8×1 像素 R8 小资源的创建+挂载+销毁(一个 run 里 13 万次,而 Linux 全程只有 217 个、都在启动期)。这个资源不是纹理也不是顶点 buffer,LD_PRELOAD 拦截 ioctl(前后六版,踩过 ioctl 号抄错、dlsym 无限递归打穿栈的坑)也只看到行为看不到动机,最后靠反汇编定位到 Mesa 源码:它是 virgl_drm_fence_create_legacy 里 fence 的载体资源——Mesa 认为我的驱动不支持 fence-fd,退化到 legacy fence 模式,每提交一次就造一个资源当 fence 用完就扔。根因在版本协商:DRM version 的 minor 被固定成 0,Mesa 看到 minor<1 就置 supports_fences=false。

修复分三件事:DRIVER_VERSION_MINOR 0→1;EXECBUFFER 实现完整的 FENCE_FD_IN(导入 in-fence 并在提交前真实等待)与 FENCE_FD_OUT(创建未决 sync_file 绑定 fence_id,宿主完成才翻转);新建 sync_file.rs 实现 Linux sync_file 语义——关键是真的语义:初始未决、宿主完成才置位,如果返回”永远已完成”的假 fence,Mesa 的回读会读到脏数据。实测 texture 625/639/637,Mesa 侧 legacy fence 资源创建归零。

5.5 完成通知链:中断为什么没进来

真 fence 落地后,完成的传递靠一个 1ms 轮询的 refresher 任务兜底。控制变量实验证明轮询就是天花板:轮询 10ms 时 offscreen 掉到 97 FPS,改成提交后自旋 500µs 冲到 1277 FPS 但 on-screen 反而变差——轮询和自旋都是错误的兜底姿势,完成通知必须走中断。而中断进不来有三层原因,逐层揭开:其一,probe 时用了 masked transport,把 INTx 屏蔽了,QEMU 发了 32064 次 notify,PCI 层 0 次;其二,解开 mask 后系统死锁,3D 入口持普通 SpinLock,被完成中断打断后中断处理程序要拿同一把锁,单核下死锁,改 lock_irqsave;其三,中断里每次全量 pump + 扫描所有 fence 等待者开销太大,改成 handler 里 pump 一次 + 只查电平 + 只扫真正有 poller 的 fence。期间顺手修了一个:flip 事件的 poll 集合堆积了 13~17 个历史残留 waker,每次唤醒全部拉起(实测 850µs),改成单读者 wake_one 后 8µs。实测 offscreen 703→1141(+62%),atomic ioctl 均价 565µs→90µs。

5.6 epoll 自唤醒循环

到这一步 GPU 侧开销已压了不少,帧时间却还剩 2ms 量级。两组对照先排除嫌疑:glmark2 直连 DRM(不经 Weston)能跑 1059 FPS,说明合成链占约一半;把 Linux 虚拟机整个调度器换成和 Starry 一样的 SCHED_RR 反而跑到 6966 FPS,证明调度算法无罪。然后用零日志原子桶插桩把每次 epoll_wait 从入口到真正 park 的时间钉死:每次都要先排空约 13 条陈旧就绪条目,96.5% 是空的,每条约 10µs。根因在我的 poll 实现:PollSet 满 64 之后环形替换并唤醒被挤掉的旧条目,而 epoll 每次 wait 都新建一个全新 waker 且从不注销——新 waker 和旧 waker 永远认不出彼此(will_wake 不命中),”唤醒上一个自己”→条目重新入队→下次再排空,无限自循环。试了三版:A 去掉二次 poll,无效;B 等待期间抑制自触发唤醒,epoll 开销消失但把真实唤醒也抑制了,直接死锁;C 每个 interest 惰性缓存同一个 waker 复用注册,环形替换命中 will_wake 快路径,不再自唤醒。配套补了 unregister 家族(ppoll 返回、card0、sync_file、axtask poll_io 都按 waker 身份注销),防止残留 waker 吞掉 wake_one 饿死真等待者。实测 texture 500-520 → 778-813(+56%)。

5.7 后台噪音治理

最后一类是纯后台唤醒噪音,而且都是我自己代码造出来的:fence refresher 每 1ms 醒一次(实测约 1000 次/秒);串口维护线程空闲时固定 1ms 轮询;网卡收包线程 10ms 轮询且每次醒来把 net-poll 带醒,两个线程乒乓。修复:refresher 加全局 poller 计数门控,有人等才 1ms 服务,没人等 50ms 兜底停车(约 20 次/秒);串口按最后真实工作空闲退避,1ms→20ms,有输入立即恢复;网卡空闲退避到 1s。有个值得记录的现象:只修 refresher,机制计数好看了 50 倍但 FPS 纹丝不动;三个一起修才 +8.8%(888/924)。解释是 serial/net 的唤醒与 glmark2 的 ioctl 活动时间相关,更容易恰好落在 commit→Weston 的关键窗口里插队。这一阶段还有个插曲:GAP 插桩显示三个后台任务各以每秒 900 次插队,顺着查了半天全错——插桩的名字槽”第一次见到就固定”,而这些 tid 早期属于别的任务,退出后被用户进程复用;给名字槽加任务指针校验后,真相是剩下每帧醒一次的都是 Wayland 管线自己的成员,Linux 带着同样的成员跑 6966 FPS,它们不是根因。

5.8 差距账本:剩下的是什么

插队问题关闭后差距还剩约 7.5 倍。瀑布插桩带来一个重要修正:系统里其实有两条循环——glmark2 渲染循环约 900 次/秒,Weston 合成循环只有 176 次/秒、上屏 88 次/秒,以前把它们混成一条”帧”算账,整个账本是错的。按渲染循环重测的差距账本如下(1.10ms = 100%)。

类目 Starry µs 占比 Linux µs 占比 倍数
应用/Mesa 用户态编码 80 7% 76 40% 1.1×(两侧相同=物理地板)
GPU 提交 ioctl(execb×2 + atomic 摊销) 259 24% 40 21% 6.5×
Wayland socket 系统调用 ×7 370 34% 7 3% 52.9×(最大单项)
唤醒/调度跳 235 21% 40 21% 5.9×
Weston 用户态分派 80 7% 20 11% 4.0×
辅助成员轮次(kb/shell/timerfd) 76 7% 7 4% 10.9×
合计 1100 100% 190 ~100% 5.8×

账本把矛头指向 socket 系统调用单价。用同一份静态二进制在宿主机、Alpine 虚拟机、StarryOS 三方对照微基准,差距分解为三层叠加。

微基准 测什么 Alpine VM StarryOS 倍数
S getpid(裸 syscall 入口) 261 ns 2067 ns 7.9×
C clock_gettime 335 ns 3758 ns 11.2×
A sendmsg+recvmsg 对(就绪,无唤醒) 1317 ns 32675 ns 24.8×
A2 recv EAGAIN(Wayland 每轮都有) 430 ns 25803 ns 60×
B fork 乒乓(含唤醒+调度) 5927 ns 124816 ns 21×

账本就此闭合:7 次 × 入口税 1.6µs + 3~4 次 × 唤醒跳 46µs ≈ 370µs,与瀑布实测吻合。结论有两条:等待本身已经没有浪费(单核上一方的等待就是另一方的运行),剩余差距几乎全是单价问题;用户态编码两侧相同,80µs 是物理下界。socket 单价与 syscall 入口税是内核级战场,超出 GPU 驱动的范围,列入后续工作(第 9 章)。

5.9 大重构适配与一个被底座暴露的地址翻译缺陷

9 月上游发生三线超大规模重构:调度/任务重建(#1775,1282 文件 +14.8 万行)、ax-cpu 集中化(#2363,642 文件)、starry-mm VMA 生命周期重设计(#2261)。我把分支逐块 rebase 到新底座:card0.rs 的 45 项导入逐项对照新符号表、约 200 处用户内存访问改带 current 参数的签名、3 个 Pollable 实现迁移到 lease 模型、遥测引用摘除。编译通过后,3D 设备初始化却失败了,而且命令完全合法、宿主也收到了——为什么就是失败?

按”命令语义”方向提出的七项假设(payload、特性协商、IRQ 等)逐项证伪后,换思路做对照:一个不经过我任何代码的纯 upstream 实验(qt-calc)在官方驱动路径下 PASSED,锁定差异点是我 fork 里的零拷贝——request() 直接把调用者栈帧借给设备 DMA。再用 QEMU HMP xp 从宿主视角直读公式算出的物理地址,看到的是用户代码页的字节,实锤错译。三个因素交汇成这个 bug:fork 零拷贝把命令缓冲留在调用者栈上;公式 virt_to_phys 只在地址落在线性映射窗口时成立;而新底座的 #1775 引入了 vmap 任务栈——内核栈按页映射,不在线性窗口。Weston 的 2D 命令因为 fire-and-forget 拷进了线性窗口内的 arena 所以不挂,分界线是”缓冲住在哪个地址窗口”,与 2D/3D 语义无关。

修复原则:不猜地址,逐页查真页表;能直通就直通,不能才拷贝。

1
2
3
4
5
6
flowchart LR
A["驱动拿到 guest 虚拟地址"] --> B["share():逐页查真页表<br/>axklib::mem_virt_to_phys_checked"]
B -->|"物理连续(堆页热路径)"| C["直通真 paddr<br/>零拷贝"]
B -->|"散页(vmap 任务栈)"| D["bounce 池拷贝<br/>64×16KB + 超大专用页,完成后拷回"]
C --> E["virtqueue DMA"]
D --> E

axklib 的 Klib trait 增加 mem_virt_to_phys_checked(默认 None),axruntime 经 axmm::AddrSpace::query_paddr 实现逐页翻译;ax-driver 的 share() 重写为逐页查询、物理连续直通、散页进 bounce 池;跨层查询走 axklib 是为了避免 ax-driver 依赖 ax-mm 成环。修复后重测,texture 直接到 1423 FPS——新底座本身带来的调度与内存改进让全局性能上了一个台阶,此后所有优化都在新底座上继续。

这版逐页翻译方案在随后的新栈重构中演化成了更简单的终态。重构后的异步热路径把命令字节全部拷进驱动自有、地址稳定的 DMA arena——在途命令不再借用任何调用方内存,”缓冲住在哪个地址窗口”的问题对热路径在结构上不复存在;仅存的零拷贝同步路径 request_sync(只剩初始化与 capset 查询在用)则把约束写进文档契约:调用方必须传分配器所有的内存,”绝不能是任意栈帧(vmap 任务栈在线性偏移的 share 实现下会错译)”。今天的树里 Hal::share 回到线性偏移公式,query_paddr 与逐页翻译代码已不复存在——那场事故留下的永久产物不是一段翻译代码,而是”内存必须先落进地址稳定窗口再交给设备”这条写进合同的边界。

5.10 fence 延迟:41 倍的最大单项

新底座上的重测账本显示,优化后的差距不再分散而是高度集中。

指标 Linux VM StarryOS 差距
FPS(texture nearest) 6145(0.163 ms/帧) 1423(0.703 ms/帧) 4.3×
fence 延迟(创建→signal) 39 µs(p50 14 µs) 1598 µs 41×
execbuffer ioctl 38 µs 64.1 µs +26 µs/帧
syscall 微基准 S/C 261/337 ns 84/117 ns Starry 反超

fence 延迟 41 倍是压倒性的最大单项,而且根因在代码里就能看到:sync_file.rs 的 fence 完成靠 refresher 任务的 1ms 轮询节拍——fence 在宿主完成后,要等下一个 1ms tick 才被 refresh+signal;满载下这个 tick 还被调度拖到平均 1.6ms,分布形状(以 1ms 为中心散开)与之精确吻合。这也修正了我此前把 0.51ms 延迟簇归因于”帧相位量化”的误判。Linux 走的是中断即时路径。做一个简易验证:把 fence 完成改为 SCHED_FIFO 实时线程即时 signal(暂不做完整事件化),texture 2394-2461(+7175%)、shading 2495(+81%),帧时间 0.70→0.41ms,总分到 2400 档。方向确认后,事件化与等待结构的完整工程化进入第 6 章。

6. 工程化落地(第 10~12 周)

6.1 异步控制队列的工程化

实验版异步验证了方向,但距离可合入还差工程化。这一步的难点不在”发出去”,而在异步与同步、2D 与 3D、正常与异常路径的两两组合下都不死锁、不丢命令、不吃内存。四个核心设计裁决如下。

难点 裁决
kick 策略两难:依赖调用方在事务边界 kick 会饿死不守约的消费者;逐命令 auto-kick 实测损失约 8% 阈值判定 + 事件去重:入队后累计未送达数,满 8 条 / avail 环将满 / 同步命令任一条件才通知;是否真写 MMIO 由标准 should_notify 决定;Weston 这类守约消费者帧末 ctrl_notify() 立即送达积压
上游 add_notify_wait_pop 在混合流量下弹出别人的完成条目 阻塞调用改 request_sync:借用调用方栈、同函数帧内”入队→kick→等自己的 token→校验”,零拷贝;同步条目不登记收割记录,收割方按 FIFO 遇无记录条目即停——异步与中断永远偷不走阻塞者的完成条目
QEMU 队列深度与直觉相反:统一 64 深直接 InvalidParam 从校验回溯到 QEMU 源码:设备固定 ctrlq=64、cursorq=16,驱动严格按设备实情配置
DMA 缓冲地址漂移:命令字节随结构体移动,设备 DMA 读到垃圾且无任何报错 初始化时一次性分配固定 arena(命令 96B/响应 24B × N),在途命令字节从头到尾活在固定内存,驱动只传 u16 槽位号——“地址漂移”这类 bug 在结构上不可能再发生

三个活性缺陷也各有回归测试钉死:阻塞 submit_3d + wait_fence 的真实死锁(两条收割路径记账不对称);wait_fence 在在飞命令低于 kick 阈值时”只等不推”的低阈值死锁(每轮开头补 notify());文档教的轮询模式永远推不动 fence(ctrl_notify 只送达不回收,修正文档并给出标准轮询循环)。A/B 定档:同机同用户态,唯一变量为提交模型,33 场景平均 +24.6%(788→982,各场景 +15~26%)。该 PR(virtio-drivers #265)保持开放;主线侧则由 6.2 节的 #2536 把这套代码带进 tgoskits 仓库的 drivers/gpu/virtio-gpu crate。

6.2 合入主线:从同步版到异步版

主线合入分两步。第一步 #2483(2026-09-22 合入)是功能完整但同步的版本:每个命令等宿主应答,先让 3D 驱动以正确语义进入 dev。随后上游 #2506 重写了整个 GPU 驱动栈(全新 rdif-gpu/axgpu 门面、card0 拆分、IRQ 任务化、reset/DeviceLost 加固),我的异步实现叠在旧接口上不再适用。第二步是把异步语义作为增量逐层重放到新栈上(#2536,2026-09-29 合入),关键裁决包括:设备层以异步 ControlQueue 为原生提交模型,必须拿应答的命令走 request_sync;同步命令保留 #2506 的 fence 戳与响应逐条校验加固;reset() 同步作废控制队列,此后一切队列操作快速失败;错误链 TimedOut→ETIMEDOUT、QueueBusy→EAGAIN 全程对齐 Linux errno。

合入后在同机实测:glmark2 33 场景完整跑完,总分 3040(对同步基线 1739/2234 为 +36%~+75%),套件平均帧时间 0.329ms;旧栈上场景级失败的 refract(GL_FRAMEBUFFER_UNSUPPORTED)随新栈恢复,实测 1433 FPS。此后的开发全部基于已合入 dev 的这版代码。

6.3 环绕点丢 kick:一个规格级的正确性缺陷

异步化之后出现过一类更严重的问题:重载几分钟后整队列耗尽、显示冻结。QMP 取证(x-query-virtio-queue-status)钉死现场:宿主主循环空闲、last-avail-idx = used-idx = 65534 冻结、inuse=0,而 guest 侧 kick 持续增长——宿主从未被告知有新命令。根因在 virtio-drivers 0.13.0 的 should_notify():它用普通 u16 >= 判断 RING_EVENT_IDX,不满足 vring_need_event 要求的环绕语义。批量发布跨过 65536 环绕点后 avail_idx 绕回小值,而落后的宿主仍持大 avail_event,此后所有边界 kick 恒被抑制。修复是让边界 kick 恒发 MMIO、不过闸门——多余的 kick 对 virtio 规范恒安全,代价仅每事务一次 MMIO 写。这个修复与 #2536 一起进入主线,同机 A/B texture 1865→2933(+57%),帧时间 0.536→0.341ms。

6.4 等待结构改造与锁外等待

完成通知钩子(IRQ worker 泵完成时唤醒 fence poller,单独实测 +4.8%)落地后,还剩最后一块结构性改造:把全部等待路径移出设备锁。这不是性能问题,是活性问题——此前三次 guest 冻结事故(09-25 fence-spin 三变体挂死、09-26 490 秒 QueueBusy、09-30 在飞窗口实验 4 倍变慢)有一个共同结构根源:等待者在全局设备锁的临界区内自旋,而等待者持有的锁,正是完成泵推进水位所需要的锁。

对照上游 Linux 驱动,它永不卡死的结构性事实是:整个驱动只有 4 个阻塞点,全部是 wait_event 族睡眠;完成推进(微秒级临界区 + 独立工作上下文)所需的全部资源,与任何等待者持有的全部资源,交集为空。等待多少、等多久、谁在等,都不影响系统前进;最坏形态是”单个任务可杀地睡着”,而不是”CPU 永远转”或”锁永远被持有”。本 PR 把本仓库 GPU 栈的等待形态对齐这一结构。

1
2
3
4
5
6
7
8
9
10
11
12
flowchart TB
subgraph before["改造前:等待者与完成泵互锁"]
W1["VIRTGPU_WAIT / UNREF / 回读<br/>持锁自旋"] -->|"持有"| L1[("MAIN_GPU 全局锁")]
P1["完成泵推进水位<br/>需要同一把锁"] -->|"永远抢不到"| L1
L1 -.->|"等待越长→泵越停→等待更长"| W1
end
subgraph after["改造后:资源交集为空"]
W2["等待者<br/>wait_gpu_condition<br/>锁间探测 + 锁外睡眠"]
P2["完成泵 / IRQ worker<br/>推进水位 + set_completion_notifier 唤醒"]
W2 -->|"探测段:拿锁→泵→查条件→放锁(有界轮次)"| P2
P2 -->|"完成事件"| W2
end

改动是四件事的组合:set_completion_notifier 让 IRQ worker 泵完成时唤醒等待者;wait_gpu_condition(timeout, cond) 提供”检查条件、放锁、睡眠、被唤醒或超时”的锁外等待原语;TRANSFER_FROM_HOST/RESOURCE_UNREF 改为带 fence 的 fire-and-forget,释放观察从”内联排空”改为等待本对象的 fence;探测段预算按背靠背实测校准到 32768 轮。其中两个形态修正都是 A/B 实测逼出来的:计划原稿的”全队列排空等待”首轮实测即暴露结构性饥饿——排空判据要求 FIFO 全空,而 glmark2 的持续提交让队列永不清空,轻场景减半、terrain 崩到 1 FPS(全套件 2123 分),撤掉唤醒补全后更是 54 分;改为 per-fence 等待后,used ring 的 FIFO 隐式排序保证”fenced 条目被弹出 ⇒ 此前全部命令已弹出”,饥饿在数学上不可能。反过来,纯睡眠形态在 QEMU 同步处理 kick 的现实下每帧付一次睡眠-唤醒跳,重场景 -40%~78%——所以最终形态是”锁间探测 + 锁外睡眠”两段:探测段有界自泵,真停滞才落睡眠。

形态 总分 墙钟
基线(钩子分支) 2749 585.8s
排空等待(计划原稿形态) 2123 1399s
排空等待、无唤醒补全 54 1477s
per-fence + 纯睡眠偏重 1349 1394s
探测 4096 轮 2513 759s
终态:探测 32768 轮 2813(+2.3%) 580.5s

终态逐场景 19/19 无一劣化超过 1.6%,14 个正收益,零超时告警。两轮审查共 7 条意见(完成查询不自泵、释放观察能力无关性、无 backing 回读误报 -ENOENT 等)全部修复,每条配确定性回归测试;修复后获审查通过,于 2026-10-02 合入 dev(合入提交 272813273,共 11 个提交,11 文件 +574/−143)。至此,异步提交、真 fence、完成通知与锁外等待构成 dev 主线 GPU 栈的最终形态。

6.5 稳定性:从三次冻结到十轮浸泡零冻结

值得单独成节的是正确性的收尾。此前三类冻结对应的宿主侧表象各不相同,但 guest 侧的结构原因都是”无界等待”:同步等待在关中断自旋内等宿主,宿主一停 guest 永久自旋;request_sync 等待无界(缓冲区在调用方栈帧,放弃等待会留下悬垂 ring 条目),后改为 5 秒截止,超时破队快败、由调用方失败路径复位设备。另一个反向教训是 09-30 的 K=3 在飞窗口实验:想用滑动窗口限制流水线深度来规避宿主侧丢完成,实测被证伪——多客户端聚合下在飞深度常态大于 3,套件 4 倍变慢、vCPU 100%,实验数据保留在旁支分支存证,路线判死。

#2557 之后的终态:重场景子集(texture-mipmap/shading-phong/effect2d/shadow/terrain)× 10 轮单引导循环,50/50 场景退出码全 0,零冻结、零超时,轮次分数无衰减;期间一次宿主侧瞬时低谷(单轮 1432)被有界等待吸收后即恢复——这个输入形态在旧栈正是会雪崩成卡死的类型。宿主侧丢完成(QEMU 11.1 virtio-gpu ↔ virglrenderer 交互问题,任何 guest 侧改造都防不住)现在的表现是 5 秒超时 + -EBUSY 重试或复位恢复,串口 warn 可见,而不是永久卡住。

7. 组件化设计

前六章按时间线讲完了”做了什么”,这一章换个视角:这些代码最终以什么形状留在仓库里。TGOSKits 的分层约定是可复用逻辑放进 drivers/、components/,OS 适配代码贴近使用它的内核;这次实习的 GPU 栈从第一天起就按这个约定切分,而这些边界不是形式——同一份驱动既要支撑 3D 全功能路径,又要让纯 2D 的 QEMU CI 配置和无 GPU 配置保持绿色,还要在上游三次大规模重构中控制迁移成本,依靠的正是本章描述的这些合同。

7.1 接口合同与能力门控

硬件无关的合同集中在 drivers/interface/rdif-gpu:宿主侧(card0/axgpu)只看得到 trait,驱动侧只需实现 trait,三份合同对象如下。

合同对象 语义
VirglOps trait 驱动能力面:submit 提交命令流并返回 fence、生命周期命令 fire-and-forget、ctrl_notify 边界投递、release_buffer 释放缓冲
Completion(标 #[must_use]) fenced 提交的”释放证明”:丢弃令牌即编译错误,编译器强制每条 fenced 释放被观察(仓库内两处合法丢弃在调用点注明理由)
GpuError 驱动域错误枚举,含 TimedOut 等语义化变体,向上翻译成 errno(见 7.4 节)

能力协商结果在驱动侧收敛为三个 getter——has_virgl、has_resource_blob、has_context_init——每个 3D 命令入口经 require_virgl() 提前失败(域错误 Unsupported),而不是把未定义命令发给宿主。有了这层门控,”纯 2D 配置”与”3D 全功能配置”共用同一份驱动代码,能力差异只体现为协商结果。

门控的价值在 #2557 第二轮审查中被一个真实缺陷反向证明:释放观察最初统一走 virgl 专属的 virgl_wait_fence,在纯 2D virtio-gpu(本仓库 QEMU 用例的配置)上对 dumb buffer 释放返回 Unsupported——UNREF 实际已成功却打出失败日志,backing 无证明交回。修复是把”等一个完成令牌”下沉为能力无关的 ax_gpu::wait_completion(completion, timeout),virgl_wait_fence 只留给唯一 virgl 语义调用者(VIRTGPU_WAIT 等 SUBMIT fence,2D 设备不可达)。”能力相关的 API 与能力无关的 API 分开存放”由此成为固定边界。

7.2 依赖方向与跨层服务注入

驱动栈不能反向依赖内核内存子系统——ax-driver 直接依赖 ax-mm 会构成循环。跨层服务因此一律走 components/axklib 里的 trait 合同:Klib 定义 mem_iomap、mem_virt_to_phys、DMA 一致性等平台服务,由 axruntime 的 KlibImpl 经 axmm/axhal 提供真实现;时间同理,驱动自身不读时钟,VirtIoGpu::new 接收 axklib::time::monotonic_nanos 作为 fence 有界等待的时钟源。依赖形状如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
flowchart LR
subgraph reusable["可复用包(no_std + alloc,跨内核)"]
VG["virtio-gpu crate<br/>协议 + 异步控制队列"]
AD["ax-driver VirtIoGpu<br/>适配与 Hal 实现"]
RG["rdif-gpu<br/>VirglOps 合同"]
end
subgraph glue["跨层合同"]
KL["axklib:Klib trait<br/>mem_iomap / virt_to_phys / 时钟"]
end
subgraph oslayer["OS 适配层"]
RT["axruntime KlibImpl<br/>经 axmm / axhal 实现"]
AXG["axgpu 门面"]
C0["card0(DRM UAPI)"]
end
VG --> AD
AD --> RG
AD -.->|"只依赖 trait"| KL
RT ==>|"提供实现"| KL
RG --> AXG
AXG --> C0

#![no_std] + alloc 是这些可复用包的统一形态,virtio-gpu 的 crate 文档开篇即写明自身只依赖 alloc、不依赖任何 OS。这个依赖形状的直接收益在 5.9 节已经出现过一次:当地址翻译的正确性问题暴露时,修复点被合同收敛在 Hal::share 与 request_sync 的文档契约两处,没有扩散成对内核子系统的普遍依赖。

7.3 能力组合与无设备退化

驱动 API 按”2D/3D × 同步/异步”的组合设计:异步控制队列是独立模块(ctrl.rs),同步 request 的语义保持不变,四种组合(OS 同步/异步、设备 2D/2D+3D)共用同一实现而不互相污染。无设备场景同样是一等公民:无 GPU 的配置里 card0 节点仍然存在,所有 GPU 调用路径以 axgpu::has_display_controller() 先行判定——这条守则来自一次真实事故:无 GPU 配置下设备从未初始化,不设防的调用会锁上未初始化的全局设备直接 panic,此后”节点存在 ≠ 设备存在”成为固定检查。

验证矩阵同样按组合展开:持续集成的 QEMU 用例配置本身就是纯 2D 设备,无设备路径由 gpu-absent 用例覆盖,3D 全功能路径由 virgl-test 应用(Weston + glmark2)在 x86 QEMU 上验收——纯 2D 线证明退化路径不腐化,3D 线证明全功能路径不回退。

7.4 错误域边界

每个组件持有自己的错误域,跨界时显式翻译:驱动 Error → GpuError → card0 映射 → VfsError。映射规则对齐 Linux errno 语义——NotReady/NotAvailable 映射为 WouldBlock(EAGAIN),TimedOut 映射为 ETIMEDOUT——让 Linux 用户态拿到标准 errno 而不是仓库自定义编码。这条边界是 UAPI 兼容的一部分:Mesa 对 EAGAIN、ETIMEDOUT、EBUSY 的处理分支都按 Linux 语义写,错误翻译错一个变体,用户态就会走进错误的重试或失败路径。上游”domain-owned error boundaries”的重构方向与这条实践一致,GPU 栈从合入第一天就按这个形态实现。

8. 成果汇总

8.1 性能轨迹

下表按时间列出每个优化步骤的改动、同机实测数值与测量口径。前中期以 texture nearest 单场景 FPS 为口径(当时只有单场景跑法),#2536 之后以 33 场景总分为口径,两类数字不可直接相比,以口径列为准。

阶段 关键改动 数值 口径
第 5 周 首次跑通 virgl 69 总分(首次跑通)
第 7 周 FENCE 位关 / attach-once / 全异步 405 → 406 → 439 总分
第 8 周 kick 边界合并 / 真 fence-fd 539 / 637 总分 / texture FPS
第 9 周 中断链 / epoll 修复 / 噪音治理 1141(offscreen)/ 813 / 924 offscreen / texture / build FPS
第 11 周 rebase 新底座 / fence 事件化(简易) 1423 / 2461 texture FPS
第 12 周 异步+真 fence 合入(#2536) 3040 33 场景总分
第 12 周 完成钩子 + 锁外等待(#2557) 2813(基线 2749) 33 场景总分
1
2
3
4
5
6
7
xychart-beta
title "glmark2 成绩轨迹(同机 QEMU 1 vCPU;混合口径仅示趋势,精确口径见上表)"
x-axis ["初跑", "FENCE", "attach", "全异步", "kick", "fence-fd", "IRQ", "epoll", "噪音", "rebase", "fence-RT", "异步合入", "锁外等待"]
y-axis "FPS / Score" 0 --> 7200
bar [69, 405, 406, 439, 539, 637, 1141, 813, 924, 1423, 2461, 3040, 2813]
line [69, 405, 406, 439, 539, 637, 1141, 813, 924, 1423, 2461, 3040, 2813]
line [6900, 6900, 6900, 6900, 6900, 6900, 6900, 6900, 6900, 6900, 6900, 6900, 6900]

图中水平参考线是同机 Linux 虚拟机的量级(6145~6966,随宿主状态波动)。轨迹上有两个不连续跳变值得注意:第 11 周的 rebase 跳变来自上游新底座本身的调度/内存改进,不是我的改动;3040 与 2813 的差异主要是宿主状态与场景组成的同日波动,两者的意义在稳定性而非分数。

8.2 拉取请求清单

仓库 PR 状态 内容
rcore-os/tgoskits #1415 已合入(2026-07-08) doom Wayland GL 测试 + DRM stride-aware present 修复
rcore-os/virtio-drivers #257 已合入(2026-08-14) virtio-gpu virgl 3D / blob 支持,含运行时门控回归测试
rcore-os/virtio-drivers #258 已合入(2026-08-17) 移除 VirtIOGpu.queue_buf_recv
rcore-os/tgoskits #2483 已合入(2026-09-22) virgl DRM 集成(同步版),3D 加速进入 dev
rcore-os/tgoskits #2536 已合入(2026-09-29) 异步 fire-and-forget 提交 + 真 fence + 环绕丢 kick 修复
rcore-os/virtio-drivers #265 开放 Linux 风格批量投递的异步控制队列
rcore-os/tgoskits #2557 已合入(2026-10-02) 控制队列等待全部移出设备锁(11 提交,11 文件 +574/−143)

8.3 沉淀的测量资产

三个月里为归因建起来的工具链比单个修复更耐久:参数化 A/B 驱动脚本(逐场景对齐、自动对表)、LD_PRELOAD ioctl 拦截器、QEMU tracepoint 命令计数解析、QMP/HMP 取证流程、零日志原子桶插桩与瀑布分解、fence 延迟分布直方图。功能侧则留下了 apps/starry/virgl-test 应用(Weston + glmark2 全自动跑完出分),它既是验收工具也是回归门禁。

9. 边界与后续工作

如实声明当前没有做完、或明确超出本次实习范围的部分。其一,7 个拉取请求中仅剩 virtio-drivers 的异步控制队列 #265 尚未合入(上游 crate 节奏较慢,语义已由 #2536 带进 tgoskits 主线);texfix 相关的 12 轮专项与 40 轮全套件浸泡尚未跑,Phase 3 的两个决策项(在飞窗口、完成回调收割)按计划由这两份数据触发。其二,性能差距还剩约 2 倍,账本显示剩余部分的主战场已不在 GPU 驱动:syscall 入口税(7.9×)与 socket 本体单价(24.8×)是内核级问题,影响所有系统调用,需要单独立项。其三,宿主侧丢完成是 QEMU 11.1 virtio-gpu 与 virglrenderer 的交互问题,任何 guest 侧改造都防不住,现靠有界等待与恢复路径兜底。其四,aarch64/riscv64/loongarch64 目标的 clippy 与 QEMU 验证本地缺交叉工具链,由 CI 覆盖,本工作不涉及实体板卡路径(virtio-gpu 是虚拟化 GPU)。

10. 结语

三个月把 StarryOS 的图形能力从”不支持 GPU”做到了 virgl 硬件加速完整可用、性能约为同机 Linux 虚拟机的一半、且连续浸泡零冻结。回头看,比分数更值钱的是方法论上的三条:先造标尺——没有 Alpine 对照和同机 A/B 纪律,后面每一个百分比都无从谈起;每个结论都要有反事实实验——异步化失败、K=3 窗口证伪、avail_event 假设证伪,这些”失败的数据”和成功修复同样重要,几次最大的性能收益恰恰来自顺着失败线索往下挖;性能问题的真相几乎总不在最初怀疑的地方——attach 风暴藏在死字段里,fence 问题藏在版本号里,地址翻译 bug 藏在”重构后新出现的地址窗口”里。剩下的一半差距账本已经写清在哪里,那是下一阶段的地图。

相关链接

  • 项目仓库:rcore-os/tgoskits
  • 实习周报(wiki,共 12 周):zyc107109102/tgoskits wiki
  • 主体分支:virgl-port(同步合入基座)、virgl-async-rebase(异步,#2536)、virgl-wait-restructure(等待改造,#2557,已合入 dev)
  • virtio-drivers fork:zyc107109102/virtio-drivers(异步控制队列,#265)
  • 测试应用:apps/starry/virgl-test(Weston + glmark2 自动化验收)

实习总结报告:为块设备存储链路实现异步 I/O 运行时——异步提交、单请求完成与背压机制的设计与实板验证

实习项目:StarryOS 块设备存储链路的异步化改造——为块设备运行时实现异步提交、单请求完成等待与背压机制,在星光 2(VisionFive 2,JH7110)平台 SD 卡上完成实测验证
工作仓库:rcore-os/tgoskits(上游开源仓库),本人仓库:https://github.com/xianxw/tgoskits


Read more »

同一台 x86_64 机器,Linux、Windows、macOS 给程序的是同一批能力:读写文件、映射内存、建线程、计时。不同的是三件事:程序怎么进内核,可执行文件长什么样,路径、换行、设备名这些约定怎么写。StarryOS 把这一层拆成可替换的包,Windows 与 macOS 各一套,分别参考 Wine 与 Darling。

Read more »

摘要

本次实习围绕 StarryOS 的网络驱动与网络栈开展工作,主要包括 SG2002 板载 WiFi 驱动的修复、适配与性能优化,以及 ax-net 网络栈的可观测性建设。

WiFi 驱动方向从上下行吞吐不对称的问题出发,先修复重构前的发送完成等待,再在主线完成驱动重构后,完成 AIC8800D80 的启动、连接与收发适配,并通过多帧聚合、发送额度缓存和连续提交减少发送路径的事务开销。搭载 AIC8800D80 的荔枝派 SG2002 板卡实测上行吞吐由适配后的约 7.3 Mbps 提升至约 40.0 Mbps,下行由约 20.3 Mbps 提升至约 35.2 Mbps。

网络栈方向完善接口累计统计,提供队列组运行状态快照,并围绕队列轮询、中断重新使能、设备暂时无法接纳操作并保留资源供重试、帧交接,以及协议执行器让出 CPU 等处理过程,定义六个静态追踪事件,供 tracefs、perf 与 eBPF 消费。同时补充内核态地址访问、eBPF 指令预处理和动态探针注册路径的错误防护,通过单元测试与系统测试检查事件触发条件和接口装配,并在四个架构上验证 eBPF 程序的加载、附着与读取,为定位网络问题和后续性能优化提供观测手段。

相关链接

一、SG2002 WiFi 驱动的修复、适配与优化

荔枝派 SG2002(LicheeRV Nano)的 WiFi 芯片通过 SDIO 总线与主控相连。这项工作起于上下行吞吐不对称的问题:上行(板卡到主机)为 0.85 Mbps,下行(主机到板卡)为 12.0 Mbps。修复后两个方向的吞吐达到同一量级,后续又通过多帧聚合与连续提交降低了发送路径的固定开销。

工作过程中,驱动结构经历了一次由主线完成的整体重构。以下分三个部分:第一部分为重构前的驱动状态与这一阶段完成的工作;第二部分为主线重构带来的契约与边界变化,它不属于本次工作,但构成后续工作的前提;第三部分为重构之后完成的芯片适配与发送路径优化。

Read more »

StarryOS 当前内核未解耦系统调用时期的默认系统调用实现是 GNU Linux 的系统调用,所以 Linux 本身就是最好的参照:同一个程序在两边跑,结果该一样,耗时该接近。做法是把两边放进同一个环境,把差距落到具体子系统,再对着 Linux 的源码改;浏览器启动这条长路径则拆成段,逐段比。

Read more »

摘要

在 StarryOS 上进行的异步驱动开发工作,为内核构建异步串口与异步网卡两个驱动,覆盖从中断响应、环形缓冲与描述符队列,到 TTY 行规程与协议栈接入、多核绑核调度的完整链路。核心工作包括三项:异步串口驱动,采用”中断只唤醒、后台任务搬运”的结构,ISR 精简至约 25 行,配合双 SPSC 环形缓冲与非阻塞 I/O,在 Lichee RV Dock D1 真板上将 64B 短包写到 96.6% 物理线速;异步网卡驱动,从硬件到应用分五层,发送以代际号与序号记账,提交、完成、回收三处计数闭合后才复用缓冲区,恢复语义区分等待者取消、提交前撤销与设备持有三种情况,在 QEMU 十六核(SMP=16)下实现 TCP/UDP 双向收发、poll/select 就绪通知与故障注入后的恢复;以及统一处理流程与多核适配,两个驱动遵循”四段流程加四条规则”的同一套写法,后台角色固定绑核、跨核恰好一次 IPI 唤醒,两驱动在同一内核中长期共存工作。在此基础上,通过 D1 真板测试与 QEMU SMP=16 六组场景,验证驱动功能性能。

Read more »

作者: 王浩旸 (silicalet)
仓库: rcore-os/tgoskits | 个人 fork: silicalet/tgoskits
跟踪分支: 001-starryos-signal-syscalls、002-starryos-nix-smoke、003-starryos-nixpkgs

训练营这三条分支走下来,我最大的感受是:内核兼容性工作的难点不在写代码,而在搞清楚”问题到底是什么”。三次都经历了同一个循环——拿到一个模糊的现象,花大量时间排除自己以为是根因的方向,最后发现真正的问题在完全没料到的地方。

001 改测试不改内核,002 顺着 Nix 挂起追到 ext4 位图缓存,003 花了一周才搞明白”QEMU 退出码 0 不代表成功”。三条分支共用一套记录方式:Spec Kit 的 spec/plan/tasks/findings 管结构,本地 ./silicalet/ 目录存实验日志,不进 upstream。本文记的是从头到尾怎么走过来的,以及过程中那些走错的弯路。


001:只改测试,不改内核

001 的任务边界一开始让我有点不适应。spec 写得很死:本轮只改测试和文档,不动内核实现。对六个信号扩展系统调用做 readiness 评审,补齐源码级回归测试。

刚开始觉得这个约束像在限制产出。但做到一半就理解了。信号子系统的 bug 不会在功能测试里暴露,它藏在特定的时序、掩码组合、siginfo 元数据场景下。StarryOS 这些 syscall 在 dispatch 层都有实现,但用户态覆盖参差不齐——有几个甚至没有独立测试。如果不知道”当前行为是什么”就直接改内核,等于在黑暗里装修。先把”正确行为”用测试固化下来,后续谁动信号子系统,这些测试就是回归防线。

实际写测试时,最难的不是写断言,是确定”参考行为”。比如 rt_sigtimedwait 的 info 参数允许传 NULL,NULL 时 siginfo 不回写但信号仍被消费——这种细节 man page 一句话带过,Linux 内核为什么这么实现、StarryOS 的实现是否符合,得自己想清楚。每个 syscall 写一篇 beginner-oriented 笔记的过程逼着我把语义想透,比单写测试难得多。

六个测试在 Linux 参考环境下断言全过后,我想去 StarryOS 上验证,结果卡在一个完全无关的地方。grouped syscall case 按字母序执行,test-raw-msg-peek 排在我的 signal 测试之前,它的 prebuild 脚本在 staging rootfs 里跑 apk wrapper 时触发了 ELOOP,整个 grouped case 在预构建阶段就断了,根本到不了我的测试。

这个阻塞让我意识到一件事:源码级测试的隔离性在 grouped runner 里并不保证。测试本身写对了、Linux 参考过了,但 grouped case 的 prebuild 依赖链可能让它在 StarryOS 上根本跑不到。001 的交付因此是完整的——六个测试、分析文档、六篇笔记——但 StarryOS 侧的 expected-vs-observed 对照只能推迟到 prebuild 阻塞解除后。这是这一轮最大的遗憾。


002:从”Nix 挂起”到 ext4 位图缓存

002 的目标很小:让 Nix 在 StarryOS 上完成一个最小 derivation 构建。但从第一天起,现象就模糊得让人抓狂。

第一个阻塞是 Nix 启动直接 abort,报”saving parent mount namespace”。这个好查——Linux 基线显示 Nix 要访问 /proc/self/ns/mnt,Starry 的 procfs 没这个节点。加了之后 Nix 能启动了。

但 nix-build 随后挂在 build 阶段,日志只有一句”waiting for lock on /nix/store/…”。这个锁是 builder 自己创建的,不存在竞争,Linux 上一秒就完成。为什么 Nix 拿不到自己的锁?

面对这种模糊现象,最容易犯的错是直接猜根因改代码。我选了相反的策略:从离现象最近的层开始,每一层先证明它不是问题,再往下走。锁语义、进程创建、PTY、clone、execve——一层层查下来全 PASS。到第六层 flock 操作,终于出现红灯:两个不同的锁文件共享同一个 inode 号,flock 按 (device, inode) 键控,自冲突。再往下查一层,是 ext4 的 inode 分配器把同一个 inode 号分配了两次——第一次分配的位图更新丢了。

这个根因和我最初想象的方向差了十万八千里。如果一开始就猜”ext4 有问题”,我会在 ext4 几百个代码路径里盲目搜索。但七层排除下来,搜索空间被一步步缩小,最后落到 inode 分配器的位图缓存路径上。每一层 PASS 不是浪费时间,它们构成了”已排除清单”,让我在进入下一层时确信”锁语义、进程、PTY、clone、execve 都没问题,问题一定在更底层”。

修完 ext4 位图缓存之后,Nix smoke 还是没通。一个 bug 修完暴露出下一个,我前后修了六七个配套问题:PID namespace 孤儿进程回收、pipe EOF 事件上报、rsext4 删除仍被 fd 引用的 inode 时过早释放、unshare(CLONE_FS) 未支持、stale flock 进程退出后不释放。每一个单独看都不大,但叠在一起才让 Nix smoke 真正跑通。

这个过程中最让我印象深刻的是 rsext4 open-unlink 的设计。Nix 创建 .lock 文件后立刻 unlink,但仍然持有 fd 并通过 fd 写入。Linux 的语义是:unlink 只删除目录项,inode 在所有 fd 关闭后才释放。Starry 原来是删了就释放,导致 Nix 通过 fd 写入到一个已被回收的 inode。修这个需要对齐 Linux 的 i_links_count 和 i_count 双计数语义。这个设计不是我凭空想出来的——是对齐 Linux 已有语义的结果。

最后 nix-nosandbox 终于 PASSED 的那一刻,我盯着日志看了很久。从”waiting for lock”到完整的构建输出,中间隔了将近一周、七层插桩、六七个配套修复。一个最小 derivation 构建能跑通,背后是一整条兼容性链路的打通。


003:QEMU 退出码 0 是个谎言

003 在 002 基础上激活 nixpkgs 源码构建。这一轮我花时间最多的不是修 bug,是搞清楚”测试到底有没有失败”。

nixpkgs 测试的现象是:no-sandbox 阶段通过,Nix 开始下载解包 nixpkgs,没有 panic,没有失败标志,QEMU 退出码是 0。如果只看 runner,这像一次正常关机。但测试脚本的 EXIT trap 没执行,shell 不是通过正常退出离开的。这个细节一开始没引起我足够重视——我以为就是测试在某个点正常退出了,只是没打 pass 标志。

最先怀疑的是 OOM。但采样显示 nix 进程 RSS 才几十 MB,系统内存还有大半空闲。把 QEMU 内存从 2GB 加到 8GB,失败只是从 80 秒延后到 400 秒,仍然静默退出,退出时还有 3.8GB 空闲。加内存只延后了失败,没消除它。这不是简单的内存耗尽。

接下来一周我陷入了排除法的泥潭。在 StarryOS 已知的三个关机路径加诊断——PID 1 退出、primary panic、recursive panic——都没触发。我当时用排除法猜”是 page cache 回收期间的递归 panic”,在 panic 路径本身加诊断后直接被推翻:panic_shutdown 和 page_cache_reclaim 都没触发。排除法能缩小范围,但确认根因必须找到触发证据,不能靠”其他都不对所以这个对”。这是我这一轮最大的教训。

转折点是追踪 system_off 的调用链。QEMU 退出码 0 来自 someboot 的 x86_64 shutdown 函数往 0x604 端口写 0x2000,触发 QEMU Q35 的 ACPI S5 关机。顺着 system_off 的五个调用点查,前四个都排除了,第五个是 libc_compat 里的 abort()。在 abort 和 shutdown 加直接 COM1 端口写诊断,重新跑,两个标记都触发了。

原来 nix(C++ 二进制)遇到致命错误时,musl libc 调用 abort()。StarryOS 的 libc_compat 把 abort() 映射成 ax_terminate → system_off → QEMU 关机。QEMU 退出码 0 不是测试成功,是整机关机。

这个结论也推翻了”几 GiB free 就排除 OOM”的判断。进程 RSS 只描述用户进程映射,不代表内核 page cache 占用。真正增长的是脏页缓存——Nix 解包源码、构建 Git cache 时创建大量文件,没有周期写回时 dirty page cache 持续增长,挤压 Rust 全局分配器的堆空间,触发 rust_oom → abort。所以”free 还很多”是假象,因为我看的是错误的 free 指标。

修了两处:后台 worker 每五秒同步一次文件缓存,避免脏页无界增长;ax-alloc 的字节分配失败路径改为调用已注册的 page reclaim callback 后重试。修完之后静默关机不再出现。

但完整 nixpkgs 仍然没通过。89,704 个条目的 archive 在 StarryOS 上解压需要 6-7 分钟,远超 180 秒预算。试了加 vCPU、换压缩格式、用 tmpfs 绕过 ext4,都没改变三分钟结果——tar-to-Git-cache 这一步本质上是单线程的。

这里我差点犯一个错。Spec Kit 任务希望有一个 grouped system regression。我测了一堆合成 VFS workload,全通过,无法复现真实 nixpkgs 超时。如果用一个本来就通过的 benchmark 冒充红色回归测试,CI 会变绿但什么也防不住。最后我选择诚实记录”当前没有有效的小型红色复现”,而不是造假。未来合适的回归层级是 axfs-ng 的 bounded page-cache pressure 测试,不是把完整 nixpkgs archive 塞进普通 syscall suite。


回头看

三条分支走完,几个感受比较深。

模糊现象是最难的起点。 Nix 挂起、QEMU 退出码 0、测试跑不到——这些现象本身不告诉你任何信息。002 的七层插桩和 003 的 abort 路径追踪都证明了同一件事:现象越模糊,越不能猜根因改代码,必须从离现象最近的层开始逐层排除。每一层 PASS 都是在缩小搜索空间。

排除法会误导自己。 003 我曾用排除法猜”recursive panic”,排除了三个已知路径就下结论,结果被下一轮诊断直接推翻。排除法只能排除你想到的路径,确认根因必须找到触发证据。

观测指标要问覆盖范围。 003 早期看到”几 GiB free”就排除 OOM,是错的——free 指标不覆盖 page cache。002 一开始想从 Nix 用户态日志找原因,但那里只有”waiting for lock”,看不到 inode 号。观测点选在哪决定了能看到什么。

不为必须有回归测试造假。 合成 workload 全通过时,诚实记录”当前没有有效复现”比造一个与根因无关的测试强。CI 变绿但防不住真正的 bug,是更糟糕的状态。

本地笔记是调查记忆。 三条分支的 silicalet/ 目录存了几十份实验日志,通过 .git/info/exclude 排除在 upstream 之外。002 从 NIX-SMOKE-001 到 NIX-SMOKE-011 每一步诊断都有记录,003 从 rev9 到 rev13 每一轮排除都有笔记。没有这些笔记,rev12 推翻 rev11 结论时,我可能都不记得 rev11 具体测了什么。切换上下文是常态,结构化笔记让调查可恢复。


交付状态

分支 交付 状态
001 六个 C 回归测试 + 分析文档 + 六篇 per-syscall 笔记 Linux 参考全过;StarryOS 验证被前置 case prebuild 阻塞
002 ext4 位图缓存修复 + PID ns 孤儿回收 + pipe EOF + rsext4 open-unlink + unshare(CLONE_FS) + stale flock nix-nosandbox PASSED
003 周期 writeback + reclaim retry + 本地 store 闭包校准 no-sandbox 通过;nixpkgs 受 180s 预算约束

致谢

感谢陈渝老师、周睿老师在训练营期间的指导。感谢交流群里的同学们,很多问题的思路是在讨论里逐渐清楚的。感谢开源社区,StarryOS、ArceOS、Linux 这些项目背后无数开发者的工作让后来者能站在巨人肩膀上学习。


报告日期:2026-06-28

一、项目背景

本次训练营中,我参与的工作主要围绕 StarryOS 的系统调用完善、系统回归测试和应用兼容性验证展开。StarryOS 是一个面向 Linux 应用兼容的操作系统项目,想要让更多真实 Linux 程序在 StarryOS 上运行,就需要不断补充系统调用语义、完善文件系统和管道等基础能力,同时也需要通过真实应用和测试框架验证系统行为是否符合预期。

我的工作可以分为两个阶段。

第一阶段主要围绕高级零拷贝 IO 相关系统调用展开,完成了 sendfile、copy_file_range 和 splice 三个系统调用相关的实现与测试。这部分工作主要涉及文件描述符、VFS、pipe、文件偏移以及不同 fd 类型之间的数据传输。

第二阶段主要围绕 StarryOS 的应用兼容性和系统测试展开。一方面完成了 apk-cmake system regression 测试,用于验证 StarryOS guest 环境中 CMake 和 C/C++ 构建链是否能够真实工作;另一方面调研并推进了 memcached 服务端应用 stress 测试,希望通过真实服务端程序验证 StarryOS 在 socket、epoll、多线程、内存分配和基础读写等方面的兼容性。

这次训练营对我来说,不只是完成几个具体任务,更重要的是比较完整地经历了真实开源操作系统项目中的开发流程:阅读已有代码、理解接口语义、实现功能、补充测试、提交 PR、根据 review 修改、排查 CI 问题,并在反馈中不断收敛修改范围。

二、第一阶段:高级零拷贝 IO 系统调用

第一阶段的主要工作是实现和测试 sendfile、copy_file_range 和 splice 三个高级零拷贝 IO 相关系统调用。

在刚开始接触这些系统调用时,我对“零拷贝”的理解还比较抽象,只知道它的目标是减少用户态和内核态之间的数据拷贝,提高 IO 效率。但实际实现后我发现,零拷贝 IO 并不是一个孤立功能,而是和文件描述符表、VFS 接口、pipe buffer、文件偏移以及不同文件对象之间的数据流动紧密相关。

这三个系统调用表面上都和数据传输有关,但它们面向的场景并不完全相同。sendfile 更偏向文件到另一个输出 fd 的传输,copy_file_range 更偏向文件到文件之间的复制,而 splice 则强调 pipe 参与的数据搬运。通过对比实现它们,我对 StarryOS 内部文件系统和系统调用层的理解比之前具体了很多。

2.1 sendfile 系统调用

sendfile 的主要作用是在两个文件描述符之间传输数据,典型场景是将普通文件中的内容直接发送到 socket 或其他输出文件描述符中。

普通的数据传输方式通常是用户程序先调用 read,把数据从输入文件读到用户态 buffer 中,再调用 write,把用户态 buffer 中的数据写到输出文件描述符。这样会经过用户态中转。sendfile 的目标则是让内核在输入文件和输出文件之间完成数据搬运,减少用户态参与。

在实现过程中,我主要处理了以下几个问题。

首先,需要根据传入的 in_fd 和 out_fd 从当前进程的文件描述符表中获取对应文件对象,并检查文件描述符是否合法。如果输入 fd 或输出 fd 不存在,需要返回对应错误。

其次,需要处理 offset 参数。sendfile 中的 offset 语义比较重要:当用户传入 offset 指针时,读取位置应当从指定 offset 开始,并在传输完成后更新该 offset;如果 offset 为空,则使用并更新输入文件自身的当前偏移。这部分让我进一步理解了“显式 offset”和“文件对象内部 offset”之间的区别。

然后,需要根据 count 参数控制最大传输长度。实现时不能简单假设一次就能传完所有数据,而是需要按照指定长度循环读取和写入,同时累计实际传输的字节数。如果中途读到 EOF,或者写入失败,就要根据已经传输的字节数和错误情况决定最终返回值。

通过实现 sendfile,我对 StarryOS 中系统调用参数处理、fd 表查找、文件对象读写接口和文件偏移维护有了更具体的认识。

2.2 copy_file_range 系统调用

copy_file_range 主要用于在两个文件之间复制数据,可以看作是更偏向文件系统内部的数据复制接口。

和 sendfile 相比,copy_file_range 的一个特点是输入文件和输出文件都可能带有显式 offset,因此实现时需要同时关注输入端和输出端的偏移管理。

在实现中,我主要关注了四个方面。

第一,根据 fd_in 和 fd_out 获取输入、输出文件对象,并检查文件描述符是否合法。如果任意一端文件描述符无效,需要及时返回错误。

第二,处理 off_in 和 off_out。如果用户传入了 offset 指针,就需要从用户指定的位置开始读写,并在复制完成后更新对应 offset;如果没有传入 offset,则使用文件对象自身的当前偏移。这里需要分别处理输入文件和输出文件两个方向的偏移,不能混淆。

第三,按照 len 指定的长度进行复制。实际可读数据可能少于用户请求的长度,因此最终返回值应当是实际复制的字节数,而不是直接返回用户传入的 len。

第四,处理边界情况。例如输入文件到达 EOF 时应当停止复制;如果复制过程中已经成功复制了一部分数据,再遇到错误,也需要根据系统调用语义返回已经完成的字节数或错误。

通过实现 copy_file_range,我对 VFS 层读写接口、文件偏移维护和系统调用参数检查有了更清楚的认识。

2.3 splice 系统调用

splice 相比 sendfile 和 copy_file_range 更强调 pipe 在数据传输中的作用。它可以在文件描述符和管道之间移动数据,是 Linux 零拷贝 IO 机制中比较重要的一部分。

在实现 splice 时,首先需要根据传入的输入 fd 和输出 fd 判断两端文件对象的类型。splice 的典型使用方式要求至少一端是 pipe,因此实现时需要区分普通文件、pipe 以及其他可能的文件对象,并对不支持的组合返回错误。

之后,需要处理输入端和输出端的数据传输逻辑。如果是普通文件到 pipe,需要从文件中读取数据并写入 pipe buffer;如果是 pipe 到普通文件或其他输出对象,则需要从 pipe 中取出数据并写入目标 fd。这个过程让我进一步理解了 StarryOS 中 pipe 的读写逻辑,以及 pipe buffer 在进程间通信中的作用。

此外,splice 同样需要处理 len、offset、EOF、部分传输和错误返回等问题。由于它涉及的文件对象类型更多,实现时比前两个系统调用更容易出现边界情况,因此测试也需要覆盖不同 fd 组合下的行为。

通过这一部分工作,我对零拷贝 IO 的理解不再停留在“减少内存复制”这个概念上,而是更具体地理解了系统调用如何通过文件描述符、VFS 接口和 pipe 机制把不同数据通路连接起来。

2.4 第一阶段小结

第一阶段的工作让我对 StarryOS 的文件系统和系统调用层有了更具体的理解。之前我对文件描述符、VFS、pipe、offset 这些概念的理解比较分散,但在实现 sendfile、copy_file_range 和 splice 的过程中,这些概念被串联到了一起。

这三个系统调用的侧重点可以简单概括为:

系统调用 主要场景 实现关注点
sendfile 文件到输出 fd 的数据传输 输入 offset、输出写入、返回实际传输长度
copy_file_range 文件到文件复制 输入/输出双 offset、EOF、部分复制
splice pipe 参与的数据移动 fd 类型判断、pipe buffer、不同方向的数据搬运

对我来说,这部分工作最大的收获是开始真正理解系统调用不是单独存在的接口,而是和内核中的多个抽象层联系在一起。一个看似简单的数据复制系统调用,背后往往需要考虑 fd 管理、文件对象、偏移语义、pipe 行为和错误返回等细节。

三、第二阶段:apk-cmake 系统回归测试

第二阶段中,我首先完成的是 StarryOS apk-cmake 系统回归测试,对应 PR #1017。这个测试的目标是验证 StarryOS guest 环境中 CMake 以及 C/C++ 构建链是否能够真实工作。

最初我认为只要在 guest 中能够安装 cmake 和 build-base,就可以说明 CMake 环境可用。但实际推进后发现,系统兼容性测试不能只停留在“工具存在”或“安装成功”这一层,而应该验证完整使用链路。

因此最终测试设计为:在 StarryOS guest 中创建一个最小 C/C++ CMake 工程,执行 cmake --version、ctest --version、cmake -E、cmake -S -B、cmake --build、ctest --test-dir,并最终运行生成的 C 和 C++ 程序。只有完整流程全部成功,才输出 APK_CMAKE_STABLE_TEST_PASSED。

3.1 主要修改内容

本次 PR 最终主要涉及以下文件:

1
2
3
4
5
6
7
test-suit/starryos/qemu-smp1/system/apk-cmake/CMakeLists.txt
test-suit/starryos/qemu-smp1/system/apk-cmake/src/apk-cmake.sh
test-suit/starryos/qemu-smp1/system/prebuild.sh
其中,`prebuild.sh` 负责在 staging rootfs 阶段安装依赖:

```sh
apk add curl build-base cmake

这里保留 curl 是因为已有的 apk-curl-equivalence 用例仍然需要使用它,而 build-base 和 cmake 则用于新增的 apk-cmake 测试。

apk-cmake.sh 是最终在 StarryOS guest 中执行的测试脚本。它不再执行 runtime apk add,而是直接检查 guest 中是否已经具备所需工具,并创建最小 C/C++ 工程进行 configure、build、ctest 和运行测试。

CMakeLists.txt 则负责把 staging rootfs 中的 CMake、CTest、GCC/G++、make、binutils、头文件、库文件和 CMake runtime files 显式安装到 guest overlay 中,保证 QEMU guest 运行测试时能够找到完整构建链。

3.2 测试流程设计

最终的 apk-cmake 测试不是只检查 cmake --version,而是完整覆盖了 C/C++ 工程的构建流程。

测试脚本会先检查基本工具是否存在,包括:

1
2
3
4
5
cmake
ctest
make
cc
c++

随后脚本会在 guest 的临时目录中创建一个最小 C/C++ CMake 工程,其中包含一个 C 程序和一个 C++ 程序。接着执行 CMake configure,生成构建目录,然后执行 build,最后通过 CTest 运行测试,并直接运行生成的二进制程序。

整个流程大致包括:

1
2
3
4
5
6
7
8
cmake --version
ctest --version
cmake -E
cmake -S ... -B ...
cmake --build ...
ctest --test-dir ...
运行生成的 C 程序
运行生成的 C++ 程序

只有这些步骤全部成功后,才输出:

1
APK_CMAKE_STABLE_TEST_PASSED

如果缺少工具、configure 失败、build 失败、ctest 失败或运行生成程序失败,都会输出失败 marker 并返回非零状态。这样可以避免测试“假通过”。

3.3 遇到的问题与解决过程

在实现 apk-cmake 测试时,我遇到了几个比较典型的问题。

3.3.1 测试路径错误

最初我把测试文件放到了:

1
test-suit/starryos/normal/qemu-smp1/system/...

但 reviewer 指出,StarryOS system grouped test 的发现路径是:

1
test-suit/starryos/qemu-smp1/system

并不包含中间的 normal 目录。因此后续我将测试文件移动到正确路径,并删除了错误路径下的内容。

3.3.2 PR 范围过大

早期 PR 中包含了 xattr、stress、curl、memcached、ptrace、io_uring、COW 等无关改动,导致 review 难以进行。根据 reviewer 意见,我后续重新整理分支,只保留 apk-cmake system regression 相关内容,避免无关修改影响合入。

这个问题让我认识到,真实开源项目中的 PR 不只是“代码能跑就行”,还需要保证修改范围清晰、主题单一、便于 reviewer 审查。

3.3.3 runtime apk add 导致 OOM

最开始我尝试在 apk-cmake.sh 中直接运行:

1
apk add --no-cache build-base cmake

但在 CI 中,x86_64 和 loongarch64 job 都出现了安装阶段内存分配失败的问题。

更重要的是,早期脚本曾经把安装失败当作 skip,导致测试可能显示为 green,但实际上并没有真正执行 CMake 构建流程。reviewer 指出这种做法不能证明测试真实通过。于是我移除了 runtime apk add,改为在 prebuild.sh 中提前安装依赖。

3.3.4 staging rootfs 依赖不会自动进入 guest

只在 prebuild.sh 中安装 build-base 和 cmake 后,guest 中仍然可能找不到:

1
2
3
4
cmake
ctest
c++
make

原因是 prebuild.sh 只影响 staging rootfs,不会自动把工具复制到最终 QEMU guest overlay 中。后来我参考已有的 apk-curl-equivalence 用例,在 apk-cmake/CMakeLists.txt 中显式把所需工具和目录从 STARRY_STAGING_ROOT 安装到 guest overlay。

3.3.5 GCC 内部程序 cc1 缺失

最开始我只复制了 /usr/bin/cc、/usr/bin/c++、cmake、ctest、make 等前端命令,但 CMake configure 阶段仍然失败,错误信息类似:

1
cc: fatal error: cannot execute 'cc1': posix_spawnp: No such file or directory

这说明只复制编译器前端是不够的,还需要复制 GCC/G++ 内部目录、binutils、头文件、库文件等完整工具链依赖。因此我继续补充了 usr/lib、usr/lib/gcc、usr/libexec/gcc、usr/include、lib、usr/share/cmake 等目录,最终使 C/C++ configure、build、ctest 和运行流程完整通过。

3.4 验证结果

最终 CI 日志中,apk-cmake 在 qemu-smp1/system 中真实执行并通过。关键日志包括:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
APK_CMAKE_CTEST_BEGIN_prebuilt
Test project /tmp/cmake-build
Start 1: hello_c_runs
1/2 Test #1: hello_c_runs ................. Passed
Start 2: hello_cpp_runs
2/2 Test #2: hello_cpp_runs ............... Passed

100% tests passed, 0 tests failed out of 2

APK_CMAKE_RUN_BEGIN_prebuilt
hello from CMake C build
hello from CMake C++ build
APK_CMAKE_REPO_TEST_DONE_prebuilt
APK_CMAKE_STABLE_TEST_PASSED
STARRY_SYSTEM_TEST_PASSED: /usr/bin/starry-test-suit/apk-cmake

这说明新增用例已经完成以下完整路径:

1
2
3
4
5
6
CMake configure
C build
C++ build
CTest
运行 C 二进制
运行 C++ 二进制

这部分工作让我认识到,系统测试不应只检查命令是否存在,而应尽可能验证真实使用场景。只有完整应用链路跑通,才能说明系统兼容性有了实际提升。

四、第二阶段:memcached 服务端应用测试

第二阶段的另一部分工作是服务端应用兼容性测试调研与 memcached stress 测试推进。

4.1 从 PostgreSQL 到 memcached

最开始我调研的是 PostgreSQL。PostgreSQL 是一个比较复杂的数据库服务端应用,它依赖 mmap、thread、socket、pipe、semaphore、shared memory 等系统能力,能够较全面地验证 StarryOS 的系统调用、进程通信、网络栈和文件系统稳定性。

但在提交 PostgreSQL 测试用例后,经过社区 review 发现,当前 dev 分支中已经存在较完整的 PostgreSQL 测试用例,并且我最初提交的目录结构与现有测试框架不完全匹配。因此我没有继续重复实现 PostgreSQL,而是重新选择新的服务端应用测试目标。

之后我选择了 memcached。memcached 是一个典型的轻量级 key-value cache server,主要依赖 socket、epoll、多线程、内存分配以及基础 read/write 等系统能力。相比 PostgreSQL、MySQL 等数据库类应用,memcached 的启动链路更简单,测试目标更清晰,但仍然能有效验证 StarryOS 对网络服务、后台进程、客户端连接和数据读写的支持情况。

4.2 memcached 测试设计

我设计的 memcached 测试流程主要包括:

  1. 通过 apk 安装 memcached 和 busybox-extras;
  2. 启动 memcached 服务并确认进程存在;
  3. 使用 nc 连接本地 11211 端口;
  4. 执行 set 命令写入键值对;
  5. 执行 get 命令读取键值对;
  6. 检查返回结果中是否包含 STORED、VALUE starry_key 和 hello_starry;
  7. 执行 stats 命令,检查输出中是否包含 STAT pid;
  8. 关闭 memcached 进程;
  9. 成功时输出 MEMCACHED_TEST_DONE=<timestamp>,失败时输出 MEMCACHED_TEST_FAILED。

通过这样的测试,可以验证 memcached 在 StarryOS guest 中不仅能够启动,而且能够接受客户端连接、处理基本 key-value 读写请求,并返回服务状态信息。

计划中的测试目录为:

1
test-suit/starryos/stress/memcached/

并参考已有的:

1
test-suit/starryos/stress/postgresql/

为 aarch64、riscv64、x86_64、loongarch64 四个架构分别添加 QEMU TOML 测试配置,使其能够作为 StarryOS 服务端应用压力测试的一部分。

4.3 PR review 中发现的问题

在 memcached PR review 阶段,也发现了当前提交中仍然需要继续修复的问题。

第一个问题是 PR 中误包含了与 memcached 无关的 xattr 修改。这部分修改导致 dev 分支中已有的完整 xattr 实现被回退为 stub,属于合并冲突处理错误。后续需要恢复 origin/dev 中的 xattr 相关实现,保证本 PR 只保留 memcached 测试相关改动。

第二个问题是 syscall/mod.rs 中重复添加了 xattr syscall 调度,导致出现重复 match arm。这部分也需要删除,只保留 dev 分支中原有的正确实现。

第三个问题是四个架构的 memcached TOML 配置文件中出现了重复字段,例如 args、uefi、to_bin、shell_prefix 等,并且部分文件中混入了 riscv64 的 QEMU 参数。后续需要分别清理 aarch64、riscv64、x86_64、loongarch64 四个架构的配置文件,保证每个文件只保留对应架构的 QEMU 参数和统一的 memcached 测试逻辑。

因此,memcached 这部分目前更准确地说是完成了测试目标调研、测试流程设计和 PR 推进,但仍需要继续根据 review 意见修复无关改动和配置问题。后续修复完成后,需要重新运行格式检查、差异检查,并优先在 riscv64 架构上验证通过,再扩展到其他架构。

4.4 memcached 工作的意义

虽然 memcached 测试还需要继续根据 review 意见修复,但这个方向对 StarryOS 的应用兼容性验证是有价值的。

和简单命令行工具相比,服务端应用通常会涉及更多系统能力,例如进程启动和后台运行、socket 监听和客户端连接、epoll 或类似事件机制、多线程、内存分配、文件和网络 IO、服务状态查询与退出清理等。

因此,memcached 可以作为一个相对轻量但有代表性的服务端测试目标,为后续 Redis、nginx、数据库类应用等更复杂测试场景提供参考。

五、遇到的问题和收获

这次训练营中,除了具体代码实现,我也遇到了很多工程上的问题。这些问题一开始看起来比较琐碎,但实际上对我理解真实项目开发帮助很大。

5.1 PR 范围需要足够清晰

在 apk-cmake 和 memcached 两个方向上,我都遇到了 PR 范围过大的问题。早期 PR 中混入了与当前主题无关的修改,导致 review 变得困难,也增加了出错概率。

这让我认识到,开源协作中一个 PR 最好只解决一个明确问题。即使本地调试过程中临时修改了很多文件,最终提交前也需要重新整理 commit 和 diff,确保 review 范围清晰。

5.2 测试不能只追求 green

在 apk-cmake 早期版本中,runtime apk add 失败曾被当作 skip 处理,这会让 CI 看起来通过,但实际上没有证明 CMake 构建链真实可用。

这个问题让我意识到,测试的目标不是让 CI 显示 green,而是提供可信的验证结果。对于系统回归测试来说,必须有明确的 pass marker,也必须在失败时返回非零状态。

5.3 staging rootfs 和 guest overlay 需要区分

apk-cmake 中一个重要问题是:在 prebuild.sh 中安装依赖,并不意味着这些依赖会自动出现在最终 guest overlay 中。只有显式将相关工具和依赖目录安装到 overlay,guest 运行测试时才能真正使用它们。

这个问题让我对 StarryOS 测试框架中 staging rootfs、guest overlay 和 system regression 之间的关系有了更清楚的理解。

5.4 真实应用测试比单点功能测试更复杂

memcached 测试让我认识到,真实应用往往不是只依赖一个系统调用,而是同时依赖进程、网络、内存、文件、事件机制等多个模块。一个系统调用能跑通,不代表应用一定能跑通;一个命令能启动,也不代表服务能够正常处理请求。

因此,应用兼容性测试的价值在于覆盖更完整的真实使用链路。

六、后续计划

后续我希望继续沿着 StarryOS 应用兼容性和系统回归测试方向推进。

短期内,我希望继续完善 memcached 测试,修复 review 中指出的无关改动和多架构配置问题,尽量推动其合入。

之后,我也希望在已有工作的基础上,继续尝试 Redis、nginx、数据库类应用等更复杂的服务端测试场景,逐步构建更完整的 StarryOS 应用兼容性测试体系。

同时,我还需要继续补充操作系统、Rust、文件系统、网络栈和 CI 调试方面的基础。通过这次训练营,我已经对开源操作系统项目的开发流程有了更具体的认识,之后希望能够在更复杂的任务中继续提升自己的能力。

七、总结

作为一名大一的学生,这次训练营对我来说,更像是第一次真正参与一个开源操作系统项目的完整开发过程。以前我对操作系统的理解更多来自课程、书本和一些零散实验,而这次是在 StarryOS 这样的真实项目里,围绕具体问题去读代码、改代码、补测试、提交 PR,再根据 review 和 CI 结果不断调整。

我的工作主要分成两部分。第一部分是高级零拷贝 IO 相关系统调用,实现和测试了 sendfile、copy_file_range 和 splice。这部分让我对文件描述符、VFS、pipe、文件偏移和系统调用语义有了更实际的理解。第二部分是应用兼容性和系统测试,主要包括 apk-cmake 系统回归测试,以及 memcached 服务端应用测试的调研和推进。apk-cmake 让我认识到,系统测试不能只看命令是否存在,而是要验证 configure、build、test、run 这样的完整链路;memcached 则让我开始理解真实服务端应用会同时牵涉网络、进程、线程、内存和基础 IO 等多个系统能力。

我也能明显感觉到自己现在还处在打基础阶段。很多问题刚开始并不能马上看懂,需要一边查资料,一边读已有代码,再一点点把任务拆开处理。所以这次完成的工作可能还不是特别复杂,但对我来说已经很有意义:我第一次比较完整地经历了从理解需求、实现功能、补充测试,到处理 review、排查 CI、收敛 PR 范围的过程。

这次训练营让我收获最大的地方,不只是学会了某几个系统调用或某个测试怎么写,而是开始理解真实项目里的工程开发方式。代码能跑只是第一步,后面还要考虑测试是否可信、PR 是否足够聚焦、修改是否影响已有功能、CI 失败该如何判断原因。这些经验在平时课程作业里很难完整接触到。