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。
- 第一级表称为页目录(page directory)。它被存放在 1 页 4K 页面中,具有 $2^{10}$(1K)个 4B 长度的表项。这些表项指向对应的二级表。线性地址的最高 10 位(位 31 ~ 22)用作一级表(页目录)中的索引值来选择 $2^{10}$ 个二级表之一。
- 第二级表称为页表(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 为什么添加中断处理例程呢?
给自己用,因为 BIOS 也是一段程序,是程序就很可能要重复性地执行某段代码,它直接将其写成中断函数,直接调用多省心。
给后来的程序用,如加载器或 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 | e820map: |
这里的 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 中断的详细调用参数:
- eax:e820h:INT 15 的中断调用参数;
- edx:534D4150h (即 4 个 ASCII 字符―SMAP) ,这只是一个签名
- ebx:如果是第一次调用或内存区域扫描完毕,则为 0。 如果不是,则存放上次调用之后的计数值;
- ecx:保存地址范围描述符的内存大小,应该大于等于 20 字节;
- es:di:指向保存地址范围描述符结构的缓冲区,BIOS 把信息写入这个结构的起始地址。
下面我们来看看 INT 15h 中断是如何进行物理内存空间的探测:
1 | /* memlayout.h */ |
从上面代码可以看出,要实现物理内存空间的探测,大体上只需要 3 步:
设置一个存放内存映射地址描述符的物理地址(这里是 0x8000)
将 e820 作为参数传递给 INT 15h 中断
通过检测 eflags 的 CF 位来判断探测是否结束。如果没有结束, 设置存放下一个内存映射地址描述符的物理地址,然后跳到步骤 2;如果结束,则程序结束
uCore 地址空间划分
.ld 链接脚本语言
连接脚本的一个主要目的是描述输入文件中的节如何被映射到输出文件中,并控制输出文件的内存排布。
下面的脚本描述了使用该脚本链接的代码应当被载入到地址 0x10000 处, 而数据应当从 0x8000000 处开始。
1 | SECTIONS |
使用关键字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 | ENTRY(kern_entry) // 定义函数入口为 kern_entry 函数 |
uCore 地址空间
从 0x7c00 地址处开始看,往下在bootasm.S
文件中 CPU 切换到保护模式的时候,movw $PROT_MODE_DSEG, %ax
、movw %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 | 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 | /* Load the kernel at this address: "." means the current address */ |
连接文件将 kernel 链接到了 0xC0100000,这个地址是 kernel 的虚拟地址。CR3 寄存器使用的地址是实际物理地址,所以需要使用宏将虚地址转变为实际物理地址。
1 |
|
之所以设置 0 ~ 4M - 0 ~ 4M 的映射,后面又立马取消是因为。在开启分页的时候,eip 指向当前执行命令的地址(小于 4M 的某个地址),如果没有设置 0 ~ 4M 的映射,让 eip 继续往下走,CPU 不知道 0 ~ 4M 对应哪一个物理地址肯定崩。所以先设置好地址,保证开启后程序不崩溃,之后通过跳转到 next ,eip 的值被设置为 next 的地址。因为 next 被定义在 kernel 里这是一个 3G 以上的内存地址,所以 eip 就被设置成了 3G 以上的地址,可以正确的往下执行。在跳转后原来的 0 ~ 4M 就被立刻置为 0 删除之。
下面来看载入的页目录表和页表:
1 | .align PGSIZE //页目录必须按页大小对齐,整个页目录占一个页 |
这个阶段结束后,OS 的代码段(0xC0000000 ~ 0xC0000000+4M),三个地址的关系是:
virt addr = linear addr = phy addr + 0xC0000000
第三阶段
这个阶段就是 pmm.c 的 boot_map_segment()
函数, 将 0x300000000x38000000 这段线性地址映射到了物理地址 0x000000000x08000000。
在解释这个函数前先对 OS 管理内存的数据结构Page
进行介绍。
在 OS 中每一个页都有一个 Page 的数据结构进行管理
1 | struct Page { |
记录引用次数、页面状态,并有用于管理空闲内存的链接指针。在执行boot_map_segment
前,函数page_init
对操作系统的页表进行了初始化(一共有两个页表,一个是操作系统用来管理内存所建立的 c 语言数据结构,另外一个是给 CPU 用来做地址转换的内存数据)。pmm.c 中定义了 pages 变量用来存储Page
数据组的首地址。该数组放在 OS 代码结束的地方。
1 |
|
在函数page_init()
执行完成后,我们完成了操作系统页表的建立,注意这个页表只是操作系统用来管内存的,其具体的映射关系是:
pages[物理地址>>12] = 该物理地址所在页面的使用情况
下面的boot_map_segment
函数填写线性地址到物理地址的映射关系,get_pte
返回该线性地址所在的页面(没有对应的页面向 OS 申请一个)。从 0x30000000 开始,一页一页的填地址映射关系(for 以页为单位迭代)
1 | // map all physical memory to linear memory with base linear addr KERNBASE |
完成这个函数后 0x300000000x38000000 这段线性地址被映射到了物理地址 0x000000000x08000000
下面简单总结一下各个数据结构在内存中的位置:
- 页目录表:占一页大小,在
entry.S
里被定义在了内核代码的数据段。 - OS 管理页面结构数组 pages : 根据探测到的内存在 OS 代码结尾处进行分配。
- 页表:空闲内存中(pages 结束向上的空闲内存)
再介绍下get_pte
函数作为练习 2 的 lab
1 | pte_t * get_pte(pde_t *pgdir, uintptr_t la, bool create) { |
第四阶段
第四阶段为 pmm.c 的gdt_init()
函数设置了新的段表,相比旧段表只有内核代码段和内核数据段增加了用户的代码和内核段。建立起了整个操作系统的内存模型
1 | gdt: |
1 | /* * |
总结
经过了上面的四个阶段,下面根据这张图对内存地址的映射关系进行描述,因为在实验中我们设置了最大只能管理 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 中。
我们采用上图中的术语描述所谓的自映射关系。
- 页表位于 Table Frame x
- Table Frame x 中的每个 4KB 大小的 Page Frame 0-1023 分别存储了页表的 Table 0-1023
- 页目录的 Entry 0-1023 也需要分别指向页表的 Table 0-1023。
- Table x 指向了 Table Frame x。(注意这里的 x,是相同的 x)
- Table x 中的页表项(Page Table Entry, PTE)0-1023,分别指向 Table Frame x 的 1024 个 Page Frame(4KB),也就是 Table 0-1023(根据第【2】条)。
- Table x 等价于页目录。页目录中的 Entry x 指向 Table x。这就被称为自映射,节省了页目录的 4KB 空间。
就是说 Table x 和页目录里面的内容相同,既然相同,Table x 也不用进行分配,直接在 Entry x 填 page dictionary 就行。
所以可以观察到,OS 先将那个 Entry x 填上自己的地址,再进行页表的初始化操作:
1 | // recursively insert boot_pgdir in itself |