线程Stack

Stack 增长
很多人知道编译器有个设置选项,里面可以设置线程栈的大小,有两个值可以设置:
 Stack Reserve Size
表示在虚拟内存中保留(Reserve)给栈的虚拟空间大小,Stack增长不能超过这个界限,如果不设置,默认是1M。
 Stack Commit Size
表示线程初始化时在为其保留的虚拟空间内提交(Commit)的内存大小,如果不设置,默认仅提交一个页,即4K。另外在调用CreateThread创建线程时也可以动态修改初始化提交的页面大小。
这两个数值具体放置在PE文件头的 IMAGE_OPTIONAL_HEADER 内的变量 SizeOfStackReserve 和 SizeOfStackCommit 
为什么不一次性提交全部保留大小,这样设计当然是为了节省物理内存。那么接下来的关键问题是:stack的Commit区域是如何增长的?

神秘函数 _chkstk
如果一个函数中在栈中分配了超过一个页大小(4096字节),查看汇编代码,在函数头部编译器会帮你插入调用_chkstk的指令:
mov  eax,     0x 1000  //这里的eax作为唯一的函数参数,表示这个函数将从栈中分配的字节数
call   _chkstk
函数_chkstk在vs2005下实现代码如下:
         push    ecx
 计算栈新的栈顶位置( TOS         lea     ecx, [esp +  4 ]            进入此函数前的栈顶位置 + 存储函数返回地址所占的4字节)
        sub     ecx, eax             ;  栈新的栈顶位置
 注意前面指令中的ecx可能小于eax,下面处理这种情况——如果出现ecx小于eax,就设置ecx为0
        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
; 下面指令从当前栈顶位置开始,按顺序逐页逐页的walk,直到新的栈顶位置
        mov     eax, esp                ;  当前栈顶位置
        and     eax,  0xFFFF000             ; Round down to current page boundary
cs10:
        cmp     ecx, eax                ;  比较是否已经到达了新的栈顶位置
        jb      short cs20                         mov     eax, ecx                ; 
        pop     ecx
        xchg    esp, eax                ;  修改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的做了什么工作:
(1) 计算栈新的栈顶位置
(2) 从当前栈顶位置开始,按顺序逐页逐页的walk,直到新的栈顶位置
(3) 修改esp指向新的位置,即分配栈空间
这里的关键的动作只有一行代码,在第(2)步: test    dword ptr [eax],eax 。 可是这行代码仅仅是读了一下eax指向的内存,难道这里隐藏着什么东西?没错,因为这里的读操作将触发一个STATUS_GUARD_PAGE异常,内核通过捕获这个异常,从而知道你的线程已经越过了栈中已提交内存区域的边界,这时应该增加新的页了。

操作系统规定栈中的页commit必须逐页提交,具体的实现是,对已提交的内存区域的最后一个页设置PAGE_GUARD属 性 ,当这个页发生 STATUS_GUARD_PAGE异常时(这个异常会自动清除其 PAGE_GUARD属性) ,再commit下一个页,同时设置其 PAGE_GUARD属性。

获取Stack 中Commit内存区域边界
1. 通过TEB(Thread Environment Block,线程环境块)获取
系 统在此TEB中保存线程频繁使用的相关数据。位于用户地址空间。进程中的每个线程都有自己的一个TEB。一个进程的所有TEB都存放在从 0x7FFDE000开始的线性内存中,每4KB为一个完整的TEB,不过该内存区域是向低地址扩展的。在用户模式下,每个线程的TEB位于独立的4KB 段,可通过CPU的FS段寄存器来访问该段,一般存储在[FS:0]。在用户态下WinDbg中可用命令$thread取得TEB地址。
TEB最前面的结构是TIB,定义如下:
typedef struct _NT_TIB {
    struct _EXCEPTION_REGISTRATION_RECORD *ExceptionList;
    PVOID StackBase;
    PVOID StackLimit;
    PVOID SubSystemTib;
    union {
        PVOID FiberData;
        DWORD Version;
    };
    PVOID ArbitraryUserPointer;
    struct _NT_TIB *Self;
} NT_TIB;
 
栈提交区域上下边界值各位于偏移4和8字节处。因此,可以通过下面方法获取:
LPVOID pStackHigh, pStackLow;
__asm
{
mov eax,fs:[4];
mov pStackHigh,eax;
mov eax,fs:[8];
mov pStackLow,eax;
}

