Swift与LLVM-Clang原理与示例
Swift與LLVM-Clang原理與示例
LLVM 學習
從 簡單匯編基礎 到 Swift 不簡單的 a + 1
作為iOS開發,程序崩潰猶如家常便飯,秉著沒有崩潰也要制造崩潰的原則
每天都吃的很飽
但學藝不精的經常有這樣的困擾,每次崩潰都定位到一堆。類似
movq $0x0, 0xc7a(%rip) 的天書里面
初識匯編
雖然不知道movq是什么意思,但知道move
move 的意思,沒錯是 飄逸
至于q,不管 q不q 的,哎e呢?
匯編語言
匯編語言:(assembly language) 是一種用于 電子計算機、微處理器、微控制器,或其他可編程器件的低級語言 - 維基百科
簡單來說,平時寫的代碼都是高級語言,計算機不理解高級語言,就像吃飯不吃塑料包裝一樣,吃的是里面的東西
匯編語言是二進制指令的 文本形式,計算機會把代碼轉換為匯編語言,匯編語言通過機器指令 還原成 二進制代碼,也就是所謂的 0,1,計算機就可以執行了。
每一個 CPU的機器指令不同,所以對應的匯編語言也不同。 ?
寄存器
為什么需要了解寄存器?
因為匯編語言 的數據存儲 與寄存器和內存 息息相關
一般來說,數據是放在內存中的,CPU 計算的時候就去內存里拿數據,但是
CPU 的運算速度 > 內存的運算速度
就仿佛
吃飯的速度 > 食堂大媽打菜的速度
受不了,大媽受得了嗎?
所以CPU 自帶了一級,二級緩存,相當于大媽讓她兒子給送飯
問題是這個中間層還是慢且不穩定
CPU 緩存的數據地址是 不固定的,意味著點了份 西紅柿蓋澆飯,讓店員給送到座位上,店員找了半個小時,發現坐在別人店里。
所以CPU 有了寄存器,來存儲頻繁使用的數據。CPU 通過寄存器 跟 內存 間接交換數據
寄存器都有自己的名稱(如 rax ,rdx等)
說坐在C區21號,店員還不是分分鐘把飯塞到嘴里,質問:喂,還要飯嗎?
所以CPU 會去 指定名稱的 寄存器拿數據,這樣速度就不快了嘛
天下武功,唯快不破。
所以為什么需要寄存器,因為讀寫速度夠快
內存
說到底,寄存器依舊是一個暫存區,只是一個中間站,真正存儲數據,操作數據的還是內存。
以下是內存分布圖:
簡單介紹一下堆棧
? 堆 heap
o 分配方式:alloc,速度相對棧比較慢,容易產生內存碎片
o 管理方式: 程序員,ARC下面,堆區的分配和釋放基本也是系統操作
o 地址分布:從低到高,非連續
o 大小:取決于計算機系統的有效的虛擬空間
o 作用:動態分配內存,存儲變量,延長生命周期
? 棧 stack
o 一端進行插入和刪除操作的特殊線性表
o 分配方式: 系統,速度比較快
o 管理方式: 系統,不受程序員控制
o 地址分布:從高到低,連續
o 大小:棧頂的地址和容量是系統決定
o 生命周期:出了作用域就會釋放
o 入棧出棧:先進后出,類似羽毛球筒,先放入的羽毛球,總是最后才能拿到?
在Linux 下,iterm2 敲下ulimit -a,可以看到棧分配的默認大小為 8192 ,也就是 8M
-t: cpu time (seconds) unlimited
-f: file size (blocks) unlimited
-d: data seg size (kbytes) unlimited
-s: stack size (kbytes) 8192
復制代碼
匯編語言
因為是iOS開發,所以就只稍微了解了 AT&T 匯編 的皮毛
雖然看起來會枯燥一點,但是理解這些比較常用的寄存器,對匯編代碼的理解就會有質的飛躍
之前是門外漢
現在好歹算個半個匯編人
iOS 模擬器、MAC OS、Linux : AT&T匯編 ;
iOS 真機: ARM 匯編
復制代碼
x86-64 中,AT&T 中常用的 寄存器有 16種:
? %rax、%rbx、%rcx、%rdx、%rsi、%rdi、%rbp、%rsp
? %r8、%r9、%r10、%r11、%r12、%r13、%r14、%r15
常用寄存器
AT&T 常用寄存器介紹:
%rax:常作為函數返回值。 一般來說,為了向后兼容,64位的寄存器會兼容32的寄存器,32和64可以一起使用
64位: 8個字節 ,以 r 開頭; 32位: 4個字節,以e 開頭,看圖
在64位的寄存器 rax中,為了兼容分配了較低的32位,也就是4個字節 給了 eax。基本上,匯編出現的eax 就是 代表rax,eax是 rax 的一部分,其他 部分寄存器同理
%rdi、%rsi、%rdx、%rcx、%r8、%r9: 常作為函數參數
r8,r9 這種32位的表示法,通常在后面加d,如r8d,r9d
%rip: 指令指針,存儲CPU 即將執行的指令地址
? 解釋一下rip
即將執行: 下一條執行
指令地址: 開頭的那一串 0x100…
截取2句匯編:
7 – 0x100000a64 <+20>: movq $0x1, 0x719(%rip)
8 – 0x100000a6f <+31>: movl %edi, -0x34(%rbp)
復制代碼
第7行中的 0x719(%rip) 中的 rip 就是指令指針,即將執行的 地址 就是 第8行 開頭的那個地址0x100000a6f
所以這里rip 的地址就是 0x100000a6f,有了rip 的地址
一般來說
0x719(%rip) 就是 0x719 + %rip地址
-0x719(rip) 就是 %rip - 0x719
復制代碼
棧相關
%rbp: 棧基址指針也稱為幀指向,指向棧底
%rsp: 棧指針,指向棧頂
?
常用指令
一些比較常見的能理解的指令
那么這個q 是干什么的呢 ?callq ,leaq ,movq 都有q?
? 這里的q 是 代表字節大小
o b-byte 字節,操作位寬 1個字節
o w-word ,2個字節
o l-long ,4個字節
o q-quadword,8個字節
q意味著,寄存器操作的數據類型 需要占用的 操作位寬,當然這根據 數據類型決定
復制代碼
所以上面那句代碼 ?
movq $0x1, 0x719(%rip)
意思是,立即數 1 尋址 (0x719 + %rip),并賦值。將 1 賦值給 (0x719 + 0x100000a6f) 這個地址,操作位寬是8 個字節
?
讀取寄存器
介紹幾個 lldb 的常用指令,可以方便查閱 寄存器的值
? register read/格式: 讀取寄存器的值
register read/x rax // 讀取寄存器 rax 里面的值
x:16進制
f:浮點
d:10進制
復制代碼
? register write 修改寄存器的值
(lldb) register read/x rax
rax = 0x0000000000000003
(lldb) register write rax 4 // 修改為4
(lldb) register read/x rax
rax = 0x0000000000000004
復制代碼
? x/數量-格式-字節大小: 讀取內存中的值
x/4xg 0x1000002
// 將 0x1000002 地址的值,以8個字節的格式,分成4份,16進制 展示
// 這里是展示 和 上面的操作不太一樣,g 表示8個字節
b - byte 1字節
h - half word 2字節
w - word 4字節
g - giant word 8字節
如果數據的值不夠分成4份,剩下的字節以0 補齊
復制代碼
棧幀
幀,在電影中指每一張畫面,一種平均單位
棧幀:站著的幀,畫面立體了起來,不單單是一個角度,里面包含了很多信息
包含了
每一次* 函數調用涉及的相關信息
局部變量、函數返回地址、函數參數等
復制代碼
都知道,函數的調用是會在棧上分配內存的,分配多少取決于函數的參數和局部變量
那么一個函數的占用的內存大小,函數的返回地址,就需要保存起來,這就用到了棧幀
? 為什么需要保存函數的信息?
因為函數運行完畢 ,在棧上需要釋放內存,以及繼續執行上一層代碼,需要上一層函數的返回地址,在本次函數執行完畢后,恢復父函數的棧幀結構
想象這樣一個場景
類比一下接力賽中,4位選手
棧頂 1 -> 2 -> 3 -> 4 棧底,每一位選手都要在拿到接力棒后,才會開跑
那么 1號選手,就需要保存2號選手的信息,不需要知道 3號 和 4號
下一個接棒者 長什么樣?身上的號碼牌?站在哪里?
1 號選手結束之后, 賽場隊伍就只剩 2 -> 3 -> 4,此時焦點就集中在2號選手
選手跑步 -> 函數調用
選手信息 -> 棧幀保存的信息
視線焦點 -> 棧指針,指向當前選手
只有清楚了下一位的接棒人(在棧中對應上一層函數),才能在本次結束之后找到正確的位置,繼續執行流程
復制代碼
至于信息的保存者? 取決于寄存器的標識 Caller Save 和 Callee Save
? 當子函數調用的時候,也會用到父函數的寄存器,可能會存在覆蓋寄存器的值。
- Caller Save,調用者保存
父函數調用子函數之前,將寄存器的值保存一份,這樣子函數就可以隨意覆蓋
- Callee Save,被調用者保存
父函數不保存,交由子函數 保存和恢復 寄存器的值
復制代碼
例子
簡單的建立一個 命令行 工程,打開匯編 Always Show Disassembly
用 Swift 寫出以下代碼
func test() -> Int {
var a = 3
a = a + 1
return a
}
-> test() // 斷點指向test,run
復制代碼
程序運行起來,程序斷點在 test 函數調用的地方
zzzmain: 0x100000bc0 <+0>: pushq %rbp 0x100000bc1 <+1>: movq %rsp, %rbp 0x100000bc4 <+4>: subq $0x20, %rsp 0x100000bc8 <+8>: movl %edi, -0x4(%rbp) 0x100000bcb <+11>: movq %rsi, -0x10(%rbp) -> 0x100000bcf <+15>: callq 0x100000bf0 ; zzz.test() -> Swift.Int at main.swift:189 0x100000bd4 <+20>: xorl %edi, %edi 0x100000bd6 <+22>: movq %rax, -0x18(%rbp) 0x100000bda <+26>: movl %edi, %eax 0x100000bdc <+28>: addq $0x20, %rsp 0x100000be0 <+32>: popq %rbp 0x100000be1 <+33>: retq 復制代碼 控制臺 用 si 進入 test 函數內部 可以看到 test 內部的匯編代碼,參考下面的圖,說一說理解 zzztest():
-> 0x100000bf0 <+0>: pushq %rbp
0x100000bf1 <+1>: movq %rsp, %rbp
0x100000bf4 <+4>: movq $0x0, -0x8(%rbp)
0x100000bfc <+12>: movq $0x3, -0x8(%rbp)
0x100000c04 <+20>: movq $0x4, -0x8(%rbp)
0x100000c0c <+28>: movl $0x4, %eax
0x100000c11 <+33>: popq %rbp
0x100000c12 <+34>: retq
復制代碼
? 借圖,侵刪
子函數調用時,調用者與被調用者的棧幀結構
分析
test 函數 一進來,就執行了下面兩句代碼
-> 0x100000bf0 <+0>: pushq %rbp
0x100000bf1 <+1>: movq %rsp, %rbp
復制代碼
一開始,test 函數 就進行了 壓棧
pushq %rbp
壓棧的是父函數 main函數的 棧幀指針 %rbp
% rbp指向的返回地址, 是main 函數 調用完 test ,應該回到哪里的地址,也就是當前函數test 調用開始時 棧的位置
而此時 test 函數的 %rbp ,相當于是新的%rbp
然后通過
movq %rsp, %rbp
將%rsp 也 指向 %rbp,test 棧幀 的初始位置
因為%rsp 總是指向新的元素,所以在被 一些局部變量等 填充之后,來到了棧頂
函數的調用: 棧幀被創建 -> 填充 -> 銷毀
接著
0x100000bf4 <+4>: movq $0x0, -0x8(%rbp)
0x100000bfc <+12>: movq $0x3, -0x8(%rbp)
0x100000c04 <+20>: movq $0x4, -0x8(%rbp)
復制代碼
將 立即數 0 ,賦值給 %rbp - 0x8的 8個字節 的內存空間 用于初始化
后面又將 參數3,覆蓋,以及計算+1 的值 繼續覆蓋,這里應該是省略了 +1 的操作?
接著
movl $0x4, %eax
前面說過,%rax 通常作為返回值,%eax 是 %rax 的32位表示,將 立即數4賦值給 %eax作為返回值
這里用到了movl 和 %eax,是因為 int類型 占用4個字節,只需要 4個字節即可,所以用到了 %rax 的 較低的 32位 `
到這里就得到了 test函數的 返回值 4
再來
0x100000c11 <+33>: popq %rbp
0x100000c12 <+34>: retq
復制代碼
前有 push ,后就有pop,將test 中的寄存器 %rbp 從棧中彈出,恢復調用前的 rbp,而
retq 等價于 popq %rip,前面說過rip 代表著 下一條指令
將%rip 指令指針,從新指回 test 函數調用后的 下一條 指令,這樣程序就可以繼續運行了
此時的 內存分布
test 函數的內存空間,隨著作用域的結束,就被釋放了
到底為止,就簡單的理解了 test 函數 a + 1的 匯編過程
x86-64 下函數調用及棧幀原理
緣起
在 C/C++ 程序中,函數調用是十分常見的操作。這一操作的底層原理是怎樣的?編譯器幫做了哪些操作?CPU 中各寄存器及內存堆棧在函數調用時是如何被使用的?棧幀的創建和恢復是如何完成的?針對上述問題,本本文進行了探索和研究。
通用寄存器使用慣例
函數調用時,在硬件層面需要關注的通常是cpu 的通用寄存器。在所有 cpu 體系架構中,每個寄存器通常都是有建議的使用方法的,而編譯器也通常依照CPU架構的建議來使用這些寄存器,因而可以認為這些建議是強制性的。
對于 x86-64 架構,共有16個64位通用寄存器,各寄存器及用途如下圖所示:
從上圖中,可以得到如下結論:
? 每個寄存器的用途并不是單一的。
? %rax 通常用于存儲函數調用的返回結果,同時也用于乘法和除法指令中。在imul 指令中,兩個64位的乘法最多會產生128位的結果,需要 %rax 與 %rdx 共同存儲乘法結果,在div 指令中被除數是128 位的,同樣需要%rax 與 %rdx 共同存儲被除數。
? %rsp 是堆棧指針寄存器,通常會指向棧頂位置,堆棧的 pop 和push 操作就是通過改變 %rsp 的值即移動堆棧指針的位置來實現的。
? %rbp 是棧幀指針,用于標識當前棧幀的起始位置
? %rdi, %rsi, %rdx, %rcx,%r8, %r9 六個寄存器用于存儲函數調用時的6個參數(如果有6個或6個以上參數的話)。
? 被標識為 “miscellaneous registers” 的寄存器,屬于通用性更為廣泛的寄存器,編譯器或匯編程序可以根據需要存儲任何數據。
這里還要區分一下 “Caller Save” 和 ”Callee Save” 寄存器,即寄存器的值是由”調用者保存“ 還是由 ”被調用者保存“。當產生函數調用時,子函數內通常也會使用到通用寄存器,那么這些寄存器中之前保存的調用者(父函數)的值就會被覆蓋。為了避免數據覆蓋而導致從子函數返回時寄存器中的數據不可恢復,CPU 體系結構中就規定了通用寄存器的保存方式。
如果一個寄存器被標識為”Caller Save”, 那么在進行子函數調用前,就需要由調用者提前保存好這些寄存器的值,保存方法通常是把寄存器的值壓入堆棧中,調用者保存完成后,在被調用者(子函數)中就可以隨意覆蓋這些寄存器的值了。如果一個寄存被標識為“Callee Save”,那么在函數調用時,調用者就不必保存這些寄存器的值而直接進行子函數調用,進入子函數后,子函數在覆蓋這些寄存器之前,需要先保存這些寄存器的值,即這些寄存器的值是由被調用者來保存和恢復的。
函數的調用
子函數調用時,調用者與被調用者的棧幀結構如下圖所示:
在子函數調用時,執行的操作有:父函數將調用參數從后向前壓棧 -> 將返回地址壓棧保存 -> 跳轉到子函數起始地址執行 -> 子函數將父函數棧幀起始地址(%rpb) 壓棧 -> 將 %rbp 的值設置為當前 %rsp 的值,即將 %rbp 指向子函數棧幀的起始地址。
上述過程中,保存返回地址和跳轉到子函數處執行由 call 一條指令完成,在call 指令執行完成時,已經進入了子程序中,因而將上一棧幀%rbp 壓棧的操作,需要由子程序來完成。函數調用時在匯編層面的指令序列如下:
… # 參數壓棧
call FUNC # 將返回地址壓棧,并跳轉到子函數 FUNC 處執行
… # 函數調用的返回位置
FUNC: # 子函數入口
pushq %rbp # 保存舊的幀指針,相當于創建新的棧幀
movq %rsp, %rbp # 讓 %rbp 指向新棧幀的起始位置
subq $N, %rsp # 在新棧幀中預留一些空位,供子程序使用,用 (%rsp+K) 或 (%rbp-K) 的形式引用空位
保存返回地址和保存上一棧幀的%rbp 都是為了函數返回時,恢復父函數的棧幀結構。在使用高級語言進行函數調用時,由編譯器自動完成上述整個流程。對于”Caller Save” 和 “Callee Save” 寄存器的保存和恢復,也都是由編譯器自動完成的。
父函數中進行參數壓棧時,順序是從后向前進行的。但是,這一行為并不是固定的,是依賴于編譯器的具體實現的,在gcc 中,使用的是從后向前的壓棧方式,這種方式便于支持類似于 printf(“%d, %d”, i, j) 這樣的使用變長參數的函數調用。
函數的返回
函數返回時,只需要得到函數的返回值(保存在 %rax 中),之后就需要將棧的結構恢復到函數調用之差的狀態,并跳轉到父函數的返回地址處繼續執行。由于函數調用時已經保存了返回地址和父函數棧幀的起始地址,要恢復到子函數調用之前的父棧幀,只需要執行以下兩條指令:
movq %rbp, %rsp # 使 %rsp 和 %rbp 指向同一位置,即子棧幀的起始處
popq %rbp # 將棧中保存的父棧幀的 %rbp 的值賦值給 %rbp,并且 %rsp 上移一個位置指向父棧幀的結尾處
為了便于棧幀恢復,x86-64 架構中提供了 leave 指令來實現上述兩條命令的功能。執行 leave 后,前面圖中函數調用的棧幀結構如下:
可以看出,調用 leave 后,%rsp 指向的正好是返回地址,x86-64 提供的 ret 指令,其作用就是從當前 %rsp 指向的位置(即棧頂)彈出數據,并跳轉到此數據代表的地址處,在leave 執行后,%rsp 指向的正好是返回地址,因而 ret 的作用就是把 %rsp 上移一個位置,并跳轉到返回地址執行。可以看出,leave 指令用于恢復父函數的棧幀,ret 用于跳轉到返回地址處,leave 和ret 配合共同完成了子函數的返回。當執行完成 ret 后,%rsp 指向的是父棧幀的結尾處,父棧幀尾部存儲的調用參數由編譯器自動釋放。
函數調用示例
為了更深入的了解函數調用原理,可以使用一個程序示例來觀察函數的調用和返回。程序如下:
int add(int a, int b, int c, int d, int e, int f, int g, int h) { // 8 個參數相加
int sum = a + b + c + d + e + f + g + h;
return sum;
}
int main(void) {
int i = 10;
int j = 20;
int k = i + j;
int sum = add(11, 22,33, 44, 55, 66, 77, 88);
int m = k; // 為了觀察 %rax Caller Save 寄存器的恢復
return 0;
}
在main 函數中,首先進行了一個 k=i+j 的加法,這是為了觀察 Caller Save 效果。因為加法會用到 %rax,add 函數的返回值也會使用 %rax。由于 %rax 是 Caller Save 寄存器,在調用 add 子函數之前,程序應該先保存 %rax 的值。
add 函數使用了 8 個參數,這是為了觀察當函數參數多于6個時程序的行為,前6個參數會保存到寄存器中,多于6個的參數會保存到堆棧中。但是,由于在子程序中可能會取參數的地址,保存在寄存器中的前6個參數是沒有內存地址的,因而可以猜測,保存在寄存器中的前6個參數,在子程序中也會被壓入到堆棧中,這樣才能取到這6個參數的內存地址。上面程序生成的和子函數調用相關的匯編程序如下:
add:
.LFB2:
pushq %rbp
.LCFI0:
movq %rsp, %rbp
.LCFI1:
movl %edi, -20(%rbp)
movl %esi, -24(%rbp)
movl %edx, -28(%rbp)
movl %ecx, -32(%rbp)
movl %r8d, -36(%rbp)
movl %r9d, -40(%rbp)
movl -24(%rbp), %eax
addl -20(%rbp), %eax
addl -28(%rbp), %eax
addl -32(%rbp), %eax
addl -36(%rbp), %eax
addl -40(%rbp), %eax
addl 16(%rbp), %eax
addl 24(%rbp), %eax
movl %eax, -4(%rbp)
movl -4(%rbp), %eax
leave
ret
main:
.LFB3:
pushq %rbp
.LCFI2:
movq %rsp, %rbp
.LCFI3:
subq $48, %rsp
.LCFI4:
movl $10, -20(%rbp)
movl $20, -16(%rbp)
movl -16(%rbp), %eax
addl -20(%rbp), %eax
movl %eax, -12(%rbp)
movl $88, 8(%rsp)
movl $77, (%rsp)
movl $66, %r9d
movl $55, %r8d
movl $44, %ecx
movl $33, %edx
movl $22, %esi
movl $11, %edi
call add
movl %eax, -8(%rbp)
movl -12(%rbp), %eax
movl %eax, -4(%rbp)
movl $0, %eax
leave
ret
在匯編程序中,如果使用的是64位通用寄存器的低32位,則寄存器以 ”e“ 開頭,比如 %eax,%ebx 等,對于 %r8-%r15,其低32 位是在64位寄存后加 “d” 來表示,比如 %r8d, %r15d。如果操作數是32 位的,則指令以 ”l“ 結尾,例如 movl $11, %esi,指令和寄存器都是32位的格式。如果操作數是64 位的,則指令以 q 結尾,例如 “movq %rsp, %rbp”。由于示例程序中的操作數全部在32位的表示范圍內,因而上面的加法和移動指令全部是用的32位指令和操作數,只有在創建棧幀時為了地址對齊才使用的是64位指令及操作數。
首先看 main 函數的前三條匯編語句:
.LFB3:
pushq %rbp
.LCFI2:
movq %rsp, %rbp
.LCFI3:
subq $48, %rsp
這三條語句保存了父函數的棧幀(注意main函數也有父函數),之后創建了main 函數的棧幀并且在棧幀中分配了48Byte 的空位,這三條語句執行完成后,main 函數的棧幀如下圖所示:
之后,main 函數中就進行了 k=i+j 的加法和 add 參數的處理:
movl $10, -20(%rbp)
movl $20, -16(%rbp)
movl -16(%rbp), %eax
addl -20(%rbp), %eax
movl %eax, -12(%rbp) # 調用子函數前保存 %eax 的值到棧中,caller save
movl $88, 8(%rsp)
movl $77, (%rsp)
movl $66, %r9d
movl $55, %r8d
movl $44, %ecx
movl $33, %edx
movl $22, %esi
movl $11, %edi
call add
在進行 k=i+j 加法時,使用 main 棧空間的方式較為特別。并不是按照通常認為的每使用一個棧空間就會進行一次push 操作,而是使用之前預先分配的 48 個空位,并且用 -N(%rbp) 即從 %rbp 指向的位置向下計數的方式來使用空位的,本質上這和每次進行 push 操作是一樣的,最后計算 i+j 得到的結果 k 保存在了 %eax 中。之后就需要準備調用 add 函數了。
add 函數的返回值會保存在 %eax 中,即 %eax 一定會被子函數 add 覆蓋,而現在 %eax 中保存的是 k 的值。在 C 程序中可以看到,在調用完成 add 后,又使用了 k 的值,因而在調用 add 中覆蓋%eax 之前,需要保存 %eax 值,在add 使用完%eax 后,需要恢復 %eax 值(即k 的值),由于 %eax 是 Caller Save的,應該由父函數main保存 %eax 的值,因而上面匯編中有一句 “movl %eax, -12(%rbp)” 就是在調用 add 函數之前來保存 %eax 的值的。
對于8個參數,可以看出,最后兩個參數是從后向前壓入了棧中,前6個參數全部保存到了對應的參數寄存器中,與本文開始描述的一致。
進入 add 之后的操作如下:
add:
.LFB2:
pushq %rbp # 保存父棧幀指針
.LCFI0:
movq %rsp, %rbp # 創建新棧幀
.LCFI1:
movl %edi, -20(%rbp) # 在寄存器中的參數壓棧
movl %esi, -24(%rbp)
movl %edx, -28(%rbp)
movl %ecx, -32(%rbp)
movl %r8d, -36(%rbp)
movl %r9d, -40(%rbp)
movl -24(%rbp), %eax
addl -20(%rbp), %eax
addl -28(%rbp), %eax
addl -32(%rbp), %eax
addl -36(%rbp), %eax
addl -40(%rbp), %eax
addl 16(%rbp), %eax
addl 24(%rbp), %eax
movl %eax, -4(%rbp)
movl -4(%rbp), %eax
leave
ret
add 中最前面兩條指令實現了新棧幀的創建。之后把在寄存器中的函數調用參數壓入了棧中。在本文前面提到過,由于子程序中可能會用到參數的內存地址,這些參數放在寄存器中是無法取地址的,這里把參數壓棧,正好印證了之前的猜想。
在參數壓棧時,看到并未使用 push 之類的指令,也沒有調整 %esp 指針的值,而是使用了 -N(%rbp) 這樣的指令來使用新的棧空間。這種使用”基地址+偏移量“ 使用棧的方式和直接使用 %esp 指向棧頂的方式其實是一樣的。
這里有兩個和編譯器具體實現相關的問題:一是上面程序中,-8(%rbp) 和 -12(%rbp) 地址并未被使用到,這兩個地址之前的地址 -4(%rbp) 和之后的 -16(%rsp) 都被使用到了,這可能是由于編譯器具體的實現方式來決定的。另外一個就是如下兩條指令:
movl %eax, -4(%rbp)
movl -4(%rbp), %eax
先是把 %eax 的值賦值給的 -4(%rbp),之后又逆向賦值了一次,可能是編譯器為了通用性才如此操作的。以上兩個問題需要后續進一步研究。
當add函數返回后,返回結果會存儲在%eax 中,%rbp 和 %rsp 會調整為指向 main 的棧幀,之后會執行main 函數中的如下指令:
movl %eax, -8(%rbp) # 保存 add 函數返回值到棧中,對應 C 語句 int sum = add(…)
movl -12(%rbp), %eax # 恢復 call save 寄存器 %eax 的值,與調用add前保存 %eax 相對應
movl %eax, -4(%rbp) # 對應 C 語句 m = k,%eax 中的值就是 k。
movl $0, %eax # main 函數返回值
leave # main 函數返回
ret
可以看出,當 add 函數返回時,把返回值保存到了 %eax 中,使用完返回值后,會恢復 caller save 寄存器 %eax的值,這時main 棧幀與調用 add 之前完全一樣。
在調用 add 之前,main 中執行了一條 subq 48, %rsp 這樣的指令,原因就在于調用 add 之后,main 中并未調用其他函數,而是執行了兩條賦值語句后就直接從main返回了。 main 結尾處的 leave、ret 兩條指令會直接覆蓋 %rsp 的值從而回到 main 的父棧幀中。如果先調整 main 棧幀的 %rsp 值,之后 leave 再覆蓋 %rsp 的值,相當于調整是多余的。因而省略main 中 add返回之后的 %rsp 的調整,而使用 leave 直接覆蓋%rsp更為合理。
結語
本文從匯編層面介紹了X86-64 架構下函數調用時棧幀的切換原理,了解這些底層細節對于理解程序的運行情況是十分有益的。并且在當前許多程序中,為了實現程序的高效運行,都使用了匯編語言,在了解了函數棧幀切換原理后,對于理解這些匯編也是非常有幫助的。
參考鏈接:
https://hanleylee.com/compile-of-ios-project.html
https://mp.weixin.qq.com/s/FSlJKnC0y51nsLDp1B3tXg
https://zhuanlan.zhihu.com/p/27339191
https://juejin.cn/post/6844903970993864711
總結
以上是生活随笔為你收集整理的Swift与LLVM-Clang原理与示例的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Imagination 的神经网络加速器
- 下一篇: 堆栈,数据,文本,heap,bss,te