菜鸟的自我学习之路。
本文介绍程序的编译原理以及运行过程中发生了什么。
程序编译过程
我们的源程序实际上就是一个由0和1组成的位(又成为比特)序列,这些位被组织成8个一组,称为字节,每个字节都表示程序中某个文本字符。大部分的操作系统都使用ASCII标准来表示文本字符,这种方式实际上就是用一个唯一的字节大小的整数值来表示每个字符。
为了能够在系统上运行我们的代码,每条c语言都必须被其他程序转换为一系列的低级机器语言指令。然后这些指令按照一种称为可执行目标程序的格式打包好,并以二进制磁盘文件的形式存放起来。目标程序也称为可执行目标文件。在Unix系统上,从源文件到目标文件的转换是由编译器驱动程序完成的,如gcc。对于一个简单的程序,如hello.c,
#include <stdio.h>
int main(int argc, char* argv[])
{
printf("%d\n",1111);
}gcc -o hello hello.c 在这里gcc编译驱动程序读取源程序hello.c,并将其翻译成一个可执行目标文件hello。这个编译过程是分四个基本完成的。如图,执行这四个阶段的程序(预处理器,编译器,汇编器,链接器)一起构成了编译系统。
预处理阶段:预处理器(cpp)根据以字符#开头的命令,修改原始的c程序。比如hello.c中的第一行的#include<stdio.h>指令告诉预处理器读取系统头文件stdio.h的内容,并把其直接插入到程序文本中去。结果得到另一个c程序,通常是以
.i作为文件扩展名。shellgcc -E hello.c -o hello.i编译阶段:编译器(ccl)将文本文件
hello.i翻译成文本文件hello.s,它包含一个汇编语言程序。汇编语言程序中的每条语句都以一种标准的文本格式确切描述了一条低级机器语言指令。汇编程序是非常有用的,因为它为不同高级语言程序中的不同编译器提供了通用的输出语句。shellgcc -S hello.i -o hello.s汇编阶段:接下来汇编器(as)将
hello.s翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序格式。并将结果保存在目标文件hello.o中。hello.o文件是一个二进制的文件,它的字节编码是机器语言指令而不是字符,如果我们在文本编译器中打开hello.o文件,呈现的将是一堆乱码。shellgcc -c hello.s -o hello.o链接阶段:请注意,我们的hello程序调用了printf函数,它是标准c库中的一个函数,每个c编译器都提供。printf函数存在于一个叫printf.o的单独的预编译目标文件中,而这个文件必须以某种方式并入到我们的hello.o程序中。链接器(ld)就是负责这种并入,结果得到一个hello文件,它是一个
可执行目标文件(或称可执行文件)。可执行程序加载到存储器后,由系统负责执行。shellgcc hello.o -o hello
cpp的编译过程同理,只是我们的编译成换成了g++,上面分步编译的命令同样适用
理解编译系统如何工作的好处
优化程序性能
现代编译器都是成熟的工具,通常可以生成很好的代码。作为程序员,我们无需为了写出高效的代码而去了解编译器内部的工作。但是为了在我们的程序里面做出好的代码选择,我们确实需要对汇编语言以及编译器如何将不同的c语句转换为汇编语言有一些基础的了解。比如:一个switch语句是不是总是比一系列的if-else语句高效呢?一个函数的代价有多少?while循环比do循环更有效吗?指针引用比数组引用更有效吗?
理解链接时出现的错误
根据我们的经验,一些令人困扰的程序错误往往都与链接器操作有关,尤其是当你试图建立大型的软件系统时。比如,链接器报告说它无法解析一个引用,这是什么意思?静态变量和全局变量的区别是什么?静态库和动态库的区别是什么?
避免安全漏洞
近年来,缓冲区的溢出错误造成了大多数的网络和Internet服务器上的安全漏洞。这些错误的存在是因为大多数的程序员忽视了编译器用来为函数产生代码的堆栈规则。
程序运行时发生了什么
为了了解运行时hello程序发生了什么,我们需要理解一个典型系统的硬件组织,下图是Inter Pentium系统产品族的模型,
CPU:中央处理器;ALU:算术/逻辑单元;PC:程序计数器;USB:通用串行总线
总线
贯穿整个系统的是一组电子管道,成为总线,它携带信息字节并负责在各个部件之间传递。通常总线被设计成传送定长的字节快,也就是字(word)。字中的字节数(即字长)是一个基本的系统参数,各个系统中也不尽相同。比如,Inter Pentiu系统的字长是4个字节,而服务器类的系统,比如Inter Itaninums和高端的Sun公司的SPARCS的字长是8个字节.用于汽车和工业中的嵌入式控制器之类较小的系统的字长往往只有一或两个字节.
IO设备
IO设备是系统与外界的联系通道.我们的示例系统包含了四个io设备:作为用户输入的键盘和鼠标,作为用户输出的显示器,以及用于长期存储数据和程序的磁盘驱动器(简单的说就是磁盘).
每个IO设备都是通过一个控制器和适配器与IO总线进行连接的.控制器和适配器之间的区别主要在于它们的组成方式.控制器是IO设备本身中或者系统的主印制电路板(通常称为主板)上的芯片组,而适配器则是一块插在主板插槽上的卡.无论无何,它们的功能都是在IO总线和IO设备之间传递信息.
主存
主存是一个临时存储设备,在处理器执行程序时,它被用来存放程序和程序处理的数据.物理上来说,主存是由一组DRAM(动态随机存储器)芯片组成的。逻辑上来说,存储器是由一个线性的字节数组组成的,每个字节都有字节唯一的地址(数组索引),这些地址是从0开始的。一般来说,组成程序的每条机器指令都由不定量的字节构成。与C程序变量相对应的数据项的大小是根据类型变化的。比如,运行LInux的Inter机器上,short类型的数据需要2个字节,int,float和long类型需要4个字节,而double类型需要8个字节。
处理器
中央处理单元(CPU)简称处理器,只解释(或执行)存储在主存中指令的引擎。处理器的核心是一个被称为程序计数器(PC)的字长大小的存储设备(或寄存器。在任何时间点上,PC都指向主存中的某条机器语言指令(内含其地址)。
从系统通电开始,直到系统断电,处理器一直在不假思索的重复执行相同的基本任务:从程序计数器指向的存储器处读取指令,解释指令中的位,执行指令指示的简单操作,然后更新程序计数器指向下一条指令,而这条指令并不一定在存储器中和刚刚执行的指令相邻。
这样简单操作的数目并不多,它们在主存,寄存器文件和算法逻辑单元(ALU)之间循环。寄存器文件是一个小的存储设备,由一些字长大小的寄存器组成,这些寄存器每个都有唯一的名字。ALU计算新的数据和地址值。
程序运行
我们做一个粗略的描述:
首先,shell程序执行它的指令,等待我们输入命令。当我们在键盘输入字符串./hello后,shell程序就逐一读取字符到寄存器,再把它存储到存储器中,如下图。
当我们在键盘上敲回车键时,shell就知道我们已经结束了命令的输入。然后shell执行一系列的指令将hello目标文件中的代码和数据从磁盘拷贝到主存中,从而加载hello文件。
利用称为DMA(直接存储器存取)的技术,数据可以不通过处理器而直接从磁盘到达主存,如图
一旦hello目标文件中的代码和数据被加载到了存储器,处理器就开始执行hello程序的主程序中的机器指令。这些指令将hello world串中的字节从存储器拷贝到寄存器文件,再从寄存器中文件拷贝到显示设备,最终显示到设备上,如图
通过这个简单的示例我们了解到了重要的一课,那就是系统花费了大量的时间把信息从一个地方挪到另一个地方。hello程序的机器指令最初存放在磁盘上,当程序加载的时候,它们被拷贝到主存。当处理器运行程序时,指令由从存储器拷贝到处理器。从一个程序员的角度来看,大量的拷贝减慢了程序实践的工作。因此,一个系统设计者的一个主要的目标就是使这些拷贝操作尽可能的快。下图是存储器层次模型的示例。
操作系统管理硬件
当shell加载和运行hello程序时,当hello程序输出自己的消息时,程序没有直接的访问键盘,显示器,磁盘或者主存储器.取而代之的是它们依靠操作系统提供的服务。我们可以把操作系统看成是应用程序和硬件之间插入的一层软件如下图,所有的应用程序对硬件的操作都必须通过操作系统。
操作系统的两个基本的功能:
- 防止硬件被失控的应用程序滥用;
- 在控制复杂而又通常广泛不同的低级硬件设备方面,为应用程序提供简单一致的方法;
操作系统通过下面图片显示的几个基本的抽象概念(进程,虚拟存储器,文件)来实现上述的两个功能。
- 文件:对IO设备的抽象表示;
- 文件只不过就是字节序列。每个IO设备,包括磁盘,键盘,显示器,甚至是网络,都可以被看成是文件。系统中的所有输入和输出都是通过使用称为Unix IO的一小组系统函数调用读写文件来实现的
- 虚拟存储器:对主存和磁盘IO设备的抽象表示;
- 它为每个进程提供一个假象,好像每个进程都在独占地使用主存。每个进程看到的存储器都是一致的,称为虚拟地址空间,如下图是Linux进程的虚拟地址空间(其他的Unix系统的设计与此类似)。在Linux中,最上面的四分之一的地址空间是预留给操作系统中的代码和数据的,这对所有的进程都一样。底部的四分之三的地址空间用来存放用户进程定义的代码和数据。请注意图中的地址是从下往上增大的
每个进程进程看到的虚拟地址空间由大量准确定义的区(area)构成,每个区都有专门的功能。 - 程序代码和数据:代码是从同一固定地址开始的,紧接着的是和C全局变量相对应的数据区。代码和数据区是由可执行目标文件直接初始化的;
- 堆:代码和数据区后紧随的就是运行时堆。代码和数据区是进程一旦开始运行时就被指定了大小,与次不同,作为调用了向malloc和free这边的c标准库函数的结果,堆可以在运行时动态的扩展和收缩;
- 共享库:在地址空间的中间附近是一块用来存放像C标准库和数学库这样共享库的代码和数据的区域;
- 栈:位于用户虚拟地址空间顶部的是用户栈,编译器用它来实现函数的调用。和堆一样,用户栈在程序执行期间可以动态的扩展和收缩。特别的,每次我们调用一个函数时,栈就会增长。每次我们从函数返回时,栈就会收缩;
- 内核虚拟存储器。内核是操作系统总是驻留在存储器中的部分。地址空间顶部的四分之一的部分是为内核预留的。应用程序不允许读写这块区域的内容或者直接调用内核代码定义的函数。
- 它为每个进程提供一个假象,好像每个进程都在独占地使用主存。每个进程看到的存储器都是一致的,称为虚拟地址空间,如下图是Linux进程的虚拟地址空间(其他的Unix系统的设计与此类似)。在Linux中,最上面的四分之一的地址空间是预留给操作系统中的代码和数据的,这对所有的进程都一样。底部的四分之三的地址空间用来存放用户进程定义的代码和数据。请注意图中的地址是从下往上增大的
- 进程:对处理器,主存,和IO设备的抽象表示;

Unix和Posix

Linux项目
1991年8月,一个名为Linux Torvalds的芬兰研究生谨慎的发布了一个新的类Unix的操作系统内核。
参考
全文皆是摘录自《深入理解计算机系统》一书,用于自己的学习和理解,若存在侵权请联系作者。
评论