Linux内核分析之基础知识-02

韩乔落

3.1 start_kernel() 总览

函数定位

start_kernel() 定义在 init/main.c 的第 1006 行至第 1218 行,是内核通用初始化的总入口。当 x86_64 的体系结构引导代码在 head64.c 中完成长模式切换、早期页表建立、内核解压等工作后,x86_64_start_reservations() 将控制权转交给 start_kernel()。从此刻起,代码路径不再受限于特定体系结构,而是进入所有平台共享的通用初始化流程。

为什么需要理解初始化顺序

start_kernel() 中约 200 个函数调用构成了一个精密的依赖链。理解这个顺序对于内核开发者至关重要:添加新的初始化逻辑时,必须将其放置在其依赖项之后、依赖它的模块之前。放错位置可能导致空指针崩溃、死锁或静默的数据损坏。我们将这 200 多个调用归纳为五个阶段,逐一分析其依赖关系和设计理由。


第一阶段:早期引导(中断禁用)

本阶段建立内核运行的最低限度基础设施。此时系统中断仍然关闭,任何外部事件都无法打断执行。

set_task_stack_end_magic(&init_task)

init_task 是内核编译时静态创建的第一个(也是此刻唯一的)进程描述符,即 PID 0 的 idle 进程。此调用在 init_task 的内核栈底部写入一个魔数(magic number)STACK_END_MAGIC(值为 0x57AC6E9D)。这是一个栈溢出检测机制——如果栈被写穿,这个魔数会被覆盖,内核在后续检查中即可发现栈溢出问题。这是第一个初始化调用,因为后续几乎所有操作都需要内核栈。

smp_setup_processor_id()

在 SMP(对称多处理器)系统中,此函数确定当前正在执行引导代码的 CPU 的 ID。对于 x86_64 平台,引导处理器(BSP)的 ID 通常为 0。此信息在后续的 per-CPU 变量初始化和 CPU 热插拔回调中都会用到。必须在其他任何涉及 CPU 身份的操作之前完成。

boot_cpu_init()

此函数将引导处理器标记为活跃(active)、在线(online)和已存在(present)。它设置 CPU 状态掩码位图(cpu_online_maskcpu_active_mask 等),使得后续代码可以通过 cpu_online() 等宏查询 CPU 状态。在 SMP 启动流程中,这是第一个被激活的 CPU,其他 CPU 要等到 smp_init() 时才会被唤醒。

pr_notice(“%s”, linux_banner)

打印 Linux 内核版本横幅,例如 "Linux version 7.0.0 ...". 这通常是控制台上出现的第一条内核消息。横幅包含内核版本号、编译器版本、编译用户和时间戳。在此时打印,是因为 printk 的基础设施已在体系结构代码中初始化,而更早的输出则依赖 early_printk

setup_arch(&command_line)

这是整个通用初始化中最重要的体系结构相关调用。对于 x86_64,它完成以下工作:

  • 解析 BIOS/UEFI 传递的内存映射(e820 表)
  • 建立完整的内核页表(从早期页表扩展为最终页表)
  • 初始化 memblock 内存分配器
  • 解析内核命令行参数中的体系结构特定选项
  • 检测并映射 PCI 设备的 MMIO 空间
  • 初始化 Local APIC 和 IO APIC
  • 设置 DMA 区域和内存区域划分(ZONE_DMA、ZONE_DMA32、ZONE_NORMAL)

注意 command_line 参数通过指针回传——setup_arch() 会修改它,将平台无关的参数留下供后续通用代码解析。此函数的复杂度极高,在 x86_64 上涉及数千行代码。它必须在所有内存和硬件相关初始化之前完成。

mm_core_init_early()

建立内存管理的核心数据结构。此函数初始化 pglist_data 节点、zone 区域列表,并设置 memblock 到伙伴系统的过渡准备。在 setup_arch() 之后调用是因为需要先知道物理内存的布局信息。

jump_label_init() 与 static_call_init()

这两个调用初始化内核的静态分支优化基础设施。jump_label(跳转标签)是内核的零开销条件分支机制,被 static_key 广泛使用,用于在不修改代码的情况下动态开启/关闭内核特性。static_call 是类似的机制,用于间接函数调用的优化。它们必须在任何使用 static_keystatic_call 的子系统之前初始化。调度器和追踪系统大量依赖这些机制。

setup_per_cpu_areas()

为每个可能的 CPU 分配独立的 per-CPU 内存区域。per-CPU 变量是内核实现无锁并发的重要机制——每个 CPU 拥有自己的变量副本,因此访问时无需加锁。此函数必须在使用任何 per-CPU 变量之前调用。由于前面已经通过 boot_cpu_init() 确定了引导处理器 ID,此函数可以正确地为 BSP 设置 per-CPU 区域的指针。

parse_early_param()

解析内核命令行中的”早期参数”。某些参数必须在通用初始化早期就生效,例如 mem=(限制可用内存大小)、maxcpus=(限制启动的 CPU 数量)、debug(启用调试输出)等。这些参数如果延迟解析,将导致后续初始化行为无法被用户控制。此函数必须在 setup_arch() 之后,因为 setup_arch() 可能已经添加了额外的命令行参数。


第二阶段:核心子系统(中断仍禁用)

在第一阶段建立了内存和 CPU 的基本框架后,本阶段初始化内核最核心的几个子系统。中断仍然禁用,这些子系统需要在一个不受打扰的稳定环境中建立自己的数据结构。

sched_init()——调度器初始化

初始化 CFS(完全公平调度器)的核心数据结构:分配每个 CPU 的运行队列(rq)、初始化调度域(scheduling domain)的基础结构、设置 init_task 的调度类为 idle_class。注意此时调度器虽然初始化了,但并未真正开始调度——因为中断仍然禁用,没有时钟中断来触发调度。调度器必须先于 RCU 初始化,因为 RCU 的宽限期检测需要调度器的上下文切换通知。

rcu_init()——RCU 机制初始化

RCU(Read-Copy-Update)是内核中最复杂的同步机制之一。rcu_init() 建立 RCU 的宽限期检测状态机、初始化 per-CPU 的 rcu_data 结构、注册 RCU 的软中断回调。RCU 被内核几乎所有子系统使用——从文件系统到网络栈再到设备驱动——因此必须尽早初始化。但它在 sched_init() 之后,因为某些 RCU 变体(如可抢占 RCU)需要调度器的支持。

early_irq_init() 与 init_IRQ()

这两个函数建立中断描述符基础设施。early_irq_init() 分配中断描述符数组(irq_desc)并初始化 radix tree 用于管理硬件中断号到 Linux IRQ 号的映射。init_IRQ() 在 x86_64 上设置 IDT(中断描述符表)中除预定义异常外的所有硬件中断门,并初始化中断控制器驱动。中断子系统必须在定时器子系统之前就绪,因为定时器依赖中断来通知内核定时事件。

tick_init(), timers_init(), hrtimers_init()

tick_init() 初始化 tick 设备层,注册 tick 广播设备。timers_init() 初始化经典定时器轮(timer wheel),用于低精度定时。hrtimers_init() 初始化高精度定时器子系统。这三者构成时间子系统的定时器层次:tick 是基础时钟脉冲,timers 提供毫秒级定时,hrtimers 提供纳秒级精度。它们必须在中断子系统之后初始化,因为定时器中断需要通过 IRQ 框架注册和处理。

timekeeping_init() 与 time_init()

timekeeping_init() 初始化时间保持子系统,设置系统时钟基准,读取 CMOS/HPET/定时硬件获取当前墙上时钟时间。time_init() 在 x86_64 上探测并初始化时钟源设备(TSC、HPET、ACPI PM Timer 等),选择最佳时钟源并注册时钟事件设备。时间子系统是后续许多功能的基础——调度器的时间片、超时机制、文件系统时间戳都依赖它。


第三阶段:开启中断

经过前两个阶段,系统已经具备了处理中断的基本条件:中断控制器已初始化、中断描述符已建立、时钟源已就绪。现在可以安全地开启中断了。

early_boot_irqs_disabled = false

将全局标志 early_boot_irqs_disabled 设为 false。这个标志在调试和断言中使用——某些代码路径会检查此标志,以确认自己是否在中断禁用的早期引导阶段被调用。锁依赖检测器(lockdep)也使用此标志来判断是否应该记录锁的获取/释放。

local_irq_enable()——中断终于开启!

执行 sti 指令(x86_64 上),正式开启本地 CPU 的中断。这是引导过程中的一个里程碑时刻——从此刻起,CPU 可以响应外部硬件事件:定时器中断开始到来、网卡可以触发接收中断、键盘输入可以被捕获。中断开启后,定时器子系统开始正常工作,为后续初始化提供超时和调度支持。

console_init()——控制台输出就绪

初始化控制台子系统,使内核消息能够输出到 VGA 文本模式控制台或帧缓冲设备。在此之前,所有 printk 输出都存储在内核日志缓冲区中,但不会显示在屏幕上。console_init() 注册第一个控制台驱动,将缓冲区中的积压消息刷新到屏幕。从这一刻起,用户可以在控制台上看到内核的初始化进度输出。


