UP | HOME

解压内核

Table of Contents

在这一部分回看到跳进内核代码的最后步骤:内核解压前的准备、重定位和直接内核解压

内核解压前的准备

在之前的部分,已经在startup_32里面看到了到startup_64的跳转:

pushl   $__KERNEL_CS
leal    startup_64(%ebp), %eax
...
...
...
pushl   %eax
...
...
...
lret

由于加载了新的全局描述符表并且在其他模式有CPU的模式转换(在这里是64位模式),可以在startup_64的开头看到数据段的建立:

.code64
.org 0x200
ENTRY(startup_64)
xorl    %eax, %eax
movl    %eax, %ds
movl    %eax, %es
movl    %eax, %ss
movl    %eax, %fs
movl    %eax, %gs
除cs之外的段寄存器,在进入长模式时已经重置

计算 内核编译时的位置 和它 被加载的位置

        #ifdef CONFIG_RELOCATABLE
        leaq    startup_32(%rip), %rbp
        movl    BP_kernel_alignment(%rsi), %eax
        decl    %eax
        addq    %rax, %rbp
        notq    %rax
        andq    %rax, %rbp
        cmpq    $LOAD_PHYSICAL_ADDR, %rbp
        jge     1f
        #endif
        movq    $LOAD_PHYSICAL_ADDR, %rbp
1:
        movl    BP_init_size(%rsi), %ebx
        subl    $_end, %ebx
        addq    %rbp, %rbx

rbp 包含了解压后 内核的起始地址 ,在这段代码执行之后 rbx 会包含用于解压的重定位内核代码的地址

前面已经在startup_32看到类似的代码(计算重定位地址),但是需要再做这个计算

因为引导加载器可以用64位引导协议,而startup_32在这种情况下不会执行

接下来是 栈指针 的设置和 标志寄存器 的重置:

leaq    boot_stack_end(%rbx), %rsp

pushq   $0
popfq

rbx寄存器包含了内核解压代码的起始地址,把这个地址的 boot_stack_entry 偏移地址相加放到表示栈顶指针的 rsp寄存器 。在这一步之后,栈就是正确的。可以在汇编源码文件 head_64.S 的末尾找到 boot_stack_end 的定义:

        .bss
        .balign 4
boot_heap:
        .fill BOOT_HEAP_SIZE, 1, 0
boot_stack:
        .fill BOOT_STACK_SIZE, 1, 0
boot_stack_end:
它在.bss节的末尾,就在.pgtable前面

如果查看 arch/x86/boot/compressed/vmlinux.lds.S 链接脚本,会找到.bss和.pgtable的定义

由于设置了栈,在计算了解压了的内核的重定位地址后,就可以复制压缩了的内核到以上地址。在查看细节之前,先看这段汇编代码:

pushq   %rsi // 为这个寄存器现在存放指向boot_params的指针
leaq    (_bss-8)(%rip), %rsi //  rsi包含_bss - 8的绝对地址
leaq    (_bss-8)(%rbx), %rdi // rdi包含_bss - 8的重定位的相对地址
movq    $_bss, %rcx // 拷贝的大小放入rcx 
shrq    $3, %rcx // 
std // 设置从后向前拷贝数据
rep     movsq // 开始copy
cld // 清空DF标志位
popq    %rsi // 在代码的结尾,会重新恢复指向boot_params的指针到rsi
  1. 把rsi压进栈,因为这个寄存器现在存放指向boot_params的指针,这是包含引导相关数据的实模式结构体。在代码的结尾,会重新恢复指向boot_params的指针到rsi
  2. 两个leaq指令用 _bss - 8偏移riprbx 计算有效地址并存放到 rsi 和 _rdi_。压缩了的代码镜像存放在从startup_32到当前的代码和解压了的代码之间。可以通过查看链接脚本 arch/x86/boot/compressed/vmlinux.lds.S 验证:

    . = 0;
    .head.text : {
        _head = . ;
        HEAD_TEXT
        _ehead = . ;
    }
    .rodata..compressed : {
        *(.rodata..compressed)
    }
    .text : {
        _text = .;  /* Text */
        *(.text)
        *(.text.*)
        _etext = . ;
    }
    
    • .head.text 节包含了 startup_32 . 可以从之前的部分回忆起它:

      __HEAD
      .code32
      ENTRY(startup_32)
      ...
      ...
      ...
      
    • .text 节包含 解压代码

      .text
      relocated:
      ...
      ...
      ...
      /*
      * Do the decompression, and jump to the new kernel..
      */
      ...
      
    • .rodata..compressed 包含了 压缩了的内核镜像
  3. _bss的地址 放到了 rcx寄存器 。正如在 vmlinux.lds.S链接脚本中看到了一样,它和设置/内核代码一起在所有节的末尾
  4. 现在可以开始用 movsq 指令每次 8字节 地从 rsirdi 复制代码
  5. 注意: 在数据复制前有 std 指令:它设置DF标志,意味着rsi和rdi会递减

    换句话说,会从后往前复制这些字节
    
  6. cld 指令清除DF标志,并恢复boot_params到rsi

