gitbook/操作系统实战45讲/docs/384366.md
2022-09-03 22:05:03 +08:00

18 KiB
Raw Blame History

16 | 划分土地(上):如何划分与组织内存?

你好我是LMOS。

内存跟操作系统的关系,就像土地和政府的关系一样。政府必须合理规划这个国家的土地,才能让人民安居乐业。为了发展,政府还要进而建立工厂、学校,发展工业和教育,规划城镇,国家才能繁荣富强。

而作为计算机的实际掌权者,操作系统必须科学合理地管理好内存,应用程序才能高效稳定地运行。

内存管理是一项复杂的工作,我会用三节课带你搞定它。

具体我是这么安排的:这节课,我们先解决内存的划分方式和内存页的表示、组织问题,设计好数据结构。下一节课,我会带你在内存中建立数据结构对应的实例变量,搞定内存页的初始化问题。最后一节课,我们会依赖前面建好的数据结构,实现内存页面管理算法。

好,今天我们先从内存的划分单位讲起,一步步为内存管理工作做好准备。

今天课程的配套代码,你可以点击这里,自行下载。

分段还是分页

要划分内存,我们就要先确定划分的单位是按段还是按页,就像你划分土地要选择按亩还是按平方分割一样。

其实分段与分页的优缺点前面MMU相关的课程已经介绍过了。这里我们从内存管理角度理一理分段与分页的问题。

**第一点,从表示方式和状态确定角度考虑。**段的长度大小不一,用什么数据结构表示一个段,如何确定一个段已经分配还是空闲呢?而页的大小固定,我们只需用位图就能表示页的分配与释放。

比方说位图中第1位为1表示第一个页已经分配位图中第2位为0表示第二个页是空闲每个页的开始地址和大小都是固定的。

**第二点,从内存碎片的利用看,**由于段的长度大小不一更容易产生内存碎片例如内存中有A段内存地址05000、 B段内存地址50018000、C段内存地址80019000这时释放了B段然后需要给D段分配内存空间且D段长度为5000。

你立马就会发现A段和C段之间的空间B段不能满足只能从C段之后的内存空间开始分配随着程序运行这些情况会越来越多。段与段之间存在着不大不小的空闲空间内存总的空闲空间很多但是放不下一个新段。

而页的大小固定分配最小单位是页页也会产生碎片比如我需要请求分配4个页但在内存中从第13个页是空闲的第4个页是分配出去了第5个页是空闲的。这种情况下我们通过修改页表的方式就能让连续的虚拟页面映射到非连续的物理页面。

**第三点,从内存和硬盘的数据交换效率考虑,**当内存不足时,操作系统希望把内存中的一部分数据写回硬盘,来释放内存。这就涉及到内存和硬盘交换数据,交换单位是段还是页?

如果是段的话其大小不一A段有50MBB段有1KBA、B段写回硬盘的时间也不同有的段需要时间长有的段需要时间短硬盘的空间分配也会有上面第二点同样的问题这样会导致系统性能抖动。如果每次交换一个页则没有这些问题。

还有最后一点,段最大的问题是使得虚拟内存地址空间,难于实施。(后面的课还会展开讲)

综上我们自然选择分页模式来管理内存其实现在所有的商用操作系统都使用了分页模式管理内存。我们用4KB作为页大小这也正好对应x86 CPU长模式下MMU 4KB的分页方式。

如何表示一个页

我们使用分页模型来管理内存。首先是把物理内存空间分成4KB大小页这页表示从地址x开始到x+0xFFF这一段的物理内存空间x必须是0x1000对齐的。这一段x+0xFFF的内存空间称为内存页

在逻辑上的结构图如下所示:

上图这是一个接近真实机器的情况,不过一定不要忘记前面的内存布局示图,真实的物理内存地址空间不是连续的,这中间可能有空洞,可能是显存,也可能是外设的寄存器。

真正的物理内存空间布局信息来源于e820map_t结构数组之前的初始化中我们已经将其转换成phymmarge_t结构数组了由 kmachbsp->mb_e820expadr指向。

那问题来了,现在我们已经搞清楚了什么是页,但如何表示一个页呢?

你可能会想到位图或者整型变量数组用其中一个位代表一个页位值为0时表示页空闲位值为1时表示页已分配或者用整型数组中一个元素表示一个页用具体数组元素的数值代表页的状态。

