MIT 6.1810 · 第 1 课

导论与概述

操作系统是什么?为什么需要它?本课程从 UNIX 系统调用入手,带你走进操作系统的世界,以 xv6 为案例探索内核的核心机制。

授课:rtm (Robert Morris) 日期:2025-01-21 难度:入门

什么是操作系统?

一台计算机由 硬件(CPU、RAM、磁盘、网卡等)和 用户应用程序(shell、编译器、数据库等)组成。操作系统(O/S)位于应用程序与硬件之间,其核心是 内核(kernel),提供文件系统、进程管理、内存管理、网络等服务。

层次例子
用户应用程序sh, cc, DB, grep, echo
内核服务FS, processes, memory, network
系统调用接口open, read, write, fork, exec, pipe
硬件CPU, RAM, disk, NIC

操作系统的目的

1. 多路复用(Multiplex)

将有限的硬件资源在众多应用程序之间共享。多个进程共用 CPU、内存和磁盘,内核负责调度与分配。

2. 隔离(Isolation)

一个程序的 bug 或恶意行为不能破坏其他程序或内核。这是安全与稳定性的基石。

3. 共享(Sharing)

协作的应用程序需要安全地交换数据——管道、文件、共享内存都是内核提供的共享机制。

4. 抽象(Abstraction)

把硬件的复杂性隐藏起来,提供简洁、可移植的接口,让程序员不必关心磁盘块、网卡寄存器等细节。

设计张力(Design Tensions)

为什么学习操作系统?

课程结构

授课内容

实验(Labs)

实验是 6.1810 的核心,占期末总评的 50%。实验分三类:

类型内容示例
系统编程使用系统调用编写应用UNIX shell
O/S 原语实现内核基本功能线程切换
内核扩展在 xv6 上添加新机制网络协议栈

重要提示:大部分实验一周完成,请尽早开始!

评分构成

项目比例
实验(Labs)50%
期中 + 期末考试30%
实验检查面谈15%
课前作业5%

介绍 UNIX 系统调用

应用程序通过 系统调用(system call) 与操作系统交互。它们看起来像函数调用,但实际上会切换到内核态执行,同时保持用户/内核隔离。

例 1:ex1.c — 复制输入到输出

// ex1.c: 从标准输入读,写到标准输出
char buf[64];
int n;
while((n = read(0, buf, sizeof(buf))) > 0) {
    write(1, buf, n);
}

read(fd, buf, n) 与 write(fd, buf, n) 是系统调用。第一个参数 fd(file descriptor)是"文件描述符"——UNIX 中一切皆文件的抽象:

read() 返回实际读取的字节数,出错返回 -1。UNIX I/O 是 8 位字节流,具体解释由应用程序决定。

例 2:ex2.c — 创建文件

// ex2.c: 创建文件并写入
int fd = open("out", O_WRONLY | O_CREATE);
write(fd, "x", 1);
close(fd);

open() 返回一个新的 fd,是一个小整数,索引到内核维护的每个进程的文件描述符表。不同进程的 fd 命名空间独立,所以 fd 3 在不同进程中通常指代不同的文件。

例 3:ex3.c — 创建新进程

// ex3.c: fork 创建新进程
int pid = fork();
if (pid == 0) {
    // 子进程执行这里
    printf("child\n");
} else {
    // 父进程执行这里
    printf("parent, child pid=%d\n", pid);
}

fork() 复制当前进程——包括指令、数据、寄存器、fd 表、当前工作目录。父子进程初始状态几乎完全相同,唯一的区别是 fork() 的返回值:父进程得到子进程的 pid,子进程得到 0。

例 4:ex4.c — 加载并执行新程序

// ex4.c: exec 替换当前程序
char *argv[] = { "echo", "a", "b", "c", 0 };
exec("echo", argv);
printf("exec failed\n");  // 如果 exec 成功,永远不会到这里

exec() 丢弃当前进程的指令和数据内存,从文件加载新的程序,但保留文件描述符。如果成功,exec() 不会返回。

例 5:ex5.c — Shell 如何运行程序

// ex5.c: fork + exec + wait
int pid = fork();
if (pid == 0) {
    exec("echo", argv);  // 子进程执行新程序
    exit(1);             // exec 失败时退出
}
wait(0);  // 父进程等待子进程结束

Shell 对每个命令都用 fork() 创建子进程,子进程调用 exec() 运行程序,父进程调用 wait() 等待。exit(status) 通过 wait(&status) 向父进程传递 32 位状态:0 表示成功,1 表示出错。

关于 fork + exec 的"浪费"

fork() 复制父进程全部内存,exec() 又立刻丢弃——看起来浪费。在"写时复制(copy-on-write)"实验中,你将消除这种浪费。

例 6:ex6.c — I/O 重定向

// ex6.c: 实现 echo hello > out
close(1);              // 释放 fd 1
open("out", O_CREATE | O_WRONLY);  // 新文件获得 fd 1
exec("echo", argv);    // echo 的 stdout 现在指向 out

open() 总是选择最小的未使用 fd。先 close(1),再 open() 就保证新文件获得 fd 1。fd 提供了间接层——程序只使用 0 和 1,不用关心输入输出的真正来源与去向。

例 7:ex7.c — 管道

// ex7.c: 实现 ls | grep x
int p[2];
pipe(p);         // p[0]=读端, p[1]=写端
if (fork() == 0) {
    close(1);
    dup(p[1]);   // 子进程 stdout 指向管道写端
    close(p[0]);
    close(p[1]);
    exec("ls", argv);
}
wait(0);
if (fork() == 0) {
    close(0);
    dup(p[0]);   // 子进程 stdin 从管道读端读取
    close(p[0]);
    close(p[1]);
    exec("grep", argv);
}

pipe() 创建一对 fd,写端写入的数据由内核缓冲,读端等到有数据后读取。管道是独立的抽象,但与 fork() > 组合后能优雅地实现进程间通信与流水线。

例 8 & 9:进程间通信与目录读取

例 8 展示父/子进程通过管道通信。例 9 展示目录的本质:目录也可以像文件一样 open() 再 read(),返回的是文件名列表。

思考题:为什么这样设计?

UNIX 的设计值得反复追问:

UNIX 的设计非常成功,但并非唯一好方案。理解设计的取舍,是学习操作系统的核心。


知识检查

Q1:以下哪项不是操作系统提供的核心服务?

Q2:fork() 的返回值,以下哪项是正确的?

Q3:Shell 如何实现 echo hello > out?

Q4:管道 pipe(p) 创建的两个 fd,用途是什么?

Q5:UNIX 中文件描述符(fd)提供的核心抽象是什么?


本课要点

OS 四大目的

多路复用、隔离、共享、抽象——操作系统通过管理硬件资源,为应用程序提供简洁、安全、可移植的接口。

系统调用是边界

应用程序通过系统调用进入内核。这条边界同时保证隔离(用户不能直接访问硬件)和可控的内核入口。

xv6 是你的实验室

xv6 是一个小型的类 UNIX 操作系统,代码量足以读完,但包含进程、文件、内存、管道等核心机制。

UNIX 设计哲学

一切皆文件、fd 间接、fork/exec 分离、管道组合——这些设计相互作用,构成了优雅的 UNIX 工具生态。

延伸阅读