程式語言簡介/編譯程式
我們有不同的方法來執行程式。 編譯器、直譯器 和 虛擬機器 是我們用來完成此任務的一些工具。所有這些工具都提供了一種在硬體中模擬程式語義的方法。雖然這些不同的技術存在於相同的核心目的 - 執行程式 - 但它們以非常不同的方式執行。它們都具有優點和缺點,在本節中,我們將更仔細地研究這些權衡。在我們繼續之前,必須說明一個重要的一點:原則上,任何程式語言都可以編譯或解釋。但是,一些執行策略在某些語言中比在其他語言中更自然。
編譯器是將高階程式語言翻譯成 低階程式語言 的計算機程式。編譯器的產出是一個 可執行 檔案,它由以特定 機器程式碼 編碼的指令組成。因此,可執行程式是特定於某種型別的 計算機體系結構 的。為不同程式語言設計的編譯器可能大不相同;然而,它們都傾向於具有下圖所示的整體宏觀架構
編譯器有一個 前端,它負責將用高階源語言編寫的程式轉換為編譯器將在後續階段處理的中間表示。我們在前端進行輸入程式的 解析,正如我們在前兩章中所見。一些編譯器,如 gcc 可以解析幾種不同的輸入語言。在這種情況下,編譯器為它可以處理的每種語言都有一個不同的前端。編譯器也有一個 後端,它執行程式碼生成。如果編譯器可以針對許多不同的 計算機體系結構,那麼它將為每個體系結構提供不同的後端。最後,編譯器通常會做一些 程式碼最佳化。換句話說,它們試圖在特定效率標準下(例如速度、空間或能耗)改程序序。通常,最佳化器不允許更改輸入程式的語義。
透過編譯執行的主要優勢是速度。因為源程式直接翻譯成機器程式碼,所以該程式很可能比解釋它更快。然而,正如我們將在下一節中看到的,一個解釋程式執行得比它的機器程式碼等效程式更快仍然是可能的,儘管不太可能。透過編譯執行的主要缺點是可移植性。編譯程式針對的是特定型別的計算機體系結構,無法在不同的硬體上執行。
一個典型的 C 程式,例如由 gcc 編譯,在硬體中執行之前將經歷許多轉換。這個過程類似於一個 生產線,其中一個階段的輸出成為下一個階段的輸入。最終,生成了最終產品,即可執行程式。這條長鏈通常對程式設計師來說是不可見的。如今,整合開發環境 (IDE) 將編譯過程中的多個工具組合成一個單一的執行環境。但是,為了演示編譯器的執行方式,我們將展示使用 gcc 編譯標準 C 檔案執行時存在的階段。這些階段、它們的產出以及一些工具示例在下圖中進行了說明。
上述步驟的目的是將原始檔翻譯成計算機可以執行的程式碼。首先,程式設計師使用 文字編輯器 建立一個原始檔,其中包含用高階程式語言編寫的程式。在本例中,我們假設是 C。這裡可以使用各種文字編輯器。其中一些提供支援的形式,例如 語法高亮 或 整合偵錯程式。假設我們剛剛編輯了以下檔案,我們想要編譯它
#define CUBE(x) (x)*(x)*(x)
int main() {
int i = 0;
int x = 2;
int sum = 0;
while (i++ < 100) {
sum += CUBE(x);
}
printf("The sum is %d\n", sum);
}
在編輯 C 檔案之後,使用 預處理器 來擴充套件原始碼中存在的 宏。宏展開在 C 中是一個相對簡單的任務,但在 lisp 等語言中可能非常複雜,例如,它們負責避免宏展開中常見的諸如 變數捕獲 等問題。在展開階段,宏的程式碼體替換程式原始碼中每次出現宏名稱的地方。我們可以透過類似 gcc -E f0.c -o f1.c 的命令來呼叫 gcc 的預處理器。預處理我們示例程式的結果是下面的程式碼。請注意,呼叫 CUBE(x) 已被表示式 (x)*(x)*(x) 替換。
int main() {
int i = 0;
int x = 2;
int sum = 0;
while (i++ < 100) {
sum += (x)*(x)*(x);
}
printf("The sum is %d\n", sum);
}
在下一階段,我們將源程式轉換為 彙編 程式碼。這個階段是我們通常所說的編譯:用 C 語法編寫的文字將被轉換為用 x86 彙編語法編寫的程式。在這個步驟中,我們執行 C 程式的解析。在 Linux 中,我們可以透過命令 cc1 f1.c -o f2.s 將原始檔(例如 f1.c)翻譯成彙編,假設 cc1 是系統的編譯器。此命令等效於呼叫 gcc -S f1.c -o f2.s。彙編程式可以在下圖左側看到。該程式是用 x86 體系結構中使用的組合語言編寫的。存在許多不同的計算機體系結構,例如 ARM、PowerPC 和 Alpha。為其中任何一個生成的組合語言將與下面的程式大不相同。為了比較,我們在圖的右側列印了同一程式的 ARM 版本。這兩種組合語言遵循非常不同的設計理念:x86 使用 CISC 指令集,而 ARM 更緊密地遵循 RISC 方法。然而,這兩個檔案,x86 的和 ARM 的,都有類似的語法骨架。組合語言具有線性結構:程式是一個類似列表的指令序列。另一方面,C 語言具有更像樹的語法結構,正如我們在之前的 章節 中所見。由於這種語法差異,這個階段包含程式在其生命週期中將經歷的最複雜的轉換步驟。
# Assembly of x86 # Assembly of ARM
.cstring _main:
LC0: @ BB#0:
.ascii "The sum is %d\12\0" push {r7, lr}
.text mov r7, sp
.globl _main sub sp, sp, #16
_main: mov r1, #2
pushl %ebp mov r0, #0
movl %esp, %ebp str r0, [r7, #-4]
subl $40, %esp str r0, [sp, #8]
movl $0, -20(%ebp) stm sp, {r0, r1}
movl $2, -16(%ebp) b LBB0_2
movl $0, -12(%ebp) LBB0_1:
jmp L2 ldr r0, [sp, #4]
L3: ldr r3, [sp]
movl -16(%ebp), %eax mul r1, r0, r0
imull -16(%ebp), %eax mla r2, r1, r0, r3
imull -16(%ebp), %eax str r2, [sp]
addl %eax, -12(%ebp) LBB0_2:
L2: ldr r0, [sp, #8]
cmpl $99, -20(%ebp) add r1, r0, #1
setle %al cmp r0, #99
addl $1, -20(%ebp) str r1, [sp, #8]
testb %al, %al ble LBB0_1
jne L3 @ BB#3:
movl -12(%ebp), %eax ldr r0, LCPI0_0
movl %eax, 4(%esp) ldr r1, [sp]
movl $LC0, (%esp) LPC0_0:
call _printf add r0, pc, r0
leave bl _printf
ret ldr r0, [r7, #-4]
mov sp, r7
pop {r7, lr}
mov pc, lr
在從高階語言到組合語言的翻譯過程中,編譯器可能會應用程式碼最佳化。這些最佳化必須遵守源程式的語義。最佳化的程式應該與原始版本做相同的事情。如今,編譯器非常擅長以提高效率的方式更改程式。例如,兩個眾所周知的最佳化(迴圈展開和常量傳播)的組合可以最佳化我們的示例程式,以至於迴圈被完全刪除。例如,假設 `cc1` 是 gcc 使用的預設編譯器,我們可以使用以下命令執行最佳化器:`cc1 -O1 f1.c -o f2.opt.s`。我們這次生成的最終程式 `f2.opt.s` 出乎意料地簡潔。
.cstring LC0: .ascii "The sum is %d\12\0" .text .globl _main _main: pushl %ebp movl %esp, %ebp subl $24, %esp movl $800, 4(%esp) movl $LC0, (%esp) call _printf leave ret
編譯鏈的下一步是將組合語言翻譯成二進位制程式碼。彙編程式仍然可以被人閱讀。二進位制程式,也稱為目標檔案,當然也可以被人閱讀,但如今很少有人能勝任這項工作。從組合語言到二進位制程式碼的翻譯是一項相當簡單的任務,因為這兩種語言具有相同的句法結構。只有它們的詞法結構不同。彙編檔案是用 ASCII [助記符] 編寫的,而二進位制檔案包含硬體處理器識別的零和一的序列。此階段使用的典型工具是 `as` 彙編器。我們可以使用以下命令生成目標檔案:`as f2.s -o f3.o`。
目標檔案目前還不可執行。它不包含足夠的資訊來指定在哪裡可以找到 `printf` 函式的實現,例如。在編譯過程的下一步中,我們更改此檔案,以便外部庫中定義的函式的地址可見。每個作業系統都為程式設計師提供一些可以與他們建立的程式碼一起使用的庫。一種特殊的軟體,稱為連結器,可以找到這些庫中函式的地址,從而修復目標檔案中的空白地址。不同的作業系統使用不同的連結器。在這種情況下,一個典型的工具是 `ld` 或 `collect2`。例如,為了在執行 Leopard 的 Mac OS 上生成可執行程式,我們可以使用以下命令:`collect2 -o f4.exe -lcrt1.10.5.o f3.o -lSystem`。
此時,我們幾乎擁有了一個可執行檔案,但我們的連結的二進位制程式在我們可以看到其輸出之前必須經過最後一次轉換。二進位制程式碼中的所有地址都是相對的。我們必須用絕對值替換這些地址,這些地址正確地指向函式呼叫的目標和其他程式物件。最後一步由一個名為載入器的程式負責。載入器將程式映像轉儲到記憶體並執行它。

