☰
内核初始化第一步:从 startup_64 汇编入口到 x86_64 早期页表构建(linux-insides-zh 系列解读)
2026/10/4 15:01:01 网站建设 项目流程
  • 文档
  • 教程
  • 操作系统

【免费下载链接】linux-insides-zh

Linux 内核揭秘

项目地址:https://gitcode.com/gh_mirrors/li/linux-insides-zh
点击查看免费下载

本篇是 linux-insides-zh 仓库中《Initialization(内核初始化)》章节的第一部分解读。它承接 Booting(引导过程) 的最后一步——解压后的内核镜像入口跳转,深入剖析 x86_64 内核在进入start_kernel之前执行的初期初始化代码:startup_64入口的地址修正、早期四级页表的构建与 Identity 映射、CR4/CR3/EFER/CR0 等控制寄存器的设置、初期内核栈与 GDT 的装载,直至跳转到第一个 C 函数x86_64_start_kernel。读完本篇,你将完整掌握 Linux 内核从引导程序交棒到 C 代码之间的全部汇编级准备工作,并能读懂arch/x86/kernel/head_64.S与arch/x86/kernel/head64.c的关键实现脉络。

一、背景:引导程序的最后一行代码

上一章 是引导过程的最后一个部分。在解压缩完 Linux 内核镜像、并将其妥善放入内存之后,内核才开始正式工作。引导程序(bootloader 及内核自身的解压代码)的任务就是为执行内核代码做准备,而本篇要探究的,正是内核代码本身在启动 PID 为1的init进程之前所做的大量工作。

在引导过程的最后一节中,代码跟踪到了arch/x86/boot/compressed/head_64.S文件中的跳转指令:

jmp *%rax

此时rax寄存器中保存的就是 Linux 内核的入口点——该地址是在调用decompress_kernel(定义于arch/x86/boot/compressed/misc.c)解压内核镜像后计算得到的。由此可见,内核引导程序的最后一行代码,是一句指向内核入口点的间接跳转。既然入口点地址已经明确,我们就可以顺着这条跳转,继续探究引导结束后内核做了些什么。

解压后的内核镜像入口点定义在arch/x86/kernel/head_64.S,该文件的开头几行如下:

__HEAD .code64 .globl startup_64 startup_64: ... ... ...

startup_64过程定义在__HEAD区段下。__HEAD只是一个宏,它会展开为可执行的.head.text区段:

#define __HEAD .section ".head.text","ax"

该区段出现在arch/x86/kernel/vmlinux.lds.S链接器脚本中:

.text : AT(ADDR(.text) - LOAD_OFFSET) { _text = .; ... ... ... } :text = 0x9090

除了.text区段的定义,我们还能从这个脚本文件中得知内核的默认物理地址与虚拟地址。_text是地址计数器,对于 x86_64 来说它定义为:

. = __START_KERNEL;

__START_KERNEL宏定义在arch/x86/include/asm/page_types.h头文件中,由内核映射的虚拟基址与物理起始点相加得到:

#define _START_KERNEL (__START_KERNEL_map + __PHYSICAL_START) #define __PHYSICAL_START ALIGN(CONFIG_PHYSICAL_START, CONFIG_PHYSICAL_ALIGN)

换句话说,默认情况下:

  • Linux 内核的物理基址:0x1000000(16 MB);
  • Linux 内核的虚拟基址:0xffffffff81000000。

这两个数值就是startup_64过程编译链接时的默认物理地址与虚拟地址。

二、内核入口的第一步:地址计算与合法性检查

虽然startup_64编译后默认位于物理地址0x1000000,但实际加载地址仍可能发生变化——例如启用 kASLR(内核地址空间布局随机化) 时。因此内核入口的第一件事,就是计算"实际加载地址与编译默认地址之间的差值",代码如下:

leaq _text(%rip), %rbp subq $_text - __START_KERNEL_map, %rbp

