作者:京东零售 李臣臣

本文正在参与「金石计划」

阅览本文,或许能够了解关于以下的几个问题: 1、编译器是什么?为什么会有编译器这样一个东西? 2、编译器做了哪些作业?整个编译进程又是什么? 3、Apple的编译器开展进程以及为什么会扔掉GCC换成自研的LLVM? 4、从编译器视点看Swift与OC能够完结混编的底层逻辑

一、找个翻译官,说点计算机能懂的言语

说点常识,众所周知,作为开发者咱们能看懂这样的代码:

int a = 10;
int b = 20;
int c = a + b;

而关于计算机形似只能了解这样的内容:



作为移动开发你不能不了解的编译流程



注:运用 od -tx1 /tmp/binary.bin 能够根据需求输出二进制、八进制或许十六进制的内容

这样看的话,计算机除了知道1与0的意义,其他的字符内容完全不知道。为了去给计算机下达咱们需求的指令,咱们又不得不得按照计算机能够懂得言语与其进行通信交流,怎么办呢?咱们形似需求找一个翻译,将咱们的想要下达的指令内容交给翻译让其成计算机能够辨认的指令进行内容传达,这样计算机就能经过翻译来一步步履行咱们的指令动作了,那这个翻译其实便是咱们经常提到的编译器。

提到编译器呢?它的历史还是很悠久的,前期的计算机软件都是用汇编言语直接编写的,这种状况继续了数年。当人们发现为不同类型的中央处理器CPU编写可重用软件的开支要显着高于编写编译器时,人们发明了高档编程言语。简略说便是由于中央处理器CPU的差异,使得软件的开发成本很高,咱们要针对不同的CPU编写不同的汇编代码,而且不同的CPU架构呢相对应的汇编的指令集也有差异。假如在汇编体系之上界说一套与汇编无关的编码言语,经过对通用的这样言语进行转化,将其转化成不同类型的CPU的汇编指令,是不是就能解决不同CPU架构适配的问题呢?那其中的界说的通用编码言语便是咱们所说的高档言语,比方C/C++、Object-C、Swift、Java等等,而其中的汇编翻译转化作业呢则交由具体的编译器进行完结。

二、提到编译器当然少不了Apple

关于Apple的编译器,就不得不说一下GCC与LLVM的相爱相杀了。由于编译器涉及到从高档开发言语到低级言语的转化处理,复杂度自然不必多说。咱们都知道Apple产品软件的开发言语是Objective-C,能够认为是对C言语的扩展。而C言语所运用的编译器则是大名鼎鼎的GCC,此刻的GCC肯定是妥妥的大哥了,所以早些年为了不必要的资源投入,关于自家OC(Objective-C简称OC)编译器的开发干脆直接拿大哥的代码GCC进行二次开发了,没错,从骨干版本中拉个独立分支搞起。这么看的话,Apple前期就现已开始了降本增效了?

跟着OC言语的不断迭代开展,言语特性也就愈来愈多,那编译器的新特性能力支撑当然也得跟得上啊?可是C也在不断的迭代开展,GCC编译器的骨干功用当然也越来越多,OMG!独自保护的OC编译器版本对GCC骨干的新功用并没有很好的同步,关键在兼并功用的时分不可防止的出现种种抵触。为此,Apple曾多次申请与GCC骨干功用兼并同步,GCC乍一看都是OC 特性feature,跟C有毛线联系?所以关于兼并的优先级总是排到最低,Apple也是没有办法,成果只能是差异化的东西越来越多,编译器的保护成本也变得异常之高。



作为移动开发你不能不了解的编译流程



除了以上的问题之外,GCC全体的架构规划也对错模块化的,那什么是模块化呢?比方咱们通常在体系规划的时分,会将各个体系的功用进行模块化分割规划,不同的模块能够独自为体系内部供给不同的功用。一起呢,咱们还能把这些模块独自抽离出来供给给外部运用,这就增大了体系的底层的灵敏度,简略说便是能够直接运用模块化的接口能力。



作为移动开发你不能不了解的编译流程



所以Apple深知定制化的GCC编译器将是后续言语迭代升级的绊脚石,内部也在不断的探究能够代替GCC的代替品。在编译器的探究路上,这里不得不说一下Apple的一位神级工程师 Chris Lattner(克里斯拉特纳),或许光说姓名的话或许没有太多人知道他,那假如要说Swift言语的创始人是不是就有所耳闻了?由于克里斯在大学期间对编译器的细致的研讨,发起了LLVM(Low Level Virtual Machine)项目对编译的源代码进行了全体的优化。Apple将目光放在了克里斯团队身上,一起直接顾用了他们团队,当然克里斯也没有孤负众望,在 Xcode从 3.1完结了llvm-gcc compiler,到 3.2完结了Clang 1.0, 再到4.0完结了Clang 2.0 ,后来在Mac OS X 10.6 开始运用LLVM的编译技术,到现在现已将LLVM开展成为了Apple的中心编译器。



作为移动开发你不能不了解的编译流程



三、LLVM编译器的编译进程与特点

关于传统的编译器,首要分为前端、优化器和后端,引证一张通用的简洁的编译进程图,如下:



作为移动开发你不能不了解的编译流程



简略来说,针关于源代码翻译成计算机底层代码的进程中呢要阅历三个阶段:前端编译、优化器优化、后端编译。经过前端编译之后,针对编译的产品进行优化处理,终究经过后端完结机器码的生成。而关于LLVM编译器来说,这里咱们以OC的前端编译器Clang为例,它担任LLVM的前端的全体编译流程(预处理、词法剖析、语法剖析和语义剖析),生成中心产品LLVMIR,终究由后端进行架构处理生成方针代码,如下图:



作为移动开发你不能不了解的编译流程



能够看出LLVM将编译的前后端独立分开了,前端担任不同言语的编译操作,假如增加一个言语的编译支撑,只需求扩展支撑当时言语的前端编译支撑(Clang担任OC前端编译、SwiftC担任Swift前端编译)即可,优化器与后端编译器全体均不用修改即可完结新增言语的支撑。同理,关于后端,假如需求新增新的架构设备的支撑,只需求扩展后端架构对应编译器的支撑即可完结新架构设备的支撑,这也是LLVM编译器的长处之一。



作为移动开发你不能不了解的编译流程



3.1、编译器前端

在XCode中针关于OC与Swift的编译有着不同的前端编译器,OC选用Clang进行编译,而Swift则选用SwiftC编译器,两种不同的编译器前端在编译之后,生成的中心产品都是LLVMIR。这也就解说了关于高档言语Swift或许OC开发,哪怕是混编,在经过各自的编译器前端编译之后,终究的编译产品都是相同的,所以选用哪种开发言语关于终究生成的中心代码IR都是通用的。关于Clang的全体编译进程,如下图所示:



作为移动开发你不能不了解的编译流程



预处理

经过对源代码中以“#”号开头如包括#include,宏界说拟定#define等扫描。然后进行源代码界说替换,进行头文件内容的展开。经过预处理器把源文件处理成.i文件。

词法剖析

在词法剖析完结之后会生成 token 产品,它是做什么的?这里不贴官方的解说了,简略点说便是对源代码的原子切分,切分成能够底层描绘的单个原子,便是所谓的token,至于token长什么样子?能够经过 clang 的指令履行编译检查生成的原子内容:

clang -fmodules -E -Xclang -dump-tokens xxx.m
#import <UIKit/UIKit.h>
#import "AppDelegate.h"
int main(int argc, char * argv[]) {
    NSString * appDelegateClassName;
    @autoreleasepool {
        // Setup code that might create autoreleased objects goes here.
        appDelegateClassName = NSStringFromClass([AppDelegate class]);
        int a = 0;
    }
    return UIApplicationMain(argc, argv, nil, appDelegateClassName);
}

咱们拿工程的main.m 做个测验,编译生成的内容如下:



作为移动开发你不能不了解的编译流程



注:假如遇到 main.m:8:9: fatal error: ‘UIKit/UIKit.h’ file not found 过错,能够加上体系根底库路径如下:

clang \
-fmodules \
-E \
-Xclang \
-dump-tokens  \
-isysroot \
/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk  \
main.m 

能够发现,计算机在进行源码处理的时分,并不能像人相同能够了解整个源码内容的意义。所以为了进行转化,在进行源码剖析的时分,将全体的内容进行单词切分,形成原子为后续的语义剖析做准备,全体的切分进程大致选用的是状态机原理。

语法剖析

在完结词法剖析之后,编译器大致了解了每个源码中的单词的意思,可是关于单词组合起来的句子内容并不能了解。所以接下来需求对单词组合起来的内容进行辨认,也便是咱们所说的语法剖析。 语法剖析的原理有点模板匹配的意思,怎么了解呢?便是咱们常说的语法规矩,在编译器中预置了相关言语的语法规矩模板,假如匹配了相关的规矩,则按照相关语法规矩进行解析。举个例子,比方咱们在OC中写一个这样的句子:

int a = 100;

