Operating Systems: Three Easy Pieces(OSTEP)学习:《Operating Systems: Three Easy Pieces》(OSTEP) 第2章
让程序方便地运行(甚至同时运行多个程序)让程序之间共享内存让程序能访问各种硬件设备(硬盘、网卡等)OS 实现这些目标的核心手段叫做虚拟化(Virtualization)。虚拟化:把真实的物理资源(CPU、内存、磁盘)包装成更通用、更易用的"虚拟版本"提供给程序使用。因此,操作系统有时也被称为虚拟机(Virtual Machine)。OS 还提供系统调用(System Call)接口,让应用程序告诉
一、程序运行时发生了什么?
当一个程序运行时,它做的事情非常简单:不断执行指令。
处理器每秒执行数百万甚至数十亿条指令,每次循环做三件事:
取指令 (Fetch) → 解码 (Decode) → 执行 (Execute)
用ASCII图示意:
内存 (Memory)
┌───────────────────┐
│ 指令1: ADD R1,R2 │ ← 处理器取出指令
│ 指令2: LOAD R3 │
│ 指令3: JMP 0x100 │
│ ... │
└───────────────────┘
↓
处理器 (CPU)
┌──────────────┐
│ 1. 取指令 │
│ 2. 解码 │
│ 3. 执行 │
│ 4. 下一条 → │
└──────────────┘
这就是经典的冯·诺依曼计算模型(Von Neumann Model)——程序存在内存里,处理器顺序取出并执行。
二、什么是操作系统(OS)?
操作系统是一套软件,负责:
- 让程序方便地运行(甚至同时运行多个程序)
- 让程序之间共享内存
- 让程序能访问各种硬件设备(硬盘、网卡等)
OS 实现这些目标的核心手段叫做虚拟化(Virtualization)。
虚拟化:把真实的物理资源(CPU、内存、磁盘)包装成更通用、更易用的"虚拟版本"提供给程序使用。
因此,操作系统有时也被称为虚拟机(Virtual Machine)。
OS 还提供系统调用(System Call)接口,让应用程序告诉操作系统"我要做什么"(如运行程序、申请内存、读写文件)。这些接口也叫做标准库(Standard Library)。
由于 OS 管理着 CPU、内存、磁盘等共享资源,它也被称为资源管理器(Resource Manager)。
三、虚拟化CPU
3.1 示例代码解析
common.h
#ifndef __common_h__
#define __common_h__
#include <sys/time.h>
#include <sys/stat.h>
#include <assert.h>
double GetTime() {
struct timeval t;
int rc = gettimeofday(&t, NULL);
assert(rc == 0);
return (double) t.tv_sec + (double) t.tv_usec/1e6;
}
void Spin(int howlong) {
double t = GetTime();
while ((GetTime() - t) < (double) howlong)
; // do nothing in loop
}
#endif // __common_h__
#include <stdio.h>
#include <stdlib.h>
#include <sys/time.h>
#include <assert.h>
#include "common.h"
int main(int argc, char *argv[])
{
/* 检查命令行参数:必须传入一个字符串 */
if (argc != 2) {
fprintf(stderr, "usage: cpu <string>\n");
exit(1);
}
char *str = argv[1]; /* 取第一个命令行参数 */
while (1) {
Spin(1); /* 等待约1秒(忙等待,反复检查时间) */
printf("%s\n", str); /* 打印传入的字符串 */
}
return 0;
}
这个程序每隔1秒打印一次传入的字符串,永远循环。
3.2 同时运行多个程序
prompt> ./cpu A & ./cpu B & ./cpu C & ./cpu D &
& 符号让程序在后台运行,所以四个程序几乎同时启动。
输出结果:
A
B
D
C
A
B
...
问题:计算机只有一个 CPU,为什么四个程序能"同时"运行?
答案:这是操作系统制造的幻觉!
OS 让 CPU 在多个程序之间快速切换,速度快到人感觉它们在同时运行。这就叫虚拟化CPU。
用图示意:
物理CPU(只有1个)
↓ OS 快速切换
┌──────────────────────────────────┐
│ 时间片1 时间片2 时间片3 时间片4 │
│ 程序A 程序B 程序C 程序D │
└──────────────────────────────────┘
从人的角度看:好像4个程序同时在跑
当多个程序都想运行时,OS 要决定谁先跑——这由 OS 的**调度策略(Policy)**来决定。
四、虚拟化内存
4.1 示例代码解析
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include "common.h"
int main(int argc, char *argv[])
{
/* malloc 申请一块存放 int 的内存,返回指向该内存的指针 */
int *p = malloc(sizeof(int));
assert(p != NULL); /* 确保申请成功,否则程序终止 */
/* 打印进程ID(PID)和申请到的内存地址 */
printf("(%d) address pointed to by p: %p\n", getpid(), p);
*p = 0; /* 把该内存初始化为0 */
while (1) {
Spin(1); /* 等待1秒 */
*p = *p + 1; /* 内存中的值加1 */
printf("(%d) p: %d\n", getpid(), *p); /* 打印PID和当前值 */
}
return 0;
}
4.2 奇怪的现象
同时运行两个此程序:
prompt> ./mem & ./mem &
(24113) address pointed to by p: 0x200000
(24114) address pointed to by p: 0x200000
(24113) p: 1
(24114) p: 1
(24113) p: 2
(24114) p: 2
...
两个程序申请到了相同的地址 0x200000,但它们各自独立地计数,互不干扰!
这说明每个程序看到的地址其实是虚拟地址(Virtual Address),不是真实的物理内存地址。
OS 为每个进程维护一个独立的虚拟地址空间(Virtual Address Space),并负责把虚拟地址映射到真实的物理内存:
进程A(PID 24113) 进程B(PID 24114)
虚拟地址 0x200000 虚拟地址 0x200000
↓ ↓
OS 内存映射表 OS 内存映射表
↓ ↓
物理地址 0x5A0000 物理地址 0x7B0000
(实际存放在不同位置)
这就是虚拟化内存:每个程序以为自己独占整个内存,但实际上物理内存是所有进程共享的,由 OS 统一管理。
五、并发(Concurrency)
5.1 示例代码解析
common_threads.h
#ifndef __common_threads_h__
#define __common_threads_h__
#include <pthread.h>
#include <assert.h>
#include <sched.h>
#ifdef __linux__
#include <semaphore.h>
#endif
#define Pthread_create(thread, attr, start_routine, arg) assert(pthread_create(thread, attr, start_routine, arg) == 0);
#define Pthread_join(thread, value_ptr) assert(pthread_join(thread, value_ptr) == 0);
#define Pthread_mutex_lock(m) assert(pthread_mutex_lock(m) == 0);
#define Pthread_mutex_unlock(m) assert(pthread_mutex_unlock(m) == 0);
#define Pthread_cond_signal(cond) assert(pthread_cond_signal(cond) == 0);
#define Pthread_cond_wait(cond, mutex) assert(pthread_cond_wait(cond, mutex) == 0);
#define Mutex_init(m) assert(pthread_mutex_init(m, NULL) == 0);
#define Mutex_lock(m) assert(pthread_mutex_lock(m) == 0);
#define Mutex_unlock(m) assert(pthread_mutex_unlock(m) == 0);
#define Cond_init(cond) assert(pthread_cond_init(cond, NULL) == 0);
#define Cond_signal(cond) assert(pthread_cond_signal(cond) == 0);
#define Cond_wait(cond, mutex) assert(pthread_cond_wait(cond, mutex) == 0);
#ifdef __linux__
#define Sem_init(sem, value) assert(sem_init(sem, 0, value) == 0);
#define Sem_wait(sem) assert(sem_wait(sem) == 0);
#define Sem_post(sem) assert(sem_post(sem) == 0);
#endif // __linux__
#endif // __common_threads_h__
#include <stdio.h>
#include <stdlib.h>
#include "common.h"
#include "common_threads.h"
volatile int counter = 0; /* 共享计数器,volatile 防止编译器优化掉它 */
int loops; /* 每个线程循环的次数 */
/* 工作线程:循环 loops 次,每次给 counter 加1 */
void *worker(void *arg) {
int i;
for (i = 0; i < loops; i++) {
counter++; /* 看似简单,实际分3步:取值、加1、写回 */
}
return NULL;
}
int main(int argc, char *argv[]) {
if (argc != 2) {
fprintf(stderr, "usage: threads <value>\n");
exit(1);
}
loops = atoi(argv[1]); /* 将命令行参数转为整数 */
pthread_t p1, p2; /* 两个线程的句柄 */
printf("Initial value : %d\n", counter);
/* 创建线程1和线程2,都执行 worker 函数 */
Pthread_create(&p1, NULL, worker, NULL);
Pthread_create(&p2, NULL, worker, NULL);
/* 等待两个线程都执行完毕 */
Pthread_join(p1, NULL);
Pthread_join(p2, NULL);
printf("Final value : %d\n", counter);
return 0;
}
5.2 奇怪的结果
当 loops=1000 时,结果正确:
Initial value : 0
Final value : 2000 ← 正确(1000+1000=2000)
当 loops=100000 时:
Initial value : 0
Final value : 143012 ← 错误!应该是200000
为什么会出错?counter++ 在 CPU 层面其实分三步执行:
counter++ = { 步骤1: R ← counter (从内存读入寄存器) 步骤2: R ← R + 1 (寄存器加1) 步骤3: counter ← R (写回内存) \text{counter++} = \begin{cases} \text{步骤1: } R \leftarrow \text{counter} & \text{(从内存读入寄存器)} \\ \text{步骤2: } R \leftarrow R + 1 & \text{(寄存器加1)} \\ \text{步骤3: } \text{counter} \leftarrow R & \text{(写回内存)} \end{cases} counter++=⎩
⎨
⎧步骤1: R←counter步骤2: R←R+1步骤3: counter←R(从内存读入寄存器)(寄存器加1)(写回内存)
当两个线程交叉执行时,可能发生如下情况:
线程1 线程2
读 counter=5
读 counter=5 ← 也读到了5,不是6!
counter+1 = 6
写回 counter=6
counter+1 = 6
写回 counter=6 ← 本应是7,却写成了6!
结果:两次加法只使 counter 增加了1,而不是2。这种现象叫做竞态条件(Race Condition)。
这就是并发问题的核心:多个线程同时访问共享数据,且操作不是原子的(Atomic,即不可分割的),就会导致数据错误。
六、持久化(Persistence)
内存(DRAM)是易失性存储,断电数据就丢失。我们需要把数据持久地保存到磁盘上。
OS 中负责管理磁盘上文件的软件叫文件系统(File System)。
6.1 文件I/O示例代码
#include <stdio.h>
#include <unistd.h>
#include <assert.h>
#include <fcntl.h>
#include <sys/types.h>
int main(int argc, char *argv[]) {
/*
* open() 系统调用:打开(或创建)文件
* 参数:
* "/tmp/file" → 文件路径
* O_WRONLY → 只写模式
* O_CREAT → 若文件不存在则创建
* O_TRUNC → 若文件存在则清空内容
* S_IRWXU → 文件权限:当前用户可读/写/执行
* 返回值:文件描述符(fd),失败时返回-1
*/
int fd = open("/tmp/file",
O_WRONLY | O_CREAT | O_TRUNC,
S_IRWXU);
assert(fd > -1); /* 确保文件打开成功 */
/*
* write() 系统调用:向文件写入数据
* 参数:fd(文件描述符),数据指针,字节数
* 返回值:实际写入的字节数
*/
int rc = write(fd, "hello world\n", 13);
assert(rc == 13); /* 确保13个字节全部写入成功 */
/*
* close() 系统调用:关闭文件
* 表示不再写入,OS可以将缓冲区的数据刷入磁盘
*/
close(fd);
return 0;
}
6.2 文件操作流程
应用程序调用 open() / write() / close()
↓
系统调用接口
↓
文件系统(OS)
↓ ┌──────────────────────────────────────┐
│ 1. 决定数据存放在磁盘的哪个位置 │
│ 2. 更新目录/索引等管理结构 │
│ 3. 发出 I/O 请求给存储设备 │
│ 4. 为防止崩溃,使用日志(Journaling)等 │
└──────────────────────────────────────┘
↓
硬盘/SSD
文件系统不像 CPU/内存那样为每个程序创建私有的"虚拟磁盘",而是让多个程序共享同一个文件空间(例如编译器读取源码文件,链接器再处理编译结果)。
七、操作系统的设计目标
OS 在设计时需要在多个目标之间做权衡:
| 目标 | 说明 |
|---|---|
| 抽象(Abstraction) | 提供简单易用的接口,隐藏硬件复杂性 |
| 高性能(Performance) | 虚拟化的开销要尽可能小 |
| 保护(Protection) | 程序之间相互隔离,一个程序不能破坏另一个 |
| 可靠性(Reliability) | OS 本身不能崩溃,否则所有程序都会受影响 |
| 节能(Energy-efficiency) | 在移动设备上尤为重要 |
| 安全(Security) | 防止恶意程序攻击系统 |
八、操作系统发展简史
关键概念演进
系统调用(System Call)的诞生:
早期 OS 只是一个库,程序直接调用函数访问磁盘等资源,没有任何保护。后来发现这样不安全(任何程序都能读所有文件),于是发明了系统调用:
用户程序
|
| 普通函数调用(user mode,权限受限)
|
↓ 执行 trap 指令 → 硬件提升权限
操作系统内核(kernel mode,全权限)
|
| 执行请求(如读磁盘)
|
↓ return-from-trap 指令 → 恢复用户态
用户程序继续运行
多道程序(Multiprogramming):
- 背景:I/O 设备很慢,程序等待 I/O 时 CPU 空闲,浪费资源
- 解决方案:把多个程序都加载到内存,一个等 I/O 时就切换到另一个运行
- 挑战:需要内存保护(程序不能互相读写内存),需要处理并发问题
九、三大主题总结
十、完整可运行的C语言演示程序
以下是一个展示并发问题的完整C程序,无需任何非标准头文件:
/*
* 演示并发竞态条件的完整程序
* 编译:gcc -o concurrency_demo concurrency_demo.c -lpthread
* 运行:./concurrency_demo 100000
*/
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
/* 共享计数器,volatile 告诉编译器不要优化这个变量的读写 */
volatile int counter = 0;
int loops; /* 每个线程循环的次数 */
/* 线程执行的函数:循环累加计数器 */
void *worker(void *arg) {
int i;
for (i = 0; i < loops; i++) {
/*
* counter++ 编译后实际是三条指令:
* MOV R, [counter] ; 从内存读到寄存器
* ADD R, 1 ; 寄存器加1
* MOV [counter], R ; 写回内存
* 两个线程可能在这三步中间被打断,导致数据丢失
*/
counter++;
}
return NULL;
}
int main(int argc, char *argv[]) {
if (argc != 2) {
fprintf(stderr, "用法: %s <循环次数>\n", argv[0]);
fprintf(stderr, "示例: %s 100000\n", argv[0]);
return 1;
}
loops = atoi(argv[1]);
pthread_t thread1, thread2; /* 两个线程的标识符 */
printf("初始计数器值: %d\n", counter);
printf("期望最终值: %d\n", loops * 2);
/* 创建两个线程,都执行 worker 函数 */
if (pthread_create(&thread1, NULL, worker, NULL) != 0) {
fprintf(stderr, "创建线程1失败\n");
return 1;
}
if (pthread_create(&thread2, NULL, worker, NULL) != 0) {
fprintf(stderr, "创建线程2失败\n");
return 1;
}
/* 等待两个线程执行完毕 */
pthread_join(thread1, NULL);
pthread_join(thread2, NULL);
printf("实际最终值: %d\n", counter);
if (counter == loops * 2) {
printf("结果:正确(这次运气好,没出现竞态条件)\n");
} else {
printf("结果:错误!丢失了 %d 次加法操作\n", loops * 2 - counter);
printf("原因:并发竞态条件(Race Condition)\n");
}
return 0;
}
编译并运行:
gcc -o concurrency_demo concurrency_demo.c -lpthread
./concurrency_demo 1000 # 小数字,通常正确
./concurrency_demo 100000 # 大数字,大概率出错
十一、关键术语速查
| 英文术语 | 中文 | 简单解释 |
|---|---|---|
| Operating System | 操作系统 | 管理硬件资源、为应用提供服务的软件 |
| Virtualization | 虚拟化 | 把一个物理资源变成多个易用的虚拟资源 |
| System Call | 系统调用 | 应用程序请求OS服务的接口 |
| Process | 进程 | 运行中的程序实例 |
| Thread | 线程 | 进程内的执行单元,共享同一内存空间 |
| Virtual Address Space | 虚拟地址空间 | 每个进程看到的独立内存视图 |
| Concurrency | 并发 | 多个任务同时推进,可能产生竞态问题 |
| Race Condition | 竞态条件 | 多线程不加同步访问共享数据导致的错误 |
| Atomic Operation | 原子操作 | 不可被中断的操作,要么全做要么不做 |
| File System | 文件系统 | OS中管理磁盘上文件的子系统 |
| Persistence | 持久化 | 数据在断电后仍然保存 |
| Kernel Mode | 内核态 | CPU特权模式,OS在此模式下运行 |
| User Mode | 用户态 | 受限模式,普通应用程序在此运行 |
| Multiprogramming | 多道程序 | 多个程序同时驻留内存并轮流使用CPU |
| Batch Processing | 批处理 | 早期计算模式,一批作业顺序执行 |
进程抽象 — 详细中文解析
原书:《Operating Systems: Three Easy Pieces》(OSTEP) 第4章
一、引子:用桃子理解虚拟化
书中用了一个生动的比喻:
假设只有一个桃子(物理桃子),但有很多人想吃。我们给每个人都"变出"一个虚拟的桃子,让他们觉得自己拥有一个真实的桃子——但实际上大家在轮流共享那一个。
关键:大多数时候,吃桃子的人在打盹或做别的事,所以可以把桃子悄悄传给下一个人,再传给下一个……
对应到计算机:
| 桃子的比喻 | 计算机中的实体 |
|---|---|
| 物理桃子(只有1个) | 物理 CPU(只有1个或少数几个) |
| 虚拟桃子(每人一个) | 虚拟 CPU(每个程序以为自己独占) |
| 轮流吃桃 | 时间片轮转(Time Sharing) |
二、什么是进程(Process)?
进程是操作系统提供的最基本抽象之一。
进程 = 运行中的程序
注意区分:
程序(Program) 进程(Process)
───────────────────── ─────────────────────
存放在磁盘上的文件 正在内存中运行的实体
一堆静态的指令和数据 有生命力,占用CPU和内存
就像一本放在书架上的书 就像一个人正在阅读这本书
一台典型的计算机可能同时运行数十甚至数百个进程(浏览器、音乐播放器、邮件客户端……),这让用户感觉 CPU 好像是"无限供应"的。
核心问题:物理 CPU 只有几个,OS 如何制造出"无数 CPU"的幻觉?
答案:时分复用(Time Sharing)——快速地轮流运行各个进程,速度快到人感觉不出切换。
代价是:如果进程很多,每个进程分到的 CPU 时间就少,运行速度会变慢。
三、机制(Mechanism)与策略(Policy)
OS 实现虚拟化 CPU 需要两层:
┌─────────────────────────────────────────┐
│ 高层:策略 (Policy) │
│ 回答"which"问题:现在应该运行哪个进程? │
│ 例:调度策略(优先级、公平性、吞吐量…) │
├─────────────────────────────────────────┤
│ 低层:机制 (Mechanism) │
│ 回答"how"问题:如何切换进程? │
│ 例:上下文切换(Context Switch) │
└─────────────────────────────────────────┘
分离的好处:改变调度策略(Policy)时,不需要重写底层切换机制(Mechanism)。这是软件设计中模块化原则的体现。
类比:
- 机制 = 汽车的变速箱(如何换挡)
- 策略 = 司机决定什么时候换挡
四、进程的机器状态(Machine State)
一个进程在运行时,需要记录哪些信息?
4.1 内存(Address Space)
进程能访问的内存区域称为地址空间(Address Space),包含:
高地址
┌─────────────────┐
│ 栈 (Stack) │ ← 函数调用、局部变量、返回地址
│ ↓ │ 向低地址增长
│ │
│ ↑ │
│ 堆 (Heap) │ ← malloc() 动态分配的内存,向高地址增长
├─────────────────┤
│ 静态数据区 │ ← 全局变量、静态变量
├─────────────────┤
│ 代码段 (Text) │ ← 程序指令本身
低地址
└─────────────────┘
4.2 寄存器(Registers)
CPU 内部的临时存储单元,执行指令时频繁读写。
几个特别重要的寄存器:
| 寄存器名 | 作用 |
|---|---|
| PC(程序计数器 / 指令指针) | 指向下一条要执行的指令的地址 |
| 栈指针(Stack Pointer) | 指向当前栈顶位置 |
| 帧指针(Frame Pointer) | 帮助管理函数调用栈帧 |
| 通用寄存器(EAX/EBX…) | 存放运算的中间结果 |
4.3 I/O 信息
进程当前打开了哪些文件——操作系统用**文件描述符(File Descriptor)**来追踪。
五、进程 API
任何 OS 都必须提供以下基本接口来操作进程:
| API 操作 | 说明 |
|---|---|
| Create(创建) | 在 shell 中输入命令或双击图标时,OS 创建新进程 |
| Destroy(销毁) | 强制终止不听话的进程(如卡死的程序) |
| Wait(等待) | 等待某个进程执行完毕 |
| Miscellaneous(其他控制) | 挂起(暂停)、恢复进程运行 |
| Status(状态查询) | 查询进程已运行多长时间、当前状态等 |
六、进程创建的详细过程
OS 如何把磁盘上的程序文件变成一个运行中的进程?
各步骤详解
步骤1:加载代码和静态数据
- 早期OS:饥饿加载(Eager Loading)——一次性把所有内容读入内存
- 现代OS:懒惰加载(Lazy Loading)——只在真正需要时才把对应代码/数据页读入内存(依赖分页/交换机制)
步骤2:初始化栈
C 程序的栈用于:局部变量、函数参数传递、保存返回地址。
OS 会把argc(参数个数)和argv(参数数组)填入栈中,这样main()函数一开始就能读到命令行参数。
步骤3:分配堆
堆用于程序运行时动态申请的内存(malloc())。堆初始很小,随着程序调用malloc()而增长,OS 按需分配更多物理内存。
步骤4:I/O 初始化
UNIX 中,每个新进程默认获得三个已打开的文件描述符:
文件描述符 0 → 标准输入 (stdin) ← 键盘输入
文件描述符 1 → 标准输出 (stdout) → 屏幕输出
文件描述符 2 → 标准错误 (stderr) → 屏幕(错误信息)
步骤5:跳转到 main()
OS 通过特殊机制将 CPU 执行权移交给新进程的 main() 函数入口,进程正式开始运行。
七、进程的三种状态
进程在其生命周期中可以处于三种基本状态:
| 状态 | 说明 |
|---|---|
| Running(运行) | 正在 CPU 上执行指令 |
| Ready(就绪) | 准备好了,但 OS 暂时没有把 CPU 给它 |
| Blocked(阻塞) | 在等待某个事件(如磁盘I/O完成),无法运行 |
状态转换图
用 ASCII 再直观描述:
OS调度
就绪 ──────────→ 运行
Ready ←────────── Running
OS取消调度
│ ↑
发起I/O │ │ I/O完成
↓ │
阻塞 ────────────┘
Blocked
八、进程状态追踪示例
示例一:只有CPU操作,无I/O
两个进程 P0 和 P1,都只做 CPU 计算:
时刻 P0状态 P1状态 说明
─────────────────────────────────────
1 Running Ready
2 Running Ready
3 Running Ready
4 Running Ready P0 运行完毕
5 ------ Running
6 ------ Running
7 ------ Running
8 ------ Running P1 运行完毕
P0 先独占 CPU 跑完,P1 再跑——串行执行,CPU 利用率 100%(但没有并发)。
示例二:有I/O操作
P0 运行一段时间后发起 I/O 请求,P1 趁机使用 CPU:
时刻 P0状态 P1状态 说明
─────────────────────────────────────
1 Running Ready
2 Running Ready
3 Running Ready P0 发起 I/O
4 Blocked Running P0 等I/O,P1 上CPU
5 Blocked Running
6 Blocked Running I/O 完成,P0 变就绪
7 Ready Running
8 Ready Running P1 运行完毕
9 Running ------
10 Running ------ P0 运行完毕
关键洞察:当 P0 在等 I/O 时(CPU 对它没用),OS 把 CPU 给 P1 用,这样 CPU 几乎没有空闲——资源利用率更高。
这正是多道程序(Multiprogramming)的核心价值。
九、OS 追踪进程的数据结构
OS 本身也是一个程序,它用数据结构来记录所有进程的信息。
进程控制块(PCB / Process Control Block)
每个进程对应一个 PCB,也叫进程描述符。
下面是 xv6(MIT 教学用 OS)的进程结构体,加上中文注释:
/* xv6 保存和恢复进程时需要的寄存器集合
* 进程切换时,把这些寄存器的值保存到这里
* 恢复时再把这里的值写回真实的寄存器
*/
struct context {
int eip; /* 指令指针:下一条要执行的指令地址 */
int esp; /* 栈指针:当前栈顶位置 */
int ebx; /* 通用寄存器 B */
int ecx; /* 通用寄存器 C(常用于循环计数) */
int edx; /* 通用寄存器 D */
int esi; /* 源索引寄存器(字符串操作) */
int edi; /* 目的索引寄存器(字符串操作) */
int ebp; /* 基址指针:当前函数栈帧的底部 */
};
/* 进程的所有可能状态 */
enum proc_state {
UNUSED, /* 该 PCB 槽位空闲,还未分配给任何进程 */
EMBRYO, /* 进程正在被创建("胚胎"状态) */
SLEEPING, /* 进程在睡眠/等待(即阻塞状态) */
RUNNABLE, /* 进程就绪,等待 OS 调度(即就绪状态) */
RUNNING, /* 进程正在 CPU 上运行 */
ZOMBIE /* 进程已退出,但父进程还没回收它(僵尸状态) */
};
/* xv6 中描述每个进程的完整结构体 */
struct proc {
char *mem; /* 进程内存的起始地址(在物理内存中) */
uint sz; /* 进程占用内存的大小(字节数) */
char *kstack; /* 该进程的内核栈底部地址
* 每个进程有独立的内核栈,
* 用于在内核态处理系统调用/中断 */
enum proc_state state; /* 进程当前状态(见上面的枚举) */
int pid; /* 进程ID:每个进程唯一的整数标识符 */
struct proc *parent; /* 指向父进程的指针 */
void *chan; /* 若非零,表示进程在等待这个"频道"上的事件 */
int killed; /* 若非零,表示该进程已被标记为"待杀" */
struct file *ofile[NOFILE]; /* 进程打开的文件表(文件描述符数组) */
struct inode *cwd; /* 当前工作目录(Current Working Directory) */
struct context context; /* 切换到此进程时需要恢复的寄存器状态
* 这是实现上下文切换的核心数据 */
struct trapframe *tf; /* 陷阱帧:保存进入内核时的 CPU 状态
* 用于系统调用和中断处理返回 */
};
进程列表
OS 维护一张进程列表(Process List),里面是所有进程的 PCB:
进程列表 (Process List)
┌──────────────────────────────────────────┐
│ PCB[0]: pid=1, state=RUNNING, ... │ ← shell
│ PCB[1]: pid=42, state=RUNNABLE, ... │ ← 浏览器
│ PCB[2]: pid=57, state=SLEEPING, ... │ ← 音乐播放器(等I/O)
│ PCB[3]: pid=63, state=ZOMBIE, ... │ ← 已退出但未被回收
│ ... │
└──────────────────────────────────────────┘
十、僵尸进程(Zombie Process)
进程退出后,并不会立刻消失,而是进入 ZOMBIE(僵尸) 状态。
原因:父进程可能需要查看子进程的退出码,判断它是否成功完成了任务。
- UNIX 惯例:返回
0表示成功,非零表示出错 - 父进程调用
wait()来回收子进程,OS 此后才真正清理 PCB
子进程退出
↓
进入 ZOMBIE 状态(释放内存,但保留退出码)
↓
父进程调用 wait(),读取退出码
↓
OS 彻底删除 PCB,进程消亡
如果父进程不调用 wait(),僵尸进程会一直占着 PCB 槽——资源泄漏。
十一、完整可运行的C语言演示程序
程序1:观察进程创建与状态
/*
* 演示进程创建(fork)和等待(wait)
* 编译:gcc -o process_demo process_demo.c
* 运行:./process_demo
*/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h> /* fork(), getpid(), getppid() */
#include <sys/wait.h> /* wait() */
int main(void)
{
printf("=== 进程演示程序 ===\n");
printf("父进程 PID = %d\n\n", getpid());
/*
* fork() 系统调用:创建一个子进程
* 返回值:
* 在父进程中返回子进程的 PID(正整数)
* 在子进程中返回 0
* 出错时返回 -1
*/
pid_t child_pid = fork();
if (child_pid < 0) {
/* fork 失败 */
fprintf(stderr, "fork 失败!\n");
return 1;
}
else if (child_pid == 0) {
/*
* 子进程执行这里
* 子进程是父进程的一份完整拷贝,从 fork() 之后继续执行
*/
printf("[子进程] PID = %d, 父进程 PID = %d\n",
getpid(), getppid());
printf("[子进程] 我在工作,稍后退出...\n");
sleep(2); /* 模拟做了一些工作 */
printf("[子进程] 工作完成,退出码 = 0\n");
exit(0); /* 子进程退出,进入 ZOMBIE 状态,等父进程回收 */
}
else {
/*
* 父进程执行这里
* child_pid 是刚创建的子进程的 PID
*/
printf("[父进程] 我创建了子进程,子进程 PID = %d\n", child_pid);
printf("[父进程] 等待子进程完成...\n");
/*
* wait() 系统调用:阻塞父进程,直到子进程退出
* status 会保存子进程的退出信息
*/
int status;
pid_t finished_pid = wait(&status);
/*
* WIFEXITED(status) 检查子进程是否正常退出
* WEXITSTATUS(status) 获取子进程的退出码
*/
if (WIFEXITED(status)) {
printf("[父进程] 子进程 %d 已退出,退出码 = %d\n",
finished_pid, WEXITSTATUS(status));
}
printf("[父进程] 清理完毕,我也退出了。\n");
}
return 0;
}
https://godbolt.org/z/Wdx6Y98vP
预期输出:
=== 进程演示程序 ===
父进程 PID = 12345
[父进程] 我创建了子进程,子进程 PID = 12346
[父进程] 等待子进程完成...
[子进程] PID = 12346, 父进程 PID = 12345
[子进程] 我在工作,稍后退出...
[子进程] 工作完成,退出码 = 0
[父进程] 子进程 12346 已退出,退出码 = 0
[父进程] 清理完毕,我也退出了。
程序2:模拟进程状态机
/*
* 用文本模拟进程状态转换:Running → Blocked → Ready → Running
* 编译:gcc -o state_demo state_demo.c
* 运行:./state_demo
*/
#include <stdio.h>
#include <unistd.h> /* sleep() */
/* 定义进程状态枚举(模拟 OS 内部的 proc_state) */
typedef enum {
READY,
RUNNING,
BLOCKED
} ProcessState;
/* 将状态枚举转换为可读字符串 */
const char* state_name(ProcessState s) {
switch (s) {
case READY: return "就绪(READY) ";
case RUNNING: return "运行(RUNNING)";
case BLOCKED: return "阻塞(BLOCKED)";
default: return "未知";
}
}
/* 打印当前两个进程的状态 */
void print_states(int time, ProcessState p0, ProcessState p1, const char *note) {
printf("时刻%2d P0: %s | P1: %s %s\n",
time, state_name(p0), state_name(p1), note);
}
int main(void)
{
printf("=== 进程状态转换模拟(含I/O)===\n");
printf("时刻 进程0状态 进程1状态 说明\n");
printf("------------------------------------------------------\n");
/* 模拟图4.4的场景:P0运行后发起I/O,P1趁机运行 */
print_states(1, RUNNING, READY, "P0 运行中");
print_states(2, RUNNING, READY, "P0 运行中");
print_states(3, RUNNING, READY, "P0 发起I/O请求...");
sleep(1);
print_states(4, BLOCKED, RUNNING, "P0 等I/O,P1 被调度上CPU");
print_states(5, BLOCKED, RUNNING, "P0 仍在等,P1 运行中");
print_states(6, BLOCKED, RUNNING, "I/O完成!P0变就绪");
sleep(1);
print_states(7, READY, RUNNING, "P0 就绪,P1 还在运行");
print_states(8, READY, RUNNING, "P1 运行完毕");
print_states(9, RUNNING, BLOCKED, "P0 重新获得CPU"); /* BLOCKED此处表示已完成 */
print_states(10, RUNNING, BLOCKED, "P0 运行完毕");
printf("------------------------------------------------------\n");
printf("结论:通过在P0等待I/O时运行P1,CPU几乎没有空闲!\n");
return 0;
}
https://godbolt.org/z/snare5Y7a
十二、关键概念汇总
十三、时分复用 vs 空间分复用
OS 共享资源有两种基本方式:
资源共享方式 = { 时分复用(Time Sharing) 资源轮流使用,如CPU 空间分复用(Space Sharing) 资源分块占用,如磁盘 \text{资源共享方式} = \begin{cases} \text{时分复用(Time Sharing)} & \text{资源轮流使用,如CPU} \\ \text{空间分复用(Space Sharing)} & \text{资源分块占用,如磁盘} \end{cases} 资源共享方式={时分复用(Time Sharing)空间分复用(Space Sharing)资源轮流使用,如CPU资源分块占用,如磁盘
时分复用:CPU 在时间维度上轮流分配给不同进程
- 每个进程运行一小段时间,然后换下一个
- 每个进程感觉自己独占了 CPU
空间分复用:磁盘在空间维度上分配给不同文件 - 文件A占用磁盘的第100200块,文件B占用第201300块
- 一个块一旦分给某文件,就不能同时给另一个文件用
十四、关键术语速查
| 英文 | 中文 | 简单解释 |
|---|---|---|
| Process | 进程 | 运行中的程序,OS 调度的基本单位 |
| Program Counter (PC) | 程序计数器 | 指向下一条指令的寄存器 |
| Address Space | 地址空间 | 进程能访问的内存范围 |
| Stack | 栈 | 存放局部变量和函数调用信息 |
| Heap | 堆 | 动态分配的内存区域 |
| Time Sharing | 时分复用 | 轮流使用 CPU 制造并发幻觉 |
| Context Switch | 上下文切换 | 保存一个进程状态、恢复另一个进程状态 |
| Mechanism | 机制 | 如何做(底层实现) |
| Policy | 策略 | 做什么(高层决策) |
| PCB | 进程控制块 | 存放一个进程所有信息的数据结构 |
| Process List | 进程列表 | OS 维护的所有 PCB 的集合 |
| Zombie | 僵尸状态 | 进程已退出但父进程未回收时的状态 |
| Eager Loading | 饥饿加载 | 一次性全部加载到内存 |
| Lazy Loading | 懒惰加载 | 按需加载,用到哪块才加载哪块 |
| File Descriptor | 文件描述符 | OS 用整数代表打开的文件(0=stdin,1=stdout,2=stderr) |
进程 API — 详细中文解析
原书:《Operating Systems: Three Easy Pieces》(OSTEP) 第5章
一、本章概述
UNIX 提供了一套优雅的进程创建接口,核心是三个系统调用:
| 系统调用 | 作用 |
|---|---|
fork() |
创建一个新进程(子进程),是父进程的近似拷贝 |
exec() |
让当前进程变身为另一个程序(替换自身) |
wait() |
父进程等待子进程执行完毕 |
这三个调用组合在一起,构成了 UNIX shell 的运行基础,也是实现管道、重定向等强大功能的关键。
二、fork() —— 最奇怪的系统调用
2.1 fork() 做了什么
fork() 创建一个新进程,新进程是调用者(父进程)的近似完整拷贝:
- 拥有自己独立的地址空间(代码、栈、堆各自一份)
- 拥有自己的寄存器、程序计数器(PC)
- 但不从
main()开始运行,而是从fork()返回处继续运行fork()的返回值是区分父子进程的唯一依据:
fork() 返回值 = { > 0 (父进程中)返回子进程的 PID = 0 (子进程中)返回 0 < 0 fork 失败 \text{fork() 返回值} = \begin{cases} > 0 & \text{(父进程中)返回子进程的 PID} \\ = 0 & \text{(子进程中)返回 0} \\ < 0 & \text{fork 失败} \end{cases} fork() 返回值=⎩ ⎨ ⎧>0=0<0(父进程中)返回子进程的 PID(子进程中)返回 0fork 失败
2.2 完整注释代码(p1.c)
/*
* 演示 fork() 的基本用法
* 编译:gcc -o p1 p1.c
* 运行:./p1
*/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h> /* fork(), getpid() */
int main(int argc, char *argv[])
{
/* getpid() 返回当前进程的 PID(进程标识符)
* 这行只会打印一次,因为 fork() 还没调用 */
printf("hello (pid:%d)\n", (int) getpid());
/*
* fork() 系统调用:创建子进程
*
* 调用之后,系统中有两个进程(父和子),
* 它们都从这里的下一行继续执行。
* 区别只在于 rc 的值不同:
* 父进程:rc = 子进程的PID(正整数)
* 子进程:rc = 0
*/
int rc = fork();
if (rc < 0) {
/* fork 失败,通常是系统资源不足 */
fprintf(stderr, "fork failed\n");
exit(1);
}
else if (rc == 0) {
/*
* 子进程执行这里
* rc == 0 是子进程的标志
* 子进程有自己独立的内存空间(从父进程拷贝而来)
*/
printf("child (pid:%d)\n", (int) getpid());
}
else {
/*
* 父进程执行这里
* rc 存放的是子进程的 PID
* 父进程和子进程的执行顺序是不确定的(由 OS 调度器决定)
*/
printf("parent of %d (pid:%d)\n", rc, (int) getpid());
}
return 0;
}
2.3 执行过程图解
程序开始
│
▼
打印 "hello (pid:29146)" ← 只打印一次
│
▼
调用 fork()
│
├─────────────────────────────┐
│(父进程继续) │(子进程在此"诞生")
▼ ▼
rc = 29147(子进程PID) rc = 0
│ │
▼ ▼
进入 else 分支 进入 else if (rc==0) 分支
打印 "parent of 29147 ..." 打印 "child (pid:29147)"
关键点:输出顺序是不确定的(Non-deterministic)。
父进程可能先打印,也可能子进程先打印,取决于 OS 调度器的决定。两种可能的输出:
可能输出1: 可能输出2:
hello (pid:29146) hello (pid:29146)
parent of 29147 ... child (pid:29147)
child (pid:29147) parent of 29147 ...
三、wait() —— 让父进程等待子进程
3.1 wait() 的作用
wait() 让父进程阻塞自身,直到某个子进程退出才返回。返回值是已退出的子进程的 PID。
这样可以保证输出顺序确定:子进程一定先完成,父进程再继续。
3.2 完整注释代码(p2.c)
/*
* 演示 fork() + wait() 的配合使用
* 编译:gcc -o p2 p2.c
* 运行:./p2
*/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h> /* fork(), getpid() */
#include <sys/wait.h> /* wait() */
int main(int argc, char *argv[])
{
printf("hello (pid:%d)\n", (int) getpid());
int rc = fork(); /* 创建子进程 */
if (rc < 0) {
fprintf(stderr, "fork failed\n");
exit(1);
}
else if (rc == 0) {
/* 子进程:直接打印然后退出 */
printf("child (pid:%d)\n", (int) getpid());
/* 子进程在这里隐式 return 0,退出 */
}
else {
/*
* 父进程:调用 wait() 等待子进程结束
*
* wait(NULL) 表示不关心子进程的退出状态
* 返回值 rc_wait 是已退出的子进程的 PID
*
* 如果子进程先于父进程运行并退出,
* 那父进程调用 wait() 时会立即返回。
* 如果父进程先运行到 wait(),
* 那父进程会在这里"睡觉",等子进程退出后再唤醒。
*/
int rc_wait = wait(NULL);
printf("parent of %d (rc_wait:%d) (pid:%d)\n",
rc, rc_wait, (int) getpid());
}
return 0;
}
3.3 加了 wait() 后的执行流
两种场景,结果都一样:
场景A:父进程先被调度
父进程运行 → 调用 wait() → 阻塞(进入睡眠)
│
子进程被调度运行
打印 "child..."
子进程退出
│
父进程从 wait() 返回 ←────────────┘
打印 "parent of..."
场景B:子进程先被调度
子进程运行 → 打印 "child..." → 子进程退出
父进程运行 → 调用 wait()(子进程已退出,立即返回)
打印 "parent of..."
两种场景下,最终输出顺序都是:
1. hello
2. child
3. parent of...
四、exec() —— 变身为另一个程序
4.1 exec() 做了什么
exec() 不是创建新进程,而是让当前进程"变身"成另一个程序:
- 从磁盘加载指定程序的代码和静态数据
- 覆盖当前进程的代码段和数据段
- 重新初始化堆和栈
- 从新程序的
main()开始执行
成功的exec()调用永远不会返回,因为当前程序已经被替换掉了。
exec() 效果 = 当前进程的外壳(PID不变) 内部完全替换为新程序 \text{exec() 效果} = \frac{\text{当前进程的外壳(PID不变)}}{\text{内部完全替换为新程序}} exec() 效果=内部完全替换为新程序当前进程的外壳(PID不变)
4.2 完整注释代码(p3.c)
/*
* 演示 fork() + exec() + wait() 的经典组合
* 子进程通过 execvp() 运行 wc(词数统计)程序
*
* 编译:gcc -o p3 p3.c
* 运行:./p3
* 注意:需要当前目录下有 p3.c 文件才能看到 wc 的输出
*/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h> /* fork(), execvp() */
#include <string.h> /* strdup() */
#include <sys/wait.h> /* wait() */
int main(int argc, char *argv[])
{
printf("hello (pid:%d)\n", (int) getpid());
int rc = fork(); /* 创建子进程 */
if (rc < 0) {
fprintf(stderr, "fork failed\n");
exit(1);
}
else if (rc == 0) {
/* 子进程执行这里 */
printf("child (pid:%d)\n", (int) getpid());
/*
* 准备传给 exec 的参数数组
* myargs[0] = 要运行的程序名
* myargs[1] = 程序的第一个参数
* myargs[2] = NULL(必须以NULL结尾,表示参数列表结束)
*/
char *myargs[3];
myargs[0] = strdup("wc"); /* 程序名:wc(word count,统计行数/单词/字节) */
myargs[1] = strdup("p3.c"); /* 参数:对 p3.c 文件进行统计 */
myargs[2] = NULL; /* 参数列表结束标志 */
/*
* execvp() 系统调用:
* 第一个参数:程序名(会在 PATH 环境变量的路径中搜索)
* 第二个参数:参数数组
*
* 调用成功后:
* - 当前进程的代码被 wc 程序的代码完全替换
* - 从 wc 的 main() 开始执行
* - 下面的 printf 永远不会被执行到!
*
* 调用失败后才会继续执行下面的代码(如程序不存在)
*/
execvp(myargs[0], myargs);
/* 如果能执行到这里,说明 execvp 失败了 */
printf("this shouldn't print out\n");
}
else {
/*
* 父进程:等待子进程(实际上是 wc 程序)运行完毕
* wait() 返回后,wc 已经执行完,输出已经打印到屏幕
*/
int rc_wait = wait(NULL);
printf("parent of %d (rc_wait:%d) (pid:%d)\n",
rc, rc_wait, (int) getpid());
}
return 0;
}
4.3 fork + exec 的执行过程
父进程(p3,PID=29383)
│
├── fork() ──────────────────────────────┐
│ │
│(父进程) │(子进程,PID=29384)
│ 调用 wait(),阻塞等待 │ 打印 "child (pid:29384)"
│ │ 准备参数 ["wc", "p3.c", NULL]
│ │ 调用 execvp("wc", myargs)
│ │
│ │ ↓ 子进程内部被完全替换
│ │ wc 程序开始运行
│ │ 输出:29 107 1030 p3.c
│ │ wc 程序退出
│ │
│ wait() 返回,rc_wait=29384 ←───────────┘
│ 打印 "parent of 29384..."
│
结束
五、为什么要把 fork() 和 exec() 分开?
这个设计看起来很奇怪——为什么不直接提供一个"创建新进程并运行新程序"的调用?
答案:分开设计让 shell 可以在 fork() 之后、exec() 之前对子进程的环境进行修改。
5.1 Shell 的工作原理
5.2 输出重定向的实现
命令 wc p3.c > newfile.txt 是如何实现的?
普通情况(无重定向):
程序输出 → 文件描述符1(stdout) → 屏幕
重定向后:
程序输出 → 文件描述符1(stdout) → newfile.txt(文件)
实现方法:在 fork 之后、exec 之前,
把文件描述符1关掉,改成指向 newfile.txt
5.3 重定向完整注释代码(p4.c)
/*
* 演示输出重定向的实现原理
* 相当于 shell 执行:wc p4.c > p4.output
*
* 编译:gcc -o p4 p4.c
* 运行:./p4
* 查看结果:cat p4.output
*/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h> /* fork(), close(), STDOUT_FILENO */
#include <string.h> /* strdup() */
#include <fcntl.h> /* open(), O_CREAT, O_WRONLY, O_TRUNC */
#include <sys/wait.h> /* wait() */
int main(int argc, char *argv[])
{
int rc = fork(); /* 创建子进程 */
if (rc < 0) {
fprintf(stderr, "fork failed\n");
exit(1);
}
else if (rc == 0) {
/*
* 子进程:在 exec 之前修改文件描述符,实现重定向
*
* UNIX 文件描述符规则:
* 0 → stdin(标准输入)
* 1 → stdout(标准输出) ← 我们要改的
* 2 → stderr(标准错误)
*
* UNIX 分配文件描述符时,总是从最小的可用数字开始。
*/
/* 第一步:关闭标准输出(文件描述符1) */
close(STDOUT_FILENO); /* STDOUT_FILENO = 1 */
/*
* 第二步:打开目标文件
* 此时文件描述符1已空出,open() 会把1分配给这个新文件
*
* O_CREAT → 文件不存在则创建
* O_WRONLY → 只写模式
* O_TRUNC → 若文件已存在则清空内容
* S_IRWXU → 文件权限(当前用户可读/写/执行)
*/
open("./p4.output", O_CREAT | O_WRONLY | O_TRUNC, S_IRWXU);
/*
* 现在的文件描述符状态:
* 0 → stdin(不变)
* 1 → p4.output(原来是屏幕,现在是文件!)
* 2 → stderr(不变)
*/
/* 第三步:exec 运行 wc,它会向 stdout(现在是文件)输出 */
char *myargs[3];
myargs[0] = strdup("wc");
myargs[1] = strdup("p4.c");
myargs[2] = NULL;
execvp(myargs[0], myargs);
/* wc 的输出会自动写入 p4.output,而不是屏幕!
* exec 调用保留了打开的文件描述符,所以重定向生效 */
}
else {
/* 父进程:等待子进程(wc)执行完毕 */
int rc_wait = wait(NULL);
/* 父进程不知道输出去哪里,也不关心,它的 stdout 没有改变 */
(void) rc_wait; /* 避免编译器"未使用变量"警告 */
}
return 0;
}
5.4 重定向原理图解
调用 close(STDOUT_FILENO) 前: 调用 open() 后(STDOUT_FILENO 空出了):
文件描述符表 文件描述符表
┌────┬──────────────┐ ┌────┬──────────────────┐
│ 0 │ 键盘(stdin) │ │ 0 │ 键盘(stdin) │
│ 1 │ 屏幕(stdout)│ ← 关闭 │ 1 │ p4.output(文件) │ ← 新分配
│ 2 │ 屏幕(stderr)│ │ 2 │ 屏幕(stderr) │
└────┴──────────────┘ └────┴──────────────────┘
exec() 之后,wc 程序运行,向 fd=1 写输出 → 写进了文件,不是屏幕!
六、管道(Pipe)的工作原理
UNIX 管道(|)与重定向原理相似,只是用了 pipe() 系统调用。
命令:grep foo file.txt | wc -l
含义:grep 的输出 → 作为 wc 的输入
内核实现(简化):
┌──────────┐ fd[1]写入 ┌─────────────┐ fd[0]读取 ┌──────────┐
│ grep │ ──────────→ │ 内核管道缓冲 │ ──────────→ │ wc │
│ 进程 │ │ (环形队列) │ │ 进程 │
└──────────┘ └─────────────┘ └──────────┘
grep 的 stdout 被重定向到 pipe 写端
wc 的 stdin 被重定向到 pipe 读端
七、进程控制与信号
除了 fork/exec/wait,UNIX 还提供了信号(Signal)机制让进程之间互发通知:
| 信号 | 快捷键 | 含义 |
|---|---|---|
| SIGINT | Ctrl+C | 中断进程(通常是终止) |
| SIGTSTP | Ctrl+Z | 暂停进程(可用 fg 恢复) |
| SIGKILL | kill -9 PID |
强制杀死进程(不可忽略) |
| SIGTERM | kill PID |
请求进程自行终止 |
进程可以用 signal() 系统调用注册信号处理函数,捕获信号后执行自定义逻辑。
八、用户与权限
| 角色 | 权限 |
|---|---|
| 普通用户(User) | 只能控制自己的进程(发信号、终止等) |
| 超级用户(Root / Superuser) | 可以控制系统中所有进程,执行关机等特权操作 |
“With great power comes great responsibility.”(权力越大,责任越大。)
日常工作尽量用普通账户,需要 root 时要谨慎。
九、常用进程管理工具
| 命令 | 作用 |
|---|---|
ps |
查看当前运行的进程列表 |
top / htop |
实时显示进程的 CPU、内存占用 |
kill <PID> |
向指定进程发送 SIGTERM 信号(请求终止) |
kill -9 <PID> |
发送 SIGKILL,强制终止(无法被进程忽略) |
killall <名称> |
按程序名批量发送信号 |
fg |
把后台暂停的进程恢复到前台运行 |
十、完整可运行演示程序
程序一:fork 基本演示
/*
* fork 完整演示,包含父子进程的执行顺序不确定性
* 编译:gcc -o fork_demo fork_demo.c
* 运行:./fork_demo
*/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void)
{
printf("=== fork 演示 ===\n");
printf("[主进程] 开始,PID = %d\n", (int)getpid());
int rc = fork();
if (rc < 0) {
fprintf(stderr, "fork 失败!\n");
return 1;
}
else if (rc == 0) {
/* 子进程 */
printf("[子进程] PID = %d, 父PID = %d\n",
(int)getpid(), (int)getppid());
printf("[子进程] 我是父进程的拷贝,但有独立内存\n");
}
else {
/* 父进程 */
printf("[父进程] PID = %d, 子进程PID = %d\n",
(int)getpid(), rc);
wait(NULL); /* 等待子进程结束 */
printf("[父进程] 子进程已结束\n");
}
return 0;
}
程序二:fork + exec + wait 完整演示(列出当前目录)
/*
* 经典三件套:fork() + exec() + wait()
* 子进程运行 /bin/ls 列出当前目录内容
*
* 编译:gcc -o forkexec_demo forkexec_demo.c
* 运行:./forkexec_demo
*/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void)
{
printf("=== fork + exec + wait 演示 ===\n");
printf("[父进程] PID = %d,准备创建子进程运行 ls\n", (int)getpid());
int rc = fork();
if (rc < 0) {
fprintf(stderr, "fork 失败!\n");
return 1;
}
else if (rc == 0) {
/* 子进程:变身为 ls 命令 */
printf("[子进程] PID = %d,即将执行 ls -l\n", (int)getpid());
/*
* execl() 的参数:
* 第1个:程序的完整路径
* 第2个起:程序名(argv[0])及各参数
* 最后:NULL 表示参数列表结束
*/
char *args[] = {"ls", "-l", NULL};
execvp("ls", args);
/* 若 execvp 成功,下面这行不会执行 */
fprintf(stderr, "[子进程] execvp 失败!\n");
exit(1);
}
else {
/* 父进程:等待子进程(ls)执行完毕 */
printf("[父进程] 等待子进程完成...\n");
int status;
int finished = wait(&status);
if (WIFEXITED(status)) {
printf("[父进程] 子进程 %d 结束,退出码 = %d\n",
finished, WEXITSTATUS(status));
}
}
return 0;
}
程序三:输出重定向完整演示
/*
* 演示在 fork 之后、exec 之前实现输出重定向
* 将 ls 的输出写入文件而非屏幕
*
* 编译:gcc -o redirect_demo redirect_demo.c
* 运行:./redirect_demo
* 结果:cat ls_output.txt
*/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h> /* open(), O_CREAT 等 */
#include <sys/wait.h>
int main(void)
{
printf("=== 输出重定向演示 ===\n");
printf("[父进程] 将把 ls 的输出重定向到 ls_output.txt\n");
int rc = fork();
if (rc < 0) {
fprintf(stderr, "fork 失败!\n");
return 1;
}
else if (rc == 0) {
/* 子进程:实现重定向,然后 exec ls */
/* 第1步:关闭标准输出(fd=1) */
close(STDOUT_FILENO);
/* 第2步:打开目标文件,它会自动占用 fd=1(最小空闲描述符)*/
int fd = open("ls_output.txt",
O_CREAT | O_WRONLY | O_TRUNC,
0644); /* 0644 = 文件权限:所有者读写,其他人只读 */
if (fd < 0) {
fprintf(stderr, "打开文件失败!\n");
exit(1);
}
/* 验证:fd 应该等于 1(STDOUT_FILENO) */
/* printf("fd = %d\n", fd); */ /* 此时 stdout 已是文件,不会打印到屏幕 */
/* 第3步:exec ls,其输出自动写入文件 */
char *args[] = {"ls", "-l", NULL};
execvp("ls", args);
/* 若执行到这里说明 exec 失败 */
exit(1);
}
else {
/* 父进程:等待子进程完成 */
wait(NULL);
printf("[父进程] ls 已执行完毕,结果在 ls_output.txt\n");
printf("[父进程] 请运行 'cat ls_output.txt' 查看结果\n");
}
return 0;
}
十一、fork/exec/wait 关系总结
十二、exec() 的六种变体(Linux)
Linux 提供了多种 exec 变体,满足不同场景:
| 函数名 | 参数传递方式 | 搜索 PATH | 传递环境变量 |
|---|---|---|---|
execl() |
可变参数列表 | 否(需完整路径) | 继承父进程 |
execlp() |
可变参数列表 | 是 | 继承父进程 |
execle() |
可变参数列表 | 否 | 自定义 |
execv() |
数组 | 否 | 继承父进程 |
execvp() |
数组 | 是 | 继承父进程 |
execvpe() |
数组 | 是 | 自定义 |
记忆规律:
- 带
l(list)→ 参数以可变参数列表传递,最后以NULL结尾 - 带
v(vector)→ 参数以字符串数组传递 - 带
p(path)→ 会在PATH环境变量中搜索程序 - 带
e(environment)→ 可以传入自定义环境变量数组
十三、关键术语速查
| 英文 | 中文 | 简单解释 |
|---|---|---|
| fork() | 复制进程 | 创建一个与当前进程几乎完全相同的子进程 |
| exec() | 替换进程映像 | 用新程序替换当前进程的代码和数据 |
| wait() | 等待子进程 | 阻塞父进程直到子进程退出 |
| PID | 进程标识符 | 每个进程唯一的整数编号 |
| Non-determinism | 不确定性 | 父子进程执行顺序由调度器决定,不可预知 |
| File Descriptor | 文件描述符 | 用整数表示打开的文件(0=stdin,1=stdout,2=stderr) |
| Redirection | 重定向 | 把程序的输入/输出从默认设备改到文件 |
| Pipe | 管道 | 连接两个进程,一个的输出成为另一个的输入 |
| Signal | 信号 | 进程间发送异步通知的机制 |
| Zombie | 僵尸进程 | 已退出但未被父进程回收的进程 |
| Superuser / Root | 超级用户 | 拥有完全系统控制权的特殊账户 |
机制:受限直接执行 — 详细中文解析
原书:《Operating Systems: Three Easy Pieces》(OSTEP) 第6章
一、核心问题:虚拟化CPU的两大挑战
OS 要把一个物理 CPU 虚拟成"无数个",核心思路是时分复用——轮流运行各个进程。但这带来两个关键挑战:
两大挑战 = { 性能(Performance) 如何在不引入过多开销的前提下实现虚拟化? 控制(Control) 如何在高效运行进程的同时,保持对CPU的掌控权? \text{两大挑战} = \begin{cases} \text{性能(Performance)} & \text{如何在不引入过多开销的前提下实现虚拟化?} \\ \text{控制(Control)} & \text{如何在高效运行进程的同时,保持对CPU的掌控权?} \end{cases} 两大挑战={性能(Performance)控制(Control)如何在不引入过多开销的前提下实现虚拟化?如何在高效运行进程的同时,保持对CPU的掌控权?
控制权尤为重要:若 OS 失去控制,一个进程可以永远霸占 CPU,或者访问它不该看到的内存。
二、基本思路:直接执行(Direct Execution)
最朴素的想法:直接把程序放到 CPU 上跑。
2.1 无限制的直接执行协议
OS 程序
──────────────────────────────────────────
在进程列表中创建条目
为程序分配内存
把程序代码从磁盘加载到内存
用 argc/argv 设置好栈
清空寄存器
执行 call main()
运行 main()
...执行各种指令...
从 main() return
释放进程内存
从进程列表中移除
这样做速度快,但有两个致命问题:
- 程序可能做"坏事":如直接读写磁盘、占用所有内存——OS 如何阻止?
- OS 何时能重新拿回 CPU?:如果程序一直在跑,OS 根本没机会执行。
三、问题一:受限操作(Restricted Operations)
3.1 两种处理器模式
解决方案是引入两种CPU特权级别:
内核态(Kernel Mode) 用户态(User Mode)
────────────────────────── ──────────────────────────
OS 在此模式运行 普通程序在此模式运行
可以执行任何指令 受限:不能直接访问I/O
可以访问所有硬件资源 不能读写任意内存
可以修改特权寄存器 不能执行特权指令
试图在用户态执行特权操作 → CPU 产生异常 → OS 通常终止该进程。
3.2 系统调用:用户程序请求内核服务的桥梁
当用户程序需要做"特权操作"时(如读磁盘、申请内存),它执行系统调用(System Call):
3.3 系统调用看起来像普通函数调用的原因
C 程序员调用 open()、read() 时,和调用普通函数没有区别。这是因为:
用户代码调用 open()
↓
C 标准库中的 open() 函数
↓
库函数内部:
1. 按约定把参数放到寄存器/栈
2. 把系统调用号写入特定位置(如寄存器 eax)
3. 执行 trap 指令(手写汇编)
↓
硬件陷入内核
C 库帮你写好了这些汇编代码,用户不需要自己写汇编。
3.4 陷阱表(Trap Table):启动时预设"跳转目录"
OS 如何知道陷入内核后该执行哪段代码?
答案:在机器启动时(boot time),OS 在内核态初始化一张陷阱表(Trap Table),告诉硬件各种异常事件对应的处理函数地址:
陷阱表(Trap Table)
┌─────────────────┬──────────────────────────┐
│ 事件类型 │ 处理函数地址 │
├─────────────────┼──────────────────────────┤
│ 系统调用 │ syscall_handler 地址 │
│ 硬盘中断 │ disk_handler 地址 │
│ 键盘中断 │ keyboard_handler 地址 │
│ 非法指令 │ illegal_instr_handler │
│ 除以零 │ div_zero_handler │
└─────────────────┴──────────────────────────┘
硬件记住这张表的位置,此后遇到对应事件直接跳过去。
用户程序无法修改陷阱表(修改陷阱表本身是特权指令),所以程序无法把 OS 引导到任意地址执行。
3.5 系统调用号:用数字代替地址
用户程序不能直接指定要跳到内核哪个地址(否则可以绕过权限检查),只能提供一个系统调用号:
系统调用号 → 内核查表 对应的处理函数 \text{系统调用号} \xrightarrow{\text{内核查表}} \text{对应的处理函数} 系统调用号内核查表对应的处理函数
例如(Linux x86-64 上):
| 调用号 | 对应系统调用 |
|---|---|
| 0 | read() |
| 1 | write() |
| 2 | open() |
| 60 | exit() |
这层间接性是一种保护:程序只能请求合法的服务编号,不能跳到任意位置。
3.6 受限直接执行完整协议(含系统调用)
OS(启动时,内核态) 硬件 程序(用户态)
─────────────────────────────────────────────────────────────────────
初始化陷阱表
记住 syscall_handler 地址
─────────────────────────────────────────────────────────────────────
(运行进程时)
创建进程列表条目
分配内存
加载程序到内存
设置用户栈(argv)
填充内核栈(寄存器/PC)
执行 return-from-trap
恢复寄存器(从内核栈)
切换到用户态
跳转到 main()
运行 main()
...
调用系统调用
执行 trap 进入OS
保存寄存器(到内核栈)
切换到内核态
跳转到陷阱处理程序
处理陷阱
执行系统调用的具体工作
执行 return-from-trap
恢复寄存器(从内核栈)
切换到用户态
跳转到 trap 之后的指令
...
从 main() 返回
执行 trap(exit())
释放进程内存
从进程列表移除
四、问题二:进程间切换(Switching Between Processes)
4.1 核心矛盾
进程在跑 → OS 没在跑 → OS 没在跑就啥也干不了 → 怎么切换进程?
核心矛盾: 进程运行中 ⇒ OS 不在CPU上 ⇒ OS 无法主动切换 \text{核心矛盾:}\ \text{进程运行中} \Rightarrow \text{OS 不在CPU上} \Rightarrow \text{OS 无法主动切换} 核心矛盾: 进程运行中⇒OS 不在CPU上⇒OS 无法主动切换
4.2 方案一:协作式(Cooperative)—— 靠进程自觉
思路:相信进程会"讲礼貌",定期主动放弃 CPU。
进程放弃 CPU 的两种方式:
- 调用系统调用(如
open()、read()、yield()),这会陷入 OS,OS 趁机切换 - 触发异常(如除以零、非法内存访问),OS 获得控制权
进程A运行
│
├── 调用 read() → 陷入OS → OS可以切换到进程B
│
├── 调用 yield() → 主动告诉OS"我先让出来"
│
└── 访问非法内存 → 产生异常 → OS介入(通常杀掉进程A)
缺陷:如果进程进入死循环,永远不调用系统调用怎么办?
→ 唯一办法:重启机器(真的,书里就这么说的)
4.3 方案二:非协作式(Non-Cooperative)—— 定时器中断
思路:硬件上设置一个定时器(Timer),每隔几毫秒强制产生一个中断,OS 趁机夺回 CPU。
时间轴:
0ms 10ms 20ms 30ms 40ms
|-------|-------|-------|-------|---
进程A运行 定时器中断! 进程B运行 定时器中断!
↑ ↑
OS获得控制权 OS获得控制权
决定切换到进程B 决定继续运行B或切换
定时器的启动本身也是特权操作(启动时由 OS 在内核态完成),普通进程无法关闭定时器。
中断发生时,硬件自动:
- 把当前进程的寄存器保存到该进程的内核栈
- 切换到内核态
- 跳转到 OS 预设的中断处理程序
五、上下文切换(Context Switch)
5.1 什么是上下文切换
当 OS 决定从进程 A 切换到进程 B 时,需要:
- 保存 A 的上下文(所有寄存器、PC、内核栈指针)→ 存入 A 的进程结构体
- 恢复 B 的上下文(从 B 的进程结构体读出)→ 写入真实寄存器
- 执行
return-from-trap,CPU 跳入 B 继续运行
上下文就是一个进程运行到某个时刻的"完整快照"——寄存器里的所有值。
5.2 两种寄存器保存/恢复
切换过程中发生两次不同级别的保存/恢复:
第一次(硬件自动,定时器中断时):
用户态寄存器(通用寄存器、PC、flags 等)
保存到:进程A的内核栈
第二次(OS软件显式,决定切换时):
内核态寄存器(调用 switch() 时的内核寄存器)
保存到:进程A的进程结构体(proc_t)
从进程B的进程结构体恢复到真实寄存器
5.3 完整切换时间线
OS(启动时) 硬件 进程A(用户态) 进程B(用户态)
────────────────────────────────────────────────────────────────────────────
初始化陷阱表
记住 syscall_handler
记住 timer_handler
启动定时器
每隔Xms中断CPU
进程A运行...
定时器中断!
保存A的寄存器→内核栈A
切换到内核态
跳转到中断处理程序
处理中断
调用 switch() 例程
保存A的内核寄存器→proc_t(A)
从proc_t(B)恢复B的内核寄存器
切换到B的内核栈
执行 return-from-trap
从内核栈B恢复B的寄存器
切换到用户态
跳转到B的PC
进程B运行...
5.4 xv6 上下文切换汇编代码注释版
/*
* xv6 的 swtch() 函数(x86 汇编)
* 功能:保存当前进程的内核寄存器,加载目标进程的内核寄存器
* 原型:void swtch(struct context *old, struct context *new);
*
* 注意:这是内核级别的切换,不是用户级别的。
* 此时用户寄存器已经由硬件保存到内核栈了。
*/
/*
.globl swtch
swtch:
# ── 保存旧进程(当前进程A)的寄存器 ──
movl 4(%esp), %eax # 把第一个参数(old指针)放入eax
# 4(%esp) 是因为栈顶是返回地址,参数在其上方
popl 0(%eax) # 把栈顶的"旧IP(返回地址)"弹出,保存到old->eip
# 此时esp已经加4(弹出了返回地址)
movl %esp, 4(%eax) # 保存当前栈指针(esp)到 old->esp
movl %ebx, 8(%eax) # 保存 ebx 到 old->ebx
movl %ecx, 12(%eax) # 保存 ecx 到 old->ecx
movl %edx, 16(%eax) # 保存 edx 到 old->edx
movl %esi, 20(%eax) # 保存 esi 到 old->esi
movl %edi, 24(%eax) # 保存 edi 到 old->edi
movl %ebp, 28(%eax) # 保存基址指针到 old->ebp
# ── 加载新进程(目标进程B)的寄存器 ──
movl 4(%esp), %eax # 重新读第一个参数位置
# (此时esp已变化,但new是第2个参数)
# 实际上这里应该读 new 指针
movl 28(%eax), %ebp # 恢复 ebp(最先恢复,后续可能用到)
movl 24(%eax), %edi # 恢复 edi
movl 20(%eax), %esi # 恢复 esi
movl 16(%eax), %edx # 恢复 edx
movl 12(%eax), %ecx # 恢复 ecx
movl 8(%eax), %ebx # 恢复 ebx
movl 4(%eax), %esp # ★ 关键:切换栈指针到进程B的内核栈!
# 从这一刻起,我们"活在"进程B的内核栈上了
pushl 0(%eax) # 把进程B的返回地址(eip)压入新栈
ret # 弹出这个返回地址并跳转过去
# 效果:从进程B的内核上下文"返回",继续B的执行
*/
用 C 语言逻辑伪代码解释相同的操作:
/*
* 上下文切换的C语言逻辑伪代码(仅用于理解,不可直接编译运行)
* 真实实现必须用汇编,因为需要直接操作寄存器
*/
/* 上下文结构体:保存内核寄存器 */
struct context {
int eip; /* 指令指针(返回地址) */
int esp; /* 栈指针 */
int ebx;
int ecx;
int edx;
int esi;
int edi;
int ebp;
};
/*
* 概念上的 switch() 做了什么:
*
* 1. 把当前CPU寄存器的值写入 old 结构体("拍照"保存进程A的状态)
* 2. 把 new 结构体的值写入CPU寄存器("还原"进程B的状态)
* 3. 切换内核栈指针(esp 指向进程B的内核栈)
* 4. return(跳到进程B上次在内核中执行到的位置)
*
* 调用者看来:调用 switch(A的ctx, B的ctx) 时"在A的世界",
* 函数返回时"突然在B的世界"——就像时间旅行!
*/
六、完整可运行C程序
程序一:测量系统调用开销
/*
* 测量系统调用的时间开销
* 方法:对一个极简系统调用(0字节read)循环一百万次,计算平均耗时
*
* 编译:gcc -O0 -o syscall_cost syscall_cost.c
* 运行:./syscall_cost
*/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h> /* read(), close() */
#include <fcntl.h> /* open() */
#include <sys/time.h> /* gettimeofday() */
/*
* 获取当前时间,单位:微秒(microsecond,1秒 = 1,000,000 微秒)
* gettimeofday() 返回自1970年1月1日以来的秒数和微秒数
*/
static long long get_time_us(void)
{
struct timeval tv;
gettimeofday(&tv, NULL);
return (long long)tv.tv_sec * 1000000LL + tv.tv_usec;
}
int main(void)
{
const int ITERATIONS = 1000000; /* 循环100万次,减小误差 */
int i;
char buf[1]; /* 读取缓冲区(实际读0字节,但需要有效指针) */
/* 打开 /dev/null:一个总是可读但读到EOF的特殊文件
* 对它执行 read() 是最简单的系统调用(几乎不做任何工作)*/
int fd = open("/dev/null", O_RDONLY);
if (fd < 0) {
fprintf(stderr, "无法打开 /dev/null\n");
return 1;
}
printf("=== 系统调用开销测量 ===\n");
printf("循环次数:%d\n", ITERATIONS);
/* 记录开始时间 */
long long start = get_time_us();
/* 循环执行系统调用 */
for (i = 0; i < ITERATIONS; i++) {
/*
* read(fd, buf, 0) 读取0个字节
* 这个调用会:
* 1. 用户态执行 trap 指令(陷入内核)
* 2. 内核处理(几乎什么都不做)
* 3. return-from-trap 回到用户态
* 几乎所有时间都花在 用户态↔内核态 的切换上
*/
read(fd, buf, 0);
}
/* 记录结束时间 */
long long end = get_time_us();
close(fd);
long long total_us = end - start;
double avg_ns = (double)total_us * 1000.0 / ITERATIONS;
printf("总耗时:%lld 微秒\n", total_us);
printf("单次系统调用平均耗时:%.2f 纳秒\n", avg_ns);
printf("\n说明:现代系统单次系统调用约需 100~500 纳秒\n");
printf(" (模态切换 + 内核栈操作 + 返回的总开销)\n");
return 0;
}
程序二:演示用户态/内核态切换(通过信号模拟中断)
/*
* 演示定时器中断的概念:
* 用 SIGALRM 信号模拟定时器中断,展示OS如何周期性地"打断"进程
*
* 编译:gcc -o timer_demo timer_demo.c
* 运行:./timer_demo
*/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h> /* signal(), SIGALRM */
#include <sys/time.h> /* setitimer() */
/* 模拟"调度计数器",记录定时器中断触发次数 */
static volatile int interrupt_count = 0;
static volatile int current_process = 0; /* 0=进程A, 1=进程B */
/*
* 定时器中断处理函数(类比OS的定时器中断处理程序)
* 真实OS中:硬件中断→陷入内核→OS决定是否切换进程
* 这里用信号处理函数模拟这个过程
*/
void timer_interrupt_handler(int signum)
{
interrupt_count++;
printf("\n[定时器中断 #%d] OS获得控制权!", interrupt_count);
/* 模拟调度决策:每次中断切换进程 */
current_process = 1 - current_process;
printf(" 切换到进程%c\n", current_process == 0 ? 'A' : 'B');
}
int main(void)
{
printf("=== 定时器中断演示(模拟非协作式调度)===\n\n");
/* 注册信号处理函数(类比OS启动时安装中断处理程序)*/
signal(SIGALRM, timer_interrupt_handler);
/* 设置定时器:每500毫秒触发一次SIGALRM(模拟定时器中断)*/
struct itimerval timer;
timer.it_value.tv_sec = 0;
timer.it_value.tv_usec = 500000; /* 首次触发:500ms后 */
timer.it_interval.tv_sec = 0;
timer.it_interval.tv_usec = 500000; /* 之后每500ms触发 */
setitimer(ITIMER_REAL, &timer, NULL);
printf("[OS] 已启动定时器,每500ms产生一次中断\n");
printf("[OS] 初始运行:进程A\n\n");
/* 模拟两个进程轮流"运行"(实际上还是同一个程序,只是打印不同内容)*/
int step = 0;
while (interrupt_count < 6) { /* 运行到发生6次中断为止 */
step++;
if (current_process == 0) {
printf(" [进程A] 正在执行第 %d 步...\n", step);
} else {
printf(" [进程B] 正在执行第 %d 步...\n", step);
}
/* 模拟进程做一些工作(sleep让输出慢一点,方便观察)*/
usleep(200000); /* 等待200ms */
}
printf("\n[OS] 演示结束,共发生 %d 次定时器中断\n", interrupt_count);
return 0;
}
程序三:系统调用号直接调用演示(Linux x86-64)
/*
* 演示系统调用号的概念:
* 直接使用系统调用号调用 write(),绕过 C 库
* 展示系统调用号→处理函数的映射机制
*
* 编译(仅限 Linux x86-64):gcc -o syscall_num syscall_num.c
* 运行:./syscall_num
*/
#include <stdio.h>
#include <unistd.h>
#include <sys/syscall.h> /* SYS_write, SYS_exit 等常量 */
int main(void)
{
printf("=== 系统调用号演示 ===\n\n");
printf("常用系统调用号(Linux x86-64):\n");
printf(" SYS_read = %d\n", SYS_read); /* 通常是 0 */
printf(" SYS_write = %d\n", SYS_write); /* 通常是 1 */
printf(" SYS_open = %d\n", SYS_open); /* 通常是 2 */
printf(" SYS_close = %d\n", SYS_close); /* 通常是 3 */
printf(" SYS_exit = %d\n", SYS_exit); /* 通常是 60 */
printf("\n直接用系统调用号调用 write()(不经过C库封装):\n");
/*
* syscall() 函数:直接触发系统调用
* 第1个参数:系统调用号
* 后续参数:传给系统调用的参数
*
* syscall(SYS_write, fd, buf, len)
* 等价于 write(fd, buf, len)
* 但前者直接执行 trap 指令,后者经过C库的包装层
*/
const char *msg = " [直接系统调用] 这是通过SYS_write号直接输出的!\n";
long len = 0;
while (msg[len]) len++; /* 手动计算字符串长度,不用strlen */
/* 直接调用内核:不经过任何C库函数 */
syscall(SYS_write,
STDOUT_FILENO, /* fd=1,标准输出 */
msg, /* 数据缓冲区 */
len); /* 字节数 */
printf("\n两种调用方式对比:\n");
printf(" C库: printf/write → C库包装 → trap指令 → 内核\n");
printf(" 直接: syscall(号) → trap指令 → 内核\n");
printf(" 结果相同,C库方式更方便,直接方式更底层\n");
return 0;
}
七、整体架构总结
八、协作式 vs 非协作式对比
协作式调度(Cooperative)
─────────────────────────
优点:简单,开销小
缺点:进程死循环 → OS 永远拿不回控制权 → 只能重启
非协作式调度(Non-Cooperative / Preemptive)
─────────────────────────────────────────────
优点:OS 始终能夺回控制权,任何情况都有效
缺点:需要硬件定时器支持,上下文切换有开销
现代OS:全部采用非协作式(抢占式)调度
九、关键性能数据
根据实测(lmbench 工具):
| 时期 | 处理器 | 系统调用耗时 | 上下文切换耗时 |
|---|---|---|---|
| 1996年 | 200 MHz P6 | 约 4 微秒 | 约 6 微秒 |
| 现代系统 | 2~3 GHz | 约 0.2~0.5 微秒 | 约 1~3 微秒 |
开销来源:
系统调用总开销 = trap指令执行 ⏟ 模态切换 + 寄存器保存/恢复 ⏟ 内核栈操作 + 内核处理实际工作 ⏟ 可变 + return-from-trap ⏟ 模态切换回来 \text{系统调用总开销} = \underbrace{\text{trap指令执行}}_{\text{模态切换}} + \underbrace{\text{寄存器保存/恢复}}_{\text{内核栈操作}} + \underbrace{\text{内核处理实际工作}}_{\text{可变}} + \underbrace{\text{return-from-trap}}_{\text{模态切换回来}} 系统调用总开销=模态切换
trap指令执行+内核栈操作
寄存器保存/恢复+可变
内核处理实际工作+模态切换回来
return-from-trap
十、关键术语速查
| 英文 | 中文 | 简单解释 |
|---|---|---|
| Limited Direct Execution | 受限直接执行 | 程序直接在CPU上跑,但受限于用户态权限 |
| User Mode | 用户态 | 受限的CPU执行模式,普通程序运行其中 |
| Kernel Mode | 内核态 | 特权CPU执行模式,OS运行其中 |
| Trap | 陷阱/系统调用陷入 | 从用户态主动进入内核态的机制 |
| Trap Table | 陷阱表 | 启动时建立的事件→处理函数地址映射表 |
| System Call Number | 系统调用号 | 标识要请求哪个内核服务的整数 |
| Return-from-trap | 从陷阱返回 | 从内核态回到用户态的特殊指令 |
| Timer Interrupt | 定时器中断 | 硬件定期触发,强制打断当前进程 |
| Context Switch | 上下文切换 | 保存A的寄存器、恢复B的寄存器,切换执行进程 |
| Cooperative Scheduling | 协作式调度 | 进程自愿放弃CPU(靠系统调用或yield) |
| Preemptive Scheduling | 抢占式调度 | OS通过定时器强制夺回CPU |
| Kernel Stack | 内核栈 | 每个进程在内核态使用的独立栈空间 |
| Interrupt Handler | 中断处理程序 | OS中处理硬件中断的函数 |
第七章:调度算法入门详解
本文对应 OSTEP(操作系统:三个简单部分)第七章,力求用最易懂的语言讲清楚每个概念。
一、什么是调度?
操作系统里同时跑着很多进程,但 CPU 同一时刻只能运行一个。**调度(Scheduling)**就是操作系统决定"现在该轮到谁用 CPU"的策略。
调度的思想其实比计算机更古老——工厂流水线、超市收银台,都需要调度。
二、简化假设(Workload Assumptions)
为了一步步分析问题,教材先做了 5 个"理想化"假设:
| 编号 | 假设内容 | 现实中是否成立 |
|---|---|---|
| 1 | 每个任务运行时间相同 | 否 |
| 2 | 所有任务同时到达 | 否 |
| 3 | 任务一旦开始就跑完 | 否 |
| 4 | 任务只用 CPU,不做 I/O | 否 |
| 5 | 调度器事先知道每个任务的运行时长 | 否 |
后面每介绍一个新算法,就会放开一条假设,让模型越来越贴近现实。
三、评价指标(Metrics)
3.1 周转时间(Turnaround Time)
衡量任务从到达到完成花了多久,是性能指标。
T t u r n a r o u n d = T c o m p l e t i o n − T a r r i v a l T_{turnaround} = T_{completion} - T_{arrival} Tturnaround=Tcompletion−Tarrival
在"所有任务同时到达"的假设下, T a r r i v a l = 0 T_{arrival} = 0 Tarrival=0,所以:
T t u r n a r o u n d = T c o m p l e t i o n T_{turnaround} = T_{completion} Tturnaround=Tcompletion
3.2 响应时间(Response Time)
衡量任务从到达到第一次被执行要等多久,是交互体验指标。
T r e s p o n s e = T f i r s t r u n − T a r r i v a l T_{response} = T_{firstrun} - T_{arrival} Tresponse=Tfirstrun−Tarrival
响应时间越短,用户感觉越流畅,就像打字后不会等很久才看到字出现。
3.3 两个指标的矛盾
性能(周转时间短)和公平(响应时间短)往往不能同时满足,这是系统设计中常见的权衡取舍(trade-off)。
四、先来先服务(FIFO / FCFS)
First In, First Out,排队谁先来谁先做,就像银行叫号。
4.1 正常情况示例
三个任务 A、B、C 同时到达,每个都需要 10 秒:
时间轴:
|<-- A (10s) -->|<-- B (10s) -->|<-- C (10s) -->|
0 10 20 30
平均周转时间:
T ˉ = 10 + 20 + 30 3 = 20 秒 \bar{T} = \frac{10 + 20 + 30}{3} = 20 \text{ 秒} Tˉ=310+20+30=20 秒
4.2 问题:护航效应(Convoy Effect)
放开假设 1(任务时间不再相等):A 需要 100 秒,B 和 C 只需 10 秒。
时间轴:
|<---------- A (100s) ---------->|<- B ->|<- C ->|
0 100 110 120
平均周转时间:
T ˉ = 100 + 110 + 120 3 = 110 秒 \bar{T} = \frac{100 + 110 + 120}{3} = 110 \text{ 秒} Tˉ=3100+110+120=110 秒
B 和 C 明明只需 10 秒,却要苦等 A 跑完 100 秒。就像超市里你只买了一瓶水,前面的人推着三车货物——非常痛苦。
五、最短任务优先(SJF)
Shortest Job First:先做最短的任务,再做次短的,以此类推。
5.1 改进示例(同时到达)
同样是 A(100s)、B(10s)、C(10s),用 SJF:
时间轴:
|<- B ->|<- C ->|<---------- A (100s) ---------->|
0 10 20 120
平均周转时间:
T ˉ = 10 + 20 + 120 3 = 50 秒 \bar{T} = \frac{10 + 20 + 120}{3} = 50 \text{ 秒} Tˉ=310+20+120=50 秒
比 FIFO 的 110 秒减少了一半以上!
SJF 结论:在所有任务同时到达的前提下,SJF 是最优的调度算法。
5.2 新问题:任务不同时到达
放开假设 2(任务可以在不同时刻到达):
- A 在 t = 0 t=0 t=0 到达,需要 100 秒
- B、C 在 t = 10 t=10 t=10 到达,各需要 10 秒
SJF(非抢占)的结果:
时间轴:
[B,C 在此到达]
|
|<---------- A (100s) ---------->|<- B ->|<- C ->|
0 10 100 110 120
B 和 C 虽然很短,但 A 已经在跑了,SJF 不会打断它。
平均周转时间:
T ˉ = ( 100 − 0 ) + ( 110 − 10 ) + ( 120 − 10 ) 3 = 100 + 100 + 110 3 ≈ 103.3 秒 \bar{T} = \frac{(100-0) + (110-10) + (120-10)}{3} = \frac{100+100+110}{3} \approx 103.3 \text{ 秒} Tˉ=3(100−0)+(110−10)+(120−10)=3100+100+110≈103.3 秒
护航效应又出现了!
六、最短剩余时间优先(STCF)
Shortest Time-to-Completion First,也叫 PSJF(抢占式 SJF)。
6.1 核心思想
放开假设 3(任务可以被中断):当新任务到达时,比较"新任务的运行时间"和"当前任务的剩余时间",谁短就先跑谁。
6.2 示例
同样是 A(100s, t=0)、B(10s, t=10)、C(10s, t=10):
时间轴:
[B,C 在此到达,A 剩余 90s,B/C 各 10s,所以先跑 B 和 C]
|
|<A: 10s>|<-B(10s)->|<-C(10s)->|<---------- A 剩余 90s ---------->|
0 10 20 30 120
平均周转时间:
T ˉ = ( 120 − 0 ) + ( 20 − 10 ) + ( 30 − 10 ) 3 = 120 + 10 + 20 3 = 50 秒 \bar{T} = \frac{(120-0) + (20-10) + (30-10)}{3} = \frac{120+10+20}{3} = 50 \text{ 秒} Tˉ=3(120−0)+(20−10)+(30−10)=3120+10+20=50 秒
比 SJF 的 103.3 秒好得多!
STCF 结论:在任务可以随时到达的前提下,STCF 是最优的(周转时间最短)。
七、STCF 的缺陷:响应时间差
STCF 对周转时间很好,但对响应时间很糟糕。
例子:三个任务同时到达,每个跑 5 秒,SJF(即不抢占)下:
|<--- A(5s) --->|<--- B(5s) --->|<--- C(5s) --->|
0 5 10 15
响应时间:
- A: 0 0 0 秒(立刻开始)
- B: 5 5 5 秒(等 A 跑完)
- C: 10 10 10 秒(等 A、B 都跑完)
T ˉ r e s p o n s e = 0 + 5 + 10 3 = 5 秒 \bar{T}_{response} = \frac{0+5+10}{3} = 5 \text{ 秒} Tˉresponse=30+5+10=5 秒
C 要等 10 秒才第一次被执行到,对于交互式程序(比如文字编辑器)这是无法接受的。
八、轮转调度(Round Robin,RR)
8.1 核心思想
不让任何任务独占 CPU,而是给每个任务一小段时间(叫做时间片 / time slice / quantum),时间到了就换下一个,循环往复。
8.2 示例对比
三个任务 A、B、C 同时到达,每个需要 5 秒,时间片 = 1 秒:
SJF(顺序执行):
|AAAAA|BBBBB|CCCCC|
0 5 10 15
RR(每 1 秒切换):
|A|B|C|A|B|C|A|B|C|A|B|C|A|B|C|
0 1 2 3 4 5 6 7 8 9 ... 15
响应时间对比:
| 算法 | A 响应时间 | B 响应时间 | C 响应时间 | 平均 |
|---|---|---|---|---|
| SJF | 0 | 5 | 10 | 5 秒 |
| RR | 0 | 1 | 2 | 1 秒 |
RR 的平均响应时间大幅优于 SJF!
8.3 RR 的代价:周转时间差
用 RR 时(时间片=1秒):
- A 在 t = 13 t=13 t=13 完成
- B 在 t = 14 t=14 t=14 完成
- C 在 t = 15 t=15 t=15 完成
T ˉ t u r n a r o u n d = 13 + 14 + 15 3 = 14 秒 \bar{T}_{turnaround} = \frac{13+14+15}{3} = 14 \text{ 秒} Tˉturnaround=313+14+15=14 秒
而 SJF 的平均周转时间只有 5 5 5 秒。RR 把每个任务都拖得很长——对周转时间来说几乎是最差的策略。
8.4 时间片长度的权衡
时间片太短:
优点:响应时间好
缺点:上下文切换太频繁,切换本身消耗大量 CPU
时间片太长:
优点:切换开销小
缺点:响应时间差,退化成 FIFO
摊销(Amortization)原则:让切换开销在足够多的计算中"均摊"掉。比如时间片 10ms,切换耗时 1ms,则 10% 时间浪费在切换上;若时间片改为 100ms,则切换开销降到 1% 以下。
注意:上下文切换的代价不仅是保存/恢复寄存器,还包括 CPU 缓存、TLB、分支预测器中的状态全部失效,这些隐性开销也很大。
九、算法总结对比
| 算法 | 核心思想 | 周转时间 | 响应时间 | 是否抢占 | 关键缺陷 |
|---|---|---|---|---|---|
| FIFO | 先来先服务 | 一般 | 差 | 否 | 护航效应 |
| SJF | 最短任务先做 | 好(同时到达时最优) | 差 | 否 | 晚到的短任务也要等 |
| STCF | 剩余最短的先做 | 好(最优) | 差 | 是 | 对交互不友好 |
| RR | 轮流分时间片 | 差 | 好 | 是 | 拖长了每个任务的完成时间 |
| 核心矛盾: |
- 想让周转时间短 → 让短任务尽快做完(SJF/STCF),但长任务要等很久
- 想让响应时间短 → 公平轮转(RR),但每个任务都被拖长了
这是系统设计中不可避免的二律背反,鱼与熊掌不可兼得。
十、处理 I/O(放开假设 4)
真实程序会做磁盘读写、网络请求等 I/O 操作。I/O 期间进程不需要 CPU,只是等待。
10.1 朴素做法(浪费)
进程 A(每 10ms 做一次 I/O)、进程 B(纯 CPU 50ms):
时间轴(未优化):
CPU: |A10ms|等I/O|A10ms|等I/O|...|B50ms|
Disk: | |IO | |IO |...| |
CPU 在等 I/O 时完全空闲——浪费!
10.2 优化做法:重叠(Overlap)
把 A 的每个 CPU 小片(10ms)当作独立的"小任务",I/O 等待期间切换去跑 B:
时间轴(优化后):
CPU: |A|B |A|B |A|B |A|B |A|B |
Disk: | |IO| |IO| |IO| |IO| |IO|
CPU 和磁盘同时工作,资源利用率大幅提升。
原则:尽可能让 CPU 和 I/O 设备同时忙碌(重叠操作),而不是串行等待。
十一、C 语言代码演示
下面用 C 语言模拟 FIFO、SJF、RR 三种算法,计算平均周转时间和响应时间。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* 定义进程结构体 */
typedef struct {
char name; /* 进程名称,如 'A', 'B', 'C' */
int arrival; /* 到达时间 */
int burst; /* 需要的 CPU 时间(总量) */
int remaining; /* 剩余需要执行的时间 */
int finish; /* 完成时间 */
int first_run; /* 第一次被调度的时间 */
int started; /* 是否已经开始执行过(0=未开始,1=已开始) */
} Process;
/* 打印分隔线 */
void print_sep(void) {
printf("----------------------------------------\n");
}
/* 计算并打印统计结果 */
void print_stats(Process procs[], int n) {
double total_turnaround = 0, total_response = 0;
printf("%-6s %-8s %-8s %-12s %-12s\n",
"进程", "到达", "完成", "周转时间", "响应时间");
print_sep();
for (int i = 0; i < n; i++) {
int turnaround = procs[i].finish - procs[i].arrival;
int response = procs[i].first_run - procs[i].arrival;
total_turnaround += turnaround;
total_response += response;
printf(" %c %-8d %-8d %-12d %-12d\n",
procs[i].name, procs[i].arrival,
procs[i].finish, turnaround, response);
}
print_sep();
printf("平均周转时间: %.2f 秒\n", total_turnaround / n);
printf("平均响应时间: %.2f 秒\n", total_response / n);
}
/* ============================================================
FIFO 调度:按到达顺序执行,不抢占
============================================================ */
void fifo(Process procs[], int n) {
printf("\n=== FIFO 调度 ===\n");
int time = 0;
for (int i = 0; i < n; i++) {
/* 如果当前时间早于任务到达,先空等 */
if (time < procs[i].arrival) {
time = procs[i].arrival;
}
procs[i].first_run = time; /* 第一次运行就是现在 */
procs[i].started = 1;
time += procs[i].burst; /* 跑完整个任务 */
procs[i].finish = time;
}
print_stats(procs, n);
}
/* ============================================================
SJF 调度:在已到达的任务中选最短的,不抢占
============================================================ */
void sjf(Process procs[], int n) {
printf("\n=== SJF 调度(非抢占) ===\n");
int done[n];
memset(done, 0, sizeof(done)); /* 0=未完成,1=已完成 */
int time = 0, finished = 0;
while (finished < n) {
/* 找当前时刻已到达且未完成的最短任务 */
int shortest = -1;
for (int i = 0; i < n; i++) {
if (!done[i] && procs[i].arrival <= time) {
if (shortest == -1 ||
procs[i].burst < procs[shortest].burst) {
shortest = i;
}
}
}
if (shortest == -1) {
/* 没有可运行的任务,时间推进到下一个任务到达 */
int next_arrival = 1e9;
for (int i = 0; i < n; i++) {
if (!done[i] && procs[i].arrival < next_arrival) {
next_arrival = procs[i].arrival;
}
}
time = next_arrival;
continue;
}
procs[shortest].first_run = time;
time += procs[shortest].burst;
procs[shortest].finish = time;
done[shortest] = 1;
finished++;
}
print_stats(procs, n);
}
/* ============================================================
RR 调度:轮转,每次最多运行 quantum 秒
============================================================ */
void round_robin(Process procs[], int n, int quantum) {
printf("\n=== RR 调度(时间片 = %d 秒) ===\n", quantum);
/* 重置剩余时间和标记 */
for (int i = 0; i < n; i++) {
procs[i].remaining = procs[i].burst;
procs[i].started = 0;
procs[i].first_run = -1;
procs[i].finish = 0;
}
int time = 0, finished = 0;
int queue[1000]; /* 就绪队列(存进程下标) */
int head = 0, tail = 0;
int in_queue[n];
memset(in_queue, 0, sizeof(in_queue));
/* 先把 t=0 时已到达的进程加入队列 */
for (int i = 0; i < n; i++) {
if (procs[i].arrival == 0) {
queue[tail++] = i;
in_queue[i] = 1;
}
}
while (finished < n) {
if (head == tail) {
/* 队列空,推进时间到下一个到达 */
int next_arrival = 1e9;
for (int i = 0; i < n; i++) {
if (procs[i].remaining > 0 &&
procs[i].arrival < next_arrival) {
next_arrival = procs[i].arrival;
}
}
time = next_arrival;
/* 把此时到达的进程加入队列 */
for (int i = 0; i < n; i++) {
if (!in_queue[i] && procs[i].remaining > 0 &&
procs[i].arrival <= time) {
queue[tail++] = i;
in_queue[i] = 1;
}
}
continue;
}
int cur = queue[head++]; /* 取出队首进程 */
in_queue[cur] = 0;
/* 记录第一次运行时间 */
if (procs[cur].first_run == -1) {
procs[cur].first_run = time;
}
/* 本轮实际运行时长 */
int run = (procs[cur].remaining < quantum)
? procs[cur].remaining
: quantum;
/* 在这段时间内,把新到达的进程加入队列 */
for (int t = time + 1; t <= time + run; t++) {
for (int i = 0; i < n; i++) {
if (!in_queue[i] && procs[i].remaining > 0 &&
procs[i].arrival == t && i != cur) {
queue[tail++] = i;
in_queue[i] = 1;
}
}
}
time += run;
procs[cur].remaining -= run;
if (procs[cur].remaining == 0) {
/* 任务完成 */
procs[cur].finish = time;
finished++;
} else {
/* 还没跑完,重新加入队尾 */
queue[tail++] = cur;
in_queue[cur] = 1;
}
}
print_stats(procs, n);
}
/* ============================================================
主函数:演示三种算法
============================================================ */
int main(void) {
/* 场景一:三个任务同时到达,长度不同 */
printf("########################################\n");
printf("场景一:三个任务同时到达(A=100s, B=10s, C=10s)\n");
printf("########################################\n");
Process p1[3] = {
{'A', 0, 100, 100, 0, 0, 0},
{'B', 0, 10, 10, 0, 0, 0},
{'C', 0, 10, 10, 0, 0, 0},
};
Process p1_copy1[3], p1_copy2[3], p1_copy3[3];
memcpy(p1_copy1, p1, sizeof(p1));
memcpy(p1_copy2, p1, sizeof(p1));
memcpy(p1_copy3, p1, sizeof(p1));
fifo(p1_copy1, 3);
sjf(p1_copy2, 3);
round_robin(p1_copy3, 3, 1);
/* 场景二:任务不同时到达 */
printf("\n########################################\n");
printf("场景二:A=100s(t=0),B=10s(t=10),C=10s(t=10)\n");
printf("########################################\n");
Process p2[3] = {
{'A', 0, 100, 100, 0, 0, 0},
{'B', 10, 10, 10, 0, 0, 0},
{'C', 10, 10, 10, 0, 0, 0},
};
Process p2_sjf[3], p2_rr[3];
memcpy(p2_sjf, p2, sizeof(p2));
memcpy(p2_rr, p2, sizeof(p2));
sjf(p2_sjf, 3); /* SJF 非抢占,结果差 */
round_robin(p2_rr, 3, 1);
return 0;
}
https://godbolt.org/z/EqfxMEEbz
编译与运行:
gcc -o scheduler scheduler.c
./scheduler
十二、各算法流程图(Mermaid)
FIFO 流程
SJF 流程
STCF 流程
RR 流程
十三、直观的ASCII演示
场景:A=100s(t=0), B=10s(t=10), C=10s(t=10)
FIFO / SJF 非抢占(结果相同,因为 A 先到):
时间: 0 10 20 100 110 120
|---------|---------|---------|---------|---------|
CPU: |<========A=100s=============================>|B|C|
^
B、C 此时到达,但 A 还在跑,只能等
STCF(抢占式):
时间: 0 10 20 30 120
|----|----|----|-------------------------|
CPU: |<A:10>|<B>|<C>|<======A 剩余 90s======>|
^
B、C 剩余 10s < A 剩余 90s,抢占!
RR(时间片=1s,简化示意):
时间: 0 1 2 3 4 5 6 7 8 9 10 11 12 ...
CPU: |A |A |A |A |A |A |A |A |A |A |B |A |C |A...
^
B、C 到达后加入轮转
十四、核心知识点速记
FIFO → 简单,有护航效应
SJF → 最优(同时到达),但不抢占
STCF → 最优(任何时刻),可抢占
RR → 响应时间最好,周转时间最差
调度的本质矛盾:
周转时间好 ←→ 响应时间好
(短任务优先)←→ (公平轮转)
I/O 优化:把 I/O 等待时间用来跑其他任务(重叠)
未解之谜(下一章):
现实中调度器不知道任务有多长——如何办?
→ 多级反馈队列(MLFQ):用历史行为预测未来
十五、课后练习解析
Q1: 三个长度均为 200 的任务,用 SJF 和 FIFO。
由于长度相同,SJF 和 FIFO 结果一样:
T ˉ t u r n a r o u n d = 200 + 400 + 600 3 = 400 秒 \bar{T}_{turnaround} = \frac{200+400+600}{3} = 400 \text{ 秒} Tˉturnaround=3200+400+600=400 秒
T ˉ r e s p o n s e = 0 + 200 + 400 3 = 200 秒 \bar{T}_{response} = \frac{0+200+400}{3} = 200 \text{ 秒} Tˉresponse=30+200+400=200 秒
Q4: 什么工作负载下 SJF 和 FIFO 给出相同周转时间?
当所有任务运行时间相同,或者任务已经按从短到长顺序排列时,SJF 和 FIFO 的结果完全一致。
Q6: SJF 下响应时间随任务长度增加会怎样?
随着任务长度增大,排在后面的任务等待时间变长,响应时间线性增长:若有 N N N 个长度为 L L L 的任务,第 k k k 个任务的响应时间为 ( k − 1 ) × L (k-1) \times L (k−1)×L。
Q7: RR 下最坏情况响应时间公式?
设有 N N N 个任务,时间片为 q q q,则最坏情况(最后一个任务)的响应时间为:
T r e s p o n s e w o r s t = ( N − 1 ) × q T_{response}^{worst} = (N-1) \times q Tresponseworst=(N−1)×q
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)