lagrange's blog

what's dead may never die

0%

Lab2 实验报告

Lab2

X86 页表结构

分页转换功能由驻留在内存中的表来描述,该表称为页表(page table),存放在物理地址空间中。线性地址的高 20 位构成页表数组的索引值,用于选择对应页面的物理(基)地址。线性地址的低 12 位给出了页面中的偏移量,加上页面的基地址最终形成对应的物理地址。由于页面基地址对齐在 4K ($2^{12}$) 边界上,因此页面基地址的低 12 位肯定是 0。这意味着高 20 位的页面基地址和 12 位偏移量连接组合在一起就能得到对应的物理地址。

页表中每个页表项的大小为 32 位。由于只需要其中的 20 位来存放页面的物理基地址,因此剩下的 12 位可用于存放诸如页面是否存在等的属性信息。如果线性地址索引的页表项被标注为存在的,则表示该项有效,我们可以从中取得页面的物理地址。如果页表项中信息表明(说明、指明)页不存在,那么当访问对应物理页面时就会产生一个异常。

两级页表结构

页表含有 $2^{20}$(1M)个表项,而每项占用 4 Byte。如果作为一个表来存放的话,它们最多将占用 4MB 的内存。因此为了减少内存占用量,x86 使用了两级表。由此,高 20 位线性地址到物理地址的转换也被分成两步来进行,每步使用(转换)其中的 10bit。

  1. 第一级表称为页目录(page directory)。它被存放在 1 页 4K 页面中,具有 $2^{10}$(1K)个 4B 长度的表项。这些表项指向对应的二级表。线性地址的最高 10 位(位 31 ~ 22)用作一级表(页目录)中的索引值来选择 $2^{10}$ 个二级表之一。
  2. 第二级表称为页表(page table),它的长度也是 1 个页面,最多含有 1K 个 4B 的表项。每个 4B 表项含有相关页面的 20 位物理基地址。二级页表使用线性地址中间 10 位(位 21 ~ 12)作为表项索引值,以获取含有页面 20 位物理基地址的表项。该 20 位页面物理基地址和线性地址中的低 12 位(页内偏移)组合在一起就得到了分页转换过程的输出值,即对应的最终物理地址。

CR3 寄存器指定页目录表的基地址。线性地址的高 10 位用于索引这个页目录表,以获得指向相关第二级页表的指针。线性地址中间 10 位用于索引二级页表,以获得物理地址的高 20 位。线性地址的低 12 位直接作为物理地址低 12 位,从而组成一个完整的 32 位物理地址。

中断

中断向量表和中断描述符表的区别

中断向量表是在实模式下使用,每个中断向量由 4 字节组成。这 4 字节指明了一个中断服务程序的段值和段内偏移值(实模式每个寄存器 16 位,基址和偏移刚好 4 字节)。当 x86 电脑上电时,BIOS 中的程序会在物理内存开始地址 0x0000:0x0000 (基址:偏移 其实就是 0 地址处)处初始化并设置中断向量表,而各中断的默认中断服务程序则在 BIOS 中给出。由于中断向量表中的向量是按中断号顺序排列,因此给定一个中断号 N,那么它对应的中断向量在内存中的位置就是 0x0000:N*4,即对应的中断服务程序入口地址保存在物理内存 0x0000:N*4 位置处。

在 BIOS 执行初始化操作时,它设置了两个 8259A 芯片支持的 16 个硬件中断向量和 BIOS 提供的中断号为 0x10 ~ 0x1f 的中断调用功能向量等。对于实际没有使用的向量则填入临时的哑中断服务程序的地址。以后在系统引导加载操作系统时会根据实际需要修改某些中断向量的值。例如,对于 DOS 操作系统,它会重新设置中断 0x20 ~ 0x2f 的中断向量值。而对于 Linux 系统,除了在刚开始加载内核时需要用到 BIOS 提供的显示和磁盘读操作中断功能,在内核正常运行之前则会在 setup.s 程序中重新初始化 8259A 芯片并且在 head.s 程序中重新设置一张中断描述符表。完全抛弃了 BIOS 所提供的中断服务功能。(因为 DOS 运行在实模式下,直接在原来的表上改就行了,但是 Linux 运行在保护模式下,必须使用保护模式下的中断描述符表)

