03-操作系统理论与Linux内核实现-原理与本质.md 28 KB


tags: [source-summary] type: source source: "操作系统理论与Linux内核实现-原理与本质" author: "AI助手" date: 2026-09-19

created: 2026-09-19

操作系统理论与Linux内核实现:原理与本质

核心问题

本文档要解决什么问题?读完后你能回答:

  • Linux 进程的"身份信息"存在哪里?每个字段解决什么问题?
  • fork 为什么能做到"几乎零成本"?COW 的触发条件是什么?
  • CFS 调度器如何量化"公平"?vruntime 的计算公式背后的物理含义是什么?
  • 字符设备驱动的核心数据结构如何把用户态操作翻译成硬件操作?
  • 系统调用从用户态到内核态经历了哪些步骤?每一步发生了什么?

1. task_struct里到底装了什么

M1 锚点与类比

锚点task_struct 是 Linux 的 TCB(Task Control Block),定义在 include/linux/sched.h,包含进程运行所需的所有信息——PID、状态、优先级、内存映射、文件描述符表、信号处理等。它是内核中最大的数据结构之一(32 位默认配置下 1~2KB,随内核选项浮动)。

类比task_struct 就像一个公司的完整运营手册——pid 是工号,mm 是办公空间平面图,files 是使用的工具清单,stack 是当前正在做的工作记录,sig 是紧急联络机制。

M2 痛点与起源

没有 task_struct,内核无法区分并发执行的多个程序。每个进程需要独立的执行状态、独立的地址空间、独立的文件访问——这些信息必须被集中管理,否则上下文切换时无法保存和恢复。

M3 关键字段机制链

pid 与 tgid:为什么需要两个 ID

字段 含义 为什么存在
pid 进程 ID(全局唯一) 每个 task_struct 都有自己的 pid,线程也有独立 pid
tgid 线程组 ID(= 主线程 pid) 用户态 getpid() 返回的是 tgid,用于区分进程和线程组

关系:主线程的 pid == tgid;同进程其他线程 pid 不同但 tgid 相同。

来源:嵌入式Linux内核基础/04-进程调度与中断管理 §1.2

state:进程状态机

stateDiagram-v2
    [*] --> TASK_RUNNING: fork()
    TASK_RUNNING --> TASK_INTERRUPTIBLE: schedule()
    TASK_INTERRUPTIBLE --> TASK_RUNNING: 被唤醒/信号
    TASK_RUNNING --> TASK_UNINTERRUPTIBLE: 等待I/O
    TASK_UNINTERRUPTIBLE --> TASK_RUNNING: 硬件就绪
    TASK_RUNNING --> TASK_STOPPED: SIGSTOP
    TASK_STOPPED --> TASK_RUNNING: SIGCONT
    TASK_RUNNING --> EXIT_ZOMBIE: exit()
    EXIT_ZOMBIE --> [*]: 父进程wait()
状态 含义 能否被信号唤醒
TASK_RUNNING 就绪或正在运行
TASK_INTERRUPTIBLE 可中断睡眠(等待事件) 可以
TASK_UNINTERRUPTIBLE 不可中断睡眠(等待硬件 I/O) 不可以
TASK_STOPPED 被 SIGSTOP 暂停(调试/Ctrl+Z) SIGCONT 可恢复

来源:嵌入式Linux内核基础/04-进程调度与中断管理 §1.3

优先级三层体系

实时优先级 (0~99)    ← SCHED_FIFO / SCHED_RR
    ↓
普通优先级 (100~139) ← SCHED_NORMAL / SCHED_BATCH
    ↓
                     nice -20 ~ +19 → prio 100~139
字段 作用 转换公式
static_prio 静态优先级(由 nice 值映射) 120 + nice
normal_prio 普通优先级(继承或计算得出) 基于 static_prio
prio 动态优先级(调度器实际使用) 内核可动态调整

来源:嵌入式Linux内核基础/04-进程调度与中断管理 §2.3

mm、stack、files

字段 指向 作用
mm struct mm_struct * 用户空间内存映射(页表、VMA 链表)
stack void * 内核栈指针(通常 8KB/16KB),与用户栈分离
files struct files_struct * 进程打开的文件描述符表

