从代码到进程

o4YBAFrWqa6AY9qWAABbbODkiAc279.png

每天都有无数的程序被编译、部署,不停地跑着,它们干着千奇百怪的事情。如同这个光怪陆离的世界,是由每个人、每个个体组成的,如果我们剖析每个人,会发现他们其实都是一样的结构,都是由细胞、组织组成,再深究便是基因了,DNA里那一个个的“核苷酸基”决定了他们。

Stay hungry, Stay Foolish!

参考链接

了解“预编译、编译、汇编、链接”这四个过程对你有很大帮助

程序的一生:从源程序到进程的辛苦历程

从编写源代码到程序在内存中运行的全过程解析

从一个可执行文件的生成到进程在内存中分布 (中)/文件到进程的转变

从源代码到进程

1、程序员利用高级语言编写源代码(如C++代码 )。

2、预编译:主要处理源代码文件中的以“#”开头的预编译指令。

3、编译:编译就是把文本形式源代码翻译为机器语言形式的目标文件(模块)的过程。

4、汇编:将汇编代码转变为及其机器码

5、链接:链接是把目标文件、操作系统的启动代码和用到的库文件进行组织形成最终生成一个完整的装入模块(可执行文件)的过程。由链接程序将目标模块,以及所需的库函数链接在一起,可执行文件。

o4YBAFrWqa6AY9qWAABbbODkiAc279.png

6、装载至内存:操作系统执行可执行文件的过程,就是将完整的装入模块(可执行文件)加载至内存,在内存生成由程序段、数据段、进程控制块(PCB)这三个部分组成的进程实体(进程映像)的过程。在这个过程中需要虚拟内存技术的支持。

Snipaste_2020-04-03_11-28-21.png

进程是进程实体的运行过程,系统进行资源分配和调度的一个基本单位。

将源程序变为内存中的可执行程序,需要经过:以上几个步骤,对应的在linux下的命令为:

1
2
3
4
5
gcc -E main.c -o main.i  # 预编译,生成main.i文件
gcc -S main.i # 编译,生成main.S文件
gcc -c main.S # 汇编,生成main.o文件
gcc main.o -o main # 链接,生成完整的装入模块/可执行文件
./main # 加载(执行),将完整的装入模块/可执行文件加载至内存,分配虚拟

基本概念

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int gdata1 = 10;
int gdata2 = 0;
int gdata3;

static int gdata4 = 11;
static int gdata5 = 0;
static int gdata6;

int main(void)
{
int a = 12;
int b = 0;
int c;

static int d = 13;
static int e = 0;
static int f;
return 0;
}

什么是数据

大家平时口中经常说程序是由程序代码、数据和进程控制块组成,但是很多人却不知道什么是数据。这里我们搞清楚两件事情,一是什么是数据,二是数据存放在哪里。

(1)、数据 数据指的是程序中定义的全局变量和静态变量。还有一种特殊的数据叫做常量。所以上面的的gdata1gdata2gdata3gdata4gdata5gdata6def 均是数据。

(2)、数据存放在哪里 数据存放的区域有三个地方:.data 段、.bss 段 和 .rodata 段。那么你肯定想知道数据是如何放在这三个段中的,怎么区分。

对于初始化不为0的全局变量和静态变量存放在 .data 段,即 gdata1gdata4d 存放在 .data 段。 对于未初始化或者初始化值为0的段存放在 .bss 段中,不占目标文件的空间,即gdata2gdata3gdata5gdata6ef 存放在 .bss 段。 而对于字符串常量则存放在 .rodata 段 中,而且对于字符串而言还有一个特殊的地方,就是它在内存中只存在一份。给个代码来测试:

1
2
3
4
5
6
7
8
9
#include<stdio.h>

int main(void){
const char *pStr1 = "hello,world";
const char *pStr2 = "hello,world";
printf("0x%x\n", pStr1);
printf("0x%x\n", pStr2);
return 0;
}

可以验证一下,输出的地址肯定是一样的。因为常量字符串 “hello,world” 只存在一份。

什么是指令

说完了数据,那什么是指令呢?也就是什么是程序代码。很简单,程序中除了数据,剩下的就都是指令了。这里有一个容易混淆的地方,如下面的代码:

1
2
3
4
5
6
7
8
9
10
#include<stdio.h>

int main(){

int a = 10;
int b = 20;
printf("a+b=%d\n", a + b);

return 0;
}

大家可能会有一个疑问,就是对于上面的代码,ab 明明是局部变量,难道不是数据吗?嗯,它真的不是数据,它是一条指令,这条指令的功能是在函数的栈帧上开辟四个字节,并向这个地址上写入指定值。

什么是符号

说完数据和指令,接下来是另一个基础而且重要的概念,那就是符号。我们在编写程序完,进行链接时会碰到这样的错误:错误 LNK1169 找到一个或多个多重定义的符号,即符号重定义。那什么是符号,什么东西会产生符号,符号的作用域又是怎样的呢?

在程序中,所有数据都会产生符号,而对于代码段只有函数名会产生符号。而且符号的作用域有 globallocal 之分。 对于未用 static 修饰过的全局变量产生的符号和函数产生的符号均是 global 符号,这样的变量和函数可以被其他文件所看见和引用。 而使用 static 修饰过的变量和函数产生的符号,作用域仅局限于当前文件,不会被其他文件所看见,即其他文件中也无法引用 local 符号的变量和函数。

对于上面的 “找到一个或多个多重定义的符号” 错误原因有可能是多个文件中定义同一个全局变量或函数,即函数名或全局变量名重了。

预编译(源代码 -> 替换)

预编译阶段将根据已放置在文件中的预处理指令修改源文件的内容。如#include指令就是一个预处理指令,它把头文件的内容添加到 *.cpp 文件中。预编译提供了很大的灵活性,以适应不同的计算机和操作系统环境的限制。

一个环境需要的代码跟另一个环境所需的代码可能有所不同,因为可用的硬件或操作系统是不同的。在许多情况下,可以把用于不同环境的代码放在同一个文件中,再在预处理阶段修改代码,使之适应当前的环境。

预编译的处理

  • 宏定义指令,如 #define a b

对于这种伪指令,预编译所要做的是将程序中的所有a用b替换,但作为字符串常量的 a则不被替换。还有 #undef,则将取消对某个宏的定义,使以后该串的出现不再被替换。

  • 条件编译指令,如 #ifdef#ifndef#else#elif#endif 等。

这些伪指令的引入使得程序员可以通过定义不同的宏来决定编译程序对哪些代码进行处理,过滤掉那些不必要的代码。

  • 头文件包含指令,如 #include "FileName" 或者 #include 等。

头文件中一般用伪指令#define定义了大量的宏(最常见的是字符常量),同时包含有各种外部符号的声明。采用头文件的目的主要是为了使某些定义可以供多个不同的C源程序使用。因为在需要用到这些定义的C源程序中,只需加上一条#include语句即可,而不必再在此文件中将这些定义重复一遍。预编译程序将把头文件中的定义统统都加入到它所产生的输出文件中,以供编译程序对之进行处理。

系统提供的头文件,编译器的类库路径里面的头文件,引入格式为 #include <***> 。 程序目录的相对路径中的头文件,引入格式为 #include "***"

  • 特殊符号,预编译程序可以识别一些特殊的符号。

在源程序中出现的LINE标识将被解释为当前行号(十进制数),FILE则被解释为当前被编译的C源程序的名称。预编译程序对于在源程序中出现的这些串将用合适的值进行替换。

预编译总结

预编译程序所完成的基本上是对源程序的“替代”工作。经过此种替代,生成一个**没有宏定义、没有条件编译指令、没有特殊符号的输出文件(*.i文件)。**这个文件的含义同没有经过预处理的源文件是相同的,但内容有所不同。通过预编译来提供程序员写代码时的方便。

1
gcc -E main.c -o main.i  # 预编译,生成main.i文件

编译(源代码 -> 汇编代码)

经过预编译得到的输出文件中,只有常量;如数字、字符串、变量的定义,以及c语言的关键字,如 main, if, else, for, while, {, }, +, -, *, \ 等等。

编译程序所要作得工作就是通过词法分析和语法分析,在确认所有的指令都符合语法规则之后,将其翻译成等价的中间代码表示或汇编代码

优化处理是编译系统中一项比较艰深的技术。它涉及到的问题不仅同编译技术本身有关,而且同机器的硬件环境也有很大的关系。

优化一部分是对中间代码的优化,这种优化不依赖于具体的计算机。主要的工作是删除公共表达式、循环优化(代码外提、强度削弱、变换循环控制条件、已知量的合并等)、复写传播,以及无用赋值的删除,等等。