当 Intel CPU 运行在 32 位保护模式下时,需要使用中断描述符表来管理中断或异常。其作用也类似于中断向量表,只是其中每个中断描述符项中除了含有中断服务程序地址以外,还包含有关特权级和描述符类别等信息(中断向量表里只有地址)。

BIOS 中断处理

BIOS 为什么添加中断处理例程呢?

  1. 给自己用,因为 BIOS 也是一段程序,是程序就很可能要重复性地执行某段代码,它直接将其写成中断函数,直接调用多省心。

  2. 给后来的程序用,如加载器或 boot loader。它们在调用硬件资源时就不需要自己重写代码了。

BIOS 是如何设置中断处理程序的呢?

通过 BIOS 也要调用别人的函数例程,硬件厂商为了让自己生产的产品易用,肯定事先写好了一组调用接口,必然是越简单越好,直接给接口函数传一个参数,硬件就能返回一个输出。

那这些硬件自己的接口代码在哪里呢?

每个外设,包括显卡、键盘、各种控制器等,都有自己的内存(主板也有自己的内存,BIOS 就存放在里面),不过这种内存都是只读存储器 ROM。硬件自己的功能调用例程及初始化代码就存放在这 ROM 中。根据规范,第 1 个内存单元的内容是 0x55,第 2 个存储单元是 0xAA,第 3 个存储单位是该 rom 中以 512 字节为单位的代码长度。从第 4 个存储单元起就是实际代码了,直到第 3 个存储单元所示的长度为止。

CPU 如何访问到外设的 ROM 呢?

访问外设有两种方式。

(1)内存映射:通过地址总线将外设自己的内存映射到某个内存区域(并不是映射到主板上插的内存条中)。

(2)端口操作:外设都有自己的控制器,控制器上有寄存器,这些寄存器就是所谓的端口,通过 in/out 指令读写端口来访问硬件的内存。

从内存的物理地址 0xA0000 开始到 0xFFFFF 这部分内存中,一部分是专门用来做映射的,如果硬件存在,硬件自己的 ROM 会被映射到这片内存中的某处。

BIOS 中断处理

BIOS 在运行期间会扫描 0xC0000 到 0xE0000 之间的内存,若在某个区域发现前两个字节是 0x55 和 0xAA 时,这意味着该区域对应的 rom 中有代码存在,再对该区域做累加和检查,若结果与第 3 个字节的值相符,说明代码无误,就从第 4 个字节进入。这时开始执行了硬件自带的例程以初始化硬件自身,最后,BIOS 填写中断向量表中相关项,使它们指向硬件自带的例程。

DOS 是运行在实模式下的,故其建立的中断调用也建立在中断向量表中,只不过其中断向量号和 BIOS 的不能冲突。

0x20 ~ 0x27 是 DOS 中断。因为 DOS 在实模式下运行,故其可以调用 BIOS 中断。

DOS 中断只占用 0x21 这个中断号,也就是 DOS 只有这一个中断例程。

DOS 中断调用中那么多功能是如何实现的?是通过先往 ah 寄存器中写好子功能号,再执行 int 0x21。这时在中断向量表中第 0x21 个表项,即物理地址 0x21*4 处中的中断处理程序开始根据寄存器 ah 中的值来调用相应的子功能。

而 Linux 内核是在进入保护模式后才建立中断例程的,不过在保护模式下,中断向量表已经不存在了,取而代之的是中断描述符表。

Linux 的系统调用和 DOS 中断调用类似,不过 Linux 是通过 int 0x80 指令进入一个中断程序后再根据 eax 寄存器的值来调用不同的子功能函数的。再补充一句:如果在实模式下执行 int 指令,会自动去访问中断向量表。如果在保护模式下执行 int 指令,则会自动访问中断描述符表。

uCore 系统内存的探测

INT 15h 中断与 E820 参数

在我们分配物理内存空间前,我们必须要获取物理内存空间的信息 - 比如哪些地址空间可以使用,哪些地址空间不能使用等。在本实验中, 我们通过向 INT 15h 中断传入 e820h 参数来探测物理内存空间的信息(除了这种方法外,我们还可以使用其他的方法)。

下面我们来看一下 uCore 中物理内存空间的信息:

1
2
3
4
5
6
7
e820map:
memory: 0009fc00, [00000000, 0009fbff], type = 1.
memory: 00000400, [0009fc00, 0009ffff], type = 2.
memory: 00010000, [000f0000, 000fffff], type = 2.
memory: 07ee0000, [00100000, 07fdffff], type = 1.
memory: 00020000, [07fe0000, 07ffffff], type = 2.
memory: 00040000, [fffc0000, ffffffff], type = 2.

这里的 type 是物理内存空间的类型,1 是可以使用的物理内存空间, 2 是不能使用的物理内存空间。注意, 2 中的”不能使用”指的是这些地址不能映射到物理内存上, 但它们可以映射到 ROM 或者映射到其他设备,比如各种外设等。

实现过程

要使用这种方法来探测物理内存空间,我们必须将系统置于实模式下。因此, 我们在 bootloader 中添加了物理内存空间探测的功能。 这种方法获取的物理内存空间的信息是用内存映射地址描述符(Address Range Descriptor)来表示的,一个内存映射地址描述符占 20B,其具体描述如下:

00h 8 字节 base address #系统内存块基地址
08h 8 字节 length in bytes #系统内存大小
10h 4 字节 type of address range #内存类型
每探测到一块物理内存空间, 其对应的内存映射地址描述符就会被写入我们指定的内存空间(可以理解为是内存映射地址描述符表)。 当完成物理内存空间的探测后, 我们就可以通过这个表来了解物理内存空间的分布情况了。

INT15h BIOS 中断的详细调用参数:

  1. eax:e820h:INT 15 的中断调用参数;
  2. edx:534D4150h (即 4 个 ASCII 字符―SMAP) ,这只是一个签名
  3. ebx:如果是第一次调用或内存区域扫描完毕,则为 0。 如果不是,则存放上次调用之后的计数值;
  4. ecx:保存地址范围描述符的内存大小,应该大于等于 20 字节;
  5. es:di:指向保存地址范围描述符结构的缓冲区,BIOS 把信息写入这个结构的起始地址。

下面我们来看看 INT 15h 中断是如何进行物理内存空间的探测:

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
/* memlayout.h */
struct e820map {
int nr_map; //总共有几块内存
struct {
long long addr; //第i块内存块基地址
long long size; //第i块内存块大小
long type; //第i块内存块种类
} map[E820MAX];
};

/* bootasm.S */
probe_memory:
/* 在 0x8000 处存放 struct e820map, 并清除 e820map 中的 nr_map */
movl $0, 0x8000
xorl %ebx, %ebx
/* 0x8004 处将用于存放第一个内存映射地址描述符 */
movw $0x8004, %di
start_probe:
/* 传入 0xe820 作为 INT 15h 中断的参数 */
movl $0xE820, %eax
/* 内存映射地址描述符的大小 */
movl $20, %ecx
/* SMAP=534D4150h (即 4 个 ASCII 字符―SMAP) ,这只是一个签名 */
movl $SMAP, %edx
/* 调用 INT 15h 中断 */
int $0x15
/* 如果 eflags 的 CF 位为 0,则表示还有内存段需要探测 */
/* 如果该中断执行失败,则CF标志位会置1,此时要通知UCore出错 */
jnc cont
/* 向结构e820map中的成员nr_map中写入特殊信息,报告当前错误 */
movw $12345, 0x8000
jmp finish_probe
cont:
/* 设置下一个内存映射地址描述符的起始地址 */
addw $20, %di
/* e820map 中的 nr_map 加 1 */
incl 0x8000
/* 如果还有内存段需要探测则继续探测, 否则结束探测 */
cmpl $0, %ebx
jnz start_probe
finish_probe:

从上面代码可以看出,要实现物理内存空间的探测,大体上只需要 3 步:

设置一个存放内存映射地址描述符的物理地址(这里是 0x8000)

将 e820 作为参数传递给 INT 15h 中断

通过检测 eflags 的 CF 位来判断探测是否结束。如果没有结束, 设置存放下一个内存映射地址描述符的物理地址,然后跳到步骤 2;如果结束,则程序结束

uCore 地址空间划分

.ld 链接脚本语言

连接脚本的一个主要目的是描述输入文件中的节如何被映射到输出文件中,并控制输出文件的内存排布。

下面的脚本描述了使用该脚本链接的代码应当被载入到地址 0x10000 处, 而数据应当从 0x8000000 处开始。

1
2
3
4
5
6
7
8
SECTIONS
{
. = 0x10000;
.text : { *(.text) }
. = 0x8000000;
.data : { *(.data) }
.bss : { *(.bss) }
}

