美文网首页
iOS开发-Clang-LLVM下,一个源文件的编译过程

iOS开发-Clang-LLVM下,一个源文件的编译过程

作者: iOS丶lant | 来源:发表于2021-11-30 15:52 被阅读0次

    LLVM是什么?

    LLVM 是编译器工具链技术的一个集合。而其中的 lld 项目,就是内置链接器。

    编译器会对每个文件进行编译,生成 Mach-O(可执行文件);链接器会将项目中的多个Mach-O 文件合并成一个。

    Xcode运行的过程就是执行一些命令脚本,下面的截图是Xcode编译main.m的脚本。

    在bin目录下找到clang命令,在后面加一些参数,比如什么语言,编译到哪些架构上,追加在Xcode设置的配置的参数,最后输出成.o文件。

    LLVM 编译器架构

    编译器分为三部分,编译器前端、通用优化器、编译器后端,中间的优化器是不会变的

    增加一种语言只需要处理好编译器前端就行了

    增加一种架构,只需要添加一种编译器后端的架构处理就可以了

    clang在编译器架构中表示 C、C++、Objective-C的前端,在命令行中也作为一个“黑盒”的Driver,封装了编译管线、前端命令、LLVM命令、Toolchain命令等。

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

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

    OC源文件的编译过程

    使用以下命令,查看OC源文件的编译过程

    clang -ccc-print-phases main.m
    

    0:先找到main.m文件

    1:预处理器,就是把include、import、宏定义给替换掉

    2:编译成IR中间代码

    3:把中间代码给后端,生成汇编代码

    4:汇编生成目标代码

    5:链接静态库、动态库

    6:适合某个架构的代码

    预处理

    使用以下命令,可以查看预处理阶段所做的工作

    clang -E main.m
    

    预处理主要做了以下几件事情:

    1、删除所有的#define,代码中使用宏定义的地方会进行替换

    2、将#include包含的文件插入到文件的位置,这个插入的过程是递归的

    3、删除掉注释符号及注释

    4、添加行号和文件标识,便于调试

    编译

    编译的过程就是把预处理后的文件进行 词法分析、语法分析、语义分析及优化后产生相应的汇编代码

    1、词法分析

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

    使用以下命令来进行词法分析

    clang -Xclang -dump-tokens main.m
    

    以下面这段代码为例:

    第11行的这段源码

    int main(int argc, char * argv[]) {
    

    通过词法分析,会转化为以下的特殊标记

    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>
    char 'char'  [LeadingSpace] Loc=<main.m:11:20>
    star '*'     [LeadingSpace] Loc=<main.m:11:25>
    identifier 'argv'    [LeadingSpace] Loc=<main.m:11:27>
    l_square '['        Loc=<main.m:11:31>
    r_square ']'        Loc=<main.m:11:32>
    r_paren ')'     Loc=<main.m:11:33>
    l_brace '{'  [LeadingSpace] Loc=<main.m:11:35>
    

    2、语法分析

    这一步就是根据词法分析的标记流,解析成一个语法树,在Clang中由Parser和Sema两个模块配合完成

    在这里面每一个节点也都标记了自己在源码中的位置

    验证语法是否正确,比如少一个;报一个错误提示

    根据当前语言的语法,生成语义节点,并将所有的节点组合成抽象语法树

    使用以下命令来进行语法分析

    clang -Xclang -ast-dump -fsyntax-only main.m
    

    会解析成以下的语法树

    -FunctionDecl 0x7ffe251a8ce0 <main.m:11:1, line:20:1> line:11:5 main 'int (int, char **)'
      |-ParmVarDecl 0x7ffe251a8b00 <col:10, col:14> col:14 argc 'int'
      |-ParmVarDecl 0x7ffe251a8bc0 <col:20, col:32> col:27 argv 'char **':'char **'
      `-CompoundStmt 0x7ffe251a9200 <col:35, line:20:1>
        |-ObjCAutoreleasePoolStmt 0x7ffe251a91b8 <line:13:5, line:18:5>
        | `-CompoundStmt 0x7ffe251a9188 <line:13:22, line:18:5>
        |   |-DeclStmt 0x7ffe251a8e30 <line:14:9, col:32>
        |   | `-VarDecl 0x7ffe251a8da8 <col:9, line:9:21> line:14:13 used eight 'int' cinit
        |   |   `-IntegerLiteral 0x7ffe251a8e10 <line:9:21> 'int' 8
        |   |-DeclStmt 0x7ffe251a8ee8 <line:15:9, col:20>
        |   | `-VarDecl 0x7ffe251a8e60 <col:9, col:19> col:13 used six 'int' cinit
        |   |   `-IntegerLiteral 0x7ffe251a8ec8 <col:19> 'int' 6
        |   |-DeclStmt 0x7ffe251a9010 <line:16:9, col:31>
        |   | `-VarDecl 0x7ffe251a8f18 <col:9, col:28> col:13 used rank 'int' cinit
        |   |   `-BinaryOperator 0x7ffe251a8ff0 <col:20, col:28> 'int' '+'
        |   |     |-ImplicitCastExpr 0x7ffe251a8fc0 <col:20> 'int' <LValueToRValue>
        |   |     | `-DeclRefExpr 0x7ffe251a8f80 <col:20> 'int' lvalue Var 0x7ffe251a8da8 'eight' 'int'
        |   |     `-ImplicitCastExpr 0x7ffe251a8fd8 <col:28> 'int' <LValueToRValue>
        |   |       `-DeclRefExpr 0x7ffe251a8fa0 <col:28> 'int' lvalue Var 0x7ffe251a8e60 'six' 'int'
        |   `-CallExpr 0x7ffe251a9128 <line:17:9, col:30> 'void'
        |     |-ImplicitCastExpr 0x7ffe251a9110 <col:9> 'void (*)(id, ...)' <FunctionToPointerDecay>
        |     | `-DeclRefExpr 0x7ffe251a9028 <col:9> 'void (id, ...)' Function 0x7ffe20b20e88 'NSLog' 'void (id, ...)'
        |     |-ImplicitCastExpr 0x7ffe251a9158 <col:15, col:16> 'id':'id' <BitCast>
        |     | `-ObjCStringLiteral 0x7ffe251a9068 <col:15, col:16> 'NSString *'
        |     |   `-StringLiteral 0x7ffe251a9048 <col:16> 'char [8]' lvalue "rank-%d"
        |     `-ImplicitCastExpr 0x7ffe251a9170 <col:26> 'int' <LValueToRValue>
        |       `-DeclRefExpr 0x7ffe251a9088 <col:26> 'int' lvalue Var 0x7ffe251a8f18 'rank' 'int'
        `-ReturnStmt 0x7ffe251a91f0 <line:19:5, col:12>
          `-IntegerLiteral 0x7ffe251a91d0 <col:12> 'int' 0
    

    3、静态分析(通过语法树进行代码静态分析,找出非语法性错误)

    1、错误检查

    如出现方法被调用但是未定义、定义但是未使用的变量

    2、类型检查

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

    4、CodeGen - IR代码生成

    1、CodeGen 负责将语法树从顶至下遍历,翻译成 LLVM IR
    2、LLVM IR是Frontend的输出,也是LLVM Backend的输入,前后端的桥接语言
    3、与Objective-C Runtime 桥接
    与Objective-C Runtime 桥接的应用

    1、在Objective-C中的 Class / Meta Class / Protocol /Category 这些结构体的内存结构就是在这一步生成的,并放在了Mach-O指定的Section中(如 Class: _DATA, _objc _classrefs),这个 DATA段也会存放一些static变量

    2、objct对象发送一个消息最终会编译成什么样子啊,会编译成objc_msgSend调用就发生在这一步,将语法树中的ObjCMessageExpr翻译成相应版本的objc_msgSend,对super关键字的调用翻译成objc_msgSendSuper

    3、根据修饰符strong / weak /copy /atomic 合成@property自动实现的getter / setter、处理@synthesize也是这一步做的

    4、生成block_layout的数据结构、变量的capture(__block / 和 __weak),生成_block_invoke函数都发生在这一步

    5、之前总说ARC是编译器帮我们插入一些内存管理的代码,具体也是在这一步完成的

    ARC: 分析对象的引用关系,将objc_StoreStrong / Objc_StoreWeak等ARC代码的插入

    将ObjCAutotreleasePoolStmt转译成objc_autoreleasePoolPush/Pop

    实现自动调用[super dealloc]

    为每个拥有ivar的Class 合成.cxx_destructor 方法来自动释放类的成员变量,代替MRC时代的 “self.xxx = nil”

    LLVM的中间产物及优化

    使用以下命令,生成LLVM中间产物IR(Intermediate Representation),把这个过程打印出来

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

    使用以下命令,会使用LLVM对代码进行优化。

    //针对全局变量优化、循环优化、尾递归优化等。
    //在 Xcode 的编译设置里也可以设置优化级别-01,-03,-0s,还可以写些自己的 Pass。
    clang -emit-llvm -c main.m -o main.bc
    

    生成汇编代码

    使用以下命令,生成相对应的汇编代码。

    clang -S -fobjc-arc main.m -o main.s
    

    至此,编译阶段完成,将书写代码转换成了机器可以识别的汇编代码,汇编器是将汇编代码转变成机器可以执行的指令,每一个汇编语句几乎都对应一条机器指令。根据汇编指令和机器指令的对照表一一翻译就可以了。

    使用以下命令,生成对应的目标文件。
    clang -fmodules -c main.m -o main.o
    
    后来的Xcode新建的工程里并没有pch文件,为什么呢?

    pch文件就是把UIKit、Foundation这些库用pch文件import一下,这样就不用在每个源文件中去解析这么多东西了,现在iOS这边乱搞把一些全局的变量,自己模块的一些东西都放在里面。

    Xcode里面出了一个modules的概念,各个setting里面也是打开的,默认把库打成一个modules的形式,尤其是UIKit、Foundation这些库全部都是modules,好处就是我加这个参数(fmodules)以后它就会自动把#import变成@import,现在的编译就会比最早的那种连pch都没有的快很多,因为它的出现pch就不会默认出现了

    $clang -E -fmodules main.m //加入fmodules参数生成可执行文件
    

    链接

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

    编译时链接器做了什么?

    1、Mach-O里面主要是代码和数据,代码是函数的定义,数据是全局变量的定义,不管是代码还是数据都是通过符号关联起来的。

    2、Mach-O里面的代码,要操作的变量和函数要绑定到各自的地址上,链接器的作用就是完成变量和函数的符号和其地址的绑定。

    为什么要做符号绑定?

    1、如果地址和符号不做绑定的话,要让机器知道你在操作什么地址,就需要写代码的时候设置好内存地址。

    2、可读性差,修改代码后要重新对地址进行维护

    3、需要针对不同平台写多份代码,相当于直接写汇编

    为什么还要把项目中的多个Mach-O合并成一个?

    1、多个文件之间的变量和接口是相互依赖的,就需要链接器把项目中多个Mach-O文件符号和地址绑定起来。

    2、不绑定的话单个文件生成的Mach-O就是无法运行的,运行时遇到调用其他文件的函数实现时,就会找不到函数地址。

    3、链接多个目标文件就会创建一个符号表,记录所有已定义和未定义的符号,如果出现相同符号的情况,就会出现“ld: dumplicate symbols”的错误信息,如果在目标文件中没有找到符号,就会提示“Undefined symbols”的错误信息。

    链接器对代码主要做了哪几件事?

    1、去代码文件中查找没有定义的变量

    2、将所有符号定义和引用地址收集起来,并放到全局符号表中

    3、计算合并后的长度及位置,生成同类型的段进行合并,建立绑定

    4、对项目中不同文件里的变量进行地址重定位

    链接器如何去除无用的函数,保证Mach-O的大小?

    链接器在整理函数的调用关系时,会以main函数为源头跟随每个引用并将其标记为live,跟随完成后那些未被标记为live的就是无用函数。

    总结:一个源文件的编译过程

    代码实践

    #import <Foundation/Foundation.h>
    int main() {
        NSLog(@"hello world!");
        return 0;
    }
    
    1、生成Mach-O可执行文件
    clang -fmodules main.m -o main
    
    2、生成抽象语法树
    clang -fmodules -fsyntax-only -Xclang -ast-dump main.m
    

    3、生成汇编代码

    clang -S main.m -o main.s
    

    装载与链接

    一个App从可执行文件到真正启动运行代码,基本需要经过装载和动态库链接两个步骤。

    程序运行起来会拥有独立的虚拟地址空间,在操作系统上会同时运行多个进程,彼此之间的虚拟地址空间是隔离的。

    装载就是把可执行文件映射到虚拟内存中的过程,由于内存资源稀缺,只将程序最常用的部分驻留在内存里,不太常用的数据放在磁盘里,这也是动态装载的过程。

    装载的过程就是进程建立的过程,操作系统主要做了3件事:

    1、创建一个独立的虚拟地址

    2、读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系

    3、将CPU的寄存区设置成可执行文件的入口地址,启动运行

    静态库

    静态库是编译时链接的库,需要链接进你的Mach-O文件里,如果需要更新就重新编译一次,无法动态的加载和更新。

    动态库

    动态库是运行时链接的库,使用dyld就可以实现动态加载,iOS中的系统库都是动态链接的。

    共享缓存

    Mach-O是编译后的产物,而动态库在运行时才会被链接,所有Mach-O中并没有动态库的符号定义。

    Mach-O中动态库中的符号是未定义的,但他们的名字和对应的库的路径会被记录下来。

    运行时dlopen 和 dlsym 导入动态库时,先根据记录的库路径找到对应的库,再通过记录的名字符号找到绑定的地址。

    优点:

    代码共用、易于维护、减少可执行文件的体积

    相关文章

      网友评论

          本文标题:iOS开发-Clang-LLVM下,一个源文件的编译过程

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