iOS底层学习 - 从编译到启动的奇幻旅程(一)

5,511 阅读17分钟

了解了对象,类,方法等底层实现之后,我们来看一下我们开发的App,在代码完成后到启动的时候,经历了哪几个步骤

总体来说,一个APP从编写完代码到运行,就经历了两大步骤,即编译运行,这一章节,主要来看一下APP的进行编译的。

编译的大体步骤如下:

  • 预处理
  • 编译
  • 汇编
  • 链接

iOS编译器

iOS的代码,是通过编译器将代码直接编写成机器码,然后直接在CPU上运行机器码的,这样能使得我们的app和手机都能效率更高,运行更快。C,C++,OC等语言,都是使用的编译器,生成相关的可执行文件

与之对应的,是Python,Shell等脚本性语言,它们使用的是解释器。解释器会在运行时解释执行代码,获取一段代码后就会将其翻译成目标代码(就是字节码(Bytecode)),然后一句一句地执行目标代码。也就是说是在运行时才去解析代码,比直接运行编译好的可执行文件自然效率就低,但是跑起来之后可以不用重启启动编译,直接修改代码即可看到效果,类似热更新,可以帮我们缩短整个程序的开发周期和功能更新周期。

总结来说:

  • 采用编译器生成机器码执行的好处是效率高,缺点是调试周期长
  • 解释器执行的好处是编写调试方便,缺点是执行效率低

目前Xcode使用的编译器为LLVM(官方链接)。LLVM 是编译器工具链技术的一个集合。而其中的 lld 项目,就是内置链接器。编译器会对每个文件进行编译,生成 Mach-O(可执行文件);链接器会将项目中的多个Mach-O 文件合并成一个。

LLVM会执行上述的整个编译流程,大体流程如下:

  • 你写好代码后,LLVM会预处理你的代码,比如把宏嵌入到对应的位置。
  • 预处理完后,LLVM 会对代码进行词法分析和语法分析,生成 AST 。AST 是抽象语法树,结构上比代码更精简,遍历起来更快,所以使用 AST 能够更快速地进行静态检查,同时还能更快地生成 IR(中间表示)
  • 最后 AST 会生成 IR,IR 是一种更接近机器码的语言,区别在于和平台无关,通过 IR 可以生成多份适合不同平台的机器码。对于 iOS 系统,IR 生成的可执行文件就是 Mach-O。

预处理

创建一个工程,使用clang -E main.m可以查看预处理阶段的所做的工作

#import <Foundation/Foundation.h>
#define DEFINEEight 8

int main(){
    @autoreleasepool {
        int eight = DEFINEEight;
        int six = 6;
        NSString* site = [[NSString alloc] initWithUTF8String:"starming"];
        int rank = eight + six;
        NSLog(@"%@ rank %d", site, rank);
    }
    return 0;
}
# 10 "main.m"
# 1 "./AppDelegate.h" 1
# 11 "./AppDelegate.h"
@interface AppDelegate : UIResponder <UIApplicationDelegate>
@end
# 11 "main.m" 2
int main(int argc, char * argv[]) {
    @autoreleasepool {
        int eight = 8;
        int six = 6;
        NSString* site = [[NSString alloc] initWithUTF8String:"starming"];
        int rank = eight + six;
        NSLog(@"%@ rank %d", site, rank);
    }
    return 0;
}

