1 Overview

操作系统是什么

一句话:操作系统是一个运行在硬件之上、为应用程序提供抽象和保护的软件层。

更具体地说,它做四件事:

  1. 抽象(Abstraction):把丑陋的硬件接口包装成好用的 API(write(fd, buf, n) 而不是"操作磁盘控制器寄存器")
  2. 虚拟化(Virtualization):让多个程序共享同一份硬件,各自以为独占
  3. 保护(Protection / Isolation):一个程序崩了不影响别人,也不能破坏 OS
  4. 仲裁(Arbitration):谁先用 CPU、内存不够了怎么办

OS 是"资源的抽象者与管理者",也是"秩序的维持者"。

为什么需要 OS:从裸机看起

假如没有 OS,写一个能读键盘、写屏幕的程序要:

  • 自己初始化串口控制器寄存器
  • 自己处理中断
  • 自己管理内存布局(不能和别的程序冲突)
  • 换一块硬盘就得重写

OS 把这些收拢成系统调用:

int fd = open("/tmp/data.txt", O_CREAT | O_WRONLY, 0644);
write(fd, "hello", 5);
close(fd);

三行代码背后是:路径解析、权限检查、文件系统元数据查找、块分配、磁盘驱动、缓存管理、错误处理。OS 把复杂度吞掉了。

三个核心抽象

概念是什么虚拟化的对象
进程(Process)运行中的程序CPU
虚拟地址空间(Address Space)程序看到的内存内存
文件(File)持久存储的命名对象磁盘

这三个抽象是 OS 的全部地基。剩下的(权限、信号、设备、网络)都是它们的延伸。

进程

进程 vs 程序

  • 程序(Program):躺在磁盘上的一堆指令 + 数据,静态的
  • 进程(Process):程序的一次执行,动态的

一个程序可以对应多个进程(同时开三个终端);一个进程也可以换程序(exec)。

进程的机器状态

一个进程被暂停再恢复时,需要保存什么?

1. 地址空间(内存):代码、栈、堆、数据段
2. 寄存器:
   - PC(程序计数器):下一条指令在哪
   - SP(栈指针)、FP(帧指针)
   - 通用寄存器
3. 打开的文件描述符表
4. 其他内核状态:信号处理器、进程关系、凭据等

前两项合起来叫上下文(Context)。上下文切换就是换掉这些。

进程 API(POSIX)

#include <unistd.h>
#include <sys/wait.h>

pid_t pid = fork();          // 复制出一个子进程(调用一次,返回两次)
if (pid == 0) {
    // 子进程
    execlp("ls", "ls", "-l", NULL);   // 把自己替换成新程序
    _exit(127);                        // exec 失败才会走到这
} else {
    int status;
    waitpid(pid, &status, 0);          // 等待子进程结束
}

fork() 的怪异之处:调用一次,返回两次。父进程得到子进程 PID,子进程得到 0。

fork() + exec() 分离的意义:fork 之后、exec 之前,子进程可以:

int fd = open("out.txt", O_WRONLY);
dup2(fd, STDOUT_FILENO);   // 重定向标准输出
close(fd);
execlp("ls", "ls", NULL);  // 现在 ls 的输出会写进 out.txt

这就是 shell 实现重定向、管道的方式。如果 fork 和 exec 合并成一个 spawn,就没这个窗口期了(Windows 的 CreateProcess 就是合并的,要靠参数传句柄)。

进程状态

  fork/创建
     │
     ▼
┌─────────┐
│  Ready  │◄────────┐
│  就绪   │         │  时间片用完 / 被抢占
└────┬────┘         │
     │ 被调度       │
     ▼              │
┌─────────┐         │
│ Running │─────────┘
│  运行   │
└────┬────┘
     │ 等待 I/O 或事件
     ▼
┌─────────┐
│ Blocked │
│  阻塞   │──► 事件到达 → Ready
└─────────┘
                      最后: Zombie(已结束,等父进程 wait 收尸)

Zombie(僵尸进程):进程已退出,但父进程还没 wait()。内核保留它的 PCB 等着父进程来收退出码。父进程先死了就交给 init(PID 1)收养。

Orphan(孤儿进程):父进程先退出,子进程被 init 收养。这是 daemon 的实现原理之一。