第四阶段:后期核心子系统

中断开启后,内核可以安全地初始化那些可能需要睡眠、等待或使用更复杂同步机制的子系统。

lockdep_init() 与 locking_selftest()

lockdep 是内核的锁依赖检测器,用于在运行时发现潜在的死锁。lockdep_init() 初始化其核心数据结构——锁类的哈希表、依赖图、栈跟踪缓存。locking_selftest() 运行一系列预设的锁场景,验证 lockdep 自身能否正确检测出死锁模式。Lockdep 必须在大部分核心锁被使用之前初始化,否则将错过早期的锁依赖信息。但由于 lockdep 本身需要 per-CPU 变量和内存分配,它不能放得太早。

pid_idr_init(), cred_init(), fork_init()

这三个函数建立进程管理的核心基础设施:

  • pid_idr_init():初始化 PID 分配器,使用 IDR(Integer ID Resource)数据结构管理 PID 号的分配和回收。每个进程需要一个唯一的 PID,PID 0 已被 init_task 占用。
  • cred_init():初始化凭证(credentials)缓存,用于分配 struct cred 结构。凭证包含进程的 UID、GID、capabilities 等安全属性。
  • fork_init():设置进程创建的基础参数,计算最大线程数(基于可用内存),分配 task_struct 的 slab 缓存。此后 kernel_thread()fork 系统调用才能工作。

这三者必须在 rest_init() 之前完成,因为 rest_init() 需要调用 kernel_thread() 来创建 init 和 kthreadd 进程。

security_init()

初始化 Linux 安全模块(LSM)框架。LSM 提供了一系列钩子点,允许安全模块在关键操作(如文件访问、网络连接、进程创建)中进行访问控制决策。此函数注册所有编译时选定的安全模块(如 SELinux、AppArmor、Smack),并调用它们的初始化函数。安全初始化必须在进程创建和文件系统挂载之前完成,以确保安全策略从一开始就生效。

vfs_caches_init()

初始化虚拟文件系统的核心缓存:dentry 缓存(目录项缓存)和 inode 缓存。这两个缓存大幅加速了路径名查找和文件元数据访问。此函数还初始化 mount 哈希表和文件对象缓存。VFS 是挂载根文件系统的前提条件——没有 dentry 和 inode 缓存,文件系统无法运作。

cgroup_init()

初始化控制组(cgroup)子系统。cgroup 是 Linux 的资源隔离和限制机制,被容器技术(Docker、Kubernetes)广泛使用。cgroup_init() 注册所有 cgroup 子系统控制器(CPU、内存、IO、PID 等),初始化 cgroup 层级结构,并创建根 cgroup。它需要 VFS 已就绪,因为 cgroup 通过 cgroupfs(伪文件系统)向用户空间暴露接口。


第五阶段:收尾与过渡

rest_init()——从内核态到用户空间的跃迁

rest_init()start_kernel() 的最后一个调用,也是最关键的一个——它完成从”单线程初始化模式”到”多任务运行模式”的跃迁。其核心工作如下:

  1. 创建 kernel_init 线程(通过 kernel_thread())——这个线程最终成为 PID 1 的 init 进程。它负责挂载根文件系统、执行 /sbin/init,启动整个用户空间。
  2. 创建 kthreadd 线程——成为 PID 2。这是内核线程的守护进程,所有后续通过 kthread_create() 创建的内核线程都由它管理。
  3. 调用 schedule()——触发第一次调度。调度器选择 kernel_init 线程运行,init 进程开始执行其初始化序列。
  4. 当前 CPU 进入 idle 循环——start_kernel() 的调用上下文(即 PID 0 的 idle 进程)进入 cpu_idle_loop(),在没有其他可运行进程时执行 hlt 指令以节能。

rest_init() 必须是最后一个调用,因为它改变了系统的运行模型——在此之前是单线程顺序执行,此后是多任务并发执行。所有需要在单线程环境下完成的初始化都必须在此调用之前完成。

3.2 setup_arch() — x86 架构相关初始化

3.2.1 概述

start_kernel() 执行的早期阶段,体系结构无关的通用初始化逻辑尚未大规模展开之前,内核需要先完成与具体硬件平台紧密相关的底层配置工作。这项任务在 x86_64 平台上由 setup_arch() 函数承担,其源码位于 arch/x86/kernel/setup.c

函数原型如下:

1
void __init setup_arch(char **cmdline_p)

__init 标注表明该函数仅在内核初始化期间执行,其代码占用的内存在初始化完成后会被回收释放。参数 cmdline_p 是一个指向字符指针的指针,setup_arch() 通过它将处理完毕的内核命令行字符串地址返回给调用者 start_kernel()

setup_arch() 是整个 x86 平台初始化过程中最为关键的枢纽函数之一。它的工作内容涵盖了从引导加载器传递的数据解析、物理内存映射的建立、硬件子系统的探测与初始化,一直到页表、中断控制器、定时器等核心基础设施的就绪。可以说,理解了 setup_arch() 的执行流程,就把握住了 x86 平台从裸硬件走向可运行内核的完整脉络。

3.2.2 引导数据的接收与解析

No-Execute 位配置

setup_arch() 首先调用 x86_configure_nx() 对处理器的 NX(No-Execute)位支持进行配置。NX 位是现代处理器提供的一种硬件安全特性,允许操作系统将内存页面标记为不可执行,从而有效防范缓冲区溢出等攻击手段中将数据段当作代码执行的安全威胁。该函数在早期即被调用,以便后续的内存映射建立过程能够正确考虑执行权限的设置。

解析引导加载器数据

接下来,parse_setup_data() 负责解析引导加载器(如 GRUB)通过 setup_data 链表传递给内核的附加信息。在现代 Linux 启动协议中,引导加载器可以将多种类型的数据(如 EFI 信息、设备树、随机种子等)以链表的形式挂接到 setup_data 字段上。该函数遍历这条链表,逐一识别并处理各类数据项。

copy_bootdata() 则负责将引导加载器填充的 boot_params 结构体(即经典的 zero-page 数据)复制到内核的合法内存区域中,并对其中的关键字段进行有效性验证。boot_params 结构体承载了引导阶段积累的大量硬件信息,包括 E820 内存映射、视频模式参数、EFI 表地址等,是内核了解当前硬件环境的重要数据来源。

随后,x86_report_nx() 将 NX 位的最终配置状态通过内核日志输出,便于开发者确认系统是否启用了此项安全特性。

3.2.3 内存探测与管理

E820 内存映射处理

E820 内存映射是 x86 平台上获取物理内存布局的标准机制。在传统 BIOS 系统中,引导加载器通过调用 BIOS 中断 INT 0x15, AX=0xE820 获取物理地址空间的区域分布信息。每个内存区域条目包含起始地址、长度和类型三个关键字段。类型字段标识了该区域是可用 RAM、已保留、ACPI 数据还是其他特殊用途的内存。

e820__memory_setup() 是处理 E820 内存映射的核心函数。它首先从 boot_params 中提取 BIOS 提供的原始内存映射数据,然后将其整理填充到内核的 e820_table 结构中。在此过程中,内核还会对内存映射进行一系列的修正和清理工作:合并相邻的同类型区域、删除零长度的无效条目、处理已知的 BIOS 错误报告等。最终形成的 e820_table 是内核管理物理内存的基础数据结构,后续所有的内存分配和保留操作都以此为依据。

内存区域的保留

在获得完整的内存映射之后,内核需要将若干特殊用途的内存区域标记为”已保留”,以防止它们被通用分配器错误分配使用。e820__reserve_setup_data() 将引导加载器传递的 setup_data 链表所在的物理内存区域进行保留。

trim_platform_memory_low_region() 对物理内存底部(通常低于 1MB)的特定区域进行裁剪处理。这部分低地址内存中包含了 BIOS 数据区、实模式代码等关键结构,必须加以保护。trim_snb_memory() 则是针对 Intel Sandy Bridge 处理器系列的一个硬件缺陷规避措施,该系列处理器的某些内存地址范围存在已知问题,内核需要将这些区域从可用内存池中排除。

memblock 子系统的填充

memblock 是内核在伙伴系统(buddy system)建立之前使用的早期内存分配器。memblock_x86_fill() 将 E820 内存映射中标记为可用的 RAM 区域添加到 memblock 的 memory 集合中,为后续的早期内存分配操作提供可用内存池。而 memblock_find_dma_reserve() 则专门计算并标记需要为 ISA DMA(直接内存访问)保留的内存区域,确保 DMA 操作所需的低地址内存不会被其他用途占用。

memblock_set_current_limit() 设置了 memblock 分配器当前允许分配的物理地址上限。在初始化早期,由于内核页表尚未完全建立,memblock 只能在已映射的地址范围内进行分配。随着页表逐步完善,这个限制会逐步放宽。

3.2.4 内核内存布局的建立

页表初始化