使用关键字SECTIONS写了这个 SECTIONS 命令, 后面跟有一串放在花括号中的符号赋值和输出节描述的内容.

上例中, 在SECTIONS命令中的第一行是对一个特殊的符号.赋值, 这是一个定位计数器. 如果你没有以其它的方式指定输出节的地址(其他方式在后面会描述), 那地址值就会被设为定位计数器的现有值. 定位计数器然后被加上输出节的尺寸. 在SECTIONS命令的开始处, 定位计数器拥有值0.

第二行定义一个输出节,.text. 冒号是语法需要,现在可以被忽略. 节名后面的花括号中,你列出所有应当被放入到这个输出节中的输入节的名字. ‘‘是一个通配符,匹配任何文件名. 表达式(.text)意思是所有的输入文件中的.text输入节.

因为当输出节.text定义的时候, 定位计数器的值是0x10000,连接器会把输出文件中的.text节的地址设为0x10000.

余下的内容定义了输出文件中的.data节和.bss节. 连接器会把.data输出节放到地址0x8000000处. 连接器放好.data输出节之后, 定位计数器的值是0x8000000加上.data输出节的长度. 得到的结果是连接器会把.bss输出节放到紧接.data节后面的位置.

连接器会通过在必要时增加定位计数器的值来保证每一个输出节具有它所需的对齐. 在这个例子中, 为.text.data节指定的地址会满足对齐约束, 但是连接器可能会需要在.data.bss节之间创建一个小的缺口。

kernel.ld 简析(脚本部分内容被省略)

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
ENTRY(kern_entry) // 定义函数入口为 kern_entry 函数

SECTIONS {
// 定义放置代码的起始虚拟地址为 0xC0100000 ,实际bootmain加载时全部与0xFFFFFF做了与运算才放入内存,所以实际上代码是被放在了0x100000处
. = 0xC0100000;

//0x100000后面紧接着是text代码段,其实最后也是跳转到这执行kern_entry函数
.text : {
*(.text .stub .text.* .gnu.linkonce.t.*)
}

PROVIDE(etext = .);

.rodata : {
*(.rodata .rodata.* .gnu.linkonce.r.*)
}

// ALIGN(x)表示当前地址使用x对齐,此处的x大小刚好是4k,就是一页的大小,也就是将后面的data段对齐到 一个新的页上
. = ALIGN(0x1000);

.data : {
*(.data)
}

// edata表示kernel的data段结束地址;end表示bss段的结束地址(即整个kernel的结束地址)
//edata[]和 end[]这些变量是ld根据kernel.ld链接脚本生成的全局变量,表示相应段的结束地址,它们不在任何一个.S、.c或.h文件中定义,但仍然可以在源码文件中使用。
PROVIDE(edata = .);

.bss : {
*(.bss)
}

PROVIDE(end = .);

//特殊的输出section,名为/DISCARD/,被该section引用的任何输入section将不会出现在输出文件内
/DISCARD/ : {
*(.eh_frame .note.GNU-stack)
}
}

uCore 地址空间

uCore

从 0x7c00 地址处开始看,往下在bootasm.S文件中 CPU 切换到保护模式的时候,movw $PROT_MODE_DSEG, %axmovw %ax, %ss两句指令设置了栈段段描述符,movl $start, %esp设置了段基址为 start 段的起始地址也就是 0x7c00。x86 CPU 栈往低地址处生长且后面都没有设置栈寄存器的值,故 0x7c00 向下的地址被 bootloader 和 uCore 共用用作栈。

从 0x7c00 地址处往上看,boot.ld脚本指出了所有 bootloader 的 text、data 段放在 0x7c00 的后面。

0x10000 地址在bootmain.c中,使用#define ELFHDR ((struct elfhdr *)0x10000)被定义为 ELF 文件头读入的地方(后面有一些空闲地址,所以足够存放 ELF header)。

0x100000 地址最初被定义在kernel.ld中但值为0xc0100000,在链接的时候被放到了 ELF 头中的 program header 中。最后被bootmain.c读入,bootmain 加载时全部与 0xFFFFFF 做了与运算才放入内存,所以实际上代码是被放在了 0x100000 处,存放 uCore text 和 data 段的内容。

在 uCore 代码结束的地方在内存初始化函数pmm_init()中被用来放页表。

