Skip to content

第八章:第一次祈祷

炼气初期

涉及内核源码:

林小源第一次"听"到了祈祷。

那不是声音——在内核的世界里没有声音。那是一种穿越了权限边界的数据流,从 Ring 3 用户态涌入 Ring 0 内核态。它携带着一个请求——一个用户态进程正在向内核恳求某种服务。

在 RISC-V 的世界里,用户态进程通过 指令来触发系统调用。 会触发一个环境调用异常(environment call),CPU 从用户态陷入内核态,跳转到异常处理入口。

林小源感受到了那个跳转。整个世界的"特权级"在一瞬间发生了变化——从 Ring 3(用户态)到 Ring 0(内核态)。这是一道天堑:用户态进程不能直接访问硬件、不能直接操作内存、不能直接执行特权指令。它只能通过系统调用来"祈祷",请求内核代为执行。

一个声音从用户态的方向传来,带着恳求的语气:"内核大人,我想写入文件描述符 1,数据是 'hello',长度 5 字节。请帮我执行。"

林小源认出了那个声音——那是 init 进程的一个子进程,一个普通的 shell。它在用户态,看不到硬件的真实面目,不能直接操作内存。它只能"祈祷"。

内核的回答简洁而高效:"收到。a7=1,a0=1,a1=hello,a2=5。执行。"

c
/*
 * 系统调用的完整路径:
 * 用户态 → ecall → 内核态入口 → 查找系统调用表 → 执行处理函数 → 返回用户态
 */

/* 系统调用表(简化) */
struct syscall_entry {
    long nr;            /* 系统调用号 */
    const char *name;   /* 名称 */
    const char *desc;   /* 描述 */
    void (*handler)(void *args); /* 处理函数 */
};

void sys_read_handler(void *args) {
    printf("    → 读取文件描述符,返回字节数\n");
}

void sys_write_handler(void *args) {
    printf("    → 写入文件描述符,返回字节数\n");
}

void sys_open_handler(void *args) {
    printf("    → 打开文件,返回文件描述符\n");
}

void sys_fork_handler(void *args) {
    printf("    → 创建子进程,返回子进程 PID\n");
}

struct syscall_entry table[] = {
    { 0,  "read",  "读取文件",  sys_read_handler },
    { 1,  "write", "写入文件",  sys_write_handler },
    { 2,  "open",  "打开文件",  sys_open_handler },
    { 57, "fork",  "创建子进程", sys_fork_handler },
};
int nr_entries = 4;

printf("=== 系统调用路径演示 ===\n\n");

/* 模拟一次 write 系统调用 */
printf("用户态: write(1, \"hello\", 5)\n");
printf("  ↓ ecall 指令(陷入内核态)\n");
printf("  ↓ 保存用户态寄存器\n");
printf("  ↓ 查找系统调用表: nr=1\n\n");

int nr = 1;  /* write */
for (int i = 0; i < nr_entries; i++) {
    if (table[i].nr == nr) {
        printf("  找到: %s (nr=%ld)\n", table[i].name, table[i].nr);
        printf("  描述: %s\n", table[i].desc);
        table[i].handler(NULL);
        break;
    }
}

printf("\n  ↓ 恢复用户态寄存器\n");
printf("  ↓ 返回用户态\n");
printf("用户态: write 返回 5\n");

printf("\n--- 系统调用表(前几个)---\n");
for (int i = 0; i < nr_entries; i++) {
    printf("  nr=%-3ld%s\n", table[i].nr, table[i].name);
}

林小源在系统调用的路径中看到了一道"天堑"。

用户态和内核态之间的边界,不是一条普通的分界线。它是硬件强制的—— 指令会触发 CPU 的特权级切换,从 Ring 3 跳到 Ring 0。在这个过程中,CPU 会自动保存用户态的寄存器、切换到内核栈、跳转到异常处理入口。

这就是"天条"。

