Linux 内核学习笔记

发布于 2026-10-06

目录

开始之前:这份文档怎么用

约定

读源码的四分法

拿到任何一个内核函数,先把它拆成四类,精力分配完全不同:

类别例子怎么处理
主干逻辑copy_process() 主体必须逐行看
数据结构操作链表挂载、引用计数看懂意图即可
错误处理goto out; if (ret) ...第一遍跳过
兼容/优化/架构差异#ifdef CONFIG_X86_64第一遍跳过

第一遍读源码,跳过后两类不看是正确操作,不是偷懒。

学习进度怎么衡量

不要用”看了多少行”衡量,用”能讲清楚几条路径”衡量。全部学完后,你应该能不查资料回答:

  1. 按下电源后到出现登录界面,内核做了什么?
  2. 敲 ./hello 回车后,这个程序是怎么跑起来的?
  3. fork() 之后父子进程为什么返回值不同?
  4. malloc() 之后为什么可能还没有占用物理内存?
  5. 文件描述符(一个整数)怎么一路找到磁盘上的数据?
  6. CPU 上同时只有一个进程在跑,为什么你感觉几十个程序”同时”在动?

开机:从按下电源到第一个进程

全景图

一句话版本:固件 → 引导程序 → 内核自举 → start_kernel() → rest_init() → 第一个用户态进程。

按下电源
   ↓
CPU 从固定地址取第一条指令(跑的是主板上的固件)
   ↓
固件找到硬盘上的引导程序(GRUB),把它加载进内存
   ↓
GRUB 把内核镜像(vmlinuz 这个文件)加载进内存,跳过去执行
   ↓
内核最开头的汇编:搭建 C 语言能跑的环境
   ↓
start_kernel()      ← init/main.c,内核的"主函数"
   ↓
rest_init()          ← 关键分岔点
   ↓
kernel_init()       ← 它最终变成 1 号进程(systemd)
   ↓
systemd fork 出所有服务 → 登录界面

先解释几个词:

第一遍学习,固件和 GRUB 不用深挖,从”内核被加载进内存”开始看即可。

内核最开头:一段汇编

位置:arch/x86/kernel/head_64.S(x86 是最常见的 PC 架构;ARM 机器对应 arch/arm64/kernel/head.S)。

为什么开头必须是汇编?因为此时 C 语言的环境还不存在:没有栈、没有全局变量地址、内存管理单元还关着。这段汇编只做三件事:

  1. 开启分页(虚拟内存的硬件基础,见第 4 章);
  2. 给自己搭一个栈;
  3. **跳进第一个 C 函数 **start_kernel(),从此不再回头。

读源码建议:这一段第一遍跳过。只要记住结论——“汇编的使命就是让 C 能跑起来,然后光荣退场”。

start_kernel():内核主函数

位置:init/main.c。这个函数很长,几百行,几乎全是函数调用,像一份总目录。内核在这里按依赖顺序给各个子系统上电:没有内存管理就没有分配器,没有分配器什么都建不起来,所以顺序是固定的。

简化后的骨架:

asmlinkage __visible void __init start_kernel(void)
{
    setup_arch();            // 架构初始化:解析内存布局、建早期页表
    setup_command_line();    // 保存内核启动参数(就是 GRUB 菜单里 "quiet splash" 那些)
    mm_core_init();          // ★ 内存管理上线:伙伴分配器(分配物理页的部门)
    sched_init();            // ★ 调度器上线(第 6 章)
    init_IRQ();              // 中断系统上线
    time_init();             // 时钟上线:调度、sleep 全靠它计时
    console_init();          // 控制台上线:这之后你才能看到 dmesg 打印的内容
    softirq_init();          // 软中断(中断处理的"下半场")上线
    ...
    /* 一路把各种子系统插上电…… */

    rest_init();             // ★★★ 全剧最重要的分岔点,见下一节
}

几个缩写顺便解释:

读法:不要逐个展开这几百个调用。把 start_kernel() 当目录读一遍,能说出”哦,内存比调度器先上电”就够了。

rest_init():从内核走向多任务世界的门口

位置:init/main.c。这是整个启动流程里最值得逐行读的函数,因为只有几十行,却是分水岭。

noinline void __init_refok rest_init(void)
{
    struct task_struct *tsk;

    // 1. 创建第一个内核线程,它去跑 kernel_init 函数
    pid = user_mode_thread(kernel_init, NULL, CLONE_FS);

    // 2. 当前的这个执行流(idle 的雏形)转去执行 cpu_startup_entry()
    //    即"没有任务可跑时 CPU 待的地方",无限循环省电
    schedule_preempt_disabled();   // 3. ★ 第一次调用调度器
    cpu_startup_entry(CPUHP_AP_ONLINE_IDLE);   // 它正常情况不会返回
}