如果这样的话,分配、释放内存页的算法就确定了,就是扫描位图或者扫描数组。这样确实可以做出最简单的内存页管理器,但这也是最低效的。

上面的方案之所以低效是因为我们仅仅只是保存了内存页的空闲和已分配的信息这是不够的。我们的Cosmos当然不能这么做我们需要页的状态、页的地址、页的分配记数、页的类型、页的链表你自然就会想到这些信息可以用一个C语言结构体封装起来。

让我们马上就来实现这个结构体在cosmos/include/halinc/下建立一个msadsc_t.h文件在其中实现这个结构体代码如下所示。

//内存空间地址描述符标志
typedef struct s_MSADFLGS
{
    u32_t mf_olkty:2;    //挂入链表的类型
    u32_t mf_lstty:1;    //是否挂入链表
    u32_t mf_mocty:2;    //分配类型,被谁占用了,内核还是应用或者空闲
    u32_t mf_marty:3;    //属于哪个区
    u32_t mf_uindx:24;   //分配计数
}__attribute__((packed)) msadflgs_t; 
//物理地址和标志  
typedef struct s_PHYADRFLGS
{
    u64_t paf_alloc:1;     //分配位
    u64_t paf_shared:1;    //共享位
    u64_t paf_swap:1;      //交换位
    u64_t paf_cache:1;     //缓存位
    u64_t paf_kmap:1;      //映射位
    u64_t paf_lock:1;      //锁定位
    u64_t paf_dirty:1;     //脏位
    u64_t paf_busy:1;      //忙位
    u64_t paf_rv2:4;       //保留位
    u64_t paf_padrs:52;    //页物理地址位
}__attribute__((packed)) phyadrflgs_t;
//内存空间地址描述符
typedef struct s_MSADSC
{
    list_h_t md_list;           //链表
    spinlock_t md_lock;         //保护自身的自旋锁
    msadflgs_t md_indxflgs;     //内存空间地址描述符标志
    phyadrflgs_t md_phyadrs;    //物理地址和标志
    void* md_odlink;            //相邻且相同大小msadsc的指针
}__attribute__((packed)) msadsc_t;

msadsc_t结构看似很大实则很小也必须要小因为它表示一个页面物理内存页有多少就需要有多少个msadsc_t结构。正是因为页面地址总是按4KB对齐所以phyadrflgs_t结构的低12位才可以另作它用。

msadsc_t结构里的链表可以方便它挂入到其他数据结构中。除了分配计数msadflgs_t结构中的其他部分都是用来描述msadsc_t结构本身信息的。

内存区

就像规划城市一样一个城市常常会划分成多个不同的小区我们Cosmos的内存管理器不仅仅是将内存划分成页面还会把多个页面分成几个内存区方便我们对内存更加合理地管理进一步做精细化的控制。

我想提醒你的是,内存区和内存页不同,内存区只是一个逻辑上的概念,并不是硬件上必需的,就是说就算没有内存区,也毫不影响硬件正常工作;但是没有内存页是绝对不行的。

那么内存区到底是什么?我们一起看一幅图就明白了,如下所示:

根据前面的图片,我们发现把物理内存分成三个区,分别为硬件区,内核区,应用区。那它们有什么作用呢?我们分别来看看。

首先来看硬件区它占用物理内存低端区域地址区间为0~32MB。从名字就能看出来这个内存区域是给硬件使用的我们不是使用虚拟地址吗虚拟地址不是和物理地址无关吗一个虚拟可以映射到任一合法的物理地址。

但凡事总有例外虚拟地址主要依赖于CPU中的MMU但有很多外部硬件能直接和内存交换数据常见的有DMA并且它只能访问低于24MB的物理内存。这就导致了我们很多内存页不能随便分配给这些设备但是我们只要规定硬件区分配内存页就好这就是硬件区的作用。

接着是内核区,内核也要使用内存,但是内核同样也是运行在虚拟地址空间,就需要有一段物理内存空间和内核的虚拟地址空间是线性映射关系。

再者很多时候内核使用内存需要大的、且连续的物理内存空间比如一个进程的内核栈要16KB连续的物理内存、显卡驱动可能需要更大的连续物理内存来存放图形图像数据。这时,我们就需要在这个内核区中分配内存了。