init_mem_mapping() 是内核建立早期页表映射的关键函数。在 x86_64 架构上,它负责为内核映像本身以及所有可用的物理内存建立适当的页表映射关系。这包括:

  • 确保内核代码段、数据段的映射具有正确的权限属性(只读、可执行、不可写等)
  • 使用大页(2MB 或 1GB)映射尽可能多的物理内存,以减少 TLB(Translation Lookaside Buffer) miss 带来的性能开销
  • 为直接物理内存映射区(direct mapping area,即 PAGE_OFFSET 开始的区域)建立线性映射

实模式内存保留

reserve_real_mode() 为实模式跳板代码(real-mode trampoline)保留物理内存。在 x86 平台上,内核在某些场景下(如系统挂起/恢复、AP 处理器启动)需要切换回实模式执行代码。为此,内核在物理内存的低地址区域(1MB 以下)保留了一段空间,用于存放实模式跳板代码及其所需的数据结构。

3.2.5 平台特定的子系统初始化

ACPI 初始化

acpi_boot_init() 启动 ACPI(Advanced Configuration and Power Interface)子系统的引导阶段初始化。在 x86_64 平台上,ACPI 是硬件资源配置和电源管理的核心机制。该函数定位并解析 ACPI 表(如 RSDP、RSDT/XSDT、 MADT、DMAR 等),从中提取多处理器信息、中断路由、NUMA 拓扑、IOMMU 配置等关键数据,为后续的设备驱动和电源管理功能奠定基础。

x86_init 抽象层

x86 架构的一个显著特点是它需要支持多种不同的运行平台:传统的 PC 兼容机、Xen 虚拟化环境、Intel MID(Mobile Internet Device)平台等。为了在同一份内核代码中优雅地处理这些差异,内核引入了 x86_init_ops 结构体,定义在 arch/x86/include/asm/x86_init.h 中。

struct x86_init_ops 将平台特定的初始化操作抽象为一系列函数指针集合,组织为多个子结构体:

  • x86_init.paging:页表相关的初始化操作。标准 PC 平台在此提供 native_pagetable_init(),而 Xen 虚拟化环境则替换为适合半虚拟化(paravirtualized)环境的实现。
  • x86_init.irqs:中断控制器的初始化操作。不同平台可能使用不同的中断控制器(如标准的 APIC 体系或虚拟化环境中的事件通道机制)。
  • x86_init.timers:定时器初始化操作。不同硬件平台可能使用不同的定时器设备(HPET、PIT、TSC 等)作为系统时钟源。
  • x86_init.oem:OEM 特定的平台钩子函数。
  • x86_init.hyper:虚拟化管理程序相关的操作。

这种基于函数指针表的抽象设计,使得 setup_arch() 中的核心流程能够保持统一,而平台差异则通过在初始化早期替换相应的函数指针来消弭。当内核运行在标准 PC 硬件上时,这些函数指针指向默认的 “native” 实现;当运行在 Xen Dom0 或 DomU 环境中时,Xen 的初始化代码会将它们替换为对应的虚拟化实现。

TSC 校准

tsc_init() 完成 TSC(Time Stamp Counter,时间戳计数器)的初始化与校准工作。TSC 是 x86 处理器内部的一个高精度计数器,在现代内核中被广泛用作高精度时间测量的基础。该函数检测 TSC 的特性(如是否恒定频率、是否可靠),确定其运行频率,并评估其是否适合作为内核时钟源设备使用。TSC 的质量直接影响系统时间keeping 的精度和性能。

3.2.6 早期参数解析与命令行返回

在完成上述所有架构相关的初始化工作之后,setup_arch() 调用 kernel_parse_early_param()(对应早期参数的 early_param() 宏定义的回调函数)处理需要在内核初始化更早阶段生效的命令行参数。这些早期参数通常涉及内存布局调整、调试选项设置等必须在通用初始化开始之前完成配置的选项。

最后,函数执行:

1
*cmdline_p = boot_command_line;

将经过处理的内核命令行字符串指针通过输出参数返回给 start_kernel()。此后,start_kernel() 的后续代码以及其他子系统就可以通过这个指针访问完整的内核命令行参数。

3.3 早期内存管理初始化

start_kernel() 的执行流程中,内存管理子系统的初始化是最关键的环节之一。x86_64 架构下,内核需要从最原始的物理内存状态逐步建立起完整的页表映射、伙伴分配器和_zone_管理机制。本节将深入分析从 mm_core_init_early()mm_core_init() 的完整初始化路径。

mm_core_init_early() — 超早期内存初始化

mm_core_init_early() 是内存管理初始化的第一步,在_slab_分配器可用之前就被调用。此时内核还没有任何通用的内存分配能力,只能依赖静态分配的数据结构和编译期预留的内存区域。

该函数的核心职责包括:

  • 引导内存数据结构初始化:设置 pg_data_t 节点的基本信息,将 memblock 分配器置于可用状态。
  • 早期页表构建:在 x86_64 上,startup_64 阶段的汇编代码已经建立了一个最小化的内核页表(使用 2MB 大页),mm_core_init_early() 会在此基础上进一步扩展映射范围,确保整个内核镜像和命令行参数区域都被正确映射。
  • 物理内存区域记录:解析 BIOS 提供的_e820_内存映射表,将所有可用的物理内存范围登记到 struct e820_table 中。这一数据结构后续会传递给 memblock 子系统。

在 x86_64 平台上,内核被映射到 0xffffffff80000000(即 -2G 位置,使用 __START_KERNEL_map 宏定义),而直接映射区(PAGE_OFFSET)则从 0xffff888000000000 开始,覆盖整个物理地址空间。

memblock 分配器:_slab_之前的临时分配器

在伙伴系统和_slab_分配器建立之前,内核需要一个临时的内存分配机制来满足启动期间的内存需求。memblock 就是承担这一角色的分配器。

memblock 的核心数据结构

1
2
3
4
5
6
struct memblock {
bool bottom_up;
phys_addr_t current_limit;
struct memblock_type memory; // 可用内存区域
struct memblock_type reserved; // 已保留区域
};

memblock.memory 记录所有可用的物理内存区域,memblock.reserved 记录已被分配或保留的区域。分配操作的本质是从 memory 中找到一个满足条件的空闲区间,然后将其标记到 reserved 中。

关键 API

  • memblock_add(base, size):向 memblock.memory 添加一段新的可用内存区域。在解析_e820_表时,每一段 E820_TYPE_RAM 区域都会通过此函数注册。
  • memblock_reserve(base, size):将一段物理内存标记为已保留。内核镜像本身(_text_end)、引导加载器传递的数据、initrd 等都通过此函数保留。
  • memblock_alloc(size, align):分配指定大小的物理内存。内部实现调用 memblock_alloc_range(),从 memory 区域中找到一段不在 reserved 中的连续空间,然后将其添加到 reserved 列表。

memblock 采用首次适应(first-fit)算法,按地址升序搜索可用区域。由于启动期间的分配请求数量有限(通常几百到几千次),线性搜索的性能完全可以接受。

memblock 到伙伴系统的过渡

当伙伴系统初始化完成后,memblock 分配的所有内存需要被正式纳入伙伴系统的管理。memblock_free_all() 函数遍历 memblock.memory 中所有未被 reserved 的区域,以单个页面(4KB)为单位逐一释放到伙伴系统的空闲链表中。这一过程标志着系统从临时分配器永久切换到正式的内存管理框架。

mm_core_init() — 完整内存管理初始化

mm_core_init()(在早期内核版本中称为 mem_init())是内存管理子系统全面初始化的入口。它完成以下关键工作:

伙伴分配器初始化

伙伴分配器(Buddy Allocator)是 Linux 物理页面管理的核心算法。初始化过程如下:

  1. zone 结构初始化:x86_64 平台通常包含三个_zone_:ZONE_DMA32(低于 4GB 的内存,服务于 32 位_DMA_ 设备)、ZONE_NORMAL(直接映射区)和 ZONE_DEVICE(持久内存)。每个_zone_ 的 free_area 数组被初始化为空链表。
  2. 页面位图建立:为每个物理页面创建 struct page 实例,存储在 mem_map 数组(或稀疏内存模型下的 section_mem_map)中。
  3. 空闲页面入链:通过 free_low_memory_core_early() 将所有空闲页面按_order_ 放入伙伴系统的空闲链表。

每_zone_水位线设置

每个_zone_ 维护三道水位线:

  • min 水位线:紧急保留水位,低于此值时只有 PF_MEMALLOC 标志的分配请求才能成功。
  • low 水位线:唤醒 kswapd 后台回收线程的阈值。
  • high 水位线kswapd 回收的目标水位,达到此值后 kswapd 停止回收。

水位线的计算由 setup_per_zone_wmarks() 完成,基准值通过 /proc/sys/vm/min_free_kbytes 控制,默认根据物理内存总量按比例计算。

页表终结化:init_mem_mapping()

init_mem_mapping() 负责建立完整的内核直接映射区页表。在 x86_64 上,该函数:

  1. 遍历所有物理内存范围(从_e820_表获取),为每一段物理内存建立 PAGE_SIZE(4KB)粒度的页表映射。
  2. 使用大页(2MB 或 1GB)优化映射,减少_TLB_ miss 的影响。内核通过 try_pud_mapping()try_pmd_mapping() 尝试使用更大的页尺寸。
  3. 调用 __flush_tlb_all() 刷新_TLB_,确保新页表对所有 CPU 可见。

