本文所使用的golang為1.14,gdb為8.1。
一直以來對於函式呼叫都僅限於函式呼叫棧這個概念上,但對於其中的詳細結構卻瞭解不多。所以用gdb除錯一個簡單的例子,一探究竟。
函式呼叫棧的結構(以下簡稱棧)
棧包含以下作用:
- 儲存函式返回地址。
- 儲存呼叫者的rbp。
- 儲存區域性變數。
- 為被呼叫函式預留返回值記憶體空間。
- 向被呼叫函式傳遞引數。
每個函式在執行時都需要一段記憶體來儲存上述的內容,這段記憶體被稱為函式的“棧幀”
一般CPU中包含兩個與棧相關的暫存器:
- rsp:始終指向整個函式呼叫棧的棧頂
- rbp:指向棧幀的開始位置
但儲存函式返回地址的記憶體單元的地址並不在rbp~rsp之間。而是在0x8(%rbp)的位置
棧的工作原理
棧是一種後進先出(LIFO)的結構,在Linux AMD64環境中,golang棧由高地址向低地址生長。
當發生函式呼叫時,由於呼叫者未執行完成,棧幀還要繼續使用,不可以被呼叫者覆蓋,所以要在當前棧頂外繼續為被呼叫者劃分棧幀。這個操作叫做壓棧(push),並向外移動rbp、rsp,棧空間隨之增長。
與之對應的,當被呼叫者執行完成時,其棧幀就會被收回。這個操作叫出棧(pop),並向內移動rbp、rsp,棧空間隨之縮小。呼叫者繼續執行
棧空間的生長和收縮是由編譯器生成的程式碼自動管理的的,與堆不同(手動或者gc)。
流程圖
先給出流程圖,好心裡有個數:
程式碼及編譯
指定 -gcflags="-N -l" 是為了關閉編譯器優化。
go build -gcflags="-N -l" -o test test.go
為了方便檢視記憶體內容,將變數都宣告為了int64。
package main
func main() {
caller()
}
func caller() {
var a int64 = 1
var b int64 = 2
callee(a, b)
}
func callee(a, b int64) (int64, int64) {
c := a + 5
d := b * 4
return c, d
}
反彙編程式碼
反彙編的內容為:
- 指令地址
- 指令相對於當前函式起始位置以位元組為單位的偏移
- 指令內容
gdb test
斷點打在caller方法上,因為主要的研究物件是caller與callee。
(gdb) b main.caller
Breakpoint 1 at 0x458360: file /root/study/test.go, line 7.
輸入run 執行程式。
caller函式反彙編,/s 表示將原始碼與彙編程式碼一起顯示,如不指定則只顯示彙編程式碼。
可使用step(s)按原始碼級別除錯,或者stepi(si)按彙編指令級別除錯。
下面是caller、callee的反彙編程式碼和原始碼註釋,還有與之相關的記憶體結構對照表。
(gdb) disassemble /s
Dump of assembler code for function main.caller:
7 func caller() {
=> 0x0000000000458360 <+0>: mov %fs:0xfffffffffffffff8,%rcx # 將當前g的指標存入rcx
0x0000000000458369 <+9>: cmp 0x10(%rcx),%rsp # 比較g.stackguard0和rsp
0x000000000045836d <+13>: jbe 0x4583b0 <main.caller+80> # 如果rsp較小,表示棧有溢位風險,呼叫runtime.morestack_noctxt
0x000000000045836f <+15>: sub $0x38,%rsp # 劃分0x38位元組的棧空間
0x0000000000458373 <+19>: mov %rbp,0x30(%rsp) # 儲存呼叫者main的rbp
0x0000000000458378 <+24>: lea 0x30(%rsp),%rbp # 設定此函式棧的rbp
8 var a int64 = 1
0x000000000045837d <+29>: movq $0x1,0x28(%rsp) # 區域性變數a入棧
9 var b int64 = 2
0x0000000000458386 <+38>: movq $0x2,0x20(%rsp) # 區域性變數b入棧
10 callee(a, b)
0x000000000045838f <+47>: mov 0x28(%rsp),%rax # 讀取第一個引數到rax
0x0000000000458394 <+52>: mov %rax,(%rsp) # callee第一個引數入棧
0x0000000000458398 <+56>: movq $0x2,0x8(%rsp) # callee第二個引數入棧
0x00000000004583a1 <+65>: callq 0x4583c0 <main.callee> # 呼叫callee
11 }
0x00000000004583a6 <+70>: mov 0x30(%rsp),%rbp # rbp還原為main的rbp
0x00000000004583ab <+75>: add $0x38,%rsp # rsp還原為main的rsp
0x00000000004583af <+79>: retq # 返回
<autogenerated>:
0x00000000004583b0 <+80>: callq 0x451b30 <runtime.morestack_noctxt>
0x00000000004583b5 <+85>: jmp 0x458360 <main.caller>
End of assembler dump.
callee函式反彙編
(gdb) s # 單步除錯進入的callee函式
main.callee (a=1, b=2, ~r2=824634073176, ~r3=0) at /root/study/test.go:13
13 func callee(a, b int64) (int64, int64) {
(gdb) disassemble /s
Dump of assembler code for function main.callee:
13 func callee(a, b int64) (int64, int64) {
=> 0x00000000004583c0 <+0>: sub $0x18,%rsp # 劃分0x18大小的棧
0x00000000004583c4 <+4>: mov %rbp,0x10(%rsp) # 儲存呼叫者caller的rbp
0x00000000004583c9 <+9>: lea 0x10(%rsp),%rbp # 設定此函式棧的rbp
0x00000000004583ce <+14>: movq $0x0,0x30(%rsp) # 初始化第一個返回值為0
0x00000000004583d7 <+23>: movq $0x0,0x38(%rsp) # 初始化第二個返回值為0
14 c := a + 5
0x00000000004583e0 <+32>: mov 0x20(%rsp),%rax # 從記憶體中獲取第一個引數值到rax
0x00000000004583e5 <+37>: add $0x5,%rax # rax+=5
0x00000000004583e9 <+41>: mov %rax,0x8(%rsp) # 區域性變數c入棧
15 d := b * 4
0x00000000004583ee <+46>: mov 0x28(%rsp),%rax # 從記憶體中獲取第二個引數值到rax
0x00000000004583f3 <+51>: shl $0x2,%rax # rax*=2
0x00000000004583f7 <+55>: mov %rax,(%rsp) # 區域性變數d入棧
16 return c, d
0x00000000004583fb <+59>: mov 0x8(%rsp),%rax # 區域性變數c的值儲存到rax
0x0000000000458400 <+64>: mov %rax,0x30(%rsp) # 將c賦值給第一個返回值
0x0000000000458405 <+69>: mov (%rsp),%rax # 區域性變數d的值儲存到rax
0x0000000000458409 <+73>: mov %rax,0x38(%rsp) # 將d賦值給第二個返回值
17 }
0x000000000045840e <+78>: mov 0x10(%rsp),%rbp # rbp還原為caller的rbp
0x0000000000458413 <+83>: add $0x18,%rsp # rsp還原為caller的rsp
0x0000000000458417 <+87>: retq # 返回
End of assembler dump.
記憶體結構對照表
一些結論
- golang通過rsp加偏移量訪問棧幀。
- 被呼叫者的入參是位於呼叫者的棧中。
- caller會為有返回值的callee,在棧中預留返回值記憶體空間。而callee在執行return時,會將返回值寫入caller在棧中預留的空間。
- 意外收穫是瞭解了多值返回的實現。