我们知道每个线程初始堆栈的默认空间是1M, 我们可以在VC编译的Linker项里进行设置,该值会被编译进最终的PE可执行文件中。线程堆栈内存包括commit部分和reserver部分,我们上面说的1M实际上指reserve部分,系统为了节约内存,并不会把所有reserve的1M都提交物理内存(commit), 所以初始只是提交部分内存。
我们可以随便找一个程序,通过WinDbg进行验证:!address -f:stack
BaseAddr EndAddr+1 RgnSize Type State Protect Usage
---------------------------------------------------------------------------------------------
90000 184000 f4000 MEM_PRIVATE MEM_RESERVE Stack [~0; 16d8.13ec]
184000 185000 1000 MEM_PRIVATE MEM_COMMIT PAGE_READWRITE|PAGE_GUARD Stack [~0; 16d8.13ec]
185000 190000 b000 MEM_PRIVATE MEM_COMMIT PAGE_READWRITE Stack [~0; 16d8.13ec]
可以看到一个线程的堆栈分3部分:0xB000字节的MEM_COMMIT内存,0x1000字节的MEM_COMMIT & PAGE_GUARD内存,还有0xF4000字节的MEM_RESERVE内存,总共是0xB000+0x1000+0xf4000 = 0x100000 = 1M
通过实验,我们可以看到线程堆栈只提交(commit)了一部分内存,大部分内存是reserve的,现在的问题是堆栈在增长的过程中,它是如何提交(commit)内存的? 我们知道,我们在函数中申明一个N字节大小的局部变量,它就是在线程的堆栈中申请的空间(实际上只需要ESP-N)就可以了。我们如果观察过函数的反汇编代码,会注意到它没有commit内存相关的代码。 那么究竟最终它是如何commit那些reserve的内存的呢?
曾经面试被问到这个问题, 因为没有看过相关的书籍, 没回答出来...
最近思考这个问题, 终于在张银奎的<<软件调试>>里找到了答案:
系统在提交栈空间时会故意多提交一个页面,称这个页面为栈保护页面(Stack Guard Page), 这点我们可以在上面WinDbg的实验中验证。栈保护页面具有特殊的PAGE_GUARD属性,当具有如此属性的内存页被访问时,CPU会产生页错误并开始执行系统的内存管理函数,当内存管理函数检测到PAGE_GUARD属性后,会清除对应页面的PAGE_GUARD属性,然后调用一个名为MiCheckForUserStackOverflow的系统函数,这个函数会从当前线程的TEB中读取用户态栈的基本信息并检查导致异常的地址,如果导致异常的被访问地址不属于栈空间范围,则返回STATUS_GUARD_PAGE_VIOLATION,否则MiCheckForUserStackOverflow函数会计算栈中是否还有足够的剩余空间可以创建一个新的栈保护页面。如果有,则调用ZwAllocateVirtualMemory从保留的空间中在提交一个具有PAGE_GUARD属性的内存页。新的栈保护页与原来的紧邻,经过这样的操作后,栈的保护页向低地址方向平移了一位,栈的可用空间增大了一个页面的大小,这便是所谓的栈空间自动增长。
栈溢出是指当提交的栈空间再被用完,栈保护页又被访问时,系统便会重复以上过程,直到当栈保护页距离保留空间的最后一个页面只剩一个页面的空间时,MiCheckForUserStackOverflow函数会提交倒数第二个页面,但不再设置PAGE_GUARD属性,因为最后一个页面永远保留不可访问,所以这时栈增长到它的最大极限,为了让应用程序知道栈将用完,MiCheckForUserStackOverflow函数返回STATUS_STACK_OVERFLOW,触发栈溢出异常。
最后感概技术深了可以再深,从C++编译器到CRT运行库, 再到操作系统, 从用户态到内核和驱动, 最后到硬件, 原理背后还有原理, 真正能掌握所有细节的又有几人呢?
posted on 2014-10-12 22:03
Richard Wei 阅读(5431)
评论(3) 编辑 收藏 引用 所属分类:
windbg