开篇

  1. 为什么要学习 MIT 6.S081 ?
    MIT 6.S081 通过 xv6 这个极简的操作系统内核,能让我们动手去写代码,看现象,从而真正理解操作系统底层的运行逻辑。
  2. 文本定位
    这是 util 实验的前置篇,通过5个极简的用户态C程序 ,从最基本的文件IO到进程的创建与替换,一步步地搞懂 系统调用、Unix IO、fork、exec 这些操作系统最基本的概念。
  3. 实验环境
    Ubuntu 20.04

一切皆文件 —— 从 IO 系统调用建立基础认知

我从 copy.copen.c 中建立了对 文件标识符(file descriptor)、一切皆文件 的核心认知,这为后面理解 文件重定向、进程的创建与替换 等打下了很好的基础

copy.c 看最朴素的 IO:读与写

// copy.c: copy input to output.

#include "kernel/types.h"
#include "user/user.h"

int main()
{
    char buf[64];

    while (1)
    {
        int n = read(0, buf, sizeof(buf));
        if (n <= 0)
            break;
        write(1, buf, n);
    }
    exit(0);
}

read 和 write 系统调用:

readwrite 系统调用以字节为单位读取或写入已经打开的以文件描述符命名的文件

  • read(fd, buf, n): 从文件描述符 fd 读取最多 n 字节,将其复制到 buf ,并返回读取的字节数。每个文件描述符都有一个与之关联的偏移量,read 从当前文件偏移量开始读取数据,然后将偏移量移进所读取的字节数:后续读取将返回前面读取成功的字节之后的字节,当没有更多的字节可读时,read 将返回 0 表示文件的结束。
  • write(fd,buf,n): 将 buf 中的 n 字节写入文件描述符,并返回写入的字节数。只有发生错误时才会写入小于 n 字节的数据。与读一样,write 在当前文件偏移量处写入数据,然后将该偏移量向前推进写入的字节数:每个 write 从上一个偏移量停止的地方开始写入。

文件描述符 (file descriptor) :

文件描述符是一个小整数(small integer),表示进程可以读取或写入的由内核管理的对象。
进程可以通过打开一个文件、目录、设备,或创建一个管道,或复制一个已存在的描述符来获得一个文件描述符。
为了简单起见,我们通常将文件描述符所指的对象称为“文件”;文件描述符接口将文件、管道和设备之间的差异抽象出来,使它们看起来都像字节流。
我们将输入和输出称为 I/O。

  • 在 xv6 内核中,文件描述符本质是单个进程内部打开文件表(ofile 数组)的下标索引;每个进程都拥有一份独立的打开文件表,因此文件描述符是进程私有资源,从 0 开始编号,且仅在当前进程的上下文内有效。
  • 按照惯例,进程从文件描述符0读取(标准输入),将输出写入文件描述符1(标准输出),并将错误消息写入文件描述符2(标准错误)。
  • shell 利用这个约定来实现I/O重定向和管道。shell 确保它始终有三个打开的文件描述符,这是控制台的默认文件描述符。

运行效果:

$ copy
hello                    # 键盘输入
hello                    # 控制台输出
Hope is a good thing.    # 键盘输入
Hope is a good thing.    # 控制台输出
$ copy > out.txt
hello                    # 键盘输入
Hope is a good thing.    # 键盘输入
                         # 键盘输入
$ cat out.txt
hello
Hope is a good thing.

$ copy < out.txt
hello                   # 控制台输出
Hope is a good thing.   # 控制台输出
						# 控制台输出

灵魂提问:

为什么我们代码里写死了 0 和 1,却能实现从文件读、写到文件?

open.c 看普通文件的打开与操作

// open.c: create a file, write to it.

#include "kernel/types.h"
#include "user/user.h"
#include "kernel/fcntl.h"

int main()
{
    int fd = open("output.txt", O_WRONLY | O_CREATE);
    write(fd, "ooo\n", 4);

    exit(0);
}

open 系统调用:

open 是文件系统的入口级系统调用:它把一个人类可读的文件路径字符串,转换成一个文件描述符(整数 fd)。
后续所有对文件的操作(读、写、关闭、查属性)都通过这个 fd 进行,内核不再需要重复解析路径。

  • 在当前进程的 ofile 数组中找到最小空闲槽位,存入 file 对象指针,将数组下标作为 fd 返回给用户态。
  • fd 是进程内部 ofile 数组的下标,仅在当前进程内有效,各进程编号独立、互不干扰。
  • 中间层 struct file 承载读写偏移,fork/dup 时通过共享该对象实现文件偏移同步,同时隔离 inode 本体。
  • 目录可通过只读方式打开,对其执行 read 将读取到 struct dirent 目录项结构。

open 的第二个参数由一组标志组成,这些标志以位表示,用于控制打开的操作。可能的值定义在文件控制(fcntl)头文件(kernel/fcntl.h)中

宏定义 功能
O_RDONLY 只读
O_WRONLY 只写
O_RDWR 可读可写
O_CREATE 如果文件不存在,自动创建文件
O_TRUNC 将文件截断为零长度

运行效果:

$ open
$ cat output.txt
ooo              # 写入成功

达成共识:

终端、普通文件、管道、设备…… 在 Unix 里都被抽象成「文件」,统一通过文件描述符操作,用同一套 read/write 接口访问。这是 Unix 最经典的设计思想之一。

小结:

文件描述符本质是进程内部的「索引表」,指向内核维护的文件对象。程序只和抽象的 fd 打交道,不用关心底层到底是什么硬件 / 资源。


