ios底層 編譯過程

舜爸發表於2020-01-15

前言

我們知道,程式語言分為編譯語言和解釋語言。兩者的執行過程不同。

編譯語言是通過編譯器將程式碼直接編寫成機器碼,然後直接在CPU上執行機器碼的,這樣能使得我們的app和手機都能效率更高,執行更快。C,C++,OC等語言,都是使用的編譯器,生成相關的可執行檔案。

解釋語言使用的是直譯器。直譯器會在執行時解釋執行程式碼,獲取一段程式碼後就會將其翻譯成目的碼(就是位元組碼(Bytecode)),然後一句一句地執行目的碼。也就是說是在執行時才去解析程式碼,比直接執行編譯好的可執行檔案自然效率就低,但是跑起來之後可以不用重啟啟動編譯,直接修改程式碼即可看到效果,類似熱更新,可以幫我們縮短整個程式的開發週期和功能更新週期。

ios 編譯器

把一種程式語言(原始語言)轉換為另一種程式語言(目標語言)的程式叫做編譯器

編譯器的組成:前端和後端

  • 前端負責詞法分析,語法分析,生成中間程式碼;
  • 後端以中間程式碼作為輸入,進行行架構無關的程式碼優化,接著針對不同架構生成不同的機器碼;

前後端依賴統一格式的中間程式碼(IR),使得前後端可以獨立的變化。新增一門語言只需要修改前端,而新增一個CPU架構只需要修改後端即可。

Objective C/C/C++使用的編譯器前端是clang,後端都是LLVM

編譯過程

先看下流程

dsf

我先寫端程式碼

#import <Foundation/Foundation.h>
#define DEBUG 1
int main(int argc, const char * argv[]) {
    @autoreleasepool {
        // insert code here...
        #ifdef DEBUG
          printf("hello debug\n");
        #else
          printf("hello world\n");
        #endif
        NSLog(@"Hello, World!");
    }
    return 0;
}
複製程式碼

一、預處理(preprocessor)

使用命令:

xcrun clang -E main.m

生成程式碼:

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        printf("hello debug\n");
        NSLog(@"Hello, World!");
    }
    return 0;
}
複製程式碼

可以看到,在預處理的時候,註釋被刪除,條件編譯被處理。

二、詞法分析(lexical anaysis)

詞法分析器讀入原始檔的字元流,將他們組織稱有意義的詞素(lexeme)序列,對於每個詞素,此法分析器產生詞法單元(token)作為輸出。

$ xcrun clang -fmodules -fsyntax-only -Xclang -dump-tokens main.m 生成程式碼

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        // ins'		Loc=<main.m:9:1>
int 'int'	 [StartOfLine]	Loc=<main.m:11:1>
identifier 'main'	 [LeadingSpace]	Loc=<main.m:11:5>
l_paren '('		Loc=<main.m:11:9>
int 'int'		Loc=<main.m:11:10>
identifier 'argc'	 [LeadingSpace]	Loc=<main.m:11:14>
comma ','		Loc=<main.m:11:18>
const 'const'	 [LeadingSpace]	Loc=<main.m:11:20>
char 'char'	 [LeadingSpace]	Loc=<main.m:11:26>
star '*'	 [LeadingSpace]	Loc=<main.m:11:31>
identifier 'argv'	 [LeadingSpace]	Loc=<main.m:11:33>
l_square '['		Loc=<main.m:11:37>
r_square ']'		Loc=<main.m:11:38>
r_paren ')'		Loc=<main.m:11:39>
...

複製程式碼

看出詞法分析多了Loc來記錄位置。

三、語法分析(semantic analysis)

詞法分析的Token流會被解析成一顆抽象語法樹(abstract syntax tree - AST)。

clang -Xclang -ast-dump -fsyntax-only main.m 輸出如下:

