【動態分配棧內存】之alloca內幕

        哎,下班回家就開始大掃除,一直到凌晨才搞定,真的累了。但是計劃的是今天必須將本文寫完,不寫完睡不着覺。那就儘快切入正題吧!

        我們經常使用malloc或者new等函數或操作符來動態分配內存,這裏的內存說的是堆內存,並且需要程序員手工釋放分配的內存。malloc對應free,new對應delete。至於你要混着用,也不是不可以,只要確保邏輯和功能的正確性,還要在規範的限制範圍內。這裏我想插一句題外話,我個人覺得,只要你將一些具有相似特徵的東西都摸透了,他們的差異你就會很明瞭,在此基礎上,隨便你怎麼用都是成竹在胸的,只需要考慮一些外界因素就可以了,比如前面說的規範等。

        本文是針對在棧上動態分配內存進行討論,分配的內存即爲棧內存,棧上的內存有一個特點即是不用我們手工去釋放申請的內存。棧內存由一個棧指針來開闢和回收,棧內存是從高地址向低地址增長的,增長時,棧指針向低地址方向移動,指針的地址值也就相應的減小;回收時,棧指針向高地址方向移動,地址值也就增加。所以棧內存的開闢和回收都只是指針的加減,由此相對於分配堆內存可以獲得一定的性能提升。由這些特性,也能對爲什麼叫“棧”內存有更進一步的理解。

        我們都知道,在C99標準之前,C語言是不支持變長數組的,如果想要動態開闢棧內存以達到變長數組的功能就得依靠alloca函數。其實在gcc下,c99下的變長數組後臺也是依靠alloca來動態分配棧內存的,當然這裏不能完全說是調用alloca來實現的,alloca可能被優化並內聯(當然你還是可以說這是在調用)。這裏就不糾結這個問題了,在本文不屬於重點。實際中,alloca函數是不推薦使用的,他存在很多不安全的因素,這裏暫時不討論這個問題,本文的目的是瞭解原理,獲得認知,以至通透。

        通常編譯器都提供了CRT庫,例如VC的諸多版本,CRT庫在一些版本間差異還是比較大,新版本的CRT一般會多了很多更嚴格的檢查和一些安全機制。本文以VS2008爲例,其爲alloca提供了對應的_alloca函數,編譯器會將其編譯爲_alloca_probe_16函數,此函數位於VC_dir\VC\crt\src\intel\alloca16.asm彙編源文件中,此乃微軟提供的彙編版本CRT相關函數。在此文件中,有兩個版本,一個是16字節對齊的_alloca_probe_16,一個是8字節對齊的_alloca_probe_8。代碼如下:

.xlist         include cruntime.inc .list

extern  _chkstk:near

; size of a page of memory

        CODESEG

page

public _alloca_probe_8 _alloca_probe_16 proc ; 16 byte aligned alloca push ecx lea ecx, [esp] + 8 ; TOS before entering this function sub ecx, eax ; New TOS and ecx, (16 - 1) ; Distance from 16 bit align (align down) add eax, ecx ; Increase allocation size sbb ecx, ecx ; ecx = 0xFFFFFFFF if size wrapped around or eax, ecx ; cap allocation size on wraparound pop ecx ; Restore ecx jmp _chkstk alloca_8: ; 8 byte aligned alloca _alloca_probe_8 = alloca_8 push ecx lea ecx, [esp] + 8 ; TOS before entering this function sub ecx, eax ; New TOS and ecx, (8 - 1) ; Distance from 8 bit align (align down) add eax, ecx ; Increase allocation Size sbb ecx, ecx ; ecx = 0xFFFFFFFF if size wrapped around or eax, ecx ; cap allocation size on wraparound pop ecx ; Restore ecx jmp _chkstk _alloca_probe_16 endp end

        默認會編譯爲16字節對齊的版本,仔細看一下,這裏所謂的16字節對齊倒也不一定,lea ecx, [esp] + 8這句獲得進入此函數之前的esp值並寫入ecx中,這裏加8的原因很明顯,前4個字節是保存的ecx的值,後4個字節是函數的返回地址,加8即得到上一層函數調用本函數時的esp值,這裏沒有參數壓棧,參數是寄存器傳遞的。因此,這個ecx的值可以假設爲一個定值(這個值也是至少4字節對齊的),然後下面3句彙編代碼中,eax是外部傳入的要開闢棧內存字節數,這個字節數始終是4字節對齊的。那麼sub ecx, eax這句之後的結果就可以是4字節對齊且非16字節對齊,這樣一來,在and ecx, ( 16 - 1 )並add eax, ecx後,eax的值就是非16字節對齊的。至於8字節對齊的版本,你可以試着推算一下會不會存在算出的eax是非8字節對齊的,這個不是難點。

        在此函數裏,我們發現還沒有真正的開闢棧內存,因爲esp(也就是前面提到的棧指針,也就是棧頂指針,上面的彙編代碼中的TOS也就是棧頂:Top of stack的意思)的值還沒有減去eax(申請內存的大小)而改變。然後我們注意到,在pop ecx還原ecx的值(因爲此函數需要ecx來協助,因此進函數就push ecx保存,然後結束之後再pop 還原)之後,還有一個jmp跳轉,跳轉到了_chkstk,此函數很明顯,意爲:check stack,用於檢查堆棧是否溢出。此函數通常會被編譯器插入到某個開闢了一定大小函數頭部,用於進入函數時進行棧內存溢出檢查,例如你在一個函數中定義一個較大的數組,此時編譯器會強制插入_chkstk函數進行檢查(這裏單指VC下,其他編譯器的方式不一定一致)。

        於是,到此可以猜測,這個_alloca_probe_16函數只是負責計算實際對齊後該分配多少字節的棧內存,並保存到eax中,由於_chkstk函數也會用到eax的值,這裏也是通過寄存器傳參的。並且可以看出_alloca_probe_16函數和_chkstk函數聯繫緊密,都是直接jmp過去的。

        好了,來看看_chkstk函數吧,此函數位於之前的目錄下,也是一個彙編源文件:chkstk.asm。代碼如下:

.xlist         include cruntime.inc .list

; size of a page of memory

_PAGESIZE_      equ     1000h

        CODESEG

page

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

        此函數較之前的要稍微複雜一些,不過代碼還是非常清晰易懂的。還是解釋一下吧,先來看lea ecx, [esp] + 8 - 4這句,與_alloca_probe_16彙編代碼相比較,多了一個減4,這裏減4是因爲從_alloca_probe_16函數到_chkstk函數之間是用的jmp,而不是call,因此沒有返回地址,只有保存的ecx值的4個字節,所以少4個字節的偏移就能取到esp的值了。由於_alloca_probe_16函數是保持棧平衡的,並且沒有改變esp的值,因此,_chkstk函數裏取到的esp與_alloca_probe_16函數取到的esp是一樣的。並且也都存放到了ecx中。後面一句與_alloca_probe_16函數的邏輯一樣,都是將ecx(esp的值)減去eax(要分配的棧內存大小,已經由_alloca_probe_16函數對齊過)。這一句之後,ecx的值就是新的esp的值,如果棧沒有溢出,那麼esp將會被設置爲這個新值,於是棧內存分配成功。

        繼續向下分析,緊接着下面3句,用得有一點巧妙。sbb eax, eax,sbb乃帶借位減法指令,如果前面的sub ecx, eax存在借位(ecx小於eax),則sbb之後eax的值爲0xffffffff,然後再not eax,eax將變成0,然後再and ecx, eax,則ecx變爲0,也就意味着新的esp值爲0。這裏先放一下,待會兒再向下分析。再看前面,sub ecx, eax存在借位,爲什麼會存在這樣的情況,難道_alloca_probe_16函數不檢查申請內存的大小的嗎?的確,他並不會關心你想申請多少字節,他只是與_chkstk配合,讓_chkstk能夠知道申請的內存過大就可以了,過大之後可以由_chkstk進行檢查並拋出異常。那麼我們來看_alloca_probe_16函數是怎麼配合_chkstk函數的檢查的呢。這又得回到_alloca_probe_16
函數的彙編源代碼中,看這三句:

add     eax, ecx                ; Increase allocation Size
sbb     ecx, ecx                ; ecx = 0xFFFFFFFF if size wrapped around
or      eax, ecx                ; cap allocation size on wraparound

        eax爲申請的大小,ecx爲新的esp值,由sub ecx, eax計算獲得。把這三句代碼與_chkstk函數的三句代碼結合着看,這裏如果eax過大(申請空間過大),add eax, ecx之後,會溢出,即CF位爲1。然後執行下一句sbb ecx,ecx,也就等同於:ecx = ecx - ecx - CF = 0 - 1 = -1 = 0xffffffff。然後在or eax, ecx,於是eax爲0xffffffff,也就是傳給_chkstk函數的申請空間大小。然後再看前面對_chkstk函數的分析,如果eax爲0xffffffff,那麼肯定會sub溢出,於是ecx(新的esp值)最後爲0。再看另外一種情況,如果在_alloca_probe_16中,eax的值大於ecx的值,那麼sub之後,會溢出,在and ecx, ( 16 - 1 )之後,再add eax, ecx,此刻假設不會溢出,sbb之後,ecx爲0,之後再or eax,ecx不會影響eax的值,但是此時eax還是大於ecx(esp的值)的。當eax傳入_chkstk之後,sub會溢出。與eax爲0xffffffff的結果一樣,都使得ecx(esp的值)的值爲0。所以由上面兩種情況分析下來,_alloca_probe_16函數和_chkstk函數之間是有一定的配合的。也可以說是_alloca_probe_16函數適應了_chkstk的檢查方案。

        我們再繼續向下分析_chkstk吧,看後面兩句,先是mov eax,esp將當前的esp值交給eax,注意這裏的esp值是_chkstk內部已經壓入保存了ecx原始值之後的esp,這個esp也就是最初有lea ecx, [esp] + 8 - 4獲得的上層esp值減4(push ecx佔用的4字節)。獲得了當前esp值之後,又and eax, not ( _PAGESIZE_ - 1),_PAGESIZE_爲0x1000,也就是4096字節(4KB),即爲windows頁內存大小規則之一。這句代碼也就是將當前esp所在的頁剩下的字節全部減掉,到達這一頁的末尾下一頁的開始。這樣做是方便後面的棧溢出檢查。

        之後,有兩個標籤cs10和cs20,cs10的開頭是判斷ecx是否小於eax,此刻的eax已經是某頁的開頭,如果ecx小於這個eax所存的地址值,則跳轉到cs20標籤裏,cs20標籤裏代碼很簡單,進入就將eax減掉一頁內存,然後是test    dword ptr [eax],eax這句,這句存在一個內存訪問,可以想象如果eax所存的內存值不可讀,那麼就會拋出異常。這裏正是利用這一點,當這裏不異常,又會跳轉到cs10標籤裏繼續比較,如果還是小,則在減一頁,再進行訪問,直到ecx大於等於eax或者拋出異常。那麼再想一下上面分析的邏輯,如果申請的空間過大,ecx的值會爲0,那麼在cs20中判斷,0會一直小於eax,這樣eax會一直減4K,直到eax爲0,這裏顯然減不到0就已經拋異常了。當eax減到一定時候,則會在test    dword ptr [eax],eax這句拋出一個棧溢出的異常,如下圖:

如果繼續執行,則會發生訪問異常。如果申請的大小不會導致棧溢出,則當eax減到一定時候ecx大於等於eax,或者第一次進去時ecx就是大於等於eax的,則進入正常開闢空間的邏輯:

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

        第一行是將ecx(新的通過驗證的esp)賦值給eax,然後是還原ecx的值,第三行就是將當前的esp值和eax做交換。esp便是開闢空間後的新值,此刻肯定比eax的值要小(棧向低地址延伸)。然後是第4句,此時eax是pop ecx之後的esp值,也就是call _alloca_probe_16函數壓入了返回地址後的esp值,因此,第四句執行後,eax的值就是,_alloca_probe_16函數函數的返回地址,我們準備返回到上層,這裏的上層不是_alloca_probe_16函數,因爲他們之間不是call的,而是jmp的,不存在返回地址壓入。這裏的上層是_alloca_probe_16函數的上層。第5行,是將eax存入當前的esp指向的內存中,因爲下一條指令ret,即將讀取這個地址,並返回到上層,其間的原理請參考《Inline Hook 之(監視任意函數)》,此文有相同的用法。

        整個過程就是這樣了,其實在很多C語言編寫的實際項目中,還是有用到alloca。就我個人而言,我覺得不管他有什麼優點和缺點,只要弄清楚了他的這些特性,完全可以規避他的缺點,而發揮他的優勢。而且也確實動態分配適量的棧空間,能獲得一些性能。本文只是爲了介紹其原理和細節,不在此爭論辯證性的論題。

       如果要使用alloca,可以非常簡單的使用,如下:

void func( void )
{
    int* p = ( int* )alloca( 4 );
    *p = 100;
}

        不用自己管理釋放,當函數結束時,esp會平衡。另外,需要提到的是,根據alloca申請的大小的變化,編譯器可能在後臺做一些調整,比如當申請的內存較小時,alloca直接被編譯成_chkstk,而不會調用_alloca_probe_16函數,這也算是一個小小的優化吧。再比如,在VS2003下,不管申請多大的空間,都會將alloca直接編譯成_chkstk。因爲vs2003的CRT沒有提供_alloca_probe_16函數的實現。

        上面提到的alloca,在VC的CRT中其實是一個宏定義,#define alloca _alloca。另外還有一些CRT宏定義,例如_malloca,這個宏定義也等於是一層封裝,在debug下,_malloca調用的是malloc,在release下,當申請的大小小於一定值時,調用的是alloca,否則調用malloc。因此,需要調用_freea來釋放內存,_freea會根據標記,判斷是malloc分配的還是alloca分配的,如果是malloc分配的堆內存則調用free,如果是alloca分配的棧內存,則不用釋放。代碼如下:

// _malloca的定義
#if defined(_DEBUG)
#if !defined(_CRTDBG_MAP_ALLOC)
#undef _malloca
#define _malloca(size) \
__pragma(warning(suppress: 6255)) \
        _MarkAllocaS(malloc((size) + _ALLOCA_S_MARKER_SIZE), _ALLOCA_S_HEAP_MARKER)
#endif
#else
#undef _malloca
#define _malloca(size) \
__pragma(warning(suppress: 6255)) \
    ((((size) + _ALLOCA_S_MARKER_SIZE) <= _ALLOCA_S_THRESHOLD) ? \
        _MarkAllocaS(_alloca((size) + _ALLOCA_S_MARKER_SIZE), _ALLOCA_S_STACK_MARKER) : \
        _MarkAllocaS(malloc((size) + _ALLOCA_S_MARKER_SIZE), _ALLOCA_S_HEAP_MARKER))
#endif

// _freea的定義
_CRTNOALIAS __inline void __CRTDECL _freea(_Inout_opt_ void * _Memory)
{
    unsigned int _Marker;
    if (_Memory)
    {
        _Memory = (char*)_Memory - _ALLOCA_S_MARKER_SIZE;
        _Marker = *(unsigned int *)_Memory;
        if (_Marker == _ALLOCA_S_HEAP_MARKER)  // 判斷是否是堆標記
         {
            free(_Memory);
        }
#if defined(_ASSERTE)
        else if (_Marker != _ALLOCA_S_STACK_MARKER)
        {
            _ASSERTE(("Corrupted pointer passed to _freea", 0));
        }
#endif
    }
}

// _MarkAllocaS的定義
__inline void *_MarkAllocaS(_Out_opt_ __crt_typefix(unsigned int*) void *_Ptr, unsigned int _Marker)
{
    if (_Ptr)
    {
        *((unsigned int*)_Ptr) = _Marker; // 打上標記, _ALLOCA_S_STACK_MARKER 或 _ALLOCA_S_HEAP_MARKER
        _Ptr = (char*)_Ptr + _ALLOCA_S_MARKER_SIZE;
    }
    return _Ptr;
}

【延伸】

        這裏延伸一個玩兒的用法,就是在寫C語言程序時,有多個函數參數是指針並且參數個數一樣,這些函數的指針參數的類型都不一樣,在C++裏有template,在C裏可沒有。於是爲了實現一個類似功能的東西,我們就可以用alloca來申請參數的空間,然後調用函數。代碼如下:

#include <stdio.h>
#include <malloc.h>

void func( char* p )
{
    printf( "%s\n", p );
}

void chk( void* arg )
{
    if ( ( void** )arg - &arg != 1 ) // 檢查參數的位置是否緊挨着arg所在的內存地址
        __asm int 3                  // 如果緊挨着,當chk執行完之後,esp即剛好指
}                                    // alloca申請的空間,因此,調用fun時就有參數了

typedef void ( *functor )( void );

int main( void )
{
    char* str = "12345";
    int*  arg = ( int* )alloca( 4 );
    functor fun = ( functor )func;

    *arg = ( int )str;

    chk( arg );

    ( *fun )();

    return 0;
}

        這裏只是一個簡單的例子,由於alloca申請的空間最後在函數結束時會平衡棧幀便回收了,而fun指針的調用是沒有壓入參數的,因此fun結束後不存在add esp,func函數是__cdecl調用約定,也不會在內部平衡棧,所以整個棧幀是平衡的。

        PS:此例子純屬玩樂,瞭解其中原理而已,更復雜的情況,並沒有測試和深入。

        不知不覺已經凌晨3點半了,本文對於瞭解原理並熟悉彙編的朋友可能羅嗦了,可以直接略過分析,我該睡覺了!歡迎交流!

       ****************如需轉載,請註明出處:http://blog.csdn.net/masefee,謝謝**********************


 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章