内存管理第二谈:内存分配机制

来源:互联网 发布:淘宝大v达人怎么申请 编辑:程序博客网 时间:2024/06/08 11:51
(本帖不适合手机用户)
上次我们讨论了内存寻址相关内容--段式管理和页式管理机制,只有搞懂了线性地址和物理地址两者关系,才能再去学习真正的内存管理机制。这一节我们简单的来了解linux内核的内存分配机制,工作中我使用的内核版本是linux-3.2.0,我也尝试着跟这个版本的代码来解析这块知识,但是水平确实不够,现代版本内核关于操作系统的几个大体系太过庞大,我往往是跟着跟着就陷到另一个体系中去回不来了,所以决定以0.11版本代码为核心,分析内存管理的核心思想,然后穿插着现代内存管理机制中一些算法。


先来总结下内存寻址相关内容:
1.进程或者说APP运行的地址空间叫做线性地址,需要最终映射到物理地址才能真正运行程序
2.线性地址和物理地址之间是通过“页目录表”和“页表”两级映射进行转换的
3.物理内存被分为一个个4KB的空间,每个4KB的起始地址叫做页框基地址
4.页框地址是存在页表中的

内存寻址中说到,通过32位线性地址寻址到页表项后就能得到页框地址,页表项内容可以作为一个“地址指针”来理解,那么首先我们就要来看下这个指针的真实面目是什么样子(以0.11版本为例)。
---------------------------------------------------------------------------------
---------------20位页框地址--------------|AVAIL|00|D|A|00|U/S|R/W|P|      位含义
31--------------------------------------12|11---------------------------0|      位数

上图就是一个页表中的所有内容,0.11源码好像没有对这个结构进行抽象,而是直接用的位操作来实现逻辑,为了下面好理解,我们可以把上面的页框内容抽象为一个结构体类型:

typedef unsigned int u32;  struct page_frame_content{    u32 page_frame_address:22;    u32 avail:2;    u32 none1:2;    u32 dirty:1;    u32 accessed:1;    u32 none2:2;    u32 user_super:1;    u32 read_write:1;    u32 present:1;}



上面结构体定义方法为位域法,正好满足我们的需求,冒号后边的数字表示结构体中的成员占的位数,也就说这个结构体总共占用4字节-32位,其中页框地址page_frame_address占用22位,是否可读写占用1位...
我们只关注和本节内容有关的成员含义:

present:占据1位,用来指示当前的页表项是否已经映射到了物理内存页,1:已经映射 0:未映射
read_write:占据1位,用来表示所指的物理内存页是否可读写,1:可读可写 0:可读不可写
page_frame_address:占据22位,表示代表的的物理内存页,大家可能奇怪了,你不是说页表内容代表物理地址吗,22位才能表示4MB,难道0.11最大才支持4MB物理内存?前面我们讲过,物理内存被分为了一个个的4KB的存储单元--页框,页表项中存储的是页框基地址,那么也就是4KB、8KB、12KB、...、100KB,用16进制表示下这些地址就是0x1000,0x2000,0x3000,...,0x19000,发现没有,由于是4KB边界,低12位全为0,那么我们何必再去写这12位呢,干脆拿出来挪作它用节省空间岂不更好。所以加入页表项中高22位page_frame_address的值为1,实际代表的物理地址应该是1<<12=4KB,这样实际上能表示的最高地址为4GB。明白了吧!

本节不再像上一节一样干巴巴的讲理论,下面将结合0.11版本内核源码来探究内存管理机制,我们只抽取核心部分,对于细节不详细解释,有些代码或者名字为了核心内容展现并且让大家好理解,我做了些许改动。
main()  //内核主函数memory_end = 16 * 1024 * 1024;  //0.11默认支持最大内存为16MB,没什么好稀奇的,也许那时候你才刚刚出生mem_init();HIGH_MEMORY = end_mem;// 设置物理内存最高端。end_mem >>= 12;while (end_mem-- > 0)mem_map[i++] = 0;