早期_ioremap_

在正常的 ioremap() 机制可用之前(因为它需要_slab_分配器),内核通过_fixmap_ 机制提供临时的_i/o_ 映射能力。early_ioremap_init() 在_fixmap_ 区域中预留了一组固定大小的槽位(FIX_BTMAPS_SLOTS,通常 8 个),每个槽位可以映射若干页面。

early_ioremap() 的实现通过修改_fixmap_ 对应的_PTE_ 表项来建立临时映射,early_iounmap() 则清除这些表项。这种机制在启动期间被广泛用于访问_PCI_ 配置空间、ACPI 表和设备寄存器。

3.4 调度器初始化

调度器是 Linux 内核最核心的子系统之一,负责决定哪个任务在哪个 CPU 上运行、运行多长时间。在 start_kernel() 的执行流程中,调度器的初始化由 sched_init() 完成,定义在 kernel/sched/core.c 中。本节将详细分析调度器从无到有的初始化过程。

sched_init() 的调用时机

sched_init()start_kernel() 中相对靠前的位置被调用——在内存管理初始化完成之后、中断子系统初始化之前。这个位置的选择不是随意的:调度器需要 struct page 和基本的内存分配能力来创建_per-CPU_ 运行队列,但此时中断尚未开启,不涉及任何上下文切换。

运行队列初始化

调度器的核心数据结构是_per-CPU_ 运行队列 struct rq,定义在 kernel/sched/sched.h 中。每个 CPU 都拥有一个独立的 rq 实例:

1
DEFINE_PER_CPU_SHARED_ALIGNED(struct rq, runqueues);

sched_init() 对每个 CPU 的 rq 执行以下初始化:

  • 自旋锁初始化rq->lock 用于保护运行队列的并发访问。虽然此时只有一个 CPU 在运行,但锁机制必须提前就绪,因为 smp_init() 之后其他 CPU 会并发访问调度器。
  • 时间统计字段归零rq->clockrq->clock_task 等时间戳初始化为 0。
  • 负载跟踪重置rq->cfs(CFS 运行队列)的负载统计信息清零。
  • 空闲任务指针rq->idle 在此处设为 NULL,稍后由 init_idle() 设置为每个 CPU 的_idle_ 任务。

调度类层次建立

Linux 调度器采用模块化的调度类(scheduling class)设计,每个调度类实现一组标准操作接口(sched_class)。调度类按优先级从高到低排列:

  1. stop_sched_class:停机调度类,最高优先级。用于 CPU 热插拔等需要完全停止 CPU 的场景。stop 任务是不可抢占的,一个 CPU 上同一时刻只能有一个 stop 任务在运行。
  2. dl_sched_class:截止时间调度类,实现_POSIX_ Sporadic Server 策略,保证任务的绝对截止时间。
  3. rt_sched_class:实时调度类,实现 SCHED_FIFOSCHED_RR 策略。实时任务严格按优先级调度。
  4. fair_sched_class:公平调度类(CFS),管理所有普通进程。这是绝大多数任务所处的调度类。
  5. idle_sched_class:空闲调度类,最低优先级。每个 CPU 的_idle_ 任务属于此类,仅在没有其他任务可运行时执行。

sched_init() 为每个 rq 初始化所有调度类对应的子运行队列,并建立调度类之间的链式关系(通过 sched_class->next 指针),使得调度器在选择下一个任务时可以按优先级遍历。

CFS 带宽控制初始化

CFS 带宽控制(bandwidth control)允许为_cgroup_ 中的任务组设置 CPU 使用上限。sched_init() 调用 init_cfs_bandwidth() 来初始化全局带宽控制的数据结构:

1
2
3
4
5
6
7
8
void init_cfs_bandwidth(struct cfs_bandwidth *cfs_b)
{
raw_spin_lock_init(&cfs_b->lock);
cfs_b->runtime = 0;
cfs_b->quota = RUNTIME_INF; // 默认无限制
cfs_b->period = ns_to_ktime(default_cfs_period());
// ...
}

带宽控制的核心参数是 quota(一个周期内允许使用的纳秒数)和 period(周期长度,默认 100ms)。当任务组的累计运行时间超过 quota 时,该组内的任务会被限流(throttled),直到下一个周期开始。

init_task:零号进程

init_task 是内核中第一个——也是最特殊的一个——任务描述符。它不是由 fork() 创建的,而是静态定义的全局变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct task_struct init_task = {
.state = TASK_RUNNING,
.prio = MAX_PRIO - 20, // 普通优先级
.static_prio = 120,
.normal_prio = 120,
.policy = SCHED_NORMAL,
.cpus_ptr = &init_task.cpus_mask,
.cpus_mask = CPU_MASK_ALL,
.nr_cpus_allowed = NR_CPUS,
.mm = NULL, // 内核线程无用户地址空间
.active_mm = NULL,
// ...
};

init_task 的 PID 为 0,也被称为 swapper 或_idle_ 任务。在引导 CPU 上,它就是 start_kernel() 当前执行的上下文。每个 CPU 后来都会获得自己的_idle_ 任务(通过 fork_idle() 创建),但引导 CPU 的_idle_ 任务始终是这个静态定义的 init_task

调度器激活时机

sched_init() 完成后,调度器的数据结构已经就绪,但调度器尚未真正”激活”——此时系统中只有一个任务(init_task),没有其他任务可以切换,中断也未开启。

调度器真正开始工作是 local_irq_enable() 被调用之后。当中断发生时,中断处理程序返回时会检查 TIF_NEED_RESCHED 标志,如果设置则调用 schedule() 进行任务切换。但在 start_kernel() 执行期间,这个标志不会被设置,因为此时不存在任何竞争 CPU 的任务。

进入 rest_init() 后,kernel_init(PID 1)和 kthreadd(PID 2)被创建,此时调度器才真正开始管理多个任务的交替执行。引导 CPU 在 cpu_startup_entry() 中进入_idle_ 循环,等待调度器选中其他可运行任务。

3.5 RCU 初始化

RCU(Read-Copy-Update,读-拷贝-更新)是 Linux 内核中最独特也最重要的同步机制之一。与自旋锁或互斥锁不同,RCU 允许读者不加任何锁、不使用原子操作、甚至不使用内存屏障就能访问共享数据,从而实现极低的读端开销。本节将分析 RCU 在启动阶段的初始化过程。

RCU 的基本原理

在深入初始化细节之前,有必要简述 RCU 的核心思想:

  1. 发布-订阅模型:写者通过 rcu_assign_pointer() 原子地发布新的数据指针,读者通过 rcu_dereference() 获取指针。两者配合确保读者不会看到部分更新的数据结构。
  2. 宽限期(Grace Period):写者修改或删除共享数据后,必须等待所有已有的读者退出临界区,这段时间称为宽限期。宽限期结束后,旧数据可以安全释放。
  3. 回调机制:宽限期的检测由内核基础设施完成。数据发布者调用 call_rcu() 注册一个回调函数,当宽限期结束时,该回调在软中断上下文中被调用来释放旧数据。

RCU 特别适合读多写少的场景。内核中大量数据结构(如进程描述符链表、网络路由表、文件系统_dentry_ 缓存等)都依赖 RCU 进行保护。

rcu_init() 的实现

rcu_init() 定义在 kernel/rcu/tree.c 中(对应可抢占 RCU 的_TREE RCU_ 实现),是 RCU 子系统的初始化入口。其主要工作如下:

rcu_state 结构初始化

struct rcu_state 是 RCU 的全局状态,管理整个宽限期检测机制。x86_64 上默认使用 rcu_state(即 rcu_sched_state)和 rcu_preempt_state(可抢占 RCU)两个实例。初始化内容包括:

  • 宽限期序号rcu_state.gp_seq 初始化为 0,用于跟踪当前宽限期编号。
  • RCU 根节点rcu_state.node 数组构成一个树形层级结构(rcu_node tree),用于高效聚合多个 CPU 的静止状态(quiescent state)报告。在典型配置下,这棵树有 2-3 层,叶子节点对应一组 CPU。
  • 内核线程:为每个 rcu_node 创建 rcu_gp 内核线程(宽限期处理线程)和 rcu_nocb 内核线程(no-callback CPU 的回调处理线程)。

per-CPU rcu_data 初始化

每个 CPU 拥有 struct rcu_data 实例,记录本 CPU 的 RCU 状态:

1
DEFINE_PER_CPU(struct rcu_data, rcu_data) __aligned(256);

rcu_init() 中,rcu_init_rdp() 对每个 CPU 的 rcu_data 执行初始化:

  • 回调链表rdp->cblist 初始化为空。后续通过 call_rcu() 注册的回调会被添加到此链表。
  • 静止状态检测rdp->cpu_no_qs 标志设为 true,表示尚未观察到静止状态。
  • 宽限期快照rdp->gp_seq 与全局 rcu_state.gp_seq 同步。
  • nocb(no-callback)配置:如果内核配置了 RCU_NOCB_CPU,则该 CPU 的回调将被卸载到专门的_per-CPU kthread_ 中处理,减少中断延迟。

