2、汇编启动阶段:head_64.S与startup_64,解析内核解压前的页表初始化与长模式切换
好,咱们直接进入正题。这一章要聊的,是内核启动过程中最底层的汇编阶段。说白了,就是CPU刚上电、还在实模式下挣扎的时候,我们怎么把它拽进64位的长模式。
我个人习惯把这段代码称为「内核的接生婆」。它干的事情很纯粹:建立页表、开启分页、切换长模式。这三步走完,CPU才算真正进入x86_64的世界,才能去解压内核、执行C代码。
2.1 从head_64.S说起
你打开arch/x86/kernel/head_64.S,第一眼看到的肯定是这段注释:
/*
* head_64.S - startup_64 for 64-bit mode
* 这是内核解压前的入口点
*/
嗯,这里要注意:head_64.S并不是内核解压后的入口。它属于压缩内核(vmlinux.bin.gz)的一部分。真正的解压工作,是由它后面的代码完成的。
我刚开始看这段代码时,有个误区——以为startup_64就是C代码的入口。后来调试一次启动失败,才发现startup_64还在页表初始化阶段,连栈都没设好呢。
2.2 startup_64:页表初始化的核心
startup_64的代码不长,但每一行都关键。咱们拆开来看:
SYM_CODE_START(startup_64)
/* 清理方向标志 */
cld
/* 设置页表基址 */
movq $early_top_pgt, %rdi
movq %rdi, %cr3
/* 启用PAE和PSE */
movl $(X86_CR4_PAE | X86_CR4_PSE), %eax
movq %rax, %cr4
/* 设置EFER寄存器,开启长模式 */
movl $MSR_EFER, %ecx
rdmsr
btsl $_EFER_LME, %eax
wrmsr
/* 启用分页 */
movl $(X86_CR0_PG | X86_CR0_PE), %eax
movq %rax, %cr0
/* 跳转到64位代码段 */
pushq $__KERNEL_CS
leaq .Lstartup_64_after_paging(%rip), %rax
pushq %rax
lretq
SYM_CODE_END(startup_64)
这段代码,我当年在QEMU上单步调试了不下20遍。每次看都有新收获。
2.2.1 页表结构:early_top_pgt
early_top_pgt是一个静态定义的页表数组。它只有4个条目,每个条目映射512GB的虚拟地址空间。为什么是4个?因为内核在启动初期只需要映射前2TB的地址空间。
我曾经犯过一个低级错误:在调试自定义内核时,修改了页表条目数,结果启动时直接三重故障。后来才意识到,页表的大小必须和CR3指向的物理页对齐。
关键点:early_top_pgt的每个条目对应一个PML4(Page Map Level 4)表项。每个表项覆盖512个PDPT(Page Directory Pointer Table)条目,每个PDPT条目覆盖1GB。所以4个PML4条目 = 2TB地址空间。
2.2.2 为什么先设CR4再设CR3?
你可能会问:为什么代码里先设置CR4(启用PAE/PSE),再设置CR3(页表基址)?
嗯,这里有个硬件约束。CR4.PAE必须在CR3加载之前设置好,否则CPU会认为页表格式不对。我见过一个案例,有人把这两行顺序写反了,结果CPU直接报#GP异常。
我的建议:在写页表初始化代码时,严格按照Intel手册的顺序来:CR4 → EFER → CR3 → CR0。别问我怎么知道的,问就是吃过亏。
2.3 长模式切换的细节
长模式切换,说白了就是让CPU从32位兼容模式进入64位模式。但这里有个坑:你不能直接从实模式跳到长模式。必须经过保护模式,再切到长模式。
startup_64的代码里,切换长模式的关键是EFER寄存器的LME位。设置完LME后,再设置CR0.PG(分页启用),CPU就会自动进入长模式。
但注意:设置CR0.PG时,CPU必须已经处于保护模式。所以代码里同时设置了CR0.PE(保护模式启用)和CR0.PG。
2.3.1 代码段选择子:__KERNEL_CS
最后那个lretq指令,其实是远返回。它把__KERNEL_CS压栈作为代码段选择子,然后把.Lstartup_64_after_paging的地址压栈作为返回地址。这样CPU就会用64位代码段去执行后续代码。
__KERNEL_CS的值是0x10,对应GDT中的第2个描述符。这个描述符必须设置成64位代码段(L=1)。
注意:如果GDT中的代码段描述符没有设置L位,那么lretq之后CPU会进入32位兼容模式,而不是64位长模式。我调试过一个板子,就是GDT初始化时漏了L位,结果内核在解压前就崩溃了。
2.4 页表映射的实战经验
在实际项目中,页表初始化最容易出问题的点有两个:
- 物理地址对齐:页表必须放在物理地址的低32位范围内,且按4KB对齐。
- 映射覆盖范围:必须确保内核代码所在的物理地址被正确映射。
我记得有一次,客户的内核在启动时卡在「Decompressing Linux...」之后。我查了三天,最后发现是页表映射漏掉了解压缓冲区所在的物理地址。解压代码一运行,直接访问了未映射的内存,触发页错误。
解决方案很简单:在early_top_pgt里加一个条目,把解压缓冲区的物理地址映射到虚拟地址空间。
2.5 核心流程图
下面这张图,是我自己画的内核解压前页表初始化与长模式切换的流程。你看一眼,基本就明白整个脉络了。
2.6 常见问题与避坑
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 设置CR0.PG后三重故障 | 页表未正确初始化或物理地址不对齐 | 检查early_top_pgt的物理地址和内容 |
| lretq后进入32位模式 | GDT中代码段描述符未设置L位 | 确保__KERNEL_CS对应的描述符L=1 |
| 解压时页错误 | 页表未映射解压缓冲区 | 在页表中添加对应映射条目 |
| CR4设置后系统挂起 | CPU不支持PAE或PSE | 检查CPU特性,或使用兼容模式 |
调试技巧:我习惯在QEMU上用-gdb单步调试这段汇编代码。设置断点在startup_64入口,然后逐条执行,观察CR0、CR3、CR4的变化。这样能直观看到页表切换的过程。
嗯,这一章的内容就到这里。页表初始化和长模式切换,说白了就是给内核搭好一个64位的运行环境。代码不多,但每一行都踩在硬件规则的边界上。你写的时候多想想「CPU此刻看到的是什么」,就不会出大问题。
公众号:蓝海资料掘金营,微信deep3321