另一种优化则主要针对目标代码的生成而进行的。同机器的硬件结构密切相关,最主要的是考虑是如何充分利用机器的各个硬件寄存器存放的有关变量的值,以减少对于内存的访问次数。另外,如何根据机器硬件执行指令的特点(如流水线、RISC、CISC、VLIW等)而对指令进行一些调整使目标代码比较短,执行的效率比较高。

1
gcc -S main.i            # 编译,生成main.S文件

汇编(汇编代码 -> 机器指令文件/模块)

汇编实际上指把汇编语言代码翻译成目标机器指令的过程。对于被翻译系统处理的每一个C语言源程序,都将最终经过这一处理而得到相应的目标文件。目标文件中所存放的也就是与源程序等效的目标的机器语言代码/机器指令。目标文件由段组成,通常一个目标文件中至少有两个段: 代码段:该段中所包含的主要是程序的指令。该段一般是可读和可执行的,但一般却不可写。 数据段:主要存放程序中要用到的各种全局变量或静态的数据。一般数据段都是可读,可写,可执行的。

UNIX环境下主要有三种类型的目标文件

1、可重定位文件 其中包含有适合于其它目标文件链接来创建一个可执行的或者共享的目标文件的代码和数据。

2、共享的目标文件 这种文件存放了适合于在两种上下文里链接的代码和数据,换而言之,这种文件可以用于两种链接方式。 - 第一种是链接程序可把它与其它可重定位文件及共享的目标文件一起处理来创建另一个目标文件。(静态链接方式) - 第二种是动态链接程序将它与另一个可执行文件及其它的共享目标文件结合到一起,创建一个进程映象(进程实体)(动态链接方式)。

3、可执行文件 它包含了一个可以被操作系统创建一个进程来执行之的文件。

汇编程序生成的实际上是第一种类型的目标文件。对于后两种还需要其他的一些处理方能得到,这个就是链接程序的工作了。

1
gcc -c main.S            # 汇编,生成main.o文件

链接(机器指令文件/模块 -> 可执行文件/装入模块)

汇编程序生成的目标文件并不能立即就被执行,其中可能还有许多没有解决的问题。例如,某个源文件中的函数可能引用了另一个源文件中定义的某个符号。或者,在程序中可能调用了某个库文件中的函数,等等。也就是存在跨文件的变量或者函数调用。所有的这些问题,都需要经链接程序的处理方能得以解决。

链接程序的主要工作就是将有关的目标文件彼此相连接,也即将在一个文件中引用的符号同该符号在另外一个文件中的定义连接起来,使得所有的这些目标文件成为一个能够被操作系统装入执行的统一整体(可执行文件),又称装入模块(Load Module)。

根据开发人员指定的同库函数的链接方式的不同,链接处理可分为两种:

静态链接

在这种链接方式下,函数的代码将从其所在地静态链接库中被拷贝到最终的可执行程序中。 这样该程序在被执行时这些代码将被装入到该进程的虚拟地址空间中静态链接库实际上是一个目标文件的集合, 其中的每个文件含有库中的一个或者一组相关函数的代码。

静态链接得到的可执行对象,被操作系统执行之后,直接就能创建进程对象。

动态链接

在此种方式下,函数的代码被放到称作是动态链接库或共享对象的某个目标文件中。链接程序此时所作的只是在最终的可执行程序中记录下共享对象的名字以及其它少量的登记信息。在此可执行文件被执行时,动态链接库的全部内容将被映射到运行时相应进程的虚地址空间。动态链接程序将根据可执行程序中记录的信息找到相应的函数代码。

静态链接得到的可执行对象,被操作系统执行之后,需要根据可执行程序中记录的信息找到相应的函数代码。

优缺点

静态链接库

在链接时,先将目标模块及所需库函数,静态链接库函数lib(Statically-linked library)文件和动态链接库函数dll(Dynamic Linkable Library)文件,链接成一个完整的可执行程序(EXE文件),此时的可执行程序是包含复制了一份所需库函数,以后不再拆开。

优点:

  • 代码装载速度快,执行速度略比动态链接库快。
  • 只需保证在开发者的计算机中有正确的.LIB文件,在以二进制形式发布程序时不需考虑在用户的计算机上.LIB文件是否存在及版本问题

缺点: - 空间浪费:因为每个可执行程序中对所有需要的目标文件都要有一份副本,所以如果多个程序对同一个目标文件都有依赖,会出现同一个目标文件都在内存存在多个副本。 - 更新困难:每当库函数的代码修改了,这个时候就需要重新进行编译链接形成可执行程序。

Snipaste_2020-04-03_21-00-06.png