最后我们来看下应用区,这个区域主是给应用用户态程序使用。应用程序使用虚拟地址空间,一开始并不会为应用一次性分配完所需的所有物理内存,而是按需分配,即应用用到一页就分配一个页。

如果访问到一个没有与物理内存页建立映射关系的虚拟内存页这时候CPU就会产生缺页异常。最终这个缺页异常由操作系统处理操作系统会分配一个物理内存页并建好映射关系。

这是因为这种情况往往分配的是单个页面,所以为了给单个页面提供快捷的内存请求服务,就需要把离散的单页、或者是内核自身需要建好页表才可以访问的页面,统统收归到用户区。

但是我们要如何表示一个内存区呢?和先前物理内存页面一样,我们需要定义一个数据结构,来表示一个内存区的开始地址和结束地址,里面有多少个物理页面,已经分配了多少个物理页面,剩下多少等等。

我们一起来写出这个数据结构,代码如下所示。

#define MA_TYPE_HWAD 1
#define MA_TYPE_KRNL 2
#define MA_TYPE_PROC 3
#define MA_HWAD_LSTART 0
#define MA_HWAD_LSZ 0x2000000
#define MA_HWAD_LEND (MA_HWAD_LSTART+MA_HWAD_LSZ-1)
#define MA_KRNL_LSTART 0x2000000
#define MA_KRNL_LSZ (0x40000000-0x2000000)
#define MA_KRNL_LEND (MA_KRNL_LSTART+MA_KRNL_LSZ-1)
#define MA_PROC_LSTART 0x40000000
#define MA_PROC_LSZ (0xffffffff-0x40000000)
#define MA_PROC_LEND (MA_PROC_LSTART+MA_PROC_LSZ)

typedef struct s_MEMAREA
{
    list_h_t ma_list;             //内存区自身的链表
    spinlock_t ma_lock;           //保护内存区的自旋锁
    uint_t ma_stus;               //内存区的状态
    uint_t ma_flgs;               //内存区的标志 
    uint_t ma_type;               //内存区的类型
    sem_t ma_sem;                 //内存区的信号量
    wait_l_head_t ma_waitlst;     //内存区的等待队列
    uint_t ma_maxpages;           //内存区总的页面数
    uint_t ma_allocpages;         //内存区分配的页面数
    uint_t ma_freepages;          //内存区空闲的页面数
    uint_t ma_resvpages;          //内存区保留的页面数
    uint_t ma_horizline;          //内存区分配时的水位线
    adr_t ma_logicstart;          //内存区开始地址
    adr_t ma_logicend;            //内存区结束地址
    uint_t ma_logicsz;            //内存区大小
    //还有一些结构我们这里不关心。后面才会用到
}memarea_t

好了关于内存区的数据结构我们就设计好了但是这仍然不能让我们高效地分配内存因为我们没有把内存区数据结构和内存页面数据结构关联起来如果我们现在要分配内存页依然要遍历扫描msadsc_t结构数组这和扫描位图没有本质的区别。

下面我们就把它们之间关联起来,也就是组织内存页。

组织内存页

如何组织内存页呢按照我们之前对msadsc_t结构的定义组织内存页就是组织msadsc_t结构而msadsc_t结构中就有一个链表你大概已经猜到了我们组织msadsc_t结构正是通过另一个数据结构中的链表将msadsc_t结构串连在其中的。

如果仅仅是这样那我们将扫描这个链表而这和之前扫描msadsc_t结构数组没有任何区别。

所以我们需要更加科学合理地组织msadsc_t结构下面我们来定义一个挂载msadsc_t结构的数据结构它其中需要锁、状态、msadsc_t结构数量挂载msadsc_t结构的链表、和一些统计数据。

typedef struct s_BAFHLST
{
    spinlock_t af_lock;    //保护自身结构的自旋锁
    u32_t af_stus;         //状态 
    uint_t af_oder;        //页面数的位移量
    uint_t af_oderpnr;     //oder对应的页面数比如 oder为2那就是1<<2=4
    uint_t af_fobjnr;      //多少个空闲msadsc_t结构即空闲页面
    uint_t af_mobjnr;      //此结构的msadsc_t结构总数即此结构总页面
    uint_t af_alcindx;     //此结构的分配计数
    uint_t af_freindx;     //此结构的释放计数
    list_h_t af_frelst;    //挂载此结构的空闲msadsc_t结构
    list_h_t af_alclst;    //挂载此结构已经分配的msadsc_t结构
}bafhlst_t;

