linux系列目录:
linux基础篇(一)——GCC和Makefile编译过程
linux基础篇(二)——静态和动态链接
ARM裸机篇(一)——i.MX6ULL介绍
ARM裸机篇(二)——i.MX6ULL启动过程
ARM裸机篇(三)——i.MX6ULL第一个裸机程序
ARM裸机篇(四)——重定位和地址无关码
ARM裸机篇(五)——异常和中断
linux系统移植篇(一)—— linux系统组成
linux系统移植篇(二)—— Uboot使用介绍
linux系统移植篇(三)—— Linux 内核使用介绍
linux系统移植篇(四)—— 根文件系统使用介绍
linux驱动开发篇(一)—— Linux 内核模块介绍
linux驱动开发篇(二)—— 字符设备驱动框架
linux驱动开发篇(三)—— 总线设备驱动模型
linux驱动开发篇(四)—— platform平台设备驱动
文章目录
引入
当程序的规模比较大的时候,程序设计的模块化往往是我们追求的目标,把复杂的系统逐步分割成小的系统,然后各个突破。把每个源代码模块独立地编译,然后按照需要把它们“组装”起来,这个组装模块的过程就是链接。
比如以下代码,我们的程序hello.c就是使用另外一个模块printf.c中的函数printf()。而printf就定义在glibc库中,glibc库是GNU组织为GNU系统以及Linux系统编写的C语言标准库。
#include <stdio.h>
int main()
{
printf("Hello World\n");
return 0;
}
glibc库
GNU C 库项目为 GNU 系统和 GNU/Linux 系统以及许多其他使用 Linux 作为内核的系统提供核心库。这些库提供了关键的 API,包括 ISO C11、POSIX.1-2008、BSD、操作系统特定的 API 等等。这些 API 包括诸如open、 read、write、malloc、printf、 getaddrinfo、dlopen、pthread_create、 crypt、login、exit等基础设施。
C 语言是贝尔实验室的 Denni s Ritchie 于 1969 年 ~ 1973 年间创建的 。美国国家 标准学会 (American National Standards Institute , ANSI) 在 1989 年颁布了 ANSI C 的标准,后来 C语言的标准化成了国际标准化组织 (International Standards Organization, ISO) 的责任 。 这些标准定义了 C 语言和一系列函数库 , 即所谓的 C 标准库 。 国际标准化组织接替了对C语言进行标准化的任务,在 1990 年推 出 了 一 个几乎和ANSI C 一样的版本,称为 “ISO C90” ,该组织在 1999 年又对 C 语言做了更新,推出" ISO C99"。 更新的版本 2011 年得到批准,称为 " ISO C11"。
GNU 编译器套装 (GNU Compiler Collection, GCC) 可以基于不同的命令行选项,依照多个不同版本的 C 语言规则来编译程序。 比如,根据 ISO C11 来编译程序 hello.c, 我们就使用命令行 :linux> gcc -std=c11 heloo.c
ELF目标文件里有什么
要想知道hello程序是怎么和printf链接到一起的,我们还需要知道hello.c编译生成的hello.o的这个目标文件里都包含了那些东西。这个时候,Binutils工具集就要派上用场了。
Binutils工具集的使用
Binutils(bin utility),是GNU二进制工具集,通常跟GCC编译器一起打包安装到系统,这些工具对帮助我们理解和处理目标文件非常有用:
- as 汇编器,把汇编语言代码转换为机器码(目标文件)。
- ld 链接器,把编译生成的多个目标文件组织成最终的可执行程序文件。
- readelf 显示目标文件的完整结构 ;
- size 列出目标文件中段的名字和大小;
- objdump 所有二进制工具之母。能够显示目标文件中所有信息。最主要的作用是可以反汇编.text段中的二进制指令;
- objcopy: 可用于目标文件格式转换,如.bin 转换成 .elf 、.elf 转换成 .bin等。
- nm 列出目标文件的符号表中定义的符号;
- strip 从目标文件中删除符号表信息;
- strings 列出一个目标文件中所有可打印的字符串;
- ar 创建静态库,插入、删除、列出和提取成员;
- ldd 列出一个可执行文件在运行时所需要的共享库;
系统默认的Binutils工具集位于/usr/bin目录下,可使用如下命令查看系统中存在的Binutils工具集:
#在Ubantu上执行如下命令
ls /usr/bin/ | grep linux-gnu-
使用GCC来编译hello.c(参数-c表示只编译不链接),生成目标文件。
gcc -c hello.c -o hello.o
使用Binutils工具readelf 来查看一下hello.o内部的结构:
readelf -S hello.o
可以看到目标代码,是被分割成了不同的段来保存的,其中程序源代码编译后的机器指令被放到代码段(.text),全局变量和局部变量被放到数据段(.data),未初始化的全局变量和局部变量被放到.bss段,只读变量和字符串存放在只读数据段(.rodata),main函数调用printf的时候,用到的字符串常量“hello world \n”就被放到了.rodata段里。除了这些以外,还有一些链接时所需要的信息,比如符号表、调试信息等。
下图展示了一个典型的ELF可重定位目标文件格式:
代码段.text:
objdump 的-s参数可以将所有段的内容一十六进制方式打印出来,-d参数可以将所有包含指令的段反汇编。我们可以通过提取出的代码段内容来分析一下hello程序。
objdump -s -d hello.o
这段内容可以与上边段信息相互参照,观察代码段的反汇编可以发现偏移为b的跳转指令,它表示的地址是00 00 00 00 的无效地址,这个地址将会在链接的时间进行重定位。
b: e8 00 00 00 00 callq 10 <main+0x10>
重定位表.rel.text .rel.data:
注意.text下边还有个.rela.text的段,这个段记录的是代码段的重定位表,因为.text段至少有一个绝对地址的引用,那就是对“printf”函数的调用,如果.data段有绝对地址引用的话,还会存在.rela.data段。链接器在处理目标文件时,需要把目标文件中对引用外部定义的函数和数据处进行重定位,需要使用到重定位表。
符号和符号表.symtab:
链接器在对引用外部定义的函数和数据处进行重定位时,是根据符号名来进行链接的,而函数名和数据名就是符号名。
可以通过nm命令来查看符号表。
nm hello.o
静态链接
通过对hello.o这个可重定位文件的简要分析,我们知道hello.o是由很多段组成的,其中还调用了由glibc提供的printf共享目标文件,那么链接器如何吧它们链接到一起最终生成可执行的目标文件呢。
- 可重定位目标文件:包含二进制代码和数据,其形式可以在编译时与其他可重定位目标文件合并起来,创建一个可执行目标文件 。
- 可执行目标文件:包含二进制代码和数据,其形式可以被直接复制到内存并执行。
- 共享目标文件:一种特殊类型的可重定位目标文件,可以在加载或者运行时被动态地加载进内存并链接。
静态库是一组连接起来的可重定位目标文件的集合,有一个头部用来描述每个成员目标文件的大小和位置 。比如linux中最常用的C语言静态库libc位于/usr/lib/libc.a,它属于glibc项目的一部分。
现在的链接器的链接过程一般分为两步:空间与地址分配;符号解析与重定位。
空间与地址分配
扫描所有的输入目标文件,并且获得它们的各个段的长度、属性和位置,并且将输入目标文件中的符号表中所有的符号定义和符号引用收集起来,统一放到一个全局符号表。这一步中,链接器将能够获得所有输入目标文件的段长度,并且将它们合并,计算出输出文件中各个段合并后的长度与位置,并建立映射关系。
符号解析与重定位
使用上面第一步收集到的所有信息,读取输入文件中断的数据、重定位信息,并进行符号解析与重定位、调整代码中的地址。
符号解析(symbol resolution)
目标文件定义和引用符号,每个符号对应于一个函数、一个全局变量或一个静态变量(即 C 语言中任何以 static 属性声明的变量。符号解析的目的是将每个符号引用正好和一个符号定义关联起来。
重定位(relocation)
编译器和汇编器生成从地址 0 开始的代码和数据节。链接器通过把每个符号定义与一个内存位置关联起来,从而重定位这些节,然后修改所有对这些符号的引用,使得它们指向这个内存位置 。链接器使用汇编器产生的重定位条目 (relocation entry) 的详细指令,不加甄别地执行这样的重定位 。
链接脚本
由于整个链接过程有很多内容需要确定:使用哪些目标文件?使用哪些库文件?是否在最终的可执行文件中保留调试信息、输出文件格式(可执行文件、库)?还有考虑是否导出某些符号以供调试器或程序本身或其他程序使用等。
链接器一般会提供多种控制整个链接过程的方法,一用来生产用户所需的文件。一般分三种:
- 使用命令行来给链接器指定参数。
- 将链接指令存放在目标文件里面。
- 使用链接控制脚本。
linux默认的链接脚本在/usr/lib/ldscripts目录下。使用 ld -verbose
可以查看linux默认的链接脚本。
不同的机器平台,输出文件格式都有相应的链接脚本。还有一些脱离操作系统的引导程序BootLoader或者嵌入式系统的程序,需要指定输出文件各个段的名称、段存放的顺序还有地址等信息。
链接脚本语法
动态链接
静态链接的方法原理上容易理解,可以大大促进程序开发的效率,但是由于每个程序内部都要保留一份静态库函数,对计算机内存空间浪费非常严重,另一个问题是静态链接对于程序的更新和部署也会带来很多麻烦,如果库进行了更新,那么整个程序都需要重新编译链接。
要解决空间浪费和更新困难这两个问题,办法就是把程序的模块相互分割开来,形成独立的文件,而不再将它们静态的链接在一起。等到程序要运行时才进行链接,也就是说把链接这个过程推迟到运行时再进行,这就是动态链接的基本思想。
动态链接还有一个特点就是程序在运行时可以动态的选择加载各种程序模块,这个优点可以被用来制作程序的插件。
动态链接还可以消除程序对不同平台之间的依赖的差异性,一个程序在不同的平台运行时可以动态链接到操作系统提供的动态链接库,这些动态链接库相当于在程序和操作系统之间增加了一个中间层,从而消除了程序对不同平台之间的依赖的差异性。
在linux系统中,ELF动态链接文件被称为动态共享对象,简称共享对象,以.so作为后缀名。
回过头再来看一下hello的例子:
#include <stdio.h>
int main()
{
printf("Hello World\n");
return 0;
}
这个程序的默认链接就是使用的动态链接,调用的是glib.c的运行库,位置在/lib/libc.so.6,整个linux内存中只保留一份代码,通过动态链接的方式供所有进程使用。
-
在Linux系统中,gcc编译链接时的动态库搜索路径的顺序通常为:首先从gcc命令的参数-L指定的路径寻找;再从环境变量LIBRARY_PATH指定的路径寻址;再从默认路径/lib、/usr/lib、/usr/local/lib寻找。
-
在Linux系统中,执行二进制文件时的动态库搜索路径的顺序通常为:首先搜索编译目标代码时指定的动态库搜索路径;再从环境变量LD_LIBRARY_PATH指定的路径寻址;再从配置文件/etc/ld.so.conf中指定的动态库搜索路径;再从默认路径/lib、/usr/lib寻找。
-
在Linux系统中,可以用ldd命令查看一个可执行程序依赖的共享库。
如果要让gcc选择静态链接则可以指定gcc选项-static,该选项会强制使用静态库进行链接。
动态链接对象共享难题
所谓的共享,并不是共享整个Libc.so的内容,而是特指共享它的代码部分也就是代码段,对于Lib.c中的数据部分也就是数据段,每个进程都需要一份自己的拷贝,因为它们可能需要独立地修改Libc.so中的数据。
因为共享对象在编译时,是不知道自己在进程虚拟地址空间中的位置的,而是在装载时,装载器根据当前地址的空闲情况,动态分配一块足够大小的虚拟地址空间给相应的共享对象,所以就需要装载时重定位技术。
装载时重定位
为了能够使共享对象在任意地址装载,我们首先能想到的方法就是静态链接中的重定位。这个想法的基本思路就是,在链接时,对所有绝对地址的引用不作重定位,而把这一步推迟到装载时再完成,一旦模块装载地址确定,即目标地址确定,那么系统就对程序中所有的绝对地址引用进行重定位。
理论上,这个思路,可以做到使共享对象在任意地址装载,但是我们想要的不仅仅是在任意位置装载,而是共享代码让任意进程共享,我们知道在重定位中,不仅有数据段的重定位,还有代码段的重定位,而一旦代码段被重定位后,指令代码必定改变了,所以就做不到同一份指令被多个进程共享,因为指令被重定位后对于每个进程来讲是不同的。由于,动态链接库中可修改数据部分对于不同的进程来说有多个副本,所以可以通过对数据段的部分来下手解决,具体解决方法就是地址无关代码。
地址无关代码
指令中的有些地址要在装载时才能确定,也就是不同的进程可能有不同的地址。我们知道共享对象的数据段,是每个进程一份的。而数据段和代码段的相对位置又是确定的。
因此,我们就可以在数据段开始的地方建立一个指针数组,叫做全局偏移量表GOT(global offset table)。里面存放跨模块的数据的地址,当然,可以在装载时动态填入。然后,共享对象指令中对跨模块数据的访问,可以通过GOT中的指针间接访问。
这样的好处是,指令中的地址就从跨模块数据的地址,变成了GOT中指针的地址,而这个地址是相对代码段确定的。
具体的实现细节可以参照《程序员的自我修养》来研究。
延迟绑定
理论上,使用装载时重定位和地址无关代码技术就能完美解决动态库链接时地址不确定的问题和代码不能共享的问题。但是,像一个libc.so这样的共享库函数中,有成百上千的函数,在加载的时候需要重定位,理论上就需要对所有函数使用的绝对地址进行重定位,这个过程将会消耗大量的时间,而实际上,一个典型的应用程序只会使用其中很少的一部分,所以,编译系统使用了另外一个技术来解决这个问题,称为延迟绑定,基本思想是函数第一次被用的时才进行绑定(符号查找、重定位等)。
延迟绑定是通过GOT和**过程链接表(PLT)**的相互交互来实现的。具体办法是,调用外部函数的指令不直接从GOT中取函数地址,而是新建一个plt段,函数先跳到PLT段里,然后再有PLT来跳转到动态链接器,再通过动态链接器更新GOT表,从而实现延迟绑定的效果,一旦延迟绑定完成后,下次在调用这个函数的时候,就会直接跳转到函数相应的地址了。
PLT在ELF文件中以独立的段存放,段名通常叫做.plt,因为它本身是一些地址无关的代码,所以它通常会被合并到代码段开始的地方。
具体的实现细节可以参照《程序员的自我修养》来研究。
多个进程都链接同一个so动态库,对于so动态库中的全局变量的使用,会相互影响吗?
需要知道在linux中,每个进程都有自己的虚拟地址空间,操作系统会把虚拟地址与实际的物理内存地址进行映射。假设装载器已经把一个so库加载到内存之后,已经有个进程A正在使用,如果这时进程B也创建并链接了同一个so库,操作系统会把这个so库的代码为新进程做mmap虚拟地址映射,其中不变的代码段使用的是同一片物理内存,会变的数据段拷贝到新的物理地址并进行映射。所以,进程对于so库中的全局变量访问,其实访问的就是自己进程内部的全局变量,进程之间对于so库中的全局变量的使用不会相互影响。