1 Overview
操作系统是什么
一句话:操作系统是一个运行在硬件之上、为应用程序提供抽象和保护的软件层。
更具体地说,它做四件事:
- 抽象(Abstraction):把丑陋的硬件接口包装成好用的 API(
write(fd, buf, n)而不是"操作磁盘控制器寄存器") - 虚拟化(Virtualization):让多个程序共享同一份硬件,各自以为独占
- 保护(Protection / Isolation):一个程序崩了不影响别人,也不能破坏 OS
- 仲裁(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 |
| 1969 | Unix 诞生 | Thompson & Ritchie,简洁哲学,C 语言重写 |
| 1970s-80s | 个人机 OS | DOS、早期 Mac OS(协作式多任务) |
| 1991 | Linux | Torvalds,宏内核 + GPL |
| 1993 | Windows 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 的污染,往往被低估