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

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

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

编译的大致步骤以下:git

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

iOS编译器

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

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

总结来讲:windows

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

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

LLVM会执行上述的整个编译流程,大致流程以下:bash

  • 你写好代码后,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;
}
复制代码

预处理主要处理规则以下:app

  • 删除全部#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文件

一、目标文件:.o

二、库文件:.a .dylib Framework

三、可执行文件: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 包含了方法和变量的元数据,代码签名等信息。

参考

相关文章
相关标签/搜索