2. 通过系统未公开的API获取
当线程切换时,FS段寄存器也会发生切换,这样导致一个问题,实际上一个线程不能通过FS段访问其它线程的TEB,它只能访问到它自己的TEB。通过ntdll.dll未公开函数 NtQueryInformationThread 可以方便访问其它线程TEB。
typedef struct _THREAD_BASIC_INFORMATION {
     NTSTATUS  ExitStatus;
     PTIB_NT   TebBaseAddress;
     CLIENT_ID ClientId;
     KAFFINITY AffinityMask;
     KPRIORITY Priority;
     KPRIORITY BasePriority;
} THREAD_BASIC_INFORMATION, *PTHREAD_BASIC_INFORMATION;
typedef NTSTATUS (__stdcall *NtQueryInformationThread_Type) (
IN HANDLE ThreadHandle,
IN THREADINFOCLASS ThreadInformationClass,
OUT PVOID ThreadInformation,
IN ULONG ThreadInformationLength,
OUT PULONG ReturnLength OPTIONAL
    );
 
相对代码如下:
NtQueryInformationThread_Type   pNtQueryInformationThread = (NtQueryInformationThread_Type)   GetProcAddress(  GetModuleHandle(_T("ntdll.dll")   ), "NtQueryInformationThread");
 
THREAD_BASIC_INFORMATION tbi;
NTSTATUS Status = pNtQueryInformationThread(hThread, ThreadBasicInformation, &tbi, sizeof(tbi), NULL);
if (NT_SUCCESS(Status))
{
stackLow = tbi.TebBaseAddress->pvStackLimit;
stackHigh = tbi.TebBaseAddress->pvStackBase;
}
Stack Overflow (栈溢出)
当 访问地址超过栈的保留内存区域时,就会发生栈溢出,windows操作系统会产生EXCEPTION_STACK_OVERFLOW异常。因为发生异常 后,异常处理代码也在同一个线程运行,实际上,当你开始访问倒数第三个页时,windows就会发出这个异常,从而给异常处理函数留下两个页的栈空间。这 里最后一个页永远是Reserver状态,windows这样做的理由是如果异常处理代码超出为它预留的2个页,就会引发访问异常。
为了观察栈访问到哪儿引发EXCEPTION_STACK_OVERFLOW异常,我用了下面的一个测试函数,通过无限梯归调用制造栈溢出。
void _declspec(naked) test_stack()
{
_asm
{
push ebp
mov ebp, esp
call test_stack;
pop ebp
ret
}
}
然后通过Vector 异常处理函数捕获栈溢出异常,并调用下面代码,观察访问到哪儿会引发访问异常。
__asm
{
mov ebx, esp
LOOP_BEGIN:
mov eax, dword ptr[ebx]
sub ebx, 4
jmp LOOP_BEGIN
}
  
(1)如何捕获栈溢出异常
 通过SetUnhandledExceptionFilter设置异常钩子
 通过 AddVectoredExceptionHandle (需要 xp或以上操作系统支持)
后一种方法在某些情况下更有效,它能有够有机会在任何基于栈的异常处理之前被调用。例如你的代码中可能存在一些自己的结构化异常处理:
__try{
......
}
__except(EXCEPTION_EXECUTE_HANDLER)
{
......
}
如 果上面的情况下使用的是SetUnhandledExceptionFilter话,这里的栈溢出异常首先被__try块捕获。如果它并不能真正处理好栈 溢出异常,接下来的栈溢出(因为只有2个页面了,这时很容易发生溢出)只会引发访问异常,不再是栈溢出异常,并被 SetUnhandledExceptionFilter捕获到,这当然不是你所希望的。如果想在第一时间捕获栈溢出,应该使用AddVectoredExceptionHandle 
 
(2)处理栈溢出
栈 溢出是非常严重的bug,大多数时候都没有恢复的必要性,唯一能做的就是及时记录crash现场信息(比如生成dump文件),因为当发生栈溢出,只剩下 2个页面的栈空间,在异常处理代码中需要小心翼翼的处理,确保对栈的使用不超出范围。但是有些系统函数,例如dbghelp的 MiniDumpWriteDump 栈空间使用超过2个页面。 一个最简单有效的方法就是创建新的线程,然后马上Sleep(INFINITE),由新线程来处理这些crash信息收集工作。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值