Linux 6.11 内核启动过程详解:以 RISC-V 为例

以 Linux 6.11 的 RISC-V 为例,给初学者画一张“从上电到 /sbin/init”的完整地图


导读

Linux 内核启动一直是那种“很重要,但第一次看很容易迷路”的代码。

它当然是整个内核最基础的一条主线;但它又横跨了汇编入口、页表建立、内存初始化、调度器、中断、设备树、多核启动,以及最后切入用户空间的过程。

第一次接触时,我觉得最常见的问题不是“某一行代码看不懂”,而是:看不清整条链路到底是怎么串起来的

这篇文章先解决“地图”问题:先知道自己在哪,再决定要深入哪一层细节。

所以本文不准备一上来就抠 CSR 每一位、PTE 每个 flag,而是以 Linux 6.11 的 RISC-V 架构 为目标,先把主线讲顺:

  • 内核是如何接过 firmware / bootloader 控制权的
  • 为什么要先建“临时页表”,再建“最终页表”
  • start_kernel() 到底在做什么
  • 其他 hart 是什么时候起来的
  • 内核又是如何走到第一个用户态 init 进程的

如果你刚开始读 Linux 启动代码,建议暂时只抓一条线:

head.S 怎么把系统送进 start_kernel()start_kernel() 又怎么把系统送进第一个用户态 init

为什么选 Linux 6.11 + RISC-V

如果你想找一个适合入门的 Linux 启动路径,Linux 6.11 + RISC-V 是一个很不错的切入点。

我选这个组合,不是因为 RISC-V 启动代码“简单到没细节”,而是因为它比较适合先建立主线。

RISC-V 的架构历史包袱相对少。相比 x86 这种经过几十年演化、兼容层极多的架构,RISC-V 的启动代码更容易顺着主线读下来。

Linux 在 RISC-V 上的启动路径也比较集中。很多关键逻辑都落在 arch/riscv/ 下面的少数几个文件里,入口明确,主干清楚。

另外,Linux 6.11 足够新。你看到的是比较现代的一套组织方式,能帮助你理解当前主线内核是如何完成自举的。

当然,这里说“适合初学者”,不是说它“很简单”,而是说:

它更容易先看见主线,再逐步进入细节。

这件事非常重要。学内核时,最怕的不是复杂,而是一上来就迷路。

说明:本文基于本地源码树 linux-master,版本为 6.11.0-rc1。由于主线结构已经对应 Linux 6.11,本文统一按 Linux 6.11 来叙述。

如果你已经在研究页表位定义、异常向量细节、SBI 扩展实现,这篇文章会偏“总览”。它更适合用来建立第一张启动流程地图。


先把调用链摆出来

先别急着进源码。我们先用一条压缩后的主线,把整个过程看一遍。

firmware / bootloader
    -> arch/riscv/kernel/head.S:_start
    -> arch/riscv/kernel/head.S:_start_kernel
    -> arch/riscv/mm/init.c:setup_vm()
    -> arch/riscv/kernel/head.S:relocate_enable_mmu
    -> init/main.c:start_kernel()
    -> arch/riscv/kernel/setup.c:setup_arch()
    -> arch/riscv/mm/init.c:paging_init()
    -> init/main.c:rest_init()
    -> kernel_init() / kthreadd
    -> 运行第一个用户态 init 进程

如果再抽象一点,整个启动过程其实就五步:

  1. 1. 进入内核最早期入口代码
  2. 2. 建立最小运行环境并打开 MMU
  3. 3. 完成通用内核初始化
  4. 4. 把其他 CPU 和各类子系统带起来
  5. 5. 切入第一个用户态进程

后面所有内容,基本都是在展开这张表。


启动前提:RISC-V 进入 Linux 时,内核默认相信什么

看启动代码之前,先看启动约定。

RISC-V 内核对 firmware / bootloader 有一些明确要求,在内核源码的 Documentation/arch/riscv/boot.rst 里写得很清楚。对初学者来说,最需要记住的是下面四条:

  • a0 保存当前启动 hart 的 hartid
  • a1 保存设备树 DTB 的物理地址
  • satp = 0,也就是 MMU 还没有开启
  • 内核镜像需要按 PMD 边界对齐
  • rv64 常见是 2MB
  • rv32 常见是 4MB

