操作系统学习笔记8 |段页式内存管理

多进程图像中的CPU管理已经告一段落,接下来要介绍另一大方面——内存管理。首先我们也来看看内存是如何被使用起来的。最后介绍段页式内存管理的实现过程。


参考资料:


1. 内存的使用与分段

1.1 内存管理的直观想法

冯·诺依曼的存储程序思想:取指执行。这种体系结构下的内存使用即程序放到内存中,PC指向开始地址。

下面从一段程序开始,如下图:

程序编译成汇编后,里面的标号偏移是从0开始的,例如 enrty 是 0,_main 的偏移是 40。而 40 就是 main函数开始的地方。

  • 接下来需要将程序放入内存中。
img

用磁盘读,就可以将指令读入内存,但是最重要的问题是把程序放到内存的哪个地方

  • 最直观的想法是 按照偏移放到物理内存的对应位置,这段程序也就必须放到物理内存的0地址处

  • 但是问题很明显:前面也提到过,0地址一般是操作系统使用的,并不空闲;而且,按照这样的约定,所有的程序起始地址都是0,这不就冲突了?

  • 所以实际上应当是在内存中找一段空闲的单元,再将程序加载进来。

  • 但是这样,地址就对不上了,如上图,按照指令跳到了40,但40处是系统程序/不空闲,本应当跳到1040.

  • 所以必须要修改上图程序中的 40.

    将程序中的 逻辑地址/相对地址 重新定位到 真实的物理地址。

    !重定位!

1.2 重定位

什么时候做重定位?

  • 编译时:

    • 这种需要在编译时就知道哪里是空闲的,并且编译时空闲不代表装入时空闲。

    • 因此需要预定,而预定就比较难做。

    • 对于嵌入式系统(静态系统),烧入程序后不再改变,那么编译时重定位就没有上述缺点。

      扩展资料:PE文件结构重定位表

  • 载入时:

    • 在载入程序时,根据内存的空余情况统一进行地址换算,更灵活,更适用于PC机等通用设备。
    • 因为载入过程中需要修改,比较慢。

两种方式的特点:

  • 编译时重定位程序只能放在内存的固定位置;
  • 载入时重定位程序一旦载入内存就不能动了。

因此载入时重定位还有问题,因为程序载入内存后还需要移动。

img

操作系统为了合理利用内存,程序载入内存后,如果进入阻塞或者睡眠,很可能会被 交换 到磁盘上,临时给别的程序腾出空间,再交换回内存后,不一定是原来的位置了。

img

1.3 运行时重定位

因此,载入时重定位也不好,应当是 运行时重定位

  • 在运行每条指令时才完成重定位,每执行一条指令都要从逻辑地址算出物理地址,也叫地址翻译

    跟前面的两种方式不同,不是修改程序中的地址,而是动态地将地址翻译为物理地址(根据Base + 逻辑地址)

有个问题就是,base放在哪里?

  • 每个进程都有自己的基址,并且,每次进程由等待队列进入CPU执行时的基址都会相应发生改变

  • 因此,base 放在 PCB中。

  • 对应的有相应的基地址寄存器,专门用来存放基地址,在硬件层面进行加速;

    在进程切换的时候,会将PCB中的基地址放到基地址寄存器中,之后这个进程执行的每条指令的寻址,都会加上这个基地址.

img

1.4 小总结:上述过程梳理

梳理一下上文的过程。

  • 由于多进程图像,进程1的程序自地址2000起始,进程2的程序从地址1000开始存放。分别把 2000 和 1000 放入各自PCB。
  • 在取指执行时,指令进入CPU,100 的逻辑地址加上 2000 这个 基地址,就对地址为 2100 的数据进行了操作。
  • 进程切换时,将进程2 PCB 中的基地址 替换进程1的 基地址寄存器。
  • 接下来执行进程2 时,逻辑地址100 就会加上 基地址 1000,处理进程2的数据。
img

1.5 程序分段

程序是整个放入/载入内存中的吗?

  • 在程序员眼中,程序不是完整一块儿,而是由若干 部分/段 组成的。如下图中的代码段、变量集data段、函数段。
  • 每个段都有各自的用途和特点:比如代码段只读,代码段/数据段不会动态增长,栈段可以动态地单向增长,函数库段可能不会在一开始就载入,并且有也不是所有函数库都载入;
    • 每个段的逻辑地址都从0开始;
    • 引入段后的地址定位是 <段号,段内偏移>
  • 所以采用分治思想可以使得用户单独考虑每个段如何存放数据,使得操作更有针对性和灵活性;