受限直接执行(Limited Direct Execution)

OS 虚拟化 CPU 的基本手法。

理想:让程序直接在 CPU 上跑(快)。 问题:程序跑了就不回来了,OS 怎么夺回控制权?

两种夺回方式

协作式(Cooperative):等程序主动让出(yield)或发起系统调用、或出错。

  • 简单,但一个死循环就能卡死整个系统(早期 Mac OS、Windows 3.x)

非协作式(Preemptive,现代做法):时钟中断。

  • 硬件定时器每隔几毫秒产生中断,强制跳回内核
  • 内核的中断处理程序决定是否切换进程
启动 OS 时:
  1. 设置"陷阱表"(trap table),告诉硬件:中断来了跳到哪
  2. 启动时钟中断

运行进程时:
  1. 内核把程序加载到内存,创建 PCB
  2. 设置内核栈,为返回用户态准备 Trapframe
  3. 从陷阱返回(return-from-trap),切到用户态,跳到程序入口
  4. CPU 直接执行程序指令(**没有 OS 参与,很快**)
  5. 时钟中断 / 系统调用 / 异常 → 回到内核态
  6. 内核保存现场、决定下一个进程、恢复现场、返回

关键洞察:第 4 步是"直接执行"——程序在 CPU 上全速跑,OS 完全不在回路里。只在切换点(中断/系统调用)OS 才介入。这就是"受限直接执行":绝大部分时间直接跑,少数时刻受限。

上下文切换

// 伪代码
void context_switch(PCB *prev, PCB *next) {
    save_registers(prev);          // 保存通用寄存器、PC、SP
    switch_page_table(next);       // 切换地址空间(换 CR3)
    restore_registers(next);       // 恢复下一个进程的寄存器
    return_from_trap();            // 返回用户态,跳到 next 的 PC
}

成本:

  • 直接的寄存器保存/恢复:几十~几百周期
  • 间接成本更大:切换页表导致 TLB 失效、Cache 变冷、分支预测器失效

实际测量上下文切换开销约 1~10 微秒,但间接影响可能让后续几千条指令都变慢。所以切换不能太频繁——这是调度器设计的核心权衡。

用户态与内核态

硬件提供的两级特权

CPU 至少有两个模式(x86 有 4 个 ring,实际只用 0 和 3):

用户态(Ring 3)内核态(Ring 0)
特权指令不能执行可以
访问硬件不能可以
修改页表不能可以
关中断不能可以
访问内核内存不能(触发段错误)可以

特权指令举例:修改控制寄存器(CR3 页表基址)、hlt、in/out(端口 I/O)、cli/sti(开关中断)。

用户态执行特权指令会触发一般保护错误(GPF),OS 收到异常,杀掉进程(Segmentation fault)。

系统调用:进内核的唯一合法途径

// 用户看到的
write(1, "hello", 5);

// 实际发生
// 1. 把系统调用号和参数放到约定位置(寄存器或栈)
// 2. 执行陷入指令(x86-64 是 syscall,32 位是 int 0x80)
// 3. CPU 切到内核态,跳到内核的系统调用入口
// 4. 内核查系统调用表,调用对应处理函数
// 5. 检查参数合法性(**关键!**)
// 6. 执行,返回结果
// 7. sysret / iret 返回用户态

为什么叫"陷入(trap)":控制权从应用主动交给了内核,像掉进了陷阱。

内核必须检查所有参数:用户传的指针可能是恶意地址(指向内核内存)。内核用 copy_from_user / copy_to_user,访问失败返回错误而不是崩。

陷阱表(Trap Table)

启动时,OS 告诉硬件:发生各类事件时跳到哪个地址。

中断/异常号  →  处理程序地址
0(除零)     →  divide_error_handler
14(缺页)    →  page_fault_handler
0x80(syscall)→ system_call_entry
时钟中断      →  timer_interrupt_handler

硬件只知道"跳过去",不知道具体做什么。这保证了 OS 可以自己定义处理方式,硬件不必理解 OS 的策略。

三种打断程序的方式

类型触发同步/异步例子
系统调用(Trap)程序主动请求同步open、read、fork
异常(Exception/Fault)程序做了非法的事同步除零、缺页、非法内存访问
中断(Interrupt)外部设备事件异步时钟、网卡收包、键盘