如果把这件事写成很粗糙的伪代码,大概是:

cpu.a0 = boot_hartid;
cpu.a1 = dtb_pa;
cpu.satp = 0;      // MMU off
jump_to(kernel_start);

这组约定看起来简单,但非常重要。因为 Linux 早期启动代码几乎就是在这些前提上直接展开的。


第一站:head.S,Linux 真正开始接管机器

RISC-V 的启动入口在:

  • arch/riscv/kernel/head.S

这里最开始的符号是 _start。不过 _start 本身更像一个镜像入口壳子,它负责提供镜像头信息,并很快跳转到真正重要的入口:

  • _start_kernel

所以从理解主线的角度看,真正值得重点盯住的是 _start_kernel


_start_kernel 在干什么

如果把 head.S 里最关键的动作提炼出来,主 hart 进入 _start_kernel 之后,大致会做这些事:

  1. 1. 关闭中断
  2. 2. 初始化全局指针 gp
  3. 3. 禁用 FPU / Vector
  4. 4. 在 CONFIG_RISCV_BOOT_SPINWAIT 下选出主启动 hart
  5. 5. 清空 .bss
  6. 6. 记录 boot hart 的 hartid
  7. 7. 建立最早期栈和 tp
  8. 8. 调用 setup_vm() 建立临时页表
  9. 9. 调用 relocate_enable_mmu 打开 MMU
  10. 10. 设置 trap 向量
  11. 11. 跳入 start_kernel()

对应的伪代码可以写成:

void early_entry(hartid, dtb_pa)
{
    disable_interrupts();
    init_global_pointer();
    disable_fpu_and_vector();

    if (spinwait_boot && !i_am_boot_hart(hartid))
        wait_for_release();

    clear_bss();
    boot_cpu_hartid = hartid;

    tp = &init_task;
    sp = init_thread_union_top;

    setup_vm(dtb_pa);
    relocate_enable_mmu();
    setup_trap_vector();

    soc_early_init();
    start_kernel();
}

这里最值得初学者注意的,不是每一条汇编指令,而是这段代码背后的目标:

在真正进入 C 语言世界之前,先把 CPU、栈、页表和最基本的异常处理环境搭出来。

这是一切后续工作的前提。


为什么 .bss 要先清零

这是一个很基础、但容易被忽略的问题。

.bss 里放的是“未显式初始化的全局变量和静态变量”。在 C 语言语义里,这些变量默认初值应该是 0。但这个“默认是 0”不是编译器帮你在神秘空间里完成的,而是启动代码必须主动把它清掉

如果这里不做,很多后续 C 代码就会在错误的初始状态下运行,内核很可能连最早期初始化都走不过去。

所以看到 head.S 里清 .bss 的循环时,不要把它当成“杂务代码”,它其实是从汇编过渡到 C 代码的一个必要条件。


为什么一开始要禁用 FPU / Vector

RISC-V 的早期启动代码里会先把 FPU 和 Vector 关掉。这样做的核心原因不是“以后不用”,而是:

此时内核还没有准备好完整的上下文管理机制。

如果早期代码意外使用了这些扩展状态,可能会带来难以追踪的问题。最稳妥的做法就是先关掉,谁在不该用的时候用了,就尽早触发异常。

这种做法很符合启动代码的一贯原则:

先保证系统可控,再逐步放开能力。


启动中最重要的一步:先建临时页表,再建最终页表

理解 Linux 启动流程时,最关键的思想之一就是:

内核不是一开始就拥有完整运行环境,它是靠“分阶段自举”把环境搭起来的。

页表建立就是最典型的例子。

在 RISC-V 上,这部分主要对应两个函数:

  • setup_vm()
  • setup_vm_final()

setup_vm():先让内核“站起来”

setup_vm() 位于:

  • arch/riscv/mm/init.c

它是在 head.S 里、MMU 还没打开时就被调用的。这个阶段的任务不是把整个系统都映射好,而是只做一件事:

先建立一个足以让内核继续往前运行的最小虚拟内存环境。

它大致会完成下面这些工作:

  • 计算内核虚拟地址基址
  • 建立早期页表 early_pg_dir
  • 建立 trampoline 映射
  • 建立 fixmap 区域映射
  • 临时映射 DTB,供后续早期解析

可以把它简化为:

void setup_vm(uintptr_t dtb_pa)
{
    decide_kernel_virtual_base();
    build_early_page_tables();
    map_kernel_minimally();
    map_fixmap();
    map_dtb_early(dtb_pa);
}

这一阶段最大的特点不是“做了很多”,而是“限制极强”。

因为这时:

  • MMU 还没真正切好
  • 系统完整内存布局还不知道
  • 很多通常可以随便调用的内核设施都还没准备好

所以 setup_vm() 对编译方式和代码行为都有严格约束。这也是为什么内核源码文档会专门强调它必须非常克制。


relocate_enable_mmu:从物理世界切到虚拟世界

有了页表,还不等于已经进入虚拟内存世界。还差真正写入 satp,把 MMU 打开。

这个动作在:

  • arch/riscv/kernel/head.S:relocate_enable_mmu

这个函数做的事情,粗略理解就够了:

  • 计算链接地址和装载地址之间的偏移
  • 修正返回地址
  • 先切到 trampoline 页表
  • 再切到正式早期页表
  • 刷新 TLB

它像一座桥,把系统从“按物理地址思考”的状态,带到“按内核虚拟地址思考”的状态。

从整体视角看,这一步的意义非常大:

从这里开始,内核终于不只是“被装进内存的一段代码”,而是在自己的地址空间模型里运行了。


setup_vm_final():真正把内存布局搭完整

进入 start_kernel() 之后,RISC-V 会在 setup_arch() 里继续调用:

  • paging_init()

paging_init() 内部会走到:

  • setup_bootmem()
  • setup_vm_final()

这时候,系统已经知道更多物理内存布局信息,于是可以建立真正的 swapper_pg_dir,完成正式映射,包括:

  • 建立线性映射(direct mapping)
  • 映射全部内存 bank
  • 建立正式的内核映射
  • 清理早期临时 fixmap 页表
  • 切换到最终页表

如果把它和 setup_vm() 摆在一起看,会更清楚:

setup_vm()
    目标:先跑起来
    特点:临时、保守、限制多

setup_vm_final()
    目标:进入正常内核内存布局
    特点:完整、正式、可支撑后续初始化

对初学者来说,只要把这个“两阶段页表建立”的思想吃透,Linux 启动流程就已经明白一大半了。


start_kernel():从“能执行”到“像一个真正的内核”

最早期汇编完成接管之后,控制流会进入:

  • init/main.c:start_kernel()

这是 Linux 启动主线里的核心函数。

如果你把整个内核启动看成一场施工,那么:

  • head.S 负责搭脚手架
  • start_kernel() 负责把主体结构一层层建起来

setup_arch():RISC-V 专属初始化的入口

start_kernel() 里非常关键的一步,是调用:

  • arch/riscv/kernel/setup.c:setup_arch()

这个函数负责把所有“必须由 RISC-V 架构自己决定”的初始化动作接到通用启动主线上。

它大致会做这些事情:

  • 解析早期 DTB:parse_dtb()
  • 初始化 init_mm 相关描述
  • 初始化 early ioremap
  • 初始化 SBI:sbi_init()
  • 解析 early param
  • 初始化 EFI:efi_init()
  • 进入正式分页初始化:paging_init()
  • 展开设备树:unflatten_device_tree()
  • 初始化内存相关结构:misc_mem_init()
  • 建立资源树:init_resources()
  • 如果开启 SMP,执行 setup_smp()
  • 探测硬件能力:riscv_fill_hwcap()
  • 应用启动期 alternatives

你可以把它理解为:

void setup_arch(char **cmdline_p)
{
    parse_dtb();
    init_init_mm();
    early_ioremap_setup();
    sbi_init();
    parse_early_param();
    efi_init();
    paging_init();
    unflatten_device_tree();
    misc_mem_init();
    init_resources();
    setup_smp();
    detect_cpu_features();
    apply_boot_alternatives();
}

