操作系统原理完整知识总结
目录
- 操作系统概述
- 进程与线程
- 2.1 进程的概念与状态
- 2.2 进程控制块(PCB)
- 2.3 进程创建与终止
- 2.4 进程间通信(IPC)
- 2.5 线程的概念与模型
- 2.6 用户级线程 vs 内核级线程
- 2.7 线程同步与互斥
- 2.8 死锁
- 进程调度
- 3.1 调度基本概念
- 3.2 调度算法详解
- 3.3 多处理器调度
- 3.4 实时调度
- 3.5 Linux 调度器(CFS)
- 内存管理
- 4.1 内存管理基础
- 4.2 分区内存管理
- 4.3 分页机制
- 4.4 分段机制
- 4.5 段页式管理
- 4.6 虚拟内存
- 4.7 页面置换算法
- 4.8 内存分配器
- 文件系统
- 5.1 文件系统基础
- 5.2 目录结构
- 5.3 文件系统实现
- 5.4 典型文件系统(FAT/ext4/NTFS/XFS)
- 5.5 虚拟文件系统(VFS)
- 5.6 日志与崩溃一致性
- 设备驱动
- 6.1 I/O 系统架构
- 6.2 设备驱动模型
- 6.3 中断处理
- 6.4 DMA 机制
- 6.5 块设备驱动
- 6.6 字符设备驱动
- 6.7 网络设备驱动
- 系统调用与内核接口
- 操作系统安全
- 经典面试题与练习题
- 参考资源
1. 操作系统概述
1.1 操作系统的定义与目标
操作系统(Operating System, OS)是管理计算机硬件与软件资源的系统软件,是用户与硬件之间的接口。其核心目标为:
| 目标 | 说明 |
|---|---|
| 资源管理 | 合理分配 CPU、内存、I/O 设备、文件等资源 |
| 抽象接口 | 向用户程序提供简洁、一致的硬件抽象 |
| 并发控制 | 支持多程序同时运行,保证隔离与安全 |
| 持久存储 | 管理文件系统,实现数据持久化 |
1.2 操作系统的结构
┌──────────────────────────────────────────────────┐
│ 用户空间 │
│ Shell │ GUI │ 应用程序 │ 系统工具 │
├──────────────────────────────────────────────────┤
│ 系统调用接口 (syscall) │
├──────────────────────────────────────────────────┤
│ 内核空间 │
│ 进程管理 │ 内存管理 │ 文件系统 │ 网络 │ 驱动 │
├──────────────────────────────────────────────────┤
│ 硬件抽象层 (HAL) │
├──────────────────────────────────────────────────┤
│ CPU │ RAM │ 磁盘 │ 网卡 │ 显卡 │ 键盘/鼠标 │
└──────────────────────────────────────────────────┘
1.3 内核架构类型
宏内核(Monolithic Kernel)
所有操作系统服务(文件系统、驱动、网络)运行在同一内核空间,通过内部函数调用交互。
- 优点:性能高,无模式切换开销
- 缺点:模块间耦合紧,一个 bug 可能崩溃整个系统
- 代表:Linux、FreeBSD、早期 UNIX
微内核(Microkernel)
内核只保留最小功能(IPC、内存管理、基本调度),其他服务运行在用户空间服务器。
- 优点:高可靠性,模块独立
- 缺点:IPC 开销大,性能较低
- 代表:Mach、L4、MINIX 3、QNX
混合内核(Hybrid Kernel)
结合宏内核性能与微内核模块化设计。
- 代表:Windows NT、macOS (XNU)
外核(Exokernel)
内核仅做资源保护与复用,应用程序直接管理硬件资源(MIT 研究项目)。
1.4 特权级与保护环
现代 CPU 通常提供至少两个特权级(x86 提供 Ring 0~3):
Ring 0 ─── 内核模式(Kernel Mode):可执行所有指令,访问所有资源
Ring 1 ─── 设备驱动(部分操作系统使用)
Ring 2 ─── 设备驱动(部分操作系统使用)
Ring 3 ─── 用户模式(User Mode):只能执行非特权指令
用户态 → 内核态 的转换方式:
- 系统调用 (
syscall/int 0x80软中断) - 硬件中断(外设触发,如键盘、网卡)
- 异常/陷阱(除零、缺页、非法指令)
2. 进程与线程
2.1 进程的概念与状态
进程(Process)是操作系统进行资源分配的基本单位,是程序的一次动态执行过程。进程 = 程序 + 数据 + 执行上下文(PC、寄存器、栈、堆)。
进程的组成
进程空间(虚拟地址)
┌──────────────────┐ 高地址
│ 内核区域 │ (内核映射,用户不可见)
├──────────────────┤
│ 栈 (Stack) │ ↓ 向低地址增长
│ │
│ (空闲虚拟空间) │
│ │
│ 堆 (Heap) │ ↑ 向高地址增长
├──────────────────┤
│ BSS 段(未初始化全局/静态变量) │
├──────────────────┤
│ Data 段(初始化全局/静态变量) │
├──────────────────┤
│ Text 段(代码段,只读) │ 低地址
└──────────────────┘
进程状态模型
五状态模型:
┌─────────────┐
│ New │ 进程被创建
└──────┬──────┘
│ 被调度器接纳
▼
┌──────────┐ ┌─────────────┐ ┌──────────────┐
│ Blocked │◄─────│ Ready │◄─────│ Running │
│(等待/阻塞)│ │ (就绪) │ │ (运行中) │
└──────┬───┘ └─────────────┘ └──────┬───────┘
│ 等待 I/O 完成/事件发生 │
│ ──────────────────────────► │
│ │
│ ┌───────▼───────┐
│ │ Terminated │
│ │ (终止/僵尸) │
└───────────────────────────────└───────────────┘
七状态模型(引入挂起概念):
| 状态 | 说明 |
|---|---|
| New | 进程正在被创建 |
| Ready | 进程可运行,等待 CPU |
| Running | 正在 CPU 上执行 |
| Blocked | 等待某事件(I/O、信号量等) |
| Ready-Suspended | 就绪但被换出到磁盘 |
| Blocked-Suspended | 阻塞且被换出到磁盘 |
| Terminated | 进程已完成,等待父进程回收 |
状态转换触发条件
| 转换 | 触发条件 |
|---|---|
| New → Ready | 操作系统接纳进程,完成 PCB 初始化 |
| Ready → Running | 调度器选中该进程(dispatch) |
| Running → Ready | 时间片耗尽或被高优先级进程抢占 |
| Running → Blocked | 进程请求 I/O、等待信号量、sleep() |
| Blocked → Ready | I/O 完成、信号量释放、定时器到期 |
| Running → Terminated | 进程调用 exit() 或被 kill |
2.2 进程控制块(PCB)
PCB(Process Control Block)是操作系统用来描述和管理进程的数据结构,每个进程对应一个 PCB。在 Linux 中称为 task_struct。
PCB 的主要字段
c
// Linux task_struct 简化示意
struct task_struct {
/* 进程标识 */
pid_t pid; // 进程ID
pid_t tgid; // 线程组ID(主线程的pid)
uid_t uid, euid; // 用户ID(真实/有效)
gid_t gid, egid; // 组ID
char comm[16]; // 进程名称
/* 进程状态 */
volatile long state; // TASK_RUNNING, TASK_INTERRUPTIBLE...
int exit_code; // 退出码
/* 调度信息 */
int prio; // 动态优先级
int static_prio; // 静态优先级(nice值决定)
struct sched_entity se; // CFS调度实体
unsigned int policy; // 调度策略 SCHED_NORMAL/FIFO/RR
/* 内存管理 */
struct mm_struct *mm; // 内存描述符(虚拟地址空间)
struct mm_struct *active_mm; // 当前使用的mm
/* 文件系统 */
struct fs_struct *fs; // 根目录、当前目录
struct files_struct *files; // 打开文件表
/* 信号 */
sigset_t blocked; // 被阻塞的信号集
struct sigpending pending; // 未决信号队列
/* 进程关系 */
struct task_struct *parent; // 父进程
struct list_head children; // 子进程链表
struct list_head sibling; // 兄弟进程链表
/* CPU上下文 */
struct thread_struct thread; // 寄存器、FPU 状态等
/* 时间统计 */
u64 utime, stime; // 用户态/内核态 CPU 时间(纳秒)
unsigned long start_time; // 进程创建时间
};
PCB 的组织方式
操作系统维护多个数据结构来组织 PCB:
就绪队列(按优先级或时间片):
[PCB1] → [PCB3] → [PCB7] → NULL
等待队列(按等待资源分类):
磁盘I/O等待队列:[PCB2] → [PCB5] → NULL
信号量等待队列:[PCB4] → NULL
运行中(单核,最多一个):
CPU0:[PCB6]
2.3 进程创建与终止
进程创建
UNIX/Linux:fork-exec 模型
c
#include <unistd.h>
#include <sys/wait.h>
#include <stdio.h>
int main() {
pid_t pid = fork(); // 创建子进程
if (pid < 0) {
// fork 失败
perror("fork");
return 1;
} else if (pid == 0) {
// 子进程:pid == 0
printf("子进程 PID: %d, 父进程 PID: %d\n", getpid(), getppid());
// 替换进程映像为新程序
execl("/bin/ls", "ls", "-l", NULL);
// execl 成功则不会返回到这里
perror("execl");
_exit(1);
} else {
// 父进程:pid == 子进程的PID
printf("父进程,子进程 PID: %d\n", pid);
int status;
wait(&status); // 等待子进程结束,回收资源
printf("子进程退出码: %d\n", WEXITSTATUS(status));
}
return 0;
}
fork() 的写时拷贝(Copy-on-Write, COW):
fork()并不立即复制父进程的内存页面- 父子进程共享相同的物理页面,页表项标记为只读
- 当任一方尝试写入时,才触发缺页异常,内核此时复制该页
- 极大减少了不必要的内存复制,
exec()后的 fork 尤其受益
进程创建流程(Linux 内核视角):
用户调用 fork()
│
▼
sys_fork() → do_fork() → copy_process()
│
├── 分配新 PID
├── 复制/共享 task_struct 字段
├── 复制/共享 mm_struct(COW 标记页表)
├── 复制文件描述符表
├── 复制信号处理器
├── 新进程加入就绪队列
│
▼
父进程返回子进程PID,子进程返回0
Windows:CreateProcess() 一步到位
c
STARTUPINFO si = { sizeof(si) };
PROCESS_INFORMATION pi;
CreateProcess(
"C:\\Windows\\System32\\notepad.exe", // 可执行文件路径
NULL, // 命令行参数
NULL, // 进程安全属性
NULL, // 线程安全属性
FALSE, // 是否继承句柄
0, // 创建标志
NULL, // 环境变量
NULL, // 当前目录
&si, &pi
);
WaitForSingleObject(pi.hProcess, INFINITE);
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
进程终止
正常终止:
- 调用
exit(status)或从main()返回 - C 运行时库会调用所有
atexit()注册的函数,刷新 I/O 缓冲区
异常终止:
- 收到未处理的信号(SIGSEGV 段错误、SIGKILL 强制杀死等)
- 除零错误、非法内存访问
僵尸进程(Zombie Process):
- 子进程已退出,但父进程未调用
wait()回收其 PCB - 僵尸进程仍占用 PID 和 PCB 内存
- 解决:父进程及时调用
wait()/waitpid(),或设置SIGCHLD处理器
孤儿进程(Orphan Process):
- 父进程在子进程之前退出
- Linux 将孤儿进程重新挂载到
init(PID=1)或subreaper进程
2.4 进程间通信(IPC)
进程拥有独立的地址空间,需要专门机制进行通信。
IPC 方式对比
| 机制 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 管道(Pipe) | 父子/兄弟进程单向通信 | 简单,内核缓冲 | 单向,只能亲缘进程 |
| 命名管道(FIFO) | 任意进程单向通信 | 通过文件名访问 | 半双工 |
| 信号(Signal) | 异步事件通知 | 轻量 | 信息量极少 |
| 消息队列(MQ) | 结构化消息传递 | 异步,带类型 | 有大小限制 |
| 共享内存(SHM) | 大量数据高速交换 | 最快(零拷贝) | 需手动同步 |
| 信号量(Semaphore) | 进程同步 | 精确控制 | 只传整数 |
| 套接字(Socket) | 跨主机/本地通信 | 灵活,跨网络 | 协议开销 |
| 内存映射文件(mmap) | 大文件共享读写 | 高效,懒加载 | 需同步 |
管道详解
c
// 匿名管道示例
int pipefd[2]; // pipefd[0] 读端,pipefd[1] 写端
pipe(pipefd);
if (fork() == 0) {
// 子进程写
close(pipefd[0]); // 关闭读端
write(pipefd[1], "Hello!", 7);
close(pipefd[1]);
exit(0);
} else {
// 父进程读
close(pipefd[1]); // 关闭写端
char buf[128];
int n = read(pipefd[0], buf, sizeof(buf));
buf[n] = '\0';
printf("收到: %s\n", buf);
close(pipefd[0]);
wait(NULL);
}
管道的内核实现:
- 内核分配一个固定大小的环形缓冲区(Linux 默认 64KB,由 16 个 4KB 页组成)
- 读写通过文件描述符进行
- 读端没有数据时阻塞;写端缓冲满时阻塞
- 当所有写端关闭,读端
read()返回 0(EOF)
共享内存详解
c
// POSIX 共享内存
// 创建者
int fd = shm_open("/my_shm", O_CREAT | O_RDWR, 0666);
ftruncate(fd, 4096);
void *ptr = mmap(NULL, 4096, PROT_READ | PROT_WRITE,
MAP_SHARED, fd, 0);
strcpy((char*)ptr, "共享数据");
// 使用者
int fd = shm_open("/my_shm", O_RDONLY, 0666);
void *ptr = mmap(NULL, 4096, PROT_READ, MAP_SHARED, fd, 0);
printf("%s\n", (char*)ptr);
// 清理
shm_unlink("/my_shm");
信号量详解
c
// POSIX 信号量用于互斥
#include <semaphore.h>
sem_t mutex;
sem_init(&mutex, 1, 1); // 第2个参数1=进程间共享,初值=1
// 进程A/B 互斥访问临界区
sem_wait(&mutex); // P操作(-1,若为0则阻塞)
// ── 临界区 ──
sem_post(&mutex); // V操作(+1,唤醒等待者)
sem_destroy(&mutex);
信号种类(Linux 常见信号):
| 信号 | 值 | 默认动作 | 说明 |
|---|---|---|---|
| SIGHUP | 1 | 终止 | 终端断开 |
| SIGINT | 2 | 终止 | Ctrl+C |
| SIGQUIT | 3 | Core | Ctrl+\ |
| SIGKILL | 9 | 终止 | 不可捕获,强制杀死 |
| SIGSEGV | 11 | Core | 段错误 |
| SIGPIPE | 13 | 终止 | 写入无读端的管道 |
| SIGALRM | 14 | 终止 | alarm() 定时器 |
| SIGTERM | 15 | 终止 | 默认 kill 信号(可捕获) |
| SIGCHLD | 17 | 忽略 | 子进程状态变化 |
| SIGSTOP | 19 | 停止 | 不可捕获,暂停进程 |
| SIGUSR1 | 10 | 终止 | 用户自定义 |
| SIGUSR2 | 12 | 终止 | 用户自定义 |
2.5 线程的概念与模型
线程(Thread)是 CPU 调度的基本单位,同一进程内的线程共享地址空间和资源,但有各自独立的:
- 程序计数器(PC)
- 寄存器组
- 栈(Stack)
- 线程局部存储(TLS)
进程 vs 线程对比
| 特性 | 进程 | 线程 |
|---|---|---|
| 资源分配 | 独立地址空间、文件描述符 | 共享进程资源 |
| 创建开销 | 大(需复制/映射地址空间) | 小(只需分配栈和TCB) |
| 切换开销 | 大(需切换页表、TLB 刷新) | 小(同一页表,只换寄存器/栈) |
| 通信方式 | IPC(管道/消息/共享内存) | 共享内存(直接访问全局变量) |
| 安全隔离 | 强(一个崩溃不影响他人) | 弱(一个线程崩溃影响全进程) |
| 并行能力 | 多核可真正并行 | 多核可真正并行(内核线程) |
线程的优点与应用场景
- 响应性:UI 线程 + 工作线程,防止界面卡死
- 资源共享:无需 IPC,同进程线程直接共享数据
- 经济性:创建/销毁比进程轻量 10~100 倍
- 多处理器利用:一个进程内多线程真正并行执行
2.6 用户级线程 vs 内核级线程
用户级线程(User-Level Thread, ULT)
由用户空间的线程库管理(如早期的 Green Threads),内核不感知线程存在。
用户空间: [线程1][线程2][线程3]
↓ 线程库调度
内核空间: [进程(单个内核线程)]
优点 :切换不需要系统调用,极快
缺点:一个线程阻塞(如 I/O),整个进程被阻塞;无法利用多核
内核级线程(Kernel-Level Thread, KLT)
内核直接管理线程,每个线程有对应的内核数据结构。
用户空间: [线程1][线程2][线程3]
↓ ↓ ↓
内核空间: [KT1] [KT2] [KT3] (每个用户线程映射一个内核线程)
优点 :真正并行,一个线程阻塞不影响他人
缺点:切换需要系统调用(陷入内核),开销较大
混合模型(M:N 模型)
用户线程: [T1][T2][T3][T4][T5]
多路复用到
内核线程: [KT1] [KT2]
现代实现(Go runtime、Erlang VM):调度器在用户空间复用 M 个用户线程到 N 个内核线程(goroutine 模型)。
Linux 线程实现
Linux 使用 clone() 系统调用实现线程,pthread_create() 在内部调用 clone():
c
// POSIX 线程(pthreads)
#include <pthread.h>
void* worker(void* arg) {
int id = *(int*)arg;
printf("线程 %d 运行中\n", id);
return NULL;
}
int main() {
pthread_t threads[4];
int ids[4];
for (int i = 0; i < 4; i++) {
ids[i] = i;
pthread_create(&threads[i], NULL, worker, &ids[i]);
}
for (int i = 0; i < 4; i++) {
pthread_join(threads[i], NULL); // 等待线程结束
}
return 0;
}
2.7 线程同步与互斥
多线程共享内存带来竞态条件(Race Condition),需要同步机制保护临界区。
竞态条件示例
c
int counter = 0; // 共享变量
void* increment(void* arg) {
for (int i = 0; i < 1000000; i++) {
counter++; // 非原子操作!可能丢失更新
// 实际是:tmp = counter; tmp++; counter = tmp;
// 两个线程交叉执行可能导致结果小于期望值
}
return NULL;
}
互斥锁(Mutex)
c
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
void* safe_increment(void* arg) {
for (int i = 0; i < 1000000; i++) {
pthread_mutex_lock(&lock); // 加锁
counter++; // 临界区
pthread_mutex_unlock(&lock); // 解锁
}
return NULL;
}
互斥锁实现原理:
Peterson 算法(软件方案,仅双进程):
turn = 进程标识
flag[i] = 是否想进入临界区
硬件支持方案(现代系统):
┌─────────────────────────────┐
│ TSL(Test-and-Set-Lock) │ 原子读-写操作
│ CAS(Compare-and-Swap) │ 原子比较-交换
│ XCHG │ 原子交换
└─────────────────────────────┘
自旋锁(Spinlock)vs 互斥锁(Mutex):
| 特性 | 自旋锁 | 互斥锁 |
|---|---|---|
| 等待方式 | 忙等(循环检测) | 睡眠(让出 CPU) |
| 适用场景 | 锁持有时间极短、多核系统 | 锁持有时间较长 |
| 上下文切换 | 无 | 有 |
| 单核性能 | 差(浪费 CPU) | 好 |
条件变量(Condition Variable)
用于等待某个条件满足时再继续执行:
c
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
int data_ready = 0;
// 消费者线程
void* consumer(void* arg) {
pthread_mutex_lock(&lock);
while (!data_ready) {
// 原子地:释放锁 + 进入等待(防止丢失唤醒)
pthread_cond_wait(&cond, &lock);
}
// 此时 data_ready == 1,且持有锁
consume_data();
pthread_mutex_unlock(&lock);
return NULL;
}
// 生产者线程
void* producer(void* arg) {
produce_data();
pthread_mutex_lock(&lock);
data_ready = 1;
pthread_cond_signal(&cond); // 唤醒一个等待者
pthread_mutex_unlock(&lock);
return NULL;
}
读写锁(Read-Write Lock)
c
pthread_rwlock_t rwlock = PTHREAD_RWLOCK_INITIALIZER;
// 多读者可并发
pthread_rwlock_rdlock(&rwlock); // 读锁
read_shared_data();
pthread_rwlock_unlock(&rwlock);
// 写者独占
pthread_rwlock_wrlock(&rwlock); // 写锁
write_shared_data();
pthread_rwlock_unlock(&rwlock);
读写锁策略:
- 读优先:有读者就不允许写,可能导致写者饥饿
- 写优先:有写者等待,不接受新读者(Linux 默认策略)
- 公平策略:按申请顺序排队
信号量(Semaphore)
c
sem_t sem;
sem_init(&sem, 0, N); // N = 可用资源数
// P 操作(等待/申请)
sem_wait(&sem); // 若 sem > 0,-1 并继续;否则阻塞
// V 操作(释放/通知)
sem_post(&sem); // +1,若有等待者则唤醒一个
// 二值信号量(N=1)等价于互斥锁
// 计数信号量可控制并发数(如连接池)
经典问题:生产者-消费者
c
sem_t empty; // 空槽数,初值 = 缓冲区大小 N
sem_t full; // 满槽数,初值 = 0
sem_t mutex; // 互斥锁,初值 = 1
// 生产者
while (true) {
produce_item();
sem_wait(&empty); // 等待空槽
sem_wait(&mutex); // 进入临界区
put_item_in_buffer();
sem_post(&mutex); // 离开临界区
sem_post(&full); // 通知有新产品
}
// 消费者
while (true) {
sem_wait(&full); // 等待产品
sem_wait(&mutex); // 进入临界区
get_item_from_buffer();
sem_post(&mutex); // 离开临界区
sem_post(&empty); // 释放空槽
consume_item();
}
原子操作与内存屏障
c
#include <stdatomic.h> // C11 原子操作
atomic_int counter = ATOMIC_VAR_INIT(0);
atomic_fetch_add(&counter, 1); // 原子自增
int val = atomic_load(&counter); // 原子读
atomic_store(&counter, 42); // 原子写
// 内存顺序(Memory Order)
// memory_order_relaxed - 仅保证原子性,无顺序保证
// memory_order_acquire - 读操作,防止后续操作提前
// memory_order_release - 写操作,防止前续操作推迟
// memory_order_seq_cst - 顺序一致(最强,默认)
2.8 死锁
死锁(Deadlock):多个进程/线程互相等待对方持有的资源,导致所有进程永久阻塞。
死锁的四个必要条件(Coffman 条件)
- 互斥(Mutual Exclusion):资源一次只能被一个进程使用
- 占有并等待(Hold and Wait):进程持有资源同时等待其他资源
- 不可抢占(No Preemption):资源不能被强制剥夺
- 循环等待(Circular Wait):存在进程等待链形成环路
四个条件必须同时满足才会发生死锁,破坏任意一个即可预防死锁。
资源分配图(Resource Allocation Graph)
进程 P1 持有 R1,等待 R2:
P1 → R2(请求边)
R1 → P1(分配边)
P2 持有 R2,等待 R1:
P2 → R1(请求边)
R2 → P2(分配边)
存在环路 P1→R2→P2→R1→P1:死锁!
死锁处理策略
1. 死锁预防(Prevention) --- 破坏必要条件
| 条件 | 破坏方法 | 缺点 |
|---|---|---|
| 互斥 | 使资源可共享(如只读文件) | 某些资源天然不可共享 |
| 占有并等待 | 要求进程一次申请所有资源 | 资源利用率低,可能饥饿 |
| 不可抢占 | 允许强制剥夺资源 | 复杂,可能导致不一致 |
| 循环等待 | 对资源编号,按序申请 | 限制了申请灵活性 |
2. 死锁避免(Avoidance) --- 银行家算法
安全状态:存在一个安全序列,使所有进程都能完成
不安全状态:不一定死锁,但存在死锁风险
银行家算法核心:
每次资源请求,先假设分配,
用安全性算法检查系统是否仍在安全状态,
是则实际分配,否则拒绝并让进程等待。
安全性算法伪代码:
输入:Allocation矩阵, Need矩阵, Available向量
初始化:Work = Available, Finish[i] = false
while 存在未完成且需求可满足的进程 i:
Work = Work + Allocation[i] // 归还资源
Finish[i] = true
若所有 Finish[i] == true:安全状态 ✓
否则:不安全状态 ✗
3. 死锁检测与恢复(Detection & Recovery)
检测:周期性运行资源分配图算法,找环路
恢复方法:
- 强制终止死锁进程(选择代价最小的进程)
- 资源抢占(保存进程状态,强制回滚到检查点)
4. 死锁忽略(Ostrich Algorithm)
- 认为死锁概率极低,不处理(等操作员重启)
- Linux、UNIX 等大多数通用 OS 采用此策略
- 原因:完整的死锁预防/避免代价过高
实际代码中的死锁示例与避免
c
// 危险:锁顺序不一致可能导致死锁
void transfer_WRONG(Account *from, Account *to, double amount) {
pthread_mutex_lock(&from->lock); // 线程A先锁A
pthread_mutex_lock(&to->lock); // 线程B先锁B,形成交叉等待
from->balance -= amount;
to->balance += amount;
pthread_mutex_unlock(&to->lock);
pthread_mutex_unlock(&from->lock);
}
// 安全:总是按固定顺序(账号地址大小)加锁
void transfer_SAFE(Account *from, Account *to, double amount) {
Account *first = (from < to) ? from : to;
Account *second = (from < to) ? to : from;
pthread_mutex_lock(&first->lock);
pthread_mutex_lock(&second->lock);
from->balance -= amount;
to->balance += amount;
pthread_mutex_unlock(&second->lock);
pthread_mutex_unlock(&first->lock);
}
3. 进程调度
3.1 调度基本概念
调度(Scheduling)是指操作系统决定哪个进程/线程在何时使用 CPU 的策略。
调度层次
| 层次 | 名称 | 功能 | 频率 |
|---|---|---|---|
| 高级调度 | 作业调度/长程调度 | 决定哪些作业进入内存 | 低(分钟级) |
| 中级调度 | 内存调度/中程调度 | 决定哪些进程被换入/换出内存 | 中 |
| 低级调度 | 进程调度/短程调度 | 决定哪个就绪进程使用 CPU | 高(毫秒级) |
调度时机
调度器在以下时机运行:
- 进程终止:当前进程结束,必须选新进程
- 阻塞操作:进程进行 I/O 等阻塞调用
- 时钟中断:时间片耗尽(抢占式调度)
- 新进程/线程创建:可能抢占当前进程
- I/O 中断:阻塞进程就绪,可能抢占当前进程
非抢占 vs 抢占式调度
| 类型 | 说明 | 代表系统 |
|---|---|---|
| 非抢占式 | 进程自愿让出 CPU(完成或阻塞) | 早期 Mac OS、Windows 3.x |
| 抢占式 | OS 可强制切换(时间片到期) | Linux、Windows NT、macOS |
调度评价指标
| 指标 | 定义 | 优化方向 |
|---|---|---|
| CPU 利用率 | CPU 执行用户进程的时间比例 | 越高越好 |
| 吞吐量 | 单位时间完成的进程数 | 越高越好 |
| 周转时间 | 从提交到完成的总时间 | 越小越好 |
| 等待时间 | 在就绪队列中等待的时间 | 越小越好 |
| 响应时间 | 从请求到第一次响应的时间 | 越小越好(交互系统) |
| 截止时间满足率 | 实时任务按时完成的比例 | 越高越好(实时系统) |
周转时间 = 完成时间 - 到达时间
带权周转时间 = 周转时间 / 服务时间(执行时长)
等待时间 = 周转时间 - 服务时间
3.2 调度算法详解
先来先服务(FCFS - First Come First Served)
- 按进程到达就绪队列的顺序调度,非抢占
- 实现简单(FIFO 队列)
- 护航效应(Convoy Effect):短作业等待长作业,平均等待时间长
示例:
| 进程 | 到达时间 | 服务时间 |
|---|---|---|
| P1 | 0 | 24 |
| P2 | 1 | 3 |
| P3 | 2 | 3 |
甘特图:[P1:0-24] [P2:24-27] [P3:27-30]
平均等待时间 = (0 + 23 + 25) / 3 = 16ms(非常差)
最短作业优先(SJF - Shortest Job First)
- 优先执行预计运行时间最短的进程
- 理论上可证明对平均等待时间最优(在同时到达情况下)
- 问题:需要预知 CPU 突发时间(实际中通过历史数据估算)
指数平均预测:
τ(n+1) = α * t(n) + (1-α) * τ(n)
其中:t(n) = 第n次实际执行时间,α ∈ [0,1](通常取 0.5)
非抢占 SJF 示例:
| 进程 | 到达时间 | 服务时间 |
|---|---|---|
| P1 | 0 | 6 |
| P2 | 2 | 8 |
| P3 | 4 | 7 |
| P4 | 6 | 3 |
甘特图:[P1:0-6] [P4:6-9] [P3:9-16] [P2:16-24]
平均等待时间 = (0 + 14 + 5 + 3) / 4 = 5.5ms
最短剩余时间优先(SRTF - Shortest Remaining Time First)
SJF 的抢占版本,每当新进程到达就比较剩余时间,若新进程更短则抢占。
优先级调度(Priority Scheduling)
- 每个进程分配优先级,调度器选最高优先级(可抢占或不可抢占)
- 饥饿(Starvation)问题:低优先级进程可能永远不被执行
- 解决:老化(Aging):随时间推移提高等待进程的优先级
Linux 优先级体系:
实时优先级 (0-99, rt_prio): 数值越大优先级越高
SCHED_FIFO/SCHED_RR 策略使用
普通优先级 (100-139, prio): 对应 nice 值 (-20 到 +19)
SCHED_NORMAL/SCHED_BATCH 使用
nice 值:-20 (最高优先级) → 0 (默认) → +19 (最低优先级)
轮转调度(RR - Round Robin)
- 每个进程分配固定的时间片(quantum),时间片用完则抢占
- 专为分时系统设计,保证公平性
- 响应时间 O(n),n 为就绪进程数
时间片大小的影响:
时间片过大 → 退化为 FCFS,响应时间差
时间片过小 → 上下文切换开销占比过高
经验值:时间片设置使 80% 的 CPU 突发 < 1 个时间片
Linux 默认:~4ms(CFS 目标延迟的 1/调度周期)
RR 示例(时间片=4):
| 进程 | 到达时间 | 服务时间 |
|---|---|---|
| P1 | 0 | 24 |
| P2 | 0 | 3 |
| P3 | 0 | 3 |
甘特图:
[P1:0-4][P2:4-7][P3:7-10][P1:10-14][P1:14-18][P1:18-22][P1:22-26][P1:26-30]
P2 完成时间: 7ms(仅服务 3ms)
P3 完成时间: 10ms(仅服务 3ms)
多级队列调度(Multilevel Queue)
将就绪队列分为多个子队列,每个队列有自己的调度算法:
优先级 ↑
┌──────────────────────────────────┐
│ 系统进程队列(FCFS) │ ← 最高优先级
├──────────────────────────────────┤
│ 交互式前台进程队列(RR) │
├──────────────────────────────────┤
│ 交互式批处理进程队列(RR) │
├──────────────────────────────────┤
│ 后台批处理作业队列(FCFS) │ ← 最低优先级
└──────────────────────────────────┘
队列间调度:优先运行高优先级队列,低优先级队列有时间才执行(可能饥饿)
多级反馈队列(MLFQ - Multilevel Feedback Queue)
进程可在队列间动态移动,是最通用的调度算法之一:
规则:
-
优先级高的队列优先执行
-
同一队列内使用 RR
-
新进程进入最高优先级队列
-
用完时间片未结束 → 降低优先级(CPU 密集型惩罚)
-
在较低优先级等待太久 → 提升优先级(老化防饥饿)
Q0(高优,时间片1ms): 新来的交互式进程
↓ 用完时间片
Q1(中优,时间片4ms):
↓ 用完时间片
Q2(低优,时间片16ms): CPU密集型长作业
↑ 等待太久(老化)提升回 Q1
3.3 多处理器调度
对称多处理(SMP)调度
SMP 系统:
CPU0 CPU1 CPU2 CPU3
↑ ↑ ↑ ↑
┌────────────────────────────┐
│ 共享就绪队列 │
└────────────────────────────┘
或
CPU0 CPU1 CPU2 CPU3
↑ ↑ ↑ ↑
[Q0] [Q1] [Q2] [Q3] (每核私有队列)
负载均衡:
- 推迁移(Push Migration):繁忙 CPU 把任务推给空闲 CPU
- 拉迁移(Pull Migration):空闲 CPU 从繁忙 CPU 拉取任务
处理器亲和性(CPU Affinity):
- 进程尽量运行在上次运行的 CPU 上,利用 Cache 热度
pthread_setaffinity_np()可设置线程绑定的 CPU
NUMA 感知调度
在 NUMA(Non-Uniform Memory Access)架构中,访问本地内存比远端内存快:
Node 0: CPU0, CPU1 ←→ 本地内存 (10ns)
↕ 互联 (30ns)
Node 1: CPU2, CPU3 ←→ 本地内存 (10ns)
调度器尽量将进程调度到与其内存分配相同 NUMA 节点的 CPU 上。
3.4 实时调度
实时系统要求任务在截止时间(Deadline)内完成。
硬实时(Hard Real-Time) :错过截止时间导致灾难性后果(航空、汽车控制)
软实时(Soft Real-Time):错过截止时间降低质量但可接受(视频流、游戏)
速率单调调度(RMS - Rate Monotonic Scheduling)
- 固定优先级:执行频率越高(周期越短),优先级越高
- CPU 利用率上界:U ≤ n(2^(1/n) - 1),n 个任务时约为 69%
最早截止时间优先(EDF - Earliest Deadline First)
- 动态优先级:截止时间最近的任务最优先
- 理论上可达 100% CPU 利用率(前提是可调度)
- Linux 支持:
SCHED_DEADLINE策略(CBS 算法实现)
c
// Linux 实时任务设置
struct sched_param param = { .sched_priority = 80 };
sched_setscheduler(pid, SCHED_FIFO, ¶m); // 实时 FIFO
// SCHED_DEADLINE 示例
struct sched_attr attr = {
.size = sizeof(attr),
.sched_policy = SCHED_DEADLINE,
.sched_runtime = 5000000, // 5ms 运行时间预算
.sched_period = 10000000, // 10ms 周期
.sched_deadline = 10000000, // 10ms 截止时间
};
syscall(SYS_sched_setattr, pid, &attr, 0);
3.5 Linux 调度器(CFS)
**完全公平调度器(CFS - Completely Fair Scheduler)**是 Linux 2.6.23 引入的主调度器。
CFS 核心思想
虚拟时钟(Virtual Runtime, vruntime):每个进程根据其 nice 值以不同速率增加 vruntime。
调度器总是选择 vruntime 最小的进程运行,确保 CPU 时间对所有进程公平分配。
vruntime 增量 = 实际运行时间 × (NICE_0_LOAD / 进程权重)
nice=0(默认): 权重 = 1024,vruntime 正常增加
nice=-5(高优先):权重 = 3121,vruntime 增加较慢(更多 CPU)
nice=+5(低优先):权重 = 335,vruntime 增加较快(更少 CPU)
CFS 数据结构
就绪队列(per-CPU, struct rq)
│
└── CFS 运行队列(struct cfs_rq)
│
└── 红黑树(按 vruntime 排序)
├── vruntime=100 [进程A]
├── vruntime=105 [进程B] ← 最左节点(下次调度)
├── vruntime=108 [进程C]
└── ...
调度周期与时间片
调度延迟(sysctl_sched_latency):默认 6ms
最小粒度(sysctl_sched_min_granularity):默认 0.75ms
时间片 = 调度延迟 × (进程权重 / 总权重)
(但不少于最小粒度)
CFS 组调度(Group Scheduling)
Linux 通过 cgroup 支持组调度,可限制容器/进程组的 CPU 使用:
bash
# 限制 cgroup 最多使用 50% CPU
echo 50000 > /sys/fs/cgroup/cpu/mygroup/cpu.cfs_quota_us # 50ms/周期
echo 100000 > /sys/fs/cgroup/cpu/mygroup/cpu.cfs_period_us # 100ms 周期
4. 内存管理
4.1 内存管理基础
内存管理的目标:
- 地址空间隔离:每个进程有独立的虚拟地址空间
- 内存保护:防止进程越界访问他人内存
- 高效利用:减少碎片,提高内存利用率
- 透明扩展:通过虚拟内存给每个进程提供超过物理内存的地址空间
地址类型
| 地址类型 | 说明 | 示例 |
|---|---|---|
| 逻辑地址(虚拟地址) | 程序使用的地址,由 CPU 生成 | 0x7fff4a3b |
| 线性地址 | 分段后的地址(x86 中段基址+偏移) | 保护模式下等于虚拟地址 |
| 物理地址 | 实际内存总线上的地址 | 0x1a3f0000 |
地址转换:虚拟地址 → (MMU/TLB) → 物理地址
内存碎片
| 类型 | 说明 | 解决方案 |
|---|---|---|
| 外部碎片 | 空闲块分散,总量够但无连续大块 | 紧缩(Compaction)、分页 |
| 内部碎片 | 分配的块比实际需要大,内部浪费 | 更精细的分配粒度 |
4.2 分区内存管理
固定分区(Fixed Partitioning)
内存划分为固定大小的分区,每分区容纳一个进程:
- 优点:简单
- 缺点:严重的内部碎片,分区数限制并发进程数
动态分区(Dynamic Partitioning)
按进程需求动态分配内存块:
分配算法:
| 算法 | 策略 | 优点 | 缺点 |
|---|---|---|---|
| 首次适应(First Fit) | 选第一个足够大的空闲块 | 快速 | 低地址碎片多 |
| 最佳适应(Best Fit) | 选最小的足够大空闲块 | 减少浪费 | 留下很多小碎片 |
| 最坏适应(Worst Fit) | 选最大的空闲块 | 剩余块较大可用 | 破坏大块 |
| 下次适应(Next Fit) | 从上次分配处开始找 | 分布均匀 | 平均性能一般 |
紧缩(Compaction):将所有已用块移到内存一端,合并空闲块。代价极高,需暂停所有进程。
4.3 分页机制
分页(Paging)是现代操作系统内存管理的基础,将物理内存和虚拟地址空间均分为固定大小的页/页框。
页(Page) :虚拟地址空间的固定大小块(典型值 4KB)
页框(Frame) :物理内存的对应固定大小块
页表(Page Table):记录页号到页框号的映射关系
地址转换过程
虚拟地址(32位,4KB页):
┌─────────────────────┬──────────────┐
│ 页号(高20位) │ 页内偏移(低12位)│
└─────────────────────┴──────────────┘
│ │
▼ │
查页表[页号] │
│ │
▼ ▼
页框号(PFN) + 偏移 = 物理地址
页表项(PTE - Page Table Entry)
x86-64 页表项(64位):
┌────┬───────────────────────────────────────┬────────────────┐
│保留│ 物理页框号(PFN,位 12-51) │ 标志位(位 0-11)│
└────┴───────────────────────────────────────┴────────────────┘
标志位:
P (0) - Present:页是否在物理内存中
R/W (1) - Read/Write:0=只读,1=读写
U/S (2) - User/Supervisor:0=内核,1=用户
PWT (3) - Page Write-Through
PCD (4) - Page Cache Disable
A (5) - Accessed:CPU 读写时置1(软件清零)
D (6) - Dirty:CPU 写时置1(仅数据页)
PAT (7) - Page Attribute Table
G (8) - Global:TLB 全局页,进程切换不刷新
NX (63) - No-Execute:禁止执行(防DEP/W^X)
多级页表
32位系统单级页表需 4MB(1M页×4字节),太浪费。多级页表按需分配,大幅节省内存。
x86 32位二级页表:
虚拟地址 32位:
┌──────────┬──────────┬──────────────┐
│ PD 索引 │ PT 索引 │ 页内偏移 │
│ (10位) │ (10位) │ (12位) │
└──────────┴──────────┴──────────────┘
│ │
▼ ▼
页目录(PD) → 页表(PT) → 物理页
1个×4KB 1024个×4KB
(1024项) (各1024项)
x86-64 四级页表:
虚拟地址 48位(仅低48位有效):
┌────┬────┬────┬────┬──────────┐
│PML4│PDP │PDT │PT │ 页内偏移 │
│9位 │9位 │9位 │9位 │ 12位 │
└────┴────┴────┴────┴──────────┘
4级:PML4 → PDPT → PD → PT → 物理页
每级 512 项,共寻址 256TB 虚拟地址
TLB(Translation Lookaside Buffer)
页表查找需要多次内存访问,TLB 是用于缓存地址转换的硬件 Cache(通常 64~1024 项)。
CPU 访问虚拟地址 VA:
│
▼
查 TLB:
├─ 命中(TLB Hit,~1周期)→ 直接得到物理地址
│
└─ 缺失(TLB Miss,~10-100周期):
│
▼
遍历页表(硬件 page walk 或软件 TLB refill)
│
▼
填入 TLB,重新翻译
TLB 刷新时机:
- 进程切换(不同进程页表不同,必须刷新 TLB)
- 页表项修改(unmap、mprotect 操作后
invlpg指令) - 全局页(TLB.G=1)不随进程切换刷新(用于内核映射)
- PCID(Process Context ID):x86-64 支持,切换进程时保留 TLB,打标签区分
4.4 分段机制
将地址空间划分为逻辑上有意义的段(代码段、数据段、栈段等),每段有独立的基地址和长度。
x86 保护模式下的段式地址转换:
逻辑地址 = 段选择子(16位) : 偏移(32位)
│
▼ 查 GDT/LDT
段描述符(基地址 + 限制 + 属性)
│
▼
线性地址 = 段基址 + 偏移
(x86-64中段基址通常为0,段机制退化)
分页 vs 分段:
| 特性 | 分页 | 分段 |
|---|---|---|
| 大小 | 固定(4KB-2MB) | 可变 |
| 碎片 | 内部碎片 | 外部碎片 |
| 共享 | 按页共享 | 按段共享(更自然) |
| 内存保护 | 按页保护 | 按段保护(代码/数据/栈) |
| 编程模型 | 透明(程序无感知) | 需要感知段 |
现代操作系统通常使用段页式:段机制提供逻辑分割和保护,分页机制提供实际内存管理。
4.5 段页式管理
结合两者优点:先分段,再对每段分页。
逻辑地址:段号 | 页号 | 页内偏移
│ │
▼ │
段表[段号] │
│ │
▼ │
页表基址 + 页号 → 页框号
│
▼
物理地址 = 页框号 × 页大小 + 偏移
4.6 虚拟内存
虚拟内存允许进程使用超过物理内存大小的地址空间,通过按需加载和换页(Swapping)实现。
按需调页(Demand Paging)
- 进程启动时,页面不全部加载进内存
- 访问未加载页时触发缺页异常(Page Fault)
- 操作系统从磁盘(交换区或文件)加载该页,恢复进程执行
缺页处理流程:
CPU 访问虚拟地址 VA
│
▼
查页表:PTE.P == 0(不在内存中)
│
▼
触发缺页异常(#PF, int 14)
│
▼
内核缺页处理程序(do_page_fault):
├─ 检查地址是否在合法的 VMA 区域内
├─ 检查权限(写只读页 → SIGSEGV)
├─ 查找/分配一个空闲物理页框
├─ 从磁盘/文件读入页内容(I/O)
│ 或分配匿名零页(malloc 触发的缺页)
├─ 更新 PTE(设置 PFN,P=1)
├─ 刷新 TLB
└─ 返回,重新执行触发缺页的指令
虚拟内存区域(VMA)
Linux 内核用 vm_area_struct 描述进程地址空间中的连续区域:
进程地址空间 mm_struct:
├── 代码段 VMA: 0x400000-0x401000, 可读可执行, MAP_SHARED, 文件映射
├── 数据段 VMA: 0x601000-0x602000, 可读可写, MAP_PRIVATE, 文件映射
├── BSS VMA: 0x602000-0x603000, 可读可写, MAP_PRIVATE, 匿名
├── 堆 VMA: 0x1234000-0x1244000, 可读可写, MAP_PRIVATE, 匿名
├── mmap 区域: 0x7f...
└── 栈 VMA: 0x7ffffffde000-0x7ffffffff000, 可读可写, MAP_PRIVATE
VMA 存储在红黑树中,支持 O(logN) 查找
mmap() 系统调用:
c
// 将文件映射到内存
void *addr = mmap(NULL, 4096,
PROT_READ | PROT_WRITE, // 保护标志
MAP_SHARED, // 共享映射(写入会同步到文件)
fd, 0); // 文件描述符和偏移
// 匿名映射(等价于 malloc 大块内存)
void *buf = mmap(NULL, 1 << 20,
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS,
-1, 0);
munmap(addr, 4096); // 解除映射
交换空间(Swap)
当物理内存不足时,OS 将不活跃页面写到磁盘交换区,腾出物理内存:
物理内存(RAM): 交换区(Swap Partition/File):
┌────────────────────┐ ┌──────────────────────────┐
│ 进程A页1(活跃) │ │ 进程B页3(被换出) │
│ 进程B页1(活跃) │ │ 进程C页2(被换出) │
│ 进程C页1(活跃) │ └──────────────────────────┘
│ 进程A页2(不活跃) │──换出→ 写入 Swap
└────────────────────┘
Linux 使用 swappiness(0-100)参数控制换页激进程度。
4.7 页面置换算法
当发生缺页且无空闲页框时,需要选择一个**牺牲页(Victim Page)**换出。
最优置换(OPT / Bélády 算法)
淘汰将来最长时间不被使用的页面,缺页次数最少,但需要预知未来(理论最优基准)。
FIFO(先进先出)
淘汰最早进入内存的页面。
Bélády 异常:增加页框数有时反而增加缺页次数(反直觉),FIFO 存在此问题。
示例(3个页框):
访问序列:7 0 1 2 0 3 0 4 2 3 0 3 2 1 2 0 1 7 0 1
FIFO缺页:9次
LRU(最近最少使用)
淘汰最近一段时间内最久未使用的页面,是最接近 OPT 的实用算法。
精确实现(代价高):
- 每次访问更新时间戳,淘汰时选最旧的
- 维护按访问时间排序的链表,每次访问 O(1) 更新,O(N) 查找
近似 LRU - Clock 算法(时钟置换):
页框排成环形,有一个"时钟指针":
每个页框有 1 个 Reference 位(R)
淘汰时:
├─ R==1:将 R 置0,指针前进(给该页第二次机会)
└─ R==0:淘汰该页,停止
访问页面时:硬件自动将 R 置1
增强型 Clock(Linux 使用 Active/Inactive 链表):
Linux 页面回收机制:
Active 链表(热页)← 最近访问的页
↓ 长时间不访问(R=0)
Inactive 链表(冷页)
↓ 再次不访问
被换出/释放
kswapd 守护进程定期扫描,维护空闲页框数 > 水位线
LFU(最不常用)
淘汰访问次数最少的页面。问题:早期频繁访问但后来不用的页会长期占用。
NRU(最近未使用)
基于 Reference® 和 Dirty(D) 两位将页分 4 类,优先淘汰类别低的:
| 类 | R | D | 说明 |
|---|---|---|---|
| 0 | 0 | 0 | 未访问未修改(最优先淘汰) |
| 1 | 0 | 1 | 未访问但已修改 |
| 2 | 1 | 0 | 已访问未修改 |
| 3 | 1 | 1 | 已访问且已修改(最不优先) |
各算法缺页次数对比
访问序列:1 2 3 4 1 2 5 1 2 3 4 5(3个页框)
OPT :4 次缺页(理论最少)
LRU :5 次缺页
FIFO :7 次缺页(有 Bélády 异常风险)
Clock:5-6 次缺页
抖动(Thrashing) :进程分配页框太少,缺页率极高,大部分时间在换页而非执行。
解决:工作集模型(Working Set Model)------为每个进程分配其当前工作集大小的页框数。
4.8 内存分配器
内核内存分配
伙伴系统(Buddy System):管理物理内存页框,以 2^n 页为单位分配/释放。
空闲链表(按阶数分组):
Order 0:[单页] [单页] ...
Order 1:[2页块] [2页块] ...
Order 2:[4页块] ...
...
Order 10:[1024页块] ...
分配 5 页:找 Order 3(8页),实际分配 8 页(有内部碎片)
释放时:与伙伴合并 → 提升到上一阶(减少外部碎片)
Slab 分配器:在伙伴系统之上,为内核对象(struct task_struct、inode 等)提供缓存池,减少频繁分配/释放的开销。
kmem_cache:
┌─────────────────────────────────────┐
│ slab 1(满): [obj][obj][obj][obj] │
│ slab 2(部分):[obj][free][obj][free]│
│ slab 3(空): [free][free][free] │
└─────────────────────────────────────┘
c
// 内核中使用 slab
struct kmem_cache *task_cache;
// 创建缓存
task_cache = kmem_cache_create("task_struct",
sizeof(struct task_struct), 0,
SLAB_HWCACHE_ALIGN, NULL);
// 分配对象
struct task_struct *task = kmem_cache_alloc(task_cache, GFP_KERNEL);
// 释放对象
kmem_cache_free(task_cache, task);
用户空间内存分配
malloc/free 的典型实现(ptmalloc/jemalloc/tcmalloc):
内存布局:
小对象(< 256字节): 使用线程局部缓存(tcache)
中对象:从堆分配(brk/sbrk 系统调用)
大对象(> 128KB): mmap 分配,释放时直接 munmap
ptmalloc chunk 结构:
┌─────────────────────────────┐
│ prev_size(前一 chunk 大小) │
│ size(当前 chunk 大小+标志) │ ← 对齐到 16 字节
│ 用户数据区域 │
│ ... │
└─────────────────────────────┘
标志位:P=1 前一 chunk 在使用,M=1 mmap 分配
内存泄漏检测工具:
bash
valgrind --leak-check=full ./my_program
# 或使用 AddressSanitizer
gcc -fsanitize=address -g -o prog prog.c
./prog
5. 文件系统
5.1 文件系统基础
文件是操作系统对持久存储数据的抽象,提供名称、结构、逻辑存储单元。
文件属性
| 属性 | 说明 |
|---|---|
| 名称 | 人类可读标识符 |
| 标识符(inode号) | 文件系统内唯一ID |
| 类型 | 普通文件、目录、符号链接、设备文件、FIFO、Socket |
| 位置 | 所在设备和磁盘地址 |
| 大小 | 字节数、块数 |
| 时间戳 | atime(访问)、mtime(修改)、ctime(元数据变更) |
| 权限 | 所有者、组、其他用户的读/写/执行权限 |
| 链接计数 | 硬链接数量 |
Linux 文件权限
ls -la 输出:
-rwxr-x--- 2 alice staff 4096 Jan 1 12:00 hello.c
│││││││││└─ 其他用户权限: --- (无权限)
│││││││└─── 组权限: r-x (读/执行)
│││││└───── 所有者权限: rwx (读/写/执行)
││││└────── 文件类型: - (普通), d(目录), l(链接), b(块设备), c(字符设备)
│││└─────── 执行位(s/S = setuid/setgid)
bash
# 权限管理
chmod 755 file # 八进制表示
chmod u+x,g-w file # 符号表示
chown alice:staff file
setuid: chmod u+s file # 程序以文件所有者权限运行(如 /usr/bin/passwd)
特殊权限位:
- SUID(Set User ID):可执行文件以所有者身份运行
- SGID(Set Group ID):目录中新建文件继承目录的组
- Sticky Bit:目录中只有文件所有者才能删除自己的文件(如 /tmp)
文件操作(系统调用)
c
// 基本文件 I/O
int fd = open("file.txt", O_RDWR | O_CREAT, 0644);
ssize_t n = read(fd, buf, sizeof(buf));
n = write(fd, buf, n);
off_t pos = lseek(fd, 0, SEEK_SET); // 移到文件开头
int ret = ftruncate(fd, 1024); // 截断到 1024 字节
close(fd);
// 文件元数据
struct stat st;
stat("file.txt", &st);
printf("大小: %lld, inode: %lu\n", (long long)st.st_size, st.st_ino);
// 目录操作
mkdir("newdir", 0755);
DIR *dir = opendir(".");
struct dirent *entry;
while ((entry = readdir(dir)) != NULL) {
printf("%s\n", entry->d_name);
}
closedir(dir);
5.2 目录结构
目录的实现
目录本质上是文件名到 inode 号的映射表:
目录文件内容(简化):
┌──────────────────────────────────────┐
│ inode号 │ 文件类型 │ 文件名 │
├──────────────────────────────────────┤
│ 2 │ d │ .(当前目录) │
│ 1 │ d │ ..(父目录) │
│ 128 │ - │ hello.c │
│ 129 │ - │ Makefile │
│ 130 │ d │ src │
└──────────────────────────────────────┘
路径解析
绝对路径解析 /home/alice/file.txt:
1. 从根目录 inode (inode=2) 开始
2. 读取根目录内容,找到 "home" → inode=5
3. 读取 inode=5 的目录,找到 "alice" → inode=42
4. 读取 inode=42 的目录,找到 "file.txt" → inode=187
5. 读取 inode=187 获取文件数据块位置
相对路径解析 ./src/main.c:
从当前目录(进程的 cwd)开始同样步骤
硬链接 vs 软链接
bash
# 硬链接:两个目录项指向同一个 inode
ln file.txt hardlink.txt
# 效果:同一文件的两个名字,link count +1
# 限制:不能跨文件系统,不能链接目录
# 软链接(符号链接):存储路径字符串的特殊文件
ln -s file.txt symlink.txt
# 效果:symlink.txt 存储 "file.txt" 字符串
# 特点:可跨文件系统,可链接目录,原文件删除后成悬空链接
硬链接:
目录A/file.txt ─────┐
├── inode 187 ──→ 数据块
目录B/link.txt ─────┘ link_count=2
软链接:
symlink ──→ 存储路径"/home/alice/file.txt" ──→ inode 187 ──→ 数据块
5.3 文件系统实现
inode 结构
inode 存储文件除名称外的所有元数据及数据块地址:
inode(UNIX 典型结构,128字节):
┌──────────────────────────────────────────────────┐
│ 文件类型和权限(mode) │
│ 硬链接计数(nlinks) │
│ 用户ID(uid)/ 组ID(gid) │
│ 文件大小(字节) │
│ 时间戳(atime/mtime/ctime) │
│ │
│ 数据块指针(以4字节指针,4KB块为例): │
│ 直接指针 [0..11] → 12个直接块 = 48KB │
│ 一级间接指针 [12] → 1024个块 = 4MB │
│ 二级间接指针 [13] → 1024²块 = 4GB │
│ 三级间接指针 [14] → 1024³块 = 4TB │
└──────────────────────────────────────────────────┘
ext4 的 extent 优化:放弃间接指针,使用 extent(连续块范围描述符),减少元数据开销,提高大文件性能。
extent = { 逻辑块号起始, 物理块号起始, 长度 }
例:{ 0, 12345, 1000 } 表示文件前 1000 块连续存于物理块 12345-13344
磁盘布局
磁盘分区布局(ext4):
┌─────────────────────────────────────────────────────────┐
│ 超级块(SuperBlock) │ 块组0 │ 块组1 │ 块组2 │ ... │ 块组N│
└─────────────────────────────────────────────────────────┘
块组(Block Group)内部布局:
┌──────────────────────────────────────────────────────┐
│超级块│块组描述符│块位图│inode位图│inode表│数据块区域 │
│副本 │ │(1块)│(1块) │(N块) │ │
└──────────────────────────────────────────────────────┘
超级块记录:总块数、空闲块数、块大小、inode数、挂载次数等
空闲空间管理
| 方法 | 实现 | 优缺点 |
|---|---|---|
| 位图(Bitmap) | 每位代表一个块/inode,1=使用,0=空闲 | 简单高效,需扫描 |
| 空闲链表 | 空闲块连链表,块内存下一空闲块地址 | 适合随机分配,顺序扫描慢 |
| Extent 树 | 记录空闲连续区域(起始+长度) | 适合大连续分配(ext4、XFS) |
| B树/B+树 | XFS 使用 B+树管理空闲 extent | 高效,适合大文件系统 |
5.4 典型文件系统详解
FAT(File Allocation Table)
FAT 结构:
FAT 表:每个簇有一个条目
FAT[0] = 保留
FAT[1] = 保留
FAT[2] = 3 (第2簇→第3簇→继续)
FAT[3] = 5
FAT[4] = 0 (空闲)
FAT[5] = EOF (文件结束)
文件存储:目录项记录起始簇号,通过 FAT 链表找所有块
缺点:FAT 链表需频繁 Seek,大文件性能差;FAT 本身是整个磁盘的瓶颈
版本:FAT12(软盘)、FAT16(<=2GB)、FAT32(<=8TB)、exFAT(>4GB文件)
ext4(Linux 主流文件系统)
特性:
- 最大文件:16TB(4KB块),最大分区:1EB
- 日志(Journal):保证崩溃一致性(元数据日志/全数据日志/writeback)
- 延迟分配(Delayed Allocation):写数据时不立即分配块,充分合并后再分配,减少碎片
- Extent 树:替代间接指针,连续大文件高效存储
- 多块分配(Multiblock Allocation):一次分配多个连续块
bash
# ext4 常用操作
mkfs.ext4 -b 4096 /dev/sdb1 # 创建文件系统
tune2fs -l /dev/sdb1 # 查看超级块信息
debugfs /dev/sdb1 # 交互式调试工具
e2fsck -f /dev/sdb1 # 文件系统检查
NTFS(Windows 主文件系统)
- MFT(Master File Table):每个文件/目录一个 1KB 的 MFT 记录,B+树目录结构
- $MFT:MFT 本身也是一个文件,元数据在 MFT 中自描述
- 事务日志($LogFile):支持崩溃恢复
- USN 变更日志:追踪文件系统变更(实时索引用)
- 稀疏文件、硬链接、ACL、加密(EFS)、压缩
XFS
- 由 SGI 开发,适合高并发大文件 I/O
- B+树管理 inode 和空闲空间
- 延迟日志(Delayed Logging):提高日志写入吞吐量
- 并行分配组(AG):多个独立分配组,并行操作
- 最大文件大小:8EB,最大分区:8EB
Btrfs / ZFS
| 特性 | Btrfs(Linux) | ZFS(Solaris/FreeBSD/Linux) |
|---|---|---|
| 写时拷贝(COW) | ✓ | ✓ |
| 快照 | ✓ | ✓ |
| 子卷 | ✓ | ✓(dataset) |
| 内置 RAID | ✓ | ✓(RAID-Z) |
| 数据校验(Checksum) | ✓ | ✓(端到端) |
| 压缩 | ✓(zstd/lzo/zlib) | ✓ |
| 在线扩展/收缩 | ✓ | 扩展(无法收缩) |
5.5 虚拟文件系统(VFS)
VFS 是 Linux 文件系统的抽象层,提供统一的接口,使得不同文件系统(ext4、XFS、NTFS、proc、tmpfs 等)对上层应用透明。
VFS 核心数据结构
c
// 超级块对象 (per-文件系统实例)
struct super_block {
unsigned long s_blocksize; // 块大小
unsigned long s_maxbytes; // 最大文件大小
struct inode *s_root; // 根目录 inode
struct super_operations *s_op; // 操作函数表
void *s_fs_info; // 文件系统私有数据
};
// inode 对象 (per-文件)
struct inode {
umode_t i_mode; // 权限和类型
unsigned long i_ino; // inode 号
loff_t i_size; // 文件大小
struct timespec i_atime, i_mtime, i_ctime;
struct inode_operations *i_op; // inode 操作(create/lookup/link...)
struct address_space *i_mapping; // 页缓存映射
void *i_private; // 私有数据
};
// 目录项对象 (per-路径分量,有缓存)
struct dentry {
struct inode *d_inode; // 关联的 inode
struct dentry *d_parent; // 父目录项
struct qstr d_name; // 文件名(含哈希)
struct list_head d_subdirs; // 子目录链表
struct dentry_operations *d_op;
};
// 文件对象 (per-打开的文件)
struct file {
struct dentry *f_dentry; // 关联目录项
loff_t f_pos; // 当前偏移(位置)
struct file_operations *f_op; // read/write/ioctl/mmap...
unsigned int f_flags; // O_RDONLY/O_WRONLY...
void *private_data; // 驱动私有数据
};
VFS 操作流程(以 open 为例)
用户调用 open("/home/alice/file.txt", O_RDONLY)
│
▼
sys_open() → do_sys_open() → do_filp_open()
│
├─ 路径解析(path_lookup):
│ 遍历 dentry 缓存(dcache),逐分量查找
│ dcache miss → 调用具体 FS 的 inode->lookup() 读磁盘
│
├─ 权限检查(inode_permission)
│
├─ 分配 struct file,设置 f_op = inode->i_fop
│
├─ 调用 file->f_op->open()(具体 FS 实现)
│
└─ 在进程 files_struct 中分配文件描述符 fd,返回 fd
页缓存(Page Cache)
VFS 在内存中缓存文件内容,减少磁盘 I/O:
页缓存(Page Cache)管理文件页:
address_space(per-inode)→ 基数树/XArray → 物理页
读文件:先查页缓存,命中返回;未命中则触发 readahead,从磁盘读入缓存页
写文件:
write-back(回写,默认):先写缓存页,标记 dirty,后台 pdflush 异步写磁盘
write-through(写穿):每次写立即写到磁盘
O_SYNC:强制每次 write 同步到磁盘
O_DIRECT:绕过页缓存,直接 DMA 到用户缓冲区(数据库常用)
5.6 日志与崩溃一致性
文件系统操作往往涉及多个磁盘写入(如创建文件:写 inode + 写目录 + 写块位图),系统崩溃可能导致不一致性。
无日志时的问题
创建文件操作涉及多步:
Step 1: 更新 inode 位图(标记 inode 已用)
Step 2: 写新 inode
Step 3: 更新目录项
崩溃在 Step 1 后:inode 位图显示已使用,但无对应文件 → 泄漏
崩溃在 Step 2 后:inode 存在但目录无入口 → 孤儿文件
日志(Journaling / Write-Ahead Logging)
日志写入流程(ordered 模式,ext4默认):
1. 将元数据变更写入日志区(Journal/Log)
2. 提交日志(写 commit 块,含 checksum)
3. 将实际变更写入文件系统数据区(checkpoint)
4. 清除日志条目
崩溃恢复:
扫描日志,找到已提交但未 checkpoint 的事务,重放之
→ 保证元数据一致性(不保证数据内容,除非 data=journal 模式)
日志模式对比(ext4):
| 模式 | 日志内容 | 性能 | 一致性 |
|---|---|---|---|
| writeback | 仅元数据(异步) | 最快 | 元数据一致,数据可能旧 |
| ordered(默认) | 仅元数据(先写数据块) | 中 | 数据有效,元数据一致 |
| journal | 元数据+数据 | 最慢 | 最强,完全一致 |
Copy-on-Write(COW)文件系统
Btrfs/ZFS/APFS 采用 COW,无需传统日志:
写文件:
不修改原有数据块,分配新块写入新内容
更新树结构指向新块(从叶节点到根原子更新)
原块在没有快照引用后才释放
优点:无需日志,天然支持快照(snapshot)
缺点:写放大(Write Amplification),树更新开销
6. 设备驱动
6.1 I/O 系统架构
应用程序
│ 系统调用(read/write/ioctl)
▼
VFS / 块层(Block Layer)/ 网络栈
│
▼
设备驱动程序(Device Driver)
│
▼
硬件抽象层(HAL)/ 总线驱动(PCI/USB/I2C)
│
▼
硬件设备(磁盘、网卡、GPU、键盘...)
设备类型
| 类型 | 特征 | 例子 | 访问接口 |
|---|---|---|---|
| 字符设备 | 按字节流访问,无缓冲 | 键盘、串口、摄像头 | /dev/ttyS0, /dev/video0 |
| 块设备 | 按固定大小块访问,有缓冲 | 硬盘、SSD、USB存储 | /dev/sda, /dev/nvme0n1 |
| 网络设备 | 基于数据包,不映射到/dev | 以太网卡、WiFi | 通过 Socket API |
6.2 设备驱动模型
Linux 设备驱动框架
Linux 设备驱动遵循"总线-设备-驱动"三元模型(struct bus_type、struct device、struct device_driver):
Bus(总线):PCI bus, USB bus, I2C bus, platform bus
│
├── Device(设备):具体硬件,挂载在总线上
│ 例:struct pci_dev, struct usb_device
│
└── Driver(驱动):匹配并管理设备的代码
例:struct pci_driver, struct usb_driver
匹配机制:驱动提供 id_table(vendor/device ID),
总线扫描时对每个设备尝试匹配,成功则调用 driver->probe()
字符设备驱动框架
c
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/uaccess.h>
#define DEVICE_NAME "mydev"
#define CLASS_NAME "myclass"
static dev_t dev_num; // 设备号(主设备号+次设备号)
static struct cdev my_cdev;
static struct class *dev_class;
// 文件操作函数
static int mydev_open(struct inode *inode, struct file *file) {
pr_info("mydev: opened\n");
return 0;
}
static int mydev_release(struct inode *inode, struct file *file) {
pr_info("mydev: released\n");
return 0;
}
static ssize_t mydev_read(struct file *file, char __user *buf,
size_t count, loff_t *ppos) {
char kernel_buf[] = "Hello from kernel!\n";
size_t len = min(count, sizeof(kernel_buf));
// copy_to_user:内核 → 用户空间(安全拷贝,验证地址)
if (copy_to_user(buf, kernel_buf, len))
return -EFAULT;
*ppos += len;
return len;
}
static ssize_t mydev_write(struct file *file, const char __user *buf,
size_t count, loff_t *ppos) {
char kernel_buf[256];
size_t len = min(count, sizeof(kernel_buf) - 1);
// copy_from_user:用户空间 → 内核(安全拷贝)
if (copy_from_user(kernel_buf, buf, len))
return -EFAULT;
kernel_buf[len] = '\0';
pr_info("mydev: received: %s\n", kernel_buf);
return len;
}
static long mydev_ioctl(struct file *file, unsigned int cmd,
unsigned long arg) {
switch (cmd) {
case 0x1001:
pr_info("mydev: ioctl CMD_1001\n");
break;
default:
return -ENOTTY;
}
return 0;
}
static struct file_operations mydev_fops = {
.owner = THIS_MODULE,
.open = mydev_open,
.release = mydev_release,
.read = mydev_read,
.write = mydev_write,
.unlocked_ioctl = mydev_ioctl,
};
// 模块初始化
static int __init mydev_init(void) {
// 动态分配主设备号
alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME);
// 初始化并注册字符设备
cdev_init(&my_cdev, &mydev_fops);
cdev_add(&my_cdev, dev_num, 1);
// 在 /sys/class/ 和 /dev/ 下创建设备节点
dev_class = class_create(THIS_MODULE, CLASS_NAME);
device_create(dev_class, NULL, dev_num, NULL, DEVICE_NAME);
pr_info("mydev: initialized, major=%d\n", MAJOR(dev_num));
return 0;
}
// 模块卸载
static void __exit mydev_exit(void) {
device_destroy(dev_class, dev_num);
class_destroy(dev_class);
cdev_del(&my_cdev);
unregister_chrdev_region(dev_num, 1);
pr_info("mydev: removed\n");
}
module_init(mydev_init);
module_exit(mydev_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Author");
编译驱动(Makefile):
makefile
obj-m += mydev.o
KERNEL_DIR ?= /lib/modules/$(shell uname -r)/build
all:
$(MAKE) -C $(KERNEL_DIR) M=$(PWD) modules
clean:
$(MAKE) -C $(KERNEL_DIR) M=$(PWD) clean
load:
sudo insmod mydev.ko
sudo chmod 666 /dev/mydev
unload:
sudo rmmod mydev
6.3 中断处理
中断(Interrupt)是硬件设备通知 CPU 有事件发生的机制,打断当前程序执行,转去执行中断处理程序(ISR)。
中断类型
| 类型 | 来源 | 例子 |
|---|---|---|
| 硬件中断(IRQ) | 外部设备 | 键盘按键、网卡收包、磁盘完成 |
| 软件中断(SoftIRQ) | CPU 指令 | 系统调用(int 0x80/syscall) |
| 异常(Exception) | CPU 内部 | 除零、缺页、非法指令 |
| NMI | 不可屏蔽中断 | 硬件故障、调试器 |
Linux 中断处理两阶段
为减少中断关闭时间(避免丢失其他中断),Linux 将中断处理分为两半:
中断发生(硬件拉 IRQ 线)
│
▼
CPU 保存现场 → 跳转中断向量表
│
▼
上半部(Top Half)------ 在中断上下文执行,不可睡眠
├─ 确认中断(ACK 硬件)
├─ 读取紧急数据(如网卡 DMA 数据地址)
└─ 触发下半部处理
│
▼
下半部(Bottom Half)------ 延迟执行,可睡眠
├─ SoftIRQ(静态定义,不可睡眠,多核并行)
│ └─ NET_RX_SOFTIRQ(网络接收)、TASKLET_SOFTIRQ
├─ Tasklet(基于 SoftIRQ,同一 tasklet 串行,可动态定义)
└─ 工作队列 workqueue(在内核线程中执行,可睡眠、延迟、并发)
注册中断处理程序
c
#include <linux/interrupt.h>
irqreturn_t my_irq_handler(int irq, void *dev_id) {
struct my_device *dev = dev_id;
// 检查是否是本设备的中断
if (!is_my_interrupt(dev))
return IRQ_NONE;
// 上半部处理(快速)
ack_interrupt(dev);
// 触发 tasklet 下半部
tasklet_schedule(&dev->tasklet);
return IRQ_HANDLED;
}
// 注册
int ret = request_irq(irq_num, // IRQ 号
my_irq_handler, // 处理函数
IRQF_SHARED, // 标志(共享IRQ)
"my_device", // 设备名
dev); // 传给处理函数的私有数据
// 注销
free_irq(irq_num, dev);
中断控制器(GIC/APIC)
x86 架构:
外设 IRQ → 8259A PIC(旧式)或 IOAPIC(现代)→ LAPIC → CPU 核
MSI(Message Signaled Interrupt):PCI 设备直接写内存触发中断
ARM 架构:
外设 IRQ → GIC(Generic Interrupt Controller,ARM 标准)→ CPU 核
GIC-v3/v4:支持 LPI(Large Physical Interrupt),每设备每队列独立 IRQ
6.4 DMA 机制
**DMA(Direct Memory Access)**允许外设直接与内存交换数据,无需 CPU 逐字节搬运。
DMA 工作流程
传统 PIO(CPU 参与):
外设 ──字节→ CPU 寄存器 ──字节→ 内存 [每字节占用 CPU]
DMA 方式:
CPU:配置 DMA 控制器(目标内存地址、数据长度、方向)
DMA 控制器:独立完成内存↔外设数据搬运
完成后:DMA 控制器触发中断通知 CPU
c
// Linux DMA API 示例(一致性 DMA,常用于控制结构体)
struct device *dev = &pdev->dev;
size_t size = 4096;
dma_addr_t dma_handle;
// 分配 DMA 一致性内存(CPU 和设备都可见的物理地址)
void *vaddr = dma_alloc_coherent(dev, size, &dma_handle, GFP_KERNEL);
// vaddr:CPU 访问的虚拟地址
// dma_handle:设备使用的总线地址(可能经过 IOMMU 转换)
// 将 dma_handle 写入设备寄存器,设备可以访问该内存
writel(dma_handle, dev_base + DMA_ADDR_REG);
writel(size, dev_base + DMA_SIZE_REG);
writel(DMA_START, dev_base + DMA_CTRL_REG);
// 释放
dma_free_coherent(dev, size, vaddr, dma_handle);
// 流式 DMA(用于数据缓冲区,需手动同步)
dma_addr_t dma_addr = dma_map_single(dev, buffer, size, DMA_TO_DEVICE);
// ... 设备 DMA 传输 ...
dma_unmap_single(dev, dma_addr, size, DMA_TO_DEVICE);
IOMMU
IOMMU(Input-Output Memory Management Unit)为 DMA 提供地址转换和访问控制:
没有IOMMU:设备可以 DMA 到任意物理内存(安全风险)
有了IOMMU:
设备使用 IOVA(I/O 虚拟地址)
IOMMU 转换 IOVA → 物理地址
限制设备只能访问授权的内存区域(防止 DMA 攻击)
应用:虚拟机直通(VFIO/SR-IOV),容器安全,Thunderbolt 安全
6.5 块设备驱动
块设备以固定大小块为单位进行 I/O,内核提供统一的块层(Block Layer):
应用层 read()/write()
│
▼
VFS(文件系统:ext4/xfs)
│
▼
通用块层(Generic Block Layer)
├─ bio(Block I/O):描述一次 I/O 请求(页面列表+扇区号)
│
▼
I/O 调度器(I/O Scheduler)
├─ none(直接下发,SSD推荐)
├─ mq-deadline(截止时间调度,通用)
└─ bfq(Budget Fair Queueing,交互场景)
│
▼
块设备驱动(SCSI/NVMe/virtio-blk)
│
▼
物理存储(HDD/SSD/NVMe)
NVMe 驱动简介
NVMe 通过 PCIe 直连 SSD,支持多队列并行:
c
// NVMe 使用多队列块层(blk-mq)
// 每个 CPU 核心有独立的提交队列(SQ)和完成队列(CQ)
// 避免队列锁竞争,极低延迟(~10μs vs SATA ~100μs)
struct nvme_command cmd = {
.rw = {
.opcode = nvme_cmd_read,
.nsid = cpu_to_le32(ns->ns_id),
.slba = cpu_to_le64(sector >> (ns->lba_shift - 9)),
.length = cpu_to_le16((blk_rq_bytes(req) >> ns->lba_shift) - 1),
}
};
6.6 字符设备驱动进阶
阻塞 I/O 与等待队列
c
// 等待队列:让进程等待某条件满足
static DECLARE_WAIT_QUEUE_HEAD(my_wait_queue);
static int data_available = 0;
// 读操作(阻塞直到有数据)
static ssize_t mydev_read(struct file *file, char __user *buf,
size_t count, loff_t *ppos) {
// 等待条件满足(可被信号中断)
wait_event_interruptible(my_wait_queue, data_available != 0);
if (signal_pending(current))
return -ERESTARTSYS;
// 读取数据 ...
data_available = 0;
return copy_to_user(buf, kernel_buf, len) ? -EFAULT : len;
}
// 中断处理程序中唤醒
static irqreturn_t my_handler(int irq, void *id) {
data_available = 1;
wake_up_interruptible(&my_wait_queue); // 唤醒等待者
return IRQ_HANDLED;
}
poll/select 支持
c
static unsigned int mydev_poll(struct file *file,
struct poll_table_struct *wait) {
unsigned int mask = 0;
poll_wait(file, &my_wait_queue, wait); // 注册等待队列
if (data_available)
mask |= POLLIN | POLLRDNORM; // 可读
if (space_available)
mask |= POLLOUT | POLLWRNORM; // 可写
return mask;
}
6.7 网络设备驱动
网络设备不映射到文件,通过 Socket 接口访问,内核提供 net_device 抽象:
c
struct net_device_ops my_netdev_ops = {
.ndo_open = my_open,
.ndo_stop = my_stop,
.ndo_start_xmit = my_start_xmit, // 发送数据包
.ndo_get_stats64 = my_get_stats64,
.ndo_set_mac_address = eth_mac_addr,
};
// 发送数据包
static netdev_tx_t my_start_xmit(struct sk_buff *skb,
struct net_device *dev) {
struct my_priv *priv = netdev_priv(dev);
// 将 skb 放入硬件发送队列
if (tx_ring_full(priv)) {
netif_stop_queue(dev); // 通知上层停止发送
return NETDEV_TX_BUSY;
}
put_skb_in_tx_ring(priv, skb);
trigger_hw_tx(priv);
dev->stats.tx_packets++;
dev->stats.tx_bytes += skb->len;
return NETDEV_TX_OK;
}
// 接收数据包(通常在 NAPI poll 中)
static int my_poll(struct napi_struct *napi, int budget) {
int work_done = 0;
while (work_done < budget && rx_packet_available()) {
struct sk_buff *skb = build_skb_from_hw();
skb->protocol = eth_type_trans(skb, dev);
napi_gro_receive(napi, skb); // 送入网络栈
work_done++;
}
if (work_done < budget) {
napi_complete_done(napi, work_done);
enable_rx_interrupt(dev); // 恢复中断
}
return work_done;
}
NAPI(New API):高速网络的软中断优化,中断触发后切换到轮询模式,减少中断风暴。
7. 系统调用与内核接口
7.1 系统调用机制
系统调用是用户程序请求内核服务的唯一合法途径。
x86-64 系统调用过程
用户程序调用 read(fd, buf, count):
│
▼
C库(glibc)封装:
mov rax, 0 ; sys_read 的系统调用号
mov rdi, fd
mov rsi, buf
mov rdx, count
syscall ; 陷入内核(保存 rip/rflags 到 RSP,切换特权级)
│
▼
内核入口(entry_SYSCALL_64):
保存用户态寄存器到内核栈
调用 sys_call_table[rax] = sys_read()
│
▼
sys_read() 执行:
→ ksys_read() → vfs_read() → file->f_op->read()
│
▼
sysret:恢复用户态寄存器,返回用户空间
│
▼
C库返回,rax 中是返回值(负数 = 错误码)
常用 Linux 系统调用
| 分类 | 系统调用 | 说明 |
|---|---|---|
| 进程 | fork, exec, exit, wait, getpid | 进程生命周期 |
| 内存 | mmap, munmap, brk, mprotect | 虚拟内存管理 |
| 文件 | open, read, write, close, seek | 文件 I/O |
| 文件系统 | stat, chmod, mkdir, unlink, rename | 文件系统操作 |
| 信号 | kill, signal, sigaction, pause | 信号机制 |
| 网络 | socket, bind, listen, connect, send, recv | 网络通信 |
| IPC | pipe, shm_open, sem_open, mq_open | 进程间通信 |
| 时间 | gettimeofday, clock_gettime, nanosleep | 时间相关 |
| 设备 | ioctl, poll, select, epoll | I/O 多路复用 |
strace --- 追踪系统调用
bash
strace -e trace=open,read,write ls /tmp
strace -p <PID> # 追踪运行中进程
strace -c ./program # 统计系统调用次数和时间
7.2 I/O 多路复用
处理多个文件描述符的并发 I/O:
c
// epoll(Linux 高性能方案,O(1) 事件通知)
int epfd = epoll_create1(0);
struct epoll_event ev = {
.events = EPOLLIN | EPOLLET, // 水平触发 vs 边缘触发
.data.fd = client_fd
};
epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev);
struct epoll_event events[64];
while (1) {
int nfds = epoll_wait(epfd, events, 64, -1);
for (int i = 0; i < nfds; i++) {
if (events[i].events & EPOLLIN) {
handle_read(events[i].data.fd);
}
}
}
8. 操作系统安全
8.1 内存安全机制
| 机制 | 全称 | 防护目标 |
|---|---|---|
| ASLR | Address Space Layout Randomization | 随机化代码/栈/堆基地址,防 ROP |
| DEP/NX | Data Execution Prevention / No-Execute | 数据页不可执行,防 shellcode |
| Stack Canary | 栈金丝雀 | 检测栈溢出(-fstack-protector) |
| RELRO | Relocation Read-Only | GOT 写保护,防函数指针覆盖 |
| CFI | Control Flow Integrity | 限制间接调用目标 |
| Safe Stack | 安全栈 | 返回地址存储在独立影子栈 |
8.2 访问控制
DAC(自主访问控制)
Linux 传统 UNIX 权限模型(owner/group/others + rwx)
MAC(强制访问控制)
- SELinux(NSA/Red Hat):基于安全标签和策略规则,进程/文件/端口均有标签
- AppArmor(Ubuntu/SUSE):基于路径的策略,更简单易用
- seccomp:限制进程可以调用的系统调用(Docker 默认启用)
bash
# seccomp 示例(仅允许 read/write/exit 系统调用)
prctl(PR_SET_SECCOMP, SECCOMP_MODE_STRICT);
# 或使用 BPF 过滤器(更灵活)
8.3 容器安全隔离
Linux 容器隔离机制:
Namespace(命名空间隔离):
├─ PID namespace:独立进程树(PID 1 是 init)
├─ NET namespace:独立网络栈(IP、路由、防火墙)
├─ MNT namespace:独立挂载点视图
├─ UTS namespace:独立主机名
├─ IPC namespace:独立 System V IPC/POSIX MQ
├─ USER namespace:独立 UID/GID 映射
└─ TIME namespace:独立系统时钟
cgroup(控制组,资源限制):
├─ cpu:CPU 时间限制(CFS 配额)
├─ memory:内存上限 + OOM 控制
├─ blkio:块设备 I/O 限速
├─ net_cls:网络流量分类
└─ devices:设备访问白名单
9. 经典面试题与练习题
9.1 进程与线程
Q1:进程和线程的区别?
进程是资源分配的基本单位,拥有独立的地址空间、文件描述符等资源;线程是 CPU 调度的基本单位,同一进程的线程共享地址空间和资源,但有各自的栈和寄存器。进程切换需要切换页表(TLB 刷新),开销大;线程切换只需切换寄存器/栈,开销小。进程间通信需要 IPC;线程间可直接访问共享内存(但需同步)。
Q2:fork() 之后父子进程的执行顺序?
不确定。调度器决定顺序,取决于当前系统负载和调度策略。Linux 默认(由 sysctl_sched_child_runs_first 控制)倾向于先运行子进程(减少 COW 写时拷贝)。
Q3:什么是写时拷贝(COW)?
fork() 后父子进程共享相同的物理页面,页表标记只读。当任一方尝试写操作时,触发缺页异常,内核此时才为写方分配新的物理页并复制内容,更新其页表指向新页。避免了大量无用的内存复制,exec() 后子进程映像完全替换时尤其高效。
Q4:僵尸进程如何产生和消除?
子进程退出后,其 PCB(进程控制块)不会立即释放,保留退出状态直到父进程调用 wait()/waitpid() 回收,期间该进程处于僵尸状态。消除方式:父进程及时调用 wait(),或为 SIGCHLD 信号设置 SIG_IGN 让内核自动回收,或父进程先于子进程退出(僵尸由 init 接管并回收)。
9.2 调度
Q5:什么时候应该用自旋锁,什么时候用互斥锁?
自旋锁适用于:锁持有时间极短(微秒级)、中断上下文(不能睡眠)、多核系统。互斥锁适用于:锁持有时间较长、持锁期间有 I/O 或其他阻塞操作、单核系统。
Q6:CFS 如何保证公平性?
CFS 为每个进程维护一个虚拟运行时间(vruntime),每次调度时选择 vruntime 最小的进程,确保所有进程的 vruntime 趋于相等,即获得相同的 CPU 时间(根据优先级加权)。
9.3 内存管理
Q7:虚拟内存有哪些好处?
- 每个进程有独立地址空间,安全隔离
- 进程可使用超过物理内存的地址空间
- 简化内存分配(无需连续物理内存)
- 支持内存共享(多进程共享代码/库)
- 支持写时拷贝等高级机制
- 便于 mmap 文件映射、IPC 共享内存
Q8:页面置换的 Bélády 异常是什么?
在 FIFO 置换算法中,增加物理页框数量有时反而会导致缺页次数增加的反常现象。LRU 和 OPT 等满足"栈属性"(stack property)的算法不会出现此问题。
Q9:什么是内存抖动(Thrashing)?如何解决?
当进程分配的物理页框数远小于其工作集大小时,缺页频繁发生,大部分时间用于换页而不是实际计算。解决方案:使用工作集模型,确保每个进程分配的页框数 ≥ 其工作集大小;引入页面局部性原理,减少同时驻留内存的进程数(降级为较少的高效执行,而非多个低效执行)。
9.4 死锁
Q10:如何检测和恢复死锁?
检测:维护资源分配图,定期运行环路检测算法(或向量法)。恢复:①终止死锁进程(选代价最小的:优先级低、运行时间短、资源使用多的);②资源抢占(强制回滚持有资源进程到检查点,释放其资源)。
Q11:银行家算法的核心是什么?
每次资源请求时,假设分配后检查系统是否仍在安全状态(存在至少一个安全序列使所有进程能顺序完成)。若安全则实际分配,否则拒绝本次请求,进程等待。时间复杂度 O(n²×m),n=进程数,m=资源类型数。
9.5 文件系统
Q12:inode 中存储了什么?没有存储什么?
存储:文件类型、权限、所有者(uid/gid)、文件大小、时间戳(atime/mtime/ctime)、链接计数、数据块指针/extent。
不存储:文件名(文件名存储在目录项 dentry 中,inode 通过 inode 号引用)。
Q13:硬链接和软链接的区别?
硬链接:直接在目录中创建一个新的目录项,指向同一个 inode;不可跨文件系统,不可链接目录;删除任一链接不影响文件(只要链接计数 > 0)。软链接:是一个独立文件,其内容是目标文件的路径字符串;可跨文件系统,可链接目录;删除原文件后成悬空链接。
9.6 设备驱动
Q14:中断处理的上半部和下半部为何要分开?
中断上半部运行在中断上下文,CPU 屏蔽同级或低级中断,若处理时间过长会导致中断响应延迟,丢失其他设备的中断。因此上半部只做最紧急的操作(确认中断、读取紧急数据),将耗时处理推给下半部(tasklet/workqueue)在正常上下文执行。
Q15:什么是 DMA,为什么需要它?
DMA(直接内存访问)允许外设绕过 CPU 直接访问内存。没有 DMA 时,每次 I/O 都需要 CPU 参与每个字节的搬运(PIO 模式),效率极低。DMA 让 CPU 只需配置传输参数,由 DMA 控制器独立完成,完成后中断通知 CPU,极大解放了 CPU 处理其他任务。
10. 参考资源
权威书籍
| 书名 | 作者 | 适用层次 |
|---|---|---|
| 《现代操作系统》(Modern Operating Systems) | Andrew S. Tanenbaum | 经典教材,全面 |
| 《操作系统概念》(Operating System Concepts) | Silberschatz, Galvin | 大学主流教材 |
| 《深入理解计算机系统》(CSAPP) | Bryant, O'Hallaron | 系统底层必读 |
| 《Linux 内核设计与实现》(LKD) | Robert Love | Linux 内核入门 |
| 《深入理解 Linux 内核》 | Bovet, Cesati | Linux 内核深入 |
| 《奔跑吧 Linux 内核》 | 张天飞 | 国内 Linux 内核实战 |
| 《操作系统:三易之道》(OSTEP) | Arpaci-Dusseau | 免费在线,极好 |
| 《Windows 内核原理与实现》 | 潘爱民 | Windows 内核 |
在线资源
- OSTEP(免费):https://pages.cs.wisc.edu/\~remzi/OSTEP/
- Linux 内核文档:https://www.kernel.org/doc/html/latest/
- Linux man 手册:https://man7.org/linux/man-pages/
- Intel 架构手册:https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html
- ARM 架构参考手册:https://developer.arm.com/documentation/
- MIT 6.828 (xv6):https://pdos.csail.mit.edu/6.828/
- Linux 内核源码浏览:https://elixir.bootlin.com/linux/latest/source
- OSDev Wiki:https://wiki.osdev.org/
实践项目
初级:
1. 实现一个简单的 shell(进程创建、管道、重定向)
2. 用 pthreads 实现生产者-消费者
3. 实现各种调度算法的模拟器
中级:
1. xv6 (MIT 教学 OS) 扩展:添加新系统调用、文件系统功能
2. Linux 字符设备驱动(从零实现)
3. 实现一个简单的内存分配器(malloc/free)
高级:
1. 实现一个简单文件系统(FUSE 用户空间文件系统)
2. 实现协程/用户态线程调度器
3. 编写 Linux 内核模块(block device、网络驱动)
4. 参与 Linux 内核社区贡献
工具清单
bash
# 进程/线程分析
ps aux / top / htop # 进程状态
/proc/<pid>/maps # 虚拟地址空间映射
/proc/<pid>/status # 进程详细状态
pmap -x <pid> # 内存映射
strace / ltrace # 系统调用/库调用追踪
perf top / perf stat # 性能分析
# 内存分析
free -h # 内存/Swap 使用
/proc/meminfo # 内存详细信息
valgrind --leak-check=full # 内存泄漏检测
/proc/buddyinfo # 伙伴系统状态
slabtop # Slab 缓存状态
# 文件系统
df -h # 文件系统使用
du -sh * # 目录大小
iostat -x 1 # I/O 统计
iotop # 进程 I/O 统计
debugfs # ext 文件系统调试
xfs_info / xfs_db # XFS 工具
# 驱动/内核
dmesg # 内核日志
lsmod / modinfo # 内核模块
/proc/interrupts # 中断统计
/proc/iomem # I/O 内存映射
lspci -vv # PCI 设备详情
udevadm monitor # 设备事件监控