有了bafhlst_t数据结构我们只是有了挂载msadsc_t结构的地方这并没有做到科学合理。

但是如果我们把多个bafhlst_t数据结构组织起来形成一个bafhlst_t结构数组并且把这个bafhlst_t结构数组放在一个更高的数据结构中这个数据结构就是内存分割合并数据结构——memdivmer_t那情况就不一样了。

有何不一样呢?请往下看。

#define MDIVMER_ARR_LMAX 52
typedef struct s_MEMDIVMER
{
    spinlock_t dm_lock;      //保护自身结构的自旋锁
    u32_t dm_stus;           //状态
    uint_t dm_divnr;         //内存分配次数
    uint_t dm_mernr;         //内存合并次数
    bafhlst_t dm_mdmlielst[MDIVMER_ARR_LMAX];//bafhlst_t结构数组
    bafhlst_t dm_onemsalst;  //单个的bafhlst_t结构
}memdivmer_t;


那问题来了,内存不是只有两个标准操作吗,这里我们为什么要用分割和合并呢?这其实取意于我们的内存分配、释放算法,对这个算法而言分配内存就是分割内存,而释放内存就是合并内存。

如果memdivmer_t结构中dm_mdmlielst数组只是一个数组那是没有意义的。我们正是要通过 dm_mdmlielst数组来划分物理内存地址不连续的msadsc_t结构。

dm_mdmlielst数组中第0个元素挂载单个msadsc_t结构它们的物理内存地址可能对应于0x10000x30000x5000。

dm_mdmlielst数组中第1个元素挂载两个连续的msadsc_t结构它们的物理内存地址可能对应于0x80000x9FFF0xA0000xBFFFdm_mdmlielst数组中第2个元素挂载4个连续的msadsc_t结构它们的物理内存地址可能对应于0x1000000x103FFF0x1040000x107FFF……

依次类推dm_mdmlielst数组挂载连续msadsc_t结构的数量等于用1左移其数组下标如数组下标为3那结果就是81<<3个连续的msadsc_t结构。

需要注意的是,我们并不在意其中第一个msadsc_t结构对应的内存物理地址从哪里开始但是第一个msadsc_t结构与最后一个msadsc_t结构它们之间的内存物理地址是连续的。

如果还是不明白,我们来画个图就清楚了。

从上图上我们可以看出每个内存区memarea_t结构中包含一个内存分割合并memdivmer_t结构而在memdivmer_t结构中又包含dm_mdmlielst数组。在dm_mdmlielst数组中挂载了多个msadsc_t结构。

那么为什么要这么组织呢?后面我们在分配内存的时候,你就会明白了。

重点回顾

今天我们从比对分段与分页的区别开始思考,确定了使用分页方式,设计了内存页、内存区等一系列数据结构,下面我们来回顾一下本课程的重点。

1.我们探讨了分段与分页的区别,发现段长度不一,容易产生内存碎片、不容易和硬盘换入换出数据,更不能实现扁平化的虚拟内存地址空间,由于这些不足我们选择了分页模式来管理内存,其实现在所有的商用操作系统都使用了分页模式管理内存。

2.为了实现分页管理,首先是解决如何表示一个物理内存页,我们想到过位图和字节数组,但是它们遍历扫描,性能太差,于是设计了更复杂的msadsc_t结构一个msadsc_t结构对应一个可用的物理内存页面。

3.为了适应不同的物理地址空间的要求,比如有些设备需要低端的物理地址,而有的需要大而连续地址空间,我们对内存进行分区,设计了memarea_t结构

每个memarea_t结构表示一个内存区memarea_t结构中包含一个内存分割合并memdivmer_t结构而在memdivmer_t结构中又包含了bafhlst_t结构类型dm_mdmlielst数组。在dm_mdmlielst数组中挂载了多个msadsc_t结构。

思考题

我们为什么要以2的052次方为页面数来组织页面呢

欢迎你在留言区跟我交流互动,也欢迎你把这节课分享给你的同事、朋友。

我是LMOS我们下节课见