計算機結構
在使用匯編語言之前必須要瞭解對應的CPU體系結構。下面是X86/AMD架構圖:
左邊是內存部分是常見的內存佈局。其中text一般對應代碼段,用於存儲要執行指令數據,代碼段一般是隻讀的。然後是rodata和data數據段,數據段一般用於存放全局的數據,其中rodata是隻讀的數據段。而heap段則用於管理動態的數據,stack段用於管理每個函數調用時相關的數據。在彙編語言中一般重點關注text代碼段和data數據段,因此Go彙編語言中專門提供了對應TEXT和DATA命令用於定義代碼和數據。
中間是X86提供的寄存器。寄存器是CPU中最重要的資源,每個要處理的內存數據原則上需要先放到寄存器中才能由CPU處理,同時寄存器中處理完的結果需要再存入內存。X86中除了狀態寄存器FLAGS和指令寄存器IP兩個特殊的寄存器外,還有AX、BX、CX、DX、SI、DI、BP、SP幾個通用寄存器。在X86-64中又增加了八個以R8-R15方式命名的通用寄存器。因爲歷史的原因R0-R7並不是通用寄存。在通用寄存器中BP和SP是兩個比較特殊的寄存器:其中BP用於記錄當前函數幀的開始位置,和函數調用相關的指令會隱式地影響BP的值;SP則對應當前棧指針的位置,和棧相關的指令會隱式地影響SP的值。
右邊是X86的指令集。CPU是由指令和寄存器組成,指令是每個CPU內置的算法,指令處理的對象就是全部的寄存器和內存。我們可以將每個指令看作是CPU內置標準庫中提供的一個個函數,然後基於這些函數構造更復雜的程序的過程就是用匯編語言編程的過程。
Go彙編爲了簡化彙編代碼的編寫,引入了PC、FP、SP、SB四個僞寄存器。四個僞寄存器加其它的通用寄存器就是Go彙編語言對CPU的重新抽象,該抽象的結構也適用於其它非X86類型的體系結構。
四個僞寄存器和X86/AMD64的內存和寄存器的相互關係如下圖:
在AMD64環境,僞PC寄存器其實是IP指令計數器寄存器的別名。僞FP寄存器對應的是函數的幀指針,一般用來訪問函數的參數和返回值。僞SP棧指針對應的是當前函數棧幀的底部(不包括參數和返回值部分),一般用於定位局部變量。僞SP是一個比較特殊的寄存器,因爲還存在一個同名的SP真寄存器。真SP寄存器對應的是棧的頂部,一般用於定位調用其它函數的參數和返回值。當需要區分僞寄存器和真寄存器的時候只需要記住一點:僞寄存器一般需要一個標識符和偏移量爲前綴,如果沒有標識符前綴則是真寄存器。比如 (SP)、 +8(SP)沒有標識符前綴爲真SP寄存器,而 a(SP)、b+8(SP)有標識符爲前綴表示僞寄存器。
指令集
MOV指令可以用於將字面值移動到寄存器、字面值移到內存、寄存器之間的數據傳輸、寄存器和內存之間的數據傳輸:
基礎的邏輯運算指令有AND、OR和NOT等幾個指令,對應邏輯與、或和取反等幾個指令:
控制流指令有CMP、JMP-if-x、JMP、CALL、RET等指令。CMP指令用於兩個操作數做減法,根據比較結果設置狀態寄存器的符號位和零位,可以用於有條件跳轉的跳轉條件。JMP-if-x是一組有條件跳轉指令,常用的有JL、JLZ、JE、JNE、JG、JGE等指令,對應小於、小於等於、等於、不等於、大於和大於等於等條件時跳轉。JMP指令則對應無條件跳轉,將要跳轉的地址設置到IP指令寄存器就實現了跳轉。而CALL和RET指令分別爲調用函數和函數返回指令:
其它比較重要的指令有LEA、PUSH、POP等幾個。其中LEA指令將標準參數格式中的內存地址加載到寄存器(而不是加載內存位置的內容)。PUSH和POP分別是壓棧和出棧指令,通用寄存器中的SP爲棧指針,棧是向低地址方向增長的:
當需要通過間接索引的方式訪問數組或結構體等某些成員對應的內存時,可以用LEA指令先對目前內存取地址,然後在操作對應內存的數據。
變量的內存分佈
Go彙編語言提供了DATA命令用於初始化包變量,DATA命令的語法如下:
DATA •symbol+offset(SB)/width,value
其中symbol爲變量在彙編語言中對應的標識符,offset是符號開始地址的偏移量,width是要初始化內存的寬度大小,value是要初始化的值。其中當前包中Go語言定義的符號symbol,在彙編代碼中對應 ·symbol,其中“·”中點符號爲一個特殊的unicode符號。
我們採用以下命令可以給Id變量初始化爲十六進制的0x2537,對應十進制的9527(常量需要以美元符號$開頭表示):
DATA •Id+0(SB)/1,$0x37
DATA •Id+1(SB)/1,$0x25
變量定義好之後需要導出以供其它代碼引用。Go彙編語言提供了GLOBL命令用於將符號導出:
GLOBL symbol(SB), width
其中symbol對應彙編中符號的名字,width爲符號對應內存的大小。用以下命令將彙編中的·Id變量導出:
GLOBL •Id, $8
至此初步完成了用匯編定義一個整數變量的工作。
用匯編定義一個 [2]int類型的數組變量num:
var num [2]int
然後在彙編中定義一個對應16字節大小的變量,並用零值進行初始化:
GLOBL •num(SB),$16
DATA •num+0(SB)/8,$0
DATA •num+8(SB)/8,$
下圖是Go語句和彙編語句定義變量時的對應關係:
彙編代碼中並不需要NOPTR標誌,因爲Go編譯器會從Go語言語句聲明的 [2]int 類型中推導出該變量內部沒有指針數據。
var num [2]int數組的內存佈局:
變量在data段分配空間,數組的元素地址依次從低向高排列。
函數
函數標識符通過TEXT彙編指令定義,表示該行開始的指令定義在TEXT內存段。TEXT語句後的指令一般對應函數的實現函數的定義的語法如下:
TEXT symbol(SB), [flags,] $framesize[-argsize]
函數的定義部分由5個部分組成:TEXT指令、函數名、可選的flags標誌、函數幀大小和可選的函數參數大小。
其中TEXT用於定義函數符號,函數名中當前包的路徑可以省略。函數的名字後面是 (SB) ,表示是函數名符號相對於SB僞寄存器的偏移量,二者組合在一起最終是絕對地址。標誌部分用於指示函數的一些特殊行爲,常見的 NOSPLIT主要用於指示葉子函數不進行棧分裂。framesize部分表示函數的局部變量需要多少棧空間,其中包含調用其它函數時準備調用參數的隱式棧空間。最後是可以省略的參數大小,之所以可以省略是因爲編譯器可以從Go語言的函數聲明中推導出函數參數的大小。
參數和返回值
我們首先從一個簡單的Swap函數開始。Swap函數用於交互輸入的兩個參數的順序,然後通過返回值返回交換了順序的結果。如果用Go語言中聲明Swap函數,大概這樣的:
package main
//go:nosplit
func Swap(a, b int) (int,int)
下面是main包中Swap函數在彙編中兩種定義方式:
//func Swap(a, b int) (int,int)
TEXT •Swap(SB), NOSPLIT, $0-32
//func Swap(a, b int) (int,int)
TEXT •Swap(SB), NOSPLIT, $0
對於這個函數,我們可以輕易看出它需要4個int類型的空間,參數和返回值的大小也就是32個字節。
那麼如何在彙編中引用這4個參數呢?爲此Go彙編中引入了一個FP僞寄存器,表示函數當前幀的地址,也就是第一個參數的地址。因此我們以通過 +0(FP)、+8(FP)、+16(FP) 和 +24(FP)來分別引用a、b、ret0和ret1四個參數。任何通過FP僞寄存器訪問的變量必和一個臨時標識符前綴組合後纔能有效,一般使用參數對應的變量名作爲前綴.
下圖是Swap函數中參數和返回值在內存中的佈局圖:
TEXT •Swap(SB), $0
MOVQ a+0(FP), AX //AX =a
MOVQ b+8(FP), BX //BX =b
MOVQ BX, ret0+16(FP) //ret0 =BX
MOVQ AX, ret1+24(FP) //ret1 =AX
RET
從代碼可以看出a、b、ret0和ret1的內存地址是依次遞增的,FP僞寄存器是第一個變量的開始地址。
函數的局部變量
從Go彙編角度看,局部變量是指函數運行時,在當前函數棧幀所對應的內存內的變量,不包含函數的參數和返回值(因爲訪問方式有差異)。函數棧幀的空間主要由函數參數和返回值、局部變量和被調用其它函數的參數和返回值空間組成。
爲了便於訪問局部變量,Go彙編語言引入了僞SP寄存器,對應當前棧幀的底部。因爲在當前棧幀時棧的底部是固定不變的,因此局部變量的相對於僞SP的偏移量也就是固定的,這可以簡化局部變量的維護工作。SP真僞寄存器的區分只有一個原則:如果使用SP時有一個臨時標識符前綴就是僞SP,否則就是真SP寄存器。比如a(SP)和b+8(SP)有a和b臨時前綴,這裏都是僞SP,而前綴部分一般用於表示局部變量的名字。而(SP)和+8(SP)沒有臨時標識符作爲前綴,它們都是真SP寄存器。
在X86平臺,函數的調用棧是從高地址向低地址增長的,因此僞SP寄存器對應棧幀的底部其實是對應更大的地址。當前棧的頂部對應真實存在的SP寄存器,對應當前函數棧幀的棧頂,對應更小的地址。如果整個內存用Memory數組表示,那麼 Memory[0(SP):end-0(SP)]就是對應當前棧幀的切片,其中開始位置是真SP寄存器,結尾部分是僞SP寄存器。真SP寄存器一般用於表示調用其它函數時的參數和返回值,真SP寄存器對應內存較低的地址,所以被訪問變量的偏移量是正數;而僞SP寄存器對應高地址,對應的局部變量的偏移量都是負數。
func Foo() {
var c []byte
var b int16
var a bool
}
然後通過彙編語言重新實現Foo函數,並通過僞SP來定位局部變量:
TEXT •Foo(SB), $32-0
MOVQ a-32(SP), AX // a
MOVQ b-30(SP), BX // b
MOVQ c_data-24(SP), CX // c.Data
MOVQ c_len-16(SP), DX // c.Len
MOVQ c_cap-8(SP), DI // c.Cap
RET
Foo函數有3個局部變量,但是沒有調用其它的函數,因爲對齊和填充的問題導致函數的棧幀大小爲32個字節。因爲Foo函數沒有參數和返回值,因此參數和返回值大小爲0個字節,當然這個部分可以省略不寫。而局部變量中先定義的變量c離僞SP寄存器對應的地址最遠,最後定義的變量a離僞SP寄存器最近。有兩個因素導致出現這種逆序的結果:一個從Go語言函數角度理解,先定義的c變量地址要比後定義的變量的地址更小;另一個是僞SP寄存器對應棧幀的底部,而X86中棧是從高向地生長的,所以最先定義有着更小地址的c變量離棧的底部僞SP更遠。
下面是Foo函數的局部變量的大小和內存佈局:
是參數和返回值是通過僞FP寄存器定位的,FP寄存器對應第一個參數的開始地址
(第一個參數地址較低),因此每個變量的偏移量是正數。而局部變量是通過僞SP寄存器定位的,而僞SP寄存器對應的是第一個局部變量的結束地址(第一個局部變量地址較大),因此每個局部變量的偏移量都是負數。
函數調用
在前文中我們已經學習了一些彙編實現的函數參數和返回值處理的規則。那麼一個顯然的問題是,彙編函數的參數是從哪裏來的?答案同樣明顯,被調用函數的參數是由調用方準備的:調用方在棧上設置好空間和數據後調用函數,被調用方在返回前將返回值放在對應的位置,函數通過RET指令返回調用方函數之後,調用方再從返回值對應的棧內存位置取出結果。Go語言函數的調用參數和返回值均是通過棧傳輸的,這樣做的優點是函數調用棧比較清晰,缺點是函數調用有一定的性能損耗。爲了便於展示,我們先使用Go語言來構造三個逐級調用的函數:
func main(){
printsum(1,2)
}
func printsum(a,b int){
var ret=sum(a,b)
println(ret)
}
func sum(a,b int)int{
return a+b
}
下圖展示了三個函數逐級調用時內存中函數參數和返回值的佈局:
要記住的是調用函數時,被調用函數的參數和返回值內存空間都必須由調用者提供。因此函數的局部變量和爲調用其它函數準備的棧空間總和就確定了函數幀的大小。調用其它函數前調用方要選擇保存相關寄存器到棧中,並在調用函數返回後選擇要恢復的寄存器進行保存。最終通過CALL指令調用函數的過程和調用我們熟悉的調用println函數輸出的過程類似。
和C語言函數不同,Go語言函數的參數和返回值完全通過棧傳遞。下面是Go函數調用時棧的佈局圖:
首先是調用函數前準備的輸入參數和返回值空間。然後CALL指令將首先觸發返回地址入棧操作。在進入到被調用函數內之後,彙編器自動插入了BP寄存器相關的指令,因此BP寄存器和返回地址是緊挨着的。再下面就是當前函數的局部變量的空間,包含再次調用其它函數需要準備的調用參數空間。被調用的函數執行RET返回指令時,先從棧恢復BP和SP寄存器,接着取出的返回地址跳轉到對應的指令執行。
函數控制流
程序主要有順序、分支和循環幾種執行流程。
順序:
func main() {
var a,b int
a=10
runtime.printint(a)
runtime.printnl()
b=a
b+=b
b*=a
runtime.printint(b)
runtime.printnl()
}
彙編函數:
TEXT •main(SB), $24-0
MOVQ $0, a-8*2(SP) //a= 0
MOVQ $0, b-8*1(SP) //b=0
//將新的值寫入a對應內存
MOVQ $10, AX //AX =10
MOVQ AX, a-8*2(SP) //a =AX
//以a爲參數調用函數
MOVQ AX, 0(SP)
CALL runtime•printint(SB)
CALL runtime•printnl(SB)
//函數調用後, AX/BX 寄存器可能被污染, 需要重新加載
MOVQ a-8*2(SP), AX //AX=a
MOVQ b-8*1(SP), BX //BX=b
//計算b值,並寫入內存
MOVQ AX, BX //BX=AX //b=a
ADDQ BX, BX //BX+=BX //b+=a
IMULQ AX, BX //BX*=AX //b*=a
MOVQ BX, b-8*1(SP) //b=BX
// 以b爲參數調用函數
MOVQ BX, 0(SP)
CALL runtime•printint(SB)
CALL runtime•printnl(SB)
RET
彙編實現main函數的第一步是要計算函數棧幀的大小。因爲函數內有a、b兩個int類型變量,同時調用
的runtime·printint函數參數是一個int類型並且沒有返回值,因此main函數的棧幀是3個int類型組成的24個字節的棧內存空間。
給a變量分配一個AX寄存器,並且通過AX寄存器將a變量對應的內存設置爲10,AX也是10。爲了輸出a變量,需要將AX寄存器的值放到 0(SP) 位置,這個位置的變量將在調用runtime·printint函數時作爲它的參數被打印。因爲我們之前已經將AX的值保存到a變量內存中了,因此在調用函數前並不需要再進行寄存器的備份工作。
在調用函數返回之後,全部的寄存器將被視爲可能被調用的函數修改,因此我們需要從a、b對應的內存中重新恢復寄存器AX和BX。然後參考上面Go語言中b變量的計算方式更新BX對應的值,計算完成後同樣將BX的值寫入到b對應的內存。
需要說明的是,上面的代碼中IMULQ AX, BX使用了IMULQ指令來計算乘法。沒有使用 MULQ指令的原因是MULQ指令默認使用AX保存結果。讀者可以自己嘗試用 MULQ指令改寫上述代碼。最後以b變量作爲參數再次調用runtime·printint函數進行輸出工作。所有的寄存器同樣可能被污染,不過main函數馬上就返回了,因此不再需要恢復AX、BX等寄存器了。
經過用匯編的思維改寫過後,上述的Go函數雖然看着繁瑣了一點,但是還是比較容易理解的。下面我們進一步嘗試將改寫後的函數繼續轉譯爲彙編函數:
TEXT •main(SB), $24-0
MOVQ $0, a-8*2(SP) //a=0
MOVQ $0, b-8*1(SP) //b=0
//將新的值寫入a對應內存
MOVQ $10, AX //AX=10
MOVQ AX, a-8*2(SP) //a=AX
//以a爲參數調用函數
MOVQ AX, 0(SP)
CALL runtime•printint(SB)
CALL runtime•printnl(SB)
//函數調用後, AX/BX 寄存器可能被污染, 需要重新加載
MOVQ a-8*2(SP), AX //AX =a
MOVQ b-8*1(SP), BX //BX =b
//計算b值, 並寫入內存
MOVQ AX, BX //BX=AX //b=a
ADDQ BX, BX //BX +=BX //b+=a
IMULQ AX, BX //BX*=AX //b*=a
MOVQ BX, b-8*1(SP) //b =BX
//以b爲參數調用函數
MOVQ BX, 0(SP)
CALL runtime•printint(SB)
CALL runtime•printnl(SB)
RET
If/goto跳轉:
func If(ok bool,a,b int)int{
if ok {
return a
}else{
return b
}
}
用匯編思維改寫後的If函數實現如下:
func If(ok int,a,b int)int {
if ok == 0{ goto L}
return a
L:
return b
}
彙編實現:
TEXT •If(SB), NOSPLIT, $0-32
MOVQ ok+8*0(FP), CX // ok
MOVQ a+8*1(FP), AX //a
MOVQ b+8*2(FP), BX //b
CMPQ CX, $0 //test ok
JZ L //ifok== 0,goto L
MOVQ AX, ret+24(FP) //return a
RET
L:
MOVQ BX, ret+24(FP) //return b
RET
首先是將三個參數加載到寄存器中,ok參數對應CX寄存器,a、b分別對應AX、BX寄存器。然後使用CMPQ比較指令將CX寄存器和常數0進行比較。如果比較的結果爲0,那麼下一條JZ爲0時跳轉指令將跳轉到L標號對應的語句,也就是返回變量b的值。如果比較的結果不爲0,那麼JZ指令將沒有效果,繼續執行後面的指令,也就是返回變量a的值。
在跳轉指令中,跳轉的目標一般是通過一個標號表示。不過在有些通過宏實現的函數中,更希望通過相對位置跳轉,這時候可以通過PC寄存器的偏移量來計算臨近跳轉的位置。
for循環:
func LoopAdd(cnt, v0, step int) int{
result :=v0
for i := 0;i < cnt; i++{
result += step
}
return result
}
用匯編思維改寫:
func LoopAdd(cnt, v0, step int) int {
var i=0
var result= 0
LOOP_BEGIN:
result=v0
LOOP_IF:
if i < cnt { goto LOOP_BODY }
goto LOOP_END
LOOP_BODY
i = i+1
result = result+step
goto LOOP_IF
LOOP_END:
return result
}
彙編語言實現:
//func LoopAdd(cnt,v0, step int)int
TEXT •LoopAdd(SB), NOSPLIT, $0-32
MOVQ cnt+0(FP), AX //cnt
MOVQ v0+8(FP), BX //v0/result
MOVQ step+16(FP), CX //step
LOOP_BEGIN:
MOVQ $0, DX //i
LOOP_IF:
CMPQ DX, AX //comparei,cnt
JL LOOP_BODY //if i< cnt:goto LOOP_BODY
JMP LOOP_END
LOOP_BODY:
ADDQ $1, DX //i++
ADDQ CX, BX //result+=step
JMP LOOP_IF
LOOP_END:
MOVQ BX, ret+24(FP) //return result
RET
其中v0和result變量複用了一個BX寄存器。在LOOP_BEGIN標號對應的指令部分,用MOVQ將DX寄存器初始化爲0,DX對應變量i,循環的迭代變量。在LOOP_IF標號對應的指令部分,使用CMPQ指令比較DX和AX,如果循環沒有結束則跳轉到LOOP_BODY部分,否則跳轉到LOOP_END部分結束循環。在LOOP_BODY部分,更新迭代變量並且執行循環體中的累加語句,然後直接跳轉到LOOP_IF部分進入下一輪循環條件判斷。LOOP_END標號之後就是返回累加結果的語句。
循環是最複雜的控制流,循環中隱含了分支和跳轉語句。掌握了循環的寫法基本也就掌握了彙編語言的基礎寫法。
進階
Go彙編實現的函數或調用函數的指令在最終代碼中也會被插入額外的指令。要徹底理解Go彙編語言就需要徹底瞭解彙編器到底插入了哪些指令。爲了便於分析,我們先構造一個禁止棧分裂的printnl函數。printnl函數內部都通過調用runtime.printnl函數輸出換行:
TEXT •printnl_nosplit(SB), NOSPLIT, $8
CALL runtime•printnl(SB)
RET
然後通過 go tool asm -S main_amd64.s 指令查看編譯後的目標代碼:
"".printnl_nosplit STEXT nosplit size=29 args=0xffffffff80000000 locals=0x10
0x0000 00000 (main_amd64.s:5) TEXT "".printnl_nosplit(SB), NOSPLIT $16
0x0000 00000 (main_amd64.s:5) SUBQ $16, SP
0x0004 00004 (main_amd64.s:5) MOVQ BP, 8(SP)
0x0009 00009 (main_amd64.s:5) LEAQ 8(SP), BP
0x000e 00014 (main_amd64.s:6) CALL runtime.printnl(SB)
0x0013 00019 (main_amd64.s:7) MOVQ 8(SP), BP
0x0018 00024 (main_amd64.s:7) ADDQ $16, SP
0x001c 00028 (main_amd64.s:7) RET
輸出代碼中我們刪除了非指令的部分。爲了便於講述,我們將上述代碼重新排版,並根據縮進表示相關的功能:
TEXT "".printnl(SB), NOSPLIT, $16
SUBQ $16, SP
MOVQ BP, 8(SP)
LEAQ 8(SP), BP
CALL runtime.printnl(SB)
MOVQ 8(SP), BP
ADDQ $16, SP
RET
第一層是TEXT指令表示函數開始,到RET指令表示函數返回。第二層是SUBQ $16, SP指令爲當前函數幀分配16字節的空間,在函數返回前通過ADDQ $16, SP指令回收16字節的棧空間。我們謹慎猜測在第二層是爲函數多分配了8個字節的空間。那麼爲何要多分配8個字節的空間呢?再繼續查看第三層的指令:開始部分有兩個指令MOVQ BP, 8(SP)和LEAQ 8(SP), BP,首先是將BP寄存器保持到多分配的8字節棧空間,然後將 8(SP)地址重新保持到了BP寄存器中;結束部分是 MOVQ 8(SP),BP指令則是從棧中恢復之前備份的前BP寄存器的值。最裏面第四次層纔是我們寫的代碼,調用runtime.printnl函數輸出換行。
如果去掉NOSPILT標誌,再重新查看生成的目標代碼,會發現在函數的開頭和結尾的地方又增加了新的指令。下面是經過縮進格式化的結果:
TEXT "".printnl_nosplit(SB), $16
L_BEGIN:
MOVQ (TLS), CX
CMPQ SP, 16(CX)
JLS L_MORE_STK
SUBQ $16, SP
MOVQ BP, 8(SP)
LEAQ 8(SP), BP
CALL runtime.printnl(SB)
MOVQ 8(SP), BP
ADDQ $16, SP
L_MORE_STK:
CALL runtime.morestack_noctxt(SB)
JMP L_BEGIN
RET
其中開頭有三個新指令, MOVQ (TLS), CX 用於加載g結構體指針,然後第二個指令 CMPQ SP,16(CX) SP棧指針和g結構體中stackguard0成員比較,如果比較的結果小於0則跳轉到結尾的L_MORE_STK部分。當獲取到更多棧空間之後,通過 JMP L_BEGIN指令跳轉到函數的開始位置重新進行棧空間的檢測。
g結構體在 $GOROOT/src/runtime/runtime2.go 文件定義,開頭的結構成員如下:
type g struct {
// Stack parameters.
stack stack
stackguard0 uintptr // offset known to liblink
stackguard1 uintptr // offset known to liblink
}
第一個成員是stack類型,表示當前棧的開始和結束地址。stack的定義如下:
type stack struct {
lo uintptr
hi uintptr
}
在g結構體中的stackguard0成員是出現爆棧前的警戒線。stackguard0的偏移量是16個字節,因此上述代碼中的 CMPQ SP, 16(AX) 表示將當前的真實SP和爆棧警戒線比較,如果超出警戒線則表示需要進行棧擴容,也就是跳轉到L_MORE_STK。在L_MORE_STK標號處,先調用runtime·morestack_noctxt進行棧擴容,然後又跳回到函數的開始位置,此時此刻函數的棧已經調整了。然後再進行一次棧大小的檢測,如果依然不足則繼續擴容,直到棧足夠大爲止。
之前在goroutine中也提到過到依靠棧擴容來實現搶佔。