img

所以我们拟采用 将程序分段放入内存 的方法。对于堆栈段来讲,分段的优点:

  • 栈是可以动态增长的,如果空间不够了,那么栈段可以单独的再重新分配,而不需要给整个程序的数据重新分配

    这里的背景知识是给程序分配的内存不够了怎么办?处理方法是整段搬迁。如果程序不分段,就是整个程序空间的搬迁,这样效率很低。

采用这种方法的相应措施是寻址方式的相应调整:

  • PCB中存储所有段的基地址

  • 寻址:段地址+段内偏移

    比如第1段的300和第3段的300 ,重定位后的物理地址并不同。

  • 进程执行时,PCB中的段地址赋值给各自的段基址寄存器

    • 如数据段是DS,指令段是CS
    • 有一个表来记录:下图中,PCB中有一个进程段表,保存了每个段的段号、基址、长度、读写标志
    • jmpi 100,cs(cs=0),这时就是从基址180再偏移100进行寻址。
img

此前 学习笔记1的setup.s理解 中曾提到过 jmpi 0,8,接着就是使用GDT表进行查表跳转(8相当于CS是段的基地址,0是偏移量)。

GDT 表实际上就是 进程段表,只不过GDT对应的是OS(看做一个进程)对应的段表,GDT唯一;而每个进程都对应一个自己的进程段表 LDT表

LDT: Local Descriptor table

1.6 真正的寻址:GDT + LDT

上面1.5中 简略形象的进程段表在具体程序中的表现就是GDT表的格式;如下图右上所示:

GDT 里放 操作系统的各个段 和 LDT,LDT 放进程的各个段基址及其他情况。

img

总结梳理一下:

  • 程序按照数据的不同用途分成多个段,每个段分别加载到内存的空闲区域,每个段的基址、长度等信息存放在LDT表中
  • 初始化好的 LDT 赋值给 PCB,可以通过 PCB 找到该进程的LDT表,接着在执行指令时通过 LDT 表查出段基址,加上指令本身的偏移就得到了物理地址
  • 段基址+程序中的相对地址 = 物理内存地址
  • ldtr寄存器是做什么用的?
    • 存放段编号,是查找LDT表的索引 (相当于数组下标)
  • 之前讲学习笔记5中拆解线程和内存管理的时候,提到的内存映射表,就是LDT表

1.7 补充:Linux 0.11 的分段处理方式

  • 根据此前进程的相关学习,Linux0.11 下 TCB/PCB 的实现都是靠一个 task_struct 结构体;这个结构体里面还有一个结构体 tss

  • tss里面放的内容可以理解为是当前进程的CPU快照,存储了CPU所有寄存器的值,也就保存了当前进程的状态。

  • 由于进程切换,段寄存器的内容就放在task_struct 里的 tss

1.8 简单总结

  • 内存使用 -> 程序分段装入内存 -> 重定位 -> 运行时重定位 -> LDT&GDT查表

  • 对于其他进程,也是同样的流程。

  • 进程切换时,切换LDT表,这也就是此前提到过的内存映射表。

    注意:LDT 是一个硬件概念,是一组寄存器。

这部分内容对应实验六。

2. 内存分区与分页

  • 上面从程序员视角、程序装入内存的角度,我们将程序分成不同的段;
  • 下面从内存的角度,看一看内存本身是怎么分区的。

2.1 内存使用过程

上面第一部分的学习角度,对于内存使用过程而言,还有缺陷;

  1. 编译时,程序分成了多个段:代码段、数据段...

  2. 在内存中找到若干块空闲的区域,用于存放多个段;

    段可以不连续。

    问题是内存为什么可以一块一块的被找到呢(内存怎么分割)?如何找到空闲的区域呢?这就是第二部分内存分区要解决的问题。

  3. 将程序的各个段从磁盘中载入到找到的空闲内存区域中,并初始化LDT表,与PCB关联起来

    这部分涉及磁盘管理和设备驱动,需要后续讲。

    初始化 LDT 以及与 PCB 关联,就是第一部分后半截讲到的内容。

2.2 内存分区

2.2.1 分区策略