从主线角度看,setup_arch() 的作用很明确:

它把“RISC-V 特有的启动工作”完整接到 Linux 的通用初始化骨架上。


start_kernel() 后半段到底在忙什么

很多人第一次读 start_kernel(),会觉得它像一长串函数调用清单。但如果从职责划分上看,其实很有层次。

大体可以分成四类。

1. 内存与异常基础设施

  • trap_init()
  • mm_core_init()
  • kmem_cache_init_late()

2. 调度与并发基础设施

  • sched_init()
  • workqueue_init_early()
  • rcu_init()

3. 中断与时间系统

  • init_IRQ()
  • tick_init()
  • timekeeping_init()
  • time_init()
  • hrtimers_init()

4. 更高层的系统子结构

  • console_init()
  • vfs_caches_init()
  • security_init()
  • cgroup_init()

如果只记一件事,我建议记住这个顺序:

先稳住 CPU、内存和异常处理,再把调度器和并发框架搭起来,然后再打开中断和时间系统,最后才继续文件系统、驱动模型、安全框架这些更高层部分。

这就是 Linux 启动的工程顺序。


多核系统里,其他 hart 是什么时候上线的

单核启动主线讲完以后,再来看多核。

RISC-V 这里最容易冒出来的问题是:

如果机器有多个 hart,为什么不是大家一起把启动流程跑完?

答案是:因为启动必须有主次。否则很多全局初始化动作会发生冲突。Linux 需要一个 boot hart 先把全局骨架搭起来,再把其他 hart 纳入系统。

主 hart 和次级 hart 的分工

CONFIG_RISCV_BOOT_SPINWAIT 模式下,head.S 里可以看到一个很直观的机制:

  • 所有 hart 都可能先进入内核
  • 其中只有一个 hart 赢得 “hart lottery”
  • 这个 hart 负责继续主启动
  • 其他 hart 先停在等待路径上

这就是一个很朴素的策略:先选一个“总负责人”。

而在 ordered booting 模式下,通常是 firmware 一开始只放出 boot hart,后面再由内核把其他 hart 逐个拉起来。

不管是哪种方式,整体思想都一样:

先让一个 hart 把系统骨架搭起来,其他 hart 等系统准备好之后再加入。

setup_smp()smp_callin():多核启动的两块关键拼图

RISC-V 多核相关主线主要可以盯住两个函数:

  • arch/riscv/kernel/smpboot.c:setup_smp()
  • arch/riscv/kernel/smpboot.c:smp_callin()

setup_smp() 在做什么

setup_smp() 的核心任务是:

  • 根据 DT 或 ACPI 找出系统里有哪些 CPU / hart
  • 建立 cpuid -> hartid 的映射
  • 标记哪些 CPU 是 possible / present

这一步的本质是:先建立“系统知道自己有多少 CPU”的认知。

smp_callin() 在做什么

次级 hart 真正被带起来后,会进入 smp_callin()。这个函数会做一些“把这个 CPU 纳入内核体系”的动作,比如:

  • 绑定 init_mm
  • 启用 IPI
  • 标记 CPU online
  • 刷新本地 cache / TLB
  • 打开本地中断
  • 进入 cpu_startup_entry()

它的简化伪代码大概是:

void smp_callin(void)
{
    current->active_mm = &init_mm;
    set_cpu_online(cpu, true);
    enable_ipi();
    local_flush_icache_all();
    local_flush_tlb_all();
    local_irq_enable();
    cpu_startup_entry();
}

对初学者来说,这一段最重要的理解不是“它具体刷了哪些状态”,而是:

次级 hart 不是天然就属于内核调度系统的,它必须被显式初始化并纳入管理。


rest_init():启动代码开始“分家”

很多介绍讲到 start_kernel() 就停了,但其实 Linux 启动流程真正的“阶段切换点”在:

  • init/main.c:rest_init()

为什么它重要?

因为到了这里,系统已经不再只是“单线程地跑一段启动代码”,而是开始形成正常运行时的角色分工。

