拆解 musl libc 启动流程:从 __libc_start_main 到 main() 到底发生了什么?
很多人知道 glibc 的启动流程,但 musl libc 作为一个轻量级替代方案,它的启动代码只有不到 200 行,却藏着不少精巧设计。本文逐函数拆解 musl 的
__libc_start_main,看它如何用最少的代码完成最完整的初始化。
一、先看全景:musl 的启动分为几个阶段?
_start (汇编) ↓ __libc_start_main ← 第一阶段:初始化 libc 自身 ↓ libc_start_main_stage2 ← 第二阶段:运行 .init_array,跳转 main() ↓ main(argc, argv, envp)为什么要分两阶段?代码里这段注释说得很清楚:
/* External linkage, and explicit noinline attribute if available, * are used to prevent the stack frame used during init from * persisting for the entire process lifetime. */翻译:如果不分阶段,编译器可能把初始化时的栈帧一直保留到进程结束,浪费栈空间。用noinline+ 弱符号间接调用,强制编译器把两阶段看成"可能不相关的函数",从而优化掉多余栈帧。
这个技巧在嵌入式和容器场景下非常实用。
二、核心函数:__init_libc在干什么?
这是整段代码最密集的部分,我们逐块看。
2.1 解析 auxv(辅助向量)
for (i=0; envp[i]; i++); libc.auxv = auxv = (void *)(envp+i+1);Linux 传递给进程的信息除了argv、envp,还有一组auxv(auxiliary vector),以{类型, 值}成对存储,以AT_NULL结尾。
musl 只取前 38 项(AUX_CNT = 38):
| auxv 类型 | 用途 | musl 中的变量 |
|---|---|---|
AT_HWCAP | CPU 特性标志 | __hwcap |
AT_PAGESZ | 页面大小 | libc.page_size |
AT_SYSINFO | vsyscall 地址 | __sysinfo |
AT_EXECFN | 可执行文件路径 | __progname |
AT_RANDOM | 随机数种子 | 传给__init_ssp(栈保护) |
AT_UID/EUID/GID/EGID | 权限检查 | 判断是否 setuid |
AT_SECURE | 是否安全模式 | 配合权限判断 |
for (i=0; auxv[i]; i+=2) if (auxv[i]<AUX_CNT) aux[auxv[i]] = auxv[i+1];把 auxv 拍平成数组,方便后续aux[AT_XXX]直接访问。这种处理比 glibc 的链表方式更简洁。
2.2 设置程序名
__progname = __progname_full = pn; for (i=0; pn[i]; i++) if (pn[i]=='/') __progname = pn+i+1;__progname_full存完整路径(如/usr/bin/ls),__progname存 basename(如ls)。这就是你在ps命令里看到的进程名来源。
2.3 安全检查:setuid 程序的特殊处理
if (aux[AT_UID]==aux[AT_EUID] && aux[AT_GID]==aux[AT_EGID] && !aux[AT_SECURE]) return;如果真实 UID = 有效 UID且真实 GID = 有效 GID且不在安全模式,说明这是一个普通程序(不是 setuid/setgid),直接跳过后面的安全处理。
否则进入安全路径:
struct pollfd pfd[3] = { {.fd=0}, {.fd=1}, {.fd=2} }; int r = __syscall(SYS_poll, pfd, 3, 0);用poll检查 stdin/stdout/stderr 是否是有效终端。如果任一 fd 返回POLLNVAL(无效文件描述符),说明这些 fd 被关闭了(常见于 daemon 进程),此时把它们重定向到/dev/null:
if (pfd[i].revents&POLLNVAL) if (__sys_open("/dev/null", O_RDWR)<0) a_crash(); libc.secure = 1;为什么要这样做?setuid 程序如果 stdin/stdout 指向不可控的终端,可能被利用进行提权攻击。musl 的策略是:检测到 fd 无效就关掉,关不掉就直接崩溃(a_crash()),宁可不启动也不留安全隐患。
三、弱符号技巧:_init和__init_array
static void dummy(void) {} weak_alias(dummy, _init); extern weak hidden void (*const __init_array_start)(void), (*const __init_array_end)(void);_init:老旧的初始化段,musl 提供一个空实现作为弱符号。如果你的程序没有定义_init,就用这个 dummy。
__init_array_start / __init_array_end:这是现代 ELF 的.init_array段,编译器会把所有__attribute__((constructor))的函数指针放在这里。musl 同样用弱符号声明,链接器会自动填入实际地址(如果没有则为 0)。
static void libc_start_init(void) { _init(); // 调用旧式构造函数(通常为空) uintptr_t a = (uintptr_t)&__init_array_start; for (; a<(uintptr_t)&__init_array_end; a+=sizeof(void(*)())) (*(void (**)(void))a)(); // 遍历调用所有 constructor }这就是为什么 C++ 全局对象的构造函数能在main之前执行——它们被放在.init_array里,musl 在跳转到main之前统一调用。
四、__libc_start_main的两阶段设计(重点)
int __libc_start_main(...) { __init_libc(envp, argv[0]); // 阶段一:初始化 libc lsm2_fn *stage2 = libc_start_main_stage2; __asm__ ( "" : "+r"(stage2) : : "memory" ); // 编译器屏障 return stage2(main, argc, argv); // 阶段二:运行 init_array,跳转 main }关键点在于__asm__这行。它的作用是:
- 告诉编译器
stage2指针可能被修改(虽然这里没改,但语义上是"不确定") "memory"clobber 告诉编译器不要把阶段一的内存操作重排到阶段二之后
效果:编译器无法把阶段一的初始化代码" hoist "(提前)或" sink "(延迟)到阶段二,两阶段被严格隔离。这也是为什么__init_libc用了noinline——配合这个屏障,确保初始化完成后才进入 stage2。
五、和 glibc 对比:musl 赢在哪?
| 维度 | musl | glibc |
|---|---|---|
| 启动代码行数 | ~180 行 | ~1000+ 行 |
| auxv 解析 | 数组拍平,O(1) 访问 | 链表遍历 |
| init_array 调用 | 手动遍历指针 | 链接器自动处理 |
| setuid 安全处理 | poll 检查 + /dev/null | 类似但更复杂 |
| 栈帧优化 | 两阶段 + noinline + asm barrier | 依赖链接器脚本 |
| 可读性 | 极高 | 较低(宏和条件编译多) |
musl 的哲学很清晰:能用 10 行解决的,绝不写 100 行。这也是为什么 Alpine Linux 能做到 5MB 镜像的原因之一。
六、总结
这段代码虽然短,但覆盖了 C 运行时启动的所有核心逻辑:
| 步骤 | 函数 | 作用 |
|---|---|---|
| 1 | __init_libc | 解析 auxv、设置环境、安全检查 |
| 2 | libc_start_init | 调用_init+.init_array |
| 3 | libc_start_main_stage2 | 跳转到main() |
| 4 | main | 你的程序 |
如果你在写自己的 runtime 或做系统编程,这段代码是极好的参考——它证明了"少即是多"不只是设计原则,也是工程能力。
参考:musl libc 1.2.5 源码src/env/__libc_start_main.c
