Linux内核笔记007 - 内存管理的进一步封装

发布者:jmpcall
发布于:2020-08-05 18:10

    内存管理学习到现在这个阶段,虽然内容又长又抽象,但其实仅仅才是整个高楼大厦中埋在地里的那部分,包括:

    Linux内核笔记001Linux内核笔记002,说明了Intel 80386 CPU为内存管理提供的硬件特性;Linux内核笔记004Linux内核笔记005Linux内核笔记006,说明了Linux内核按"页面"为单位,对各个进程的虚拟内存空间、对整个系统的物理内存空间和交换分区的基础管理。而实际应用场景,需要利用这些最基础的方式,封装更多的内存管理方式。

    根据我个人的总结,实际应用一般包括两种场景:

  • 批发型

        基于以"页"为单位的已有管理接口,向系统"批发"内存,再以更灵活粒度的实现一层分配接口。

        比如libc库中的malloc()函数,分配粒度完全自由。

malloc(100);    /* 如果独占1个页面,浪费3996字节空间 */
malloc(5000);   /* 如果独占2个页面,浪费3192字节空间 */
        另外,如果写过应用层网络流量处理程序,可能又基于malloc()函数,实现过用于存储网络会话的缓冲池。最简易的缓冲池,是在程序启动时,就预先分配大量内存,直到程序结束时才释放,比如程序启动时分配100万个session,那么启动后就能保证一直有100万个session可以使用,从而可以避免,系统中其它进程在某个时刻消耗过多内存,流量处理程序分配不到session的情况,另外,相比任意粒度的分配释放流程,固定结构的管理逻辑显然要简单的多,比如通过used成员就可以区分session使用/空闲状态,从而每次session的"分配"/"释放"操作,性能上也略优于直接使用malloc()/free(),但当程序处理的流量很大,每秒钟都需要分配/释放大量的session时,整体性能的提升就比较明显了。

        

        除了这两个例子,应用层根据不同的业务需要,还有很多其它的内存管理思路,内核层本身也封装了各种基于"页"管理之上的内存使用接口,比如: kmalloc()、vmalloc()、slab等。

  • 映射型

        映射指的是内存空间和IO空间之间的映射,通过内存访问的方式,读写磁盘和各种其它外设。

        一方面为了突破硬件的限制:Intel x86系统CPU虽然提供了IN、OUT指令,用于访问I/O空间,但是通过查询Intel手册可以知道,用于传递IO地址的参数,是8位的立即数或16位的DX寄存器,即最多可以寻址64K的I/O空间,随着电脑同时能接的外设越来越多,以及各种新型外设的功能越来越丰富,即对外的寄存器越来越多,64K的I/O寻址能力已经远远不够用了,针对这种情况,内核实现了ioremap()函数,可以将I/O空间映射到内存空间,对外设进行操作。

       

        另一方面就是为了丰富功能:比如文件映射,应用层使用mmap()系统调用,内核层相应实现了sys_mmap2()函数。

