在Linux内核以及Linux系统编程的时候,经常会碰到mmap内存映射,mmap函数是实现高性能编程的一个关键点。本文详细介绍一下mmap实现原理。
虚拟地址映射物理地址
虚拟地址映射物理地址采用的是页表机制,64位CPU采用的是4级页表。 64位CPU虚拟地址长度为64位,但实际只用48位就已满足虚拟地址映射物理内存的要求,如下图:
用户空间和内核空间共256T,2的48次方刚好为256T,所以48位地址空间能映射所有的虚拟地址。
48位虚拟地址由五部分组成:
- pgd表偏移,四级表,9位
- pud表偏移,三级表,9位
- pmd表偏移,二级表,9位
- ptl表偏移,一级表,9位
- 物理页偏移,12位
pgd,pud,pmd,ptl表实现原理都相同,我们以pgd来讲解。
一张pgd表对应一个物理页,一个物理页的大小为4KB,一个pgd_t表项为8个字节,一张pgd表能存储4*1024/8=512个表项。
2的9次方等于512,所以采用9位的表偏移就能索引整张表的表项。
虚拟地址映射物理地址需要依次索引pgd,pud,pmd,ptl表,具体过程如下:
-
查询pgd表:查询pgd表,首先需要找到pgd表物理页首地址,pgd表物理页首地址由task_struct->mm_struct->pgd成员保存,每个进程的task_struct->mm_struct->pgd成员数值不同,所以不同的进程即使使用相同的虚拟地址也不会访问相同到物理地址。通过pgd表首地址+虚拟地址pgd表偏移索引到pgd_t表项完成pgd表查询。
-
查询pud表:pgd_t表项存储的是pud表物理页首地址。通过pud表首地址+虚拟地址pud表偏移索引到pud_t表项完成pud表查询。
-
查询pmd表:pud_t表项存储的是pmd表物理页首地址。通过pmd表首地址+虚拟地址pmd表偏移索引到pmd_t表项完成pmd表查询。
-
查询ptl表:pmd_t表项存储的是ptl表物理页首地址。通过ptl表首地址+虚拟地址ptl表偏移索引到pte表项完成ptl表查询。
-
步骤5:映射物理地址:pte表项存储的是物理页首地址,pte+虚拟地址物理页偏移就能定位到物理地址。
定位到物理地址后,虚拟地址映射物理地址的过程就已完成。
mmap实现原理
mmap函数是一种内存映射文件的方法,它可以将一个文件或设备映射到进程的地址空间中,使得进程可以像访问内存一样访问文件或设备。
mmap可以分为:文件映射和匿名映射。
mmap函数主要工作就是创建VMA。
VMA简介
VMA(Virtual Memory Area,虚拟内存区域)是Linux内核中用于管理进程虚拟内存的数据结构。每个进程都有一个VMA链表,用于描述进程的虚拟地址空间的不同区域。
VMA包含了一段连续的虚拟地址空间,它定义了该区域的起始地址、结束地址以及一些属性信息。VMA可以表示进程的代码段、数据段、堆、栈等不同的内存区域。
VMA对应Linux内核struct vm_area_struct对象。
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) */ ...
};
struct vm_area_struct结构体主要成员如下:
-
vm_start:虚拟内存区域起始地址。
-
vm_end:虚拟内存区域结束地址,vm_end减去vm_start为映射区域长度。
-
vm_page_prot:虚拟内存访问权限,PROT_READ:可读,PROT_WRITE:可写,PROT_EXEC:可执行
-
vm_page_flags:内存映射标志,MAP_SHARED:共享映射,MAP_PRIVATE:私有映射
-
vm_ops:文件映射操作集合,匿名映射为NULL。
-
vm_pgoff:文件映射文件偏移量,匿名映射无效。
-
vm_file:映射文件,匿名映射为NULL。
注意:VMA用于指导虚拟内存映射物理内存,没有VMA指导无法完成虚拟地址和物理地址映射。
其中需要重点关注的是vm_ops变量,它指向的是一组函数指针,定义如下:
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);
};
当进程在申请的内存的时候,linux内核其实只分配一块虚拟内存地址,并没有分配实际的物理内存,相当于操作系统只给进程这一块地址的使用权。只有当程序真正使用这块内存时,会产生一个缺页异常,这时内核去真正为进程分配物理页,并建立对应的页表,从而将虚拟内存和物理内存建立一个映射关系,这样可以做到充分利用到物理内存。
mmap系统调用
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如下:
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等系统调用。
源码分析
基于3.10.0-514
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) //判断off是不是按页対齐的goto out;error = sys_mmap_pgoff(addr, len, prot, flags, fd, off >> PAGE_SHIFT);
out:return error;
}
内部直接调用的是sys_mmap_pgoff函数:
SYSCALL_DEFINE6(mmap_pgoff, unsigned long, addr, unsigned long, len,unsigned long, prot, unsigned long, flags,unsigned long, fd, unsigned long, pgoff)
{struct file *file = NULL;unsigned long retval = -EBADF;if (!(flags & MAP_ANONYMOUS)) {//有名文件映射audit_mmap_fd(fd, flags);if (unlikely(flags & MAP_HUGETLB))return -EINVAL;file = fget(fd);//根据fd得到对应file结构if (!file)goto out;if (is_file_hugepages(file))//如果是hugetlbfs文件系统文件,将文件大小对齐到页面大小len = ALIGN(len, huge_page_size(hstate_file(file)));} else if (flags & MAP_HUGETLB) {struct user_struct *user = NULL;struct hstate *hs = hstate_sizelog((flags >> MAP_HUGE_SHIFT) &SHM_HUGE_MASK);if (!hs)return -EINVAL;len = ALIGN(len, huge_page_size(hs));/** VM_NORESERVE is used because the reservations will be* taken when vm_ops->mmap() is called* A dummy user value is used because we are not locking* memory so no accounting is necessary*/file = hugetlb_file_setup(HUGETLB_ANON_FILE, len,VM_NORESERVE,&user, HUGETLB_ANONHUGE_INODE,(flags >> MAP_HUGE_SHIFT) & MAP_HUGE_MASK);if (IS_ERR(file))return PTR_ERR(file);}flags &= ~(MAP_EXECUTABLE | MAP_DENYWRITE);retval = vm_mmap_pgoff(file, addr, len, prot, flags, pgoff);if (file)fput(file);
out:return retval;
}
sys_mmap_pgoff只是做了一些准备,其通过调用位于mm/util.c的vm_mmap_pgoff进行地址映射,部分源码如下:
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函数,此函数流程如下:
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:
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指向的是针对文件操作的一系列函数:
const struct vm_operations_struct generic_file_vm_ops = { .fault = filemap_fault, .map_pages = filemap_map_pages, .page_mkwrite = filemap_page_mkwrite,
};
其中包括缺页处理,映射页,置为可写三个操作;其中缺页异常的处理逻辑如下:
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文件映射过程:
- 用户在进程中触发mmap操作
- 内核对参数做基本的校验,并针对映射长度做一些内存对齐
- 分配vm_area_struct结构,并对其进行初始化; 调用文件系统的mmap映射,将缺页异常等函数指针赋于vm_ops
- 将新建的vm_area_struct结构插入到mm链表中; 当进程访问这片内存时,引发缺页异常,从而调用filemap_fault
- 缺页异常查找cache中有无请求的页,如果没有,内核发起请求将数据从磁盘装入内存
与read/write的区别
用户进程发起read操作,内核会做一些基本的page cache判断,从磁盘中读取数据到kernel buffer中;,然后内核将buffer的数据再拷贝至用户态的user buffer,唤醒用户进程继续执行。
内核直接将内存暴露给用户态,用户态对内存的修改也直接反映到内核态,少了一次的内核态至用户态的内存拷贝,速度上会有一定的提升。