后三句我没有注释,需要单讲。
首先,mem_map[]数组是做什么用的呢?答:它是一个内存映射数组,用来标识物理内存页映射的次数。
说的挺难理解的,举个例子,假如有一本书有200页,你看了一段时间后,怎么区分那一页看过,哪一页没看过呢?很简单,只要在看过的页上做个标记就可以了是吧。同样道理,内存也一样。它就是用这么一个数组来标示哪个页用了,哪个没用。内核中定义是这样的:
unsigned char mem_map[PAGING_PAGES] = { 0, };
假如mem_map[1]=0,mem_map[100]=1,mem_map[200]=2,就表示第1个物理内存页没用被占用,第100个被一个页表项占用,第200个被两个页表项占用或者说映射。这个数组当然是在内存初始化时候分配的,也是占用内存空间的,那么我们来计算下这个数据结构占用多少总空间(注意单位):
设物理内存总大小为x字节,那么总的内存页数就是x/4KB=x>>12,那么mem_map总占用大小=(x>>12)项 * 1字节(每个数组占1字节)x/4096。也就说这块基本上占用总大小的1/4096。而发展到现在版本内核,那3.2.0来说,mem_map的每个数组项已经占用32字节了,需要整个空间的差不多1%,当然,每个字节表示的信息量就更大了,而且现在内存动辄几个G,这点无所谓啦。

其实内核启动初始化过程对于内存管理就做了这么点事情,其它的内存分配相关内容是通过异常中断来进行的。我们现在不去深究异常中断实现过程,你只需要知道如果访问一个线性地址,如果出现以下情况,就会导致异常中断产生:
1.到页表项寻址时候发现根本没有相应的物理内存页与之相对应,引起缺页异常
2.有物理内存页与之对应,但是执行的是写操作,但是物理内存权限中read_write标志位为0--只读,会引起页写保护异常

下面只需要跟踪三个函数就能理清楚内存分配机制:
1.缺页异常处理函数--do_no_page
2.页写保护异常--do_wp_page 
3.fork(克隆进程)库函数调用的--copy_page_tables

我们copy_page_tables开始,因为这个函数是整个内存管理中最复杂的函数,但是只要掌握这个函数的涉及内容,其它两点就很容易理解了。
跟踪代码前先大体说一下它的功能:复制指定线性地址和长度,主要为用户空间克隆进程服务。什么叫进程克隆呢,我们在用户空间编程时候往往需要对某个进程复制,这就是进程克隆。可以想象一下,你同时打开了两个qq,而这两个qq其实程序是一样的,但是都需要物理空间来运行。那么改如何进行复制呢?需不需要把qq1的物理空间全部复制给qq2呢?答案是no,Linux运用了一种叫“写时复制”的机制,即两个qq运行在不同的线性地址,但是线性地址映射到的物理地址空间是相同的,直到某个进程发生了写操作,才会把相应的页克隆,注意不是克隆全部。这样就大大节省了物理内存。这里边还牵扯一些进程管理知识,我们不去深究,下面用一幅图来表示这种机制。电脑画实在没那个精力,就用笔画的,不忍直视,现在比较穷,就两支笔,见谅见谅~



左边是线性地址,两个段运行的进程是qq1和qq2,不是991,992,汗~,0.11为每个进程分配的空间是64MB,现在内核每个进程最多能用4GB。首先qq1运行时候,内核为它分配了一个页表(假如4MB够它用的),并为页表中每一项都分配了物理地址。这时候qq2启动了,内核不会重新分配相同的物理空间给qq2,而是通过复制一个和qq1运行相同的页表,让页表中每一项指向和qq1相同的物理内存。这样qq2就能运行起来了,够清楚吧。假如某个时刻qq2要往原本共享的0x5空间写东西,当然你不能乱写,否则qq1再读取这块空间时候就乱了。所以这时候,为qq2申请一块新的物理内存空间0x6,把原理页表项中的0x5改为0x6,这样对于这一页数据,两个qq进程就有不同的物理空间了,再执行写操作时候就不会影响到另外一个了。

我们来跟一下源码,其中需要上一节内存寻址的知识。
/*from:源线性地址to:目的线性地址size:拷贝的线性地址大小,字节为单位*/int copy_page_tables (unsigned long from, unsigned long to, long size) //取得源地址和目的地址的目录项地址(from_dir 和to_dir),看起来比较难,其实代码这么写比较容易理解 //from_dir = (unsigned long *) ((from / 4MB)*4B & 0xffc);每个目录项表示4MB,每个目录下4B,&0xffc是为了4B对齐,自己想吧from_dir = (unsigned long *) ((from >> 20) & 0xffc);to_dir = (unsigned long *) ((to >> 20) & 0xffc);size = ((unsigned) (size + 0x3fffff)) >> 22; //为什么加个0x3fffff,你可以带入几个数想想,最后得到size大小是占用目录项个数for (; size-- > 0; from_dir++, to_dir++)  //开始复制每一项对应的页表{from_page_table = (unsigned long *) (0xfffff000 & *from_dir);  //得到源页表地址to_page_table = (unsigned long *) get_free_page (); //申请一页新的页表空间*to_dir = (unsigned long) to_page_table; //设置目录项为新页表地址for(....){//对新页表中每一项进行复制原页表操作for(i=0; i<1024; i++){page_content = *from_page_table;  //取得源页表地址处的第一项的内容(注意指针操作)page_content &= ~(1<<1);//把第1位清0,也就是置读写位为只读*to_page_table = page_content;  //把源页表第一项的内容复制给目的页表第一项,然后两者就指向同一块物理内存了from_page_table++;  //指针指向下一个地址,继续复制to_page_table++;*from_page_table = this_page; //把原来页表项物理地址权限也改为只读this_page >>= 12;  //取到内存管理数组的项mem_map[this_page]++;}}}


