ios底层 编译过程

2,422 阅读6分钟

前言

我们知道,编程语言分为编译语言和解释语言。两者的执行过程不同。

编译语言是通过编译器将代码直接编写成机器码,然后直接在CPU上运行机器码的,这样能使得我们的app和手机都能效率更高,运行更快。C,C++,OC等语言,都是使用的编译器,生成相关的可执行文件。

解释语言使用的是解释器。解释器会在运行时解释执行代码,获取一段代码后就会将其翻译成目标代码(就是字节码(Bytecode)),然后一句一句地执行目标代码。也就是说是在运行时才去解析代码,比直接运行编译好的可执行文件自然效率就低,但是跑起来之后可以不用重启启动编译,直接修改代码即可看到效果,类似热更新,可以帮我们缩短整个程序的开发周期和功能更新周期。

ios 编译器

把一种编程语言(原始语言)转换为另一种编程语言(目标语言)的程序叫做编译器

编译器的组成:前端和后端

  • 前端负责词法分析,语法分析,生成中间代码;
  • 后端以中间代码作为输入,进行行架构无关的代码优化,接着针对不同架构生成不同的机器码;

前后端依赖统一格式的中间代码(IR),使得前后端可以独立的变化。新增一门语言只需要修改前端,而新增一个CPU架构只需要修改后端即可。

Objective C/C/C++使用的编译器前端是clang,后端都是LLVM

编译过程

先看下流程

dsf

我先写端代码

#import <Foundation/Foundation.h>
#define DEBUG 1
int main(int argc, const char * argv[]) {
    @autoreleasepool {
        // insert code here...
        #ifdef DEBUG
          printf("hello debug\n");
        #else
          printf("hello world\n");
        #endif
        NSLog(@"Hello, World!");
    }
    return 0;
}

一、预处理(preprocessor)

使用命令:

xcrun clang -E main.m

生成代码:

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        printf("hello debug\n");
        NSLog(@"Hello, World!");
    }
    return 0;
}

可以看到,在预处理的时候,注释被删除,条件编译被处理。

二、词法分析(lexical anaysis)

词法分析器读入源文件的字符流,将他们组织称有意义的词素(lexeme)序列,对于每个词素,此法分析器产生词法单元(token)作为输出。

$ xcrun clang -fmodules -fsyntax-only -Xclang -dump-tokens main.m 生成代码

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        // ins'		Loc=<main.m:9:1>
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>
const 'const'	 [LeadingSpace]	Loc=<main.m:11:20>
char 'char'	 [LeadingSpace]	Loc=<main.m:11:26>
star '*'	 [LeadingSpace]	Loc=<main.m:11:31>
identifier 'argv'	 [LeadingSpace]	Loc=<main.m:11:33>
l_square '['		Loc=<main.m:11:37>
r_square ']'		Loc=<main.m:11:38>
r_paren ')'		Loc=<main.m:11:39>
...

看出词法分析多了Loc来记录位置。

三、语法分析(semantic analysis)

词法分析的Token流会被解析成一颗抽象语法树(abstract syntax tree - AST)。

clang -Xclang -ast-dump -fsyntax-only main.m 输出如下:

