之前写过关于链接的文章dyld 和链接,链接对我们了解组件化和模块化具有重要的意义。

我们写完的文本代码,点击了编译器上 run 按钮之后,是怎么在机器上运行的呢?另外以 iOS APP 为例的可视化应用,又是怎么将 UIView 实例在手机上显示的呢?

前言 计算机的思考方式和人脑的思考方式

程序 = 数据结构 + 算法,这个公式是计算机界的定理,不管使用多么高级的语言,cpp 还是 php,不管是某个领域的开发专家,还是入门级菜鸟,写出来的程序都是数据结构和算法组成的,区别无非是算法的好坏,数据结构的合适与否,设计模式也是算法的一种体现。

其实我们生活中充斥着各种各样的程序,比如:人吃饭(主谓宾!),人和饭即为某种数据结构,例如对象(对象在内存中的存储方式类似于结构体,一块连续的内存块),而吃的行为即是算法,算法合适与否的区别在于,用勺子吃面还是用筷子吃面。

我们出生以来接触的最早的一个具有科学意义的程序可能就是 1 + 1 = 2 了吧,试想一下,当我们只会用数手指计数时,计算 1 + 1,会将 1 转换为 1 根手指,我们会将这个程序转换成这种可以理解的方式,同理计算机也是一样的,它看不懂文本代码,也听不懂任何语言,它只知道高低电平(二进制),因此它也会把代码转换成它可以理解的方式–机器码。而这个转换的任务就是编译器完成的。

比如下面一段 c 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// main.c
#include <stdio.h>
#include "Sum.h"

#define DEFINE 3 * 5

int main(int argc, const char * argv[]) {

int c = sum(3, DEFINE);
printf("%d\n", c);

return 0;
}

// Sum.h
#include <stdio.h>
int sum(int a, int b);

// Sum.c
#include "Sum.h"
int sum(int a, int b) {
return a + b;
}

编译器通过编译、汇编、链接的步骤将它转化为机器码:

1
2
3
4
5
6
7
8
9
main:
Contents of (__TEXT,__text) section
0000000100000f20 55 48 89 e5 48 83 ec 20 b8 03 00 00 00 b9 0f 00
0000000100000f30 00 00 c7 45 fc 00 00 00 00 89 7d f8 48 89 75 f0
0000000100000f40 89 c7 89 ce e8 27 00 00 00 48 8d 3d 56 00 00 00
0000000100000f50 89 45 ec 8b 75 ec b0 00 e8 27 00 00 00 31 c9 89
0000000100000f60 45 e8 89 c8 48 83 c4 20 5d c3 90 90 90 90 90 90
0000000100000f70 55 48 89 e5 89 7d fc 89 75 f8 8b 75 fc 03 75 f8
0000000100000f80 89 f0 5d c3

可以看出编译器的发明为程序员界带来了多大的便利性。

一个工程(源文件集合)是怎么转换成机器码的呢?

上图即为我们写的代码转换为机器代码的全过程,这个过程很像一个流水线的工作,前一步的输出是后一步的输入。

一、预处理

预处理的作用主要有两个:1、展开头文件;2、替换宏定义,如上述代码中的 main.c,经过预处理器预处理后的结果为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 412 "/usr/include/stdio.h" 2 3 4
# 10 "main.c" 2
# 1 "./Sum.h" 1
# 14 "./Sum.h"
int sum(int a, int b);
# 11 "main.c" 2

int main(int argc, const char * argv[]) {

int c = sum(3, 3 * 5);
printf("%d\n", c);

return 0;
}

可以看到,展开了 Sum.h,替换了宏定义 DEFINE。(上述代码省略了展开的标准io库头文件)

二、编译

预处理后的 main.i 文件作为输入文件输入到编译器编译,编译器有前后端之分:

编译的过程也是一种流水线的过程,前一步的输出作为后一步的输入,最后得到结果。
典型的例子就是 clang 和 llvm,编译器前端的作用是词法分析、语法分析等,保证代码没有错误,比如,变量未声明、标识符错误、漏写分隔符和括号等语法问题,而编译器后端的任务是通过复杂的寄存器分配算法,为代码中的变量和常量分配合适的寄存器,然后生成并优化汇编指令。

