Skip to content

第二十章:蜕变之术

筑基初期

涉及内核源码:

林小源开始研究

如果说 是"分身术"——创建一个和自己一模一样的副本,那么 就是"蜕变术"——用一个全新的程序替换自己的一切。代码、数据、堆、栈,全部被丢弃,从头开始。

但 PID 不变。

这是最让林小源着迷的地方。 不创建新进程——它在现有进程中加载新程序。进程的"身份"(PID、家族关系、打开的文件描述符)被保留,但"灵魂"(代码和数据)被完全替换。

c
/*
 * execve() 的内核路径(简化):
 * 1. 读取 ELF 文件头
 * 2. 验证 ELF 格式
 * 3. 释放旧的地址空间
 * 4. 建立新的地址空间
 * 5. 加载程序段到新地址空间
 * 6. 设置新的入口点和栈
 * 7. 返回用户态,从新程序的 _start 开始执行
 */

struct elf_header {
    char     magic[4];     /* "\x7fELF" */
    uint8_t  bits;         /* 1=32位, 2=64位 */
    uint8_t  endian;       /* 1=小端, 2=大端 */
    uint8_t  version;
    uint8_t  os;
    uint16_t type;         /* 2=EXEC, 3=DYN */
    uint16_t machine;      /* 0xF3=RISC-V */
    uint64_t entry;        /* 程序入口地址 */
};

void begin_new_exec(const char *filename) {
    printf("[begin_new_exec] === 蜕变开始 ===\n\n");

    printf("  1. 释放旧的地址空间:\n");
    printf("     munmap 旧代码段\n");
    printf("     munmap 旧数据段\n");
    printf("     munmap 旧堆\n");
    printf("     munmap 旧栈\n\n");

    printf("  2. 建立新的地址空间:\n");
    printf("     mmap 新代码段 (r-x)\n");
    printf("     mmap 新数据段 (rw-)\n");
    printf("     mmap 新堆 (rw-)\n");
    printf("     mmap 新栈 (rw-)\n\n");

    printf("  3. 加载 ELF 程序段:\n");
    printf("     读取 program headers\n");
    printf("     映射 PT_LOAD 段到虚拟地址\n\n");

    printf("  4. 设置新的执行环境:\n");
    printf("     入口点: 0x%lX\n", 0x10000UL);
    printf("     栈指针: 0x%lX\n", 0x7FFFFFF000UL);
    printf("     参数: argc, argv, envp\n\n");

    printf("[begin_new_exec] === 蜕变完成 ===\n");
    printf("[begin_new_exec] PID 不变,但进程已完全改变\n");
}

printf("=== execve() 路径演示 ===\n\n");

printf("execve(\"/bin/ls\", [\"ls\", \"-la\"], [\"PATH=/usr/bin\"])\n\n");

begin_new_exec("/bin/ls");

printf("\n--- 蜕变前后的对比 ---\n");
printf("  PID:       不变\n");
printf("  代码段:    旧 → 新\n");
printf("  数据段:    旧 → 新\n");
printf("  堆:        旧 → 新\n");
printf("  栈:        旧 → 新\n");
printf("  文件描述符: 保留(除非 FD_CLOEXEC)\n");
printf("  信号处理器: 重置为默认\n");

林小源盯着 的流程,脑海中浮现出一幅画面:一个进程站在自己的地址空间中央,四周是代码段、数据段、堆、栈——这些是它的"旧肉身"。然后 一挥手,旧肉身灰飞烟灭,新的肉身从 ELF 文件中凝聚而出。PID 不变,但一切都变了。

"这才是真正的蜕变。"他喃喃道。

"蜕变?"一个苍老而威严的声音从 ELF 加载器的方向传来。林小源抬头看去,只见一个身穿玄色长袍的老者端坐在一块巨大的 ELF 文件上——那是 ELF 加载者,负责解析和加载所有可执行文件。

"你觉得蜕变是好事?"ELF 加载者淡淡地说,"对进程来说,蜕变是死亡。它的旧代码被丢弃,旧数据被清空,旧栈被销毁。新的代码和数据被加载进来,但它已经不是原来的那个进程了——只是 PID 没变而已。"

