获取物理内存容量
在引导阶段通过 int $0x15, %eax = 0xe820
获取的 ARDS 结构体数组保存在内存 0x7_a204
处,解析代码如下,详见 kern/module/mem.c,逻辑很简单,直接在代码处注释了:
1 | // ARDS 结构体 |
物理内存管理
对于 32-bit
内核来说,物理内存最大支持 4GB
(2^32
),hoo
直接用位图作管理的数据结构。一个物理页对应一个比特位,所以 4GB
对应的位图结构的大小是 128KB
这 128KB
大小是静态的,即在代码中定义一个这么大的数组,当需要访问物理内存管理结构时,直接从内核中取得。理由是物理内存管理模块是使用内存的基础,其管理结构必须预先确定,否则当物理内存管理模块自身需要使用管理结构时,去哪里取得呢?
分配与释放
主要就是操作位图,比特位清位表示可以分配的,置位表示不可分配。由于一个比特位对应一个物理页,所以得到空闲的比特位下标后可以像下面这样转化:
1 | /* |
上方是位图索引,下方是物理页地址,可以发现一个规律就是将位图索引左移 12
位就是物理页地址了。需要注意的是,物理内存中 hoo
不管理最低端的 1MB
,所以最终的物理地址还需要加上 0x10_0000
因此,分配物理地址就是找出空闲的比特位,然后置位;释放就是找出对应的比特位,清位
1 |
|
bitmap_t
是一个统一封装的位图结构,详见 kern/utilities/bitmap.h
- 分配
bitmap_scan_empty()
在给定位图结构里面寻找一个为 0 的比特位,返回位图数组的索引bitmap_set()
将给定位图数组的元素置位- 最后将索引左移 12 位并加上
1MB
即可用的物理地址
- 释放
null
是一个void *
指针,并不是 0,否则虚拟地址 0 就不能被使用了。本质上null
是一个符号,对于内核,它的定义详见 kern/x86.c;对于用户态应用,它的定义详见 user/null.cpanic()
函数的作用是向显卡写入字符串,然后执行hlt
指令,更多细节详见 kern/panic.c- 释放物理地址的时候,先计算出位图数组的索引,然后调用
bitmap_clear()
将位图数组指定元素清位
页表机制
虚拟内存管理的基础设施是页表机制,可以参考其他资源。hoo
将每个页目录表的最后一项指向页目录表自己,利用这个窍门可以实现通过虚拟地址访问页目录表和页表,下面来详细说下这个窍门
如图所示是线程的线性空间,每个 PDE 代表了 4MB
的线性空间,所有 1024 个 PDE 就可以表示整个 4GB
空间
换句话来说,当线程访问的虚拟地址是 0x3f_1000
就是在访问 PDE 0、当访问 0xa0_3000
就是在访问 PDE 2,通过上面这个图从逻辑上可以很快找到答案。在真实的访存场景下,x86 MMU(Memory Management Unit,内存管理单元)会自动将虚拟地址转换为物理地址,不需要 OS 开发者关心
在启用了分页后,访问每个虚拟地址总是确定的,即 PDE 是固定的,不同的是 PDE 的值,这个值就是一个物理地址。可以在不同时刻为 PDE 填入不同的值,此时便说,同一个虚拟地址可以映射不同的物理地址
MMU 的逻辑详见 IA32 手册 volume 3A,4.3 章图片 4-2,如下
可以视为 3 次计算,第一次计算 PDE,第二次计算 PTE,第三次计算物理页
借助这个规则,当 OS 开发者想要访问页目录表时,那就要让第一次计算 PDE、第二次也是计算 PDE、第三次也是计算 PDE,则计算完毕就是访问页目录表自身了。所以,当最后一项 PDE 指向页目录表自身时:
- 虚拟地址最高 10 位给出
0x3ff
,则第一次计算会找到 PDE 1023 - 虚拟地址中间 10 位给出
0x3ff
,则第二次计算依然会找到 PDE 1023 - 虚拟地址最低 12 位就可以指定任意索引了,比如给出
0x1
时,第三次计算会找到 PDE 1;给出0x123
时,第三次计算会找到 PDE 291
当把上述虚拟地址组合起来,高 10 位、中间 10 位全部填 1,就是区间 [0xffff_f000, 0xffff_ffff)
,用来访问页目录表本身
同样的道理,当需要访问页表本身时,需要第一次计算 PDE、第二次计算 PDE、第三次计算 PTE。其中,第二次需要指定 PDE 索引,所以组合起来就是区间 [0xffc0_0000, 0xffff_f000)
根据这个规则,定义以下宏用来操作页目录表和页表,详见 kern/paga/page_stuff.h:
1 |
PGDOWN()
:虚拟地址向下取整,即0xc000_5124
会输出0xc000_5000
,物理页需要对齐4KB
PGUP()
:虚拟地址向上取整,即0xc000_5124
会输出0xc000_6000
PD_INDEX()
:虚拟地址转换为页目录表索引,即0xc000_5124
会输出0xc00
,表示 PDE 768PT_INDEX()
:虚拟地址转换为页表索引,即0xc000_5124
会输出5
,表示 PTE 5GET_PDE()
:虚拟地址转换为对应页目录表的虚拟地址,原理就是上面的窍门,定义较为繁琐GET_PTE()
:虚拟地址转换为对应页表的虚拟地址,原理也是上面的窍门
借助这些宏定义,可以实现创建映射的接口,具体代码详见 kern/mem/pm.c,以下代码有删减:
1 | void |
上面代码片段有一条 x86
指令 invlpg
,详见 《IA32 Architectures Software Developer’s Manual, Volume 3A》,Sections 4.10.4 关于 TLB 的解释,该指令用于:
- 当修改了 PDE 映射时,应该为使用这个 PDE 的虚拟地址使用
invlpg
- 当修改了 PTE 映射时,应该重新赋值
%cr3
- 如果一个多用途的 paging-structure(比如既用作 PTE 又用作 PDE)被修改,则需要多次为使用这个表项的虚拟地址使用
invlpg
(比如先对 PTE 虚拟地址invlpg
后对 PDE 虚拟地址invlpg
)
上面代码总体逻辑是:
- 首先取消 MMU 的映射缓存,通过
invlpg
指令完成 - 然后取出 PDE,通过
GET_PDE()
宏获得一个虚拟地址,通过这个虚拟地址就可以访问到页目录表。访问页目录表对应项,查看 页目录表标识位 判断是否已写入页表(hoo
写入页表是总是将用户可访问的 US 标识、可读可写的 RW 标识、存在位 PS 标识置位)。如果没有页表,则重新分配一个物理页作为页表 - 最后取出 PTE,通过
GET_PTE()
宏获得一个虚拟地址,通过这个虚拟地址访问页表,最后将形参指定的物理地址写入页表
hoo
没有提供取消映射的接口,一是每次创建映射的时候,直接覆盖就可以了;二是取消映射换一个角度来看,只要能保证线程未来不使用这个虚拟地址,那就不会有问题,相当于这个映射就被取消了。而这一点可以交给后一章要实现的虚拟内存管理模块来保证
虚拟内存管理
虚拟内存也是需要管理的,毕竟在进程的线性空间里,地址也是需要唯一的
线性空间有两种类型:内核空间和用户空间。内核线性空间只有真正的内核线程(hoo
进程和线程概念是等同的,因为进程就是单线程进程,后文统一称为线程)hoo
一个,用户线性空间则适用于其余剩下的所有线程
线性空间有个前提,高端地址 [2GB, 4GB)
是共享的,所以无论内核还是用户,实际上都只能使用 [0, 2GB)
空间
对于内核来说:
- 开头
2GB
不仅自己使用,还会暴露出去(共享给所有进程) - 紧接着的
2GB
是重复的,只是一个共享的映射,意味着即使虚拟地址落于这个区间,但数据实际上也是读或写开头的2GB
对于用户线程来说:
- 开头
2GB
可以自己使用,不会暴露出去 - 最后
2GB
不可以使用,这是内核的映射
管理方式
总体来说有位图结构和链表结构两种管理方式,前者逻辑和物理内存管理模块一样,后者就是分配了多少内存就用链表结点记录下来
hoo
的管理结构是两者都有。理由是,申请虚拟地址时会涉及连续的分配,位图结构没有额外信息可以在释放地址时知道 “连续” 的概念,链表则可以在数据域想填充什么数据就填充什么数据。而位图则是用来分配管理结构,因为管理结构也需要使用虚拟地址,而链表是动态结构,没办法在事前知道需要使用多少结点
管理结构的分配释放和物理内存管理一样,详见 kern/mem/vm_kern.c 分配函数 和 kern/mem/vm_kern.c 释放函数
下图是链表结构的简化视图,具体定义详见 kern/mem/vspace.h:
这是一个双重链表。单链表负责管理虚拟地址(虚线箭头矩形),每个单链表结点总是有序的,高地址总是放在链头,目的是分配地址时尽可能保证 O(1)
复杂度。引入双重链表的理由是,释放可能会破坏单链表有序的结构,比如从有序的链表中间释放一个虚拟地址,那么必须将链表后面的结点重新组织为另一个链表,详见后文释放一节
分配
分配存在两种情况,分别是在空链表上分配、在非空链表上分配
单链表为空
考虑虚拟内存管理模块(本章简称为虚拟模块)最开始的时候,某线程请求分配 8KB
内存。虚拟模块搜索空闲地址时,总是从 0x0000_0000
开始,这个时候虚拟模块中的双重链表也为空,所以锁定空闲地址为 0x0000_0000
8KB
即两个物理页,因此结点记录 0x0000_0000
这个虚拟地址以及 2
。因此,单链表为空时,新建一个单链表,新建管理结构
具体代码详见 kern/mem/vm.c,以下是简化片段:
1 | // 新建单链表 |
prev
是在遍历双重链表过程中,找到的第一个可分配地址的元素。如果找到的 prev
为空(本节描述的情况),则创建一个新的单链表,并且在最后将该新链表加入双重链表中
中间的逻辑则是创建结点,填充要分配的虚拟地址(last_end
)、要分配多少页(amount
),然后加入单链表的链头(list_insert()
)
单链表非空
假设还是这个线程,经过一段时间的运行后(若干次分配和释放)请求 60KB
内存,那么:
进程依然是从 0x0000_0000
开始遍历,这里先忽略其中的细节,只关注核心思路,即双重链表记录了已经使用的地址区间是 [0x0000_0000, 0x0000_2000) ∪ [0x0001_0000, 0x0001_7000)
,中间有一段地址区间是可用的,但稍微计算后会发现只有 56KB
,因此最后会将空闲地址锁定在 0x0001_7000
。之后便是一些链表的插入操作,因此,单链表非空时,插入结点
具体代码详见 kern/mem/vm.c,以下是关键代码片段:
1 | while (worker != null) { |
worker
是双重链表的工作指针,while()
是整个双重链表的遍历过程。if()
条件(本节描述的情况)表示当前的地址区间太少不够分配,则可分配虚拟地址、工作指针往后移。直至找到足够大的空间,进入 else()
复用前一节逻辑
释放
释放的情况稍微有点复杂,这是因为分配地址总是从 0x0000_0000
开始,且单链表是递减有序排列的,这就要求释放采取一些手段来保证所有单链表依然有序
释放两端地址
某线程经若干次分配释放后,现在请求释放 0x0000_0000
这个虚拟地址
这里直接释放就行,从整体上看地址区间没有问题。另一种情况比如释放 0x0000_2000
也是同样道理。所以,如果释放地址就是链头第一个,或者链尾最后一个(即释放地址在两端),直接删除
具体代码详见 kern/mem/vm.c,以下是简化片段:
1 | enum location_e { |
定义一个枚举值用来表示单链表结点的位置:位于单链表的链头 LCT_BEGIN
、中间 LCT_MIDDLE
和链尾 LCT_END
cur_list
是遍历双重链表时的工作结点,第一个 while()
循环根据待释放地址 va
来确定需要修改的单链表。当找到这样的单链表后,cur_list
会指向它
第二个 do-while()
循环用来遍历单链表,每枚举一个链表结点,就判断其位置。对于单链表两端的结点,只需要直接移除
后续的逻辑依次是释放虚拟地址 va
对应的物理页,这会调用物理内存管理模块的接口来释放;以及释放虚拟模块使用的管理结构,与管理结构相关的分配和释放接口详见 kern/mem/metadata.{h,c},逻辑十分直白简单
释放中间地址
这是另一种释放地址的情况,假设之后线程需要释放 0x7000
地址,则:
现在,释放地址前后有其他结点(即释放地址在中间),则需要为链表后面的所有结点创建一个新的单链表
关键代码片段如下:
1 | do { |
当在单链表遍历过程中发现这是一个中间结点,首先创建一个新的单链表,然后 for()
循环将后续的结点 “转移” 到新链表 —— 原链表 list_remove()
,新链表 list_insert()
。最后将原链表 vspace_append()
到新链表后面(为了减少遍历次数,新链表放在前面)
动态内存分配
虚拟内存分配粒度太大了,每次分配都是一个 4KB
页,因此引入动态内存分配来作更小粒度的内存分配
hoo
和 C/C++
程序稍微有点不一样,其线性空间如下:
整个 4GB
空间分为两半,线程自己可用的空间和内核空间各 2GB
。hoo
为所有线程统一分配栈,所以线程栈不放在线程自己的空间,而是放在内核空间,除此之外和普通 C/C++
程序线性空间是一致的
程序二进制数据即代码段、数据段这些,不同程序有不同大小,所以这里二进制数据的边界是浮动的。对于 hoo
内核,为简化问题,二进制数据边界定为一个页表(实际上 hoo
纯二进制很小,几十 kb),即 4MB
,也即 hoo
堆空间从 0x40_0000
开始
无论是前面粒度更大的虚拟内存分配,还是现在粒度更小的动态内存分配,都是从线程自己的堆空间中分配内存
hoo
是参考 Linux 0.11 桶分配机制(其解释可以参考赵炯博士的 《Linux 内核 0.11 详细注释》,pdf 456 页)作出的实现
每个线程都有一个 “manager”,管理着各自的堆空间。“manager” 将可用空间组织为桶,每个桶都有其要管理的内存大小,比如 8B 桶则负责分配和回收 8B 内存空间
桶下面挂靠着一个或多个链表,每个链表都是一个 4KB
页,所以不同桶的链表元素的数量是不一样的,8B 桶可以近似看成 4KB / 8
个元素,16B 桶则近似是 4KB / 16
每个 4KB
页的格式从管理信息中的链头开始,连接下一个链表元素,直至 4KB
页中所有元素都连接到一起
随着分配释放的进行,有可能一个桶会耗尽。这个时候会从线程堆空间再分配另一个 4KB
页,并格式化为上述结构
小结一下动态内存分配机制:
- 分配内存可以视为从链表中取出元素
- 释放内存可以视为将元素加入链表
hoo
实现了一个格式化链表,详见 kern/mem/format_list.h,该定义对应着链表的管理信息:
1 | typedef struct format_list { |
这里链表是一个统一的数据结构,详见 kern/utilities/list.h,提供以下接口:
1 | void list_init(list_t *list, bool cycle); // 初始化 |
格式化链表提供分配和释放两个接口:
1 | void *fmtlist_alloc(fmtlist_t **fmtlist); |
格式化链表的分配,其实是取出链表元素,即 list_remove()
;释放则对应插入链表元素,即 list_insert()
桶管理者定义详见 kern/mem/bucket.h:
1 | typedef struct buckX_manager { |
基于上述基础结构,hoo
实现的动态分配详见 kern/dyn/dynamic.c:
1 | void * |
- 注释 1:由于堆空间每个线程都不一样,所以
get_current_pcb()
(暂时当成黑盒)取出线程 pcb,再取出桶管理者 - 注释 2:
while()
循环遍历所有的桶。这里有两种情况,要么动态分配 1024B 以下的空间,要么动态分配更大的空间。对于前者,初始情况下每个桶的链表是空的,这个时候需要新分配一个4KB
页 - 注释 3:如果是动态分配 1024B 以上的空间,则前一步循环遍历完后,桶管理者依然会是空的,这个时候再进行按需分配,分配完成后直接返回该虚拟地址
- 注释 4:如果是动态分配 1024B 以下的空间,则复用格式化链表的分配接口来分配内存
动态释放详见 kern/dyn/dynamic.c:
1 | void |
- 注释 1:复用格式化链表的释放接口
- 注释 2:如果经上一步释放后,格式化链表全部元素都已释放(管理信息记录的容量和链表当前长度相等),则整个
4KB
的格式化链表也可以释放 - 注释 3:如果要释放的内存地址不在桶的链表中出现,则表示这是大于 1024B 的内存空间,这种类型的空间都是按一个
4KB
页来分配的,所以释放的时候也当成一个4KB
页来释放
小结
对于物理内存管理模块,需要实现两类型接口:
对于页表机制,需要实现:
- 创建映射:
void set_mapping()
对于虚拟内存管理模块,也是两类接口:
- 分配
- 管理结构:
void *vir_alloc_kern()
- 非管理结构:
void *vir_alloc_pages()
- 管理结构:
- 释放
- 管理结构:
void vir_release_kern()
- 非管理结构:
void vir_release_pages()
- 管理结构:
对于动态内存管理模块,也是两类接口: