操作系统内核原理学习笔记

从进程调度到内存管理,深入理解操作系统底层运作机制。


1 · 操作系统概述

1.1 什么是操作系统

操作系统是管理硬件资源、为上层应用提供统一接口的系统软件。它负责:

  • 进程/线程管理
  • 内存管理
  • 文件系统
  • 设备驱动
  • 网络协议栈
  • 安全与权限控制

没有操作系统,每个程序都要直接和硬件打交道,开发效率和稳定性都会极低。

1.2 内核架构分类

宏内核(Monolithic Kernel)

  • 所有核心功能运行在同一个内核地址空间
  • 性能高,模块间通信快
  • 代表:Linux、Windows NT(混合内核偏宏内核)

微内核(Microkernel)

  • 内核只保留最基本功能(调度、IPC、内存管理)
  • 文件系统、设备驱动等运行在用户态
  • 更稳定、更安全,但 IPC 开销大
  • 代表:Minix、QNX、seL4

混合内核

  • 介于宏内核和微内核之间
  • 代表:Windows NT、macOS(XNU)

1.3 内核态 vs 用户态

CPU 有多个特权级别(Ring 0 ~ Ring 3):

  • Ring 0:内核态,可以执行特权指令、访问所有硬件
  • Ring 3:用户态,普通应用程序运行在此

系统调用(System Call) 是用户态进入内核态的唯一合法入口。例如:

  • read() / write()
  • open() / close()
  • fork() / exec()
  • mmap() / brk()

上下文切换有开销:保存/恢复寄存器、切换地址空间、刷新 TLB。


2 · 进程与线程

2.1 进程

进程是程序的一次执行实例,拥有独立的地址空间。

进程控制块(PCB)包含:

  • PID(进程 ID)
  • 状态(就绪、运行、阻塞、僵尸)
  • CPU 寄存器快照
  • 内存映射
  • 打开的文件描述符表
  • 调度信息

进程状态转换:

图 1 \xb7 进程状态转换

创建 ↓ 就绪 ← 时间片到 ↓ 调度 运行 ↓ I/O 请求 / 等待事件 阻塞 ↓ I/O 完成 就绪 ↓ 执行完成 终止

2.2 线程

线程是进程内的执行单元,多个线程共享进程的地址空间和资源。

一个线程有独享的:

  • 程序计数器(PC)
  • 寄存器
  • 栈

共享的:

  • 代码段
  • 数据段
  • 堆
  • 打开的文件描述符

线程模型:

  • 1:1 模型:一个用户线程对应一个内核线程(Linux pthread)
  • N:1 模型:多个用户线程映射到一个内核线程(早期 Green Thread)
  • M:N 模型:M 个用户线程映射到 N 个内核线程(Go goroutine 近似)

2.3 进程间通信(IPC)

方式 特点 适用
管道 单向,父子进程 简单数据流
命名管道 任意进程,文件系统可见 无亲缘关系进程
消息队列 内核维护消息链表 异步消息
共享内存 最快,需要同步 大数据量
信号量 同步与互斥 配合共享内存
信号 异步通知 中断处理
Socket 可跨网络 分布式进程

共享内存为什么最快?

因为数据不需要在内核和用户空间之间拷贝,多个进程直接映射同一块物理内存。

2.4 协程

协程是用户态轻量级线程,由运行时调度。

特点:

  • 切换无需进入内核态
  • 极低的切换开销
  • 适合高并发 I/O 密集型场景

代表:

  • Go goroutine
  • Python asyncio
  • Rust async/await
  • C++20 coroutine

Go 的 M:P:G 模型:

  • G:Goroutine
  • M:操作系统线程
  • P:逻辑处理器,M 需要绑定 P 才能执行 G

3 · CPU 调度

3.1 调度目标

  • 公平性:每个进程都能获得 CPU 时间
  • 吞吐量:单位时间完成的进程数
  • 响应时间:交互式系统关注(用户点击后多久有反应)
  • 周转时间:批处理系统关注(提交到完成的总时间)
  • 实时性:实时系统满足截止时间

3.2 经典调度算法

FCFS(先来先服务)

  • 按到达顺序执行
  • 优点:简单
  • 缺点:护航效应(长任务阻塞短任务)

SJF(短作业优先)

  • 平均等待时间最短
  • 缺点:长任务可能饿死

