首页
社区
课程
招聘
[原创]栈又溢出了
发表于: 2020-11-1 21:20 5094

[原创]栈又溢出了

2020-11-1 21:20
5094

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

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

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

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

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

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

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

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

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

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

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

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

稍微解释几个关键点:

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

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

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

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

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

读取四字节(99行)。

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

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

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

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

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

 
 
#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;
}
#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
    {

[培训]内核驱动高级班,冲击BAT一流互联网大厂工作,每周日13:00-18:00直播授课

收藏
免费 2
支持
分享
最新回复 (4)
雪    币: 10734
活跃值: (7642)
能力值: ( LV5,RANK:70 )
在线值:
发帖
回帖
粉丝
2
这样应该不会爆,你在每个大括号内定义,当然是占用三份内存呀
    BigData data;
    if (argc == 2)
    {
        Use(&data);
    }
    else if (argc == 3)
    {
        Use(&data);
    }
    else
    {
        Use(&data);
    }
2020-11-1 23:41
0
雪    币: 918
活跃值: (1900)
能力值: ( LV3,RANK:30 )
在线值:
发帖
回帖
粉丝
3
这个就关乎之前作用域的设计思想了,局部变量在括号内声明必然会有一个实例,所以有时候可以继续沿用c的代码风格,先声明后使用
2020-11-2 12:45
0
雪    币: 8519
活跃值: (9122)
能力值: ( LV12,RANK:360 )
在线值:
发帖
回帖
粉丝
4
bluefish蓝鱼 这样应该不会爆,你在每个大括号内定义,当然是占用三份内存呀 BigData data; if (argc == 2) { Use(&data); ...
对,这样不会爆栈,release版有惊喜
2020-11-2 20:59
0
雪    币: 8519
活跃值: (9122)
能力值: ( LV12,RANK:360 )
在线值:
发帖
回帖
粉丝
5
wuxiwudi 这个就关乎之前作用域的设计思想了,局部变量在括号内声明必然会有一个实例,所以有时候可以继续沿用c的代码风格,先声明后使用
嗯,但是依我之见,这是完全可以避免的。我用 release版测试了,无此问题下一篇文章把这个设置揪出来
2020-11-2 21:01
0
游客
登录 | 注册 方可回帖
返回
//