之前寫過關於連結的文章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
複製程式碼
*一個簡單的編譯器的例子
某種簡單的加法計算器,只接受兩種指令 push
和 add
,push
是壓棧操作,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.o
和 main.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的顯示效能。
七、結語
現在知道我們寫完的程式碼是怎麼轉換成機器能明白的語言了吧。