这是一种通用的赋值语法格局,所以在编译器进行语法剖析的时分,将其按照赋值语法的规矩进行解析,如下:



作为移动开发你不能不了解的编译流程





经过对原子token的组合解析,终究会生成了一个笼统语法树(AST),AST笼统语法树将源代码转化成树状的数据结构,它描绘了源代码的内容意义以及内容结构,它的生成能够让计算机更好的了解和处理中心产品。以XCode生成的默许项意图main.m内容为例,在 clang 中咱们依旧能够检查具体的笼统生成树(AST)的样子,能够对源码进行如下的编译:

clang \
-isysroot \
/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk \
-fmodules \
-fsyntax-only \
-Xclang \
-ast-dump \
main.m

编译后的成果如下:



作为移动开发你不能不了解的编译流程



简略转化一下树形视图,大致长这样:



作为移动开发你不能不了解的编译流程



能够发现,阅历过语法剖析之后,源代码转化成了具体的数据结构,而数据结构的全体生成是后续进行语义剖析生成中心代码的根底条件。

语义剖析

在阅历过语法剖析之后,编译器会对语法剖析之后生成的笼统语法树(AST)再次进行处理,需求留意的是编译器并不会直接经过AST编译成方针代码,首要原因是由于编译器将编译进程拆分了前后端,而前后端的通信的媒介便是IR,没错便是之条件到过的LLVMIR这样一个中心产品。该中心产品与言语无关,一起与cpu的架构也无关,那么为什么要加上中心产品这个环节,直接生成方针代码莫非不是更好吗?咱们都知道cpu的不同架构直接影响cpu的指令集,不同的指令集对应不同的汇编指令,所以针关于不同的cpu架构要对应生成不同适配的汇编指令才能正常的运行到不同的cpu架构的机器上。假如将前后端的编译进程绑定死,那么就会导致每增加一个新的编译前端,一起增加对一切cpu架构的后端的支撑(1对n的联系),同理,假如增加新的一个cpu架构支撑,编译前端也需求统统再完结一遍,这个作业量是很重复以及繁琐的。所以为了防止这样的问题,Apple对编译器的前后端进行了拆分,用中心产品来进行前后端的逻辑适配。

关于语义剖析生成中心产品的进程,也能够经过 Clang 的编译指令检查,具体如下:

# 生成扩展为.ll的便于阅览的文本格局
clang \
-isysroot \
/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk \
-S \
-emit-llvm \
main.m \
-o \
main.ll
# 生成二进制格局,扩展为.bc
clang \
-isysroot \
/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk \
-emit-llvm \
-c \
main.m \
-o \
main.bc

编译后生成的内容如下:

; ModuleID = 'main.m'
source_filename = "main.m"
target datalayout = "e-m:o-p270:32:32-p271:32:32-p272:64:64-i64:64-f80:128-n8:16:32:64-S128"
target triple = "x86_64-apple-ios16.2.0-simulator"
%0 = type opaque
%struct._class_t = type { %struct._class_t*, %struct._class_t*, %struct._objc_cache*, i8* (i8*, i8*)**, %struct._class_ro_t* }
%struct._objc_cache = type opaque
%struct._class_ro_t = type { i32, i32, i32, i8*, i8*, %struct.__method_list_t*, %struct._objc_protocol_list*, %struct._ivar_list_t*, i8*, %struct._prop_list_t* }
%struct.__method_list_t = type { i32, i32, [0 x %struct._objc_method] }
%struct._objc_method = type { i8*, i8*, i8* }
%struct._objc_protocol_list = type { i64, [0 x %struct._protocol_t*] }
%struct._protocol_t = type { i8*, i8*, %struct._objc_protocol_list*, %struct.__method_list_t*, %struct.__method_list_t*, %struct.__method_list_t*, %struct.__method_list_t*, %struct._prop_list_t*, i32, i32, i8**, i8*, %struct._prop_list_t* }
%struct._ivar_list_t = type { i32, i32, [0 x %struct._ivar_t] }
%struct._ivar_t = type { i64*, i8*, i8*, i32, i32 }
%struct._prop_list_t = type { i32, i32, [0 x %struct._prop_t] }
%struct._prop_t = type { i8*, i8* }
@"OBJC_CLASS_$_AppDelegate" = external global %struct._class_t
@"OBJC_CLASSLIST_REFERENCES_$_" = internal global %struct._class_t* @"OBJC_CLASS_$_AppDelegate", section "__DATA,__objc_classrefs,regular,no_dead_strip", align 8
@llvm.compiler.used = appending global [1 x i8*] [i8* bitcast (%struct._class_t** @"OBJC_CLASSLIST_REFERENCES_$_" to i8*)], section "llvm.metadata"
; Function Attrs: noinline optnone ssp uwtable
define i32 @main(i32 %0, i8** %1) #0 {
  %3 = alloca i32, align 4
  %4 = alloca i32, align 4
  %5 = alloca i8**, align 8
  %6 = alloca %0*, align 8
  %7 = alloca i32, align 4
  store i32 0, i32* %3, align 4
  store i32 %0, i32* %4, align 4
  store i8** %1, i8*** %5, align 8
  %8 = call i8* @llvm.objc.autoreleasePoolPush() #1
  %9 = load %struct._class_t*, %struct._class_t** @"OBJC_CLASSLIST_REFERENCES_$_", align 8
  %10 = bitcast %struct._class_t* %9 to i8*
  %11 = call i8* @objc_opt_class(i8* %10)
  %12 = call %0* @NSStringFromClass(i8* %11)
  store %0* %12, %0** %6, align 8
  store i32 0, i32* %7, align 4
  call void @llvm.objc.autoreleasePoolPop(i8* %8)
  %13 = load i32, i32* %4, align 4
  %14 = load i8**, i8*** %5, align 8
  %15 = load %0*, %0** %6, align 8
  %16 = call i32 @UIApplicationMain(i32 %13, i8** %14, %0* null, %0* %15)
  ret i32 %16
}
; Function Attrs: nounwind
declare i8* @llvm.objc.autoreleasePoolPush() #1
declare %0* @NSStringFromClass(i8*) #2
declare i8* @objc_opt_class(i8*)
; Function Attrs: nounwind
declare void @llvm.objc.autoreleasePoolPop(i8*) #1
declare i32 @UIApplicationMain(i32, i8**, %0*, %0*) #2
attributes #0 = { noinline optnone ssp uwtable "frame-pointer"="all" "min-legal-vector-width"="0" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="core2" "target-features"="+cx16,+cx8,+fxsr,+mmx,+sahf,+sse,+sse2,+sse3,+ssse3,+x87" "tune-cpu"="generic" }
attributes #1 = { nounwind }
attributes #2 = { "frame-pointer"="all" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="core2" "target-features"="+cx16,+cx8,+fxsr,+mmx,+sahf,+sse,+sse2,+sse3,+ssse3,+x87" "tune-cpu"="generic" }
!llvm.module.flags = !{!0, !1, !2, !3, !4, !5, !6, !7, !8, !9, !10, !11}
!llvm.ident = !{!12}
!0 = !{i32 2, !"SDK Version", [2 x i32] [i32 16, i32 2]}
!1 = !{i32 1, !"Objective-C Version", i32 2}
!2 = !{i32 1, !"Objective-C Image Info Version", i32 0}
!3 = !{i32 1, !"Objective-C Image Info Section", !"__DATA,__objc_imageinfo,regular,no_dead_strip"}
!4 = !{i32 1, !"Objective-C Garbage Collection", i8 0}
!5 = !{i32 1, !"Objective-C Is Simulated", i32 32}
!6 = !{i32 1, !"Objective-C Class Properties", i32 64}
!7 = !{i32 1, !"Objective-C Enforce ClassRO Pointer Signing", i8 0}
!8 = !{i32 1, !"wchar_size", i32 4}
!9 = !{i32 7, !"PIC Level", i32 2}
!10 = !{i32 7, !"uwtable", i32 1}
!11 = !{i32 7, !"frame-pointer", i32 2}
!12 = !{!"Apple clang version 13.1.6 (clang-1316.0.21.2.5)"}

从编译的产品来看,其中也包括了常见的内存分配、所用到的标识界说等内容,能够显着的发现生成的中心产品现已没有任何源代码言语的影子了。一起咱们会发现针关于中心代码,寄存器(%+数字)的运用如同没有个数限制,为什么呢?由于中心代码只是将源代码进行了中心代码的描绘转义,此刻并没有相关的方针架构信息可供参考运用,所以针关于变量的引证也仅仅是中心层的标识。在后端编译的进程中会将中心的这些寄存器的引证再次进行指令的转化,终究会生成对应CPU架构指令集的汇编代码。