第一行将 RIP 相对地址(rip-relative)_text放入rbp寄存器;第二行从中减去$_text - __START_KERNEL_map。已知:

  • _text编译后的默认虚拟地址为0xffffffff81000000,物理地址为0x1000000;
  • __START_KERNEL_map宏展开为0xffffffff80000000。

于是第二行汇编代码对应的表达式为:

rbp = 0x1000000 - (0xffffffff81000000 - 0xffffffff80000000)

计算过后rbp的值为0,它代表实际加载地址与编译默认地址之间的差值。在我们的例子中,0表示内核被加载到了默认地址,并且没有启用 kASLR。此后所有需要"按实际加载位置修正"的符号,都依赖这个rbp差值。

得到startup_64的地址后,需要检查该地址是否已正确对齐。下面的代码执行这一检查:

testl $~PMD_PAGE_MASK, %ebp jnz bad_address

这里将rbp寄存器的低 32 位与PMD_PAGE_MASK比较。PMD_PAGE_MASK代表中层页目录(Page Middle Directory,PMD)的屏蔽位,其定义如下(关于分页的详细理论,可阅读分页一章):

#define PMD_PAGE_MASK (~(PMD_PAGE_SIZE-1)) #define PMD_PAGE_SIZE (_AC(1, UL) << PMD_SHIFT) #define PMD_SHIFT 21

可以很容易得出PMD_PAGE_SIZE为2 MB。这里采用标准公式检查对齐:如果_text的地址没有对齐到 2 MB,就跳转到bad_address。

在此之后,通过检查高 18 位来防止地址过大:

leaq _text(%rip), %rax shrq $MAX_PHYSMEM_BITS, %rax jnz bad_address

该地址必须不超过 46 个比特,即小于 2 的 46 次方:

#define MAX_PHYSMEM_BITS 46

完成这些初步检查后,内核才能继续进行后续工作。

三、修正页表基地址

在开始设置 Identity 分页之前,需要先修正以下地址——如果startup_64的实际加载值不是默认的0x1000000,则包括early_level4_pgt、level3_kernel_pgt在内的很多地址都会不正确:

addq %rbp, early_level4_pgt + (L4_START_KERNEL*8)(%rip) addq %rbp, level3_kernel_pgt + (510*8)(%rip) addq %rbp, level3_kernel_pgt + (511*8)(%rip) addq %rbp, level2_fixmap_pgt + (506*8)(%rip)

rbp寄存器中保存的正是前面计算出的相对差值,因此把它与early_level4_pgt、level3_kernel_pgt以及level2_fixmap_pgt中特定的表项相加,即可得到正确的地址。先来看这些页表的定义:

NEXT_PAGE(early_level4_pgt) .fill 511,8,0 .quad level3_kernel_pgt - __START_KERNEL_map + _PAGE_TABLE NEXT_PAGE(level3_kernel_pgt) .fill L3_START_KERNEL,8,0 .quad level2_kernel_pgt - __START_KERNEL_map + _KERNPG_TABLE .quad level2_fixmap_pgt - __START_KERNEL_map + _PAGE_TABLE NEXT_PAGE(level2_kernel_pgt) PMDS(0, __PAGE_KERNEL_LARGE_EXEC, KERNEL_IMAGE_SIZE/PMD_SIZE) NEXT_PAGE(level2_fixmap_pgt) .fill 506,8,0 .quad level1_fixmap_pgt - __START_KERNEL_map + _PAGE_TABLE .fill 5,8,0 NEXT_PAGE(level1_fixmap_pgt) .fill 512,8,0

这些代码看起来难懂,实则不然。先看early_level4_pgt(早期顶层全局页目录):它的前(4096 - 8)个字节全为0,即前 511 项均不使用;最后一项是level3_kernel_pgt - __START_KERNEL_map + _PAGE_TABLE。由于__START_KERNEL_map是内核的虚拟基地址,减去它之后就得到了level3_kernel_pgt的物理地址。而_PAGE_TABLE是页表项的访问权限位:

#define _PAGE_TABLE (_PAGE_PRESENT | _PAGE_RW | _PAGE_USER | \ _PAGE_ACCESSED | _PAGE_DIRTY)

level3_kernel_pgt(上层页目录)中保存的两项用来映射内核空间,其前510(即L3_START_KERNEL)项均为0。L3_START_KERNEL保存的是上层页目录(Page Upper Directory)中包含__START_KERNEL_map地址的那一条索引,它等于510。后面一项level2_kernel_pgt - __START_KERNEL_map + _KERNPG_TABLE中的level2_kernel_pgt是一条页表项,包含指向中层页目录的指针,用来映射内核空间,访问权限为:

#define _KERNPG_TABLE (_PAGE_PRESENT | _PAGE_RW | _PAGE_ACCESSED | \ _PAGE_DIRTY)

level2_fixmap_pgt(fixmap 映射目录)是一组虚拟地址,它们可以在内核空间中指向任意的物理地址。它以level2_fixmap_pgt为入口点,用 10 MB 大小的空间为 vsyscalls 做映射。level2_kernel_pgt则调用PMDS宏,在__START_KERNEL_map地址处为内核的.text创建了 512 MB 大小的空间(这 512 MB 空间的后面是模块内存空间)。

结合 Theory/linux-theory-1.md 中给出的 x86_64 虚拟内存映射,可以印证这套布局:ffffffff80000000 - ffffffffa0000000正是 512 MB 的 kernel text mapping(从物理 0 开始),其上是约 1525 MB 的模块映射空间,再往上是 vsyscalls 区域。

现在回到本节开头的四行修正代码。rbp保存的是实际地址与链接时startup_64地址之差,因此只要把它与各个页表项的基地址相加,就能得到正确地址。换句话说,页表之间的指向关系如下:

early_level4_pgt[511] -> level3_kernel_pgt[0] level3_kernel_pgt[510] -> level2_kernel_pgt[0] level3_kernel_pgt[511] -> level2_fixmap_pgt[0] level2_kernel_pgt[0] -> 512 MB kernel mapping level2_fixmap_pgt[507] -> level1_fixmap_pgt

即:early_level4_pgt的最后一项指向level3_kernel_pgt;level3_kernel_pgt的最后两项分别指向level2_kernel_pgt和level2_fixmap_pgt;level2_fixmap_pgt的第 507 项指向level1_fixmap_pgt页目录。

需要注意:这里修正的只是页表项中保存的下一级页表基地址,并不是在构造、填充页目录结构本身——真正的填充工作在接下来的 Identity 映射阶段完成。

四、构建 Identity Map 分页

现在进入初期页表的 Identity 映射(恒等映射)初始化过程。在 Identity 映射中,虚拟地址会被映射到与自身数值相同的物理地址上,即 1 : 1 映射,这是内核在开启分页但尚未建立完整内核映射时的过渡手段。

首先,找到_text与early_level4_pgt的 RIP 相对地址,分别放入rdi与rbx寄存器:

leaq _text(%rip), %rdi leaq early_level4_pgt(%rip), %rbx

随后用rax保存_text的地址。全局页目录表中有一条记录存放的正是_text的地址,为了得到这条索引,把_text的地址右移PGDIR_SHIFT位:

movq %rdi, %rax shrq $PGDIR_SHIFT, %rax leaq (4096 + _KERNPG_TABLE)(%rbx), %rdx movq %rdx, 0(%rbx,%rax,8) movq %rdx, 8(%rbx,%rax,8)

PGDIR_SHIFT为39,表示虚拟地址中全局页目录位(PGD 索引)所占的位移。下面的宏定义了各级页目录的索引位移:

#define PGDIR_SHIFT 39 #define PUD_SHIFT 30 #define PMD_SHIFT 21

