Skip to content

Latest commit

 

History

History
159 lines (121 loc) · 13.9 KB

缺页小记.md

File metadata and controls

159 lines (121 loc) · 13.9 KB

直接展开,探讨一下内存管理中重要的page fault流程,在很多书中将其翻译成缺页中断,但是就我个人来说更喜欢直译的缺页异常这一说法,这主要是为了和一般中断区别开来。 两者的区别在于什么呢?

  • 一般中断: 发生中断时保护现场上下文环境,然后跳转到需要立马进行处理的地方,然后返回执行下一条指令
  • 缺页中断: 发生缺页时保护好上下文环境,然后判断内存是否有足够的空间存储需要的页/段,然后进行调页,结束后返回到发生缺页的指令

如果要谈论page fault的发生就需要先谈一下它的诞生。 这个其实需要从分页机制说起,这一部分先前有详细探讨过并还进行过代码的实验,因此这次要说的其实单单就是分页本身的意义,这又要谈及虚拟内存物理内存的关系,众所周知虚拟内存带来的优势很多,比如共享内存对物理内存的保护内存空间隔离等等,这些先前聊过,因为数据最终都是写在物理内存上的,所以如何快速通过某种方式将物理内存虚拟内存关联起来?那么最终的结果就很明显了,页机制其实是为了高效地翻译虚拟内存地址。

如果说单纯地将地址的对应关系记录在内存中的某张表里,这其实没什么问题,但是造成的结果就是极度消耗资源,因为如果内存中的每一个字节都有记录的话,那么表的本身就会超过内存的大小,最终直接导致系统瘫痪掉。那么将内存划分成一个4kb的page的集合就显得比较合理了,物理内存就用物理页虚拟内存就用虚拟页,而二者的对应关系则采用页号:页内偏移的方式记录,这样就极大地减少了记录的条目数量。

231c6967-d3a5-4d34-bd56-964e0d51e6dd.png

但是新的问题就来了,即时关系记录已经缩减了上千倍,但是如果表内容太多还是会占用巨大的内存,这就又有了技术上的缓解手段:

  1. 分段+分页结合
  2. 多级页表

这个我觉得记录起来还是蛮有意义的,因为先前只阐述过技术上的实现,而没有去阐述技术诞生的原因,这显然是不够合理的

分段的方式的分页对象是一个,这样每一个页表的大小就减少了,是独立管理,那么不同的也页表访问频繁度也不同,这样就提高了访问效率,但是并没有解决页表过大这样一个实质性的问题,因此操作系统并没有采用这样的方式,而是多采用多级页表,其思想就是倘若页表中所有的页表项都是无效的那就完全不需要为这一段空间的页维护页表,这样就能减少页表的大小。

一级页表

就是如上图所示,也可以称为线性页表,因为最初的设计上适用的是4GB的内存空间,而每页的大小是4kb,因此一个pgt的大小是4G/4K = 1M个页面,上面说了十分浪费内存,因为每一个进程都需要维护一个4MB大小的页表提供给自身查询。

二级页表

为了减少内存开销,又引入了一个新的表叫做page directory(页目录),可容纳1k4b大小的表项,那么其本身为4kb大小,存放在一个4kb页中,其中的每一个表项指向一个4kb大小的页表,里面依然是1k个表项,而每一个表项代表一个page,这样算起来的覆盖范围是1k * 1k * 4k = 4G,但是问题是不是需要4MB + 4kb来存放表吗,这样占用的内存不是更多了? 其实不是,页目录中确实是记录了所有的页表,占用了4kb的内存空间,但是会标记其中的表项即页表是否有效,如果无效的的话则不需要维护该页表。因为页表是可以分散在不同的页面上,这样的话就无需维持一个4MB的连续内存,而且有相当多的虚拟内存是没有被使用的,所以这些都不需要页表

三级页表

