C 語言編程 — 結構化程序流的匯編代碼與 CPU 指令集
目錄
文章目錄
目錄
文章目錄
為什么要保留匯編語言
順序程序流
條件程序流
循環程序流
函數調用棧的工作原理
文章目錄
《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; }
1
2
3
4
5
6
7
Step 2. 編譯(Compile),將高級語言編譯成匯編語言(ASM)程序。
$ gcc -g -c test.c
1
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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
NOTE:這里的程序入口是 main() 函數,而不是第 0 條匯編代碼。
條件程序流
值得注意的是,某些特殊的指令,比如跳轉指令,會主動修改 PC 的內容,此時下一條地址就不是從存儲器中順序加載的了,而是到特定的位置加載指令內容。這就是 if…else 條件語句,while/for 循環語句的底層支撐原理。
Step 1. 編寫高級語言程序。
// test.c #include
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Step 2. 編譯(Compile),將高級語言編譯成匯編語言。
$ gcc -g -c test.c
1
Step 3. 使用 objdump 命令反匯編目標文件,輸出可閱讀的二進制信息。我們主要分析 if…else 語句。
if (r == 0) 33: 83 7d fc 00 cmp DWORD PTR [rbp-0x4],0x0 37: 75 09 jne 42
1
2
3
4
5
6
7
8
9
10
11
首先進入條件判斷,匯編代碼為 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
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; } }
1
2
3
4
5
6
7
8
9
10
11
12
計算機指令與匯編代碼
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
1
2
3
4
5
6
7
8
9
10
11
12
13
函數調用棧的工作原理
與普通的跳轉程序(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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
使用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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
示意圖
可見,每一次函數調用,都會對調用者的棧幀基址 EBP 進行壓棧操作(為了調用回歸),并且由于子函數的棧幀基址 EBP 來自于棧指針 ESP 而來(生成新的子函數的棧幀),所以各層函數的棧幀基址很巧妙的構成了一個鏈,即當前的棧幀基址指向下一層函數棧幀基址所在的位置。
由此當子函數執行完成時,ESP 依舊在棧頂,但 EBP 就跳轉到父函數的棧幀底部了,并且堆棧下一個彈出的就是子函數的調用回歸點,最終程序流回到調用點并繼續往下執行。
通過函數調用堆棧的工作原理我們可以看出,無論程序中具有多少層的函數調用,或遞歸調用,只需要維護好每個棧幀的 EBP 和 ESP 就可以管理還函數之間的跳轉。但堆棧也是由容量限制的,如果函數調用的層級太多就會出現棧溢出的錯誤(Stack Overflow)。
C 語言 單片機 匯編語言
版權聲明:本文內容由網絡用戶投稿,版權歸原作者所有,本站不擁有其著作權,亦不承擔相應法律責任。如果您發現本站中有涉嫌抄襲或描述失實的內容,請聯系我們jiasou666@gmail.com 處理,核實后本網站將在24小時內刪除侵權內容。
版權聲明:本文內容由網絡用戶投稿,版權歸原作者所有,本站不擁有其著作權,亦不承擔相應法律責任。如果您發現本站中有涉嫌抄襲或描述失實的內容,請聯系我們jiasou666@gmail.com 處理,核實后本網站將在24小時內刪除侵權內容。