进程地址空间
1、地址空间
每个进程都有一个32位或者64位寻址的地址空间。就32为寻址来讲,尽管进程可以寻址4GB(232)的虚拟内存,但是这并不意味着进程就有权访问所有的虚拟内存。每个内存区域具有相关权限,如果一个进程访问了不在有效范围内的内存区域,或以不正确的方式访问了有效地址,内核就会终止该进程,并返回段错误。
通常,内存区域包含的内存对象如下:
- 可执行文件代码的内存映射,称为代码段(text section);
- 可执行文件的已初始化全局变量的内存映射,称为数据段(data section);
- 包含未初始化全局变量,bss段(block started by symbol)的零页;
- 用户进程空间栈的零页的内存映射;
- 各种库的代码段、数据段、bss段也会被载入进程的地址空间;
- 任何内存映射文件;
- 任何共享内存段;
- 任何匿名的内存映射,比如malloc分配的内存
2、内存描述符
内核使用内存描述符结构体表示进程的地址空间,由mm_struct结构体表示,定义在文件 <linux/sched.h> 中:
mm_users域记录正在使用该地址的进程数目。
mm_count域是mm_struct结构体的主引用计数。当mm_users减为0时,mm_count也变为0, 此时mm_struct结构体会被撤销。
mmap和mm_rb这两个数据结构都描述:地址空间中的全部内存区域。不过mmap是以链表的形式存放,mm_rb是以红黑树的形式存放(时间复杂度为lgn)。链表可以简单高效地遍历所有元素,而红黑树更适合搜索指定元素。
所有的mm_struct结构体都是通过自身的mmlist域连接在一个双向链表中,该链表的首元素是init_mm内存描述符,代表Init进程的地址空间。操作该链表时需要使用mmlist_lock锁防止并发访问,锁定义在kernel/fork.c中。
2.1、分配内存描述符
在进程的进程描述符的mm域存放了该进程使用的内存描述符,所以current->mm就指向了当前进程的进程描述符。
在fork()时,fork()函数利用copy_mm() 函数复制父进程的内存描述符,而子进程中的mm_struct结构体是通过kernel/fork.c中的allocate_mm() 宏从mm_cachep slab缓存中分配得到。
如果父进程希望和子进程共享地址空间,可以在调用clone() 时,设置CLONE_VM标志,这样创建的进程也被称作线程(线程对内核来说仅仅是一个共享特定资源的进程而已)。当CLONE_VM被指定后,内核就不需要在调用allocate_mm() 函数,仅仅需要在调用copy_mm() 函数中将mm域指向其父进程的内存描述符就可以了。
2.2、撤销内存描述符
当进程退出时,内核会调用定义在kernel/exit.c中的exit_mm() 函数,该函数会调用mmput() 函数减少内存描述符中的mm_users用户计数,如果用户计数降为零,将调用mmdrop() 函数,减少mm_count使用计数。如果使用计数也为零,则调用free_mm() 宏通过kmem_cache_free() 函数将mm_struct结构体归还到mm_cachep slab缓存中。
2.3、mm_struct与内核线程
内核线程没有进程地址空间,也没有相关的内存描述符。所以内核线程对应的进程描述符中的mm域为空。因为内核线程在用户空间没有任何页,索引它们也不需要有自己的内存描述符和页表。但是,内核线程在访问内核内存时还是需要页表等数据的。因此,为了避免内核线程为内存描述符和页表浪费内存空间,也是为了避免浪费处理器周期向新地址空间进行切换,内核线程将直接使用前一个进程的内存描述符。
当一个进程被调度时,进程的mm域指向的地址空间被装载到内存,进程描述符中的active_mm域会被更新,指向新的地址空间。内核线程没有的地址空间,所以mm域为NULL。当一个内核线程被调度时,内核发现它的mm域为NULL,就会保留前一个进程的地址空间,随后更新内核线程对应的进程描述符的active_mm域,使其指向前一个进程的内存描述符。所以在需要时,内核线程便可以使用前一个进程的页表,内核线程不访问用于空间的内存,所以它们仅仅使用地址空间中和内核内存相关的信息。
3、虚拟内存区域
内存区域也被称作虚拟内存区域(virtual memory Areas,VMAs),由vm_area_struct结构体描述,定义在文件 <linux/mm_types.h> 中。
vm_area_struct结构体描述了指定地址空间内连续区间上的一个独立内存范围。内核将每个内存区域作为一个单独的内存对象管理,每个内存区域都拥有一致的属性,一致的操作。按照这种方式,每个VMA就可以代表不同类型的内存区域。 VFS的结构体定义如下所示:
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; /* VMA链表 */
struct rb_node vm_rb; /* rb树的VMA节点 */
/*
* Largest free memory gap in bytes to the left of this VMA.
* Either between this VMA and vma->vm_prev, or between one of the
* VMAs below us in the VMA rbtree and its ->vm_prev. This helps
* get_unmapped_area find a free area of the right size.
*/
unsigned long rb_subtree_gap;
/* Second cache line starts here. */
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.,标志 */
/*
* For areas with an address space and backing store,
* linkage into the address_space->i_mmap interval tree.
*/
struct {
struct rb_node rb;
unsigned long rb_subtree_last;
} shared;
/*
* A file's MAP_PRIVATE vma can be in both i_mmap tree and anon_vma
* list, after a COW of one of the file pages. A MAP_SHARED vma
* can only be in the i_mmap tree. An anonymous MAP_PRIVATE, stack
* or brk vma (with NULL file) can only be in an anon_vma list.
*/
struct list_head anon_vma_chain; /* Serialized by mmap_sem & * page_table_lock */
struct anon_vma *anon_vma; /* Serialized by page_table_lock,匿名VMA对象 */
/* 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,文件中的偏移量 */
struct file * vm_file; /* File we map to (can be NULL).,被映射的文件(如果存在) */
void * vm_private_data; /* was vm_pte (shared mem) */
#ifndef CONFIG_MMU
struct vm_region *vm_region; /* NOMMU mapping region */
#endif
#ifdef CONFIG_NUMA
struct mempolicy *vm_policy; /* NUMA policy for the VMA */
#endif
struct vm_userfaultfd_ctx vm_userfaultfd_ctx;
};
vm_start指向区间的首地址(最低地址),vm_end指向区间的尾地址(最高地址)之后的第一个地址。也就是vm_start包含在区间内,而vm_end不再区间内。所以vm_end - vm_start的大小就是内存区间的长度。
3.1、VMA标志
vm_flags 标志了内存区域所包含的页面的行为和信息。VMA标志定义在 <linux/mm.h> 中。VMA标志反映了内核处理页面所需要遵守的行为准则。如下表所示:
当访问VMA时,需要查看其访问权限。
VM_SHARD指明了内存区域包含的映射是否可以在多线程间共享,如果该标志被设置,则这段内存称为共享映射;如果未被设置,且仅仅只有一个进程使用,则称为私有映射。
VM_IO标志内存区域中包含对设备I/O空间的映射。通常在设备驱动程序执行mmap() 函数进行I/O空间映射时才被设置。该标志也一位置内存区域不能被包含在任何进程的存放转存中。
VM_RESERVED标志规定了内存区域不能被换出,也是在设备驱动程序进行映射时被设置。
VM_SEQ_READ标志暗示内核应用程序对映射内容执行有序的(线性和连续的)读操作,这样,内核可以有选择地执行预读程序;VM_RAND_READ标志则暗示内核对映射内容执行随机的读操作,这样,内核可以有选择地减少或者取消文件预读。这两个标志可以通过系统调用madvise() 设置,设置参数分别是MADV_SEQUENTIAL和MADV_RANDOM。
3.2、VMA操作
vm_ops域指向与指向内存区域相关的操作函数表,操作表定义在文件 <linux/mm.h> 中,下面列出了4.9.1内核版本的操作表:
struct vm_operations_struct {
void (*open)(struct vm_area_struct * area); /* 当指定的内存区域被加入到一个地址空间时,该函数被调用 */
void (*close)(struct vm_area_struct * area); /* 当指定的内存区域从地址空间中删除时,该函数被调用 */
int (*mremap)(struct vm_area_struct * area);
/* 当没有出现在物理内存中的页面被访问时,该函数被页面故障处理调用 */
int (*fault)(struct vm_area_struct *vma, struct vm_fault *vmf);
int (*pmd_fault)(struct vm_area_struct *, unsigned long address,
pmd_t *, unsigned int flags);
void (*map_pages)(struct fault_env *fe,
pgoff_t start_pgoff, pgoff_t end_pgoff);
/* notification that a previously read-only page is about to become
* writable, if an error is returned it will cause a SIGBUS,
* 当某个页面为只读页面时,该函数被页面故障处理调用 */
int (*page_mkwrite)(struct vm_area_struct *vma, struct vm_fault *vmf);
/* same as page_mkwrite when using VM_PFNMAP|VM_MIXEDMAP */
int (*pfn_mkwrite)(struct vm_area_struct *vma, struct vm_fault *vmf);
/* called by access_process_vm when get_user_pages() fails, typically
* for use by special VMAs that can switch between memory and hardware,
* 当get_user_pages()函数调用失败时,该函数被access_process_vm()函数调用*/
int (*access)(struct vm_area_struct *vma, unsigned long addr,
void *buf, int len, int write);
/* Called by the /proc/PID/maps code to ask the vma whether it
* has a special name. Returning non-NULL will also cause this
* vma to be dumped unconditionally. */
const char *(*name)(struct vm_area_struct *vma);
#ifdef CONFIG_NUMA
/*
* set_policy() op must add a reference to any non-NULL @new mempolicy
* to hold the policy upon return. Caller should pass NULL @new to
* remove a policy and fall back to surrounding context--i.e. do not
* install a MPOL_DEFAULT policy, nor the task or system default
* mempolicy.
*/
int (*set_policy)(struct vm_area_struct *vma, struct mempolicy *new);
/*
* get_policy() op must add reference [mpol_get()] to any policy at
* (vma,addr) marked as MPOL_SHARED. The shared policy infrastructure
* in mm/mempolicy.c will do this automatically.
* get_policy() must NOT add a ref if the policy at (vma,addr) is not
* marked as MPOL_SHARED. vma policies are protected by the mmap_sem.
* If no [shared/vma] mempolicy exists at the addr, get_policy() op
* must return NULL--i.e., do not "fallback" to task or system default
* policy.
*/
struct mempolicy *(*get_policy)(struct vm_area_struct *vma,
unsigned long addr);
#endif
/*
* Called by vm_normal_page() for special PTEs to find the
* page for @addr. This is useful if the default behavior
* (using pte_page()) would not find the correct page.
*/
struct page *(*find_special_page)(struct vm_area_struct *vma,
unsigned long addr);
};
3.3、内存区域查看
可以使用/proc文件系统和pmap工具查看给定进程的内存空间和其中所含的内存区域:
每行数据格式如下:
[开始——结束] [访问权限] [偏移] [主设备号:次设备号] [i节点] [文件]
注意:
- (1)、代码段具有可读且可执行权限;
- (2)、数据段和bss段它们都包含全局变量,且具有可读、可写但不可执行权限;
- (3)、堆栈具有可读可写,可能包含可执行权限。
对于共享内存和不可写内存,内核只需要在内存中为文件保留一份映射。例如C库在物理内存中仅仅保留一份,不需要为每个调用C库的进程在内存中开辟新的空间。
4、操作内存区域
4.1、find_vma()
内核提供了find_vma() 函数用来找寻一个给定的内存地址属于哪一个内存区域。函数定义在 <linux/mmap.c> 中:
struct vm_area_struct * find_vma(struct mm_struct * mm, unsigned long addr);
函数在指定的地址空间中搜索第一个vm_end大于addr的内存区域。如果没有发现这样的区域,函数返回NULL;否则返回指向匹配的内存区域的mm_area_struct结构体指针。由于只返回搜索到的第一个vm_end大于addr的内存区域,所以,返回的VMA首地址可能大于addr,指定的地址并不一定包含在返回的VMA中。通常,find_vma() 函数返回的结果会被缓存在内存描述符的mmap_cache域中,此域用来存放近期使用的VMA。因为在对某个VMA操作后,后续的其他操作可能也在VMA中 ,所以再进行搜索时检查mmap_cache域会更高效。如果指定的地址不在缓存中,那么就必须通过红黑树搜索和内存描述符相关的所有内存区域:
struct vm_area_struct *find_vma(struct mm_struct *mm, unsigned long addr)
{
struct vm_area_struct *vma;
/* check the cache first */
vma = vmacache_find(mm, addr);
if (likely(vma))
return vma;
/* trawl the list (there may be multiple mappings in which addr
* resides) */
for (vma = mm->mmap; vma; vma = vma->vm_next) {
if (vma->vm_start > addr)
return NULL;
if (vma->vm_end > addr) {
vmacache_update(addr, vma);
return vma;
}
}
return NULL;
}
4.2、find_vma_prev()
find_vma_prev() 返回第一个小于addr的VMA,pprev参数存放指向先于addr的VMA指针,函数定义和声明分别在mm/mmap.c和 <linux/mm.h> 中:
struct vm_area_struct *
find_vma_prev(struct mm_struct *mm, unsigned long addr,
struct vm_area_struct **pprev)
{
struct vm_area_struct *vma;
vma = find_vma(mm, addr);
if (vma) {
*pprev = vma->vm_prev;
} else {
struct rb_node *rb_node = mm->mm_rb.rb_node;
*pprev = NULL;
while (rb_node) {
*pprev = rb_entry(rb_node, struct vm_area_struct, vm_rb);
rb_node = rb_node->rb_right;
}
}
return vma;
}
4.3、find_vma_intersection()
find_vma_intersection() 返回第一个和指定地址区间相交的VMA,定义在 <linux/mm.h> 中:
static inline struct vm_area_struct * find_vma_intersection(struct mm_struct * mm, unsigned long start_addr, unsigned long end_addr)
{
struct vm_area_struct * vma = find_vma(mm,start_addr);
if (vma && end_addr <= vma->vm_start)
vma = NULL;
return vma;
}
参数mm为要搜索的地址空间,start_addr是区间的首地址,end_addr是区间的尾地址。如果命中,返回有效的VMA指针,否则返回NULL。
5、创建地址区间
内核使用do_mmap() 函数创建一个新的线性地址区间。do_mmap() 函数会将一个地址区间加入到进程的地址空间中,如果创建的地址区间和一个已经存在的地址区间相邻,并且它们具有相同的访问权限的话,两个区间将合并为一个。如果不能合并,就需要创建一个新的VMA。
do_mmap() 函数定义在文件 <linux/mm.h> 中:
unsigned long do_mmap(struct file *file, unsigned long addr,
unsigned long len, unsigned long prot,
unsigned long flags, vm_flags_t vm_flags,
unsigned long pgoff, unsigned long *populate)
file参数指向的文件会被映射,如果file参数为NULL,则代表这次映射没有与文件相关,称为匿名映射;如果file参数不为空,则该映射称为文件映射。
port参数指定内存区域中页面访问权限。访问全线标志定义在文件 <asm/mmanm.h> 中,或者在其包含的头文件中,不同的体系结构标志的定义有所不同,但是所有的体系结构都会包含以下四种标志:
标志 | 对新建区间中页的要求 |
---|---|
PROT_READ | 对应VM_READ |
PROT_WRITE | 对于VM_WRITE |
PROT_EXEC | 对应VM_EXEC |
PROT_NONE | 页不可被访问 |
如果传递给系统调用do_mmap() 的参数无效,那么它将返回一个负值;成功会在虚拟内存中分配一个合适的新内存区域。如果新区域和邻近区域权限一样则进行合并,否则内核从vm_area_cachep(slab)缓存中分配一个vm_area_struct结构体并使用vma_link() 函数将新分配的内存区域添加到地址空间的内存区域链表和红黑树中,随后更新内存描述符中的total_vm域,并放回新分配的地址区间的初始地址。
用户空间可以通过mmap() 系统调用获取内核函数do_mmap() 的功能。
6、删除地址区间
do_munmap() 函数从特定的进程地址空间中删除指定地址区间,函数定义在文件 <linux/mm.h> 中:
int do_munmap(struct mm_struct *mm, unsigned long start, size_t len);
删除mm参数指定的内存地址空间中,从地址start开始,长度为len字节的地址区间。如果成功,返回0;失败返回负的错误码。
用户通过系统调用munmap() 从自身地址空间中删除指定地址区间。
7、页表
处理器在操作时需要将应用程序映射在物理内存上的虚拟内存的虚拟地址转换成物理地址才能进行访问请求。地址的转换工作需要通过查询页表进行。总的来讲,地址转换需要将虚拟地址分段,是每段虚拟地址都作为一个索引指向页表,而页表项则指向下一级页表或者指向最终的物理页面。可以按照需要在编译时简化页表的结构,比如只是用两级页表。
Linux中使用三级页表完成地址转换。多级页表能够节约地址转换所需占用的存放空间。
顶级页表是页全局目录(PGD),它包含了一个pgd_t类型数组,大多数体系结构中pgd_t为无符号长整形类型,其中的表项指向二级页目录中的表项PMD。
二级页表是中间页目录(PMD),它包含了一个pmd_t类型数组,其中的表项指向PTE中的表项。
末级页表简称页表,它包含了一个pte_t类型的页表项,指向物理页面。
进程的内存描述符中的pgd域指向的就是进程的页全局目录。
注意:在操作和检索页表时必须使用page_table_lock锁来防止竞争条件。
页表的结构体实现依赖于具体的体系结构,定义在 <asm/page.h> 中。
由于搜索内存中的物理地址速度有限,因此为了加快搜索,多数体系结构够实现了翻译后缓冲器(translate lookaside buffer,TLB)。TLB是一个将虚拟地址映射到物理地址的硬件缓存,当请求访问一个虚拟地址时,处理器首先检查TLB中是否缓存了该虚拟地址到物理地址的映射,如果在缓存中命中,则直接返回,否则就需要进行页表的解析操作。
8、总结
内核通过mm_struct表示进程空间,通过vm_area_struct表示进程空间中的内存区域。用户可以通过mmap()和munmap() 系统调用来创建和删除这些区域。CPU在进行访问请求需要进行虚拟内存地址向物理地址的转换,可能是通过TLB进行或者页表进行。