拷贝完成后 跳转到 .text节的重定位后的地址

leaq    relocated(%rbx), %rax
jmp     *%rax

内核解压

调用 extract_kernel

.text节relocated 标签开始。它做的第一件事是 清空 .bss节

xorl    %eax, %eax // 清空 eax 
leaq    _bss(%rip), %rdi // 把_bss的地址放到rdi
leaq    _ebss(%rip), %rcx // 把_ebss放到rcx
subq    %rdi, %rcx // 把整个 .bss 区 填 0 
shrq    $3, %rcx
rep     stosq
因为很快要跳转到C代码,所以要初始化.bss节

最后,可以调用 extract_kernel 函数:

pushq   %rsi
movq    %rsi, %rdi // rdi: 指向boot_params结构体的指针 
leaq    boot_heap(%rip), %rsi // rsi: 指向早期启动堆的起始地址 boot_heap
leaq    input_data(%rip), %rdx // rdx: 指向压缩的内核的地址
movl    $z_input_len, %ecx // ecx: 压缩的内核的大小
movq    %rbp, %r8 // r8: 解压后内核的起始地址
movq    $z_output_len, %r9 // r9: 解压后内核的大小
call    extract_kernel
popq    %rsi
  1. 设置 rdi 为指向 boot_params结构体 的指针并把它保存到栈中
  2. 设置 rsi 指向用于 内核解压的区域
  3. 准备extract_kernel的参数并调用这个解压内核的函数。extract_kernel函数在 arch/x86/boot/compressed/misc.c 源文件定义并有六个参数:
    • rmode: 指向 boot_params 结构体的指针

      boot_params 被引导加载器填充或在早期内核初始化时填充
      
    • heap: 指向早期启动堆的起始地址 boot_heap 的指针
    • input_data: 指向压缩的内核,即 arch/x86/boot/compressed/vmlinux.bin.bz2 的指针
    • input_len: 压缩的内核的大小
    • output:解压后内核的起始地址
    • output_len: 解压后内核的大小

所有参数根据 System V Application Binary Interface 通过寄存器传递

extract_kernel

extract_kernel函数 从图形/控制台初始化开始

因为不知道是不是从实模式开始,或者是使用了引导加载器,或者引导加载器用了32位还是64位启动协议

所以这里还要再做一遍某些代码

在最早的初始化步骤后,保存空闲内存的起始和末尾地址

free_mem_ptr     = heap;
free_mem_end_ptr = heap + BOOT_HEAP_SIZE;

这里 heap 从 arch/x86/boot/compressed/head_64.S 传给 extract_kernel 函数的第二个参数:

leaq    boot_heap(%rip), %rsi

而 boot_heap 定义为:

boot_heap:
        .fill BOOT_HEAP_SIZE, 1, 0
BOOT_HEAP_SIZE是一个展开为0x10000 (对bzip2内核是0x400000) 的宏,代表堆的大小 

堆指针初始化后,下一步是从 arch/x86/boot/compressed/kaslr.c 调用 choose_random_location 函数

从函数名猜到,它选择内核镜像解压到的内存地址

因为Linux内核支持kASLR,为了安全,它允许解压内核到随机的地址

在这一部分,不会考虑Linux内核的加载地址的随机化,会在下一部分讨论