task_struct 这么大(几百个字段)是因为 Linux 需要管理的东西远比 FreeRTOS 多:完整的地址空间、文件系统、信号机制、网络协议栈、安全上下文等。FreeRTOS 的 TCB 只需要栈指针、优先级、状态和链表节点。

task_struct 完整字段分组

struct task_struct {
    /* --- 标识 --- */
    pid_t pid;                  // 进程 ID (全局唯一)
    pid_t tgid;                 // 线程组 ID (= 主线程 PID)
    char comm[TASK_COMM_LEN];   // 进程名称 (16 字节)

    /* --- 状态 --- */
    volatile long state;        // 进程状态 (TASK_RUNNING 等)
    int exit_state;             // 退出状态 (EXIT_ZOMBIE / EXIT_DEAD)
    unsigned int flags;         // 进程标志 (PF_KTHREAD, PF_USED_MATH 等)

    /* --- 调度 --- */
    int prio;                   // 动态优先级 (0~139)
    int static_prio;            // 静态优先级 (由 nice 值映射)
    int normal_prio;            // 普通优先级
    unsigned int rt_priority;   // 实时优先级 (0~99)
    unsigned int policy;        // 调度策略 (SCHED_NORMAL / SCHED_FIFO 等)
    struct sched_entity se;     // CFS 调度实体(vruntime 在这里)
    struct sched_rt_entity rt;  // 实时调度实体

    /* --- 内存 --- */
    struct mm_struct *mm;       // 用户空间内存描述符
    struct mm_struct *active_mm; // 当前活跃的内存描述符

    /* --- 栈 --- */
    void *stack;                // 内核栈指针 (通常 8KB / 16KB)

    /* --- 家族 --- */
    struct task_struct *parent; // 父进程
    struct list_head children;  // 子进程链表
    struct task_struct *group_leader; // 线程组领头进程

    /* --- 文件与信号 --- */
    struct fs_struct *fs;       // 当前工作目录
    struct files_struct *files;// 打开的文件表
    struct signal_struct *sig; // 信号信息
};

来源:嵌入式Linux内核基础/04-进程调度与中断管理 §1.2

M8 易错点

误区 正确理解
pid 和 tgid 总是相同 只有主线程的 tgid 等于 pid,其他线程 tgid 相同但 pid 不同
TASK_UNINTERRUPTIBLE 可被信号唤醒 前者不响应任何信号,后者可以被信号唤醒
task_struct 在内核栈上分配 通过 slab 分配器从专用缓存 task_struct_cachep 分配
vruntime 是 task_struct 的直接字段 实际在 task_struct.se.vruntime,属于 sched_entity

2. fork为什么用COW(写时复制)

M1 锚点与类比

锚点:传统 fork 复制整个地址空间 → COW 共享物理页 → 写入时才复制。fork 性能提升的关键是"偷懒"——能不复制就不复制。

类比:COW 就像图书馆复印——fork 时给你一张"借书证"(页表映射),你可以看所有书(读取共享页);但如果你想在书上做笔记(写入),图书馆才会给你复印一本(复制该页)。

M2 痛点与起源

传统 Unix 的 fork 每次都完整复制父进程地址空间。问题是:大多数 fork 后紧跟 exec,新程序会覆盖整个地址空间——复制的内存全浪费了。在内存 256MB 的嵌入式系统上,一次完整的地址空间复制代价极高。

M3 COW 机制链

flowchart TD
    A["进程写入共享页"] --> B["CPU 发现页是只读"]
    B --> C["触发 page fault"]
    C --> D["内核 do_page_fault()"]
    D --> E["分配新物理页 + 复制内容"]
    E --> F["更新页表指向新页 + 标记可写"]
    F --> G["返回用户态继续执行"]