区分的关键:中断与当前指令无关(异步),异常和系统调用由当前指令触发(同步)。

内核架构

架构特点代表
宏内核(Monolithic)所有功能(文件系统、驱动、网络、调度)都在内核态,一个大程序Linux、BSD、Windows NT
微内核(Microkernel)内核只保留最核心(IPC、调度、地址空间),其他做成用户态服务Mach、L4、QNX、seL4
混合内核宏内核为主,部分模块化Windows NT、macOS(XNU = Mach + BSD)
外内核(Exokernel)内核只做资源复用,抽象交给库MIT exokernel(研究性质)
Unikernel应用和 OS 编译成一个镜像,无用户/内核之分云/嵌入式场景

宏内核 vs 微内核

宏内核:

  • ✓ 性能好(模块间是函数调用,不需要 IPC)
  • ✗ 一个驱动 bug 就能崩整个系统(Linux 内核代码 3000 万行)
  • ✗ 庞大、难维护

微内核:

  • ✓ 可靠性/安全性好(文件系统崩了可以重启这个服务,不影响内核)
  • ✓ 内核小(L4 约 1 万行,可形式化验证——seL4 已完成)
  • ✗ 性能差(模块间通信要 IPC + 上下文切换)

历史:1990s 的 Mach 微内核性能太差,Linus 选择了宏内核,赢了桌面/服务器市场。但 L4/seL4 后来证明了微内核也能很快(IPC 优化到几十个周期),现在在高安全场景(汽车、航空、军工)有应用。

现实中没有纯粹的形式:Linux 有内核模块(可动态加载,但仍跑在内核态),Windows 有用户态子系统。

虚拟化(Virtualization)

OS 虚拟化 CPU 的基本手法:让每个进程以为自己独占 CPU。

手法:

  • 时分复用:进程 A 跑 10ms,切到 B 跑 10ms,来回切
  • 切换时保存/恢复上下文

代价:每次切换有开销。切换太频繁(时间片太短)→ 开销占比高;切换太少(时间片太长)→ 响应性差。这是调度器的核心权衡(见虚拟化章节)。

操作系统简史

时期形态关键进展
1940s-50s无 OS,手工操作打孔卡片,一次一个作业
1950s-60s批处理系统作业队列,自动切换
1960s多道程序(Multiprogramming)内存中同时放多个程序,I/O 等待时切另一个
1960s-70s分时系统(Time Sharing)交互式,CTSS / Multics
1969Unix 诞生Thompson & Ritchie,简洁哲学,C 语言重写
1970s-80s个人机 OSDOS、早期 Mac OS(协作式多任务)
1991LinuxTorvalds,宏内核 + GPL
1993Windows NT混合内核,企业级
2000s虚拟化 / 云计算VMware、Xen、KVM
2010s容器cgroups + namespace,Docker

Unix 的核心遗产:一切皆文件、进程 fork/exec、管道、shell、可移植的 C 实现。

常用工具

# 进程
ps aux                 # 进程列表
top / htop             # 实时占用
pstree                 # 进程树(能看到父子关系)
kill -9 <pid>          # SIGKILL

# 系统调用追踪
strace -c ./program    # 统计系统调用次数和耗时(Linux)
dtruss ./program       # macOS
ltrace ./program       # 库函数调用

# 综合性能
perf top / perf record  # 采样分析
vmstat 1               # 上下文切换、中断、内存
sar -n DEV 1           # 网络
iostat -x 1            # 磁盘

strace 是理解 OS 最好的工具——它能让你看见一个 ls 到底调用了哪些系统调用。

常见坑

  • 以为 fork() 后父子内存共享:不是,fork 后是独立的地址空间(现代 OS 用写时复制优化,但语义上独立)
  • 忘记 wait():产生僵尸进程,PID 泄漏
  • 以为系统调用很便宜:实际约 1000+ 周期。循环里 read(fd, &c, 1) 逐字节读是性能灾难,要用缓冲
  • 混淆进程和线程:线程共享地址空间,进程不共享。这是个大话题,见并发章节
  • 内核态/用户态切换的隐藏成本:不只是指令开销,还有 TLB 和 Cache 的污染,往往被低估