stack 扩展机制
windows中,每个线程都关联一个stack,stack的默认大小是1M,用于存放临时变量,函数参数,返回地址等。
但是当一个线程开始运行的时候不是其相关stack的内存就真正被提交,因为如果一个进程有10个线程,那么如果这10个线程的stack的内存都被提交,那么虚拟内存就占用了10M,就需要想对应的页表项等开销,而且这10M到底是否被真的使用还是个未知数,所以系统的策略是只提交几个页面,然后通过一个guard page来实现按需提交。
先看一下GUARD_PAGE:
TEB at 7ffdf000 ExceptionList: 0013fd0c StackBase: 00140000 StackLimit: 0013e000 0: kd> dt _TEB 7ffdf000 ntdll!_TEB ...... +0xe0c DeallocationStack : 0x00040000 // 1M stack 范围 StackBase ~ DeallocationStack
0: kd> .formats(0x140000-0x40000)/0n1024 Evaluate expression: Hex: 00000400 Decimal: 1024 Octal: 00000002000 Binary: 00000000 00000000 00000100 00000000 Chars: .... Time: Thu Jan 01 08:17:04 1970 Float: low 1.43493e-042 high 0 Double: 5.05923e-321 // stack 大小 1024KB --- 1M
StackBase ~ StackLimit: 0013e000 这个是 COMMIT 的页面
StackLimit 下个页面是 MEM_COMMIT | PAGE_READWRITE | PAGE_GUARD
StackLimit 再下一个页面是 MEM_RESERVE
也就是说TEB,确切说是TIB记录着线程的guard page。
当一个函数的局部变量过大,例如:char szBuffer[0x10000] = { 0 },那么线程被系统预先提交的页不满足使用了,那么编译器会在该函数的开头插入_chkstk,用以给该函数提交足够大的stack的页面用以装载很大的局部变量。
_chkstk的核心一个是使ESP减少,另一个就是提交页面,提交页面是个很有趣的过程:
public _alloca_probe _chkstk proc _alloca_probe = _chkstk push ecx ; Calculate new TOS. lea ecx, [esp] + 8 - 4 ; TOS before entering function + size for ret value sub ecx, eax ; new TOS ; Handle allocation size that results in wraparound. ; Wraparound will result in StackOverflow exception. sbb eax, eax ; 0 if CF==0, ~0 if CF==1 not eax ; ~0 if TOS did not wrapped around, 0 otherwise and ecx, eax ; set to 0 if wraparound mov eax, esp ; current TOS and eax, not ( _PAGESIZE_ - 1) ; Round down to current page boundary cs10: cmp ecx, eax ; Is new TOS jb short cs20 ; in probed page? mov eax, ecx ; yes. pop ecx xchg esp, eax ; update esp mov eax, dword ptr [eax] ; get return address mov dword ptr [esp], eax ; and put it at new TOS ret ; Find next lower page and probe cs20: sub eax, _PAGESIZE_ ; decrease by PAGESIZE test dword ptr [eax],eax ; probe page. jmp short cs10 _chkstk endp end
其中,test dword ptr [eax],eax; 可是这行代码仅仅是读了一下eax指向的内存,这里的读操作将触发一个STATUS_GUARD_PAGE异常, 内核通过捕获这个异常,从而知道你的线程已经越过了栈中已提交内存区域的边界, 这时应该增加新的页了。
操作系统规定栈中的 commit 页时,必须逐页提交,具体的实现是:对已提交的内存区域的最后一个页设置 PAGE_GUARD属性,当这个页发生 STATUS_GUARD_PAGE异常时(这个异常会自动清除其 PAGE_GUARD属性), 再commit下一个页,同时设置其 PAGE_GUARD属 性。
typedef struct _NT_TIB { struct _EXCEPTION_REGISTRATION_RECORD *ExceptionList; PVOID StackBase; // 栈的最高地址 , 栈底 PVOID StackLimit; // 已经commit的栈的内存的最低地址, 栈顶, ..... } NT_TIB;
栈的内存如此排布:
StackBase ----> |..............| <----- 高 -\ |______________| | |..............| | |______________| | |..............| Protect 00000004 PAGE_READWRITE |______________| State 00001000 MEM_COMMIT |..............| |______________| | |..............| | |______________| | |..............| | StackLimit ---> |______________| <___________||..............| Protect 00000104 PAGE_READWRITE | PAGE_GUARD |______________| <___State 00001000 MEM_COMMIT |..............| | |______________| | |..............| | |______________| State 00002000 MEM_RESERVE (没有Commit的页谈不上Protect) |..............| | |______________| | |..............| <----------/
当一个线程被创建的时候,操作系统会给它的栈reserve一块区域,通常大小为1M,然后立刻在栈顶commit n个pages。
前 n-1 个Page是供线程立刻可以使用,第二个page是守护页面(guard page), 当线程用完第一个页面的时候,需要更多栈内存会访问到守护页面,操作系统会得到通知。系统会再commit一个页面,把下一个页面作为新的守护页面。