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. 进入内核最早期入口代码
- 2. 建立最小运行环境并打开 MMU
- 3. 完成通用内核初始化
- 4. 把其他 CPU 和各类子系统带起来
- 5. 切入第一个用户态进程
后面所有内容,基本都是在展开这张表。
启动前提:RISC-V 进入 Linux 时,内核默认相信什么
看启动代码之前,先看启动约定。
RISC-V 内核对 firmware / bootloader 有一些明确要求,在内核源码的 Documentation/arch/riscv/boot.rst 里写得很清楚。对初学者来说,最需要记住的是下面四条:
a0保存当前启动 hart 的hartida1保存设备树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. 关闭中断
- 2. 初始化全局指针
gp - 3. 禁用 FPU / Vector
- 4. 在
CONFIG_RISCV_BOOT_SPINWAIT下选出主启动 hart - 5. 清空
.bss - 6. 记录 boot hart 的 hartid
- 7. 建立最早期栈和
tp - 8. 调用
setup_vm()建立临时页表 - 9. 调用
relocate_enable_mmu打开 MMU - 10. 设置 trap 向量
- 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. 创建
pid 1 - 2. 创建
kthreadd - 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. 优先尝试 initramfs 里的
/init - 2. 如果命令行指定了
init=...,尝试用户指定路径 - 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->_startarch/riscv/kernel/head.S:74->relocate_enable_mmuarch/riscv/kernel/head.S:129->secondary_start_sbiarch/riscv/kernel/head.S:197->_start_kernelarch/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_kernelsetup_vmsetup_archstart_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 启动过程并不是一团乱麻,而是一条非常讲顺序、非常讲依赖关系的主线。