长模式
Table of Contents
接下来将会看到在保护模式中的最初几步,比如:
注意:这部分将会有大量的汇编代码,如果不熟悉汇编,建议找本书参考一下
在前一章节,停在了跳转到位于 arch/x86/boot/pmjump.S 的 32 位入口点这一步:
jmpl *%eax
eax 寄存器包含了 32 位入口点的地址。可以在 x86 linux 内核引导协议 中找到相关内容:
When using bzImage, the protected-mode kernel was relocated to 0x100000 当使用 bzImage 时,保护模式下的内核被重定位至 0x100000
检查一下 32 位入口点的寄存器值来确保这是对的:
eax 0x100000 1048576 ecx 0x0 0 edx 0x0 0 ebx 0x0 0 esp 0x1ff5c 0x1ff5c ebp 0x0 0x0 esi 0x14470 83056 edi 0x0 0 eip 0x100000 0x100000 eflags 0x46 [ PF ZF ] cs 0x10 16 ss 0x18 24 ds 0x18 24 es 0x18 24 fs 0x18 24 gs 0x18 24
在这里可以看到 cs 寄存器包含了 0x10 (代表了全局描述符表中的第二个索引项), eip 寄存器的值是 0x100000,并且包括代码段在内的所有内存段的基地址都为0。所以可以得到物理地址: 0:0x100000 或者 0x100000,这和协议规定的一样
现在就可以从 32 位入口点代码开始
32 位入口点
可以在汇编源码 arch/x86/boot/compressed/head_64.S 中找到 32 位入口点的定义
__HEAD .code32 ENTRY(startup_32) .... .... .... ENDPROC(startup_32)
首先,为什么目录名叫做 被压缩的 compressed ?实际上 bzimage 是由 vmlinux + 头文件 + 内核启动代码 被 gzip 压缩之后获得的
在前几个章节已经看到了启动内核的代码
head_64.S 的主要目的就是做好进入长模式的准备之后进入长模式,进入以后再解压内核
接下来将会看到直到内核解压缩之前的所有步骤
在 arch/x86/boot/compressed 目录下有两个文件:
这里只和 x86_64 有关,所以只会关注 head_64.S, head_32.S 没有被用到
先看一下 arch/x86/boot/compressed/Makefile 。可以看到以下目标:
vmlinux-objs-y := $(obj)/vmlinux.lds $(obj)/head_$(BITS).o $(obj)/misc.o \ $(obj)/string.o $(obj)/cmdline.o \ $(obj)/piggy.o $(obj)/cpuflags.o
注意 \((obj)/head_\)(BITS).o 。这意味着将会选择基于 $(BITS) 所设置的文件执行链接操作,即 head_32.o 或者 head_64.o 。$(BITS) 在 arch/x86/Makefile 之中根据 .config 文件另外定义:
ifeq ($(CONFIG_X86_32),y) BITS := 32 ... ... else BITS := 64 ... ... endif
现在知道从哪里开始了,那就来吧
重新加载内存段寄存器
首先看到了在 startup_32 之前的特殊段属性定义:
__HEAD .code32 ENTRY(startup_32)
这个 __HEAD 是一个定义在头文件 include/linux/init.h 中的宏,展开后就是下面这个段的定义:
#define __HEAD .section ".head.text","ax"
其拥有 .head.text 的命名和 ax 标记。这些标记说明这个段是 可执行的,或者换种说法,包含了代码。可以在 arch/x86/boot/compressed/vmlinux.lds.S 这个链接脚本里找到这个段的定义:
SECTIONS { . = 0; .head.text : { _head = . ; HEAD_TEXT _ehead = . ; }
简单来说,这个 . 符号是一个 链接器 的特殊变量 位置计数器 。其被赋值为 相对于该段 的 偏移
在这里,将位置计数器赋值为0,这意味着代码被链接到内存的 0 偏移处 如果不熟悉 GNU LD 这个链接脚本语言的语法,可以在 https://sourceware.org/binutils/docs/ld/Scripts.html#Scripts 中找到更多信息
此外,可以从注释里找到更多信息:
Be careful parts of head_64.S assume startup_32 is at address 0. 要小心, head_64.S 中一些部分假设 startup_32 位于地址 0
在 startup_32 函数的开始,可以看到 cld 指令将 标志寄存器的 DF 方向标志位 清空。当方向标志被清空,所有的串操作指令像 stos , scas 等等将会 增加 索引寄存器 esi 或者 edi 的值
需要清空方向标志是因为接下来会使用汇编的串操作指令来做为页表腾出空间等工作
在清空 DF 标志后,下一步就是从内核加载头中的 loadflags 字段来检查 KEEP_SEGMENTS 标志
在本书的最初一节,其实已经看到过 loadflags 在那里检查了 CAN_USE_HEAP 标记以使用堆
现在需要检查 KEEP_SEGMENTS 标记。这些标记在 linux 的 引导协议 文档中有描述:
Bit 6 (write): KEEP_SEGMENTS Protocol: 2.07+ - If 0, reload the segment registers in the 32bit entry point. - If 1, do not reload the segment registers in the 32bit entry point. Assume that %cs %ds %ss %es are all set to flat segments with a base of 0 (or the equivalent for their environment). 第 6 位 (写): KEEP_SEGMENTS 协议版本: 2.07+ - 为0,在32位入口点重载段寄存器 - 为1,不在32位入口点重载段寄存器。假设 %cs %ds %ss %es 都被设到基地址为0的普通段中(或者在他们的环境中等价的位置)
所以,如果 KEEP_SEGMENTS 位在 loadflags 中没有被设置,需要重置 ds , ss 和 es 段寄存器到一个基地址为 0 的普通段中。如下:
testb $(1 << 6), BP_loadflags(%esi) jnz 1f cli movl $(__BOOT_DS), %eax movl %eax, %ds movl %eax, %es movl %eax, %ss
记住 __BOOT_DS 是 0x18 (位于 全局描述符表 中 数据段 的索引)。如果设置了 KEEP_SEGMENTS ,就跳转到最近的 1f 标签,或者当没有 1f 标签,则用 __BOOT_DS 更新段寄存器
这非常简单,但这是一个有趣的操作 如果已经读了前一章节,或许还记得在 arch/x86/boot/pmjump.S 中切换到保护模式的时候已经更新了这些段寄存器 那么为什么还要去关心这些段寄存器的值呢? 答案很简单,Linux 内核也有32位的引导协议,如果一个引导程序之前使用32位协议引导内核,那么在 startup_32 之前的代码就会被忽略 在这种情况下 startup_32 将会变成引导程序之后的第一个入口点,不保证段寄存器会不会处于未知状态
下一步就是计算代码的加载和编译运行之间的位置偏差了
记住 setup.ld.S 包含了以下定义:在 .head.text 段的开始 . = 0 这意味着这一段代码被编译成从 0 地址运行
可以在 objdump 工具的输出中看到:
arch/x86/boot/compressed/vmlinux: file format elf64-x86-64
Disassembly of section .head.text:
0000000000000000 <startup_32>:
0: fc cld
1: f6 86 11 02 00 00 40 testb $0x40,0x211(%rsi)
objdump 工具显示 startup_32 的地址是 0
但实际上并不是。现在需要知道实际上在哪里 在长模式下,这非常简单,因为其支持 rip 相对寻址
但是当前处于保护模式下。将会使用一个常用的方法来确定 startup_32 的地址。需要定义一个标签并且跳转到它,然后把栈顶抛出到一个寄存器中:
call label label: pop %reg
在这之后,那个寄存器将会包含标签的地址,Linux 内核中类似的寻找 startup_32 地址的代码:
leal (BP_scratch+4)(%esi), %esp // 把 scratch 的地址加 4 存入 esp 寄存器 call 1f // 跳转到1f 1: popl %ebp // 把1f标签的地址放入ebf subl $1b, %ebp
esi 寄存器包含了 boot_params 结构的地址,这个结构在切换到保护模式之前已经被填充了。bootparams 这个结构体包含了一个特殊的字段 scratch ,其偏移量为 0x1e4 。这个 4 字节的区域将会成为 call 指令 的 临时栈
之所以在 BP_scratch 基础上加 4 是因为,如之前所说的,这将成为一个临时的栈 而在 x86_64 架构下,栈是自顶向下生长的。所以栈指针就会指向栈顶
接下来就可以看到上面描述的过程。跳转到 1f 标签并且把该标签的地址放入 ebp 寄存器
因为在执行 call 指令之后我们把返回地址放到了栈顶 那么,既然已经拥有 1f 标签的地址,也能够很容易得到 startup_32 的地址
只需要把 从栈里得到的地址 减去 标签的地址 :
startup_32 (0x0) +-----------------------+ | | | | | | | | | | | | | | | | 1f (0x0 + 1f offset) +-----------------------+ %ebp - 实际物理地址 | | | | +-----------------------+
startup_32 被链接为在 0x0 地址运行,这意味着 1f 的地址 为 0x0 + 1f 的偏移量 。实际上偏移量大概是 0x22 字节。 ebp 寄存器 包含了 1f 标签的实际物理地址 。所以如果从 ebp 中减去 1f ,就会得到 startup_32 的实际物理地址。Linux 内核的引导协议描述了保护模式下的内核基地址是 0x100000
可以用 gdb 来验证。启动调试器并且在 1f 的地址 0x100022 添加断点。如果这是正确的,将会看到在 ebp 寄存器中值为 0x100022 :
$ gdb (gdb)$ target remote :1234 Remote debugging using :1234 0x0000fff0 in ?? () (gdb)$ br *0x100022 Breakpoint 1 at 0x100022 (gdb)$ c Continuing. Breakpoint 1, 0x00100022 in ?? () (gdb)$ i r eax 0x18 0x18 ecx 0x0 0x0 edx 0x0 0x0 ebx 0x0 0x0 esp 0x144a8 0x144a8 ebp 0x100021 0x100021 esi 0x142c0 0x142c0 edi 0x0 0x0 eip 0x100022 0x100022 eflags 0x46 [ PF ZF ] cs 0x10 0x10 ss 0x18 0x18 ds 0x18 0x18 es 0x18 0x18 fs 0x18 0x18 gs 0x18 0x18
如果执行下一条指令 subl $1b, %ebp ,将会看到:
nexti ... ebp 0x100000 0x100000 ...
好了,那是对的。startup_32 的地址是 0x100000
知道了 startup_32 的地址之后,可以开始准备切换到 长模式了
下一个目标是建立栈并且确认 CPU 对长模式和 SSE 的支持
栈的建立和 CPU 的确认
如果不知道 startup_32 标签的地址,就无法建立栈
可以把栈看作是一个数组,并且栈指针寄存器 esp 必须指向 数组的底部 。当然可以在自己的代码里定义一个数组,但是需要知道其真实地址来正确配置栈指针。看一下代码:
movl $boot_stack_end, %eax // eax 寄存器将包含 boot_stack_end 链接后的地址 (0x0 + boot_stack_end) addl %ebp, %eax // ebp 寄存器里是 startup_32 的实际物理地址 movl %eax, %esp // esp 指向 boot_stack_end的实际物理地址
boots_stack_end 标签被定义在同一个汇编文件 head_64.S 中,位于 .bss 段:
.bss .balign 4 boot_heap: .fill BOOT_HEAP_SIZE, 1, 0 boot_stack: .fill BOOT_STACK_SIZE, 1, 0 boot_stack_end:
- 首先,把 boot_stack_end 放到 eax 寄存器 中。那么 eax 寄存器将包含 boot_stack_end 链接后的地址或者说 0x0 + boot_stack_end
为了得到 boot_stack_end 的实际地址,需要加上 startup_32 的实际地址
回忆一下,前面找到了这个地址并且把它存到了 ebp 寄存器中
- 最后,eax 寄存器将会包含 boot_stack_end 的实际地址,只需要将其放到栈指针上
到这里已经建立了栈
下一步是 CPU 的确认。既然将要切换到 长模式 ,需要检查 CPU 是否支持 长模式 和 SSE 。跳转到 verify_cpu 函数之后执行:
call verify_cpu testl %eax, %eax jnz no_longmode
这个函数定义在 arch/x86/kernel/verify_cpu.S 中,包含了几个对 cpuid 指令的调用。该指令用于 获取 处理器的信息
这里,它检查了对 长模式 和 SSE 的支持
通过 eax 寄存器返回 0 表示成功,1 表示 失败
如果 eax 的值不是 0 ,就跳转到 no_longmode 标签
no_longmode: 1: hlt jmp 1b
- 用 hlt 指令停止 CPU ,期间不会发生硬件中断
- 如果 eax 的值为0,万事大吉,可以继续
计算重定位地址
下一步是在必要的时候计算解压缩之后的地址
首先,需要知道 内核重定位 的意义
我们已经知道 Linux 内核的32位入口点地址位于 0x100000,但是那是一个32位的入口 默认的内核基地址由内核配置项 CONFIG_PHYSICAL_START 的值所确定,其默认值为 0x1000000 或 16 MB
主要问题是如果内核崩溃了,内核开发者需要一个配置于不同地址加载的 救援内核 来进行 kdump 。Linux 内核提供了特殊的配置选项以解决此问题 CONFIG_RELOCATABLE 。可以在内核文档中找到:
This builds a kernel image that retains relocation information so it can be loaded someplace besides the default 1MB. Note: If CONFIG_RELOCATABLE=y, then the kernel runs from the address it has been loaded at and the compile time physical address (CONFIG_PHYSICAL_START) is used as the minimum location. 这建立了一个保留了重定向信息的内核镜像,这样就可以在默认的 1MB 位置之外加载了。 注意:如果 CONFIG_RELOCATABLE=y, 那么 内核将会从其被加载的位置运行,编译时的物理地址 (CONFIG_PHYSICAL_START) 将会被作为最低地址位置的限制
简单来说,这意味着相同配置下的 Linux 内核可以从不同地址被启动。这是通过将程序以 位置无关代码 的形式编译来达到的。如果参考 arch/x86/Makefile 将会看到解压器的确是用 -fPIC 标记编译的:
KBUILD_CFLAGS += -fno-strict-aliasing -fPIC
当使用位置无关代码时,一段代码的地址是由一个 控制地址 加上 程序计数器 计算得到的
可以从任意一个地址加载使用这种方式寻址的代码。这就是为什么我们需要获得 startup_32 的实际地址 现在回到 Linux 内核代码。目前的目标是计算出内核解压的地址
这个地址的计算取决于内核配置项 CONFIG_RELOCATABLE :
#ifdef CONFIG_RELOCATABLE movl %ebp, %ebx // ebp 寄存器的值就是 startup_32 标签的物理地址 movl BP_kernel_alignment(%esi), %eax decl %eax addl %eax, %ebx notl %eax andl %eax, %ebx // 对齐到 2M 的整数倍 cmpl $LOAD_PHYSICAL_ADDR, %ebx // 和 LOAD_PHYSICAL_ADDR 的值 jge 1f #endif // 加上偏移来获得解压内核镜像的地址 movl $LOAD_PHYSICAL_ADDR, %ebx 1: addl $z_extract_offset, %ebx // 直接加上 z_extract_offset
如果在内核配置中 CONFIG_RELOCATABLE 内核配置项开启:
- 就把 ebp 寄存器放到 ebx 寄存器中
- 对齐到 2M 的整数倍
- 和 LOAD_PHYSICAL_ADDR 的值比较
LOAD_PHYSICAL_ADDR 宏定义在头文件 arch/x86/include/asm/boot.h 中:
#define LOAD_PHYSICAL_ADDR ((CONFIG_PHYSICAL_START \ + (CONFIG_PHYSICAL_ALIGN - 1)) \ & ~(CONFIG_PHYSICAL_ALIGN - 1))
该宏只是展开成对齐的 CONFIG_PHYSICAL_ALIGN 值,其表示了内核加载位置的物理地址
- 给 startup_32 加上 偏移 来获得 解压内核镜像的地址 :
- 如果 CONFIG_RELOCATABLE 选项在内核配置时没有开启,就直接将默认的地址加上 z_extract_offset
在前面的操作之后,ebp 包含了加载时的地址,ebx 被设为内核解压缩的目标地址
更新全局描述符表和启用PAE
在得到了重定位内核镜像的基地址之后,开始做切换到64位模式之前的最后准备
首先,需要更新全局描述符表:
leal gdt(%ebp), %eax // 把 ebp 寄存器加上 gdt 的偏移存到 eax 寄存器 movl %eax, gdt+2(%ebp) // 把这个地址放到 ebp 加上 gdt+2 偏移的位置上 lgdt gdt(%ebp) // 用 lgdt 指令载入 全局描述符表
为了理解这个神奇的 gdt + 2偏移量,需要关注 全局描述符表 的定义。可以在同一个源文件中找到其定义:
.data gdt: .word gdt_end - gdt // 全局描述符表的大小 (16位) .long gdt // 全局描述符表的基址 (32位) .word 0 .quad 0x0000000000000000 /* NULL descriptor */ .quad 0x00af9a000000ffff /* __KERNEL_CS */ .quad 0x00cf92000000ffff /* __KERNEL_DS */ .quad 0x0080890000000000 /* TS descriptor */ .quad 0x0000000000000000 /* TS continued */ gdt_end:
全局描述符表位于 .data 段,并且包含了5个描述符: null 、 内核代码段 、 内核数据段 和其他两个 任务描述符
已经在上一章节载入了 全局描述符表 ,现在要做的也差不多 需要把描述符改为 CS.L = 1, CS.D = 0 从而在 64 位模式下执行
而gdt 的定义:
- 两个字节:gdt_end - gdt ,代表了 gdt 表的最后一个字节,或者说表的范围
- 4个字节包含了 gdt 的基地址
全局描述符表 保存在 48位 GDTR寄存器 中,由两个部分组成:
- 全局描述符表的大小 (16位)
- 全局描述符表的基址 (32位)
所以,当把 gdt 的地址放到 eax 寄存器,然后存到 .long gdt 和 gdt+2。最后用 lgdt 指令 载入 到GDPR寄存器 中。
这样全局描述符表就更新完毕了
接下来必须启用 PAE 模式。方法是将 cr4 寄存器 的值传入 eax ,将 第5位 置 1 ,然后再 写回 cr4
movl %cr4, %eax // 载入 cr4 寄存器 orl $X86_CR4_PAE, %eax // 第5位置为1 movl %eax, %cr4 // 写回 cr4 寄存器
现在已经接近完成进入64位模式前的所有准备工作了 最后一步是建立页表,但是在此之前,先介绍一些关于长模式的知识
长模式
长模式是 x86_64 系列处理器的原生模式。首先看一看 x86_64 和 x86 的一些区别,64位 模式提供了一些新特性,比如:
- 从 r8 到 r15 8个新的通用寄存器,并且所有通用寄存器都是 64位 的
- 64位指令指针: RIP
- 新的操作模式:长模式
- 64位地址和操作数
- RIP 相对寻址
长模式是一个传统保护模式的扩展,其由两个子模式构成:
- 64位模式
- 兼容模式
为了切换到 64位 模式,需要完成以下操作:
- 启用 PAE
- 建立 页表 并且将顶级页表的地址放入 cr3 寄存器
- 启用 EFER.LME
- 启用 分页
页表初始化
现在,现在来看看如何建立初始的 4G 启动页表
注意:不会在这里解释虚拟内存的理论
Linux 内核使用 4级 页表,通常会建立 6个页表 :
- 1 个 PML4 或称为 4级页映射 表,包含 1 个项
- 1 个 PDP 或称为 页目录指针 表,包含 4 个项
- 4 个 页目录表 ,一共包含 2048 个项
首先在内存中为页表清理一块缓存。每个表都是 4096 字节 ,所以我们需要 24 KB 的空间
leal pgtable(%ebx), %edi // 把和 ebx 相关的 pgtable 的地址放到 edi 寄存器中 xorl %eax, %eax // 清空 eax 寄存器 movl $((4096*6)/4), %ecx // 将 ecx 赋值为 6144 // rep stosl 指令将会把 eax 的值写到 edi 指向的地址,然后给 edi 加 4 , ecx 减 4 // 重复直到 ecx 小于等于 0 rep stosl
pgtable 定义在 head_64.S 的最后:
.section ".pgtable","a",@nobits .balign 4096 pgtable: .fill 6*4096, 1, 0
可以看到,其位于 .pgtable 段,大小为 24KB
为 pgtable 分配了空间之后,可以开始构建顶级页表 PML4 :
leal pgtable + 0(%ebx), %edi // 把和 ebx 相关的,或者说和 startup_32 相关的 pgtable 的地址放到 edi 寄存器 leal 0x1007 (%edi), %eax // 相对此地址偏移 0x1007 的地址放到 eax 寄存器中 movl %eax, 0(%edi) // 把第一个 PDP(页目录指针) 项的地址写到 PML4 中
0x1007 是 PML4 的大小 4096 加上 7 7 代表了 PML4 的项标记,这些标记是 PRESENT + RW + USER
接下来的一步,会在 页目录指针 PDP 表(3级页表)建立 4 个带有 PRESENT+RW+USE 标记的 Page Directory (2级页表) 项:
leal pgtable + 0x1000(%ebx), %edi // 把 3 级页目录指针表的基地址(从 pgtable 表偏移 4096 或者 0x1000 )放到 edi leal 0x1007(%edi), %eax // 把第一个 2 级页目录指针表的首项的地址放到 eax 寄存器 movl $4, %ecx // 把 4 赋值给 ecx 寄存器,会作为接下来循环的计数器 1: movl %eax, 0x00(%edi) // 将第一个页目录指针项写到 edi 指向的地址。因此 edi 将会包含带有标记 0x7 的第一个页目录指针项的地址 addl $0x00001000, %eax // 计算下一个页目录指针项的地址,把地址赋值给 eax addl $8, %edi // 写入下一个页目录项的地址,因为每一项占用8字节,所以必须写入到 edi + 8 处 decl %ecx // 重复循环 jnz 1b
最后一步就是建立 2048 个 2MB 页的页表项:
leal pgtable + 0x2000(%ebx), %edi movl $0x00000183, %eax movl $2048, %ecx 1: movl %eax, 0(%edi) addl $0x00200000, %eax addl $8, %edi decl %ecx jnz 1b
所有的表项都带着标记 $0x00000183 PRESENT + WRITE + MBZ 。最后将会拥有 2048 个 2MB 大的页,或者说:
>>> 2048 * 0x00200000 4294967296 这是一个 4G 页表,其映射了 4G 大小的内存
现在可以把高级页表 PML4 的地址放到 cr3 寄存器 中了:
leal pgtable(%ebx), %eax movl %eax, %cr3
所有的准备工作都已经完成,可以开始看如何切换到长模式了
切换到长模式
首先需要设置 MSR 中的 EFER.LME 标记为 0xC0000080 :
movl $MSR_EFER, %ecx // 把 MSR_EFER 标记对应的寄存器放入 ecx 寄存器 rdmsr // 读取 MSR 寄存器,结果读取到 edx:eax (高 32 位在 EDX,低 32 位在 EAX),其取决于 ecx 的值 btsl $_EFER_LME, %eax // 检查 EFER_LME 位(位测试并置位) wrmsr // 将 eax 的数据写入 MSR 寄存器
- 把 MSR_EFER 标记(在 arch/x86/include/uapi/asm/msr-index.h 中定义)放到 ecx 寄存器中
- rdmsr: 读取 MSR 寄存器
- btsl 指令检查 EFER_LME 位
- 通过 wrmsr 指令将 eax 的数据写入 MSR 寄存器
下一步将内核段代码地址入栈(在 GDT 中定义了),然后将 startup_64 的地址导入 eax
pushl $__KERNEL_CS // 内核段代码地址入栈 leal startup_64(%ebp), %eax // 将 startup_64 的地址导入 eax
把这个地址入栈然后通过设置 cr0 寄存器 中的 PG 和 PE 启用 分页 :
movl $(X86_CR0_PG | X86_CR0_PE), %eax movl %eax, %cr0
然后执行:
lret
前一步已经将 startup_64 函数的地址入栈 在 lret 指令之后,CPU 取出了其地址跳转到那里
这样终于来到了64位模式:
.code64 .org 0x200 ENTRY(startup_64) .... .... ....
下一节将会看到内核解压缩流程
Next:解压内核 | Previous: 进入保护模式 | Home:启动引导 |