Linux内核学习系列(5)——内存管理

前言

本章记录一下个人对linux0.12内存管理的理解。主要涉及物理内存页分配与回收,页表复制等具体操作。同样的,笔记仍然由上而下地进行知识点梳理,而不是单纯介绍函数及其作用。

内存管理

linux内存管理是对什么进行管理

  • 内核中的内存管理,主要管理的是物理内存地址空间。而用户所谓的内存管理,指的是对其当前进程线性地址空间的管理

为什么要进行内存管理

  • 内核中的内存管理,是为了在分页机制基础上,实现虚拟存储
  • 用户中的内存管理,是为了合理利用线性地址空间

什么时候需要内核内存管理

  • 内存管理是贯彻整个内核运行过程的
  • 因为内存管理负责实现并维护分页机制,并为任务分配物理内存上主内存区域内存
  • 例如,当任务执行fork的过程中,涉及新的task_truct与新的页表,这些都需要主存提供存储。如何让主存有序地提供存储,也是内存管理的职责

如何进行内存管理

  • 由分页机制,我们可以知道,按4k为一页单位分别划分物理内存地址与线性地址空间,因此两者可以实现以4k页单位的地址映射
  • 因此内存管理第一步,先将物理内存地址以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顺序存放在页目录中的。因此根据线性地址可以计算出任务序号,利用任务序号在页目录中索引能够获得对应任务使用的页表。
Linux内核学习系列(5)——内存管理_第1张图片

释放页表

与物理页释放类似,只不过输入为页表,需要遍历页表中对应的每个物理页,将其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寄存器存放的页目录地址,找到页目录,根据线性地址解析出的页目录索引找到对应页表项,根据线性地址解析出的页表项索引找到对应物理页的物理内存地址,通过线性地址解析出的偏移量进行访问

Linux内核学习系列(5)——内存管理_第2张图片

场景1:执行内核初始化代码init/main.c

内核代码在内核空间中,即线性地址0~16M。根据内核页表能够访问到物理内存

场景2:任务0在用户态执行

任务0代码属于内核代码一部分,对应线性地址0~640k。根据内核页表能够访问到物理内存。此时无需为任务0创建页表。
Linux内核学习系列(5)——内存管理_第3张图片

场景3:任务0执行fork创建任务1

任务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,申请一页物理页存放新的页表
  • 页表必须将64M-64+640k映射到物理内存0-640k上
    Linux内核学习系列(5)——内存管理_第4张图片
    注意了,此时任务1的tss.cr3中仍用任务0的cr3,无需修改。因为页目录永远在物理地址0处。同时,此时的页目录添加了第五个页目录项,存放任务1新建的页表地址。进一步地,我们理一下此时任务1的寻址空间范围。由分段机制我们知道,任务寻址可以通过设置段选择符中的第14位,选择GDT还是LDT,选择GDT对应的就是内核代码段,0-16M。选择LDT则是64M-128M(为了省空间,其实是64M-64+640k)。那么,当选择0-16M时,就会索引页目录的前四项页表地址,找到0-16M的物理地址空间,选择64M-64+640k时,则索引页目录第五项页表地址,找到0-640kb的物理地址空间。两个物理地址空间是有重叠的。

上述便能说明任务执行fork的过程中,为什么还能使用get_free_page,通过线性地址直接管理物理页。

场景4:任务n-1执行fork创建任务n

场景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中

场景5:任务n执行exec,成为新的任务n

exec的过程比较复杂,并且还涉及需求加载机制。但只需要知道,该过程会清除原有的页表内容,更换成新的页表内容。也即场景4中,128M-192M线性地址空间无需映射到物理地址0-16M中,可以映射4-16M等与父任务不同的地址。要注意的是,任务创建与更替,改变的只是映射LDT段线性地址与物理地址的页表,页目录中的前4项内核页表是不会动的。也就意味着,通过系统调用等中断,切换成内核态时,执行内核代码段,仍然是一一映射到0-16M的物理地址中

Linux内核学习系列(5)——内存管理_第5张图片

写时复制

基本概念

由上可知,fork()在创建子进程时,子进程与父进程使用不同页表,但共享着同一块物理内存地址。这么做的好处是,降低了物理内存的使用,减少不必要的浪费,并且节省了复制进程的时间。因为子进程如果只是读取数据,可以直接读取父进程物理内存地址的内容。但如果子进程或者父进程要写数据,则待写的物理页不可共享,需要将其复制一份给子进程,这便是写时复制的由来。

