欢迎光临
我们一直在努力

Linux0.11阅读笔记

Linux 0.11阅读笔记

本文以 Linux 0.11 的 i386 源码为起点,对比现代 Linux 的引导、进程创建、进程调度、睡眠唤醒和退出回收机制,并补充 ARM64/QEMU 平台的对应关系。

1. 学习目标

阅读本笔记后,应能够回答以下问题:

  • Linux 0.11 的 bootsect.s、setup.s 和 head.s 分别负责什么?

  • 为什么 Linux 0.11 要在 0x7C00、0x90000、0x10000 和 0x00000 之间搬移代码?

  • Linux 0.11 如何从 16 位实模式进入 32 位保护模式并开启分页?

  • 现代 Linux 为什么通常不再由内核自己的第一个扇区读取磁盘?

  • GRUB、UEFI Stub、bzImage、boot_params 和 start_kernel() 分别处于哪一层?

  • ARM64 启动过程与 Linux 0.11 的 x86 启动过程如何对应?

  • Linux 0.11 中“进程、任务和调度对象”是什么关系?

  • fork() 如何通过 copy_process() 创建进程,并实现写时复制?

  • schedule() 如何依据 counter 和 priority 选择进程?

  • sleep_on()、wake_up() 如何完成睡眠与唤醒?

  • do_exit() 为什么不会立即彻底删除进程?

  • 上述机制与现代 Linux 的线程模型、每 CPU 运行队列和 EEVDF 调度器有什么联系?

  • 2. 首先区分两个概念

    “Linux 引导程序”经常包含两类程序。

    2.1 外部引导加载器

    它运行在 Linux 内核之前,主要负责:

    • 找到内核映像;

    • 将内核装入内存;

    • 加载 initramfs;

    • 准备内核命令行和硬件信息;

    • 跳转到内核规定的入口地址。

    现代系统中的典型例子包括:

    • GRUB;

    • systemd-boot;

    • U-Boot;

    • UEFI 固件及 Linux EFI Stub。

    2.2 内核早期启动代码

    这部分属于 Linux 内核自身,主要负责:

    • 建立最初的栈;

    • 设置 CPU 工作模式;

    • 建立临时页表;

    • 解压和重定位内核;

    • 准备进入通用 C 语言内核初始化函数。

    Linux 0.11 把这两部分结合得比较紧密。bootsect.s 自己读取磁盘,而 setup.s、head.s 又继续完成内核早期初始化。现代 Linux 则把外部加载器和内核自身启动代码明确分开。

    3. Linux 0.11 的完整启动链路

     flowchart TD
        A["BIOS"] –> B["bootsect.s:加载映像"]
        B –> C["setup.s:硬件信息与保护模式"]
        C –> D["head.s:页表与分页"]
        D –> E["init/main.c:main()"]

    可以简化为:

     BIOS
      → bootsect.s
      → setup.s
      → head.s
      → init/main.c 中的 main()
      → 初始化内核子系统
      → 创建进程 1

    3.1 第一阶段:BIOS 加载 bootsect.s

    传统 PC BIOS 完成最基本的硬件自检后,将启动设备的第一个扇区读入:

     物理地址 0x7C00。大小512字节。

    然后从 0x7C00 开始执行。此时CPU仍处于16位实模式。Linux 0.11的 bootsect.s 首先将自身复制到 0x90000:

     0x07C00 ──复制512字节──> 0x90000

    这样做是为了避开低地址区域,防止后续加载内核时覆盖仍在运行的引导代码。随后bootsect.s 使用BIOS磁盘服务int 0x13继续加载:

     setup.s → 0x90200
     system   → 0x10000

    其中 system 是由 head.s 和内核主体构成的映像。

    3.2 Linux 0.11 早期内存布局

    物理地址内容说明
    0x00000 内核最终运行位置、后来的页目录 setup.s 会将 system 搬到这里
    0x07C00 BIOS 最初加载的引导扇区 共 512 字节
    0x10000 system 临时加载位置 避免直接覆盖 BIOS 低地址区域
    0x90000 搬移后的 bootsect、启动参数区 setup.s 后续也在这里保存硬件参数
    0x90200 setup 程序 紧跟在引导扇区之后
    0x9FF00 bootsect 使用的临时栈附近 栈向低地址增长

    该布局体现了早期 x86 的限制:BIOS 运行在实模式下,使用“段地址 × 16 + 偏移地址”形成物理地址,并且早期加载过程主要活动在 1 MiB 以下。

    3.3 bootsect.s 的主要任务

    bootsect.s 主要完成:

  • 将自身从 0x7C00 搬到 0x90000;

  • 设置临时段寄存器和栈;

  • 使用 BIOS 中断加载 setup.s;

  • 获取软盘的磁道参数;

  • 将 system 映像加载到 0x10000;

  • 根据配置或软盘格式确定根设备;

  • 关闭软盘电机;

  • 跳转到 setup.s。

  • 需要注意:它直接按照磁道、磁头、扇区读取数据,不认识 ext4、FAT 等文件系统,也没有现代引导菜单。

    3.4 第二阶段:setup.s 获取硬件信息

    setup.s 仍然运行在 16 位实模式,可以继续使用 BIOS 服务。它通过 BIOS 获取并保存:

    信息主要来源
    光标位置和显示模式 int 0x10
    扩展内存大小 int 0x15
    硬盘参数 BIOS 中断向量表及 int 0x13

    这些数据被放在 0x90000 附近,后面的 C 语言内核代码再从固定地址读取。

    3.5 setup.s 将内核搬到地址 0

    system 映像最初位于 0x10000,setup.s 将其向低地址搬移:

     0x10000~内核末尾
            ↓
     0x00000~对应位置

    原因是 Linux 0.11 的 head.s 按绝对地址 0x00000000 组织,并且最初的页目录也放在地址 0。这种设计非常依赖固定内存布局。现代 Linux 通常不会把内核整体搬到物理地址 0。

    3.6 从实模式进入保护模式

    在切换模式之前,setup.s 依次完成:

     cli,关闭中断
          ↓
     搬移 system 映像
          ↓
     加载临时 IDT 和 GDT
          ↓
     打开 A20 地址线
          ↓
     重新编程 8259A 中断控制器
          ↓
     设置 CR0.PE
          ↓
     远跳转到代码段选择子 8、偏移 0

    其中:

    • A20 地址线:打开后 CPU 才能正常访问 1 MiB 以上的地址;

    • GDT:为保护模式建立代码段和数据段描述符;

    • IDT:此时只建立最简单的临时中断描述表;

    • 8259A 重映射:避免硬件中断向量与 CPU 异常向量冲突;

    • 远跳转:装载新的 CS,真正进入 32 位保护模式代码。

    3.7 第三阶段:head.s 建立分页环境

    进入 head.s 时,CPU 已经处于 32 位保护模式,但分页尚未开启。head.s 的主要工作是:

  • 设置数据段寄存器和内核栈;

  • 建立正式的临时 GDT、IDT;

  • 检查 A20 是否真正打开;

  • 检查 x87 数学协处理器;

  • 在物理地址 0 建立页目录;

  • 在 0x1000~0x4000 建立 4 张页表;

  • 恒等映射前 16 MiB 物理内存;

  • 将页目录地址写入 CR3;

  • 设置 CR0.PG,开启分页;

  • 进入 init/main.c 中的 main()。

  • 恒等映射表示:

     线性地址 0x00100000 → 物理地址 0x00100000
     线性地址 0x00200000 → 物理地址 0x00200000

    地址转换关系暂时保持不变,可以降低刚开启分页时的复杂度。一个很有代表性的细节是head.s 最初就在地址 0 运行,而建立页目录时又会清空地址 0 开始的内存,因此早期启动代码会被页目录覆盖。此时相关代码已经执行完毕,不再需要返回。

    3.8 进入 main() 后

    此时引导汇编阶段基本完成,内核开始初始化:

    • 内存管理;

    • 中断和陷阱;

    • 块设备、字符设备;

    • 时钟和调度器;

    • 缓冲区和文件系统;

    • 进程 0 和进程 1。

    因此可以把 main() 看作:

    体系结构相关的早期引导

    通用内核初始化


    4. 现代x86-64 Linux的典型启动过程

    现代 Linux 没有唯一固定的外部启动方式,常见的是传统 BIOS 路径和 UEFI 路径。

    4.1 传统 BIOS 路径

    BIOS
    → MBR 中的少量代码
    → GRUB 等完整引导加载器
    → 加载 bzImage、initramfs 和命令行
    → Linux 实模式兼容代码
    → 进入保护模式
    → 建立临时页表并进入 64 位模式
    → 解压、重定位内核
    → start_kernel()

    传统 BIOS 仍然只能首先加载一个很小的启动扇区,但这个扇区通常只负责找到更完整的 GRUB 阶段,不再像 Linux 0.11 的 bootsect.s 一样直接承担整个内核加载过程。

    4.2 UEFI 路径

    flowchart TD
    A["UEFI 固件"] –> B["GRUB、systemd-boot 或 EFI Stub"]
    B –> C["内核、initramfs、启动参数"]
    C –> D["解压与重定位"]
    D –> E["64位内核入口与 start_kernel()"]

    UEFI 能识别 EFI 系统分区中的文件,并执行 PE/COFF 格式程序,不再依赖 BIOS 将第一个 512 字节扇区装入 0x7C00。启用 EFI Stub 后,Linux 内核映像可以表现为 EFI 可执行文件,因此固件或 systemd-boot 可以直接加载它,不一定需要 GRUB。

    4.3 现代内核内部的启动链路

    现代 x86-64 内核内部可以概括为:

    arch/x86/boot/header.S

    arch/x86/boot/main.c

    go_to_protected_mode()

    arch/x86/boot/compressed/head_64.S

    decompress_kernel()

    arch/x86/kernel/head_64.S

    x86_64_start_kernel()

    start_kernel()

    不同启动协议可能跳过其中部分传统实模式路径,但最终都要建立内核需要的 CPU、内存和参数环境。

    4.4 bzImage 与内核解压

    现代 x86 内核通常以压缩的 bzImage 形式启动。引导加载器负责将它放到符合协议的位置,压缩内核代码随后负责:

    • 建立解压阶段使用的临时内存环境;

    • 选择合适的目标地址;

    • 根据需要实施 KASLR 地址随机化;

    • 解压真正的 ELF 内核;

    • 跳转到解压后的内核入口。

    因此现代加载过程更接近:

    加载压缩映像

    选择或随机化目标地址

    解压内核

    进入解压后的内核入口

    而不是 Linux 0.11 的“加载到 0x10000 后整体搬到地址 0”。

    4.5 标准化启动参数

    现代 x86 启动加载器通过 Linux Boot Protocol 向内核传递 struct boot_params,内容可包括:

    • 物理内存布局;

    • 内核命令行;

    • initramfs 地址和大小;

    • 显示信息;

    • ACPI RSDP 地址;

    • 引导加载器类型;

    • 内核装载地址;

    • 扩展 setup_data。

    Linux 0.11 使用的是固定地址、固定偏移;现代 Linux 使用了版本化的启动协议。这使内核可以与 GRUB、UEFI、QEMU、kexec 等多种加载环境配合。

    4.6 进入 start_kernel()

    start_kernel() 相当于现代 Linux 的通用内核初始化总入口,功能上对应 Linux 0.11 的 main()。后续大致为:

    start_kernel()
    → 内存、异常、中断、时钟、调度器等初始化
    → rest_init()
    → 创建 PID 1:kernel_init
    → 创建 PID 2:kthreadd
    → 启动任务变为 CPU0 idle

    PID 1 完成剩余初始化后,通过 kernel_execve() 执行 /init、/sbin/init 等用户空间程序。


    5. 两者的相似之处

    虽然实现规模相差巨大,但核心阶段仍然一致。

    共同目标Linux 0.11现代 Linux
    固件先运行 BIOS BIOS 或 UEFI,也可能存在其他平台固件
    将内核装入内存 bootsect.s 直接读扇区 GRUB、UEFI、U-Boot 等加载
    传递硬件信息 固定保存在 0x90000 附近 boot_params、EFI、ACPI 或设备树
    准备 CPU 状态 实模式转保护模式 进入保护模式、长模式或相应架构模式
    建立初始页表 映射前 16 MiB 建立多级临时页表和正式页表
    汇编进入 C head.s → main() head_64.S → start_kernel()
    初始化操作系统 初始化内存、设备、文件系统和调度器 同样的总体目标,但子系统更多
    创建第一个用户空间进程 创建 PID 1 并运行 init 创建 PID 1,最终执行用户空间 init

    共同的抽象过程为:

    加载内核
    → 准备启动参数
    → 建立最小执行环境
    → 开启地址转换
    → 进入通用 C 代码
    → 初始化完整内核


    6. 两者的主要区别

    比较项Linux 0.11现代 Linux
    外部加载器 内核自带 bootsect.s 承担大量加载工作 通常使用独立的 GRUB、UEFI、systemd-boot 或 U-Boot
    固件接口 传统 PC BIOS UEFI 为主,同时保留 BIOS 兼容路径
    磁盘访问 BIOS int 0x13,按磁道、磁头、扇区读取 加载器可支持 FAT、ext4、NVMe、USB、网络等
    内核装载位置 固定装入 0x10000,再搬到 0x00000 通常装入 1 MiB 以上,可重定位
    内核映像 简单 system 映像 通常为压缩的 bzImage,解压后为 ELF 内核
    地址随机化 不支持 可支持 KASLR
    启动参数 固定地址和固定偏移 标准化、版本化的 Boot Protocol
    根文件系统 通常直接挂载软盘或硬盘根设备 经常先进入 initramfs,再挂载真正根文件系统
    CPU模式 16 位实模式转 32 位保护模式 最终进入 x86-64 长模式
    分页 两级页表,恒等映射前 16 MiB 多级页表、超大内存和高地址内核映射
    处理器数量 单处理器 引导处理器启动后继续唤醒其他 CPU
    硬件描述 BIOS 参数与大量固定假设 ACPI、EFI 内存映射、设备树等
    安全能力 基本没有 可配合 Secure Boot、签名验证和 TPM 测量启动
    支持架构 早期 i386 PC x86-64、ARM64、RISC-V 等多种架构

    7. 功能对应关系

    不能把 Linux 0.11 文件与现代文件完全一一对应,但可以从功能上理解:

    Linux 0.11现代系统中的近似对应功能
    bootsect.s BIOS 启动扇区、GRUB、UEFI 或 U-Boot 的一部分加载功能
    setup.s 获取 BIOS 参数 Boot Protocol、EFI、ACPI、设备树
    setup.s 切换保护模式 arch/x86/boot/pm.c 等体系结构早期代码
    head.s 建立页表 compressed/head_64.S、kernel/head_64.S
    main() start_kernel()
    固定的 0x90000 参数区 struct boot_params
    直接选择根设备 内核命令行 root=、initramfs 和用户空间挂载流程

    最核心的变化是:

    Linux 0.11:加载器与内核早期代码紧密结合
    现代 Linux:固件 → 通用加载器 → 标准启动协议 → 内核早期代码

    8. 从内核引导进入进程管理

    完成 head.s → main() 后,Linux 0.11 开始初始化内存、异常、中断、设备、文件系统和调度器。进程管理由此接入启动流程。

    Linux 0.11 的关键路径为:

    head.s
    → main()
    → sched_init()
    → sti()
    → move_to_user_mode()
    → fork()
    ├─ 子进程:执行 init(),成为 PID 1
    └─ 父进程:反复调用 pause(),成为任务 0

    sched_init() 主要完成:

    • 初始化任务 0 的 TSS 和 LDT;

    • 清空其他任务槽;

    • 初始化 8253/8254 定时器;

    • 安装 0x20 时钟中断;

    • 安装 0x80 系统调用入口。

    现代 Linux 的对应路径大致为:

    start_kernel()
    → sched_init()
    → rest_init()
    ├─ kernel_clone(kernel_init) → PID 1
    ├─ kernel_thread(kthreadd) → PID 2
    └─ cpu_startup_entry() → CPU0 idle

    两者都先有一个静态建立的启动任务,再创建 PID 1,最后让原启动任务承担 idle 角色。区别在于 Linux 0.11 没有成熟的内核线程框架,需要任务 0 先进入用户态再调用 fork();现代 Linux 可以直接在内核态创建 kernel_init 和 kthreadd。


    9. 进程、任务和线程模型

    9.1 Linux 0.11:进程就是任务

    Linux 0.11 使用:

    struct task_struct *task[NR_TASKS];

    其中 NR_TASKS = 64,包括任务 0,因此整个系统只有固定的 64 个任务槽。每个任务由一个 task_struct 描述,保存:

    • 运行状态;

    • counter 和 priority;

    • PID、父进程 PID;

    • 信号和阻塞信号;

    • 文件描述符;

    • 当前目录、根目录和可执行文件;

    • TSS、LDT;

    • 用户时间和内核时间;

    • 地址空间相关信息。

    task_struct 与内核栈共同占用一个 4 KiB 页面:

    一个4 KiB页面
    ┌──────────────────────────────┐ 低地址
    │ task_struct │
    ├──────────────────────────────┤
    │ │
    │ 内核栈空闲区域 │
    │ │
    ├──────────────────────────────┤
    │ 内核栈顶 │ 高地址
    └──────────────────────────────┘

    Linux 0.11 尚未支持现代意义上的多线程和线程组。因此基本可以认为:

    一个进程
    = 一个 task_struct
    = 一个 PID
    = 一个调度对象
    = 一个执行流

    9.2 现代 Linux:线程是直接调度对象

    现代 Linux 仍然使用 task_struct,但每一个线程都有自己的 task_struct:

    一个进程(线程组)
    ├─ 主线程 task_struct,TID 1000
    ├─ 线程1 task_struct,TID 1001
    └─ 线程2 task_struct,TID 1002

    三者共享 TGID 1000

    现代 Linux 内核并没有为“线程”和“进程”分别设计两套完全不同的调度对象。它通过 clone 标志决定不同任务共享哪些资源:

    • CLONE_VM:共享地址空间;

    • CLONE_FILES:共享文件描述符表;

    • CLONE_SIGHAND:共享信号处理方式;

    • CLONE_THREAD:加入同一线程组。

    所以现代 Linux 中更准确的关系是:

    一个 task_struct = 一个可调度线程
    多个共享资源的 task_struct = 一个用户可见进程


    10. Linux 0.11 的进程创建

    10.1 创建调用链

    Linux 0.11 创建进程的主要调用链为:

    用户调用 fork()

    int 0x80

    system_call.s 中的 sys_fork

    find_empty_process()

    copy_process()

    copy_mem()

    copy_page_tables()

    子进程变为 TASK_RUNNING

    10.2 find_empty_process()

    它完成两件事:

  • 产生一个当前系统中未使用的 PID;

  • 在 task[1]~task[63] 中寻找空槽。

  • 如果固定任务表已经没有空槽,就返回 -EAGAIN。这说明 Linux 0.11 的进程数量由编译时数组大小直接限制。

    10.3 copy_process()

    copy_process() 的核心过程为:

  • 申请一个 4 KiB 页面;

  • 将页面起始地址作为新 task_struct;

  • 先整体复制父进程的 task_struct;

  • 将子进程暂时设置为 TASK_UNINTERRUPTIBLE;

  • 设置新 PID、父 PID、启动时间和统计信息;

  • 设置 counter = priority;

  • 构造子进程的 TSS 寄存器现场;

  • 将子进程的 EAX 设置为 0;

  • 调用 copy_mem() 建立子进程地址空间;

  • 增加共享文件、目录和可执行文件 inode 的引用计数;

  • 在 GDT 中建立该任务的 TSS、LDT 描述符;

  • 最后设置为 TASK_RUNNING。

  • “最后才设置为可运行”非常重要:只有初始化全部成功后,调度器才能看到并运行新进程。

    10.4 为什么父子进程的 fork() 返回值不同

    父进程继续执行原来的系统调用路径,copy_process() 向父进程返回子进程 PID。子进程的 TSS 中被预先设置:

    子进程 eax = 0;

    所以从用户程序角度看:

    pid = fork();

    if (pid == 0) {
    /* 子进程 */
    } else {
    /* 父进程,pid为子进程PID */
    }

    并不是 fork() 真正执行了两次,而是父子进程从相同指令位置继续运行时,寄存器返回值不同。

    10.5 地址空间与写时复制

    Linux 0.11 为不同任务分配固定的 64 MiB 线性地址区间:

    任务n的线性地址基址 = n × 64 MiB

    copy_mem() 修改子进程 LDT 中代码段和数据段的基址,然后通过 copy_page_tables() 复制页表关系。父子进程不会立即复制全部物理内存,而是:

    父页表 ─┐
    ├─→ 同一个只读物理页
    子页表 ─┘

    某一方尝试写入

    触发缺页异常

    复制物理页

    修改者获得自己的可写副本

    这就是写时复制 COW。它减少了 fork() 的内存复制量,尤其适合子进程很快调用 exec() 的情况。

    10.6 与现代 Linux 进程创建的联系和区别

    现代 Linux 的公共创建路径大致为:

    fork / vfork / clone / clone3

    kernel_clone()

    copy_process()

    wake_up_new_task()

    两者的共同点包括:

    • 都分配新的 task_struct;

    • 都复制新任务的寄存器现场;

    • 都复制或引用父任务资源;

    • 普通 fork() 都继续使用写时复制;

    • 初始化完成后才让新任务进入可运行状态。

    主要差别包括:

    比较项Linux 0.11现代 Linux
    创建接口 主要是 fork() fork、vfork、clone、clone3、内核线程
    任务存储 固定 task[64] 动态分配并使用多个索引结构
    线程支持 没有线程组 可通过 CLONE_* 共享资源并组成线程组
    地址空间 每任务固定 64 MiB 线性区间 独立 mm_struct,支持庞大虚拟地址空间
    内核栈 与 task_struct 位于同一 4 KiB 页面 独立分配,可配置更大栈或虚拟映射栈
    隔离机制 基本没有 PID、用户、挂载、网络等命名空间
    管理机制 简单引用计数 cgroup、LSM、seccomp、审计、RCU 等

    11. Linux 0.11 的进程调度

    11.1 调度器要解决的问题

    调度器需要从可运行任务中选择一个任务占用 CPU:

    当前任务主动阻塞
    或时间片耗尽
    或系统调用返回前发现需要调度

    schedule()

    选择下一个任务

    switch_to(next)

    11.2 counter 与 priority

    Linux 0.11 中:

    • priority:任务的基础优先级;

    • counter:当前剩余时间片,同时也作为动态选择值。

    新进程创建时:

    counter = priority;

    时钟中断到来时,当前任务的 counter 递减。counter 越大的可运行任务越容易被选中。

    11.3 schedule() 的选取过程

    调度逻辑可以写成以下等价伪代码:

    for (;;) {
    next = 0; /* 默认选择任务0 */
    max_counter = -1;

    扫描 task[1] 到 task[63];
    在 TASK_RUNNING 中选择 counter 最大者;

    if (max_counter != 0)
    break;

    对所有已存在任务重新计算 counter;
    }

    switch_to(next);

    存在三种情况:

  • 找到 counter > 0 的可运行任务:直接选择最大者;

  • 有可运行任务,但它们最大的 counter == 0:重新计算时间片;

  • 没有任何普通任务可运行:max_counter 保持为 -1,选择任务 0。

  • 11.4 什么时候重新计算 counter

    需要准确理解为当所有可运行的普通任务都没有剩余时间片时,调度器重新计算所有已存在任务的 counter。公式为:

    counter = (counter >> 1) + priority;

    重算对象不仅包括当前可运行任务,也包括正在睡眠的任务。例如:

    任务状态旧 counterpriority重算后
    A 可运行,时间片耗尽 0 5 5
    B 睡眠,保留时间片 6 5 8

    任务 B 睡眠期间没有消耗完旧时间片,唤醒后可能拥有更大的 counter。这使 I/O 密集型、经常睡眠的任务通常能较快得到响应。所以它不是简单地“所有任务统一重新获得相同时间片”,而是将旧动态值衰减一半,再叠加静态优先级。

    11.5 时钟中断和内核不可抢占

    do_timer(cpl) 每个时钟滴答都会减少当前任务的 counter:

    counter仍大于0
    → 继续运行

    counter减到0
    ├─ 当前在用户态 → 调用schedule()
    └─ 当前在内核态 → 暂不抢占

    因此 Linux 0.11 属于“用户态可被时钟抢占、内核态基本不可抢占”的模型。内核代码通常要等到:

    • 主动调用 schedule();

    • 因等待资源而睡眠;

    • 系统调用返回用户态前;

    • 其他明确的调度点;

    才会切换任务。

    11.6 上下文切换

    Linux 0.11 的 switch_to(n) 使用每任务 TSS 和 x86 硬件任务切换:

    计算下一个任务的TSS选择子

    更新current

    通过ljmp触发硬件任务切换

    CPU保存旧TSS、装载新TSS

    因此每个任务都需要自己的 TSS 和 LDT 描述符。

    11.7 现代 Linux 调度器

    现代 Linux 已不再扫描一个全局 task[64] 数组。主要特征包括:

    • 每个 CPU 有自己的运行队列 struct rq;

    • 调度器直接调度线程对应的 task_struct;

    • 支持普通、公平、实时、截止时间、idle 等调度类别;

    • 支持 CPU 亲和性、负载均衡、NUMA 和跨 CPU 唤醒;

    • 支持可配置内核抢占;

    • 通过软件代码保存和恢复寄存器,不再使用 TSS 硬件任务切换。

    当前主线 Linux 的公平调度类以 EEVDF 思想选择普通任务:

    • 虚拟运行时间用于衡量已获得的 CPU 服务;

    • lag 表示任务相对公平份额是“欠运行”还是“超额运行”;

    • 在符合运行资格的任务中,优先选择虚拟截止时间较早者;

    • nice 值通过权重影响虚拟时间推进速度。

    网上很多资料只写“现代 Linux 使用 CFS”。更准确地说,公平调度类沿用了 CFS 的大量基础设施,但当前主线的任务选择逻辑已经采用 EEVDF。

    11.8 调度机制对比

    比较项Linux 0.11现代 Linux
    CPU模型 单 CPU SMP、多核、NUMA
    运行任务保存 全局固定 task[64] 每 CPU struct rq
    普通任务选择 扫描并选最大 counter 公平类采用虚拟时间、lag、虚拟截止时间
    时间片更新 全局周期式重算 counter 不存在同样的全任务统一重算周期
    调度类型 一套简单规则 多个调度类和策略
    内核抢占 内核态基本不可抢占 由配置决定,可支持完全抢占和 PREEMPT_RT
    多核均衡 不存在 负载均衡、迁移和 CPU 亲和性
    上下文切换 x86 TSS 硬件任务切换 context_switch() 加体系结构软件切换
    idle任务 只有任务 0 每个 CPU 都有自己的 idle 任务

    12. 睡眠与唤醒机制

    12.1 为什么进程需要睡眠

    进程等待磁盘、键盘、管道数据或其他资源时,不应该一直占用 CPU:

    资源尚未就绪

    当前任务设置为睡眠

    schedule()运行其他任务

    资源就绪后wake_up()

    任务重新变为TASK_RUNNING

    12.2 Linux 0.11 的 sleep_on()

    核心逻辑可以概括为:

    tmp = *wait_head;
    *wait_head = current;
    current->state = TASK_UNINTERRUPTIBLE;
    schedule();

    if (tmp != NULL)
    tmp->state = TASK_RUNNING;

    等待头保存最后进入睡眠的任务,而前一个等待任务保存在后一个任务内核栈中的局部变量 tmp 里,形成一种隐式的后进先出等待链。wake_up() 首先唤醒等待头。该任务恢复执行后,再唤醒它保存的前一个任务,从而形成级联唤醒。这不是现代意义上的显式链表等待队列,也不适合多 CPU 并发环境。

    12.3 两种睡眠状态

    状态含义
    TASK_INTERRUPTIBLE 可以因为信号到来而被唤醒
    TASK_UNINTERRUPTIBLE 一般只能等待明确的资源事件

    任务 0 是特殊 idle 任务,不允许调用 sleep_on() 真正睡眠。

    12.4 现代等待队列

    现代 Linux 使用 wait_queue_head 和 wait_queue_entry:

    wait_queue_head
    ├─ wait_queue_entry → task A
    ├─ wait_queue_entry → task B
    └─ wait_queue_entry → task C

    现代等待机制具有:

    • 显式双向链表;

    • 自旋锁和内存屏障;

    • SMP 并发安全;

    • 条件等待宏 wait_event*;

    • 独占唤醒和全部唤醒;

    • 与 mutex、semaphore、completion、futex 等机制配合。

    两者的基本思想相同:改变任务状态、让出 CPU、等待事件、重新进入运行队列;区别在于现代实现具有完整的并发和竞态保护。


    13. 进程退出、僵尸与最终删除

    13.1 “退出”不等于“立即删除”

    进程退出后,父进程仍需要获得:

    • 子进程 PID;

    • 退出码;

    • 是否被信号终止;

    • CPU 时间等统计信息。

    因此内核不能在 exit() 时立即删除全部信息,而是先保留一个僵尸记录。

    13.2 Linux 0.11 的退出路径

    用户调用exit()

    sys_exit()

    do_exit()

    释放页表、文件和目录等资源

    子进程重新托管给PID 1

    设置TASK_ZOMBIE和exit_code

    向父进程发送SIGCHLD

    schedule(),退出者不再运行

    do_exit() 会释放或处理大部分资源,但仍保留 task_struct、PID 和退出状态。

    13.3 父进程通过 waitpid() 回收

    父进程调用 waitpid() 后:

  • 扫描自己的子进程;

  • 如果子进程仍在运行,父进程可以睡眠等待;

  • 如果发现 TASK_ZOMBIE,读取退出码和运行时间;

  • 调用 release();

  • 将该任务从 task[] 中移除;

  • 释放保存 task_struct 和内核栈的页面。

  • 完整状态变化为:

    flowchart TD
    A["创建"] –> B["TASK_RUNNING"]
    B –> C["正在运行"]
    C –>|等待资源| D["睡眠"]
    D –>|wake_up| B
    C –>|exit| E["TASK_ZOMBIE"]
    E –>|父进程wait| F["release并最终释放"]

    如果父进程一直不调用 wait(),僵尸记录就会继续占据任务槽。

    13.4 现代 Linux 的退出与回收

    现代 Linux 仍保留相同的两阶段思想:

    do_exit()
    → 释放mm、files等资源
    → exit_notify()
    → EXIT_ZOMBIE或EXIT_DEAD
    → 父进程wait4()/waitid()
    → release_task()
    → 引用计数归零后最终释放task_struct

    现代实现还需要处理:

    • 多线程和线程组退出;

    • exit_group();

    • PID 命名空间;

    • 子收割器和重新托管;

    • ptrace;

    • cgroup;

    • 性能事件和审计;

    • RCU 延迟释放;

    • 多 CPU 上仍在引用该任务的情况。

    因此现代 do_exit() 和 release_task() 很复杂,但“先退出成为可等待状态,再由父进程或内核回收”的基本语义仍继承自早期 Linux。

    13.5 退出机制对比

    比较项Linux 0.11现代 Linux
    退出入口 sys_exit() → do_exit() exit/exit_group → do_exit()
    资源释放 页表、文件、inode、终端等 mm、文件、命名空间、cgroup等大量资源
    僵尸状态 TASK_ZOMBIE EXIT_ZOMBIE,也可能自动回收为 EXIT_DEAD
    通知父进程 SIGCHLD SIGCHLD、pidfd、等待队列等
    父进程等待 sys_waitpid() wait4()、waitid() 等
    最终释放 release() 释放任务页 release_task()、引用计数和 RCU 延迟释放
    多线程处理 不存在 线程组、组退出和最后线程语义

    14. 进程完整生命周期

    把创建、调度、睡眠和销毁串起来,可以得到:

    fork()

    分配并初始化task_struct

    TASK_RUNNING

    schedule()选择并切换到该任务

    运行
    ┌────────┴────────┐
    ↓ ↓
    等待资源/睡眠 exit()
    ↓ ↓
    wake_up() TASK_ZOMBIE
    ↓ ↓
    返回可运行状态 父进程wait()
    └────────┐ ↓
    └──→ release()
    最终释放

    其中必须区分三件事:

  • 创建完成:任务结构初始化完成并变为可运行;

  • 退出完成:任务不再执行,但可能仍是僵尸;

  • 删除完成:父进程读取退出信息后,内核最终释放任务结构。

  • 15. Linux 0.11 与现代 Linux 的函数映射

    功能Linux 0.11现代 Linux
    启动任务 init_task、任务 0 init_task、swapper/0
    创建 PID 1 任务 0 进入用户态后 fork() kernel_clone(kernel_init)
    普通进程创建 sys_fork → copy_process kernel_clone → copy_process
    线程创建 不支持现代线程模型 clone/clone3 加 CLONE_*
    地址空间复制 copy_mem → copy_page_tables copy_mm → dup_mm/dup_mmap
    写时复制 页表只读加缺页复制 仍然使用 COW,机制更完整
    任务集合 task[64] 动态任务结构和每 CPU 运行队列
    调度入口 schedule() schedule() → __schedule()
    选择任务 最大 counter 调度类的 pick_next_task 逻辑
    上下文切换 switch_to 加硬件 TSS context_switch 加体系结构软件切换
    睡眠 sleep_on、interruptible_sleep_on wait_event*、mutex、completion等
    唤醒 wake_up wake_up*、try_to_wake_up
    退出 do_exit do_exit
    等待子进程 sys_waitpid wait4、waitid
    最终回收 release release_task 加引用计数、RCU

    可以看到,很多函数名和核心抽象一直保留至今,例如 task_struct、schedule()、copy_process() 和 do_exit()。真正发生根本变化的是它们内部的数据结构、并发模型和可扩展能力。


    16. 总体联系与本质区别

    16.1 一直保留下来的思想

    • 使用 task_struct 描述可调度任务;

    • 任务具有运行、睡眠、停止和退出等状态;

    • fork() 复制执行上下文和资源关系;

    • 使用写时复制降低创建成本;

    • 阻塞任务主动让出 CPU;

    • 事件到来后将任务重新变为可运行;

    • schedule() 选择下一个任务;

    • 退出后保留僵尸信息供父进程读取;

    • 父进程通过 wait() 完成最终回收;

    • 没有普通任务可运行时进入 idle。

    16.2 已经根本改变的实现

    • 从“一个进程就是一个任务”变成线程组模型;

    • 从固定 64 个任务槽变成动态任务管理;

    • 从单 CPU 变成 SMP、NUMA;

    • 从扫描 task[] 变成每 CPU 运行队列和多调度类;

    • 从 counter 算法变成 EEVDF、实时和截止时间调度并存;

    • 从不可抢占内核变成可配置抢占和实时抢占;

    • 从 TSS 硬件切换变成软件上下文切换;

    • 从简单指针睡眠链变成 SMP 安全等待队列;

    • 从立即释放一个任务页变成引用计数和 RCU 协同回收;

    • 增加命名空间、cgroup、LSM、审计、pidfd 等机制。

    17. 源码与官方文档

    17.1 Linux 0.11 引导

    • boot/bootsect.s

    • boot/setup.s

    • boot/head.s

    • init/main.c

    17.2 Linux 0.11 进程管理

    • include/linux/sched.h

    • kernel/system_call.s

    • kernel/fork.c

    • mm/memory.c

    • kernel/sched.c

    • kernel/exit.c

    17.3 现代 Linux 引导

    • Linux/x86 Boot Protocol

    • Linux EFI Boot Stub

    • arch/x86/boot/header.S

    • arch/x86/boot/main.c

    • arch/x86/boot/pm.c

    • arch/x86/boot/compressed/head_64.S

    • arch/x86/kernel/head_64.S

    • init/main.c

    17.4 现代 Linux 进程管理

    • kernel/fork.c

    • kernel/sched/core.c

    • kernel/sched/fair.c

    • EEVDF Scheduler

    • include/linux/wait.h

    • kernel/exit.c

    • arch/x86/include/asm/switch_to.h

    17.5 ARM64

    • Booting AArch64 Linux

    • arch/arm64/kernel/head.S

    赞(0)
    未经允许不得转载:171主机测评 » Linux0.11阅读笔记
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址