预处理主要处理规则如下:

  • 删除所有#define,并将所有宏定义展开,在源码中使用的宏定义会被替换为对应代码
  • 将被包含的文件插入到预编译指令(#include)所在位置(这个过程是递归的)
  • 删除所有注释:// 、/* */等
  • 添加行号和文件名标识,以便于编译时编译器产生调试用的行号信息及编译时能够显示警告和错误的所在行号
  • 保留所有的#pragma编译器指令,因为编译器须要使用它们

当我们无法判断宏定义是否正确或者头文件是否包含时可以查看预编译后的文件来确定问题

编译

编译的过程就是把预处理完的文件进行一些列词法分析、语法分析、语义分析及优化后生产相应的汇编代码文件,这个过程往往是我们整个程序构建的核心部分.

词法分析

使用clang -Xclang -dump-tokens main.m来进行词法分析,得到如下结果

at '@'	 [StartOfLine]	Loc=<./AppDelegate.h:11:1>
identifier 'interface'		Loc=<./AppDelegate.h:11:2>
identifier 'AppDelegate'	 [LeadingSpace]	Loc=<./AppDelegate.h:11:12>
colon ':'	 [LeadingSpace]	Loc=<./AppDelegate.h:11:24>
identifier 'UIResponder'	 [LeadingSpace]	Loc=<./AppDelegate.h:11:26>
less '<'	 [LeadingSpace]	Loc=<./AppDelegate.h:11:38>
identifier 'UIApplicationDelegate'		Loc=<./AppDelegate.h:11:39>
greater '>'		Loc=<./AppDelegate.h:11:60>
at '@'	 [StartOfLine]	Loc=<./AppDelegate.h:14:1>
identifier 'end'		Loc=<./AppDelegate.h:14:2>
int 'int'	 [StartOfLine]	Loc=<main.m:14:1>
identifier 'main'	 [LeadingSpace]	Loc=<main.m:14:5>
l_paren '('		Loc=<main.m:14:9>
int 'int'		Loc=<main.m:14:10>
identifier 'argc'	 [LeadingSpace]	Loc=<main.m:14:14>
comma ','		Loc=<main.m:14:18>
char 'char'	 [LeadingSpace]	Loc=<main.m:14:20>
star '*'	 [LeadingSpace]	Loc=<main.m:14:25>
identifier 'argv'	 [LeadingSpace]	Loc=<main.m:14:27>
l_square '['		Loc=<main.m:14:31>
r_square ']'		Loc=<main.m:14:32>
r_paren ')'		Loc=<main.m:14:33>
l_brace '{'	 [LeadingSpace]	Loc=<main.m:14:35>

...

这一步把源文件中的代码转化为特殊的标记流,源码被分割成一个一个的字符和单词,在行尾Loc中都标记出了源码所在的对应源文件和具体行数,方便在报错时定位问题

语法分析

使用clang -Xclang -ast-dump -fsyntax-only main.m命令来进行语法分析,结果如下

...

| `-PointerType 0x7f9824831b10 'char *'
|   `-BuiltinType 0x7f9824830ca0 'char'
|-TypedefDecl 0x7f9825006458 <<invalid sloc>> <invalid sloc> implicit __builtin_va_list 'struct __va_list_tag [1]'
| `-ConstantArrayType 0x7f9825006400 'struct __va_list_tag [1]' 1
|   `-RecordType 0x7f9825006280 'struct __va_list_tag'
|     `-Record 0x7f9825006200 '__va_list_tag'
|-ObjCInterfaceDecl 0x7f98250064a8 <./AppDelegate.h:11:1, line:14:2> line:11:12 AppDelegate
|-FunctionDecl 0x7f98250067e0 <main.m:14:1, line:23:1> line:14:5 main 'int (int, char **)'
| |-ParmVarDecl 0x7f98250065b8 <col:10, col:14> col:14 argc 'int'
| |-ParmVarDecl 0x7f98250066d0 <col:20, col:32> col:27 argv 'char **':'char **'
| `-CompoundStmt 0x7f9825006f28 <col:35, line:23:1>
|   |-ObjCAutoreleasePoolStmt 0x7f9825006ee0 <line:15:5, line:21:5>
|   | `-CompoundStmt 0x7f9825006eb8 <line:15:22, line:21:5>
|   |   |-DeclStmt 0x7f9825006960 <line:16:9, col:32>
|   |   | `-VarDecl 0x7f98250068e0 <col:9, line:12:21> line:16:13 used eight 'int' cinit
|   |   |   `-IntegerLiteral 0x7f9825006940 <line:12:21> 'int' 8
|   |   |-DeclStmt 0x7f9825006a10 <line:17:9, col:20>
|   |   | `-VarDecl 0x7f9825006990 <col:9, col:19> col:13 used six 'int' cinit
|   |   |   `-IntegerLiteral 0x7f98250069f0 <col:19> 'int' 6
|   |   `-DeclStmt 0x7f9825006b30 <line:19:9, col:31>
|   |     `-VarDecl 0x7f9825006a40 <col:9, col:28> col:13 used rank 'int' cinit
|   |       `-BinaryOperator 0x7f9825006b10 <col:20, col:28> 'int' '+'
|   |         |-ImplicitCastExpr 0x7f9825006ae0 <col:20> 'int' <LValueToRValue>
|   |         | `-DeclRefExpr 0x7f9825006aa0 <col:20> 'int' lvalue Var 0x7f98250068e0 'eight' 'int'
|   |         `-ImplicitCastExpr 0x7f9825006af8 <col:28> 'int' <LValueToRValue>
|   |           `-DeclRefExpr 0x7f9825006ac0 <col:28> 'int' lvalue Var 0x7f9825006990 'six' 'int'
|   `-ReturnStmt 0x7f9825006f18 <line:22:5, col:12>
|     `-IntegerLiteral 0x7f9825006ef8 <col:12> 'int' 0
`-FunctionDecl 0x7f9825006bd0 <line:20:9> col:9 implicit used NSLog 'void (id, ...)' extern
  |-ParmVarDecl 0x7f9825006c68 <<invalid sloc>> <invalid sloc> 'id':'id'
  `-FormatAttr 0x7f9825006cd0 <col:9> Implicit NSString 1
  
...

这一步是把词法分析生成的标记流,解析成一个抽象语法树(abstract syntax tree -- AST),同样地,在这里面每一节点也都标记了其在源码中的位置。

静态分析

把源码转化为抽象语法树之后,编译器就可以对这个树进行分析处理。静态分析会对代码进行错误检查,如出现方法被调用但是未定义、定义但是未使用的变量等,以此提高代码质量。当然,还可以通过使用 Xcode 自带的静态分析工具(Product -> Analyze)

类型检查

一般会把类型分为两类:动态的和静态的。动态的在运行时做检查,静态的在编译时做检查。以往,编写代码时可以向任意对象发送任何消息,在运行时,才会检查对象是否能够响应这些消息。由于只是在运行时做此类检查,所以叫做动态类型。

至于静态类型,是在编译时做检查。当在代码中使用 ARC 时,编译器在编译期间,会做许多的类型检查:因为编译器需要知道哪个对象该如何使用。

在此阶段clang会做检查,最常见的是检查程序是否发送正确的消息给正确的对象,是否在正确的值上调用了正常函数。如果你给一个单纯的 NSObject* 对象发送了一个 hello 消息,那么 clang 就会报错,同样,给属性设置一个与其自身类型不相符的对象,编译器会给出一个可能使用不正确的警告。

其他分析

其他分析ObjCUnusedIVarsChecker.cpp是用来检查是否有定义了,但是从未使用过的变量。ObjCSelfInitChecker.cpp是检查在 你的初始化方法中中调用 self 之前,是否已经调用[self initWith...][super init]了。

参考资料

clang静态分析

LLVM IR 中间产物

使用clang -O3 -S -emit-llvm main.m -o main.ll命令,生成LLVM中间产物IR(生成main.ll文件),IR是编译过程的前端的输出后端的输入。

; 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.13.0"

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

@__CFConstantStringClassReference = external global [0 x i32]
@.str = private unnamed_addr constant [3 x i8] c"%d\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 ([3 x i8], [3 x i8]* @.str, i32 0, i32 0), i64 2 }, section "__DATA,__cfstring", align 8

; Function Attrs: ssp uwtable
define i32 @main(i32, i8** nocapture readnone) local_unnamed_addr #0 {
  %3 = tail call i8* @objc_autoreleasePoolPush() #2
  notail call void (i8*, ...) @NSLog(i8* bitcast (%struct.__NSConstantString_tag* @_unnamed_cfstring_ to i8*), i32 1)
  tail call void @objc_autoreleasePoolPop(i8* %3)
  ret i32 0
}

declare i8* @objc_autoreleasePoolPush() local_unnamed_addr

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

declare void @objc_autoreleasePoolPop(i8*) local_unnamed_addr

attributes #0 = { ssp uwtable "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-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,+sse,+sse2,+sse3,+sse4.1,+ssse3,+x87" "unsafe-fp-math"="false" "use-soft-float"="false" }
attributes #1 = { "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,+sse,+sse2,+sse3,+sse4.1,+ssse3,+x87" "unsafe-fp-math"="false" "use-soft-float"="false" }
attributes #2 = { nounwind }

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

!0 = !{i32 1, !"Objective-C Version", i32 2}
!1 = !{i32 1, !"Objective-C Image Info Version", i32 0}
!2 = !{i32 1, !"Objective-C Image Info Section", !"__DATA,__objc_imageinfo,regular,no_dead_strip"}
!3 = !{i32 4, !"Objective-C Garbage Collection", i32 0}
!4 = !{i32 1, !"Objective-C Class Properties", i32 64}
!5 = !{i32 1, !"wchar_size", i32 4}
!6 = !{i32 7, !"PIC Level", i32 2}
!7 = !{!"Apple LLVM version 9.1.0 (clang-902.0.39.2)"}

LLVM优化

使用clang -emit-llvm -c main.m -o main.bc命令,会使用LLVM对代码进行优化。

  • 针对全局变量优化、循环优化、尾递归优化等。
  • 在 Xcode 的编译设置里也可以设置优化级别-01,-03,-0s,还可以写些自己的 Pass。
  • Pass 是 LLVM 优化工作的一个节点,一个节点做些事,一起加起来就构成了 LLVM 完整的优化和转化。
  • 如果开启了 bitcode苹果会做进一步的优化,有新的后端架构还是可以用这份优化过的 bitcode 去生成。

生成汇编代码

使用clang -S -fobjc-arc main.m -o main.s会生成相对应的汇编代码

至此,编译阶段完成,将书写代码转换成了机器可以识别的汇编代码

汇编

汇编器是将汇编代码转变成机器可以执行的指令,每一个汇编语句几乎都对应一条机器指令。所以汇编器的汇编过程相对于编译器来讲比较简单,它没有复杂的语法,也没有语义,也不需要做指令优化,只是根据汇编指令和机器指令的对照表一一翻译就可以了。

使用clang -fmodules -c main.m -o main.o生成对应的目标文件

使用Xcode构建的程序会在DerivedData目录中找到这个文件,如下图

链接

链接主要分为静态链接动态链接,编译器阶段的链接为静态链接,相关动态链接的部分,会在下一章App启动中讲解

这一阶段是将上个阶段生成的目标文件和引用的静态库链接起来,最终生成可执行文件,链接器解决了目标文件和库之间的链接。

使用clang main.m生成可执行文件,可以看出可执行文件类型为Mach-O类型,在 MAC OS 和 iOS 平台的可执行文件都是这种类型。

至此,编译过程全部结束,生成了可执行文件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 开关,来开启自动去除无用代码的功能。并且,这个开关是默认开启的。

Mach-O分析

Mach-O其实是Mach Object文件格式的缩写,是mac以及iOS上可执行文件的格式, 类似于windows上的PE格式 (Portable Executable ), linux上的elf格式 (Executable and Linking Format)

Mach-O是OS X中二进制文件的原生可执行格式,是传送代码的首选格式。可执行格式决定了二进制文件中的代码和数据读入内存的顺序。代码和数据的顺序会影响内存使用和分页活动,从而直接影响程序的性能。

Mach-O二进制文件被组织成段。每个部分包含一个或多个部分。段的大小由它所包含的所有部分的字节数来度量,并四舍五入到下一个虚拟内存页边界。因此,一个段总是4096字节或4千字节的倍数,其中4096字节是最小大小。

常见的Mach-O文件

1、目标文件:.o

2、库文件:.a .dylib Framework

3、可执行文件:dyld .dsym

Mach-O文件格式

MachO可以是多架构的二进制文件,称之为「通用二进制文件」

主要架构有armv7,armv7s,arm64,i386,x86_64,其中iPhone中多数使用arm64

通用二进制文件是苹果公司提出的一种程序代码。能同时适用多种架构的二进制文件

  • 同一个程序包中同时为多种架构提供最理想的性能。
  • 因为需要储存多种代码,通用二进制应用程序通常比单一平台二进制的程序要大。
  • 但是由于两种架构有共通的非执行资源,所以并不会达到单一版本的两倍之多。
  • 而且由于执行中只调用一部分代码,运行起来也不需要额外的内存。

Mach-O的文件结构

Header

Header 包含该二进制文件的一般信息 字节顺序、架构类型、加载指令的数量等。 使得可以快速确认一些信息,比如当前文件用于32位还是64位,对应的处理器是什么、文件类型是什么

可使用otool -v -h a.out查看其结构,或者使用MachOView来直接查看

Load Commons

Load commands是一张包含很多内容的表。内容包括区域的位置、符号表、动态符号表等。这一段紧跟Header,加载Mach-O文件时会使用这里的数据来确定内存的分布

LC_LOAD_DYLINKER

LC_LOAD_DYLINKER 该字段标明我们的MachO是被谁加载进去的。一般情况下都是dyld,下一个章节我们会讲dyld是如何对Mach-o进行加载的

LC_LOAD_DYLIB

LC_LOAD_DYLIB 该字段标记了所有动态库的地址,只有在LC_LOAD_DYLIB中有标记,我们MachO外部的动态库(如:Framework)才能被dyld正确的引用,否则dyld不会主动加载

Data

Data 通常是对象文件中最大的部分,包含Segement的具体数据,如静态C字符串,带参数/不带参数的OC方法,带参数/不带参数的C函数。

包含 Load commands 中需要的各个 segment,每个 segment 中又包含多个 section。当运行一个可执行文件时,虚拟内存 (virtual memory) 系统将 segment 映射到进程的地址空间上。

使用xcrun size -x -l -m a.out查看segment内容,或者MachOView

名称 含义
Segment __PAGEZERO 大小为 4GB,规定进程地址空间的前 4GB 被映射为不可读不可写不可执行
Segment __TEXT 包含可执行的代码,以只读和可执行方式映射。
Segment __DATA 包含了将会被更改的数据,以可读写和不可执行方式映射。
Segment __LINKEDIT 包含了方法和变量的元数据,代码签名等信息。

参考