fork 时发生了什么

  1. copy_page_range() 仅复制页表项(虚拟→物理映射),不复制物理页面
  2. 父子进程页表项都标记为只读(pte_set_wrprotect()
  3. 物理页面引用计数 +1

写入时发生了什么

  1. CPU 检查页表发现只读 → 触发 page fault
  2. 内核分配新的物理帧 → 复制原页内容 → 更新页表指向新页 → 标记可写

fork + exec 的典型场景:fork 创建子进程 → exec 加载新程序 → COW 避免了无意义的完整复制。如果子进程立即 exec,整个 COW 过程只复制了页表(几十 KB),而非整个地址空间。

do_fork 流程

用户态 fork()/clone()
        │
        ▼
  do_fork()                      // 5.9+ 内核中名为 kernel_clone()
        │
        ├── copy_process()          // 核心:复制进程描述符
        │   ├── dup_task_struct()   // 复制 task_struct + 内核栈
        │   ├── copy_creds()        // 复制权限信息
        │   ├── copy_mm()           // 复制内存映射 (COW)
        │   ├── copy_files()        // 复制文件描述符
        │   ├── copy_fs()           // 复制文件系统
        │   ├── copy_sighand()      // 复制信号处理
        │   ├── copy_signal()       // 复制信号信息
        │   └── sched_fork()        // 初始化调度相关字段
        │
        └── wake_up_new_task()      // 将子进程加入调度器

copy_page_range() 中的关键操作:

static int copy_page_range(...) {
    /* 仅复制页表项,设置 COW 标志 */
    pte_t *src_pte = pte_offset_map(...);
    if (pte_present(*src_pte)) {
        pte_set_wrprotect(src_pte);  // 父进程页表改为只读
        copy_pte(dst, src_pte);      // 子进程映射同一页,只读
    }
}

来源:嵌入式Linux内核基础/04-进程调度与中断管理 §3.2

vfork 的存在意义

对比项 fork(COW) vfork
地址空间 独立页表,共享物理页 完全共享父进程地址空间
限制 无特殊限制 子进程不能修改父进程数据
典型场景 需要独立执行流的场景 fork 后立即 exec
性能 需复制页表 连页表都不复制

glibc 的 pthread_create() 底层调用 clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD, ...),线程之间共享地址空间,本质就是"不复制"。

来源:嵌入式Linux内核基础/04-进程调度与中断管理 §3.1

M8 易错点

误区 正确理解
fork 后父子进程虚拟地址相同 虚拟地址相同,但写入后映射到不同物理页
COW 在所有情况下都节省内存 如果 fork 后大量写入,反而多了一次复制开销
vfork 比 fork 快所以应该用 vfork vfork 限制太多(子进程不能修改数据),现代 fork 已足够快
FreeRTOS 也能用 COW FreeRTOS 没有 MMU,无法做页级保护

3. CFS的vruntime为什么这样算

M1 锚点与类比

锚点:CFS(完全公平调度器)的核心是 vruntime(虚拟运行时间)。公式:vruntime += delta_exec × (NICE_0_LOAD / weight)。nice 值越低 → 权重越高 → vruntime 增长越慢 → 获得的 CPU 时间越多。

类比:vruntime 就像"排队积分"——每次轮到你办事,根据你的 VIP 等级(nice 值)消耗不同积分。VIP 等级高(nice 低)的人消耗积分慢,所以排队时间长但每次办事时间也长;普通用户消耗积分快,分到的时间短。

M2 痛点与起源

早期 Linux 使用 O(1) 调度器——基于位图和优先级数组,查找下一个进程 O(1)。但它有两个致命问题:(1) 无法保证公平性,交互式进程可能饿死;(2) 需要启发式判断"交互式"和"批处理",判断错误导致系统卡顿。CFS 用 vruntime 彻底消除了"猜测"——谁的 vruntime 最小,谁就运行。

M3 CFS 机制链

vruntime 计算公式

vruntime 增量 = delta_exec × (NICE_0_LOAD / weight)
变量 含义 示例值
delta_exec 本次实际运行时间 10ms
NICE_0_LOAD nice=0 的权重(基准值) 1024
weight 当前进程的权重 由 nice 值通过 prio_to_weight[] 映射

nice 值与权重对照

nice 值 权重 vruntime 增长速度 含义
-20 88761 极慢 最高优先级,获得最多 CPU
-10 9548 较高优先级
0 1024 基准 普通进程
10 110 较低优先级
19 15 极快 最低优先级,获得最少 CPU

nice -20 的权重是 nice 19 的约 5900 倍(88761/15)。

来源:嵌入式Linux内核基础/04-进程调度与中断管理 §2.2.1