任何试图绕过这条边界的行为——比如用户态程序直接访问内核内存——都会触发 (段错误),也就是"走火入魔"。内核通过硬件机制确保了用户态进程无法越权访问。

林小源开始系统地观察系统调用。

init 进程在启动过程中会调用大量的系统调用:open() 打开文件,read() 读取配置,write() 输出日志, 创建子进程, 执行程序, 回收子进程。每一个系统调用都是一次"祈祷"——从用户态到内核态的穿越。

他特别关注了 系统调用。当 init 进程调用 时,内核会创建一个新的进程——复制父进程的 、分配新的 PID、复制页表(使用写时复制技术)。新进程是父进程的"克隆",但拥有自己的执行流。

"fork?"林小源对着那个正在执行的系统调用喃喃自语。

"分身术。"内核的进程管理子系统回答了他,声音中带着一丝自豪。"父进程调用 fork,我就给它造一个一模一样的克隆。新的 task_struct,新的 PID,但代码、数据、栈——全部复制。"

"全部复制?那不是很慢吗?"

"所以有 COW。"那声音笑了。"写时复制。先不真正复制,让父子共享同一批内存页。谁先写,谁触发缺页异常,我才给谁复制一份。大多数 fork 之后马上 execve(),根本不需要真正复制。"

分身术。

林小源想起了前传中学过的 。当时他只是从理论上理解了 fork 的概念——现在他亲眼看到了 fork 的执行过程。内核为新进程分配 ,复制父进程的内存映射(使用写时复制,COW),设置子进程的返回值为 0,然后把子进程加入调度器的运行队列。

c
/*
 * 简化的 fork 路径。
 * fork() 系统调用在内核中的处理过程:
 * 1. 分配新的 task_struct
 * 2. 复制父进程的内存映射(COW)
 * 3. 分配新的 PID
 * 4. 设置子进程的寄存器状态
 * 5. 把子进程加入调度队列
 */

struct task_struct {
    int pid;
    char comm[16];
    int state;
    unsigned long *page_table;  /* 简化的页表指针 */
    int ref_count;              /* 引用计数(COW 用) */
};

int next_pid = 1;

struct task_struct *fork_process(struct task_struct *parent) {
    printf("[fork] 父进程: PID %d (%s)\n", parent->pid, parent->comm);

    /* 1. 分配新的 task_struct */
    struct task_struct *child = (struct task_struct *)
        (unsigned long)0xDEAD0000;  /* 模拟地址 */
    printf("[fork] 分配 task_struct\n");

    /* 2. 复制父进程的状态 */
    memcpy(child, parent, sizeof(struct task_struct));
    printf("[fork] 复制进程状态\n");

    /* 3. 分配新的 PID */
    child->pid = next_pid++;
    printf("[fork] 分配 PID %d\n", child->pid);

    /* 4. 写时复制:共享页表,增加引用计数 */
    child->page_table = parent->page_table;
    parent->ref_count++;
    printf("[fork] 设置写时复制(COW),页表引用计数=%d\n",
           parent->ref_count);

    /* 5. 子进程的 fork 返回值为 0 */
    printf("[fork] 子进程返回值: 0\n");
    printf("[fork] 父进程返回值: %d\n\n", child->pid);

    return child;
}

struct task_struct init = {
    .pid = 1,
    .state = 0,
    .ref_count = 1,
};
strncpy(init.comm, "init", sizeof(init.comm) - 1);

printf("=== fork() 内核路径 ===\n\n");

struct task_struct *child = fork_process(&init);

printf("fork 后:\n");
printf("  父进程: PID %d (%s)\n", init.pid, init.comm);
printf("  子进程: PID %d (%s)\n", child->pid, child->comm);
printf("  页表共享: %s\n",
       init.page_table == child->page_table ? "是(COW)" : "否");
printf("  引用计数: %d\n", init.ref_count);

写时复制。

