编译器
把一种编程语言(原始语言)转换为另一种编程语言(目标语言)的程序叫做编译器
大多数编译器由两部分组成:前端和后端。
- 前端负责词法分析,语法分析,生成中间代码;
- 后端以中间代码作为输入,进行行架构无关的代码优化,接着针对不同架构生成不同的机器码。
Objective C/C/C++使用的编译器前端是clang,swift是swift,后端都是LLVM。
llvm介绍
LLVM是什么,是low level virtual machine的简称,其实是一个编译器框架。LLVM 的优点主要得益于它的三层式架构 – 第一层支持多种语言作为输入(例如 C, ObjectiveC, C++ 和 Haskell),第二层是一个共享式的优化器(对 LLVM IR 做优化处理),第三层是许多不同的目标平台(例如 Intel, ARM 和 PowerPC)。编译器前端主要进行语法分析,语义分析,生成中间代码。编译器后端会进行机器无关的代码优化,生成机器语言,并且进行机器相关的代码优化,根据不同的系统架构生成不同的机器码。
现在,Xcode 的默认编译器是 clang。本文中我们提到的编译器都表示 clang。clang 的功能是首先对 Objective-C 代码做分析检查,然后将其转换为低级的类汇编代码:LLVM Intermediate Representation(LLVM 中间表达码)。接着 LLVM 会执行相关指令将 LLVM IR 编译成目标平台上的本地字节码,这个过程的完成方式可以是即时编译 (Just-in-time),或在编译的时候完成。而Clang 是一个C、C++、Objective-C和Objective-C++编程语言的编译器前端。它采用了底层虚拟机(LLVM)作为其后端。它的目标是提供一个GNU编译器套装(GCC)的替代品。Clang项目包括Clang前端和Clang静态分析器等。
下图是iOS编译过程:
使用llvm命令
一、使用xcode自带
使用xcrun来调用:
1 | xcrun clang -v |
二、使用HomeBrew安装
1 | # 1.使用以下命令安装llvm |
安装后可以使用 brew info llvm
检查信息。brew安装llvm后,不会直接添加命令的快捷引用,需要自己手动来完成。
1 | # 查看llvm的安装路径 |
这样之后就可以直接调用自己安装的llvm了:
1 | clang -v |
编译器处理过程
在编译一个源文件时,编译器的处理过程分为几个阶段。要想查看编译 hello.m 源文件需要几个不同的阶段,上文中安装llvm后,我们可以让通过 clang 命令观察:
1 | sunTongShengdeMacBook-Pro:asdasd suntongsheng$ clang -ccc-print-phases ViewController.m |
可以看到clang将其分为 input、preprocessor 、compiler、backend、assembler、linker、bind-arch几个阶段。
- 预处理阶段:符号化、宏定义展开头文件展开
- 语法和语义分析阶段:将符号化后的内容转化为一棵解析树、解析树做语义分析 输出一棵抽象语法树
- 生成代码和优化阶段:将 AST 转换为更低级的中间码 (LLVM IR)、对生成的中间码做优化、生成特定目标代码、输出汇编代码
- 汇编器阶段:将汇编代码转换为目标对象文件。
- 链接器:将多个目标对象文件合并为一个可执行文件 (或者一个动态库)
preprocessor 预处理
每当编源译文件的时候,编译器首先做的是一些预处理工作。具体表现为import头文件替换、macro宏展开、其他预编译指令,`#这个符号是编译器预处理的标志。
一. 对头文件的处理:
例如,如果在源文件中出现下述代码:
1 |
预处理器对这行代码的处理是用 Foundation.h 文件中的内容去替换这行代码,如果 Foundation.h 中也使用了类似的宏引入,则会按照同样的处理方式用各个宏对应的真正代码进行逐级替代。这也就是为什么人们主张头文件最好尽量少的去引入其他的类或库,因为引入的东西越多,编译器需要做的处理就越多
示例:假设我们写了一个简单的 C 程序 hello.c:
1 |
|
然后给上面的代码执行以下预处理命令
1 | # -E Only run the preprocessor |
打开 hello.o ,发现有542行。但是如果在上述代码上加上 #import <Foundation/Foundation.h>
,hello.o 的行数暴增到9万多行。(当然对于这种情况引入了模块 - modules功能)
打开模块功能再试试:
1 | #-fmodules 允许modules的语言特性 |
二、对于宏的处理
假设一段这样的代码:
1 | // hello.c |
用 clang -E hello.c
进行宏展开的预处理结果是如下所示:
1 | int main() { |
i++
被替换到了a
中,而不是预想的 i++的值。
compiler 词法分析解析标记
一、词法分析-Lexical Analysis
在compiler阶段,首先代码文本都会从 string 转化成特殊的标记流。将代码切成一个个 token,比如大小括号,等于号还有字符串等。是计算机科学中将字符序列转换为标记序列的过程。 例如,下面这段程序:
1 | // hello.c |
利用 clang 命令 clang -Xclang -dump-tokens hello.m 来将上面代码的标记流导出:
1 | #clang -Xclang -dump-tokens hello.c |
每一个标记都包含了对应的源码内容和其在源码中的位置,如果编译过程中遇到什么问题,clang 能够在源码中指出出错的具体位置。
二、语法分析 - Semantic Analysis
之后上面的标记流会解析生成抽象语法树,我们可以使用 clang -Xclang -ast-dump -fsyntax-only hello.c
来展现解析这个过程
1 | # clang -Xclang -ast-dump -fsyntax-only hello.c |
在抽象语法树中的每个节点都标注了其对应源码中的位置,同样的,如果产生了什么问题,clang 可以定位到问题所在处的源码位置。
三、静态分析 - Static Analyzer
一旦编译器把源码生成了抽象语法树,编译器可以对这棵树做分析处理,以找出代码中的错误,比如类型检查:即检查程序中是否有类型错误。例如:如果代码中给某个对象发送了一个消息,编译器会检查这个对象是否实现了这个消息(函数、方法)。此外,clang 对整个程序还做了其它更高级的一些分析,以确保程序没有错误。
1 | #命令行执行 通过clang -cc1 -analyzer-checker-help可以列出能调用的 checker,但这些checker并不是所有都是默认开启的 |
使用scan-build可以从命令行运行分析器,类似如:
1 | scan-build xcodebuild -project asdasd.xcodeproj |
关于静态分析更多可以查看 :Clang 静态分析器
生成代码和优化阶段
一、生成 LLVM 代码
clang 完成代码的标记,解析和分析后,接着就会生成 LLVM 代码。下面继续看看hello.c:
1 |
|
要把这段代码编译成 LLVM 字节码(绝大多数情况下是二进制码格式),我们可以执行下面的命令:
clang -emit-llvm hello.c -c -o hello.bc
接着用另一个命令来查看刚刚生成的二进制文件:
llvm-dis < hello.bc | less
部分输出如下:
1 | ; ModuleID = '<stdin>' |
二、优化 LLVM 代码
LLVM 会去做些优化工作,在 Xcode 的编译设置里也可以设置优化级别-01,-03,-0s,还可以写些自己的 Pass,Pass就是LLVM系统转化和优化的工作的一个节点,每个节点做一些工作,这些工作加起来就构成了LLVM整个系统的优化和转化。官方有比较完整的 Pass 教程: Writing an LLVM Pass — LLVM 5 documentation 。如果开启了 bitcode 苹果会做进一步的优化
三、输出汇编代码
我们可以使用下面的命令让clang输出汇编代码:
1 | #输入 |
1 | #输出 |
具体的代码解读,有兴趣的同学可以到苹果的 OS X Assembler Reference 了解详细。
汇编器
汇编器将可读的汇编代码转换为机器代码。它会创建一个目标对象文件,一般简称为 对象文件。这些文件以 .o 结尾。如果用 Xcode 构建应用程序,可以在工程的 derived data 目录中,Objects-normal 文件夹下找到这些文件。
1 | #例如 生成 main.o文件 |
链接器
链接器解决了目标文件和库之间的链接。例如上面汇编代码中 callq _printf
, printf()
是 libc 库中的一个函数。无论怎样,最后的可执行文件需要能需要知道 printf() 在内存中的具体位置:例如,_printf 的地址符号是什么。链接器会读取所有的目标文件 (此处只有一个) 和库 (此处是 libc),并解决所有未知符号 (此处是 _printf) 的问题。然后将它们编码进最后的可执行文件中 (可以在 libc 中找到符号 _printf),接着链接器会输出可以运行的执行文件。
1 | #生成 hello.out 的 Mach-O 二进制文件 |
关于Mach-O文件格式有兴趣的同学可以通过apple文档来了解。
这里说 hello.out 是可执行文件,可以通过file
来判断:
1 | file hello.out |
编译多个文件
之前上面我们的实验都是使用单个hello.c文件。现在我们通过多个文件情况 了解下汇编器和链接器 ,如下三个文件:
1 | //File1.h: |
1 | //File1.m: |
1 | //File2.m: |
上面有三个文件,需要我们编译多个文件。我们需要让clang对输入每个文件生成对应的目标文件:
1 | # clang -c Only run preprocess, compile, and assemble steps |
这里我们加了-c
并没有编译头文件,现在我们来完成链接器
这一步,将两个 .o 文件与Foundation库链接起来:
1 | # -Wl,<arg> Pass the comma separated arguments in <arg> to the linker |
现在我们可以运行 a.out
了。
1 | ./a.out |
符号表和链接
File1 和 File2 都使用了 Foundation framework。 File2.o 目标文件使用了它的 autorelease pool,并间接的使用了 libobjc.dylib 中的 Objective-C 运行时。它需要运行时函数来进行消息的调用。所有的这些关联的东西都被形象的称之为符号。
每个函数、全局变量和类等都是通过符号的形式来定义和使用的。当我们将目标文件链接为一个可执行文件时,链接器在目标文件和动态库之间对符号做了解析处理。
可执行文件和目标文件有一个符号表,这个符号表规定了它们的符号。如果我们用 nm 工具观察一下 File2.o 目标文件,可以看到如下内容:
1 | #nm : llvm symbol table dumper |
上面就是那个目标文件的所有符号。_OBJC_CLASS_$_Foo 是 Foo Objective-C 类的符号。该符号是 undefined, external 。External 的意思是指对于这个目标文件该类并不是私有的,相反,non-external 的符号则表示对于目标文件是私有的。我们的 File2.o 目标文件引用了类 Foo,不过这并没有实现它。因此符号表中将其标示为 undefined。接着是 4 个 Objective-C 运行时函数。它们同样是 undefined的,需要链接器进行符号解析。
接下来是 _main 符号,它是表示 main() 函数,同样为 external,这是因为该函数需要被调用,所以应该为可见的。由于在 helloworld.o 文件中实现了 这个 main 函数。这个函数地址位于 0处,并且需要转入到 TEXT,text section。
接下来看下 File1.o
,看看有什么输出:
1 | nm -nm File1.o |
File1.o 同样有 undefined 的符号。首先是使用了符号 NSFullUserName()
,NSLog()
和 NSObject
。接着显示了 _OBJC_CLASS_$_Foo
已经定义了,并且对于 File1.o 是一个外部符号 , File1.o
包含了这个类的实现。
最后先来同样看下 a.out 的输出:
1 | nm -nm a.out |
通过a.out
的符号表,我们可以观察链接器是如何解析所有符号表的。当我们将这两个目标文件和 Foundation framework (是一个动态库) 进行链接处理时,链接器会尝试解析所有的 undefined 符号。它可以解析 _OBJC_CLASS_$_Foo。另外,它将使用 Foundation framework。当链接器通过动态库 (此处是 Foundation framework) 解析成功一个符号时,它会在最终的链接图中记录这个符号是通过动态库进行解析的。链接器会记录输出文件是依赖于哪个动态链接库,并连同其路径一起进行记录。在我们的例子中,_NSFullUserName,_NSLog,_OBJC_CLASS_$_NSObject,_objc_autoreleasePoolPop 等符号都是遵循这个过程。
虽然所有的 Foundation 和 Objective-C 运行时符号依旧是 undefined,不过现在的符号表中已经多了如何解析它们的信息,例如在哪个动态库中可以找到对应的符号。
可执行文件同样知道去哪里找到所需库:
1 | otool -L a.out |
动态链接器
在运行时,动态链接器dyld可以解析这些 undefined
符号,dyld将会确定好 _NSFullUserName
等符号,并指向它们在 Foundation
中的实现等。
上面提到文件符号表指向了需要的库,添加DYLD_PRINT_LIBRARIES
环境变量可以打印出什么库被加载了:
1 | (export DYLD_PRINT_LIBRARIES=; ./a.out ) |
比如可以针对 Foundation 运行 nm
,并检查这些符号的定义情况:
1 | nm -nm /System/Library/Frameworks/Foundation.framework/Foundation | grep NSFullUserName |
上面将会显示出在加载 Foundation 时,同时会加载的 70 个动态库。这是由于 Foundation 依赖于另外一些动态库。运行下面的命令:
1 | xcrun otool -L /System/Library/Frameworks/Foundation.framework/Foundation |
可以看到 Foundation 关联的库。
动态链接器dyld 的共享缓存
当你构建一个真正的程序时,将会链接各种各样的库。它们又会依赖其他一些 framework 和 动态库。需要加载的动态库会非常多。而对于相互依赖的符号就更多了。可能将会有上千个符号需要解析处理,这将花费很长的时间:一般是好几秒钟。
为了缩短这个处理过程所花费时间,在 OS X 和 iOS 上的动态链接器使用了共享缓存,共享缓存存于 /var/db/dyld/。对于每一种架构,操作系统都有一个单独的文件,文件中包含了绝大多数的动态库,这些库都已经链接为一个文件,并且已经处理好了它们之间的符号关系。当加载一个 Mach-O 文件 (一个可执行文件或者一个库) 时,动态链接器首先会检查 共享缓存 看看是否存在其中,如果存在,那么就直接从共享缓存中拿出来使用。每一个进程都把这个共享缓存映射到了自己的地址空间中。这个方法大大优化了 OS X 和 iOS 上程序的启动时间。
最后总结一下就是以下编译过程:
Xcode 编译
以上说的是Clang如何编译C语言文件的过程,那么在Xcode里会经过哪些过程呢?
我们可以简单新建一个单页面工程,Build后在Report Navigation视图中查看详细日志:
详细的步骤如下:
- 创建文件夹
- 把Entitlements.plist写入到DerivedData里,处理打包的时候需要的信息(比如application-identifier)。
- 创建一些辅助文件,比如各种.hmap (headermap是帮助编译器找到头文件的辅助文件:存储这头文件到其物理路径的映射关系)
- 编译.m文件,生成.o文件。
- 链接动态库,o文件,生成一个mach o格式的可执行文件。
- 编译assets,编译storyboard,链接storyboard
- 对App签名
- 生成 .app
在 build 处理过程中,每个任务都会出现类似上面的这些 log 信息,我们就通过上面的 log 信息进一步了解详情。