时钟源回调注册

RCU 依赖时钟中断来检测宽限期的进展。rcu_init() 通过 tick_nohz_idle_ret_tick() 和相关接口注册了时钟事件回调。每当 CPU 经历一次上下文切换、进入_idle_ 或从_idle_ 唤醒、或经历一次时钟中断时,RCU 都会得到通知,从而判断该 CPU 是否已经通过了静止状态。

rcu_scheduler_starting()

rcu_init() 完成后,RCU 的数据结构已经就绪,但 RCU 并未真正开始追踪宽限期。这是因为此时系统中只有引导 CPU 在运行,且中断尚未开启——不存在并发读者,也不需要宽限期检测。

rcu_scheduler_starting()rest_init() 中被调用,此时调度器即将开始创建内核线程。该函数:

  1. rcu_scheduler_activeRCU_SCHEDULER_INACTIVE 设置为 RCU_SCHEDULER_INIT
  2. 启用宽限期检测逻辑:此后 call_rcu() 注册的回调将在宽限期结束后被真正执行。

在所有 CPU 都上线(smp_init() 完成)后,rcu_end_inkernel_boot()rcu_scheduler_active 设置为 RCU_SCHEDULER_RUNNING,RCU 进入完全运行状态。

为什么 RCU 必须在中断之前初始化

RCU 在 start_kernel() 中的初始化位置非常靠前——早于中断控制器、定时器和调度器的初始化。这看似过早,实则必要:

  • 中断处理程序使用 RCU:网络中断、块设备完成中断等大量中断处理程序中调用了 rcu_read_lock() / rcu_read_unlock()。如果 RCU 未初始化,这些操作会导致未定义行为。
  • 定时器回调使用 RCU:定时器软中断中也会触发 RCU 回调执行。
  • 调度器使用 RCU:任务切换时的 context_switch() 调用 rcu_note_context_switch() 来报告静止状态。

因此,RCU 基础设施必须在中断和调度器启用之前完全就绪,确保任何使用 RCU 的代码路径都能正确运行。

3.6 中断与时钟初始化

中断和时钟子系统是操作系统的”脉搏”——没有它们,内核无法响应外部事件,无法进行任务调度,也无法维护时间概念。在 start_kernel() 中,中断和时钟子系统的初始化紧密交织,本节将逐一分析各组件的初始化过程。

early_irq_init() — 中断描述符预分配

early_irq_init() 是中断子系统的第一个初始化函数,负责为所有可能的中断线(IRQ line)预分配描述符。在 x86_64 上,中断描述符的管理采用稀疏_irq_ 方式(CONFIG_SPARSE_IRQ):

  • irq_desc 树:使用_radix tree_ 管理中断描述符,键为中断号(IRQ number)。与早期内核中固定大小的数组不同,稀疏方式只为实际使用的中断号分配内存。
  • 默认数量NR_IRQS_LEGACY(通常 16)个_legacy_ IRQ 描述符在此时被预分配,对应 IBM PC 兼容机的传统 ISA 中断线。
  • per-CPU 统计:每个 irq_desc 包含 irq_data 和_per-CPU_ 的中断统计计数器 kstat_irqs

此时硬件中断控制器(APIC)尚未初始化,中断描述符只是软件侧的数据结构准备。

init_IRQ() — x86_64 中断控制器初始化

init_IRQ() 是架构相关的中断初始化入口,x86_64 上的调用链如下:

native_init_IRQ()

定义在 arch/x86/kernel/irqinit.c 中,主要完成:

  1. IDT(中断描述符表)完善:在 startup_64 阶段,汇编代码已经设置了一个最小化的 IDT(仅覆盖异常向量 0-31)。native_init_IRQ() 为剩余的外部中断向量(32-255)设置 IDT 表项。x86_64 上,外部中断向量从 FIRST_EXTERNAL_VECTOR(0x20)到 LAST_EXTERNAL_VECTOR(0xFF)分配。

    1
    2
    3
    4
    for (i = FIRST_EXTERNAL_VECTOR; i < NR_VECTORS; i++) {
    if (i != IA32_SYSCALL_VECTOR)
    set_intr_gate(i, interrupt[i - FIRST_EXTERNAL_VECTOR]);
    }
  2. APIC 初始化

    • Local APIC:每个 CPU 内部的中断控制器,负责接收中断、分发到 CPU 核心、处理_IPI_(处理器间中断)。lapic_init() 映射 Local APIC 的_MMIO_ 寄存器(x86_64 上默认地址为 0xfee00000),并配置定时器、错误处理等向量。
    • I/O APIC:主板上的中断控制器芯片,负责将外部设备的中断信号转发给 Local APIC。io_apic_init() 枚举所有 I/O APIC 芯片(通过_ACPI_ MADT 表获取信息),建立中断重定向表。
  3. 中断芯片设置:为每个 IRQ 描述符关联对应的 irq_chip 操作结构体。例如,I/O APIC 管理的中断线使用 ioapic_chip,它实现了 irq_enableirq_disableirq_ackirq_mask 等底层硬件操作。

tick_init() — 时钟设备框架

tick_init() 注册时钟事件通知机制。tick_notify() 回调会在新时钟设备注册或现有设备状态变化时被调用,决定使用哪个设备作为系统节拍源。

Linux 内核将时钟硬件抽象为两类设备:

  • clock_event_device:能够产生定时中断的设备(如 Local APIC Timer、HPET),用于驱动调度器节拍和高精度定时器。
  • clocksource:单调递增的高精度计数器(如 TSC、HPET 计数器),用于读取当前时间。

timers_init() — 经典定时器轮

timers_init() 初始化内核的经典定时器轮(TIMER wheel),定义在 kernel/time/timer.c 中。定时器轮是一个多级哈希表结构:

1
2
层级:  LVL_0    LVL_1    LVL_2    LVL_3    LVL_4    LVL_5    LVL_6
粒度: 1 jiffy 8 jiffy 64 jiffy 512 jiffy 4096 32768 262144

每个_slot_ 是一个链表头,存放到期时间落在该范围内的定时器。TIMER wheel 以_jiffy_(默认 1ms 或 4ms,取决于 HZ 配置)为分辨率。虽然精度不高,但对于大多数内核内部定时任务(如超时检测、轮询调度)已经足够。

init_timer_keys() 完成每级每_slot_ 链表的初始化,以及 timer_base 的_per-CPU_ 分配。

hrtimers_init() — 高精度定时器

hrtimers_init() 初始化高精度定时器子系统(high-resolution timers),定义在 kernel/time/hrtimer.c 中。与经典定时器轮不同,hrtimer 使用红黑树组织定时器,精度可达纳秒级。

初始化内容包括:

  • per-CPU 时钟基础:每个 CPU 维护多个 hrtimer_clock_base(对应 HRTIMER_BASE_MONOTONICHRTIMER_BASE_REALTIME 等时钟基),每个基础包含一棵红黑树。
  • 高精度模式切换:当高精度时钟源可用时,hrtimer 可以从”低分辨率模式”切换到”高分辨率模式”(通过 hrtimer_switch_to_hres()),此时系统节拍将变为无节拍(tickless)模式。

timekeeping_init() — 系统时间维护

timekeeping_init() 初始化系统时钟,包括:

  • 选择并注册默认 clocksource(通常是 TSC)。
  • 初始化 timekeeper 结构体,设置 clocksource 到墙上时钟(wall-clock time)的映射参数。
  • 从_CMOS_ RTC(或 EFI runtime services)读取启动时的墙上时间。

time_init() — x86_64 时钟硬件初始化

time_init() 在 x86_64 上执行平台特定的时钟硬件探测:

  1. TSC(Time Stamp Counter)初始化tsc_init() 检测 TSC 的频率(通过 CPUID 或与已知频率的定时器对比)、检查 TSC 是否可靠(是否在 CPU 频率变化时保持恒定速率、是否在 CPU 间同步)。如果 TSC 质量满足条件,它将被注册为首选 clocksource
  2. HPET 初始化hpet_init() 探测 High Precision Event Timer,映射其_MMIO_ 寄存器,并将其注册为 clocksourceclock_event_device
  3. PIT 初始化:8254 PIT(Programmable Interval Timer)作为最后的后备定时器被设置。

最终的时钟源优先级通常为:TSC > HPET > PIT。内核会选择质量最高的可用时钟源作为系统时间基准。

3.7 控制台与早期打印

在内核启动的漫长过程中,开发者需要一种手段来输出诊断信息。然而控制台的初始化涉及大量依赖(设备驱动、中断、内存管理等),不可能在启动最开始就可用。Linux 内核通过”早期打印”机制和控制台延迟注册策略解决了这一鸡生蛋蛋生鸡的问题。本节将分析从最早的 early_printk 到完整控制台子系统的初始化路径。

早期打印:boot console

