内核压缩与解压

和应用程序一样,内核编译的时候是一个ELF文件,需要被加载到内存里,然后经过神奇一跃跳入内核执行。

内核是如何找到自己应该加载的物理地址的?又是如何在页表里建立虚拟地址映射的?这些都困扰着我。让我尝试看看是否能够解答。

从piggy.S开始

bzImage的全貌中,我们看到内核是通过include的方式包含在了一个压缩内核中的。当时我们就是知道一个大概,这里需要再展开看看。

piggy.S的代码较短,我们贴上来看看。

.section ".rodata..compressed","a",@progbits
.globl z_input_len
z_input_len = 9993406
.globl z_output_len
z_output_len = 37640768
.globl input_data, input_data_end
input_data:
.incbin "arch/x86/boot/compressed/vmlinux.bin.zst"
input_data_end:


.section ".rodata","a",@progbits
.globl input_len
input_len:
	.long 9993406
.globl output_len
output_len:
	.long 37640768

其中定义了两个section,这两个section都能在arch/x86/boot/compressd/vmlinux.lds.S中找到对应的。 每个section里定义了点变量,就是这么简单。不过这次我们要看的是这个值是怎么来的。

具体的可以看代码mkpiggy.c,因为这个piggy.S是mkpiggy生成的。这里面的值是对应解压缩来说的:

  • input是指解压缩的输入

  • output是指解压缩的输出

所以我们看到input_len小于output_len。这两个值就应该是压缩后的文件大小和压缩前的文件大小。让我们来看看是不是

在arch/x86/boot/compressed目录下的这两个文件正是压缩前后的文件。其大小和piggy.S中生成的内容一致。

解压缩内核

内核跳转到保护模式后,设置了基本的段寄存器和页表后,就是要处理内核的解压缩了。这里我们就来看看整个解压缩的过程。

获得解压缩内核的起始地址

这部分在上一篇笔记中已经详细看过,最后计算出的值是CONFIG_PHYSICAL_START配置的16M。而且这个值保存在了rbp中,备用。

移动压缩内核

上面这段上一篇也看过了,就是把startup_32到_bss这一段代码都拷贝到了rbx为结尾的内存。为了避免覆盖,所以是从后面往前进行的拷贝。但是这个rbx是怎么来的呢?看看下面这段代码。

上面的代码可以写成: rbx = rbp + init_size - (startup_32 - _end) 因为startup_32是0, => rbx = rbp + init_size - _end

PS: startup_32的值是0,也可以通过nm命令来确认

接下来我们来看看BP_init_size(%rsi)是什么。rsi实际上是指向了boot_param,所以这个值是boot_param中init_size的保存的值。这个值在arch/x86/boot/header.S中定义。

但是这个INIT_SIZE的定义有很多分岔情况,我这里只列出自己实验机器和配置上的情况。

至于为什么这么定义,大家可以看上面的注释,我是没有仔细看,反正和解压缩有关。 这么一堆定义,我们整理一下

那这些ZO_xxx都是什么呢?这些是arch/x86/boot/zoffset.h中定义的。而这里面的值也是通过nm arch/x86/boot/compressed/vmlinux得到的,在原先的符号上加了ZO_作为定义名称。

如果你想验证一下是否如此,可以make arch/x86/boot/header.s,看一下预编译的文件内容是否符合。下面就是我实验时header.s的结果。

而zoffset.h中的值为(调整了下顺序,方便对比)

看来完全符合~

总之,计算了一通后,我们终于在解压缩内核地址rbp的基础上增加了一个安全的偏移,得到了我们要移动压缩内核的地址rbx。

原地解压

内核玩的是 in-place decompression。估计是以前内存紧张,要好好计算该放到哪里才能不在解压缩的时候破坏内存现场。这也是为什么刚才的INIT_SIZE计算得这么辛苦。

extract_kernel一共有两个参数,boot_param和目标地址。也就是我们刚才算出来的rbp。 返回也就一个参数entry_point,保存在rax中。

我们来看看究竟会返回什么样的地址.(没有高级功能的情况下)

首先将压缩内核解压,到output开始的内存中。也就是我们刚才计算得到的rbp=16M的地址。 然后parse_elf。注意,这个时候,output的内容已经是内核根目录下的vmlinux,而不是arch/x86/boot下的任何一个vmlinux了。所以这里的ehdr.e_entry是根目录下vmlinux的入口地址.

因为output=rbp,在这次计算中就是LOAD_PHYSICAL_ADDR。现在我们展开一下extract_kernel的返回值看看:

也就是说,我们预期解压缩后,得到的跳转地址就是ehdr.e_entry。那我们现在看看vmlinux中的entry值,再用bochs来验证一下。

我们看到Entry point address: 0x1000000。先看一下extract_kernel返回后的寄存器状态。

确实是0x1000000,如我们所预料的。我们跳进取,反汇编一下看看。

正好是startup_64里的代码。好了,终于来到了我们熟知的内核了!

慢着,为啥是startup_64呢?这还要从链接和加载说起。

链接与加载

我们解压缩出来的vmlinux是ELF格式的。就像在《自己动手写操作系统》所讲的一样,我们需要按照program header中描述的把内核加载到指定位置才行。而不是解压缩完了就行了。

vmlinux的program header

我们先来看看编译出来的vmlinux中program header的样子

其中VirtAddr和PhysAddr是我们要关心的。为什么会是这样的值呢?

vmlinux.lds.S

在编译vmlinux时,采用了链接脚本vmlinux.lds.S。我们看到的地址,就是在链接脚本中定义的。

该脚本太长,我们截取其中一部分看一下。

先说一个概念,链接脚本中有两种地址: vma (virtial memory address); lma (load memory address)。对应的应该就是program header中的VirtAddr和PhysAddr。

首先我们看到的是一个定义贼长的地址,__START_KERNEL_map。对,这个就是定义了内核地址空间的结界。高地址的2G空间,是内核专属空间。

接下来我们看.text的vma和lma。vma的值就是__START_KERNEL = __START_KERNEL_map + LOAD_PHYSICAL_ADDR。这个LOAD_PHYSICAL_ADDR就是16M。所以这两个值的和就是0xffffffff81000000。是不是和program header中第一个的VirtAddr对上了。lma是由冒号后面的AT关键字定义的。值是.text的地址 - LOAD_OFFSET,其实算下来就是LOAD_PHYSICAL_ADDR。是不是又和program header对上了?

所以编译链接完后,我们内核该加载到哪里,该是什么地址运行都已经写得清清楚楚了!

加载到各自的位置

既然我们看到每个program header标识了应该被加载到的位置,那内核什么时候被加载的呢?还记得我们刚才看的内核解压缩么?就藏在那里面了。

对了,就是这么一个个program header加载过去的。

Last updated

Was this helpful?