rest_init() 的关键动作不多,但都很要命:

  1. 1. 创建 pid 1
  2. 2. 创建 kthreadd
  3. 3. 当前 boot 线程转入 idle

这意味着系统里最核心的三个角色开始分化出来了:

  • pid 0:boot CPU 上的 idle 线程
  • pid 1:未来会进入用户空间的 init 线程
  • pid 2:负责管理内核线程的 kthreadd

这一步很像系统从“施工阶段”切换到“运营阶段”的分界点。

值得注意的是,rest_init() 会先创建 pid 1。这不是随意安排,而是 Linux 启动语义的一部分:第一个用户空间 init 必须拿到 pid 1


kernel_init():把内核启动收尾,然后去执行用户空间

rest_init() 创建出来的 pid 1,一开始执行的并不是 /sbin/init,而是:

  • kernel_init()

这意味着:虽然“用户态入口”的那个线程已经创建出来了,但它还要先替内核把最后一段启动工作收尾。


kernel_init_freeable():大量收尾工作都发生在这里

kernel_init() 会先等待 kthreadd 就绪,然后进入:

  • kernel_init_freeable()

这个阶段会继续做很多重要事情,例如:

  • smp_prepare_cpus()
  • workqueue_init()
  • do_pre_smp_initcalls()
  • smp_init()
  • sched_init_smp()
  • do_basic_setup()
  • wait_for_initramfs()
  • prepare_namespace()

其中 do_basic_setup() 又会继续调用:

  • driver_init()
  • do_initcalls()

所以你可以把这个阶段理解成:

内核前面已经把基础设施搭出来了,现在开始把大量子系统、驱动和设备相关初始化真正跑起来。


什么是 initcall

这也是读启动流程时一定会遇到的概念。

内核里大量模块和子系统会通过各种 initcall 宏,把自己的初始化函数挂到不同级别的启动队列里。到了合适时机,Linux 会按 level 顺序把这些函数一批批执行。

从高层看,它几乎可以理解成:

for each initcall_level:
    for each fn in this_level:
        fn();

这也是为什么很多驱动和子系统明明没在 start_kernel() 里直接出现,但系统启动时它们还是会自动初始化。

因为它们的入口函数已经通过 initcall 机制挂进主线了。


最终一步:内核如何走到第一个用户态进程

kernel_init() 把剩余内核初始化工作做完,就会开始尝试执行第一个用户态程序。

顺序大致是:

  1. 1. 优先尝试 initramfs 里的 /init
  2. 2. 如果命令行指定了 init=...,尝试用户指定路径
  3. 3. 然后尝试默认路径
  • /sbin/init
  • /etc/init
  • /bin/init
  • /bin/sh

如果这些都失败,内核会 panic。

对应的伪代码可以写成:

int kernel_init(void)
{
    wait_for_kthreadd();
    kernel_init_freeable();
    free_initmem();
    mark_kernel_readonly();

    if (run("/init") == OK) return 0;
    if (run(user_specified_init) == OK) return 0;
    if (run("/sbin/init") == OK) return 0;
    if (run("/etc/init") == OK) return 0;
    if (run("/bin/init") == OK) return 0;
    if (run("/bin/sh") == OK) return 0;

    panic("No working init found");
}

当第一个用户态 init 成功运行时,从宏观角度看,Linux 内核启动过程就已经完成了。

因为系统已经正式从“内核自举阶段”过渡到“用户空间接管阶段”。


最后用一条时间线收束

前面讲得比较散:汇编入口、页表、start_kernel()、SMP、pid 1

最后再压成一条时间线:

firmware / bootloader 交棒

  • 准备 a0 = hartid
  • 准备 a1 = dtb_pa
  • 保持 satp = 0
  • 跳入内核 _start

head.S 早期接管

  • 关闭中断
  • 选择 boot hart
  • .bss
  • 准备最早期栈和 tp
  • 调用 setup_vm()
  • 打开 MMU
  • 建立 trap 向量
  • 跳入 start_kernel()

start_kernel() 建立内核骨架

  • 调用 setup_arch()
  • 完成正式分页
  • 初始化内存管理、调度器、中断和时间系统
  • 初始化控制台和更多系统基础设施

