Java是一门基于JVM的语言,其一切都基于JVM。所以大多数Java开发人员对于链接没有太多的概念,或者是看过就丢了,而链接又是理解虚拟内存和进程的物理意义必不可少的一个知识点,所以还是要简单了解下。
实验环境:Centos 7
提示:
要想对c语言的编译过程有基本的认识,需要先掌握一些c的基本知识
c .h头文件blog.csdn.net c static关键字blog.csdn.net c extern关键字blog.csdn.net c 全局变量、局部变量c.biancheng.net一、.c文件到可执行文件
C语言从源文件到最终的可执行文件,要经过下面几个步骤,可以通过gcc编译工具来进行每一步的处理
gcc的常用选项
-E 只预处理,不会编译、汇编、链接
-S 编译到汇编语言不进行汇编和链接
-c 编译和汇编,不会链接
-o 指定输出文件名为file,这个名称不能跟源文件名同名
- 首先创建一个极其简单的c文件,代码如下
- 预处理
预处理主要作用是将define定义的宏替换到源码中,还有就是如果通过include引入了.h文件,需要.h文件中的内容插入到当前源码中,最终生成.i文件。可以使用下面命令对一个.c文件进行预处理:
gcc -E hello.c -o hello.i
预处理后的文件通常会比源文件大很多
查看.i文件可以看到源码中的宏已经被实际数字取代
- 编译
编译阶段可以将预处理后的文件转换成汇编指令,可以通过下面命令进行编译.i文件,生成.s文件:
gcc -S hello.i -o hello.s
编译后的汇编代码如下
- 汇编
在得到.i结尾的汇编代码之后,可以经过汇编阶段,将汇编代码转换成计算机可以理解的机器码,生成可重定位目标文件,通常以.o为后缀,gcc命令如下:
不过转换后的文件是二进制,所以无法以文本方式查看内容:
- 链接
在得到可重定位目标文件之后,需要经过链接器将一切必要的系统文件以及外部引入的其他.o文件进行组合,最终创建一个操作系统的可执行目标文件:
gcc hello.o -o hello
- 执行
在得到可执行目标文件之后,可以交给shell的loader函数执行。loader函数将可执行文件的代码和数据复制到内存中,然后将控制移交给可执行文件的开头
二、目标文件
如果将一个目标模块定义为一个连续的字节序列,则一个目标文件可以被定义成一个以文件形式存放在磁盘上的目标模块。
- 汇编阶段输出的目标文件被称为可重定位目标文件或者共享目标文件(一种特殊的可重定位文件)
- 链接阶段输出的目标文件被称为可执行目标文件
可重定位目标文件(共享目标文件)、可执行目标文件大体结构上类似,但还是有一些差别的,理解这两种文件的组织结构,有利于以后深入了解进程和内存相关知识。
目标文件在不同的操作系统上有不同的格式,Windows中使用PE格式,Linux中使用ELF格式,如果是为了了解其概念,只需要关注其中一种即可。
准备工作,先根据前面的步骤,将下面源码汇编成一个可重定位目标文件以及链接成一个可执行目标文件:
- 可重定位目标文件 hello.o
- 可执行目标文件 hello
(一)ELF可重定位目标文件
可重定位目标文件中以ELF头为起点,节头部表为终点,在它们之间是一个个的节,每一个节可以理解为存放特定数据的连续字节序列。
由于代码可以帮助我们更好的理解数据结构,为了能够更好的记住目标文件格式,最好还是结合代码来看。
下面是目标文件相关的数据类型的定义:
32表示32位机器4字节,64表示64位机器8字节
数据类型 字节数 描述
Elf32_Addr 4 无符号程序地址
Elf32_Half 2 无符号中等大小整数
Elf32_Off 4 无符号文件偏移
Elf32_Sword 4 有符号大整数
Elf32_Word 4 无符号大整数
unsigned char 1 无符号小整数
- ELF头
其代码定义如下:
#define EI_NIDENT 16
typedef struct{
unsigned char e_ident[EI_NIDENT]; // 16字节数组,每个字节都有自己固定的含义
// 通常也被称为魔数
Elf32_Half e_type; //ELF目标文件类型,1:可重定位 2:可执行 3:共享
Elf32_Half e_machine; //指明该目标文件适用于什么架构的CPU,比如x86架构
Elf32_Word e_version; // ELF文件版本号
Elf32_Addr e_entry; // 程序入口虚拟地址,只对可执行文件有意义,其他都是0
Elf32_Off e_phoff; // 段头部表的偏移量,可执行文件才有段的概念,其他类型文件为0
Elf32_Off e_shoff; // 节头部表的偏移量,没有则为0
Elf32_Word e_flags; // 处理器的标志位,通常不关心
Elf32_Half e_ehsize; // ELF头所占用字节数
Elf32_Half e_phentsize; // 段头部表中每一个表项的大小
Elf32_Half e_phnum; // 段头部表中表项的个数
Elf32_Half e_shentsize; // 节头部表中每一个表项的大小
Elf32_Half e_shnum; // 节头部表中表项的个数
Elf32_Half e_shstrndx; // .shstrtab节存储所有节的name字符串的ASCII码值,
// 该值表示.shstrtab节在节头部表中的索引
}Elf32_Ehdr;
在Linux操作系统中可以通过
readelf -h hello.o
查看可重定位目标文件hello.o的头部信息:
- 节头部表
在可重定位目标文件中没有段的概念,只有节的概念。一个可重定位目标文件中可以有许多节,通常以'.'开头的节为系统自带的节,比如.shstrtab、.text、.data等。每一个节从物理上来看是一段连续的字节,其逻辑上的意义由节头部定义。节头部表中包含了可重定位目标文件中所有节的节头部信息。节头部定义如下:
typedef struct {
Elf32_Word sh_name; // 当前节的name字符串在.shstrtab节中的索引
Elf32_Word sh_type; // 节的类型,包含字符串表的节、包含符号哈希表的节
Elf32_Word sh_flags; // 节的标志,比如readable、writeable、execable等
Elf32_Addr sh_addr; // 当前节的起始位置的虚拟内存地址
Elf32_Off sh_offset; // 当前节的起始位置在当前文件中的偏移地址
Elf32_Word sh_size; // 当前节占用的字节数
Elf32_Word sh_link; // 节头部的索引链接,其意义依赖于节类型,比如当前节
// 是包含字符串表的节,则该值标志字符串表(也是一个节)
// 对应的节头部在节头部表中的索引
Elf32_Word sh_info; // 节附加信息,其意义依赖于节类型
Elf32_Word sh_addralign; // 当前节是否需要地址对齐
Elf32_Word sh_entsize; // 当前节关联了其他的表,比如符号表
// 约束每个表项的字节数
} Elf32_Shdr;
在Linux操作系统总中可以通过
readelf -S hello.o
查看节头部表中的所有节头部定义
每一个节都有一个自己的编号,如上图所示 0 - 12,可以通过
readelf -x 编号 hello.o
来查看对应索引的节的二进制详细内容
- .shstrtab节
section header string table,节头字符串表。可以理解为逻辑上是一个String[],其内容为所有节的name字符串。其详细内容如下:
- .strtab节
string table,通用的字符串表,其内容为程序中定义和引用的变量名、函数名等,其详细内容如下:
- .text节
该节中保存的是汇编后的机器代码,比如函数转化为的机器码指令序列,其详细内容如下:
- .rodata节
read-only-data,该节中保存的是只读的常量数据。比如#define定义的常量,或者是代码中使用的字符串常量,其详细内容如下:
- .data节
该节中保存的数据如下:
- 全局静态变量:已经初始化且初始值不为0
- 局部静态变量:已经初始化且初始值不为0
- 普通全局变量:已经初始化且初始值不为0
其详细内容如下:
- .bss节
该节中保存的数据如下,
- 全局静态变量:未初始化或者初始值为0
- 局部静态变量:未初始化或者初始值为0
- 普通全局变量: 已经初始化且初始值为0(未初始化的普通全局变量不存在于目标文件中)
其详细内容如下:
不过这些数据只有在运行时才会被加载到内存中,在磁盘上实际上是没有任何数据的
- .symtab
symbol table符号表,该节中保存了程序中定义和引用的全局变量、函数信息。符号表项的定义如下:
typedef struct {
ELF32_Word st_name; // 该值表示当前符号的name字符串在.strtab节中的索引
ELF32_Addr st_value; // 符号的值在所存储的节中的偏移地址,
// 比如已经初始化的全局变量存储在.data节中
// 如果是函数,则表示该函数的第一条指令,存在.text节中
ELF32_Word st_size; // 符号在其对应的节中占据的大小
unsigned char st_info; // 1、标志当前符号是 全局变量还是函数或者是文件名
// 2、标志当前符号定义的函数或者全局变量
// 是否可以被外部引用,这个涉及到c语言语法
unsigned char st_other;
Elf32_Half sth_shndx; // 表明当前符号的实际存储位置,即存储在哪个节
// 有四种不同的选项值:
// 1、数字:即所在节的索引号 和readelf -S 命令看到的一样
// 2、ABS: 不需要被链接器处理,比如文件名
// 3、UNDEF:在本模块中引用,但是不在本模块中定义
// 需要链接器在链接时找到该符号的定义
// 4、COM:表示尚未分配磁盘存储空间,即在当前目标文件
// 中不存在,比如前面提到过未初始化的全局变量保存在.bss
// 节,但是文件中没有实际数据
} Elf32_Sym;
由于节中本身保存的是二进制数据:
为了更清晰的查看该节的内容,可以通过下面命令来查看格式化的symtab节数据:
readelf -s hello.o
- 实践
为了更好的理解和验证前面对于节的介绍,下面来通过一个例子实践一下:
通过查看节表和符号表,可以在符号表中看到源码中所有定义和引用的变量以及函数名。
索引3表示.data节,索引4表示.bss节。全局静态变量staticGlobalInit1和staticGlobalInit2、局部静态变量staticPartInit1,和staticPartInit2的初始值都不为0,所以他们保存在索引为3的.data节中。value表示他们的真正值在对应节上的偏移量:
以staticGlobalInit1为例,其value值为8,数据大小为4byte,也就是说.data节中 8 - 11四个节0100中存储的是其初始值。由于ELF头文件中描述当前文件为小端序,所以其真正的值为0001,即初始值为1
(二)ELF可执行目标文件
单个或者多个可重定位目标文件经过链接就得到了可执行目标文件。链接是由链接器完成的,链接器主要提供符号解析和重定位两个工作。可执行目标文件的组织结构和可重定位目标文件稍有不同:
从上图的组织结构图中可以看到相比较于可重定位目标文件,可执行文件中多了段的概念。
- ELF头
可执行文件的ELF头定义与可重定位文件相同,不同的是某些头部字段的含义有些区别,比如下面是可执行文件hello的ELF头:
从图中可以看到相比较于可重定位目标文件,一是文件体积大大增加,因为多了很多节;二就是多了段的定义,最后能看到Entry point address有了初始值,该值表示当前可执行文件对应进程的第一条指令在内存中的虚拟地址。
- 段头部表
段头部表中包含了一组段头部,段头部是一个段的描述信息,段头部的定义如下:
typedef struct
{
Elf32_Word p_type; // 该段类型,比如是否需要加载进内存
Elf32_Off p_offset; // 该段的首字节在当前可执行文件中,相对于ELF头的偏移地址
Elf32_Addr p_vaddr; // 该段的首字节在内存中的虚拟地址
Elf32_Addr p_paddr; // 该段的首字节在内存中的物理地址,一般只在ROM中使用
// 其值通常和虚拟地址相同,如果在需要使用物理地址的系统中
// 可以在链接时手动指定
Elf32_Word p_filesz; // 当前段在文件中所占的字节数
Elf32_Word p_memsz; // 当前段在内存中的字节数
Elf32_Word p_flags; // 段的一些标志
Elf32_Word p_align; // 地址对齐
} Elf32_Phdr;
可以通过下面命令查看可执行文件的段头部表具体内容:
readelf -l hello
从可执行文件的组织结构图上能够看到所谓的段,其物理意义就是一个或者多个节的集合。比如从上图中最下面的部分就能看到每个段中包括哪些节。
ELF可执行文件这种分段设计使得其可以很容易的被加载到内存,映射到连续的内存单元中。比如上图中的第一个LOAD类型的段, 其在文件中的偏移地址为0,在虚拟内存中的绝对地址为0x400000,占用空间大小为0x07dc字节。
三、链接
通过查看可执行目标文件中的内容,可以了解到可重定位目标文件与可执行目标文件,一个很明显的不同点就是多了虚拟内存的概念。可重定位目标文件中的地址基本都是相对于目标文件本身的偏移地址,而可执行目标文件中在文件本身偏移地址的基础上又定义了内存中的虚拟地址。
链接过程主要做了两个工作:
- 符号解析
符号解析的概念很容易理解,就是将代码中的符号引用和符号表中的符号关联起来。可以在汇编文件中查看符号引用:
而符号解析要做的事情就是将这个符号引用和可重定位文件中的符号表中的符号信息关联起来。
- 重定位
一旦完成了符号解析,此时连接器就知道每个节的确切大小了,然后进行重定位工作。重定位主要是给解析的每个符号分配一个运行时地址,即虚拟地址。
重定位也由两步组成,1、重定位节和符号定义 2、重定位节中的符号引用。
连接器不仅可以链接单个可重定位目标文件,还可以链接多个可重定位目标文件,所以重定位需要将所有的可重定位目标文件中的相同节合并到一起,比如.data节,而这个合并的节成为最终输出的可执行目标文件中的.data节。在完成所有可重定位目标文件中的相同节合并,独有节整合,形成了可执行目标文件的所有节。然后链接器会给每个节,以及每个符号定义一个运行时地址,即虚拟地址。
符号定义分配了虚拟地址之后,需要修改.text节代码里已经关联到符号定义的符号引用,使其直接指向所关联的符号定义的虚拟地址。
四、加载可执行目标文件
在Linux环境中,可以通过shell来让操作系统的某个加载器去执行可执行目标文件。加载器将可执行目标文件中的代码和数据从磁盘复制到内存中,然后执行可执行目标文件的第一条指令。该指令的虚拟地址可以在可执行目标文件的ELF头中查看:
同样也可以在符号表中查看,入口函数的符号为_start:
Linux操作系统中,每个可执行目标文件在运行时都会在内存中创建一个内存映像,如下图所示:
从图中可以看到由.data节、.bss节组成的数据段,.init、.text、.rodata节组成的代码段会被加载到内存中,代码段靠近虚拟地址的起始端,且首字节地址固定为虚拟地址0x400000。