UCORE开启虚拟存储器过程详解
声明:转载请注明原作者以及出处并附上链接
前言
本文是对ucore里面从开启到完成内核虚拟存储器(lab1-lab2)的一个总结和详解,其中有大量的内容分别来自于以下的地方,向原作者表示感谢
文中绝大部分代码的来源(以及大部分代码的注释和日志语句):
https://github.com/libinyl/lcore ucore注释版
ucore的实验指导书
关于tss:
https://blog.csdn.net/q1007729991/article/details/52650822 13-任务状态段(TSS)
关于lab2:
http://blog.gqylpy.com/gqy/24219/ lab2
https://segmentfault.com/a/1190000009450840 lab2
https://twinkle0331.github.io/ucore-lab2.html lab2
值得注意的是,ucore其实在市面上其实是有两个版本的,而这两个版本在我们要讲的这一段内的区别还是比较显著的。(一个版本内存有4个阶段的映射变化,一个有3个阶段),我们这里按照3个阶段的版本来叙述,但中心思想和大部分函数其实是一样的。
建议
首先你需要做过lab2,或者知道一个大概lab2做了什么,不然你会根本看不懂
如果有哪里看不懂,可以考虑先补一下下面的内容:
- 操作系统(废话)
- elf文件格式
- 程序的链接
- 高地址内核映射
- 链接脚本的基本格式
- 实验指导书
链接脚本解析
因为各个段在可执行程序中的定位是由链接器规定的,所以首先我们要来看看链接脚本。
链接,简单的来说,就是规定不同的段在可执行文件中被放在哪个虚拟地址。
在本项目中,有boot.ld和kernel.ld两个链接脚本需要注意
前者是bootloader的链接脚本,后者是内核的脚本,通过将可重定位文件链接,生成可执行的文件。
先来放两个链接脚本的注解:
/DISCARD/ : {*(.comment)} //将输入文件的comment段丢弃
PROVIDE//该关键字定义一个(目标文件内被引用但没定义)符号。相当于定义一个全局变量,其他C文件可以引用它。
请特别注意这个第二个符号,在链接文件里会定义一些全局变量之后会被引用,通常是一些虚拟地址
先来看boot.ld
OUTPUT_FORMAT("elf32-i386")
OUTPUT_ARCH(i386)//目标机器格式和架构,写在头里面
/**虚拟地址结构
.data
* .startup
* 0x7C00 .text
*/
//定义段
SECTIONS {
//.代表目前的地址
. = 0x7C00;
//把startup这个段放在0x7c00这里,这个段是bootasm.o的代码段
.startup : {
*bootasm.o(.text)
}
.text : { *(.text) }
.data : { *(.data .rodata) }
/DISCARD/ : { *(.eh_*) }
}
从上面的链接文件我们可以看到,这个叫startup的段里面是bootasm.o的代码段,被链接在了0x7c00这个虚拟地址,我们知道,这也是bootloader的起始地址。
这样,当机器把bootloader加载进来时
物理地址=虚拟地址
当然这个时候无论是分段还是分页还都没有开,无论怎样映射都不会涉及到地址的转换。
物理地址在一开始的分布
下面再来看kernel.ld文件
OUTPUT_FORMAT("elf32-i386", "elf32-i386", "elf32-i386")
OUTPUT_ARCH(i386)
//入口地址为kern_entry
ENTRY(kern_entry)
SECTIONS {
//注意,kernel被链接在了这个地址
. = 0xC0100000;
.text : {
*(.text .stub .text.* .gnu.linkonce.t.*)
}
PROVIDE(etext = .); //定义etext为这个(虚拟)地址
.rodata : {
*(.rodata .rodata.* .gnu.linkonce.r.*)
}
.......
//中间省略,定义了一些数据段,调试用等等以及一些段的位置信息
.bss : {
*(.bss)
}
PROVIDE(end = .);/* bootloader加载ucore的结束地址 */
/DISCARD/ : {
*(.eh_frame .note.GNU-stack)
}
}
可以看到ucore的代码段的虚拟地址被链接到了0xc0100000,此外,在这个链接脚本里面定义了一系列的全局变量,如end等,在后文会用到。
bootloader解析
bootloader概念
以Intel 80386为例,计算机开机,CPU从物理地址0xFFFFFFF0(物理地址0xFFFFFFF0(由初始化的CS:EIP确定,此时CS和IP的值分别是0xF000和0xFFF0))开始执行,在0xFFFFFFF0这里只是存放了一条跳转指令,通过跳转指令跳到BIOS例行程序起始点。cpu将bios加载之后执行bios,bios完成必要的工作之后将主引导扇区(bootloader)加载进内存,代码段加载在0x7c00,并跳到这里开始执行
bootloader由bootasm.s和bootmain.c构成,在磁盘的第一扇区(主引导扇区)
第二扇区及之后就是内核的elf(可执行目标文件)程序
注意:最开始机器工作在16的实模式,我们需要先打开段映射,将机器开启保护模式,进入32位的寻址空间。
因为刚开始只有16位,(地址总线为20位)所以只有(2^20)=1MB的寻址空间,所以刚开始不能直接把内核加载进来
bootasm.s解析
理解了这些,我们下面来看bootasm.s :系统就是从这里来执行bootloader的。
#刚开始4行定义了一些宏
.set PROT_MODE_CSEG, 0x8 #protect mode code segment,代码段选择子
.set PROT_MODE_DSEG, 0x10 #protect mode data segment,数据段选择子
.set CR0_PE_ON, 0x1 # 保护模式位 protected mode enable flag
.set SMAP, 0x534d4150
#下面是代码段
.globl start
start:
.code16 # 当前 CPU 处于实模式,所以需要在 16-bit 模式下编译.
cli # 关中断, 防止干扰开启A20, 和保证设置GDT的完整性
cld # 设置字符串操作是递增方向(direct flag标志位清零)
# 设置重要的段寄存器(置零)(DS, ES, SS),段映射为对等映射
xorw %ax, %ax # Segment number zero
movw %ax, %ds # -> Data Segment
movw %ax, %es # -> Extra Segment
movw %ax, %ss # -> Stack Segment
# 开启 A20:代码省略
# 为了兼容向下,一开始处在16位的实模式,20以上的总线无法使用
# A20地址线并不是打开保护模式的关键, 但若不开A20, 无法使用1MB以上的内存
# 探测内存分布:
probe_memory:
movl $0, 0x8000
#注意这里,内存分布实际上生成了一个数组,放在了0x8000这个位置,之后会用
xorl %ebx, %ebx
movw $0x8004, %di
start_probe:
movl $0xE820, %eax
movl $20, %ecx
movl $SMAP, %edx
int $0x15
jnc cont
movw $12345, 0x8000
jmp finish_probe
cont:
addw $20, %di
incl 0x8000
cmpl $0, %ebx
jnz start_probe
finish_probe:
# 用lgdt加载段表,之后才从实模式切换到保护模式.
#但这里的gdt是一个对等映射的段表,即物理地址=虚拟地址,所以地址映射不会改变
lgdt gdtdesc
# CR0中包含了6个预定义标志,0位是保护允许位PE(Protedted Enable),用于启动保护模式,如果PE位置1,则保护模式启动.
#下面是通过控制寄存器cr0开启保护模式
movl %cr0, %eax
orl $CR0_PE_ON, %eax
movl %eax, %cr0
# 跳转到下一个指令,但是是在 32 位模式下.
# 必须用 ljmp, 长转移指令,相对跳转
#由于上面的代码已经打开了保护模式了,所以这里要使用逻辑地址,而不是之前实模式的地址了。
#这里用到了PROT_MODE_CSEG, 他的值是0x8。根据段选择子的格式定义,0x8就翻译成:
INDEX TI CPL
0000 0000 0000 1 00 0
#INDEX代表GDT中的索引,TI代表使用GDTR中的GDT, CPL代表处于特权级。
#PROT_MODE_CSEG选择子选择了GDT中的第1个段描述符。这里使用的gdt就是变量gdt,下面可以看到gdt的第1个段描述符的基地址是0x0000,所以经过映射后和转换前的内存映射的物理地址一样。
#跳到$PROT_MODE_CSEG段的偏移为 $protcseg的地方,ljmp(基质,偏移)
ljmp $PROT_MODE_CSEG, $protcseg
.code32 # 这一行让汇编器从此处开始汇编为32位的代码
#$protcseg在这里
protcseg:
# 设置保护模式数据段寄存器
# 每个数据段选择子设置为0x10.也就是数据段的段选择子
# 而所有数据段都位于 GDT 的第 2 项,所以把高 12 位设置为 2.
movw $PROT_MODE_DSEG, %ax
movw %ax, %ds # -> DS: Data Segment
movw %ax, %es # -> ES: Extra Segment
movw %ax, %fs # -> FS
movw %ax, %gs # -> GS
movw %ax, %ss # -> SS: Stack Segment
#这里设置了栈
# 设置 sp(栈指针)为调用 C 代码.注意栈是向下生长的,所以栈是贴着数据段向下长,数据段向上(就是0x7c00这里,下面有图可以返回来看看)
# 栈基址 = 0
# 栈指针 = start(0x7c00)
movl $0x0, %ebp
movl $start, %esp
# ok, 32位保护模式环境已经构建完毕(寄存器,GDT,保护模式位)
call bootmain #开始bootmain.c主函数
# bootmain 不应返回至此.
spin:
jmp spin
# 截至加载 GDT 之前,整个物理内存的分段情况是base=0,limit=0FFFFH,即一大块,相当于没有分段.
#下面是数据段
.data
# Bootstrap 阶段 GDT,维护了 段选择子索引+段区间.寻址时,段寄存器拿着索引来找段区间,再加上偏移地址定位.
.p2align 2 # 强制 4 byte 对齐
#这里面是真正的段表
gdt:
SEG_NULLASM # 第 0 项, null seg
SEG_ASM(STA_X|STA_R, 0x0, 0xffffffff) # 第 1 项, bootloader 和 kernel 的代码段, type = exec,read, base = 0 ,limit = 0xffffffff = 4GB
SEG_ASM(STA_W, 0x0, 0xffffffff) # 第 2 项, bootloader 和 kernel 的数据段, type = write, base = 0 ,limit = 0xffffffff = 4GB
#这里就是lgdt里面load进来的东西,第一项是一个长度,第二项是gdt的地址
gdtdesc:
.word 0x17 # sizeof(gdt) - 1
.long gdt # address gdt
可以看出来,上面的操作中从实模式到了保护模式,并在开启前加载了段表以供保护模式使用(还开辟了栈供c语言程序使用)。不过段表是等值映射,所以还是
虚拟地址=线性地址=物理地址
最后跳转到了bootmain.c的main函数里面
bootmain.c解析
自然而然地,接下来我们来看bootmain.c
在这个函数里,我们要将操作系统内核读入物理地址
具体:先读入elf头,再根据头读入各段到1m开始的地址中
//按照惯例,开头定义宏
#define SECTSIZE 512//扇区的大小
//elf文件头的地址,elf头中包含了这个程序的各个段的信息,版本,等等信息
//注意elf头是10000四个0,之后读入内核到100000五个0
#define ELFHDR ((struct elfhdr *)0x10000)
/* waitdisk - 等待磁盘就绪*/
static void
waitdisk(void)
/* readsect - 从secno的地方读一个扇区到dst 函数体省略*/
static void
readsect(void *dst, uint32_t secno)
/* readseg
* 从内核的offset处读取 count 个字节到虚拟地址 va. 扇区号=(offset / SECTSIZE) + 1. kenerl 位于 1 号.
* 封装了对于 va 的处理
* 函数体省略
*/
static void
readseg(uintptr_t va, uint32_t count, uint32_t offset)
//从这里开始执行
void bootmain(void) {
// 读取 1 号磁盘(即 kernel 位于的磁盘)上4KB=1PAGE 的内容到 elf header 处就位
readseg((uintptr_t)ELFHDR, SECTSIZE * 8, 0);
// 通过elf文件头魔数判断格式是否为elf
if (ELFHDR->e_magic != ELF_MAGIC) {
goto bad;
}
struct proghdr *ph, *eph;
// 把每个 program 加载到其期待被加载到的虚拟地址位置.
//即elfhdr的位置加上elfheader里面规定的各段的偏移量
//e_phnum: number of program headers 程序头表项数
//具体了解可以去看看elf文件格式
ph = (struct proghdr *)((uintptr_t)ELFHDR + ELFHDR->e_phoff);
eph = ph + ELFHDR->e_phnum;
//遍历程序头表
for (; ph < eph; ph ++) {
// 参考 lab2 附录C
// ph->p_va = 0xC0100000 = 3073M,用0xFFFFFF 取低 24 位,得到0x00100000,即实际内核代码在内存的位置从 1M 位置开始
//代码段从1m的物理地址位置开始,但其实内核的虚拟地址还要在往上0xc0000000的地方,即将虚拟空间的0xc0100000往上映射到了0x0100000的物理地址
readseg(ph->p_va & 0xFFFFFF, ph->p_memsz, ph->p_offset);
}
// 调用 elf header 指定的 entry point,即 entry.S 中的 kern_entry
// note: does not return
// 注意,这里也用 0xFFFFFF 对代码入口地址进行了变化
// 这样,对于内核的很高的虚拟地址的访问,都转化了在物理内存中较低的 1M 以内的访问.
((void (*)(void))(ELFHDR->e_entry & 0xFFFFFF))();
bad:
outw(0x8A00, 0x8A00);
outw(0x8A00, 0x8E00);
/* do nothing */
while (1);
}
可以看到,主要是读入了内核的程序,并且对内核中高区的代码强行映射到低1m以内
之后跳转到内核的入口,即entry.s
由上面所述,内核被加载到了0x100000的物理地址,此时的映射还是虚拟=线性=物理,但是我们加载内核的时候并没有按照虚拟地址所指示的,将内核加载到和虚拟地址一样的物理地址上,即实际上虚拟!=物理。如果此时直接访问就会发生错误。说白了就是映射歪了。(如果还不懂的话建议了解下计算机程序的链接过程)
比如我们要访问0xc0100000的虚拟地址,其物理地址在0x100000(因为内核的代码段被加载到了这里,而在虚拟空间中,代码段的起始却在0xc0100000的地方)
为了解决这个问题,我们下面用realloc这个函数来抵消偏移量,
现在为止,我们已经从下往上,堆栈,bootloader的data和text,elf头一直到bss段结束,已经加载到物理内存里面了。
内核相关代码解析
entry.s解析
下面就是真正的内核里面的代码了
内核入口目标: 作为内核入口点, 建立好 boot 阶段的映射关系,把虚拟内存 [0, 4M)和[KERNBASE, KERNBASE+4M)都映射到物理内存[0, 4M)上.
原因:
0) bootloader 建立的是临时的偏移式分段虚拟地址(bootmain.c, 几处& 0xFFFFFF),现在转化为由 MMU 管理的真正的地址翻译
1)高地址映射:内核运行期地址, 起始于 KERNBASE.
2)低地址映射: 在 movl %eax, %cr0 之后还有几个指令需要执行,如果不映射的话就访问不到了.
#define KERNBASE 0xC0000000
这里在补充一句,关于内核应该放在什么地方。一般操作系统都会被放在虚拟空间较高的地方中去,对于linux,会将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF),供内核使用,称为内核空间,而将较低的3G字节(从虚拟地址0x00000000到0xBFFFFFFF),供各个进程使用,称为用户空间。(虚拟空间里面),原因是用户最可能也最习惯从低地址开始编排程序,所以把内核放在高地址不容易让用户碰到的地方。
ucore就是放在了c0100000的地方。
想了解更多,请参考关于higher half kernel
#define REALLOC(x) (x - KERNBASE)
#这里是内核的代码段,被加载在物理地址的1mB的位置
.text
.globl kern_entry
kern_entry:
# 初始化内核环境的页表,把页目录表地址重定位后放入cr3
#页目录表分析在下面
movl $REALLOC(__boot_pgdir), %eax
movl %eax, %cr3
# 使能 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
# 现在我们要更新eip寄存器,现在的eip = 0x1.....
# mind here!(注意这里)
#为什么我们要更新eip寄存器?以及为何我们需要一个暂时的(0-4m):(0-4m)的地址映射?
#因为我们刚刚开启了分页,这时候要寻址的话,eip寄存器里面应该存的是虚拟地址,但问题是现在ip寄存器里面存的还是我们开启分页以前的物理地址,如果我们直接使用(kernbase,kernbase+4m):(0-4m)的地址映射的话就会找不到地方,所以我们现在需要一个(0-4m):(0-4m)的映射来在我们更新eip寄存器(把正确的虚拟地址放到eip寄存器里面替换现在的物理地址)完成之前还能让程序找到地方。
# 执行下面这条指令时,虽然访问的仍然是处于[0,4MB)的物理空间(kernelbase开始的虚拟地址),因为开启了分页了,会经过页表的查找.将虚拟地址映射到物理地址
# 为了更新 eip
# 将next的地址存入eax,存入的next是虚拟地址,jmp之后eip里面也就是虚拟地址了
leal next, %eax
# set eip = KERNBASE + 0x1.....
jmp *%eax
next:
# jmp 过来之后,eip也就成功的从物理地址切换到虚拟地址了。所以我们现在不需要(0-4m):(0-4m)的映射了
# 在这里取消虚拟地址 0 ~ 4M 的映射,即将 __boot_pgdir 置零.
xorl %eax, %eax
movl %eax, __boot_pgdir
# set ebp, esp,搭建栈(ss在之前已经设定好了)
movl $0x0, %ebp
# 内核栈范围是 [bootstack, bootstacktop)
# 内核栈大小是 KSTACKSIZE (8KB), 在memlayout.h 中定义.
movl $bootstacktop, %esp
# 内核栈就绪, 调用C 函数
call kern_init
# should never get here
spin:
jmp spin
#这里是数据段
.data
.align PGSIZE
.globl bootstack
bootstack:
.space KSTACKSIZE
.globl bootstacktop
bootstacktop:
# 内核内置一级页表.
# 每个一级页表对应 1024 个二级页表,即一个一级页表(页目录)对应4MB的内存
# 我们只需要映射两块,把对虚拟内存 [0,4M)和[KERNBASE, KERNBASE+4M)都映射到物理内存[0,4M)上.
# 所以只需 2 个一级页表项.
# 第一个页表项的上限是KERNBASE,共占用 768 个 entry,共 3072Byte<PAGESIZE,加上第二个页表项,再加上对齐,也没超过1 个 PAGESIZE.
# 而一个 PAGESIZE 可以容纳 4K/4=1K 个 entry. KERNBASE 大概在其中 3/4 的位置,还可以容纳 1K - 768 = 256 个 entry,即 1G 的容量.
# 实际 nm 输出
# c0158000 d __boot_pt1
# c0157c00 d __second_PDE
# c0157000 D __boot_pgdir
# 也可以得到验证, 一级页表共占用 0x1000Byte=4KB
.section .data.pgdir
.align PGSIZE
__boot_pgdir:
.globl __boot_pgdir
# 第一个一级页表项,把虚拟地址 0 ~ 4M 临时映射到 0 ~ 4M. 在跳到 kern_init 之前就已抹除.
.long REALLOC(__boot_pt1) + (PTE_P | PTE_U | PTE_W)
# 从 0 到KERNBASE,中间间隔了 KERNBASE/4M = 3072/4=768 项,共 768*4B = 3072Byte,不到一个 PAGESIZE.
# 为何最后还要<<2 ?每个页表项占用 1 个long,是 32bit,从 byte 到 long 需要*4,即<<2
.space (KERNBASE >> PGSHIFT >> 10 << 2) - (. - __boot_pgdir) # 与 KERNBASE 的一级页表项对齐
__second_PDE:
# 第二个一级页表项,把虚拟地址 KERNBASE + (0 ~ 4M) 映射到物理地址 0 ~ 4M
.long REALLOC(__boot_pt1) + (PTE_P | PTE_U | PTE_W)
.space PGSIZE - (. - __boot_pgdir) # 与 PAGESIZE 对齐.
# ↓ 两个一级页表项都指向下边的二级页表项
# boot 阶段临时单个二级页表内容,专门用于映射至 [0,4MB)
# 定义: 一个二级页表 1024 项,即按 1K 再次分页
# 每项的内容: 从 0 开始,每项的值递增 4096,即 i * PGSIZE,辅以属性
.set i, 0
__boot_pt1:
.globl __boot_pt1
.rept 1024
.long i * PGSIZE + (PTE_P | PTE_W)
.set i, i + 1
.endr
可以看见,我们在这里将分页机制开启,页表与页目录采用的是同样是在这里定义的__boot_pgdir,__second_PDE和__boot_pt1
此时的映射关系为
virt addr = linear addr = phy addr # 线性地址在0~4MB之内三者的映射关系
virt addr = linear addr = phy addr + 0xC0000000 # 线性地址在0xC0000000~0xC0000000+4MB之内三者的映射关系
经过上面的变换后,取消掉了上面一行,只剩下下面一行。
(一个pdt只能映射4m)
此时的内核(EIP)还在0~4M的低虚拟地址区域运行,而在之后,这个区域的虚拟内存是要给用户程序使用的。为此,需要使用一个绝对跳转来使内核跳转到高虚拟地址,即上面代码中的jmp 到next里面
之后用boot_pgdir[0]对应的第一个页目录表项(0~4MB)清零来取消了临时的页映射关系。
# unmap va 0 ~ 4M, it's temporary mapping
xorl %eax, %eax
movl %eax, __boot_pgdir
结束后,地址的映射关系为
virt addr = linear addr = phy addr + 0xC0000000 # 线性地址在0~4MB之内三者的映射关系
这一阶段的目的是更新映射关系的同时将运行中的内核(EIP)从低虚拟地址“迁移”到高虚拟地址。但是仅仅映射了0~4MB。对于段表而言,也缺少了运行ucore所需的用户态段描述符和TSS(段)描述符相应表项。
经过以上的地址变化,来到了init.c里面的kern_init
init.c
从pmm_init函数被调用开始,我们将完善段表和页表。pmm_init完成页目录项的填充,更新了段映射机制,加载新的段表,形成了我们希望的虚拟地址、线性地址以及物理地址之间的映射关系:
virt addr = linear addr = phy addr + 0xC0000000
下面我们来看这最后一个阶段是如何完成的。
对于kern_init这个函数,是内核初始化的一个主要函数,它分别调用了不同的内核启动组件来完成相应部分的开启与初始化,这里我们知道它调用了pmm_init这个函数,并且在这个函数里完成了我们的虚拟存储的初始化。
int
kern_init(void) {
//下面这两个在连接文件中定义的,bss段的开头和结尾
extern char edata[], end[];
memset(edata, 0, end - edata); // 清空 bss 段
cons_init(); // init the console
print_history();
print_kerninfo();
//grade_backtrace();
//在这个函数里完成了虚拟存储的建立,这整个大函数我们只要注意这里就够了
pmm_init(); // init physical memory management
pic_init(); // init interrupt controller
idt_init(); // init interrupt descriptor table
vmm_init(); // init virtual memory management
sched_init(); // init scheduler
proc_init(); // init process table
ide_init(); // init ide devices
swap_init(); // init swap
fs_init(); // init fs
clock_init(); // init clock interrupt
intr_enable(); // enable irq interrupt
cpu_idle(); // run idle process
}
下面我们来具体解析pmm.c
pmm.c解析
注意:
下文可能会有一些乱,我们将以pmm.c的pmminit为主线,依次具体讲述里面每一个函数的用处。
建议先看一遍pmm_init的大致介绍(下方),每看一个下文的具体函数分析会过来看一下走到了哪一步
//pmm_init - 建立pmm管理物理地址, 建立PDT&PT开启分页机制
void
pmm_init(void) {
// 之前已经开启了paging,用的是 bootloader 的页表基址.现在单独维护一个变量boot_cr3 即内核一级页表基址.这个boot_pgdir就是我们刚刚开启分页时所采用的那个一级页表
boot_cr3 = PADDR(boot_pgdir);//physical addr()
LOG_TAB("已维护内核页表物理地址;当前页表只临时维护了 KERNBASE 起的 4M 映射,页表内容:\n");
print_all_pt(boot_pgdir);
// 初始化物理内存分配器,之后即可使用其 alloc/free pages 的功能
init_pmm_manager();
// 探测物理内存分布,初始化 pages, 然后调用 pmm->init_memmap 来初始化 freelist
page_init();
// 测试pmm 的alloc/free
check_alloc_page();
//测试用函数
check_pgdir();
// 编译时校验: KERNBASE和KERNTOP都是PTSIZE的整数,即可以用两级页表管理(4M 的倍数)
static_assert(KERNBASE % PTSIZE == 0);
static_assert( KERNTOP % PTSIZE == 0);
//自映射实现:下文会具体探讨,在这里留个印象
LOG("\n开始建立一级页表自映射: [VPT, VPT + 4MB) => [PADDR(boot_pgdir), PADDR(boot_pgdir) + 4MB).\n");
boot_pgdir[PDX(VPT)] = PADDR(boot_pgdir) | PTE_P | PTE_W;
LOG("\n自映射完毕.\n");
print_all_pt(boot_pgdir);
// 把所有物理内存区域映射到虚拟空间.即 [0, KMEMSIZE)->[KERNBASE, KERNBASE+KERNBASE);
// 在此过程中会建立二级页表, 写对应的一级页表.
boot_map_segment(boot_pgdir, KERNBASE, KMEMSIZE, 0, PTE_W);
print_all_pt(boot_pgdir);
// 到目前为止还是用的 bootloader 的GDT.
// 现在更新为内核的 GDT,把内存平铺, virtual_addr 0 ~ 4G = linear_addr 0 ~ 4G.
// 然后设置内存中的TSS即 ts, ss:esp, 设置 gdt 中的 TSS指向&ts, 最后设置 TR 寄存器的值为 gdt 中 TSS 项索引.
gdt_init();
// 基本的虚拟地址空间分布已经建立.检查其正确性.
check_boot_pgdir();
print_pgdir();
kmalloc_init();
LOG_LINE("初始化完毕:内存管理模块");
}
init_pmm_manager()初始化了pmm manager,具体为
struct pmm_manager {
const char *name; // XXX_pmm_manager's name
void (*init)(void); // initialize internal description&management data structure
// (free block list, number of free block) of XXX_pmm_manager
void (*init_memmap)(struct Page *base, size_t n); // setup description&management data structcure according to
// the initial free physical memory space
struct Page *(*alloc_pages)(size_t n); // allocate >=n pages, depend on the allocation algorithm
void (*free_pages)(struct Page *base, size_t n); // free >=n pages with "base" addr of Page descriptor structures(memlayout.h)
size_t (*nr_free_pages)(void); // return the number of free pages
void (*check)(void); // check the correctness of XXX_pmm_manager
};
static void
init_pmm_manager(void) {
pmm_manager = &default_pmm_manager;
pmm_manager->init();
LOG_TAB("物理内存管理器实例- %s 初始化完毕.\n",pmm_manager->name);
}
我们可以看到,pmm_manager是一个结构体,里面有一些函数指针(面向对象的c实现),值得注意的是,这里面的函数指针是在default_pmm_manager.c文件里面,即default_pmm_manager就是pmm_manager的具体实现。
对于这个数据结构,从他的函数就可以瞥见它的功能,可以分配,管理,回收页。在这里对我们的内存初始化的主线不是十分相关,按下不表
具体的default_pmm_manager.c文件有时间单独拿出来说(这个文件网上参考资料更多)
我们只看一眼在init_pmm_manager中用到的default_init。
static void
default_init(void) {
list_init(&free_list);
nr_free = 0;
初始化了空闲页双向链表,将空闲页数设为0
接下来 page_init();
static void
page_init(void) {
LOG_LINE("初始化开始:内存分页记账");
LOG("目标: 根据探测得到的物理空间分布,初始化 pages 表格.\n\n");
LOG_TAB("1. 确定 pages 基址. 通过 ends 向上取整得到, 位于 end 之上, 这意味着从此就已经突破了内核文件本身的内存空间,开始动态分配内存\n");
LOG_TAB("2. 确定 page 数 npages,即 可管理内存的页数.\n");
LOG_TAB("\t2.1 确定实际管理的物理内存大小maxpa.即向上取探测结果中的最大可用地址,但不得大于管理上限 KMEMSIZE. maxpa = min{maxpa, KMEMSIZE}.\n");
LOG_TAB("\t2.2 npage = maxpa/PAGESIZE.\n");
LOG_TAB("\t3. 确定可管理内存中每个空闲 page 的属性,便于日后的换入换出的调度; 加入到 freelist 中.\n\n");
//memmap是一个描述物理区域状态的数组,具体见下文
//这个结构体的实例mmemmap是之前内存探测时候就初始化好了的,描述物理空间状态,保存在0x8000的地方(初始化见指导书lab2附录2)
struct e820map *memmap = (struct e820map *)(0x8000 + KERNBASE);
uint64_t maxpa = 0; // 可管理物理空间上限,最大不超过 KMEMSIZE
LOG("1) e820map信息报告:\n\n");
LOG(" 共探测到%d块内存区域:\n\n",memmap->nr_map);
int i;
for (i = 0; i < memmap->nr_map; i ++) {
uint64_t range_begin = memmap->map[i].addr, range_end = range_begin + memmap->map[i].size;
LOG_TAB("区间[%d]:[%08llx, %08llx], 大小: 0x%08llx Byte, 类型: %d, ",
i, range_begin, range_end - 1, memmap->map[i].size, memmap->map[i].type);
if (memmap->map[i].type == E820_ARM) { // E820_ARM,ARM=address range memory,可用内存,值=1
LOG_TAB("系可用内存.\n");
if (maxpa < range_end && range_begin < KMEMSIZE) {
maxpa = range_end;
LOG_TAB("\t调整已知物理空间最大值 maxpa 至 0x%08llx = %lld K = %lld M\n", maxpa, maxpa /1024, maxpa /1024/1024);
}
}else{
LOG_TAB("系不可用内存.\n");
}
}
if (maxpa > KMEMSIZE) { // 可管理物理空间上限不超过 KMEMSIZE=0x38000000
maxpa = KMEMSIZE;
}
extern char end[]; //bootloader加载ucore的结束地址,这是在linker script里面定义的
npage = maxpa / PGSIZE;//页面(框)数
pages = (struct Page *)ROUNDUP((void *)end, PGSIZE);
for (i = 0; i < npage; i ++) {
SetPageReserved(pages + i); // 设置 pages 表格中 page 的属性为不可交换,供内核使用
}
LOG("\n2) 物理内存维护表格 pages 初始化:\n \n");
LOG_TAB("实际管理物理内存大小 maxpa = 0x%08llx = %dM\n",maxpa,maxpa/1024/1024);
LOG_TAB("需要管理的内存页数 npage = maxpa/PGSIZE = %d\n", npage);
LOG_TAB("内核文件地址边界 end: 0x%08llx\n",end);
LOG_TAB("表格起始地址 pages = ROUNDUP(end) = 0x%08lx = %d M\n", (uintptr_t)pages, (uintptr_t)pages/1024/1024);
LOG_TAB("pages 表格自身内核虚拟地址区间 [pages,pages*n): [0x%08lx, 0x%08lx)B,已被设置为不可交换.\n",pages,((uintptr_t)pages + sizeof(struct Page) * npage), (uintptr_t)pages/1024/1024, ((uintptr_t)pages + sizeof(struct Page) * npage)/1024/1024);
uintptr_t freemem = PADDR((uintptr_t)pages + sizeof(struct Page) * npage);
LOG_TAB("pages 表格结束于物理地址 freemem :0x%08lxB ≈ %dM. 也是后序可用内存的起始地址. \n\n",freemem, freemem/1024/1024);
LOG_TAB("考察管理区间, 将空闲区域标记为可用.\n");
//初始化这些页的一些属性
for (i = 0; i < memmap->nr_map; i ++) {
uint64_t begin = memmap->map[i].addr, end = begin + memmap->map[i].size;
LOG_TAB("考察区间: [%08llx,%08llx):\t",begin,end);
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) {
LOG_TAB("此区间可用, 大小为 0x%08llx B = %lld KB = %lld MB = %lld page.\n", (end - begin), (end - begin)/1024, (end - begin)/1024/1024, (end - begin)/PGSIZE);
//initmemmap将一块连续的空闲地址加入freelist
init_memmap(pa2page(begin), (end - begin) / PGSIZE);
}
}else{
LOG_TAB("此区间不可用, 原因: 边界非法.\n");
}
}else{
LOG_TAB("此区间不可用, 原因: BIOS 认定非可用内存.\n");
}
}
LOG_LINE("初始化完毕: 内存分页记账");
}
上面开启分页总共可分为如下几步:
1.根据探测内存的结果,找到最后一个可用空间的结束地址或内核结束地址的小者,算出物理空间总共有多少个页
2.找到内核结束地址(end),定义在连接脚本里面,从这个地址的下一个页(pages)写入page的信息(参考上面的内存分布图)
3.从pages开始,将所有页信息设置为系统预留
4.找到free页开始地址,初始化这些页的信息。
memmap的类定义:是一个描述一块物理区域的数组
struct e820map {
int nr_map;
struct {
uint64_t addr;
uint64_t size;
uint32_t type;
} __attribute__((packed)) map[E820MAX];
};
下面两个函数是供评阅以及测试使用,省略
//测试pmm的alloc/free
check_alloc_page();
//测试用函数
check_pgdir();
在接下来,因为已经建立好了页信息,现在要往页表里面写入映射关系。
建立自映射
在建立好上文所说的映射之后,如果我们这时需要按虚拟地址的地址顺序显示整个页目录表和页表的内容,则要查找页目录表的页目录表项内容,根据页目录表项内容找到页表的物理地址,再转换成对应的虚地址,然后访问页表的虚地址,搜索整个页表的每个页目录项。这样过程比较繁琐。
我们需要有一个简洁的方法来实现这个查找。ucore做了一个很巧妙的地址自映射设计,把页目录表和页表放在一个连续的4MB虚拟地址空间中,并设置页目录表自身的虚地址<–>物理地址映射关系。这样在已知页目录表起始虚地址的情况下,通过连续扫描这特定的4MB虚拟地址空间,就很容易访问每个页目录表项和页表项内容。
主要是pmm init里面的这一段:
// 定义一块映射,使得可以更方便地访问一级页表的内容.在 print_pgdir 中用到.
// 定义一个高于 KERNBASE + KMEMSIZE 的地址 VPT, 设置[VPT, VPT + 4MB) => [PADDR(boot_pgdir), PADDR(boot_pgdir) + 4MB )的映射.
// 这样一来, 只要访问到 VPT 起始的 4MB 的虚拟地址范围内,都会映射到 boot_pgdir 对应的起始的 4MB ,即一级页表本身!
LOG("\n开始建立一级页表自映射: [VPT, VPT + 4MB) => [PADDR(boot_pgdir), PADDR(boot_pgdir) + 4MB).\n");
boot_pgdir[PDX(VPT)] = PADDR(boot_pgdir) | PTE_P | PTE_W;
LOG("\n自映射完毕.\n");
//print_pgdir();
print_all_pt(boot_pgdir);
首先可以参考lab2的指导手册附录4
首先定义了两个常量地址vpt和vdt,vpt的中10位和低位全为0
vdt的高10和中10位相等,低位为0
注意:一个线性地址正是高10位作为页目录偏移,中10位为页表偏移,低12位为页内偏移
VPT=0xFAC00000
pte_t * const vpt = (pte_t *)VPT;
//vpt:1111 1010 11|00 0000 0000| 0000 0000 0000
pde_t * const vpd = (pde_t *)PGADDR(PDX(VPT), PDX(VPT), 0);
//VPD:1111 1010 |11 11 1110 1011 |0000 0000 0000
boot_pgdir[PDX(VPT)] = PADDR(boot_pgdir) | PTE_P | PTE_W
上面代码首先定义了两个地址变量,vpt和vpd,pdx(vpt)是1003,在页目录表的1003项写入页目录表的基地址(pdx()获取页目录偏移)
若用vpd这个地址作为线性地址翻译物理地址,首先会查看高10位,即第1003项的页目录表项,得到的还是页目录表的基地址,再查看中间10位即页表项,还是1003项,过去还是会回到页目录表的基地址。也就是说,我们可以用vpt的最后12bit来访问页目录表。
例如,假如我们要访问0xf8000000,首先我们取得他的页目录项,即1111 1000 00,我们只需要将这个值放在vpd的后12位就行了。
即
vpd+(0xf8000000>>22)
对于vpt,vpt成了页目录表中第一个目录条目所指向页表的虚拟地址
假设现在访问vpt所代表的这个地址,先取高10位查页目录表,然后回到页目录表(也是页表),然后查中间10位,全是0,访问第0项,因为实际访问的此时还是页目录表,后12位为0,偏移为0,所以指向页目录表第0项指向的地方,即第一个页表的虚地址。
同样的道理,利用vpt可以获得页表项,具体就是改变vpt的低22位来得到具体的页表项,具体就是
vpt+addr>>12
上面这些功能是通过boot_pgdir[PDX(VPT)] = PADDR(boot_pgdir) | PTE_P | PTE_W完成的。下面我们把所有物理内存区域映射到虚拟空间.即 [0, KMEMSIZE)->[KERNBASE, KERNBASE+KERNBASE);
在此过程中会建立二级页表, 写对应的一级页表.
boot_map_segment解析
/**
* 对区域进行映射,专用于内核.
* 把虚拟地址[la, la + size)映射至物理地址[pa, pa + size),映射关系保存在 pgdir 指向的一级页表上.
* la 和 pa 将向下对 PGSIZE 取整.
* 映射过程:
* 对于给定的虚拟地址,每隔一个 PGSIZE值,就根据指定的一级页表地址pgdir,找到对应的二级页表项,写入相应的的物理地址和属性.
*
* 计算: 对于内核初始值 KMEMSIZE=896MB,需要多少个/多大空间的二级页表来保存映射关系?
* 1) 对于一级页表, 参考 entry.S, 一个 PAGESIZE 大小的一级页表,在 KERNELBASE之上还可以维护 1G 的内存>896MB,所以仍然使用已经定义的一级页表__boot_pgdir即可.
* 2) 对于二级页表, 则需要896M/4K=224K个 entry,每个二级页表含 1K个 entry,所以共需要 224 个二级页表.
* 3) ucore 中比较方便地设定为每个页表的大小是 1024 个,正好占用一个 page.所以需要224*4K=896KB 的空间容纳这些页表.
*/
static void
boot_map_segment(pde_t *pgdir, uintptr_t la, size_t size, uintptr_t pa, uint32_t perm) {
LOG_LINE("开始: 内核区域映射");
//LOG_TAB("一级页表地址:0x%08lx\n",pgdir);
LOG_TAB("映射区间[0x%08lx,0x%08lx + 0x%08lx ) => [0x%08lx, 0x%08lx + 0x%08lx )\n", la, la, size, pa, pa, size);
LOG_TAB("区间长度 = %u M\n", size/1024/1024);
assert(PGOFF(la) == PGOFF(pa));// 要映射的地址在 page 内的偏移量应当相同
size_t n = ROUNDUP(size + PGOFF(la), PGSIZE) / PGSIZE;
la = ROUNDDOWN(la, PGSIZE);
pa = ROUNDDOWN(pa, PGSIZE);
LOG_TAB("校准后映射区间: [0x%08lx, 0x%08lx), 页数:%u\n", la, pa, n);
LOG_TAB("校准后映射区间: [0x%08lx,0x%08lx + 0x%08lx ) => [0x%08lx, 0x%08lx + 0x%08lx )\n", la, la, n * PGSIZE, pa, pa, n * PGSIZE);
for (; n > 0; n --, la += PGSIZE, pa += PGSIZE) {
pte_t *ptep = get_pte(pgdir, la, 1);
assert(ptep != NULL);
*ptep = pa | PTE_P | perm; // 写 la 对应的二级页表; 要保证权限的正确性.
}
LOG_TAB("映射完毕, 直接按照可管理内存上限映射. 虚存对一级页表比例: [KERNBASE, KERNBASE + KMEMSIZE) <=> [768, 896) <=> [3/4, 7/8)\n");
LOG_LINE("完毕: 内核区域映射");
}
调用该函数语句为
boot_map_segment(boot_pgdir, KERNBASE, KMEMSIZE, 0, PTE_W);
可见,boot_map_segment将内核的虚拟空间映射在了二级页表上,一级页表还是之前的boot_pgdir
到这里为止,使用的还是最开始的那个gdt段表,下面要改为使用内核的gdt,将虚拟地址的0-4g映射到物理地址的0-4g的对等映射。
然后设置内存的tss,将gdt中tss指向建立的这个tss
gdt_init()解析
static void
gdt_init(void) {
LOG_LINE("初始化开始: 全局段描述表&TSS");
LOG_TAB("1. 设置内存中的 ts 结构 ts.ts_esp0 = bootstacktop\n");
LOG_TAB("2. 设置内存中的 ts 结构 ts.ts_ss0 = KERNEL_DS\n");
LOG_TAB("3. 设置 GDT 表中的 TSS 一项, 维护内存 ts 地址\n");
LOG_TAB("4. 加载 TSS 段选择子到 TR 寄存器\n");
LOG_TAB("5. 更新所有段寄存器的段选择子值为 0,即平铺结构\n");
// 设置初始内核栈基址和默认的 SS0
load_esp0((uintptr_t)bootstacktop); // 设置TSS内核栈指针为bootstacktop
ts.ts_ss0 = KERNEL_DS; // 设置TSS内核栈段选择子(即内核栈基址),默认指向 GDT 中的 SEG_KDATA一项
/**
* 初始化 gdt 中的 TSS 字段:
* gdt[索引] = type | base | limit | dpl
*/
gdt[SEG_TSS] = SEGTSS(STS_T32A, (uintptr_t)&ts, sizeof(ts), DPL_KERNEL);
// 更新所有段寄存器(的段选择子)的值
lgdt(&gdt_pd);
// 加载 TSS 段选择子到 TR 寄存器
ltr(GD_TSS);
LOG_LINE("初始化完毕: 全局段描述表&TSS");
}
我们来看下tss和gdt的具体定义
static struct segdesc gdt[] = {
// gdt[索引] = type | base | limit | dpl
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, //在 gdt_init 中初始化
};
//gdt是一个segdesc(segment descripter)的数组,大括号里面是初始化这个数组的内容
//可见,gdt中不同段除了权限不同,执行的是对等映射,并没有实际的进行地址变换。
//对比之前gdt的定义,我们可以看见,这里的gdt分段更加详细,还多了tss和用户/内核权限控制
static struct pseudodesc gdt_pd = {
sizeof(gdt) - 1, (uintptr_t)gdt
};
//gdt_pd是一个结构体,第一项是gdt的大小,第二项是真正gdt的指针,这和我们之前最开始bootloader中的gdt的格式是一样的。
tss是任务状态段的意思,我们知道,计算机运行中,不同的特权级拥有不同的栈,当进行特权级切换的时候,就要找到不同级别栈的地址。而这就保存在下图对应字段里。
CPU可以通过 gdtr 寄存器来知道 GDT表在哪里,通过 idtr 寄存器知道 IDT 表在哪里。CPU是通过 tr 寄存器来确定 TSS 的位置的。根据我们上面的代码,通过gdt也是可以找到tss的
GDT 表中可以存放多个TSS描述符,这意味着内存中可以存在多份不同的TSS。总有一个 TSS 是在当前使用中的,也就是 tr 寄存器指向的那个 TSS。当使用 call/jmp + TSS段选择子的时候,CPU做了以下几件事情。
1.把当前所有寄存器(TSS结构中有的那些寄存器)的值填写到当前 tr 段寄存器指向的 TSS 中
2.把新的 TSS 段选择子指向的段描述符加载到 tr 段寄存器中
3.把新的 TSS 段中的值覆盖到当前所有寄存器(TSS结构中有的那些寄存器)中
用结构体来描述tss是这样的
typedef struct TSS {
DWORD link; // 保存前一个 TSS 段选择子,使用 call 指令切换寄存器的时候由CPU填写。
// 这 6 个值是固定不变的,用于提权,CPU 切换栈的时候用
DWORD esp0; // 保存 0 环栈指针
DWORD ss0; // 保存 0 环栈段选择子
DWORD esp1; // 保存 1 环栈指针
DWORD ss1; // 保存 1 环栈段选择子
DWORD esp2; // 保存 2 环栈指针
DWORD ss2; // 保存 2 环栈段选择子
// 下面这些都是用来做切换寄存器值用的,切换寄存器的时候由CPU自动填写。
DWORD cr3;
DWORD eip;
DWORD eflags;
DWORD eax;
DWORD ecx;
DWORD edx;
DWORD ebx;
DWORD esp;
DWORD ebp;
DWORD esi;
DWORD edi;
DWORD es;
DWORD cs;
DWORD ss;
DWORD ds;
DWORD fs;
DWORD gs;
DWORD ldt;
// 这个暂时忽略
DWORD io_map;
} TSS;
到这里为止,内核虚拟存储器已经算是基本建立了,建立之后的物理地址空间就成了这个样子。
而我们的映射也已完成
virt addr = linear addr = phy addr + 0xC0000000
段表相应项和tss也已经设置完成
总结下:
开机->开启对等映射->加载内核到低地址->开启分页并映射内核到高地址 ->让整个物理空间都得到映射 ->重写段表 ->写tss
最后
做过ucore的人应该都不会反对,地址映射这部分(lab1,lab2)是整个ucore除了最后一两个lab外最难的地方,尤其是lab1,2在刚开始的位置,那就是开幕雷击,连官方都说lab1,2是一个坎,迈过去了就会轻松许多。
我也是第二次扣这里了,相比第一次,这一次在学过了elf格式,程序的链接等内容之后再看这部分真的轻松了很多,也找到了更多的参考资料,但还是整整这篇博客写了三天。当然,这部分之所以很难不仅因为它需要将许多别的知识融会贯通,还在于他的函数,结构,变化都相当复杂。所以上文所述还难免存在一些问题,希望各位同好交流指出。