編譯、彙編、連結、載入、顯示

Mitsui_發表於2018-10-19

之前寫過關於連結的文章dyld 和連結,連結對我們瞭解元件化和模組化具有重要的意義。

我們寫完的文字程式碼,點選了編譯器上 run 按鈕之後,是怎麼在機器上執行的呢?另外以 iOS 為例的視覺化應用,又是怎麼將 UIView 例項在手機上顯示的呢?

前言 計算機的思考方式和人腦的思考方式

程式 = 資料結構 + 演算法,這個公式是計算機界的定理,不管使用多麼高階的語言,cpp 還是 php,不管是某個領域的開發專家,還是入門級菜鳥,寫出來的程式都是資料結構和演算法組成的,區別無非是演算法的好壞,資料結構的合適與否,設計模式也是演算法的一種體現。

其實我們生活中充斥著各種各樣的程式,比如:人吃飯(主謂賓!),人和飯即為某種資料結構,例如物件(物件在記憶體中的儲存方式類似於結構體,一塊連續的記憶體塊),而吃的行為即是演算法,演算法合適與否的區別在於,用勺子吃麵還是用筷子吃麵。

我們出生以來接觸的最早的一個具有科學意義的程式可能就是 1 + 1 = 2 了吧,試想一下,當我們只會用數手指計數時,計算 1 + 1,會將 1 轉換為 1 根手指,我們會將這個程式轉換成這種可以理解的方式,同理計算機也是一樣的,它看不懂文字程式碼,也聽不懂任何語言,它只知道高低電平(二進位制),因此它也會把程式碼轉換成它可以理解的方式--機器碼。而這個轉換的任務就是編譯器完成的。

比如下面一段 c 程式碼:

// main.c
#include <stdio.h>
#include "Sum.h"

#define DEFINE 3 * 5

int main(int argc, const char * argv[]) {

    int c = sum(3, DEFINE);
    printf("%d\n", c);
    
    return 0;
}

// Sum.h
#include <stdio.h>
int sum(int a, int b);

// Sum.c
#include "Sum.h"
int sum(int a, int b) {
    return a + b;
}
複製程式碼

編譯器通過編譯、彙編、連結的步驟將它轉化為機器碼:

main:
Contents of (__TEXT,__text) section
0000000100000f20	55 48 89 e5 48 83 ec 20 b8 03 00 00 00 b9 0f 00 
0000000100000f30	00 00 c7 45 fc 00 00 00 00 89 7d f8 48 89 75 f0 
0000000100000f40	89 c7 89 ce e8 27 00 00 00 48 8d 3d 56 00 00 00 
0000000100000f50	89 45 ec 8b 75 ec b0 00 e8 27 00 00 00 31 c9 89 
0000000100000f60	45 e8 89 c8 48 83 c4 20 5d c3 90 90 90 90 90 90 
0000000100000f70	55 48 89 e5 89 7d fc 89 75 f8 8b 75 fc 03 75 f8 
0000000100000f80	89 f0 5d c3 
複製程式碼

可以看出編譯器的發明為程式設計師界帶來了多大的便利性。

一個工程(原始檔集合)是怎麼轉換成機器碼的呢?

編譯、彙編、連結、載入、顯示

上圖即為我們寫的程式碼轉換為機器程式碼的全過程,這個過程很像一個流水線的工作,前一步的輸出是後一步的輸入。

一、預處理

預處理的作用主要有兩個:1、展開標頭檔案;2、替換巨集定義,如上述程式碼中的 main.c,經過前處理器預處理後的結果為:

# 412 "/usr/include/stdio.h" 2 3 4
# 10 "main.c" 2
# 1 "./Sum.h" 1
# 14 "./Sum.h"
int sum(int a, int b);
# 11 "main.c" 2

int main(int argc, const char * argv[]) {

    int c = sum(3, 3 * 5);
    printf("%d\n", c);

    return 0;
}
複製程式碼

可以看到,展開了 Sum.h,替換了巨集定義 DEFINE。(上述程式碼省略了展開的標準io庫標頭檔案)

二、編譯

預處理後的 main.i 檔案作為輸入檔案輸入到編譯器編譯,編譯器有前後端之分:

編譯、彙編、連結、載入、顯示

編譯的過程也是一種流水線的過程,前一步的輸出作為後一步的輸入,最後得到結果。 典型的例子就是 clang 和 llvm,編譯器前端的作用是詞法分析、語法分析等,保證程式碼沒有錯誤,比如,變數未宣告、識別符號錯誤、漏寫分隔符和括號等語法問題,而編譯器後端的任務是通過複雜的暫存器分配演算法,為程式碼中的變數和常量分配合適的暫存器,然後生成並優化彙編指令。

編譯、彙編、連結、載入、顯示

*.i 中儲存的我們的程式碼是一種字元流的形式,詞法分析器會將字元流轉換為記號流,舉個栗子:

if (x > 5)
    y = "h";
else 
    z = 1;
複製程式碼

