Linux 内核 教程 | 内存管理篇之内核初始化与内存管理启用

内核初始化与内存管理启用

Posted by Aiden on April 23, 2019

前言

内存寻址之后, 本篇开始介绍Linux内核地址空间初始化过程。

通过内存寻址篇我们知道, Linux 系统运行过程中位于保护模式,系统必须要是用MMU来完成地址寻址, 这就依赖于段表页表

但是问题来了, 系统是如何将段表跟页表是如何装入的呢?

本文通过 Linux 系统初始化过程,开始介绍内存管理的构建过程。

BIOS 时代:

当PC机加电的那一刻,主机开始获取操作指令,初始化操作系统。

这个时候,系统cpu是运行在实模式(详情见说明)下的, CPU最开始从0xFFFF0 处定位BIOS通过影子内存(详情见说明)定位BIOS第一条指令。

BIOS 就开始地检测内存、显卡等外设信息,当硬件检测通过之后,就在内存的物理内存的起始位置 0x000 ~ 0x3FF建立中断向量表

然后, BIOS 将启动磁盘中的第1个扇区(MBR 扇区,Master Boot Record)的 512 个字节的数据加载到物理内存地址为 0x7C00 ~ 0x7E00 的区域,然后程序就跳转到 0x7C00 处开始执行,至此,BIOS 就完成了所有的工作,将控制权转交到了 MBR 中的代码。通过MBR加载Linux 内核映像。

实模式运行阶段:

先将内核镜像文件中的起始第一部分 boot/setup.bin 加载到 0x7c00 地址之上的物理内存中,然后跳转到 setup.bin 文件中的入口地址开始执行。

涉及的文件有 arch/x86/boot/header.S链接脚本setup.ldarch/x86/boot/main.cheader.S 第一部分定义了 .bstext.bsdata.header 这 3 个节,共同构成了vmlinuz 的第一个512字节(即引导扇区的内容)。常量 BOOTSEGSYSSEG 定义了引导扇区和内核的载入的地址。

BOOTSEG     = 0x07C0        /* original address of boot-sector */
SYSSEG      = 0x1000        /* historical load address >> 4 */

主要完成的工作:

  • 初始化早期启动状态下的控制台(console)。
  • 初始化临时堆栈空间。
  • 检测 CPU 相关信息。
  • 通过向 BIOS 查询的方式,收集硬件相关信息,并将结果存放在第 0 号物理页中。

实模式下的最终内存模型

image.png

保护模式运行模式

第一次处于保护模式下-内核加载

为了进入保护模式,需要先设置gdt,这个时候的gdt为boot_gdt,代码段和数据段描述符中的基址都为0.

arch/x86/boot/pm.c    

static void setup_gdt(void)
{           
     /* There are machines which are known to not boot with the GDT
         being 8-byte unaligned.  Intel recommends 16 byte alignment. */

      static const u64 boot_gdt[] __attribute__((aligned(16))) = {
          /* CS: code, read/execute, 4 GB, base 0 */
          [GDT_ENTRY_BOOT_CS] = GDT_ENTRY(0xc09b, 0, 0xfffff),
          /* DS: data, read/write, 4 GB, base 0 */
          [GDT_ENTRY_BOOT_DS] = GDT_ENTRY(0xc093, 0, 0xfffff),
          /* TSS: 32-bit tss, 104 bytes, base 4096 */
          /* We only have a TSS here to keep Intel VT happy;
             we don't actually use it for anything. */
          [GDT_ENTRY_BOOT_TSS] = GDT_ENTRY(0x0089, 4096, 103),
      };

      /* Xen HVM incorrectly stores a pointer to the gdt_ptr, instead
         of the gdt_ptr contents.  Thus, make it static so it will
          stay in memory, at least long enough that we switch to the
         proper kernel GDT. */

      static struct gdt_ptr gdt;

      gdt.len = sizeof(boot_gdt)-1;
      gdt.ptr = (u32)&boot_gdt + (ds() << 4);

      asm volatile("lgdtl %0" : : "m" (gdt));	//加载段描述符
 }

当完成以上内容后, 置 CPU PE标志为1, 打开保护模式, 这时候分页还没有开启。 进入保护模式后,就设置各个段选择子.所有段寄存器(dsesfsgsss)都为设置为 _BOOT_DS 选择子.

再由于没有分页,所以线性地址就是物理地址.

然后, 把内核镜像 bzImage 中的第二部分 boot/vmlinux.bin 加载到物理内存中起始地址为 0x100000 的位置.

  • boot/vmlinux.bin 文件中解压内核的代码拷贝到物理内存中 boot/vmlinux.bin 的后面。
  • 初始化 stack 和 heap 空间。
  • 解压缩内核,解压缩后的内核就是我们从源码编译得到的 vmlinux ELF 可执行文件。

第二次设置 gdtr

解压完内核后就应该跳入真正的内核,即内核中第二个 startup_32() .这个时候的整个vmlinux的编译链接地址都是从虚拟地址(线性地址) 0xc0000000(__PAGE_OFFSET) 开始的,所以需要重新设置下段寻址。

这个是linux内核第二次设置段寻址,称为第二次进入保护模式.

这一次设置的原因是在之前的处理过程中,指令地址是从物理地址 0x100000 开始的,而此时整个 vmlinux 的编译链接地址是从虚拟地址 0xC0000000(__PAGE_OFFSET) 开始的,所以需要在这里重新设置 boot_gdt 的位置。