回头看 misc.c. 在获得内核镜像的地址后,需要有一些检查以确保获得的随机地址是正确对齐的,并且地址没有错误:

if ((unsigned long)output & (MIN_KERNEL_ALIGN - 1))
                error("Destination physical address inappropriately aligned");

if (virt_addr & (MIN_KERNEL_ALIGN - 1))
                error("Destination virtual address inappropriately aligned");

if (heap > 0x3fffffffffffUL)
                error("Destination address too large");

if (virt_addr + max(output_len, kernel_total_size) > KERNEL_IMAGE_SIZE)
                error("Destination virtual address is beyond the kernel mapping area");

if ((unsigned long)output != LOAD_PHYSICAL_ADDR)
                error("Destination address does not match LOAD_PHYSICAL_ADDR");

if (virt_addr != LOAD_PHYSICAL_ADDR)
                error("Destination virtual address changed when not relocatable");

通过所有这些检查后,可以看到熟悉的消息:

Decompressing Linux... 

然后调用解压内核的 __decompress 函数:

__decompress(input_data, input_len, NULL, NULL, output, output_len, NULL, error);

__decompress函数的实现取决于在内核编译期间选择什么压缩算法:

#ifdef CONFIG_KERNEL_GZIP
#include "../../../../lib/decompress_inflate.c"
#endif

#ifdef CONFIG_KERNEL_BZIP2
#include "../../../../lib/decompress_bunzip2.c"
#endif

#ifdef CONFIG_KERNEL_LZMA
#include "../../../../lib/decompress_unlzma.c"
#endif

#ifdef CONFIG_KERNEL_XZ
#include "../../../../lib/decompress_unxz.c"
#endif

#ifdef CONFIG_KERNEL_LZO
#include "../../../../lib/decompress_unlzo.c"
#endif

#ifdef CONFIG_KERNEL_LZ4
#include "../../../../lib/decompress_unlz4.c"
#endif

在内核解压之后,最后两个函数是 parse_elfhandle_relocations 。这些函数的主要用途是把解压后的内核移动到正确的位置

实际上,解压过程会原地解压,还需要把内核移动到正确的地址

https://en.wikipedia.org/wiki/In-place_algorithm

内核镜像是一个ELF可执行文件,所以parse_elf的主要目标是移动可加载的段到正确的地址。从 readelf 的输出看到可加载的段:

readelf -l vmlinux

Elf file type is EXEC (Executable file)
Entry point 0x1000000
There are 5 program headers, starting at offset 64

Program Headers:
Type           Offset             VirtAddr           PhysAddr
FileSiz            MemSiz              Flags  Align
LOAD           0x0000000000200000 0xffffffff81000000 0x0000000001000000
0x0000000000893000 0x0000000000893000  R E    200000
LOAD           0x0000000000a93000 0xffffffff81893000 0x0000000001893000
0x000000000016d000 0x000000000016d000  RW     200000
LOAD           0x0000000000c00000 0x0000000000000000 0x0000000001a00000
0x00000000000152d8 0x00000000000152d8  RW     200000
LOAD           0x0000000000c16000 0xffffffff81a16000 0x0000000001a16000
0x0000000000138000 0x000000000029b000  RWE    200000

parse_elf函数的目标是加载这些段到从 choose_random_location 函数得到的 output地址 。这个函数从检查ELF签名标志开始:

Elf64_Ehdr ehdr;
Elf64_Phdr *phdrs, *phdr;

memcpy(&ehdr, output, sizeof(ehdr));

if (ehdr.e_ident[EI_MAG0] != ELFMAG0 ||
    ehdr.e_ident[EI_MAG1] != ELFMAG1 ||
    ehdr.e_ident[EI_MAG2] != ELFMAG2 ||
    ehdr.e_ident[EI_MAG3] != ELFMAG3) {
        error("Kernel is not a valid ELF file");
        return;
}
  • 如果 ELF 签名无效的,它会打印一条错误消息并停机
  • 如果得到一个有效的ELF文件,从给定的ELF文件遍历所有程序头,并用正确的地址复制所有可加载的段到输出缓冲区:

    for (i = 0; i < ehdr.e_phnum; i++) {
                    phdr = &phdrs[i];
    
                    switch (phdr->p_type) {
                    case PT_LOAD:
    #ifdef CONFIG_RELOCATABLE
                                    dest = output;
                                    dest += (phdr->p_paddr - LOAD_PHYSICAL_ADDR);
    #else
                                    dest = (void *)(phdr->p_paddr);
    #endif
                                    memmove(dest, output + phdr->p_offset, phdr->p_filesz);
                                    break;
                    default:
                                    break;
                    }
    }
    