用工厂比喻理解这三步:

一个重要认知:到这里为止,世界上没有任何普通进程,只有内核自己。


上述存在一些误解,所以第一次学习时候有点没懂user_mode_thread 创建的不是“内核里第一个线程”,下面详细解释一下:

所以user_mode_thread 创建的不是“内核里第一个线程”,而是“第一个被 fork 类机制创建出来的线程”。PID 0 比它资格老,但没经过创建过程。

顺带解释 user_mode_thread 和 kernel_thread 的区别:内核里有两个造线程的函数,kernel_thread() 造的线程只准待在内核态;user_mode_thread() 造的线程将来允许切换到用户态去执行。kernel_init 之后要 execve("/sbin/init") 变成用户态进程,所以必须用 user_mode_thread。

那 rest_init 最后去哪了

rest_init() 是函数不是任务,它执行完(其实永远不返回)后,执行它的这条执行流——PID 0——的身份就变成 idle:

cpu_startup_entry(CPUHP_AP_ONLINE_IDLE);    ↓do_idle()   // 死循环:反复检查"有没有活干"              没活 → 执行 mwait/hlt 指令让 CPU 睡眠省电              来活了 → 被调度器叫醒 → 干活 → 再回来睡

所以之前那个厂长比喻可以再精确一步:


补充术语:

kernel_init():第一个用户态进程的诞生

位置:还在 init/main.c。kernel_init() 作为内核线程先跑腿:

kernel_init()
   ├─ 完成剩余初始化:根据启动参数挂载真正的根文件系统
   ├─ (如果配了 initrd/initramfs,先用它过渡)
   └─ ★ run_init_process("/sbin/init")
        └─ 本质是 kernel_execve():加载磁盘上的 ELF 文件并开始执行

关键的戏剧性时刻:kernel_execve() 执行 /sbin/init(在现代发行版上,它是软链接,指向 systemd——1 号进程、所有服务的管理者)。此时 kernel_init 这个 task_struct 被复用:PID 依然是 1,但它从”内核线程”变成了”用户态进程”。

开机到此结束。此后你机器上的一切进程,全是 1 号进程直接或间接 fork 出来的。

系统调用:用户程序进入内核的门

为什么要有这扇门

先立地基,两个概念:

为什么这么设计?如果任何程序都能直接读写磁盘、改别人的内存,一个 bug 就能毁掉整台机器。所以 CPU 硬件层面划了这道墙,而系统调用(system call,常缩写 syscall)就是墙上唯一的门:用户程序想用内核的服务(读文件、分配内存、创建进程),必须排队敲门,让内核代劳。

一个类比:用户程序是客户,内核是柜台后面的职员,syscall 是取号机。客户不能翻柜台自己翻档案,只能取号(发起系统调用),职员(内核)验号后替你去办。

一次 read() 的完整旅程

需求:C 代码里 read(fd, buf, 100),从文件读 100 字节。它要穿过几道门。

第 0 步:libc 包装

你调用的 read() 其实是 libc(C standard library,C 标准库,glibc 最常见)里的包装函数。它做的事:把参数装进寄存器、把系统调用号装进寄存器、执行特殊指令。

第 1 步:syscall 指令——墙上的门缝

x86-64 上就是一条指令:syscall(ARM64 上叫 svc)。它做两件事,硬件自动完成:

  1. CPU 从用户态切到内核态(权限升级);
  2. 跳到内核事先登记好的固定入口(门的位置是内核定的,用户程序无法伪造)。

同时 libc 按约定(ABI,Application Binary Interface,二进制接口约定)把参数放好:系统调用号放 rax,三个参数放 rdi、rsi、rdx。

第 2 步:入口分发

位置:arch/x86/entry/entry_64.S → arch/x86/entry/common.c。

syscall 指令
   ↓ entry_SYSCALL_64          (汇编入口:保存现场、切换栈)
   ↓ do_syscall_64             (查系统调用表)
   ↓ x64_sys_call()            (根据 rax 里的编号分发)

系统调用表:一个函数指针数组,编号即下标。read 的编号是 0,write 是 1,open 是 2……(unistd.h 里那些 __NR_read 宏)。所以”门后面的房间”就是一张内核预先建好的大表,按号取房。

第 3 步:进入通用层

位置:fs/read_write.c。

SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count)
{
    return ksys_read(fd, buf, count);
}

