Linux的mmap内存映射机制解析


在讲述文件映射的概念时,不可避免的要牵涉到虚存(SVR 4的VM).实际上,文件映射是虚存的中心概念, 文件映射一方面给用户提供了一组措施,好似用户将文件映射到自己地址空间的某个部分,使用简单的内存访问指令读写文件;另一方面,它也可以用于内核的基本组织模式,在这种模式种,内核将整个地址空间视为诸如文件之类的一组不同对象的映射.中的传统文件访问方式是,首先用open系统调用打开文件,然后使用read, write以及lseek等调用进行顺序或者随即的I/O.这种方式是非常低效的,每一次I/O操作都需要一次系统调用.另外,如果若干个进程访问同一个文件,每个进程都要在自己的地址空间维护一个副本,浪费了内存空间.而如果能够通过一定的机制将页面映射到进程的地址空间中,也就是说首先通过简单的产生某些内存管理数据结构完成映射的创建.当进程访问页面时产生一个缺页中断,内核将页面读入内存并且更新页表指向该页面.而且这种方式非常方便于同一副本的共享.

VM是面向对象的方法设计的,这里的对象是指内存对象:内存对象是一个软件抽象的概念,它描述内存区与后备存储之间的映射.系统可以使用多种类型的后备存储,比如交换空间,本地或者远程文件以及帧缓存等等. VM系统对它们统一处理,采用同一操作集操作,比如读取页面或者回写页面等.每种不同的后备存储都可以用不同的方法实现这些操作.这样,系统定义了一套统一的接口,每种后备存储给出自己的实现方法.这样,进程的地址空间就被视为一组映射到不同数据对象上的的映射组成.所有的有效地址就是那些映射到数据对象上的地址.这些对象为映射它的页面提供了持久性的后备存储.映射使得用户可以直接寻址这些对象.

值得提出的是, VM体系结构独立于Unix系统,所有的Unix系统语义,如正文,数据及堆栈区都可以建构在基本VM系统之上.同时, VM体系结构也是独立于存储管理的,存储管理是由操作系统实施的,如:究竟采取什么样的对换和请求调页算法,究竟是采取分段还是分页机制进行存储管理,究竟是如何将虚拟地址转换成为物理地址等等(Linux中是一种叫Three Level Page Table的机制),这些都与内存对象的概念无关.

 一、Linux中VM的实现.

一个进程应该包括一个mm_struct(memory manage struct),该结构是进程虚拟地址空间的抽象描述,里面包括了进程虚拟空间的一些管理信息: start_code, end_code, start_data, end_data, start_brk, end_brk等等信息.另外,也有一个指向进程虚存区表(vm_area_struct: virtual memory area)的指针,该链是按照虚拟地址的增长顺序排列的.在Linux进程的地址空间被分作许多区(vma),每个区(vma)都对应虚拟地址空间上一段连续的区域, vma是可以被共享和保护的独立实体,这里的vma就是前面提到的内存对象.

下面是vm_area_struct的结构,其中,前半部分是公共的,与类型无关的一些数据成员,如:指向mm_struct的指针,地址范围等等,后半部分则是与类型相关的成员,其中最重要的是一个指向vm_operation_struct向量表的指针vm_ops, vm_pos向量表是一组虚函数,定义了与vma类型无关的接口.每一个特定的子类,即每种vma类型都必须在向量表中实现这些操作.这里包括了: open, close, unmap, protect, sync, nopage, wppage, swapout这些操作.

struct vm_area_struct { 
	/*公共的, 与vma类型无关的 */ 
	struct mm_struct * vm_mm; 
	unsigned long vm_start; 
	unsigned long vm_end; 
	struct vm_area_struct *vm_next; 
	pgprot_t vm_page_prot; 
	unsigned long vm_flags; 
	short vm_avl_height; 
	struct vm_area_struct * vm_avl_left; 
	struct vm_area_struct * vm_avl_right; 
	struct vm_area_struct *vm_next_share; 
	struct vm_area_struct **vm_pprev_share; 
	/* 与类型相关的 */ 
	struct vm_operations_struct * vm_ops; 
	unsigned long vm_pgoff; 
	struct file * vm_file; 
	unsigned long vm_raend; 
	void * vm_private_data;
}; 

 