动态链接库

对于某些目标模块的链接,是在程序执行中需要该目标模块时,才对它进行链接。

优点: - 更加节省内存并减少页面交换。 使用动态链接能够使最终的可执行文件比较短小,并且当共享对象被多个进程使用时能节约一些内存,因为在内存中只需要保存一份此共享对象的代码。 - DLL文件与EXE文件独立,只要输出接口不变(即名称、参数、返回值类型和调用约定不变),更换DLL文件不会对EXE文件造成任何影响,因而极大地提高了可维护性和可扩展性; - 适用于大规模的软件开发,使开发过程独立、耦合度小, 便于不同开发者和开发组织之间进行开发和测试。 - 不同编程语言编写的程序只要按照函数调用约定就可以调用同一个DLL函数。

缺点: - 使用动态链接库的应用程序不是自完备的,它依赖的DLL模块也要存在,如果使用载入时动态链接,程序启动时发现DLL不存在,系统将终止程序并给出错误信息。而使用运行时动态链接,系统不会终止,但由于DLL中的导出函数不可用,程序会加载失败。 - 速度比静态链接慢。当某个模块更新后,如果新模块与旧的模块不兼容,那么那些需要该模块才能运行的软件,统统死掉。

1
2
gcc main.o -o main       # 链接,生成可执行文件,无后缀。
ld # 链接

内存加载

这个阶段涉及了虚拟存储器的工作。 创建一个进程,然后装载相应的可执行文件(完整的装入模块)并且执行。 上述过程最开始只需要做三件事情。

分配页目录

创建一个独立的虚拟地址空间。主要是分配一个页目录(Page Directory),关于页目录详见多级分页存储方式

基于页目录装载相应的可执行文件

读取可执行文件的头,并且基于分页存储方式来建立虚拟空间和可执行文件的映射关系。 主要是把可执行文件映射到虚拟地址空间,即做虚拟页和物理页的映射,以便“缺页”中断产生时载入。

虚拟内存的存储结构

Snipaste_2020-04-06_12-58-09.png

前面什么是数据说过,一个程序本质上都是由.data 段、.rodata段 和 .bss 段 三个段组成的。一个可执行程序(装入模块)在辅存(没有调入内存)时分为代码段、已初始数据区(.data段 和 .rodata段 )和未初始化数据区(.bss段)三部分。

未初始化数据区.bss段):通常用来存放程序中未初始化的全局变量和静态变量的一块内存区域。.bss段属于静态分配,程序结束后静态变量资源由系统自动释放。.bss段并不占用可执行文件的大小,它是由链接器来获取内存的

已初始化数据区.data段 和 .rodata段 ):存放程序中已初始化的全局变量的一块内存区域,数据段也属于静态内存分配,.data段 和 .rodata段在编译时已经分配了空间

代码段:存放程序执行代码的一块内存区域。这部分区域的大小在程序运行前就已经确定,并且内存区域属于只读。在代码段中,也有可能包含一些只读的常数变量。

已初始化数据区包含经过初始化的全局变量以及它们的值。未初始化数据区的大小从可执行文件中得到,然后链接器得到这个大小的内存块,紧跟在已初始化数据区的后面。当这个内存进入程序的地址空间后全部清零。 包含已初始化数据区未初始化数据区的整个区段此时通常称为数据区

可执行程序在运行时又多出两个区域:栈区和堆区。

栈区(系统使用)

由编译器自动释放,存放函数的参数值、局部变量等。每当一个函数被调用时,该函数的返回类型和一些调用的信息被存放到栈中。然后这个被调用的函数再为他的自动变量和临时变量在栈上分配空间。每调用一个函数一个新的栈就会被使用栈区是从高地址位向低地址位增长的,是一块连续的内存区域,最大容量是由系统预先定义好的,申请的栈空间超过这个界限时会提示溢出,用户能从栈中获取的空间较小。

堆区(程序员使用)

用于动态分配内存,位于未初始化数据区栈区中间的地址区域。由程序员申请分配和释放。堆是从低地址位向高地址位增长,采用链式存储结构,因此堆的效率比栈要低的多。频繁的malloc/free 造成内存空间的不连续,产生碎片。当申请堆空间时库函数是按照一定的算法搜索可用的足够大的空间。

设置指令寄存器

将CPU的指令寄存器设置成可执行文件的入口地址,启动运行。从ELF文件(一种用于二进制文件、可执行文件、目标代码、共享库和核心转储格式文件)中的入口地址开始执行程序。

