对一个进程而言,它好像都可以访问整个系统的所有物理内存。即使单独一个进程,它拥有的地址空间也远远大于系统物理内存。
1.地址空间
每个进程都有一个32位或者64位的平坦(flat)地址空间,空间具体大小取决于体系结构,平坦指的是地址空间范围是一个独立的连续区间。一些操作系统提供了段地址空间,这种空间并非是一个独立的线性区域,而是被分段的,但现代采用虚拟内存的操作系统通常都是使用平坦地址空间而不是分段式的内存模式。通常情况下,每个进程都有唯一的这种平坦地址空间。一个进程的地址空间与另一个进程的地址空间即使有相同的内存地址,实际上彼此不相干。我们称这样的进程为线程。
尽管一个进程可以寻址4GB的虚拟内存,但这并不代表它就有权访问所有的虚拟地址。我们把可被访问的合法地址空间称为内存区域。通过内核,进程可以给自己的地址空间动态增减。
进程只能访问有效内存区域内的内存地址。如果访问不在有效范围内或者访问方式不正确,那么内核就会终止该进程,并返回『段错误』信息。内存区域可以包含各种内存对象:代码段;数据段;bss段;用户栈;C库或动态链接程序的代码段,数据段和bss;内存映射文件;共享内存段;匿名的内存映射如由malloc
分配的内存。进程地址空间中的任何有效地址都只能位于唯一的区域,这些内存区域不能互相覆盖。
2.内存描述符
内核使用内存描述符结构体表示进程的地址空间,该结构包含了和进程地址空间有关的全部信息。
mm_users
用于记录正在使用该地址的进程数目,若有9个线程共享某个地址空间,那么mm_users
将会是9,而mm_count
为1,当mm_users
减为0,则mm_count
为0。
mmap
和mm_rb
这两个不同的数据结构描述的对象是相同的:该地址空间中的全部内存区域。前者以链表形式存放,后者为红黑树。内核通常会避免使用两种数据结构组织同一种数据,但此处内核这样的冗余是有用的。mmap
结构体作为链表,利于简单,高效地遍历所有元素;而mm_rb
更适合搜索。更多关于红黑树的部分可以参考我之前的博客:红黑树和AVL树的个人总结。
所有的mm_struct
结构体都通过自身的mmlist
域连接在一个双向链表中,该链表的首元素是init_mm
内存描述符,也就是进程init的内存描述符。操作该链表需要加mmlist_lock
锁。
在进程描述符中(task_struct
),mm
域存放进程的使用的内存描述符。fork()
利用copy_mm()
复制父进程的内存描述符。如果父进程希望和其子进程共享地址空间,可以在调用clone()
时候,设置CLONE_VM
标志。我们把这样的进程称为线程。是否共享地址空间几乎是进程和Linux中的线程间的本质上的唯一区别,除此以为,Linux内核不区别对待它们,线程对内核来说仅仅是一个共享特定资源的进程而已。
内存描述符存在于slab缓存中。
内核线程没有进程地址空间,也没有相关的内存描述符。所以内核线程对应的进程描述符中mm
域为空。事实上,这也正是内核线程的真实含义--它们没有用户上下文。内核线程并不需要访问任何用户控件的内存,而且因为内核线程在用户空间中没有任何页,所以实际上并不需要有自己的内存描述符和页表。尽管如此,即使访问内核内存,内核线程还是需要使用一些数据,比如页表。为了避免内核线程为内存描述符和页表浪费内存,也为了当新内核线程运行时,避免浪费处理器周期向新地址空间进行切换,内核线程将直接使用前一个进程的内存描述符。当一个进程被调度时,该进程的mm
域指向的地址空间被装载到内存,进程描述符中的active_mm
域会被更新,指向新的地址空间。内核线程没有地址空间,所以mm
域为NULL
。于是,当一个内核线程被调度时,内核发现它的mm
域为NULL
,就会保留前一个进程的地址空间,随后内核更新内核线程对于的进程描述符中的active_mm
域,使其指向前一个进程的内存描述符。
3.虚拟内存区域
内存区域由vm_area_struct
结构体描述,在Linux内核中也经常成为虚拟内存区域(virtual memoryAreas, VMAS)。vm_area_struct
结构体描述了制定地址空间内连续区间上的一个独立内存范围。内核将每个内存区域作为一个单独的内存对象管理,每个内存区域都拥有一致的属性,比如访问权限等,另外,相应地操作也都一致。按照这种方式,每一个VMA就可以代表不同类型的内存区域(比如内存映射文件,进程用户栈,堆段等等),这种管理方式类似于使用VFS层的面向对象方法。每个虚拟内存描述符对应于进程地址空间中的唯一区间。
vm_end - vm_start
的大小便是虚拟内存区域的大小,vm_mm
域指向和VMA相关的mm_struct
结构体,因为每个VMA对其相关的mm_struct
来说都是唯一的,所以即便两个独立的进程将同一个文件映射到各自的地址空间,它们分别都会有一个vm_area_struct
结构体来标识自己的内存区域;反过来,如果两个线程共享一个地址空间,那么它们也同时共享其中所有的vm_area_struct
。
可以通过内存描述符中的mmap
和mm_rb
域之一来访问内存区域,这两个域各自独立地指向与内存描述符相关的全体内存区域对象。其实,它们包含完全相同的vm_area_struct
结构体指针,仅仅是组织方式不同。mmap
域使用单独链表连接所有的内存区域对象。每一个vm_area_struct
结构体通过自身的vm_next
域被加入链表,所有的区域按地址增长的方向排序。mm_rb
域使用红黑树连接所有的内存区域对象。mm_rb
域指向红黑树根节点,地址空间中每一个vm_area_struct
结构体通过自身的vm_rb
域连接到树种。
3.1查看实际使用的内存区域
/proc/<pid>/maps
的输出显示了该进程地址空间中的全部内存区域:
pmap
工具将上述信息以更方便阅读的形式输出:
前三行分别对应C库中lic.so
的代码段,数据段和bss段;接下来两行为可执行对象的代码段和数据段,此处无bss端;再接下来的三行为动态连接程序ld.so
的代码段,数据段和bss段;最后一行是进程的栈。注意,代码段具有我们所要求的可读且可执行权限;另一方面,数据段和bss具有可读,可写单不可执行权限,堆栈则为可读,可写,甚至可执行--虽然这点并不常用到。
可以通过find_vma()
找到一个给定的内存地址属于哪一个内存区域,如上所述,查找一般都用红黑树实现。
总结来说,mm_struct
代表进程的整个地址空间,vm_area_struct
代表某一部分,如堆、栈、数据段、bss段、代码段等等。
4.mmap()
和do_mmap()
:创建地址区间
内核使用do_mmap()
函数创建一个新的线性地址区间。但是说该函数创建一个新的VMA并不非常准确,因为如果创建的地址区间和一个已经存在的地址区间相邻,并且具有相同访问权限,则会合二为一;若不能合并,则需要创建新的VMA。用户空间通过mmap()
系统调用获取内核函数do_mmap()
的功能。do_munmap()
用于删除。
5.页表
虽然应用程序操作的对象是映射到物理内存之上的虚拟内存,但是处理器直接操作的却是物理内存。所以当程序访问一个虚拟地址时,首先必须将虚拟地址转换为物理地址,然后处理器才能解析地址访问请求。地址的转换工作需要通过查询页表才能完成,概况地讲,地址转换需要将虚拟地址分段,使每段虚拟地址都作为一个索引表指向页面,而页表则指向下一级别的页表或者最终的物理页面。
Linux使用三级页表完成转换。顶级页表(PGD),它包含了一个pgd_t
类型数组,多数体系结构中pgd_t
类型等同于无符号长整型类型。PGD中的表现指向二级页目录中的表项:PMD;二级页表是中间页目录(PMD),它是一个pmd_t
类型数组,其中的表项指向PTE中的表项;最后一级的页表简称为页表,其中包含了pte_t
类型的页表项,该页表项指向物理页面。多数体系结构中,搜索页表的工作由硬件完成。
内存描述符中的pgd
域指向的就是进程的页全局目录。操作和检索页表时必须加page_table_loc
锁,存在于进程内。
由于几乎每次对虚拟内存中的页面访问都必须先解析它,从而得到物理内存中的对应地址,所以页表操作的性能非常关键。多数体系结构实现了一个TLB(translate lookaside buffer)作为一个将虚拟地址映射到物理地址的硬件缓存,当请求访问一个虚拟地址时,处理器将首先检查TLB是否缓存了该虚拟地址到物理地址的映射,如果命中则直接返回,否则,就需要通过页表搜索需要的物理地址。
2.6版本内核对页表管理的主要改进:从高端内存分配部分页表。今后可能存在的改进:通过写时拷贝(copy-on-write)的方式共享页表,这种机制使得在fork()
操作中可由父子进程共享页表。因为只有当子进程或者父进程试图修改特定页表项时,内核才去创建该页表项的新拷贝,此后父子进程才不再共享该页表项。可以看到,利用共享页表可以消除fork()
操作中页表拷贝所带来的消耗。
参考
《Linux内核设计与实现》