1. 内核缓冲区的管理

  • slab特点

        ① 需要某种对象时,并不直接分配这种对象,而是先分配slab对象,然后将slab对象的对象区,用于最终对象,并且整个所需的对象,可以由多个slab承载,slab之间用链表相连。这样便于扩充和回收,比如流量处理程序,在上网高峰期,启动时默认分配的100万个session就不够用了,就需要扩充session缓冲池的大小,上网低峰期,可能20万个session就足够了,剩余空闲的,就可以释放给系统;

        ② mem_zone的道理相同,为了可以方便的合并连续页面,每个slab占用2^n个连续页面块,最多也不超过32个页面;

        ③ 每个slab有一个空闲对象链数组,它和对象区数组的大小一致,free成员为第一个空闲对象的下标,同时空闲对象链数组的该下标处,为对象区中下一个空闲对象的下标,直接为BUFCTL_END,slab分配/释放对象时,空闲对象链数组也会相应更新;

        ④ 为了加快空闲对象的查找速度,slab的实现始终保持:同一个kmem_cache_s结构上挂的slab,根据使用情况划为三截,第一截中的slab,包含的对象为全部使用,第二截中的slab,包含的对象为部分使用,第三截中的slab,包含的对象为全部空闲。
        这些特点,相比malloc(),在性能和缓解内存碎片方面是有优势的,不过malloc()侧重的是灵活的分配粒度,所以在这两点上有些牺牲,完全是情有可原的。


  • 着色区

        书上简单提了一下,着色区可以提高缓冲行命中,但没有过多解释为什么可以提高命中,所以仍然是写点我个人的理解:

        我记不清就是这本书,还是内核代码的某个注释提到过,通过slab管理的数据结构,访问频率越高的成员,建议放在结构体的越前面。同时我又查了一下高级缓存的命中原理,它包括全相联映射、直接相联映射、组相联映射三种方式,总之就是很多不同的内存页,会映射到相同的高级缓存块,比如下图所示,两个slab都是以页边界开始,第一个着色区为0,第二个着色区为一个缓冲行大小,这样,两个slab中的所有对象,前面部分的高频访问成员,映射的缓冲行就是错开的,从而访问对象1、对象2的前n个成员时,都不会覆盖对方前n个成员对应的缓冲行,从而降低了不同对象中高频访问成员在高级缓存中的冲突率。

           

  • slab整体组织

        单个slab,由slab_t结构管理,描述包含的实际对象的情况,包含同种对象的多个slab,由kmem_cache_t结构(即struct kmem_cache_s)管理,而每个kmem_cache_t对象本身,又是从挂在一个总根kmem_cache_t对象(变量名为cache_cache)上的slab分配的。

    

        比如,现在需要预先分配100万个session对象,那么最终得到的就是一个kmem_cache_t对象,这个kmem_cache_t对象可能挂了10个新创建的slab,每个slab包含10万个session,而这个kmem_cache_t对象本身,又是从挂在cache_cache上的某个slab中分配的。

        梳理一下,就是这样:

kmem_cache_t *kmem_cache_create()
  |- 从cache_cache分配一个kmem_cache_t对象
  |- 创建10个新slab_t对象,每个slab包含10万个session,并且全部链入kmem_cache_t对象
        这里有一点点容易犯糊涂:cache_cache也是一个kmem_cache_t对象,它是如何被创建的呢?
        很显然,cache_cache不能通过调用kmem_cache_create()函数创建,要不然就成"先有鸡先有蛋"的问题了,通过代码可以看到,它是一个全局变量,占用的内存在进程的数据段中,在内核加载时就分配了,并且将slab_t对象链表初始化为空,由后续触发扩展逻辑时添加slab:
/* internal cache of cache description objs */
static kmem_cache_t cache_cache = {
	slabs:		LIST_HEAD_INIT(cache_cache.slabs),
	firstnotfull:	&cache_cache.slabs,
	objsize:	sizeof(kmem_cache_t),
	flags:		SLAB_NO_REAP,
	spinlock:	SPIN_LOCK_UNLOCKED,
	colour_off:	L1_CACHE_BYTES,
	name:		"kmem_cache",
};

  • 接口

        经过上述描述,slab的代码就很容易看懂了:
        ① slab创建/销毁:kmem_cache_create() / kmem_cache_destroy();
        ② 从slab分配/释放实际对象:kmem_cache_alloc() / kmem_cache_free(),slab中的实际对象已全部使用时,kmem_cache_alloc()会调用kmem_cache_grow()进行扩展;
        ③ 回收空闲slab:kmem_cache_shrink(),Linux内核笔记006分析kswapd()函数时,就已经见到过内存紧张时,这个函数被调用的过程。

