1. 引言
很多实时非linux系统的栈都是固定而且很小,但申明的局变变量过大时就会出现栈溢出导致非法改写问题。linux的栈有个自动增长的属性,那么linux的栈是如何保证自动增长的呢?这个自动增长有上限吗?
栈相对于堆也显得更加神秘,因为堆内存通常都会调用malloc函数去申请,调用free去释放,开发者至少有显示地去申请和释放它,解决过内存改写和内存泄露的人也会深入到c库的内存申请函数去看看malloc,free的实现,也些大多数人都会知道堆内存是怎么来的。但是对应栈就神秘多了,因为栈变量只管申明就可以了,系统会去申请,有点像脚本语言,只管申明这个类型然后使用就可以了(当然有些脚本语言都不需要申明其类型)。那么栈内存的本质是什么呢?它在内核到底是一种什么样的存在呢?除了我们经常从书上了解的栈指针自动向下或自动向上增长这个理论上的知识点?
本文从简单的例子出发一直到内核对应的代码分析及跟踪确认了详细了解linux栈的自动增长的特点及内核实现的原理。
先将结论总结在这儿:
a)linux内核支持栈的自动增长,这个自动增长是在8M这最大值下的(可配置)的自动增长,不是无限增长。
b)当超过最大值时出现栈空间溢出, 用户态出现的错误是段错误问题,因为此时返回的地址是一个不存的没有映射过的地址。读写都会出现异常。
c)运行过程栈的自动增长都发生在写这个时候,同时遵循写时复制的原则。先扩展线性地址空间,然后申请内存。
d)内核支持向下增长和向下增长两种试的栈,从_do_page_fault中实现可以看到只有向下增长的栈才支持自动增长栈空间。
e)对于向下增长这种方式,内核在内核除了将vma->vm_start不断调小,同时还需要更新到rsp寄存器。因为栈空间变大了,rsp的值反而变下了,
也就是开始地址反而变小, 所以称为向下增长。
f)栈空间内存本身是mmap内存本身在本质上没有区别,都是一段特理内存,有自己的vma, 写时申请真正的物理内存。区别栈空间的线性地址内核自动维护。
2, 从简单例子出发
void bigstack() {
int n = 2;
int stackLen= 1024*1023 - 8* n;
long llBuf[stackLen];
llBuf[0]='z';
llBuf[stackLen-1]='p';
}
int main(int argc, char** argv) {
bigstack();
return 0;
}
上面的例子很简单,就是申请一个大的局部变量数组,我测试的机器上sizeof(long)=8, 所以当调用到bigstack这个函数到使用llBuf这个局部数组时,栈空间的变量基于就在8M附近了。
8M这个大小是因为我事先对linux的栈的默认最大大小理解,所以很快就指定了这个值。
如果不知道也可以基于二分法思想不断地尝试直到找到这个长度值,如可以创始假设这个值的大小是1M,如果不出现异常就让sackLen的值变为2M,4M...,直到出现段错误。
linux上栈的大小有多大可以通过ulimit -s命令或者是ulimit -a命令快速得知,我的机器上得到刚好是默认大小8192K=8M
stack size (kbytes, -s) 8192
上面的代码中将stackLen赋值成1024*1023-8*n是为了在gdb中方便调试,如基于二分法思想不断调整n的大小看看n大于哪个值就会出现死机。
3. 基于简单例子的调试分析
如何基于gdb分析调试问题是一项区别于常入的调试技巧,这儿我将我的详细分析步骤写下来方便对此技巧感兴趣的人来了解和掌握
3.1)编译:
gcc -g teststack.c -o teststack
3.2)gdb启动调试
gdb ./teststack
3.3) 设置断点
b 2
3.4) 运行
输入 r 运行
结果看到如下结果:
Program received signal SIGSEGV, Segmentation fault.
0x000000000040118b in bigstack () at teststack.c:5
5 llBuf[0]='z';
从这个结果可以知道写llBuf[0]时出现了段错误,所以肯定是llBuf[0]的地址肯定是一个不能访问的地址,而不能访问的原因是因为什么呢?
先基于gdb打印看看此地址:
(gdb) p &llBuf[0]
$4 = (long *) 0x7fffff7feb40
再打印一下此地址对应的值:
(gdb) p llBuf[0]
Cannot access memory at address 0x7fffff7feb40
直接提示此地址不能访问,也就是说此地址不能读也不能写,为什么既不能读也不能写呢?
3.5)基于maps信息从用户态确认出现段错误的原因
假设进程的pid=10017
cat /proc/10017/maps
这儿只将0x7fffff7feb40地址附近的列出来分析
7ffff7fd5000-7ffff7fd6000 r--p 00000000 08:13 134229 /usr/lib/x86_64-linux-gnu/ld-2.28.so
7ffff7fd6000-7ffff7ff4000 r-xp 00001000 08:13 134229 /usr/lib/x86_64-linux-gnu/ld-2.28.so
7ffff7ff4000-7ffff7ffc000 r--p 0001f000 08:13 134229 /usr/lib/x86_64-linux-gnu/ld-2.28.so
7ffff7ffc000-7ffff7ffd000 r--p 00026000 08:13 134229 /usr/lib/x86_64-linux-gnu/ld-2.28.so
7ffff7ffd000-7ffff7ffe000 rw-p 00027000 08:13 134229 /usr/lib/x86_64-linux-gnu/ld-2.28.so
7ffff7ffe000-7ffff7fff000 rw-p 00000000 00:00 0
7ffffffdc000-7ffffffff000 rw-p 00000000 00:00 0 [stack]
0x7fffff7feb40地址在最行两行地址之间,也就是说它比到处第二行的尾地址7ffff7fff000这个地址大,比到处第一行的开始地址7ffffffdc000小。
所以0x7fffff7feb40是一个没有映射成功的线性地址。
为什么没有映射成功后面看了内核的代码就知道了,先看看映射成功的状态。
3.6)基于二分法修改n的值,查看成功状态下的内存映射
由于 n=2 段错误,偿试n=4,n=8, n=16 , n=32
结果发现n=32时不再段错误。
下个点 n = (16+32)/2=24, 段错误
下个点 n = (16+24)/2=20, 段错误
下个点 n = (20+24)/2=22, OK
下个点 n = (22+20)/2=21, OK
所以临界点已经找到n=20 段错误 ,n=21 OK
看看n=21时此时的map状态 ,它表进当这个变量的空间大于 8*(1024*1023-8*21)就出现栈溢出了。
通过maps表看到的地址映射情况如下:
7ffff7ffd000-7ffff7ffe000 rw-p 00027000 08:13 134229 /usr/lib/x86_64-linux-gnu/ld-2.28.so
7ffff7ffe000-7ffff7fff000 rw-p 00000000 00:00 0
7fffff7ff000-7ffffffff000 rw-p 00000000 00:00 0 [stack]
看看7fffff7ff000-7ffffffff000这个地址是多大的地址范围。
(gdb) p -0x7fffff7ff000+0x7ffffffff000
$24 = 8388608
刚好是8M,所以栈的空间已经达到极限
此时变量的地址如下:
(gdb) p &llBuf[0]
$22 = (long *) 0x7fffff7ff000
(gdb) p &llBuf[stackLen-1]
$23 = (long *) 0x7fffffffcab8
可以看到llBuf[0]的地址已经指向栈的最顶端了。
栈寄存器rsp的值如下:
(gdb) p $rsp
$26 = (void *) 0x7fffff7ff000
所以以看到这个堆栈是向下增长的,即rsp的值会越来越小,从7ffffffff000逐渐减小到7fffff7ff000.
总结:
通过用户态的段错误分析可以得到如下几个结论:
a)linux的栈空间自动增长是在8M这个配置值的情况下自动增长,超过了8M也会报错。
b)linux内核一直遵循着使用时申请的原则,即写时copy的原则,对栈也是如此,用多少申请多少。
所以栈的内存申请在内核估计也是基于缺页的原理来申请内存,待后面内核代码确认。
c)当前看到的栈内存是自动向下增长方式实现,即rsp这个栈顶指针的大小从大到小方向变化,地址值越来越小。
d)8M栈空间以线程为单位,不是某个函数用8M的这间,而从此线程启动起来开始计算,如上面的测试中发现运行到申请llBuf变量时
栈空间的变量已经达到了140K=7ffffffdc000-7ffffffff000
4)内核实现分析
用户态的表现已经知道了,现在看看内核态的实现
4.1)找到分析的入口
由于栈空间内存没有显式内存申请函数,因此内核从 RLIMIT_STACK 这个宏的使用开始分析
先搜索这个宏的定义
grep -rns --include=*.h "RLIMIT_STACK" .
发现它在./include/uapi/asm-generic/resource.h:19
:#define RLIMIT_STACK 3 /* max stack size */
再搜索内核使用它的地方:
zhoupeng@zhoupeng-PC:~/linux/code/uos-x86-kernel/x86-kernel$ grep -rns --include=*.c "RLIMIT_STACK" .
结果发现了mmap.c中的这行代码,
./mm/mmap.c:2269: if (size > rlimit(RLIMIT_STACK)) {
当然其它地方也是,但是基于前面的分析发现栈空间的内存也是一种缺页方式来申请内存的,所以栈内存的申请很有可能是先找到申请线性地址空间,然后写内存时基于do_page_fault原理来申请内存。
因此先分析mmap.c:2269这行代码对应的函数, 这行代码对应的函数就是acct_stack_growth
4.2)acct_stack_growth函数实现分析
static int acct_stack_growth(struct vm_area_struct *vma,
unsigned long size, unsigned long grow)
{
struct mm_struct *mm = vma->vm_mm;
unsigned long new_start;
//先检查是否还有性线地址空间,空间线性地址空间都没有,那么堆栈也就无法增长了
if (!may_expand_vm(mm, vma->vm_flags, grow))
return -ENOMEM;
//检查新的堆栈大小是否超过限制,如果超过了,返回没有内存了,这儿的rlimit(RLIMIT_STACK)默认为8M
if (size > rlimit(RLIMIT_STACK)) {
return -ENOMEM;
}
//这个计算需要理解,可以看到如果是向上增长,那么开始地址就取vma_start的地址。
//如果是向下增长,那么开始地址就将尾地址vm_end减去size
new_start = (vma->vm_flags & VM_GROWSUP) ? vma->vm_start :
vma->vm_end - size;
//x86,arm,mips都返回0,对于这些常用平台不会成为堆栈增长的绊脚石
if (is_hugepage_only_range(vma->vm_mm, new_start, size))
return -EFAULT;
//做详细地权限检查
if (security_vm_enough_memory_mm(mm, grow))
return -ENOMEM;
return 0;
}
从上面的分析可以知道 acct_stack_growth只是做一些校验,当然这儿我们最关心的是大小检验。如果整个堆栈大小超过了rlimit(RLIMIT_STACK),从大小的限制来看是会返回错误的,
这儿的错误就是-ENOMEM.
要看到堆栈增长还需要继续查看调用acct_stack_growth的地方,对于向下增长的堆栈,这个函数是expand_downwards
4.3) expand_downwards 关键实现分析
该函数的原型如下:
int expand_downwards(struct vm_area_struct *vma, unsigned long address);
该函数的处理逻辑如下:
a)首先判断现在需要返回的地址是不是已经比vm_start小了,因为这个是堆栈向下增长模块,增长堆栈时需要不断调小vm_start的值。
当address比vm_start小时说明address不在[vm_start,vm_end]之间,那么此时的目标就是减少vm_start的值,保证address在它们两者之间
b)如果在两者之间则什么都不做,否则开始调整。首先计算出新的堆栈大小。计算公式就是size = vma->vm_end - address;
c)计算需要增长的页数,计算公式是grow = (vma->vm_start - address) >> PAGE_SHIFT;
d) 保证需要偏移的页数比这个线性区可偏移的页数要小。这个判断是 grow <= vma->vm_pgoff
e)如果没有超出vm_pgoff的偏移值,那么调用acct_stack_growth进行权限及大小验证。该函数已经在4.2)节详细说明,如果该函数返回0表示检验成功,
否则返回-ENOMEM错误。退出
f)对mm->page_table_lock加锁, 这说明需要修改页表。更新内存统计,如果vma->vm_flags 带了 VM_LOCKED标志,那么更新被锁内存大小。
mm->locked_vm += grow;
g)调用vm_stat_account更新mm->stack_vm的计算值。
mm->stack_vm += grow;
h)由于vma这个线性区结构估的地址值马上要被修改了,修改之前需要先成一些内部红黑树中移除,这个调用anon_vma_interval_tree_pre_update_vma完成
i)最关键的代码到来,开始更新vma->vm_start地址,让它等于address, 这个就是栈的开始地址发随着address来调整了。
然后更新vma->vm_pgoff这个总偏移值。
vma->vm_start = address;
vma->vm_pgoff -= grow;
j)再次调用anon_vma_interval_tree_post_update_vma将修改属性后的vma重新插入到内部红黑树中。
k)对 m->page_table_lock解锁
l)调用perf_event_mmap记录这一次map事件,方便perf进行性能分析
总结:
expand_downwards最核心的功能就是根据新的addrss完成了这个栈线性区地址的更新。需要理解的是这个是mmap的本质:申请线性地址,但没有内存申请,内存申请基于写时copy来申请。
现在的疑问变成了expand_downwards到底是什么时候调用的?和上面的用户态代码是什么关系呢?需要继续向前跟踪,是谁调用了expand_downwards函数
4.4) 谁调用了expand_downwards函数
#ifdef CONFIG_STACK_GROWSUP
int expand_stack(struct vm_area_struct *vma, unsigned long address)
{
return expand_upwards(vma, address);
}
#else
int expand_stack(struct vm_area_struct *vma, unsigned long address)
{
return expand_downwards(vma, address);
}
#endif
可以看到堆栈到底是向上增长还是向下增长在编译阶段就确定了,目当CONFIG_STACK_GROWSUP这个配置没有打开,默认都是用向下增长的方式。
4.5)谁调用了 expand_stack
通过搜索的方式找到了最有可能的代码,如下:
./arch/x86/mm/fault.c:1359: if (unlikely(expand_stack(vma, address))) {
调用这条语句的代码就是_do_page_fault函数,这个和上面的分析非常一致,因为上面是扩展线性地址空间,而扩展线性地址空间的原因是因为之前的内存不存了,需要扩展地址然后申请内存。
该函数中的最重要的几步如下:
a) 根据新的address调用find_vma找到对应的vma
vma = find_vma(mm, address);
b) 如果找到的vma的开始地址比address小,那么就不调用expand_stack了,直接调用handle_mm_fault去申请内存
if (likely(vma->vm_start <= address))
goto good_area;
c)判断找到的vma的开始地址是否比address大。如果比address小,那么意味着这个address在[vm_start, vm_end]之间,是一个正常的缺页处理。
这儿包含了两个重要信息:
首先vma->vm_start比address大这种情况说明这个vma就是一个栈空间的vma, 因为正常的mmap访问肯定需要保证自己访问的地址在[vm_start, vm_end]之间。
第二个重要的信息就是这儿必须是向下增长的栈才可以支持,因为只有向下增长的栈才能用 vm_start是否比address大来判断。
所以向上增长的栈是不支持这种动态增长的,我想这也应该是栈为什么都默认编译成向下增长了。
d)如果vm_start > address, 那么现在需要确认堆栈是不是真的是向下增长的方式。如果不是那么意味着这个访问的地址是一个不正常的访问
if (unlikely(!(vma->vm_flags & VM_GROWSDOWN))) {
bad_area(regs, error_code, address);
return;
}
e)当上面的两步都过了后就开始调用expand_stack来扩展堆栈了。
expand_stack(vma, address)
f)最后调用handle_mm_fault来完成真正的内存申请
fault = handle_mm_fault(vma, address, flags);
总结:
从_do_page_fault中的实现可以知道如下几个重要知识点:
堆栈的自动增长主要发生在写时copy这个时刻。
只有自动向下增长的堆栈才支持自动增长,这个通过_do_page_fault这个函数的实现可以看出来。
5)内核打印堆栈确认
在内核的expand_downward函数中增加堆栈打印确认分析是否正确。同时将这时的堆栈大小也打印出来。
if (size > rlimit(RLIMIT_STACK)) {
if (g_zp_debug_stack & 2) {
printk("zhoupeng36-2 size=%d, max_stack_size=%d pid=%dn", size, rlimit(RLIMIT_STACK), current->pid);
if (g_zp_debug_stack & 64) dump_stack();
if (g_zp_debug_stack & 128) {
msleep_interruptible(1000*20);
}
}
return -ENOMEM;
}
得到的打印信息及堆栈信息如下:
[ 1061.377345] zhoupeng36 size=8392704, max_stack_size=8388608 pid=11007
[ 1061.377360] CPU: 4 PID: 4568 Comm: teststack Tainted: P OE 4.19.67+ #190
[ 1061.377360] Hardware name: Dell Inc. Precision 5520/0NKT5P, BIOS 1.3.4 06/08/2017
[ 1061.377361] Call Trace:
[ 1061.377366] dump_stack+0x5c/0x80
[ 1061.377368] expand_downwards.cold.37+0x3e/0x7c
[ 1061.377370] __do_page_fault+0x3ec/0x4f0
[ 1061.377373] page_fault+0x1e/0x30
总结:
很明显示堆栈增长的调用流程就是_do_page_fault --> expand_stack --> expand_downwards (向下增长,即rsp越来越小) -->acct_stack_growth
每个线程的默认的栈空间大小确实是8M=8388608
6)确认此时的用户态在做什么
最好的方式就是堆栈扩展时让内核态停下来,就是上面的这几行代码:
if (g_zp_debug_stack & 128) {
msleep_interruptible(1000*20);
}
以可中断的方式停止下来,这样gdb可以看到用户态的堆栈。
调试发现此时用户态就停在了这条语句上:
llBuf[0]='z';
此至整个问题已经全部理清。
7)将堆栈改成看看上面的测试代码是否能够成功运行
ulimit -s 16384
再次运行上面的代码,成功运行,因为堆栈空间已经支持到16M了。
8)总结
a)linux内核支持栈的自动增长,这个自动增长是在8M这最大值下的(可配置)的自动增长,不是无限增长。
b)当超过最大值时出现栈空间溢出, 用户态出现的错误是段错误问题,因为此时返回的地址是一个不存的没有映射过的地址。读写都会出现异常。
c)运行过程栈的自动增长都发生在写这个时候,同时遵循写时复制的原则。先扩展线性地址空间,然后申请内存。
d)内核支持向下增长和向下增长两种试的栈,从_do_page_fault中实现可以看到只有向下增长的栈才支持自动增长栈空间。
e)对于向下增长这种方式,内核在内核除了将vma->vm_start不断调小,同时还需要更新到rsp寄存器。因为栈空间变大了,rsp的值反而变下了,
也就是开始地址反而变小, 所以称为向下增长。
f)栈空间内存本身是mmap内存本身在本质上没有区别,都是一段特理内存,有自己的vma, 写时申请真正的物理内存。区别栈空间的线性地址内核自动维护。