- 嵌入式C语言自我修养:从芯片、编译器到操作系统
- 王利涛编著
- 2374字
- 2021-04-30 22:14:39
4.1 从源程序到二进制文件
程序的编译过程,其实就是将我们编写的C源程序翻译成CPU能够识别和运行的二进制机器指令的过程。关于C程序我们已经很熟悉了:一个C程序主要由一行行C语言语句组成,不同的语句构成一个个代码块或函数,每个语句由C语言的关键字、运算符、预处理命令、用户定义的变量名、函数名等很多token构成。一个C语言项目通常由多个文件组成。
在上面的程序中,我们创建了2个C程序源文件:main.c和sub.c。在main.c中定义了项目的入口函数main(),在main()函数中我们调用了add()和sub()函数对数据进行加、减运算。add() 和sub()函数在sub.c文件中定义,并在sub.h头文件中声明。在main.c中调用这两个函数之前,我们首先要把sub.h头文件包含进来,对这两个函数进行函数原型声明,编译器在编译程序时会根据这些函数声明对我们的源程序进行语法检查:检查实参类型、返回结果类型和函数声明的类型是否匹配。
以上就是一个典型的C程序项目中多文件的组织原则:可以把sub.c看作一个模块,定义了很多API函数供其他模块调用,并将这些API的声明封装在sub.h头文件中。如果其他模块想调用sub.c中的函数,则要先#include"sub.h"这个头文件,然后就可以直接使用了。如果我们想让上面的程序在ARM平台上运行,则要使用ARM交叉编译器将C源程序编译生成ARM格式的二进制可执行文件。
将生成的二进制文件复制到ARM平台上就可以直接运行了。ARM交叉编译器成功地将C源程序翻译为可执行文件,这中间的过程我们先不管,我们先看看生成的可执行文件a.out到底长什么样。在Shell终端下用你修长的手指敲入readelf命令,将会看到如下信息。
查看可执行文件a.out的section header。
readelf-h命令主要用来获取可执行文件的头部信息,主要包括可执行文件运行的平台、软件版本、程序入口地址,以及program headers、section header等信息。通过文件的头部信息,我们可以知道在a.out可执行文件里一共有多少个section headers。
图4-1 可执行文件的内部结构
section headers是干什么用的呢?它主要用来描述可执行文件的section信息。如图4-1所示,一个可执行文件通常由不同的段(section)构成:代码段、数据段、BSS段、只读数据段等。每个section用一个section header来描述,包括段名、段的类型、段的起始地址、段的偏移和段的大小等。一个可执行文件中的每一个section都有一个section header,将这些section headers集中放到一起,就是section header table,翻译成中文就是节头表。我们可以使用readelf-S命令来查看一个可执行文件的节头表。
通过section header table信息,我们可以窥探一个可执行文件的基本构成:一个可执行文件由一系列section组成,section header table自身也是以一个section的形式存储在可执行文件中的。section header table里的各个section header用来描述各个section的名称、类型、起始地址、大小等信息。除此之外,可执行文件还会有一个文件头ELF header,用来描述文件类型、要运行的处理器平台、入口地址等信息。当程序运行时,加载器会根据此文件头来获取可执行文件的一些信息。
在一个可执行文件中,我们比较熟悉的section有.text、.data、.bss,就是我们常说的代码段、数据段、BSS段。C程序中定义的函数、变量、未初始化的全局变量经过编译后会放置在不同的段中:函数翻译成二进制指令放在代码段中,初始化的全局变量和静态局部变量放在数据段中。BSS段比较特殊,一般来讲,未初始化的全局变量和静态变量会放置在BSS段中,但是因为它们未初始化,默认值全部是0,其实没有必要再单独开辟空间存储,为了节省存储空间,所以在可执行文件中BSS段是不占用空间的。但是BSS段的大小、起始地址和各个变量的地址信息会分别保存在节头表section header table和符号表.symtab里,当程序运行时,加载器会根据这些信息在内存中紧挨着数据段的后面为BSS段开辟一片存储空间,为各个变量分配存储单元。
知道了可执行文件的基本构成,我们也就知道了程序编译的大概流程,如图4-2所示,就是将C程序中定义的函数、变量,挑挑拣拣、加以分类,分别放置在可执行文件的代码段、数据段和BSS段中。程序中定义的一些字符串、printf函数打印的字符串常量则放置在只读数据段.rodata中。如果程序在编译时设置为debug模式,则可执行文件中还会有一个专门的.debug section,用来保存可执行文件中每一条二进制指令对应的源码位置信息。根据这些信息,GDB调试器就可以支持源码级的单步调试,否则你单步执行的都是二进制指令,可读性不高,不方便调试。在最后环节,编译器还会在可执行文件中添加一些其他section,如.init section,这些代码来自C语言运行库的一些汇编代码,用来初始化C程序运行所依赖的环境,如内存堆栈的初始化等。
图4-2 从C程序到可执行文件
从C程序到可执行文件,整个编译过程并不是一气呵成、一步完成的,而是环环相扣、多步执行的。如图4-3所示,程序的整个编译流程主要分为以下几个阶段:预处理、编译、汇编、链接。每个阶段需要调用不同的工具去完成,上一阶段的输出作为下一阶段的输入,步步推进。
图4-3 程序的编译、链接流程
在一个多文件的C项目中,编译器是以C源文件为单位进行编译的。在编译的不同阶段,编译程序(如gcc、arm-linux-gcc)会调用不同的工具来完成不同阶段的任务。在编译器安装路径的bin目录下,你会看到各种各样的编译工具,gcc在程序编译过程中会分别调用它们,常见的工具有预处理器、编译器、汇编器、链接器。
● 预处理器:将源文件main.c经过预处理变为main.i。
● 编译器:将预处理后的main.i编译为汇编文件main.s。
● 汇编器:将汇编文件main.s编译为目标文件main.o。
● 链接器:将各个目标文件main.o、sub.o链接成可执行文件a.out。
最后生成的可执行文件a.out其实也是目标文件(object file),唯一不同的是,a.out是一种可执行的目标文件。目标文件一般可以分为3种。
● 可重定位的目标文件(relocatable files)。
● 可执行的目标文件(executable files)。
● 可被共享的目标文件(shared object files)。
汇编器生成的目标文件是可重定位的目标文件,是不可执行的,需要链接器经过链接、重定位之后才能运行。可被共享的目标文件一般以共享库的形式存在,在程序运行时需要动态加载到内存,跟应用程序一起运行。
如果能坚持看到这里,相信大家已经对程序编译的基本流程有了一个大致的了解。可这还远远不够,接下来的几节,我们将按照编译的基本流程:预处理、编译、汇编和链接,进一步去深入学习。