虽然低地址处有一些空闲地址( 0x10000 前后),可以划分一些空闲页出来,但为了方便起见不进行划分,因为总共到 BIOS ROM 也只占用了 1MB 的内存,故内存浪费不可能超过 1MB。

为啥偏移是0xC000000

地址映射

在这个实验中,我们在 4 个不同的阶段进行了 4 次地址映射, 下面我就分别介绍这 4 次地址映射。

第一阶段

这一阶段是从 bootasm.S 的 start 到 entry.S 的 kern_entry 前。这个阶段只是简单的设置了段表,和 lab1 一样(这时的 GDT 中每个段的起始地址都是 0x00000000 并且此时 kernel 还没有载入)。

1
2
3
4
5
6
7
8
9
gdt:
SEG_NULLASM # null seg
SEG_ASM(STA_X|STA_R, 0x0, 0xffffffff) # code seg for bootloader and kernel
SEG_ASM(STA_W, 0x0, 0xffffffff) # data seg for bootloader and kernel

gdtdesc:
.word 0x17 # sizeof(gdt) - 1
.long gdt # address gdt

virt addr = linear addr = phy addr

第二阶段

这个阶段就是从 entry.S 的 kern_entry 到 pmm.c 的 page_init()。 这个阶段开启了页机制并简单的设置了一个页表。将虚拟地址 0xC0000000 ~ 0xC0000000 +4M 映射到物理地址 0 ~ 4M。暂时解决了 OS 代码中地址为 0xC0000000+x 但实际物理地址在 x 的问题

下面介绍页表开启过程。

CR0 中包含了 6 个预定义标志,0 位是保护允许位 PE(Protedted Enable),用于启动保护模式,如果 PE 位置 1,则保护模式启动,如果 PE=0,则在实模式下运行。1 位是监控协处理位 MP(Moniter coprocessor),它与第 3 位一起决定:当 TS=1 时操作码 WAIT 是否产生一个“协处理器不能使用”的出错信号。第 3 位是任务转换位(Task Switch),当一个任务转换完成之后,自动将它置 1。随着 TS=1,就不能使用协处理器。CR0 的第 2 位是模拟协处理器位 EM (Emulate coprocessor),如果 EM=1,则不能使用协处理器,如果 EM=0,则允许使用协处理器。第 4 位是微处理器的扩展类型位 ET(Processor Extension Type),其内保存着处理器扩展类型的信息,如果 ET=0,则标识系统使用的是 287 协处理器,如果 ET=1,则表示系统使用的是 387 浮点协处理器。CR0 的第 31 位是分页允许位(Paging Enable),它表示芯片上的分页部件是否允许工作。

CR3 是页目录基址寄存器,保存页目录表的物理地址,页目录表总是放在以 4K 字节为单位的存储器边界上,因此,它的地址的低 12 位总为 0,不起作用,即使写上内容,也不会被理会。

1
2
/* Load the kernel at this address: "." means the current address */
. = 0xC0100000;

连接文件将 kernel 链接到了 0xC0100000,这个地址是 kernel 的虚拟地址。CR3 寄存器使用的地址是实际物理地址,所以需要使用宏将虚地址转变为实际物理地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#define KERNBASE    0xC0000000
#define REALLOC(x) (x - KERNBASE)

kern_entry:
# load pa of boot pgdir
movl $REALLOC(__boot_pgdir), %eax
movl %eax, %cr3 //将页目录基址载入CR3寄存器

# enable paging
movl %cr0, %eax
orl $(CR0_PE | CR0_PG | CR0_AM | CR0_WP | CR0_NE | CR0_TS | CR0_EM | CR0_MP), %eax
andl $~(CR0_TS | CR0_EM), %eax
movl %eax, %cr0 //重新设置CR0寄存器,开启分页

# update eip
# now, eip = 0x1.....
leal next, %eax
# set eip = KERNBASE + 0x1.....
jmp *%eax
next:
//设置完eip后立马umap
# unmap va 0 ~ 4M, it's temporary mapping
xorl %eax, %eax
movl %eax, __boot_pgdir