2. 外部设备存储空间的地址映射

    PCI外设中,都会有用于配置pci地址的寄存器, 具体可以等到学习书中8.4节"PCI总线"时,再详细了解,目前只需要知道,电脑启动阶段,BIOS会为每个PCI外设安排好总线地址,总线地址即物理地址(lspci命令不带参数,查看到的是总线号,lspci -v可以看到总线地址,不要搞混淆),这样内核就可以通过映射的物理内存,读写外设对外的寄存器了。但是由于保护模式下,程序不可以直接访问物理内存,所以就需要分配一块虚拟内存区间,映射到外设所在的物理内存,再由程序使用,这就是ioremap()函数的功能
    相比内存分配的过程,ioremap()不需要分配物理页面,直接获取BIOS为外设安排的物理地址即可,这些物理页面也不会被swapd()函数换出,因为为了保证对外设的访问必须经过内核,所以是从内核空间分配一块虚拟区间进行映射的。
ioremap()
 |- // 0xA0000-0x100000,用于VGA、BIOS
 |- // 0~virt_to_phys(high_memory),用于内存
 |- 总线/物理地址区间检查
 |- __ioremap()
     |- get_vm_area()  // 从内核空间分配一块空闲的虚拟区间,内核空间由vmlist管理
     |  // 内核不是进程,所以没有task_struct结构,使用init_mm记录内存管理信息
     |- remap_area_pages()  // 建立虚拟页面与物理页面映射(设置目录表、页表)
    我有时候觉得,内核之所以难理解,可能是因为我们先接触到的是上层,学习的过程也相应是"自上而下"的,然而"自下而上"往往更接近自然的发展过程,所以有些情况,我们也可以从底层往上层想,比如对于人的感观,系统中的进程都是在"同时"执行,但实际上 ,如果先从上层撇开进程的概念,从最底层把整个系统看作一个"超大程序",则:
    ① "超大程序"执行初期,一直顺着内核的代码执行,直到执行到idle进程创建的逻辑,以及根据系统启动配置创建init进程,和更多的用户进程,使得"超大程序"又动态增多了一部分指令, 所以进程的本质,就是"动态增加指令,并分配相应资源,然后跳转到增加的指令执行";
    ② 对于单核的情况,每个时刻,CPU只可能执行"超大程序"的其中一条指令,所以进程不会"同时"执行;
    ③ 内核代码,有一部分在没有进程之前执行,有一部分,由进程根据用户代码通过中断/异常的方式"调用",或者由时钟中断"调用","中断、异常和系统调用"是书中第三章内容,下一篇笔记就开始学习,目前只需要知道,内核的代码,有一部分是"依附"于进程执行的,"超大程序"在创建进程时,才会相应创建一个进程管理对象,里面包含这个进程使用的各种资源等,而不是"依附"进程执行到内核时,就一定或者可能还没有进程管理对象,所以即使每个进程管理结构,都有对内核空间的管理信息,仍然需要一个内核独立使用的管理对象,remap_area_pages()函数中,init_mm就是由内核独立使用。
   
    对于内核空间,我之前产生过这样的疑问:某个进程修改内核空间的映射,其它进程如何"知道"?
    Linux内核笔记004,介绍过swapper_pg_dir变量,当时的情景,对应的是实模式到保护模式的过渡阶段,只介绍了根据初始值中的2个目录项,临时映射了8MB大小的内存,后续执行到pagetable_init()函数,会为目录表的后1/4部分,即用于内核空间映射的目录项(0xc0000000~0xffffffff虚拟地址的最高10,对应的目录项索引为0x300~0x3ff,即768~1023),直接分配页表并建立映射。
    每个进程都是独立使用一个目录表,索引值小于768的目录项,指向的页表也都是独立的,所以用户空间才可以被隔离,相比而言,索引值达到768的目录项,指向的页表是内核初始化阶段就统一分配的,内核空间的映射,只在页表层修改,即使某个页表中的页表项全部为0,也不释放该页表,这样,只要各个进程的目录表后1/4是从swapper_pg_dir复制的,就能保证内核空间可以正确的被共享,内核的实际实现也正是这样:idle进程是系统中的第一个进程,进程管理对象直接通过全局变量定义,通过初始化值就可以看出,目录表指向的就是swapper_pg_dir,其它进程的目录表又都是从父进程复制。