时间片轮转(RR)

  • 每个进程分配固定时间片
  • 时间片太大 → 响应差;太小 → 切换开销大
  • 通常 10ms ~ 100ms

优先级调度

  • 高优先级先执行
  • 需要防饿死:老化(Aging)机制,等待时间越长优先级越高

多级反馈队列(MLFQ)

  • 多个队列,不同优先级
  • 新进程进入高优先级队列,时间片短
  • 用完时间片降级到低优先级队列
  • 兼顾交互式和批处理任务

3.3 Linux CFS

Linux 的 Completely Fair Scheduler 目标是让每个进程获得"公平"的 CPU 时间。

核心思想:

  • 每个进程维护 vruntime(虚拟运行时间)
  • vruntime 增长越快,说明它占用 CPU 越多
  • 调度时选择 vruntime 最小的进程运行

数据结构:

  • 使用 Red-Black Tree 维护可运行进程
  • vruntime 最小的节点在最左边

nice 值影响权重:

  • nice 越低,权重越高,实际运行时间增长越慢
  • nice 越高,权重越低,实际运行时间增长越快

4 · 内存管理

4.1 虚拟内存

每个进程有独立的虚拟地址空间,大小由硬件决定(x86-64 通常 48 位有效地址,256TB)。

虚拟地址通过页表映射到物理地址。

为什么需要虚拟内存?

  • 进程隔离:一个进程不能直接访问另一个进程的内存
  • 内存超分配:进程的虚拟地址空间可以比物理内存大
  • 简化编程:每个进程都以为自己独占内存
  • 共享内存:不同进程可以映射到同一物理页

4.2 分页机制

内存被划分成固定大小的页(Page),通常 4KB。

页表记录虚拟页到物理页的映射。

x86-64 使用多级页表(4 级):

CR3 → PGD → PUD → PMD → PTE → Physical Page

如果页表项不存在或权限不足,会触发缺页异常(Page Fault)。

4.3 TLB

TLB(Translation Lookaside Buffer)是 CPU 内部的高速缓存,缓存最近用过的虚拟→物理映射。

TLB 命中:地址翻译几乎无开销。 TLB 未命中:需要查页表,可能多级内存访问。

进程切换时 TLB 会被刷新(或 ASID 标记)。

4.4 页面置换算法

物理内存不足时,需要把某些页换出到磁盘。

OPT:替换最长时间不会被访问的页(理论最优,无法实现) FIFO:先进先出,简单但效果差 LRU:替换最久未使用的页 Clock(二次机会):近似 LRU,用访问位实现 Linux Active/Inactive 链表:近似 LRU 的实现

4.5 内存分配

Buddy System(伙伴系统)

  • 按 2 的幂次分配物理页
  • 分配和合并都高效
  • 用于内核大块内存分配

Slab / Slub

  • 内核小对象分配器
  • 预分配固定大小的对象池
  • 减少碎片和分配开销

用户态 malloc

  • ptmalloc(glibc 默认)
  • jemalloc(多线程友好,Redis、Facebook 用)
  • tcmalloc(Google)

malloc 不一定每次都向内核申请内存,而是通过 brk 或 mmap 申请大块,内部切分管理。

4.6 mmap

mmap 把文件或设备映射到进程地址空间。

用途:

  • 文件映射 I/O
  • 共享内存
  • 动态库加载
  • 进程间通信

相比 read/write,mmap 可以减少一次内核到用户空间的拷贝。


5 · 文件系统

5.1 VFS

VFS(Virtual File System)是 Linux 提供的统一文件系统接口。

四个核心对象:

  • superblock:文件系统整体信息
  • inode:文件元数据(大小、权限、时间戳、数据块位置)
  • dentry:目录项,缓存路径到 inode 的映射
  • file:打开的文件实例

5.2 inode

inode 不包含文件名,文件名存在目录的数据块中。

目录项 = 文件名 + inode 号

硬链接:多个文件名指向同一个 inode 软链接:独立文件,内容是目标路径

5.3 ext4 文件系统

ext4 的关键特性:

  • 块组(Block Group):把磁盘分成多个块组,每个块组相对独立
  • 延迟分配:写入时先缓存,刷新时再分配磁盘块
  • 日志(JBD2):先写日志再写数据,崩溃后可恢复
  • Extents:连续存储大文件,替代间接块映射

5.4 页缓存(Page Cache)

读文件时,先查页缓存:

  • 命中:直接返回
  • 未命中:从磁盘读入页缓存,再返回给用户