内存分区有两种思路:固定分区 和 可变分区:

  • 固定分区:在操作系统初始化时将可用内存等长划分,但很显然不合适,因为程序各不相同,每个程序的段也有大有小;
  • 可变分区:根据段请求的需求进行内存划分。
img

2.2.2 可变分区的管理思路

想法其实非常简单,对已分配和未分配的区域分别进行打表(数据结构),每次段请求时就查阅表格,分出相应内存时再将表格进行对应的修改。整个过程就跟 记账取物 差不多。

  • 首先是用已分配分区表记录 被占用内存区域的起始地址和长度。
img
  • 当段请求到来,根据请求从空闲内存划分相应区域;并修改空闲分区表和已分配分区表;
img
  • 当某段程序不再需要,如下图段2(200K到250K),则在空闲分区表增加一项,在已分配分区表中去除第三项。
img

当段请求再次到来 reqSize = 40K,此时有两个空闲区域,且都能满足需要,应该分配到哪一块中?接下来就涉及算法了。

2.2.3 空闲分区选择算法

  • 最佳适配:
    • 找到跟请求大小最接近的区域,即下图中的50K大小的区域;
    • 空闲分区会越割越细,越割越碎。
  • 最差适配:
    • 找到跟请求大小最不适配的区域,即下图长度150K的区域;
    • 空闲分区会趋于均匀。
  • 首先适配:
    • 找到第一个满足请求大小的区域就放入;
    • 这样时间复杂度很低。

可见这几种算法个有特点,很难说哪个更为优秀,只能讲合不合适。

img

这里 2.3 和 2.4 就是 数据结构(表) + 算法(选择算法) 的结合

现代操作系统并不是通过内存分区来做的,而是通过内存分页。分区算法实际上是虚拟内存的划分方式,这个留待后话。

段对应的虚拟内存中的分区,虚拟内存又要对应的分页的物理内存上,这个后面讲了段页结合的内存管理就理解了

2.3 内存分页

2.3.1 分区的问题

内存分区带来的问题也显而易见,无论哪种分区选择算法,最后都会带来“破碎”的内存,如若最后到来一个大小为160K的段请求,就会出现没有一个空闲分区能够适配的情况,但实际上空闲分区加起来能够超过160K。

当然也是可以解决的:移动内存分区,使内存紧缩,让内存空闲分区扩大。

但是内存紧缩这种方法花费时间较大;并且移动过程要考虑到每个进程的 LDT 修改,内存移动过程中CPU不能执行上层用户程序,相当于死机了。

img

所以还有一种想法,即将请求大小 160K 打散。

2.3.2 分页的处理方法

上面分区的方法中,内存就像面包,被切碎,最后剩下了谁都不想要的碎屑;而分页的考虑就是,将面包切成片,将内存分成页。

  • 操作系统初始化时,将物理内存分为大小一定的页;

    • 实际上就是限制最小可划分的内存单元,避免过小碎片的产生;
    • 学习笔记1的main.c部分 中的 mem_map 就是完成上述的初始化工作,将内存打成 4k 为单位的页,若被使用则为1,空闲则为0.
  • 针对每个段的内存请求,系统一页一页地分配给这个段,并向上取整。

  • 这样就避免了内存分区效率低的问题,并且不需要内存紧缩。由于设置了最小可分单元--页,一个程序最多浪费 4K-1 的内存空间。

img

物理内存的角度要求内存浪费尽可能少,而用户/程序员 角度更能理解分段的思想。所以后续会继续介绍分页和分段结合起来的综合办法。

2.3.3 寻址方式

段被打散为页,但跟分区一样,程序在以页为单位的内存中同样需要被寻址才能被执行。

  • 比如,我们需要执行一个 jmp 40 这个指令,我们需要知道 40 被分配到了哪一页上。

  • 假设页的大小为100,那么40在第0页;

    同段的页共用一套逻辑地址,因为是同一个程序中的地址。比如页1的地址就是从100~199

  • 物理地址怎么算?第0页放在第5页框(物理内存度量),所以实际上应当是540.

逻辑地址结构如下图所示:一共16位,高4位表示查询页表的索引,低12位是偏移量,也意味着页的大小(最大偏移)为 4K。

4K 的设计在16进制中正好是3位,对于一个地址来说,右移3位即可得到它所在的页。

