操作系统原理:从入门到精通全解析

操作系统原理完整知识总结

目录

  1. 操作系统概述
  2. 进程与线程
    • 2.1 进程的概念与状态
    • 2.2 进程控制块(PCB)
    • 2.3 进程创建与终止
    • 2.4 进程间通信(IPC)
    • 2.5 线程的概念与模型
    • 2.6 用户级线程 vs 内核级线程
    • 2.7 线程同步与互斥
    • 2.8 死锁
  3. 进程调度
    • 3.1 调度基本概念
    • 3.2 调度算法详解
    • 3.3 多处理器调度
    • 3.4 实时调度
    • 3.5 Linux 调度器(CFS)
  4. 内存管理
    • 4.1 内存管理基础
    • 4.2 分区内存管理
    • 4.3 分页机制
    • 4.4 分段机制
    • 4.5 段页式管理
    • 4.6 虚拟内存
    • 4.7 页面置换算法
    • 4.8 内存分配器
  5. 文件系统
    • 5.1 文件系统基础
    • 5.2 目录结构
    • 5.3 文件系统实现
    • 5.4 典型文件系统(FAT/ext4/NTFS/XFS)
    • 5.5 虚拟文件系统(VFS)
    • 5.6 日志与崩溃一致性
  6. 设备驱动
    • 6.1 I/O 系统架构
    • 6.2 设备驱动模型
    • 6.3 中断处理
    • 6.4 DMA 机制
    • 6.5 块设备驱动
    • 6.6 字符设备驱动
    • 6.7 网络设备驱动
  7. 系统调用与内核接口
  8. 操作系统安全
  9. 经典面试题与练习题
  10. 参考资源

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):只能执行非特权指令

用户态 → 内核态 的转换方式:

  1. 系统调用syscall/int 0x80 软中断)
  2. 硬件中断(外设触发,如键盘、网卡)
  3. 异常/陷阱(除零、缺页、非法指令)

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(管道/消息/共享内存) 共享内存(直接访问全局变量)
安全隔离 强(一个崩溃不影响他人) 弱(一个线程崩溃影响全进程)
并行能力 多核可真正并行 多核可真正并行(内核线程)
线程的优点与应用场景
  1. 响应性:UI 线程 + 工作线程,防止界面卡死
  2. 资源共享:无需 IPC,同进程线程直接共享数据
  3. 经济性:创建/销毁比进程轻量 10~100 倍
  4. 多处理器利用:一个进程内多线程真正并行执行

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 条件)
  1. 互斥(Mutual Exclusion):资源一次只能被一个进程使用
  2. 占有并等待(Hold and Wait):进程持有资源同时等待其他资源
  3. 不可抢占(No Preemption):资源不能被强制剥夺
  4. 循环等待(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 高(毫秒级)
调度时机

调度器在以下时机运行:

  1. 进程终止:当前进程结束,必须选新进程
  2. 阻塞操作:进程进行 I/O 等阻塞调用
  3. 时钟中断:时间片耗尽(抢占式调度)
  4. 新进程/线程创建:可能抢占当前进程
  5. 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)

进程可在队列间动态移动,是最通用的调度算法之一:

规则

  1. 优先级高的队列优先执行

  2. 同一队列内使用 RR

  3. 新进程进入最高优先级队列

  4. 用完时间片未结束 → 降低优先级(CPU 密集型惩罚)

  5. 在较低优先级等待太久 → 提升优先级(老化防饥饿)

    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, &param);  // 实时 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 内存管理基础

内存管理的目标:

  1. 地址空间隔离:每个进程有独立的虚拟地址空间
  2. 内存保护:防止进程越界访问他人内存
  3. 高效利用:减少碎片,提高内存利用率
  4. 透明扩展:通过虚拟内存给每个进程提供超过物理内存的地址空间
地址类型
地址类型 说明 示例
逻辑地址(虚拟地址) 程序使用的地址,由 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_typestruct devicestruct 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:虚拟内存有哪些好处?

  1. 每个进程有独立地址空间,安全隔离
  2. 进程可使用超过物理内存的地址空间
  3. 简化内存分配(无需连续物理内存)
  4. 支持内存共享(多进程共享代码/库)
  5. 支持写时拷贝等高级机制
  6. 便于 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 内核

在线资源

实践项目

复制代码
初级:
  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              # 设备事件监控
相关推荐
航Hang*2 小时前
第2章:进阶Linux系统——第4节:配置与管理NFS服务器
linux·运维·服务器·笔记·学习·vmware
唔662 小时前
原生 Android(Kotlin)仅串口「继承架构」完整案例二
android·开发语言·kotlin
错把套路当深情2 小时前
Kotlin 全方向开发技术栈指南
开发语言·kotlin
飞Link2 小时前
LangGraph 核心架构解析:节点 (Nodes) 与边 (Edges) 的工作机制及实战指南
java·开发语言·python·算法·架构
xuhaoyu_cpp_java3 小时前
Boyer-Moore 投票算法
java·经验分享·笔记·学习·算法
JavaEdge.3 小时前
Chrome加载已解压的扩展程序-清单文件缺失或不可读取 无法加载清单
java
亚空间仓鼠3 小时前
OpenEuler系统常用服务(三)
linux·运维·服务器·网络
iReachers3 小时前
HTML打包EXE配置管理教程:多项目打包设置一键保存、加载与切换
java·前端·javascript
武藤一雄3 小时前
WPF中ViewModel之间的5种通讯方式
开发语言·前端·microsoft·c#·wpf