本章记录一下个人对linux0.12内存管理的理解。主要涉及物理内存页分配与回收,页表复制等具体操作。同样的,笔记仍然由上而下地进行知识点梳理,而不是单纯介绍函数及其作用。
linux内存管理是对什么进行管理
物理内存地址空间
。而用户所谓的内存管理,指的是对其当前进程线性地址空间的管理为什么要进行内存管理
什么时候需要内核内存管理
如何进行内存管理
将物理内存地址以4k字节为单位划分物理页
。为了能够知道每个物理页的使用情况,设计一个数组mem_map[page_nums]
用于存储page_nums
个物理页使用情况。未使用为0,被使用则+1,被n个任务共用,则为n。同时设置共用上限,如n=100进行物理页分配
。当任务需要申请主内存时,通过mem_map查找主内存地址空间中的物理页,选择未使用的先清空对应物理页,进而分配,返回线性地址。(这里涉及到一个细节问题,mem_map是基于线性地址的,为何能够直接管理物理内存地址?后续解答)进行物理页释放
。将mem_map对应位置的值置减1即可,注意,无需清空具体内容,因为别的任务也可能在用。复制页表
。要注意的是,linux内核运行过程中,没用从0到1创建页表的需求。因为通过fork创建进程时,都是直接复制父进程的页表。而最根源的进程0的页表,在初始化时已经设置好。复制的过程,可以简单的这么理解:首先,页表是记录线性地址到物理地址的映射关系的。to页表的复制只需要将from页表的线性地址换成新任务对应的新线性地址,并且将mem_map关于具体物理页的值+1,表示共享该物理页的任务增加
这个操作使得复制页表之后,物理内存是共享的,便于实现写时复制。当然,在复制之前,需要从主存申请一个物理页,存放to页表。(这又有个细节问题,当前任务如何通过线性地址申请到物理页的呢?)释放页表
。与物理页释放类似,只不过输入为页表,需要遍历页表中对应的每个物理页,将其mem_map对应值减1。进一步地,我们看看源码是否如上述而言
将物理内存地址以4k字节为单位划分物理页
。为了能够知道每个物理页的使用情况,设计一个数组mem_map[page_nums]
用于存储page_nums
个物理页使用情况。未使用为0,被使用则+1,被n个任务共用,则为n。同时设置共用上限,如n=100
定义mem_map
mm/memory.c
#define PAGING_MEMORY (15*1024*1024) //include\linux\mm.h
#define PAGING_PAGES (PAGING_MEMORY>>12) //include\linux\mm.h
unsigned char mem_map [ PAGING_PAGES ] = {0,}
物理内存16M,其中1M存放内核代码与数据,可用于分配的只有15M,PAGING_PAGES=15M/4k
get_free_page() 申请空闲物理页
mm/swap.c
判断mem_map中值为0的位置,置1,并清空对应物理页,返回物理页线性地址。物理页线性地址=物理页索引*4k+LOW_MEM(1Mb)
unsigned long get_free_page(void)
{
register unsigned long __res asm("ax");
repeat:
__asm__("std ; repne ; scasb\n\t"
"jne 1f\n\t"
"movb $1,1(%%edi)\n\t"
"sall $12,%%ecx\n\t"
"addl %2,%%ecx\n\t"
"movl %%ecx,%%edx\n\t"
"movl $1024,%%ecx\n\t"
"leal 4092(%%edx),%%edi\n\t"
"rep ; stosl\n\t"
"movl %%edx,%%eax\n"
"1:"
:"=a" (__res)
:"0" (0),"i" (LOW_MEM),"c" (PAGING_PAGES),
"D" (mem_map+PAGING_PAGES-1)
:"di","cx","dx");
if (__res >= HIGH_MEMORY)
goto repeat;
if (!__res && swap_out())
goto repeat;
return __res;
}
这里涉及到一个理解内存管理中地址映射的关键问题。即如何通过线性地址能够直接管理16M物理内存。比较绕,理解了其实很简单。这与上一篇内存映射内容有关,即内核代码段与数据段为了方便管理,其使用的内核页表存储了0-16M线性地址与0-16M物理地址的一一映射关系。因此操作0-16M线性地址,就是操作0-16M物理地址。可能有人会问,当任务切换时,页表不也会切换成任务页表吗?实际上,Linux0.12在任务运行过程中,页目录的位置永远不变,因此fork的过程中,不会发生tss.cr3这一字段被修改。只是会为新任务申请一块内存并创建新的页表,存储LDT相关的地址映射,最终会放入页目录中。(这部分说起来比较拗口,看后续是否进行展开)
将mem_map对应位置的值置减1即可,注意,无需清空具体内容,因为别的任务也可能在用。
free_page() mm/memory.c
void free_page(unsigned long addr)
{
if (addr < LOW_MEM) return;
if (addr >= HIGH_MEMORY)
panic("trying to free nonexistent page");
addr -= LOW_MEM;
addr >>= 12;
if (mem_map[addr]--) return;
mem_map[addr]=0;
panic("trying to free free page");
}
要注意的是,linux内核运行过程中,没用从0到1创建页表的需求。因为通过fork创建进程时,都是直接复制父进程的页表。而最根源的进程0的页表,在初始化时已经设置好。复制的过程,可以简单的这么理解:首先,页表是记录线性地址到物理地址的映射关系的。to页表的复制只需要将from页表的线性地址换成新任务对应的新线性地址,并且将mem_map关于具体物理页的值+1,表示共享该物理页的任务增加
这个操作使得复制页表之后,物理内存是共享的,便于实现写时复制。当然,在复制之前,需要从主存申请一个物理页,存放to页表。
copy_page_tables()
mm/memory.c
对具体代码的逐行解析不是本章重点,具体可自行看书学习
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 new_page;
unsigned long nr;
if ((from&0x3fffff) || (to&0x3fffff))
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;
for( ; size-->0 ; from_dir++,to_dir++) {
if (1 & *to_dir)
panic("copy_page_tables: already exist");
if (!(1 & *from_dir))
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_dir = ((unsigned long) to_page_table) | 7;
nr = (from==0)?0xA0:1024;
for ( ; nr-- > 0 ; from_page_table++,to_page_table++) {
this_page = *from_page_table;
if (!this_page)
continue;
if (!(1 & this_page)) {
if (!(new_page = get_free_page()))
return -1;
read_swap_page(this_page>>1, (char *) new_page);
*to_page_table = this_page;
*from_page_table = new_page | (PAGE_DIRTY | 7);
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]++;
}
}
}
invalidate();
return 0;
}
传入该函数的参数为线性地址,函数可以根据线性地址算出页表位置。因为4G的线性地址空间被n个任务划分,每个任务占64MB,内核代码及数据同属任务0。并且页表是依任务结构数组task顺序存放在页目录中的。因此根据线性地址可以计算出任务序号,利用任务序号在页目录中索引能够获得对应任务使用的页表。
与物理页释放类似,只不过输入为页表,需要遍历页表中对应的每个物理页,将其mem_map对应值减1。
int free_page_tables(unsigned long from,unsigned long size)
{
unsigned long *pg_table;
unsigned long * dir, nr;
if (from & 0x3fffff)
panic("free_page_tables called with wrong alignment");
if (!from)
panic("Trying to free up swapper memory space");
size = (size + 0x3fffff) >> 22;
dir = (unsigned long *) ((from>>20) & 0xffc); /* _pg_dir = 0 */
for ( ; size-->0 ; dir++) {
if (!(1 & *dir))
continue;
pg_table = (unsigned long *) (0xfffff000 & *dir);
for (nr=0 ; nr<1024 ; nr++) {
if (*pg_table) {
if (1 & *pg_table)
free_page(0xfffff000 & *pg_table);
else
swap_free(*pg_table >> 1);
*pg_table = 0;
}
pg_table++;
}
free_page(0xfffff000 & *dir);
*dir = 0;
}
invalidate();
return 0;
}
了解了分页机制之后,我们可以知道,通过分页机制,4G线性地址空间能够映射到16M的物理内存地址空间中,我们将页表简单理解成map,存储线性地址:物理内存地址。页目录则是页表的地址。可以模拟一下任务执行过程中页表映射运作流程,已巩固对分页机制的理解
首先要明确,linux0.12对页目录的使用。boot/head.s
中描述了页目录和内核页表的初始化过程。并且页目录处于线性地址为0处,并预留了1024个页表项。内核页表共4页,作为页目录的前4项。
接着程序设置管理内存的分页处理机制,将页目录表放在绝对物理地址 0 开始处(也是本程序所处的物理内存位置,因此这段程序已执行部分将被覆盖掉),紧随后面会放置共可寻址 16MB 内存的 4 个页表,并分别设置它们的表项。页目录表项和页表项格式见图 6-10 所示。其中 P 是页面存在于内存标志;R/W 是读写标志;U/S 是用户/超级用户标志;A 是页面已访问标志;D 是页面内容已修改标志;最左边20 比特是表项对应页面在物理内存中页面地址的高 20 比特位。 ----《Linux内核完全注释》6.4 head.s程序
开启分页机制后,CPU将获得的线性地址,根据CR3寄存器存放的页目录地址,找到页目录,根据线性地址解析出的页目录索引找到对应页表项,根据线性地址解析出的页表项索引找到对应物理页的物理内存地址,通过线性地址解析出的偏移量进行访问
内核代码在内核空间中,即线性地址0~16M。根据内核页表能够访问到物理内存
任务0代码属于内核代码一部分,对应线性地址0~640k。根据内核页表能够访问到物理内存。此时无需为任务0创建页表。
任务1代码也属于内核代码一部分,需要理解的是,内核代码及数据存储在物理内存0-640k处,我们可以通过设置页表实现线性地址到0-640k物理内存地址的访问。如通过页表设置64M-64+640kb线性地址到0-640k物理地址的映射,那么通过访问64M-64+640kb线性地址,也能够调用0-640k物理地址上的内核代码。同理,通过页表设置128M-128+640kb线性地址到0-640k物理地址的映射,那么通过访问128M-128+640kb线性地址,也能够调用0-640k物理地址上的内核代码。
因此任务1的创建既要满足fork的自动化流程,还要能够执行0-640k物理地址上的内核代码(自己那部分)。因此必须这么做
上述便能说明任务执行fork的过程中,为什么还能使用get_free_page,通过线性地址直接管理物理页。
场景3中,任务1希望共享任务0的内核代码及数据,而任务n也希望共享任务n-1的代码及数据。同样地,假设任务n-1的LDT线性地址空间范围是64M-128M,通过页表n-1映射到物理地址0-16M中。通过fork,复制页表n-1为页表n,任务n的LDT线性地址范围是128-192M,也能够映射到物理地址0-16M中
exec的过程比较复杂,并且还涉及需求加载机制。但只需要知道,该过程会清除原有的页表内容,更换成新的页表内容。也即场景4中,128M-192M线性地址空间无需映射到物理地址0-16M中,可以映射4-16M等与父任务不同的地址。要注意的是,任务创建与更替,改变的只是映射LDT段线性地址与物理地址的页表,页目录中的前4项内核页表是不会动的。也就意味着,通过系统调用等中断,切换成内核态时,执行内核代码段,仍然是一一映射到0-16M的物理地址中
由上可知,fork()
在创建子进程时,子进程与父进程使用不同页表,但共享着同一块物理内存地址。这么做的好处是,降低了物理内存的使用,减少不必要的浪费,并且节省了复制进程的时间。因为子进程如果只是读取数据,可以直接读取父进程物理内存地址的内容。但如果子进程或者父进程要写数据,则待写的物理页不可共享,需要将其复制一份给子进程,这便是写时复制
的由来。
要完成写时复制,需要以下几个条件
copy_page_table()
复制页表过程中,设置from页表和to页表权限为只读。如此一来,当父进程或子进程执行写操作时,会触发页保护中断(系统调用)要注意的是,复制过程只针对某个待写页,而非所有共享物理页
下述过程书中关于copy_page_table()的注释已足够详细,在此不展开
copy_page_table()
复制页表过程中,设置from页表和to页表权限为只读。如此一来,当父进程或子进程执行写操作时,会触发页保护中断(系统调用)
关于页保护中断部分
1 . 定义页保护中断执行程序 _page_fault
mm/page.s
。该过程会调用do_no_page
与do_wp_page
两个函数。do_no_page
为缺页中断程序在此不展开,do_wp_page
为页保护中断程序
_page_fault:
xchgl %eax,(%esp)
pushl %ecx
pushl %edx
push %ds
push %es
push %fs
movl $0x10,%edx
mov %dx,%ds
mov %dx,%es
mov %dx,%fs
movl %cr2,%edx
pushl %edx
pushl %eax
testl $1,%eax
jne 1f
call _do_no_page
jmp 2f
1: call _do_wp_page
2: addl $8,%esp
pop %fs
pop %es
pop %ds
popl %edx
popl %ecx
popl %eax
iret
do_wp_page()
mm/memory.c
do_wp_page()函数会对这块导致写入异常中断的物理页面进行取消共享操作(通过调用 un_wp_page()函数),并为写进程复制一新的物理页面,从而使得父进程 A 和子进程 B 各自拥有一块内容相同的物理页面,并且把将要执行写入操作的这块物理页面标记成可以写访问的,这时才真正地进行了复制操作(只复制这一块物理页面)。最后,从异常处理函数中返回时 CPU 就会重新执行刚才导致异常的写入操作指令,使进程能够继续执行下去。
void un_wp_page(unsigned long * table_entry)
{
unsigned long old_page,new_page;
old_page = 0xfffff000 & *table_entry;
if (old_page >= LOW_MEM && mem_map[MAP_NR(old_page)]==1) {
*table_entry |= 2;
invalidate();
return;
}
if (!(new_page=get_free_page()))
oom();
if (old_page >= LOW_MEM)
mem_map[MAP_NR(old_page)]--;
copy_page(old_page,new_page);
*table_entry = new_page | 7;
invalidate();
}
/*
* This routine handles present pages, when users try to write
* to a shared page. It is done by copying the page to a new address
* and decrementing the shared-page counter for the old page.
*
* If it's in code space we exit with a segment error.
*/
void do_wp_page(unsigned long error_code,unsigned long address)
{
if (address < TASK_SIZE)
printk("\n\rBAD! KERNEL MEMORY WP-ERR!\n\r");
if (address - current->start_code > TASK_SIZE) {
printk("Bad things happen: page error in do_wp_page\n\r");
do_exit(SIGSEGV);
}
#if 0
/* we cannot do this yet: the estdio library writes to code space */
/* stupid, stupid. I really want the libc.a from GNU */
if (CODE_SPACE(address))
do_exit(SIGSEGV);
#endif
un_wp_page((unsigned long *)
(((address>>10) & 0xffc) + (0xfffff000 &
*((unsigned long *) ((address>>20) &0xffc)))));
}
内存管理应该还涉及swap交换,由于涉及块设备,个人没学习到,该部分笔记放于后续再记录
该部分内容为补充笔记。前述笔记只是梳理了 Linux0.12 版本中,如何申请/释放一个物理页,即关于页的管理。实际上,内核申请内存时,更多的需求是希望获得大小为N个字节的内存。在该版本中常用的申请接口为 malloc,在后续版本中为了与用户程序的 malloc 方法区分,改名为 kmalloc。
相较于申请整个物理页,根据指定字节申请内存更符合实际需求,否则容易出现内存碎片。该方式实现逻辑简述如下:
意味着,当内核希望申请 4b 字节内存空间时,将获得一个 4b 对象在一块物理页上的线性地址。那么如果申请的不是 4b 而是 12b 呢?则又需要申请一个物理页,将其按 12b 进行平分。那么为了加速分配的过程,内核需要设计一定的数据结构来完成这项功能。
对此,该版本内核通过内存桶来实现 malloc 的功能:
《Linux内核完全注释》中的示意图很好地表明了内存桶的设计,如下所示
根据上图,我们可以猜想初始化内存桶的流程应该为:
那么对于内存桶的分配过程就变得简单,只需要根据需要分配的大小,从桶目录找到对应桶大小的桶描述符,判断 freeptr 是否为空,若为空说明空闲可分配,直接返回
我们进入 malloc 看看其实现是否如上所述:
void *malloc(unsigned int len)
{
struct _bucket_dir *bdir;
struct bucket_desc *bdesc;
void *retval;
/*
* First we search the bucket_dir to find the right bucket change
* for this request.
*/
for (bdir = bucket_dir; bdir->size; bdir++)
if (bdir->size >= len)
break;
if (!bdir->size) {
printk("malloc called with impossibly large argument (%d)\n",
len);
panic("malloc: bad arg");
}
/*
* Now we search for a bucket descriptor which has free space
*/
cli(); /* Avoid race conditions */
for (bdesc = bdir->chain; bdesc; bdesc = bdesc->next)
if (bdesc->freeptr)
break;
/*
* If we didn't find a bucket with free space, then we'll
* allocate a new one.
*/
if (!bdesc) {
char *cp;
int i;
if (!free_bucket_desc)
init_bucket_desc();
bdesc = free_bucket_desc;
free_bucket_desc = bdesc->next;
bdesc->refcnt = 0;
bdesc->bucket_size = bdir->size;
bdesc->page = bdesc->freeptr = (void *) cp = get_free_page();
if (!cp)
panic("Out of memory in kernel malloc()");
/* Set up the chain of free objects */
for (i=PAGE_SIZE/bdir->size; i > 1; i--) {
*((char **) cp) = cp + bdir->size;
cp += bdir->size;
}
*((char **) cp) = 0;
bdesc->next = bdir->chain; /* OK, link it in! */
bdir->chain = bdesc;
}
retval = (void *) bdesc->freeptr;
bdesc->freeptr = *((void **) retval);
bdesc->refcnt++;
sti(); /* OK, we're safe again */
return(retval);
}
对此,我们对 malloc 的梳理基本完成。随着后续对 2.6版本的学习,会发现内存桶的设计很类似伙伴系统,应该是伙伴系统的前身。在内核对比学习系列中,笔者会进一步比较这两个版本的差异。经过这段时间的学习,笔者认为 Linux0.12 确实是打开 Linux 内核最合适的方式。