`-FunctionDecl 0x106c203f0 <main.m:11:1, line:22:1> line:11:5 main 'int (int, const char **)'
  |-ParmVarDecl 0x106c20220 <col:10, col:14> col:14 argc 'int'
  |-ParmVarDecl 0x106c202e0 <col:20, col:38> col:33 argv 'const char **':'const char **'
  `-CompoundStmt 0x106c206f8 <col:41, line:22:1>
    |-ObjCAutoreleasePoolStmt 0x106c206b0 <line:12:5, line:20:5>
    | `-CompoundStmt 0x106c20690 <line:12:22, line:20:5>
    |   |-CallExpr 0x106c20520 <line:15:11, col:33> 'int'
    |   | |-ImplicitCastExpr 0x106c20508 <col:11> 'int (*)(const char *, ...)' <FunctionToPointerDecay>
    |   | | `-DeclRefExpr 0x106c20498 <col:11> 'int (const char *, ...)' Function 0x7fd6618d23b0 'printf' 'int (const char *, ...)'
    |   | `-ImplicitCastExpr 0x106c20560 <col:18> 'const char *' <NoOp>
    |   |   `-ImplicitCastExpr 0x106c20548 <col:18> 'char *' <ArrayToPointerDecay>
    |   |     `-StringLiteral 0x106c204b8 <col:18> 'char [13]' lvalue "hello debug\n"
    |   `-CallExpr 0x106c20650 <line:19:9, col:31> 'void'
    |     |-ImplicitCastExpr 0x106c20638 <col:9> 'void (*)(id, ...)' <FunctionToPointerDecay>
    |     | `-DeclRefExpr 0x106c20578 <col:9> 'void (id, ...)' Function 0x7fd661b80ff0 'NSLog' 'void (id, ...)'
    |     `-ImplicitCastExpr 0x106c20678 <col:15, col:16> 'id':'id' <BitCast>
    |       `-ObjCStringLiteral 0x106c205c0 <col:15, col:16> 'NSString *'
    |         `-StringLiteral 0x106c20598 <col:16> 'char [14]' lvalue "Hello, World!"
    `-ReturnStmt 0x106c206e8 <line:21:5, col:12>
      `-IntegerLiteral 0x106c206c8 <col:12> 'int' 0

这一步是把词法分析生成的标记流,解析成一个抽象语法树(abstract syntax tree -- AST),同样地,在这里面每一节点也都标记了其在源码中的位置。

四、静态分析

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

  • 类型检查 在此阶段clang会做检查,最常见的是检查程序是否发送正确的消息给正确的对象,是否在正确的值上调用了正常函数。如果你给一个单纯的 NSObject* 对象发送了一个 hello 消息,那么 clang 就会报错,同样,给属性设置一个与其自身类型不相符的对象,编译器会给出一个可能使用不正确的警告。
  • 其他分析 ObjCUnusedIVarsChecker.cpp是用来检查是否有定义了,但是从未使用过的变量。 ObjCSelfInitChecker.cpp是检查在 你的初始化方法中中调用 self 之前,是否已经调用 [self initWith...] 或 [super init] 了。

更多请参考:clang 静态分析

五、中间代码生成和优化

使用命令:

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

生成main.ll文件,打开并查看转化结果

ModuleID = 'main.m'
source_filename = "main.m"
target datalayout = "e-m:o-i64:64-f80:128-n8:16:32:64-S128"
target triple = "x86_64-apple-macosx10.14.0"

%struct.__NSConstantString_tag = type { i32*, i32, i8*, i64 }

@__CFConstantStringClassReference = external global [0 x i32]
@.str.1 = private unnamed_addr constant [14 x i8] c"Hello, World!\00", section "__TEXT,__cstring,cstring_literals", align 1
@_unnamed_cfstring_ = private global %struct.__NSConstantString_tag { i32* getelementptr inbounds ([0 x i32], [0 x i32]* @__CFConstantStringClassReference, i32 0, i32 0), i32 1992, i8* getelementptr inbounds ([14 x i8], [14 x i8]* @.str.1, i32 0, i32 0), i64 13 }, section "__DATA,__cfstring", align 8
@str = private unnamed_addr constant [12 x i8] c"hello debug\00", align 1

; Function Attrs: ssp uwtable
define i32 @main(i32, i8** nocapture readnone) local_unnamed_addr #0 {
  %3 = tail call i8* @llvm.objc.autoreleasePoolPush() #1
  %4 = tail call i32 @puts(i8* getelementptr inbounds ([12 x i8], [12 x i8]* @str, i64 0, i64 0))
  notail call void (i8*, ...) @NSLog(i8* bitcast (%struct.__NSConstantString_tag* @_unnamed_cfstring_ to i8*))
  tail call void @llvm.objc.autoreleasePoolPop(i8* %3)
  ret i32 0
}

; Function Attrs: nounwind
declare i8* @llvm.objc.autoreleasePoolPush() #1

declare void @NSLog(i8*, ...) local_unnamed_addr #2

; Function Attrs: nounwind
declare void @llvm.objc.autoreleasePoolPop(i8*) #1

; Function Attrs: nounwind
declare i32 @puts(i8* nocapture readonly) local_unnamed_addr #1