一次调用,两次返回 —— 理解 fork 与进程的诞生

我从 fork.c 中理解了进程的创建

最反直觉的系统调用:fork

// fork.c: create a new process

#include "kernel/types.h"
#include "user/user.h"

int main()
{
    int pid;

    pid = fork();

    printf("fork() returned %d\n", pid); 

    if (pid == 0) {
        printf("child\n");
    } else {
        printf("parent\n");
    }

    exit(0);
}

代码执行逻辑

  1. 调用 fork() 触发系统调用,内核会完整复制当前进程的地址空间与运行上下文,生成一个完全独立的子进程。
  2. fork 会在父子两个进程中分别返回,这是区分二者身份的唯一依据:
    父进程中返回子进程的 PID(非 0 正整数);
    子进程中固定返回 0。
  3. 通过 pid == 0 的条件判断,父子进程进入不同的执行分支,分别打印自身的身份标识。
  4. 最终两个进程各自调用 exit(0) 正常退出。

子进程不是从头重新执行 main 函数,而是从 fork 调用的下一行开始执行 —— 因为内核复制了程序计数器 PC。
尽管fork复制了文件描述符表,但是每个基础文件偏移量在父文件和子文件之间是共享的

从「输出乱码」看并发的本质

$ fork
forfk() orretk(u)rn ered t4urne
d par0e
cnhtil
d
  • xv6 的 printf 是逐字符调用 write,每次系统调用返回时都可能触发进程调度
  • 父子进程共享控制台输出,交替执行时就会出现字符穿插

小结

fork 的本质是「复制出一个独立的执行流」。每个进程有独立的地址空间、独立的执行状态,操作系统通过调度让它们并发运行。


替换灵魂 —— exec 与 Unix 经典的进程编程范式

为什么 exec 成功不返回?
为什么 fork 和 exec 要分开设计?
Shell 的工作原理是什么?

exec:不是调用函数,是替换整个进程

// exec.c: replace a process with an executable file

#include "kernel/types.h"
#include "user/user.h"

int main()
{
    char* argv[] = {"echo", "this", "is", "echo", 0};

    exec("echo", argv);

    printf("exec failed!\n");

    exit(0);
}

运行效果

$ exec    
this is echo

代码执行逻辑

  1. 构造参数数组 argv:遵循 Unix 系统调用约定,第一个元素是程序自身名称,后续是命令行参数,末尾必须以空指针(0)结尾,作为参数结束标记。
  2. 调用 exec(“echo”, argv):内核加载文件系统中的 echo 可执行文件,将当前进程的代码段、数据段、栈全部替换为新程序的内容,随后 CPU 直接从 echo 程序的入口开始执行。
  3. 核心特性:exec 调用成功时永远不会返回。只有调用失败(比如可执行文件不存在、权限不足)时,才会继续执行后续代码,打印失败提示并退出。

为什么失败才返回?
因为成功时,原进程的代码、数据、栈都被覆盖了,exec 后面的指令在内存里已经不存在了,自然不可能回来执行。

黄金组合:fork + exec + wait

#include "kernel/types.h"
#include "user/user.h"

// forkexec.c: fork then exec

int main()
{
    int pid, status;

    pid = fork();
    if (pid == 0) {
        char* argv[] = {"echo", "THIS", "IS", "ECHO", 0};
        exec("echo", argv);
        printf("exec failed!\n");
        exit(1);
    } else {
        printf("parent waiting\n");
        wait(&status);
        printf("the child exited with status %d\n", status);
    }

    exit(0);
}

运行效果

$ forkexec
parent waiting
THIS IS ECHO
the child exited with status 0
$ forkexec
pTHIS IS ECHO
arent waiting
the child exited with status 0

代码执行逻辑

  • fork 创建子进程,父子分流
  • 子进程分支:构造参数,调用 exec 替换成 echo 程序;失败则打印错误退出
  • 父进程分支:打印提示,调用 wait 阻塞等待

wait 的三个作用

  1. 阻塞等待:父进程暂停,直到子进程退出
  2. 获取退出状态:通过指针参数拿到子进程 exit 的状态码
  3. 回收资源:避免子进程变成「僵尸进程」,泄漏内核资源

灵魂拷问:为什么要分开设计 fork 和 exec

在 fork 和 exec 之间,子进程可以做任意定制化修改,再运行新程序

  • 替换文件描述符:实现输入输出重定向、管道
  • 关闭不需要的文件
  • 修改当前工作目录
  • 调整进程权限、设置环境变量

Shell 到底是怎么运行命令的?
我们每天在命令行输入 ls -l、echo hello,底层其实就是 Shell 在无限循环执行这套流程:

  1. 打印提示符,解析用户输入的命令和参数
  2. fork 创建子进程
  3. 子进程处理重定向、管道等逻辑
  4. 子进程调用 exec 加载运行目标程序
  5. 父进程(Shell)调用 wait 等待子进程结束
  6. 回到第一步,等待下一条命令

总结

1. 系统调用的本质

它是用户态程序进入内核态的唯一受控入口。我们写的 read、write、fork、exec,表面上是 C 函数,底层都会触发 ecall 陷入内核,由内核完成真正的操作,再返回结果。

2. 进程的抽象

操作系统给每个进程编织了一个「独享内存、独享 CPU」的美梦。fork 就是创造一个新的独立梦境,exec 就是把这个梦境里的内容全部换掉。

3. Unix 设计思想的体现

一切皆文件:用统一的接口管理所有 IO 资源
机制与策略分离:简单的系统调用,组合出复杂的功能
做一件事并做好:每个工具、每个系统调用都职责单一

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