上面代码为了好理解我改了很多,也略去很多,基本框架就是这样,如果明白思想,再去看源码应该问题不大。这里除了上面的复制操作之外,还有一点要注意,就是那个&操作,因为进程地址空间克隆后,两者实际上是共用的一块物理内存,任何一个进程写都会导致另一个进程出问题,所以必须复制后把这块物理内存权限置为只读,当两者中某个要发生写操作时候,再利用下面要说的“页写保护异常”来处理。

总结下上边这个函数功能:
1.拷贝连续的线性地址,一般是涉及到几个页表,给应用层进程克隆之类的接口使用,比如fork
2.拷贝的并不是真正的物理内存,只是把新建立的页表和源页表指向相同的物理内存
3.新拷贝的页表指向的物理内存权限为只读,如果某个进程要写这些内存,会发生“页写保护异常”

下面我们再来看“页写保护异常”函数处理,其实就是操作系统中常说的“写时复制”机制,还是先说下思想,再跟源码
首先,这个异常肯定是由于进程写的线性地址指向的物理内存的权限是只读,于是内核会把进程访问的线性地址重新映射到新的物理地址,并把源物理地址的内容拷贝到新的物理地址页,这样这个进程的写操作就不会影响到其它进程了,就这么简单。我们看下源码:

/*error_code:由CPU自己产生,不用管address: 造成异常的线性地址,也就是进程需要写的线性地址*/void do_wp_page (unsigned long error_code, unsigned long address)//这个函数形参看着挺吓人的,其实就是取了线性地址对应的页表项地址,自己分析下  un_wp_page ((unsigned long *)(((address >> 10) & 0xffc) + (0xfffff000 &*((unsigned long*) ((address >> 20) & 0xffc)))));old_page = 0xfffff000 & *table_entry;// 取原页面对应的目录项号。new_page = get_free_page ()  //先申请一页新的物理内存页mem_map[...]--;  //把这一页标记为占用*table_entry = new_page | 7;  //访问线性地址对应的页表项权限设置为可读写copy_page (old_page, new_page);

“页写保护”异常是内存管理里比较简单的一个处理,下面就剩下最后一个点--“缺页异常”,这个异常是怎么产生的呢?大家可以想象这么一种情况,一个进程刚创建时候,它的运行空间还没有,它只要访问某个线性地址,肯定是没有对应到物理地址的,或者说进程调用了malloc之类的函数申请内存并访问,这时候需要的物理地址都是新的,没有经过映射的。所以就会导致“缺页异常”的产生。这个函数层次很简单,但是涉及的体系太大,包括进程管理、文件系统、块设备驱动,很多地方我也没弄懂,所以只是给大家列一下基本思想。

首先,内核会试着从已经存在的进程,找一个可以与当前进程共享的页面,如果找到了,万事大吉,先用着,等有写操作时候就不归我管了,交给上面的“页写保护”异常处理。如果没找到,就分配一页新的物理页,然后把块设备上的代码或者数据读到这块物理地址上,并和线性地址做映射,代码框架:

/*error_code:由CPU自己产生,不用管address: 造成异常的线性地址*/void do_no_page (unsigned long error_code, unsigned long address)if(share_page() == 成功);  //试着找个共享的return;elsepage = get_free_page ();  //申请一页新的bread_page (page, current->executable->i_dev, nr);  //从块设备读取到物理内存put_page (page, address);  //完成物理地址和线性地址的映射


以我的水平,只能说到这样了,剩下的只能把现代内核中找些内核分配算法整理下了,放到第三谈吧,大家小年好!

0 0
原创粉丝点击