Appearance
系统调用与进程管理
系统调用机制
在前面的章节中,我提到过很多次"系统调用"这个词,比如在文件系统那章,我用open、read、write来操作文件。但这些函数到底是怎么从用户态"跳"进内核态的?这一节我来详细讲讲。
为什么需要系统调用
操作系统的核心职责之一就是保护。用户程序运行在 Ring 3,内核运行在 Ring 0。如果用户程序能随便访问内核数据或者直接操作硬件,那整个系统的安全性就荡然无存了。
但用户程序确实需要内核提供的服务——比如读写文件、分配内存、创建进程。系统调用就是一道"受控的门":用户程序通过它请求内核服务,内核验证请求后执行操作,再把结果返回给用户。
int 0x80 中断机制
x86 架构提供了多种实现系统调用的方式:int 0x80 软中断是最经典的,sysenter/sysexit 是更快的现代方式。我选择 int 0x80,因为它概念清晰、实现简单,非常适合学习。
整体流程是这样的:

当用户程序执行 int 0x80 指令时,CPU 会:
保存当前的 CS、EIP、EFLAGS、SS、ESP 到内核栈
从 IDT 表取出 0x80 号中断的处理函数地址
切换到 Ring 0,跳转到中断处理函数
参数传递约定
我采用寄存器传参的方式,这比压栈更快:
EAX:系统调用号
EBX:第一个参数
ECX:第二个参数
EDX:第三个参数
返回值也通过 EAX 返回。这套约定和 Linux 早期的系统调用是一致的。
用户态封装
在 syscall.cpp 中,我用 C++ 模板封装了系统调用的内联汇编:
c++
template<typename... Args>
requires (sizeof... (Args) <= 3)
auto syscall(int num, Args... args) -> int
{
int ret;
if constexpr (sizeof...(Args) == 0) {
asm volatile (
"int $0x80"
: "=a"(ret)
: "a"(num)
: "memory"
);
} else if constexpr (sizeof...(Args) == 1) {
asm volatile (
"int $0x80"
: "=a"(ret)
: "a"(num), "b"(args...[0])
: "memory"
);
}
// ... 2个和3个参数的情况类似
return ret;
}这样,上层调用就很简洁了:
c++
auto getpid() -> u32 { return syscall(+sysid::getpid); }
auto fork() -> pid_t { return syscall(+sysid::fork); }
auto read(i32 fd, void* buf, u32 count) -> i32 {
return syscall(+sysid::read, fd, buf, count);
}系统调用表
内核这边,我维护了一个函数指针数组 syscall_table,下标就是系统调用号:
c++
auto constexpr syscall_nr = 32;
using sysfunc = void*;
extern "C" sysfunc syscall_table[syscall_nr]{};
auto syscall_init() {
#define install(X) syscall_table[+sysid::X] = (sysfunc)X
install(getpid);
install(write);
install(fork);
install(read);
install(exec);
install(wait);
install(exit);
// ... 共24个系统调用
}
中断处理程序
interrupt.asm 中的 syscall_handler 是系统调用的入口:
text
syscall_handler:
push 0 ; 占位(与普通中断格式统一)
push ds
push es
push fs
push gs
pushad ; 保存所有通用寄存器
push 0x80 ; 中断号(调试用)
push edx ; 第三个参数
push ecx ; 第二个参数
push ebx ; 第一个参数
call [syscall_table + eax * 4] ; 调用对应的内核函数
add esp, 12 ; 清理参数
mov [esp + 8 * 4], eax ; 把返回值写入栈中EAX的位置
jmp intr_exit ; 从中断返回关键点在最后两行:内核函数的返回值在 EAX 中,我把它写入栈里保存 EAX 的位置,这样 popad 恢复寄存器时,用户态就能拿到返回值了。
完整的系统调用号
目前我的系统支持 24 个系统调用:
c++
enum struct sysid : int {
getpid, // 0: 获取进程ID
write, // 1: 写文件
malloc, // 2: 分配内存
free, // 3: 释放内存
fork, // 4: 创建子进程
read, // 5: 读文件
clear, // 6: 清屏
putchar, // 7: 输出字符
getcwd, // 8: 获取当前目录
open, // 9: 打开文件
close, // 10: 关闭文件
lseek, // 11: 移动文件指针
unlink, // 12: 删除文件
mkdir, // 13: 创建目录
opendir, // 14: 打开目录
closedir, // 15: 关闭目录
chdir, // 16: 切换目录
rmdir, // 17: 删除目录
readdir, // 18: 读取目录项
rewinddir, // 19: 重置目录指针
stat, // 20: 获取文件状态
ps, // 21: 打印进程列表
exec, // 22: 执行程序
wait, // 23: 等待子进程
exit // 24: 退出进程
};这些系统调用覆盖了进程管理、内存管理、文件系统三大类功能。
fork 进程复制
fork 是 Unix 系统中最经典的系统调用之一。它创建一个与父进程几乎完全相同的子进程,父子进程从 fork 返回的位置继续执行,唯一的区别是返回值不同——父进程拿到子进程的 PID,子进程拿到 0。
fork 的设计思路
fork 需要复制的东西相当多:
PCB:进程控制块,包含进程状态、优先级、文件描述符表等
虚拟地址位图:记录哪些虚拟地址已被使用
页表:虚拟地址到物理地址的映射
用户空间:代码段、数据段、堆、栈的实际内容

实现步骤
我把 fork 拆成了几个子函数,每个负责复制一部分资源。
复制 PCB 和虚拟地址位图
c++
auto copy_pcb_vaddr_bitmap_stack0(task* child, task* parent) -> void
{
// 复制整个页(包含PCB和内核栈)
memcpy(child, parent, PG_SIZE);
// 单独修改子进程的属性
child->pid = pid_pool.allocate();
child->elapsed_ticks = 0;
child->stu = thread_status::ready;
child->ticks = child->priority;
child->parent_pid = parent->pid;
// 清空链表指针
child->general_tag.prev = child->general_tag.next = nullptr;
child->all_list_tag.prev = child->all_list_tag.next = nullptr;
// 复制虚拟地址位图
auto bitmap_pg_cnt = div_ceil((0xc0000000 - USER_VADDR_START) / PG_SIZE / 8, PG_SIZE);
auto vaddr_bitmap = get_kernel_pages(bitmap_pg_cnt);
memcpy(vaddr_bitmap, child->userprog_vaddr.bits, bitmap_pg_cnt * PG_SIZE);
child->userprog_vaddr.bits = (u8*)vaddr_bitmap;
}这里有个细节:直接 memcpy 整个页后,子进程的 userprog_vaddr.bits 还指向父进程的位图。所以我额外分配了新的内核页来存放子进程自己的位图。
复制用户空间
用户空间的复制需要借助内核缓冲区作为中转:
c++
auto copy_body_stack3(task* child, task* parent, void* buf_page) -> void
{
auto vaddr_bitmap = (u8*)(parent->userprog_vaddr.bits);
auto bitmap_bytes_len = parent->userprog_vaddr.sz;
auto vaddr_start = parent->userprog_vaddr.vaddr_start;
for (auto idx_byte : std::iota[bitmap_bytes_len]) {
if (not vaddr_bitmap[idx_byte]) continue;
for (auto idx_bit : std::iota[8]) {
if (not (1 << idx_bit & vaddr_bitmap[idx_byte])) continue;
auto prog_vaddr = (idx_byte * 8 + idx_bit) * PG_SIZE + vaddr_start;
// 1. 父进程数据 -> 内核缓冲区
memcpy(buf_page, (void*)prog_vaddr, PG_SIZE);
// 2. 切换到子进程页表
page_dir_active(child);
// 3. 为子进程分配物理页
get_a_page_without_op_vaddr_bitmap(pool_flags::USER, prog_vaddr);
// 4. 内核缓冲区 -> 子进程用户空间
memcpy((void*)prog_vaddr, buf_page, PG_SIZE);
// 5. 切回父进程页表
page_dir_active(parent);
}
}
}为什么需要内核缓冲区?因为切换页表后,父进程的用户空间就访问不到了。所以必须先把数据拷到内核空间(内核空间在所有进程中都映射到相同位置),再切换页表,最后从内核空间拷到子进程。
构建子进程栈
子进程调度运行时,需要从 switch_to 返回。我构建了一个假的栈帧,让它"返回"到 intr_exit,直接恢复中断现场:
c++
auto build_child_stack(task* child) -> void
{
// 获取中断栈位置
auto intr_0_stack = (intr_stack*)((u32)child + PG_SIZE - sizeof(intr_stack));
// 子进程的返回值是 0
intr_0_stack->eax = 0;
// 构建 switch_to 需要的栈帧
auto ebp_ptr = (u32*)intr_0_stack - 5;
auto ret_addr = (u32*)intr_0_stack - 1;
*ret_addr = (u32)intr_exit; // 返回地址指向 intr_exit
child->self_kstack = ebp_ptr;
}fork 主函数
把上面的步骤串起来:
c++
auto fork() -> pid_t
{
auto parent = running_thread();
auto child = (task*)get_kernel_pages(1);
if (not child) return -1;
ASSERT(intr_get_status() == intr_status::INTR_OFF);
ASSERT(parent->pgdir); // 内核线程不能 fork
if (not copy_process(child, parent)) return -1;
// 加入就绪队列
thread_ready_list.push_back(&child->general_tag);
thread_all_list.push_back(&child->all_list_tag);
return child->pid; // 父进程返回子进程 PID
}我的实现没有写时复制(Copy-on-Write)优化。真正的 Linux 内核会延迟复制物理页,只在父子进程其中一方修改数据时才触发复制。这能显著减少 fork 的开销,但实现复杂度也高得多。
exec 程序加载
exec 系统调用用于加载并执行一个新程序,它会用新程序的代码和数据替换当前进程的内容。通常的用法是 fork 之后在子进程里调用 exec。
ELF 文件格式
Linux 下的可执行文件通常是 ELF(Executable and Linkable Format)格式。我只需要支持最简单的 32 位静态链接 ELF 文件。
ELF 头
c++
namespace elf32 {
struct ehdr {
char ident[16]; // 魔数 "\177ELF" 等标识
half type; // 文件类型(2=可执行文件)
half machine; // 目标机器(3=i386)
word version; // ELF版本
addr entry; // 程序入口地址
off phoff; // 程序头表偏移
off shoff; // 节头表偏移
word flags;
half ehsize; // ELF头大小
half phentsize; // 程序头表项大小
half phnum; // 程序头表项数量
// ...
};
}程序头
程序头描述了需要加载到内存的段:
c++
struct phdr {
word type; // 段类型(1=LOAD,需要加载)
off offset; // 段在文件中的偏移
addr vaddr; // 段的虚拟地址
addr paddr; // 物理地址(通常忽略)
word filesz; // 段在文件中的大小
word memsz; // 段在内存中的大小(可能比filesz大,多出的部分是BSS)
word flags; // 权限标志
word align; // 对齐要求
};
段加载实现
对于每个 LOAD 类型的段,我需要把它从文件读入对应的虚拟地址:
c++
auto segment_load(i32 fd, u32 offset, u32 filesz, u32 memsz, u32 vaddr) -> bool
{
auto vaddr_first_page = vaddr & 0xfffff000;
auto size_in_first_page = PG_SIZE - (vaddr & 0x00000fff);
// 计算需要的页数(根据 memsz)
auto occupy_pages = 0u;
if (memsz > size_in_first_page) {
occupy_pages = div_ceil(memsz - size_in_first_page, PG_SIZE) + 1;
} else {
occupy_pages = 1;
}
// 为进程分配内存页
auto vaddr_page = vaddr_first_page;
for (auto i : std::iota[occupy_pages]) {
auto pde = pgtable::pde_ptr(vaddr_page);
auto pte = pgtable::pte_ptr(vaddr_page);
if (not (*pde & 1) or not (*pte & 1)) {
if (not get_a_page(pool_flags::USER, vaddr_page)) {
return false;
}
}
vaddr_page += PG_SIZE;
}
// 读取文件内容
if (filesz > 0) {
lseek(fd, offset, whence::set);
if (read(fd, (void*)vaddr, filesz) != (i32)filesz) {
return false;
}
}
// 清零 BSS 段
if (memsz > filesz) {
memset((void*)(vaddr + filesz), 0, memsz - filesz);
}
return true;
}加载器主函数
c++
auto load(char const* pathname) -> i32
{
auto fd = open(pathname, +open_flags::read);
if (fd == -1) return 0;
// 读取并校验 ELF 头
auto elf_header = elf32::ehdr{};
read(fd, &elf_header, sizeof elf_header);
if (memcmp(elf_header.ident, "\177ELF\1\1\1", 7)
or elf_header.type != 2 // 必须是可执行文件
or elf_header.machine != 3) { // 必须是 i386
close(fd);
return 0;
}
// 遍历所有程序头,加载 LOAD 段
for (auto i : std::iota[elf_header.phnum]) {
auto prog_header = elf32::phdr{};
lseek(fd, elf_header.phoff + i * elf_header.phentsize, whence::set);
read(fd, &prog_header, sizeof prog_header);
if (prog_header.type == +elf32::segment::load) {
segment_load(fd, prog_header.offset,
prog_header.filesz, prog_header.memsz,
prog_header.vaddr);
}
}
close(fd);
return elf_header.entry; // 返回入口地址
}exec 系统调用
c++
auto exec(char const* path, char* argv[]) -> i32
{
auto argc = 0u;
while (argv[argc]) ++argc;
auto entry_point = load(path);
if (not entry_point) return false;
auto cur = running_thread();
memcpy(cur->name, path, sizeof(task{}.name));
// 修改中断栈,设置新的入口和栈
auto intr_0_stack = (intr_stack*)((u32)cur + PG_SIZE - sizeof(intr_stack));
intr_0_stack->ebx = (i32)argv;
intr_0_stack->ecx = argc;
intr_0_stack->eip = (void(*)())entry_point;
intr_0_stack->esp = (void*)0xc0000000; // 新栈顶
// 直接从中断返回,开始执行新程序
asm volatile (
"movl %0, %%esp;"
"jmp intr_exit;"
:
: "g"(intr_0_stack)
: "memory"
);
return true; // 实际上不会执行到这里
}exec 的巧妙之处在于:它修改了中断栈中的 EIP 和 ESP,然后跳转到 intr_exit。当 iret 执行时,CPU 会恢复这些寄存器,于是程序就"返回"到了新程序的入口点,栈也指向了新的位置。整个过程没有真正返回,而是"借尸还魂"。
wait/exit 进程同步
进程不可能永远运行。当子进程结束时,父进程需要回收它的资源;如果父进程先退出,子进程则变成孤儿,需要被 init 进程收养。这一节讲 wait 和 exit 的实现。
进程生命周期

一个进程从 fork 创建开始,可能经历 exec 替换程序,最终通过 exit 退出。退出后进程变成僵尸状态(hanging),等待父进程调用 wait 回收。
exit:进程退出
exit 的职责是释放进程占用的资源,并通知父进程:
c++
auto exit(i32 status) -> void
{
auto child = running_thread();
// 释放用户空间资源
release_program_resource(child);
// 将所有子进程过继给 init
for (auto& nd : thread_all_list) {
init_adopt_a_child(&nd, child->pid);
}
// 如果有子进程已经是僵尸,唤醒 init 来回收
// ...
child->exit_status = status;
child->stu = thread_status::hanging; // 变成僵尸
// 如果父进程在等待,唤醒它
auto parent = find_task_by_pid(child->parent_pid);
if (parent->stu == thread_status::waiting) {
thread_unblock(parent);
}
schedule(); // 切换到其他进程,永不返回
}资源释放
release\_program\_resource 负责释放用户进程的三类资源:
c++
auto release_program_resource(task* thread) -> void
{
// 1. 回收页表中用户空间的页框
for (auto pde_i : std::iota[768]) { // 前768个PDE是用户空间
auto v_pde_ptr = thread->pgdir + pde_i;
if (pgtable::contains(v_pde_ptr)) {
for (auto pte_i : std::iota[1024]) {
auto v_pte_ptr = first_pte_vaddr(pde_i) + pte_i;
if (pgtable::contains(v_pte_ptr)) {
free_a_phy_page(*v_pte_ptr & 0xfffff000);
}
}
free_a_phy_page(*v_pde_ptr & 0xfffff000);
}
}
// 2. 回收虚拟地址池位图占用的内核页
mfree_page(pool_flags::KERNEL, thread->userprog_vaddr.bits,
thread->userprog_vaddr.sz / PG_SIZE);
// 3. 关闭打开的文件
for (auto fdi : std::iota[3, MAX_FILES_OPEN_PER_PROC]) {
if (thread->fd_table[fdi] != -1) {
close(fdi);
}
}
}wait:等待子进程
wait 阻塞父进程直到有子进程退出:
c++
auto wait(i32& status) -> pid_t
{
auto parent = running_thread();
while (true) {
// 查找已退出的子进程
auto child_node = thread_all_list.find([&](list_node* nd) {
return find_hanging_child(nd, parent->pid);
});
if (child_node) {
auto child = find_task_by_all(child_node);
status = child->exit_status;
auto child_pid = child->pid;
// 回收子进程的PCB页
mfree_page(pool_flags::KERNEL, child, 1);
thread_all_list.erase(child_node);
pid_pool.release(child_pid);
return child_pid;
}
// 没有僵尸子进程,检查是否有活着的子进程
auto has_child = thread_all_list.find([&](list_node* nd) {
return find_child(nd, parent->pid);
});
if (not has_child) {
return -1; // 没有子进程
}
// 有子进程但都还活着,阻塞等待
parent->stu = thread_status::waiting;
schedule();
}
}孤儿进程处理
如果父进程先于子进程退出,子进程会成为孤儿。我的处理方式是把它们过继给 init 进程(PID 为 1):
c++
auto init_adopt_a_child(list_node* pnd, i32 pid) -> void
{
auto thread = find_task_by_all(pnd);
if (thread->parent_pid == pid) {
thread->parent_pid = 1; // 过继给 init
}
}僵尸进程(zombie)是指已经退出但还没被父进程回收的进程。如果父进程不调用 wait,僵尸会一直占用 PID 和少量内存。在真实系统中,这是一种资源泄漏。
最后的演示
到这里,我的操作系统已经具备了完整的进程管理和系统调用机制。现在是时候把所有东西串起来,展示一个真正能运行用户程序的操作系统了。
这一节我会演示三类用户程序的完整流程:从编译链接、写入磁盘镜像、到在 Shell 中执行。
程序构建流程概述
用户程序的生命周期分为两个阶段:
构建阶段(宿主机):用 CMake 编译链接生成 ELF 可执行文件,然后用自定义目标写入
hd64M.img磁盘镜像的特定扇区运行阶段(内核):内核启动时,
write_execution()从磁盘扇区读取程序,写入文件系统的/bin/目录;用户通过 Shell 执行这些程序
所有用户程序的源码都在 kernel/module/program/ 目录下。
无参数程序:program_no_arg
这是最简单的用户程序,它不接收命令行参数。
源码
c++
// program_no_arg.cpp
import std;
auto main() -> int
{
std::println("Hello World from user program!");
std::putchar('O');
std::putchar('K');
std::putchar('\n');
while(true) {}
}注意这里的 main() 没有参数。这是因为我没有使用 CRT(C Runtime),直接把 main 作为入口点。
CMake 链接配置
cmake
add_executable(program_no_arg
program_no_arg.cpp
)
target_link_options(program_no_arg PRIVATE
-e main # 入口点直接是 main
)
target_link_libraries(program_no_arg PRIVATE stdc++)关键在于 -e main:这告诉链接器把 main 函数作为程序入口,跳过了通常的 _start → main 调用链。
写入磁盘镜像
CMake 的自定义目标把编译好的 ELF 文件写入磁盘镜像:
cmake
# 写入到扇区1000,预留100个扇区(50KB)
add_disk_target(write_program_no_arg
${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/program_no_arg
1000
100
)内核侧:从磁盘读取并写入文件系统
内核启动时,wexe.cpp 中的 write_execution() 会被调用:
c++
// 写入应用程序 std_print (program_no_arg)
auto std_print() -> void
{
write_program_to_fs(1000, 100, "/bin/a");
}write_program_to_fs 函数做了这些事:
从磁盘扇区 1000 读取 100 个扇区到内存
解析 ELF 头,计算实际文件大小
在文件系统中创建
/bin/a把 ELF 镜像写入文件
Shell 中执行
启动操作系统后,在 Shell 中:

如图 1.6 所示,我们进入 /bin 目录并执行 ./a。 程序成功输出了 "Hello World from user program!" 和 "OK"。这是用户态程序通过 write 系统调用向控制台输出的结果,验证了系统调用链路的畅通。 由于程序最后是一个 while(true) 死循环,所以 Shell 没有返回提示符,系统停留在用户程序执行中。
带参数程序:program_arg
这个程序展示了更高级的功能:解析命令行参数、调用 CRT、fork/exec/wait 以及使用 shell 内置命令无法完成的功能(如调用 ps)。
源码
c++
// program_arg.cpp
import std.io;
import std.os; // 包含 fork, exec, ps, getpid 等封装
auto main(int const argc, char* argv[]) -> int
{
// 1. 打印参数
for(auto i = 0; i != argc; ++i) {
std::println("argv[{}] is {}", i, argv[i]);
}
// 2. 演示 fork + exec
auto const pid = std::fork();
if (pid) {
// 父进程
std::println("I am father, my pid is {}. Showing process list:", std::getpid());
std::ps(); // 调用 ps 系统调用
} else {
// 子进程
std::println("I am child, my pid is {}. Executing {}...", std::getpid(), argv[1]);
std::exec(argv[1], argv);
}
return 0; // 返回值传给 exit
}CRT 与参数传递
这个程序使用了 argc 和 argv,这就需要 C Runtime (CRT) 的支持。
start.asm (CRT 入口):
text
[bits 32]
extern main
extern exit
global _start
_start:
; 此时栈顶是返回地址(无用),参数在 exec 构建的栈中
; 但我的 exec 实现稍微 hack 了一下,把参数直接压在栈上了
; 或者通过寄存器传递,这里假设通过 stack 传递
; 配合 Shell 的 exec 实现:
; Shell 把 argc 和 argv 放在了 ecx 和 ebx 寄存器中
; (参见 kernel/module/user/exec.cpp)
push ebx ; 压入 argv
push ecx ; 压入 argc
call main ; main(argc, argv)
push eax ; main 的返回值
call exit ; exit(retval)CMake 配置
cmake
add_executable(program_arg
${CMAKE_SOURCE_DIR}/kernel/crt/start.asm # 链接 start.asm
program_arg.cpp
)
target_link_options(program_arg PRIVATE -e _start) # 入口点是 _start写入扇区 1100,内核侧对应 prog_arg() 函数,写入路径 /bin/eb。
Shell 中执行

如图 1.7 所示,我们执行 ./eb /bin/a。 输出结果包含丰富的信息:
参数传递:
argv[0]和argv[1]被正确打印,证明 CRT 的栈参数传递和内核 exec 的参数压栈逻辑正确。进程树与 ps:父进程(PID 5)调用
ps系统调用,清晰展示了当前的进程状态。我们可以看到 Shell(2) 是 eb(5) 的父进程。fork 与 exec:子进程(PID 6)成功执行了
exec,加载并运行了/bin/a,输出了 Hello World。
这个演示完整验证了 fork、exec、wait 以及参数传递的正确性。
文件查看工具:cat
cat 是标准的 Unix 工具,通过我的 C++ 标准库封装实现非常简洁。
源码
c++
// cat.cpp
import std.io; // open, read, write, close
import std.os; // getcwd
import std.container; // buffer, vector
auto main(int argc, char* argv[]) -> int
{
if (argc < 2) {
std::println("cat: only support 1 argument.");
std::println("eg: cat filename");
std::exit(-2);
}
// 处理路径(支持相对路径)
std::array<char,512> abs_path{};
std::string_view filename{ argv[1] };
std::buffer a{ abs_path.data() };
if(filename.front() != '/') {
std::getcwd(abs_path.data(), abs_path.size());
a += '/';
a += filename;
} else {
a += filename;
}
// 打开文件(只读模式)
auto fd = std::open(abs_path.data(), std::open_flags::read);
if (fd == -1) {
std::println("cat: open {} failed!", filename);
return -1;
}
// 读取并输出到标准输出(stdout=1)
std::vector buf(1024, char{});
while (true) {
auto n = std::read(fd, buf.data(), buf.size());
if (n <= 0) break;
std::write(1, buf.data(), n);
}
std::close(fd);
return 66; // 自定义返回码
}演示准备
为了演示 cat,我在内核启动时预先创建了一个文本文件 /readme.txt:
c++
// kernel/main.cpp
auto create_readme() -> void {
unlink("/readme.txt");
auto fd = open("/readme.txt", +open_flags::create | +open_flags::write);
if(fd != -1) {
auto constexpr static content =
"Who knows how many git commits were pushed at 4 AM...\n"
"Countless page faults... Stack overflow? Memset reversed? \n"
" Link error?\n"
"Facing this vast repo, debugging often felt like a losing \n"
" battle.\n"
"Only those who've walked this path understand the hardship:\n"
" Tracing the execution flow from zero, with a trembling \n"
" heart.\n\n"
"Anyway, I persisted to this moment.\n"
" Even though some mysteries still linger in this OS.\n"
"But now, it is time to say goodbye.\n"sv;
write(fd, content.data(), content.size());
close(fd);
console::println("Created /readme.txt for demo.");
}
}Shell 中执行

如图 1.8 所示,我们使用 cat readme.txt 命令成功读取了内核预先创建的文件readme.txt。 这验证了文件系统系统调用的完整性:从 open 打开文件,到 read 读取内容,再到 write 输出到标准输出。
Shell 内置命令
最后展示 Shell 的基础导航功能:

如图 1.9 所示,我们演示了 ls、mkdir、cd、pwd、rmdir、ps 等命令。
ls列出了当前目录下的文件和目录(包括刚刚创建的readme.txt)。mkdir创建了目录,cd成功切换了工作路径(命令提示符也随之改变)。pwd确认了当前路径。ps列出了当前的进程状态。最后
rmdir成功删除了空目录。
至此,我的操作系统不仅有了一个小但完整的内核,而且拥有了一个简易的用户态生态:能编译标准 C++ 程序,能通过 Shell 交互,能管理文件和进程。一个微型但五脏俱全的 OS 诞生了。