内核镜像文件

前面我们介绍了内核中关于内存的分段和分页,介绍了 CPU 在处理指令地址和数据地址时经历的流程,从而得到真正的物理地址,访问内存中的 指令和数据。但是在末尾,还是提出了我们的疑问,虽然单个地址的操作可以理解,但是还是没搞清楚内核在这个流程中是如何交互的。内核到底在哪里

今天我们开始从 Linux Kernel 加载开始讲内核的内存管理。前面我们说过,Linux Kernel 在静态上是数据和指令按特定规范组织在一起的文件, 大概就是文件头包含一些文件的元信息,比如有哪些段,每个段在文件中的偏移基址/长度,符号表等信息。这里的段是个抽象概念,是同属性事物 的集合,比如所有已经被初始化的全局变量放在数据段,没有被初始化的全局变量放在 BSS 段,指令放在代码段。所以我们可以简单得认为内核 镜像的内容的一段一段的。 kernel-image source

内核加载

内核镜像首先是放在软盘/硬盘中,比如我们自己装操作系统时一般会把操作系统放在 U 盘中。当机器加电时,CPU 引脚会收到一个 RESET 信号, 然后 eip 指令寄存器会被设置成 0xfffffff0。这个地址指向 ROM 中的指令,就是 BIOS(Basic Input/Output System),主要包含一些 硬件初始化和驱动指令,比如驱动键盘(接收用户输入)/显示屏(显示操作系统初始化过程)/磁盘(读取内核镜像)。此时 CPU 处于实地址模式,在 该地址模式下, 物理地址等于 seg*16 + off。

BIOS 负责将操作系统所在的硬盘的第一个扇区 (512 bytes) 加载到内存物理地址 0x00007c00。这个扇区里放置了 boot-loader,负责把内核镜像加载到内存中, 其实 boot loader 比较大的话还有一次 small boot-loader 到 big boot-loader 的类似磁盘装入和 eip 跳转,但不影响主线流程。

CPU 跳转到 boot-loader 中的指令后,这些指令负责将内核镜像加载到内存物理地址 0x00010000 或者 0x00100000 处。特别的,内核镜像中的一个 setup 函数 (固定被放在镜像的 0x200 偏移处) 被装载到另一个固定的物理地址 0x00090200,然后 eip 被设置成了这个地址。

setup 汇编函数主要负责初始化硬件,特别重要的是建立一个临时中断描述符表和临时全局描述符表, 其实就是预留大小确定的一段内存空间,按照描述符的 类型进行对应的初始化。然后设置 cr0 控制寄存器中的 PE 位之后,CPU 检测到该位置位后会开启保护模式,也就是采用前面说的分段寻址模式。

再接着就是 eip 被设置成内核镜像被加载到内存的首地址 0x00010000 或者 0x00100000,那里是 startup_32(1) 汇编函数的入口。该函数还是做一些内核数据 的初始化,压缩内核镜像解压到 0x00100000, 然后设置 eip 指向 0x00100000, 那里是另一个 startup_32(2) 函数。

从这个 startup_32(2) 函数开始,才开始大量初始化 Linux Kernel 相关的数据,为内核进程 0 准备环境,比如初始化临时内核页表,设置 cr0 的 PG 位开启分页, 为进程0 设置内核态堆栈,然后跳转到 start_kernel 函数。

start_kernel 函数初始化几乎所有内核部件,比如初始化调度需要的数据结构,初始化物理内存管理需要的数据结构,初始化中断异常需要的数据结构,初始化时钟 相关的数据结构。

load-kernel source

基本到这里内核的加载和初始化就结束了。那我们一开始想要回答的问题,对于鸡(内核)而言,它的内存是怎么管理的?

  1. 内核镜像中内核组件定义的全局变量被统一放置在内核数据段或者内核 BSS 段,指令被统一放置在内核代码段,boot loader 负责将整个段加载到内存中。
  2. 实际内核镜像被放置在 0x00100000(1M) 开始的地方,因为物理内存前 1M 被预留给了一些硬件做内存映射
  3. 经过步骤 2 之后,内核必须的数据和指令都已经在 1M ~3M 的物理内存中了。经过 start_kernel 对内存中内核数据的一系列初始化之后,内核数据被 设置成了正常的状态。比如初始化内核内存分配器 buddy system 和 slab system 相关的数据结构之后,内核具备了动态分配内存给内核的能力。当内核想要创建 一些内核进程时,就可以通过这些内核内存分配器申请进程描述符数据结构大小的内存空间。

所以可以初略得认为内核镜像被直接加载到了物理内存从某个地址开始的空间中,此时整个内核的数据和指令都能被 CPU 寻址访问到了。只要控制 eip 等寄存器的 值,内核指令就能被 CPU 不断执行,不断更新内核数据状态。

内核与进程线性地址空间

讲到这里感觉还有东西没有说清楚。内核的静态文件形式和动态运行形式我们都大概描述了,可以看到内核只不过是在内存低地址的一些数据和指令,CPU eip 指向 了内核指令,所以内核运行起来了。但是我们平时写代码的时候,内核看起来不是这样的,我们都说自己的代码处于用户态,可以操作 0 ~ 3G 进程线性地址空间, 然后内核在进程线性地址的 3G~4G 线性地址空间。然后当我们需要执行一些和硬件相关的操作时需要陷入内核,进程被切换到了内核态,操作完成时再从内核态切换 回用户态。从用户的角度看,这个过程多么线性,多么符合思维习惯。

但是进程线性地址空间到底是什么?
可以想象出 CPU 上大概执行的代码逻辑吗?
内核在 3G ~ 4G 进程地址空间是什么意思?
陷入内核是什么意思?进程从用户态切换到内核态是什么意思?
内核态和内核是什么关系?
前面不是说内核被加载到内存后 CPU 开始执行内核相关的指令吗?那这里进程角度的相关数据和指令是什么时候执行的?

进程地址空间

前面我们说过进程线性地址空间是内核给进程内存的一种抽象,进程感觉自己被分配并且拥有了 4G 的连续线性内存,但是实际上进程并没有被分配并拥有 4G 的内存空间。 那进程拥有的 4G 线性地址空间到底是什么? 要回答这个问题,我们需要先回答`拥有`是什么意思。当我们拥有一块饼干时,我们能看到饼干的形状大小,可以摸到饼干的表面,确认我们拥有了这个饼干。 但是我们拥有一段地址空间就没有这么具体了,试想一下,我们用 malloc 分配一个 n size 的内存块时,实际上我们拿到的是这块 n size 的内存块的首地址,所以我们说我们拥有了一块 n size 大小内存,实际上是持有了一个 n size 大小内存块首地址。这个地址是被背后的内存分配器认证 过的。所以我们拥有了 4G 的连续线性地址的真正意思是,我们持有了能完成 4G 大小内存寻址能力的页目录和页表。 举个例子:

  1. 我们现在编译得到了一个 executable binary,现在一般都是 elf 文件格式。elf 文件格式也基本是分成好几个段,其中比较重要的是代码段和数据段。
  2. 现在我们在 shell 中执行这个可执行文件,此时内核会创建一个新的 struct task_struct 来维护进程状态,这个 task_struct 的内存占用是在物理内存前 1G 中的,这个我们下一个博客再介绍。
  3. 其中,为了管理线性页(虚拟页)到物理页的转换,task_struct 中肯定需要初始化这个进程的页全局目录,也就是会有一个指针指向一个 4K 的页(直接放在 task_struct 中就有点大了)。
  4. 为了能执行这个进程的指令和改变这个进程的数据,需要将指令和数据都保存在 task_struct,所以进程在概念上定义了代码段,数据段,堆,栈等线性区,其实对于 task_struct 而言就是一个地址范围,不同的地址范围有自己的一些含义和数据,通过头尾指针链接在一起。也就是说,平时我们说的堆内存占用大,其实有2层意思,一层是堆这个线性区间的地址范围 很大,比如 [0.7G, 2.5G), 但是在 task_struct 只是两个 int 型字段的值变了而已,其实并没有占用实际的物理内存。当我们真正去写所有的堆内存时,比如将[0.7G, 2.5G] 的地址全部 设置成0 时,此时 CPU 在执行写指令的时候,会用这个线性地址去进程页全局目录和页表中查询对应的页表项是否存在。如果页表项不存在,则说明实际的物理地址不存在,需要内核分配物理页 ,然后填充进程页表项,此时才算进程占用了大量的物理内存。但也只是使用了大量的物理内存,真正维护进程拥有哪些物理页信息的也只是页全局目录和页表的物理占用。
  5. 说到这里,就可以明白其实进程拥有 4G 进程地址空间,在实现上就是一些线性区间信息的维护 + 页全局目录和页表项

4G-virtual-address-space source

内核在哪里

所以神奇的内核在哪里? 神奇的内核被映射到了进程的 [3G, 4G) 线性地址空间中。内核实际上还是在物理内存的低 1G 的位置,不管是内核镜像还是内核动态分配的 task_struct 之类的数据结构。只不过,内核的线性地址 被平移到了 [3G, 4G),也就是内核中所有的指令地址和数据地址都被加上了 0xC0000000。并且,内核拥有自己的页全局目录和页表,但只填充 [3G, 4G) 对应的页表项。而每个进程[3G, 4G)的页表项 内容都是从内核页表项中拷贝过来的。

所以我们说,内核被映射到了所有进程的[3G, 4G) 线性地址空间中。一旦我们从用户态切换到内核态执行,中断指令会触发 DS 和 CS 寄存器更新成对应的内核段地址,所有的内核指令地址和数据地址都会通过查找内核页表的方式,访问到低 1G 的物理内存空间中。

kernel-memory-map source

总结

  1. 内核被加载到了物理内存的低地址空间中
  2. 内核的物理指令地址和物理数据地址都被平移映射到 [3G, 4G) 线性地址空间中,通过页表维护对应关系
  3. 进程的 4G 线性地址空间其实是个虚拟概念,实际维护的是不同的线性区间,具体线性地址到物理地址的转换也是通过页表映射实现的
  4. 进程的[3G, 4G) 的页表项从内核页表项获取,所以我们说内核被映射到了所有进程线性地址空间中的高 1G 位置