操作系统段页结合的实际内存管理--13
Posted 大忽悠爱忽悠
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了操作系统段页结合的实际内存管理--13相关的知识,希望对你有一定的参考价值。
操作系统段页结合的实际内存管理--13
- 段、页结合: 程序员希望用段, 物理内存希望用页,所以…
- 段、页同时存在:段面向用户/页面向硬件
- 段、页同时存在是的重定位(地址翻译)
- 一个实际的段、页式内存管理
- 程序、虚拟内存+物理内存的样子
- *p=7? 父进程*p=7、子进程*p=8? 读写内存 *p=7
- MMU简单科普
- 页表作用小结
- 参考
段、页结合: 程序员希望用段, 物理内存希望用页,所以…
- 如果按段来操作内存,需要在内存中直接划分出一整块内存来存放当前段
这种方式对物理内存的使用而言很不友好,因为会造成大量的内存碎片,但是对程序员而言却很友好,因为访问某个段的内存是连续,而不是离散的
- 如果按页来操作内存,就需要将当前段打散成很多页后,存放在内存中
这种方式对于内存的管理而言非常的友好,不会造成内存碎片问题,但是站在程序员的角度来看,段内的内存是不连续的。
能否站在程序员的视角看来,程序分段存放在内存上的模样是连续的,但是站在物理内存视角看来,却是分页管理的呢?
我们期望的模样就是上面这个样子,在程序员看来段内内存是连续的,但是实际物理内存是分页管理的,那么为了实现这个效果,势必就需要额外增加一层映射。
那么处于中间状态,看上去像物理内存的内存应该被叫做什么呢?
- 对于用户来说,可以在该内存上划分地址空间来存放对应的段,但是这样划分得到的地址并不是真实的物理地址,还需要再次经过一层映射,映射到物理页上才行。
- 在该内存上分配的地址并不是真实地址,而是虚拟地址,只有经过一层映射后,才能映射到真实的物理页上,所以该内存也被称为虚拟内存。
对于应用程序而言,只需要在虚拟内存中划分出一整块空间来存放当前段即可,然后会由操作系统将这块虚拟内存空间映射到对应的多个物理页上。
当程序需要访问段中某个数据时,也只需要访问对应虚拟内存中的地址,然后由操作系统将该虚拟地址映射到真实的物理地址上,完成访问。
段、页同时存在:段面向用户/页面向硬件
有了虚拟内存的之后,用户写的程序首先在虚拟内存中划分出对应的空间来存放,但是实际程序载入内存时,却会根据先前划分的虚拟地址空间,分别打散存储到对应多个物理页上。
当用户想要访问内存时,也只需要面向虚拟内存操作即可,用户发出的地址都是虚拟地址,但是操作系统通过将虚拟地址映射到物理地址后,用户就可以正常读取和设置物理内存中的数据了,对于用户而言操作虚拟内存和物理内存无区别。
段、页同时存在是的重定位(地址翻译)
当段页结合,引入虚拟内存之后,关键点就在于如何将虚拟地址翻译为真实的物理地址,这个过程如上兔所示:
- 首先,用户按照段的方式进行内存访问,利用: 段号+段内偏移,然后通过查询段表(LDT),得到段基址,然后加上段内偏移,得到虚拟地址。
- 虚拟地址,经过MMU计算,得到虚拟页号,然后去查询对应的页表,得到对应的真实物理页号
- 通过真实的物理页号,和对应的页内偏移地址,就可以计算出真实的物理地址了
一个实际的段、页式内存管理
这个故事从哪里开始?
- 内存管理核心就是内存分配,所以从程序放入内存、使用内存开始…
程序是一段代码,存放在磁盘上,想要让程序运行起来,就必须将程序从磁盘中读取到内存中来,那么这个过程就涉及到内存的分配: 在虚拟内存中为当前程序分配对应的段,建立对应的段表。并且还需要为每个虚拟内存中的段,打散后映射到多个物理页上,然后建立对应的页表,这样才能把程序顺序读入到内存中来。
运行起来的程序就是进程,因此上面讲述的过程其实是包含在一个新进程被创建出来的过程中的,因此是进程带动了内存的使用。
所以,下面将目光放到fork创建进程的地方,从这里讲起:
段、页式内存下程序如何载入内存?
- 首先需要在虚拟内存中通过分区适配算法,找到一块空闲分区,来存放程序中的段,这里的存放实际上指的是在段表中新增一条表项,记录当前分配在虚拟内存中的段基址和占据的大小
- 将虚拟内存中分配的段空间打散,按照对应的分页机制,映射到若干物理页上去,这里需要完成的是建立对应的页表,这样才能通过虚拟地址翻译,然后查询页表得到真实的物理地址
- 然后配合磁盘读写,就可以将程序分段读入到对应的物理页上
当用户需要访存时,首先通过段号,查询段表,得到段基址; 然后拼接段内偏移地址得到虚拟地址,再通过对应的分页机制,解析出虚拟地址对应到页表中哪一个表项,然后得到对应的物理页号,通过物理页号就可以轻松计算出物理页号的基址,加上页内偏移地址,最终计算出实际的物理地址。
故事从fork()开始 --> 分配虚存,建段表
一个程序要运行,那肯定对应要有一个进程,因此首先是创建进程,分配完各种资源后,才能运行,而创建进程的流程之前已经分析过了,简化下就是这几步: fork()—>sys_fork—>copy_process
在linux/kernel/fork.c中
int copy_process(int nr, long ebp,...)
...
copy_mem(nr, p); ...
copy_process函数负责完成进程各种资源的初始化工作,当然也包括对于程序需要内存的创建了,而该职责由copy_mem函数完成。
copy_mem函数就主要完成在虚拟内存中对段空间的申请,对应页表的建立和程序从磁盘读入到物理页的过程。
// 设置新任务的代码和数据段基址、限长并复制页表。
// nr 为新任务号;p 是新任务数据结构的指针。
int copy_mem (int nr, struct task_struct *p)
unsigned long old_data_base, new_data_base, data_limit;
unsigned long old_code_base, new_code_base, code_limit;
code_limit = get_limit (0x0f); // 取局部描述符表中代码段描述符项中段限长。
data_limit = get_limit (0x17); // 取局部描述符表中数据段描述符项中段限长。
old_code_base = get_base (current->ldt[1]); // 取原代码段基址。
old_data_base = get_base (current->ldt[2]); // 取原数据段基址。
if (old_data_base != old_code_base) // 0.11 版不支持代码和数据段分立的情况。
panic ("We don't support separate I&D");
if (data_limit < code_limit) // 如果数据段长度 < 代码段长度也不对。
panic ("Bad data_limit");
new_data_base = new_code_base = nr * 0x4000000; // 新基址=任务号*64Mb(任务大小)。
p->start_code = new_code_base;
set_base (p->ldt[1], new_code_base); // 设置代码段描述符中基址域。
set_base (p->ldt[2], new_data_base); // 设置数据段描述符中基址域。
//------------------------------下面可以暂时不看----------------------------------------------
if (copy_page_tables (old_data_base, new_data_base, data_limit))
// 复制代码和数据段。
free_page_tables (new_data_base, data_limit); // 如果出错则释放申请的内存。
return -ENOMEM;
return 0;
下面这三行代码就完成了在虚拟空间中对段空间的申请:
new_data_base=nr*0x4000000; //64M*nr
set_base (p->ldt[1], new_code_base); // 设置代码段描述符中基址域。
set_base (p->ldt[2], new_data_base); // 设置数据段描述符中基址域。
可以看出linux 0.11中代码段和数据段是不进行区分的,因此这两者是共享一块虚拟内存的
进程0、进程1、进程2的虚拟地址
- 每个进程的代码段、数据段都是一个段
- 每个进程占64M虚拟地址空间,互不重叠
因为每个进程的段空间不重叠,意味着各个进程的虚拟空间中的虚拟地址不会重叠,那么对应各个进程的虚拟地址解析得到的虚拟页号不会重叠,因此在linux 0.11中多个进程可以共享一套页表。
对于现代操作系统而言,各个进程对应的页表是会产生重叠的,因此每个进程需要有自己的段表和页表
接下来应该是什么了? —> 分配内存、建页表
在分配完段表后,下面就是对页表进行创建和处理了。
上面cpoy_process函数中还有最后一段没有看,下在时机成熟了,可以来看看:
// 设置新任务的代码和数据段基址、限长并复制页表。
// nr 为新任务号;p 是新任务数据结构的指针。
int copy_mem (int nr, struct task_struct *p)
unsigned long old_data_base, new_data_base, data_limit;
unsigned long old_code_base, new_code_base, code_limit;
code_limit = get_limit (0x0f); // 取局部描述符表中代码段描述符项中段限长。
data_limit = get_limit (0x17); // 取局部描述符表中数据段描述符项中段限长。
old_code_base = get_base (current->ldt[1]); // 取原代码段基址。
old_data_base = get_base (current->ldt[2]); // 取原数据段基址。
if (old_data_base != old_code_base) // 0.11 版不支持代码和数据段分立的情况。
panic ("We don't support separate I&D");
if (data_limit < code_limit) // 如果数据段长度 < 代码段长度也不对。
panic ("Bad data_limit");
new_data_base = new_code_base = nr * 0x4000000; // 新基址=任务号*64Mb(任务大小)。
p->start_code = new_code_base;
set_base (p->ldt[1], new_code_base); // 设置代码段描述符中基址域。
set_base (p->ldt[2], new_data_base); // 设置数据段描述符中基址域。
//------------------------------重点看下面这段代码----------------------------------------------
//old_data_base指向的是父进程在虚拟内存中数据段和代码段的地址,作为拷贝的源地址
//而new_data_base指向的是上面在虚拟内存中分配的子进程数据段和代码段的地址,作为拷贝的目的地址
//data_limit是段限长
if (copy_page_tables (old_data_base, new_data_base, data_limit))
// 复制代码和数据段。
free_page_tables (new_data_base, data_limit); // 如果出错则释放申请的内存。
return -ENOMEM;
return 0;
copy_page_tables是用来对子进程页表进行处理的,那么具体是怎么处理的呢?
下面就一点点来读一下它的源码吧:
copy_page_tables函数源码解析
copy_page_tables这个函数在父进程创建子进程的过程中使用,父进程要负责设置子进程的代码段、数据段(线性空间),然后为子进程拥有的线性地址空间创建对应的页目录项和页表,使得子进程能够进行内存寻址。copy_page_tables的工作就是通过复制父进程的页表来创建子进程的页表,并设置相应的页目录项。
这里线性地址空间就是虚拟内存
注意页目录表和页表的区别,并且要搞清楚他们分别的作用是什么:
一个页目录表管理1024个页目录项
一个页表管理1024个页表项
一个页表项管理一个物理页面,一个物理页面有4K物理地址
copy_page_tables函数的源码我们先一点点来读一下:
- 先来看看copy_page_tables函数的参数
int copy_page_tables(unsigned long from,unsigned long to,long size)
函数的参数是从fork->copy_process->copy_mem中传递过来的old_data_base,new_data_base,data_limit。
- 其中old_data_base是父进程局部描述符表LDT中数据段的基地址(虚拟地址空间)
- new_data_base为即将创建的子进程在虚拟地址空间中的基地址
linux 0.11中整个4G大小的虚拟内存是等分的,目前支持的是64个进程,则4G/64=64MB,即每个进程分配64MB的虚拟地址空间,因此对于一个任务号为nr的进程,即对应的虚拟地址空间起始地址为nr*64MB,
- data_limit为父进程的局部描述符表LDT中数据段描述符中的段限长。
- copy_page_tables首先会去检查虚拟地址对齐
if ((from&0x3fffff) || (to&0x3fffff)) //4M对齐检测,否则die
panic("copy_page_tables called with wrong alignment");
32位虚拟地址拆分成3部分来看,10 | 10 | 12, 前十位对应页目录项,中间10位对应页表项,最后12位对应页内偏移。
0x3fffff:最后12位全为1,相当于4MB,也就是一个页表管辖4MB的地址空间(4MB线性地址空间<->4MB物理地址空间)。
一个页表可以管理多大的物理内存空间,取决于该页表内部存放了多少条页表项,每个页表项会记录一个虚拟页号到物理页号的映射关系,并且一个物理页大小为4K
上述代码也就是判断末尾的22位是否全为0,也就是说任意进程的虚拟地址空间必须是从0x000000开始的4MB的整数倍虚拟地址。
- 然后再通过父进程数据段基地址对应的虚拟地址得到页目录项的物理地址
from_dir = (unsigned long *) ((from>>20) & 0xffc); // _pg_dir = 0,通过线性地址得到页目录项物理地址
存放页目录项由内存中一个空闲页面完成的,一页大小为4K,每个表项大小为4B,因此页目录表共1K项。
从上图可以看出,想要得到某个虚拟地址对应的页目录号,只需要将虚拟地址左移22位即可,但是为什么上面只是左移了20位呢? 还有& 0xffc
是什么意思呢?
- 页目录号索引=from>>22,又因为1个页目录项占4B,则页目录项相对物理地址=页目录号<<2,例如页目录号为1,则页目录项相对物理地址为4B
又因为页目录的基址为0x000000,因此页目录项相对物理地址=页目录项绝对物理地址。
故【(线性地址>>22)<<2】得到的就是页目录项的物理地址。【(线性地址>>22)<<2】这句话实际上就等价于【(线性地址>>20) & 0xffc】, 也就是把最后2位清空。
拿到父进程起始页目录项的地址
- 计算要复制的页表数
size = ((unsigned) (size+0x3fffff)) >> 22; //计算页表数
size的单位是B,是父进程数据段对应的段限长,这个段限长首先限制的是虚拟地址空间寻址大小,例如进程0的数据段限长为640KB,因此其虚拟地址空间为0~640KB,同时也间接得限制了物理地址空间的寻址大小,因为任何物理地址都需要有与之相映射的虚拟地址,对于0进程而言,映射的物理内存是物理内存起始开始的640KB的空间。
而对于一般的进程,虚拟地址空间为64M,对应的最大物理内存空间也是64M,一个页目录项或者说一个页表管辖4MB的物理内存空间,因此一个进程线性地址空间最多可以拥有16个页目录项,也即16个页表。因此,页表数=(size+4M/4M),相当于求需要多少个页表来管辖,也就是copy_page_tables要复制多少个页表。有多少个页表,则就有多少个对应的页表项。
计算子进程要复制多少个父进程的页表
- 根据页目录项物理地址得到页表物理地址
from_page_table = (unsigned long *) (0xfffff000 & *from_dir);//根据页目录项地址得到页表物理地址
from_dir是页目录项的地址,取*号,相当于取页目录项的内容,因为页目录项指向页表,也即页目录项的内容就是页表的物理地址。
因为get_free_page返回的物理地址=相对物理地址<<12+LOW_MEM(每页大小为2^12 =4KB),因此将低12位清零&fffff000得到的就是页面的物理地址,页表也是占用1页面大小
,因此这里得到的就是页表的物理地址,同时也是第一项页表项的物理地址。
最后12位实际上用于存储页表的属性。至于为什么只用20位存储,因为目前使用的是16M物理内存,16MB/4KB=224/212=2^12页数,20位足够用了,实际上20位可以索引最多4G的物理空间。
拿到父进程起始页目录项指向的页表地址
- 拷贝页表项
if (!(to_page_table = (unsigned long *) get_free_page()))
return -1; /* Out of memory, see freeing */ //取空闲物理页为to_page_table赋值
*to_dir = ((unsigned long) to_page_table) | 7; //将页表存进相应页目录项,//7表示可读写
nr = (from==0)?0xA0:1024; //如果是0地址,只拷贝160页,否则拷贝1024页
//一个页目录表管理1024个页目录项
//一个页表管理1024个页表项
//一个页表项管理有4K物理地址
for ( ; nr-- > 0 ; from_page_table++,to_page_table++)
this_page = *from_page_table; //从源页表中取源页表项
if (!(1 & this_page)) //如果源页表项未被使用,跳过
continue;
this_page &= ~2; //目的页表项读写位, 设置为只读
*to_page_table = this_page; //将源页表项存进目的页表项
if (this_page > LOW_MEM) //如果是主内存区,1MB以内不参与用户分页管理,1MB以上mem_map才管
*from_page_table = this_page;//源页表项也要设置为只读
this_page -= LOW_MEM; //取相对主内存的偏移地址
this_page >>= 12; //取主内存管理数组索引
mem_map[this_page]++; //物理页引用次数加1
首先为子进程页表(1个页表占4K)存储申请一个页面的物理内存,get_free_page()。
然后执行*to_dir = ((unsigned long) to_page_table) | 7,将子进程页表存到相应的页目录项当中,页目录项也是前20位代表页表的地址,后12位代表页表对应的页面的属性,因此把最后3位设置成用户权、可读写、存在,相当于设置了页表对应页面的属性
。
如果是0进程则只拷贝160个页表项,否则全部拷贝1K个页表项,接着for循环拷贝每一个页表项,对于未使用的页表项不进行拷贝。
this_page &= ~ 2其中2=010,~2=101,代表用户,只读,存在。因为此时父子进程共享了物理页,因此需要对物理页的引用次数+1。可以看到这里面索引的计算,索引=(物理地址- LOW_MEM)/4KB。
页表项是前20位代表页表的地址,后12位代表页表对应的页面的属性,因此把最后3位设置成用户权、可读写、存在,相当于设置了页表项对应页面的属性
注意
另外还要强调一点:
if (!(to_page_table = (unsigned long *) get_free_page()))
return -1; /* Out of memory, see freeing */ //取空闲物理页为to_page_table赋值
*to_dir = ((unsigned long) to_page_table) | 7; //将页表存进相应页目录项,//7表示可读写
此时并没有把子进程申请的页表对应的物理页面和用户进程的线性地址空间进行映射
。某一个物理页面和进程的线性地址空间映射是指,将这个页面的物理地址存入该进程线性地址空间所对应的页表的某个页表项当中,这样进程才有权限对该页面进行访问。
这部分严格来说不属于本节内容,建议大家可以先跳过这里,等学到下一节时,自然会明白这里所讲的内容:
写时复制
最后还要强调一下如下代码:
if (this_page > LOW_MEM) //如果是主内存区,1MB以内不参与用户分页管理,1MB以上mem_map才管
*from_page_table = this_page;//源页表项也要设置为只读
this_page -= LOW_MEM; //取相对主内存的偏移地址
this_page >>= 12; //取主内存管理数组索引
mem_map[this_page]++; //物理页引用次数加1
对于这段代码的理解:
对于内核空间,不使用写时复制。一个页面被多个进程共享,每当一个进程产生一次写保护错误,内核将给进程分配一个新的物理页面,将共享页面的内容复制过来,新的页面将设置为可读写,而共享页面仍然是只读的,只是共享计数减小了。当其他共享进程都产生了一次写保护错误后,共享页面的共享计数减成了1,其实就是被一个进程独占了,但此时该共享页面仍然是只读的,如果独占它的进程对它进行写操作仍然会产生写保护出错。为什么不在共享计数减成了1之后就将共享页面置为可写呢?
原因很简单,因为系统并不知道最后是哪个页表项指向这个共享页,如果要把它查找出来会有很大的系统开销,这是中断处理程序应当尽量避免的,所以采用了以逸待劳的办法。如果当初共享的页面不属于主内存块,在共享时就没有作共享计数的处理,就不存在共享计数的问题,直接复制就可以了。
但是,对于内核空间来说,却是不同的。内核空间不使用写时复制机制!这也是Linus在其内核代码注释中提到的,对于内核空间来说,还是以特殊的进程0和进程1来说。进程0是系统中第一个手工创建的程序,其特殊在其地址空间属于内核空间,也就是说,进程0是内核空间的物理页面。系统通过fork函数产生了进程1,此时进程0和1共享内核物理页面(假设为KM)。但是其特殊就特殊在在fork时,内核针对内核空间是特殊对待的。也就是说,只有对于非内核地址空间的页面,才会将被fork共享的页面所对应的页表项设为只读,从而在最后一次写操作的时候,将源页面释放。而对于内核地址空间的地址共享,只将进程1的页目录的属性设置为只读,而源目录表项(进程0)依然是可读写的。这就导致进程1fork进程0而产生之后,只有进程1对共享的物理页面(内核地址空间)进行写操作的时候才会产生写时复制,为进程1在主内存中申请一页新的物理页面作为独属于进程1的物理页面。而进程0对其共享内存的写操作不会引起写时复制,即KM就好像独属于进程0一样。
copy_page_tables完整源码如下:
/*
* copy_page_tables()函数只被fork函数调用
* 拷贝只是拷贝页表,页表是管理4M地址的,所以按照4M对齐
* 不拷贝物理页内容,当发生写时拷贝才会拷贝页表所管理的物理页内容
* 对于进程0和1,只拷贝前160页共640Kb,出于效率考虑
* 0-1M作为内核驻留地址区域,禁止写覆盖
* 参数from,to是0-4G线性地址,size是字节为单位
*/
int copy_page_tables(unsigned long from,unsigned long to,long size)
unsigned long * from_page_table; //用于管理源页表
unsigned long * to_page_table; //用于管理目的页表
unsigned long this_page; //用于保存页表
unsigned long * from_dir, * to_dir; //用于管理源页目录项,目的页目录项
unsigned long nr; //用于保存页表项个数
if ((from&0x3fffff) || (to&0x3fffff)) //4M对齐检测,否则die
panic("copy_page_tables called with wrong alignment");
from_dir = (unsigned long *) ((from>>20) & 0xffc); /* _pg_dir = 0 */
//源页目录项
to_dir = (unsigned long *) ((to>>20) & 0xffc); //目的页目录项
size = ((unsigned) (size+0x3fffff)) >> 22; //页表项个数是字节数除以4M
for( ; size-->0 ; from_dir++,to_dir++)
if (1 & *to_dir) //最后一位P位,如果目的页目录项已经被使用,die
panic("copy_page_tables: already exist");
if (!(1 & *from_dir)) //最后一位代表P位,如果源页目录项未使用,跳过,不拷贝
continue;
from_page_table = (unsigned long *) (0xfffff000 & *from_dir);//取源页表地址
if (!(to_page_table = (unsigned long *) get_free_page()))
return -1; /* Out of memory, see freeing */ //取空闲物理页为to_page_table赋值
//如果没有空闲物理页,die
*to_dir = ((unsigned long) to_page_table) | 7; //将页表存进相应页目录项,
//7表示可读写
//想一下常用的chmod 777 anyfile
nr = (from==0)?0xA0:1024; //如果是0地址,只拷贝160页,否则拷贝1024页
//一个页目录表管理1024个页目录项
//一个页表管理1024个页表项
//一个页表项管理有4K物理地址
for ( ; nr-- > 0 ; from_page_table++,to_page_table++)
this_page = *from_page_table; //从源页表中取源页表项
if (!(1 & this_page)) //如果源页表项未被使用,跳过
continue;
this_page &= ~2; //目的页表项读写位,
//设置为只读
*to_page_table = this_page; //将源页表项存进目的页表项
if (this_page > LOW_MEM) //如果是主内存区
*from_page_table = this_page;//源页表项也要设置为只读
this_page -= LOW_MEM; //取相对主内存的偏移地址
this_page >>= 12; //取主内存管理数组索引
mem_map[this_page]++; //物理页引用次数加1
invalidate(); //刷新高速缓存
return 0; //返回0表示成功
操作系统的内存管理——页式段式管理段页式管理