新的问题又来了,x86引入了物理地址扩展后,可以支持大于4G(36位)的物理内存,但是碍于32位的原因,虚拟地址依然还是4G,那这样对应关系实际就遭到了破坏,因此为了解决这个问题,再次引入了新的一张表叫做page middle directory(页中间目录,简称pmd),不过这个并没有持续多久,因为硬件在发展

四级页表

x86_64时代代表着64位的CPU,然而问题在于CPU地址总线宽度只有40bit,所以内核地址也只有40bit而已,而虚拟地址则为48bit,不过这依然需要再次引入页表也就是page upper director(页上级目录,简称pud)

不过不管多级页表怎么变,核心道理就是:对于连续无效页组成的页表,没有必要去记录。

关于页表

首先是内核内存虚拟内存之间的映射是的映射,而这个对应关系不管中间经过了怎样的翻译过程,最终都是需要在页表中表现出来的,而进程之间的用户态内存是相互独立的,你可以有一个0x2223的地址,我也可以有一个0x2223的地址,然而二者对应的物理地址却是不同的,而要做到这样就得需要有不同的页表参与其中,而从task_struct的属性中看出来,这张不同的表是页目录,存放在进程当中:

mm->pgd;

其实是有两张表,可以称为内核页表进程页表内核页表唯一存在于内核中的页全局目录里,被所有进程共享,而进程页表就是上述的东西了,其中映射的虚拟地址既包括内核态地址又包含用户态地址,而二者虚 - 实的方式并不相同,因此内核态虚拟地址这部分的映射就需要用到内核页表,这儿用到的方式是每个进程的进程页表中的内核态地址相关表项都是内核页表的拷贝,对于地址转换来说,其实虚拟页并没有什么意义。

缺页

终于说到缺页了,上面说了一大通,那么问题在哪呢?很简单,因为虚拟内存机制的引入使得大部分程序无法直接读写物理内存,一切行为都会过一层审核,这样极大的保护了操作系统不容易遭到破坏,那么自然的,从程序的读写数据都是通过虚拟内存,而又因为物理内存映射了这段虚拟内存,所以才使得能够和物理内存交互,而导致缺页异常发生的情况有如下:

  1. 地址不在虚拟地址空间
  2. 地址在虚拟地址空间中,但是其访问权限不够,例如写只读区间
  3. 还有和物理地址建立映射关系
  4. 映射的物理页已经不在内存中
  5. 映射的物理页访问权限不够
  6. 内核态下,通过vmalloc获取线性地址引起的异常,主要是因为延迟更新的问题,导致进程页表还没有更新

而通过这些缺页异常而衍生出多种linux特性出来,例如COW延迟分配内存回收中磁盘和内存交换等,具体形式如下:

  1. COW,著名的Dirty COW漏洞就是爆发在这儿,意义在于使得地址空间上页的拷贝被推迟到实际发生写入的时候,举个简单的例子就是fork()出来的进程往往都是父子进程共享数据,但是对于子进程来说页只读,因此直到发生写入时,内核才会为其分配物理内存并拷贝数据到这个内存上供子进程写入。
  2. 请求调页,这个往往发生在malloc之类的函数上,这些函数在调用时其实仅仅分配了一段连续的虚拟内存,而并没有分配物理内存,直到实际访问的时候才去分配物理页框,同样的在内存回收时也会发生请求调页,当分配的物理页上的数据已经写到了磁盘上后就会被系统回收掉,当需要读写的时候再分配物理页框。这种机制使得程序运行时不必将整个程序加载进内存,而是采用惰性交换器,直到需要时才加载。