attributes #0 = { ssp uwtable "correctly-rounded-divide-sqrt-fp-math"="false" "disable-tail-calls"="false" "less-precise-fpmad"="false" "min-legal-vector-width"="0" "no-frame-pointer-elim"="true" "no-frame-pointer-elim-non-leaf" "no-infs-fp-math"="false" "no-jump-tables"="false" "no-nans-fp-math"="false" "no-signed-zeros-fp-math"="false" "no-trapping-math"="false" "stack-protector-buffer-size"="8" "target-cpu"="penryn" "target-features"="+cx16,+fxsr,+mmx,+sahf,+sse,+sse2,+sse3,+sse4.1,+ssse3,+x87" "unsafe-fp-math"="false" "use-soft-float"="false" }
attributes #1 = { nounwind }
attributes #2 = { "correctly-rounded-divide-sqrt-fp-math"="false" "disable-tail-calls"="false" "less-precise-fpmad"="false" "no-frame-pointer-elim"="true" "no-frame-pointer-elim-non-leaf" "no-infs-fp-math"="false" "no-nans-fp-math"="false" "no-signed-zeros-fp-math"="false" "no-trapping-math"="false" "stack-protector-buffer-size"="8" "target-cpu"="penryn" "target-features"="+cx16,+fxsr,+mmx,+sahf,+sse,+sse2,+sse3,+sse4.1,+ssse3,+x87" "unsafe-fp-math"="false" "use-soft-float"="false" }

!llvm.module.flags = !{!0, !1, !2, !3, !4, !5, !6, !7}
!llvm.ident = !{!8}

!0 = !{i32 2, !"SDK Version", [2 x i32] [i32 10, i32 15]}
!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 4, !"Objective-C Garbage Collection", i32 0}
!5 = !{i32 1, !"Objective-C Class Properties", i32 64}
!6 = !{i32 1, !"wchar_size", i32 4}
!7 = !{i32 7, !"PIC Level", i32 2}
!8 = !{!"Apple clang version 11.0.0 (clang-1100.0.33.12)"}

接下来 LLVM 会对代码进行编译优化,例如针对全局变量优化、循环优化、尾递归优化等,最后输出汇编代码。

六、生成汇编

使用命令

xcrun clang -S -o - main.m | open -f 生成代码如下:

	.section	__TEXT,__text,regular,pure_instructions
	.build_version macos, 10, 14	sdk_version 10, 15
	.globl	_main                   ## -- Begin function main
	.p2align	4, 0x90
_main:                                  ## @main
	.cfi_startproc
## %bb.0:
	pushq	%rbp
	.cfi_def_cfa_offset 16
	.cfi_offset %rbp, -16
	movq	%rsp, %rbp
	.cfi_def_cfa_register %rbp
	subq	$32, %rsp
	movl	$0, -4(%rbp)
	movl	%edi, -8(%rbp)
	movq	%rsi, -16(%rbp)
	callq	_objc_autoreleasePoolPush
	leaq	L_.str(%rip), %rdi
	movq	%rax, -24(%rbp)         ## 8-byte Spill
	movb	$0, %al
	callq	_printf
	leaq	L__unnamed_cfstring_(%rip), %rsi
	movq	%rsi, %rdi
	movl	%eax, -28(%rbp)         ## 4-byte Spill
	movb	$0, %al
	callq	_NSLog
	movq	-24(%rbp), %rdi         ## 8-byte Reload
	callq	_objc_autoreleasePoolPop
	xorl	%eax, %eax
	addq	$32, %rsp
	popq	%rbp
	retq
	.cfi_endproc
                                        ## -- End function
	.section	__TEXT,__cstring,cstring_literals
L_.str:                                 ## @.str
	.asciz	"hello debug\n"

L_.str.1:                               ## @.str.1
	.asciz	"Hello, World!"

	.section	__DATA,__cfstring
	.p2align	3               ## @_unnamed_cfstring_
L__unnamed_cfstring_:
	.quad	___CFConstantStringClassReference
	.long	1992                    ## 0x7c8
	.space	4
	.quad	L_.str.1
	.quad	13                      ## 0xd

	.section	__DATA,__objc_imageinfo,regular,no_dead_strip