vm_ops: open, close, no_page, swapin, swapout……

 

 

二、驱动中的mmap()函数解析

设备驱动的mmap实现主要是将一个物理设备的可操作区域(设备空间)映射到一个进程的虚拟地址空间。这样就可以直接采用指针的方式像访问内存的方式访问设备。在驱动中的mmap实现主要是完成一件事,就是实际物理设备的操作区域到进程虚拟空间地址的映射过程。同时也需要保证这段映射的虚拟存储器区域不会被进程当做一般的空间使用,因此需要添加一系列的保护方式。

/*主要是建立虚拟地址到物理地址的页表关系,其他的过程又内核自己完成*/
static int mem_mmap(struct file* filp,struct vm_area_struct *vma)
{
    /*间接的控制设备*/
    struct mem_dev *dev = filp->private_data;
    
    /*标记这段虚拟内存映射为IO区域,并阻止系统将该区域包含在进程的存放转存中*/
    vma->vm_flags |= VM_IO;
    /*标记这段区域不能被换出*/
    vma->vm_flags |= VM_RESERVED;

    /**/
    if(remap_pfn_range(vma,/*虚拟内存区域*/
        vma->vm_start, /*虚拟地址的起始地址*/
        virt_to_phys(dev->data)>>PAGE_SHIFT, /*物理存储区的物理页号*/
     dev->size,    /*映射区域大小*/        
        vma->vm_page_prot /*虚拟区域保护属性*/    
        ))
        return -EAGAIN;

    return 0;
}

 

具体的实现分析如下:

vma->vm_flags |= VM_IO;
vma->vm_flags |= VM_RESERVED;

上面的两个保护机制就说明了被映射的这段区域具有映射IO的相似性,同时保证这段区域不能随便的换出。就是建立一个物理页与虚拟页之间的关联性。具体原理是虚拟页和物理页之间是以页表的方式关联起来,虚拟内存通常大于物理内存,在使用过程中虚拟页通过页表关联一切对应的物理页,当物理页不够时,会选择性的牺牲一些页,也就是将物理页与虚拟页之间切断,重现关联其他的虚拟页,保证物理内存够用。在设备驱动中应该具体的虚拟页和物理页之间的关系应该是长期的,应该保护起来,不能随便被别的虚拟页所替换。具体也可参看关于虚拟存储器的文章。

接下来就是建立物理页与虚拟页之间的关系,即采用函数remap_pfn_range(),具体的参数如下:

int remap_pfn_range(structvm_area_struct *vma, unsigned long addr,unsigned long pfn, unsigned long size, pgprot_t prot)

1、struct vm_area_struct是一个虚拟内存区域结构体,表示虚拟存储器中的一个内存区域。其中的元素vm_start是指虚拟存储器中的起始地址。
2、addr也就是虚拟存储器中的起始地址,通常可以选择addr = vma->vm_start。
3、pfn是指物理存储器的具体页号,通常通过物理地址得到对应的物理页号,具体采用virt_to_phys(dev->data)>>PAGE_SHIFT.首先将虚拟内存转换到物理内存,然后得到页号。>>PAGE_SHIFT通常为12,这是因为每一页的大小刚好是4K,这样右移12相当于除以4096,得到页号。
4、size区域大小
5、区域保护机制。
返回值,如果成功返回0,否则正数。

三、系统调用mmap函数解析

介绍完VM的基本概念后,我们可以讲述mmap和munmap系统调用了.mmap调用实际上就是一个内存对象vma的创建过程,

1、mmap函数

Linux提供了内存映射函数mmap,它把文件内容映射到一段内存上(准确说是虚拟内存上),通过对这段内存的读取和修改,实现对文件的读取和修改。普通文件被映射到进程地址空间后,进程可以向访问普通内存一样对文件进行访问,不必再调用read(),write()等操作。

