程序员的自我修养笔记
本文最后更新于:4 分钟前
静态链接
库是一组目标文件的包,就是一些常用的代码编译成目标文件后打包存放。
第三章 目标文件里有什么
3.1 目标文件的格式
目标文件从结构上讲,它是已经编译后的可执行文件格式,只是还没有经过链接的过程,其中可能有些符号或者有些地址还没有被调整。
现在PC平台流形的可执行文件格式,主要是windows下的PE(Portable Executable)和Linux下的ELF(Executable Linkable Format),它们都是COFF(Common file format)格式的变种。
指令和数据分开存放的好处:
一方面当程序被装载后,数据和指令分别被映射到两个虚存区域。由于数据区域对于进程来说是可读写的,而指令区域对于进程来说是只读的,所以这两个虚存区域的权限可以被设置成可读写和只读,这样可以防止程序的指令被有意或无意地改写。
另一方面是现代CPU有强大的缓存体系,由于缓存很重要,所以程序必须尽量提高缓存命中率。指令区和数据区分离有利于提高程序的局部性。现代CPU的缓存一般都被设计成数据缓存和指令缓存,所以程序的指令和数据分开存放对于CPU的缓存命中率提高有好处。
第三个原因,也是最重要的原因,就是当系统中运行着多个该进程副本时,他们的指令都是一样的,所以内存中只需要保存一份程序的指令部分。
真正了牛逼的程序员对自己的程序每一个字节都了如指掌。
1 | |
| 段名称 | 内容 |
|---|---|
| .data | - 初始化的全局变量 - 局部静态变量 |
| .rodata | 只读数据段,对这个段的任何修改都是非法的,保证了程序的安全性。 有时候编译器会把字符串放到data段 - 只读变量 const 修饰 - 字符串常量 |
| .bss | 不占磁盘空间, - 未初始化的全局变量 - 未初始化的局部静态变量 - 初始化为0的静态变量 |
| .comment | 存放编译器版本信息,比如字符串“GCC:(GNU)4.2.0” |
| .line | 调试时的行号表,即源代码行号与编译后指令的对应表 |
| .note | 额外的编译器信息,如程序公司名,版本号 |
| .symtab | Symbol Table符号表 |
| .plt | 动态链接的跳转表 |
| .got | 动态链接的全局入口表 |
段名称都是.前缀,表示这些表名字是系统保留的,应用程序也可以使用一些非系统保留的名字作为段名称。比如可以加入一个music段,里面存一首mp3音乐,运行起来后就会播放音乐,打算自定义段不能使用.作为前缀,以免与系统保留段名冲突。
Q: 如何将一个二进制文件,如图片,MP3文件作为目标文件的一个段?
A: 可以使用objcopy工具,比如有一个图片 image..jpg,大小为0x2100字节:
$ objcopy -I binary -O elf32-i388 -B i38 image.jpg image.o
正常情况下编译出来的目标文件,代码会放到.text段,但是有时候你希望变量或者某些代码能放到你指定的段中去,以实现某些特定的功能。比如为了满足某些硬件的内存和IO地址布局。GCC提供了扩展机制,使得程序员可以指定变量所处的段:
1 | |
3.4 ELF 文件结构
使用readelf命令查看elf文件详细信息。
ELF 魔数,确认文件类型。
文件类型
常量 值 含义 ET_REL 1 可重定位文件,一般问.o文件 ET_EXEC 2 可执行文件 ET_DYN 3 共享目标文件,一般为.so文件 机器类型
常量 值 含义 EM_M32 1 AT&T WE 32100 EM_SPARC 2 SPARC EM_M386 3 Intel x86 EM_68K 4 Motorola 68000 EM_88K 5 Motorola 88000 EM_860 6 Intel 80860
段表是保存各个段的基本属性的结构。段表是除文件头外最重要的结构。编译器,链接器和装载器都是依靠段表来定位和访问各个段的属性。
3.5 链接的接口-符号
符号表结构
链接过程的本质就是要把多个不同的目标文件之间相互粘到一起。
目标文件B要用到目标文件A的函数foo,我们称目标文件A定义了函数foo,目标文件B引用了目标文件A的函数foo。
链接中,我们将函数和变量统称为符号,函数名或变量名就是符号名。、
每一个目标文件都会有一个相应的符号表,表里记录了目标文件中所用到的所有符号。每个符号都有一个对应值,叫符号值,对于变量和函数来说,符号值就是他们的地址。
符号类型:
- 定义在本目标文件的全局符号,可以被其他目标引用。
- 在本目标文件中应用的全局符号,却没有定义在本目标文件。
- 段名称,也就是段起始地址。
- 局部符号,一些静态变量等。
- 行号信息。
最重要的就是第一类和第二类。链接只关心全局符号的相互粘合,其他都是次要的。
可以使用 readelf objdump nm等命令查看符号信息。
特殊符号
一些特殊符号,没有在程序中定义,但是可以直接声明并引用它:
__executable_start,程序起始地址,不是入口地址,是程序最开始的地址。__etext__etextetext代码段结束地址,代码段最末尾的地址。_edataedata数据段结束地址,数据段最末尾地址。__endend程序结束地址。
符号修饰
符号应与对应的函数或者变量同名,但是在C语言发明时,已经存在了很多库和目标文件,如果再用一样的函数或变量就会冲突为了避免冲突,C语言编译后符号名前会加上下划线_,如foo变成_foo,Fortran语言编译后会在符号前后加上下划线_foo_。
C++具有类,继承,重载等复杂机制,为了支持这些复杂特性,人们发明了符号修饰和符号改编。
函数签名包含了一个函数的信息,包括函数名,参数类型,所在类和名称空间等信息。它用于识别不同的函数。在编译器和链接器处理符号时,使用某种名称修饰的方法,是的每个函数签名对应一个修饰后名称。
由于不同的编译器采用不同的名字修饰方式,必然导致由不同编译器编译产生的目标文件无法正常互相链接,这是导致不同编译器之间不能互操作的主要原因之一。
extern C
C++为了兼容C,C++编译器会将在extern C 的大括号内部的代码当做C语言代码处理,这样就不会使用C++的名称修饰机制。(也就不会在编译的时候加上下划线)
但是C语言并不支持extern C关键字,又不能为同一个库函数写两套头文件,这时候就可以用C++的宏,__cplusplus。C++编译器会在编译C++的程序时默认定义这个宏,我们可以用条件宏来判断当前编译单元是不是C++代码。
1 | |
弱符号与强符号
我们经常碰到符号重定义,多个目标文件中含有相同名字全局符号的定义,那么这些目标文件链接的时候就会出现符号重定义的错误。比如在两个文件中定义了相同的全局变量。
对于C/C++来说,编译器默认函数和初始化了的全局变量为强符号,未初始化的全局变量为弱符号。
也可以使用GCC的__attribute__((weak))来定义任何一个强符号为弱符号。
- 不允许强符号被多次定义,如果多次定义,则链接器报重复定义错误;
- 如果一个符号在某文件中是强符号,在其他文件中都是弱符号,那么选择强符号。
- 如果一个符号在所有目标文件中都是弱符号,那么选择其中占用空间最大的一个。
第四章 静态链接
空间地址分配
可执行文件中的代码段和数据段就是多个文件合并而来的,对于多个文件链接器如何将它们合并到输出文件?
按序叠加:最简单的方式,按照输入文件顺序依次合并。这会导致大量碎片,比如x86的硬件,段的装载地址和空间的对齐单位是页,也就是4096字节,那么如果一个段的长度只有1字节,它在内存里也要占用4096字节。
相似段合并:将所有相同性质的段合并在一起。
现在的链接器基本上采用第二种。使用这种方法的链接器都采用一种叫两步链接的方法。
第一步,空间与地址分配。扫描所有的输入目标文件,并且获得各个段的长度,属性和位置,并将输入目标文件中的符号表所有的符号定义和符号引用收集起来,统一放到一个全局符号表。这一步,链接器将能够获得所有输入目标文件的段长度,并且将他们合并,计算出输出文件中各个段合并后的长度和位置,并建立映射关系。
第二部,符号解析与重定位。使用上面收集到的信息,读取输入文件中段的数据,重定位信息。并且进行符号解析与重定位,调整代码中的地址。
VMA (Virtual Memory Address)虚拟地址,LMA(Load Memory Address)加载地址。正常情况这两个值是一样的。
链接之前目标文件的所有短VMA都是0,因为虚拟空间还没有被分配,默认为0,链接之后各个段就会被分配相应的虚拟地址。
Linux下,ELF可执行文件默认从地址0x8048000开始分配。
符号解析与重定位
1 | |
源代码在编译成目标文件时并不知道函数的调用地址。需要通过链接时重定位。
链接器如何知道哪些指令需要被调整?这就用到了重定位表。
重定位表就是ELF文件的一个段,所以其实重定位表也可以叫重定位段。
1 | |
每个要被重定位的地方叫一个重定位入口(Relocation Entry)。
重定位过程也伴随着符号的解析过程,每个目标文件都可能定义一些符号,或引用到定义在其他文件的符号。重定位过程中,每个重定位的入口都是对一个符号的引用,那么当链接器需要对某个符号的引用进行重定位时,他就要确定这个符号的目标地址。这时候链接器就会取查找由所有输入目标文件的符号表组成的全局符号表,找到对应的符号进行重定位。
1 | |
对于32位x86平台下的ELF文件的重定位入口所修正的指令寻址方式只有两种:
- 绝对近址32位寻址
- 相对近址32位寻址
x86基本重定位类型
| 宏定义 | 值 | 重定位修正方法 |
|---|---|---|
| R_386_32 | 1 | 绝对寻址修正 S+A |
| R_386_PC32 | 2 | 相对寻址修正 S+A-P |
A = 保存在被修正位置的值
P = 被修正的位置(相对于段开始的偏移量或者虚拟地址),注意,该值可通过r_offset计算得到
S = 符号的实际地址,即由 r_info的高24位指定的符号的实际地址
第6章 可执行文件的装载与进程
程序运行时是有局部性原理的,所以我们可以将程序最常用的部分驻留在内存中,将不常用的数据存放在磁盘里,这就是动态载入的基本原理。
COMMON块
Q:在目标文件中,编译器为什么不直接把未初始化的全局变量也当做未初始化的局部静态变量一样处理,为它在BSS段分配空间,而是将其标记为一个COMMON类型的变量?
A:当编译器将一个编译单元编译成目标文件时,如果该编译单元包含了弱符号(未初始化的全局变量就是典型),那么该弱符号最终所占大小未知,因为有可能其他编译单元中该符号所占空间比当前的大所以编译器此时无法为该符号在BSS段分配空间。但链接器在链接过程中可以确定弱符号大小,因为当链接器读取所有输入目标文件后,任何一个弱符号大小都可以确定,所以它可以在最终输出文件的BSS段为其分配空间。总体来看,未初始化全局变量最终还是被放在BSS段。
GCC的-fno-common吧所有未初始化的全局变量不以COMMON块形式处理。
__attribute__扩展也可以实现,int global __attribute__((nocommon))。这样未初始化的全局变量就是强符号。
Q: 为什么静态运行库里面一个目标文件只包含一个函数?比如libc.o里面printf.o只包含printf()函数,strlen.o只有strlen函数?
A:因为链接器在链接静态库时是以目标文件为单位的,比如我们引用了静态库中的printf函数,那么链接器就会把库中包含printf函数的那个目标文件链接进来,如果很多函数写在一个目标文件中,就将没用到的函数一起链接进了输出结果中。
链接的过程控制
第七章
动态链接模块的装载地址是从0x00000000开始的。
共享对象的最终装载地址在编译时是不确定的。
静态共享库: 将程序的各个模块交给操作系统管理,操作系统在某个特定的地址划分出一些地址块,为那些已知的模块预留足够的空间。
装载时重定位:程序在编译时被装载的目标地址为0x1000,但是在装载时操作系统发现0x1000这个地址已经被别的程序使用了,从0x4000开始有一块足够大的空间可以容纳,那么该程序就可以被装载至0x4000,程序指令和数据所有引用都只需要加上0x3000偏移量即可。因为他们在程序中的相对位置是不会改变的。
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!