红黑树组织

graph TB
    A["vruntime=500"] --> B["vruntime=300"]
    A --> C["vruntime=700"]
    B --> D["vruntime=200"]
    B --> E["vruntime=400"]
    C --> F["vruntime=600"]
    C --> G["vruntime=800"]
    style D fill:#4caf50,color:#fff
  • 最左节点(vruntime 最小)始终是调度器下一个选择的进程
  • 插入/删除 O(log n),取最左节点 O(1)(缓存 rb_leftmost 指针)
  • 数千进程时红黑树性能远优于链表(链表排序插入 O(n))

调度周期

调度周期 (sysctl_sched_latency) = 6ms(默认)
最小粒度 (sysctl_sched_min_granularity) = 0.75ms

每个进程分配的时间片 = max(调度周期 / 可运行进程数, 最小粒度)

进程数超过 8 时,6ms / 8 = 0.75ms 触发最小粒度限制,CFS 退化为近似轮转。

调度类继承链

Linux 内核通过调度类实现可插拔调度策略,五级调度类从高到低:

  1. stop_sched_class — 最高优先级,用于 CPU 热插拔、迁移
  2. dl_sched_class — Deadline 调度,SCHED_DEADLINE
  3. rt_sched_class — 实时调度,SCHED_FIFO / SCHED_RR
  4. fair_sched_class — CFS 公平调度,SCHED_NORMAL / SCHED_BATCH
  5. idle_sched_class — 空闲任务,每个 CPU 一个 idle 线程

pick_next_task() 从最高级调度类开始遍历,直到找到可运行的进程。这就是为什么实时进程总是抢占普通进程。

来源:嵌入式Linux内核基础/04-进程调度与中断管理 §2.5

睡眠公平性

进程睡眠醒来后 vruntime 可能远小于当前树中其他进程的 vruntime,导致它独占 CPU。CFS 的处理:place_entity() 函数中,thresh = sysctl_sched_latency / 2(GENTLE_FAIR_SLEEPERS),将 vruntime 设为 min_vruntime - thresh,确保睡眠进程获得合理份额但不会抢占过多 CPU。

来源:嵌入式Linux内核基础/04-进程调度与中断管理 §2.2.2 ~ §2.2.4

M8 易错点

误区 正确理解
vruntime 在 task_struct 直接字段中 task_struct.se.vruntime,属于 sched_entity
nice 值越大优先级越高 nice 值越大优先级越低(nice=-20 最高优先级)
CFS 完全靠 vruntime 决策 还需考虑睡眠时间、唤醒抢占等因素
红黑树是 O(1) 查找 取最左节点 O(1),插入/删除 O(log n)

4. 字符设备驱动的file_operations

M1 锚点与类比

锚点:字符设备驱动的核心是 file_operations 结构体——把内核对设备的操作(open/read/write/ioctl)映射到用户空间的系统调用。定义在 include/linux/fs.h

类比file_operations 就像餐厅的菜单——顾客(用户程序)点菜(系统调用),服务员(VFS)把订单传给厨房(驱动),厨房按菜单上的函数(回调)做菜(操作硬件)。

M2 痛点与起源

Linux "一切皆文件"——硬件设备也暴露为 /dev/ 下的文件节点。用户程序用标准 open/read/write/close 操作文件,但内核需要知道如何把这些操作翻译成对具体硬件的寄存器读写。file_operations 就是这个翻译层。

M3 机制链

file vs inode 的生命周期