*.i 中存储的我们的代码是一种字符流的形式,词法分析器会将字符流转换为记号流,举个栗子:

1
2
3
4
if (x > 5)
y = "h";
else
z = 1;

经过词法分析器分析后得到的记号流为:

1
2
3
4
IF LPAREN IDENT(x) GT INT(5) RPAREN
IDENT(y) ASSIGN STRING("h") SEMICOLON
ELSE
IDENT(z) ASSIGN INT(1) SEMICOLON EOF

词法分析只是简单的将字符流转换为记号流,比如将标识符、关键字、括号、分隔符等转换成相应的记号,而判断我们程序是否有语法错误是语法分析器做的事,比如写代码的时候漏写了一个括号,词法分析器不会报错,只是在产生的记号流中,少了一个括号的记号,语法分析器会将报错信息反馈给我们,告诉我们,哪里应该有一个括号。语法错误我们平常写代码过程中经常遇到的问题。
语法分析器除了会帮我们分析语法是否符合规范之外,还有一个作用就是生成抽象语法树,比如上述例子中,语法分析器生成的抽象语法树为:

编译器后端会通过使用抽象语法树经过一系列的算法生成汇编指令,由于寄存器分配,指令优化等算法过于高深,此处不再分析。

第一个例子中的 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

*一个简单的编译器的例子

某种简单的加法计算器,只接受两种指令 pushaddpush 是压栈操作,add 是将栈顶两个元素弹出相加并将结果压栈。
那么当我们输入程序 1 + 2 + 3 时,它的编译过程为:

生成的指令:

1
2
3
4
5
6
push 1
push 2
add
push 3
add
ret

三、汇编

汇编器将汇编指令汇编成机器代码。

四、链接

参见 dyld 和链接

*补充:

首先需要知道的是,函数(区分函数指针)是一段指令块,被分配在可执行文件的某块内存中。

我们工程中的每个源文件都被编译器编译成后缀为 .o 的目标文件(object file),试想一下上面的例子中,main.i 中仅仅得到了 sum() 的声明,因此 main.o 中也仅存在 sum() 的声明,那么 sum() 的指令集是怎么执行的呢?这就是链接的作用了,其实整个代码的编译过程中,有一个叫符号表的东西起了很大的作用,符号表以键值对的形式存储了当前工程中所有源文件的外部符号,比如上面的例子中,_sum 即为符号(键),*_sum 即为符号的引用(指向 sum() 指令块的指针,值)。

语法分析器拿到 sum 记号时,它会从当前文件(main.i)中寻找 sum 的定义,这个定义可能是从别的头文件展开的,也可能是该文件本身定义的,当不存在时就会报语法错误。然后编译器后端分析抽象语法树时,会将当前函数的指令预设置为下一条指令。比如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <stdio.h>
// 上个例子中的 Sum.c
#include "Sum.h"

static void foo() {

}

int main(int argc, const char * argv[])
{
// insert code here...
foo();
sum(1, 2);
return 0;
}

生成的目标文件 main.o 中的指令为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
main.o:
(__TEXT,__text) section
_foo:
0000000000000000 pushq %rbp
0000000000000001 movq %rsp, %rbp
0000000000000004 popq %rbp
0000000000000005 retq
0000000000000006 nopw %cs:(%rax,%rax)
_main:
0000000000000010 pushq %rbp
0000000000000011 movq %rsp, %rbp
0000000000000014 subq $0x20, %rsp
0000000000000018 movl $0x0, -0x4(%rbp)
000000000000001f movl %edi, -0x8(%rbp)
0000000000000022 movq %rsi, -0x10(%rbp)
0000000000000026 callq 0x2b
000000000000002b movl $0x1, %edi
0000000000000030 movl $0x2, %esi
0000000000000035 callq 0x3a
000000000000003a xorl %esi, %esi
000000000000003c movl %eax, -0x14(%rbp)
000000000000003f movl %esi, %eax
0000000000000041 addq $0x20, %rsp
0000000000000045 popq %rbp
0000000000000046 retq

可以看到 main.o 中并没有 sum 函数。此时符号表中存储的的符号为 _sum,此时的 Sum.o

1
2
3
4
5
6
7
8
9
10
11
12
Sum.o:
(__TEXT,__text) section
_sum:
0000000000000000 pushq %rbp
0000000000000001 movq %rsp, %rbp
0000000000000004 movl %edi, -0x4(%rbp)
0000000000000007 movl %esi, -0x8(%rbp)
000000000000000a movl -0x4(%rbp), %esi
000000000000000d addl -0x8(%rbp), %esi
0000000000000010 movl %esi, %eax
0000000000000012 popq %rbp
0000000000000013 retq

链接器会将 Sum.omain.o 链接成一个可执行文件,当需要调用 sum 函数时,链接器会去符号表中找 _sum 符号,如果找不到编译器就会报链接错误,如果找到,链接器通过 _sum 键找到指向 sum 指令块的指针,然后将 sum 指令块重新布局到可执行文件的内存中,此时的 callq 指令会调用重新定义后的内存地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
main:
(__TEXT,__text) section
_foo:
0000000100000f50 pushq %rbp
0000000100000f51 movq %rsp, %rbp
0000000100000f54 popq %rbp
0000000100000f55 retq
0000000100000f56 nopw %cs:(%rax,%rax)
_main:
0000000100000f60 pushq %rbp
0000000100000f61 movq %rsp, %rbp
0000000100000f64 subq $0x20, %rsp
0000000100000f68 movl $0x0, -0x4(%rbp)
0000000100000f6f movl %edi, -0x8(%rbp)
0000000100000f72 movq %rsi, -0x10(%rbp)
0000000100000f76 callq 0x100000f50 // foo 函数首地址
0000000100000f7b movl $0x1, %edi
0000000100000f80 movl $0x2, %esi
0000000100000f85 callq 0x100000fa0 // sum 函数首地址
0000000100000f8a xorl %esi, %esi
0000000100000f8c movl %eax, -0x14(%rbp)
0000000100000f8f movl %esi, %eax
0000000100000f91 addq $0x20, %rsp
0000000100000f95 popq %rbp
0000000100000f96 retq
0000000100000f97 nop
0000000100000f98 nop
0000000100000f99 nop
0000000100000f9a nop
0000000100000f9b nop
0000000100000f9c nop
0000000100000f9d nop
0000000100000f9e nop
0000000100000f9f nop
_sum:
0000000100000fa0 pushq %rbp
0000000100000fa1 movq %rsp, %rbp
0000000100000fa4 movl %edi, -0x4(%rbp)
0000000100000fa7 movl %esi, -0x8(%rbp)
0000000100000faa movl -0x4(%rbp), %esi
0000000100000fad addl -0x8(%rbp), %esi
0000000100000fb0 movl %esi, %eax
0000000100000fb2 popq %rbp
0000000100000fb3 retq

这就是静态链接过程中,静态链接器的工作。

但是 iOS 开发中方法的调用会更复杂,涉及到 runtime 和 dyld,大致流程为:

上图中,dyld 会将 0xyy 重定向为 objc_msgSend() 指令块的地址(运行时完成,动态链接)。- foo 的首地址被存储在名为 Foo 的类对象中(类似于 C++ 的虚函数表)。然后该指令块会在运行时被调用。了解更多

五、加载

dyld 会将链接完成的可执行文件加载到内存中:

  • __TEXT,__text 中的指令拷贝到虚拟内存的 .rodata(readonly) 中。
  • __DATA,__data 中的全局和静态变量拷贝到虚拟内存的 .rwdata (readwrite)中。
  • 使用 .symbol 中的符号完成动态链接。
  • 初始化堆栈,从 main() 函数开始执行程序。

其中涉及到的 + load 函数,参见 dyld 和链接

六、显示

参见优化APP的显示性能

七、结语

现在知道我们写完的代码是怎么转换成机器能明白的语言了吧。