0%

mmap机制分析

mmap是POSIX规范接口中用来处理内存映射的一个系统调用,它本身的使用场景非常多,可以用来申请大块内存,可以用来申请共享内存,也可以将文件或设备直接映射到内存中,进程可以像访问普通内存一样访问被映射的文件,在实际开发过程使用场景非常多。这次要聊的重点是mmap映射普通文件的一些原理。

备注:文中引用的所有linux相关源码均基于4.1版本。

1、Linux内存管理

如果要了解mmap的原理,需要先了解一下linux对内存是怎么管理的。
计算机系统中物理内存的大小总是有限的,而我们需要在计算机中运行多个进程,如果允许进程直接访问物理内存,那么可能会发生一些致命的问题,比如A进程不小心把数据写到了B进程的内存中,产生的问题可能是灾难性的。
为了更加安全有效的对内存进行管理,在现代计算机系统中对物理内存做了一层抽象,即虚拟内存。它为每一个进程都提供一块连续的私有地址空间,在32位模式下,每一块虚拟地址空间大小为4GB。
系统将虚拟内存分割成一块块固定大小的虚拟页(Virtual Page),同样的,物理内存也会被分割成物理页(Physical Page),当进程访问内存时,CPU通过内存管理单元(MMU)根据页表(Page Table)将虚拟地址翻译成物理地址,最终取到内存数据。这样在每个进程内部都像是独享整个主存。

而Linux操作系统中,会把高地址的1GB内存作为内核空间,低地址的3GB作为用户空间。
下面是Linux进程内存的布局:

从上图可以看出,一个进程的虚拟空间有多个部分组成,mmap的文件所处的内存空间在内存映射段中。
Linux内核使用vm_area_struct来表示一块内存区域,因为一个进程中会出现多个不同的内存区块,每一个内存块都会使用vm_area_struct结构体来表示,
vm_area_struct的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
struct vm_area_struct {  
/* The first cache line has the info for VMA tree walking. */
unsigned long vm_start; /* Our start address within vm_mm. */
unsigned long vm_end; /* The first byte after our end address
within vm_mm. */
/* linked list of VM areas per task, sorted by address */
struct vm_area_struct *vm_next, *vm_prev;
struct rb_node vm_rb;
unsigned long rb_subtree_gap;

struct mm_struct *vm_mm; /* The address space we belong to. */
pgprot_t vm_page_prot; /* Access permissions of this VMA. */
unsigned long vm_flags; /* Flags, see mm.h. */
struct {
struct rb_node rb;
unsigned long rb_subtree_last;
} shared;

struct list_head anon_vma_chain; /* Serialized by mmap_sem &
* page_table_lock */
struct anon_vma *anon_vma; /* Serialized by page_table_lock */

/* Function pointers to deal with this struct. */
const struct vm_operations_struct *vm_ops;

/* Information about our backing store: */
unsigned long vm_pgoff; /* Offset (within vm_file) in PAGE_SIZE
units, *not* PAGE_CACHE_SIZE */
struct file * vm_file; /* File we map to (can be NULL). */
void * vm_private_data; /* was vm_pte (shared mem) */
...
};

其中需要重点关注的是vm_ops变量,它指向的是一组函数指针,定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct vm_operations_struct {  
void (*open)(struct vm_area_struct * area);
void (*close)(struct vm_area_struct * area);
int (*fault)(struct vm_area_struct *vma, struct vm_fault *vmf);
void (*map_pages)(struct vm_area_struct *vma, struct vm_fault *vmf);
int (*page_mkwrite)(struct vm_area_struct *vma, struct vm_fault *vmf);
int (*pfn_mkwrite)(struct vm_area_struct *vma, struct vm_fault *vmf);
int (*access)(struct vm_area_struct *vma, unsigned long addr,
void *buf, int len, int write);
const char *(*name)(struct vm_area_struct *vma);
struct page *(*find_special_page)(struct vm_area_struct *vma,
unsigned long addr);
};

这是一组用于针对不同内存类型的不同操作,可以看到有open(打开)、close(关闭)、fault(缺页异常)等等接口,后面会着重讲针对文件映射的操作原理。
当进程在申请的内存的时候,linux内核其实只分配一块虚拟内存地址,并没有分配实际的物理内存,相当于操作系统只给进程这一块地址的使用权。只有当程序真正使用这块内存时,会产生一个缺页异常,这时内核去真正为进程分配物理页,并建立对应的页表,从而将虚拟内存和物理内存建立一个映射关系,这样可以做到充分利用到物理内存。

2、mmap原理

mmap系统调用的定义:

1
void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset);

参数说明:
start:映射空间的起始地址,一般设置为 NULL;
length:映射空间的长度;
prot:内存保护标志,包括PROT_EXEC(可执行)、PROT_READ(可读)、PROT_WRITE(可写)、PROT_NONE(不可访问) ;
flags:映射类型,通常用来标记共享内存(MAP_SHARED)、匿名映射(MAP_ANONYMOUS)等。
fd:真正要映射的文件描述符;
offset:映射文件的偏移量。

一个简单的demo如下:

1
2
3
4
5
6
7
8
9
10
11
12
int main(int argc, char **argv)  
{
char *filename = "/tmp/foo.data";
struct stat stat;
int fd = open(filename, O_RDWR, 0);
fstat(fd, &stat);
void *bufp = mmap(NULL, stat.st_size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
memcpy(bufp, "Linuxdd", 7);
munmap(bufp, stat.st_size);
close(fd);
return 0;
}

从demo中可以看出,mmap是将一个文件直接映射到进程的地址空间,进程可以像操作内存一样去读写磁盘上的文件内容,而不需要再调用read/write等系统调用。
下面分析一下mmap的实现原理:

1
2
3
4
5
6
7
8
9
10
11
12
13
SYSCALL_DEFINE6(mmap, unsigned long, addr, unsigned long, len,  
unsigned long, prot, unsigned long, flags,
unsigned long, fd, unsigned long, off)
{
long error;
error = -EINVAL;
if (off & ~PAGE_MASK)
goto out;

error = sys_mmap_pgoff(addr, len, prot, flags, fd, off >> PAGE_SHIFT);
out:
return error;
}

内部直接调用的是sys_mmap_pgoff函数,流程如下:

再经过vm_mmap_pgoff到do_mmap_pgoff,源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
unsigned long do_mmap_pgoff(struct file *file,  
unsigned long addr,
unsigned long len,
unsigned long prot,
unsigned long flags,
unsigned long pgoff,
unsigned long *populate)
{
// 申请一个vm_area_struct结构体
struct vm_area_struct *vma;

// ...

// 为vma分配内存
vma = kmem_cache_zalloc(vm_area_cachep, GFP_KERNEL);
if (!vma)
goto error_getting_vma;

// ... 初始化相关

// 如果是文件映射,给文件添加一个引用计数
if (file) {
region->vm_file = get_file(file);
vma->vm_file = get_file(file);
}

down_write(&nommu_region_sem);

// ...

// 真正去做文件映射
if (file && vma->vm_flags & VM_SHARED)
ret = do_mmap_shared_file(vma);
else
ret = do_mmap_private(vma, region, len, capabilities);

// ...
// 将vma插入到链表中
add_vma_to_mm(current->mm, vma);
// ...
}

在做文件映射时,如果不是共享的文件,则调用的是do_mmap_private函数,此函数流程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static int do_mmap_private(struct vm_area_struct *vma,  
struct vm_region *region,
unsigned long len,
unsigned long capabilities)
{
// ...
if (capabilities & NOMMU_MAP_DIRECT) {
// 调用文件映射的方法
ret = vma->vm_file->f_op->mmap(vma->vm_file, vma);
// ...
}

// ...
}

此处f_op->mmap指向的是generic_file_mmap:

1
2
3
4
5
6
7
8
9
10
int generic_file_mmap(struct file * file, struct vm_area_struct * vma)  
{
struct address_space *mapping = file->f_mapping;

if (!mapping->a_ops->readpage)
return -ENOEXEC;
file_accessed(file);
vma->vm_ops = &generic_file_vm_ops;
return 0;
}

内部就是给前面提到的vm_ops函数指针的集合赋值,generic_file_vm_ops指向的是针对文件操作的一系列函数:

1
2
3
4
5
const struct vm_operations_struct generic_file_vm_ops = {  
.fault = filemap_fault,
.map_pages = filemap_map_pages,
.page_mkwrite = filemap_page_mkwrite,
};

其中包括缺页处理,映射页,置为可写三个操作;其中缺页异常的处理逻辑如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
int filemap_fault(struct vm_area_struct *vma, struct vm_fault *vmf)  
{
// ...

// 先判断当前页有没有被cache
page = find_get_page(mapping, offset);
if (likely(page) && !(vmf->flags & FAULT_FLAG_TRIED)) {
// 预读机制,从cache中拿到数据
do_async_mmap_readahead(vma, ra, file, page, offset);
} else if (!page) {
// 未cache到,直接同步读取
do_sync_mmap_readahead(vma, ra, file, offset);
count_vm_event(PGMAJFAULT);
mem_cgroup_count_vm_event(vma->vm_mm, PGMAJFAULT);
ret = VM_FAULT_MAJOR;
retry_find:
page = find_get_page(mapping, offset);
if (!page)
goto no_cached_page;
}

// ...

// 找到对应页将其赋值给vmf,并返回
vmf->page = page;
return ret | VM_FAULT_LOCKED;
// ...
}

总结mmap文件映射过程:
a) 用户在进程中触发mmap操作;
b) 内核对参数做基本的校验,并针对映射长度做一些内存对齐;
c) 分配vm_area_struct结构,并对其进行初始化;
d) 调用文件系统的mmap映射,将缺页异常等函数指针赋于vm_ops;
e) 将新建的vm_area_struct结构插入到mm链表中;
f) 当进程访问这片内存时,引发缺页异常,从而调用filemap_fault;
g) 缺页异常查找cache中有无请求的页,如果没有,内核发起请求将数据从磁盘装入内存;

3、mmap和read/write的区别

read的系统调用的流程大概如下图所示:

a) 用户进程发起read操作;
b) 内核会做一些基本的page cache判断,从磁盘中读取数据到kernel buffer中;
c) 然后内核将buffer的数据再拷贝至用户态的user buffer;
d) 唤醒用户进程继续执行;

而mmap的流程如下图所示:

内核直接将内存暴露给用户态,用户态对内存的修改也直接反映到内核态,少了一次的内核态至用户态的内存拷贝,速度上会有一定的提升;

4、总结

经过对linux内核源码的一些剖析,对mmap的原理会有更深层次的理解 。 mmap的优点有很多,相比传统的read/write等I/O方式,直接将虚拟地址的区域映射到文件,没有任何数据拷贝的操作,当发现有缺页时,通过映射关系将磁盘的数据加载到内存,用户态程序直接可见,提高了文件读取的效率。对索引数据这种大文件的读取、cache、换页等操作直接交由操作系统去调度,间接减少了用户程序的复杂度,并提高了运行效率。