签完offer后感觉人一下子就懒惰下来了,整天除了上课答个到就是跟群里同样签了offer的同学一起瞎聊,最近几天终于又静下心来拿起书继续看。
前几天读到第三部分内存管理的第16章线程的堆栈时看到作者提到了“C/C++运行期库的堆栈检查函数”,作者说这个函数叫StackCheck,并且给出了一段C语言代码来实现这个函数,昨天下午自己写了个程序测试了半天,分别定义了一些大小为40KB、80KB和320KB的数组,却发现线程被创建后已经是将比数组所需更大的栈内存提交到了物理存储器(即使只定义不使用这个数组),连我自己都不相信Windows和VC编译器会这么傻……在微博和群里问了很多人也没得到答案,今天起床后又实验的时候突然发现昨天我测试的都是debug生成的程序,换成release生成程序,果然跟做着所说的情况一样了!那到底编译器把这个函数插入到哪里了呢?这个函数的内容究竟是什么?自己调试一下吧。
点击http://files.cnblogs.com/pianoid/CrtCheckStack.rar可以下载我编写的实验代码和和release生成的exe文件。
为了查看线程堆栈中的保留和提交的内存大小,还需要使用Sysinternals提供的程序VMMap,如果你想直接下载可以点击http://download.sysinternals.com/Files/vmmap.zip。
测试代码很简单,首先通过char arr[44*1024];来声明一个44KB大小的数组,这个数组应该在本线程的栈里分配,然后我们给它赋值并访问:
arr[40*1024+2] = 'a'; //x86下4KB为一页,现在访问第11页
arr[40*1024+3] = '\0';
MessageBox(hwndDlg, &arr[40*1024+2], "Msg", MB_OK); //使用一下,否则编译器会优化掉上两句
运行程序后使用VMMap查看一下,可以看到:
此进程中只有一个线程,堆栈总大小是1024KB即1MB,其中高48KB已经提交到了物理存储器,保护属性是可读可写。再低4KB的一页内存保护属性多了一个Guard,也就是多了一个PAGE_GUARD标志,这个标志的作用就是当访问该页面的内容时会产生一个异常_XCPT_UNABLE_TO_GROW_STACK,操作系统捕获该异常后就会知道这个线程已经用光了已分配的48KB的栈,操作系统就会再多分配一些供我们的线程使用,毕竟物理存储器是要节省着用的,如果每个线程都将全部1MB的栈提交就太浪费了,有很多线程根本用不了这么多。那为什么是4KB呢?因为这是x86下一个内存页面的大小,提交到物理存储器时只能以页面为单位提交。再低972KB只是分配了地址空间,并没有提交到物理存储器中,所以保护属性是Reserved,这个通常被翻译为保留,我觉得如果翻译为预留或者备用更贴切:-)
或许你已经从上面这段话里发现了问题:如果我们的线程用完了已经提交的栈内存之后,并没有访问下一页面,而是访问了下一页面的下一页面呢?毕竟只有那一个页面有PAGE_GUARD标志而已,如果跳过了它会不会出错?《Windows核心编程》中所说的StackCheck函数就是为了防止出现这种情况的――如果我们声明的变量大小超过了一个内存页面,在x86平台下也就是超过了4KB的话,编译器将会插入一个函数来每经过4KB就尝试访问一下,以触发上段提到的那个异常,这样操作系统就会再提交一页内存,并将再下一个页面的内存提交并添加PAGE_GUARD标志,如果超过8KB那就进行两次这种操作……直到能完整的容纳我们的变量为止,在我编写的程序中,CheckStack函数将会至少执行11次这种操作,因为我们访问到了第11页,那到底是在哪里执行的?执行了多少次?如何实现的呢?
现在点击DO按钮进行赋值和使用,然后切换到VMMap里点F5刷新一下,看到的结果如下图:
显然,现在比上图的时候又多提交了48KB的内存到了物理存储器,虽然我们的数组只需要44KB,但是还是多提交了4KB以备万一。因为如果只提交刚好足够的内存的话,万一我们在本函数里又调用了另一个函数呢?那栈空间又不够了,还得再触发一次异常才行,不如这次就多提交一个页面了。比如我的代码里确实又调用了EndDialog函数和MessageBox函数,就会引起上面所说的的情况。我故意将数组声明放在了DialogProc函数中,你如果愿意实验的话可以试一下把这个数组声明改到“case IDC_DO:”语句的下面,这样测试的话就会发现只提交了44KB,而不是现在看到的48KB。
现在我们把这个程序拖到OllyDbg里调试,刚开始调试时就再用VMMap查看一下(如果你有装了一些OD插件,特别是StrongOD之类的插件,先把它们剪切到别的地方,否则VMMap会因为访问不了这个进程而卡死),就会发现现在只提交了40KB的栈内存,在OD里按F9使程序运行,运行后切换回VMMap刷新一下就看到第一张图那样了,又提交了两页8KB的栈内存。如果你直接按Ctrl+G跳转到DialogProc的话就能看到像下面这样:
如果你仔细看了这个函数名的话应该可以认出来,这里的chkstk函数不就是《Windows核心编程》中提到的StackCheck函数么?只可惜我一开始调试时直接跑到响应ID_DO按钮消息的地方下断点去了,结果直接单步跟踪到地老天荒又跟回来了!不过如果直接在0x00401000地址或者win32._chkstk函数内部下断点的话会疯狂的被断下来,我们根本没机会看到窗口……那还是像我一开始做的那样,在响应WM_COMMAND消息的地方下断点吧,从上图可以看到,地址是0x00401064,下断后F9运行程序,然后点击程序的DO按钮会被断下,再按Ctrl+G找到上图的0x00401000处下断点,再F9就会被断在这里了。
然后我们分析一下这个函数,首先来看B004这个数字是怎么回事,用计算器换算一下,0xB004=45060,也就是44*1024+4,这个数字得减去一个4KB的内存页面,我在上面提到了,编译器会多给提交4KB以备万一,那也就是说应该是40*1024+4,熟悉么?看看最上面的代码,arr[40*1024+2] = 'a',显然,编译器为我们进行了4字节的数据对齐成了40*1024+4,然后多加了4*1024字节备用,就把0xB004通过eax寄存器传递给了chkstk函数,不过通过eax寄存器传递参数可不符合stdcall调用约定啊,我纳闷了老半天才明白,原来这个函数是用汇编语言写的!那stdcall调用约定岂不就是浮云,只要这个函数的编写者知道参数怎么传递就行了。
下面来看这个函数的代码:
00401870 push ecx ;保护ecx寄存器
00401871 lea ecx, dword ptr ss:[esp+4] ;获得执行上一条指令前的栈底地址(esp)
00401875 sub ecx, eax ;栈底地址减去需要的地址得到新的栈底地址(因为栈从高地址向低地址生长)
00401877 sbb eax, eax ;如果CF标志位为1说明没有足够的栈空间供提交了,产生栈溢出异常,下面有解释
00401879 not eax ;如果栈溢出,将eax置0,否则为0xFFFFFFFF
0040187B and ecx, eax ;如果栈溢出,ZF标志位置1
0040187D mov eax, esp ;获得当前栈底地址
0040187F and eax, FFFFF000 ;当前栈底地址按4KB向高地址对齐
00401884 /cmp ecx, eax ;新栈底地址是否小于当前栈底地址?
00401886 |jb short win32.00401892 ;小于,那就说明还没提交完,跳过去继续触发异常
00401888 |mov eax, ecx ;到了这里说说明已经提交了足够大小的栈,保存新的栈底地址
0040188A |pop ecx ;恢复ecx寄存器
0040188B |xchg eax, esp ;设置新栈底地址
0040188C |mov eax, dword ptr ds:[eax] ;取回老栈底存放的返回地址
0040188E |mov dword ptr ss:[esp], eax ;将返回地址保存到新栈底以用于下一条指令正确的返回
00401891 |retn
00401892 |sub eax, 1000 ;向下一个内存页面4KB
00401897 |test dword ptr ds:[eax], eax ;尝试访问,这里会触发异常,OS捕获后处理该异常以使栈增长
00401899 \jmp short win32.00401884 ;跳回去再比较是否提交了足够的大小
我已经为上面的反汇编代码添加了足够详细的注释,所以就不再多解释了,只说一下如果栈空间不够的情况吧。如果栈空间不够了,那将会造成回卷,也就是说新栈底地址大于老栈底地址,执行sbb eax,eax指令时由于CF标志位为1导致eax的值为0xFFFFFFFF,下一条not eax使eax为0,然后and ecx,eax就使ecx为0,也就是说使新栈底地址为0,那么cmp ecx,eax指令执行时,ecx永远都是小于eax的,所以jb short win32.00401892指令时会一直成立,因此sub eax,1000指令也会一直执行,直到eax的值减为0x00030000,这个4KB的页面是不会被提交的,也就是说保护属性将一直是Reserved,在通过test指令访问这个地址自然就被操作系统发现了,然后产生栈溢出异常,这个异常发生时,操作系统就直接将我们的整个进程(而不是当前线程)直接咔嚓掉,连弹个窗口的机会都不会给!
来看一下栈溢出时VMMap里显示的结果:
现在已经完整地介绍了《Windows核心编程》中提到的StackCheck函数的具体实现,编译器自动插入的位置就是我们声明该变量的位置,该函数的原理就是从高地址到低地址每隔一个内存页面尝试访问一下以触发_XCPT_UNABLE_TO_GROW_STACK异常,操作系统捕获该异常后提交该页面,并将再下一页面的保护属性添加PAGE_GUARD标志,直到提交了足够我们使用的栈空间函数返回。这个函数在《Windows核心编程》中被称为StackCheck,但是在VS2005中它叫ChkStk,默认情况下这个函数存放在C:\Program Files\Microsoft Visual Studio 8\VC\crt\src\intel\chkstk.asm文件中,你应该也能在自己的编译器中找到它。