// idle进程目录项,由全局变量初始值指定为swapper_pg_dir
init_idle() -> &init_task -> init_task_union -> INIT_TASK() -> INIT_MM() -> pgd=swapper_pg_dir
// 其它进程目录项,768索引之后的部分,从父进程复制
sys_fork()
 |-> do_fork()
       |-> copy_mm()
             |-> mm_init()
             |-> pgd_alloc()
                   |-> get_pgd_fast()
                         |-> get_pgd_slow()
                                |-> 复制:swapper_pg_dir+768偏移 -> 新目录表+768偏移

3. 系统调用brk()

    内核代码的逻辑,决定了对各个进程虚拟空间的布局,Linux系统中,各个进程虚拟空间的低地址区,依次为代码段、初始化数据段、未初始化数据段,再往上是堆和栈,其中,栈靠近内核空间,向下增长,堆靠近数据段,向上增长。

    堆的底端,在进程创建完成时就确定了,大小只能从顶端的边界开始调整,增长时要延着边界向上,分配虚拟区间和物理页面,并建立映射,减小时要延着边界向下,撤消虚拟区间与物理页面的映射。brk()函数是一个系统调用,就是用于动态调整进程堆区的大小。系统调用需要通过"门"进行调用,跟调用应用程序中的函数相比,除了会让CPU跳转到特定的指令块执行,还会让CPU从用户态切换到内核态,等到学习第三章"中断、异常和系统调用",就能详细的了解,先明白brk()函数用于增加/减小进程的堆即可。libc库,在堆之上,又实现了一层管理,从而可以利用malloc()/free()函数,执行更灵活粒度的分配/释放。

    

    通过brk()函数进入内核态后,执行的是sys_brk()内核函数:

sys_brk()
 |- if (期望边界 < 当前边界)
 |   |- do_munmap()
 |   |   |  // 从当前进程已使用虚拟区间中,查找第一个结束地址高于期望边界的区间
 |   |   |- find_vma_prev()
 |   |   |- do_munmap()还会在其它情景中被调用,所以要考虑释放某个区间当中的区域,将原区间一分为二的情况
 |   |   |- 也要考虑释放区间,跨越已存在的多个区间的情况
 |   |   |- /* 以下部分,涉及到文件系统,可以先不深究 */
 |   |   |  /*
 |   |   |   * fs/namei.c,202,i_writecount含义注释:
 |   |   |   *  0:  no writers, no VM_DENYWRITE mappings
 |   |   |   * <0: (-i_writecount) vm_area_structs with VM_DENYWRITE set exist
 |   |   |   * >0: (i_writecount) users are writing to the file.
 |   |   |  */
 |   |   |- "<0"与">0"互斥,所以执行到此处的前提是"i_writecount<=0"
 |   |   |- 如果要撤消的虚拟区间,是通过mmap()遇到到文件的,执行740~750行的代码时,要避免其它进程对文件进行写操作
 |   |   |- /* 以下是目前需要深刻理解的 */
 |   |   |- // 解除虚拟页面和物理页面的映射
 |   |   |- zap_page_range()
 |   |       |- zap_pmd_range()  // 循环执行,直到最后一个映射对应的目录项
 |   |           |- zap_pte_range()  // 循环执行,直到最后一个映射对应的中级目录项
 |   |               |- ptep_get_and_clear()  // 清除相应范围的页表项
 |   |               |- free_pte()
 |   |                   |- if (pte_present(pte))
 |   |                   |   |- free_page_and_swap_cache()
 |   |                   |       |- delete_from_swap_cache_nolock()
 |   |                   |       |  |- lru_cache_del()  // 从LRU队列移除,如果来自非交换文件,先将内容写进文件
 |   |                   |       |  |- __delete_from_swap_cache()  // 从换入换出队列移除(在address_space结构中)
 |   |                   |       |  |- page_cache_release()  // 释放LRU占用的引用计数
 |   |                   |       |- page_cache_release()     // 释放分配时就分配的引用计数1
 |   |                   |- else
 |   |                       |- swap_free()   // 递减盘上页面使用计数
 |   |- unmap_fixup()    // 处理虚拟区间一分为二等情况
 |   |- free_pgtables()  // 撤消掉一些映射后,可能有些页表的页表项全部为0了,相应的的页表也要释放
 |- else  // 扩展堆的大小
     |- 检查资源限制(ulimit命令可以查看/设置各个进程堆大小、栈大小,以及其它资源的限制)
     |- find_vma_intersection()  // 检查是否与已有区间冲突,高端区间可能是与栈冲突,低端区间可能是与进程本身的数据段冲突
     |- vm_enough_memory()       // 检查是否有足够的内存用于扩展
     |- do_brk()                 // 扩展
         |- make_pages_present()
             |- handle_mm_fault()  // 主动利用缺页异常处理函数,建立映射

4. 系统调用mmap()

  • mmap()应用

        mmap()函数用于将文件的指定区间,映射到内存,从而可以直接通过内存,读写文件内容。

        原型:void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);

        flags参数包含很多标志位,个人觉得以下2个对mmap()函数的理解,影响较大:

        MAP_ANONYMOUS:匿名映射,映射到的是dev/zero文件,可以用于父子进程间共享内存;

        MAP_FIXED:flags不包含该标志时,addr参数只是一个参考值,包含该标志时,则要求mmap()一定使用addr地址处的虚拟区间进行映射,并且要求addr处的虚拟区间原本就已经映射,一般用于加载.so文件,.text段、.data段、.bss段属于不同,不可以位于同一个虚拟区间,但各自mmap(),又不能保证位置上连续,所以先映射出一块总的,然后分别MAP_FIXED覆盖。

  • mmap()函数分析

// Linux-2.4.0往前一些的版本
old_mmap()
 |- copy_from_user()  // 将参数拷贝到内核空间
 |- do_mmap2()

// Linux-2.4.0版本
sys_mmap2()
 |- do_mmap2()

// old_mmap()和sys_mmap2()仅仅是传递参数的方式不一样,关键逻辑都调用do_mmap2()完成
do_mmap2()
 |- do_mmap_pgoff()
     |- 检查文件属性,是否满足flags参数指定的映射方式
     |- kmem_cache_alloc()  // 分配一个vm_area_struct对象,用于管理新增的虚拟区间
     |- if (flags参数指定MAP_FIXED)
     |   |- 用于覆盖已有虚拟区间,不需要重新分配
     |- else
     |   |  // 从TASK_UNMAPPED_BASE即1G开始往上找一块可以容纳映射长度的空闲虚拟区间
     |   |- get_unmapped_area()
     |- kmem_cache_alloc()  // 分配一个虚拟区间管理结构,并初始化成员
     |  // 记录映射在文件中的起点,这样虚拟区间的每个页面对应的"盘上页面"就已经确定了,不需要像swap文件那样依赖PTE
     |- vma->vm_pgoff = pgoff
     |  // 先分配,再检查条件
     |  // 否则如果由于分配函数内部暂时分配不到物理页面,"礼让"其它进程先执行再回到这里,先前的检查就都失效了
     |- if (do_munmap()撤消重叠区间失败
     |      || 进程虚拟区间个数已经达到上限
     |      || 本次为建立进程专用可写区间,但物理内存不够)
     |   |- 释放新分配的虚拟区间管理结构
     |  // flags参数包含MAP_ANONYMOUS标志位,为匿名映射,file=NULL
     |- if (file)
     |   |- if (flags参数指定MAP_DENYWRITE)
     |   |   |- deny_write_access()  // 不允许通过常规操作访问文件
     |   |- get_file()  // 递增file引用计数
     |   |- file->f_op->mmap()  // 如果file是Ext2文件,mmap函数指针指向generic_file_mmap()函数
     |   |  generic_file_mmap()
     |   |   |  // file_private_mmap/file_shared_mmap又进一步提供了nopage函数指针:filemap_nopage()
     |   |   |- vma->vm_ops = &file_private_mmap/&file_shared_mmap  // 根据flags参数是否指定VM_SHARED
     |- else if (flags参数指定MAP_SHARED)
         |- shmem_zero_setup()
             |  // 书中6.7节"共享内存"详细介绍
             |- shmem_file_setup()   // 映射到dev/zero文件
             |- vma->vm_ops = &shmem_shared_vm_ops
    通过mmap()的实现可以看到,它主要就是新增了一个虚拟区间,然后设置了映射的目标文件和范围,以及一些函数指针。换句话说,它只是描述了要如何映射,并没有实际映射任何一个虚拟页面到文件,实际的映射是在某个虚拟页面受到访问时,在do_page_fault()函数中完成的:
do_page_fault()
 |- handle_mm_fault()
     |- handle_pte_fault()
         |- if (!pte_present(entry))
             |- if (pte_none(entry))
                 |- do_no_page()
                     |- vma->vm_ops->nopage()  // nopage函数指针,由mmap()函数设置

    mmap()函数中执行的file->f_op->mmap(),和do_page_fault()函数中执行的vma->vm_ops->nopage(),都叫做VFS(虚拟文件系统)接口,对于Ext2文件,它们最终执行的分别是Ext2文件驱动中的do_mmap2()、filemap_nopage()。有一句话叫做"Linux一切皆文件",比如,socket()函数返回的就是文件描述符,读写该文件描述符,就可以收发网络报文,除此之外,键盘对应的是字符设备文件,硬盘对应的是块设备文件。。而不同文件的功能不同,是因为对应的硬件和驱动的实现逻辑不同,在各种不同的驱动之上,内核抽象了一套文件操作的统一接口,即VFS(相当于文件操作框架),内核本身只使用VFS接口操作文件,不关心不同文件系统的区别,VFS接口的功能由驱动层具体实现。所以书中建议,等到学完第五章"文件系统",可以回头再看一遍mmap()函数的实现。

  • 对比

        ① mmap()文件映射 —— read()、write()

            read()、write()函数会为文件在内核空间分配缓存,所以mmap()的性能优势在首次读写文件的时候,比如read()函数从硬盘读取文件内容,要先从内核空间分配一块缓存(不能直接读到用户态buffer映射的物理内存,因为读取文件也是以页为单位的,另外还有预读等情况,会覆盖buffer之外的内存,所以要另外找一块物理内存作为缓存),然后复制到用户空间,而mmap()用户态的buffer,天生就会映射到文件内容所在的物理页面,所以少了一次内核态与用户态之间的复制,write()函数同理。

        ② mmap()匿名映射 —— brk()

            mmap()匿名映射和brk()一样,用于malloc()内部实现。使用brk()只能通过边界调整堆的大小,如果边界上的页面还在使用,即使free()掉再多的内存,仍然由libc层维持着,并没有真正释放给系统,但可以缓解虚拟空间的碎片;使用mmap()可以从1G虚拟地址之上,到栈顶之间,寻找空闲的虚拟区间,每个区间可以使用brk()单独释放。

malloc()
 |- if (分配大小 < 128K)
 |   |- brk()
 |- else
     |- mmap()

5. malloc()

    应用层,又基于内核的接口,对内存管理做了更高一层的封装,最亲切的就是malloc()/free()。搞清楚malloc()/free()内部的机制,是利用堆溢出漏洞的基本要求,墙裂推荐一个链接:https://sploitfun.wordpress.com/archives/

声明:该文观点仅代表作者本人,转载请注明来自看雪