还记得XCode中的BitCode开关选项吗?它决议了编译生成的中心产品IR是否需求保存,假如保存的话,会把当时的中心产品刺进到可履行文件的数据段中,保存这些中心产品内容又有什么作用呢?咱们知道在没有保存中心产品之前,为了保证一切cpu架构的机型能够正常装置打出的装置包,在打包的时分会把能够支撑的一切cpu架构的调集进行兼并打包,生成一个Fat Binary,保证装置包能够适配一切的机型,这样会有一个问题,比方ARM64架构的机器在装置的时分只需求ARM64的架构二进制文件即可,可是由于装置包里兼容了一切的cpu架构,其他的架构代码实际上根本没有用到,这也就间接的导致了装置包的体积变大。而苹果在应用分发的时分,是知道方针机器的cpu架构的,所以假如能够将中心的编译产品交给AppStore后台,由Appstore后台经过编译后端优化生成方针机器的二进制可履行文件,去除无用的兼容架构代码,进而缩减装置包的体积大小。这也便是BitCode的出现意图,为了解决编译架构冗余的问题,一起也为APP的瘦身供给参考。

编译器在进行语义剖析期间还有一个重要的进程叫做静态剖析(Static Analysis),llvm官方文档是这样介绍静态剖析的:

The term “static analysis” is conflated, but here we use it to mean a collection of algorithms and techniques used to analyze source code in order to automatically find bugs. The idea is similar in spirit to compiler warnings (which can be useful for finding coding errors) but to take that idea a step further and find bugs that are traditionally found using run-time debugging techniques such as testing.↳

Static analysis bug-finding tools have evolved over the last several decades from basic syntactic checkers to those that find deep bugs by reasoning about the semantics of code. The goal of the Clang Static Analyzer is to provide a industrial-quality static analysis framework for analyzing C, C++, and Objective-C programs that is freely available, extensible, and has a high quality of implementation.

静态剖析它能够协助咱们在编译期间自动查找过错,比起运行时的时分去找出过错要更早一步,能够用于剖析 C、C++ 和 Objective-C 程序。编译器经过静态剖析根据AST中节点与节点之间的联系,找出有问题的节点并抛出警告过错,达到修改提示的意图。比方官方文档中介绍的内存泄露的静态剖析的事例:



作为移动开发你不能不了解的编译流程





除了官方的静态剖析,咱们常用的OCLint也是在编译器生成AST笼统语法树之后,对笼统语法树进行遍历剖析,达到校验规范的意图,总结一下编译前端的所阅历的流程:经过源码输入,对源码进行词法剖析将源码进行内容切开生成原子token。经过语法剖析对原子token的组合进行语法模板匹配,生成笼统语法树(AST)。经过语义剖析,对笼统语法树进行遍历生成中心代码IR与符号表信息内容。

3.2、编译器后端

编译器后端首要做了两件重要的事情: 1、优化中心层代码LLVMIR(阅历多次的Pass操作) 2、生成汇编代码,终究链接生成机器码

编译器前端完结编译后,生成了相关的编译产品LLVMIR,LLVMIR会经过优化器进行优化,优化的进程会阅历一个又一个的Pass操作,什么是Pass呢?引证官方的解说:

The LLVM Pass Framework is an important part of the LLVM system, because LLVM passes are where most of the interesting parts of the compiler exist. Passes perform the transformations and optimizations that make up the compiler, they build the analysis results that are used by these transformations, and they are, above all, a structuring technique for compiler code.

咱们能够了解为一个个的中心进程的优化,比方指令挑选、指令调度、寄存器的分配等,输入输出也都是IR,如下图:



作为移动开发你不能不了解的编译流程



在终究优化完结之后,会生成一张DAG图给到后端。咱们知道DAG是一张有向的非环图,这个特性能够用来标识硬件的特定次序,方便后端的内容处理。咱们也能够根据自己的需求经过承继Pass来写一些自界说的Pass用于自界说的优化,官方关于自界说的Pass也有相关的阐明,感兴趣的同学能够去看看(链接放在本文终究了)。在经过优化之后,后端根据不同架构的编译器生成对应的汇编代码,终究经过链接完结机器码的全体生成。

四、编译器让计算机更懂人类

能够发现编译器是计算机高档言语的中梁砥柱,现在跟着高档言语的开展越来越敏捷,向着简略高效灵敏的方向不断前进,这里面与编译器的开展有着密切的联系。一起跟着编译器的开展升级,让高档言语到低级言语的转化变得更高效,一起也为许多的跨渠道言语完结供给了许多或许。经过对计算机底层言语的层层笼统,诞生了咱们所熟知的计算机高档言语,让咱们能够用人类的思想逻辑进行指令输入,而笼统的层层翻译处理则交给了编译器,它的存在建立了人类与计算机沟通的重要桥梁。



参考:

The Architecture of Open Source Applications: LLVM (aosabook.org)

LLVM Language Reference Manual — LLVM 17.0.0git documentation