从现在开始,所有可加载的段都在正确的位置

在parse_elf函数之后是调用 handle_relocations 函数。这个函数的实现依赖于 CONFIG_X86_NEED_RELOCS 内核配置选项。如果它被启用,这个函数调整内核镜像的地址

只有在内核配置时启用了CONFIG_RANDOMIZE_BASE配置选项才会调用

handle_relocations函数的实现足够简单。这个函数从基准内核加载地址的值减掉LOAD_PHYSICAL_ADDR的值,从而获得内核链接后要加载的地址和实际加载地址的差值。在这之后我们可以进行内核重定位,因为我们知道内核加载的实际地址、它被链接的运行的地址和内核镜像末尾的重定位表

进入内核

在内核重定位后,就从 extract_kernel 返回到 arch/x86/boot/compressed/head_64.S :

jmp     *%rax // 内核的地址在rax寄存器
到此为止,就正式进入内核代码里

内核地址随机化

Linux内核的入口点是 main.cstart_kernel 函数,它在 LOAD_PHYSICAL_ADDR 地址开始执行。这个地址依赖于 CONFIG_PHYSICAL_START 内核配置选项,默认为 0x1000000 :

config PHYSICAL_START
	hex "Physical address where the kernel is loaded" if (EXPERT || CRASH_DUMP)
	default "0x1000000"
	---help---
	  This gives the physical address where the kernel is loaded.
      ...
      ...
      ...

这个选项在内核配置时可以修改,使得加载地址可以选择为一个随机值。很多时候 CONFIG_RANDOMIZE_BASE内核配置选项在内核配置时应该启用

在这种情况下,Linux内核镜像解压和加载的物理地址会被随机化

接下来考虑 为了安全原因,内核镜像的加载地址被随机化的情况

https://github.com/torvalds/linux/blob/master/Documentation/x86/boot.txt** 页表的初始化 在内核解压器要开始找随机的内核解压和加载地址之前,应该 初始化恒等映射identity mapped , 虚拟地址和物理地址相同)页表。如果 引导加载器 使用 16位或32位引导协议 ,那么已经有了页表

任何情况下,如果内核解压器选择它们之外的内存区域,需要新的页。这就是为什么需要建立新的恒等映射页表

在此之前,让我们回忆一下是怎么来到这里的

前面已经看到了到长模式的转换,并跳转到了内核解压器的入口点 extract_kernel 函数。随机化从调用这个函数开始:

void choose_random_location(unsigned long input,
                            unsigned long input_size,
                                        unsigned long *output,
                            unsigned long output_size,
                                        unsigned long *virt_addr)

这个函数有五个参数:

  • input
  • input_size
  • output
  • output_isze
  • virt_addr

第一个input参数来自源文件 arch/x86/boot/compressed/misc.c 里的extract_kernel函数:

asmlinkage __visible void *extract_kernel(void *rmode, memptr heap,
                                                          unsigned char *input_data,
                                                          unsigned long input_len,
                                                          unsigned char *output,
                                                          unsigned long output_len)
{
                // ...
                choose_random_location((unsigned long)input_data, input_len,
                                                           (unsigned long *)&output,
                                                           max(output_len, kernel_total_size),
                                                           &virt_addr);
        //  ...
}

这个参数由 arch/x86/boot/compressed/head_64.S 的汇编代码传递:

leaq    input_data(%rip), %rdx

input_datamkpiggy 程序生成。如果编译过Linux内核源码,会找到这个程序生成的文件,它应该位于 linux/arch/x86/boot/compressed/piggy.S 。这个文件是这样的:

.section ".rodata..compressed","a",@progbits
.globl z_input_len
z_input_len = 6988196
.globl z_output_len
z_output_len = 29207032
.globl input_data, input_data_end
input_data:
.incbin "arch/x86/boot/compressed/vmlinux.bin.gz"
input_data_end:

它有四个全局符号:

  • 前两个z_input_len和z_output_len是压缩的和解压后的vmlinux.bin.gz的大小
  • 第三个是input_data,它指向二进制格式(去掉所有调试符号、注释和重定位信息)的Linux内核镜像
  • 最后的input_data_end指向压缩的Linux镜像的末尾
所以 choose_random_location函数的第一个参数是指向嵌入在piggy.o目标文件的压缩的内核镜像的指针
  • choose_random_location函数的第二个参数是 刚刚看到的 z_input_len
  • choose_random_location函数的第三和第四个参数分别是解压后的内核镜像的位置和长度
    • 放置解压后内核的地址来自 arch/x86/boot/compressed/head_64.S,并且它是 startup_32 对齐到 2MB 边界的地址
    • 解压后的内核的大小来自同样的piggy.S,并且它是 z_output_len
  • choose_random_location函数的最后一个参数是内核加载地址的虚拟地址。它和默认的物理加载地址相同:

    unsigned long virt_addr = LOAD_PHYSICAL_ADDR;
    
    • 它依赖于内核配置:

      #define LOAD_PHYSICAL_ADDR ((CONFIG_PHYSICAL_START \
                                      + (CONFIG_PHYSICAL_ALIGN - 1)) \
                                      & ~(CONFIG_PHYSICAL_ALIGN - 1))
      

建立新的恒等映射内存页表

这个函数从检查内核命令行的 nokaslr 选项开始:

if (cmdline_find_option_bool("nokaslr")) {
        warn("KASLR disabled: 'nokaslr' on cmdline.");
        return;
}

如果有这个选项,那么就退出choose_random_location函数,并且内核的加载地址不会随机化。相关的命令行选项可以在 内核文档找到:

kaslr/nokaslr [X86]

Enable/disable kernel and module base offset ASLR
(Address Space Layout Randomization) if built into
the kernel. When CONFIG_HIBERNATION is selected,
kASLR is disabled by default. When kASLR is enabled,
hibernation will be disabled.

假设没有把nokaslr传到内核命令行,并且CONFIG_RANDOMIZE_BASE启用了内核配置选项。下一步:

initialize_identity_maps();

它在 arch/x86/boot/compressed/pagetable.c 定义。这个函数从初始化 x86_mapping_info结构体 的一个实例开始:

mapping_info.alloc_pgt_page = alloc_pgt_page;
mapping_info.context = &pgt_data;
mapping_info.page_flag = __PAGE_KERNEL_LARGE_EXEC | sev_me_mask;
mapping_info.kernpg_flag = _KERNPG_TABLE | sev_me_mask;

x86_mapping_info结构体在 arch/x86/include/asm/init.h 头文件定义:

struct x86_mapping_info {
        void *(*alloc_pgt_page)(void *); // 为一个页表项分配空间时调用的回调函数 
        void *context; // 跟踪已分配页表的alloc_pgt_data结构体的实例
        unsigned long page_flag; // PMD或PUD表项的标志
        unsigned long offset; // 内核虚拟地址到PMD级物理地址的偏移
        bool direct_gbpages; // 对大页的支持
        unsigned long kernpg_flag; // 未来被覆盖的内核页的标志
};
这个结构体提供了关于内存映射的信息

在前面的部分,已经建立了初始的从0到4G的页表,现在可能需要访问4G以上的内存来在随机的位置加载内核

所以,initialize_identity_maps函数初始化一个内存区域,它用于可能需要的新页表
  • alloc_pgt_page: 为一个页表项分配空间时调用的回调函数
  • context: 用于跟踪已分配页表的alloc_pgt_data结构体的实例
  • page_flag: PMD或PUD表项的标志
  • kernpg_flag: 在之后被覆盖的内核页的标志
  • direct_gbpages: 对大页的支持
  • offset: 内核虚拟地址到PMD级物理地址的偏移

alloc_pgt_page 函数检查有一个新页的空间,从缓冲区分配新页并返回新页的地址:

entry = pages->pgt_buf + pages->pgt_buf_offset;
pages->pgt_buf_offset += PAGE_SIZE;

缓冲区在此结构体中:

struct alloc_pgt_data {
                unsigned char *pgt_buf;
                unsigned long pgt_buf_size;
                unsigned long pgt_buf_offset;
};

initialize_identity_maps函数最后的目标是初始化 pgdt_buf_size 和 pgt_buf_offset. 由于只是在初始化阶段,initialize_identity_maps函数设置 pgt_buf_offset 为0:

pgt_data.pgt_buf_offset = 0;
pgt_data.pgt_buf_size会根据引导加载器所用的引导协议(64位或32位)被设置为77824或69632

pgt_data.pgt_buf也是一样。如果引导加载器从 startup_32 引导内核,pgdt_data.pgdt_buf会指向已经在 arch/x86/boot/compressed/head_64.S 初始化的页表的末尾:

pgt_data.pgt_buf = _pgtable + BOOT_INIT_PGT_SIZE;

_pgtable指向这个页表 _pgtable 的开头

另一方面,如果引导加载器用64位引导协议并在 startup_64 加载内核,早期页表应该由引导加载器建立,并且_pgtable会被重写:

pgt_data.pgt_buf = _pgtable
在新页表的缓冲区被初始化之下,回到 choose_random_location函数

避开保留的内存范围

在恒等映射页表相关的数据被初始化之后,可以开始选择放置解压后内核的随机位置

但是正如你猜的那样,不能选择任意地址

在内存的范围中,有一些保留的地址。这些地址被重要的东西占用,如initrd, 内核命令行等等

函数 mem_avoid_init 会做这件事。所有不安全的内存区域会收集到:

mem_avoid_init(input, input_size, *output);

struct mem_vector {
        unsigned long long start;
        unsigned long long size;
};

static struct mem_vector mem_avoid[MEM_AVOID_MAX];

其中 MEM_AVOID_MAX 来自枚举类型 mem_avoid_index , 定义在源文件 arch/x86/boot/compressed/kaslr.c 中,代表不同类型的保留内存区域:

enum mem_avoid_index {
                MEM_AVOID_ZO_RANGE = 0,
                MEM_AVOID_INITRD,
                MEM_AVOID_CMDLINE,
                MEM_AVOID_BOOTPARAMS,
                MEM_AVOID_MEMMAP_BEGIN,
                MEM_AVOID_MEMMAP_END = MEM_AVOID_MEMMAP_BEGIN + MAX_MEMMAP_REGIONS - 1,
                MEM_AVOID_MAX,
};

mem_avoid_init 主要目标是在mem_avoid数组存放关于被mem_avoid_index枚举类型描述的保留内存区域的信息,并且在新的恒等映射缓冲区为这样的区域创建新页。函数里的几个部分很相似,先看看其中一个:

mem_avoid[MEM_AVOID_ZO_RANGE].start = input;
mem_avoid[MEM_AVOID_ZO_RANGE].size = (output + init_size) - input;
add_identity_map(mem_avoid[MEM_AVOID_ZO_RANGE].start,
                                 mem_avoid[MEM_AVOID_ZO_RANGE].size);