此后将level3_kernel_pgt的地址放进rdx,访问权限设为_KERNPG_TABLE(见上文),然后把level3_kernel_pgt填入early_level4_pgt的两项中(0(%rbx,%rax,8)与8(%rbx,%rax,8),跨越相邻两个全局页目录项)。

接下来给rdx寄存器加上4096(即early_level4_pgt一页的大小),并把rdi中的_text物理地址赋给rax,然后向上层页目录中写入两个表项:

addq $4096, %rdx movq %rdi, %rax shrq $PUD_SHIFT, %rax andl $(PTRS_PER_PUD-1), %eax movq %rdx, 4096(%rbx,%rax,8) incl %eax andl $(PTRS_PER_PUD-1), %eax movq %rdx, 4096(%rbx,%rax,8)

这里通过shrq $PUD_SHIFT提取_text在上层页目录中的索引,andl $(PTRS_PER_PUD-1)保证索引不越界(PTRS_PER_PUD为 512),并把下一级页目录地址(level2_kernel_pgt,位于early_level4_pgt之后偏移 4096 字节处)写入level3_kernel_pgt对应的两个相邻表项。

下一步,把中层页目录表项的地址写入level2_kernel_pgt,然后修正内核 text 和 data 的虚拟地址:

leaq level2_kernel_pgt(%rip), %rdi leaq 4096(%rdi), %r8 1: testq $1, 0(%rdi) jz 2f addq %rbp, 0(%rdi) 2: addq $8, %rdi cmp %r8, %rdi jne 1b

这里先把level2_kernel_pgt的地址赋给rdi,把"末项地址"赋给r8(即rdi + 4096)。随后循环检查level2_kernel_pgt中每一项的存在位(testq $1, 0(%rdi)):如果该项有效(低位为 1),就把rbp差值加到该项上完成地址修正;否则跳过。每轮迭代将rdi加 8 指向下一项,直到与r8相等(遍历完 512 项)才结束循环。

接下来使用rbp修正phys_base物理地址,将early_level4_pgt的物理地址装入rax,并跳转至标签1:

addq %rbp, phys_base(%rip) movq $(early_level4_pgt - __START_KERNEL_map), %rax jmp 1f

其中phys_base与level2_kernel_pgt第一项相同,表示 512 MB 的内核映射。

五、跳转至内核入口点之前的最后准备

5.1 开启 PAE/PGE 并装载 CR3

跳转到标签1后,开启PAE(Physical Address Extension)与PGE(Paging Global Extension),并把phys_base的物理地址(见上)放入rax,同时写入cr3寄存器:

1: movl $(X86_CR4_PAE | X86_CR4_PGE), %ecx movq %rcx, %cr4 addq phys_base(%rip), %rax movq %rax, %cr3

cr3在 x86_64 分页机制中存放的是顶层分页结构(Page Global Directory)的物理地址,其地址字段位于第 12~51 位,这与 Theory/linux-theory-1.md 中描述的四级分页结构完全对应:CPU 借助cr3找到顶层页目录,再按线性地址各段位移逐级索引,最终获得物理页框与页内偏移。

5.2 检查 NX 位并配置 EFER

接下来检查 CPU 是否支持 NX(No-eXecute,不可执行)位:

movl $0x80000001, %eax cpuid movl %edx,%edi

先将0x80000001放入eax,执行cpuid指令获取处理器扩展信息,结果存入edx,再转移到edi。

随后把MSR_EFER(即0xc0000080)放入ecx,执行rdmsr读取 CPU 的 Model Specific Register(MSR):

movl $MSR_EFER, %ecx rdmsr

返回值存放于edx:eax。EFER各个位的含义如下(SCE在第 0 位,LME在第 8 位,LMA在第 10 位,NXE在第 11 位,SVME在第 12 位,FFXSR在第 14 位,TCE在第 15 位,其余位保留):

