微言博命
分类: LINUX
2013-12-06 18:35:59
原文地址:Linux内核设计与实现(13)--进程地址空间 作者:leon_yu
上一节讲了内核如何管理物理内存,其实内核除了管理本身的内存外,还必须管理用户空间中进程的内存,这就是进程地址空间,也就是系统中每个用户空间进程所看到的内存。
Linux采用虚拟内存技术,系统中所有进程之间以虚拟方式共享内存,对一个进程而言,可以访问整个系统的所有物理内存,其拥有地址空间也可以远远大于系统物理内存。
每个进程都有唯一的这种平坦地址空间,并且不同进程之间,彼此互不相干,地址空间完全独立。
尽管一个进程可以寻址4GB虚拟内存(32bit),但这并不代表它就有权访问所有虚拟内存,在地址空间中,更关心的是一些可以合法访问的虚拟内存的地址空间,这个空间称为“内存区域(memory areas)”,进程只能访问有效内存区域内的内存地址,每个内存区域也具有相关权限,如可读、可写,可执行属性。如果一个进程访问了不在有效范围的内存区域,或以不正确的方式访问了有效地址,内核就会终止该进程,并返回“段错误”。
内存区域可以包含各种内存对象,比如:
代码段(text section):可执行文件代码的内存映射
数据段(data section): 可执行文件的已初始化全局变量的内存映射
bss段:包含未初始化全局变量,也就是bss段的零页 (页面中的信息全部为0,可用于映射bss段等目的) 的内存映射
栈:用户进程用户空间的栈(不要和进程内核栈混淆,进程的内核栈独立存在并且由内核维护)每个诸如C库或动态链接程序等共享库的代码段、数据段和BSS也会被载入进程的地址空间。
任何内存映射文件;
任何共享内存段;
任何匿名的内存映射,比如malloc()分配的内存;
进程地址空间中的任何有效地址都只能位于唯一的区域,这些内存区域不能相互覆盖;在执行的进程中,每个不同的内存片段都对应一个独立的内存区域:栈、对象代码、全局变量、被映射的文件等。
内核使用内存描述符来表示进程的地址空间,该结构体包含了和进程地址空间有关的官不信息,mm_struct结构体,定义在
点击(此处)折叠或打开
mm_users域记录正在使用该地址的进程数目,mm_count表示mm_struct结构体的主引用计数,当mm_users值减少为0时(所有使用该地址空间的线程都退出),mm_count变为0;当mm_count等于0,说明已经咩有人和指向该mm_stuct结构体的引用了,这时该结构体会被撤销。
mmap和mm_rb描述同一个对象:该地址空间中的全部内存区域。Mmap以链表形式存放,mm_rb以红-黑树形式存放。内核通常会避免用两种数据结构组织同一种数据,但此处这种冗余派的上用场,mmap链表,利于简单、高效地遍历所有元素;而mm_rb结构更适合搜索指定的元素。覆盖树上的链表并用这两个结构体同时访问相同的数据集,有时候这种操作称为线索树。
所有mm_stuct都通过自身的mmlist域链接在一个双向链表中,该链表首元素是init_mm内存描述符,它代表init进程的地址空间,另外注意,操作该链表是需要使用mmlist_lock来防止并发访问。
2.1 分配内存描述符
在进程的进程描述符task_struct中,mm域存放着该进程使用的内存描述符,所以current->mm指向当前进程的内存描述符。
fork()函数利用copy_mm()函数复制父进程的内存描述符,而子进程中的mm_struct结构体实际是通过文件kernel/fork.c中的allocate_mm()宏从mm_cachep slab缓存中分配得到的。通常每个进程都有唯一的mm_struct结构体,即唯一的进程地址空间。
如果父进程希望和子进程共享地址空间,可以调用clone()时,设置CLONE_VM标志,这样的进程称作线程,Linux中所谓的线程和进程的本质区别,就是是否共享地址空间。
当CLONE_VM被指定后,内核就不再需要调用allocate_mm()函数,而仅仅需要在调用copy_mm()函数中将mm域指向其父进程的内存描述符就可以了:
点击(此处)折叠或打开
2.2 撤销内存描述符
进程退出时,内核会调用exit_mm(),该函数执行一些常规撤销工作,同时更新一些统计量。
该函数会调用mmput()减少内存描述符中的mm_users用户基数,如果用户计数降到0,将调用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域,使其指向前一个进程的内存描述符。所以需要时,内核线程便可以使用前一个进程的页表。
内存区域由vm_area_struct结构体描述,定义在
vm_area_struct描述了指定地址空间内连续区间上的一个独立内存范围,内核将每个内存区域作为一个单独的内存对象管理,每个内存区域都拥有一致的属性,比如访问权限等,相应的操作也应该一致。按这种方式,每个VMA就可以代表不同类型的内存区域(比如内存映射文件或者进程用户空间栈)。
点击(此处)折叠或打开
每个内存描述符都对应于进程地址空间中的唯一区间,vm_end-vm_start大小就是内存区间的长度。在同一地址空间内的不同内存区间不能重叠。
vm_mm域指向和VMA相关的mm_struct结构体,每个VMA对其相关的mm_struct结构体来说都是唯一的,如果两个线程共享一个地址空间,那么它们也同时共享其中所有的vm_area_struct结构体。
3.1VMA标志
VMA是一种位标志,它包含在vm_flags域内,标志了内存区域所包含的页面行为和信息。VMA标识反映了内核处理页面所需遵守的行为准则,而不是硬件要求。vm_flags包含了内存区域中每个页面的信息或内存区域的整体信息,而不是具体的独立页面。
几个重要的标志(这些标志可以按需求组合):
VM_READ,VM_WRITE和VM_EXEC标志了区域中页面的读、写和执行权限。
VM_SHARD:指明内存区域包含的映射是否可以在多进程间共享,
VM_IO:标志内存区域中按对设备I/O空间的映射,该标志通常在设备驱动程序执行mmap()函数进行I/O空间映射时才被设置。
VM_SEQ_READ:标志内核应用程序对映射内容执行有序的(线性和连续的)读操作,这样内核可以有选择地执行预读文件。
3.2 VMA操作
Vm_area_struct结构体中的vm_ops域指向与指定内存区域相关的操作函数
点击(此处)折叠或打开
3.3 内存区域的树型结构和内存区域的链表结构
mmap和mm_rb,独立地指向与内存描述符相关的全体内存区域对象,它们包含完全相同的vm_area_struct结构体指针,仅仅方法不同。
mmap域使用单独的链表链接所有的内存区域对象,每个vm_area_struct结构体通过自身vm_next域被连入链表,mmap域指向链表中的一个内存区域,链中最后一个结构体指针指向空
mm_rb域使用红-黑树链接所有内存区域对象,mm_rb指向红-黑树根节点,地址空间中每个vm_area_struct通过自身的vm_rb连接到树中。
链表用于需要遍历全部节点的时候,而红黑树适用于在地址空间中定位特定内存区域的时候,内核为了内存区域上的各种不同操作都能获得高性能,所以同时使用了这两种数据结构。
3.4 实际使用中的内存区域
可使用/proc文件系统和pmap工具查看给定进程的内存空间和其中所含的内存区域。
点击(此处)折叠或打开
该程序执行pid是2874,那么
leon@ubuntu:~$ cat /proc/2874/maps
08048000-08049000 r-xp 00000000 08:01 131477 /home/leon/a.out
08049000-0804a000 r--p 00000000 08:01 131477 /home/leon/a.out
0804a000-0804b000 rw-p 00001000 08:01 131477 /home/leon/a.out
b7589000-b758a000 rw-p 00000000 00:00 0
b758a000-b772e000 r-xp 00000000 08:01 524970 /lib/i386-linux-gnu/libc-2.15.so
b772e000-b7730000 r--p 001a4000 08:01 524970 /lib/i386-linux-gnu/libc-2.15.so
b7730000-b7731000 rw-p 001a6000 08:01 524970 /lib/i386-linux-gnu/libc-2.15.so
b7731000-b7734000 rw-p 00000000 00:00 0
b7744000-b7747000 rw-p 00000000 00:00 0
b7747000-b7748000 r-xp 00000000 00:00 0 [vdso]
b7748000-b7768000 r-xp 00000000 08:01 524935 /lib/i386-linux-gnu/ld-2.15.so
b7768000-b7769000 r--p 0001f000 08:01 524935 /lib/i386-linux-gnu/ld-2.15.so
b7769000-b776a000 rw-p 00020000 08:01 524935 /lib/i386-linux-gnu/ld-2.15.so
bfb5b000-bfb7c000 rw-p 00000000 00:00 0 [stack]
每行数据格式如下:
内存地址开始-结束 访问权限 偏移 主设备号:次设备号 i节点 文件
或者用pmap命令查看
leon@ubuntu:~$ pmap 2874
2874: ./a.out
08048000 4K r-x-- /home/leon/a.out
08049000 4K r---- /home/leon/a.out
0804a000 4K rw--- /home/leon/a.out
b7589000 4K rw--- [ anon ]
b758a000 1680K r-x-- /lib/i386-linux-gnu/libc-2.15.so
b772e000 8K r---- /lib/i386-linux-gnu/libc-2.15.so
b7730000 4K rw--- /lib/i386-linux-gnu/libc-2.15.so
b7731000 12K rw--- [ anon ]
b7744000 12K rw--- [ anon ]
b7747000 4K r-x-- [ anon ]
b7748000 128K r-x-- /lib/i386-linux-gnu/ld-2.15.so
b7768000 4K r---- /lib/i386-linux-gnu/ld-2.15.so
b7769000 4K rw--- /lib/i386-linux-gnu/ld-2.15.so
bfb5b000 132K rw--- [ stack ]
total 2004K
分别表示程序和C库的代码段、数据段、bss段
进程全都地址空间大约2004KB,但只有大概不到200KB的内存区域是可写或私有的。如果一片内存范围是共享的或不可写的,那么内核只需要在内存中为文件保留一份映射,比如C库的代码,只读入一次是安全的。
由于内存未被共享,所以只要一有进程写该处数据,那么该处数据就将被拷贝出来(写时拷贝),然后才被更新。
每个和进程相关的内存区域都对应于一个vm_area_strcut结构体。
内核时常需要在某个内存区域上执行一些操作,这些操作非常频繁,它们也是mmap()例程的基础,为了方便这类对内存区域的操作,内核定义了许多辅助函数声明在
4.1 查找一个给定的内存地址属于哪一个内存区域: find_vma()
点击(此处)折叠或打开
该函数在指定地址空间中搜索的一个vm_end大于addr的内存区域,这样返回的VMA首地址可能大于addr,所以指定的地址并不一定就包含在返回的VMA中。
因为很有可能在执行某个VMA操作后,其他操作还会对该VMA进行操作,所以find_vma()函数返回的结果被缓存在内存描述符的mmap_cache域中,实践证明,被缓存的VMA有相当好的命中率(30~40%),检查被缓存的VMA速度会很快,如果指定的地址不在缓存中,那么必须搜索和内存描述符相关的所有内存区域,这种搜索通过红黑树进行。
4.2 查找第一个和指定地址区间相交的VMA:find_vma_intersection()
点击(此处)折叠或打开
mm:要搜索的地址空间
start_addr:区间的起始地址
end_addr:区间尾地址
如果find_vma()返回NULL,那么find_vma_intersection()返回NULL;
如果find_vma()返回有效VMA,find_vma_intersection()只有在该VMA的起始位置于给定的地址区间结束位置之前,才将其返回,否者返回NULL
内核使用do_mmap()函数创建一个新的线性地址区间。如果这个新的VMA与相邻地址区间具有相同访问权限的话,将合并为一个VMA,如果不能合并,就确实需要创建一个新的VMA了。
无论如何,do_mmap()函数都会将一个地址区间加入到进程的地址空间中,无论是扩展已存在的内存区域还是创建一个新的区域。
在
static inline unsigned long do_mmap(struct file *file, unsigned long addr,
unsigned long len, unsigned long prot,
unsigned long flag, unsigned long offset)
该函数映射由file指定的文件,具体映射从文件偏移ofset开始,长度为len字节。如果file参数是NULL并且offset是0,那么代表这次映射没有和文件相关,这叫做匿名映射(anonymous mapping)。否则叫文件映射(file-backed mapping)。
addr是可选参数,它指定搜索空闲区域的起始位置。
prot参数指定内存区域中页面的访问权限。
flag参数指定VMA标志,这些标志指定类型并改变映射的行为
如果系统调用do_mmap()的参数中有无效参数,它返回一个负值;否者,就会在虚拟内存中分配额一个合适的新内存区域(有可能从slab中获取)。
在用户空间通过调用mmap()系统调用获取内核do_mmap()的功能。
void *mmap2(void *addr,
size_t length,
int prot,
int flags,
int fd,
off_t pgoffset)
该系统调用是mmap()调用的第二种变种,所以起名为mmap2(),原始的mmap()方法的调用最后一个参数是字节偏移量,而mmap2()使用页面偏移量;mmap()调用由POSIX定义,C库中任然作为mmap()方法使用,但新内核中已经没有对应实现了,mmap()方法的调用是通过将字节偏移转化为页面偏移,从而转化为对mmap2()函数的调用来实现的。
do_mummap()从特定的进程地址空间中删除指定地址区间,定义在
int do_munmap(struct mm_struct *mm, unsigned long start, size_t len)
从mm指定的用户空间,删除从地址start开始,长度为len字节的地址区间。
Itn munmap(void *start, size_t length)
该系统调用定义在文件mm/mmap.c中,它是对do_mummap()函数的一个简单封装:
点击(此处)折叠或打开
虽然应用程序操作的对象是对应虚拟内存,但处理器直接操作的却是物理内存,当应用程序访问一个虚拟地址时,首先必须将虚拟地址转化成物理地址,然后处理器才能解析地址访问请求。
Linux使用三级页表管理完成地址转换,可按需求在编译简化使用两级,用三级是利用“最大公约数”的思想--- 一种设计简单的体系结构。
每个进程都有自己的页表(线程会共享页表),内存描述符的pgd域指向的就是进程的页全局目录,注意,操作和检索页表时必须使用page_table_lock锁,该锁在相应进程的内存描述符中,防止竞争条件。
页表对应的结构体依赖于具体的体系结构,定义在
由于几乎每次对虚拟内存的页面访问都必须先解析它,从而得到物理地址,所以页表操作的性能非常关键。但不幸的是搜索内存中的物理地址速度很有限,为了加快搜索,多数体系结构实现了一个翻译后缓存器(translate lookaside buffer, TLB)。TLB缓存虚拟地址到物理地址的映射,如果访问的虚拟地址在缓存中命中,物理地址立刻返回;否者就需要再通过页表搜索需要的物理地址。