咱们知道每一个线程初始堆栈的默认空间是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运行库, 再到操做系统, 从用户态到内核和驱动, 最后到硬件, 原理背后还有原理, 真正能掌握全部细节的又有几人呢?