缺页异常的处理步骤:

  1. 陷入内核态。
  2. 保存用户寄存器和进程状态。
  3. 确定中断是否为缺页错误。
  4. 检查页面引用是否合法,并确定页面的磁盘位置。
  5. 从磁盘读入页面到空闲帧: . 在该磁盘队列中等待,直到读请求被处理。 . 等待磁盘的寻道或延迟时间。 . 开始传输磁盘页面到空闲帧。
  6. 在等待时,将 CPU 分配给其他用户(CPU 调度,可选)。
  7. 收到来自 I/O 子系统的中断(I/O 完成)。
  8. 保存其他用户的寄存器和进程状态(如果执行了第 6 步)。
  9. 确认中断是来自上述磁盘的。
  10. 修正页表和其他表,以表示所需页面现在已在内存中。
  11. 等待 CPU 再次分配给本进程。
  12. 恢复用户寄存器、进程状态和新页表,再重新执行中断的指令。

编程中的缺页

上面有很多其实都是内核特性一样的东西,而真正到了编程中,因为page fault而导致的出人意料的情况比较直观的就在于内存分配上,如果实际去测试malloc时候就会发现,在内存管理上面C库有自己的一套想法,mallocC库函数,当第一次向申请小于128kb内存时,C库会通过sys_brk调整brk的值,算作是扩大heap的范围,将这一段内存交给C库来管理,这部分内存的管理算是一种从大到小的链表结构。malloc在分配虚拟内存时其实会往内存写入元数据,这实际上是C库内存管理中需要用到的边界标记,这导致的结果是什么呢?就是当利用malloc分配一个内存时就已经发生了调页,而再touch的时候就可能不再调页了。

这儿的问题就在于假设你申请的是一个16byte大小的空间,然而malloc实际去占用的大小为32byte,因为元数据的原因分配小内存会造成浪费。

这儿的调页情况涉及到一个chunk的结构问题:

struct malloc_chunk {
  INTERNAL_SIZE_T mchunk_prev_size; /* Size of previous chunk (if free). */
  INTERNAL_SIZE_T mchunk_size; /* Size in bytes, including overhead. */
  struct malloc_chunk* fd; /* double links -- used only if free. */
  struct malloc_chunk* bk;
  /* Only used for large blocks: pointer to next larger size. */
  struct malloc_chunk* fd_nextsize; /* double links -- used only if free. */
  struct malloc_chunk* bk_nextsize;
};

不同的chunk的结构不太相同,但是大部分都有首位部分,简单来讲就是会在一个申请到的线性内存前后写入数据,这就造成了调页产生,而中间的部分如果是在空白页中的就是没有被mapped的,但是如果你访问了,就会统一映射到一个page,这点可以从crash中看出来地址是完全一样的一个page

这一点调试方式建议crash(vtop)+gdb+maps一起调试,可以比较直白的看出来

总结

针对新分配的内存,如果不进行touch的话,确实不会有任何物理页面的映射,而如果第一次行为为read的话,则是触发一次page fault然后调入一个zero page进行映射,而如果第一次是write的话则是直接从物理池中分配物理页映射,而如果是先读后写的话,则是发生COW,分配新的物理页映射后拷贝信息。 内核代码参考:

static int do_anonymous_page(struct mm_struct *mm, struct vm_area_struct *vma,
        unsigned long address, pte_t *page_table, pmd_t *pmd,
        unsigned int flags)
{
    ...
    /* Use the zero-page for reads */
    if (!(flags & FAULT_FLAG_WRITE)) {
        // 只读情况直接从zeropage里拿即可。
        entry = pte_mkspecial(pfn_pte(my_zero_pfn(address),
                        vma->vm_page_prot));
        page_table = pte_offset_map_lock(mm, pmd, address, &ptl);
        if (!pte_none(*page_table))
            goto unlock;
        goto setpte;
    }
    ...
    // 非只读,才会实际从物理池里分配页面
    page = alloc_zeroed_user_highpage_movable(vma, address);
    ...
    // 如果读fault映射了zeropage,将不会递增AnonRSS计数器。
    inc_mm_counter_fast(mm, MM_ANONPAGES);
    page_add_new_anon_rmap(page, vma, address);
setpte:
    ...
}

参考资料