經過詞法分析器分析後得到的記號流為:

IF LPAREN IDENT(x) GT INT(5) RPAREN
    IDENT(y) ASSIGN STRING("h") SEMICOLON
ELSE
    IDENT(z) ASSIGN INT(1) SEMICOLON EOF 
複製程式碼

詞法分析只是簡單的將字元流轉換為記號流,比如將識別符號、關鍵字、括號、分隔符等轉換成相應的記號,而判斷我們程式是否有語法錯誤是語法分析器做的事,比如寫程式碼的時候漏寫了一個括號,詞法分析器不會報錯,只是在產生的記號流中,少了一個括號的記號,語法分析器會將報錯資訊反饋給我們,告訴我們,哪裡應該有一個括號。語法錯誤我們平常寫程式碼過程中經常遇到的問題。 語法分析器除了會幫我們分析語法是否符合規範之外,還有一個作用就是生成抽象語法樹,比如上述例子中,語法分析器生成的抽象語法樹為:

編譯、彙編、連結、載入、顯示

編譯器後端會通過使用抽象語法樹經過一系列的演算法生成彙編指令,由於暫存器分配,指令優化等演算法過於高深,此處不再分析。

第一個例子中的 main.c,我們可以通過反彙編得到機器碼對應的彙編指令為:

   0: 55  	               pushq	%rbp
   1:	48 89 e5 	        movq	%rsp, %rbp
   4:	48 83 ec 20          	subq	$32, %rsp
   8:	b8 03 00 00 00       	movl	$3, %eax
   d:	b9 0f 00 00 00       	movl	$15, %ecx
  12:	c7 45 fc 00 00 00 00 	movl	$0, -4(%rbp)
  19:	89 7d f8 	        movl 	%edi, -8(%rbp)
  1c:	48 89 75 f0 	        movq 	%rsi, -16(%rbp)
  20:	89 c7 	                movl 	%eax, %edi
  22:	89 ce 	                movl	%ecx, %esi
  24:	e8 00 00 00 00 	       callq	0 <_main+0x29>
  29:	48 8d 3d 1a 00 00 00 	leaq	26(%rip), %rdi
  30:	89 45 ec 	        movl	%eax, -20(%rbp)
  33:	8b 75 ec 	        movl	-20(%rbp), %esi
  36:	b0 00                	movb	$0, %al
  38:	e8 00 00 00 00 	       callq	0 <_main+0x3D>
  3d:	31 c9                	xorl 	%ecx, %ecx
  3f:	89 45 e8             	movl 	%eax, -24(%rbp)
  42:	89 c8 	                movl	%ecx, %eax
  44:	48 83 c4 20          	addq 	$32, %rsp
  48:	5d                      popq	%rbp
  49:	c3 	                retq
複製程式碼

*一個簡單的編譯器的例子

某種簡單的加法計算器,只接受兩種指令 pushaddpush 是壓棧操作,add 是將棧頂兩個元素彈出相加並將結果壓棧。 那麼當我們輸入程式 1 + 2 + 3 時,它的編譯過程為:

編譯、彙編、連結、載入、顯示

生成的指令:

push 1
push 2
add
push 3
add
ret
複製程式碼

三、彙編

彙編器將彙編指令彙編成機器程式碼。

四、連結

參見 dyld 和連結

*補充:

首先需要知道的是,函式(區分函式指標)是一段指令塊,被分配在可執行檔案的某塊記憶體中。

我們工程中的每個原始檔都被編譯器編譯成字尾為 .o 的目標檔案(object file),試想一下上面的例子中,main.i 中僅僅得到了 sum() 的宣告,因此 main.o 中也僅存在 sum() 的宣告,那麼 sum() 的指令集是怎麼執行的呢?這就是連結的作用了,其實整個程式碼的編譯過程中,有一個叫符號表的東西起了很大的作用,符號表以鍵值對的形式儲存了當前工程中所有原始檔的外部符號,比如上面的例子中,_sum 即為符號(鍵),*_sum 即為符號的引用(指向 sum() 指令塊的指標,值)。

語法分析器拿到 sum 記號時,它會從當前檔案(main.i)中尋找 sum 的定義,這個定義可能是從別的標頭檔案展開的,也可能是該檔案本身定義的,當不存在時就會報語法錯誤。然後編譯器後端分析抽象語法樹時,會將當前函式的指令預設定為下一條指令。比如

#include <stdio.h>
// 上個例子中的 Sum.c
#include "Sum.h"

static void foo() {
    
}

int main(int argc, const char * argv[])
{
    // insert code here...
    foo();
    sum(1, 2);
    return 0;
}

複製程式碼

生成的目標檔案 main.o 中的指令為:

main.o:
(__TEXT,__text) section
_foo:
0000000000000000	pushq	%rbp
0000000000000001	movq	%rsp, %rbp
0000000000000004	popq	%rbp
0000000000000005	retq
0000000000000006	nopw	%cs:(%rax,%rax)
_main:
0000000000000010	pushq	%rbp
0000000000000011	movq	%rsp, %rbp
0000000000000014	subq	$0x20, %rsp
0000000000000018	movl	$0x0, -0x4(%rbp)
000000000000001f	movl	%edi, -0x8(%rbp)
0000000000000022	movq	%rsi, -0x10(%rbp)
0000000000000026	callq	0x2b
000000000000002b	movl	$0x1, %edi
0000000000000030	movl	$0x2, %esi
0000000000000035	callq	0x3a
000000000000003a	xorl	%esi, %esi
000000000000003c	movl	%eax, -0x14(%rbp)
000000000000003f	movl	%esi, %eax
0000000000000041	addq	$0x20, %rsp
0000000000000045	popq	%rbp
0000000000000046	retq
複製程式碼

可以看到 main.o 中並沒有 sum 函式。此時符號表中儲存的的符號為 _sum,此時的 Sum.o

Sum.o:
(__TEXT,__text) section
_sum:
0000000000000000	pushq	%rbp
0000000000000001	movq	%rsp, %rbp
0000000000000004	movl	%edi, -0x4(%rbp)
0000000000000007	movl	%esi, -0x8(%rbp)
000000000000000a	movl	-0x4(%rbp), %esi
000000000000000d	addl	-0x8(%rbp), %esi
0000000000000010	movl	%esi, %eax
0000000000000012	popq	%rbp
0000000000000013	retq
複製程式碼

連結器會將 Sum.omain.o 連結成一個可執行檔案,當需要呼叫 sum 函式時,連結器會去符號表中找 _sum 符號,如果找不到編譯器就會報連結錯誤,如果找到,連結器通過 _sum 鍵找到指向 sum 指令塊的指標,然後將 sum 指令塊重新佈局到可執行檔案的記憶體中,此時的 callq 指令會呼叫重新定義後的記憶體地址。

main:
(__TEXT,__text) section
_foo:
0000000100000f50	pushq	%rbp
0000000100000f51	movq	%rsp, %rbp
0000000100000f54	popq	%rbp
0000000100000f55	retq
0000000100000f56	nopw	%cs:(%rax,%rax)
_main:
0000000100000f60	pushq	%rbp
0000000100000f61	movq	%rsp, %rbp
0000000100000f64	subq	$0x20, %rsp
0000000100000f68	movl	$0x0, -0x4(%rbp)
0000000100000f6f	movl	%edi, -0x8(%rbp)
0000000100000f72	movq	%rsi, -0x10(%rbp)
0000000100000f76	callq	0x100000f50 // foo 函式首地址
0000000100000f7b	movl	$0x1, %edi
0000000100000f80	movl	$0x2, %esi
0000000100000f85	callq	0x100000fa0 // sum 函式首地址
0000000100000f8a	xorl	%esi, %esi
0000000100000f8c	movl	%eax, -0x14(%rbp)
0000000100000f8f	movl	%esi, %eax
0000000100000f91	addq	$0x20, %rsp
0000000100000f95	popq	%rbp
0000000100000f96	retq
0000000100000f97	nop
0000000100000f98	nop
0000000100000f99	nop
0000000100000f9a	nop
0000000100000f9b	nop
0000000100000f9c	nop
0000000100000f9d	nop
0000000100000f9e	nop
0000000100000f9f	nop
_sum:
0000000100000fa0	pushq	%rbp
0000000100000fa1	movq	%rsp, %rbp
0000000100000fa4	movl	%edi, -0x4(%rbp)
0000000100000fa7	movl	%esi, -0x8(%rbp)
0000000100000faa	movl	-0x4(%rbp), %esi
0000000100000fad	addl	-0x8(%rbp), %esi
0000000100000fb0	movl	%esi, %eax
0000000100000fb2	popq	%rbp
0000000100000fb3	retq
複製程式碼

這就是靜態連結過程中,靜態連結器的工作。

但是 iOS 開發中方法的呼叫會更復雜,涉及到 runtime 和 dyld,大致流程為:

編譯、彙編、連結、載入、顯示

上圖中,dyld 會將 0xyy 重定向為 objc_msgSend() 指令塊的地址(執行時完成,動態連結)。- foo 的首地址被儲存在名為 Foo 的類物件中(類似於 C++ 的虛擬函式表)。然後該指令塊會在執行時被呼叫。瞭解更多

五、載入

dyld 會將連結完成的可執行檔案載入到記憶體中:

  • __TEXT,__text 中的指令拷貝到虛擬記憶體的 .rodata(readonly) 中。
  • __DATA,__data 中的全域性和靜態變數拷貝到虛擬記憶體的 .rwdata (readwrite)中。
  • 使用 .symbol 中的符號完成動態連結。
  • 初始化堆疊,從 main() 函式開始執行程式。

其中涉及到的 + load 函式,參見 dyld 和連結

六、顯示

參見優化APP的顯示效能

七、結語

現在知道我們寫完的程式碼是怎麼轉換成機器能明白的語言了吧。

相關文章