线程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
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值