寻址过程如下图所示:

页表索引寄存器 cr3 存储页表相关信息(之前的段表索引寄存器是ldtr),同样的,PCB 关联了页表(有指向页表的指针),在切换进程时,用 PCB 指向的页表对 CR3 进行赋值。

页表索引寄存器 CR3 存储 页号、页框号和权限检查。

表用来建立逻辑地址与物理地址的联系。

实际的物理地址=物理页框号对应的物理基地址+逻辑地址的低12位。

实例:

  • 对于指令 mov [0x2240],%eax,首先寻址 [0x2240];

  • 0x2240 右移3位(4k)得到0x2,余数为 0x240,这表示地址所在的页号为2,页内偏移为240;

  • 拿着页号 2 查找页表索引,得到页框号 3;

    还是注意区分页号和页框号。页号是逻辑地址的分页,页框号是物理地址的分块单元(也意味着首地址)。

  • 3*4k + 240 即为真实物理地址 3240。

  • 上述寻址过程由 MMU,内存管理单元 在硬件层面完成。

img

2.4 简单总结

程序分段,如果按段放入内存,内存会出现无法利用的大量碎片,所以建立最小内存划分单元--页,按照页响应段请求。页的寻址需要建立页表,页表与 PCB 关联。

3. 多级页表与快表

3.1 问题

上述思路,已经趋于完善,但是在实际系统中还是存在问题:

  • 为了提高内存空间利用率,页应当小,但是页小的话数量增加,据此建立的页表就会变大。
  • 页太多了的话,页表太大 维护和查询页表的开销就大了
    • 比如32位程序(4G内存),如果页大小为4K,则有1M / 220个页。
    • 这里的1M仅是页号,与之相应的页表项还有 页框号 和 保护位,每个页表项4个字节。页表整体就占用4M内存。
    • 每个进程都需要同样的页表,如果有 100 个进程,则占用 400M 内存。

3.2 尝试1:去除无用表项

但实际上 每张页表可能并不需要 4M 这么大的内存。因为单个进程并不会把 4G 内存全用上,也就是说 1M 个页表项并不会全部派上用场。

img

所以一种自然的想法就是:只对使用到的逻辑页建立页表项。

  • 但随之而来的就是页号不连续,如下图右上角。

  • 此前上文中本来用硬件索引,只需要对号入座,很快查到对应的表项,现在不连续了,就不能这么做了。

    参见上面2.3.3 寻址方式,我们先拿到的是2240这样的地址,上文方法右移三位就可以拿到 2 这个页号,这样做的依据是,页是连续的,我们可以根据页的大小推算页的号码,而页不连续,就不能说2240在第2页,我们需要去查表匹配。

  • 必须逐一比较、顺序查找 / 二分查找。

  • 而内存访问本身就很慢,每一次访存都需要额外的查找操作(也是访存操作),会降低指令的访存效率。

所以不连续的算法复杂度高于上文提到的连续存放( O(1) ),但是连续存放的页表占用内存太大,会产生浪费。

img

3.3 尝试2:多级页表

解决上述问题,可以参考生活中的书籍的章节目录,我们要查找第五章的第三节(在书中是第20节)就不必查找前四章的目录,直接跳到第五章的目录下继续向下查,大大节约了搜索开销。

在内存管理中,"不必查找" 对应的就是 这些区域 在该进程中不被使用,既减少了无用表项,也保证了大章连续,单节之间也连续。这个尝试是成功的。

具体细节如下图:

  • 页目录号 10 位(相当于章),页号10位(相当于节),页内偏移 12 位 / 4k。

  • 查找流程就是,找到对应章,在对应章下找对应节,对应节得到物理页框号和偏移量,得到最终真实物理地址。

    由于多级页表的连续性,这其中的每一步查找都是O(1),可以直接索引得到。

  • 分析:

    • 章有10位,也就是页目录号有210个表项,每个表项 4 字节,正好是 4k / 一页;

    • 同理,节(页号/二级页)也有 210 个表项,每个表项 4 字节,也是 4k / 一页;

    • 一个章/顶级页目录,管理 210个节/二级页,每个节(表项)是4K,所以 一章 管理 4M 空间,下图程序使用了 3 章就使用了 12M 内存。

      • 注意:

      • 下图程序随意的使用了内存中的三个部分,如果不采用多级页表,则需要将4G空间整体打表才能使用,此时页表的存储需要4M,而其中有许多内存不需要打表(因为不会被用到,下图空白处)。

      • 而采用多级页表的页表项使用的空间是 210 个目录项 × 4字节地址 = 4K,总共需要16K远小于4M.

        章 / 顶级页目录中,共有 3 个表项是有效的,分别指向3个节 / 二级页目录,所以顶级页目录+3个二级页目录共16K.

    • 顶级页目录的前两个表项是在低地址空间,后一个表项是在高地址空间,对于这种不连续的逻辑地址的使用也是完美处理的;

      也就是说,程序可以使用不连续的逻辑地址空间,但是多级页表仍然是连续的,查找的时间复杂度是O(1)的

