在上一章Linux内核引导过程中,我们已经解压缩Linux内核镜像,并加载到内存中,为执行内核代码做好了准备。在本章,我们将继续探究内核的初始化过程,即在启动pid 1进程前内核的初始化过程。
在上一章的最后一部分,我们跟踪到了arch/x86/boot/compressed/head_64.S中的jmp指令:
/*
* Jump to the decompressed kernel.
*/
jmp *%rax此时,%rax保存的是Linux内核入口点。
解压缩后的内核镜像的入口点定义在arch/x86/kernel/head_64.S。
.text
__HEAD
.code64
.globl startup_64
startup_64:
UNWIND_HINT_EMPTY.text在arch/x86/kernel/vmlinux.lds.S链接脚本文件中定义:
/* Text and read-only data */
.text : AT(ADDR(.text) - LOAD_OFFSET) {
_text = .;
_stext = .;
/* bootstrapping code */
...
/* End of text section */
_etext = .;
} :text = 0x9090在这个链接脚本中,_text = .指示当前位置的地址,在x86_64下定义为. = __START_KERNEL;。
__START_KERNEL在rch/x86/include/asm/page_types.h文件中定义,由内核映射的虚拟基址和物理地址起始点相加得到:
#define __PHYSICAL_START ALIGN(CONFIG_PHYSICAL_START, CONFIG_PHYSICAL_ALIGN)
#define __START_KERNEL (__START_KERNEL_map + __PHYSICAL_START)即:
- Linux内核的物理基址(
__PHYSICAL_START) -0x1000000; - Linux内核的虚拟基址(
__START_KERNEL) -0xffffffff81000000;
进入内核初始化后,需要建立函数调用栈,检验CPU信息。verify_cpu在arch/x86/kernel/verify_cpu.S中定义,验证CPU是否运行在长模式,是否SSE。
__startup_64函数在arch/x86/kernel/head64.c中定义,其功能为执行页表修复,根据SME状态将pgd地址修改%cr3寄存器。
unsigned long __head __startup_64(unsigned long physaddr,
struct boot_params *bp)
{
...
...
}进入__startup_64函数后,首先调用check_la57_support检查并修正5级页表。
check_la57_support根据CONFIG_X86_5LEVEL配置与否有不同的实现方式。在没有定义(即,没有配置5级页表)的情况下,直接返回false;在定义(即,开启5级页表)的情况下,代码如下:
static bool __head check_la57_support(unsigned long physaddr)
{
/*
* 5-level paging is detected and enabled at kernel decomression
* stage. Only check if it has been enabled there.
*/
if (!(native_read_cr4() & X86_CR4_LA57))
return false;
*fixup_int(&__pgtable_l5_enabled, physaddr) = 1;
*fixup_int(&pgdir_shift, physaddr) = 48;
*fixup_int(&ptrs_per_p4d, physaddr) = 512;
*fixup_long(&page_offset_base, physaddr) = __PAGE_OFFSET_BASE_L5;
*fixup_long(&vmalloc_base, physaddr) = __VMALLOC_BASE_L5;
*fixup_long(&vmemmap_base, physaddr) = __VMEMMAP_BASE_L5;
return true;
}其中fixup_int、fixup_long调用fixup_pointer获取对应的物理地址。fixup_pointer函数实现如下:
static void __head *fixup_pointer(void *ptr, unsigned long physaddr)
{
return ptr - (void *)_text + (void *)physaddr;
}在默认情况下(即,只有4级页表情况下且定义CONFIG_X86_5LEVEL),这些初始值为:
#ifdef CONFIG_X86_5LEVEL
unsigned int __pgtable_l5_enabled __ro_after_init;
unsigned int pgdir_shift __ro_after_init = 39;
EXPORT_SYMBOL(pgdir_shift);
unsigned int ptrs_per_p4d __ro_after_init = 1;
EXPORT_SYMBOL(ptrs_per_p4d);
#endif
#ifdef CONFIG_DYNAMIC_MEMORY_LAYOUT
unsigned long page_offset_base __ro_after_init = __PAGE_OFFSET_BASE_L4;
EXPORT_SYMBOL(page_offset_base);
unsigned long vmalloc_base __ro_after_init = __VMALLOC_BASE_L4;
EXPORT_SYMBOL(vmalloc_base);
unsigned long vmemmap_base __ro_after_init = __VMEMMAP_BASE_L4;
EXPORT_SYMBOL(vmemmap_base);
#endif在这里,我们需要验证加载的物理地址(physaddr)是否有效。验证的过程如下:
- 检验加载地址是否过大,如果超过
MAX_PHYSMEM_BITS,则是无效地址。
/* Is the address too large? */
if (physaddr >> MAX_PHYSMEM_BITS)
for (;;);MAX_PHYSMEM_BITS定义如下:
# define MAX_PHYSMEM_BITS (pgtable_l5_enabled() ? 52 : 46)- 计算实际运行地址与编译地址间偏差
在上面我们可以看到,Linux内核默认运行的物理地址为0x1000000。由于可能开启KASLR,实际加载的地址有变化,需要计算偏差。计算过程如下:
load_delta = physaddr - (unsigned long)(_text - __START_KERNEL_map);- 检查偏差是否对齐
在计算偏差后,检查偏差是否按照2MB对齐。代码如下:
/* Is the address not 2M aligned? */
if (load_delta & ~PMD_PAGE_MASK)
for (;;);PMD_PAGE_MASK代表中层页目录(Page middle directory)掩码位,定义如下:
#define PMD_PAGE_SIZE (_AC(1, UL) << PMD_SHIFT)
#define PMD_PAGE_MASK (~(PMD_PAGE_SIZE-1))
#define PMD_SHIFT 21- 计算SME偏差
在SME开启的情况下,计算其偏差。如下:
sme_enable(bp);
load_delta += sme_get_me_mask();接下来,我们修正页表中的物理地址。在上一章内核初始化过程中,我们初始化了4G的页表。现在我们需要修正页表中物理地址,修正的页表包括:
pgd = fixup_pointer(&early_top_pgt, physaddr);
p4d = fixup_pointer(&level4_kernel_pgt, physaddr);
pud = fixup_pointer(&level3_kernel_pgt, physaddr);
pmd = fixup_pointer(level2_fixmap_pgt, physaddr);现在,我们看下early_top_pgt, level4_kernel_pgt, level3_kernel_pgt, level2_fixmap_pgt的定义。
NEXT_PGD_PAGE(early_top_pgt)
.fill 512,8,0
.fill PTI_USER_PGD_FILL,8,0
#ifdef CONFIG_X86_5LEVEL
NEXT_PAGE(level4_kernel_pgt)
.fill 511,8,0
.quad level3_kernel_pgt - __START_KERNEL_map + _PAGE_TABLE_NOENC
#endif
NEXT_PAGE(level3_kernel_pgt)
.fill L3_START_KERNEL,8,0
/* (2^48-(2*1024*1024*1024)-((2^39)*511))/(2^30) = 510 */
.quad level2_kernel_pgt - __START_KERNEL_map + _KERNPG_TABLE_NOENC
.quad level2_fixmap_pgt - __START_KERNEL_map + _PAGE_TABLE_NOENC
NEXT_PAGE(level2_kernel_pgt)
PMDS(0, __PAGE_KERNEL_LARGE_EXEC,
KERNEL_IMAGE_SIZE/PMD_SIZE)
NEXT_PAGE(level2_fixmap_pgt)
.fill (512 - 4 - FIXMAP_PMD_NUM),8,0
pgtno = 0
.rept (FIXMAP_PMD_NUM)
.quad level1_fixmap_pgt + (pgtno << PAGE_SHIFT) - __START_KERNEL_map \
+ _PAGE_TABLE_NOENC;
pgtno = pgtno + 1
.endr
/* 6 MB reserved space + a 2MB hole */
.fill 4,8,0
NEXT_PAGE(level1_fixmap_pgt)
.rept (FIXMAP_PMD_NUM)
.fill 512,8,0
.endrearly_top_pgt
首先,我们看到early_top_pgt,它开始的4096字节填充为0(在开启CONFIG_PAGE_TABLE_ISOLATION的情况下,为8192字节),即我们不使用前512(或前1024)项页表;
level4_kernel_pgt
在启用CONFIG_X86_5LEVEL的情况下,level4_kernel_pgt的前511项为0,即我们不使用这些页表。之后一项值为evel3_kernel_pgt - __START_KERNEL_map + _PAGE_TABLE_NOENC。
__START_KERNEL_map是内核的虚拟基地址,因此减去__START_KERNEL_map后就得到了level3_kernel_pgt的物理地址。_PAGE_TABLE_NOENC是页表项的访问权限,定义如下:
#define _KERNPG_TABLE_NOENC (_PAGE_PRESENT | _PAGE_RW | _PAGE_ACCESSED | \
_PAGE_DIRTY)
#define _PAGE_TABLE_NOENC (_PAGE_PRESENT | _PAGE_RW | _PAGE_USER | \
_PAGE_ACCESSED | _PAGE_DIRTY)level3_kernel_pgt
level3_kernel_pgt前510项为0,L3_START_KERNEL值为0。接下来2项为level2_kernel_pgt和level2_fixmap_pgt的物理地址。
level2_kernel_pgt
level2_kernel_pgt页表项包括映射内核的PMD的物理地址。它调用PDMS宏创建KERNEL_IMAGE_SIZE/PMD_SIZE个页表项,KERNEL_IMAGE_SIZE值为512MB,即创建256个页表项。KERNEL_IMAGE_SIZE在arch/x86/include/asm/page_64_types.h定义。
level2_fixmap_pgt
level2_fixmap_pgt前506项为0;接下来2项为level1_fixmap_pgt的页表项;3项(6MB)的预留项和1项(2MB)的内存洞。
FIXMAP_PMD_NUM在arch/x86/include/asm/fixmap.h定义,定义如下:
#define FIXMAP_PMD_NUM 2
/* fixmap starts downwards from the 507th entry in level2_fixmap_pgt */
#define FIXMAP_PMD_TOP 507level1_fixmap_pgt
level1_fixmap_pgt包括两个页,每个页中512项都置为0。
在了解到上面的定义后,接下来,初始化pgd, p4d, pud, pmd项等,代码如下:
p = pgd + pgd_index(__START_KERNEL_map);
if (la57)
*p = (unsigned long)level4_kernel_pgt;
else
*p = (unsigned long)level3_kernel_pgt;
*p += _PAGE_TABLE_NOENC - __START_KERNEL_map + load_delta;
if (la57) {
p4d = fixup_pointer(&level4_kernel_pgt, physaddr);
p4d[511] += load_delta;
}
pud = fixup_pointer(&level3_kernel_pgt, physaddr);
pud[510] += load_delta;
pud[511] += load_delta;
pmd = fixup_pointer(level2_fixmap_pgt, physaddr);
for (i = FIXMAP_PMD_TOP; i > FIXMAP_PMD_TOP - FIXMAP_PMD_NUM; i--)
pmd[i] += load_delta;在这之后我们得到了:
`la57`启用的情况下:
early_top_pgt[511] -> level4_kernel_pgt[0]
level4_kernel_pgt[511] -> level3_kernel_pgt[0]
`la57`没有启用的情况下:
early_top_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[1]
level2_fixmap_pgt[506] -> level1_fixmap_pgt[0]注意,我们并没有修正early_top_pgt和其他页目录的基地址,我们会在构造、填充这些页目录时修正。在修正了页表基址后,我们可以开始构造这些页目录了。
现在我们进行对标识(identity)区域进行内存映射,这个区域虚拟地址以相同的方式映射到物理地址上。首先,将early_dynamic_pgts的第一个和第二个页表项设置为pud和pmd,代码如下:
next_pgt_ptr = fixup_pointer(&next_early_pgt, physaddr);
pud = fixup_pointer(early_dynamic_pgts[(*next_pgt_ptr)++], physaddr);
pmd = fixup_pointer(early_dynamic_pgts[(*next_pgt_ptr)++], physaddr);early_dynamic_pgts的定义如下,它保存了早期的64个临时页表。
NEXT_PAGE(early_dynamic_pgts)
.fill 512*EARLY_DYNAMIC_PAGE_TABLES,8,0接下来,初始化pgtable_flags,将每个页表标记设置为_KERNPG_TABLE_NOENC + sme_get_me_mask()。
pgd
在5级页表启用的情况下,在获取p4d后进行后初始化;否则,直接初始化pgd。如下:
if (la57) {
p4d = fixup_pointer(early_dynamic_pgts[(*next_pgt_ptr)++],
physaddr);
i = (physaddr >> PGDIR_SHIFT) % PTRS_PER_PGD;
pgd[i + 0] = (pgdval_t)p4d + pgtable_flags;
pgd[i + 1] = (pgdval_t)p4d + pgtable_flags;
i = physaddr >> P4D_SHIFT;
p4d[(i + 0) % PTRS_PER_P4D] = (pgdval_t)pud + pgtable_flags;
p4d[(i + 1) % PTRS_PER_P4D] = (pgdval_t)pud + pgtable_flags;
} else {
i = (physaddr >> PGDIR_SHIFT) % PTRS_PER_PGD;
pgd[i + 0] = (pgdval_t)pud + pgtable_flags;
pgd[i + 1] = (pgdval_t)pud + pgtable_flags;
}pud
pud初始化过程和pgd类似。如下:
i = physaddr >> PUD_SHIFT;
pud[(i + 0) % PTRS_PER_PUD] = (pudval_t)pmd + pgtable_flags;
pud[(i + 1) % PTRS_PER_PUD] = (pudval_t)pmd + pgtable_flags;pmd
pmd初始化_end - _text大小的页表,其页表项(pmd_entry)不支持__PAGE_KERNEL_*位。如下:
pmd_entry = __PAGE_KERNEL_LARGE_EXEC & ~_PAGE_GLOBAL;
/* Filter out unsupported __PAGE_KERNEL_* bits: */
mask_ptr = fixup_pointer(&__supported_pte_mask, physaddr);
pmd_entry &= *mask_ptr;
pmd_entry += sme_get_me_mask();
pmd_entry += physaddr;
for (i = 0; i < DIV_ROUND_UP(_end - _text, PMD_SIZE); i++) {
int idx = i + (physaddr >> PMD_SHIFT);
pmd[idx % PTRS_PER_PMD] = pmd_entry + i * PMD_SIZE;
}- 修正
代码段和数据段虚拟地址
接下来,修正内核程序中的_text到_end区域映射的内存,将_text之前和_end后的区域标记为不存在(~_PAGE_PRESENT)。
/* fixup pages that are part of the kernel image */
for (; i <= pmd_index((unsigned long)_end); i++)
if (pmd[i] & _PAGE_PRESENT)
pmd[i] += load_delta;- 修正实际的物理地址
*fixup_long(&phys_base, physaddr) += load_delta - sme_get_me_mask();phys_base为level2_kernel_pgt中的第一个项。
- 修正
.bss..decrypted区域
接下来,调用sme_encrypt_kernel加密内核。在SME启用时加密内核,并.bss..decrypted区域清除加密。
if (mem_encrypt_active()) {
vaddr = (unsigned long)__start_bss_decrypted;
vaddr_end = (unsigned long)__end_bss_decrypted;
for (; vaddr < vaddr_end; vaddr += PMD_SIZE) {
i = pmd_index(vaddr);
pmd[i] -= sme_get_me_mask();
}
}__startup_64函数的最后一步是返回初始化pgdir页表项,修改到%cr3中。返回值为:return sme_get_me_mask();。
现在,我们回到arch/x86/kernel/head_64.S,接下来计算跳转地址:
addq $(early_top_pgt - __START_KERNEL_map), %rax将early_top_pgt的物理地址加到%rax上,(%rax保存了SME加密掩码).
接下来,进行CPU设置,设置CPU工作在相应状态下。其中状态定义可参考Control register。Linux内核关于寄存器的状态在arch/x86/include/uapi/asm/processor-flags.h和arch/x86/include/asm/msr-index.h文件中定义。
- 分页设置(
%cr4) - 开启CPUPAE,PGE标记,尝试开启LA57标记; - 页表地址设置(
%cr3) - 设置4级或5级页表到cr3寄存器,将phys_base地址加载到%cr3。 - 确保执行执行的是虚拟地址 - 将
$1f地址放到rax寄存器中,并跳转到改地址。验证是否运行的是虚拟地址; - 是否支持
nx- 通过cpuid指令执行0x80000001指令,验证CPU是否支持nx; - 设置
EFER(Extended Feature Enable Register)- 设置CPU支持系统调用(System Call),并尝试设置nx; - 设置
%cr0- 设置%cr0为CR0_STATE状态。即:
#define CR0_STATE (X86_CR0_PE | X86_CR0_MP | X86_CR0_ET | \
X86_CR0_NE | X86_CR0_WP | X86_CR0_AM | \
X86_CR0_PG)设置rsp函数栈为initial_stack。initial_stack在同一个文件中定义:
GLOBAL(initial_stack)
.quad init_thread_union + THREAD_SIZE - SIZEOF_PTREGSTHREAD_SIZE在arch/x86/include/asm/page_64_types.h中定义,如下:
#ifdef CONFIG_KASAN
#define KASAN_STACK_ORDER 1
#else
#define KASAN_STACK_ORDER 0
#endif
#define THREAD_SIZE_ORDER (2 + KASAN_STACK_ORDER)
#define THREAD_SIZE (PAGE_SIZE << THREAD_SIZE_ORDER)THREAD_SIZE的大小根据KASAN配置与否不同,没有配置的情况下4个页大小,开启的情况下8个页大小,表示线程栈的大小。
为什么是线程(thread)?我们知道一个进程(Process)可能有父进程(Parent Process)和子进程(Child process)。父进程和子进程使用不同的栈空间,每个新进程都会拥有一个新的内核栈。在Linux内核中,这个栈由thread_union结构表示,thread_union在include/linux/sched.h定义。如下:
union thread_union {
#ifndef CONFIG_ARCH_TASK_STRUCT_ON_STACK
struct task_struct task;
#endif
#ifndef CONFIG_THREAD_INFO_IN_TASK
struct thread_info thread_info;
#endif
unsigned long stack[THREAD_SIZE/sizeof(long)];
};CONFIG_ARCH_TASK_STRUCT_ON_STACK内核配置选项只能适用于ia64架构;CONFIG_THREAD_INFO_IN_TASK配置选项在x86_64架构下是开启的。因此,thread_union使用的是struct task_struct。
init_thread_union在include/asm-generic/vmlinux.lds.h中定义,
#define INIT_TASK_DATA(align) \
. = ALIGN(align); \
__start_init_task = .; \
init_thread_union = .; \
init_stack = .; \
KEEP(*(.data..init_task)) \
KEEP(*(.data..init_thread_info)) \
. = __start_init_task + THREAD_SIZE; \
__end_init_task = .;这个宏在arch/x86/kernel/vmlinux.lds.S文件中使用,如下:
.data : AT(ADDR(.data) - LOAD_OFFSET) {
...
INIT_TASK_DATA(THREAD_SIZE)
...
} :data因此,initial_stack指向thread_union.stack + THREAD_SIZE(16KB) - SIZEOF_PTREGS(8B,函数栈尾检测约定).
将EFLAGS寄存器置零。
更新lgdt为early_gdt_descr,early_gdt_descr在同一个文件定义,如下:
.data
.align 16
.globl early_gdt_descr
early_gdt_descr:
.word GDT_ENTRIES*8-1
early_gdt_descr_base:
.quad INIT_PER_CPU_VAR(gdt_page)GDT_ENTRIES值为32。gdt_page在arch/x86/include/asm/desc.h中定义,如下:
struct gdt_page {
struct desc_struct gdt[GDT_ENTRIES];
} __attribute__((aligned(PAGE_SIZE)));desc_struct的定义如下:
/* 8 byte segment descriptor */
struct desc_struct {
u16 limit0;
u16 base0;
u16 base1: 8, type: 4, s: 1, dpl: 2, p: 1;
u16 limit1: 4, avl: 1, l: 1, d: 1, g: 1, base2: 8;
} __attribute__((packed));desc_struct结构和GDT描述符类似,gdt_page结构以PAGE_SIZE大小对齐,即gdt占用一个页大小。
INIT_PER_CPU_VAR在arch/x86/include/asm/percpu.h定义,如下:
#define INIT_PER_CPU_VAR(var) init_per_cpu__##varINIT_PER_CPU_VAR宏定义连接init_per_cpu__和给定的参数。INIT_PER_CPU_VAR(gdt_page)展开为init_per_cpu__gdt_page,在arch/x86/kernel/vmlinux.lds.S可以看到其定义。
#define INIT_PER_CPU(x) init_per_cpu__##x = ABSOLUTE(x) + __per_cpu_load
INIT_PER_CPU(gdt_page);
INIT_PER_CPU(fixed_percpu_data);
INIT_PER_CPU(irq_stack_backing_store);我们创建PER_CPU变量时,每个CPU都拥有一份自己的拷贝,这种类型的变量有很多优点,每个CPU都只访问自己的变量而不需要锁。
在将%ds, %ss, %es, %fs, %gs寄存器重置后,需要重新设置%gs寄存器,将其指向一个用于处理中断(Interrupt)的栈。
movl $MSR_GS_BASE,%ecx
movl initial_gs(%rip),%eax
movl initial_gs+4(%rip),%edx
wrmsrMSR_GS_BASE在arch/x86/include/asm/msr-index.h定义,如下:
#define MSR_GS_BASE 0xc0000101 /* 64bit GS base */initial_gs在同一个文件中定义,如下:
GLOBAL(initial_gs)
.quad INIT_PER_CPU_VAR(fixed_percpu_data)fixed_percpu_data在arch/x86/include/asm/processor.h中定义,如下:
struct fixed_percpu_data {
/*
* GCC hardcodes the stack canary as %gs:40. Since the
* irq_stack is the object at %gs:0, we reserve the bottom
* 48 bytes of the irq stack for the canary.
*/
char gs_base[40];
unsigned long stack_canary;
};我们把MSR_GS_BASE放入ecx寄存器,同时利用wrmsr指令向eax和edx处的地址加载数据(即指向initial_gs)。cs, fs, ds和ss段寄存器在64位模式下不用来寻址,但fs和gs可以使用,fs和gs有一个隐含的部分(与实模式下的cs段寄存器类似),这个隐含部分存储了一个描述符,其指向Model Specific Registers。因此上面的0xc0000101是一个gs.base MSR地址。当发生系统调用(System call)或者中断(Interrupt)时,入口点处并没有内核栈,因此MSR_GS_BASE将会用来存放中断栈。
经过上面的初始化后,我们终于可以进入C函数了。但是,我们现在还运行在标记映射空间上,必须跳转到完整的64位模式下,只能进行间接跳转。代码如下:
/* rsi is pointer to real mode structure with interesting info.
pass it to C */
movq %rsi, %rdi
.Ljump_to_C_code:
pushq $.Lafter_lret # put return address on stack for unwinder
xorl %ebp, %ebp # clear frame pointer
movq initial_code(%rip), %rax
pushq $__KERNEL_CS # set correct cs
pushq %rax # target address in negative space
lretq
.Lafter_lret:%rsi保存的是bootparam的地址(从实模式开始一直保存着),将其放入%rdi(函数调用的第一个参数)。在向函数栈中压入返回地址,__KERNEL_CS,initial_code地址后,通过lretq指令弹出返回值(%rax)并跳转。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)
{
...
}本文描述了Linux内核初始化的第一部分,修正了内存页表并将CPU中的寄存器设置在正确状态。
本系列文章翻译自linux-insides,如果你有任何问题或者建议,请联系0xAX或者创建 issue。