目录

Linux-0.11-06

Peach Fuzzer Professional本文是Linux 0.11系列学习记录的正式的第六篇。

从本篇开始,在每篇文章中会加入自己的理解和补充,各位可按需查看。

09 Intel 的内存管理:分段与分页

上文说到 head.s 代码在重新设置了 gdtidt 之后,此时的内存分布如下:

https://cdn.jsdelivr.net/gh/AlexsanderShaw/BlogImages@main/img/202112220908162.png

然后待执行的代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
jmp after_page_tables
...
after_page_tables:
    push 0
    push 0
    push 0				
    push L6				; 模拟调用 mainc.c 程序时首先将返回地址入栈的操作,main.c退出时,会返回到 L6,从而进入死循环
    push _main			; main.c 地址入栈,这样在设置分页处理结束后,执行 ret 时会将 main.c 地址 pop 出来,从而去执行 main.c
    jmp setup_paging
L6:
    jmp L6

1. 分页机制

在前面有介绍,在保护模式下,要先经过分段机制的转换才能变成物理地址:

https://cdn.jsdelivr.net/gh/AlexsanderShaw/BlogImages@main/img/202112220920927.png

在没有开启分页的时候,分段机制回顾:

  1. 分段机制涉及的4个关键内容:逻辑地址、段描述符(描述段的属性)、段描述符表(包含多个段描述符的“数组”)、段选择子(段寄存器,用于定位段描述符表中表项的索引)。
  2. 转换逻辑地址到物理地址分过程如下:CPU把逻辑地址(由段选择子selector和段偏移offset组成)中的段选择子的内容作为段描述符表的索引,找到表中对应的段描述符,然后把段描述符中保存的段基址加上段偏移值,形成线性地址。

但是开启分页之后,会多一步转换:

https://cdn.jsdelivr.net/gh/AlexsanderShaw/BlogImages@main/img/202112220921401.png

可以看到,在开启分页后,逻辑地址经过分段机制的转换后,不会直接获得物理地址,而是一个线性地址,然后需要再通过一次分页机制转换才能得到最终的物理地址,此时其过程如下:

https://cdn.jsdelivr.net/gh/AlexsanderShaw/BlogImages@main/img/202112221000442.png

而对于从线性地址到分页物理地址的转换过程如下(使用32-bit分页机制):

https://cdn.jsdelivr.net/gh/AlexsanderShaw/BlogImages@main/img/202112221004330.png

以4K页为例,线性地址的前10位表示页表目录,中间10位表示页表项,最后12位表示页内偏移。

首先根据高10位在页目录表中找到一个页目录项,这个页目录项的值加上中间10位拼接后的地址去页表中寻找一个页表项,这个页表项的值再加上后12位的偏移地址,就是最终的物理地址。

接下来以一个例子来感受分页机制:

假设经过分段机制转换后的线性地址是15M,二进制表示为 0000000011_0100000000_000000000000,其转换过程如下:

https://cdn.jsdelivr.net/gh/AlexsanderShaw/BlogImages@main/img/202112221002418.png

上述管转换过程的操作由MMU也就是内存管理单元完成,其主要作用就是将虚拟地址转换为物理地址。所以整个过程OS作为软件层,只需要提供好页目录表和页表即可,这种页表方案叫做二级页表,第一级叫做页目录表PDE,第二级叫做页表PTE,其结构如下:

https://cdn.jsdelivr.net/gh/AlexsanderShaw/BlogImages@main/img/202112221014509.png

之后再开启分页机制的开关,其实就是更改 cr0 寄存器中的第31位即可。在开始保护模式时,也是更改该寄存器中的第0位的值:

https://cdn.jsdelivr.net/gh/AlexsanderShaw/BlogImages@main/img/202112221015127.png

然后,MMU 就可以帮我们进行分页的转换了。此后指令中的内存地址(就是程序员提供的逻辑地址),就统统要先经过分段机制的转换,再通过分页机制的转换,才能最终变成物理地址。

2. 开启分页机制

下面看分页机制如何开启,也就是 setup_paging 部分,主要是帮我们把页表和页目录表在内存中写好,然后开启 cr0 寄存器的分页开关:

 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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
;/*
; * Setup_paging
; *
; * 这个子程序通过设置控制寄存器cr0 的标志(PG 位31)来启动对内存的分页处理
; * 功能,并设置各个页表项的内容,以恒等映射前16 MB 的物理内存。分页器假定
; * 不会产生非法的地址映射(也即在只有4Mb 的机器上设置出大于4Mb 的内存地址)。
; *
; * 注意!尽管所有的物理地址都应该由这个子程序进行恒等映射,但只有内核页面管
; * 理函数能直接使用>1Mb 的地址。所有“一般”函数仅使用低于1Mb 的地址空间,或
; * 者是使用局部数据空间,地址空间将被映射到其它一些地方去-- mm(内存管理程序)
; * 会管理这些事的。
; */