ENTRY(startup_32)
	cld
	lgdt boot_gdt_descr - __PAGE_OFFSET
	movl $(__BOOT_DS),%eax
	movl %eax,%ds
	movl %eax,%es
	movl %eax,%fs
	movl %eax,%gs

/*
 * Clear BSS first so that there are no surprises...
 * No need to cld as DF is already clear from cld above...
 */
	xorl %eax,%eax
	movl $__bss_start - __PAGE_OFFSET,%edi
	movl $__bss_stop - __PAGE_OFFSET,%ecx
	subl %edi,%ecx
	shrl $2,%ecx
	rep ; stosl

内核运行到这个时候,所有段基址都是0x00000000开始,而内核链接的线性地址都是从虚拟地址0xc0000000,但是这个时候还没有开启分页,那如果要访问一个变量应该怎么寻址呢? 则使用 X-__PAGE_OFFSET 如上所示,或者使用 __pa, __va 宏定义:

#define __pa(x)			((unsigned long)(x)-PAGE_OFFSET)
#define __va(x)			((void *)((unsigned long)(x)+PAGE_OFFSET))

进入分页模式 - 建立临时内核页表

虽然可以使用X-__PAGE_OFFSET来获得真实位置,但是依然不是长久之计,当务之急是开启分页,在内核编译链接时,就已经存在了一张全局目录:

ENTRY(swapper_pg_dir)
	.fill 1024,4,0

内核通过把swapper_pg_dir所有项都填充为0来创建期望映射,不过 0, 1, 0x300(768项),0x301(769项)除外。

  • 0 项和 0x300 项的地址字段置位 pg0 的物理地址,
  • 1 项和 0x301 项的地址字段置为紧随 pg0后的页框的物理地址。
  • 这四项的 Present, Read/WriteUser/Supervisor 标志都置位
  • 把这四项中的 Accessed, Dirty, PCD, PWD 和Page Size 标志清零。

pg0 这两个页表分别实现如下范围内的映射关系,依次实现对物理地址前8M 的寻址

0x00000000 - 0x007fffff -> 0x00000000 - 0x007fffff
0xc0000000 - 0xc07fffff -> 0x00000000 - 0x007fffff

在第一次开启分页时就把这张表作为页全局目录,将其地址给cr3寄存器,并开启分页.

movl $swapper_pg_dir-__PAGE_OFFSET,%eax
movl %eax,%cr3		/* set the page table pointer.. */
movl %cr0,%eax
orl $0x80000000,%eax
movl %eax,%cr0		/* ..and set paging (PG) bit */

第三次设置 gdtr:

开启分页之后, 接下来就通过分页寻址得到编译好的最终全局描述符表 gdt 的地址(cpu_gdt_table),将其地址付给gdtr,把段寄存器初始化为最终值。

收尾

通过以上系统初始过程, Linux 进入保护保护模式, 并完成对前 8M RAM内存空间的映射关系。

接下来便可以在保护模式下对内核代码段与数据段进行寻址。

image.png

后续,代码跳转到 start_kernel() 函数, 完成 Linux内核初始化工作。

  • 调度程序初始化
  • 内存管理区初始化
  • 伙伴系统分配程序初始化
  • IDT 初始化
  • slab 分配器初始化


说明:

  • 内核版本 2.6.11.2
  • 处理机: i386

实模式 :

它是 Intel公司 80286 及以后的x86(80386,80486等)处理器的一种操作模式。 实模式被特殊定义为20位地址内存可访问空间上,这就意味着它的容量是$2^{20}$(1M)的可访问内存空间(物理内存和BIOS-ROM),软件可通过这些地址直接访问BIOS程序和外围硬件。 实模式下处理器没有硬件级的内存保护概念和多道任务的工作模式。但是为了向下兼容,所以80286及以后的x86系列兼容处理器仍然是开机启动时工作在实模式下。

在寻址上实模式采用了分段寻址模式, 具体为: [16位段基地址DS]:[16位偏移EA] 组成。 其地址换算方式为: 物理地址 = (DS << 4) +EA, 例如 1000:FFFF = 1FFFFF

虽然理论上这种寻址模式支持的最大值为FFFF:FFFF=10FFEF, 但是由于只有20为有效地址总线,所以无法对第21为进行寻址。 为了解决上述兼容性问题,IBM使用键盘控制器上剩余的一些输出线来管理第21根地址线(从0开始数是第20根) 的有效性,被称为A20: 如果A20 Gate被打开,则当程序员给出100000H-10FFEFH之间的地址的时候,系统将真正访问这块内存区域

影子内存

影子内存(Shadow RAM,或称ROM shadow)是为了提高系统效率而采用的一种专门技术。它把系统主板上的系统ROM BIOS和适配器卡上的视频ROM BIOS等拷贝到系统RAM内存中去运行,其地址仍使用它们在上位内存中占用的原地址。

确切地说,是将ROM中的数据,拷贝至RAM。

“影子”内存所占用的空间是768KB—1024KB之间的区域。

参考文档 :

Linux内核初始化阶段内存管理的几种阶段(1) maxwellxxx’s Blog

Linux 内核加载启动过程分析

setup.s 分析—— Linux-0.11 学习笔记(二) - ARM的程序员敲着诗歌的梦 - CSDN博客

CPU 实模式 保护模式 和虚拟8086模式 - 辉仔 の专栏 - CSDN博客

Linux页表机制初始化 - vanbreaker的专栏 - CSDN博客

document/深入理解linux内核中文第三版.pdf at master · saligia-eva/document · GitHub

The Linux Kernel Archives