写文件时:

  • 先写到页缓存,标记为脏页
  • 由 pdflush / flush 线程异步刷盘
  • fsync() 强制刷盘,保证持久化

数据库通常用 O_DIRECT 绕过页缓存,自己管理缓冲池。


6 · 中断与异常

6.1 中断

中断是 CPU 暂停当前任务,去处理异步事件的机制。

硬件中断:由外设触发

  • 网卡收到数据包
  • 磁盘 I/O 完成
  • 定时器到期

软件中断:由指令触发

  • int 0x80(传统 Linux 系统调用)
  • syscall / sysenter(现代 x86 系统调用)

中断处理流程:

  1. CPU 收到中断信号
  2. 保存当前上下文(寄存器、程序计数器)
  3. 查中断描述符表(IDT),找到处理函数
  4. 执行中断处理
  5. 恢复上下文,返回原任务

6.2 上半部与下半部

上半部(Top Half)

  • 紧急、必须快速完成
  • 通常关闭中断执行
  • 只做最少工作,如保存数据、调度下半部

下半部(Bottom Half)

  • 可以稍后执行
  • 开中断执行,不阻塞其他中断
  • 实现方式:softirq、tasklet、workqueue

例如网卡收包:

  • 上半部:把数据从网卡寄存器复制到内核缓冲区
  • 下半部:协议栈处理(IP、TCP)

6.3 异常

异常是 CPU 执行指令时产生的同步事件:

  • 除零错误
  • 缺页异常
  • 非法指令
  • 栈溢出
  • 断点(int 3)

内核会向进程发送信号:

  • SIGSEGV:段错误
  • SIGFPE:浮点异常
  • SIGBUS:总线错误
  • SIGILL:非法指令

7 · 内核同步机制

7.1 为什么需要同步

多核 CPU 并发执行,多个 CPU 可能同时访问同一个内核数据结构(链表、计数器、页表)。需要同步原语保护共享数据。

7.2 自旋锁(Spinlock)

  • 拿不到锁时忙等待(自旋)
  • 不会睡眠
  • 适合临界区极短的场景
  • 不能递归获取
spin_lock(&my_lock);
// 临界区
spin_unlock(&my_lock);

7.3 互斥锁(Mutex)

  • 拿不到锁时睡眠,让出 CPU
  • 适合临界区较长或可能阻塞的场景
  • 可以睡眠

7.4 信号量(Semaphore)

  • 计数器,允许多个线程同时访问
  • P 操作:减 1,为负则等待
  • V 操作:加 1,唤醒等待线程

7.5 RCU

Read-Copy-Update,适合读多写少的场景。

  • 读不加锁
  • 写时复制一份数据,修改后替换指针
  • 老数据延迟释放,等待所有读者退出

Linux 内核大量使用 RCU,如进程链表、路由表。

7.6 内存屏障

现代 CPU 和编译器会乱序执行指令以优化性能。内存屏障防止特定类型的乱序:

  • smp_mb():全屏障
  • smp_rmb():读屏障
  • smp_wmb():写屏障

无锁编程中经常使用。


8 · 系统调用

8.1 常见系统调用

类别 系统调用
进程 fork / vfork / clone / exec / wait / exit
文件 open / read / write / close / lseek / stat
目录 mkdir / rmdir / chdir / getcwd
网络 socket / bind / listen / accept / connect / send / recv
内存 brk / mmap / munmap / mprotect
信号 kill / sigaction / sigprocmask
时间 gettimeofday / clock_gettime / nanosleep

8.2 系统调用流程(x86-64)

  1. 用户程序把系统调用号放入 rax 寄存器
  2. 参数放入 rdi、rsi、rdx 等寄存器
  3. 执行 syscall 指令
  4. CPU 切换到 Ring 0,查 MSR 寄存器找到入口函数
  5. 根据系统调用号调用 sys_call_table 中的函数
  6. 执行内核逻辑
  7. 返回值放入 rax,执行 sysret 返回用户态

8.3 用 strace 调试

# 跟踪指定系统调用
strace -e trace=open,read,write ./myapp

# 统计系统调用次数和耗时
strace -c ./myapp

# 附加到运行中的进程
strace -p <pid>

strace 是排查程序问题的重要工具,比如:

  • 程序卡在某个系统调用上
  • 配置文件路径不对
  • 权限问题导致 open 失败