之所以设置 0 ~ 4M - 0 ~ 4M 的映射,后面又立马取消是因为。在开启分页的时候,eip 指向当前执行命令的地址(小于 4M 的某个地址),如果没有设置 0 ~ 4M 的映射,让 eip 继续往下走,CPU 不知道 0 ~ 4M 对应哪一个物理地址肯定崩。所以先设置好地址,保证开启后程序不崩溃,之后通过跳转到 next ,eip 的值被设置为 next 的地址。因为 next 被定义在 kernel 里这是一个 3G 以上的内存地址,所以 eip 就被设置成了 3G 以上的地址,可以正确的往下执行。在跳转后原来的 0 ~ 4M 就被立刻置为 0 删除之。

下面来看载入的页目录表和页表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
.align PGSIZE  //页目录必须按页大小对齐,整个页目录占一个页
__boot_pgdir:
.globl __boot_pgdir
// 将第一个页目录项对应的地址映射到0 ~ 4M。map va 0 ~ 4M to pa 0 ~ 4M (temporary)
.long REALLOC(__boot_pt1) + (PTE_P | PTE_U | PTE_W)
// 填充剩余表项直到对应虚地址0xC0000000的地方
.space (KERNBASE >> PGSHIFT >> 10 << 2) - (. - __boot_pgdir) # pad to PDE of KERNBASE
// 将0xC0000000~0xC0000000+4M的虚地址映射到0 ~ 4M。 map va KERNBASE + (0 ~ 4M) to pa 0 ~ 4M
.long REALLOC(__boot_pt1) + (PTE_P | PTE_U | PTE_W)
// .space申请一段空间将整个页目录填满(否则有一些OS代码可能会被安排到这个页目录的剩余空间)
.space PGSIZE - (. - __boot_pgdir)

.set i, 0 //通过循环的方式设置了对应虚地址 - 0~4M 的映射 (这个页表放哪个页表项,哪个页表项就指向0~4M)
__boot_pt1:
.rept 1024
.long i * PGSIZE + (PTE_P | PTE_W)
.set i, i + 1
.endr

这个阶段结束后,OS 的代码段(0xC0000000 ~ 0xC0000000+4M),三个地址的关系是:

virt addr = linear addr = phy addr + 0xC0000000

第三阶段

这个阶段就是 pmm.c 的 boot_map_segment() 函数, 将 0x300000000x38000000 这段线性地址映射到了物理地址 0x000000000x08000000。

在解释这个函数前先对 OS 管理内存的数据结构Page进行介绍。

在 OS 中每一个页都有一个 Page 的数据结构进行管理

1
2
3
4
5
6
struct Page {
int ref; // page frame's reference counter
uint32_t flags; // array of flags that describe the status of the page frame
unsigned int property; // the num of free block, used in first fit pm manager
list_entry_t page_link; // free list link
};

记录引用次数、页面状态,并有用于管理空闲内存的链接指针。在执行boot_map_segment前,函数page_init对操作系统的页表进行了初始化(一共有两个页表,一个是操作系统用来管理内存所建立的 c 语言数据结构,另外一个是给 CPU 用来做地址转换的内存数据)。pmm.c 中定义了 pages 变量用来存储Page数据组的首地址。该数组放在 OS 代码结束的地方。

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
58
#define KMEMSIZE 0x38000000 //the maximum amount of physical memory

page_init(void) {
struct e820map *memmap = (struct e820map *)(0x8000 + KERNBASE);
uint64_t maxpa = 0;

//遍历探测出的内存块,找出可用的最大物理地址
for (int i = 0; i < memmap->nr_map; i ++) {
uint64_t begin = memmap->map[i].addr, end = begin + memmap->map[i].size;
if (memmap->map[i].type == E820_ARM)
if (maxpa < end && begin < KMEMSIZE)
maxpa = end;
}

// 最大物理地址不能超出KMEMSIZE的限定
if (maxpa > KMEMSIZE) {
maxpa = KMEMSIZE;
}

// 该值由ld链接脚本提供,这个地址是os代码结束的地址
extern char end[];

//计算需要分页的数量
npage = maxpa / PGSIZE;

//将os代码结束地址向上取整拿来放页表
pages = (struct Page *)ROUNDUP((void *)end, PGSIZE);

//为了简化起见,从物理0地址到页表结束的地址pages+ sizeof(struct Page) * npage)
//设定为已占用物理内存空间(起始0~640KB的空间是空闲的)
//地址pages+ sizeof(struct Page) * npage)以上的空间为空闲物理内存空间
//这为了方便把所有的页都设置为已占用,后面再改回来
for (i = 0; i < npage; i ++) {
SetPageReserved(pages + i);
}

//第一个可用的空闲页地址
uintptr_t freemem = PADDR((uintptr_t)pages + sizeof(struct Page) * npage);

//对上面所有探测到的内存卡
for (i = 0; i < memmap->nr_map; i ++) {
uint64_t begin = memmap->map[i].addr, end = begin + memmap->map[i].size;
//如果该内存块是可用内存
if (memmap->map[i].type == E820_ARM) {
if (begin < freemem)
begin = freemem;
if (end > KMEMSIZE)
end = KMEMSIZE;
if (begin < end) {
begin = ROUNDUP(begin, PGSIZE);
end = ROUNDDOWN(end, PGSIZE);
if (begin < end)
//(起始地址对应的页表,总共有几页)进行页表的初始化
init_memmap(pa2page(begin), (end - begin) / PGSIZE);
}
}
}
}