mem_avoid_init函数的开头尝试避免用于 当前内核解压的内存区域

  1. 用这个区域的 起始地址大小 填写 mem_avoid数组 的一项
  2. 调用 add_identity_map 函数,它会为这个区域建立恒等映射页。add_identity_map函数同样在 arch/x86/boot/compressed/kaslr.c 定义:

    void add_identity_map(unsigned long start, unsigned long size)
    {
                    unsigned long end = start + size;
    
                    start = round_down(start, PMD_SIZE);
                    end = round_up(end, PMD_SIZE);
                    if (start >= end)
                                    return;
    
                    kernel_ident_mapping_init(&mapping_info, (pgd_t *)top_level_pgt,
                                                                      start, end);
    }
    
    • 它对齐内存到 2MB 边界并检查给定的起始地址和终止地址
    • 调用 kernel_ident_mapping_init 函数,它在源文件 arch/x86/mm/ident_map.c 中,并传入 初始化好的mapping_info实例顶层页表的地址 和建立 新的恒等映射的内存区域的地址
      • 为新页设置默认的标志,如果它们没有被给出:

        if (!info->kernpg_flag)
                        info->kernpg_flag = _KERNPG_TABLE;
        
      • 建立新的2MB (因为mapping_info.page_flag中的PSE位) 给定地址相关的页表项(五级页表中的PGD -> P4D -> PUD -> PMD或者四级页表中的PGD -> PUD -> PMD)

        for (; addr < end; addr = next) {
                        p4d_t *p4d;
        
                        next = (addr & PGDIR_MASK) + PGDIR_SIZE; // 找给定地址在 页全局目录 的下一项 
                        if (next > end) // 如果它大于给定的内存区域的末地址end,把它设为end
                                        next = end; 
        
                        p4d = (p4d_t *)info->alloc_pgt_page(info->context); // 用之前看过的x86_mapping_info回调函数分配一个新页 
                        result = ident_p4d_init(info, p4d, addr, next); // ident_p4d_init函数做同样的事情,但是用于低层的页目录 (p4d -> pud -> pmd)
        
                        return result;
        }
        
这不是mem_avoid_init函数的末尾,但是其他部分类似

它建立用于 initrd、内核命令行等数据的页

物理地址随机化

在保留内存区域存储在mem_avoid数组并且为它们建立了恒等映射页之后,选择 最小可用的地址 作为解压内核的随机内存区域:

min_addr = min(*output, 512UL << 20);
它应该小于512MB. 选择这个512MB的值只是避免低内存区域中未知的东西

下一步是选择随机的物理和虚拟地址来加载内核。首先是物理地址:

random_addr = find_random_phys_addr(min_addr, output_size);

find_random_phys_addr 函数在 同一个 源文件中定义:

static unsigned long find_random_phys_addr(unsigned long minimum,
                                           unsigned long image_size)
{
                minimum = ALIGN(minimum, CONFIG_PHYSICAL_ALIGN);

                if (process_efi_entries(minimum, image_size))
                                return slots_fetch_random();

                process_e820_entries(minimum, image_size);
                return slots_fetch_random();
}

process_efi_entries 函数的主要目标是在整个可用的内存找到所有的合适的内存区域来加载内核。如果内核没有在支持EFI的系统中编译和运行,继续在 e820 区域中找这样的内存区域。所有找到的内存区域会存储 slot_area 数组中:

struct slot_area {
        unsigned long addr;
        int num;
};

#define MAX_SLOT_AREA 100

static struct slot_area slot_areas[MAX_SLOT_AREA];

内核解压器应该选择这个数组随机的索引,并且它会是内核解压的随机位置。这个选择会被 slots_fetch_random 函数执行:

// 通过kaslr_get_random_long函数从slot_areas数组选择随机的内存范围
slot = kaslr_get_random_long("Physical") % slot_max; 

kaslr_get_random_long函数在源文件 arch/x86/lib/kaslr.c 中定义,它返回一个随机数

注意:这个随机数会通过不同的方式得到,取决于内核配置、系统机会(基于时间戳计数器的随机数、rdrand等等)

虚拟地址随机化

在内核解压器选择了随机内存区域后,新的恒等映射页会为这个区域按需建立:

random_addr = find_random_phys_addr(min_addr, output_size);

if (*output != random_addr) {
                add_identity_map(random_addr, output_size);
                *output = random_addr;
}
此时 output 会存放内核将被解压的一个内存区域的基地址

但是现在,只是随机化了物理地址。而在x86_64架构,虚拟地址也应该被随机化:

if (IS_ENABLED(CONFIG_X86_64))
                random_addr = find_random_virt_addr(LOAD_PHYSICAL_ADDR, output_size);

*virt_addr = random_addr;

find_random_virt_addr 函数计算可以保存内存镜像的虚拟内存范围的数量并且调用在尝试找到随机的物理地址的时候,之前已经看到的 kaslr_get_random_long函数

对于非x86_64架构,随机化的虚拟地址和随机化的物理地址相同

到此为止同时有了用于解压内核的随机化的物理(*output)和虚拟(*virt_addr)基地址
Previous:长模式 Home:启动引导