MIT 6.S081 入门:5 个 demon 搞懂 Unix IO、fork 与 exec 的底层逻辑

发布时间:2026/7/16 5:33:46
MIT 6.S081 入门:5 个 demon 搞懂 Unix IO、fork 与 exec 的底层逻辑 开篇为什么要学习 MIT 6.S081 MIT 6.S081 通过xv6这个极简的操作系统内核能让我们动手去写代码看现象从而真正理解操作系统底层的运行逻辑。文本定位这是util实验的前置篇通过5个极简的用户态C程序 从最基本的文件IO到进程的创建与替换一步步地搞懂系统调用、Unix IO、fork、exec这些操作系统最基本的概念。实验环境Ubuntu 20.04一切皆文件 —— 从 IO 系统调用建立基础认知我从copy.c和open.c中建立了对文件标识符(file descriptor)、一切皆文件的核心认知这为后面理解文件重定向、进程的创建与替换等打下了很好的基础从copy.c看最朴素的 IO读与写// copy.c: copy input to output.#includekernel/types.h#includeuser/user.hintmain(){charbuf[64];while(1){intnread(0,buf,sizeof(buf));if(n0)break;write(1,buf,n);}exit(0);}read 和 write 系统调用read和write系统调用以字节为单位读取或写入已经打开的以文件描述符命名的文件read(fd, buf, n):从文件描述符 fd 读取最多 n 字节将其复制到 buf 并返回读取的字节数。每个文件描述符都有一个与之关联的偏移量read 从当前文件偏移量开始读取数据然后将偏移量移进所读取的字节数后续读取将返回前面读取成功的字节之后的字节当没有更多的字节可读时read 将返回 0 表示文件的结束。write(fdbufn):将 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.# 控制台输出$ copyout.txt hello# 键盘输入Hope is a good thing.# 键盘输入# 键盘输入$catout.txt hello Hope is a good thing.$ copyout.txt hello# 控制台输出Hope is a good thing.# 控制台输出# 控制台输出灵魂提问为什么我们代码里写死了 0 和 1却能实现从文件读、写到文件从open.c看普通文件的打开与操作// open.c: create a file, write to it.#includekernel/types.h#includeuser/user.h#includekernel/fcntl.hintmain(){intfdopen(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$catoutput.txt ooo# 写入成功达成共识终端、普通文件、管道、设备…… 在 Unix 里都被抽象成「文件」统一通过文件描述符操作用同一套 read/write 接口访问。这是 Unix 最经典的设计思想之一。小结文件描述符本质是进程内部的「索引表」指向内核维护的文件对象。程序只和抽象的 fd 打交道不用关心底层到底是什么硬件 / 资源。一次调用两次返回 —— 理解 fork 与进程的诞生我从fork.c中理解了进程的创建最反直觉的系统调用fork// fork.c: create a new process#includekernel/types.h#includeuser/user.hintmain(){intpid;pidfork();printf(fork() returned %d\n,pid);if(pid0){printf(child\n);}else{printf(parent\n);}exit(0);}代码执行逻辑调用 fork() 触发系统调用内核会完整复制当前进程的地址空间与运行上下文生成一个完全独立的子进程。fork 会在父子两个进程中分别返回这是区分二者身份的唯一依据父进程中返回子进程的 PID非 0 正整数子进程中固定返回 0。通过 pid 0 的条件判断父子进程进入不同的执行分支分别打印自身的身份标识。最终两个进程各自调用 exit(0) 正常退出。子进程不是从头重新执行 main 函数而是从 fork 调用的下一行开始执行 —— 因为内核复制了程序计数器 PC。尽管fork复制了文件描述符表但是每个基础文件偏移量在父文件和子文件之间是共享的从「输出乱码」看并发的本质$ fork forfk()orretk(u)rn ered t4urne d par0e cnhtil dxv6 的 printf 是逐字符调用 write每次系统调用返回时都可能触发进程调度父子进程共享控制台输出交替执行时就会出现字符穿插小结fork 的本质是「复制出一个独立的执行流」。每个进程有独立的地址空间、独立的执行状态操作系统通过调度让它们并发运行。替换灵魂 —— exec 与 Unix 经典的进程编程范式为什么 exec 成功不返回?为什么 fork 和 exec 要分开设计Shell 的工作原理是什么exec不是调用函数是替换整个进程// exec.c: replace a process with an executable file#includekernel/types.h#includeuser/user.hintmain(){char*argv[]{echo,this,is,echo,0};exec(echo,argv);printf(exec failed!\n);exit(0);}运行效果$execthis isecho代码执行逻辑构造参数数组 argv遵循 Unix 系统调用约定第一个元素是程序自身名称后续是命令行参数末尾必须以空指针0结尾作为参数结束标记。调用 exec(“echo”, argv)内核加载文件系统中的 echo 可执行文件将当前进程的代码段、数据段、栈全部替换为新程序的内容随后 CPU 直接从 echo 程序的入口开始执行。核心特性exec 调用成功时永远不会返回。只有调用失败比如可执行文件不存在、权限不足时才会继续执行后续代码打印失败提示并退出。为什么失败才返回因为成功时原进程的代码、数据、栈都被覆盖了exec 后面的指令在内存里已经不存在了自然不可能回来执行。黄金组合fork exec wait#includekernel/types.h#includeuser/user.h// forkexec.c: fork then execintmain(){intpid,status;pidfork();if(pid0){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 status0$ forkexec pTHIS IS ECHO arent waiting the child exited with status0代码执行逻辑fork 创建子进程父子分流子进程分支构造参数调用 exec 替换成 echo 程序失败则打印错误退出父进程分支打印提示调用 wait 阻塞等待wait 的三个作用阻塞等待父进程暂停直到子进程退出获取退出状态通过指针参数拿到子进程 exit 的状态码回收资源避免子进程变成「僵尸进程」泄漏内核资源灵魂拷问为什么要分开设计 fork 和 exec在 fork 和 exec 之间子进程可以做任意定制化修改再运行新程序替换文件描述符实现输入输出重定向、管道关闭不需要的文件修改当前工作目录调整进程权限、设置环境变量Shell 到底是怎么运行命令的我们每天在命令行输入 ls -l、echo hello底层其实就是 Shell 在无限循环执行这套流程打印提示符解析用户输入的命令和参数fork 创建子进程子进程处理重定向、管道等逻辑子进程调用 exec 加载运行目标程序父进程Shell调用 wait 等待子进程结束回到第一步等待下一条命令总结1. 系统调用的本质它是用户态程序进入内核态的唯一受控入口。我们写的 read、write、fork、exec表面上是 C 函数底层都会触发 ecall 陷入内核由内核完成真正的操作再返回结果。2. 进程的抽象操作系统给每个进程编织了一个「独享内存、独享 CPU」的美梦。fork 就是创造一个新的独立梦境exec 就是把这个梦境里的内容全部换掉。3. Unix 设计思想的体现一切皆文件用统一的接口管理所有 IO 资源机制与策略分离简单的系统调用组合出复杂的功能做一件事并做好每个工具、每个系统调用都职责单一