console_init() 被调用之前,内核已经需要输出信息了(比如 start_kernel() 开头的版本字符串和命令行参数)。此时可用的打印手段是_early printk_,通过 boot console(bootcon)实现。

early_printk 的实现

在 x86_64 上,early_printk 的实现非常原始但有效:

  • VGA 文本模式输出:直接向 VGA 文本缓冲区(物理地址 0xB8000)写入字符。VGA 文本模式缓冲区为 80x25 的字符网格,每个字符占 2 字节(1 字节字符 + 1 字节颜色属性)。early_printk() 通过简单的内存写入将字符逐个送到屏幕上,完全不需要任何驱动程序或中断支持。
  • 串口输出:如果内核命令行包含 console=ttyS0 等参数,early_printk() 会向串口寄存器(通常是 0x3F8,COM1)直接写入字节。串口输出同样不依赖中断——采用轮询方式等待发送保持寄存器(THR)空闲后写入下一个字节。

setup_early_printk()start_kernel() 非常早期的位置被调用(甚至在 mm_core_init_early() 之前),确保内核从启动的第一刻起就具备输出能力。

boot console 的特点

boot console 注册到 console_drivers 链表时带有 CON_BOOT 标志。它的特点包括:

  • 不支持虚拟终端、不支持滚动、不支持中文或_UTF-8_ 显示。
  • 输出是同步的、阻塞的——每写一个字符都要等待硬件就绪。
  • 一旦真正的控制台驱动注册完成,boot console 会被自动注销。

printk() 的消息缓冲

printk() 是内核中最常用的日志输出函数。它的实现有一个关键特性:消息先被存入环形缓冲区,控制台输出是异步的。

__log_buf 环形缓冲区

内核在编译时静态分配了一个环形日志缓冲区:

1
2
#define __LOG_BUF_LEN (1 << CONFIG_LOG_BUF_SHIFT)  // 默认 16KB-128KB
static char __log_buf[__LOG_BUF_LEN];

printk() 被调用时,消息首先被格式化并写入 __log_buf,同时记录日志级别(KERN_EMERGKERN_DEBUG,共 8 级)和时间戳。即使此时没有可用的控制台,消息也不会丢失——它们安全地存放在缓冲区中,等待控制台驱动就绪后输出。

消息输出时机

消息从缓冲区输出到实际控制台的时机取决于:

  • 控制台是否已注册console_init() 之前,只有 boot console 接收输出。
  • 控制台日志级别:由 console_loglevel 控制,只有优先级高于此阈值的消息才会输出到控制台。
  • 是否在中断上下文:中断上下文中的 printk() 将输出推迟到安全时刻执行。

dmesg 命令读取的就是 __log_buf 中的完整日志(通过 syslog 系统调用),不受控制台日志级别的过滤。

console_init() — 真正的控制台注册

console_init() 定义在 kernel/printk.c 中,标志着控制台子系统从启动模式切换到正常运行模式。其主要工作包括:

遍历并注册控制台驱动

