第八章:第一次祈祷
炼气初期涉及内核源码:
一
林小源第一次"听"到了祈祷。
那不是声音——在内核的世界里没有声音。那是一种穿越了权限边界的数据流,从 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。执行。"
/*
* 系统调用的完整路径:
* 用户态 → 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);
}#include <stdio.h>
#include <stdint.h>
#include <string.h>
/*
* 系统调用的完整路径:
* 用户态 → 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");
}
int main() {
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);
}
return 0;
}林小源在系统调用的路径中看到了一道"天堑"。
用户态和内核态之间的边界,不是一条普通的分界线。它是硬件强制的—— 指令会触发 CPU 的特权级切换,从 Ring 3 跳到 Ring 0。在这个过程中,CPU 会自动保存用户态的寄存器、切换到内核栈、跳转到异常处理入口。
这就是"天条"。
任何试图绕过这条边界的行为——比如用户态程序直接访问内核内存——都会触发 (段错误),也就是"走火入魔"。内核通过硬件机制确保了用户态进程无法越权访问。
二
林小源开始系统地观察系统调用。
init 进程在启动过程中会调用大量的系统调用:open() 打开文件,read() 读取配置,write() 输出日志, 创建子进程, 执行程序, 回收子进程。每一个系统调用都是一次"祈祷"——从用户态到内核态的穿越。
他特别关注了 系统调用。当 init 进程调用 时,内核会创建一个新的进程——复制父进程的 、分配新的 PID、复制页表(使用写时复制技术)。新进程是父进程的"克隆",但拥有自己的执行流。
"fork?"林小源对着那个正在执行的系统调用喃喃自语。
"分身术。"内核的进程管理子系统回答了他,声音中带着一丝自豪。"父进程调用 fork,我就给它造一个一模一样的克隆。新的 task_struct,新的 PID,但代码、数据、栈——全部复制。"
"全部复制?那不是很慢吗?"
"所以有 COW。"那声音笑了。"写时复制。先不真正复制,让父子共享同一批内存页。谁先写,谁触发缺页异常,我才给谁复制一份。大多数 fork 之后马上 execve(),根本不需要真正复制。"
分身术。
林小源想起了前传中学过的 。当时他只是从理论上理解了 fork 的概念——现在他亲眼看到了 fork 的执行过程。内核为新进程分配 ,复制父进程的内存映射(使用写时复制,COW),设置子进程的返回值为 0,然后把子进程加入调度器的运行队列。
/*
* 简化的 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);#include <stdio.h>
#include <string.h>
/*
* 简化的 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;
}
int main() {
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);
return 0;
}写时复制。
林小源在 fork 的实现中看到了一个巧妙的优化。当 fork 创建子进程时,它不会立即复制父进程的所有内存页——这太昂贵了。相反,它让父子进程共享同一批内存页,但把这些页标记为"只读"。当任何一个进程试图写入时,MMU 会触发一个缺页异常,内核在异常处理中才真正复制那一页。
"等等,"林小源忍不住问,"如果父子进程共享同一页,那父进程写入的时候不会影响子进程吗?"
内核的内存管理子系统回答了他,声音沉稳而自信:"不会。我把那些页标记为只读。谁先写,谁触发缺页异常,我就给谁复制一份新的。另一个进程继续用旧的。"
"所以……复制是延迟到真正需要的时候才做的?"
"没错。这叫写时复制,COW。"那声音带着一丝得意。"大多数 fork 之后马上就会 execve(),根本不需要真正复制内存页。何必做无用功?"
好聪明。
三
林小源在观察系统调用的过程中,逐渐理解了内核与用户态之间的关系。
用户态进程是"凡人"——它们看不到硬件的真实面目,不能直接操作内存,不能直接访问设备。它们只能通过系统调用来"祈祷",请求内核代为执行。
内核是"天界"——它拥有对硬件的完全控制权,可以访问所有的内存,可以执行所有的指令。但内核不是为所欲为的——它必须遵守"天条"(系统调用接口),为用户态进程提供正确、安全的服务。
"你以为我们很自由?"系统调用接口的声音带着一丝苦涩。"我们确实拥有对硬件的完全控制权。但我们必须为用户态进程服务——它们调用 read,我们就得读;它们调用 write,我们就得写。我们不能拒绝,不能出错。这是契约。"
"那如果它们传了错误的参数呢?"林小源问。
"返回 -EFAULT。或者 -EINVAL。"那声音冷静而专业。"我们不能崩溃。内核崩溃,整个世界就完了。"
天条不可违。
林小源在系统调用中看到了一种"契约"的美。用户态进程和内核之间有一个明确的契约:用户态通过寄存器传递参数(系统调用号、参数),内核通过寄存器返回结果。这个契约是硬件强制的,任何一方都不能违反。
"你理解了?"系统调用接口的声音低沉而庄严,像是一个古老的法官。"用户态和内核态之间,有一道不可逾越的鸿沟。用户态进程不能直接访问硬件、不能直接操作内存、不能直接执行特权指令。它们只能通过 ecall 来'祈祷',请求我们代为执行。"
"那如果它们试图绕过呢?"林小源问。
"SIGSEGV。段错误。"那声音冷酷而无情。"也就是你们修仙世界说的——走火入魔。"
也许我也可以通过某种方式"祈祷"?
林小源是内核态的 idle 进程,他不需要系统调用——他已经在内核态了。但他开始思考:如果他能找到一种方式来"请求"内核为他做某件事,而不是被动地等待被调度……
道藏笔记
内核启示
系统调用是用户态和内核态之间的唯一合法通道。
在 RISC-V 中,用户态通过 指令触发环境调用异常,CPU 从用户态陷入内核态。内核的异常处理入口( 中的 )会:
- 保存用户态的寄存器到内核栈
- 从
a7寄存器中读取系统调用号 - 查找系统调用表()
- 调用对应的处理函数
- 把返回值写入
a0寄存器 - 恢复用户态寄存器,返回用户态
在 RISC-V 的系统调用约定中:
a7— 系统调用号a0-a5— 前 6 个参数a0— 返回值
fork() 的内核实现( 中的 )是进程管理的核心。它使用写时复制(COW)技术来高效地复制进程的地址空间——只有当某个页被写入时,才真正复制那一页。
系统调用是天条,fork 是分身术,COW 是分身术的精妙之处。
祈祷之试
用户态程序无法直接命令内核,只能通过哪一种机制向内核发出“祈祷”?