img

3.4 多级页表的时空复杂度

多级页表增加了访存的次数。每多一级,内存空间的节省越多,但也会多访问一次内存。32位系统或许不太明显,64位系统可能是五、六级的页表,这时时间消耗就会很大,效率降低。

3.5 优化:快表

上述多级页表降低了空间复杂度,但时间复杂度依旧不理想,这里引入了 TLB,快表。

TLB 是一组相联快速存储(快表),是CPU内的寄存器;可以非常快的找到最近使用的逻辑页对应的物理页框号。

TLB: Translation Lookaside Buffer.直译可以翻译为旁路转换缓冲,常称之为快表。

这部分在计组的Cache有讲过,这里相当于是另一种角度来看这个问题。

工作过程:

  • TLB中存放从虚拟地址到物理地址的转换表;项由两部分组成:逻辑地址页号+偏移

  • CPU 需要访存时,优先在TLB中寻址,并且通过硬件组相联的设计,在硬件电路上进行比对,可以达到 近似 O(1) 的查找效果。

img

TLB能够降低时间开销的原因:

  • 公式如下图所示,想办法增加TLB的命中率,就能使有效访存时间降低至近似访存1次;

    简单讲就是减少了访问物理内存的次数。

  • 下图公式是 TLB + 单级页表的计算公式。

img

页的个数通常很多,而TLB可以容纳的索引很少,TLB可以有效工作的原理是:

  • 程序的地址访问存在局部性,
    • 空间局部性:比如程序的循环、顺序结构使得被访问地址周边的地址也很快被访问;
    • 时间局部性:有一些变量和语句经常被访问;
  • 把这些经常被使用的放入TLB,并采用合适的替换策略,就能够保证TLB的命中率较高,降低访存时间。
img

采用 TLB+多级页表 的方法,首先在TLB中寻址存,如果 TLB 未命中,则对多级页表寻址,这样就降低了访存的时空复杂度。

4. 段页结合的内存管理

4.1 虚拟内存

在真实的操作系统中,通常采用段页式存储管理,段面向用户,页面向硬件内存。这一部分就讲解一下第 1 部分的 段 和 第 2 部分的 页 如何结合在一起。

简单的想法是:

  • 程序编译为段,段被装入内存分区(这里的内存并不是物理内存,因为前面论证过时空效率不高),依据段表寻址;

    分段:代码分段+内存分区+段表,代码映射到程序员眼中的内存

  • 而面向物理内存,划分为页,并根据页表/多级页表寻址;

    分页:内存分页 + 多级页表

  • 在前者和后者之间建立映射关系,即将程序员眼中的内存与物理内存建立映射,就完成了 段 和 页 的结合。

  • 这里程序员眼中的内存 就是 虚拟内存

4.2 段页结合的构成机理

一个很好的参考资料:关于逻辑地址、线性地址、虚拟地址、物理地址的理解

4.2.1 程序放入物理内存

首先是程序放入物理内存的过程。

  • 如下图所示,程序的代码段在虚拟内存上按照 2.2 内存分区的方法,找到空闲区域并放入;
  • 将虚拟内存上的地址映射到物理内存上。
    • 举例:将左侧用户代码段再次打碎放入内存页中。
    • 0x00345008 映射为 0x7008,再到物理内存取址;
    • 这个映射对用户不可见/透明,所以用户眼中看到的就是下图左侧的虚拟内存。
img

4.2.2 程序如何访存执行

