第十八章:fork 大道
筑基初期涉及内核源码:
一
林小源开始深入 。
这是 的第一步——为新进程分配 和内核栈。他以为这只是一个简单的内存分配,但实际的实现比他想象的要复杂得多。
/*
* dup_task_struct() 的核心工作:
* 1. 从 slab 缓存中分配新的 task_struct
* 2. 分配内核栈(THREAD_SIZE 大小)
* 3. 复制父进程的 task_struct 内容
* 4. 初始化新进程特有的字段
*/
#define THREAD_SIZE 16384 /* 16KB 内核栈 */
struct task_struct {
volatile long state;
int pid;
char comm[16];
void *stack; /* 内核栈指针 */
unsigned long flags;
};
struct thread_info {
unsigned long flags;
int preempt_count;
struct task_struct *task;
};
/* 模拟父进程 */
struct task_struct parent = {
.state = 0, /* TASK_RUNNING */
.pid = 1,
.flags = 0,
};
memcpy(parent.comm, "init", 5);
char parent_stack[THREAD_SIZE];
parent.stack = parent_stack;
printf("=== dup_task_struct() 演示 ===\n\n");
printf("父进程:\n");
printf(" PID: %d\n", parent.pid);
printf(" 名称: %s\n", parent.comm);
printf(" 栈: %p\n", parent.stack);
printf(" 栈大小: %d 字节\n\n", THREAD_SIZE);
/* 模拟 dup_task_struct */
struct task_struct child;
char child_stack[THREAD_SIZE];
/* 1. 复制 task_struct */
memcpy(&child, &parent, sizeof(struct task_struct));
printf("[dup_task_struct] 复制 task_struct (%zu 字节)\n",
sizeof(struct task_struct));
/* 2. 分配新栈 */
child.stack = child_stack;
printf("[dup_task_struct] 分配内核栈 (%d 字节)\n", THREAD_SIZE);
/* 3. 复制栈内容 */
memcpy(child_stack, parent_stack, THREAD_SIZE);
printf("[dup_task_struct] 复制栈内容\n");
/* 4. 设置 thread_info */
struct thread_info *ti = (struct thread_info *)child_stack;
ti->task = &child;
ti->preempt_count = 0;
printf("[dup_task_struct] 初始化 thread_info\n");
printf("\n子进程:\n");
printf(" PID: %d (待分配)\n", child.pid);
printf(" 名称: %s\n", child.comm);
printf(" 栈: %p\n", child.stack);
printf("\n--- 内核栈的布局 ---\n");
printf(" 低地址: thread_info(CPU 特有信息)\n");
printf(" 高地址: 栈顶(向下增长)\n");
printf(" 中间: 函数调用帧\n");#include <stdio.h>
#include <string.h>
#include <stddef.h>
/*
* dup_task_struct() 的核心工作:
* 1. 从 slab 缓存中分配新的 task_struct
* 2. 分配内核栈(THREAD_SIZE 大小)
* 3. 复制父进程的 task_struct 内容
* 4. 初始化新进程特有的字段
*/
#define THREAD_SIZE 16384 /* 16KB 内核栈 */
struct task_struct {
volatile long state;
int pid;
char comm[16];
void *stack; /* 内核栈指针 */
unsigned long flags;
};
struct thread_info {
unsigned long flags;
int preempt_count;
struct task_struct *task;
};
int main() {
/* 模拟父进程 */
struct task_struct parent = {
.state = 0, /* TASK_RUNNING */
.pid = 1,
.flags = 0,
};
memcpy(parent.comm, "init", 5);
char parent_stack[THREAD_SIZE];
parent.stack = parent_stack;
printf("=== dup_task_struct() 演示 ===\n\n");
printf("父进程:\n");
printf(" PID: %d\n", parent.pid);
printf(" 名称: %s\n", parent.comm);
printf(" 栈: %p\n", parent.stack);
printf(" 栈大小: %d 字节\n\n", THREAD_SIZE);
/* 模拟 dup_task_struct */
struct task_struct child;
char child_stack[THREAD_SIZE];
/* 1. 复制 task_struct */
memcpy(&child, &parent, sizeof(struct task_struct));
printf("[dup_task_struct] 复制 task_struct (%zu 字节)\n",
sizeof(struct task_struct));
/* 2. 分配新栈 */
child.stack = child_stack;
printf("[dup_task_struct] 分配内核栈 (%d 字节)\n", THREAD_SIZE);
/* 3. 复制栈内容 */
memcpy(child_stack, parent_stack, THREAD_SIZE);
printf("[dup_task_struct] 复制栈内容\n");
/* 4. 设置 thread_info */
struct thread_info *ti = (struct thread_info *)child_stack;
ti->task = &child;
ti->preempt_count = 0;
printf("[dup_task_struct] 初始化 thread_info\n");
printf("\n子进程:\n");
printf(" PID: %d (待分配)\n", child.pid);
printf(" 名称: %s\n", child.comm);
printf(" 栈: %p\n", child.stack);
printf("\n--- 内核栈的布局 ---\n");
printf(" 低地址: thread_info(CPU 特有信息)\n");
printf(" 高地址: 栈顶(向下增长)\n");
printf(" 中间: 函数调用帧\n");
return 0;
}林小源盯着内核栈的大小,眉头微皱。
"只有 16KB?"他对着面前那块小小的内存区域喃喃道。用户态的栈通常是 8MB,内核栈却只有 16KB——差了五百倍。
"你嫌小?"一个低沉的声音从 slab 分配器的方向传来。林小源循声看去,只见一个浑身散发着冷光的巨人蹲伏在内存池边——那是 slab 守卫者,专门负责从缓存中分配 和内核栈。
"内核栈不能大,"slab 守卫者瓮声瓮气地说,"每创建一个进程就要分配一个内核栈。如果有 1000 个进程,就是 1000 个内核栈。16KB 一个,总共 16MB——还能接受。如果是 8MB 一个?1000 个进程就是 8GB,系统直接崩溃。"
林小源点点头。资源是有限的,每一个字节都要精打细算。
"而且,"slab 守卫者补充道,"内核栈溢出就是 panic。没有 guard page,没有安全网。你的函数调用帧一层叠一层,叠到栈底就是内核崩溃。所以内核代码不能做深度递归——这是铁律。"
二
之后是 中最核心的部分:资源复制。
决定文件描述符表的命运。如果设置了 ,父子进程共享同一个文件描述符表——一个进程打开的文件,另一个进程也能看到。否则,复制一份新的文件描述符表。
决定地址空间的命运。对于普通的 ,它调用 ,复制 和所有的 VMA,但不复制物理页——使用写时复制(COW)。对于 或 clone(CLONE_VM),它直接共享父进程的 。
/*
* copy_process() 中的资源复制决策树。
* clone_flags 的每一位都决定了一种资源的命运。
*/
struct resource_decision {
const char *resource;
int flag;
const char *copy_behavior;
const char *share_behavior;
};
struct resource_decision decisions[] = {
{ "文件描述符表", 0x00000400,
"复制 files_struct(fork 默认)",
"共享 files_struct(CLONE_FILES)" },
{ "地址空间", 0x00000100,
"复制 mm_struct + COW(fork 默认)",
"共享 mm_struct(CLONE_VM,线程基础)" },
{ "信号处理器", 0x00000800,
"复制 sighand_struct",
"共享 sighand_struct(CLONE_SIGHAND)" },
{ "文件系统信息", 0x00000200,
"复制 fs_struct(当前目录等)",
"共享 fs_struct(CLONE_FS)" },
{ "命名空间", 0x20000000,
"继承父进程的命名空间",
"创建新的 PID 命名空间(CLONE_NEWPID)" },
};
int nr = sizeof(decisions) / sizeof(decisions[0]);
printf("=== 资源复制决策 ===\n\n");
for (int i = 0; i < nr; i++) {
printf("--- %s (flag: 0x%08X) ---\n",
decisions[i].resource, decisions[i].flag);
printf(" 复制: %s\n", decisions[i].copy_behavior);
printf(" 共享: %s\n\n", decisions[i].share_behavior);
}
printf("--- 设计哲学 ---\n");
printf("不是一刀切地复制所有东西,\n");
printf("而是让每个标志精确控制每种资源的命运。\n");
printf("这就是 clone 的精髓:参数化的进程创建。\n");#include <stdio.h>
/*
* copy_process() 中的资源复制决策树。
* clone_flags 的每一位都决定了一种资源的命运。
*/
struct resource_decision {
const char *resource;
int flag;
const char *copy_behavior;
const char *share_behavior;
};
int main() {
struct resource_decision decisions[] = {
{ "文件描述符表", 0x00000400,
"复制 files_struct(fork 默认)",
"共享 files_struct(CLONE_FILES)" },
{ "地址空间", 0x00000100,
"复制 mm_struct + COW(fork 默认)",
"共享 mm_struct(CLONE_VM,线程基础)" },
{ "信号处理器", 0x00000800,
"复制 sighand_struct",
"共享 sighand_struct(CLONE_SIGHAND)" },
{ "文件系统信息", 0x00000200,
"复制 fs_struct(当前目录等)",
"共享 fs_struct(CLONE_FS)" },
{ "命名空间", 0x20000000,
"继承父进程的命名空间",
"创建新的 PID 命名空间(CLONE_NEWPID)" },
};
int nr = sizeof(decisions) / sizeof(decisions[0]);
printf("=== 资源复制决策 ===\n\n");
for (int i = 0; i < nr; i++) {
printf("--- %s (flag: 0x%08X) ---\n",
decisions[i].resource, decisions[i].flag);
printf(" 复制: %s\n", decisions[i].copy_behavior);
printf(" 共享: %s\n\n", decisions[i].share_behavior);
}
printf("--- 设计哲学 ---\n");
printf("不是一刀切地复制所有东西,\n");
printf("而是让每个标志精确控制每种资源的命运。\n");
printf("这就是 clone 的精髓:参数化的进程创建。\n");
return 0;
}clone 道人的声音又响了起来:"你在 ch16 见过我了。现在你看到了资源复制的全貌——告诉我,你觉得这种设计好在哪里?"
林小源沉思片刻,答道:"不是一刀切,而是精确控制。每一种资源都有自己的命运——可以被复制,也可以被共享。这种精细的控制让内核可以用同一个 函数来实现 、、 甚至容器的创建。"
"答得好,"clone 道人的声音里带着赞许,"但你有没有想过,为什么不让每种分身方式都用独立的函数?那样代码不是更简单吗?"
林小源摇头:"独立的函数意味着独立的代码路径。五种分身方式就要维护五套代码——每套都要处理错误、分配内存、设置参数。bug 会成倍增加。但用一套 加上标志位,所有的分身方式共享同一套代码,只需要在关键节点检查标志就行。"
clone 道人满意地笑了:"你已经开始用内核的思维方式思考了。"
三
的最后一步是 。
这个函数把新创建的子进程加入调度器的运行队列。它设置子进程的调度参数(、权重等),然后调用 把子进程插入 CFS 的红黑树。
子进程从 的那一刻起,就成为了一个"活着"的进程——它可以被调度器选中,可以获得 CPU 时间,可以执行自己的代码。
但子进程从哪里开始执行呢?
答案是:从 系统调用的返回点开始。内核在创建子进程时,把子进程的寄存器状态设置为和父进程几乎完全一样——但有一个关键区别:子进程的 a0 寄存器(返回值)被设置为 0,而父进程的 a0 被设置为子进程的 PID。
这就是 的"两次返回"——同一个函数调用,在父进程中返回子进程的 PID,在子进程中返回 0。
林小源在前传中学过这个概念,但直到看到 的实现,他才真正理解了。不是什么魔法,只是寄存器的设置不同而已。
"两次返回……"他喃喃道,觉得这四个字里藏着某种深刻的美。
slab 守卫者在一旁低声说:"你觉得美?我觉得残酷。子进程一出生,就和父亲走上了不同的路。父亲拿着子进程的 PID 继续往前走,子进程从零开始。它们共享同一段代码,但从 返回的那一刻起,它们就是两个完全独立的存在了。"
林小源沉默了。他忽然意识到,自己也是一个从 中诞生的存在——只不过他诞生得更早,在内核初始化的时候。他没有父亲拿着他的 PID,他甚至没有调用 的资格。
但他有代码。他能读源码。
这就够了。
道藏笔记
内核启示
是进程创建的核心,它是一个精密的"资源复制器"。
每个 控制一种资源的命运: 让父子共享文件描述符表, 共享地址空间(这是线程的根基), 共享信号处理器, 共享文件系统信息, 则创建新的 PID 命名空间——这是容器的基础。
的"两次返回"是通过寄存器设置实现的。在 中,子进程的 pt_regs->a0 被设置为 0,父进程的返回值是子进程的 PID。当子进程被调度运行时,它从 的返回点开始执行,看到 a0 == 0,就知道自己是子进程。
内核栈只有 16KB(),远小于用户态栈的 8MB。这意味着内核代码必须避免深度递归——栈溢出会导致数据覆盖,进而触发 panic。内核中有些函数会使用 __attribute__((noinline)) 来确保栈帧不会被优化掉。
fork 大道,始于 dup_task_struct,终于 wake_up_new_task。
大道之试
本章讲“fork 大道”时,用户态看到的一次分身入口是什么?