`-FunctionDecl 0x106c203f0 <main.m:11:1, line:22:1> line:11:5 main 'int (int, const char **)'
  |-ParmVarDecl 0x106c20220 <col:10, col:14> col:14 argc 'int'
  |-ParmVarDecl 0x106c202e0 <col:20, col:38> col:33 argv 'const char **':'const char **'
  `-CompoundStmt 0x106c206f8 <col:41, line:22:1>
    |-ObjCAutoreleasePoolStmt 0x106c206b0 <line:12:5, line:20:5>
    | `-CompoundStmt 0x106c20690 <line:12:22, line:20:5>
    |   |-CallExpr 0x106c20520 <line:15:11, col:33> 'int'
    |   | |-ImplicitCastExpr 0x106c20508 <col:11> 'int (*)(const char *, ...)' <FunctionToPointerDecay>
    |   | | `-DeclRefExpr 0x106c20498 <col:11> 'int (const char *, ...)' Function 0x7fd6618d23b0 'printf' 'int (const char *, ...)'
    |   | `-ImplicitCastExpr 0x106c20560 <col:18> 'const char *' <NoOp>
    |   |   `-ImplicitCastExpr 0x106c20548 <col:18> 'char *' <ArrayToPointerDecay>
    |   |     `-StringLiteral 0x106c204b8 <col:18> 'char [13]' lvalue "hello debug\n"
    |   `-CallExpr 0x106c20650 <line:19:9, col:31> 'void'
    |     |-ImplicitCastExpr 0x106c20638 <col:9> 'void (*)(id, ...)' <FunctionToPointerDecay>
    |     | `-DeclRefExpr 0x106c20578 <col:9> 'void (id, ...)' Function 0x7fd661b80ff0 'NSLog' 'void (id, ...)'
    |     `-ImplicitCastExpr 0x106c20678 <col:15, col:16> 'id':'id' <BitCast>
    |       `-ObjCStringLiteral 0x106c205c0 <col:15, col:16> 'NSString *'
    |         `-StringLiteral 0x106c20598 <col:16> 'char [14]' lvalue "Hello, World!"
    `-ReturnStmt 0x106c206e8 <line:21:5, col:12>
      `-IntegerLiteral 0x106c206c8 <col:12> 'int' 0
複製程式碼

這一步是把詞法分析生成的標記流,解析成一個抽象語法樹(abstract syntax tree -- AST),同樣地,在這裡面每一節點也都標記了其在原始碼中的位置。

四、靜態分析

把原始碼轉化為抽象語法樹之後,編譯器就可以對這個樹進行分析處理。靜態分析會對程式碼進行錯誤檢查,如出現方法被呼叫但是未定義、定義但是未使用的變數等,以此提高程式碼質量。當然,還可以通過使用 Xcode 自帶的靜態分析工具(Product -> Analyze)

  • 型別檢查 在此階段clang會做檢查,最常見的是檢查程式是否傳送正確的訊息給正確的物件,是否在正確的值上呼叫了正常函式。如果你給一個單純的 NSObject* 物件傳送了一個 hello 訊息,那麼 clang 就會報錯,同樣,給屬性設定一個與其自身型別不相符的物件,編譯器會給出一個可能使用不正確的警告。
  • 其他分析 ObjCUnusedIVarsChecker.cpp是用來檢查是否有定義了,但是從未使用過的變數。 ObjCSelfInitChecker.cpp是檢查在 你的初始化方法中中呼叫 self 之前,是否已經呼叫 [self initWith...] 或 [super init] 了。

更多請參考:clang 靜態分析

五、中間程式碼生成和優化

使用命令:

clang -O3 -S -emit-llvm main.m -o main.ll

生成main.ll檔案,開啟並檢視轉化結果

ModuleID = 'main.m'
source_filename = "main.m"
target datalayout = "e-m:o-i64:64-f80:128-n8:16:32:64-S128"
target triple = "x86_64-apple-macosx10.14.0"

%struct.__NSConstantString_tag = type { i32*, i32, i8*, i64 }

@__CFConstantStringClassReference = external global [0 x i32]
@.str.1 = private unnamed_addr constant [14 x i8] c"Hello, World!\00", section "__TEXT,__cstring,cstring_literals", align 1
@_unnamed_cfstring_ = private global %struct.__NSConstantString_tag { i32* getelementptr inbounds ([0 x i32], [0 x i32]* @__CFConstantStringClassReference, i32 0, i32 0), i32 1992, i8* getelementptr inbounds ([14 x i8], [14 x i8]* @.str.1, i32 0, i32 0), i64 13 }, section "__DATA,__cfstring", align 8
@str = private unnamed_addr constant [12 x i8] c"hello debug\00", align 1

; Function Attrs: ssp uwtable
define i32 @main(i32, i8** nocapture readnone) local_unnamed_addr #0 {
  %3 = tail call i8* @llvm.objc.autoreleasePoolPush() #1
  %4 = tail call i32 @puts(i8* getelementptr inbounds ([12 x i8], [12 x i8]* @str, i64 0, i64 0))
  notail call void (i8*, ...) @NSLog(i8* bitcast (%struct.__NSConstantString_tag* @_unnamed_cfstring_ to i8*))
  tail call void @llvm.objc.autoreleasePoolPop(i8* %3)
  ret i32 0
}

; Function Attrs: nounwind
declare i8* @llvm.objc.autoreleasePoolPush() #1

declare void @NSLog(i8*, ...) local_unnamed_addr #2

; Function Attrs: nounwind
declare void @llvm.objc.autoreleasePoolPop(i8*) #1