到这里,一个源代码经过预编译、编译、汇编、链接、内存加载,在最后一步将CPU指令寄存器设置为该可执行文件的入口地址,就可以开始运行了。

Python程序与C++程序执行过程的不同

编程语言根据执行过程,可以分为3种类型:

1、直接编译型编程语言:编译型语言编译、汇编过程,实际上就是将高级语言翻译成计算机能够直接理解的机器指令文件(机器指令模块),最典型的例子是C++。编译型语言在程序运行之前就已经对程序做出了“翻译”,所以在运行时就少掉了“翻译”的过程,所以效率比较高。一些解释型语言也可以通过解释器的优化来在对程序做出翻译时对整个程序做出优化,从而在效率上超过编译型语言。

2、直接解释型编程语言:解释型语言就没有这个编译过程,而是在程序运行的时候,通过解释器对程序逐行做出解释,逐行加载,然后运行,最典型的例子是Ruby。

3、基于虚拟机技术的先编译后解释型编程语言:先是通过编译器编译成字节码文件,然后在运行时通过解释器(虚拟机)给解释成机器文件。最典型的例子是Java。

1
2
javac hello.java
java hello

所以我们说Java是一种先编译后解释的语言。

Python是怎么回事?

而Python也是一种基于虚拟机的先编译后解释型编程语言,在说这个问题之前,我们先来说两个概念,PyCodeObjectpyc 文件。 我们在硬盘上看到的 pyc 自然不必多说,而其实 PyCodeObject 则是Python编译器真正编译成的结果。 当Python程序第一次运行时,编译的结果是直接保存在位于内存中的 PyCodeObject ,当Python程序运行结束时,Python解释器则将 PyCodeObject 写回到 pyc 文件中。 当Python程序第二次运行时,首先程序会检测源代码是否修改,如果修改了就重新编译,否则就直接在硬盘中寻找对应的 pyc 文件,如果找到,则直接载入,否则就重复第一次运行时的操作。 所以我们应该这样来定位 PyCodeObjectpyc 文件:pyc 文件其实是 PyCodeObject 的一种持久化保存方式。

我们之所以要把 py 文件编译成 pyc 文件,最大的优点在于我们在运行程序时,不需要重新对该模块进行再次解释。

但是,并非所有的 *.py 文件都会生成 *.pyc 文件,需要编译成 pyc 文件的应该是那些可以重用的模块,这与我们在设计类时是一样的目的。所以Python的解释器认为:只有import进来的模块,才是需要被重用的模块。

Python检测 py 源码文件是否修改或者 pyc 文件是否过期的具体机制

在将 PyCodeObject 写入到 pyc 文件的时候,写了一个 Long 型变量,变量的内容则是对应的 py 文件的最近修改日期,Python解释器每次在载入 pyc 文件之前都会检查一下pyc 文件的这个 long 值和 pyc 文件对应的 py 文件最后修改日期是否一致,如果不一致则重新生成新的pyc 文件。

进一步思考

其实了解Python程序的执行过程对于大部分程序员来说意义都是不大的,那么真正有意义的是,我们可以从Python解释器的做法上学到一些处理问题的方式和方法:

1、在Python中判断是否生成pyc文件和我们在设计缓存系统时是一样的,我们可以仔细想想,到底什么是值得扔在缓存里面 的,什么是不值得的。 2、在运行一个耗时的Python脚本时,我们如何能够做到稍微压榨一些程序的运行时间呢?就是将模块从主模块分开。(虽然往往这都不是瓶颈) 3、在设计一个软件系统时,重用和非重用的东西是不是也可以分开来对待,这是软件设计原则的重要部分。 4、在设计缓存系统(或者其他系统)时,我们如何来避免程序的过期,其实Python解释器为我们提供了一个特别常见而且有效的解决方案。

参考链接

Python程序的执行过程(解释型语言和编译型语言)

每日一言

合抱之木,生于毫末;九层之台,起于垒土;千里之行,始于足下。 积土成山,风雨兴焉;积水成渊,蛟龙生焉;积善成德,而神明自得,圣心备焉。故不积跬步,无以至千里;不积小流,无以成江海。骐骥一跃,不能十步;驽马十驾,功在不舍。锲而舍之,朽木不折;锲而不舍,金石可镂。蚓无爪牙之利,筋骨之强,上食埃土,下饮黄泉,用心一也。蟹六跪而二螯,非蛇鳝之穴无可寄托者,用心躁也。