堆疊的工作原理
宣告:以下均為個人收集的一些資料,非原創
每一個使用c語言的都應該知道棧的重要性,我們能夠使用C/C++語言寫出諸多複雜的程式,很大功勞一部分有歸於棧的實現,因為它可以幫助我們實現函式間的巢狀呼叫。
彙編程式的執行是不需要棧的,所以註定它函式的巢狀層數不會太多,一般是父函式呼叫子函式,然後在子函式就返回了,很少見到子函式還會呼叫孫子函式的情況。這是由它的語言特性決定的。因為每當組合語言呼叫子函式時,就會將返回的PC地址儲存在LR中, 如果子函式還要呼叫孫子函式,那麼執行時也會將子函式的返回地址儲存在LR中,這時如果要返回父函式,就需要將返回父PC的地址儲存在另外一個暫存器中,比如R0中,這將佔用另外一個暫存器。
cpu的暫存器資源是很有限的,如果一個程式相對複雜,函式間有4、5層的巢狀呼叫,那將會佔用至少4、5個暫存器資源,這是不現實的,也一般不會這樣做,而且對於一種與硬體聯絡緊密的組合語言來說,太複雜的邏輯關係或巢狀關係也不好實現。
組合語言是底層語言,它沒有棧,它也不需要編寫很複雜很龐大的程式,即使沒有記憶體(SDRAM),它也能在cpu的片內記憶體執行以完成一些裸機硬體程式;但是C語言是高階語言,它能夠編寫複雜龐大的程式,所以它需要函式間的多層呼叫,它需要用到指標的靈活賦值等等,但是這些都有依賴於棧, 那麼棧是怎麼讓C語言能夠變得如此強大呢?
一、棧的基本瞭解
每次我們開機的時候,系統都會初始化好棧指標(SP),初始方法也很簡單,在boot_load程式碼裡我們可以看到:ldr sp, =4096 這樣的語句,實際就是讓SP指標指向這樣的地址,但是注意,這個地址是記憶體中的地址,而不是cpu片內地址,記憶體資源相對cpu資源來說充裕多了,所以SP可以有很大的增長空間,這也是C語言可以寫複雜程式的前提。
我們知道棧在不同的系統中的增長方向是不一樣的,但是棧的結構決定了它是一個先進後出的模型,所以和我們函式呼叫的過程是類似的,最先呼叫的函式總是最後返回,而最後呼叫的函式則是最最先返回,也就後呼叫先返回。
棧的出棧方式決定函式的返回過程,棧的增長空間支援函式巢狀的複雜程度。
二、棧的基本原理
下面是收集的基於ARM平臺的一個例子
C語言進行函式呼叫的時候,常常會傳遞給被呼叫的函式一些引數,對於這些C語言級別的引數,被編譯器翻譯成組合語言的時候,
就要找個地方存放一下,並且讓被呼叫的函式能夠訪問,否則就沒發實現傳遞引數了。對於找個地方放一下,分兩種情況。
一種情況是,本身傳遞的引數就很少,就可以通過暫存器傳送引數,因為在前面的儲存現場的動作中,已經儲存好了對應的暫存器的值,那麼此時,這些暫存器就是空閒的,可以供我們使用的了,那就可以放引數,而引數少的情況下,就足夠存放引數了,比如引數有2個,那麼就用r0和r1存放即可。(關於引數1和引數2,具體哪個放在r0,哪個放在r1,就是和APCS中的“在函式呼叫之間傳遞/返回引數”相關了,APCS中會有詳細的約定。感興趣的自己去研究)
但是如果引數太多,暫存器不夠用,那麼就得把多餘的引數堆疊中了,即可以用堆疊來傳遞所有的或暫存器放不下的那些多餘的引數。
舉例分析C語言函式呼叫是如何使用堆疊的
對於上面的解釋的堆疊的作用顯得有些抽象,此處再用例子來簡單說明一下,就容易明白了:
用:
arm-inux-objdump –d u-boot > dump_u-boot.txt
可以得到dump_u-boot.txt檔案。該檔案就是中,包含了u-boot中的程式的可執行的彙編程式碼,
其中我們可以看到C語言的函式的原始碼,到底對應著那些彙編程式碼。
下面貼出兩個函式的彙編程式碼,
一個是clock_init,
另一個是與clock_init在同一C原始檔中的,另外一個函式CopyCode2Ram:
33d0091c <CopyCode2Ram>:
33d0091c: e92d4070 push {r4, r5, r6, lr}
33d00920: e1a06000 mov r6, r0
33d00924: e1a05001 mov r5, r1
33d00928: e1a04002 mov r4, r2
33d0092c: ebffffef bl 33d008f0 <bBootFrmNORFlash>
... ...
33d00984: ebffff14 bl 33d005dc <nand_read_ll>
... ...
33d009a8: e3a00000 mov r0, #0 ; 0x0
33d009ac: e8bd8070 pop {r4, r5, r6, pc}
33d009b0 <clock_init>:
33d009b0: e3a02313 mov r2, #1275068416 ; 0x4c000000
33d009b4: e3a03005 mov r3, #5 ; 0x5
33d009b8: e5823014 str r3, [r2, #20]
... ...
33d009f8: e1a0f00e mov pc, lr
(1)clock_init部分的程式碼
可以看到該函式第一行:
33d009b0: e3a02313 mov r2, #1275068416 ; 0x4c000000
就沒有我們所期望的push指令,沒有去將一些暫存器的值放到堆疊中。這是因為,我們clock_init這部分的內容,
所用到的r2,r3等等暫存器,和前面呼叫clock_init之前所用到的暫存器r0,沒有衝突,所以此處可以不用push去儲存這類暫存器的值,
不過有個暫存器要注意,那就是r14,即lr,其是在前面呼叫clock_init的時候,用的是bl指令,所以會自動把跳轉時候的pc的值賦值給lr,
所以也不需要push指令去將PC的值儲存到堆疊中。
而clock_init的程式碼的最後一行:
33d009f8: e1a0f00e mov pc, lr
就是我們常見的mov pc, lr,把lr的值,即之前儲存的函式呼叫時候的PC值,賦值給現在的PC,
這樣就實現了函式的正確的返回,即返回到了函式呼叫時候下一個指令的位置。
這樣CPU就可以繼續執行原先函式內剩下那部分的程式碼了。
(2)CopyCode2Ram部分的程式碼
其第一行:
33d0091c: e92d4070 push {r4, r5, r6, lr}
就是我們所期望的,用push指令,儲存了r4,r5,r以及lr。
用push去儲存r4,r5,r6,那是因為所謂的儲存現場,以後後續函式返回時候再恢復現場,
而用push去儲存lr,那是因為此函式裡面,還有其他函式呼叫:
33d0092c: ebffffef bl 33d008f0 <bBootFrmNORFlash>
... ...
33d00984: ebffff14 bl 33d005dc <nand_read_ll>
... ...
也用到了bl指令,會改變我們最開始進入clock_init時候的lr的值,所以我們要用push也暫時儲存起來。
而對應地,CopyCode2Ram的最後一行:
33d009ac: e8bd8070 pop {r4, r5, r6, pc}
就是把之前push的值,給pop出來,還給對應的暫存器,其中最後一個是將開始push的lr的值,pop出來給賦給PC,因為實現了函式的返回。
另外,我們注意到,在CopyCode2Ram的倒數第二行是:
33d009a8: e3a00000 mov r0, #0 ; 0x0
是把0賦值給r0暫存器,這個就是我們所謂返回值的傳遞,是通過r0暫存器的。
此處的返回值是0,也對應著C語言的原始碼中的“return 0”.
對於使用哪個暫存器來傳遞返回值:
當然你也可以用其他暫時空閒沒有用到的暫存器來傳遞返回值,但是這些處理方式,本身是根據ARM的APCS的暫存器的使用的約定而設計的,
最好不要隨便改變使用方式,最好還是按照其約定的來處理,這樣程式更加符合規範。
下面是收集的x86平臺的一個例子(個人覺得講的很好)
1)本文討論的編譯環境是 Visual C/C++,由於高階語言的堆疊工作機制大致相同,因此對其他編譯環境或高階語言如C#也有意義。
2)本文討論的堆疊,是指程式為每個執行緒分配的預設堆疊,用以支援程式的執行,而不是指程式設計師為了實現演算法而自己定義的堆疊。
3) 本文討論的平臺為intel x86。
4)本文的主要部分將盡量避免涉及到彙編的知識,在本文最後可選章節,給出前面章節的反編譯程式碼和註釋。
5)結構化異常處理也是通過堆疊來實現的(當你使用try…catch語句時,使用的就是c++對windows結構化異常處理的擴充套件),但是關於結構化異常處理的主題太複雜了,本文將不會涉及到。
從一些基本的知識和概念開始
1) 程式的堆疊是由處理器直接支援的。在intel x86的系統中,堆疊在記憶體中是從高地址向低地址擴充套件(這和自定義的堆疊從低地址向高地址擴充套件不同),如下圖所示:
因此,棧頂地址是不斷減小的,越後入棧的資料,所處的地址也就越低。
2) 在32位系統中,堆疊每個資料單元的大小為4位元組。小於等於4位元組的資料,比如位元組、字、雙字和布林型,在堆疊中都是佔4個位元組的;大於4位元組的資料在堆疊中佔4位元組整數倍的空間。
3) 和堆疊的操作相關的兩個暫存器是EBP暫存器和ESP暫存器的,本文中,你只需要把EBP和ESP理解成2個指標就可以了。ESP暫存器總是指向堆疊的棧頂,執行PUSH命令向堆疊壓入資料時,ESP減4,然後把資料拷貝到ESP指向的地址;執行POP命令時,首先把ESP指向的資料拷貝到記憶體地址/暫存器中,然後ESP加4。EBP暫存器是用於訪問堆疊中的資料的,它指向堆疊中間的某個位置(具體位置後文會具體講解),函式的引數地址比EBP的值高,而函式的區域性變數地址比EBP的值低,因此引數或區域性變數總是通過EBP加減一定的偏移地址來訪問的,比如,要訪問函式的第一個引數為EBP+8。
4) 堆疊中到底儲存了什麼資料? 包括了:函式的引數,函式的區域性變數,暫存器的值(用以恢復暫存器),函式的返回地址以及用於結構化異常處理的資料(當函式中有try…catch語句時才有,本文不討論)。這些資料是按照一定的順序組織在一起的,我們稱之為一個堆疊幀(Stack Frame)。一個堆疊幀對應一次函式的呼叫。在函式開始時,對應的堆疊幀已經完整地建立了(所有的區域性變數在函式幀建立時就已經分配好空間了,而不是隨著函式的執行而不斷建立和銷燬的);在函式退出時,整個函式幀將被銷燬。
5) 在文中,我們把函式的呼叫者稱為caller(呼叫者),被呼叫的函式稱為callee(被呼叫者)。之所以引入這個概念,是因為一個函式幀的建立和清理,有些工作是由Caller完成的,有些則是由Callee完成的。
開始討論堆疊是如何工作的
我們來討論堆疊的工作機制。堆疊是用來支援函式的呼叫和執行的,因此,我們下面將通過一組函式呼叫的例子來講解,看下面的程式碼:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
int
foo1( int m,
int n) { int
p=m*n; return
p; } int
foo( int a,
int b) { int
c=a+1; int
d=b+1; int
e=foo1(c,d); return
e; } int
main() { int
result=foo(3,4); return
0; } |
這段程式碼本身並沒有實際的意義,我們只是用它來跟蹤堆疊。下面的章節我們來跟蹤堆疊的建立,堆疊的使用和堆疊的銷燬。
堆疊的建立
我們從main函式執行的第一行程式碼,即int result=foo(3,4); 開始跟蹤。這時main以及之前的函式對應的堆疊幀已經存在在堆疊中了,如下圖所示:
圖1
引數入棧
當foo函式被呼叫,首先,caller(此時caller為main函式)把foo函式的兩個引數:a=3,b=4壓入堆疊。引數入棧的順序是由函式的呼叫約定(Calling Convention)決定的,我們將在後面一個專門的章節來講解呼叫約定。一般來說,引數都是從右往左入棧的,因此,b=4先壓入堆疊,a=3後壓入,如圖:
圖2
返回地址入棧
我們知道,當函式結束時,程式碼要返回到上一層函式繼續執行,那麼,函式如何知道該返回到哪個函式的什麼位置執行呢?函式被呼叫時,會自動把下一條指令的地址壓入堆疊,函式結束時,從堆疊讀取這個地址,就可以跳轉到該指令執行了。如果當前"call foo"指令的地址是0x00171482,由於call指令佔5個位元組,那麼下一個指令的地址為0x00171487,0x00171487將被壓入堆疊:
圖3
程式碼跳轉到被呼叫函式執行
返回地址入棧後,程式碼跳轉到被呼叫函式foo中執行。到目前為止,堆疊幀的前一部分,是由caller構建的;而在此之後,堆疊幀的其他部分是由callee來構建。
EBP指標入棧
在foo函式中,首先將EBP暫存器的值壓入堆疊。因為此時EBP暫存器的值還是用於main函式的,用來訪問main函式的引數和區域性變數的,因此需要將它暫存在堆疊中,在foo函式退出時恢復。同時,給EBP賦於新值。
1)將EBP壓入堆疊
2)把ESP的值賦給EBP
圖4
這樣一來,我們很容易發現當前EBP暫存器指向的堆疊地址就是EBP先前值的地址,你還會發現發現,EBP+4的地址就是函式返回值的地址,EBP+8就是函式的第一個引數的地址(第一個引數地址並不一定是EBP+8,後文中將講到)。因此,通過EBP很容易查詢函式是被誰呼叫的或者訪問函式的引數(或區域性變數)。
為區域性變數分配地址
接著,foo函式將為區域性變數分配地址。程式並不是將區域性變數一個個壓入堆疊的,而是將ESP減去某個值,直接為所有的區域性變數分配空間,比如在foo函式中有ESP=ESP-0x00E4,(根據燭秋兄在其他編譯環境上的測試,也可能使用push命令分配地址,本質上並沒有差別,特此說明)如圖所示:
圖5
奇怪的是,在debug模式下,編譯器為區域性變數分配的空間遠遠大於實際所需,而且區域性變數之間的地址不是連續的(據我觀察,總是間隔8個位元組)如下圖所示:
圖6
我還不知道編譯器為什麼這麼設計,或許是為了在堆疊中插入除錯資料,不過這無礙我們今天的討論。
通用暫存器入棧
最後,將函式中使用到的通用暫存器入棧,暫存起來,以便函式結束時恢復。在foo函式中用到的通用暫存器是EBX,ESI,EDI,將它們壓入堆疊,如圖所示:
圖7
至此,一個完整的堆疊幀建立起來了。
堆疊特性分析上一節中,一個完整的堆疊幀已經建立起來,現在函式可以開始正式執行程式碼了。本節我們對堆疊的特性進行分析,有助於瞭解函式與堆疊幀的依賴關係。
1)一個完整的堆疊幀建立起來後,在函式執行的整個生命週期中,它的結構和大小都是保持不變的;不論函式在什麼時候被誰呼叫,它對應的堆疊幀的結構也是一定的。
2)在A函式中呼叫B函式,對應的,是在A函式對應的堆疊幀“下方”建立B函式的堆疊幀。例如在foo函式中呼叫foo1函式,foo1函式的堆疊幀將在foo函式的堆疊幀下方建立。如下圖所示:
圖8
3)函式用EBP暫存器來訪問引數和區域性變數。我們知道,引數的地址總是比EBP的值高,而區域性變數的地址總是比EBP的值低。而在特定的堆疊幀中,每個引數或區域性變數相對於EBP的地址偏移總是固定的。因此函式對引數和區域性變數的的訪問是通過EBP加上某個偏移量來訪問的。比如,在foo函式中,EBP+8為第一個引數的地址,EBP-8為第一個區域性變數的地址。
4)如果仔細思考,我們很容易發現EBP暫存器還有一個非常重要的特性,請看下圖中:
圖9
我們發現,EBP暫存器總是指向先前的EBP,而先前的EBP又指向先前的先前的EBP,這樣就在堆疊中形成了一個連結串列!這個特性有什麼用呢,我們知道EBP+4地址儲存了函式的返回地址,通過該地址我們可以知道當前函式的上一級函式(通過在符號檔案中查詢距該函式返回地址最近的函式地址,該函式即當前函式的上一級函式),以此類推,我們就可以知道當前執行緒整個的函式呼叫順序。事實上,偵錯程式正是這麼做的,這也就是為什麼除錯時我們檢視函式呼叫順序時總是說“檢視堆疊”了。
返回值是如何傳遞的
堆疊幀建立起後,函式的程式碼真正地開始執行,它會操作堆疊中的引數,操作堆疊中的區域性變數,甚至在堆(Heap)上建立物件,balabala….,終於函式完成了它的工作,有些函式需要將結果返回給它的上一層函式,這是怎麼做的呢?
首先,caller和callee在這個問題上要有一個“約定”,由於caller是不知道callee內部是如何執行的,因此caller需要從callee的函式宣告就可以知道應該從什麼地方取得返回值。同樣的,callee不能隨便把返回值放在某個暫存器或者記憶體中而指望Caller能夠正確地獲得的,它應該根據函式的宣告,按照“約定”把返回值放在正確的”地方“。下面我們來講解這個“約定”:
1)首先,如果返回值等於4位元組,函式將把返回值賦予EAX暫存器,通過EAX暫存器返回。例如返回值是位元組、字、雙字、布林型、指標等型別,都通過EAX暫存器返回。
2)如果返回值等於8位元組,函式將把返回值賦予EAX和EDX暫存器,通過EAX和EDX暫存器返回,EDX儲存高位4位元組,EAX儲存低位4位元組。例如返回值型別為__int64或者8位元組的結構體通過EAX和EDX返回。
3) 如果返回值為double或float型,函式將把返回值賦予浮點暫存器,通過浮點暫存器返回。
4)如果返回值是一個大於8位元組的資料,將如何傳遞返回值呢?這是一個比較麻煩的問題,我們將詳細講解:
我們修改foo函式的定義如下並將它的程式碼做適當的修改:
1
2
3
4
|
MyStruct foo( int
a, int
b) { ... } |
1
2
3
4
5
6
|
struct
MyStruct { int
value1; __int64
value2; bool
value3; }; |
這時,在呼叫foo函式時引數的入棧過程會有所不同,如下圖所示:
圖10
caller會在壓入最左邊的引數後,再壓入一個指標,我們姑且叫它ReturnValuePointer,ReturnValuePointer指向caller區域性變數區的一塊未命名的地址,這塊地址將用來儲存callee的返回值。函式返回時,callee把返回值拷貝到ReturnValuePointer指向的地址中,然後把ReturnValuePointer的地址賦予EAX暫存器。函式返回後,caller通過EAX暫存器找到ReturnValuePointer,然後通過ReturnValuePointer找到返回值,最後,caller把返回值拷貝到負責接收的區域性變數上(如果接收返回值的話)。
你或許會有這樣的疑問,函式返回後,對應的堆疊幀已經被銷燬,而ReturnValuePointer是在該堆疊幀中,不也應該被銷燬了嗎?對的,堆疊幀是被銷燬了,但是程式不會自動清理其中的值,因此ReturnValuePointer中的值還是有效的。
堆疊幀的銷燬
當函式將返回值賦予某些暫存器或者拷貝到堆疊的某個地方後,函式開始清理堆疊幀,準備退出。堆疊幀的清理順序和堆疊建立的順序剛好相反:(堆疊幀的銷燬過程就不一一畫圖說明了)
1)如果有物件儲存在堆疊幀中,物件的解構函式會被函式呼叫。
2)從堆疊中彈出先前的通用暫存器的值,恢復通用暫存器。
3)ESP加上某個值,回收區域性變數的地址空間(加上的值和堆疊幀建立時分配給區域性變數的地址大小相同)。
4)從堆疊中彈出先前的EBP暫存器的值,恢復EBP暫存器。
5)從堆疊中彈出函式的返回地址,準備跳轉到函式的返回地址處繼續執行。
6)ESP加上某個值,回收所有的引數地址。
前面1-5條都是由callee完成的。而第6條,引數地址的回收,是由caller或者callee完成是由函式使用的呼叫約定(calling convention )來決定的。下面的小節我們就來講解函式的呼叫約定。
函式的呼叫約定(calling convention)
函式的呼叫約定(calling convention)指的是進入函式時,函式的引數是以什麼順序壓入堆疊的,函式退出時,又是由誰(Caller還是Callee)來清理堆疊中的引數。有2個辦法可以指定函式使用的呼叫約定:
1)在函式定義時加上修飾符來指定,如
1
2
3
4
|
void
__thiscall mymethod(); { ... } |
常用的呼叫約定有以下3種:
1)__cdecl。這是VC編譯器預設的呼叫約定。其規則是:引數從右向左壓入堆疊,函式退出時由caller清理堆疊中的引數。這種呼叫約定的特點是支援可變數量的引數,比如printf方法。由於callee不知道caller到底將多少引數壓入堆疊,因此callee就沒有辦法自己清理堆疊,所以只有函式退出之後,由caller清理堆疊,因為caller總是知道自己傳入了多少引數。
2)__stdcall。所有的Windows API都使用__stdcall。其規則是:引數從右向左壓入堆疊,函式退出時由callee自己清理堆疊中的引數。由於引數是由callee自己清理的,所以__stdcall不支援可變數量的引數。
3) __thiscall。類成員函式預設使用的呼叫約定。其規則是:引數從右向左壓入堆疊,x86構架下this指標通過ECX暫存器傳遞,函式退出時由callee清理堆疊中的引數,x86構架下this指標通過ECX暫存器傳遞。同樣不支援可變數量的引數。如果顯式地把類成員函式宣告為使用__cdecl或者__stdcall,那麼,將採用__cdecl或者__stdcall的規則來壓棧和出棧,而this指標將作為函式的第一個引數最後壓入堆疊,而不是使用ECX暫存器來傳遞了。
反編譯程式碼的跟蹤(不熟悉彙編可跳過)
以下程式碼為和foo函式對應的堆疊幀建立相關的程式碼的反編譯程式碼,我將逐行給出註釋,可對照前文中對堆疊的描述:
main函式中 int result=foo(3,4); 的反彙編:
1
2
3
4
5
|
008A147E push 4
//b=4 壓入堆疊 008A1480 push 3
//a=3 壓入堆疊,到達圖2的狀態 008A1482 call foo (8A10F5h)
//函式返回值入棧,轉入foo中執行,到達圖3的狀態 008A1487 add esp,8
//foo返回,由於採用__cdecl,由Caller清理引數 008A148A mov dword ptr [result],eax
//返回值儲存在EAX中,把EAX賦予result變數 |
下面是foo函式程式碼正式執行前和執行後的反彙編程式碼
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
008A13F0 push ebp
//把ebp壓入堆疊 008A13F1 mov ebp,esp
//ebp指向先前的ebp,到達圖4的狀態 008A13F3 sub esp,0E4h
//為區域性變數分配0E4位元組的空間,到達圖5的狀態 008A13F9 push ebx
//壓入EBX 008A13FA push esi
//壓入ESI 008A13FB push edi
//壓入EDI,到達圖7的狀態 008A13FC lea edi,[ebp-0E4h]
//以下4行把區域性變數區初始化為每個位元組都等於cch 008A1402 mov ecx,39h
008A1407 mov eax,0CCCCCCCCh
008A140C rep stos dword ptr es:[edi]
......
//省略程式碼執行N行 ...... 008A1436 pop edi
//恢復EDI 008A1437 pop esi
//恢復ESI 008A1438 pop ebx
//恢復EBX 008A1439 add esp,0E4h
//回收區域性變數地址空間 008A143F cmp ebp,esp
//以下3行為Runtime Checking,檢查ESP和EBP是否一致
008A1441 call @ILT+330(__RTC_CheckEsp) (8A114Fh)
008A1446 mov esp,ebp
008A1448 pop ebp
//恢復EBP 008A1449 ret
//彈出函式返回地址,跳轉到函式返回地址執行 //(__cdecl呼叫約定,Callee未清理引數) |
參考
Debug Tutorial Part 2: The Stack
http://msdn.microsoft.com/zh-cn/library/46t77ak2(VS.80).aspx
http://www.360doc.com/content/10/1126/23/3267996_72551321.shtmlhttp://www.cnblogs.com/dwlsxj/p/Stack.html
相關文章
- JavaScript的工作原理:引擎,執行時和呼叫堆疊JavaScript
- 華為裝置堆疊原理
- 【譯】JavaScript的工作原理:引擎,執行時和呼叫堆疊的概述JavaScript
- JavaScript 工作原理之一-引擎,執行時,呼叫堆疊(譯)JavaScript
- JS 堆疊JS
- java堆疊Java
- 堆疊圖
- 平衡堆疊
- 圖的深度優先遍歷[非堆疊、堆疊實現]
- Thrift的網路堆疊
- [譯] JavaScript 如何工作:對引擎、執行時、呼叫堆疊的概述JavaScript
- C#中堆和堆疊的區別C#
- JavaScript是如何工作的:引擎,執行時和呼叫堆疊的概述!JavaScript
- (8)jvm堆疊底層原理,伺服器啟動JVM伺服器
- 圖的深度優先遍歷(堆疊實現和非堆疊實現)
- 記憶體堆疊記憶體
- C#堆疊(Stack)C#
- Java堆疊的區別有哪些Java
- [golang]如何看懂呼叫堆疊Golang
- C++堆疊詳解C++
- 泛型鏈式堆疊泛型
- 第六講 堆疊操作
- 益智補劑:Stamets堆疊
- C中關於堆疊的總結
- java 堆疊的使用方法說明Java
- 函式呼叫中堆疊的個人理解函式
- junkman 遠端堆疊監控
- 什麼是網路堆疊?
- Java 堆疊記憶體分配Java記憶體
- iOS crash 日誌堆疊解析iOS
- (js佇列,堆疊) (FIFO,LIFO)JS佇列
- z-index堆疊規則Index
- StackOverflowError堆疊溢位錯誤Error
- JS中堆疊記憶體的練習JS記憶體
- 堆疊溢位報錯引發的思考
- android 解碼混淆過的堆疊資訊Android
- javascript堆疊記憶體分配的區別JavaScript記憶體
- 不一樣的 Android 堆疊抓取方案Android