31 16 15 14 13 12 11 10 9 8 7 1 0 -------------------------------------------------------------------------------- | | T | | | | | | | | | | | Reserved MBZ | C | FFXSR | LMSLE |SVME|NXE|LMA|MBZ|LME|RAZ|SCE| | | E | | | | | | | | | | --------------------------------------------------------------------------------

本篇不逐一解释每个位的含义,未涉及到的位与其他 MSR 将在专门章节介绍。把EFER读入edx:eax后,通过btsl将_EFER_SCE(第 0 位)置 1——设置SCE位将启用SYSCALL与SYSRET指令。下一步检查edi(cpuid 结果)中的第 20 位:如果该位(即 NX 位)置位,就同时把EFER_NX写入 MSR:

btsl $_EFER_SCE, %eax btl $20,%edi jnc 1f btsl $_EFER_NX, %eax btsq $_PAGE_BIT_NX,early_pmd_flags(%rip) 1: wrmsr

注意,当 CPU 支持 NX 时,除了设置EFER.NXE,还会把_PAGE_BIT_NX写入early_pmd_flags,以便后续构造页表时直接使用 NX 位标记不可执行页。

5.3 设置 CR0 控制寄存器

在设置完 NX 后,还要对cr0(控制寄存器)中的一些位进行设置:

  • X86_CR0_PE- 系统处于保护模式;
  • X86_CR0_MP- 与 CR0 的 TS 标志位一同控制 WAIT/FWAIT 指令的功能;
  • X86_CR0_ET- 386 允许指定外部数学协处理器为 80287 或 80387;
  • X86_CR0_NE- 如果置位,则启用内置的 x87 浮点错误报告,否则启用 PC 风格的 x87 错误检测;
  • X86_CR0_WP- 如果置位,则 CPU 在特权等级为 0 时无法写入只读内存页;
  • X86_CR0_AM- 当 AM 位置位、EFLAGS 中的 AC 位置位、特权等级为 3 时,进行对齐检查;
  • X86_CR0_PG- 启用分页。
#define CR0_STATE (X86_CR0_PE | X86_CR0_MP | X86_CR0_ET | \ X86_CR0_NE | X86_CR0_WP | X86_CR0_AM | \ X86_CR0_PG) movl $CR0_STATE, %eax movq %rax, %cr0

至此,分页(PG)与保护模式(PE)正式启用,内核进入了以页表转换为基础的现代寻址模式。

5.4 建立初期内核栈

为了从汇编代码顺利转入 C 语言代码,内核需要一个可用的栈。首先将栈指针指向内存中合适的区域,然后重置 FLAGS 寄存器:

movq stack_start(%rip), %rsp pushq $0 popfq

这里最有意思的地方在于stack_start,它也定义在当前的源文件arch/x86/kernel/head_64.S中:

GLOBAL(stack_start) .quad init_thread_union+THREAD_SIZE-8

GLOBAL宏我们应当很熟悉,它定义在arch/x86/include/asm/linkage.h头文件中:

#define GLOBAL(name) \ .globl name; \ name:

THREAD_SIZE定义在arch/x86/include/asm/page_64_types.h,它依赖于KASAN_STACK_ORDER的值:

#define THREAD_SIZE_ORDER (2 + KASAN_STACK_ORDER) #define THREAD_SIZE (PAGE_SIZE << THREAD_SIZE_ORDER)

先考虑禁用了 KASAN 且PAGE_SIZE为 4096 的情况,此时THREAD_SIZE为16 KB,代表一个线程内核栈的大小。为什么是"线程"?每一个进程都可能拥有父进程和子进程,事实上父进程和子进程使用不同的栈空间,每一个新进程都会拥有一个新的内核栈。在 Linux 内核中,这个栈由thread_info结构中的一个 union 表示:

union thread_union { struct thread_info thread_info; unsigned long stack[THREAD_SIZE/sizeof(long)]; };

例如,init_thread_union定义如下:

union thread_union init_thread_union __init_task_data = { INIT_THREAD_INFO(init_task) };

其中INIT_THREAD_INFO接受task_struct结构类型的参数,并进行一些初始化操作:

#define INIT_THREAD_INFO(tsk) \ { \ .task = &tsk, \ .flags = 0, \ .cpu = 0, \ .addr_limit = KERNEL_DS, \ }

task_struct结构在内核中代表对进程的描述。因此,thread_union包含了关于一个进程的低级信息,并且位于其内核栈底部,整体布局如下:

+-----------------------+ | | | | | | | Kernel stack | | | | | | | |-----------------------| | | | struct thread_info | | | +-----------------------+

注意:栈顶保留了 8 个字节的空间,用于保护对下一个内存页的非法访问(这正是stack_start中init_thread_union + THREAD_SIZE - 8减 8 的原因)。

5.5 加载新的 GDT

初期启动栈设置好之后,使用lgdt指令更新全局描述符表(GDT):

lgdt early_gdt_descr(%rip)

其中early_gdt_descr定义如下:

early_gdt_descr: .word GDT_ENTRIES*8-1 early_gdt_descr_base: .quad INIT_PER_CPU_VAR(gdt_page)

需要重新加载 GDT 的原因是:虽然目前内核仍工作于用户空间的低地址中,但很快它将在自己的内存地址空间中运行。early_gdt_descr中的全局描述符表包含 32 项,用于内核代码、数据、线程局部存储段等:

#define GDT_ENTRIES 32

再来看early_gdt_descr_base。首先,gdt_page的定义在arch/x86/include/asm/desc.h中:

struct gdt_page { struct desc_struct gdt[GDT_ENTRIES]; } __attribute__((aligned(PAGE_SIZE)));

它只包含一项desc_struct的数组gdt。desc_struct定义如下:

struct desc_struct { union { struct { unsigned int a; unsigned int b; }; struct { u16 limit0; u16 base0; unsigned base1: 8, type: 4, s: 1, dpl: 2, p: 1; unsigned limit: 4, avl: 1, l: 1, d: 1, g: 1, base2: 8; }; }; } __attribute__((packed));

它跟 GDT 描述符的位布局很像。同时注意,gdt_page结构是PAGE_SIZE(4096)对齐的,即gdt将占用一页内存。

再看INIT_PER_CPU_VAR,它定义在arch/x86/include/asm/percpu.h,作用只是把给定参数与init_per_cpu__前缀连接起来:

#define INIT_PER_CPU_VAR(var) init_per_cpu__##var

宏展开后得到init_per_cpu__gdt_page。而在链接器脚本arch/x86/kernel/vmlinux.lds.S中可以发现:

#define INIT_PER_CPU(x) init_per_cpu__##x = x + __per_cpu_load INIT_PER_CPU(gdt_page);

INIT_PER_CPU展开后同样得到init_per_cpu__gdt_page,并将其值设置为相对于__per_cpu_load的偏移量。这样,我们就得到了新 GDT 的正确基地址。

per-CPU 变量是 2.6 内核引入的特性:当我们创建一个 per-CPU 变量时,每个 CPU 都会拥有一份它自己的拷贝,此处创建的就是gdt_pageper-CPU 变量。这种变量的优点是每个 CPU 都只访问自己的拷贝,无需加锁。因此在多处理器环境下,每一个处理器核心都拥有一份自己的 GDT 表,其中的每一项都代表一块内存,这块内存可以由在该核心上运行的线程访问。关于 per-CPU 变量的更详细介绍,可阅读 Concepts/linux-cpu-1.md(Per-cpu 变量)。

5.6 重新加载段寄存器与 GS 基址

加载好新的全局描述符表之后,与之前一样重新加载各个段寄存器:

xorl %eax,%eax movl %eax,%ds movl %eax,%ss movl %eax,%es movl %eax,%fs movl %eax,%gs

在所有这些步骤结束后,还需要设置gs寄存器,令它指向一个特殊的栈irqstack,用于处理中断:

movl $MSR_GS_BASE,%ecx movl initial_gs(%rip),%eax movl initial_gs+4(%rip),%edx wrmsr

其中MSR_GS_BASE为:

#define MSR_GS_BASE 0xc0000101

需要把MSR_GS_BASE放入ecx寄存器,并利用wrmsr指令把eax、edx中的数据(即initial_gs指向的地址)写入 MSR。在 64 位模式下,cs、fs、ds、ss段寄存器不再用于寻址,但fs和gs仍可使用——它们有一个隐含的部分(与实模式下的cs段寄存器类似),这个隐含部分存储了一个描述符,指向 Model Specific Register(MSR)。因此0xc0000101正是gs.base的 MSR 地址。当发生系统调用或中断时,入口点处并没有内核栈,所以MSR_GS_BASE会被用来存放中断栈。

5.7 跳转到第一个 C 函数

接下来,把实模式 bootparam 结构的地址放入rdi(注意rsi从一开始就保存了该结构体的指针,rdi在进入 C 前被置为实模式数据地址),然后跳转到 C 语言代码:

movq initial_code(%rip),%rax pushq $0 pushq $__KERNEL_CS pushq %rax lretq

这里把initial_code放入rax,并依次向栈中压入一个无用的地址、__KERNEL_CS和initial_code的地址。随后的lretq指令从栈上弹出返回地址并跳转过去。initial_code同样定义在这个文件里:

.balign 8 GLOBAL(initial_code) .quad x86_64_start_kernel ... ... ...

可以看到initial_code保存的是x86_64_start_kernel的地址,其定义在arch/x86/kernel/head64.c:

asmlinkage __visible void __init x86_64_start_kernel(char * real_mode_data) { ... ... ... }

这个函数接受一个参数real_mode_data(即前面保存到rdi寄存器中的实模式数据地址)。它是内核中第一个执行的 C 语言代码!

六、走进 x86_64_start_kernel:start_kernel 前的最后准备

在真正到达"内核入口点"——init/main.c中的start_kernel函数之前,还有最后的准备工作。

首先,x86_64_start_kernel函数中有一系列编译期检查:

BUILD_BUG_ON(MODULES_VADDR < __START_KERNEL_map); BUILD_BUG_ON(MODULES_VADDR - __START_KERNEL_map < KERNEL_IMAGE_SIZE); BUILD_BUG_ON(MODULES_LEN + KERNEL_IMAGE_SIZE > 2*PUD_SIZE); BUILD_BUG_ON((__START_KERNEL_map & ~PMD_MASK) != 0); BUILD_BUG_ON((MODULES_VADDR & ~PMD_MASK) != 0); BUILD_BUG_ON(!(MODULES_VADDR > __START_KERNEL)); BUILD_BUG_ON(!(((MODULES_END - 1) & PGDIR_MASK) == (__START_KERNEL & PGDIR_MASK))); BUILD_BUG_ON(__fix_to_virt(__end_of_fixed_addresses) <= MODULES_END);

这些检查包括:模块的虚拟地址不能低于内核 text 段基地址__START_KERNEL_map;包含模块的内核 text 段空间大小不能小于内核镜像大小;内核映射与模块区必须与 PMD 对齐;模块区必须位于内核映射之后且与内核映射落在同一个 PGD 条目内等等。BUILD_BUG_ON宏定义如下:

#define BUILD_BUG_ON(condition) ((void)sizeof(char[1 - 2*!!(condition)]))

这个设计的原理很巧妙。以第一个条件MODULES_VADDR < __START_KERNEL_map为例:!!condition等价于condition != 0——若条件为真则为 1,否则为 0;乘以 2 后变成2或0。于是1 - 2*!!(condition)为-1(条件成立)或1(条件不成立)。当条件成立时,char[-1]数组长度非法,sizeof表达式在编译期直接报错;条件不成立时则是一个合法的长度为 1 的char数组,编译通过。通过这种"让非法常量触发编译错误"的技巧,内核把布局假设固化成了编译期契约。