这时的访存问题也就是 段页同时存在时的重定位 / 地址翻译。

  1. 首先要知道 程序段 在 虚拟内存 上的位置
    • 用户发出的逻辑地址段号 + 段内偏移
    • 基址:如下图在段表中,根据段号找到 基址 4800
    • 虚拟地址:段内偏移+基址
  2. 虚拟地址向物理内存进行映射,考虑分页机制,再计算真实物理地址;
  3. 操作系统把真实物理地址发到地址总线,访存完成。

这样,从用户角度看,完全是段的概念,同一代码段是连续的。但从操作系统角度讲,这个段是虚拟的,需要再映射到物理地址上。从物理内存角度讲,虚拟内存的段被打散在不同页上,并且使用了多级页表,提高了时空效率。

几个疑问的解答:

  • 前文第2部分提到过,内存分区会有越分越碎的问题,为什么这里还能用分区的思路来划分虚拟内存呢?

    • 答:虚拟内存放在磁盘上,这样用户眼中就会有比实际大得多的内存空间。
  • 引入虚拟内存的优越性,为什么不直接对段分页?

    • 每个用户代码都有多个段,如果直接给段分页,进程的每一个段需要都维护一个页表;

      经过虚拟内存的中介,只需要给虚拟内存维护一个页表;

    • 优化碎片问题,进一步提高空间利用效率。

      假设一个程序共有2047KB,分为两个段,1个段是1025KB,一个段是1022KB;

      如果经过虚拟内存的中介,只需要放到两页上;如果直接给段分页,就要放到3页上

img

从用户角度看, 能够使用所有的虚拟地址空间。 从物理内存上看, 用户仅仅使用了一些内存页。

4.3 一个段页式内存管理实例

实验6. 下面开始看代码啦。

4.3.1 程序放入内存

  • 为程序/进程分配内存:分配段、页,建立段、页表;进程可以带动内存;
  • 进程有了自己的内存,程序放入内存:从进程 fork 中的内存分配开始说。
img
  • 段页式内存下程序如何载入内存?

    • 需要从虚拟内存中分出空闲区域,将程序段放入

    • 对虚拟内存进行分区,使用 2.2.3 中的分区选择算法,对程序的各个段进行适配,建立段表

    • -------------------👆虚拟内存层,物理内存层👇------------------

    • 对虚拟内存占用的空间进行分页,分配物理内存的页,建立页表,同时程序也就载入了内存;

      将用户数据段划分为3片,每一片关联物理内存的页框

img

4.3.2 程序使用内存

使用内存的核心是重定位,抑或是说 寻址。

以上图为例,需要执行 mov [300],0

  1. 300是逻辑地址,查找段表,找到虚拟地址为 0x00045300;
  2. 再将虚拟地址重映射到物理地址,得到真实物理地址 0x0007300;
  3. 进行访存操作,如上图中,是将 0 放入 该位置。

4.3.3 简单总结 && 思路梳理

上面的思路大致是如下几步,代码实现也是这几个部分:

  1. 从虚拟内存中划分出空闲区域;
  2. 建立与段表的关联并修改;
  3. 物理内存中找空闲页;
  4. 建立与页表的关联并修改;
  5. 重定位寻址,即 *p = 7;

4.3.4 具体代码实现1 | fork=>copy_mem

首先 fork 需要实现上述思路的前 4 步。

  • fork -> sys_fork -> copy_process 这个过程在学习笔记5的进程切换中已经了解过了;
  • copy_process 中调用 copy_mem,将父进程的内存赋值子进程的内存;

copy_mem 做的事情:

  • nr 是进程的编号,p 是PCB;

  • new_data_base 就是每个进程在虚拟内存中的起始地址,每个进程占 64M 虚拟地址空间,互不重叠;

    程序中的 0x400_0000 是 64 M ,乘以 nr 就得到了 第 nr 个进程的起始地址。

    给 new_data_base 赋值就是在虚拟内存中分区

    这是子进程虚拟内存的起始地址。

  • set_base() 就是在设置段表,在这里的实现中,数据段和代码段是同一个(没有像上文那样被划分成程序员熟悉的多段),这一步就是在建立段表

  • 这里完成了第 1 步 和 第 2 步。

img

上面的划分方法,数据段和代码段都是同一个段,所以这种划分虚拟内存的方法比较弱智,但是它比较简单,适合初学者入门。


