Cinux · musl 静态移植:对齐 Linux ABI、铺初始栈,以及一个被 SMAP 拦下的潜伏 bug

Cinux · musl 静态移植:对齐 Linux ABI、铺初始栈,以及一个被 SMAP 拦下的潜伏 bug

如果您是想尝试 Cinux,并对一些驱动、前沿细节的实现感兴趣的朋友,请移步到下面的仓库:

https://github.com/Awesome-Embedded-Learning-Studio/Cinux

如果您对手写一个现代 C++ 操作系统感兴趣的朋友,请到这里:

https://github.com/Awesome-Embedded-Learning-Studio/Cinux-Book

或者,直接访问文档站开始阅读:https://awesome-embedded-learning-studio.github.io/Cinux-Book/

如果上面的内容,对您的学习和实际的开发哪怕有一丝帮助,都是笔者极大的荣幸!喜欢的话,麻烦小小的赏一个 ⭐(QAQ)。自己的知识仍不精湛,文章必然还有很多错误,还请各位大佬批评斧正!

musl 静态移植:把内核对齐到 Linux 的规矩

之前用户态跑的是 Cinux 自己那个极简 libc(手写的 syscall.h 那几个壳)。这一章换一套做法:让内核能跑用真 musl libc 编译的静态程序 ------musl 是 Linux 世界里常用的轻量 libc,用它编译的程序(比如 hello world)静态链接一个 libc.a 进来,扔到内核上就能跑。要做到这件事,内核得把自己对齐到 Linux 的 ABI :syscall 号得和 Linux x86_64 一致、返回值得是负 errno、给程序铺的初始栈得带 musl 启动要读的辅助向量(auxv)。这一章把这三件事做了,然后跑 musl 的 hello------结果 hello 一跑,挖出一个在内核里潜伏了很久的真 bug ,而这个 bug 之所以一直没被发现,正好和上一卷(056)讲的 SMAP / WSL2 那条边界有关。验证口径:punchline 是 musl 编译的 hello 真在内核里跑起来、打出 Hello from musl、干净退出。内核侧的 ABI + 初始栈靠测试验证;musl 程序的端到端跑通靠一个专门的 ring3 测试。

换 musl 意味着什么

我们先想清楚一件事:跑自己那个极简 libc 和跑真 musl,对内核的要求差在哪。自己的 libc 是「内核写什么、用户就读什么」,两边是一起改的,号错了、结构体歪了,大不了重编一遍就对上。musl 不一样------它是预编译好的 ,musl-gcc 编程序时就把自己那套 syscall 桩(src/internal/x86_64/syscall.s 那几个)链进去了,运行时直接 syscall 指令发 Linux 标准号 ;它读 stat 按 Linux UAPI 的字段顺序来;它一启动就从栈上按 SysV ABI 的规矩读 argc/argv/envp/auxv。换句话说,契约的另一半写死了、在 musl 那边,内核必须单方面把自己对齐过去,没有任何「两边一起改」的余地。所以这一章的路线就是三块:号表和返回约定对齐、初始栈铺上 auxv、补齐 musl 启动链要的 syscall------最后拿一个真 musl 程序端到端验收。你会发现,前面三块都对了,验收那一刻还能炸出一个跟它们仨都无关的潜伏 bug,这是这一章最值得记的部分,先别急,我们一块一块来。

代码路线:号表、初始栈、新 syscall、ring3 冒烟

syscall 号表:全表核对,挖出一个撞号真 bug

现在我们要做的是把 kernel/syscall/syscall_nums.hpp 这张号表跟 Linux x86_64 全表核对一遍。musl 发 SYS_write=1,内核的号 1 就得是 write;发 SYS_openat=257,内核的 257 就得是 openat。对齐的时候几乎全对------除了一处真 bug:SYS_chdirSYS_brk 曾经都被定义成了 12。修复后的表长这样:

cpp 复制代码
enum class SyscallNr : uint64_t {
    ...
    SYS_brk            = 12,  ///< set program break / heap end (F2-M3)
    ...
    SYS_chdir          = 80,  ///< change working directory (was wrongly 12, collided with brk)
    ...
};

