本文从一个最简单C程序(hello)是如何被编译和链接讲起,描述了一个程序运行的原理。
一、程序运行之前
使用IDE(集成开发环境)的朋友们经常会有这样的疑问:代码是怎么从一个文本文件变成可执行程序的呢?代码毕竟不是咒语,一个c程序在被运行之前其实经过了四个步骤,两次编译,汇编和链接。
1.预编译
这里我们只需要知道有一个叫预处理器(preprocessor也称预编译器)的程序会对c程序进行一些预处理工作。比如对一些宏定义的变量进行替换。
2.编译
编译的过程中,编译器(compiler)把C语言程序翻译成汇编语言程序,一条c语句通常需要好几条汇编代码来取代,C编译器为了提高程序执行的效率有时候会对程序进行优化,这就是为什么即使在c程序中声明了register变量,程序也不一定会用。了解编译器这个特性对程序员来说也很重要,比如程序员可以通过指令告诉编译器是生成“易于调试”(debug)还是“代码尽可能小”(release)的版本。
3.汇编
编译得到的汇编语言通过汇编器(assembler)再汇编成可重定位目标程序hello.o,与之相反的一个过程被称为反汇编(disassemble)。
4.链接(Link)
hello.0不能被系统直接运行,而且通常情况下.o有很多个,程序中还要包含一些共享目标文件,比如hello程序中的printf函数,它位于静态库中,需要用链接器(linker)进行链接,Unix的连接器就是大名鼎鼎的ld。
printf的声明在头文件stdio.h中,如果在安装vc6.0时选择了“安装CRT源码”就可以在VC98\CRT\SRC目录找到printf.c,里面函数完整的定义。事实上很多编译器套装(比如gcc)为了提高编译的效率,已经把这个头文件中牵涉到的所有函数分别编译成单独模块并最后打包成了一个文件(放在系统固定的文件夹中),这个文件就是所谓的静态库,windows中后缀名是.lib,unix是.a,当我们link的时候,只需要在指定库中找到printf对应的那部分二进制代码添加到程序中就行了。从理论上讲hello.c中有几个printf,就会在可执行文件中嵌入几次printf的二进制模块,而且当系统内有多个hello同时运行时每个hello都会维护一段属于自己的printf,这样做显然是一种浪费。
使用共享库(shared library)可以解决这个问题,共享库也是一个目标模块(后缀名.so),它在程序运行之前会被加载到存储器中某一个特定的区域(linux中,是从地址0×40000000开始的一段区域),并和用到它的程序链接起来,这个过程被称为动态链接,因此共享库在windows中又被称为动态链接库(DLL)。比如hello在链接时其实并没有把printf模块加到可执行程序中,而只是告诉我们的hello一声,待会要用到printf的时候去共享库里找xx就行了。链接是程序再被真正执行前一个极其重要的步骤,但由于IDE给别人造成的错觉,很多程序员居然不知道有这么一步。
经过以上几个步骤,hello.c已经变成了可执行程序hello,我们在shell中输入./hello,屏幕上打印出“hello,world”。gcc提供了以上这些工具的一个集合,我们通常把gcc叫做一个编译器,其实是不完整的,编译器只是gcc的一个部分,gcc的全称应该是gnu编译器套装(GNU Compiler Collection)。
二、存储器中的hello
我们知道可执行程序在被CPU执行以前存在于内存中,于是我们很快就有了新的疑问,二进制代码在内存中长什么样?内存其实是个模棱两可的叫法,如果说世界上只有两种存储设备,那么说其中一个是内存另一个是外存就不会有争议,但是站在CPU角度看,cache明显要比我们的内存条要内多了,而站在U盘的角度,硬盘也顿时变成了内存。内和外永远是相对的,比较科学的称呼应该是dram(读音为/draem/,即动态随机存储器)。既然有动态随机存储器(dynamic ram),也就有静态随机存储器(static ram),CPU内部的高速缓存用的就是用sram。
在了解存储器之前我们先来区分一下进程和程序这两个概念,维基百科上找到定义是:进程是程序执行的一个实例(instance)。这种说法解释了为什么同一个程序在内存中能有很多个进程。有些书上写,进程是程序执行的一个过程,也没有错,但问题是进程本来和过程就是同一个东西(process),我们怎么能用馒头去解释馍馍呢?
因此hello程序和hello进程是两个东西,前者是留在磁盘中的一些磁信号,而后者是系统各种资源(cpu、存储器、IO设备……)共同作用的结果。如果我们要彻底理解hello是怎么运行的,首先就必须hello在内存中的布局有一个比较理性的认识。下面来看一个程序在存储器中的图像。
可能有人要问了,图中存储器的地址空间为什么有4G?(0到0xffffffff),如果计算机的只有1G主存,那岂不是溢出了?事实上现代操作系统采取了一种叫虚拟存储器(virtual memory)的机制来有效地管理存储器,即把系统的存储设备全部隐藏在背后,无论实际的物理存储器(dram)有多大都提供给我们一个固定虚拟的线性空间(32位操作系统就有4G空间),系统在幕后对实际的地址进行映射(可能在dram中,也可能在磁盘上),而我们就感觉自己在使用一台存储器很大的计算机,尽管当实际的dram很小时我们还是感觉很慢,于此同时硬盘灯在不停闪烁。
Linux将虚拟存储器高端的1/4留给内核,剩下3/4全留给用户进程。虚拟存储器上中的程序主要由以下几个重要组成部分:
1.进程控制块(process control block,简称PCB)
PCB中保存了进程hello的运行时的存储器图像和寄存器信息,它帮助操作系统在内存中找到我们的hello程序,如果没有它,hello只是和其它程序杂乱无章地分布在内存中就乱套了。
2.栈(stack)和堆(heap)
程序中的自动变量都位于栈上,而堆则用来让程序员自己手动分配(malloc)和释放(free)的内存空间,如果程序员忘了释放,则有垃圾收集器gc代劳。除此以外,栈还是程序转移中一个很重要的概念,程序的返回地址通常也保存在栈上。
3.文本段(text segment)和数据段(data segment)
所谓的文本段和数据段对应的就是程序的代码部分和全局变量,把程序的代码和数据分开处理是有好处的,比如我们在windows打开好几个word,这些进程只是数据段不同罢了(它们都拥有相同的代码),因此内存中永远只要有一份word的代码就行了。
4.共享库的映射区域
操作系统通过将共享的对象映射到虚拟存储器的“共享区域”来使得代码能够共享,一方面提高存储器的利用率,一方面可以使得进程能够共享一些数据。
如果某一时刻系统中有20个程序正在运行,而这些程序都需要在屏幕上打印东西,系统就没有必要为每个程序都维护一段printf的代码,只要分别从各自的.bss中取出字符串然后用同一个printf完成输出就行了。同样的道理,当有多个hello在系统中运行时,它们也完全可以共享同一个文本段。这也就是为什么会把进程定义为程序的一个实例的原因。不妨回想一下面向对象中对象的概念,我们在写class的时候定义成员字段不就是在分配数据?而定义方法字段不就是在操作这些数据?在对象被实例化以前,这些定义只不过是一些“白纸黑字”,而只有经过实例化,实例们才在存储器中有了自己的映像。而多个实例之间可以共享“方法”(文本)但是独有“成员”(数据)的特点,也和进程如出一辙。
现在我们可以描述hello在存储器中图像了。hello的代码位于文本段中,字符串“hello,world”在只读段中,printf位于共享库的映射区域,程序在执行时用到了用户栈,用户栈从0xbfffffff开始,向下生长。以上的图景只发生在一瞬间,我们难以追踪,要想看清hello的本来模样,还是得在目标文件上做文章。
三、目标文件的格式
1.可重定位目标文件hello.o
这是书上典型的一个elf格式的可重定位目标文件:
ELF Header
.text
.rodata
.data
.bss
.symtab
.rel.text
.rel.data
.debug
.line
.strtab
Section Headers
有兴趣的朋友可以在Unix/Linux下使用readelf这个工具来查看hello.o的具体格式。
2.可执行目标文件a.out
可重定位目标文件(hello.o)离最终的可执行目标文件(a.out)只有一步之遥,这关键的一步就是前面说的链接。
链接通常有两步,第一步是解析符号,符号解析主要用来解决多个模块之间全局字段的协调问题,比如我们在两个.c的文件中都定义了全局变量x,或者引用了不曾定义过的函数foo(),链接器都会报错(link error)。第二步就是重定位,重定位将每个目标模块的节最终合并成一个大的节(section),并且根据rel.text来修改调用外部函数(printf)或者引用任何全局变量(“hello,world”)的指令。hello.o和a.out最大的区别在于,a.out的节头目表为每个节都分配了真实地址,而hello.o中的节头目表只在重定位时为链接器提供了一个快速定位节的方式。
下面是一个典型可执行目标文件(但实际上要复杂得多):
ELF Header
.init
.text
.rodata
.data
.bss
.symtab
.debug
.line
.strtab
Section Headers
笔者在学习c的时候就听到过这么一句话——“main是程序的入口”,真的是这样吗?尝试一下这条命令:
ld hello.o -lc
ld: warning: cannot find entry symbol _start; defaulting to 080481a4
这说明编译器在_main之前会先去找一个_start符号。事实上程序在运行的初期还需要做一些初始化和清理的工作,这些代码位于crt1.o模块中,即c运行时(runtime)库,它包含了程序的入口函数_start,由它负责调用__libc_start_main初始化libc,并且调用main函数进入真正的程序主体,这部分代码必须在链接时加进来(对我们来说是透明的),否则程序根本运行不到的main。
3.printf
printf的机器码位于/lib/libc.so.6的共享库中,它将在程序运行时被加载到存储器的共享库映射区域。printf中又用到了系统调用write来输出格式串,所谓系统调用可以看成是操作提供给程序员的一个编程接口,我们可以调用它来获取操作系统提供的一些服务,完成一些和输入输出有关的操作。