目錄
文章目錄
《C 語言編程 — GCC 工具鏈》
《C 語言編程 — 程序的編譯流程》
《C 語言編程 — 靜態庫、動態庫和共享庫》
《C 語言編程 — 程序的裝載與運行》
《計算機組成原理 — 指令系統》
《C 語言編程 — 結構化程序流的彙編代碼與 CPU 指令集》
爲什麼要保留彙編語言
彙編語言是與機器語言最接近的高級編程語言(或稱爲中級編程語言),彙編語言基本上與機器語言對應,即彙編指令和計算機指令是相對匹配的。雖然彙編語言具有與硬件的關係密切,佔用內存小,運行速度快等優點,但也具有可讀性低、可重用性差,開發效率低下等問題。高級語言的出現是爲了解決這些問題,讓軟件開發變得更加簡單高效,易於協作。但高級語言也存在自己的缺陷,例如:難以編寫直接操作硬件設備的程序等。
所以爲了權衡上述的問題,最終彙編語言被作爲中間的狀態保留了下來。一些高級語言(e.g. C 語言)提供了與彙編語言之間的調用接口,彙編程序可作爲高級語言的外部過程或函數,利用堆棧在兩者之間傳遞參數或參數的訪問地址。兩者的源程序通過編譯或彙編生成目標文件(OBJ)之後再利用連接程序(linker)把它們連接成爲可執行文件便可在計算機上運行了。保留彙編語言還爲程序員提供一種調優的手段,無論是 C 程序還是 Python 程序,當我們要進行代碼性能優化時,瞭解程序的彙編代碼是一個不錯的切入點。
順序程序流
計算機指令是一種邏輯上的抽象設計,而機器碼則是計算機指令的物理表現。機器碼(Machine Code),又稱爲機器語言,本質是由 0 和 1 組成的數字序列。一條機器碼就是一條計算機指令。程序由指令組成,但讓人類使用機器碼來編寫程序顯然是不人道的,所以逐步發展了對人類更加友好的高級編程語言。這裏我們需要了解計算機是如何將高級編程語言編譯爲機器碼的。
Step 1. 編寫高級語言程序。
// test.c
int main()
{
int a = 1;
int b = 2;
a = a + b;
}
Step 2. 編譯(Compile),將高級語言編譯成彙編語言(ASM)程序。
$ gcc -g -c test.c
Step 3. 使用 objdump 命令反彙編目標文件,輸出可閱讀的二進制信息。下述左側的一堆數字序列就是一條條機器碼,右側 push、mov、add、pop 一類的就是彙編代碼。
$ objdump -d -M intel -S test.o
test.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <main>:
int main()
{
0: 55 push rbp
1: 48 89 e5 mov rbp,rsp
int a = 1;
4: c7 45 fc 01 00 00 00 mov DWORD PTR [rbp-0x4],0x1
int b = 2;
b: c7 45 f8 02 00 00 00 mov DWORD PTR [rbp-0x8],0x2
a = a + b;
12: 8b 45 f8 mov eax,DWORD PTR [rbp-0x8]
15: 01 45 fc add DWORD PTR [rbp-0x4],eax
}
18: 5d pop rbp
19: c3 ret
NOTE:這裏的程序入口是 main()
函數,而不是第 0 條彙編代碼。
條件程序流
值得注意的是,某些特殊的指令,比如跳轉指令,會主動修改 PC 的內容,此時下一條地址就不是從存儲器中順序加載的了,而是到特定的位置加載指令內容。這就是 if…else 條件語句,while/for 循環語句的底層支撐原理。
Step 1. 編寫高級語言程序。
// test.c
#include <time.h>
#include <stdlib.h>
int main()
{
srand(time(NULL));
int r = rand() % 2;
int a = 10;
if (r == 0)
{
a = 1;
} else {
a = 2;
}
}
Step 2. 編譯(Compile),將高級語言編譯成彙編語言。
$ gcc -g -c test.c
Step 3. 使用 objdump 命令反彙編目標文件,輸出可閱讀的二進制信息。我們主要分析 if…else 語句。
if (r == 0)
33: 83 7d fc 00 cmp DWORD PTR [rbp-0x4],0x0
37: 75 09 jne 42 <main+0x42>
{
a = 1;
39: c7 45 f8 01 00 00 00 mov DWORD PTR [rbp-0x8],0x1
40: eb 07 jmp 49 <main+0x49>
} else {
a = 2;
42: c7 45 f8 02 00 00 00 mov DWORD PTR [rbp-0x8],0x2
}
首先進入條件判斷,彙編代碼爲 cmp 比較指令,比較數 1:DWORD PTR [rbp-0x4]
表示變量 r 是一個 32 位整數,數據在寄存器 [rbp-0x4] 中;比較數 2:0x0
表示常量 0 的十六進制。比較的結果會存入到 條件碼寄存器,等待被其他指令讀取。當判斷條件爲 True 時,ZF 設置爲 1,反正設置爲 0。
條件碼寄存器(Condition Code)是一種單個位寄存器,它們的值只能爲 0 或者 1。當有算術與邏輯操作發生時,這些條件碼寄存器當中的值就隨之發生變化。後續的指令通過檢測這些條件碼寄存器來執行條件分支指令。常用的條件碼類型如下:
- CF:進位標誌寄存器。最近的操作是最高位產生了進位。它可以記錄無符號操作的溢出,當溢出時會被設爲 1。
- ZF:零標誌寄存器,最近的操作得出的結果爲 0。當計算結果爲 0 時將會被設爲 1。
- SF:符號標誌寄存器,最近的操作得到的結果爲負數。當計算結果爲負數時會被設爲 1。
- OF:溢出標誌寄存器,最近的操作導致一個補碼溢出(正溢出或負溢出)。當計算結果導致了補碼溢出時,會被設爲 1。
回到正題,PC 繼續自增,執行下一條 jnp 指令。jnp(jump if not equal)會查看 ZF 的內容,若爲 0 則跳轉到地址 42 <main+0x42>
(42 表示彙編代碼的行號)。前文提到,當 CPU 執行跳轉類指令時,PC 就不再通過自增的方式來獲得下一條指令的地址,而是直接被設置了 42 行對應的地址。由此,CPU 會繼續將 42 對應的指令讀取到 IR 中並執行下去。
42 行執行的是 mov 指令,表示將操作數 2:0x2
移入到 操作數 1:DWORD PTR [rbp-0x8]
中。就是一個賦值語句的底層實現支撐。接下來 PC 恢復如常,繼續以自增的方式獲取下一條指令的地址。
循環程序流
- C 語言代碼
// test.c
int main()
{
int a = 0;
int i;
for (i = 0; i < 3; i++)
{
a += i;
}
}
- 計算機指令與彙編代碼
for (i = 0; i < 3; i++)
b: c7 45 f8 00 00 00 00 mov DWORD PTR [rbp-0x8],0x0
12: eb 0a jmp 1e <main+0x1e>
{
a += i;
14: 8b 45 f8 mov eax,DWORD PTR [rbp-0x8]
17: 01 45 fc add DWORD PTR [rbp-0x4],eax
for (i = 0; i < 3; i++)
1a: 83 45 f8 01 add DWORD PTR [rbp-0x8],0x1
1e: 83 7d f8 02 cmp DWORD PTR [rbp-0x8],0x2
22: 7e f0 jle 14 <main+0x14>
}
函數調用棧的工作原理
與普通的跳轉程序(e.g. if…else、while/for)不同,函數調用的特點在於具有迴歸(return)的特點,在調用的函數執行完之後會再次回到執行調用的 call 指令的位置,繼續往下執行。能夠實現這個效果,完全依賴堆棧(Stack)存儲區的特性。 首先我們需要了解幾個概念。
-
堆棧(Stack):是有若干個連續的存儲器單元組成的先進後出(FILO)存儲區,用於提供操作數、保存運算結果、暫存中斷和子程序調用時的線程數據及返回地址。通過執行堆棧的 Push(壓棧)和 Pop(出棧)操作可以將指定的數據在堆棧中放入和取出。堆棧具有棧頂和棧底之分,棧頂的地址最低,而棧底的地址最高。堆棧的 FILO 的特性非常適用於函數調用的場景:父函數調用子函數,父函數在前,子函數在後;返回時,子函數先返回,父函數後返回。
-
棧幀(Stack Frame):是堆棧中的邏輯空間,每次函數調用都會在堆棧中生成一個棧幀,對應着一個未運行完的函數。從邏輯上講,棧幀就是一個函數執行的環境,保存了函數的參數、函數的局部變量以及函數執行完後返回到哪裏的返回地址等等。棧幀的本質是兩個指針寄存器: EBP(基址指針,又稱幀指針)和 ESP(棧指針)。其中 EBP 指向幀底,而 ESP 指向棧頂。當程序運行時,ESP 是可以移動的,大多數信息的訪問都通過移動 ESP 來完成,而 EBP 會一直處於幀低。EBP ~ ESP 之間的地址空間,就是當前執行函數的地址空間。
NOTE:EBP 指向當前位於系統棧最上邊一個棧幀的底部,而不是指向系統棧的底部。嚴格說來,“棧幀底部” 和 “系統棧底部” 不是同一個概念,而 ESP 所指的棧幀頂部和系統棧頂部是同一個位置。
簡單概括一下函數調用的堆棧行爲,ESP 隨着當前函數的壓棧和出棧會不斷的移動,但由於 EBP 的存在,所以當前執行函數棧幀的邊界是始終清晰的。當一個當前的子函數調用完成之後,EBP 就會跳到父函數棧幀的底部,而 ESP 也會隨其自然的來到父函數棧幀的頭部。所以,理解函數調用堆棧的運作原理,主要要掌握 EBP 和 ESP 的動向。下面以一個例子來說明。
NOTE:我們習慣將將父函數(調用函數的函數)稱爲 “調用者(Caller)”,將子函數(被調用的函數)稱爲 “被調用者(Callee)”。
- C 程序代碼
#include <stdio.h>
int add(int a, int b) {
int result = 0;
result = a + b;
return result;
}
int main(int argc, char *argv[]) {
int result = 0;
result = add(1, 2);
printf("result = %d \r\n", result);
return 0;
}
- 使用gcc編譯,然後gdb反彙編main函數,看看它是如何調用add函數的
(gdb) disassemble main
Dump of assembler code for function main:
0x08048439 <+0>: push %ebp
0x0804843a <+1>: mov %esp,%ebp
0x0804843c <+3>: and $0xfffffff0,%esp
0x0804843f <+6>: sub $0x20,%esp
0x08048442 <+9>: movl $0x0,0x1c(%esp) # 給 result 變量賦 0 值
0x0804844a <+17>: movl $0x2,0x4(%esp) # 將第 2 個參數 argv 壓棧(該參數偏移爲esp+0x04)
0x08048452 <+25>: movl $0x1,(%esp) # 將第 1 個參數 argc 壓棧(該參數偏移爲esp+0x00)
0x08048459 <+32>: call 0x804841c <add> # 調用 add 函數
0x0804845e <+37>: mov %eax,0x1c(%esp) # 將 add 函數的返回值地址賦給 result 變量,作爲子函數調用完之後的迴歸點
0x08048462 <+41>: mov 0x1c(%esp),%eax
0x08048466 <+45>: mov %eax,0x4(%esp)
0x0804846a <+49>: movl $0x8048510,(%esp)
0x08048471 <+56>: call 0x80482f0 <printf@plt>
0x08048476 <+61>: mov $0x0,%eax
0x0804847b <+66>: leave
0x0804847c <+67>: ret
End of assembler dump.
(gdb) disassemble add
Dump of assembler code for function add:
0x0804841c <+0>: push %ebp # 將 ebp 壓棧(保存函數調用者的棧幀基址)
0x0804841d <+1>: mov %esp,%ebp # 將 ebp 指向棧頂 esp(設置當前被調用函數的棧幀基址)
0x0804841f <+3>: sub $0x10,%esp # 分配棧空間(棧向低地址方向生長)
0x08048422 <+6>: movl $0x0,-0x4(%ebp) # 給 result 變量賦 0 值(該變量偏移爲ebp-0x04)
0x08048429 <+13>: mov 0xc(%ebp),%eax # 將第 2 個參數的值賦給 eax 寄存器(準備運算)
0x0804842c <+16>: mov 0x8(%ebp),%edx # 將第 1 個參數的值賦給 edx 寄存器(準備運算)
0x0804842f <+19>: add %edx,%eax # 運算器執行加法運算 (edx+eax),結果保存在 eax 寄存器中
0x08048431 <+21>: mov %eax,-0x4(%ebp) # 將運算結果 eax 賦給 result 變量
0x08048434 <+24>: mov -0x4(%ebp),%eax # 將 result 變量的值賦給 eax 寄存器(eax 的地址將作爲函數返回值)
0x08048437 <+27>: leave # 恢復函數調用者的棧幀基址(pop %ebp)
0x08048438 <+28>: ret # 返回(準備執行下條指令)
End of assembler dump.
- 示意圖
可見,每一次函數調用,都會對調用者的棧幀基址 EBP 進行壓棧操作(爲了調用迴歸),並且由於子函數的棧幀基址 EBP 來自於棧指針 ESP 而來(生成新的子函數的棧幀),所以各層函數的棧幀基址很巧妙的構成了一個鏈,即當前的棧幀基址指向下一層函數棧幀基址所在的位置。
由此當子函數執行完成時,ESP 依舊在棧頂,但 EBP 就跳轉到父函數的棧幀底部了,並且堆棧下一個彈出的就是子函數的調用迴歸點,最終程序流回到調用點並繼續往下執行。
通過函數調用堆棧的工作原理我們可以看出,無論程序中具有多少層的函數調用,或遞歸調用,只需要維護好每個棧幀的 EBP 和 ESP 就可以管理還函數之間的跳轉。但堆棧也是由容量限制的,如果函數調用的層級太多就會出現棧溢出的錯誤(Stack Overflow)。