(syscall_nums.hpp:35/:68。)坑在哪?注册的时候 chdir 在前、brk 在后,brk 把 slot 12 覆盖 了------chdir 实际不可达。也就是说,shell 里敲 cd,发出去的 syscall 12 命中的是 sys_brk(改 program break),目录纹丝不动。Linux x86_64 里 chdir 是 80、brk 是 12,改成 SYS_chdir = 80 就对了。这个 bug 最气人的地方是它不报错 :cd 返回值没人检查,cd / 之后 pwd 还在原地,谁会盯着「为什么 cd 不生效」深究呢?要不是这次为了 musl 全表核对,它还能再潜伏很久。

号表的单一事实源。 顺手把一个结构性隐患也修了:用户侧那个 user/libc/syscall.cpp 原来是自己硬编码号,现在改成 #include "kernel/syscall/syscall_nums.hpp" 直接用 SyscallNr::SYS_chdir。改 enum 一处,内核和用户壳自动同步(只要全量重编)。这样不会再出现「内核改了号、用户壳没跟上」的漂移------这次撞号能在眼皮底下活那么久,跟两张表各写各的有直接关系。

号对了,返回约定也得收齐。Linux 的 syscall 出错返的是负 errno (-EPERM 这种),不是裸 -1------musl 的 wrapper 拿到负值就去查 errno 表,你返个裸 -1 它会当成 EPERM。Cinux 大部分已经是这个风格(sys_open-to_errno(...)),只有几个老 syscall 残留裸 -1(比如 getcwd 坏地址返 -1),这一步按场景补成正确的负 errno(-kEfault/-kEinval/-kEsrch)。结构体布局同理:statsigaction 得逐字段对齐 Linux UAPI------musl 按 Linux 的 struct stat 字段顺序读,内核填的顺序不一样就是字段错位,而且是那种「读出来数值都合法、就是全错了」的静默错位。这部分没有捷径,读 musl 源码 + Linux UAPI 头文件,一个一个对,不猜。

初始栈:musl 一上来就要读的 auxv

接下来问题来了:musl 的启动链(_start_start_c__libc_start_main__init_libc)一进来就从用户栈上读辅助向量(auxv) ------它要 AT_PHDR/AT_PHNUM/AT_PHENT 找程序头(定位 TLS),要 AT_PAGESZ(还断言非 0),要 AT_RANDOM(给栈 canary 取种子),要 AT_UID/EUID/GID/EGID + AT_SECURE,等等。而原 Cinux 的 execve/launch_user_program 完全不铺栈内容 ------argv/envp 被直接忽略,RSP 指向一个 demand-fault 出来的零页。musl 一读 argc=[rsp]=0、auxv 全无,直接起不来。所以得照 Linux 的规矩,把进程入口的标准栈帧铺出来,布局(低地址 → 高地址)是:

复制代码
low VA                                            stack_top (high VA)
[ argc | argv[] | NULL | envp[] | NULL | auxv.. | AT_NULL | strings.. | pad ]

(initial_stack.hpp:9。)实现是一个 header-only 的纯函数 helper build_initial_stack,musl 要的 AT_* 键就定义在它头上,和 Linux uapi/linux/auxvec.h 一致:

cpp 复制代码
// Linux auxiliary-vector types (uapi/linux/auxvec.h); the subset Cinux emits.
constexpr uint64_t AT_NULL         = 0;
constexpr uint64_t AT_PHDR         = 3;
constexpr uint64_t AT_PHENT        = 4;
constexpr uint64_t AT_PHNUM        = 5;
constexpr uint64_t AT_PAGESZ       = 6;
constexpr uint64_t AT_ENTRY        = 9;
constexpr uint64_t AT_UID          = 11;
constexpr uint64_t AT_EUID         = 12;
constexpr uint64_t AT_GID          = 13;
constexpr uint64_t AT_EGID         = 14;
constexpr uint64_t AT_SECURE       = 23;
constexpr uint64_t AT_RANDOM       = 25;
constexpr uint64_t AT_EXECFN       = 31;

