編譯器入門:沒有siri的那些年,我們如何實現人機對話?
編譯器可將源代碼轉換成計算機理解的可執行的機器代碼,或將源代碼轉換成另一種編程語言。本文從 LLVM 入手介紹了編譯器工具。
編譯器不過就是一個翻譯其它程序的程序。傳統的編譯器將源代碼轉換成計算機可理解的可執行的機器代碼。(一些編譯器將源代碼轉換為另一種編程語言,這些編譯器被稱為源到源轉換器或轉譯器)。LLVM 是一個廣泛使用的編譯器項目,包括多個模塊化的編譯器工具。
傳統的編譯器設計包括三個部分:
- 前端將源代碼轉換成一種中間表示(IR)。clang (http://clang.llvm.org/) 是 LLVM 項目中 C 類語言的前端工具。
- 優化器解析 IR 并將其轉換成一種更高效的形式。opt是 LLVM 項目的優化器工具。
- 后端通過將 IR 映射到目標硬件指令集上來生成機器代碼。llc 是 LLVM 項目的后端工具。
LLVM IR 是一種類似匯編的低級語言。但是,它不針對特定的硬件信息編程。
你好,編譯器
下面是一個簡單的打印「Hello,Compiler」字符串的 C 語言程序。雖然程序員可以讀懂 C 語言語法,但是計算機卻看的一臉懵逼。接下來我要過一遍編譯的三個階段,以便將以下程序轉換成機器可執行的程序。
- // compile_me.c
- // Wave to the compiler. The world can wait.
- #include <stdio.h>
- int main() {
- printf("Hello, Compiler!\n");
- return 0;
- }
前端
前文講到,clang 是 LLVM C 類語言的前端工具。Clang 由一個 C 預處理器、詞法分析器(lexer)、解析器、語義分析器和中間表示生成器組成。
C 預處理器在源代碼轉換成 IR 之前對其進行修改。預處理器會將外部文件包含進來,比如上面的 #include
- clang -E compile_me.c -o preprocessed.i
詞法分析器(Lexer,也叫 scanner 或 tokenizer)將一串字符轉換成一串詞。每個詞或符號,按其屬性被分配到對應的句法類別:標點符號、關鍵詞、標識符、常量或注釋。
compile_me.c 的詞法分析:
解析器判定由詞法分析器生成的一串詞是否包含源語言中的有效語句。在分析完詞的語法以后,解析器輸出了一個抽象語法樹(AST)。Clang AST 中的節點分別表示聲明與類型。
compile_me.c 的 AST:
語義分析器遍歷 AST,判定語句的涵義是否有效。這個階段會檢查類型錯誤。如果 compile_me.c 中的 main 函數返回了 "zero" 而不是 0, 語義分析器就會拋出一個錯誤,因為 "zero" 不是 int 類型。
IR 生成器將 AST 轉換為 IR。
在 compile_me.c 上運行 clang 前端,生成 LLVM IR:
- clang -S -emit-llvm -o llvm_ir.ll compile_me.c
llvm_ir.ll 中的 main 函數:
- ; llvm_ir.ll
- @.str = private unnamed_addr constant [18 x i8] c"Hello, Compiler!\0A\00", align 1
- define i32 @main() {
- %1 = alloca i32, align 4 ; <- memory allocated on the stack
- store i32 0, i32* %1, align 4
- %2 = call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([18 x i8], [18 x i8]* @.str, i32 0, i32 0)) ret i32 0
- }
- declare i32 @printf(i8*, ...)
優化器
優化器的任務是基于對程序運行時行為的理解,提升代碼的效率。優化器的輸入為 IR,輸出為優化后的 IR。LLVM 的優化器工具 opt 將使用 -O2(大寫字母 o,數字 2)標記優化處理器速度,使用-Os(大寫字母 o,s)標記優化生成目標的大小。
看一下優化器優化之前的 LLVM IR 代碼和優化后的代碼:
- opt -O2 -S llvm_ir.ll -o optimized.ll
optimized.ll 的 main 函數:
- ; optimized.ll
- @str = private unnamed_addr constant [17 x i8] c"Hello, Compiler!\00"
- define i32 @main() {
- %puts = tail call i32 @puts(i8* getelementptr inbounds ([17 x i8], [17 x i8]* @str, i64 0, i64 0)) ret i32 0
- }
- declare i32 @puts(i8* nocapture readonly)
優化后,main 函數沒有在棧上分配內存,因為它沒有使用任何內存。優化后的代碼調用了 puts 函數而不是 printf 函數,因為它沒有使用 printf 函數的任何格式化功能。當然了,優化器不僅僅知道什么時候該用 puts 代替 printf。優化器也會展開循環,內聯簡單計算的結果。思考以下代碼,它將兩個數加起來并打印結果:
- // add.c
- #include <stdio.h>
- int main() {
- int a = 5, b = 10, c = a + b;
- printf("%i + %i = %i\n", a, b, c);
- }
未優化的 LLVM IR:
- @.str = private unnamed_addr constant [14 x i8] c"%i + %i = %i\0A\00", align 1
- define i32 @main() {
- %1 = alloca i32, align 4 ; <- allocate stack space for var a
- %2 = alloca i32, align 4 ; <- allocate stack space for var b
- %3 = alloca i32, align 4 ; <- allocate stack space for var c
- store i32 5, i32* %1, align 4 ; <- store 5 at memory location %1
- store i32 10, i32* %2, align 4 ; <- store 10 at memory location %2
- %4 = load i32, i32* %1, align 4 ; <- load the value at memory address %1 into register %4
- %5 = load i32, i32* %2, align 4 ; <- load the value at memory address %2 into register %5
- %6 = add nsw i32 %4, %5 ; <- add the values in registers %4 and %5. put the result in register %6
- store i32 %6, i32* %3, align 4 ; <- put the value of register %6 into memory address %3
- %7 = load i32, i32* %1, align 4 ; <- load the value at memory address %1 into register %7
- %8 = load i32, i32* %2, align 4 ; <- load the value at memory address %2 into register %8
- %9 = load i32, i32* %3, align 4 ; <- load the value at memory address %3 into register %9
- %10 = call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([14 x i8], [14 x i8]* @.str, i32 0, i32 0), i32 %7, i32 %8, i32 %9)
- ret i32 0
- }
- declare i32 @printf(i8*, ...)
優化后的 LLVM IR:
- @.str = private unnamed_addr constant [14 x i8] c"%i + %i = %i\0A\00", align 1
- define i32 @main() {
- %1 = tail call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([14 x i8], [14 x i8]* @.str, i64 0, i64 0), i32 5, i32 10, i32 15)
- ret i32 0
- }
- declare i32 @printf(i8* nocapture readonly, ...)
優化后的 main 函數實際上就是在未優化版本的 17 和 18 行將變量進行內聯。opt 對加法進行運算,因為所有的變量都是常量。很酷吧?
后端
LLVM 的后端工具是 llc。它經歷了三個階段,最終把 LLVM IR 輸入轉化生成機器代碼:
- 指令選取(instruction selection)是從 IR 指令到目標機器指令集的映射。這一步使用了虛擬寄存器一個***的命名空間。
- 寄存器分配(register allocation)是從虛擬寄存器到目標架構真實寄存器的映射。我的 CPU 是 x86 架構的,也就是說只能使用 16 個寄存器。但是,編譯器會盡可能少地使用寄存器。
- 指令調度(instruction scheduling)是對操作的重新安排,它反映了目標機器上的性能限制。
執行以下命令將生成部分機器代碼!
- llc -o compiled-assembly.s optimized.ll
- _main:
- pushq %rbp
- movq %rsp, %rbp
- leaq L_str(%rip), %rdi
- callq _puts
- xorl %eax, %eax
- popq %rbp
- retq
- L_str:
- .asciz "Hello, Compiler!"
這是一個 x86 匯編語言程序,是計算機和程序員共通的語言。看似晦澀,但肯定有人懂我。
原文:https://nicoleorchard.com/blog/compilers
【本文是51CTO專欄機構“機器之心”的原創譯文,微信公眾號“機器之心( id: almosthuman2014)”】