文章目录
前言
内存管理是操作系统一个重要的作用,进程要想在操作系统中运行起来,就必须先加载到内存中。操作系统就要保证分配给进程足够的内存空间,并且保护该进程的内存空间不被其他进程访问。从进程的角度(用户)来看,是更希望内存能够按照分段的方式来提供使用的;而从操作系统的角度(管理者)来看,分页似乎是更好的选择。那么操作系统是如何做到从分段到分页的呢?一个程序如何载入到内存中运行的呢?
一、进程与内存
1、程序如何执行
编译器在编译程序的源文件时,会将高级语言编译为汇编语言,此时在汇编语言中就已经得到了每条指令的明确地址。例如程序
int main (int argc, char * argv[])
{
………………
}
在编译完成后,得到的汇编文件就是
_entry:
call _main
call_exit
_main:
……………………
ret
如果_entry是入口地址,main相对于entry的地址为40,则相当于
_entry: // 入口地址
call 40
call _exit
_main: // 偏移为40
………………
所以如果程序要运行,只需要将初始的PC指针指向call 40这跳指令的地址,之后就会跳到main函数中不断取指执行就可以了。
在以前的单道批处理系统中,这么设计确实没有问题,因为同一时刻系统中只有一个程序(进程)在运行,运行中的程序不会干扰到下一个程序,也不会受到别的作业的干扰。但在多道批处理系统中就有问题了,因为系统中或者说是在内存中会同时存在多个程序,很有可能别的程序编译后的汇编文件中的函数地址也是40,那么就产生冲突了。
2、逻辑地址与重定位
为了避免各程序间的地址冲突,上例中的40就不能是真实的地址即物理地址,而是一种逻辑上的地址,只是表示一种偏移。在程序运行时,必须加上某一个基地址,这就是重定位。比_entry这条指令放在1000地址处,则实际上call 40这条指令应该跳转的是1040处的地址。
重定位方式
既然已经知道了需要重定位地址,那有多少种重定位方式呢?哪种比较好呢?
编译重定位
如果在编译时系统中就有一块以1000为起始的空闲地址,且这块地址足够程序使用,那么就把基地址设为1000,之后的所有地址都加上这个基地址,即1040。但是这样会导致编译耗时更久,并且在运行时很可能这块地址已经被分配给其他进程了,导致程序运行失败。所以编译时重定位更适合比较硬的系统比如嵌入式系统,某个程序固定在一段地址上运行。
载入时重定位
编译时重定位无法确保运行时所需要的空闲空间,那么换成载入时重定位不就行了吗?乍一看好像没什么问题,系统在载入程序时有一块以1000为起始的足够大的内存,那么这时再把基地址设为1000,后面的地址都加上这个基地址不就行了吗?但是这样也会导致一个问题,如果该进程因为IO请求长时间阻塞,而系统中的内存资源很紧张,这时还把该进程放在内存中是不合理的。因该将该进程换出到磁盘中,等其IO事件发生了再换回内存。此时1000这个地址很可能已经运行了别的程序了,产生冲突。
运行时重定位
所以最好的办法就是运行时重定位。在运行到call 40这条指令时,就把基地址1000取出来,加上40就可以了。而且基地址在程序运行期间是有可能变化的,因此必须将基地址保存起来并且时时更新,那么保存在哪呢?当然是PCB啦。
3、内存分段模型
前面所说的是将整个进程都放入某一块连续的地址中,但实际上是这样吗?实际上是讲进程所需的内存分为几个段,每个段载入到一块连续地址中,但段与段之间的地址并不要求是连续的。程序都可以分为几个段
这样做也是为了更好的利用内存。假设系统中有几块空闲的内存空间,但每块地址空间都不能单独满足进程的内存需求。如果是将整个进程放入一块地址中,就放不下了,分段则可以放入。那么,基地址就不应该只有一个,其意义也不是进程的基地址。而是每个段都有一个基地址,成为段基址,编译后的地址则是端偏移地址。系统为每个进程建立了一个表用来记录段信息,就是LDT(局部描述表),有一个专门存放该表地址的寄存器LDTR。
至此,进程已经可以运行起来了。
4、如何在内存中找到空闲内存
首先考虑的是如何分割内存,再将分割后的内存提供给进程使用。即内存如何分区。
固定分区
系统在初始化内存时分为n个相同大小的区间,然后记录空闲区间,将内存分配给内存。这种分区方式实现简单,系统开销小。但是会有内存碎片,(小进程大分区),且进程的数量最多为n个。
可变分区
可变分区的基本思想时建立已分配分区表和空闲分区表。已分配分区表中记录了已经使用的内存,保存这使用内存的进程信息,起初地址和长度。空闲分区表记录了内存的空闲区域,包括起始地址和长度。进程在请求内存时,根据请求的内存大小以及空闲分区表的情况来分配内存,同时更新这两张表。这样做的好处是:可以给需求大内存的进程分配大块地址,给需求小内存的进程分配小块地址,提高内存利用率。
可变分区的三种适配方式
可变分区的方式需要考虑如何适配的问题,比如,一个进程请求50K的内存,此时空闲分区表上有多个内存区域大于50K,该分配哪一块给进程呢?
首先适配:将第一个符合该请求的内存分配出去。好处是块,时间复杂度为O(1),但没有考虑到进程的特点,造成内存碎片。
最佳适配:遍历所有的空闲分区,将最接近50K的空闲分区分配给进程,可以提高内存的利用率,但会造成很多细小的内存碎片。
最差适配:遍历所有空闲分区,将最大块的内存分配给进程。好处是剩下的内存块都比较均匀。
可变分区存在的问题
可变分区其实都会造成内存碎片,比如一个进程需要100K的内存,系统中的空闲分区有60K和50K的,但它们不连续。要满足该进程的需求有两种方式,一是内存紧缩,即想办法将60K的内存空间和50K的内存空间连接在一起,但这种方式比较费时,并且在此期间系统都不能为应用程序服务。另一种方式就是将将这100K的请求打散,分散分配到各个小的空闲区间上,就是内存分页的思想。
二、分页模型
1.分段还是分页
要对物理内存进行划分,就要先确定划分的单位时按段还是按页。从内存管理的角度,分析以下分段与分页的优缺点。
第一点,从表示方式和状态确定角度考虑。段的长度大小不一,用什么数加结构表示一个段比较好,又如何确定一个段已经分配还是空闲呢?而页的大小固定,只需用位图就能表示页的分配与释放。
第二点,从内存碎片的利用看。由于段的长度大小 不一,更容易产生内存碎片。而页的大小固定,是分配内存的最小单位。页也会产生碎片,但可以通过设置映射方式来将不连续的物理页映射到连续的逻辑地址中,从而消除外部碎片。而一个页的内部碎片最多也就是这个页的大小。而且如果一个程序需要连续的内存空间,但此时只有分散的页时,也可以更改映射方式,满足进程的需求。
第三点,从内存和硬盘的数据交换效率考虑。当内存不足时,操作系统需要把不活跃的进程中的部分内存换出到硬盘中,内存充足时又需要从硬盘中写回内存。由于段的大小不已,交换的时间也不相同,会导致系统性能抖动。如果以页为单位交换内存,则不会出现这些问题。
2.如何分页
使用分页模型来管理内存,首先把物理空间分成4KB大小的页,这一页管理这从物理地址x到x+0xFFF这一段的物理内存空间。x必须是0x1000对齐的。
在Linux中为表示每个物理页,建立了一个数据结构Page。代码如下所示
struct page {
//page结构体的标志,它决定页面是什么状态
unsigned long flags;
union {
struct {
//挂载上级结构的链表
struct list_head lru;
//用于文件系统,address_space结构描述上文件占用了哪些内存页面
struct address_space *mapping;
pgoff_t index;
unsigned long private;
};
//DMA设备的地址
struct {
dma_addr_t dma_addr;
};
//当页面用于内存对象时指向相关的数据结构
struct {
union {
struct list_head slab_list;
struct {
struct page *next;
#ifdef CONFIG_64BIT
int pages;
int pobjects;
#else
short int pages;
short int pobjects;
#endif
};
};
//指向管理SLAB的结构kmem_cache
struct kmem_cache *slab_cache;
//指向SLAB的第一个对象
void *freelist;
union {
void *s_mem;
unsigned long counters;
struct {
unsigned inuse:16;
unsigned objects:15;
unsigned frozen:1;
};
};
};
//用于页表映射相关的字段
struct {
unsigned long _pt_pad_1;
pgtable_t pmd_huge_pte;
unsigned long _pt_pad_2;
union {
struct mm_struct *pt_mm;
atomic_t pt_frag_refcount;
};
//自旋锁
#if ALLOC_SPLIT_PTLOCKS
spinlock_t *ptl;
#else
spinlock_t ptl;
#endif
};
//用于设备映射
struct {
struct dev_pagemap *pgmap;
void *zone_device_data;
};
struct rcu_head rcu_head;
};
//页面引用计数
atomic_t _refcount;
#ifdef LAST_CPUPID_NOT_IN_PAGE_FLAGS
int _last_cpupid;
#endif
} _struct_page_alignment;
3、虚拟内存
进程的逻辑地址(即编译后产生的相对地址)是用分段模型表示的。分为代码算,常量区,全局数据区,栈,堆,映射区等。而操作系统管理又是按页为单位管理物理内存的。那么如何从段映射到页呢?这就采用了虚拟内存技术。通常上进程向操作系统请求的内存空间都是虚拟空间,等到实际用到这部分空间时,操作系统才会建立映射,使其映射到物理内存。
虚拟内存就是逻辑上的一个数值,而虚拟内存空间就是一堆数值的集合。在32位的处理器可以表示的虚拟空间位0~0xFFFFFFFF,即4个字节大小。而64位的虚拟地址空间则更大,有0~0xFFFFFFFFFFFFFFFF,即8个字节大小的虚拟空间。
对于如此巨大的虚拟地址,就不能向管理物理内存那样管理虚拟内存了,因为这样建立的页的结构体就太多了。而且对于进程而言,其内存分布是一段一段的,虚拟内存空间是按一个区域一个区域来管理的。
4、虚拟地址到物理地址的映射
那么一个虚拟地址如何映射到真正的物理地址呢?答案就是MMU页表。每个进程的PCB中都保存着一份页表指针,指向该进程页表。
以32位系统为例,一共可以表示4G的虚拟地址。如果按一级页表进行映射,则一个进程的MMU页表需要4G/4K也就是4M个表项,假设每个表项占用4个字节,则需要16M字节空间来存放MMU页表。那么10个进程呢?100个进程呢?如果按照一级页表的形式进行映射的话,页表的空间开销太大。因此就产生了多级页表。就像人们看书一样,大多数书也不会只有一级目录,应该是分为几章几节的,索引时就可以先看哪一章,再看哪一节的内容了。
二级页表下的32位系统虚拟空间就可以分为3部分了。低12位用来表示业内偏移,共可以表示0~0xFFF共4KB,正好是一个物理页的大小。中间10位用来表示二级页号(节),共有1K个节,高10位就用来表示一级页号(章),共有1K个章,4KB * 1KB * 1KB = 4GB。
所以从虚拟地址到物理地址映射的步骤为:
- 计算一级页号、二级页号和页内偏移。
- 以一级页号为索引(章)在MMU页表中(每个进程都有一个MMU页表指针)找到页目录项(在第几章)。
- 以二级页号为索引(节)找到页表的第几项。查看页表项的物理页框字段是否有效,若是有效,以此字段为索引取出Page结构体,拿到物理页的基地址,加上页内偏移,即找到了物理地址。
- 若是字段无效,则会发生一个缺页异常。Linux是伙伴系统分配一个物理页(Page),然后操作系统再将此物理页的索引填入到MMU页表表项中。
可以看到,二级页表的优点是节省了空间。其原理是进程的虚拟空间不需要全都映射到物理空间中。假设访问的虚拟空间在第7章第6节,那么1到6章虽然也占据了一个表项空间,但实际上没有二级页目录。系统只为访问到的虚拟空间建立页表的映射。缺点就是增加了访问内存的次数。无论是一级页目录还是二级页目录都是放在内存中,相当于是以时间换空间了。
多级页表性能上的损失可以由TLB(快表)来弥补。TLB是一种访问速度比内存快很多的高速缓冲存储器,用来存放最近访问的虚拟页号->到物理页号的映射。在计算出虚拟页号之后,若是命中,则直接可以获得物理页号,若没有命中,则还需要查MMU表,并把此次映射填入TLB中。
总结
介绍了进程的分段模型即为何要分段,并由分段容易产生内存碎片的缺点引出了物理内存的分页管理模型,之后介绍了MMU页表的工作原理,比较了一级页表和多级页表的优缺点。实际上这只是内存管理的简略介绍。内存管理的核心还没有涉及,比如如何为进程分配虚拟内存?发生缺页异常时进程如何分配物理内存页?还有比页更小的分配单元吗?其实是有的。Linux就是采用伙伴系统和Slab分配器为进程和操作系统分配物理内存的。这些没时间写了(肝不动了),但是需要去了解一下。
注:此博客只是为记录本人在学习操作系统的总结和心得体会,也是我第一次写博客,写的不好敬请谅解。
参考资料:哈尔滨工业大学李志军老师的操作系统课程
极客时间彭东操作系统实战