align 2		;// 按4 字节方式对齐内存地址边界。
setup_paging:	;// 首先对5 页内存(1 页目录+ 4 页页表)清零
	mov ecx,1024*5		;/* 5 pages - pg_dir+4 page tables */
	xor eax,eax
	xor edi,edi			;/* pg_dir is at 0x000 */
							;// 页目录从0x000 地址开始。
	pushf		;// VC内汇编使用cld和std后,需要自己恢复DF的值
	cld
	rep stosd
;// 下面4 句设置页目录中的项,我们共有4 个页表所以只需设置4 项。
;// 页目录项的结构与页表中项的结构一样,4 个字节为1 项。参见上面的说明。
;// "$pg0+7"表示:0x00001007,是页目录表中的第1 项。
;// 则第1 个页表所在的地址= 0x00001007 & 0xfffff000 = 0x1000;第1 个页表
;// 的属性标志= 0x00001007 & 0x00000fff = 0x07,表示该页存在、用户可读写。
	mov eax,_pg_dir
	mov [eax],pg0+7		;/* set present bit/user r/w */
	mov [eax+4],pg1+7		;/*  --------- " " --------- */
	mov [eax+8],pg2+7		;/*  --------- " " --------- */
	mov [eax+12],pg3+7		;/*  --------- " " --------- */
;// 下面6 行填写4 个页表中所有项的内容,共有:4(页表)*1024(项/页表)=4096 项(0 - 0xfff),
;// 也即能映射物理内存4096*4Kb = 16Mb。
;// 每项的内容是:当前项所映射的物理内存地址+ 该页的标志(这里均为7)。
;// 使用的方法是从最后一个页表的最后一项开始按倒退顺序填写。一个页表的最后一项
;// 在页表中的位置是1023*4 = 4092。因此最后一页的最后一项的位置就是$pg3+4092。
	mov edi,pg3+4092		;// edi -> 最后一页的最后一项。
	mov eax,00fff007h		;/*  16Mb - 4096 + 7 (r/w user,p) */
							;// 最后1 项对应物理内存页面的地址是0xfff000,
							;// 加上属性标志7,即为0xfff007.
	std					;// 方向位置位,edi 值递减(4 字节)。
L3:	stosd				;/* fill pages backwards - more efficient :-) */
	sub eax,00001000h	;// 每填写好一项,物理地址值减0x1000。
	jge L3				;// 如果小于0 则说明全添写好了。
	popf
;// 设置页目录基址寄存器cr3 的值,指向页目录表。
	xor eax,eax		;/* 页目录表(pg_dir)在0x0000 处。 */
	mov cr3,eax		;/* cr3 - page directory start */
;// 设置启动使用分页处理(cr0 的PG 标志,位31)
	mov eax,cr0
	or  eax,80000000h	;// 添上PG 标志。
	mov cr0,eax			;/* set paging (PG) bit */
	ret						;/* this also flushes prefetch-queue */
;// 在改变分页处理标志后要求使用转移指令刷新预取指令队列,这里用的是返回指令ret。
;// 该返回指令的另一个作用是将堆栈中的main 程序的地址弹出,并开始运行/init/main.c 
;// 程序。本程序到此真正结束了。

当时 linux-0.11 认为,总共可以使用的内存不会超过 16M,也即最大地址空间为 0xFFFFFF。而按照当前的页目录表和页表这种机制,1 个页目录表最多包含 1024 个页目录项(也就是 1024 个页表),1 个页表最多包含 1024 个页表项(也就是 1024 个页),1 页为 4KB(因为有 12 位偏移地址),因此,16M 的地址空间可以用 1 个页目录表 + 4 个页表搞定。

4(页表数)* 1024(页表项数) * 4KB(一页大小)= 16MB

所以,上面这段代码就是,将页目录表放在内存地址的最开头

1
2
3
4
5
_pg_dir:
_startup_32:
    mov eax,0x10
    mov ds,ax
    ..

之后紧挨着这个页目录表,放置 4 个页表,代码里也有这四个页表的标签项。

1
2
3
4
5
.org 0x1000 pg0:
.org 0x2000 pg1:
.org 0x3000 pg2:
.org 0x4000 pg3:
.org 0x5000

最终将页目录表和页表填写好数值,来覆盖整个 16MB 的内存。随后,开启分页机制。此时内存中的页表相关的布局如下。

https://cdn.jsdelivr.net/gh/AlexsanderShaw/BlogImages@main/img/202112221055131.png

这些页目录表和页表放到了整个内存布局中最开头的位置,就是覆盖了开头的 system 代码了,不过被覆盖的 system 代码已经执行过了,所以无所谓。同时,如 idt 和 gdt 一样,我们也需要通过一个寄存器告诉 CPU 我们把这些页表放在了哪里,就是这段代码。

1
2
xor eax,eax
mov cr3,eax

我们相当于告诉 cr3 寄存器,0 地址处就是页目录表,再通过页目录表可以找到所有的页表,也就相当于 CPU 知道了分页机制的全貌了。

至此后,整个内存布局如下。

https://cdn.jsdelivr.net/gh/AlexsanderShaw/BlogImages@main/img/202112221055286.png

那么具体页表设置好后,映射的内存是怎样的情况呢?那就要看页表的具体数据了,就是这一坨代码。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
setup_paging:
    ...
    mov eax,_pg_dir
    mov [eax],pg0+7
    mov [eax+4],pg1+7
    mov [eax+8],pg2+7
    mov [eax+12],pg3+7
    mov edi,pg3+4092
    mov eax,00fff007h
    std
L3: stosd
    sub eax, 1000h
    jpe L3
    ...

很简单,对照刚刚的页目录表与页表结构看。

https://cdn.jsdelivr.net/gh/AlexsanderShaw/BlogImages@main/img/202112221056131.png

前五行表示,页目录表的前 4 个页目录项,分别指向 4 个页表。比如页目录项中的第一项 [eax] 被赋值为 pg0+7,也就是 0x00001007,根据页目录项的格式,表示页表地址为 0x1000,页属性为 0x07 表示改页存在、用户可读写。后面几行表示,填充 4 个页表的每一项,一共 4*1024=4096 项,依次映射到内存的前 16MB 空间。

画出图就是这个样子,其实刚刚的图就是。

https://cdn.jsdelivr.net/gh/AlexsanderShaw/BlogImages@main/img/202112221056643.png

最终的效果就是,经过这套分页机制,线性地址将恰好和最终转换的物理地址一样

现在只有四个页目录项,也就是将前 16M 的线性地址空间,与 16M 的物理地址空间一一对应起来了。

https://cdn.jsdelivr.net/gh/AlexsanderShaw/BlogImages@main/img/202112221057664.png

对于上述内容可以整理总结如下:

Intel 体系结构的内存管理可以分成两大部分,也就是标题中的两板斧,分段分页

分段机制在之前几回已经讨论过多次了,其目的是为了为每个程序或任务提供单独的代码段(cs)、数据段(ds)、栈段(ss),使其不会相互干扰。

分页机制是本回讲的内容,开机后分页机制默认是关闭状态,需要我们手动开启,并且设置好页目录表(PDE)和页表(PTE)。其目的在于可以按需使用物理内存,同时也可以在多任务时起到隔离的作用,这个在后面将多任务时将会有所体会。

在 Intel 的保护模式下,分段机制是没有开启和关闭一说的,它必须存在,而分页机制是可以选择开启或关闭的。所以如果有人和你说,它实现了一个没有分段机制的操作系统,那一定是个外行。

再说说那些地址:

逻辑地址:我们程序员写代码时给出的地址叫逻辑地址,其中包含段选择子和偏移地址两部分。

线性地址:通过分段机制,将逻辑地址转换后的地址,叫做线性地址。而这个线性地址是有个范围的,这个范围就叫做线性地址空间,32 位模式下,线性地址空间就是 4G。

物理地址:就是真正在内存中的地址,它也是有范围的,叫做物理地址空间。那这个范围的大小,就取决于你的内存有多大了。

虚拟地址:如果没有开启分页机制,那么线性地址就和物理地址是一一对应的,可以理解为相等。如果开启了分页机制,那么线性地址将被视为虚拟地址,这个虚拟地址将会通过分页机制的转换,最终转换成物理地址。

扩展资料

关于逻辑地址-线性地址-物理地址的转换,可以参考 Intel 手册:

Intel 3A Chapter 3 Protected-Mode Memory Management

https://cdn.jsdelivr.net/gh/AlexsanderShaw/BlogImages@main/img/202112221058244.png

而有关这些地址的定义和说明,在本小节中也做了详细的说明,看这里的介绍是最权威也是最透彻的。相信我,它很简单。

https://cdn.jsdelivr.net/gh/AlexsanderShaw/BlogImages@main/img/202112221058986.png

页目录表和页表的具体结构,可以看

Intel 3A Chapter 4.3 32-bit paging

https://cdn.jsdelivr.net/gh/AlexsanderShaw/BlogImages@main/img/202112221058129.png

https://cdn.jsdelivr.net/gh/AlexsanderShaw/BlogImages@main/img/202112221058427.png

原文地址

你管这破玩意叫操作系统源码 | 第八回 Intel内存管理两板斧:分段与分页