32 位的平台上,线性地址空间为固定的 4GB,并且由于采用了保护机制,Linux内核将这 4GB 分为两部分,线性地址较高的 1GB(0xC0000000 到 0xFFFFFFFF )为共享的内核空间;而较低的 3GB 为每个进程的用户空间。由于每个进程都不能直接访问内核空间,而是通过系统调用间接进入内核,因此所有的进程都共享内核空间。而每个进程都拥有各自的用户空间,各个进程之间不能互相访问彼此的用户空间。
一个进程的用户地址空间主要由两个数据结构来描述。一个是 mm_struct 结构,它对进程的整个用户空间进行描述,简称内存描述符;另一个是 vm_area_struct 结构,它对用户空间中各个区间( 代码区、数据区等 )进行描述。
进程用户空间的描述
内存描述符
每个进程只有一个 mm_struct
结构,在每个进程的 task_struct
结构中,有一个指向该结构的指针。
struct mm_struct {
struct vm_area_struct *mmap; /* list of VMAs */
struct rb_root mm_rb;
pgd_t * pgd; //页目录基址
atomic_t mm_users; //记录正在使用该地址空间的进程数目
atomic_t mm_count; //记录mm_struct 结构体被引用的次数。
//如果当前地址空间只被两个进程共享,那么该值为1,mm_users为2
int map_count; //虚拟内存区的个数
spinlock_t page_table_lock; /* Protects page tables and some counters */
struct rw_semaphore mmap_sem;
struct list_head mmlist; /* List of maybe swapped mm's. These are globally strung
* together off init_mm.mmlist, and are protected
* by mmlist_lock
*/
unsigned long start_code, end_code, start_data, end_data;
unsigned long start_brk, brk, start_stack;
unsigned long arg_start, arg_end, env_start, env_end;
……
};
- 一个进程的虚拟空间中可能有多个虚拟区间,对这些虚拟区间的组织形式有两种,
- mmap 单链表,利于高效的遍历所有元素
- mm_rb 红黑树 适合搜索指定元素,快速定位
- mmap_cache 指向虚拟区间, 根据局部性原理,最近一次用到的虚拟区间很可能下一次还要用到,因此把最近用到的虚区间放到高速缓存
- pgd 指向该进程的页目录,当调度程序调度一个程序运行时就将这个地址转换成物理地址并写入CR3
- page_table_lock 和 mmap_sem 提供互斥操作
- 还有代码段、数据段等的起始地址和结束地址。
虚拟内存区域( VMA )
虚拟内存区域由 vm_area_struct
结构体描述,每一块虚拟内存区都是由连续的虚拟地址组成。每个 vm_area_struct
代表了不同的内存区域。
struct vm_area_struct {
struct mm_struct * vm_mm; //内存描述符
unsigned long vm_start; //区域的首地址
unsigned long vm_end; //区域的尾地址
struct vm_area_struct * vm_next,*prev; //VMA双链表
struct rb_node_ vm_rb; //VMA的红黑树结构
pgrot t_vm_page_prot; //访问控制权限
unsigned long vm_flags; //保护标志位和属性标志位
...
struct vm_operations_struct * vm_ops; //虚拟区的操作函数
struct file * vm_file; //这块内存是由哪个文件映射的,如果没有则这块内存是匿名的
void * vm_private_data; //设备驱动私有数据,与内存管理无关。
};
为什么要划分出区间?
因为每个虚存区可能来源不同,有的可能来自于可执行文件,有的可能来自共享库,有的可能是动态分配的内存区,对不同的虚存区可能有不同的访问权限,也可能有不同的操作。因此Linux将进程的用户空间分割管理,并利用虚存区处理函数( vm_ops )来抽象对不同来源虚存区的处理方法。面向对象的思想,相当于class了一个对象表示虚存区,有属性,有操作。
struct vm_operations_struct {
void (*open) (struct vm_area_struct * area);
void (*close) (struct vm_area_struct * area);
struct page * (*nopage)(struct vm_area_struct *area, unsigned long address, int write_access);
...
}
vm_ops 结构中包含的是函数指针,nopage()是当虚存页面不在物理内存而引起的缺页异常时所应该调用的函数。
相关数据结构之间的关系
查看进程内存空间
cat /proc/<pid>/maps
就是遍历 struct vm_area_struct *mmap 单链表。
一个程序编译链接之后形成的地址空间是一个虚拟地址空间,但是程序程序最终还是要运行在物理内存中,所以应用程序访问一个虚拟地址时,需要将虚拟地址转换为物理地址,然后处理器才能解析地址访问请求,这个转换工作通过查询页表来完成。每个进程的内存描述符保存了这个进程页表指针 pgd,每一块虚拟内存页都和页表的某一项对应。
一个虚拟内存 vm_area_struct 块是由连续的虚拟内存页组成,但是这些虚拟内存页映射的物理内存却不一定是连续的,如下图:
有三个页映射到物理内存,还有两个页没有映射,所以常驻内存 RSS 为 12KB,虚拟内存区大小为 20KB。对于有映射到物理内存的三个页页表项 PTE 的 Present 标志设为 1,而没有映射的两个虚拟内存页表项的 Present 位清除,所以访问那两块内存时会导致缺页异常。
当应用程序申请内存或者文件映射时,内核先响应这个请求,分配或更新虚拟内存;但是这些虚拟内存并没有映射到真正的物理内存,即内核总是尽量延后分配物理内存,用户进程总是先获得一个虚拟内存区的使用权,只有等到访问这块内存时,通过缺页异常获得真正的物理内存。它会告诉内核真正的为进程分配物理页,并建立对应的页表。
进程用户空间的创建
在调用 fork() 系统调用创建一个新进程时就为这个进程创建了一个完整的用户空间。
简单的说在创建的过程中所做的工作是 mm_struct 结构的建立、vm_area_struct 结构的建立以及页目录和页表的建立。并没有真正地复制一个物理页面,这也是为什么 Linux 内核能迅速创建进程的原因之一。
虚存映射
Linux并不将可执行映像装入到物理内存,相反,可执行文件只是被装载到进程的用户空间中。当调用 exec() 系统调用开始执行一个进程时,进程的可执行映像被装入到进程的用户地址空间,如果还用到任何一个共享库,那么共享库页必须装入到进程的用户空间。随着进程的运行,被引用的程序部分会由操作系统装入到物理内存,这种将映像映射到进程用户空间的方法被称为“虚存映射”,也就是把文件从磁盘映射到进程的用户空间,这样把对文件的访问转化为对虚存区的访问。
有两种类型的虚存映射:
- 共享的:有几个进程共享这一映射,如果一个进程对共享的虚存区进行写,其他进程都能感觉到,而且会修改磁盘上对应的文件。
- 私有的:进程创建的这种映射只是为了读文件,对虚存区的写操作不会修改磁盘上的文件。
如果映射与文件无关,就叫匿名映射,比如堆和栈。
当可执行映像映射到进程的用户空间时,将产生一组 vm_area_struct 结构来描述各个虚拟区间的起始点和终止点,每个 vm_area_struct 结构代表可执行映像的一部分。
一个进程基本上可以分为如下几种 VMA 区域:
- 代码 VMA,权限只读、可执行;有映像文件
- 数据 VMA,权限可读写、可执行;有映像文件
- 堆 VMA,权限可读写、可执行;无映像文件,匿名,可向上扩展
- 栈 VMA,权限可读写、不可执行;无映像文件,匿名,可向下扩展
这一部分可结合可执行文件的装载和动态链接的相关内容来学习。一个常见进程的虚拟地址空间基本是下面这样的: