iOS 編譯過程原理(2)


一、前言

《iOS編譯過程的原理和應用》文章介紹了 iOS 編譯相關基礎知識和簡單應用,但也很有多問題都沒有解釋清楚:

  • Clang 和 LLVM 究竟是什么
  • 源文件到機器碼的細節
  • Linker 做了哪些工作
  • 編譯順序如何確定
  • 頭文件是什么?XCode 是如何找到頭文件的?
  • Clang Module
  • 簽名是什么?為什么要簽名

為了搞清楚這些問題,我們來挖掘下 XCode 編譯 iOS 應用的細節。

二、編譯器

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

大多數編譯器由兩部分組成:前端和后端

  • 前端負責詞法分析、語法分析、生成中間代碼;
  • 后端以中間代碼作為輸入,進行與架構無關的代碼優化,接着針對不同架構生成不同的機器碼。

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

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

![19](https://upload-images.jianshu.io/upload_images/5294842-dd65ea5de43d8fc1.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

三、LLVM

LLVM(Low Level Virtual Machine)是一個強大的編譯器開發工具套件,聽起來像是虛擬機,但實際上 LLVM 和傳統意義的虛擬機關系不大,只不過項目最初的名字是 LLVM 罷了。

LLVM 的核心庫提供了現代化的 source-target-independent 優化器和支持諸多流行 CPU 架構的代碼生成器,這些核心代碼是圍繞着 LLVM IR(中間代碼)建立的。

基於 LLVM 又衍生出了一些強大的子項目,其中 iOS 開發者耳熟能詳的是:Clang 和 LLDB。

四、clang

clang 是 C 語言家族的編譯器前端,誕生之初是為了替代 GCC,提供更快的編譯速度。一張圖了解 clang 編譯的大致流程:

![20](https://upload-images.jianshu.io/upload_images/5294842-867e93a43ad184f9.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

接下來,從代碼層面看一下具體的轉化過程,新建一個 main.c:

#include <stdio.h>
// 注釋
#define DEBUG 1
int main() {
#ifdef DEBUG
  printf("hello debug\n");
#else
  printf("hello world\n");
#endif
  return 0;
}

五、預處理(preprocessor)

預處理會進行頭文件引入、宏替換、注釋處理、條件編譯(#ifdef)等操作。

#include "stdio.h" 就是告訴預處理器將這一行替換成頭文件 stdio.h 中的內容,這個過程是遞歸的:因為 stdio.h 也有可能包含其他頭文件。

用 clang 查看預處理的結果:

$ xcrun clang -E main.c

預處理后的文件有很多行,在文件的末尾,可以找到 main 函數。

$ xcrun clang -E main.c

...

...

extern int __vsnprintf_chk (char * restrict, size_t, int, size_t,
       const char * restrict, va_list);
# 412 "/usr/include/stdio.h" 2 3 4
# 10 "main.c" 2




int main() {

    printf("hello debug\n");



    return 0;
}

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

六、詞法分析(lexical anaysis)

詞法分析器讀入源文件的字符流,將它們組織成有意義的詞素(lexeme)序列,對於每個詞素,詞法分析器產生詞法單元(token)作為輸出。

$ xcrun clang -fmodules -fsyntax-only -Xclang -dump-tokens main.c

輸出:

$ xcrun clang -fmodules -fsyntax-only -Xclang -dump-tokens main.c
annot\_module\_include '#include <stdio.h>

// 一點注釋

#define DEBUG 1
int main() {
#ifdef DEBUG
    printf("hello debug\\n");
#else
    printf'		Loc=<main.c:9:1>
int 'int'	 \[StartOfLine\]	Loc=<main.c:14:1>
identifier 'main'	 \[LeadingSpace\]	Loc=<main.c:14:5>
l_paren '('		Loc=<main.c:14:9>
r_paren ')'		Loc=<main.c:14:10>
l_brace '{'	 \[LeadingSpace\]	Loc=<main.c:14:12>
identifier 'printf'	 \[StartOfLine\] \[LeadingSpace\]	Loc=<main.c:16:5>
l_paren '('		Loc=<main.c:16:11>
string_literal '"hello debug\\n"'		Loc=<main.c:16:12>
r_paren ')'		Loc=<main.c:16:27>
semi ';'		Loc=<main.c:16:28>
return 'return'	 \[StartOfLine\] \[LeadingSpace\]	Loc=<main.c:20:5>
numeric_constant '0'	 \[LeadingSpace\]	Loc=<main.c:20:12>
semi ';'		Loc=<main.c:20:13>
r_brace '}'	 \[StartOfLine\]	Loc=<main.c:21:1>
eof ''		Loc=<main.c:21:2>

Loc=<main.c:9:1> 標示這個 token 位於源文件 main.c 的第 9 行,從第 1 個字符開始。保存 token 在源文件中的位置是方便后續 clang 分析的時候能夠找到出錯的原始位置。

七、語法分析(semantic analysis)

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

$ xcrun clang -fsyntax-only -Xclang -ast-dump main.c | open -f

main 函數 AST 的結構:

[0;1;32mTranslationUnitDecl 0x7fd9a18166e8 <<invalid sloc>> <invalid sloc>
|-TypedefDecl 0x7fd9a1816c60 <<invalid sloc>> <invalid sloc> implicit __int128_t '__int128'
| `-BuiltinType 0x7fd9a1816980 '__int128'
|-TypedefDecl 0x7fd9a1816cd0 <<invalid sloc>> <invalid sloc> implicit __uint128_t 'unsigned __int128'
| `-BuiltinType 0x7fd9a18169a0 'unsigned __int128'
|-TypedefDecl 0x7fd9a1816fa8 <<invalid sloc>> <invalid sloc> implicit __NSConstantString 'struct __NSConstantString_tag'
| `-RecordType 0x7fd9a1816db0 'struct __NSConstantString_tag'
|   `-Record 0x7fd9a1816d28 '__NSConstantString_tag'
|-TypedefDecl 0x7fd9a1817040 <<invalid sloc>> <invalid sloc> implicit __builtin_ms_va_list 'char *'
| `-PointerType 0x7fd9a1817000 'char *'
|   `-BuiltinType 0x7fd9a1816780 'char'
|-TypedefDecl 0x7fd9a1817308 <<invalid sloc>> <invalid sloc> implicit referenced __builtin_va_list 'struct __va_list_tag [1]'
| `-ConstantArrayType 0x7fd9a18172b0 'struct 

...

有了抽象語法樹,clang 就可以對這個樹進行分析,找出代碼中的錯誤。比如類型不匹配,亦或 Objective-C 中向 target 發送了一個未實現的消息。

AST 是開發者編寫 clang 插件主要交互的數據結構,clang 也提供很多 API 去讀取 AST。更多細節:Introduction to the Clang AST

八、CodeGen

CodeGen 遍歷語法樹,生成 LLVM IR 代碼。LLVM IR 是前端的輸出,后端的輸入。

xcrun clang -S -emit-llvm main.c -o main.ll

main.ll 文件內容:

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

@.str = private unnamed_addr constant \[13 x i8\] c"hello debug\\0A\\00", align 1

; Function Attrs: noinline nounwind optnone ssp uwtable
define i32 @main() #0 {
  %1 = alloca i32, align 4
  store i32 0, i32* %1, align 4
  %2 = call i32 (i8*, ...) @printf(i8* getelementptr inbounds (\[13 x i8\], \[13 x i8\]* @.str, i32 0, i32 0))
  ret i32 0
}

...

Objective-C 代碼在這一步會進行 runtime 的橋接:property 合成、ARC 處理等。

LLVM 會對生成的 IR 進行優化,優化會調用相應的 Pass 進行處理。Pass 由多個節點組成,都是 Pass 類的子類,每個節點負責做特定的優化,更多細節:Writing an LLVM Pass

九、生成匯編代碼

LLVM 對 IR 進行優化后,會針對不同架構生成不同的目標代碼,最后以匯編代碼的格式輸出。

生成 arm 64 匯編:

$ xcrun clang -S main.c -o main.s

查看生成的 main.s 文件。對匯編感興趣的同學可以看看這篇文章:iOS匯編快速入門

	.section	__TEXT,__text,regular,pure_instructions
	.macosx_version_min 10, 13
	.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	$16, %rsp
	leaq	L_.str(%rip), %rdi
	movl	$0, -4(%rbp)
	movb	$0, %al
	callq	_printf
	xorl	%ecx, %ecx
	movl	%eax, -8(%rbp)          ## 4-byte Spill
	movl	%ecx, %eax
	addq	$16, %rsp
	popq	%rbp
	retq
	.cfi_endproc
                                        ## -- End function
	.section	__TEXT,__cstring,cstring_literals
L_.str:                                 ## @.str
	.asciz	"hello debugn"


.subsections_via_symbols

十、匯編器

匯編器以匯編代碼作為輸入,將匯編代碼轉換為機器代碼,最后輸出目標文件(object file)。

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

還記得代碼中調用了一個函數 printf 么?通過 nm 命令,查看下 main.o 中的符號

$ xcrun nm -nm main.o
                 (undefined) external _printf
0000000000000000 (\_\_TEXT,\_\_text) external _main

_printf 是一個 undefined external 的。undefined 表示在當前文件暫時找不到符號 _printf,而 external 表示這個符號是外部可以訪問的,對應表示文件私有的符號是 non-external。

什么是符號(Symbols)?

符號就是指向一段代碼或者數據的名稱。還有一種叫做 WeakSymols,也就是並不一定會存在的符號,需要在運行時決定。比如 iOS12 特有的 API,在 iOS11 上就沒有。

十一、鏈接

連接器把編譯產生的 .o 文件和(dylib、a、tbd)文件,生成一個 mach-o 文件。

$ xcrun clang main.o -o main

就得到了一個 mach o 格式的可執行文件

$ file main
main: Mach-O 64-bit executable x86_64
$ ./main
hello debug

再用 nm 命令,查看可執行文件的符號表:

$ nm -nm main
                 (undefined) external _printf (from libSystem)
                 (undefined) external dyld\_stub\_binder (from libSystem)
0000000100000000 (\_\_TEXT,\_\_text) \[referenced dynamically\] external \_\_mh\_execute_header
0000000100000f60 (\_\_TEXT,\_\_text) external _main

_printf 仍然是 undefined,但是后面多了一些信息:from libSystem,表示這個符號來自於 libSystem,會在運行時動態綁定。

十二、XCode 編譯

通過上文我們大概了解了 Clang 編譯一個 C 語言文件的過程,但是 XCode 開發的項目不僅僅包含了代碼文件,還包括了圖片、plist 等。XCode 中編譯一次都要經過哪些過程呢?

新建一個單頁面的 Demo 工程:CocoaPods 依賴 AFNetworking 和 SDWebImage,同時依賴於一個內部 Framework。按下Command + B,在 XCode 的 Report Navigator 模塊中,可以找到編譯的詳細日志:

![21](https://upload-images.jianshu.io/upload_images/5294842-850e886824831a69.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

詳細的步驟:

  • 創建 Product.app 的文件夾
  • 把 Entitlements.plist 寫入到 DerivedData 里,處理打包的時候需要的信息(比如 application-identifier)。
  • 創建一些輔助文件,比如各種 .hmap,這是 headermap 文件,具體作用下文會講解。
  • 執行 CocoaPods 的編譯前腳本:檢查 Manifest.lock 文件。
  • 編譯 .m 文件,生成 .o 文件。
  • 鏈接動態庫。.o 文件,生成一個 mach o 格式的可執行文件。
  • 編譯 assets,編譯 storyboard,鏈接 storyboard
  • 拷貝動態庫 Logger.framework,並且對其簽名
  • 執行 CocoaPods 編譯后腳本:拷貝 CocoaPods Target 生成的 Framework
  • 對 Demo.App 簽名,並驗證(validate)
  • 生成 Product.app
  • 生成 dYSM 文件

Entitlements.plist 保存了 App 需要使用的特殊權限,比如 iCloud、遠程通知、Siri 等。

十三、編譯順序

編譯的時候有很多的 Task(任務)要去執行,XCode 如何決定 Task 的執行順序呢?

答案是:依賴關系

還是以剛剛的 Demo 項目為例,整個依賴關系如下:

![22](https://upload-images.jianshu.io/upload_images/5294842-9b0c7342fdc5b545.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

可以從 XCode 的 Report Navigator 看到 Target 的編譯順序:

![23](https://upload-images.jianshu.io/upload_images/5294842-1c02d00b99428ab4.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

XCode 編譯的時候會盡可能的利用多核性能,多 Target 並發編譯。

那么,XCode 又從哪里得到了這些依賴關系呢?

  • Target Dependencies - 顯式聲明的依賴關系
  • Linked Frameworks and Libraries - 隱式聲明的依賴關系
  • Build Phase - 定義了編譯一個 Target 的每一步

十四、增量編譯

日常開發中,一次完整的編譯可能要幾分鍾,甚至幾十分鍾,而增量編譯只需要不到 1 分鍾,為什么增量編譯會這么快呢?

因為 XCode 會對每一個 Task 生成一個哈希值,只有哈希值改變的時候才會重新編譯。

比如,修改了 ViewControler.m,只有圖中灰色的三個 Task 會重新執行(這里不考慮 build phase 腳本)。

![24](https://upload-images.jianshu.io/upload_images/5294842-0eaaea9ee243f79c.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

十五、頭文件

C 語言家族中,頭文件(.h)文件用來引入函數/類/宏定義等聲明,讓開發者更靈活的組織代碼,而不必把所有的代碼寫到一個文件里。

頭文件對於編譯器來說就是一個 promise。頭文件里的聲明,編譯會認為有對應實現,在鏈接的時候再解決具體實現的位置。

![25](https://upload-images.jianshu.io/upload_images/5294842-b5be48b5d1a1c97d.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

當只有聲明,沒有實現的時候,鏈接器就會報錯。

Undefined symbols for architecture arm64:
“_umimplementMethod”, referenced from:
-\[ClassA method\] in ClassA.o
ld: symbol(s) not found for architecture arm64
clang: error: linker command failed with exit code 1 (use -v to see invocation)

Objective-C 的方法要到運行時才會報錯,因為 Objective-C 是一門動態語言,編譯器無法確定對應的方法名(SEL)在運行時到底有沒有實現(IMP)。

日常開發中,兩種常見的頭文件引入方式:

#include "CustomClass.h" // 自定義
#include <Foundation/Foundation.h> // 系統或者內部 framework

引入的時候並沒有指明文件的具體路徑,編譯器是如何找到這些頭文件的呢?

回到 XCode 的 Report Navigator,找到上一個編譯記錄,可以看到編譯 ViewController.m 的具體日志:

![27](https://upload-images.jianshu.io/upload_images/5294842-d620556105843b4f.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

26

把這個日志整體拷貝到命令行中,然后最后加上 -v,表示我們希望得到更多的日志信息,執行這段代碼,在日志最后可以看到clang 是如何找到頭文件的:

#include "..." search starts here:
 /Users/.../Build/Intermediates.noindex/Demo.build/Debug-iphoneos/Demo.build/Demo-generated-files.hmap (headermap)
 /Users/.../Build/Intermediates.noindex/Demo.build/Debug-iphoneos/Demo.build/Demo-project-headers.hmap (headermap)
 /Users/.../Build/Products/Debug-iphoneos/AFNetworking/AFNetworking.framework/Headers
 /Users/.../Build/Products/Debug-iphoneos/SDWebImage/SDWebImage.framework/Headers
 
#include <...> search starts here:
 /Users/.../Build/Intermediates.noindex/Demo.build/Debug-iphoneos/Demo.build/Demo-own-target-headers.hmap (headermap)
 /Users/.../Build/Intermediates.noindex/Demo.build/Debug-iphoneos/Demo.build/Demo-all-non-framework-target-headers.hmap (headermap)
 /Users/.../Build/Intermediates.noindex/Demo.build/Debug-iphoneos/Demo.build/DerivedSources
 /Users/.../Build/Products/Debug-iphoneos (framework directory)
 /Users/.../Build/Products/Debug-iphoneos/AFNetworking (framework directory)
 /Users/.../Build/Products/Debug-iphoneos/SDWebImage (framework directory)
 /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/clang/10.0.0/include
 /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/include
 $SDKROOT/usr/include
 $SDKROOT/System/Library/Frameworks (framework directory)
 
End of search list.

這里有個文件類型叫做 heademap,headermap 是幫助編譯器找到頭文件的輔助文件:存儲着頭文件到其物理路徑的映射關系。

可以通過一個輔助的小工具 hmap 查看 hmap 中的內容:

$ ./hmap print Demo-project-headers.hmap 
AppDelegate.h -> /Users/huangwenchen/Desktop/Demo/Demo/AppDelegate.h
Demo-Bridging-Header.h -> /Users/huangwenchen/Desktop/Demo/Demo/Demo-Bridging-Header.h
Dummy.h -> /Users/huangwenchen/Desktop/Demo/Framework/Dummy.h
Framework.h -> Framework/Framework.h
TestView.h -> /Users/huangwenchen/Desktop/Demo/Demo/View/TestView.h
ViewController.h -> /Users/huangwenchen/Desktop/Demo/Demo/ViewController.h

這就是為什么備份/恢復 Mac 后,需要 clean build folder,因為兩台 mac 對應文件的物理位置可能不一樣。

clang 發現 #import "TestView.h" 的時候,先在 headermap(Demo-generated-files.hmap,Demo-project-headers.hmap) 里查找,如果 headermap 文件找不到,接着在 own target 的 framework 里找:

/Users/.../Build/Products/Debug-iphoneos/AFNetworking/AFNetworking.framework/Headers/TestView.h
/Users/.../Build/Products/Debug-iphoneos/SDWebImage/SDWebImage.framework/Headers/TestView.h

系統的頭文件查找的時候也是優先 headermap,headermap 查找不到會查找 own target framework,最后查找 SDK 目錄。

以 #import <Foundation/Foundation.h> 為例,在 SDK 目錄查找時:

  1. 首先查找 framework 是否存在

    $SDKROOT/System/Library/Frameworks/Foundation.framework
    
  2. 如果 framework 存在,再在 headers 目錄里查找頭文件是否存在

    $SDKROOT/System/Library/Frameworks/Foundation.framework/headers/Foundation.h
    

十六、Clang Module

傳統的 #include/#import 都是文本語義:預處理器在處理的時候會把這一行替換成對應頭文件的文本,這種簡單粗暴替換是有很多問題的:

  1. 大量的預處理消耗。假如有 N 個頭文件,每個頭文件又 #include 了 M 個頭文件,那么整個預處理的消耗是 N*M。
  2. 文件導入后,宏定義容易出現問題。因為是文本導入,並且按照 include 依次替換,當一個頭文件定義了 #define std hello_world,而另一個頭文件剛好又是 C++ 標准庫,那么 include 順序不同,可能會導致所有的 std 都會被替換。
  3. 邊界不明顯。拿到一組 .a 和 .h 文件,很難確定 .h 是屬於哪個 .a 的,需要以什么樣的順序導入才能正確編譯。

clang module 不再使用文本模型,而是采用更高效的語義模型。clang module 提供了一種新的導入方式:@import,module 會被作為一個獨立的模塊編譯,並且產生獨立的緩存,從而大幅度提高預處理效率,這樣時間消耗從 M*N 變成了 M+N。

XCode 創建的 Target 是 Framework 的時候,默認 define module 會設置為 YES,從而支持 module,當然像 Foundation 等系統的 framwork 同樣支持 module。

#import <Foundation/NSString.h> 的時候,編譯器會檢查 NSString.h 是否在一個 module 里,如果是的話,這一行會被替換成 @import Foundation。

![28](https://upload-images.jianshu.io/upload_images/5294842-f9261463ed11b9e9.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

那么,如何定義一個 module 呢?答案是:modulemap 文件,這個文件描述了一組頭文件如何轉換為一個 module,舉個例子:

framework module Foundation  [extern_c] [system] {
	umbrella header "Foundation.h" // 所有要暴露的頭文件
 	export *
	module * {
 		export *
 	}
 	explicit module NSDebug { //submodule
 		header "NSDebug.h"
 		export *
 	}
 }

swift 是可以直接 import 一個 clang module 的,比如你有一些 C 庫,需要在 Swift 中使用,就可以用 modulemap 的方式。

十七、Swift 編譯

現代化的語言幾乎都拋棄了頭文件,swift 也不例外。問題來了,swift 沒有頭文件又是怎么找到聲明的呢?

編譯器干了這些臟活累活。編譯一個 Swift 頭文件,需要解析 module 中所有的 Swift 文件,找到對應的聲明。

![29](https://upload-images.jianshu.io/upload_images/5294842-bd4a0844c9c85adb.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

當開發中難免要有 Objective-C 和 Swift 相互調用的場景,兩種語言在編譯的時候查找符號的方式不同,如何一起工作的呢?

  1. Swift 引用 Objective-C

    Swift 的編譯器內部使用了 clang,所以 swift 可以直接使用 clang module,從而支持直接 import Objective-C 編寫的framework。

    ![30](https://upload-images.jianshu.io/upload_images/5294842-cd4f5c7f8eafc71e.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

    swift 編譯器會從 Objective-C 頭文件里查找符號,頭文件的來源分為兩大類:

    • Bridging-Header.h 中暴露給 swfit 的頭文件
    • framework 中公開的頭文件,根據編寫的語言不同,可能從 modulemap 或者 umbrella header 查找。

    XCode 提供了宏定義 NS_SWIFT_NAME 來讓開發者定義 Objective-C => Swift的符號映射,可以通過 Related Items -> Generate Interface 來查看轉換后的結果:

    ![31](https://upload-images.jianshu.io/upload_images/5294842-bd928c0ee2b8d45a.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
  2. Objective-C 引用 swift

    xcode 會以 module 為單位,為 swift 自動生成頭文件,供 Objective-C 引用,通常這個文件命名為 ProductName-Swift.h。

    swift 提供了關鍵詞 @objc 來把類型暴露給 Objective-C 和 Objective-C Runtime。

    @objc public class MyClass
    

十八、深入理解 Linker

鏈接器會把編譯器編譯生成的多個文件,鏈接成一個可執行文件。鏈接並不會產生新的代碼,只是在現有代碼的基礎上做移動和補丁。

鏈接器的輸入可能是以下幾種文件:

  • object file(.o),單個源文件的編輯結果,包含了由符號表示的代碼和數據。
  • 動態庫(.dylib),mach o 類型的可執行文件,鏈接的時候只會綁定符號,動態庫會被拷貝到 app 里,運行時加載
  • 靜態庫(.a),由 ar 命令打包的一組 .o 文件,鏈接的時候會把具體的代碼拷貝到最后的 mach-o。
  • tbd,只包含符號的庫文件

這里提到了一個概念:符號(Symbols),那么符號是什么呢?

符號是一段代碼或者數據的名稱,一個符號內部也有可能引用另一個符號。

以一段代碼為例,看看鏈接時究竟發生了什么?

源代碼:

- (void)log
{
    printf("hello world\n");
}

.o 文件:

#代碼
adrp    x0, l_.str@PAGE
add     x0, x0, l_.str@PAGEOFF
bl      _printf

#字符串符號
l_.str:                                 ; @.str
        .asciz  "hello world\\n"

在 .o 文件中,字符串 "hello world\n" 作為一個符號(l_.str)被引用,匯編代碼讀取的時候按照 l_.str 所在的頁加上偏移量的方式讀取,然后調用 printf 符號。到這一步,CPU 還不知道怎么執行,因為還有兩個問題沒解決:

  1. l_.str 在可執行文件的哪個位置?
  2. printf 函數來自哪里?

再來看看鏈接之后的 mach o 文件:

![32](https://upload-images.jianshu.io/upload_images/5294842-ac9f852d87c4ebb1.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

鏈接器如何解決這兩個問題呢?

  1. 鏈接后,不再是以頁+偏移量的方式讀取字符串,而是直接讀虛擬內存中的地址,解決了 l_.str 的位置問題。
  2. 鏈接后,不再是調用符號 _printf,而是在 DATA 段上創建了一個函數指針 _printf$ptr,初始值為 0x0(null),代碼直接調用這個函數指針。啟動的時候,dyld 會把 DATA 段上的指針進行動態綁定,綁定到具體虛擬內存中的 _printf 地址。更多細節,可以參考這篇文章:深入理解iOS App的啟動過程

Mach-O 有一個區域叫做 LINKEDIT,這個區域用來存儲啟動時 dyld 需要動態修復的一些數據:比如剛剛提到的 printf 在內存中的地址。

十九、理解簽名

  1. 非對稱加密

    在密碼學中,非對稱加密需要兩個密鑰:公鑰和私鑰。私鑰加密的只能用公鑰解密,公鑰加密的只能用私鑰解密。

  2. 數字簽名

    數字簽名表示我對數據做了個標記,表示這是我的數據,沒有經過篡改。

    數據發送方 Leo 產生一對公私鑰,私鑰自己保存,公鑰發給接收方 Lina。Leo 用摘要算法,對發送的數據生成一段摘要,摘要算法保證了只要數據修改,那么摘要一定改變。然后用私鑰對這個摘要進行加密,和數據一起發送給 Lina。

    ![33](https://upload-images.jianshu.io/upload_images/5294842-b929196750ad26b6.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

    Lina 收到數據后,用公鑰解密簽名,得到 Leo 發過來的摘要;然后自己按照同樣的摘要算法計算摘要,如果計算的結果和 Leo 的一樣,說明數據沒有被篡改過。

    ![34](https://upload-images.jianshu.io/upload_images/5294842-cacead84adbed4a6.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

    但是,現在還有個問題:Lina 有一個公鑰,假如攻擊者把 Lina 的公鑰替換成自己的公鑰,那么攻擊者就可以偽裝成 Leo 進行通信,所以 Lina 需要確保這個公鑰來自於 Leo,可以通過數字證書來解決這個問題。

    數字證書由 CA(Certificate Authority)頒發,以 Leo 的證書為例,里面包含了以下數據:簽發者、Leo 的公鑰、Leo 使用的 Hash 算法、證書的數字簽名、到期時間等。

    有了數字證書后,Leo 再發送數據的時候,把自己從 CA 申請的證書一起發送給 Lina。Lina 收到數據后,先用 CA 的公鑰驗證證書的數字簽名是否正確,如果正確說明證書沒有被篡改過,然后以信任鏈的方式判斷是否信任這個證書,如果信任證書,取出證書中的數據,可以判斷出證書是屬於 Leo 的,最后從證書中取出公鑰來做數據簽名驗證。

二十、iOS App 簽名

為什么要對 App 進行簽名呢?簽名能夠讓 iOS 識別出是誰簽名了 App,並且簽名后 App 沒有被篡改過。

除此之外,Apple 要嚴格控制 App 的分發:

  1. App 來自 Apple 信任的開發者
  2. 安裝的設備是 Apple 允許的設備

20.1 證書

通過上文的講解,我們知道數字證書里包含着申請證書設備的公鑰,所以在 Apple 開發者后台創建證書的時候,需要上傳 CSR 文件(Certificate Signing Request),用 keychain 生成這個文件的時候,就生成了一對公/私鑰:公鑰在 CSR 里,私鑰在本地的 Mac 上。Apple 本身也有一對公鑰和私鑰:私鑰保存在 Apple 后台,公鑰在每一台 iOS 設備上。

![35](https://upload-images.jianshu.io/upload_images/5294842-ec7d73889dc3f8e4.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

20.2 Provisioning Profile

iOS App 安裝到設備的途徑(非越獄)有以下幾種:

  • 開發包(插線,或者 archive 導出 develop 包)
  • Ad Hoc
  • App Store
  • 企業證書

開發包和 Ad Hoc 都會嚴格限制安裝設備,為了把設備 uuid 等信息一起打包進 App,開發者需要配置 Provisioning Profile。

![36](https://upload-images.jianshu.io/upload_images/5294842-696d4d9bbf81ec36.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

可以通過以下命令來查看 Provisioning Profile 中的內容:

security cms -D -i embedded.mobileprovision > result.plist
open result.plist

本質上就是一個編碼過后的 plist。

![37](https://upload-images.jianshu.io/upload_images/5294842-46a1386a0e86d8aa.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

20.3 iOS 簽名

生成安裝包的最后一步,XCode 會調用 codesign 對 Product.app 進行簽名。

創建一個額外的目錄 _CodeSignature 以 plist 的方式存放安裝包內每一個文件簽名

<key>Base.lproj/LaunchScreen.storyboardc/01J-lp-oVM-view-Ze5-6b-2t3.nib</key>
<data>
T2g5jlq7EVFHNzL/ip3fSoXKoOI=
</data>
<key>Info.plist</key>
<data>
5aVg/3m4y30m+GSB8LkZNNU3mug=
</data>
<key>PkgInfo</key>
<data>
n57qDP4tZfLD1rCS43W0B4LQjzE=
</data>
<key>embedded.mobileprovision</key>
<data>
tm/I1g+0u2Cx9qrPJeC0zgyuVUE=
</data>
...

代碼簽名會直接寫入到 mach-o 的可執行文件里,值得注意的是簽名是以頁(Page)為單位的,而不是整個文件簽名:

![38](https://upload-images.jianshu.io/upload_images/5294842-8c96d1f4a8fcb7b1.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

20.4 驗證

安裝 App 的時候

  • 從 embedded.mobileprovision 取出證書,驗證證書是否來自 Apple 信任的開發者
  • 證書驗證通過后,從證書中取出 Leo 的公鑰
  • 讀取 _CodeSignature 中的簽名結果,用 Leo 的公鑰驗證每個文件的簽名是否正確
  • 文件 embedded.mobileprovision 驗證通過后,讀取里面的設備 id 列表,判斷當前設備是否可安裝(App Store 和企業證書不做這步驗證)
  • 驗證通過后,安裝 App

啟動 App 的時候

  • 驗證 bundle id、entitlements 和 embedded.mobileprovision中的 AppId,entitlements 是否一致
  • 判斷 device id 包含在 embedded.mobileprovision 里。App Store 和企業證書不做驗證
  • 如果是企業證書,驗證用戶是否信任企業證書
  • App 啟動后,當缺頁中斷(page fault)發生的時候,系統會把對應的 mach-o 頁讀入物理內存,然后驗證這個 page 的簽名是否正確。
  • 以上都驗證通過,App 才能正常啟動

二十一、文章

黃文臣 & 深入淺出iOS編譯


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM