Skip to content

系统调用与进程管理

系统调用机制

在前面的章节中,我提到过很多次"系统调用"这个词,比如在文件系统那章,我用openreadwrite来操作文件。但这些函数到底是怎么从用户态"跳"进内核态的?这一节我来详细讲讲。

为什么需要系统调用

操作系统的核心职责之一就是保护。用户程序运行在 Ring 3,内核运行在 Ring 0。如果用户程序能随便访问内核数据或者直接操作硬件,那整个系统的安全性就荡然无存了。

但用户程序确实需要内核提供的服务——比如读写文件、分配内存、创建进程。系统调用就是一道"受控的门":用户程序通过它请求内核服务,内核验证请求后执行操作,再把结果返回给用户。

int 0x80 中断机制

x86 架构提供了多种实现系统调用的方式:int 0x80 软中断是最经典的,sysenter/sysexit 是更快的现代方式。我选择 int 0x80,因为它概念清晰、实现简单,非常适合学习。

整体流程是这样的:

系统调用流程

当用户程序执行 int 0x80 指令时,CPU 会:

  1. 保存当前的 CS、EIP、EFLAGS、SS、ESP 到内核栈

  2. 从 IDT 表取出 0x80 号中断的处理函数地址

  3. 切换到 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 复制流程

实现步骤

我把 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;    // 对齐要求
};
ELF 加载过程

段加载实现

对于每个 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 进程收养。这一节讲 waitexit 的实现。

进程生命周期

进程生命周期

一个进程从 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 中执行。

程序构建流程概述

用户程序的生命周期分为两个阶段:

  1. 构建阶段(宿主机):用 CMake 编译链接生成 ELF 可执行文件,然后用自定义目标写入 hd64M.img 磁盘镜像的特定扇区

  2. 运行阶段(内核):内核启动时,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 函数作为程序入口,跳过了通常的 _startmain 调用链。

写入磁盘镜像

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 函数做了这些事:

  1. 从磁盘扇区 1000 读取 100 个扇区到内存

  2. 解析 ELF 头,计算实际文件大小

  3. 在文件系统中创建 /bin/a

  4. 把 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 与参数传递

这个程序使用了 argcargv,这就需要 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。

这个演示完整验证了 forkexecwait 以及参数传递的正确性。

文件查看工具: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 中执行

cat 命令读取文件演示

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

Shell 内置命令

最后展示 Shell 的基础导航功能:

Shell 内置命令演示

如图 1.9 所示,我们演示了 lsmkdircdpwdrmdirps 等命令。

  • ls 列出了当前目录下的文件和目录(包括刚刚创建的 readme.txt)。

  • mkdir 创建了目录,cd 成功切换了工作路径(命令提示符也随之改变)。

  • pwd 确认了当前路径。

  • ps 列出了当前的进程状态。

  • 最后 rmdir 成功删除了空目录。

至此,我的操作系统不仅有了一个小但完整的内核,而且拥有了一个简易的用户态生态:能编译标准 C++ 程序,能通过 Shell 交互,能管理文件和进程。一个微型但五脏俱全的 OS 诞生了。