1.brk系统调用服务例程
malloc()是一个API,这个函数在库中封装了系统调用brk。因此如果调用malloc,那么首先会引发brk系统调用执行的过程。brk()在内核中对应的系统调用服务例程为SYSCALL_DEFINE1(brk, unsigned long, brk),参数brk用来指定heap段新的结束地址,也就是重新指定mm_struct结构中的brk字段。
brk系统调用服务例程首先会确定heap段的起始地址min_brk,然后再检查资源的限制问题。接着,将新老heap地址分别按照页大小对齐,对齐后的地址分别存储与newbrk和okdbrk中。
brk()系统调用本身既可以缩小堆大小,又可以扩大堆大小。缩小堆这个功能是通过调用do_munmap()完成的。如果要扩大堆的大小,那么必须先通过find_vma_intersection()检查扩大以后的堆是否与已经存在的某个虚拟内存重合,如何重合则直接退出。否则,调用do_brk()进行接下来扩大堆的各种工作。
1 | SYSCALL_DEFINE1(brk, unsigned long , brk) |
3 | unsigned long rlim, retval; |
4 | unsigned long newbrk, oldbrk; |
5 | struct mm_struct *mm = current->mm; |
8 | down_write(&mm->mmap_sem); |
10 | #ifdef CONFIG_COMPAT_BRK |
11 | min_brk = mm->end_code; |
13 | min_brk = mm->start_brk; |
18 | rlim = rlimit(RLIMIT_DATA); |
19 | if (rlim < RLIM_INFINITY && (brk - mm->start_brk) + |
20 | (mm->end_data - mm->start_data) > rlim) |
22 | newbrk = PAGE_ALIGN(brk); |
23 | oldbrk = PAGE_ALIGN(mm->brk); |
28 | if (!do_munmap(mm, newbrk, oldbrk-newbrk)) |
33 | if (find_vma_intersection(mm, oldbrk, newbrk+PAGE_SIZE)) |
36 | if (do_brk(oldbrk, newbrk-oldbrk) != oldbrk) |
42 | up_write(&mm->mmap_sem); |
brk系统调用服务例程最后将返回堆的新结束地址。
2.扩大堆
用户进程调用malloc()会使得内核调用brk系统调用服务例程,因为malloc总是动态的分配内存空间,因此该服务例程此时会进入第二条执行路径中,即扩大堆。do_brk()主要完成以下工作:
1.通过get_unmapped_area()在当前进程的地址空间中查找一个符合len大小的线性区间,并且该线性区间的必须在addr地址之后。如果找到了这个空闲的线性区间,则返回该区间的起始地址,否则返回错误代码-ENOMEM;
2.通过find_vma_prepare()在当前进程所有线性区组成的红黑树中依次遍历每个vma,以确定上一步找到的新区间之前的线性区对象的位置。如果addr位于某个现存的vma中,则调用do_munmap()删除这个线性区。如果删除成功则继续查找,否则返回错误代码。
3.目前已经找到了一个合适大小的空闲线性区,接下来通过vma_merge()去试着将当前的线性区与临近的线性区进行合并。如果合并成功,那么该函数将返回prev这个线性区的vm_area_struct结构指针,同时结束do_brk()。否则,继续分配新的线性区。
4.接下来通过kmem_cache_zalloc()在特定的slab高速缓存vm_area_cachep中为这个线性区分配vm_area_struct结构的描述符。
5.初始化vma结构中的各个字段。
6.更新mm_struct结构中的vm_total字段,它用来同级当前进程所拥有的vma数量。
7.如果当前vma设置了VM_LOCKED字段,那么通过mlock_vma_pages_range()立即为这个线性区分配物理页框。否则,do_brk()结束。
可以看到,do_brk()主要是为当前进程分配一个新的线性区,在没有设置VM_LOCKED标志的情况下,它不会立刻为该线性区分配物理页框,而是通过vma一直将分配物理内存的工作进行延迟,直至发生缺页异常。
3.缺页异常的处理过程
经过上面的过程,malloc()返回了线性地址,如果此时用户进程访问这个线性地址,那么就会发生缺页异常(Page Fault)。整个缺页异常的处理过程非常复杂,我们这里只关注与malloc()有关的那一条执行路径。
当CPU产生一个异常时,将会跳转到异常处理的整个处理流程中。对于缺页异常,CPU将跳转到page_fault异常处理程序中:
5 | CFI_ADJUST_CFA_OFFSET 4 |
该异常处理程序会调用do_page_fault()函数,该函数通过读取CR2寄存器获得引起缺页的线性地址,通过各种条件判断以便确定一个合适的方案来处理这个异常。
3.1.do_page_fault()
该函数通过各种条件来检测当前发生异常的情况,但至少do_page_fault()会区分出引发缺页的两种情况:由编程错误引发异常,以及由进程地址空间中还未分配物理内存的线性地址引发。对于后一种情况,通常还分为用户空间所引发的缺页异常和内核空间引发的缺页异常。
内核引发的异常是由vmalloc()产生的,它只用于内核空间内存的分配。显然,我们这里需要关注的是用户空间所引发的异常情况。这部分工作从do_page_fault()中的good_area标号处开始执行,主要通过handle_mm_fault()完成。
2 | dotraplinkage void __kprobes |
3 | do_page_fault( struct pt_regs *regs, unsigned long error_code) |
7 | write = error_code & PF_WRITE; |
9 | if (unlikely(access_error(error_code, write, vma))) { |
10 | bad_area_access_error(regs, error_code, address); |
13 | fault = handle_mm_fault(mm, vma, address, write ? FAULT_FLAG_WRITE : 0); |
3.2.handle_mm_fault()
该函数的主要功能是为引发缺页的进程分配一个物理页框,它先确定与引发缺页的线性地址对应的各级页目录项是否存在,如何不存在则分进行分配。具体如何分配这个页框是通过调用handle_pte_fault()完成的。
1 | int handle_mm_fault( struct mm_struct *mm, struct vm_area_struct *vma, |
2 | unsigned long address, unsigned int flags) |
9 | pgd = pgd_offset(mm, address); |
10 | pud = pud_alloc(mm, pgd, address); |
13 | pmd = pmd_alloc(mm, pud, address); |
16 | pte = pte_alloc_map(mm, pmd, address); |
19 | return handle_pte_fault(mm, vma, address, pte, pmd, flags); |
3.3.handle_pte_fault()
该函数根据页表项pte所描述的物理页框是否在物理内存中,分为两大类:
请求调页:被访问的页框不再主存中,那么此时必须分配一个页框。
写时复制:被访问的页存在,但是该页是只读的,内核需要对该页进行写操作,此时内核将这个已存在的只读页中的数据复制到一个新的页框中。
用户进程访问由malloc()分配的内存空间属于第一种情况。对于请求调页,handle_pte_fault()仍然将其细分为三种情况:
1 | static inline int handle_pte_fault( struct mm_struct *mm, |
2 | struct vm_area_struct *vma, unsigned long address, |
3 | pte_t *pte, pmd_t *pmd, unsigned int flags) |
6 | if (!pte_present(entry)) { |
9 | if (likely(vma->vm_ops->fault)) |
10 | return do_linear_fault(mm, vma, address, |
11 | pte, pmd, flags, entry); |
13 | return do_anonymous_page(mm, vma, address, |
17 | return do_nonlinear_fault(mm, vma, address, |
18 | pte, pmd, flags, entry); |
19 | return do_swap_page(mm, vma, address, |
20 | pte, pmd, flags, entry); |
1.如果页表项确实为空(pte_none(entry)),那么必须分配页框。如果当前进程实现了vma操作函数集合中的fault钩子函数,那么这种情况属于基于文件的内存映射,它调用do_linear_fault()进行分配物理页框。否则,内核将调用针对匿名映射分配物理页框的函数do_anonymous_page()。
2.如果检测出该页表项为非线性映射(pte_file(entry)),则调用do_nonlinear_fault()分配物理页。
3.如果页框事先被分配,但是此刻已经由主存换出到了外存,则调用do_swap_page()完成页框分配。
由malloc分配的内存将会调用do_anonymous_page()分配物理页框。
3.4.do_anonymous_page()
此时,缺页异常处理程序终于要为当前进程分配物理页框了。它通过alloc_zeroed_user_highpage_movable()来完成这个过程。我们层层拨开这个函数的外衣,发现它最终调用了alloc_pages()。
1 | static int do_anonymous_page( struct mm_struct *mm, struct vm_area_struct *vma, |
2 | unsigned long address, pte_t *page_table, pmd_t *pmd, |
6 | if (unlikely(anon_vma_prepare(vma))) |
8 | page = alloc_zeroed_user_highpage_movable(vma, address); |
经过这样一个复杂的过程,用户进程所访问的线性地址终于对应到了一块物理内存。
参考:
1.《深入理解LINUX内核》
2.《深入LINUX内核架构》
背景
C++中经常使用new来分配空间,而在内核态则是通过调用函brk()向内核申请空间。对于4G的虚存空间,
每个进程都有3G字节的用户虚存空间,其余1G字节为内核虚存空间。如下图所示:
其中Text段存放当前运行代码的二进制代码,这个图不全面,在堆和Text之间还有一个 Static data段用于存放
常量和静态数据,因为数组是固定长度因此也会存放在数据段。而堆中存放就是类似链表的动态增长和减少空间
的数据结构。而最上面的栈就是用于每次函数调用。
内核中对各个部分的映射可以分为以下两类:
图1
其中第一类是基于文件的映射,直接将文件映射到内存中,对文件的操作如同内存访问而不是使用
系统调用sys_read(),sys_write()等,此种方式后续文章中说明。第二类即是匿名映射,这篇文章将
说明。在内核中的效果如下图所示:
图2
各个类型内存区域的分配分别调用如下的系统函数:
表1
另外要说明的是内核中的分页机制,i386的分页机制是:通过页面目录和页面表分两个层次从线性地址到物理地址的映射。这种
映射模式在大多数情况下可以节省页面表所占用的空间。因为大多数进程不会用到整个虚存空间,在虚存空间中留有很大的“空洞”。
还有一个原因,采用分级的数据结构可以有效的节约页面目录和页面表自身所占的虚存空间。具体描述如下,根据参考资料【2】
虚拟空间数据结构
内核中管理虚拟内存的数据结构主要是两个,第一:struct mm_struct 每个进程都使用此数据结构管理所有的虚存区域,如图2所示,
当然为了提高查询效率,当虚存区域到达一定规模后会建立一个AVL树让查询效率降低。 第二:struct vm_area_struct 表示每个虚拟
内存区域。 具体解释如下,根据参考资料【1】
源码分析
此函数的参数brk表示所要求的新边界,第221行表示这个边界不能低于代码段的终点,这里不是数据段是因为也许没有静态数据需要保存。
第223行要求此地址必须与页面大小对齐,这是因为虚拟空间的分配的最小是4K的一个页。第229行所示新的边界要低于原来的边界,
因此需要释放空间。其调用函数do_munmap()如下所示:
函数find_vma_prev()扫描当前进程空间的vm_area_struct结构链表或AVL树,试图找到结束地址高于addr的第一个区间,
如果找到,则函数返回该区间的vm_area_struct结构指针。第685行和第689行要解除映射到那部分空间原来就没有映射,
所以直接返回0。如果这部分空间落在某个空间的中间,则在解除这部分空间的映射以后会造成一个空洞而使原来的空间一分
为二,但是一个进程可以拥有的虚存空间的数量是有限制的,所以若此数目达到上限MAX_MAP_COUNT,则返回错误。
因为原来的vm_area_struct可能一分为二,因此先分配一个空白的extra;另外有可能解除的区域有几个内存区域,
因此如第708行所示使用free将要解除的内存区域链接起来方便以后处理。变量mnp为结束地址高于addr的第一
个区间,变量addr为新的边界,变量len为要释放的区域的长度, 变量npp指向mpnt或者mm->mmap,如果
addr+len 大于 mpnt->vm_start,那么有三种可能。第一种:addr+len小于mpnt->vm_end,这样的话要解除
的内存区域只有一个。第二种:addr+len 大于mpnt->vm_end,但是小于下一个vm_area_struct的vm_start,那么
因为多余vm_end是空洞,因此实际上解除的内存区域仍然是一个。第三种:addr+len 大于下一个vm_area_struct的
vm_start那么下一个内存区域也是要解除,需要链入free链表,后续会有一个while循环依次出来这些内存区域。
第713行表示从AVL树中删除内存区域。第715行表示清空内存区域的缓存。继续看以下对各个要解除的内存区域的处理。
第728行是将free执行下一个要处理的内存区域,第730到733行就是划定要处理的区域。第735到737行的处理是因为一个
文件可以通过系统调用mmap()映射到内存,而其他进程调用可以通过文件系统的系统调用sys_read()进行文件操作,这里
是对两者进行互斥操作,通过inode的成员变量i_writecount。函数remove_shared_vm_struct()看看处理的区域是不是
这样的区间,如果是,就将其vm_area_struct结构从目标文件的inode结构内的i_mapping队列中解除。由背景知识中
可以知道物理地址到虚拟地址之间的映射关系,因此这里解除映射的过程,也需要将在页面目录表和页面表中的记录清空。
清空页面目录表的过程如下:
第357行通过宏pgd_offset()在第一层页面目录中找到起始地址所属的目录项,然后通过一个do-while循环这个
目录项开始处理涉及的所有目录项。宏pgd_offset()定义相关如下:
一个32位的地址中高地址的10位为页面目录表的地址,因此宏pgd_index()会首先右移22位(页面表地址+页内偏移)。而数据结构
mm_struct中的成员变量pgd为页面目录表首地址。第370行调用的函数zap_pmd_range()解除中间目录并会调用函数解除页面表。
第371行就是处理的起始地址递增到下一个页面目录表,而第372行就是递增页面目录表自身指针。通过函数zap_page_range(),zap_pmd_range()
和zap_pte_range()就解除了整个映射关系并且释放所映射的内存页面,其余两个函数的说明可以参考资料【2】。
现在回到函数do_munmap()的第750行的函数unmap_fixup(),此函数用于在对一个内存区域进行了调整后,一方面要对虚存空间
的vm_area_struct数据结构和进程的mm_struct数据结构作出调整,以反映已经发生的变化,如果整个区间都要解除映射,则要释放原有
的vm_area_struct数据结构;另一方面原有的区间还可能要一分为二,因而需要插入一个新的vm_area_struct数据结构。其代码如下:
此函数处理解除内存区域的映射的情况大致有4种:第一 解除整个vm_area_struct的内存区域;
第二 解除从vm_start开始的前半段;第三 解除从中间到vm_end;第四解除中间一部分区域,让
原来的内存区域一分为二。第546到548行为对进程的mm_struct的数据结构进行调整。第550行为
第一种情况解除整个内存区域。第562行表示的为解除靠后面内存区域,修改vm_end即可。第566行
就是解除靠前内存区域,修改vm_start。最后的else语句就是第四种情况将原来的内存区域一分为二。
第1880行是对高端边界的确认,内存空间分布中堆上面的应该是“空洞”,“空洞”上面
起码会有栈的内存区域,也有可能有mmap区域。此时函数find_vma_prepare()返回的vma应该
是下一个真正映射的内存区域,通过第1881行if判断,
如果所需的区域没有到下一个内存区域的起点,那么
自己退出,否则需要对内存区域进行解除映射。第1900行就是判断可以不可以和原有的内存区域合并。
第1907行开始就是新建一个vm_area_struct。第1925行的make_pages_present()就是建立内存映射。