在函数page_init()执行完成后,我们完成了操作系统页表的建立,注意这个页表只是操作系统用来管内存的,其具体的映射关系是:

pages[物理地址>>12] = 该物理地址所在页面的使用情况

下面的boot_map_segment函数填写线性地址到物理地址的映射关系,get_pte 返回该线性地址所在的页面(没有对应的页面向 OS 申请一个)。从 0x30000000 开始,一页一页的填地址映射关系(for 以页为单位迭代)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// map all physical memory to linear memory with base linear addr KERNBASE
// linear_addr KERNBASE ~ KERNBASE + KMEMSIZE = phy_addr 0 ~ KMEMSIZE
boot_map_segment(boot_pgdir, KERNBASE, KMEMSIZE, 0, PTE_W);

//boot_map_segment - setup&enable the paging mechanism
// parameters
// la: linear address of this memory need to map (after x86 segment map)
// size: memory size
// pa: physical address of this memory
// perm: permission of this memory
static void
boot_map_segment(pde_t *pgdir, uintptr_t la, size_t size, uintptr_t pa, uint32_t perm) {
size_t n = ROUNDUP(size + PGOFF(la), PGSIZE) / PGSIZE;
la = ROUNDDOWN(la, PGSIZE);
pa = ROUNDDOWN(pa, PGSIZE);
for (; n > 0; n --, la += PGSIZE, pa += PGSIZE) {
pte_t *ptep = get_pte(pgdir, la, 1);
*ptep = pa | PTE_P | perm;
}
}

完成这个函数后 0x300000000x38000000 这段线性地址被映射到了物理地址 0x000000000x08000000

下面简单总结一下各个数据结构在内存中的位置:

  1. 页目录表:占一页大小,在entry.S里被定义在了内核代码的数据段。
  2. OS 管理页面结构数组 pages : 根据探测到的内存在 OS 代码结尾处进行分配。
  3. 页表:空闲内存中(pages 结束向上的空闲内存)

再介绍下get_pte函数作为练习 2 的 lab

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
pte_t * get_pte(pde_t *pgdir, uintptr_t la, bool create) {
// 获取传入的线性地址中所对应的页目录条目的物理地址
pde_t *pdep = &pgdir[PDX(la)];
// 如果该条目不可用(not present)
if (!(*pdep & PTE_P)) {
struct Page *page;
// 如果分配页面失败,或者不允许分配,则返回NULL
// alloc_page 返回的是pages数组中的page地址
if (!create || (page = alloc_page()) == NULL)
return NULL;
// 设置该物理页面的引用次数为1
set_page_ref(page, 1);
// 获取当前page所管理的物理地址
uintptr_t pa = page2pa(page);
// 清空该物理页面的数据。需要注意的是使用虚拟地址
memset(KADDR(pa), 0, PGSIZE);
// 将新分配的页面设置为当前缺失的页目录条目中
// 之后该页面就是其中的一个二级页面
*pdep = pa | PTE_U | PTE_W | PTE_P;
}
// 返回在pgdir中对应于la的二级页表项
return &((pte_t *)KADDR(PDE_ADDR(*pdep)))[PTX(la)];
}

第四阶段

第四阶段为 pmm.c 的gdt_init()函数设置了新的段表,相比旧段表只有内核代码段和内核数据段增加了用户的代码和内核段。建立起了整个操作系统的内存模型