结构体 生命周期 存放什么
inode 设备存在期间持久 设备全局状态(设备号、设备元数据)
file 每次 open 创建一个 单次打开的会话状态(private_data

设备的全局状态应放在和 inode 相关的结构中;单次打开的会话状态应放在 file.private_data 中。

新驱动推荐注册流程

flowchart TD
    A["alloc_chrdev_region()"] --> B["cdev_init()"]
    B --> C["cdev_add()"]
    C --> D["class_create()"]
    D --> E["device_create()"]
    E --> F["用户空间 /dev/xxx 出现"]
步骤 API 作用
1 alloc_chrdev_region 动态分配设备号(避免冲突)
2 cdev_init 初始化 cdev,绑定 file_operations
3 cdev_add 注册到内核
4 class_create /sys/class/ 下创建设备类
5 device_create /dev/ 下自动创建设备节点

来源:嵌入式Linux驱动开发实战/03-字符设备驱动核心/01-字符设备驱动基础 §3.1~§3.3

file_operations 关键回调

回调 对应用户态 作用
open open() 打开设备,初始化硬件,设置 private_data
release close() 关闭设备,释放资源
read read() 从设备读取数据到用户空间
write write() 从用户空间写入数据到设备
unlocked_ioctl ioctl() 设备特定控制命令
poll select/poll/epoll 支持 I/O 多路复用
mmap mmap() 将设备内存映射到用户空间

ioctl 命令编码

ioctl 命令通过宏自动编码方向、数据大小、magic 和序号,避免手动编码冲突:

#include <linux/ioctl.h>

#define LED_MAGIC   'L'
#define LED_ON      _IO(LED_MAGIC, 0)        // 无数据传输
#define LED_OFF     _IO(LED_MAGIC, 1)        // 无数据传输
#define LED_SET     _IOW(LED_MAGIC, 2, int)  // 向内核写数据
#define LED_GET     _IOR(LED_MAGIC, 3, int)  // 从内核读数据
方向 含义
_IO 仅命令,无数据传输
_IOR 从内核读数据到用户态
_IOW 从用户态写数据到内核
_IOWR 读写 双向传输

用户态到内核态调用链

sequenceDiagram
    participant App as 用户程序
    participant VFS as VFS
    participant Fops as file_operations
    participant HW as 硬件

    App->>VFS: read(fd, buf, cnt)
    VFS->>Fops: f_op->read(filp, buf, cnt, off)
    Fops->>HW: 寄存器操作 / 数据传输
    HW-->>Fops: 数据
    Fops-->>App: copy_to_user() 返回数据

来源:嵌入式Linux驱动开发实战/03-字符设备驱动核心/01-字符设备驱动基础 §1.3~§1.4

M8 易错点

误区 正确理解
file_operations 可被多进程共享 结构体本身共享,但每次 open 创建新的 file 实例
驱动中用 printf 打印调试信息 应该用 printk,且注意日志级别(KERN_DEBUG 等)
注册设备后设备节点自动出现 需要手动 mknod 或使用 udev(class_create + device_create)
copy_to_user 返回负数表示失败 返回未拷贝的字节数(0 成功,非 0 失败)

5. 系统调用怎么从用户态到内核态

M1 锚点与类比

锚点:系统调用 = 用户程序通过 SVC 指令陷入内核 → 查 sys_call_table → 执行内核函数 → 返回用户态。

类比:系统调用就像去政府办事——你(用户程序)填表(系统调用号)→ 到办事大厅(SVC 陷入)→ 取号排队(查 sys_call_table)→ 窗口办理(执行内核函数)→ 拿结果回家(返回用户态)。

M2 痛点与起源

用户程序运行在非特权模式,无法直接操作硬件或访问内核数据。但程序需要读文件、发网络包、分配内存——这些都必须请求内核代劳。系统调用是用户态和内核态之间唯一的合法入口。

M3 机制链

ARM 的 SVC 指令

ARM Cortex-A7 使用 SVC 指令(以前叫 SWI)触发软中断:

  1. CPU 从用户模式(USR)切换到管理模式(SVC)
  2. CPSR 保存到 SPSR_svc
  3. 返回地址保存到 LR_svc
  4. 跳转到异常向量表偏移 0x08 处的 vector_swi

异常向量表布局

/* arch/arm/kernel/entry-armv.S(Linux 4.1) */
    .section .vectors, "ax", %progbits
__vectors_start:
    W(b)   vector_rst        /* 0x00: 复位 Reset */
    W(b)   vector_und        /* 0x04: 未定义指令 */
    W(ldr) pc, __vectors_start + 0x1000  /* 0x08: SWI/SVC */
    W(b)   vector_pabt       /* 0x0C: 指令预取中止 */
    W(b)   vector_dabt       /* 0x10: 数据访问中止 */
    W(b)   vector_addrexcptn /* 0x14: 保留 */
    W(b)   vector_irq        /* 0x18: IRQ 中断 */
    W(b)   vector_fiq        /* 0x1C: FIQ 快速中断 */

Cortex-A7 有 8 个异常入口,IRQ 占一个。SVC(系统调用)的向量在偏移 0x08,而 0x00 是复位向量——两者常被混为一谈。

来源:嵌入式Linux内核基础/04-进程调度与中断管理 §4.1

系统调用表

sys_call_table 是系统调用号 → 函数指针的映射表:

系统调用号 0 → sys_restart_syscall
系统调用号 1 → sys_exit
系统调用号 3 → sys_read
系统调用号 4 → sys_write
...

参数传递与返回值

  • 参数通过 R0-R5 传递最多 6 个参数
  • 系统调用号通过 R7 传递(ARM)
  • 返回值在 R0

完整流程

sequenceDiagram
    participant App as 用户程序
    participant SVC as SVC指令
    participant KStk as 内核栈
    participant SCT as sys_call_table
    participant KFn as 内核函数

    App->>SVC: SVC #0(系统调用号→R7)
    SVC->>KStk: 保存用户态上下文(R0-R15, CPSR)到内核栈
    KStk->>SCT: 用 R7 索引 sys_call_table
    SCT->>KFn: 调用 sys_read/sys_write/...
    KFn-->>KStk: 执行完毕,返回值→R0
    KStk-->>App: 恢复用户态上下文,返回用户程序

内核栈与用户栈切换

运行模式 大小 用途
用户栈 USR 模式 用户空间 用户程序局部变量、函数调用
内核栈 SVC/IRQ 模式 8KB/16KB 系统调用、中断处理的内核函数调用

进入内核态时切换到该进程的内核栈(task_struct.stack 指向),返回用户态时切回用户栈。

来源:嵌入式Linux内核基础/04-进程调度与中断管理 §4.1

与 FreeRTOS 的区别

FreeRTOS 没有用户态/内核态的隔离,所有代码都在同一个特权级别运行。任务切换通过 PendSV 异常完成,没有系统调用的概念——任何函数调用都直接访问所有资源。Linux 的系统调用机制是安全隔离的基础。

M8 易错点

误区 正确理解
系统调用就是普通函数调用 涉及模式切换、栈切换、特权级变化,开销远大于函数调用
所有系统调用都很快 有些系统调用可能阻塞(如 read 等待数据),进程被调度走
系统调用号是固定的 不同架构的系统调用号可能不同,同一架构不同内核版本也可能变化
SVC 指令和 SWI 指令不同 SVC 是 ARMv7+ 的名称,SWI 是旧名称,功能相同

总结:Linux内核的设计特点 vs FreeRTOS

维度 FreeRTOS Linux
有无 MMU
调度器 位图 + O(1) 查找 CFS 红黑树 + vruntime
内存管理 heap_1~5(静态分配/简单堆) 伙伴系统 + slab + COW
上下文切换 PendSV 软中断 / schedule()
IPC 队列 / 信号量 / 任务通知 管道 / 共享内存 / 消息队列 / 信号 / socket
地址空间 所有任务共享同一地址空间 每个进程独立地址空间(mm_struct)
驱动模型 无统一框架,直接操作寄存器 字符设备 / 块设备 / 网络设备 + VFS
系统调用 无(所有代码同一特权级) SVC 指令 + sys_call_table + 模式切换
典型开销 上下文切换 < 10μs 上下文切换 数百μs ~ 数ms
适用场景 确定性实时、资源受限 MCU 通用计算、丰富外设、网络、GUI
graph LR
    subgraph FreeRTOS
        A1["位图调度 O(1)"] --> A2["heap_1~5"]
        A2 --> A3["无MMU共享地址空间"]
        A3 --> A4["PendSV切换"]
    end
    subgraph Linux
        B1["CFS红黑树+vruntime"] --> B2["伙伴系统+slab"]
        B2 --> B3["每进程独立地址空间"]
        B3 --> B4["SVC系统调用隔离"]
    end
    style A1 fill:#e8f5e9
    style B1 fill:#e1f5fe

来源:综合 嵌入式Linux内核基础/04-进程调度与中断管理FreeRTOS学习笔记/02-任务调度与状态管理Linux+C++技术体系/字符设备驱动框架