虚拟内存布局
ARM32体系中Linux将4G的虚拟地址空间划分成两部分,03G是用户空间、34G是内核空间。每个用户进程都拥有不同的3G用户空间,他们可以映射到不同的物理内存可以映射到相同的物理内存,不同场景会有所不同,比如完全不相关的两个进程前3G映射的物理地址是完全不同的,相关的父子进程可能会共用部分物理内存;后1G的内核地址空间所有进程都共用相同的映射关系。
对于内核来说不仅物理内存是系统重要的资源,内核空间的虚拟地址也同样是重要的资源,所以在内核中动态申请物理页面和动态申请虚拟地址空间处可见,尤其是高端内存。低端内存再内核中是线性映射。
CPU中由一个页面目录基地址寄存器,ARM中简写为TTBRx,这个寄存器存放当前进程页表目录所在物理内存的起使地址(物理地址),进程运行时访问的地址全是线性地址,CPU通过TTBRx寄存器找到页面目录后依据线性地址逐层映射可以获得对应的物理地址从而访问物理内存。在切换进程时由一个关键操作就是根据task_struct->mm_struct->pgd内容更新到TTBRx寄存器以切换页面映射,这样每个进程在使用相同线性地址的情况下可以访问不同的物理内存。这个特性非常重要否则不同应用程序在链接时还要考虑其它应用程序占用了哪些内存以避免共用内存而冲突,这实际上也是做不到的,因为应用程序来自不同开发者根本无法协调。
内核空间从PAGE_OFFSET开始,被划分为低端内存和高端内存。低端内存包括swapper_pg_dir、内核镜像及动态分配区域。高端内存包括vmalloc区域、临时内存区域。swapper_pg_dir放置的是内核页面目录地址从0xc0004000~0xc0007FFF赋值给init_mm.pgd,内核镜像分别是代码段初始化数据段、未初始化数据段、BSS段,这些都是内核在引导成功后,自解压程序从镜像文件解压到内存中,剩余的低端内存由系统通过页面分配器动态使用。vmalloc区域是vmalloc调用时使用的地址空间,它可以使用系统所有可用的物理内存并将不连续的物理内存映射为连续的虚拟内存;fixmap临时映射区域由系统模块按需临时申请使用,使用后要及时释放;vector存放中断向量表。ARM32内存布局和x86的内存布局是由区别的,x86中PAGE_OFFSET开始前16M是预留的,内核镜像从PAGE_OFFSET+16M开始,还有不存在vector段,多了一个pkmap段。
- pkmap:在系统调用kmap(struct page)如果发现page是高端内存页面,就从pkmap虚拟内存区域申请一段地址来映射这个页面;
- fixmap:临时地址映射,可以调用set_fixmap(fixmap_addr, phy_addr)设置映射关系,这个区域的映射一般都是临时的,使用过后要及时接触映射;
- vmalloc:调用vmalloc函数是使用该区域进行映射,可以将不连续的内存区域映射到连续的虚拟内存区域;
内核空间中的所有映射都是在swapper_pg_dir页表目录中完成。内核自己的数据结构必须放在低端内存中,高端内存经常用来给进程或者一些内核的task使用,例如IO操作中使用的buffer,内核自身使用的必须是位于低端内存。
内核时不存在专门C程序运行栈的,运行时使用的是进程内核栈,因此图中没有画出专门的内核栈。进程堆栈由两部分组成用户空间栈和内核空间栈。在进程创建时申请task_struct内存,这时会申请两个连续的页面一部分存放task_struct一部分用来作为进程的内核空间堆栈,内核运行时都是借用当前活动进程的内核堆栈。
用户空间0~PAGE_OFFSET-1,内存布局如图。用户空间内存主要分为代码区、堆、mmap映射区、栈、模块区、命令行区几个部分。代码区是进程建立时通过可执行文件构建的,同时建立虚拟内存到物理内存的映射,这个建立过程不是一次完成的而是载运行过程中逐步构建起来的,堆区主要时应用程序调用malloc库函数进而调用brk系统调用逐步增长的,方向是从低地址到高地址,用户空间栈是程序运行过程中通过缺页中断逐步向内核申请物理页面建立的。在堆和栈之间还有一个mmap区域,这个是留给mamp系统调用和动态库使用的。堆、mmap映射区、栈三个区域都会随着程序的运行时动态变化的存在冲突的可能性。
- 代码区,系统调用fork、execve执行后载入适当的可执行文件装配这个区域,如果不调用execve则这个区域和父进程相同,elf文件有对应的区域。
- 堆,应用程序代用malloc库函数进而调用brk系统调用逐渐建立,malloc申请的内存大小和系统调用brk并不一致,brk是按页分配内存形成一个内存池,库函数在这个内存池的基础上二次分配;
- 栈,应用程序函数调用及局部变量使用这个区域,当栈空间不够时会发生缺页中断分配新的内存页扩大栈空间
- memory map segment:mmap、动态库加载用这个区域
进程所有的内存区域在内核中都由vm_area_struct链表和红黑树组织管理,通过task_struct可以索引到。
进程代码段大小时固定的,链接生成可执行文件后就确定了。栈的大小并不固定,程序在运行时Linux内核会根据入栈情况对栈区进行动态增长(其实也就是添加新的页表)。但是并不是说栈区可以无限增长,它也有最大限制RLIMIT_STACK(一般为 8M---在32位模式下的进程内存默认布局(Linux kernel 2.6.7以后)中),在Linux中可以通过ulimit -s命令查看和设置栈的最大值,当程序使用的栈超过该值时, 发生栈溢出(Stack Overflow),程序收到一个段错误(Segmentation Fault)。注意,调高栈容量可能会增加内存开销和启动时间。
内存映射
MMU根据TTBRx寄存器获取页表目录基址,自动逐层将线性地址映射到物理地址,这个过程不需要软件参与硬件自动完成,但是页表目录的维护还是要软件完成。为了加快映射速度MMU的TLBs和Cache会缓存最近常用页表目录项,在缓存中查不到页表信息时才去内存查照对应的页表目录。
ARMv7 处理器的二级页表根据最终页的大小可以
分为如下 4 种情况。
- 超级大段(SuperSection):支持16MB大小的超级大块。
- 段(section):支持1MB大小的段。
- 大页面(Large page):支持64KB大小的大页。
- 页面(page):4KB的页,Linux内核默认使用4KB的页。
如果只需要支持超级大段和段映射,那么只需要一级页表即可。如果要支持 4KB 页面或 64KB 大页映射,那么需要用到二级页表。不同大小的映射,一级或二级页表中的页表项的内容也不一样。以 4KB 页的映射为例。
ARM32地址采用两级映射,虚拟地址被分成3段,2031共12位作为一级页表索引(pgd),1219共8位作为二级页表索引(pte),0~11共12位是物理地址低12位。这样pgd共2^12=4096个表项需要16KB的存储空间,每个表项存有存有二级目录的基地址,pte = TTBRx[(addr&0xFFF00000)>>20]&0xFFFFFC00(bit31~bit10)获取二级页表的pte基地址;二级页表有256个表项占用1KB内存,二级页表每个表项存储的内容是物理地址的高20位,pte[(addr&0x000FF000)>>12]获取二级表项的内容。
物理地址计算公式:
phy_addr=(pte[(addr&0x000FF000)>>12]&0xFFFFF000)|(addr&0xFFF)
上面公式中的addr为虚拟地址。
TTBRx寄存器的内容由linux系统维护,在进程切换时将task_struct->mm_struct->pgd的内容设置到该寄存器。Linux系统的第一个进程init的页表完全由系统手动构建,设置pgd指针。init时所有其它进程的祖先,通过fork系统调用生成其它进程时pgd默认指向父进程的页表目录,直到子进程需要使用不同的物理内存才会申请页面逐渐构建自己的一级页表二级页表。
一级页表的低10位不做地址解析,可以作他用。
二级页表的低12位不做地址解析,可以作他用,这写位定义了页面的权限等信息,不同架构的处理器定义不同。
反向映射
AV是anon_vma,AVC是anon_vma_chain结构,VMA是vm_area_struct,下面的简写不再说明
linux系统中虚拟地址和物理地址是多对一关系,反过来是一对多关系,这是由于共享内存,多个进程可以共用物理页面。这就意味着一个物理内存可能会被多个页表索引,在页面回收、迁移、换出到硬盘的交换分区时必须修改所有页表。为了快速的找到和物理内存相关的页表项内核中采用了以空间换时间的策略,记录和物理内存相关所有页表项的索引信息,通过这些信息从物理地址可以方便的找到映射到这个地址的虚拟地址,这就是反向映射。
在linux2.4内核中这个过程是通过遍历所有进程的页表实现的,显然这个算法是耗时的低效率的,因此2.6内核引入了反向映射机制来解决这个问题。
第一代反向映射是把每个物理页关联的页表项(PTE)保存在page结构里面,为每个页结构(page)维护一个链表页表项链表,这样就可以方便的通过物理地址找到与之相关的所有页表项信息,但此链表所占用的空间及维护此链表的代价很大,因为内核中每个物理页面都有一个page与之对应,page的数量是相当巨大的而且大部分页面并不需要反向映射,为此内核又引入的一种更高校的反向映射机制。
用户空间使用的页有两种基本类型:匿名页和文件页,其中文件映射页和文件系统中某个文件相关联,一般包括进程的代码段、data段、通过mmap映射的文件页、普通打开文件的缓存页、mmap动态库的代码段、data段,这部分页通过adress_space中的VMA间树找到所有引用此页的的页表项,而匿名页包括堆、栈、bss段等占据的页面,这些就必须专门构建直接反向映射机制来完成反向映射。
文件页反向映射
struct page结构中有个mapping字段,当mapping!=0 且 mapping[0] == 0时此字段指向adress_space结构,每个文件映射都有一个adress_space结构与之对应,通过i_mmap可以遍历引用该页的所有VMA(这些VMA可以能属于不同进程),进而找到每个VMA对应的mm_struct->pgd及解除映射。
匿名页反向映射
匿名映射mapping!=0 且 mapping[0] != 0,此时mapping指向一个anon_vma数据结构,anon_vma中有一个vma链表可以访问到页面可能相关的所有vma。匿名映射一般发生在父子进程之间,当父进程fork时,由于COW的存在,子进程会建立vma结构分配新页以和父进程区分使用不同的内存,新分配的子进程的vma也会挂到父进程的,这样新分配的vma结构在反向映射时也会被遍历到。
这种匿名映射存在一个缺点,很多和页面不相关的vma也会被遍历,比如子进程新建立的vma和fork之前父进程已经映射的页面没什么关系,子进程新分配的匿名页也肯定不会是父进程已经占用的页面,这就造成了页面在解除映射时要扫描无用的vma。这种反向映射机制虽然存在缺陷但是管理简单,只要在vma中加入一个list_head链表头就可以链入av中。
通过组织所有的匿名页到同一个父进程的同一个anon_vma结构中,在需要知道反向映射一个页的信息时需要每次都遍历这个链表,这样就会有一个问题,持有同样一个锁取扫描大量的VMA,特别是有些物理页没有被其中的一些VMA引用。所以新版的匿名映射改进主要是AV和VMA的关系维护,减少冗余VMA的扫描。内核为此引入了avc结构作为vma和av的桥梁,av中建立avc的红黑树,avc有指向av和vma的指针,因此通过遍历红黑树可以方便的访问到所有相关的vma。新的方法是每个进程创建一个AV结构,把他们链接到一起而不是VMA对象,这样当COW之后分裂页的时候将page->mapping指向自己进程的AV,而不是所有子进程新的页再共享父进程的av。这个链接关系通过一个AVC的对象来完成,它充当av和vma的连接结构。
<<linux内存管理-反向映射>>对反向映射描述的比较,可以仔细阅读。
static void anon_vma_chain_link(struct vm_area_struct *vma, struct anon_vma_chain *avc, struct anon_vma *anon_vma)
{
avc->vma = vma; //建立struct anon_vma_chain和struct vm_area_struct关联
avc->anon_vma = anon_vma; //建立struct anon_vma_chain和struct anon_vma关联
list_add(&avc->same_vma, &vma->anon_vma_chain);//将AVC添加到struct vm_area_struct->anon_vma_chain链表中。
anon_vma_interval_tree_insert(avc, &anon_vma->rb_root);//将AVC添加到struct anon_vma->rb_root红黑树中。
}
参考文章:
https://www.lmlphp.com/user/72155/article/item/699103/
http://blog.chinaunix.net/uid-69961881-id-5829403.html
https://blog.csdn.net/u012294613/article/details/124103630
https://blog.csdn.net/qq_26768741/article/details/54375524
https://www.elecfans.com/news/1833434.html
https://blog.csdn.net/faxiang1230/article/details/106609834/