; Function Attrs: nounwind
declare i32 @puts(i8* nocapture readonly) local_unnamed_addr #1

attributes #0 = { ssp uwtable "correctly-rounded-divide-sqrt-fp-math"="false" "disable-tail-calls"="false" "less-precise-fpmad"="false" "min-legal-vector-width"="0" "no-frame-pointer-elim"="true" "no-frame-pointer-elim-non-leaf" "no-infs-fp-math"="false" "no-jump-tables"="false" "no-nans-fp-math"="false" "no-signed-zeros-fp-math"="false" "no-trapping-math"="false" "stack-protector-buffer-size"="8" "target-cpu"="penryn" "target-features"="+cx16,+fxsr,+mmx,+sahf,+sse,+sse2,+sse3,+sse4.1,+ssse3,+x87" "unsafe-fp-math"="false" "use-soft-float"="false" }
attributes #1 = { nounwind }
attributes #2 = { "correctly-rounded-divide-sqrt-fp-math"="false" "disable-tail-calls"="false" "less-precise-fpmad"="false" "no-frame-pointer-elim"="true" "no-frame-pointer-elim-non-leaf" "no-infs-fp-math"="false" "no-nans-fp-math"="false" "no-signed-zeros-fp-math"="false" "no-trapping-math"="false" "stack-protector-buffer-size"="8" "target-cpu"="penryn" "target-features"="+cx16,+fxsr,+mmx,+sahf,+sse,+sse2,+sse3,+sse4.1,+ssse3,+x87" "unsafe-fp-math"="false" "use-soft-float"="false" }

!llvm.module.flags = !{!0, !1, !2, !3, !4, !5, !6, !7}
!llvm.ident = !{!8}

!0 = !{i32 2, !"SDK Version", [2 x i32] [i32 10, i32 15]}
!1 = !{i32 1, !"Objective-C Version", i32 2}
!2 = !{i32 1, !"Objective-C Image Info Version", i32 0}
!3 = !{i32 1, !"Objective-C Image Info Section", !"__DATA,__objc_imageinfo,regular,no_dead_strip"}
!4 = !{i32 4, !"Objective-C Garbage Collection", i32 0}
!5 = !{i32 1, !"Objective-C Class Properties", i32 64}
!6 = !{i32 1, !"wchar_size", i32 4}
!7 = !{i32 7, !"PIC Level", i32 2}
!8 = !{!"Apple clang version 11.0.0 (clang-1100.0.33.12)"}

複製程式碼

接下來 LLVM 會對程式碼進行編譯優化,例如針對全域性變數優化、迴圈優化、尾遞迴優化等,最後輸出彙編程式碼。

六、生成彙編

使用命令

xcrun clang -S -o - main.m | open -f 生成程式碼如下:

	.section	__TEXT,__text,regular,pure_instructions
	.build_version macos, 10, 14	sdk_version 10, 15
	.globl	_main                   ## -- Begin function main
	.p2align	4, 0x90
_main:                                  ## @main
	.cfi_startproc
## %bb.0:
	pushq	%rbp
	.cfi_def_cfa_offset 16
	.cfi_offset %rbp, -16
	movq	%rsp, %rbp
	.cfi_def_cfa_register %rbp
	subq	$32, %rsp
	movl	$0, -4(%rbp)
	movl	%edi, -8(%rbp)
	movq	%rsi, -16(%rbp)
	callq	_objc_autoreleasePoolPush
	leaq	L_.str(%rip), %rdi
	movq	%rax, -24(%rbp)         ## 8-byte Spill
	movb	$0, %al
	callq	_printf
	leaq	L__unnamed_cfstring_(%rip), %rsi
	movq	%rsi, %rdi
	movl	%eax, -28(%rbp)         ## 4-byte Spill
	movb	$0, %al
	callq	_NSLog
	movq	-24(%rbp), %rdi         ## 8-byte Reload
	callq	_objc_autoreleasePoolPop
	xorl	%eax, %eax
	addq	$32, %rsp
	popq	%rbp
	retq
	.cfi_endproc
                                        ## -- End function
	.section	__TEXT,__cstring,cstring_literals
L_.str:                                 ## @.str
	.asciz	"hello debug\n"

L_.str.1:                               ## @.str.1
	.asciz	"Hello, World!"

	.section	__DATA,__cfstring
	.p2align	3               ## @_unnamed_cfstring_
L__unnamed_cfstring_:
	.quad	___CFConstantStringClassReference
	.long	1992                    ## 0x7c8
	.space	4
	.quad	L_.str.1
	.quad	13                      ## 0xd

	.section	__DATA,__objc_imageinfo,regular,no_dead_strip
