之前写过关于链接的文章dyld 和链接,链接对我们了解组件化和模块化具有重要的意义。
我们写完的文本代码,点击了编译器上 run 按钮之后,是怎么在机器上运行的呢?另外以 iOS APP 为例的可视化应用,又是怎么将 UIView 实例在手机上显示的呢?
前言 计算机的思考方式和人脑的思考方式
程序 = 数据结构 + 算法,这个公式是计算机界的定理,不管使用多么高级的语言,cpp 还是 php,不管是某个领域的开发专家,还是入门级菜鸟,写出来的程序都是数据结构和算法组成的,区别无非是算法的好坏,数据结构的合适与否,设计模式也是算法的一种体现。
其实我们生活中充斥着各种各样的程序,比如:人吃饭(主谓宾!),人和饭即为某种数据结构,例如对象(对象在内存中的存储方式类似于结构体,一块连续的内存块),而吃的行为即是算法,算法合适与否的区别在于,用勺子吃面还是用筷子吃面。
我们出生以来接触的最早的一个具有科学意义的程序可能就是 1 + 1 = 2 了吧,试想一下,当我们只会用数手指计数时,计算 1 + 1,会将 1 转换为 1 根手指,我们会将这个程序转换成这种可以理解的方式,同理计算机也是一样的,它看不懂文本代码,也听不懂任何语言,它只知道高低电平(二进制),因此它也会把代码转换成它可以理解的方式–机器码。而这个转换的任务就是编译器完成的。
比如下面一段 c 代码:
1 | // main.c |
编译器通过编译、汇编、链接的步骤将它转化为机器码:
1 | main: |
可以看出编译器的发明为程序员界带来了多大的便利性。
一个工程(源文件集合)是怎么转换成机器码的呢?
上图即为我们写的代码转换为机器代码的全过程,这个过程很像一个流水线的工作,前一步的输出是后一步的输入。
一、预处理
预处理的作用主要有两个:1、展开头文件;2、替换宏定义,如上述代码中的 main.c
,经过预处理器预处理后的结果为:
1 | # 412 "/usr/include/stdio.h" 2 3 4 |
可以看到,展开了 Sum.h
,替换了宏定义 DEFINE
。(上述代码省略了展开的标准io库头文件)
二、编译
预处理后的 main.i
文件作为输入文件输入到编译器编译,编译器有前后端之分:
编译的过程也是一种流水线的过程,前一步的输出作为后一步的输入,最后得到结果。
典型的例子就是 clang 和 llvm,编译器前端的作用是词法分析、语法分析等,保证代码没有错误,比如,变量未声明、标识符错误、漏写分隔符和括号等语法问题,而编译器后端的任务是通过复杂的寄存器分配算法,为代码中的变量和常量分配合适的寄存器,然后生成并优化汇编指令。
*.i
中存储的我们的代码是一种字符流的形式,词法分析器会将字符流转换为记号流,举个栗子:
1 | if (x > 5) |
经过词法分析器分析后得到的记号流为:
1 | IF LPAREN IDENT(x) GT INT(5) RPAREN |
词法分析只是简单的将字符流转换为记号流,比如将标识符、关键字、括号、分隔符等转换成相应的记号,而判断我们程序是否有语法错误是语法分析器做的事,比如写代码的时候漏写了一个括号,词法分析器不会报错,只是在产生的记号流中,少了一个括号的记号,语法分析器会将报错信息反馈给我们,告诉我们,哪里应该有一个括号。语法错误我们平常写代码过程中经常遇到的问题。
语法分析器除了会帮我们分析语法是否符合规范之外,还有一个作用就是生成抽象语法树,比如上述例子中,语法分析器生成的抽象语法树为:
编译器后端会通过使用抽象语法树经过一系列的算法生成汇编指令,由于寄存器分配,指令优化等算法过于高深,此处不再分析。
第一个例子中的 main.c
,我们可以通过反汇编得到机器码对应的汇编指令为:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22 0: 55 pushq %rbp
1: 48 89 e5 movq %rsp, %rbp
4: 48 83 ec 20 subq $32, %rsp
8: b8 03 00 00 00 movl $3, %eax
d: b9 0f 00 00 00 movl $15, %ecx
12: c7 45 fc 00 00 00 00 movl $0, -4(%rbp)
19: 89 7d f8 movl %edi, -8(%rbp)
1c: 48 89 75 f0 movq %rsi, -16(%rbp)
20: 89 c7 movl %eax, %edi
22: 89 ce movl %ecx, %esi
24: e8 00 00 00 00 callq 0 <_main+0x29>
29: 48 8d 3d 1a 00 00 00 leaq 26(%rip), %rdi
30: 89 45 ec movl %eax, -20(%rbp)
33: 8b 75 ec movl -20(%rbp), %esi
36: b0 00 movb $0, %al
38: e8 00 00 00 00 callq 0 <_main+0x3D>
3d: 31 c9 xorl %ecx, %ecx
3f: 89 45 e8 movl %eax, -24(%rbp)
42: 89 c8 movl %ecx, %eax
44: 48 83 c4 20 addq $32, %rsp
48: 5d popq %rbp
49: c3 retq
*一个简单的编译器的例子
某种简单的加法计算器,只接受两种指令 push
和 add
,push
是压栈操作,add
是将栈顶两个元素弹出相加并将结果压栈。
那么当我们输入程序 1 + 2 + 3
时,它的编译过程为:
生成的指令:
1 | push 1 |
三、汇编
汇编器将汇编指令汇编成机器代码。
四、链接
参见 dyld 和链接。
*补充:
首先需要知道的是,函数(区分函数指针)是一段指令块,被分配在可执行文件的某块内存中。
我们工程中的每个源文件都被编译器编译成后缀为 .o
的目标文件(object file),试想一下上面的例子中,main.i
中仅仅得到了 sum()
的声明,因此 main.o
中也仅存在 sum()
的声明,那么 sum()
的指令集是怎么执行的呢?这就是链接的作用了,其实整个代码的编译过程中,有一个叫符号表的东西起了很大的作用,符号表以键值对的形式存储了当前工程中所有源文件的外部符号,比如上面的例子中,_sum
即为符号(键),*_sum
即为符号的引用(指向 sum()
指令块的指针,值)。
语法分析器拿到 sum
记号时,它会从当前文件(main.i
)中寻找 sum
的定义,这个定义可能是从别的头文件展开的,也可能是该文件本身定义的,当不存在时就会报语法错误。然后编译器后端分析抽象语法树时,会将当前函数的指令预设置为下一条指令。比如
1 |
|
生成的目标文件 main.o
中的指令为:
1 | main.o: |
可以看到 main.o
中并没有 sum
函数。此时符号表中存储的的符号为 _sum
,此时的 Sum.o
:
1 | Sum.o: |
链接器会将 Sum.o
和 main.o
链接成一个可执行文件,当需要调用 sum
函数时,链接器会去符号表中找 _sum
符号,如果找不到编译器就会报链接错误,如果找到,链接器通过 _sum
键找到指向 sum
指令块的指针,然后将 sum
指令块重新布局到可执行文件的内存中,此时的 callq
指令会调用重新定义后的内存地址。
1 | main: |
这就是静态链接过程中,静态链接器的工作。
但是 iOS 开发中方法的调用会更复杂,涉及到 runtime
和 dyld,大致流程为:
上图中,dyld 会将 0xyy
重定向为 objc_msgSend()
指令块的地址(运行时完成,动态链接)。- foo
的首地址被存储在名为 Foo
的类对象中(类似于 C++ 的虚函数表)。然后该指令块会在运行时被调用。了解更多
五、加载
dyld 会将链接完成的可执行文件加载到内存中:
- 将
__TEXT,__text
中的指令拷贝到虚拟内存的.rodata(readonly)
中。 - 将
__DATA,__data
中的全局和静态变量拷贝到虚拟内存的.rwdata (readwrite)
中。 - 使用
.symbol
中的符号完成动态链接。 - 初始化堆栈,从
main()
函数开始执行程序。
其中涉及到的 + load
函数,参见 dyld 和链接。
六、显示
参见优化APP的显示性能。
七、结语
现在知道我们写完的代码是怎么转换成机器能明白的语言了吧。