理解进程栈内存的使用
1.进程栈的初始化
进程在启动后会调用exec加载可执行文件,会为进程分配4KB的初始内存。系统会通过do_execve
和do_execve_common
函数完成可执行程序的实际加载过程。其中,do_execve_common
会调用bprm_mm_init
来申请新的mm_struct
地址空间对象,为进程运行做好准备工作。
// file:fs/exec.c
static int bprm_mm_init(struct linux_binprm *bprm)
{// 申请一个全新的地址空间mm_struct对象bprm->mm = mm = mm_alloc();__bprm_mm_init(bprm);......
}
在申请完地址空间后,就会给新进程的栈申请一页大小的虚拟内存空间,作为给新进程准备的栈内存。申请完后把栈的指针保存到bprm->p
中记录起来。
// file:fs/exec.c
static int __bprm_mm_init(struct linux_binprm *bprm)
{bprm->vma = vma = vm_area_alloc(mm);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
结构体来表示。因此,栈内存的申请实际上只是创建一个描述地址范围的 vma
对象,而非直接分配物理内存。
在 __bprm_mm_init
函数中,内核会调用 vm_area_alloc 来创建一个用作栈的 vma
对象。该对象的 vm_end
指向 STACK_TOP_MAX
(地址空间顶部附近),而 vm_start
和 vm_end
之间预留了一个页(4KB)的空间,作为默认的栈大小。最终,栈指针会被记录在 bprm->p
中。
如下图所示:
在进程初始化栈的过程中,内核通过 vm_area_struct
结构体为栈区划定一段连续的虚拟地址空间:以 STACK_TOP_MAX
为高地址参考,向下预留 4KB 大小的虚拟地址范围作为初始栈空间,并通过 vm_start
和 vm_end
记录该范围的起止地址;同时,设置初始栈顶指针指向栈区的初始顶部,为后续函数调用等栈操作提供起始位置。这一步骤仅完成了栈的虚拟地址空间分配,后续进程实际使用栈时,才会通过页表机制将虚拟地址映射到物理内存(若物理内存尚未分配,会触发 “缺页异常”,由内核分配物理内存并建立映射)。
接下来的进程加载过程会使用 load_elf_binary
真正加载可执行二进制程序。在加载时,会把前面准备的进程栈的地址空间指针设置到新进程 mm 对象上,如下图所示:
task_struct
里的mm
指向mm_struct
,mm_struct
通过stack_start
关联栈区,vm_area_struct
的vm_start
和vm_end
界定了栈区在进程地址空间的范围,进程地址空间按高到低地址分布,栈区靠近高地址,以STACK_TOP_MAX
为高地址边界,初始有 4KB
大小,这样就完成了栈空间在进程内存管理结构中的初始化,为后续栈的使用奠定基础。
2.栈的自动增长
在进程启动加载时,栈内存默认仅分配4KB
空间。随着程序运行,调用链和局部变量的增加会导致栈使用量超出初始容量。此时,缺页处理函数__do_page_fault
将介入处理:当需要访问的地址address小于栈对应虚拟内存区域(vma)的起始地址时,系统会调用expand_stack
函数扩展栈的虚拟地址空间。__do_page_fault
函数的源码显示,栈空间扩展的具体实现正是由expand_stack
完成的。
// file:arch/x86/mm/fault.c
static inline
void do_user_addr_fault(..., unsigned long address)
{......if (likely(vma->vm_start <= address))goto good_area;// 如果vma的开始地址比address大,则判断VM_GROWSDOWN是否可以动态扩充if (unlikely(!(vma->vm_flags & VM_GROWSDOWN))) {bad_area(regs, hw_error_code, address);return;}// 对vma进行扩充if (unlikely(expand_stack(vma, address))) {bad_area(regs, error_code, address);return;}good_area:handle_mm_fault(vma, address, flags, regs);......
}
下图中展示了栈的自动增长逻辑:当进程地址空间中,访问的address超过了栈区原本由vm_area_struct
的vm_start
和vm_end
所界定的范围时,就会触发栈的扩充。此时会新增一个扩充区域,使栈的大小得以增加,以满足进程对栈空间的需求,整个进程地址空间按高地址到低地址(从STACK_TOP_MAX
到0x00000000
)分布,栈区及扩充的新区域处于接近高地址的部分。
在do_user_addr_fault
函数里,首先判断要访问的变量地址address
是否在vma
(虚拟内存区域)内部,若在内部,就调用handle_mm_fault
来分配实际的物理页。还有另一种逻辑:要是address
超出了vma
的范围(因为栈一般是从高地址向低地址增长的,当vma->vm_start
大于address
时,就说明栈空间不够用了),就会调用expand_stack
对栈进行扩充。
以下代码是expand_stack
函数内部实现细节:
// file:mm/mmap.c
int expand_stack(struct vm_area_struct *vma, unsigned long address)
{......return expand_downwards(vma, address);
}int expand_downwards(struct vm_area_struct *vma, unsigned long address)
{......// 计算栈扩大后的最后大小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 - address;
- 计算需要增长的页数。计算公式是
grow = (vma->vm_start - address) >> PAGE_SHIFT;
接着检查栈空间是否允许扩展,这一判断在acct_stack_growth
函数中进行。若允许扩展,只需调整vma->vm_start
的值即可。扩充的具体操作如下图所示:
在栈扩展过程中,内核通过vm_area_struct
结构体管理进程虚拟地址空间中的栈区域。当需要扩展栈空间时,系统会根据访问地址address
动态调整vma->vm_start
的值。从进程地址空间布局来看,栈区域位于高地址端(靠近STACK_TOP_MAX
),而低地址端起始于0x00000000
。通过下移vma->vm_start
边界,内核可以扩展栈区域的可用空间,从而满足进程运行时对栈空间的动态增长需求。
以下是acct_stack_growth
函数进行的操作:
// file:mm/mmap.c
static int acct_stack_growth(struct vm_area_struct *vma,unsigned long size, unsigned long grow)
{......// 检查地址空间是否超出限制if (!may_expand_vm(mm, grow))return -ENOMEM;// 检查是否超出栈的大小限制if (size > rlimit(RLIMIT_STACK))return -ENOMEM;......return 0;
}
在acct_stack_growth
函数中主要执行一系列条件判断。其中,may_expand_vm
用于检查增长后的页数是否超出虚拟地址空间的总大小限制;而rlim[RLIMIT_STACK].rlim_cur
则存储着栈空间大小的限制值。这些限制参数都可以通过ulimit
命令进行查看。
#ulimit -a
......
max memory size (kbytes, -m) unlimited
stack size (kbytes, -s) 8192
virtual memory (kbytes, -v) unlimited
系统显示当前虚拟地址空间大小无限制,而栈空间限制为8MB。若进程栈大小超过此限制,系统将返回-ENOMEM错误。如需调整默认设置,可通过ulimit命令进行修改。
#ulimit -s 10240
#ulimit -a
stack size (kbytes, -s) 10240
3.总结
本节深入解析进程栈内存的工作原理,主要分为三个关键机制:
(1)初始栈空间分配
进程加载时,内核会为其栈区域分配一个虚拟地址空间VMA对象。该对象默认预留4KB空间(一个Page大小),通过vm_start和vm_end指针界定范围。
(2)按需物理内存分配
进程运行期间,当首次访问栈上的变量时,若对应物理页尚未分配,会触发缺页中断。此时内核通过伙伴系统完成实际物理内存的分配。
(3)动态栈扩展机制
当栈使用量超过初始4KB时,系统会自动扩展栈空间。扩展上限可通过ulimit -s
命令查看和配置,确保栈增长处于可控范围内。