L_OBJC_IMAGE_INFO:
	.long	0
	.long	64


.subsections_via_symbols
複製程式碼

彙編器以彙編程式碼作為輸入,將彙編程式碼轉換為機器程式碼,最後輸出目標檔案(object file)。

xcrun clang -fmodules -c main.m -o main.o

裡面都是二進位制檔案

七、連結

聯結器把編譯產生的.o檔案和(dylib,a,tbd)檔案,生成一個mach-o檔案。

$ xcrun clang main.o -o main

就生成一個mach o格式的可執行檔案 我們執行下:

Mac-mini-2:測試mac jxq$ file main
main: Mach-O 64-bit executable x86_64
Mac-mini-2:測試mac jxq$ ./main
hello debug
2020-01-15 15:10:32.430 main[4269:156652] Hello, World!
Mac-mini-2:測試mac jxq$ 
複製程式碼

在用nm命令,檢視可執行檔案的符號表:

Mac-mini-2:測試mac jxq$ nm -nm main
                 (undefined) external _NSLog (from Foundation)
                 (undefined) external ___CFConstantStringClassReference (from CoreFoundation)
                 (undefined) external _objc_autoreleasePoolPop (from libobjc)
                 (undefined) external _objc_autoreleasePoolPush (from libobjc)
                 (undefined) external _printf (from libSystem)
                 (undefined) external dyld_stub_binder (from libSystem)
0000000100000000 (__TEXT,__text) [referenced dynamically] external __mh_execute_header
0000000100000ef0 (__TEXT,__text) external _main
複製程式碼

至此,編譯過程全部結束,生成了可執行檔案Mach-O

那麼

編譯時連結器做了什麼

Mach-O 檔案裡面的內容,主要就是程式碼和資料:程式碼是函式的定義;資料是全域性變數的定義,包括全域性變數的初始值。不管是程式碼還是資料,它們的例項都需要由符號將其關聯起來。 為什麼呢?因為 Mach-O 檔案裡的那些程式碼,比如 if、for、while 生成的機器指令序列,要操作的資料會儲存在某個地方,變數符號就需要繫結到資料的儲存地址。你寫的程式碼還會引用其他的程式碼,引用的函式符號也需要繫結到該函式的地址上。 連結器的作用,就是完成變數、函式符號和其地址繫結這樣的任務。而這裡我們所說的符號,就可以理解為變數名和函式名。

為什麼要進行符號繫結

  • 如果地址和符號不做繫結的話,要讓機器知道你在操作什麼記憶體地址,你就需要在寫程式碼時給每個指令設好記憶體地址。
  • 可讀性和可維護性都會很差,修改程式碼後對需要對地址的進行維護
  • 需要針對不同的平臺寫多份程式碼,本可以通過高階語言一次編譯成多份
  • 相當於直接寫彙編

為什麼還要把專案中的多個 Mach-O 檔案合併成一個

專案中檔案之間的變數和介面函式都是相互依賴的,所以這時我們就需要通過連結器將專案中生成的多個 Mach-O 檔案的符號和地址繫結起來。

沒有這個繫結過程的話,單個檔案生成的 Mach-O 檔案是無法正常執行起來的。因為,如果執行時碰到呼叫在其他檔案中實現的函式的情況時,就會找不到這個呼叫函式的地址,從而無法繼續執行。

連結器在連結多個目標檔案的過程中,會建立一個符號表,用於記錄所有已定義的和所有未定義的符號。

  • 連結時如果出現相同符號的情況,就會出現“ld: dumplicate symbols”的錯誤資訊;
  • 如果在其他目標檔案裡沒有找到符號,就會提示“Undefined symbols”的錯誤資訊。

連結器對程式碼主要做了哪幾件事兒

  • 去專案檔案裡查詢目的碼檔案裡沒有定義的變數。
  • 掃描專案中的不同檔案,將所有符號定義和引用地址收集起來,並放到全域性符號表中。
  • 計算合併後長度及位置,生成同型別的段進行合併,建立繫結。
  • 對專案中不同檔案裡的變數進行地址重定位。

連結器如何去除無用函式,保證Mach-O大小

連結器在整理函式的呼叫關係時,會以 main 函式為源頭,跟隨每個引用,並將其標記為 live。跟隨完成後,那些未被標記 live 的函式,就是無用函式。然後,連結器可以通過開啟 Dead code stripping 開關,來開啟自動去除無用程式碼的功能。並且,這個開關是預設開啟的。

總結

ios編譯過程就是生成mach—o檔案的過程,在這個過程中,進行了一系列的語法檢查,程式碼優化,符號繫結等工作,那mach—o檔案是怎麼儲存這些資訊呢? 下篇文章講。

相關文章