ssize_t ksys_read(unsigned int fd, char __user *buf, size_t count)
{
    struct fd f = fdget_pos(fd);   // ★ 拿到 struct file
    ...
    ret = vfs_read(f.file, buf, count, &f.file->f_pos);
    ...
}

先解释宏:SYSCALL_DEFINE3(read, ...) 的意思是”定义一个 3 参数的系统调用,名字叫 read”。数字就是参数个数。展开后就是你熟悉的函数定义,这是内核用来统一生成元数据的写法。

注意一个重要细节:buf 的类型是 char __user *——用户态指针。内核绝不直接信任用户给的指针(它可能为 NULL、可能越界、进程可能骗你)。所有跨墙拷贝必须用 copy_to_user() 这类专用函数,先验证再拷贝。内核里每一个安全漏洞的祖师爷,几乎都是”直接解引用了用户指针”。

第 4 步:VFS 通用层

vfs_read()(同文件)做权限检查,然后是本节最重要的一行:

ret = file->f_op->read_iter(...)   // 或走新版封装 new_sync_read

f_op 是 struct file_operations(文件操作表)指针。这是什么?内核版的多态:

struct file_operations {
    ssize_t (*read_iter)(...);
    ssize_t (*write_iter)(...);
    int (*open)(...);
    int (*release)(...);
    ...
};

设计思想:具体文件系统负责填表,通用层负责查表。

普通文件:ext4 填的表 → 读写走页缓存,最终到磁盘
终端:tty 填的表     → 读写走到键盘/显示器
管道:pipe 填的表    → 读写走到内存里的环形缓冲区

这就是为什么 read() 对文件、终端、管道都能用:通用层只认接口,不问出身。这与 OOP(面向对象编程)里的”接口 + 实现”完全同构,只是用 C 的函数指针手工实现。

第 5 步:返回

结果写回寄存器,sysret 指令降回用户态,libc 拿到返回值,你的 read() 返回。全程微秒级。

把整条链串起来(背下来)

用户 read()
→ libc 装参数 → syscall 指令
→ entry_SYSCALL_64(保存现场、升权限)
→ 按调用号查表分发 → SYSCALL_DEFINE3(read)
→ fdget_pos(fd)   拿到 struct file(整数 fd 的真身)
→ vfs_read()
→ file->f_op->read_iter(...)   函数指针分发到具体文件系统
→ 数据经 copy_to_user() 交回用户 buf
→ sysret 降回用户态

用 strace 现场验尸

strace(system call trace,系统调用追踪器)能打印进程的每次敲门:

strace cat /etc/hostname

你会亲眼看到:openat(...) = 3(fd 3 被分配)、read(3, "...", 8192)、write(1, ...)(fd 1 是标准输出)、close(3)。纸上看到的链,用 strace 都能对上号——这是检验你理解的最快手段。

进程:fork、exec 与线程

进程的”户口本”:task_struct

内核里进程不叫 object、不叫 Process,管理单位叫任务(task),核心结构是 struct task_struct,定义在 include/linux/sched.h,有几百个字段。第一遍不要试图全懂,先抓这张关系图:

task_struct(一个执行流的户口本)
 ├── mm          → 该进程的整个地址空间(第 4 章主角)
 ├── files       → 打开的文件表(fd 0/1/2 和你 open 的所有文件)
 ├── fs          → 当前目录、根目录
 ├── signal      → 信号相关(Ctrl+C 怎么杀死进程就靠它)
 ├── parent      → 父进程是谁
 ├── pid / tgid → 编号
 ├── cred        → 身份:哪个用户、什么权限
 └── 调度字段    → 优先级、虚拟运行时间等(第 6 章)

两个编号顺便讲清:

内核里所有任务串在一条全局链表(task_struct->tasks)上,ps 命令就是遍历它(实际读 /proc,/proc 是它的投影)。

fork 的总思路:先整体照抄,再按需改

破除一个常见误解:Linux 创建进程不是照图纸从头造一个,而是把父进程整个复制一份,然后按参数决定哪些资源”真复制”、哪些”共享”。这就是 fork 极快的根本原因。

调用链:

用户 fork()
→ kernel_clone()          ← kernel/fork.c
→ copy_process()          ← fork 的核心,90% 逻辑在这
→ wake_up_new_task()      ← 新任务放进调度队列
→ 两个进程各回用户态

fork() 只是 libc 里的薄包装,真实实现是 kernel_clone()(以前叫 _do_fork,新版本改名了,内核喜欢改这种名字,老博客别较真)。

copy_process() 逐段拆解

位置:kernel/fork.c。按执行顺序,共六步。

第 1 步:领户口本

