Lab 010 · Linux ABI 拼图验证:让 stub 的艺术可观测
对应
document/book/07-userland/010/。验证档 A 档:教程即验证——把章节里讲的「判断一个号该怎么应答看 libc 拿到值后的行为」「-ENOSYS是正面信号」「pread64不动 offset」「cinux_exit≠sys_exit」这些认知点从源码注释变成可观测事实。不写答案——给方法论和观察路径,让读者自己跑出证据。
目标
把章节里的五条认知点逐个变成「跑了一下,看到 X,这说明 Y」的观察记录:
- 让
-ENOSYS降级肉眼可见(改 stub 返回值,看 glibc/musl 启动行为差异); - 真实现/stub/策略型三类分类(给方法论,自己填表);
getrandom真给字节 + 随机源验证(改 seed 看可预测性);pread64不改 offset 验证(对照read推进 offset);- 诚实边界确认(挑两条边界写程序触发,看「内核没真做」的表现)。
工具链:用 tools/musl/cinux-exit.c 同款的 musl sysroot 静态链小测试程序(参考 tools/musl/build-musl.sh 编出 sysroot + musl-gcc 静态链),run-buildroot-usability gate 跑(cmake/qemu.cmake:604)。产出是观察记录,不是代码——「看到 X,这说明 Y」。
任务一:让 -ENOSYS 降级肉眼可见
步骤
- 读
kernel/syscall/sys_linux_stubs.cpp:41-43,把sys_set_robust_list的return 0;改成return -cinux::kEnosys;(就是把它从「探测型返 0」改成「功能型返 ENOSYS」)。 - rebuild 内核,跑一个带 SSP 的 musl 程序(或直接跑
run-buildroot-usabilitygate,看 busybox 起得来不)。 - 对照原版(返 0)的行为,记录差异。
观察什么
- 返 0(原版):glibc/musl 的 robust-futex probe 通过,启动链继续。程序正常起。
- 返
-ENOSYS(你改的):glibc 把整条 robust-futex 路径判废。可能仍能起(没 pthread 没人真用 robust 锁),但某些 glibc 版本会在启动日志或行为上不同——比如打一条「robust futexes unavailable」之类的诊断,或者pthread_mutexattr_setrobust路径行为变化。
引导
这把章节里「set_robust_list 为什么偏偏返 0 不返 ENOSYS」从源码注释变成可观测事实。自己 grep glibc 源码(或 musl 源码)确认:
- 它真在启动时探这个号(grep
__NR_set_robust_list或set_robust_list); - 它真把返回值 0 当成功、把
-ENOSYS当「不支持」。
如果 glibc 收到 0 走「支持 robust futex」路径、收到 ENOSYS 走「判废 robust」路径——那章节讲的「返 0 vs 返 ENOSYS 取决于探测语义」就坐实了。
不写的事:不给你改好的
sys_set_robust_listpatch(你刚改的return -kEnosys就是);不给你完整的 glibc 行为预测表——你自己跑、自己 grep、自己记观察。
任务二:真实现/stub/策略型三类分类
步骤
给一份方法论(不给答案表——那是答案 dump):
分类维度:判断一个 syscall 该怎么应答,看「libc 拿到这个返回值后行为对不对」,具体三类:
- 真实现:用户态真要消费返回值里的字节/数据(如
getrandom给字节、access给权限裁决); - 探测 stub:返
-ENOSYS让 libc 降级(如rseq/clone3),或返 0 满足探测(set_robust_list); - 策略型:返「无限」或固定值哄过 probe,不真做 enforcement(如
prlimit64返 RLIM_INFINITY、getrandomflags 忽略)。
观察什么
把章节里讲的十五个号(十四个 Linux ABI + 一个 Cinux 专有:access/pread64/getrandom/prlimit64/time/gettimeofday/tkill/setitimer/sched_getaffinity/getcpu/rseq/clone3/sendfile/set_robust_list/cinux_exit)填进你自己的表,每个号写:
- 一句话:libc/用户态拿到这个返回值后会怎样?(走降级?走真路径?踩空?)
- 分类:真实现 / 探测 stub / 策略型 / Cinux 专有。
引导问题(自检):
prlimit64为什么不返-ENOSYS?如果返-ENOSYS会怎样?(提示:glibc 会以为 syscall 不存在,走 fallback。)sched_getaffinity返字节数而不是 0——如果改成return 0会怎样?(提示:glibc 拿到「写了 0 字节」以为没 CPU 在线,nproc报 0。)set_robust_list返 0 和返-ENOSYS哪个对?(提示:看 libc 拿到 ENOSYS 后会不会判废整条 robust 路径——任务一的观察正好答这个。)
不写的事:不给你完整的十五行答案表。你读章节 + grep 源码自己填——「学到」和「记下别人给的表」是两回事。
任务三:getrandom 真给字节 + 随机源验证
步骤
写一个最小 musl 程序调
getrandom取 32 字节、hex 打印(参考tools/musl/hello.c的编译方式,musl-gcc -static)。跑两次,看两次的 32 字节是否不同。打开
kernel/lib/random.cpp:61-87(KRandom::init),把uint64_t seed = rdtsc();那行改成uint64_t seed = 0x1234567890ABCDEFULL;(固定 seed)。rebuild,再跑两次getrandom程序,看输出是否变得可预测(两次相同 / 不同机器相同)。进一步:把
seed ^= cinux::drivers::PIT::get_ticks() << 16;也注释掉,再观察。
观察什么
- 原版(
rdtsc+PIT+ addr +rdrand):两次运行的 32 字节大概率不同(因为 rdtsc 和 PIT 时序每 boot 不一样)——说明 seed 含时序熵。 - 固定 seed(去掉 rdtsc):两次运行字节相同——说明 rdtsc 是随机性的主源。
- 再去掉 PIT:仍然相同(已经固定了),但对照能看出 PIT 在原版里贡献了多少熵。
引导
这把章节里「随机源 = rdtsc ^ PIT<<16 ^ addr>>4 ^ rdrand,boot 一次 seed」从读源码变成可观测。如果改 seed 后字节变可预测,说明 rdtsc 那行真的在贡献熵——它不是装饰,是随机性的关键来源。
不写的事:不给你预期的 hex 串(那是答案 dump);不给你改好的
random.cpppatch(上面三行就是)。
任务四:pread64 不改 offset 验证
步骤
写一个 musl 程序,
open一个文件,先pread64(fd, buf1, 5, 10)(从 offset 10 读 5 字节),再read(fd, buf2, 5)(顺序读 5 字节)。打印buf1和buf2的内容。对照实验:把
pread64换成第二次read(两次顺序read),看第二次读到的内容。
观察什么
pread64+read:buf1是文件 offset 10-14 的内容,buf2是文件 offset 0-4 的内容(从文件头读)——说明pread64没推进file->offset,后面的read仍从文件头开始。read+read(对照):第一次读到 offset 0-4,第二次读到 offset 5-9——说明read推进了file->offset。
引导
这把章节头号明星点「pread64 不推进 offset 不是靠锁或回滚,是靠接口签名」从源码注释变成可观测行为。如果你看到 pread64+read 的 buf2 是文件头内容,说明 sys_pread64 真没动 file->offset——InodeOps::read(inode, offset, ...) 的 offset 是入参,不写回。
读 kernel/syscall/sys_pread64.cpp:25-44 和 kernel/syscall/sys_read.cpp:48-58 对照:sys_read 有 file->offset += n,sys_pread64 没有。
不写的事:不给你完整的 musl 程序(你照
tools/musl/hello.c改);不给你预期的buf1/buf2内容(你自己跑出来记)。
任务五:诚实边界确认
步骤
document/book/07-userland/010/ 的「诚实的边界」段列了 9 条刻意简化。挑其中 2 条写小程序触发并观察「内核没真做」的表现。
推荐挑这两条(都可观测):
prlimit64不强制任何资源限制:写 musl 程序调setrlimit(RLIMIT_NOFILE, {rlim_cur=10, rlim_max=10}),然后开 100 个 fd(open("/dev/null", O_RDONLY)× 100,或者就用dup)。观察:是否被限在第 10 个 fd 上?- 预期(
prlimit64不强制):100 个 fd 全开成功——内核忽略了setrlimit,没真限 fd 表。 - 对照(Linux 真机):开到第 10 个之后
EMFILE——内核真限了。
- 预期(
access无 ACL 只有 root bypass:写 musl 程序,创建一个 0644 文件,调access(path, X_OK)。观察返回值。- 预期:
access(X_OK)对 0644 文件返-1(errno=EACCES)——因为 root 想执行也要文件有 x 位(sys_access.cpp:46-53的 root bypass 只对 R/W 放行,X 看执行位)。 - 进一步:把文件
chmod 0755再access(X_OK),应该返 0——因为现在有 x 位了。
- 预期:
其他可挑的边界(任选):
getrandom不是 CSPRNG(任务三已做);pread64对 pipe 返 EBADF 而非 ESPIPE(写程序pread64一个 pipe fd 看返啥);setitimer只支持 ITIMER_REAL(写程序setitimer(ITIMER_VIRTUAL, ...),看是否-EINVAL);cinux_exit占 Linuxfadvise64号 221(写 musl 程序发裸 syscall 221 当posix_fadvise,看是不是触发 QEMU 退出——这是个危险实验,会直接关 QEMU,留给你自己判断要不要做)。
观察什么
把「内核没真做」从文字变成可验证事实。你写的不是「相信源码注释」,而是「跑了一下,真的没限/真的返 EACCES/真的不支持」。
工具链
- musl sysroot:
tools/musl/build-musl.sh编出 sysroot(libc.a+ crt 文件 + 头文件),musl-gcc -static编你的小测试程序。参考tools/musl/hello.c和tools/musl/cinux-exit.c的编译方式。 - 跑测试:把编好的静态程序放进 ext2 盘(
scripts/create_ext2_disk.sh或 buildroot rootfs),cmake --build build --target run(进 shell 跑程序看输出)或cmake --build build --target run-buildroot-usability(cmake/qemu.cmake:604,gate 跑 busybox + 测试脚本)。 - 改内核:改完
kernel/syscall/*.cpp后 rebuild kernel(cmake --build build --target image),重启 QEMU 看行为差异。
验收清单
- [ ] 任务一:改了
sys_set_robust_list返回值,记录返 0 vs 返-ENOSYS的行为差异;grep 了 glibc/musl 源码确认它真探这个号。 - [ ] 任务二:十五个号填了自己的分类表,每个号写了「libc 拿到返回值后会怎样」一句话。
- [ ] 任务三:跑了
getrandom程序两次,字节不同;改rdtsc行后字节变得可预测。 - [ ] 任务四:
pread64+read验证了pread64不推进 offset(read仍从文件头读),对照read+read第二次接着第一次读。 - [ ] 任务五:挑了 2 条边界写程序触发,记录「内核没真做」的可观测表现。
别做这些
- 别把章节当 changelog 找「批号」「F6」「wholesale」——教程正文叙述里这些都被剥掉了(源码逐字引用块里保留的是源码真貌,不是教程在用批号)。你看到的只有「为什么需要这个 syscall + 真实现/stub + 反直觉点」。
- 别把任务二做成答案表 dump——你要的是「自己跑、自己 grep、自己填」,不是抄章节里的表。章节里没给完整答案表是有原因的。
- 别在任务一里只改返回值不跑真程序——光改不跑,你看到的只是代码差异,不是「glibc 收到 ENOSYS 后真降级」的可观测事实。
- 别指望
prlimit64真限 fd 表——它返 RLIM_INFINITY 是策略,不是没做完。如果你写程序发现真限了,那是别的原因(比如 fd 表本身满了),不是prlimit64起作用。 - 别把任务五的「
cinux_exit占 221」实验随便做——发裸 syscall 221 会让 QEMU 直接退出,如果你在交互 shell 里试,你的 shell 会瞬间消失。在测试脚本里做,做好心理准备。