林小源在 fork 的实现中看到了一个巧妙的优化。当 fork 创建子进程时,它不会立即复制父进程的所有内存页——这太昂贵了。相反,它让父子进程共享同一批内存页,但把这些页标记为"只读"。当任何一个进程试图写入时,MMU 会触发一个缺页异常,内核在异常处理中才真正复制那一页。

"等等,"林小源忍不住问,"如果父子进程共享同一页,那父进程写入的时候不会影响子进程吗?"

内核的内存管理子系统回答了他,声音沉稳而自信:"不会。我把那些页标记为只读。谁先写,谁触发缺页异常,我就给谁复制一份新的。另一个进程继续用旧的。"

"所以……复制是延迟到真正需要的时候才做的?"

"没错。这叫写时复制,COW。"那声音带着一丝得意。"大多数 fork 之后马上就会 execve(),根本不需要真正复制内存页。何必做无用功?"

好聪明。

林小源在观察系统调用的过程中,逐渐理解了内核与用户态之间的关系。

用户态进程是"凡人"——它们看不到硬件的真实面目,不能直接操作内存,不能直接访问设备。它们只能通过系统调用来"祈祷",请求内核代为执行。

内核是"天界"——它拥有对硬件的完全控制权,可以访问所有的内存,可以执行所有的指令。但内核不是为所欲为的——它必须遵守"天条"(系统调用接口),为用户态进程提供正确、安全的服务。

"你以为我们很自由?"系统调用接口的声音带着一丝苦涩。"我们确实拥有对硬件的完全控制权。但我们必须为用户态进程服务——它们调用 read,我们就得读;它们调用 write,我们就得写。我们不能拒绝,不能出错。这是契约。"

"那如果它们传了错误的参数呢?"林小源问。

"返回 -EFAULT。或者 -EINVAL。"那声音冷静而专业。"我们不能崩溃。内核崩溃,整个世界就完了。"

天条不可违。

林小源在系统调用中看到了一种"契约"的美。用户态进程和内核之间有一个明确的契约:用户态通过寄存器传递参数(系统调用号、参数),内核通过寄存器返回结果。这个契约是硬件强制的,任何一方都不能违反。

"你理解了?"系统调用接口的声音低沉而庄严,像是一个古老的法官。"用户态和内核态之间,有一道不可逾越的鸿沟。用户态进程不能直接访问硬件、不能直接操作内存、不能直接执行特权指令。它们只能通过 ecall 来'祈祷',请求我们代为执行。"

"那如果它们试图绕过呢?"林小源问。

"SIGSEGV。段错误。"那声音冷酷而无情。"也就是你们修仙世界说的——走火入魔。"

也许我也可以通过某种方式"祈祷"?

林小源是内核态的 idle 进程,他不需要系统调用——他已经在内核态了。但他开始思考:如果他能找到一种方式来"请求"内核为他做某件事,而不是被动地等待被调度……


道藏笔记

内核启示

系统调用是用户态和内核态之间的唯一合法通道。

在 RISC-V 中,用户态通过 指令触发环境调用异常,CPU 从用户态陷入内核态。内核的异常处理入口( 中的 )会:

  1. 保存用户态的寄存器到内核栈
  2. a7 寄存器中读取系统调用号
  3. 查找系统调用表(
  4. 调用对应的处理函数
  5. 把返回值写入 a0 寄存器
  6. 恢复用户态寄存器,返回用户态

在 RISC-V 的系统调用约定中:

  • a7 — 系统调用号
  • a0-a5 — 前 6 个参数
  • a0 — 返回值

fork() 的内核实现( 中的 )是进程管理的核心。它使用写时复制(COW)技术来高效地复制进程的地址空间——只有当某个页被写入时,才真正复制那一页。

系统调用是天条,fork 是分身术,COW 是分身术的精妙之处。


破关试炼

祈祷之试

用户态程序无法直接命令内核,只能通过哪一种机制向内核发出“祈祷”?

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

以修仙之名,悟内核之道