内核维护一个 console_drivers 全局链表。console_init() 调用所有已通过 register_console() 注册的控制台驱动的 ->setup() 回调。常见的控制台驱动包括:

  • VGA 文本控制台(vgacon:直接操作 VGA 文本模式硬件,支持光标定位、滚动、属性设置。这是传统 x86 平台上最常见的控制台。
  • 帧缓冲控制台(fbcon:建立在帧缓冲设备(fbdev)之上的控制台驱动。它将控制台文本渲染为像素图形,支持更高的分辨率和更多的颜色。
  • 串口控制台(uartcon:通过串口线输出控制台信息,常用于无头服务器和嵌入式设备的调试。

注销 boot console

当第一个非 CON_BOOT 的控制台驱动成功注册后,内核遍历 console_drivers 链表,移除所有带 CON_BOOT 标志的 boot console。此后所有 printk() 输出都由真正的控制台驱动处理。

日志级别与打印宏

内核定义了 8 个日志级别(从高到低):

级别 含义
0 KERN_EMERG 系统不可用
1 KERN_ALERT 必须立即处理
2 KERN_CRIT 严重错误
3 KERN_ERR 错误
4 KERN_WARNING 警告
5 KERN_NOTICE 正常但值得注意
6 KERN_INFO 信息
7 KERN_DEBUG 调试信息

为了简化使用,内核提供了一组辅助宏:

  • pr_debug(fmt, ...):对应 KERN_DEBUG 级别。如果定义了 CONFIG_DYNAMIC_DEBUG,则运行时可动态控制输出;否则在未启用 DEBUG 宏时编译为空操作。
  • pr_info(fmt, ...):对应 KERN_INFO 级别,用于输出常规信息。
  • pr_warn(fmt, ...):对应 KERN_WARNING 级别,用于输出警告。
  • pr_err(fmt, ...):对应 KERN_ERR 级别,用于输出错误。

这些宏在格式字符串前自动添加日志级别标记,使得 printk() 可以正确分类和过滤消息。对于设备驱动,还提供了 dev_info()dev_warn()dev_err() 等变体,在消息中自动包含设备名称和总线信息。

3.8 rest_init() — 从内核到用户空间

rest_init()start_kernel() 调用的最后一个函数,也是内核启动过程的真正转折点。在此之前,整个系统只有引导 CPU 在执行单一的控制流(init_task,PID 0)。rest_init() 之后,系统将拥有多个内核线程、全部 CPU 核心在线、并最终通过 /init 程序进入用户空间。本节将详细追踪这一关键转折的每一步。

rest_init() 的整体结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
static void __init rest_init(void)
{
struct task_struct *tsk;
int pid;

rcu_scheduler_starting();

pid = user_mode_thread(kernel_init, NULL, CLONE_FS);
// PID 1: kernel_init

pid = kernel_thread(kthreadd, NULL, NULL, CLONE_FS | CLONE_FILES);
// PID 2: kthreadd

system_state = SYSTEM_SCHEDULING;

rcu_end_inkernel_boot();

smp_init(); // 唤醒所有应用处理器

sched_init_smp();

ksoftirqd_setup();

do_pre_smp_initcalls();

cpu_startup_entry(CPUHP_ONLINE); // 引导 CPU 进入 idle 循环
}

注意:上述代码是概念性的简化版本,不同内核版本中 rest_init() 的具体实现细节有所差异,但核心逻辑不变。

创建 kernel_init 线程(PID 1)

rest_init() 创建的第一个内核线程是 kernel_init,它将成为系统中 PID 为 1 的进程——即所有用户进程的最终祖先进程:

1
pid = user_mode_thread(kernel_init, NULL, CLONE_FS);

使用 user_mode_thread() 而非 kernel_thread() 是因为 kernel_init 最终需要切换到用户模式执行 /init 程序。CLONE_FS 标志表示与父进程共享文件系统信息(根目录、当前目录等)。

kernel_init() 的执行流程

kernel_init() 函数定义在 init/main.c 中,其核心执行路径为:

  1. 等待 kthreadd 就绪kernel_init() 首先通过 wait_for_completion(&kthreadd_done) 等待 PID 2 的 kthreadd 线程完成初始化。这是因为后续操作可能需要 kthreadd 来创建辅助内核线程。

  2. 调用 kernel_init_freeable():这是 kernel_init 的主要工作函数,包含以下关键步骤:

    • do_pre_smp_initcalls():执行标记为 early_initcall 的初始化函数。这些函数需要在 SMP 启动之前完成,例如_per-CPU_ 数据的早期设置。

    • smp_init():唤醒所有应用处理器(Application Processors, APs)。引导 CPU(BSP)向每个 AP 发送 INIT IPI,AP 从实模式启动代码开始执行,经过保护模式、长模式的页表设置,最终到达 start_secondary() 并进入_idle_ 循环。此时系统的所有 CPU 核心都已在线。

    • do_basic_setup():这是启动过程中最重要的函数之一,它调用 do_initcalls() 执行所有剩余的_initcall_ 级别:

      级别 典型用途
      1 pure_initcall 最早期初始化
      2 core_initcall 核心子系统
      3 postcore_initcall 核心后初始化
      4 arch_initcall 架构相关
      5 subsys_initcall 子系统初始化
      6 fs_initcall 文件系统
      7 device_initcall 设备驱动
      8 late_initcall 延迟初始化

      绝大多数内核模块和驱动程序的初始化入口都在 device_initcall 级别被执行。

    • free_initmem():释放 __init 段的内存。所有标记为 __init 的函数和 __initdata 的数据在启动完成后不再需要,其占用的物理页面被回收并归还给伙伴系统。这通常释放数兆字节的内存。

    • 标记只读数据mark_rodata_ro() 将内核的只读数据段(.rodata)设置为只读属性,增强安全性。

    • system_state = SYSTEM_RUNNING:将系统状态标记为”正常运行”,这会影响锁调试、RCU、睡眠合法性检查等子系统的行为。

  3. 执行 /init 程序kernel_init() 的最后一步是尝试执行用户空间的 init 程序:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    if (ramdisk_execute_command) {
    if (run_init_process(ramdisk_execute_command) == 0)
    return 0;
    }
    if (execute_command) {
    if (run_init_process(execute_command) == 0)
    return 0;
    panic("Requested init %s failed.", execute_command);
    }
    if (!run_init_process("/sbin/init") ||
    !run_init_process("/etc/init") ||
    !run_init_process("/bin/init") ||
    !run_init_process("/bin/sh"))
    return 0;
    panic("No working init found.");

    搜索顺序为:rdinit= 参数指定的程序(用于 initrd 场景)、init= 参数指定的程序、然后依次尝试 /sbin/init/etc/init/bin/init/bin/sh。如果全部失败,内核触发_panic_。run_init_process() 内部调用 kernel_execve(),完成从内核态到用户态的切换。

创建 kthreadd 线程(PID 2)

kthreadd 是内核的内核线程守护进程,负责创建和管理所有后续的内核线程:

1
pid = kernel_thread(kthreadd, NULL, NULL, CLONE_FS | CLONE_FILES);

kthreadd 的实现非常简单——它在一个无限循环中等待请求:

1
2
3
4
5
6
7
8
9
10
int kthreadd(void *unused)
{
for (;;) {
// 等待创建请求
while (list_empty(&kthread_create_list))
schedule();
// 处理创建请求列表
// ...
}
}

当内核其他部分需要创建内核线程时,它们不直接调用 kernel_thread(),而是通过 kthread_create()kthreadd 发送请求。kthreadd 在其上下文中完成实际的线程创建,确保新线程的父进程是 kthreadd(PID 2)而非调用者——这避免了线程创建者退出时的孤儿线程问题。

引导 CPU 进入 idle 循环

rest_init() 的最后一步,引导 CPU(此时仍在 init_task 上下文中,PID 0)调用 cpu_startup_entry(CPUHP_ONLINE),最终进入 do_idle() 函数:

1
2
3
4
5
6
7
8
9
10
static void do_idle(void)
{
for (;;) {
// 通知调度器进入 idle
// 检查是否有任务需要运行
// 如果没有,进入低功耗状态(mwait/halt)
// 被中断唤醒后重新检查
schedule_idle();
}
}

do_idle() 是一个永远不会返回的循环。当没有其他任务可运行时,CPU 执行 hlt(或 mwait)指令进入低功耗状态。当中断到来时(定时器中断、I/O 完成中断等),CPU 被唤醒,调度器选择下一个可运行的任务执行。如果仍然没有任务,CPU 再次进入低功耗状态。

从此刻起,引导 CPU 的大部分时间都在_idle_ 循环中度过,只在有工作需要处理时短暂醒来。系统的真正工作由 kernel_init(PID 1)启动的用户空间进程和各种内核线程完成。

启动流程总结

start_kernel() 到用户空间 /init 的完整路径可以概括为:

1
2
3
4
5
6
7
8
9
10
11
start_kernel()
└─ 各子系统初始化(内存、调度器、RCU、中断、时钟、控制台...)
└─ rest_init()
├─ 创建 kernel_init (PID 1)
├─ 创建 kthreadd (PID 2)
├─ SMP 启动(唤醒所有 CPU)
├─ 执行 initcalls(驱动和模块初始化)
├─ 释放 __init 段
└─ 执行 /init → 进入用户空间

引导 CPU (PID 0) → cpu_startup_entry() → do_idle() 永久循环

至此,内核的启动使命完成,系统进入正常运行状态。

3.9 initcall 机制与驱动初始化

内核启动过程中,大量子系统与驱动需要执行各自的初始化函数。如果每个驱动都显式调用注册函数,内核的启动代码将变得难以维护。Linux 采用了一种优雅的解决方案——initcall 机制:通过宏定义和链接器脚本,将所有初始化函数的指针收集到特殊的内存区域中,启动时按优先级依次调用,启动完成后释放这些只使用一次的代码所占用的内存。

3.9.1 什么是 initcall

initcall 是一类被 __init 修饰的函数,它们在内核启动期间被统一调用。开发者不需要手动调用初始化函数,只需通过宏(如 core_initcall()device_initcall() 等)将函数注册到对应的 initcall 级别,内核启动时自动按级别顺序执行。

这些函数指针被放置在特殊的链接器段(linker section)中,从 __initcall0_start__initcall7_start,共 8 个级别。启动完成后,free_initmem() 会回收所有标记为 __init 的代码和数据所占用的内存空间。

3.9.2 八个 initcall 级别

Linux 定义了 8 个 initcall 级别,定义在 include/linux/init.h 中,按从低到高的顺序执行:

1
2
3
4
5
6
7
8
级别 0 (pure):   pure_initcall(fn)     — 必须最先执行,极少使用
级别 1 (core): core_initcall(fn) — 核心子系统(中断控制器、调度器核心)
级别 2 (post): postcore_initcall(fn) — 核心之后的设置
级别 3 (arch): arch_initcall(fn) — 架构相关驱动
级别 4 (subsys): subsys_initcall(fn) — 子系统注册(总线类型、网络栈)
级别 5 (fs): fs_initcall(fn) — 文件系统初始化
级别 6 (device): device_initcall(fn) — 绝大多数驱动的 init 函数
级别 7 (late): late_initcall(fn) — 最后执行,如 RTC、网络最终配置

这种分级设计确保了核心基础设施先于上层驱动初始化。例如,中断子系统(core 级别)必须先于 PCI 驱动(device 级别)完成初始化,否则驱动无法注册中断处理程序。每个级别内部函数的执行顺序取决于链接器将目标文件合并的顺序,因此在同一级别内不应依赖特定的执行先后关系。

3.9.3 实现原理

initcall 机制的核心实现在 init/main.c 中,主要涉及以下几个函数:

do_initcalls() 是总调度入口,遍历全部 8 个级别,对每个级别调用 do_initcall_level()

do_initcall_level() 负责处理单个级别:首先解析该级别可能涉及的模块参数,然后依次调用该级别中的每一个 initcall 函数。

do_one_initcall() 是最终执行单个 initcall 的函数,它不仅调用目标函数,还执行安全性检查:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int __init_or_module do_one_initcall(initcall_t fn)
{
int count = preempt_count();
int ret;

if (initcall_blacklisted(fn))
return -EPERM;

ret = fn();

/* 检查抢占计数或中断状态是否被破坏 */
if (preempt_count() != count)
pr_warn("initcall %pF changed preempt count\n", fn);

return ret;
}

该函数在调用前后比较 preempt_count() 的值,如果 initcall 函数内部错误地修改了抢占计数或中断状态,内核会打印警告信息。这种调试机制有助于及早发现驱动中的锁泄漏或中断状态不一致等 bug。此外,initcall_blacklisted() 机制允许通过内核命令行参数 initcall_blacklist= 跳过特定的 initcall 函数,便于调试。

3.9.4 链接器如何收集 initcall

initcall 机制的关键在于链接器脚本(vmlinux.lds.S)的巧妙运用。每个 initcall 宏在编译时将函数指针放入一个以级别命名的特殊段中:

1
2
3
4
#define __define_initcall(fn, id) \
static initcall_t __initcall_##fn##id \
__used \
__attribute__((__section__(".initcall" #id ".init"))) = fn;

链接器脚本将这些段按级别顺序排列,并定义边界符号:

1
2
3
4
5
6
__initcall_start     -- 整个 initcall 区域的起始
__initcall0_start -- 级别 0 的起始
__initcall1_start -- 级别 1 的起始
...
__initcall7_start -- 级别 7 的起始
__initcall_end -- 整个 initcall 区域的结束

do_initcalls() 通过遍历 __initcall_start__initcall_end 之间的函数指针数组来执行所有 initcall。内核中使用 initcall_levels[] 数组记录每个级别的起始地址,便于按级别分批执行。

3.9.5 __init__initdata

__init__initdata 是内核中非常重要的优化标记:

  • __init 将函数代码放入 .init.text
  • __initdata 将数据放入 .init.data

这些段在内核启动完成后不再需要。free_initmem() 函数在启动末期回收这些内存,将其归还给伙伴分配器。在典型的服务器系统上,这可以回收数兆字节的内存。

必须注意:启动完成后绝对不能调用 __init 函数。因为该函数的代码已经被释放,调用将导致页面错误(page fault)甚至内核崩溃。内核为此提供了 __initref 注解,允许少数特殊情况下的引用,但需要通过 initcall_debug 等手段仔细验证。

3.9.6 早期 initcall

在 SMP(多处理器)启动之前,内核需要执行一些特殊的初始化工作。do_pre_smp_initcalls() 负责在 SMP 拓扑建立之前运行这些早期 initcall。它们通过 early_initcall() 宏定义,在级别上排在所有标准 initcall 之前。

典型的早期 initcall 例子包括 init_real_mode()——它为 CPU 实模式跳板(trampoline)设置所需的内存区域,这是 AP(应用处理器)启动的基础。由于 AP 启动需要进入实模式获取 SIPI(Startup IPI),这些设置必须在 SMP bringup 之前完成。

3.9.7 模块的 initcall

对于可加载模块(loadable module),module_init() 宏扮演了不同角色:

  • 内建模块(built-in)module_init() 被展开为 device_initcall(),函数指针进入 initcall 链表,启动时自动调用
  • 可加载模块:函数指针存储在 struct module 中,当模块通过 insmodmodprobe 加载时,内核调用该函数完成初始化

这种统一接口的设计使得驱动代码可以同时支持内建和模块化编译,开发者只需使用 module_init() 一个宏即可,编译配置决定最终行为。与之配合的 module_exit() 仅对可加载模块有效——内建驱动的退出函数在编译时被忽略,因为内建驱动不会在运行时卸载。

总结而言,initcall 机制是 Linux 内核启动架构的核心组件之一。它通过链接器段、宏定义和分级执行策略,优雅地解决了数百个初始化函数的有序调用问题,同时通过 __init 标记实现了启动内存的自动回收。

3.10 挂载根文件系统与 exec /init

内核启动的最后阶段面临一个经典的”鸡与蛋”问题:内核需要挂载文件系统才能执行 init 进程,但文件系统驱动本身存储在文件系统中。Linux 通过 initramfs(初始内存文件系统)巧妙地解决了这一困境。本节追踪从 initramfs 解包到最终 exec /init 的完整路径。

3.10.1 根文件系统困境

内核启动的终极目标是启动第一个用户空间进程——init。为此,内核必须挂载一个包含 init 程序的根文件系统。然而这里存在循环依赖:

  • 挂载文件系统需要对应的文件系统驱动(ext4、xfs 等)
  • 文件系统驱动可能作为模块存储在文件系统中
  • 存储设备可能需要先加载 SCSI、NVMe 或 MD/DM 驱动
  • 这些驱动也可能作为模块存储在文件系统中

解决方案是 initramfs——一个打包在内核映像内或由引导加载器(bootloader)加载的微型内存文件系统。它包含足够多的工具和驱动模块来完成真实根文件系统的挂载。

3.10.2 initramfs 机制

initramfs 本质上是一个 gzip 压缩的 cpio 归档文件。它有两种来源:

  • 内建 initramfs:在内核编译时通过 CONFIG_INITRAMFS_SOURCE 指定,直接链接到内核映像的 __initramfs_start__initramfs_size 区域
  • 外部 initrd:由 GRUB 等 bootloader 从磁盘加载到内存中的 initrd_startinitrd_end 区域

解包工作由 init/initramfs.c 中的 unpack_to_rootfs() 完成。该函数解析 cpio 格式的归档数据,将文件逐个解压到根目录的 tmpfs 文件系统中。cpio 格式被选择是因为它简单、流式处理友好,且无需事先知道归档总大小。

initramfs 中通常包含以下早期用户空间组件:

  • udevd:设备节点管理守护进程
  • modprobe:内核模块加载工具
  • mount 及文件系统检查工具
  • lvm / dmsetup:设备映射器工具(用于 LVM、加密卷等)
  • NFS 客户端工具(用于 NFS 根文件系统)

如果内核命令行指定了 rdinit= 参数,内核将从 initramfs 中执行指定的程序而非默认的 /init。这使得 initramfs 可以执行复杂的引导逻辑,例如询问用户密码来解锁加密的根分区。

3.10.3 根文件系统挂载流程

根文件系统的挂载流程在 init/main.ckernel_init_freeable() 函数中编排,主要步骤如下:

第一步:解包 initramfs

1
2
3
if (initrd_start && !IS_ENABLED(CONFIG_INITRAMFS_FORCE)) {
unpack_to_rootfs((char *)initrd_start, initrd_end - initrd_start);
}

如果 initrd_start 已设置(bootloader 加载了 initrd),则调用 unpack_to_rootfs() 将其解包到内存中。

第二步:等待异步设备就绪

内核等待设备映射器(device-mapper)和 MD 软件 RAID 完成初始化。这是通过 wait_for_device_probe() 和异步探测机制实现的。某些存储设备(特别是网络块设备和加密设备)需要额外的准备时间。

第三步:挂载真实根文件系统

如果 initramfs 中的 /init 程序(通常是一个脚本或 systemd)已经完成工作,它会通过 switch_root 系统调用切换到真实根文件系统。如果内核直接负责挂载(未使用 initramfs 或 initramfs 未提供 /init),则调用 mount_root() 完成挂载。

3.10.4 do_mount_root() 与 mount_root()

mount_root() 负责根据内核命令行参数找到并挂载真实的根文件系统。它支持多种设备指定方式:

  • 设备路径root=/dev/sda1 — 直接指定设备节点
  • PARTUUIDroot=PARTUUID=12345678-... — 按 GPT 分区 UUID 查找
  • UUIDroot=UUID=abc123-def456-... — 按文件系统 UUID 查找
  • LABELroot=LABEL=rootfs — 按文件系统标签查找
  • NFSroot=/dev/nfs nfsroot=192.168.1.1:/export/root — 网络根文件系统

相关的内核命令行参数包括:

  • rootfstype=ext4:指定文件系统类型
  • rootflags=data=journal:传递挂载选项
  • ro:以只读方式挂载(默认行为,fsck 安全)
  • rw:以读写方式挂载

do_mount_root() 最终调用 kern_mount()do_mount() 系统调用来完成实际的挂载操作。挂载成功后,内核通过 devtmpfs_mount() 确保设备节点可用。

3.10.5 exec /init:从内核到用户空间

根文件系统挂载完成后,内核开始寻找并执行 init 程序。这是启动过程中最具里程碑意义的时刻——内核从此不再是主角,控制权交给用户空间的 PID 1 进程。

查找顺序严格定义在 kernel_init_freeable() 中:

1
2
3
4
5
6
7
8
9
10
if (execute_command)
run_init_process(execute_command); /* init= 内核参数 */

if (!try_to_run_init_process("/sbin/init") ||
!try_to_run_init_process("/etc/init") ||
!try_to_run_init_process("/bin/init") ||
!try_to_run_init_process("/bin/sh"))
return;

panic("No working init found.");

内核首先检查 init= 内核命令行参数是否指定了自定义的 init 路径(如 init=/bin/systemdinit=/bin/bash)。如果指定了,则直接执行该程序。否则依次尝试 /sbin/init/etc/init/bin/init/bin/sh。如果所有路径都失败,内核进入 panic()——此时系统无法继续启动。

run_init_process()try_to_run_init_process() 内部调用 kernel_execve() 系统调用。这是一个关键的过渡点:当前内核线程(PID 1,名为 “init”)通过 kernel_execve() 将自身替换为一个用户空间进程。该系统调用不会返回——它将当前任务的地址空间从内核空间切换到用户空间,开始执行 init 程序的入口点。

1
2
3
4
5
6
7
8
9
static int run_init_process(const char *init_filename)
{
const char *const *p;

argv_init[0] = init_filename;
pr_info("Run %s as init process\n", init_filename);

return kernel_execve(init_filename, argv_init, envp_init);
}

至此,PID 1 从一个内核线程蜕变为用户空间进程。在大多数现代 Linux 发行版中,这个进程是 systemd/sbin/init 通常是到 /lib/systemd/systemd 的符号链接)。

3.10.6 PID 1 的职责

init 进程(PID 1)在用户空间中拥有特殊地位,承担以下核心职责:

文件系统挂载:init 进程负责挂载 /proc/sys/dev(devtmpfs)、/tmp/run 等虚拟和临时文件系统。虽然内核已经挂载了根文件系统,但其余的挂载点由 init 负责处理。

服务管理:启动系统服务——网络配置、日志系统(journald/syslog)、D-Bus 通信总线、定时任务(cron)、SSH 守护进程等。systemd 通过 .service 单元文件以并行方式启动服务,显著加速启动过程。

进程回收:当任何进程的父进程退出后,该进程会被重新分配给 PID 1。当这些”孤儿进程”退出时,PID 1 负责调用 wait() 回收它们的资源,防止产生僵尸进程(zombie)。这是 PID 1 不可推卸的责任。

信号处理:PID 1 对信号有特殊行为——内核不会向 PID 1 发送没有注册处理程序的信号。这意味着如果 init 没有为 SIGTERM 注册处理器,那么发送 SIGTERM 给 PID 1 会被内核直接丢弃。这保护了 init 进程免受意外终止。

系统关机:init 进程管理系统的关机和重启流程。当收到 SIGTERM(传统 SysVinit)或通过 D-Bus 发出关机请求(systemd)时,init 进程负责优雅地停止所有服务,最终调用 reboot() 系统调用通知内核关机。

start_kernel()kernel_execve(),内核完成了从早期硬件初始化到用户空间启动的全部工作。initcall 机制确保了子系统按正确顺序初始化,initramfs 解决了根文件系统的引导困境,而 kernel_execve() 标志着内核启动阶段的终结和用户空间时代的开始。

  • Title: Linux内核分析之基础知识-02
  • Author: 韩乔落
  • Created at : 2026-05-28 16:45:21
  • Updated at : 2026-05-28 18:40:00
  • Link: https://jelasin.github.io/2026/05/28/Linux内核分析之基础知识-02/
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments
On this page
Linux内核分析之基础知识-02