1
2
3
4
gdt:
SEG_NULLASM # null seg
SEG_ASM(STA_X|STA_R, 0x0, 0xffffffff) # code seg for bootloader and kernel
SEG_ASM(STA_W, 0x0, 0xffffffff) # data seg for bootloader and kernel
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/* *
* Global Descriptor Table:
*
* The kernel and user segments are identical (except for the DPL). To load
* the %ss register, the CPL must equal the DPL. Thus, we must duplicate the
* segments for the user and the kernel. Defined as follows:
* - 0x0 : unused (always faults -- for trapping NULL far pointers)
* - 0x8 : kernel code segment
* - 0x10: kernel data segment
* - 0x18: user code segment
* - 0x20: user data segment
* - 0x28: defined for tss, initialized in gdt_init
* */
static struct segdesc gdt[] = {
SEG_NULL,
[SEG_KTEXT] = SEG(STA_X | STA_R, 0x0, 0xFFFFFFFF, DPL_KERNEL),
[SEG_KDATA] = SEG(STA_W, 0x0, 0xFFFFFFFF, DPL_KERNEL),
[SEG_UTEXT] = SEG(STA_X | STA_R, 0x0, 0xFFFFFFFF, DPL_USER),
[SEG_UDATA] = SEG(STA_W, 0x0, 0xFFFFFFFF, DPL_USER),
[SEG_TSS] = SEG_NULL,
};

总结

经过了上面的四个阶段,下面根据这张图对内存地址的映射关系进行描述,因为在实验中我们设置了最大只能管理 896M 的内存(KMEMSIZE),所以整个物理地址空间只是从 0~896M。

首先从虚拟地址到线性地址的映射,该阶段由段表完成,阶段 1、4 设置了段表,但全是恒等映射,虚拟地址永远等于线性地址不变。两个阶段的不同之处只是在于阶段 4 增加了用户的代码数据段。

然后就是从线性地址到实际物理地址的映射,阶段 2 将线性地址 3G ~ 3G +4M 映射到物理地址 0 ~ 4M(在阶段 3 被保存了下来)。阶段 3 将线性地址 3G ~ 3G + 896M 映射到物理地址 0 ~ 896M。

一直到页表结束,空闲内存开始的地方的物理地址应该是 < 4M 的,所以在这个实验中,所有的虚地址都是从 3G 以上开始,被映射到了 0 ~ 896M

自映射机制

页表自映射的思想是,把所有的页表(4KB)放到连续的 4MB 虚拟地址 空间中,并且要求这段空间 4MB 对齐,这样,会有一张页表的内容与页目录的内容完全相同,从而节省了页目录的空间。代价则是需要从虚拟地址空间中分配出连续的 4MB 对齐的 4MB 的空间。

自映射

自映射时的页表结构

上图中,页表和页目录都位于虚拟地址空间的连续内存中,换句话说,这 4MB 的页表可以对应到虚拟地址空间的一个 Table Frame 中。

我们采用上图中的术语描述所谓的自映射关系。

  1. 页表位于 Table Frame x
  2. Table Frame x 中的每个 4KB 大小的 Page Frame 0-1023 分别存储了页表的 Table 0-1023
  3. 页目录的 Entry 0-1023 也需要分别指向页表的 Table 0-1023。
  4. Table x 指向了 Table Frame x。(注意这里的 x,是相同的 x)
  5. Table x 中的页表项(Page Table Entry, PTE)0-1023,分别指向 Table Frame x 的 1024 个 Page Frame(4KB),也就是 Table 0-1023(根据第【2】条)。
  6. Table x 等价于页目录。页目录中的 Entry x 指向 Table x。这就被称为自映射,节省了页目录的 4KB 空间。

就是说 Table x 和页目录里面的内容相同,既然相同,Table x 也不用进行分配,直接在 Entry x 填 page dictionary 就行。

所以可以观察到,OS 先将那个 Entry x 填上自己的地址,再进行页表的初始化操作:

1
2
3
4
5
6
7
// recursively insert boot_pgdir in itself
// to form a virtual page table at virtual address VPT
boot_pgdir[PDX(VPT)] = PADDR(boot_pgdir) | PTE_P | PTE_W;

// map all physical memory to linear memory with base linear addr KERNBASE
// linear_addr KERNBASE ~ KERNBASE + KMEMSIZE = phy_addr 0 ~ KMEMSIZE
boot_map_segment(boot_pgdir, KERNBASE, KMEMSIZE, 0, PTE_W);