形成正常运行时角色

  • rest_init() 创建 pid 1
  • 创建 kthreadd
  • boot 线程转入 idle

启动收尾并切到用户空间

  • 启动其他 CPU
  • 执行 initcalls
  • 初始化驱动与文件系统相关子系统
  • 准备根文件系统
  • 执行第一个用户态 init

如果只想记一句话,可以记成:

Linux 启动的本质,就是内核先在极简环境里完成自举,再逐步建立完整虚拟内存、调度和中断体系,最后把控制权平稳交给用户空间。


初学者最值得对照的源码位置

如果你读完本文后想回到源码里继续看,我建议优先盯住下面这些位置:

  • arch/riscv/kernel/head.S:21 -> _start
  • arch/riscv/kernel/head.S:74 -> relocate_enable_mmu
  • arch/riscv/kernel/head.S:129 -> secondary_start_sbi
  • arch/riscv/kernel/head.S:197 -> _start_kernel
  • arch/riscv/mm/init.c:1065 -> setup_vm()
  • arch/riscv/mm/init.c:1315 -> setup_vm_final()
  • arch/riscv/mm/init.c:1395 -> paging_init()
  • arch/riscv/kernel/setup.c:249 -> setup_arch()
  • init/main.c:701 -> rest_init()
  • init/main.c:903 -> start_kernel()
  • init/main.c:1458 -> kernel_init()
  • init/main.c:1548 -> kernel_init_freeable()
  • arch/riscv/kernel/smpboot.c:158 -> setup_smp()
  • arch/riscv/kernel/smpboot.c:210 -> smp_callin()
  • init/init_task.c:64 -> init_task

如果你时间有限,我建议只先盯四个点:

  • _start_kernel
  • setup_vm
  • setup_arch
  • start_kernel

只要这四个函数读顺了,Linux 启动主线就已经抓住了很大一部分。


我建议你读源码时避开这几个坑

不要一开始就陷进位级细节

比如一上来就去抠页表位含义、CSR 每一位的定义、异常入口每条指令的微观行为。这样很容易把自己困在局部里。

更好的方式是先回答三个问题:

  • 现在处在启动的哪个阶段?
  • 这一阶段的目标是什么?
  • 这一步做完之后,系统多了什么能力?

要区分“架构专属代码”和“Linux 通用主线”

启动流程里至少有两层:

  • arch/riscv/... 负责 RISC-V 特有动作
  • init/main.c 等文件负责 Linux 的通用启动骨架

能分清这两层,读代码会轻松很多。

要接受“启动是分阶段自举”这件事

Linux 启动不是一口气把所有东西都准备好,而是:

  • 先建最小环境
  • 再建正式页表和内存布局
  • 再建调度和中断体系
  • 再拉起其他 CPU 和子系统
  • 最后切到用户空间

把这个思路建立起来之后,很多“为什么这一步现在才做”的问题就自然想通了。


结语

现在再回头看 Linux 6.11 在 RISC-V 上的启动过程,我会这样概括:

内核先接过 bootloader 交来的一个极简机器状态,随后通过一系列分阶段自举动作,逐步建立起自己的地址空间、内存管理、调度和中断体系,再把其他 hart 和各类子系统纳入运行框架,最后执行第一个用户态 init 进程。

我喜欢从 RISC-V 看这条启动路径,也是因为它足够现代,路径相对清楚,又能完整展示 Linux 内核启动最核心的工程思想:

不是一开始就什么都有,而是靠严格的顺序一步步把系统搭起来。

如果你接下来还想继续深入,我建议不要急着铺开全部文件。可以先做一个小练习:

第一步,从 _start_kernel 开始,自己画一张调用关系图。

第二步,每遇到一个函数,都问同样三个问题:

  • 它在解决什么问题?
  • 它为什么必须在这个时机执行?
  • 它执行之后,系统多了什么能力?

顺着这个方法继续读,你会发现 Linux 启动过程并不是一团乱麻,而是一条非常讲顺序、非常讲依赖关系的主线。

相关阅读