栈又溢出了

编程难

共 3027字,需浏览 7分钟

 ·

2020-11-03 20:13

缘起

最近,项目代码再次出现了栈溢出问题。这次的栈溢出跟上次有点不同,调用栈不深,而且报错的时候函数代码还没开始执行。是不是有点“诡异”?一起来看看这次是什么原因导致的吧。

“诡异” 的栈溢出

运行程序后,异常发生了。对于程序崩溃,早就见怪不怪了。重启程序,附加调试器,再次执行相同的功能,果然中断到调试器中。有了上次的经验(没仔细看错误提示导致懵逼了很久,文章在这里),仔细检查了错误码,又是 0xC00000FD, stackoverflow。在 vs2013 中按 ctrl + alt + c 查看调用栈,发现调用栈并不深,没有递归调用的迹象。仔细看报错的位置,居然没有执行到任何代码。下图是我用测试代码截的图:

stackoverflow

函数调用的时候,会把参数、局部变量、返回地址等信息都存储在栈上,而栈空间默认只有 1 MB,如果调用栈帧太多,那么可能会用光这 1 MB,从而导致 stack overflow

小贴士:这里的 1 MB 不太精确,实际可用的栈空间比 1 MB 小,最后一个页面永远是不可用的。为了描述简单而且好记就这么描述了。

大胆猜测

调用栈并不深,难道就是这几个栈帧就把栈耗光了?简单浏览当前函数中的局部变量和参数,很快就找到了几个值得怀疑的局部变量。但是通过 sizeof 查看对应结构体大小后发现:虽然大(大概 400 KB),但是并没有大到爆栈的程度。继续观察,发现了一个很有意思的现象,这些变量在每个 if else 分支中都定义了一份,难道这些分支中的局部变量占用的栈空间被累加了?一个大概 400 KB3 个加起来就超过 1 MB 了(默认的栈大小是 1 MB),足以爆栈了!到底是不是这样的呢?

确认猜想

为了确认猜想是否正确,新建一个简单的测试工程,测试代码如下:

#include "stdafx.h"

struct BigData { char data[409600]; /* 400KB */ };

void Use(BigData* pData) printf("%c", pData[0]); }

void CorrpuptStackEasyly(int argc)
{
    if (argc == 2)
    {
        BigData data;
        Use(&data);
    }
    else if (argc == 3)
    {
        BigData data;
        Use(&data);
    }
    else
    {
        BigData data;
        Use(&data);
    }
}

int _tmain(int argc, _TCHAR* argv[])
{
    CorrpuptStackEasyly(argc);
    return 0;
}

BigData 大概占用 400 KB,如果猜想(三个 BigData 类型的局部变量会占用 1.2 MB 左右的空间)是正确的,那么这个函数应该会爆栈。编译运行,果真和猜想的一样——爆栈了!

stackoverflow

查看函数 CorrpuptStackEasyly 对应的反汇编,如下图:

alloca_probe

0x12C0DC 转换成十进制是 1229020 大概是 1.2 MB__alloca_probe 是编译器生成的函数,内部直接跳转到 _chkstk

__alloca_probe_chkstk

_chkstk

从名字可以很容易的猜出 _chkstk  是用来检查栈的。当函数中包含超大局部变量(大于等于一个页面, 4 KB)时,编译器会在函数头部插入一段检查栈是否够用的代码。

_chkstk 虽然是汇编代码写的,但是内部逻辑并不复杂,而且在安装 vs 的时候提供了带注释的源码,可读性极强。我机器上同时安装了 vs2010vs2013,可以在下图中的几个位置找到 _chkstk 对应的汇编代码文件 chkstk.asm,如下图:

_chkstk_asm_locations

因为这个函数超级精炼,有效汇编代码不到 20 行,这里截图放上来,感兴趣的小伙伴儿可以读一读:

chkstk_content

稍微解释几个关键点:

  1. EAX 记录了需要检查的栈大小,外部调用的时候需要设置好。

  2. ECX 记录最低地址(栈是从高向低扩展的)。(73,74 行)

  3. 根据 ESP 计算出当前地址所属页面的起始位置。(83,84 行)

  4. 判断是否结束,没结束则执行 5,6,7步。(87, 88 行)

  5. 减去 _PAGESIZE 得到下一页面的起始位置(98 行)

  6. 读取四字节(99行)。

    本行代码是关键,如果访问的地址所在的页面是保护页面(带有 PAGE_GUARD 属性)并且经判定不需要抛栈溢出异常,则会触发 STATUS_GUARD_PAGE_VIOLATION 异常(应该内部叫 _XCPT_GUARD_PAGE_VIOLATION,异常码是 0x80000001),操作系统会去除保护页面的保护属性,并分配物理内存,为下一个界面设置保护属性。

  7. 跳转到第四步(cs10 的位置),不断重复这个过程。(100行)

注意:创建线程的时候指定了一个栈保留大小(默认是 1MB),刚开始的时候这 1MB 并不是都对应着物理内存,是按需分配的。这里说的增长栈空间,并不是栈保留大小变大了,而是占用的物理页增多了。相信大多数小伙伴儿应该已经知道了,但是这里还是要啰嗦一句:访问某个虚拟地址的时候,只有当这个虚拟地址对应的页面有与之对应的物理页面才可以访问,否则会报访问异常。

排除问题

知道问题的根源后,解决就简单了。只需要消除重复的大局部变量即可。把分支中重复的变量提取到函数开始的位置即可。

void CorrpuptStackEasyly(int argc)
{
    BigData data;
    if (argc == 2)
    {
        Use(&data);
    }
    else if (argc == 3)
    {
        Use(&data);
    }
    else
    {
        Use(&data);
    }
}

深入思考

说实话,解决完这个问题后,我是震惊的!vs 真的就这么简单粗暴的把所有局部变量的大小累加起来为函数分配栈空间吗?这太太太不合理了!如果真是这样,分支多的函数太有可能出现栈溢出了。个人觉得合理的做法是:把分支中占用内存最大的作为分支部分的内存占用,加上其它不在分支中的局部变量的内存空间来为函数分配栈空间。

总结

  • 并不只有递归调用才会导致栈溢出,过度使用栈空间就会导致栈溢出。
  • 线程默认栈保留大小是 1 MB,如果确实需要使用大局部变量,请考虑在堆上分配,避免栈溢出的问题。
  • 如果函数内部有超大局部变量,编译器会在最开始的位置插入调用 _chkstk 的代码,来确保栈是可用的。如果栈不够用,则在检查的过程中就会抛出栈溢出错误,这也就是为什么在函数的一开始就报栈溢出的原因。


浏览 14
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报