(initial_stack.hpp:37 起。)helper 的核心逻辑分三步。第一步量尺寸:argv/envp/filename 的字符串区,加上「argc + argv 指针 + NULL + envp 指针 + NULL + auxv 键值对 + AT_NULL」这个块:

cpp 复制代码
    // 2. Block = argc + argv ptrs + NULL + envp ptrs + NULL + auxv pairs +
    //    AT_NULL pair. The auxv holds the caller's entries plus AT_EXECFN,
    //    AT_RANDOM and AT_PLATFORM (3 extra, F4-B0), plus the AT_NULL pair (+1).
    uint64_t total_aux  = auxc + 3;
    uint64_t block_size = 8 * (1 + argc + 1 + envc + 1) + 16 * (total_aux + 1);

    uint64_t raw  = block_size + str_bytes;
    uint64_t size = (raw + 15) & ~static_cast<uint64_t>(15);  // RSP % 16 == 0
    if (size == 0 || size > cap)
        return 0;
    uint64_t base = cap - size;

(initial_stack.hpp:117。)注意那行 & ~15ull:入口 RSP(= stack_top - size)必须 16 字节对齐 ,这是 SysV x86_64 ABI 对进程入口的约定(入口处没有返回地址,对齐由内核保证),musl 里的 SSE 指令指望着它。而 base = cap - size 是整个设计的第二个关键:整块内容右对齐 铺进 caller 给的 buffer,buf 末尾 ↔ 用户栈顶 stack_top。这样 caller 可以把栈顶页的 direct-map 内核虚址直接当 buffer 传进来,铺完就是用户栈,零拷贝。第三步才是逐项写入:argc、argv 指针(指向后面的字符串区)、NULL、envp、NULL、auxv 键值对,最后 AT_NULL 收尾。这里有个容易写错的地方:argv/envp 指针、AT_RANDOM/AT_EXECFN 的值都得是绝对用户虚址 (从 stack_top 反推:buf offset o → VA (stack_top - cap + o)),不是 buffer 内偏移------用户程序拿到的就是真地址,没有「加载时再重定位」这一说。

内核侧的 caller(user_launch.cpp)把前面几卷攒的家底全用上了:TLS 要的程序头来自 ELF 加载器(elf_aux),身份四件套来自进程结构,AT_RANDOM 的 16 字节来自内核 PRNG,凑成 14 项 auxv,然后把栈顶页的 direct-map 地址直接递给 helper:

cpp 复制代码
    auto*    page = reinterpret_cast<uint8_t*>(top_page_phys + cinux::arch::DIRECT_MAP_BASE);
    uint64_t size = build_initial_stack(page, cinux::arch::PAGE_SIZE, stack_top, argc, argv, envc,
                                        envp, auxv, 14, path, random16);
    if (size == 0) {
        cinux::lib::kprintf("[PROC] initial stack build failed (overflow)\n");
        Scheduler::exit_current();
    }
    uint64_t user_rsp = stack_top - size;

    task->addr_space->activate();
    update_syscall_stack(task->kernel_stack_top);
    jump_to_usermode(entry, user_rsp, 0);

(user_launch.cpp:132。)还有一条工程约束值得记:helper 是 kernel 和 host 单测双编译 的,而 freestanding 内核没有 <cstring>,所以 strlen/memcpy/put_u64 这几个字节操作都是自带的 inline 实现。听起来啰嗦,但正是这个「自带依赖」的决定,让下面这层验证成为可能。

host 单测:按 musl 的读法把这块栈读回来

铺栈对不对,不能靠眼睛看十六进制dump------我们按 musl 在 _start 时刻的读法 把这块 buffer 读回来:test/unit/test_initial_stack.cpp 里有个 Reader,拿着 stack_top 和 size 反推 bias,把每个 VA 翻译回 buffer 偏移:

cpp 复制代码
TEST("initial_stack: argc/argv/envp layout and 16-byte RSP alignment") {
    ...
    uint64_t size = build_initial_stack(buf, CAP, TOP, 2, argv, 3, envp, aux, 5, "/bin/prog", rnd);
    ASSERT_TRUE(size > 0);

    Reader r = Reader::make(buf, CAP, TOP, size);
    ASSERT_EQ(r.rsp % 16, 0ULL);  // SysV: process entry RSP 16-byte aligned

    uint64_t p = r.rsp;
    ASSERT_EQ(r.u64(p), 2ULL);                    // argc
    p += 8;
    ASSERT_TRUE(r.str_eq(r.u64(p), "/bin/prog")); // argv[0]
    ...

(test_initial_stack.cpp:63。)第二个 case 专门扫 auxv:从 AT_PHDR 一路走到 AT_NULL,断言 AT_RANDOM 真的指向那 16 个注入字节、AT_EXECFN 真的指向文件名字符串、AT_PLATFORM 指向 "x86_64"(glibc 会拿它做 arch dispatch / IFUNC gating);第三个 case 验 buffer 不够大时返 0 而不是越界写。这三条就是「内核侧 musl 就绪」的客观证据------栈布局这种东西,单测能把对齐、指针值、键值覆盖一次钉死,比在 QEMU 里碰运气强得多。

新 syscall:musl 启动链要的那几个

号表和栈都就位,还差 handler。musl 启动到某一步会发几个 Cinux 原来没有(或不全)的 syscall,拿到 -ENOSYS 它就走不下去,这一步补齐,号都是 Linux x86_64 标准号:

cpp 复制代码
    // --- musl-required numbers added in F10-M1 batch 4 ---
    SYS_arch_prctl      = 158,  ///< set/get thread FS/GS base (musl __init_tp ARCH_SET_FS for TLS)
    SYS_set_tid_address = 218,  ///< record cleartid addr; returns tid (musl __init_tp)
    SYS_clock_gettime   = 228,  ///< read a clock (musl time; 99 is sysinfo, not clock_gettime)
    SYS_exit_group      = 231,  ///< terminate thread group (musl exit(); falls back to SYS_exit)
    SYS_openat          = 257,  ///< open relative to dirfd (musl open/openat; AT_FDCWD=-100)
    SYS_newfstatat      = 262,  ///< stat relative to dirfd (musl stat/fstat/lstat)

(syscall_nums.hpp:94。)每个都有明确的「谁在要」:arch_prctlARCH_SET_FS 是 musl __init_tp 设 TLS fs_base 用的(没它,%fs:0x28 读栈 canary 直接段错误);set_tid_address__init_tp 的另一只脚,登记 cleartid 地址(跟前面 clone/TLS 那套机制配套);openat/newfstatat 是 musl 文件路径的统一入口(现代 musl 的 open/stat 都转发到这两个 dirfd 风格的 syscall);exit_group 是 musl exit() 的第一选择,退不到 SYS_exit 才 fallback。你会发现一个规律:libc 要的 syscall 不多,但一个都不能缺 ------启动链是串行的,卡在哪个 -ENOSYS 上,后面全部免谈。

跑 musl hello:第一次进 ring3 就 #DF

前面三件事都对齐了,该来验证一下了。测试程序是 tools/musl/hello.cmusl-gcc 编的静态小程序,整个程序就这么大:

c 复制代码
#include <unistd.h>

int main(void) {
    write(1, "Hello from musl on Cinux!\n", 26);
    return 0;
}

(hello.c:18。)这里故意用 write() 不用 printf():write 是一记直接 syscall,但程序链进的还是完整的 musl 运行时 ------_start__init_libc 读 auxv、__init_tls/__init_tp 设 TLS、读栈 canary、exit_group 干净退出,一个环节不少;printf 的 stdio 惰性初始化当时还踩着一个单独的段错误(follow-up,不影响运行时证明),所以 smoke 先拿 write 把「运行时活着」钉死。

跑它的测试也值得说一句。单测跑完之后,kernel/test/main_test.cpp 里这个可选阶段(-DCINUX_MUSL_HELLO_SMOKE=ON 才编进)会进入真调度器 :一个 worker 任务 fork,子进程装个全新 AddressSpace 然后 launch_user_program("/hello", ...),父进程 waitpid 等它退出、拿 exit_status==0 当通过信号;而且不是跑一次,是 fork+execve+waitpid 连跑 20 遍 (main_test.cpp:169 起)------ring3 的间歇性竞态(accessor/CoW/cleartid/futex 那类)只有重复跑才掏得出来,跑一次绿不算数。

结果呢?第一次进 ring3 就 #DF(Double Fault)

定位这个过程是这一章最值得记的。用 addr2line 反推 RIP------拿 LSTAR=syscall_entry 当锚算出加载基址------落在 jump_to_usermode,就是内核切用户态的那个函数;诊断打印确认 gs:0(per-CPU)、LSTAR 都对,所以 #DF 就发生在 jump_to_usermode 执行期间。看修复后的这段汇编:

asm 复制代码
jump_to_usermode:
    movq %rdi, %rcx        # %rdi→%rcx: user entry point (SYSRET loads into RIP)
    movq %rsi, %rsp        # %rsi→%rsp: user stack pointer
    movq %rdx, %rdi        # %rdx→%rdi: argument for user entry function
    ...
    # R11 = RFLAGS for SYSRET: set IF (bit 9) so interrupts are enabled in user mode.
    # Use a direct immediate load (not push/pop): RSP was just switched to the user
    # stack above, so a push would write user memory from kernel mode -- a SMAP
    # violation (#PF -> #DF, since RSP is already user) when CR4.SMAP is set.
    movq $0x202, %r11      # $0x202→%r11: RFLAGS with IF|bit1 set (SYSRET restores)

(usermode.S:96。)问题在修复前 的那一版:它切到用户栈(movq %rsi, %rsp,RSP 已经指向用户内存)之后,用的是 pushq $0x202; popq %r11 来设 RFLAGS。pushq当前 RSP 指的内存 写------而此时 RSP 是用户栈,这是内核态写用户内存 。SMAP(056 开的那个)一开,内核写用户内存 → #PF;而 #PF 的硬件推栈往当前 RSP 推错误码,RSP 还是那个用户栈,内核照样写不进去 → 推栈失败 → 升级成 #DF。一条 pushq,在「SMAP 开着的真机」上就是双杀。修法就是上面那行 movq $0x202, %r11:用立即数加载设 RFLAGS,根本不碰内存,语义等价且永远安全。

修完之后,串口输出如下:

复制代码
[F10-M1] musl hello ring-3 smoke: 20 iterations
[EXECVE] loaded /hello entry=0x40103B pid=10
Hello from musl on Cinux!          ← musl write(1,...) → SYS_write → kprintf
[SYSCALL] sys_exit(0) from tid=139 ← musl exit_group(0) 干净退出

整条 musl 启动链(_start__libc_start_main__init_libc 读 auxv → __init_tls/__init_tp 设 TLS → 读栈 canary → mainwriteexit_group)在 Cinux ring3 上端到端跑通,20 遍全绿。这一步前面铺的 ABI / 初始栈 / syscall 工作,全部经住了真 musl 程序的检验。

相关推荐
_Narcissus_26 分钟前
单调栈笔记及例题详解
数据结构·c++·笔记·算法·力扣·单调栈·洛谷
流浪00131 分钟前
C/C++后端筑基系列(二):C++ 从基础到进阶
开发语言·c++
kyle~33 分钟前
x86 汇编LOCK前缀 --- 硬件架构向软件提供的核心同步原语
汇编·c++·性能优化·硬件架构·实时系统
Escalating_xu2 小时前
【C++入门基础(下)】默认参数、函数重载、引用、inline 与 nullptr
android·c++·redis
艾莉丝努力练剑3 小时前
【AI大模型接入SDK】Provider分析与实现
c++·人工智能·学习·面试·大模型·llm
2401_868534789 小时前
指令集-最常用 Linux 命令速查手册(整合版)
linux·网络协议
ltl9 小时前
小文件问题:文件数量如何放大元数据与 I/O 成本
linux