接下来,start_kernel之前还会调用cr4_init_shadow函数,为每个 CPU 保存cr4的 Shadow Copy。上下文切换可能会修改cr4中的位,因此需要保存每个 CPU 中cr4的内容。在这之后调用reset_early_page_tables函数,它重置所有的全局页目录项,并向cr3重新写入全局页目录表的地址:

for (i = 0; i < PTRS_PER_PGD-1; i++) early_level4_pgt[i].pgd = 0; next_early_pgt = 0; write_cr3(__pa_nodebug(early_level4_pgt));

很快我们就会设置新的页表。这里遍历了所有的全局页目录项(PTRS_PER_PGD为 512),将其清零,然后把next_early_pgt置为 0,并把early_level4_pgt的物理地址写入cr3。__pa_nodebug是一个宏,展开为:

((unsigned long)(x) - __START_KERNEL_map + phys_base)

即通过虚拟地址减去内核映射基址再加上实际物理基址偏移,得到物理地址。next_early_pgt的用途会在后续篇章展开——在 Initialization/linux-initialization-2.md 中可以看到,它被用作early_dynamic_pgts动态页表缓冲区的下标(EARLY_DYNAMIC_PAGE_TABLES为 64),当固定页表不够用时,早期缺页处理程序会据此动态分配新的页表。

此后,内核清空从__bss_stop到__bss_start的_bss段,下一步将是建立初期 IDT(中断描述符表)的处理代码——内容很多,留待下一部分探究。

七、总结与延伸阅读

第一部分关于 Linux 内核初始化过程的旅程到这里就结束了。回顾一下这条清晰的主线:

  1. 引导程序通过jmp *%rax把控制权交给解压后的内核入口startup_64(arch/x86/kernel/head_64.S);
  2. startup_64计算实际加载地址与链接地址的差值,完成 2 MB 对齐与地址上限检查;
  3. 修正early_level4_pgt、level3_kernel_pgt、level2_fixmap_pgt中保存的下一级页表基地址;
  4. 构建 Identity 映射,把_text所在的物理地址 1:1 映射进早期四级页表;
  5. 设置CR4.PAE|PGE、装载CR3、检测 NX 并配置EFER.SCE/NXE、设置CR0启用保护模式与分页;
  6. 建立初期内核栈(init_thread_union)、加载新 GDT(per-CPU 的gdt_page)、重设段寄存器与GS_BASE;
  7. 通过lretq跳转到第一个 C 函数x86_64_start_kernel,并在其中完成编译期布局校验、cr4_init_shadow与reset_early_page_tables,为start_kernel铺路。

下一部分(Initialization/linux-initialization-2.md)会看到初期中断处理程序的初始化过程、早期缺页处理函数以及内核空间内存映射等内容。本系列的完整目录与各篇定位可参见 Initialization/README.md。

相关链接

  • 分页理论(四级页表、页表项布局、x86_64 虚拟内存映射):Theory/linux-theory-1.md
  • 上一部分——内核解压:Booting/linux-bootstrap-5.md
  • per-CPU 变量详解:Concepts/linux-cpu-1.md
  • 后续部分——早期中断与异常控制:Initialization/linux-initialization-2.md
  • Model Specific Register(MSR)与 NX 位、ASLR 等概念,可结合本系列其他篇章与 Intel 手册进一步研读。
  • 文档
  • 教程
  • 操作系统

【免费下载链接】linux-insides-zh

Linux 内核揭秘

项目地址:https://gitcode.com/gh_mirrors/li/linux-insides-zh
点击查看免费下载

相关推荐

上一篇:浏览器操作 GitHub 网页版:4 个阶段跑通协作闭环
下一篇:在 Exoscale 上部署 Kubernetes Cluster Autoscaler:SKS Nodepool 与 Instance Pool 的自动扩缩容实战指南

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询