要完成写时复制,需要以下几个条件

  1. copy_page_table()复制页表过程中,设置from页表和to页表权限为只读。如此一来,当父进程或子进程执行写操作时,会触发页保护中断(系统调用)
  2. 实现页保护中断程序,功能为
    • 获得触发页保护中断的线性地址
    • 将线性地址对应物理页内容复制到新物理页中
    • 修改子进程页表,指向新物理页
    • 中断返回,重新执行写操作

要注意的是,复制过程只针对某个待写页,而非所有共享物理页

代码实现

下述过程书中关于copy_page_table()的注释已足够详细,在此不展开

copy_page_table()复制页表过程中,设置from页表和to页表权限为只读。如此一来,当父进程或子进程执行写操作时,会触发页保护中断(系统调用)

关于页保护中断部分
1 . 定义页保护中断执行程序 _page_fault mm/page.s。该过程会调用do_no_pagedo_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交换,由于涉及块设备,个人没学习到,该部分笔记放于后续再记录

内存分配

malloc 概念简述

该部分内容为补充笔记。前述笔记只是梳理了 Linux0.12 版本中,如何申请/释放一个物理页,即关于页的管理。实际上,内核申请内存时,更多的需求是希望获得大小为N个字节的内存。在该版本中常用的申请接口为 malloc,在后续版本中为了与用户程序的 malloc 方法区分,改名为 kmalloc。

相较于申请整个物理页,根据指定字节申请内存更符合实际需求,否则容易出现内存碎片。该方式实现逻辑简述如下:

  1. 申请一个物理页
  2. 根据指定大小,将该物理页划分成多个指定大小的对象
  3. 返回对象地址即可

意味着,当内核希望申请 4b 字节内存空间时,将获得一个 4b 对象在一块物理页上的线性地址。那么如果申请的不是 4b 而是 12b 呢?则又需要申请一个物理页,将其按 12b 进行平分。那么为了加速分配的过程,内核需要设计一定的数据结构来完成这项功能。

对此,该版本内核通过内存桶来实现 malloc 的功能:

  1. 首先,规定只能够申请 2^n 大小的内存。对于每个大小,使用一个桶结构体进行管理
  2. 该桶结构体会包含一个指向空闲对象地址的指针,意味着初始化桶结构体时,需要根据桶大小将物理页平分好对象。此外,每个对象之间通过链表串联,便于遍历访问
  3. 将所有不同大小的桶结构体存成一个数组,该数组成为桶目录

《Linux内核完全注释》中的示意图很好地表明了内存桶的设计,如下所示
Linux内核学习系列(5)——内存管理_第6张图片
根据上图,我们可以猜想初始化内存桶的流程应该为:

  1. 根据不同内存大小,初始化存储桶目录,桶目录项的chain字段指向相应大小的桶描述符地址
  2. 如果chain为空,则需要对桶描述符进行初始化
  3. 初始化桶描述符前,需要先申请物理页,并根据桶大小平分物理页,并且每个对象之间通过链表串联
  4. 由于同样大小的桶可以有多个,因此桶描述符也应通过next指针组成链表进行串联

那么对于内存桶的分配过程就变得简单,只需要根据需要分配的大小,从桶目录找到对应桶大小的桶描述符,判断 freeptr 是否为空,若为空说明空闲可分配,直接返回

源码分析

我们进入 malloc 看看其实现是否如上所述:

  1. 首先遍历桶目录(bucket_dir),根据当前需求找到对应桶描述符。例如申请 3b,则找 4b 的桶进行后续分配。如果申请内存过大,没有桶可以分配则报异常
  2. 否则,找到对应的桶描述符(bdesc)。判断其 freeptr 是否非空,若非空说明可分配,则直接将该地址返回。若为空,则根据链表往后遍历。
  3. 若遍历完仍找不到可分配的对象地址,则需要进行新的分配。
    4. 判断空闲桶描述符链表(free_bucket_desc) 是否存在,否则说明是第一次 malloc,需要通过 init_bucket_desc 进行初始化
    5. 拿到空闲桶描述符后,先通过 get_free_page 申请一块空闲物理页。接着根据桶大小对其进行平分,并将首个对象地址设为 freeptr
    6. 做好内存桶相关数据结构的设定
  4. 返回 freeptr
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 内核最合适的方式。

你可能感兴趣的:(内核,linux)