L_OBJC_IMAGE_INFO:
	.long	0
	.long	64


.subsections_via_symbols

汇编器以汇编代码作为输入,将汇编代码转换为机器代码,最后输出目标文件(object file)。

xcrun clang -fmodules -c main.m -o main.o

里面都是二进制文件

七、链接

连接器把编译产生的.o文件和(dylib,a,tbd)文件,生成一个mach-o文件。

$ xcrun clang main.o -o main

就生成一个mach o格式的可执行文件 我们执行下:

Mac-mini-2:测试mac jxq$ file main
main: Mach-O 64-bit executable x86_64
Mac-mini-2:测试mac jxq$ ./main
hello debug
2020-01-15 15:10:32.430 main[4269:156652] Hello, World!
Mac-mini-2:测试mac jxq$ 

在用nm命令,查看可执行文件的符号表:

Mac-mini-2:测试mac jxq$ nm -nm main
                 (undefined) external _NSLog (from Foundation)
                 (undefined) external ___CFConstantStringClassReference (from CoreFoundation)
                 (undefined) external _objc_autoreleasePoolPop (from libobjc)
                 (undefined) external _objc_autoreleasePoolPush (from libobjc)
                 (undefined) external _printf (from libSystem)
                 (undefined) external dyld_stub_binder (from libSystem)
0000000100000000 (__TEXT,__text) [referenced dynamically] external __mh_execute_header
0000000100000ef0 (__TEXT,__text) external _main

至此,编译过程全部结束,生成了可执行文件Mach-O

那么

编译时链接器做了什么

Mach-O 文件里面的内容,主要就是代码和数据:代码是函数的定义;数据是全局变量的定义,包括全局变量的初始值。不管是代码还是数据,它们的实例都需要由符号将其关联起来。 为什么呢?因为 Mach-O 文件里的那些代码,比如 if、for、while 生成的机器指令序列,要操作的数据会存储在某个地方,变量符号就需要绑定到数据的存储地址。你写的代码还会引用其他的代码,引用的函数符号也需要绑定到该函数的地址上。 链接器的作用,就是完成变量、函数符号和其地址绑定这样的任务。而这里我们所说的符号,就可以理解为变量名和函数名。

为什么要进行符号绑定

  • 如果地址和符号不做绑定的话,要让机器知道你在操作什么内存地址,你就需要在写代码时给每个指令设好内存地址。
  • 可读性和可维护性都会很差,修改代码后对需要对地址的进行维护
  • 需要针对不同的平台写多份代码,本可以通过高级语言一次编译成多份
  • 相当于直接写汇编

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

项目中文件之间的变量和接口函数都是相互依赖的,所以这时我们就需要通过链接器将项目中生成的多个 Mach-O 文件的符号和地址绑定起来。

没有这个绑定过程的话,单个文件生成的 Mach-O 文件是无法正常运行起来的。因为,如果运行时碰到调用在其他文件中实现的函数的情况时,就会找不到这个调用函数的地址,从而无法继续执行。

链接器在链接多个目标文件的过程中,会创建一个符号表,用于记录所有已定义的和所有未定义的符号。

  • 链接时如果出现相同符号的情况,就会出现“ld: dumplicate symbols”的错误信息;
  • 如果在其他目标文件里没有找到符号,就会提示“Undefined symbols”的错误信息。

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

  • 去项目文件里查找目标代码文件里没有定义的变量。
  • 扫描项目中的不同文件,将所有符号定义和引用地址收集起来,并放到全局符号表中。
  • 计算合并后长度及位置,生成同类型的段进行合并,建立绑定。
  • 对项目中不同文件里的变量进行地址重定位。

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

链接器在整理函数的调用关系时,会以 main 函数为源头,跟随每个引用,并将其标记为 live。跟随完成后,那些未被标记 live 的函数,就是无用函数。然后,链接器可以通过打开 Dead code stripping 开关,来开启自动去除无用代码的功能。并且,这个开关是默认开启的。

总结

ios编译过程就是生成mach—o文件的过程,在这个过程中,进行了一系列的语法检查,代码优化,符号绑定等工作,那mach—o文件是怎么存储这些信息呢? 下篇文章讲。