本文转载自微信公众号《Linux内核那些事》,作者songsong001。转载本文请联系Linux内核那些事儿公众号。在《你真的理解内存分配》一文中,我们介绍了malloc申请内存的原理,但是在内核中是如何实现的呢?因此,本文主要分析Linux内核中堆内存分配的实现过程。本文使用的是文章?中介绍的Linux2.6.32版本代码内存分区对象。Linux将进程虚拟内存空间划分为多个分区,在Linux内核中用vm_area_struct对象来表示。其定义如下:structvm_area_struct{structmm_struct*vm_mm;//分区所属内存管理对象unsignedlongvm_start;//分区起始地址unsignedlongvm_end;//分区结束地址structvm_area_struct*vm_next;//连接进程的所有内存分区通过这个指针变成一个链表...structrb_nodevm_rb;//红黑树的节点用来保存在内存分区红黑树中...};我们简化了vm_area_struct对象,只保留本文需要的字段。内核使用vm_area_struct对象来记录一个内存分区(如代码段、数据段、堆空间等)。下面介绍vm_area_struct对象的各个字段的作用:vm_mm:指定当前内存分区所属的内存管理对象。vm_start:内存分区的起始地址。vm_end:内存分区的结束地址。vm_next:通过这个指针将进程中的所有内存分区连接成一个链表。vm_rb:另外,为了快速找到内存分区,内核还将进程的所有内存分区保存到红黑树中。vm_rb是红黑树的节点,用于将内存分区保存到红黑树中。如果进程A现在有4个内存分区,它们的范围如下:代码段:00400000~00401000数据段:00600000~00601000堆空间:00983000~009a4000栈空间:7f37ce866000~7f3fce867000那么这4个内存分区在内核中的结构如图1所示:在图1中,我们可以看到有一个mm_struct的对象,它被各个进程持有,是进程虚拟内存空间和物理内存空间的管理对象。简单介绍一下这个对象,其定义如下:structmm_struct{structvm_area_struct*mmap;//指向进程内存分区连接的链表structrb_rootmm_rb;//内核使用红黑树保存了进程内存分区的所有内存分区process,这是一个红黑树根节点unsignedlongstart_brk,brk;//堆空间的起始地址和结束地址...};下面介绍一下mm_struct对象的各个字段的作用:mmap:指向一个由进程的所有内存分区连接而成的链表。mm_rb:为了加快内存分区的查找速度,内核使用红黑树来保存所有的内存分区。这是红黑树的根节点。start_brk:堆空间的起始内存地址。brk:堆空间的栈顶内存地址。我们再回顾一下进程虚拟内存空间的布局,如图2所示:start_brk和brk字段用来记录堆空间的范围,如图2所示。一般来说,start_brk不会变,brk会随着内存的分配和释放而变化。《你真的理解内存分配》关于虚拟内存分配一文中提到,当调用malloc申请内存时,最终会调用brk系统调用从堆空间中分配内存。下面分析一下brk系统调用的实现:unsignedlongsys_brk(unsignedlongbrk){unsignedlongrlim,retval;unsignedlongnewbrk,oldbrk;structmm_struct*mm=current->mm;...down_write(&mm->mmap_sem);//对内存管理对象进行Lock...//判断堆空间大小是否超过限制,超过限制则不处理rlim=current->signal->rlim[RLIMIT_DATA].rlim_cur;if(rlimstart_brk)+(mm->end_data-mm->start_data)>rlim)gotoout;newbrk=PAGE_ALIGN(brk);//新brk值oldbrk=PAGE_ALIGN(mm->brk);//旧brk值if(oldbrk==newbrk)//如果新旧位置相同,则无需处理gotoset_brk;...//调用do_brk函数进行下一步if(do_brk(oldbrk,newbrk-oldbrk)!=oldbrk)gotoout;set_brk:mm->brk=brk;//设置堆空间的栈顶位置(brk指针)out:retval=mm->brk;up_write(&mm->mmap_sem);returnretval;}为总结上面的代码,主要有以下几个步骤:1.判断si是否堆空间的ze超出限制。如果超过限制,则什么也不做,直接返回旧的brk值。2、如果新的brk值与旧的brk值一致,则不做任何处理。3、如果新的brk值发生变化,则调用do_brk函数进行下一步。4.将进程的brk指针(堆空间的顶部)设置为新的brk值。我们看到在第3步调用了do_brk函数进行处理,do_brk函数的实现有点复杂,这里简单介绍一下处理流程:从进程内存分区的红黑树中找到对应的内存通过堆空间Partition对象(即vm_area_struct)的起始地址start_brk。将堆空间的内存分区对象的vm_end字段设置为新的brk值。至此brk系统调用的工作就完成了(释放内存的情况上面不分析),总结一下,brk系统调用的工作主要有两部分:将进程的brk指针设置为新的brk价值。将堆空间的内存分区对象的vm_end字段设置为新的brk值。物理内存分配从上面的分析我们知道brk系统调用申请的是虚拟内存,但是只能使用物理内存来存储数据。因此,虚拟内存必须映射到物理内存才能使用。那么什么时候进行内存映射呢?《你真的理解内存分配》一文中介绍,当对未映射的虚拟内存地址进行读写操作时,CPU会触发pagefault异常。内核收到缺页异常后,会调用do_page_fault函数进行修复。下面分析一下do_page_fault函数的实现(精简后):;address=read_cr2();//获取导致缺页异常的虚拟内存地址...vma=find_vma(mm,address);//通过虚拟内存地址从进程内存分区中找到对应的内存分区对象...if(likely(vma->vm_start<=address))//如果找到内存分区对象gotogood_area;...good_area:write=error_code&PF_WRITE;...//调用handle_mm_fault函数映射虚拟内存addressfault=handle_mm_fault(mm,vma,address,write?FAULT_FLAG_WRITE:0);...}do_page_fault函数主要完成以下操作:获取导致缺页异常的虚拟内存地址,保存在address变量中。调用find_vma函数从进程内存分区中找到异常虚拟内存地址对应的内存分区对象。如果找到内存分区对象,则调用handle_mm_fault函数映射虚拟内存地址。从上面的分析可以看出,虚拟内存的映射操作是通过handle_mm_fault函数完成的,而handle_mm_fault函数的主要工作是完成进程页表的填充。我们通过图3了解了内存映射的原理,可以参考文章《一文读懂 HugePages的原理》:我们来分析下handle_mm_fault的实现,代码如下:/pageglobaldirectoryitempud_t*pud;//page上层目录itempmd_t*pmd;//页中间目录itempte_t*pte;//页表item...pgd=pgd_offset(mm,address);//获取虚拟内存地址对应的页全局目录项pud=pud_alloc(mm,pgd,address);//获取虚拟内存地址对应的页上层目录项...pmd=pmd_alloc(mm,pud,address);//获取虚拟内存对应的页中间目录项address...pte=pte_alloc_map(mm,pmd,address);//获取虚拟内存地址对应的页表项...//映射页表项returnhandle_pte_fault(mm,vma,address,pte,pmd,flags);18}handle_mm_fault函数主要是映射每一层的页表(参考图3很容易理解),最后调用handle_pte_fault函数映射页表项。我们继续分析handle_pte_fault函数的实现,代码如下:pte_present(entry)){//还没有映射到物理内存if(pte_none(entry)){...//调用do_anonymous_page函数进行匿名页映射(堆空间要使用匿名页)returndo_anonymous_page(mm,vma,address,pte,pmd,flags);}...}...}上面的代码简化了很多与本文无关的逻辑。从上面的代码可以看出,handle_pte_fault函数最终会调用do_anonymous_page来完成内存映射操作。下面分析一下do_anonymous_page函数的实现://如果是读操作引起的异常//使用`zeropage`映射entry=pte_mkspecial(pfn_pte(my_zero_pfn(address),vma->vm_page_prot));...gotosetpte;}...//如果是写操作引起的异常//申请新的物理内存页page=alloc_zeroed_user_highpage_movable(vma,address);...//根据物理内存页地址生成映射关系entry=mk_pte(page,vma->vm_page_prot);if(vma->vm_flags&VM_WRITE)entry=pte_mkwrite(pte_mkdirty(entry));...setpte:set_pte_at(mm,address,page_table,entry);//设置页表项为新的映射关系。..return0;}do_anonymous_page函数的实现比较有意思。根据页面错误是由读操作还是写操作引起的,会分为两种不同的处理逻辑。如下:如果是读操作引起的,那么会使用零页进行映射(零页是Linux内核中的一个特殊内存页,所有读操作引起的页错误都会指向这个页,从而减少物理内存消耗),并将其设置为只读(因为不能写入零页)。如果下一次对该页进行写操作,则会触发写操作的pagefault异常,从而进入下面的步骤。如果是写操作引起的,申请一个新的物理内存页,然后根据物理内存页的地址生成映射关系,然后填充(映射)页表项。小结本文主要介绍Linux内存分配的整个过程,当然这里只介绍从堆空间分配内存的过程。Linux分配内存的方式还有很多,比如mmap、HugePages等,有兴趣的可以参考相关资料和书籍。