当前位置: 首页 > 科技观察

从进程栈内存的底层原理到Segmentationfault的报错

时间:2023-03-13 11:35:40 科技观察

大家好,我是飞哥!堆栈是编程中使用内存最简单的方式。例如,下面简单代码中的局部变量n是在栈上分配的。#includevoidmain(){intn=0;printf("0x%x\n",&v);}然后我有几个问题想请教大家,看看大家是不是真的对栈内存的理解感兴趣。栈的物理内存是什么时候分配的?堆栈的大小限制是多少?这个限制可以调整吗?当堆栈溢出时,应用程序会发生什么?如果你对以上问题还没有深入了解,飞哥今天就带你来练练进程栈内存的内功!1.进程栈的初始化前面我们在文章《你写的代码是如何跑起来的?》中介绍了进程启动过程。当进程开始调用exec加载可执行文件进程时,会为进程栈申请一块4KB的初始内存。今天就把这个逻辑提炼出来看一下。加载系统调用execve依次调用do_execve和do_execve_common完成可执行程序的实际加载。//file:fs/exec.cstaticintdo_execve_common(constchar*filename,...){bprm_mm_init(bprm);...}会在bprm_mm_init中申请一个新的地址空间mm_struct对象,预留给新进程使用。//file:fs/exec.cstaticintbprm_mm_init(structlinux_binprm*bprm){//申请新的地址空间mm_structobjectbprm->mm=mm=mm_alloc();__bprm_mm_init(bprm);}也会给新进程的栈申请一个page-sized的虚拟内存空间作为新进程的栈内存。申请完成后,将栈的指针保存到bprm->p中,记录下来。//file:fs/exec.cstaticint__bprm_mm_init(structlinux_binprm*bprm){bprm->vma=vma=kmem_cache_zalloc(vm_area_cachep,GFP_KERNEL);vma->vm_end=STACK_TOP_MAX;vma->vm_start=vma->vm_end-PAGE_SIZE;...bprm->p=vma->vm_end-sizeof(void*);}我们平时调用的进程虚拟地址空间,在Linux中由每个vm_area_struct对象表示。每个vm_area_struct(也就是上面__bprm_mm_init函数中的vma)对象代表了进程虚拟地址空间中的一个范围,它的vm_start和vm_end代表了启用的虚拟地址范围的开始和结束。//file:include/linux/mm_types.hstructvm_area_struct{unsignedlongvm_start;无符号长vm_end;...}需要注意的是,这只是地址范围,并不是真正的物理内存分配。在上面的__bprm_mm_init函数中,通过kmem_cache_zalloc申请了一个vma内核对象。vm_end指向STACK_TOP_MAX(地址空间顶部附近的位置),在vm_start和vm_end之间留下一个Pagesize。也就是说默认为栈准备了4KB的大小。最后将栈的指针记录到bprm->p。接下来,进程加载过程将使用load_elf_binary真正开始加载可执行二进制程序。加载时,会将之前准备好的进程栈的地址空间指针设置为新的进程mm对象。//file:fs/binfmt_elf.cstaticintload_elf_binary(structlinux_binprm*bprm){//ELF文件头解析//程序头读取//清除父进程继承的资源retval=flush_old_exec(bprm);...current->mm->start_stack=bprm->p;}这样以后新进程就可以使用栈进行函数调用和申请局部变量了。前面说过,这里只是为栈申请地址空间对象,并没有真正申请物理内存。我们来看看物理内存页是什么时候分配的。2.物理页的申请当进程在运行过程中开始分配和访问栈上的变量时,如果物理页还没有分配,就会触发缺页中断。物理内存实际上是在页面错误中断中分配的。为避免篇幅过长,触发缺页中断的过程不再展开。我们直接看一下缺页中断的核心处理入口__do_page_fault,它位于arch/x86/mm/fault.c文件下。//file:arch/x86/mm/fault.cstaticvoid__kprobes__do_page_fault(structpt_regs*regs,unsignedlongerror_code){...//根据新地址找到对应的vmavma=find_vma(mm,address);//如果找到的vma起始地址小于address//那么直接调用expand_stack,调用if(likely(vma->vm_start<=address))gotogood_area;...if(unlikely(expand_stack(vma,address))){bad_area(regs,error_code,address);返回;}good_area://调用handle_mm_fault完成真正的内存申请可变地址地址。接下来调用if(vma->vm_start<=address)是判断地址空间是否足够。如果栈内存vma的起始值小于要访问的地址,则证明地址空间足够,只需要分配物理内存页即可。如果栈内存vma的起始地址大于要访问的地址,则需要先调用expand_stack扩展栈虚拟地址空间vma。扩展虚拟地址空间的细节将在第三节中讨论。这里假设要访问的变量地址在栈内存中vma对象的vm_start和vm_end之间。然后缺页中断处理会跳转到good_area去运行。这里调用handle_mm_fault完成真正的物理内存申请。//file:mm/memory.cinthandle_mm_fault(structmm_struct*mm,structvm_area_struct*vma,unsignedlongaddress,unsignedintflags){...//依次查看每一级页表项pgd=pgd_offset(mm,地址);pud=pud_alloc(mm,pgd,地址);pmd=pmd_alloc(mm,pud,地址);pte=pte_offset_map(pmd,地址);returnhandle_pte_fault(mm,vma,address,pte,pmd,flags);}Linux使用四级页表来管理虚拟地址空间和物理内存之间的映射。因此,在实际申请一个物理页之前,需要先检查是否存在每一层需要的页表项。如果他们不存在,他们需要申请。为了区分,Linux还给每一级页表起了一个名字。Level1页表:PageGlobalDir,简称pgdLevel2页表:PageUpperDir,简称pudLevel3页表:PageMidDir,简称pmdLevel4页表:PageTable,简称pudpte看下图比较好理解//file:mm/memory.cinthandle_pte_fault(structmm_struct*mm,structvm_area_struct*vma,unsignedlongaddress,pte_t*pte,pmd_t*pmd,unsignedintflags){...//匿名映射页面处理returndo_anonymous_page(mm,vma,address,pte,pmd,flags);}在handle_pte_fault中会处理多种内存页面错误处理,比如文件映射页面错误处理,swap页面错误处理、copy-on-writepagefault处理、匿名映射Page处理等几种情况。我们今天讨论的话题是栈内存,对应匿名映射页面处理,会进入do_anonymous_page函数。//file:mm/memory.cstaticintdo_anonymous_page(structmm_struct*mm,structvm_area_struct*vma,unsignedlongaddress,pte_t*page_table,pmd_t*pmd,unsignedintflags){//分配可移除的匿名页,底层通过alloc_pagepage=alloc_zeroed_user_highpage_movable(vma,地址);...}在do_anonymous_page中调用alloc_zeroed_user_highpage_movable分配一个可移动的匿名物理页。在底层会调用伙伴系统的alloc_pages来分配实际的物理页。内核使用伙伴系统来管理所有物理内存页面。当其他模块需要物理页时,会调用伙伴系统提供的函数申请物理内存。关于伙伴系统,我们在内核内存管理一文中有详细介绍。感兴趣的同学可以移步本文了解更多。至此,开头的问题有了答案。栈的物理内存是什么时候分配的?进程加载时,只会分配一个地址空间范围给新进程的栈内存。真正的物理内存是在访问时触发缺页中断后向伙伴系统申请的。3.栈的自动增长正如我们之前看到的,当进程加载并启动时,栈内存默认只分配了4KB的空间。那么随着程序的运行,当栈中保存的调用链和局部变量越来越多时,必然会超过4KB。回头看了看页错误处理函数__do_page_fault。如果栈内存vma的起始地址大于要访问的地址,则需要先调用expand_stack扩展栈虚拟地址空间vma。回顾一下__do_page_fault的源码,我们可以看到扩展栈空间是由expand_stack函数完成的。//file:arch/x86/mm/fault.cstaticvoid__kprobes__do_page_fault(structpt_regs*regs,unsignedlongerror_code){...if(likely(vma->vm_start<=address))gotogood_area;//ifstackvma起始地址大于address,需要扩栈返回;}good_area:...}让我们看看expand_stack的内部细节。实际上,Linux栈的地址空间有两个方向增长,一个是从高地址到低地址,一个是反之。在大多数情况下,增长是从高到低。本文仅以向下增长为例。//file:mm/mmap.cintexpand_stack(structvm_area_struct*vma,unsignedlongaddress){...returnexpand_downwards(vma,address);}intexpand_downwards(structvm_area_struct*vma,unsignedlongaddress){...//计算扩展栈的最终大小size=vma->vm_end-address;//计算需要扩展多少页grow=(vma->vm_start-address)>>PAGE_SHIFT;//判断是否允许扩容acct_stack_growth(vma,size,grow);//如果允许则开始扩展vma->vm_start=address;return...}在expand_downwards中进行了几次计算。计算新的堆栈大小。计算公式为size=vma->vm_end-地址;计算需要增加的页数。计算公式为grow=(vma->vm_start-address)>>PAGE_SHIFT;然后会判断本次是否允许扩展栈空间,判断在acct_stack_growth中完成。如果允许扩展,只需修改vma->vm_start即可!我们看acct_stack_growth做的限制判断。//file:mm/mmap.cstaticintacct_stack_growth(structvm_area_struct*vma,unsignedlongsize,unsignedlonggrow){...//检查地址空间是否超出限制if(!may_expand_vm(mm,grow))return-ENOMEM;//检查是否超过堆栈大小限制if(size>ACCESS_ONCE(rlim[RLIMIT_STACK].rlim_cur))return-ENOMEM;...return0;}在acct_stack_growth中,只是做了一系列的判断。may_expand_vm判断这些页增长后是否超出了整体虚拟地址空间大小的限制。rlim[RLIMIT_STACK].rlim_cur记录堆栈空间大小的限制。可以通过ulimit命令查看这些限制。#ulimit-a......maxmemorysize(kbytes,-m)unlimitedstacksize(kbytes,-s)8192virtualmemory(kbytes,-v)unlimited以上输出表明没有限制大小虚拟地址空间和堆栈空间限制为8MB。如果进程堆栈大小超过此限制,则返回-ENOMEM。如果觉得系统默认的大小不合适,可以用ulimit命令修改。#ulimit-s10240#ulimit-astacksize(kbytes,-s)10240本文开头的第二个问题也有答案。堆栈的大小限制是多少?这个限制可以调整吗?进程栈大小的限制在每台机器上都不一样,可以通过ulimit命令查看,也可以用这个命令修改。至于开篇问题3,当栈溢出时,应用程序会发生什么?随便写一个简单的无限递归调用,你可能遇到过。错误结果是'Segmentationfault(coredumped)Thisarticleissummarized对本文的内容进行总结。本文讨论进程栈内存的工作原理。首先,进程加载时,为进程栈申请一个虚拟地址空间vma内核对象。在vm_start和vm_end之间留了一个Page,也就是说默认为栈准备了4KB的空间。其次,当进程在运行过程中开始分配和访问栈上的变量时,如果物理页面还没有分配,就会触发缺页中断。在页面错误上调用内核的伙伴系统实际上分配物理内存。第三,当栈中存储超过4KB时,会自动扩容。但是大小是有限制的,可以通过ulimit-s查看和设置它的大小限制。请注意,今天我们谈论的是进程堆栈。线程堆栈与进程堆栈有些不同。以后有空再单独看线程栈。在回顾和总结中,我们一开始就提出了三个问题:问题一:栈的物理内存是什么时候分配的?进程加载时,只会分配一个地址空间范围给新进程的栈内存。真正的物理内存是在访问时触发缺页中断后向伙伴系统申请的。问题2:堆栈的大小限制是多少?这个限制可以调整吗?进程栈大小的限制在每台机器上都不一样,可以通过ulimit命令查看,也可以用这个命令修改。问题三:当栈溢出时,应用程序会发生什么?当堆栈溢出时,我们会收到错误“Segmentationfault(coredumped)”。最后,让我们一起思考一下。为什么你认为内核应该限制进程栈的地址空间?