static __latent_entropy struct task_struct *copy_process(...)
{
    p = dup_task_struct(current);   // ★ current 是谁?
// dup_task_struct 内部(简化)
*p = *orig;    // ★★ 整块 memcpy 照抄父进程

fork 的”儿子像爹”,物理来源就是这一行:先整个照抄。

第 2 步:改身份

copy_creds(p, clone_flags);   // 拷贝用户 ID、权限(cred = credentials,凭证)

第 3 步:按 clone_flags 决定资源”复制”还是”共享”

这是整个 fork 的灵魂。 clone_flags(克隆标志位)是一组二进制开关,每个开关管一项资源:

开关效果
不设 CLONE_VM复制地址空间 → 表现为进程
设 CLONE_VM共享地址空间 → 表现为线程
设 CLONE_FILES共享打开文件表(线程常用)
设 CLONE_SIGHAND共享信号处理表
都不设全套复制 → 经典 fork

对不上号的直觉:进程和线程在内核里没有本质区别,都是 task_struct,区别只在这组开关。

逐个看三个关键函数:

copy_mm()——Copy-on-Write(COW,写时复制)的诞生地:

static int copy_mm(unsigned long clone_flags, struct task_struct *tsk)
{
    if (clone_flags & CLONE_VM) {
        // 线程:地址空间直接共享,引用计数 +1
        mmget(oldmm);
        tsk->mm = oldmm;
        return 0;
    }
    // 进程:dup_mm → 复制的是"页表"这层簿记,不是物理内存!
    retval = dup_mm(tsk);
}

关键:dup_mm() 复制的是页表(地址翻译的”账本”,见第 4 章),让父子两本账都指向同一批物理页,并把两本账上的所有条目都标记为只读。物理内存一个字节都没多花。

之后谁先写,谁的 CPU 就发现”这页标了只读但你却在写”——触发缺页异常(page fault),内核在异常处理里(mm/memory.c 的 do_wp_page())才当页复制那一页,再各自改各自的。这就是”写时复制”四个字的全部含义:抄账本不抄货,谁动谁付钱。

推论:一个 malloc 了几个 GB 的进程,fork 瞬间只多付一本页表的钱(每 4KB 页一条账目,页表本身也压缩得很好),这就是 fork 快的真正原因。

copy_files()——文件表的规则:

fd 表整体复制,父子各持一份表。但表里每项指向的 struct file(打开文件对象)不复制,只把引用计数 +1。所以 fork 后父进程再 read() 会推进同一个文件偏移量——父子读同一本书,共用一个书签。

copy_thread()——一场精妙的”伪造现场”(本节高潮):

int copy_thread(struct task_struct *p, ...)
{
    // 取出内核栈顶的异常帧(保存用户态现场的结构)
    struct pt_regs *regs = task_pt_regs(p);

    // 让子进程的"用户态返回值" = 0
    regs->ax = 0;    // ★★★
    // 子进程内核栈里放一个假的中断返回帧
    ...
    p->thread.ip = (unsigned long) ret_from_fork;   // 子进程第一次被调度时的起跑点
}

理解它,就能回答初学者三大经典问题之一:“fork 为什么返回两次、值还不一样?”

真相是:fork 只被真正执行了一次(父进程)。子进程从未执行过 fork——它是被伪造了一个”刚要返回”的现场:内核往子进程的内核栈里塞了一个假的寄存器现场,指令指针指向”从 fork 返回”的位置,返回值寄存器 ax 被写成 0。等调度器第一次切到子进程、恢复这个现场,它便”从 fork 处醒来”,拿到 0。

而父进程是真实跑完 fork 的,返回值是新任务的 PID。两个返回值,来自两个不同的现场恢复,不是函数被调了两次。

第 4 步:登记身份

copy_process() 内:
    pid = alloc_pid(...)   // 领编号
    p->pid = pid_nr(pid);
    p->real_parent = current;   // 认爹
    // 并挂进若干哈希表和全局链表
    list_add_tail_rcu(&p->tasks, ...);

RCU(Read-Copy-Update,读时复制更新):内核核心的并发技巧——改链表时不打断读者,先造新版再原子切换指针。第一遍知道名字即可。

第 5 步:上户口

回到 kernel_clone():

wake_up_new_task(p);   // 选个 CPU 的运行队列,挂进去

见第 6 章。然后父进程正常返回——此刻子进程还没跑过一行代码,只是被登记进调度队列等待发车。

exec:不造新人,只换灵魂

fork 单独用没意义,经典组合是 fork + exec:fork 出子进程,子进程立刻 execve() 装入新程序(这就是 shell 执行命令、posix_spawn、所有进程诞生的标准姿势)。

位置:fs/exec.c。

execve("ls") 干的事:

  1. 找到磁盘上的 ELF(可执行文件格式);
  2. 销毁当前 mm:解除所有旧映射(页表、映射区域全扔);
  3. 读 ELF 的段(segment),建新映射:代码段、数据段、堆、栈;
  4. 把 argv、envp(参数表、环境变量表)压到新栈顶;
  5. PID 不变、files 不变(所以 fd 能传给新程序!)、parent 不变;
  6. 设好返回用户态的现场:指令指针 = ELF 的入口地址。

一句话:户口本不动,灵魂整个换掉。exec 不是”创建”。

这个特性 + COW 是 Unix 的传家宝:fork 极便宜(抄账本)+ exec 干净(换灵魂)+ fd 继承,组合出 shell 管道(ls | grep:fork 两个子进程,用 pipe() 连起来,各自 exec)。

退出与收尸:exit、wait 和”僵尸”

学到这里顺手自测一个高频面试题:为什么大量产生子进程却不 wait,会撑爆系统?——每个僵尸占一条不可回收的 task_struct。

本章小结(背下来)

fork = 整体照抄(dup_task_struct 的 memcpy)
       + 按 clone_flags 决定每项资源复制还是共享(copy_mm / copy_files / ...)
       + copy_thread 伪造"刚从 fork 返回"的现场(ax = 0)
       + wake_up_new_task 挂进调度队列
exec = 不造新人换灵魂:旧 mm 全销毁,装 ELF 新映射,PID/fd/爹都不变
线程 = clone(CLONE_VM|CLONE_FILES|...):只换开关,别无不同
退出 = do_exit 释放一切 → 僵尸等父 wait 收尸 → 孤儿归 systemd

虚拟内存:页表、缺页与写时复制

为什么要虚拟内存

假如程序直接用物理地址:两个程序都想住 0x1000 这间房怎么办?一个 bug 把别人内存写了怎么办?

解法:给每个进程一套独立编号的”虚拟地址”,真实住在哪由一个翻译层决定。这就是虚拟内存(virtual memory):

进程眼里的地址(虚拟地址)
        ↓  翻译(硬件自动做)
真实的物理地址(哪个内存条、哪个格子)

好处三合一:隔离(各住各的编号,互不干扰)、省内存(用多少分多少)、灵活(可以搬家、可以映射文件、可以换出到磁盘)。

翻译层:MMU 与页表

一个关键认知:账本是按进程一本一本建的。两个进程虚拟地址相同(比如都从 0x400000 开始),物理上毫无冲突,因为账本不同。

内核里的三个核心结构

都在 include/linux/mm_types.h:

struct mm_struct {
    pgd_t * pgd;      // ★ 账本第一级的地址(此结构 = 进程的整个地址空间)
    ...
    struct maple_tree mm_mt;    // ★ 该进程所有"已登记区域"的索引
};

struct vm_area_struct {         // 缩写 VMA(Virtual Memory Area,虚拟内存区域)
    unsigned long vm_start;     // 这块区域的起始虚拟地址
    unsigned long vm_end;       // 结束
    unsigned long vm_flags;     // 读/写/执行权限
    struct file *vm_file;       // 若非空:这块区域映射的是哪个文件
    const struct vm_operations_struct *vm_ops;  // 这块区域自己的"操作表"(又是函数指针!)
};

struct page {                   // ★ 物理内存的格子:每个物理页一个,全局一份
    unsigned long flags;        // 状态标记:是否脏(被写过)、是否在内存里……
    atomic_t _refcount;         // ★ 几人在共享(引用计数)
    ...
};

三者的关系,一张图:

进程的虚拟地址空间(mm_struct,一本账)
 ┌────────────────────────────────────────┐
 │ VMA: 代码段(只读,vm_file=hello 可执行文件) │
 │ VMA: 数据段(读写)                          │
 │ VMA: 堆(malloc 用,匿名=vm_file 为空)      │
 │ 未登记的空洞 ← 访问这里直接 SIGSEGV          │
 │ VMA: 共享库映射(只读,vm_file=libc.so)      │
 │ VMA: 栈(读写)                              │
 └────────────────────────────────────────┘
       ↓ 账本:页表(pgd 起头的四级结构)
       ↓  每条账目指向……
  struct page(物理格子,几号内存条、第几格)

一个高频面试点的答案就藏在这里:malloc 不报错但一写就崩,为什么? 因为 malloc 只在堆上”登记”了更大的 VMA(账面动作),没分物理页。真正写入时 MMU 查账:条目不存在 → 触发 page fault → 内核现场分页 → 若发现连 VMA 都没有 → SIGSEGV。延迟分配(lazy allocation)是贯穿整个内存子系统的设计哲学:能拖到最后一刻的分配就拖。

mmap:登记一块区域

mmap() 系统调用(mm/mmap.c)就是把上面图中”新增一个 VMA”:

// 用户 mmap(NULL, len, PROT_READ|PROT_WRITE, MAP_ANONYMOUS|MAP_PRIVATE, -1, 0)
SYSCALL_DEFINE6(mmap, ...)
    ↓
map = mmap_region(...)
    // 核心:造一个 vm_area_struct,填好起止地址、权限,
    // 挂进 mm_struct 的索引树。结束。
    // ★ 没分配任何物理页。

两种模式的分岔,务必分清:

page fault:账上没这页?现场补

page fault(缺页异常):MMU 查账没查到(或权限冲突),CPU 临时停下当前指令,跳进内核预先登记的处理函数,处理完再回来重试那条指令。它 99% 不是错误,是内核”现场办事”的机制。

处理链(mm/memory.c):

MMU 翻译失败 → CPU 跳异常入口(汇编, arch/x86/entry/entry_64.S)
→ exc_page_fault()            (x86 入口)
→ handle_mm_fault(vma, address, flags)
   ├─ 先查:这个地址落在哪个 VMA?
   │   ├─ 不在任何 VMA → 发 SIGSEGV(真正的错误)
   │   └─ 在 VMA → 接着看是哪种缺页 ↓
   ├─ ① 读匿名页缺页(第一次碰 malloc 的内存)
   │      → do_anonymous_page():领一个物理页,清零,记账,返回
   │        → CPU 重试指令 → 成功
   ├─ ② 读文件页缺页
   │      → 从磁盘把对应页读进来(走页缓存,第 5 章),记账
   └─ ③ 写"只读标记的 COW 页"(第 3 章埋的伏笔在此引爆)
          → do_wp_page():★ 真正复制这一页(写时复制的"付钱时刻")
            检查:真的只有我在用?→ 独占,只改回可写权限
                  还有别人共住?   → 复制一份给自己

把 ③ 讲透,因为它回收了第 3 章的伏笔:fork 时 dup_mm 抄了账本、所有页标只读。此后:

COW 的省钱账:fork 后子进程往往立刻 execve,旧地址空间全部销毁,99% 的页从未被写——那些复制就永远不必发生。

顺带两个词,常见但不该陌生:

本章小结(背下来)

mm_struct = 一本账(pgd 指向四级页表)
VMA       = 账上登记的一块区域(mmap 只登记、不分配)
page      = 物理格子(全局一份,引用计数管共享)
MMU/TLB   = 硬件翻译员 + 小抄
page fault= 账上没有就现场补:匿名页→清零给页
                                 文件页→从盘读入
                                 COW页→当场复制
SIGSEGV   = 查无此人或权限不符

文件系统:VFS 四层结构

VFS 是什么

一台机器上往往同时有 ext4(Linux 常用本地盘格式)、tmpfs(纯内存文件系统,/tmp)、procfs(内核信息投影,/proc)、NFS(网络文件系统)……为什么 open/read/write 一套 API 通吃?

答案是 VFS(Virtual File System,虚拟文件系统):内核在”通用 API”和”各家具体文件系统”之间加了一层统一抽象。思想与第 2 章 file_operations 一脉相承——通用层定接口,各家填函数指针。VFS 是 Linux 内核最成功的一次抽象设计,没有它就没有”一切皆文件”的 Unix 哲学。

四层结构:一个 fd 的族谱

核心是分清四个概念。所有初学者混的都是它们:

fd(整数,进程私有的表格下标)
 └─→ struct file("这次打开"的会话状态:偏移量、权限、引用计数)
      └─→ dentry("路径名 → inode"的翻译件,目录项缓存)
           └─→ inode(文件本体元数据:大小、权限、修改时间……)
                └─→ 具体文件系统/设备里的真实数据

一道经典的分概念题,此刻你应该能秒答:rm 删文件名后文件为什么还可能在?(fd 还开着,struct file 引用着 inode,数据仍在——这正是 lsof +L1 能找回”已删除但仍被进程持有”文件的原理。)

open 的旅程:字符串 → inode

open("/home/a.txt", O_RDONLY) 的链(fs/open.c、fs/namei.c):

SYSCALL_DEFINE(openat 系列入口)
→ do_sys_openat2()
   ├─ get_unused_fd_flags()   // ★ 第一步:领一个空闲 fd 号
   ├─ do_filp_open()
   │    └─ path_openat()  → filename_lookup()   ← 逐级查路径,重头戏在这
   │         走到 fs/namei.c 的 link_path_walk():
   │           从根("/")或当前目录的 dentry 出发
   │           取出路径分量"home"→ 在父目录 inode 里查"home"这个 dentry
   │           → 下一段"a.txt" → 查到目标 inode
   │           (dcache 缓存命中就不真查盘)
   │    └─ 造一个新的 struct file,填好:指向目标 dentry/inode、
   │         调用 inode->i_fop->open(...)【又是函数指针,各家文件系统的入口】
   └─ fd_install(fd, f)       // ★ 最后一步:fd 号放进本进程的表格
→ 返回用户态

两个值得咀嚼的细节:

  1. 顺序反直觉:fd 号是先领的,最后才装进表——领号排队在前,办事在后,fail 早退原则。
  2. 权限检查在每个分量上做:/home/a.txt 要有权限穿过 home、进到 a.txt,逐级查(execute 权限在目录上的含义就是”允许穿过这个目录找东西”)。

read 的旅程:回到第 2 章那条链

read(fd, buf, len)
→ fd → struct file → dentry → inode → 具体 file_operations → ……

唯一新知识:页缓存(page cache)。本地文件系统几乎不直接让 read 走盘:读文件时按页缓存进内存(address_space 结构管理的”文件 → 物理页”缓存树),第二次读同一段就不再碰盘。写也是同理——先写页缓存、标记”脏”(dirty,已改未刷盘),内核攒一批再异步入盘。Linux 文件 I/O 的性能引擎就是页缓存。

read 命中页缓存 → 微秒级,纯内存
read 未命中     → page fault(第 4 章 ②)→ 从盘读入页缓存 → 下次命中

第 4 章的”文件映射缺页”与此完全打通:mmap** 一个文件后读它 = 用缺页异常按需从页缓存取页**——两条看似无关的机制,在页缓存处合流。这是内核设计中”一个缓存服务所有路径”的典范。

一图收束(背下来)

fd(下标)
→ file(会话:书签/偏移量,fork 共享)
→ dentry(名字,全局缓存)
→ inode(本体元数据+数据指针,引用计数管生死)
→ file_operations(函数指针表,各家文件系统填)
→ 页缓存(读写性能引擎,mmap 与 read 在此合流)

调度器:CPU 时间怎么分

要解决什么问题

每个 CPU 核同一时刻只能跑一个任务。但你有浏览器、音乐、终端几十个进程”同时在动”。原理是:把 CPU 时间切成毫秒级的小片,轮着给——快到人感觉不出切换。

任务三种典型状态:

运行中(正占着 CPU)
   ↓ 时间片耗尽或被更高优先级抢走
就绪(万事俱备,只欠 CPU —— 挂在运行队列里排队)
   ↓ 被调度器选中        ↑ 被唤醒(事件到达,如数据可读)
睡眠(等事件:等磁盘、等键盘、等锁、sleep)──┘

直觉校准:大部分切换是任务自己睡的(主动),“抢来抢去”是少数——所以几十个任务并不真的在”争抢”,多数在睡觉排队等事件。

位置与入口

调度器在 kernel/sched/:

kernel/sched/core.c    ← 总入口:schedule()、context_switch()(第一遍主读这个)
kernel/sched/fair.c    ← 普通任务的"完全公平"策略(新版本叫 EEVDF)
kernel/sched/rt.c      ← 实时任务策略(rt = real time,实时,必须尽快响应的任务)
include/linux/sched.h  ← task_struct 定义

schedule():核心只有十几个字

core.c 里的主干(简化到骨架):

static void __sched __schedule(void)
{
    prev = current;                      // 当前任务

    next = pick_next_task(rq, prev);     // ① 从运行队列挑下一个

    if (prev != next) {
        context_switch(rq, next);       // ② 换人
    }
}
context_switch 内部:
    ① 切地址空间:换 mm(刷 TLB —— 第 4 章讲过为什么贵);
      内核线程不切(它没有用户 mm)。
    ② switch_to(prev, next):最后一刀,汇编级换栈+换寄存器。
       执行完这行,CPU 已经在跑 next 了。
       ★ prev 之后的代码要等 prev 下次被切回来才继续执行
         —— 这就是"被切换者视角":
         每个任务眼里的世界都是"睡了一觉/被人切走过,
         从 schedule() 调用点后面继续跑"。

switch_to() 有一层微妙的理解:它不是”函数调用后回来”,而是从此以后 CPU 的执行流属于 next;prev 挂在 switch_to 后面那几行,要等下次 prev 被别人选中才轮到执行——“下一段代码”是一张期货。把第 3 章 copy_thread 的”伪造现场”和这里对上,你就完全打通了”任务切换”的全部原理。

挑人:公平怎么定义

普通任务用公平调度(fair.c):核心直觉是**“记账”**——每个任务记一笔 vruntime(virtual runtime,虚拟运行时间,= 已占的 CPU 时间按权重折算)。

挑人规则:永远挑 vruntime 最小的 —— 谁最久没被 CPU 照顾,谁先来
睡眠任务醒来 → vruntime 很小 → 很快被挑到(等了很久,欠的该还)
CPU 密集任务 → vruntime 一直涨 → 让位给交互任务

一句话:排队时比的是”你多久没跑过”,不是”你多重要”(重要程度用 nice 值加权,nice 值越低越优先)。实时任务(rt.c)另立山头:严格优先级压倒公平,调度器永远先喂实时队列——所以实时任务要是失控占满 CPU,普通任务会饿死,内核为此留了保底机制。

唤醒:把睡觉的人捞回队列

第 4~5 章埋的伏笔在此收尾——“睡眠→唤醒”是调度器与所有等待场景共用的骨架:

read(fd, buf, len) 无数据可读
→ 标记自己睡眠(挂到该 socket/管道/文件的"等待队列" waiter 上)
→ 主动 schedule() 让出 CPU
...
事件到达(网卡中断收到包 / 对端写入管道)
→ wake_up(该等待队列)
→ 把任务改回就绪态,放回运行队列
→ 被 pick_next_task 选中 → 回到 read() 调用点后面继续执行

这一段就是 read() 在没有数据时会”卡住”的全部原理——它没卡住,它睡了。现在你应该能同时从三条链(第 2 章系统调用、第 3 章进程状态、本章调度)讲清同一个现象了。

本章小结(背下来)

schedule() = pick_next_task + context_switch
公平策略   = 比 vruntime(谁久没跑谁先跑,睡眠者有欠账优势)
实时策略   = 严格优先级,先于公平
换人成本   = 换 mm + 刷 TLB + 存取寄存器现场
睡眠/唤醒  = 挂等待队列 + 事件 wake_up —— read 卡住的全部原理

动手实验与下一步

不装环境的实验

# ① 看开机主线(第 1 章):所有 [    0.000000] 开头的都是 start_kernel 的打印
dmesg | head -50

# ② 看系统调用(第 2 章)
strace cat /etc/hostname

# ③ 看 fork 链(第 3 章):数一数启动一个 shell 要 clone/execve 多少次
strace -c -e trace=clone,execve sh -c 'echo hi'

# ④ 看内存布局(第 4 章):找 VMA、找堆、找栈
cat /proc/self/maps
# 按 4KB 探测 malloc 何时真正拿页(观察 Rss/缺页):
grep -E 'VmSize|VmRSS' /proc/self/status

# ⑤ 看文件结构(第 5 章):两个硬链接同一个 inode 号
touch a b && ln a b2 && ls -li a b2
# 已删但被进程持有(fd 指着 inode):
tail -f /var/log/syslog &   # 占住文件后另开终端 rm,lsof 还能看到

# ⑥ 看调度(第 6 章):观察睡眠为主的进程
ps -eo state,comm | sort | uniq -c | sort -rn

进阶:内核里的断点(有 QEMU 时)

QEMU(Quick EMUlator,开源虚拟机)+ GDB(GNU Debugger,调试器)里起一个测试内核,断点直接下在这份笔记讲过的函数上,眼见为实:

start_kernel        ← 第 1 章:开机主函数
rest_init           ← 第 1 章:分岔点
copy_process        ← 第 3 章:fork 核心(观察参数、clone_flags)
do_execveat_common  ← 第 3 章:exec
handle_mm_fault     ← 第 4 章:所有缺页的总入口
do_wp_page          ← 第 4 章:COW 付钱时刻
path_openat         ← 第 5 章:open 找路
schedule            ← 第 6 章:换人入口

学完这份笔记之后的地图

七条主干已覆盖内核 70% 的日常认知。再往外走,按需选:

中断与下半场(softirq/tasklet/workqueue):事件的"接收→延后处理"机制,驱动和网络的地基
块设备与 I/O 栈:第 5 章页缓存之下的故事
网络栈(net/):socket → TCP → IP → 网卡驱动,复杂度≈以上全部之和,放最后
同步(include/linux/spinlock.h 等):锁的原理,读 COW/调度时已经反复撞见
命名空间与 cgroup(kernel/cgroup/):容器(Docker)的内核地基——fork/进程语法的延伸