美文网首页
iOS的编译原理和应用

iOS的编译原理和应用

作者: faroe000 | 来源:发表于2020-05-19 01:37 被阅读0次

    iOS的编译原理和应用

    什么是编译和编译器

    在一般的编程过程中,都要先编译再执行。所谓编译就是把C语言等编程语言编写的文件(源文件)转换成机器语言(原生代码)编写的文件。

    实现这个过程的程序叫做编译器。

    大多数编译器由两部分组成:前端和后端。

    · 前端负责词法分析,语法分析,生成中间代码;

    · 后端以中间代码作为输入,进行行架构无关的代码优化,接着针对不同架构生成不同的机器码。

    LLVM简介

    · LLVM项目是模块化、可重用的编译器以及工具链技术的集合。

    · 美国计算机协会(ACM)将其 2012 年软件系统奖颁给了LLVM,之前曾获得此奖项的软件和技术包括:Java、Apache、Mosaic、the World Wide Web、SmallTalk、UNIX、Eclipse等等。

    ·  LLVM项目的发展起源于2000年伊利诺伊大学厄巴纳-香槟分校维克拉姆·艾夫(Vikram Adve)与克里斯·拉特纳(Chris Lattner)的研究,他们想要为所有静态及动态语言创造出动态的编译技术。LLVM是以BSD授权来发展的开源软件。2005年,苹果电脑雇用了克里斯·拉特纳及他的团队为苹果电脑开发应用程序系统,LLVM为现今Mac OS X及iOS开发工具的一部分。

    ·  LLVM的命名最早源自于底层虚拟机(Low Level Virtual Machine)的首字母缩写,由于这个项目的范围并不局限于创建一个虚拟机,这个缩写导致了广泛的疑惑。官方描述如下:The name “LLVM” itself is not an acronym;it is the full name of the project。LLVM这个名称并不是首字母缩略词,它是项目的全名。

    ·  LLVM开始成长之后,成为众多编译工具及低级工具技术的统称,使得这个名字变得更不贴切,开发者因而决定放弃这个缩写的意涵,现今LLVM已单纯成为一个品牌,适用于LLVM下的所有项目,包含LLVM中介码(LLVM IR)、LLVM除错工具、LLVM C++标准库等。

    ·  目前NDK/Xcode均采用LLVM作为默认的编译器。

    传统编译器架构

    传统的静态编译器最流行的就是经典的三段式设计,其主要组件是前端(Frontend)、优化器(Optimiser)、和后端(Backend)。如下图所示:

    ·  前端(Fontend): 主要对源代码进行词法分析、语法分析、语义分析并生成具有层级关系的抽象语语法树(AST),最后生成中间代码(IR)

    ·  优化器(Optimizer):优化器负责执行各种各样的转换,以尝试改进代码的运行时间,例如消除冗余代码,这个过程通常是与前后端无关的。

    ·  后端(Backend):主要是根据目标指令集生成机器代码,还可以根据所支持的体系结构特点,来生成适应该架构的优质代码。编译器后端常见的部分包括指令选择、寄存器分配和指令调度。

    LLVM编译器架构

    LLVM的不同之处在于,可以作为多种前端编译器的优化器,并且可以针对多种CPU架构生成对应的机器代码。其优势就是优化器的功能可重用,适用于多种编程语言和多种CPU架构平台。如下图所示:

    [if !vml]

    [endif]

    这种架构设计的优势:

    [if !supportLists]·      [endif]可重用:由于前后端是分离的,当需要移植一个新语言源时,只需要实现一个新的前端,而现有的优化器和后端可以直接重用。

    [if !supportLists]·      [endif]模块化:不同于其他编译器(如:GCC)的整体式设计,LLVM将各个阶段的编译技术模块化,尤其是语言无关的通用优化器,可支持多种语言输入,可输出多种架构的机器代码。

    [if !supportLists]·      [endif]丰富的开发者资源:这种设计意味着它支持不止一种源语言和目标平台(如:x86、ARM、MIPS),会吸引更多的开发人员参与到该项目中,就会有更多高质量的代码产生,这自然会对编译器带来更多的增强和改进。

    [if !supportLists]·      [endif]有利于分工:实现前端所需的技能与优化器、后端所需的技能不同,将它们分开可以使“前端人员”更容易地增强和维护他们的编译器部分。”后端人员“可以专注于中间代码的优化和目标平台机器代码的生成。

    前端将各种类型的源代码编译为中间代码,也就是bitcode,在LLVM体系内,不同的语言有不同的编译器前端,常见的如clang负责 c/c++/oc的编译,flang负责fortran的编译,swiftc负责swift的编译等等。

    不同的前后端使用统一的中间代码LLVM Intermediate Representation(LLVM

    IR)。

    LLVM体系中,不同语言源代码将会被转化为统一的bitcode格式,三个模块相互独立,可以充分复用。比如,如果开发一门新的语言,只要制造一个该语言的前端,将源码编译为bitcode,优化和后端不用管。同理,如果新的芯片架构问世,只需基于LLVM重新编写一套目标平台的后端即可。

    后端,也叫CodeGenerator,负责把优化后的bitcode编译为指定目标架构的机器码,比如 X86Backend负责把bitcode编译为x86指令集的机器码。

    优化阶段是一个通用的阶段,针对的是统一的LLVM IR,无论是新的编程语言,还是支持新的硬件设备,都不需要对优化阶段做修改,具体是对bitcode进行各种类型的优化,将bitcode代码进行一些逻辑的转换,使得代码效率更高,体积更小,比如DeadStrip/SimplifyCFG。

    GCC相比之下,前后端耦合在了一起。所以,GCC支持一门新的语言,或是为了支持一个新的平台,就变得异常困难。

    Clang

    Clang是LLVM编译工具集中的一个重要成员,由C++编写,是C、C++、Objective-C/C++ 的编译前端。

    Clang的开发目标是提供一个可以替代GCC的前端编译器。与GCC相比,Clang是一个重新设计的编译器前端,具有一系列优点,例如模块化,代码简单易懂,占用内存小以及容易扩展和重用等。由于 Clang 在设计上的优异性,使得 Clang 非常适合用于设计源代码级别的分析和转化工具。Clang 也已经被应用到一些重要的开发领域,如 Static Analysis 是一个基于 Clang 的静态代码分析工具。

    由于 GNU 编译器套装 (GCC) 系统庞大,而且 Apple 大量使用的 Objective-C 在 GCC 中优先级较低,同时 GCC 作为一个纯粹的编译系统,与 IDE 配合并不优秀,Apple 决定从零开始写 C family 的前端,也就是基于 LLVM 的 Clang 了。Clang 由 Apple 公司开发,源代码授权使用 BSD 的开源授权。

    Clang 的特性

    相比于 GCC,Clang 具有如下优点:

    [if !supportLists]·      [endif]编译速度快:在特定平台上,Clang 的编译速度显著的快过 GCC。

    [if !supportLists]·      [endif]占用内存小:Clang 生成的 AST 所占用的内存是 GCC 的五分之一左右。

    [if !supportLists]·      [endif]模块化设计:Clang 采用基于库的模块化设计,易于 IDE 集成及其他用途的重用。

    [if !supportLists]·      [endif]诊断信息可读性强:在编译过程中,Clang 创建并保留了大量详细的元数据 (metadata),有利于调试和错误报告,例如Xcode中对错误精确的标红提示,以及给出快捷修复建议等。

    [if !supportLists]·      [endif]设计清晰简单:容易理解,易于扩展增强,与代码基础古老的 GCC 相比,学习曲线平缓。

    当前 Clang 还处在不断完善过程中,相比于 GCC, Clang 在以下方面还需要加强:

    [if !supportLists]·      [endif]支持更多语言:GCC 除了支持 C/C++/Objective-C, 还支持 Fortran/Pascal/Java/Ada/Go 和其他语言。Clang 目前支持的语言有C/C++/Objective-C/Objective-C++。

    [if !supportLists]·      [endif]加强对 C++ 的支持:Clang 对 C++ 的支持依然落后于 GCC,Clang 还需要加强对 C++ 提供全方位支持。

    [if !supportLists]·      [endif]支持更多平台:GCC 流行的时间比较长,已经被广泛使用,对各种平台的支持也很完备。Clang 目前支持的平台有 Linux/Windows/Mac OS。

    Clang编译过程分析

    [if !vml]

    [endif]

    大致看来, Clang可以分为一下几个步骤: 预处理 -> 词法分析 -> 语法分析 -> 静态分析 -> 生成中间代码和优化 -> 汇编 -> 链接。

    [if !supportLists]1.    [endif]预处理(preprocessor)

    预处理会进行如下操作:

    [if !supportLists]·      [endif]头文件引入,递归将头文件引用替换为头文件中的实际内容,所以尽量减少头文件中的#import,使用@class替代,把#import放到.m文件中

    [if !supportLists]·      [endif]宏替换,在源码中使用的宏定义会被替换为对应#define的内容,不要在需要预处理的代码中加入太多的内联代码逻辑

    [if !supportLists]·      [endif]注释处理,在预处理的时候, 注释被删除

    [if !supportLists]·      [endif]条件编译,(#if,#else,#endif)

    [if !supportLists]2.    [endif]词法分析(lexical anaysis)

    这一步把源文件中的代码转化为特殊的标记流。词法分析器读入源文件的字符流,将他们组织称有意义的词素(lexeme)序列,对于每个词素,此法分析器产生词法单元(token)作为输出。

    源码被分割成一个一个的字符和单词,在行尾Loc中都标记出了源码所在的对应源文件和具体行数,方便在报错时定位问题。类似于下面:

    [if !vml]

    [endif]

    [if !supportLists]3.    [endif]语法分析(semantic analysis)

    词法分析的Token流会被解析成一颗抽象语法树(abstract syntax tree - AST)。在这里面每一节点也都标记了其在源码中的位置。

    有了抽象语法树,clang就可以对这个树进行分析,找出代码中的错误。比如类型不匹配,亦或Objective C中向target发送了一个未实现的消息。

    AST是开发者编写clang插件主要交互的数据结构,clang也提供很多API去读取AST。

    [if !supportLists]4.    [endif]静态分析(CodeGen)

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

    常见的操作有:

    [if !supportLists]·      [endif]当在代码中使用 ARC 时,编译器在编译期间,会做许多的类型检查. 最常见的是检查程序是否发送正确的消息给正确的对象,是否在正确的值上调用了正常函数。如果你给一个单纯的 NSObject* 对象发送了一个 hello 消息,那么 clang 就会报错,同样,给属性设置一个与其自身类型不相符的对象,编译器会给出一个可能使用不正确的警告。

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

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

    [if !supportLists]·      [endif]检查是否有定义了,但是从未使用过的变量

    [if !supportLists]·      [endif]检查在 你的初始化方法中中调用 self 之前, 是否已经调用 [self initWith…] 或 [super init] 了

    此处遍历语法树,最终生成LLVM IR代码。LLVM IR是前端的输出,后端的输入。Objective C代码在这一步会进行runtime的桥接:property合成,ARC处理等。

    LLVM会去做些优化工作, 在 Xcode 的编译设置里也可以设置优化级别-01,-03,-0s,还可以写些自己的 Pass。

    如果开启了 Bitcode 苹果会做进一步的优化。虽然Bitcode仅仅只是一个中间码不能在任何平台上运行, 但是它可以转化为任何被支持的CPU架构,包括现在还没被发明的CPU架构。iOS Apps中Enable Bitcode 为可选项,WatchOS和tvOS, Bitcode必须开启。如果你的App支持Bitcode, App Bundle(项目中所有的target)中的所有的 Apps 和 frameworks 都需要支持Bitcode。

    [if !supportLists]5.    [endif]生成汇编指令

    LLVM对IR进行优化后,会对代码进行编译优化例如针对全局变量优化、循环优化、尾递归优化等, 然后会针对不同架构生成不同的目标代码,最后以汇编代码的格式输出。

    [if !supportLists]6.    [endif]汇编

    在这一阶段,汇编器将上一步生成的可读的汇编代码转化为机器代码。最终产物就是以 .o 结尾的目标文件。使用Xcode构建的程序会在DerivedData目录中找到这个文件

    Tips:什么是符号(Symbols)?符号就是指向一段代码或者数据的名称。还有一种叫做WeakSymols,也就是并不一定会存在的符号,需要在运行时决定。比如iOS 12特有的API,在iOS11上就没有。

    [if !supportLists]7.    [endif]链接

    目标文件(.o)和引用的库(dylib,a,tbd)链接起来,最终生成可执行文件(mach-o), 链接器解决了目标文件和库之间的链接。

    这时可执行文件的符号表信息已经有了,会在运行时动态绑定。

    [if !supportLists]8.    [endif]Mach-O文件

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

    Mach-O是记录编译后的可执行文件,对象代码,共享库,动态加载代码和内存转储的文件格式。不同于 xml 这样的文件,它只是二进制字节流,里面有不同的包含元信息的数据块,比如字节顺序,cpu 类型,块大小等。文件内容是不可以修改的,因为在 .app 目录中有个 _CodeSignature 的目录,里面包含了程序代码的签名,这个签名的作用就是保证签名后 .app 里的文件,包括资源文件,Mach-O 文件都不能够更改。

    Mach-O文件包含三个区域:

    [if !supportLists]·      [endif]Mach-O Header: 包含字节顺序,magic,cpu 类型,加载指令的数量等。

    [if !supportLists]·      [endif]Load Commands: 包含很多内容的表,包括区域的位置,符号表,动态符号表等。每个加载指令包含一个元信息,比如指令类型,名称,在二进制中的位置等。

    [if !supportLists]·      [endif]Data: 最大的部分,包含了代码,数据,比如符号表,动态符号表等。

    [if !supportLists]9.    [endif]dyld动态链接

    生成可执行文件后就是在启动时进行动态链接了,进行符号和地址的绑定。首先会加载所依赖的 dylibs,修正地址偏移,因为 iOS 会用 ASLR 来做地址偏移避免攻击,确定 Non-Lazy Pointer 地址进行符号地址绑定,加载所有类,最后执行 load 方法和 clang attribute 的 constructor 修饰函数。

    [if !supportLists]10.[endif]dSYM

    在每次编译后都会生成一个 dSYM 文件,程序在执行中通过地址来调用方法函数,而 dSYM 文件里存储了函数地址映射,这样调用栈里的地址可以通过 dSYM 这个映射表能够获得具体函数的位置。一般都会用来处理 crash 时获取到的调用栈 .crash 文件将其符号化。

    当release的版本 crash的时候,会有一个日志文件,包含出错的内存地址,使用symbolicatecrash工具能够把日志和dSYM文件转换成可以阅读的log信息,也就是将内存地址,转换成程序里的函数或变量和所属于的文件名。

    从上述分析可以看出在LLVM架构下的编译器是如何一步一步将我们写源代码编译成机器可执行的代码的。通常,狭义的LLVM仅包括优化器和编译后端,也就是只负责IR的优化和目标代码的生成。然而,广义上的LLVM则是指所有的三个阶段、完整的编译工具链,以及一整套的SDK编译器开发技术体系,我们通常称之为:LLVM集合。

    Xcode编译

    以上说的是Clang的编译过程,那么在Xcode里会经过哪些过程呢?

    我们可以简单新建一个单页面工程,Build后在Report Navigation视图中查看详细日志:

    [if !vml]

    [endif]

    详细的步骤如下:

    [if !supportLists]·      [endif]编译信息写入辅助文件,创建编译后的文件架构(name.app)

    [if !supportLists]·      [endif]把Entitlements.plist写入到DerivedData里,处理打包的时候需要的信息(比如application-identifier,iCloud,远程通知,Siri等)。

    [if !supportLists]·      [endif]创建一些辅助文件,比如各种.hmap (headermap是帮助编译器找到头文件的辅助文件:存储这头文件到其物理路径的映射关系)

    [if !supportLists]·      [endif]执行CocoaPods的编译前脚本:检查Manifest.lock文件。

    [if !supportLists]·      [endif]编译.m文件,生成.o文件。.o文件是编译后的产物

    [if !supportLists]·      [endif]链接动态库,.o文件,生成一个mach-o格式的可执行文件。

    [if !supportLists]·      [endif]编译assets,编译storyboard,链接storyboard

    [if !supportLists]·      [endif]拷贝动态库Logger.framework,并且对其签名

    [if !supportLists]·      [endif]执行CocoaPods编译后脚本:拷贝CocoaPods Target生成的Framework

    [if !supportLists]·      [endif]对Demo.App签名,并验证(validate)

    [if !supportLists]·      [endif]生成Product.app

    编译顺序

    编译的时候有很多的Task(任务)要去执行,XCode如何决定Task的执行顺序呢?

    答案是:依赖关系

    XCode编译的时候会尽可能的利用多核性能,多Target并发编译。

    那么,XCode又从哪里得到了这些依赖关系呢?

    [if !supportLists]·      [endif]Target

    Dependencies - 显式声明的依赖关系

    [if !supportLists]·      [endif]Linked

    Frameworks and Libraries - 隐式声明的依赖关系

    [if !supportLists]·      [endif]Build

    Phase - 定义了编译一个Target的每一步

    增量编译

    日常开发中,一次完整的编译可能要几分钟,甚至几十分钟,而增量编译只需要不到1分钟,为什么增量编译会这么快呢?

    因为XCode会对每一个Task生成一个哈希值,只有哈希值改变的时候才会重新编译。

    头文件

    头文件对于编译器来说就是一个promise.头文件里的声明, 编译会认为有对应实现, 在链接的时候再解决具体实现的位置. 当只有声明,没有实现的时候,链接器就会报错。

    Objective C的方法要到运行时才会报错,因为Objective C是一门动态语言,编译器无法确定对应的方法名(SEL)在运行时到底有没有实现(IMP).

    日常开发中,两种常见的头文件引入方式:

    [if !vml]

    [endif]

    这里有个文件类型叫做heademap,headermap是帮助编译器找到头文件的辅助文件:存储这头文件到其物理路径的映射关系。

    [if !supportLists]·      [endif]clang发现#import “TestView.h”的时候,先在headermap(Demo-generated-files.hmap,Demo-project-headers.hmap)里查找,如果headermap文件找不到,接着在own target的framework里找

    [if !supportLists]·      [endif]系统的头文件查找的时候也是优先headermap,headermap查找不到会查找own target framework,最后查找SDK目录

    [if !supportLists]·      [endif]以#import <Foundation/Foundation.h>为例,在SDK目录查找时首先查找framework是否存在,如果framework存在,再在headers目录里查找头文件是否存在

    Clang Module

    传统的#include/#import都是文本语义:预处理器在处理的时候会把这一行替换成对应头文件的文本,这种简单粗暴替换是有很多问题的:

    [if !supportLists]·      [endif]大量的预处理消耗。假如有N个头文件,每个头文件又#include了M个头文件,那么整个预处理的消耗是N*M。

    [if !supportLists]·      [endif]文件导入后,宏定义容易出现问题。因为是文本导入,并且按照include依次替换,当一个头文件定义了#define std hello_world,而第另一个头文件刚好又是C++标准库,那么include顺序不同,可能会导致所有的std都会被替换。

    [if !supportLists]·      [endif]边界不明显。拿到一组.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。

    [if !vml]

    [endif]

    modulemap文件描述了一组头文件如何转换为一个module。

    [if !vml]

    [endif]

    Clang的应用

    Clang作为编译前端,对源代码进行词法分析和语法分析,并将分析结果转换为抽象语法树(AST),最后生成IR中间代码提交给LLVM做下一步的优化。下面我们将从应用的角度讲一下,Clang是如何进行这些分析的。

    首先,我们在终端创建一个main.m 文件,示例代码如下:

    [if !vml]

    [endif]

    然后,Clang会对代码进行词法分析,将代码切分成Token,可通过如下命令来查看所有的Token:

    [if !vml]

    [endif]

    输入的Token序列打印如下:

    [if !vml]

    [endif]

    这个命令的作用是,显示每个 Token 的类型、值,以及位置。包括:关键字(比如:if、else、for…)、标识符(变量名)、字面量(值、数字、字符串)、特殊字符(加减乘除符号等)。

    接下来,会进行语法分析,将输出的Token先按照语法组合成语义,生成节点,然后将这些节点按照层级关系构成抽象语法树(AST)。

    在终端中执行如下命令即可看到main.m 文件源码的语法树:

    [if !vml]

    [endif]

    输出的AST代码如下所示:

    [if !vml]

    [endif]

    其中TranslationUnitDecl是根节点,表示一个编译单元;Decl表示一个声明;Expr表示的是表达式;Literal表示字面量,是一个特殊的Expr;Stmt表示语句。

    Optimiser(优化器)

    通过上述Clang的介绍,以及Clang是如何将main.m文件中的源代码一步步转换为AST的,最后Clang会将AST 转换为中间代码IR,交由优化器(Optimiser)来做代码优化。

    [if !vml]

    [endif]

    从上图可以看出,在LLVM IR优化阶段,优化器被设计为由若干个Pass组成的集合,每个优化模块(Pass)都能读入IR,完成一些任务后,输出优化后的IR。

    常见优化模块的例子是内联优化,它会将函数体替换为调用点(call sites),还可以将表达式重新组合(expression reassociation)、移动循环不变代码(loop-invariant code

    motion)等等。根据优化级别的不同,可以调用不同的优化模块:例如,Clang编译器使用-O0(无优化状态)参数进行编译时不调用pass,在使用-O3时将会调用67个pass来进行IR的优化(从LLVM 2.8开始)。

    由于优化器的模块化设计,Pass也可以进行自主开发,实现对LLVM 优化器的改进和增强。

    相关文章

      网友评论

          本文标题:iOS的编译原理和应用

          本文链接:https://www.haomeiwen.com/subject/wxamohtx.html