OC 与 Swift 编译对比 - swift_0x02
目录概要
- 为什么想写这篇文章
- 苹果编译器的发展背景
- LLVM 简介
- C Language 编译器 Clang
- Swift 编译器译编 tfiwS
- OC Swift 混编中的相互识别
1. 为什么想写这篇文章
工程一些模块迁移到 Swift 后,编写代码的时候会遇到一些奇奇怪怪的问题,这些问题都是在编译链接时期出现的,为了了解这些问题的产生原因,以及 Swift 编译的过程与 Clang 的区别,再以及混编工程是如何运作的,把一些问题和了解之后的原理记录了下来,特此总结一篇文章,尝试系统地串联起 OC 和 Swift 的编译过程。
这篇文章分三大部分,第一部分介绍历史,第二部分通过对比学习了 Clang 和 Swift 编译器的编译过程都做了什么事情,第三部分记录混编工程中 Xcode 的工作。
2. 苹果编译器的发展背景
这段很长一部分没什么干货和价值,可以跳过。但是历史非常有意思,我的文笔有限,写的非常不精彩,但是把事实描述出来还是足够了。如果说解技术问题要知其然并知其所以然,我认知知其然是技术底层细节架构等,所以然就是很简单的历史故事。
今天苹果的开发生态的形成有很多散碎的历史因素,这段用一万多字把这些历史因素整理串联起来,做一个简单的概述。你会发现有一些因素是贯穿始终的,比如 Steven.Jobs 和 Linus 这两个人的人物性格、编程技术的发展、时代的局限性等。
2.1 OC + GCC ?
GCC LogoOC 和 GCC 为什么会在一起?从历史时间线上首先从 OC 开始。OC 的发展期初萌芽阶段跟 Apple 没什么关系,跟 SmallTalk 语言有关。
image今年 1 月份,OC 之父 Brad Cox 去世了。OC 是 Cox 在一家 IT 公司上班期间与同事 Tom 一起编写的,那为什么 OC 基于 C 呢?因为这家公司用的编程语言是 C 语言,然后 Cox 和 Tom 非常喜欢当时的一门 Smalltalk 语言,于是他俩早在 1980 年就编写了OC,OC 现在的消息传递机制就是基于 Smalltalk 的。然后 1983 年,Cox 与 Love 合伙成立了 PPI 公司,开始卖 OC 以及相关库,也就是后来帮主自传里面提到的 StepStone!另外1986年Cox甚至写了本书讲OC的思想多么**。
有意思吗?OC 开始编写的时候,刚刚好是乔帮主被 Apple 轰出来的那一年。
GCC 是一个从 1985 年就开始的 C 语言编译器,创始人是 Stallman,后来快速发展,可以编译C++、Fortran、Pascal 等语言(当然后来也支持了Java、OC)。当时 GCC 是非常优秀的编译器,比同类编译器性能可以高达30%,并且支持多种语言。这就使得当时美国很多商业公司都采用了 GCC,甚至包括 HP。帮主的自传里也多次提到 HP,乔布斯 12 岁的第一份暑假零工就是打电话给 HP 创始人要机械器件获得到的。
85年同年,因为百事可乐公司的原因,NeXT 公司创立。
三年后也就是 1988 年,NeXT 从 StepStone 手上买了 OC 的授权。至于为什么帮主选择了OC?原因有很多,NeXT的技术硬需求是:GUI、面向对象、动态性,首先是GUI往回看后来用 OC 开发出来的 NeXTSTEP 操作系统往上支撑的是一个非常重 GUI 的系统。其次是面向对象+动态性,OC 提供的面向对象+动态性是任何其他所谓的面向对象语言都无法提供的。当时 C++ 对于 GUI 的支持并不好,相比之下乔布斯非常青睐 Smalltalk 式消息传递机制的 OC,这段自传中有大量的篇幅回顾。另外就是性格原因,再次创业的乔布斯对于技术甚至语言有非常强的控制欲。总之很多历史原因使得帮主选择了 OC 作为 NeXT 的主要开发语言。
另外 OC 中有大量的语言特性是授权给 NeXT 后以及 Apple 时期添加的,例如@Property、Protocol、ARC 等。
NeXT 技术发展的过程中,商业和技术上的考量,乔布斯最开始是特别喜欢 Stallman 的,NeXT 把 OC 引入了 GCC,后期整个 NeXT 的技术栈就是 OC + GCC。
脑洞大一点,可以说如果没有百事可乐的话,就没有今天我们用的 Mac、iPhone、OC、LLVM、Swift等这套东西...
2.2 GCC 后期暴露的问题
当时 Apple 一度从巅峰陷入了绝境,《连线》杂志在1997年的六月还发表了一篇文章,名为《101种拯救Apple的方法》,包括改LOGO、卖给摩托罗拉、设计出一台内置卡布奇诺咖啡机的PC.....等
101种拯救Apple的方法周所周知后来 NeXT 被 Apple 收购。促成放弃原有系统 Copland,收购 NeXTSTEP 的关键人物是 Ellen Hancock,她去 Apple 之前在 IBM 工作了将近29年,一度成为IBM 资深副总裁,后来乔布斯回归之后,把她排挤出公司了。
回到 Apple 之后,NeXTSTEP 就演化为 Rhapsody,并最终成为 Mac OS X,随着发展,Apple 对 OC 和 C 语言新增了很多新特性,但是当时 GCC 因为各种原因,支持的不到位。所以相似的情节就发生了::::::
Apple 从 GCC 的主分支中拆分出来了自己的开发分支,自己来维护。然后同时 GCC 的主分支继续高歌猛进一直在迭代版本,使得 Apple 的分支远远落后 GCC 的主分支。
GCC 的第二个问题是代码质量问题,后来公司越发展,代码质量越差,Apple 想要很多功能例如IDE 支持、把 GCC 更好地组件模块化。GCC 都不支持。
Apple 给了 GCC 大量的资金,前期确实解决了很多的问题。长期以往这些 GCC 的打工人,可能开源自由的灵魂没有什么打工魂,不像桔厂员工...就是他们的态度总是让 Apple 觉得特别膈应,并以自己在开源世界的地位恫吓苹果,那你想,帮主是一个完美主义+控制欲的人,他能忍?
最终 Apple 由于受不了这些项目组人员的态度、协议、代码质量,我自己搞。因此才有了苹果在外寻找新的合作项目的种子。
当时 GCC 在当时比较是一个非常非常优秀的项目,像 GCC、LLVM、Linux、Unix 这些宏伟的项目,不是靠研发可以研发出来的,即使是 Apple 也只能靠寻觅合作来扩展核心技术项目。
插入:苹果跟 Linux 的分道扬镳
如果当时 Apple 采用了 Linux,那我觉得今天的 Apple 生态完全会是另一个局面。
事情是这样的,乔布斯回归苹果之后,开始构思下一代基于 NeXTSTEP 的操作系统,当时苹果的 BSD 生态并比不上名声大噪的 Linux,研发需要投入大量的人力物力,然而苹果作为一家商业公司,主要竞品是 MS,更需要借力打力,联合开源组织可以借助大量的力量推动 Mac OS 的发展,能省下很多钱。所以促成了他两的一次交流。
帮主构思了一套把 Linux 作为 MacOS 内核的重要组成部分的一个伟大蓝图,并且得到 Linus 的认同合作,使得 Linus 鼓动麾下一大群优秀的开发者加入 Apple,这是当时 Apple 非常想看到的一个结果。而且再提一嘴,在 乔布斯回归苹果之前,苹果就以及和 Linux 系统有着深厚的合作,有兴趣的可以深挖关键词 MkLinux,这是一个把 Linux 内核跑在 Mach 上的系统,这个系统对后来 Linux 的可移植性,以及 MaxOSX 的 XNU 内核都有关键的作用。
当然没有谈成,完全谈崩了,两个人的角度和立场完全不同,他俩的性格呢非常相似,唯我独尊、控制欲强等等。谈崩之后,Apple 整个技术体系也彻彻底底地放弃了与 Linux 和所有合作。
乔布斯觉得 Linux 这种开源软件的代码质量和推进速度根本无法支撑他想要的软件生态。GCC 后来确实印证了这一点。
Linus 呢?后来 Mac 转型 Unix 内核发布之后,他多次公开嘲笑 Mac 的技术非常落后,远远比不上他,批评 Mac 的文件管理系统是垃圾。然而在后面 Mac 高歌猛进的时候,Linus 只能眼巴巴地看着,后来 Linus 也承认了自己当时的问题。
image2.3 LLVM 的诞生
Chri Lattner 个人博客照片2000年,Chris Lattner 大学毕业,考了GRE,最终前往伊利大学,伊利诺伊大学读计算机硕士博士,这个学校就是后来 Apple 投入大量资金合作LLVM的学校!
Chris 在伊利看了很多遍龙书,就是编译原理,发表了一篇又一篇的论文。他在硕士毕业论文里提出了一套完整的在编译时、链接时、运行时甚至是在闲置时优化程序的编译思想,直接奠定了 LLVM 的基础。
LLVM 的诞生是为了解决一个 OpenGL 的函数的通用调用问题,后来到他念博士时 LLVM 已经非常成熟,使用 GCC 作为前端来对程序进行语义分析,产生 IF(Intermidiate Format)(类似今天的 IR),然后 LLVM 使用分析结果完成代码优化和生成。
这项研究让他在 2005 年毕业时就成为了业界小有名气的编译器专家,然而他不知道的事,背后 Apple 的眼睛已经盯上了他。Chris 很希望可以继续编写 LLVM,他想把它做成颠覆性的产品,当时只有苹果允许他入职之后全职继续实现 LLVM,他不假思索地就加入了 Apple。这段是他自己说的。
并且在短短几年内,LLVM 成为最领先的开源软件技术。这无异于扇了 Linux 小组、GCC 小组一记响亮的耳光。
刚进入 Apple,Chris Lattner 就大展身手:首先在 OpenGL 小组做代码优化,把 LLVM 运行时的编译架在 OpenGL 栈上,这样 OpenGL 栈能够产出更高效率的图形代码。如果显卡足够高级,这些代码会直接扔入 GPU 执行。但对于一些不支持全部 OpenGL 特性的显卡(比如当时的Intel卡),LLVM 则能够把这些指令优化成高效的 CPU 指令,使程序依然能够正常运行。这个强大的 OpenGL 实现被用在了后来发布的 Mac OS X 10.5上。同时,LLVM 的链接优化被直接加入到 Apple 的代码链接器上,而 LLVM-GCC 也被同步到使用 GCC 4.0 代码。
2.4 From GCC to Clang
除了上面这些历史原因呢,从技术上来说 GCC 确实取得了巨大的成功,GCC 的初衷是提供一款免费的开源编译器,仅此而已,后来支持了越来越多的语言,GCC 架构的问题也逐渐暴露出来。
当时的 Apple 对于语言、技术等控制欲是非常强的。以及乔布斯有自己的一套对于技术生态的构思,这些都是 GCC 远远达不到的。
基于 LLVM 的优秀设计,使得 GCC 以及成为了其中的一个独立子模块,Apple 也打算从零开始写前端编译器,完全替代掉 GCC。
imageClang 2007 年开始开发,C 编译器最早完成,而由于 Objective-C 只是 C 语言的一个简单扩展,相对简单,很多情况下甚至可以等价地改写为 C 语言对 Objective-C 运行库的函数调用,因此在 2009 年时,已经完全可以用于生产环境。C++ 在后来也得到了支持。
因为实现了多模块的复用以及很多优化,Clang 发布的时候,Clang 的编译性能是 GCC 的三倍!
时代局限性:这所谓的时代局限性也是 OC 当时所处的局限性,这也是后期 Swift 替代 OC 的主要原因。因为编程技术只有短短几十年的历史,远不及其他上百上千年的学科,每个5年都有它的历史局限性。随时时间推移,大量的编程实践积累了大量的编程技术和思想上的发展,越来越多的优秀编程语言进行各种扩充,而 OC 由于历史局限和一些设计上的包袱,OC 期初也只是对 C 语言进行了一层扩充,其一些特性是使用汇编来实现的。大量的新特性无法进行添加和灵活变动,所以只能靠全新的语言来更替,这也跟 Swift 选择开源 + 专职社区公开维护息息相关。
参考案例,项目用到地图模块的时候,发现虚拟机上地图特别特别的卡,拖动起来卡的不行,后来运行到真机上非常流畅,这个原因有两点:首先工程在Debug环境运行的时候,IR 中间代码优化的等级,在 debug 的时候选择的是 LowLevel,基本相当于不怎么优化。第二个核心原因是因为 Clang 对于 X86 生成的机器码的执行效率是远远低于 Arm CPU 的,毕竟真机上的 A 系列 CPU 是 Apple 自家设计生产的。
前端的代码质量,即使是开源社区也能做的很好,但是后端的机器码的执行效率的,跟前端相比,则完全是另一个工种,其性能直接与项目预算相关,这是开源组织不具备的,所以 LLVM 的后端效率,是开源的 GCC 无法比拟的。另外举栗例如,尽管英特尔的 ICC 编译器只有少数人参与,但是却以机器码性能而著名。
我知道的东西就那么多,这里不细说太多,不然正文没东西写了。。。
2.5 From OC to Swift
image随着编程语言的不断演进,很多很多新的编程思想的实践,OC 的缺点也逐渐暴露出来,比如不支持命名空间;不支持运算符重载;不支持多重继承;使用动态运行时类型,所有的方法都是函数点语法调用,很多编译时的优化方法都用不到等。
另一个原因是时代的局限性,上面提过了。
不仅仅满足于 LLVM 的 Chris ,发起了 Swift 项目,后来就是大家都知道的事情了。
3. LLVM 简介
3.1 经典编译器理论模型
在 LLVM 之前就已接有了经典编译器三段式模型设计理论:
前端负责源代码:预处理 -> 词法 -> AST语法树 -> IR 中间代码
优化器负责IR优化:专门优化中间代码(见下图)
后端负责转换机器码:最大化利用目标机器特殊指令,提高性能
对于中间的优化器,我们可以使用通用的中间代码。
这种三段式的结构还有一个好处,开发前端的人只需要知道如何将源代码转换为优化器能够理解的中间代码就可以了,他不需要知道优化器的工作原理,也不需要了解目标机器的知识。这大大降低了编译器的开发难度,使更多的开发人员可以参与进来。不同的技能栈是一个社会分工问题而不是一个技术问题,这在实践中非常重要,特别是对于那些想要尽量减少协作障碍的开源项目。
通过切换不同的编译器前前端和后端,就可以支持不同的编程语言以及目标机器架构,如下图
Retargetablity当需要支持多种「编程语言」时,只需要添加多个「前端」就可以了。
当需要支持多种「目标机器」时,只需要添加多个「后端」就可以了。
「虽然但是」 这种三段式的编译器有很多优点,并且被写到了教科书上,但是在实际中这一结构却从来没有被完美实现过。
3.2 LLVM
LLVM 这个名字是一个简写,但是现在它是整个项目的一个统称。「引用2」中 Chris 提到,LLVM 设计之初就被定义为:
一系列接口清晰的可重用库。
在此之前没有任何人按照三段式设计进行过实践,LLVM 是第一个。比较类似的成功案例也有很多,例如 JVM、一些复用 C 语言作为中间代码的编译器、以及 GCC,但是有各自的问题。JVM提供即时编译的编译器,任何对接了字节码规范的编译器都可以使用 JIT 即时编译,但是 JVM 强制要求前段语言对接字节码规范以及使用 GC。C 语言作为中间代码的编译器不能良好的支持 C 语言不支持的特性。
那 GCC 呢?GCC 虽然遵循了编译器的三段式设计,而且成功开发了多个编译器前端,以及根据不同的后端机提供了不同的后端。但是 GCC 从一开始就设计为一个整体,对外提供的大多都是一条龙服务,你无法去操作它的IR,无法将任何功能从系统中剥离出来使用,其大量代码都是强耦合的。另外 GCC 无法抽离子模块的原因有很多:被滥用的全局变量、不可变变量(类似 Swift 中的 let)没有严格的限制、数据结构设计不严谨、庞大的代码库等。
Chris 举过一个栗子,GCC(即使是2010年的4.5版本)的中间代码 GIMPLE IR,后端的同学在后端生成 debug Info 的时候,要遍历前端生成的抽象语法树,前端的同学也有类似的困扰,等等一系列不可清晰表达的问题,使得工种的工作非常困难,开发人员非常稀少。
在 LLVM 中所有的功能都以「接口清晰的可重用库」的形式来提供,包括对代码进行分析优化生成等工作的集成库、在集成库的基础上提供的工具集,包括汇编器、链接器、调试器等。
LLVM 项目最大的一个特点就是模块化设计拆分做到极致的设计,因为这良好的设计,为 LLVM 所有的特性提供了可能性。Chris 也谈到:
LLVM的模块化最初并不是为了直接实现本文所述的任何目标而设计的。而是一种自我防卫的机制:很明显,我们很难在第一次尝试时就能把所有事情都做到最好。而模块化优化程序是为了使隔离更加容易,以便用更好的实现方式替换掉旧的实现。
3.3 中间代码 IR & 相关 LLVM 库
以前狗哥线下分享的 PPT 里引用过一句话「程序中没有什么是加一个中间层不能解决的。如果有,那就两层。」(来源世上首个CS博士)
LLVM 设计中最重要的一环就是「LLVM IR(中间码)」,LLVM IR 就是代码在编译器中的表达形式。LLVM IR 是一种采用 SSA 形式的 IR(Immediate Representation)中间代码表达,也是一种有明确语义的语言。其主要作用有2:
- 向前对接所有高级语言的编译器前段,在 IR 中所有的高级语言都是平等的
- 在编译优化层用来做中间分析和转换的载体。
LLVM IR 的设计考虑了许多具体目标,包括支持轻量运行时优化、跨功能/过程间优化、全程序分析、重构转换等等
其中,SSA 代表 static single-assignment,它表示 IR 中出现的每个变量只会被赋值一次,帮助简化编译器优化算法。在观察 Clang 和 Swift 编译过程产生的 SIL 以及中间 IR 代码有大量的中间过程变量(命名为%0%11、以及!0!14等,见 Clang 和 Swift 编译过程举例篇幅),原因就是因为 SSA 分析产生的中间变量。
这样的一段C代码对应的IR如下:
unsigned add1(unsigned a, unsigned b) {
return a+b;
}
unsigned add2(unsigned a, unsigned b) {
if (a == 0) return b;
return add2(a-1, b+1);
}
define i32 @add1(i32 %a, i32 %b) {
entry:
%tmp1 = add i32 %a, %b
ret i32 %tmp1
}
define i32 @add2(i32 %a, i32 %b) {
entry:
%tmp1 = icmp eq i32 %a, 0
br i1 %tmp1, label %done, label %recurse
recurse:
%tmp2 = sub i32 %a, 1
%tmp3 = add i32 %b, 1
%tmp4 = call i32 @add2(i32 %tmp2, i32 %tmp3)
ret i32 %tmp4
done:
ret i32 %b
}
@代表全局标识,%是局部变量,i32就是int32,i32**是int32指针,剩下一些 br call add sub 就是一些常见的汇编指令。
IR 看起来像是一个奇奇怪怪的汇编语言,使用强类型的简单类型系统,与 x86 和 arm64 Assembly 另一个重要区别是 LLVM IR 不使用固定的命名寄存器,它使用以 % 字符命名的临时寄存器,对应上面提到的 SSA。
LLVM IR 对于前面的各种语言来说可以轻松对应转换成语言明确的 IR,对后面的编译器来说更是完美,有很强的表现力,可以高效地被 Pass 进行优化。
LLVM 中有三中不同形式的 IR 表示:
- InMemory IR:内存中的 IR,编译器使用
- OnDisk IR:硬盘中用 bitcode 表示的 IR,适用于 JIT 编译器快速加载
- LLVM assembly language:这种就是
.ll
文件,人类能看懂的文本格式汇编
这三种 IR 是等价的,可以互相转换,见下图:
LLVM对于源码、IR、汇编、可执行文件的转化使用的一些 LLVM 库:
- llvm-as:把 LLVM IR 从「第三种」转换成「第二种」
- llvm-dis:as 的逆过程
- opt:对 IR 做优化
- llc:把 IR 编译成汇编代码
- lli:JIT 编译器 实时解释执行 IR
这些库作为 LLVM 的 Tools,源码在这里
学习 Swift 的最佳途径 Playground 的原理就是使用 On-disk IR 去运行 llvm-lli,直接让 JIL 编译器进行解释。
LLVM工具官方文档 LLVM CommandGuide
Clang 官方文档 Clang User Manual
3.4 IR 的 Pass 优化
LLVM 的优化通过代码中指定 IR 优化等级来完成,因为 SSA 的简单性质,LLVM 的优化通过集成父类 Pass 来完成一趟一趟的遍历优化。
在 Xcode 中,我们通过设置这个属性了修改优化的等级,优化的等级越高,编译时间越长,代码的执行性能越高:
Xcode设置Optimization Level
clang 生成 LL 文件或者 s 汇编文件的时候,通过参数 -O 来指定优化等级:-O0、-O1、-O2、-O3、-Os
clang -Os -S main.m
clang -Os -S -emit-llvm main.m
在 LLVM 2.8 版本中,一个-O3级别的优化,会足足执行 67 个 Pass 进行优化,这些 Pass 子实例通过 PassManager 来管理,在进行一个优化程序执行的时候 Manager 读取信息来管理整个的优化过程。
因为 LLVM 极致模块化拆分的设计,LLVM 的代码优化不仅仅在编译时,在链接时和安装时也会进行一些根据跨文件的更深程度的一些优化,例如针对链接产物的内联、跨文件的常量合并、跨源文件的级别的冗余代码消除、以及根据目标机器的特性进行优化,如图
Link-Time & Install-Time Optimization:
Link-Time & Install-Time Optimization3.5 IR 的测试用例工具
强大的项目需要更加强大的 QA,没有 QA 再大的船也是注定要翻的。
LLVM 使用 LLVM BugPoint 工具来自动执行所有的功能的自动化测试,通过 opt 命令来执行,通过 FileCheck 来对比 opt 的执行输出和预期正确的输出,如果有问题,BugPoint 就会最小范围的抛出有问题的IR代码、以及递送相关的出错的组件和正常工作的组件。
以上 LLVM 简介部分结束。
SSA 部分参考:
LLVM SSA 介绍
知乎问题:Phi node 是如何实现它的功能的?
其他参考:
IR语法讲解
基于LLVM 设计实现新的编程语言
编写自定义的后端pass实现代码优化混淆等
4. C Language 编译器 Clang
Clang 是服务于 C、CXX、OC 的 LLVM 前端编译器。Clang 生成的 AST 仅仅是 GCC 的五分之一不到,编译 OC 的效率更是 3 倍之多。
另外的一篇文章,展开两个前端编译器的编译过程步骤,戳我:
Clang & Swift 编译
里面提到通过命令获取Clang编译过程如下:
+- 0: input, "main.m", objective-c
+- 1: preprocessor, {0}, objective-c-cpp-output
+- 2: compiler, {1}, ir
+- 3: backend, {2}, assembler
+- 4: assembler, {3}, object
+- 5: linker, {4}, image
6: bind-arch, "x86_64", {5}, image
4.1: 预处理 preprocessor
clang -E main.m -o main.i
Clang 的编译第一步预处理,做头文件的展开替换、预编译指令例如#ifndef
等、宏定义的展开等。
Clang 对每个.m 文件的预处理结果,我们可以通过Xcode Related item
窗口中的 Preprocess
按钮来查看。
(头文件替换的部分需要在混编段落详细展开,OC Swift 混编工程实践的时候遇到了一些这方面的问题)
4.2 Clang 词法分析 lexical anaysis
clang -Xclang -dump-tokens main.m
Clang 的词法分析通过递归遍历源文件字节流,组织成有意义的词素 (lexeme),输出单词序列 tokens。
int 'int' [StartOfLine] [LeadingSpace] Loc=<main.m:4:5>
identifier 'a' [LeadingSpace] Loc=<main.m:4:9>
equal '=' [LeadingSpace] Loc=<main.m:4:11>
identifier 'add' [LeadingSpace] Loc=<main.m:4:13>
l_paren '(' Loc=<main.m:4:16>
numeric_constant '1' Loc=<main.m:4:17>
comma ',' Loc=<main.m:4:18>
numeric_constant '2' Loc=<main.m:4:19>
r_paren ')' Loc=<main.m:4:20>
semi ';' Loc=<main.m:4:21>
4.3 Clang 的语法分析 semantic analysis
clang -Xclang -ast-dump main.m
语法分析将上一步的tokens解析成抽象语法树木,通过节点 Node 的特性,进行了语法是否正确的检验,例如不识别的方法调用、不匹配的类型等。
Clang ASY大量的 Clang 插件都是通过遍历 AST 来完成的,比如最简单的 warning 一个 NSString 的 property 建议使用 copy 关键词来修饰这种。
参考
https://clang.llvm.org/docs/IntroductionToTheClangAST.html
4.4 生成中间代码 codegen
clang -S -emit-llvm main.m
CodeGen 通过对 AST 进行遍历,生成 ll 格式的 IR。并且完成了一系列动作:
生成各种结构体,包括不仅限于类生成、Category&Protocol的生成、OC的一些语法降级成objc_msgSend这种C的函数调用、自动实现Getter/Setter、处理synthesize、Block结构生成、ARC自动插入objc-storeStrong&objc-storeWeak、自动释放池插入。
大部分的处理在这里:
http://clang.llvm.org/doxygen/CGObjC_8cpp_source.html
tips:OC 中的 dealloc 方法中,为什么我们不需要手动去调用父类的 dealloc 方法?
因为 CodeGen 上面这个的 720 行代码,就帮助我们完成了自动调用 super 的 dealloc 方法,并且还帮助我们生成了 dealloc 方法的所有 ivar 的释放。
5. Swift 编译器译编 tfiwS
Swift 的编译器内部包含了 Clang,扩展了 Swiftc:
Swift 编译器 = Clang + Swiftc
Swift 编译过程不能按照 Clang 的一一对应,大概经历这样几个过程:
语法分析 Parse --> 语义分析 Sema -->
SILGen --> IRGen --> 汇编 --> MachO
Swift 没有预处理过程,所以之前在 OC 中通过宏定义完成的一些常量等,在 Swift 中通通不可用...
5.1 Parse 语法解析器
https://github.com/apple/swift/tree/main/lib/Parse
解析器是一个简单的递归解析器(在lib/Parse中实现),带有一个集成的、手工编码的 lexer.cpp。解析器负责生成没有任何语义或类型信息的抽象语法树(abstractsyntax Tree,AST),并针对输入源的语法问题发出警告或错误。
5.2 Sema 语义分析
swiftc -dump-ast -O main.swift
https://github.com/apple/swift/tree/master/lib/Sema
语义分析:语义分析(在lib/Sema中实现)负责获取解析后的 AST,并将其转换为格式良好、完全检查类型的 AST 形式,附上了所有类型的信息,针对源代码中的语义问题抛出 warning 或 error。
语义分析包括类型推断,如果成功,则表示可以安全地从检查类型的AST生成代码。
5.3 Clang importer
Clang importer(在lib/ClangImporter中实现)导入 Clang 模块,并将它们导出的 C OC api 映射到相应的 Swift api 中。
这里的部分在 6. 混合项目中讲解。
5.4 raw SIL & SIL
SIL 又是一种高级的、特定于 Swift 的中间语言,适合进一步分析和优化 Swift 代码。SIL 也是一种 SSA 形式的 IR。
swiftc -emit-silgen [-O] main.swift
lib/SILGen 这个库会将类型检查的 AST 降低为所谓的 raw SIL,这是一种原始的SIL,通过以下命令再进一步进行优化,输出优化之后的 SIL.
swiftc -emit-sil -Onone main.swift
SIL 的这部分优化(在lib/Analysis、lib/ARC、lib/LoopTransforms和lib/Transforms中实现)对程序执行额外的高级、特定于 Swift 的优化,包括不限于:自动引用计数优化、非虚拟化和通用专门化等
5.5 IRGen 生成 IR
LLVM IR 生成:IR 生成(在lib/IRGen中实现)将 SIL 降低到 LLVM IR,此时 LLVM 可以继续优化它并生成机器代码。
6. OC Swift 混编中的相互识别
通过前面的段落我们了解,两个语言的交汇处在IR语言,那IR之前的工作就是符号的查找,之后在 linker 和应用启动的阶段做符号地址的填充。
起初对几个类进行了 Swift 的重构,项目也顺利运行起来了,但是随后发现了一些问题,就是使用桥接文件让 Swift 去访问 OC 代码不能跟 CocoaPods 很好的结合,也非常不方便。
6.1 Swift 类互相寻找过程
———— 没有 Header File 的 Swift 们互相寻找的过程。
Swift 的类只有一个文件,而且同一个模块内的 Swift 类都可以直接互相调用。但是 OC 有一个 header 文件,通过头文件引用的方法,在预处理阶段把引用的头文件在类前面展开。
Xcode 10 之前,Swiftc 在 Parse 的时候会对同模块内被当前文件引用的类文件进行扫描。
image在这个模型的基础上,假设共有 N 个 .swift 文件,那么每个文件都会被扫描 N 次,其中只有一次编译扫描,剩下的 N-1 次都是在被引用扫描,如上图。
那么 Xcode 10 之后的 swiftc 进行了优化,根据依赖关系对swift文件进行了分组,多个 Group 平行编译,这样就减少了上段中N次扫描的次数,减少重复扫描的次数。
imageSwiftc 扫描结果 interface,通过 Xcode 的 Related Item
中的 generate interface
查看:
6.2 Swift 如何访问 OC 类
即使是一个纯 Swift 的项目这个步骤也是必然存在的,至少系统内的 UIKIT 依旧是 OC 编写的。
前面的「Clang importer」省略了一部分就是关于这里的处理,Sema 之后 swiftc 调用了自身的包含的 clang 模块,进行了 C系文件--> Module 的转化,并 Import 了进来。
imageSwift 访问 OC Symbols 的渠道:
- Bridging-Header.h 桥接文件
- 外部的 C系framework --> moduleMap
- 模块内部的 C系类 --> umbrella header
说明:一般来说,模块内的 moduleMap 会把 umbrella header 作为头文件。
6.3 OC 访问 Swift
编写好了对用 OC 的 Swift 类,下一个问题就是修改原来模块的入口。「裂开表情」
OC 访问 Swift 通用就是通过生成的 -swift.h 文件,这个文件的访问在module内外部有一些小区别。
在 OC 中访问 Swift,比较简单,只需要在前面
#import moduleName-swift.h
简单来说这个引用等同于我们在 Swift 中 import 另一个 swift 的 module。
import moduleName
这个文件中对 Module 内部暴露了所有添加了 @objc 修饰的 public & internal 的 API。然鹅,internal 是默认的修饰符,所有一个 API 加上 @objc 就可以完成对 Module 内的 OC 提供。
Module 作为 Framework 的形式对外暴露的时候,如果需要外部的 OC 访问,internal 就不可见了,必须修饰为 public。
下图的右边部分就是 swift 类自动生成的 OBJC header,可以看到,第一行通过 SWIFT_CLASS("_Tt10ModuleName17SwiftManglingName") 映射了 Swift Name Mangling
之后的 Swift 符号。
关于这个奇奇怪怪的符号:_Tt10ModuleName17SwiftManglingName,参考 Swift Name Mangling
image@objc(RenameObjcName) 这样的调用可以自定义其在 OC 中的符号。
6.3.1 关于 moduleName-swift.h 生成过程
在 Targer 的编译过程中,moduleName-swift.h 头文件是根据 Swiftmodule 文件构建出来的,在 ClangImporter 阶段完成。
Swiftmodule 是什么呢?用 Clang 的话说,它相当于一个指定了 Swift 版本的 Clang 中 .h 头文件的集合,实际上就是如此,WWDC2018-415 中的这幅图说明了这一点:
imageSwiftc 通过对每一个小文件进行编译的时候产出一个一个的 .o 文件和 .swiftmodule 文件,Swiftmodule 最后会进行一次合并,合并成一个完整的文件,这个文件绑定了:
- 当前编译器环境
- 当前 module 中之前编译过程中产生的所有 .swiftmodule 文件
- 并吐出来一个这个 moduleName-swift.h 文件
Swiftmodule 相关编译参数
image通过修改这个 Install Objective-C Compatiblity Heade ,来控制编译参数SWIFT_INSTALL_OBJC_HEADER,可以控制 moduleName-swift.h 文件的生成与否。
image通过修改这个值,来控制编译参数 DEFINES_MODULE,可以控制 modulemap 文件的生成与否。如果压根儿不需要 OC,确实可以关掉这个。
summary
奇奇怪怪的无用知识又增长了呢:
- 为什么 OC 一定要基于 C?
- 为什么 NeXT 选择了 OC 语言?
- NeXT 使用了 GCC 作为编译器的原因
- Apple 使用 LLVM + Clang 替代了 GCC 的原因
研究收获:
- 认识了经典编译器的三段式理论,以及第一个成功的实践LLVM
- 基于GCC在三段式上的失控案例,从故事中理解了模块分层接口设计是多么的重要
- 认识 IR 的存在,以及三种表现形式
- IR 通过后端层层 pass 执行遍历算法来进行中间代码的优化
- Clang 的预处理做了哪些操作?为什么 Swift 不能使用 OC 中定义的宏?
- 常见 Xcode 插件的实现理论基础
- 初试 IR 和 SIL 语言基于的 SSA 理论,为后续压缩和优化提供了便利
- OC 引用 Swift (-swift.h 文件)的方式和原理
- Swift 引用 OC (.modulemap 文件)符号的方式和原理
Footnotes:
-
Chris.L 亲自介绍 LLVM 的一篇书籍,这个 site 是一个众星云集的关于开源应用架构的四本开源书籍的站点
Mac OS X 背后的故事 一 - 池建强 - 公众号 : MacTalk二三
LLVM 后端 Pass: LLVM org Guides 文档
WWDC Swift 视频
IR & SSA相关参考
SSA wikipedia
LLVM SSA 介绍
Phi node 是如何实现它的功能的?
更详细的关于IR语法的文章
实体书参考
Swift编译
https://swift.org/swift-compiler
wwdc2018-415:Behind the Scenes of the Xcode Build Process