Linux 教程 | 内存管理篇之内核初始化与内存管理启用
前言
继内存寻址之后, 本篇开始介绍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.ld
、arch/x86/boot/main.c
。header.S
第一部分定义了 .bstext
、.bsdata
、.header
这 3 个节,共同构成了vmlinuz 的第一个512字节(即引导扇区的内容)。常量 BOOTSEG
和 SYSSEG
定义了引导扇区和内核的载入的地址。
1
2
BOOTSEG = 0x07C0 /* original address of boot-sector */
SYSSEG = 0x1000 /* historical load address >> 4 */
主要完成的工作:
- 初始化早期启动状态下的控制台(console)。
- 初始化临时堆栈空间。
- 检测 CPU 相关信息。
- 通过向 BIOS 查询的方式,收集硬件相关信息,并将结果存放在第 0 号物理页中。
实模式下的最终内存模型
保护模式运行模式
第一次处于保护模式下-内核加载
为了进入保护模式,需要先设置gdt,这个时候的gdt为boot_gdt,代码段和数据段描述符中的基址都为0.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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, 打开保护模式, 这时候分页还没有开启。 进入保护模式后,就设置各个段选择子.所有段寄存器(ds
、es
、fs
、gs
、ss
)都为设置为 _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
的位置。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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
宏定义:
1
2
#define __pa(x) ((unsigned long)(x)-PAGE_OFFSET)
#define __va(x) ((void *)((unsigned long)(x)+PAGE_OFFSET))
进入分页模式 - 建立临时内核页表
虽然可以使用X-__PAGE_OFFSET
来获得真实位置,但是依然不是长久之计,当务之急是开启分页,在内核编译链接时,就已经存在了一张全局目录:
1
2
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
/Write
和User
/Supervisor
标志都置位 - 把这四项中的
Accessed
,Dirty
,PCD
,PWD
和Page Size
标志清零。
pg0 这两个页表分别实现如下范围内的映射关系,依次实现对物理地址前8M 的寻址
1
2
0x00000000 - 0x007fffff -> 0x00000000 - 0x007fffff
0xc0000000 - 0xc07fffff -> 0x00000000 - 0x007fffff
在第一次开启分页时就把这张表作为页全局目录,将其地址给cr3寄存器
,并开启分页.
1
2
3
4
5
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内存空间的映射关系。
接下来便可以在保护模式下对内核代码段与数据段进行寻址。
后续,代码跳转到 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内核初始化阶段内存管理的几种阶段
setup.s 分析—— Linux-0.11 学习笔记(二) - ARM的程序员敲着诗歌的梦 - CSDN博客
CPU 实模式 保护模式 和虚拟8086模式 - 辉仔 の专栏 - CSDN博客
Linux页表机制初始化 - vanbreaker的专栏 - CSDN博客
document/深入理解linux内核中文第三版.pdf at master · saligia-eva/document · GitHub