1、进程地址空间
(1)进程地址空间内存布局
进程地址空间:进程地址空间由进程可寻址的虚拟内存组成,内核允许进程使用这种虚拟内存中的地址。每个进程都有一个32位或64位平坦地址空间。内存地址是一个给定的值,要在地址空间范围之内,尽管一个进程可以寻址4GB的虚拟内存(32位),但并不代表它有权访问所有的虚拟地址。可被访问的合法地址空间称为内存区域。
访问权限:进程只能访问有效内存区域内的内存地址,每个内存区域具有相关权限如可读、可写、可执行属性。如果一个进程访问了不在有效范围中的内存区域,或以不正确的方式访问了有效地址,那么内核就会终止该进程,并返回“段错误”信息。
内存区域包含各种内存对象进程内存布局如下:
- 栈:局部变量、const局部常量、函数参数、函数返回地址等;从上往下增长即从高地址往低地址增长;栈区不可以无限增长,它有个最大限制RLIMIT_STACK(一般8MB),当程序使用的栈超过该值时, 发生栈溢出(Stack Overflow),程序收到一个段错误(Segmentation Fault)。
- 内存映射段mmap:将文件的内容直接映射到内存,内存映射是一种方便高效的文件I/O方式,所以它被用来加载动态链接库。创建一个不对应于任何文件的匿名内存映射也是可能的,此方法用于存放程序的数据。所以内存映射分为文件映射(包括动态链接库)和匿名映射。mmap其实和堆一样,也是动态分配内存的。mmap用于映射可执行文件用到的动态链接库以及通过malloc()请求一大块内存,C运行库将创建一个匿名内存映射(该映射没有对应的文件, 可用于存放程序数据),而不使用堆内存。
- 堆:动态分配的内存;malloc申请的最大内存理论值在2.9GB左右;从下往上增长及从低地址往高地址增长。堆管理器通过链表管理每个申请的内存,由于堆申请和释放是无序的,最终会产生内存碎片。堆内存一般由应用程序分配释放,回收的内存可供重新使用。若程序员不释放,程序结束时操作系统可能会自动回收。堆的末端由break指针标识,当堆管理器需要更多内存时,可通过系统调用brk()和sbrk()来移动break指针以扩张堆,以满足请求所需的内存块,一般由系统自动调用。使用堆时经常出现两种问题:1) 释放或改写仍在使用的内存(“内存破坏”);2)未释放不再使用的内存(“内存泄漏”)。
- BSS段(Block Started by Symbol,以符号开始的块):未初始化/初始化为0的静态变量/全局变量;可读可写;BSS段在目标文件中并不占据空间,这样可减少目标文件体积。但程序运行时需为变量分配内存空间,故目标文件必须记录所有未初始化的静态分配变量大小总和(通过start_bss和end_bss地址写入机器代码)。当加载器(loader)加载程序时,将为BSS段分配的内存初始化为0。在嵌入式软件中,进入main()函数之前BSS段被C运行时系统映射到初始化为全零的内存(效率较高)。当程序读取BSS段的数据时,内核会将其转到一个全零页面,不会发生缺页故障,也不会为其分配相应的物理内存。
- 数据段:初始化为非0的静态变量/全局变量;可读可写;当程序读取数据段的数据时,系统会触发缺页故障,从而分配相应的物理内存;
- 代码段:可执行代码、常量(字符串常量;const全局常量;enum常量;#define常量等);只读可执行;
- 保留区:它并不是一个单一的内存区域,而是对地址空间中受到操作系统保护而禁止用户进程访问的地址区域的总称。Linux中用户进程可执行程序一般从虚拟地址空间0x08048000开始加载。0x08048000以下的地址空间通常由C动态链接库、动态加载器ld.so和内核VDSO(内核提供的虚拟共享库)等占用。通过使用mmap系统调用,可访问0x08048000以下的地址空间。
- Random stack offset、Random mmap offset和Random brk offset等随机值意在防止恶意程序,设置全局变量randomize_va_space 值为1。Linux通过对栈、内存映射段、堆的起始地址加上随机偏移量来打乱布局,以免恶意程序通过计算访问栈、库函数等地址。
- 在将应用程序加载到内存空间执行时,操作系统负责代码段、数据段和BSS段的加载,并在内存中为这些段分配空间。栈也由操作系统分配和管理;堆由程序员自己管理,即显式地申请和释放空间。BSS段、数据段和代码段是程序编译时的分段,运行时还需要分段栈和堆。
(2)若干细节问题
Linux中所有的应用程序都是这个布局,每个应用程序都是从0x80480000这个地址开始的,这样冲突吗?不冲突,因为这个地址是一个虚拟地址,Linux中每个应用程序都有自己的虚拟地址空间,而这些虚拟地址映射到物理内存的不同内存段,每个进程都有自己的页表进行虚拟内存空间到物理内存空间的映射。
BSS段和数据段的区别:就拿全局变量来比较:bss段的全局变量只占运行时的内存空间,而不占文件空间;而data段的全局变量既占文件空间,又占用运行时的内存空间。BSS段不占据ELF目标文件的空间,即不占据实际的磁盘空间,只在段表(Section Header Table)中记录起始地址和大小,在符号表(.symtab段)中记录变量符号,仅当ELF目标文件加载运行时,才分配空间以及初始化。
普通局部变量和static局部变量的区别:第一存储方式不同,普通局部变量保存在栈或堆上,static局部变量保存在数据段或BSS段。第二生命周期不同,正是由于存储方式的不同,使得static局部变量在程序的整个生命周期中存在,也就是说:普通局部变量每次进入函数都要初始化一次,static局部变量只被初始化一次,即第一次进入函数时初始化,以后每次进入函数都依据上一次的结果值。但是,虽然static局部变量在函数调用结束后仍然存在,但其他函数是不能直接引用它的。
普通全局变量和static全局变量的区别:第一存储方式相同,都在数据段或BSS段。第二作用域不同,static全局变量的作用域是仅本文件有效。当一个源程序由多个源文件组成时,普通全局变量在各个源文件中都是有效的,而static全局变量则限制了其作用域, 即只在定义该static全局变量的源文件内有效, 在同一源程序的其它源文件中不能使用它。
(3)栈和堆的区别
- ①管理方式:栈由编译器自动管理;堆由程序员控制,使用方便,但易产生内存泄露。
- ②生长方向:栈向低地址扩展(即”向下生长”),是连续的内存区域;堆向高地址扩展(即”向上生长”),是不连续的内存区域。这是由于系统用链表来存储空闲内存地址,自然不连续,而链表从低地址向高地址遍历。
- ③空间大小:栈顶地址和栈的最大容量由系统预先规定(默认8MB);堆的大小则受限于计算机系统中有效的虚拟内存(理论可达2.9G空间)。
- ④存储内容:栈在函数调用时,首先压入主调函数中下条指令的地址,然后是函数实参,然后是被调函数的局部变量。本次调用结束后,局部变量先出栈,然后是参数,最后栈顶指针指向最开始存的指令地址,程序由该点继续运行下条可执行语句。堆通常在头部用一个字节存放其大小,堆用于存储生存期与函数调用无关的数据,具体内容由程序员安排。
- ⑤分配方式:栈可静态分配或动态分配。静态分配由编译器完成,如局部变量的分配。动态分配由alloca函数在栈上申请空间,用完后自动释放。堆只能动态分配且手工释放。
- ⑥分配效率:栈由计算机底层提供支持:分配专门的寄存器存放栈地址,压栈出栈由专门的指令执行,因此效率较高。堆由函数库提供,机制复杂,效率比栈低得多。
- ⑦碎片问题:栈不会存在碎片问题,因为栈是先进后出的队列,内存块弹出栈之前,在其上面的后进的栈内容已弹出。而堆频繁申请释放操作会造成堆内存空间的不连续,从而造成大量碎片,使程序效率降低。
(4)C程序内存空间分配实例
#include <stdio.h>
#include <stdlib.h>
int A; //全局未初始化变量
int B = 0; //全局初始化为0的变量
int C = 2; //全局初始化变量
int static D; //全局静态未初始化变量
int static E = 0; //全局静态初始化为0的变量
int static F = 4; //全局静态初始化变量
int const G = 5; //全局常量
int main()
{
int a; //局部未初始化变量
int b = 0; //局部初始化为0的变量
int c = 2; //局部初始化变量
int static d; //局部静态未初始化变量
int static e = 0; //局部静态初始化为0的变量
int static f = 4; //局部静态初始化变量
int const g = 5; //局部常量
char h1[] = "abcde"; //局部字符数组
char* h2 = "abcde"; //局部指针指向字符串常量
int* i = malloc(sizeof(int)); //堆
return 0;
}
- 不同的局部变量保存在栈,动态申请的局部变量保存在堆
- 初始化为非0的全局变量&静态全局变量&静态局部变量保存在.data段,未初始化及初始化为0的全局变量&静态全局变量&静态局部变量保存在.bss段
- 全局常量保存在代码段,而局部常量保存在栈
- 字符串常量保存在代码段,但字符串指针保存在栈,字符数组也保存在栈
2、内存描述符mm_struct
(1)内存描述符
内核使用内存描述符结构体mm_struct表示进程的地址空间,该结构包含了和进程地址空间有关的全部信息。进程描述符task_struct中的mm域指向该结构。
- mmap和mm_rb:该地址空间的全部内存区域,也就是VMA链表,mmap是链表适合遍历,mm_rb是红黑树适合查找。mmap链表中每一项都是虚拟内存区域描述符vm_area_struct结构。内存描述符中的mmap和mm_rb之一访问内存区域。它们包含完全相同的vm_area_struct结构体的指针,仅仅组织方法不同。内核为了内存区域上的各种不同操作都能获得高性能,同时使用了这两种数据结构。
- mm_user:记录正在使用该地址的线程数目
- mm_count:记录正在使用该地址的进程数目
- mmlist:所有的mm_struct结构体都通过自身的mmlist域链接在一个双向链表中,该链表的首元素是init_mm内存描述符,操作该链表时,需要使用mmlist_lock锁来防止并发访问。
- start_code和end_code:代码段首尾地址
- start_data和end_data:数据段首尾地址
- start_brk和brk:堆首尾地址
- arg_start和arg_end:命令行参数首尾地址
- env_start和env_end:环境变量首尾地址
(2)内存描述符分配和撤销
fork()函数利用copy_mm()函数复制父进程的内存描述符。mm_struct结构体,实际是通过allocate_mm()宏从mm_cachep_slab缓存中分配得到的。通常每个进程唯一。如果父进程希望和子进程共享地址空间,在调用clone时设置CLONE_VM标志共享地址空间,就会生成线程,copy_mm()将mm域指向其父进程的内存描述符。线程对于内核来说仅仅是一个共享特定资源的进程而已。
进程退出时,内核会调用exit_mm()函数,执行内存的销毁,同时更新一些统计量。函数调用mmput()
函数减少内存描述中符的mm_users用户计数,如果计数为0,调用mmdrop()
减少mm_count使用计数,使用计数也为0,则调用free_mm()
宏通过kmem_cache_free()将mm_struct结构体归还到mm_cachep_slab中。
(3)mm_struct与内核线程
内核线程没有进程地址空间,也没有相关的内存描述符,所以内核线程对于的进程描述符中mm域为空。内核线程没有用户上下文。内核线程被调度时,内核发现mm域为NULL,就会保留前一个进程的内存描述符。所以在需要时,内核线程可以使用前一个进程的页表。因为内核线程不访问用户空间的内存,所以它们仅仅使用地址空间中和内核内存有关的信息。
3、虚拟内存区域描述符vm_area_struct
(1)虚拟内存区域描述符
虚拟内存区域描述符描述了指定地址空间内连续区间上的一个独立内存范围。内核将每个内存区域作为一个单独的内存对象管理。每一个VMA就可以代表不同类型的内存区域(比如内存映射文件或者进程用户空间栈或者数据段、代码段)。虚拟内存区域由vm_area_struct结构体描述。
- task_struct 进程描述符,里面保存了许多关于进程控制的信息。
- mm_struct 内存描述符,包含了和进程地址空间有关的全部信息。
- vm_area_struct 虚拟内存描述符,描述了指定地址空间内连续区间上的一个独立内存范围,将每个内存区域作为一个单独的内存对象管理。
(2)VMA标志vm_flags
VMA标志是一种位标志,包含在unsigned long vm_flags域内,标志了内存区域所包含的页面的行为和信息,和物理页的访问权限不同,VMA标志反映了内核处理页面所需要遵守的行为准则,而不是硬件要求。
- VM_READ、VM_WRITE、VM_EXEC:标志了内存区域中页面的读、写、执行权限
- VM_SHARD:内存区域包含的映射是否可以在多进程间共享
- VM_IO:内存区域中包含对设备I/O空间的映射
- VM_SEQ_READ:标志暗示内核应用程序对映射内容执行有序的(线性和连续的)读操作,这样内核可以有选择的执行预读文件。
- VM_RAND_READ:与其刚好相反,映射内容执行随机的读操作,内核减少或者取消文件预读。
(3)VMA操作
vm_operations_struct 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 *,struct vm_fault*);
/* 页面为只读是,该函数被页面故障处理调用 */
*int page_mkwrite(struct vm_area_struct *area,struct vm_fault *vmf);
/* get_user_pages()函数调用失败时,该函数被access_process_vm()调用 */
*int access(struct vm_area_struct *vma,unsigned long address,void *buf,int len,int write)
};
(4)实际使用的内存区域
可以使用/proc文件系统和pmap
工具查看给定的进程内存空间和其中所含的内存区域。 如使用cat /proc/24027/maps
查看htop的全部内存域。
共享不可写内存技术:如果一片内存范围是共享的或不可写的,内核只需要在内存中为文件保留一份映射,如C库在物理内存仅需占用1212KB空间,而不需要为每个使用C库的进程在内存中都保存一个1212KB的空间。利用共享不可写内存的方法节约了大量的内存空间。
4、操作内存区域
- find_vma:为了找到一个给定的内存地址属于哪一个内存区域
- find_vma_prev:返回第一个小于addr的VMA
- find_vma_intersection:返回第一个和指定地址区间相交的VMA
5、创建和删除地址区间VMA
(1)创建地址区间
用户空间可以通过mmap()系统调用获得内核函数do_mmap()的功能,将文件的内容直接映射到内存,即将内存地址空间映射到指定的文件file,分为文件映射或者匿名映射。do_mmap()会将一个新的地址区间加入到进程的地址空间。内核使用do_mmap()函数创建一个新的线性地址区间。如果创建的地址区间和一个已经存在的相邻,并且具有相同的访问权限,两个区间将合并为一个。如果不能合并就创建一个新的VMA了。
unsigned long do_mmap(
struct file *file, /* 指定映射源文件 */
unsigned long addr, /* 可选,指定搜索空闲区域的起始位置 */
unsigned long len, /* 长度为len字节的范围内数据 */
unsigned long port, /* 指定内存区域中页面的访问权限 */
unsigned long flag, /* 指定VMA标志,这些标志指定类型并改变映射的行为 */
unsigned long offset /* 映射的文件偏移 */
);
(2)删除地址区间
do_mummap()函数从特定的进程地址空间中删除指定地址区间。
int do_mummap(struct mm_struct *m,unsigned long start,size_t len);
int munmap(void *start,size_t length);
6、页表
每一个进程都会有一个进程描述符task_struct,每个进程描述符都有一个mm指针指向每个进程的内存描述符,每个内存描述符都会有单独的页表,页表将虚拟地址空间映射到物理地址空间。程序访问一个虚拟地址时,要将虚拟地址转化为物理地址,处理器才能解析地址访问请求。地址的转换工作需要通过查询页表才能完成。32位操作系统中使用三级页表进行地址转换,转换过程如下:
多数体系结构,实现了一个翻译后缓冲器(TLB,块表)。TLB作为一个将虚拟地址映射到物理地址的硬件缓存。Linux中使用写时拷贝的方式共享页表。
7、页高速缓存
(1)页高速缓存的好处
页高速缓存:Linux内核实现磁盘缓存,将磁盘中的数据缓存到物理内存中,把对磁盘的访问变为对物理内存的访问。可以减少对磁盘的I/O操作。
页高速缓存的好处:访问磁盘的速度要远低于访问内存的速度。因此从内存访问数据比从磁盘访问更快。数据一旦被访问,就很有可能在短期内再次被访问到。
页高速缓存是由内存中的物理页面组成的,其内容对应磁盘上的物理块。当内核开始读操作,会首先检查需要的数据是否在页高速缓存中。
(2)写缓存手段
写缓存手段:
- 不缓存:高速缓存不去缓存任何写操作。当对缓存中的数据片进行写时,将直接跳过缓存,写到磁盘,同时也使缓存中的数据失效。这种策略效率较低
- 写透缓存:写操作会更新缓存,同时更新磁盘文件。写操作会立刻穿透缓存到磁盘中。这种策略对保持缓存一致性很有好处。
- 回写(Linux采用):程序直接写到缓存中,后端存储不会立刻直接更新,而是将页高速缓存中被写入的页面标记成“脏”,并加入脏页链表中。然后由一个回写进程周期将脏页链表中的页写回到磁盘,保持磁盘和内存数据一致,并清理“脏”页标志。
(3)缓存回收策略
缓存回收原因:缓存算法最后涉及的重要内容是缓存中的数据如何清除,如为更重要的缓存项腾出位置,或者是收缩缓存大小,腾出内存给其他地方使用。
缓存回收做法:Linux缓存回收是通过选择干净页进行简单替换,如果缓存中没有足够的干净页面,内核将强制地进行回写操作,回收脏页,以腾出更多的干净可用页。
缓存回收策略:
- 理想回收策略:回收以后最不可能使用的页面。但这种策略太理想,无法真正实现。
- 最近最少使用(LRU):LRU回收策略跟踪每个页面的访问踪迹,以便能回收最老时间戳的页面。优点:该策略的良好效果源自于缓存的数据越久未被访问,则月不大可能近期再被访问,而最近访问的最有可能被再次访问。缺点:许多文件被访问一次,再不被访问的情景下,LRU效果很差,将这些页面放在LRU链的顶端显然不是最优。
- 双链策略(LRU/2):Linux实现的是一个修改过的LRU,也称为双链策略。Linux维护的不再是一个LRU链表,而是维护两个链表:活跃链表和非活跃链表。活跃链表上的页面不会被换出,而非活跃链表上的页面则是可以被换出的。两个链表需要保持平衡,如果活跃链表超过了非活跃链表,那么活跃链表的头页面将会重新移回到非活跃链表中,以便能再次回收,就可以解决传统LRU算法中仅访问一次的问题。
(4)缓存应用场景
应用场景:
- 打开大量源文件时,文件存储在页高速缓存中,从一个文件跳到另一个文件将瞬间完成。
- 编辑文件时,存储文件也会瞬间完成,因为写操作只需要写到内存,而不是磁盘。
- 编译项目时,缓存的文件将使得编译过程更少访问磁盘,所以编译的速度就加快了。
8、Linux 页高速缓存
(1)address_space
页高速缓存可能包含了多个不连续的物理磁盘块。它可以通过扩展inode结构体支持I/O操作。为了更好的性能,其使用address_space结构体。其与vm_are_struct的物理地址对等。文件只能有一个address_sapce可以有多个vm_area_struct即虚拟地址可以有多个,但是物理内存只能有一份。
struct address_space {
struct inode *host; /* 拥有节点 */
struct radix_tree_root page_tree; /* 包含全部页面的radix树 */
spinlock_t tree_lock; /* 保护页面的自旋锁 */
unsigned long nrpages; /* 页面总数 */
pgoff_t writeback_index;/* writeback starts here */
struct address_space_operations *a_ops; /* methods */
struct list_head i_mmap; /* 私有映射连败哦 */
struct list_head i_mmap_shared; /* 共享map链表 */
struct semaphore i_shared_sem; /* 保护所有的链表 */
atomic_t truncate_count; /* 截断计数 */
unsigned long flags; /* gfp_mask掩码和错误标志 */
struct backing_dev_info *backing_dev_info; /* 预读信息等 */
spinlock_t private_lock; /* 私有address_space锁 */
struct list_head private_list; /* address_space链表 */
struct address_space *assoc_mapping; /* 相关缓冲 */
};
struct address_space_operations {
int (*writepage)(struct page *page, struct writeback_control *wbc);
int (*readpage)(struct file *, struct page *);
int (*sync_page)(struct page *);
/* Write back some dirty pages from this mapping. */
int (*writepages)(struct address_space *, struct writeback_control *);
/* Set a page dirty */
int (*set_page_dirty)(struct page *page);
int (*readpages)(struct file *filp, struct address_space *mapping,
struct list_head *pages, unsigned nr_pages);
/* * ext3 requires that a successful prepare_write() call be followed * by a commit_write() call - they must be balanced */
int (*prepare_write)(struct file *, struct page *, unsigned, unsigned);
int (*commit_write)(struct file *, struct page *, unsigned, unsigned);
/* Unfortunately this kludge is needed for FIBMAP. Don't use it */
sector_t (*bmap)(struct address_space *, sector_t);
int (*invalidatepage) (struct page *, unsigned long);
int (*releasepage) (struct page *, int);
ssize_t (*direct_IO)(int, struct kiocb *, const struct iovec *iov,
loff_t offset, unsigned long nr_segs);
};
(2)基树
每个address_space对象都有一个唯一的基树(radix tree)来进行文件偏移量的搜索。页面高速缓存的搜索函数find_get_page()要调用函数radix_tree_lookup(),该函数会指定基树中搜索指定页面。2.6之前,系统通过维护一个全局散列表进行检索。存在种种缺点:如锁的争用情况严重,搜索范围大等。所以之后用基于基树的页高速缓存。
9、缓冲区高速缓存
对于linux来说,2.4之前的内核中有两种缓存,一种是vfs的页高速缓存,另外一种是缓冲区高速缓存,实际上缓冲区对应于磁盘块,就是磁盘块在内存中的表示罢了,其中的数据也还是文件中的数据,只不过页高速缓存是按照页面管理的,而缓冲区高速缓存是按照块来管理的,两个缓存在数据本身上有一定的重合,这就造成了冗余,在内核中是很不好的,比如123456是页高速缓存的数据,到了缓冲区高速缓存,其实还是123456,只不过不再这么排列了,而是分成了一块一块的,比如1,2,3,…如果能将这些1,2,3指向页高速缓存,那么就可以不再需要为缓冲区高速缓存分配大量的内存空间了。2.4往后的内核就是这么做的,到了2.6内核,有了bio,那么buffer_head再也不用作为IO容器了。现在只有一个磁盘缓存,即页高速缓存。
10、flusher线程
以下三种情况发生时,脏页被写回磁盘:
- 当空闲内存低于一个特定的阀值时
- 脏页在内存中驻留时间超过一个特定的阀值时;flusher线程后台例程会被周期性唤醒来执行这个操作。
- 用户进程调用sync()和fsync()系统调用时;内核会按照要求执行回写动作。
内核中由一群内核线程(flusher线程)执行这三种工作。当空闲内存比阀值(dirty_background_ratio)还低时,内核便会调用flusher_thread()唤醒一个或者多个flusher线程,随后flusher线程进一步调用函数bdi_writeback_all()开始将脏页写回磁盘。
膝上型计算机模式:它是一种特殊的页回写策略:将硬盘的转动的机械行为最小化,允许硬盘尽可能长时间的停滞,以此延长电池供电。可以通过/proc/sys/vm/laptop_mode文件进行配置。向其中写入1开启。其会找准磁盘运转的实际,把所有其它的物理磁盘I/O、刷新脏页缓冲等统统写回到磁盘。来减少写磁盘要求的主动运行。
避免拥塞的方法:使用多线程,页回写时可能存在拥塞。使用多个回写线程并发执行,避免一个bdflush堵塞在某个队列的处理(正在等待将请求提交给磁盘的I/O请求队列)上。这也是使用多个flusher的原因。不同的flusher线程处理不同的设备队列。pflush线程采取了拥塞回避策略:主动尝试从那些没有拥塞的对了回写页。