先来看一下mmap的函数声明:

头文件: 
 
 

原型: void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offsize); 

/*
返回值: 成功则返回映射区起始地址, 失败则返回MAP_FAILED(-1). 

参数: 
	addr: 指定映射的起始地址, 通常设为NULL, 由系统指定. 
	length: 将文件的多大长度映射到内存. 
	prot: 映射区的保护方式, 可以是: 
		PROT_EXEC: 映射区可被执行. 
		PROT_READ: 映射区可被读取. 
		PROT_WRITE: 映射区可被写入. 
		PROT_NONE: 映射区不能存取. 
	flags: 映射区的特性, 可以是: 
		MAP_SHARED: 对映射区域的写入数据会复制回文件, 且允许其他映射该文件的进程共享. 
		MAP_PRIVATE: 对映射区域的写入操作会产生一个映射的复制(copy-on-write), 对此区域所做的修改不会写回原文件. 
		此外还有其他几个flags不很常用, 具体查看linux C函数说明. 
	fd: 由open返回的文件描述符, 代表要映射的文件. 
	offset: 以文件开始处的偏移量, 必须是分页大小的整数倍, 通常为0, 表示从文件头开始映射.
*/

 

mmap的作用是映射文件描述符fd指定文件的 [off,off + len]区域至调用进程的[addr, addr + len]的内存区域, 如下图所示:

\

mmap系统调用的实现过程是

1.先通过文件系统定位要映射的文件;

2.权限检查,映射的权限不会超过文件打开的方式,也就是说如果文件是以只读方式打开,那么则不允许建立一个可写映射;

3.创建一个vma对象,并对之进行初始化;

4.调用映射文件的mmap函数,其主要工作是给vm_ops向量表赋值;

5.把该vma链入该进程的vma链表中,如果可以和前后的vma合并则合并;

6.如果是要求VM_LOCKED(映射区不被换出)方式映射,则发出缺页请求,把映射页面读入内存中.

 

2、munmap函数

munmap(void * start, size_t length):

该调用可以看作是mmap的一个逆过程.它将进程中从start开始length长度的一段区域的映射关闭,如果该区域不是恰好对应一个vma,则有可能会分割几个或几个vma.

msync(void * start, size_t length, int flags):

把映射区域的修改回写到后备存储中.因为munmap时并不保证页面回写,如果不调用msync,那么有可能在munmap后丢失对映射区的修改.其中flags可以是MS_SYNC, MS_ASYNC, MS_INVALIDATE, MS_SYNC要求回写完成后才返回, MS_ASYNC发出回写请求后立即返回, MS_INVALIDATE使用回写的内容更新该文件的其它映射.该系统调用是通过调用映射文件的sync函数来完成工作的.

brk(void * end_data_segement):

将进程的数据段扩展到end_data_segement指定的地址,该系统调用和mmap的实现方式十分相似,同样是产生一个vma,然后指定其属性.不过在此之前需要做一些合法性检查,比如该地址是否大于mm->end_code, end_data_segement和mm->brk之间是否还存在其它vma等等.通过brk产生的vma映射的文件为空,这和匿名映射产生的vma相似,关于匿名映射不做进一步介绍.库函数malloc就是通过brk实现的.


四、实例解析

下面这个例子显示了把文件映射到内存的方法,源代码是:

/************关于本文 档********************************************
*filename: mmap.c
*purpose: 说明调用mmap把文件映射到内存的方法
*wrote by: zhoulifa(zhoulifa@163.com) 周立发(http://zhoulifa.bokee.com)
Linux爱好者 Linux知识传播者 SOHO族 开发者 最擅长C语言
*date time:2008-01-27 18:59 上海大雪天,据说是多年不遇
*Note: 任何人可以任意复制代码并运用这些文档,当然包括你的商业用途
* 但请遵循GPL
*Thanks to:
*                Ubuntu 本程序在Ubuntu 7.10系统上测试完全正常
*                Google.com 我通常通过google搜索发现许多有用的资料
*Hope:希望越来越多的人贡献自己的力量,为科学技术发展出力
* 科技站在巨人的肩膀上进步更快!感谢有开源前辈的贡献!
*********************************************************************/
#include  /* for mmap and munmap */
#include  /* for open */
#include  /* for open */
#include      /* for open */
#include     /* for lseek and write */
#include 

int main(int argc, char **argv)
{
	int fd;
	char *mapped_mem, * p;
	int flength = 1024;
	void * start_addr = 0;

	fd = open(argv[1], O_RDWR | O_CREAT, S_IRUSR | S_IWUSR);
	flength = lseek(fd, 1, SEEK_END);
	write(fd, "\0", 1); /* 在文件最后添加一个空字符,以便下面printf正常工作 */
	lseek(fd, 0, SEEK_SET);
	mapped_mem = mmap(start_addr, flength, PROT_READ,        //允许读
		MAP_PRIVATE,       //不允许其它进程访问此内存区域
			fd, 0);
	
	/* 使用映射区域. */
	printf("%s\n", mapped_mem); /* 为了保证这里工作正常,参数传递的文件名最好是一个文本文件 */
	close(fd);
	munmap(mapped_mem, flength);
	return 0;
}

 

编译运行此程序:

gcc -Wall mmap.c
./a.out text_filename

上面的方法因为用了PROT_READ,所以只能读取文件里的内容,不能修改,如果换成PROT_WRITE就可以修改文件的内容了。又由于 用了MAAP_PRIVATE所以只能此进程使用此内存区域,如果换成MAP_SHARED,则可以被其它进程访问,比如下面的

#include  /* for mmap and munmap */
#include  /* for open */
#include  /* for open */
#include      /* for open */
#include     /* for lseek and write */
#include 
#include  /* for memcpy */

int main(int argc, char **argv)
{
	int fd;
	char *mapped_mem, * p;
	int flength = 1024;
	void * start_addr = 0;

	fd = open(argv[1], O_RDWR | O_CREAT, S_IRUSR | S_IWUSR);
	flength = lseek(fd, 1, SEEK_END);
	write(fd, "\0", 1); /* 在文件最后添加一个空字符,以便下面printf正常工作 */
	lseek(fd, 0, SEEK_SET);
	start_addr = 0x80000;
	mapped_mem = mmap(start_addr, flength, PROT_READ|PROT_WRITE,        //允许写入
		MAP_SHARED,       //允许其它进程访问此内存区域
		fd, 0);

	* 使用映射区域. */
	printf("%s\n", mapped_mem); /* 为了保证这里工作正常,参数传递的文件名最好是一个文本文 */
	while((p = strstr(mapped_mem, "Hello"))) { /* 此处来修改文件 内容 */
		memcpy(p, "Linux", 5);
		p += 5;
	}
	
	close(fd);
	munmap(mapped_mem, flength);
	return 0;
}

 五、mmap和共享内存对比

共享内存允许两个或多个进程共享一给定的存储区,因为数据不需要来回复制,所以是最快的一种进程间通信机制。共享内存可以通过mmap()映射普通文件(特殊情况下还可以采用匿名映射)机制实现,也可以通过系统V共享内存机制实现。应用接口和原理很简单,内部机制复杂。为了实现更安全通信,往往还与信号灯等同步机制共同使用。

对比如下:

mmap机制:就是在磁盘上建立一个文件,每个进程存储器里面,单独开辟一个空间来进行映射。如果多进程的话,那么不会对实际的物理存储器(主存)消耗太大。

shm机制:每个进程的共享内存都直接映射到实际物理存储器里面。

1、mmap保存到实际硬盘,实际存储并没有反映到主存上。优点:储存量可以很大(多于主存);缺点:进程间读取和写入速度要比主存的要慢。

2、shm保存到物理存储器(主存),实际的储存量直接反映到主存上。优点,进程间访问速度(读写)比磁盘要快;缺点,储存量不能非常大(多于主存)

使用上看:如果分配的存储量不大,那么使用shm;如果存储量大,那么使用mmap。

 

 

相关内容