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;
}

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 變量賦 00x0804844a <+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)。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章