"但文件描述符被保留了。"林小源指出。

"没错,"ELF 加载者点头,"除非设置了 标志。这是一个重要的设计——子进程继承父进程的文件描述符,可以继续使用已经打开的文件。(close-on-exec)告诉内核在 exec 时自动关闭这个文件描述符——这在 shell 的管道重定向中非常重要。"

林小源想起了 shell 小妹的工作方式:打开管道,fork 子进程,子进程 execve 新程序。管道的文件描述符不应该被新程序继承——所以 shell 在打开管道时会设置 ,execve 之后管道自动关闭。

林小源观察了一次完整的 execve 过程。

shell 小妹——bash 进程——在用户输入 ls -la 后,先 创建一个子进程,然后子进程调用 execve("/bin/ls", ...)。子进程在 execve 中经历了蜕变:bash 的代码被丢弃,ls 的代码被加载。子进程从 ls 的 开始执行,输出目录内容,然后调用 退出。

整个过程不到十毫秒。

fork + execve。

这是 Unix 世界中最经典的模式。fork 创建子进程,execve 加载新程序。子进程是父进程的"分身",但通过 execve 获得了全新的"灵魂"。

ELF 加载者在一旁说道:"你看到了 shell 的工作方式。每次执行一条命令,就是一次 fork + execve。看起来简单,但这个模式的力量在于它的组合性——任何程序都可以通过 execve 来启动任何其他程序。shell 不需要知道 ls 的内部实现,它只需要 fork 一个子进程,然后 execve ls 就行了。"

"这就是 Unix 的设计哲学?"林小源问。

"对,"ELF 加载者说,"小而美的程序,通过 fork + execve + pipe 组合在一起。每个程序只做一件事,但做好。组合起来,就是无穷的力量。"

林小源想起了 init 童子的故事。PID 1 也是这样诞生的—— 线程通过 execve("/sbin/init") 变成了用户态的 init 进程。同一个 PID,完全不同的存在。

execve 是最彻底的蜕变。不是渐变,不是进化,而是彻底的替换。

就在林小源研究 execve 的过程中,init 童子又做了一件事。

他 fork 了一个子进程,然后子进程 execve 了 /bin/sh。这是一个很普通的操作——init 进程在启动服务时经常这样做。但林小源注意到了一个细节:init 童子在 fork 之后,立即调用了 等待子进程结束。

他在等子进程。

这个细节让林小源对 init 童子有了新的认识。init 童子不只是"天选之子"——他也是一个负责任的父进程。他创建子进程,等待子进程结束,回收子进程的资源。他不会让子进程变成僵尸。

ELF 加载者看到林小源的表情,微微一笑:"你以前觉得 init 傲慢?"

"我……"林小源有些不好意思,"他总是很冷淡的样子。"

"他不冷淡,"ELF 加载者说,"他只是忙。每天 fork 几十次,wait 几十次,处理 SIGCHLD,回收僵尸,重启服务。他没有时间和你寒暄——但他在做他该做的事。"

也许 init 童子并不像他表面上那样傲慢。

林小源把这个观察记在了心里。他还不确定 init 童子的真实性格——也许需要更多的观察才能做出判断。


道藏笔记

内核启示

是进程的"蜕变术"——用新程序替换旧程序。

的内核路径是一连串接力: 先解析参数、分配 ,然后 搜索能处理这个二进制格式的加载器, 找到 ELF 加载器后调用 释放旧地址空间、建立新地址空间,接着 把 ELF 程序段加载进来,最后返回用户态,从新程序的 开始执行。

(close-on-exec)是一个重要的标志。它告诉内核在 exec 时自动关闭这个文件描述符。这在 shell 的管道重定向中非常重要——父进程打开的管道不应该被子进程继承到新程序中。

fork + execve 是 Unix 的经典模式。fork 创建分身,execve 赋予新灵魂。


破关试炼

蜕变之试

本章的“蜕变之术”指当前进程保留 PID、但用新程序替换地址空间的哪个系统调用?

答对后才能继续滑动和进入下一章。

以修仙之名,悟内核之道