下一步就是要建立虚拟内存与物理内存的联系,分配页建立页表

  • 由于所有进程的虚拟地址互不重叠,所以所有进程可以共用一套页表;但是现在的操作系统,进程的虚拟地址是会重叠的,所以每个进程应该维护自己的页表;

    Linux 0.11 中就是前者,没有切换页表,现代操作系统中就应当切换。如下面所讲的 fork 过程就各自维护各自的页表。

img
  • copy_mem 调用 copy_page_tables,子进程和父进程共用物理内存的页,因此在fork子进程的过程中,没有新分配物理内存,只需要建立新的页表(拷贝旧页表)
img
  • 下面具体看看 copy_page_tables 建立新页表。

    • 32位地址空间,实参 old_data_base 对应形参 from,表示父进程的虚拟内存的起始地址,from 右移20位,和 0xffc 进行位与结果赋给 from_dir(将后两位清为 0);
      • 解释:根据虚拟地址结构,右移 22 位,就得到了页目录号,再乘以 4,得到顶级页目录中的索引位置(顶级页目录中的每一项占4字节),所以代码里直接写了右移 20 位再将后两位清 0,这是等价的;
      • 现在就找到了 章。
    • to_dir 来源同理,找到的子进程的 章。
    • from_dir 是章,也就是指向父进程的第一个/节 / 二级页目录的 指针,*from_dir 就是父进程的第一个节 / 二级页目录;
    • 同理,to_dir 是子进程的 章,指向子进程的第一个 节的指针;
    • size 是 章 的个数。
    • 代码见下图;
img
  • 找到了父进程的章,也找到了子进程的章;接下来根据父进程章下的节建立子进程章下的节,先分配页,再循环复制页目录和页表。

    • 循环 size 次,给子进程的节 / 二级目录分配页,每一章的二级目录就分配一页。

    • get_free_page() 使用 内嵌汇编(如下图右上角) 从空闲列表中取出一页;

      这一步相当于分配二级页,上面4.3.3 中思路的第三步。

      mem_map 在学习笔记1 中的OS开机过程中的 main.c 中初始化,空闲为0,占用为1,内嵌汇编 做的事情就是找到 mem_map数组的空闲位。

    img
    • 分配页后,按照父进程的每一个二级页目录,建立子进程的每一个二级页目录,这就是复制页表项的过程。

      这一步相当于根据父进程页表建立页表,上述思路的第 4 步。

      this_page = *from_page_table; 再将 *from_page_table赋值给了 to_page_table,相当于拷贝父进程页表内容。

      由于此时父进程和子进程都指向了同一个物理页,需要将其设置为只读:this_page = ~2;

      设置只读:如果一个进程读,另一个进程写,就混乱了。

      mem_map[this_page]++,有点像引用计数的概念。

      比如这里两个虚拟内存页共享一个物理内存页,数组值就是2。这意味着,其中一个进程释放内存时,不能直接减到0(而是1,减到0就意味着空闲了)。

  • 这里用了写时复制 的方法。

子进程 一开始 页表 是 copy 父进程页表的,读的时候对应的物理内存都一样,这时读时共享

直到父子进程有一个进程要用写操作修改物理内存,这时父子进程在物理内存层就不能保证完全一致,再为子进程相应的段分配物理空间。即写的时候要重新修改页表对应的映射 即 写时复制

img

最后得到的内存图像如下图所示,进程1 fork 得到进程2,它们的虚拟内存指向了同一块物理内存空间,一页一页对应:

img

4.3.5 具体代码实现2 | 代码执行

段表、页表建立之后,就可以运行程序,进行第 5 步重定位 / 地址翻译了:

  • 当用户程序访存时(读写内存时),查段表、页表进行地址翻译的工作由 MMU 完成;

  • 读时共享,写时复制,子进程执行*p=8,根据虚拟内存的映射会写到新的物理页里面,

  • 我的理解:子进程如果没有写操作,就可以以只读的方式共享父进程的内存页;如果有写操作,就会使用写时复制,产生子进程独立的内存页,并且需要修改页表

img

4.4 总结

整体思路其实还是很清晰的,但是代码实现我还不是很理解,特别是最后介绍到的写时复制概念,只是隐约感觉到,通过虚拟内存的一层映射,使得父子进程的逻辑地址的写入实现了分离。

整个段页结合的内存管理思路在 4.3.3 。

posted @ 2022-09-08 11:44  climerecho  阅读(1178)  评论(3编辑  收藏  举报