目标文件从结构上讲,它是已经编译后的可执行文件格式,只是还没有经过链接的过程,其中可能有些符号或有些地址还没有被调整。其实它本身就是按照可执行文件格式存储的,只是跟真正的可执行文件在结构上稍有不同
可执行文件,动态链接库文件,静态链接库文件都按照可执行文件格式存储,Windows下按照
PE-COFF格式存储,Linux下按照ELF格式存储。
静态链接库稍有不同,它是把很多目标文件捆绑在一起形成一个文件,再加上一些索引,可以简单理解为一个包含很多目标文件的文件包
目录
目标文件种的内容有编译后的机器指令代码,数据,还包括链接时所须要的一些信息,比如符号表,调试信息,字符串等。目标文件将这些信息按不同的属性,以"节"的形式存储,有时候也叫"段",它们都表示一个一定长度的区域,基本上不加以区别
程序源代码编译后的机器指令经常被放在代码段里,代码段常见的名".code" 或 ".text"
全区变量和局部静态变量数据经常放在数据段,数据段的名 ".data"
被编译后的程序
.bss段
未初始化的全局变量和局部静态变量默认值都为0,本来它们也可以被放在.data段的,但因为它们是0,所以给它们在.data段分配空间并且存放数据0是没有必要的。程序运行时它们的确是要占内存空间,并且可执行文件必须记录所有未初始化的全局变量和静态变量的大小总和,记为.bss段。bss段只是给未初始化的全局变量和局部静态变量预留位置而已,他并没有内容,所以它在文件中也不占据空间
有些编译器会将未初始化的全局变量存放在目标文件.bss段,有些则不存放,只是预留一个未定义的全局变量符号,等到最终链接成可执行文件的时候再在.bss段分配空间
数据和指令分段的好处
1)当程序被装载后,数据和指令分别被映射到两个虚存区域。由于数据区域对于进程来说是可读写的,而指令区域对于进程来说只是可读的,所以这两个虚存区域的权限可以被分别设置成可读写和只读。这样可以防止程序的指令被有意或无意地改写
2)另外一方面对于现代的CPU来说,它们有着极为强大的缓存体系。由于缓存在现代计算机中地位非常重要,所以程序必须尽量提高缓存的命中率。指令区和数据区的分离有利于提高程序的局部性。现代CPU的缓存一般都被设计成数据缓存和指令缓存分离,所以程序的指令和数据被分开存放对CPU的缓存命中率提高有好处
3)当系统中运行着多个该程序的副本时,它们的指令都是一样的,所以内存中只需要保存一份改程序的指令部分。对于指令的这种只读区域来说是这样的,对于其他的只读数据也一样,比如很多程序里面带有的图标,图片,文本等资源也是属于可以共享的。当然每个副本进程的数据是不一样的,它们是进程私有的。共享指令,特别是在有动态链接的系统中,可以节省大量的内存
.rodata段
存放的是只读数据,一般是程序里面的只读变量(如const修饰的变量)和字符串常量。单独设立".rodata"段的好处,不光是语义上支持了C++的const关键字,而且操作系统在加载的时候可以将".rodata"段的属性映射成只读,这样对于这个段的任何修改操作都会作为非法操作处理,保证了程序的安全性
Quiz 变量存放位置
static int x1 = 0;
static int x2 = 1;
x1会被存放在.bss段,x2会被存放在.data段。因为x1为0,可以认为是未初始化的,因为未初始化的都是0,所以被优化掉了可以放在.bss,这样可以节省磁盘空间,因为.bss不占磁盘空间。x2初始值为1,是初始化的,所以放在.data中
自定义段
有时候希望变量或某些代码放到自己所指定的段中去,以实现某些特定的功能。比如为了满足某些硬件的内存和I/O的地址布局,或者是像Linux操作系统内核中用来完成一些初始化和用户空间复制时出现页错误异常等。可以在全局变量或函数之前加上 "_attribute_((section("name")))" 属性就可以把相应的变量或函数放到以 "name" 作为段名的段中
文件头中定义了ELF魔数,文件机器字长,数据存储方式,版本,运行平台,ABI版本,ELF重定位类型,硬件平台,硬件平台版本,入口地址,程序头入口和长度,段表的位置和长度及段的数量等
ELF魔数
"Magic" 的这16个字节被ELF标准规定用来标识ELF文件的平台属性,比如ELF字长,字节序,ELF文件版本,第一个字节对应字符里面的DEL控制符,后面3个字节刚好是ELF这3个字母的ASII码。这4个字节被称为ELF文件的魔数,这种魔数用来确认文件的类型,操作系统在加载可执行文件的时候会确认魔数是否正确,如果不正确会拒绝加载
第5个字节是用来标识ELF的文件类的,第6个字节是字节序,规定该ELF文件是大端的还是小端的。第7个字节规定ELF文件的主版本号,一般是1
文件类型
e_type 成员表示ELF文件类型,每个文件类型对应一个常量。系统通过这个常量来判断ELF的真正文件类型,而不是通过文件的扩展名
段表
段表描述ELF的各个段的信息,比如每个段的段名,段的长度,在文件中的偏移,读写权限及段的其他属性,ELF文件的段结构就是由段表决定的,编译器,链接器和装载器都是依靠段来定位和访问各个段的属性的。段表在ELF文件中位置由ELF文件头的"e_shoff"成员决定
段表的结构比较简单,它是一个以"Elf32_Shdr" 结构体为元素的数组。数组元素的个数等于段的个数,每个"Elf32_Shdr" 结构体对应一个段。"Elf32_Shdr" 又称段描述符。ELF段表的这个数组的第一个元素是无效的段描述符,它的类型为"NULL",除此之外每个段描述符都对应一个段
typedef struct
{
Elf32_Word sh_name;
Elf32_Word sh_type;
Elf32_Word sh_flags;
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;
sh_name --- 段名
sh_type --- 段的类型
sh_flags --- 段的标志位
sh_addr --- 段虚拟地址
如果该段可以被加载,则sh_addr为该段被加载后在进程地址空间中的虚拟地址;
否则为0
sh_offset --- 段偏移
如果该段存在于文件中,则表示该段在文件中的偏移;
否则无意义
sh_size --- 段的长度
sh_link --- 段的链接信息
sh_info --- 段的链接信息
sh_addralign --- 段地址对齐
有些段对段地址对齐有要求,假设有个段刚开始的位置包含了一个double变量,Linux系统要求浮点数的存储地址必须是本身的整数倍,也就是说保存double变量的地址必须是8字节的整数倍。这样对一个段来说,它的Sh_addr必须是8的整数倍
由于地址对齐的数量都是2的指数倍,sh_addralign 表示是地址对齐数量中的指数,即
sh_addrlign = 3 表示对齐为2的3次方倍,即8倍,即sh_addr %(2 ** sh_addralign)= 0。
**表示指数运算
如果 sh_addralign 为0或1,则表示该段没有对齐要求
sh_entsize --- 项的长度
有些段包含了一些固定大小的项,比如符号表,它包含的每个符号所占的大小都一样的。对于这种段,sh_entsize 表示每个项的大小。如果为0,则表示该段不包含固定大小的项
事实上段的名字对于编译器,链接器来说是有意义的,但是对于操作系统来说并没有实际的意义,对于操作系统来说,一个段该如何处理取决于它的属性和权限,即由段的类型和段的标志位这两个成员决定
段的类型
段的标志位
段的标志位表示该段在进程虚拟地址空间中的属性
段的链接信息
重定位表
链接器在处理目标文件时,须要对目标文件中某些部位进行重定位,即代码段和数据段中哪些绝对地址的引用的位置。这些重定位的信息都记录在ELF文件中重定位表里面,对于每个须要重定位的代码段或数据段,都会有一个相应的重定位表
字符串表
把字符串集中起来存放到一个表,然后使用字符串在表中的偏移来引用字符串
在ELF文件中引用字符串只须要一个数字下标即可,不用考虑字符串长度的问题。字符串表分别为字符串表 和 段表字符串表。字符串表用来保存普通的字符串,比如符号的名字;段表字符串表用来保存段表中用到的字符串
链接过程的本质就是要把多个不同的目标文件之间相互 "粘" 到一起。在链接中,目标文件之间相互拼合实际上是目标文件之间对地址的引用,即对函数和变量地址的引用。每个函数或变量都有自己独特的名字(C++名字粉碎技术),才能避免链接过程中不同变量和函数之间混淆。在链接中,我们将函数和变量统称为符号,函数名和变量名就是符号名
符号看作是链接中的粘合剂,整个连接过程正是基于符号才能够正确完成。链接过程中很关键的一部分就是符号的管理,每个目标文件都会一个相应的符号表,这个表里记录目标文件中所用到的所有符号。每个定义的符号有一个对应的值,叫符号值,对于变量和函数来说,符号值就是它们的地址
符号的类型
1)定义在本目标文件的全局符号,可以被其他目标文件引用
2)在本目标文件中引用的全局符号,却没有定义在本目标文件,这个叫做外部符号
3)段名,这种符号由编译器产生,它的值就是该段的起始地址
4)局部符号,这类符号只在编译单元可见。调式器可以使用这些符号来分析程序或崩溃时的核心转储文件。这些局部符号对于链接过程没有作用,链接器往往也忽略它们
5)行号信息,即目标文件指令与源代码中代码行的对应关系
链接过程只关心全局符号的相互 "粘合" ,局部符号,段名,行号等都是次要的,它们对于其他目标文件来说是 不可见的,在链接过程中也是无关紧要的
符号表的结构
- typedef struct
- {
- Elf32_Word st_name;
- Elf32_Addr st_value;
- Elf32_Word st_size;
- unsigned char st_info;
- unsigned char st_other;
- Elf32_Half st_shndx;
- }
符号类型和绑定信息
st_infor,该成员低4位表示符号的类型,高4位表示符号绑定信息
符号所在段
如果符号定义在目录文件中,那么这个成员表示符号所在的段在段表的下标;
如果不是定义在本目标文件中,或者对于有些特殊符号,sh_shndx的值有些特殊
符号值
每个符号都有一个对应的值,如果这个符号是一个函数或变量的定义,那么符号就是这个函数或变量的地址
1)在目标文件中,如果是符号的定义并且该符号不是未初始化的全局变量,st_value表示该符号在段中的偏移。即符号所对应的函数或变量位于由st_shndx指定的段,偏移st_value的位置
2)在目标文件中,如果符号是未初始化的全局量,则st_value表示该符号的对齐属性
3)在可执行文件中,st_value表示符号的虚拟地址。这个虚拟地址对于动态链接器来说十分有用
特殊符号
使用ld链接器来链接生产可执行文件,它会为我们定义很多特殊的符号,这些符号并没有在你的程序中定义,但是你可以直接声明并且引用它,称之为特殊符号
链接器会在程序最终链接成可执行文件的时候将其解析成正确的值,只有使用ld链接生产最终可执行文件的时候这些符号才会存在
1)_executable_start,该符号为程序的起始地址,注意,不是入口地址,是程序的最开始的地址
2)_etext 该符号为代码段结束地址,即代码段最末尾的地址
3)_edata或 edata,该符号为数据段结束地址,即数据段最末尾的地址
4)_end或end,该符号为程序结束地址
以上地址都为程序被装载时的虚拟地址
C++的符号修饰机制
函数签名包含了一个函数的信息,包括它的参数类型,所在的类和名称空间按及其他信息。函数签名用于识别不同的函数,函数的名字只是函数签名的一部分。在编译器及链接器处理符号时,它们使用某种名称修饰的方法,使得每个函数签名对应一个修饰后名称。C++的源代码编译后的目标文件中所使用的符号名是相应的函数和变量修饰后名称。所以对于不同函数签名的函数,即使函数名相同,编译器和链接器都认为它们是不同的函数
GCC编译器下,相对应的修饰后名称
GCC的基本C++名称修饰工作:所有符号都以"_Z"开头,对于嵌套的名字(在名称空间或在类里面的),后紧跟"N",然后是各个名称空间和类的名字,每个名字前是名字字符串的长度,再以"E"结尾,对于一个函数来说它的参数列表紧跟在"E"后面,对于int类型来说,就是字母 '' i "
签名和名称修饰机制不光被使用到函数上,C++中全局变量和静态变量也有同样的机制。对于全局变量来说,他跟函数一样都是一个全局可见的名称,他也遵循上面的名称修饰机制。值得注意的是,变量的类型并没有被加入到修饰后的名称中,所以不论这个变量是整形还是浮点型甚至是一个全局对象,它的名称都是一样的
- #include
- using namespace std;
-
- int foo = 20;
-
- float foo = 2.0;
-
- int main()
- {
-
- return 0;
- }
名称修饰机制也被用来防止静态变量的名字冲突。比如main() 函数有一个静态变量叫foo,而func()
函数里面也有一个静态变量叫foo。为了区分这两个变量,GCC会将它们的符号名分别修饰为
_ZZ4mainE3foo 和 _ZZ4funcE3foo,这样就区分了这两个变量
- #include
- using namespace std;
-
- int func()
- {
- static int a = 10;
-
- return ++a;
- }
-
- int main()
- {
- static int a = 20;
- func();
- func();
- func();
- printf("%d %d\n", a, func());
- return 0;
- }
C++为了与C兼容,在符号管理上,C++有一个用来声明或定义一个C的符号的" extern "C" "关键字用法
- extern "C"
- {
- int func(int);
- int var;
- }
C++编译器会将extern "C"的大括号内部的代码当作C语言代码处理。所以以上代码中,C++的名称修饰机制将不会起作用。
当我们的C语言程序包含 string.h 的时候,并且用到了memset这个函数,编译器会将memset符号引用正确处理;但是在C++语言中,编译器会认为这个memset是一个C++函数,将memset的符号修饰成_Z6memsetPvii,这样链接器就无法与C语言库中的memset符号进行链接。所以对于C++来说使用条件宏来判断当前编译器单元是不是C++代码
- #ifdef __cplusplus
- extern "C"
- {
- #endif
- void* memset(void*, int, size_t);
-
- #ifdef __cplusplus
- }
- #endif
编译器默认函数和初始化了的全局变量为强符号,未初始化的全局变量为弱符号
可以通过GCC的"__attribute__((weak))",来定义任何一个强符号为弱符号
- extern int ext;
-
- int weak;
-
- int strong = 1;
-
- __attribute__((weak)) weak2 = 2;
-
- int main()
- {
- return 0;
- }
weak 和 weak2是弱符号,"strong" 和 "main"是强符号,"ext" 既非强符号也非弱符号,因为它是一个外部变量的引用
链接器按如下规则处理与选择被多次定义的全局符号:
1)不允许强符号被多次定义(即不同的目标文件中不能有同名的强符号)
如果有多个强符号定义,则链接器报符号重复定义错误
2)如果一个符号在某个目标文件中是强符号,在其他文件中都是弱符号,那么选择强符号
3)如果一个符号在所有目标文件中都是弱符号,那么选择其中占用空间最大的一个
弱引用和强引用
对于外部目标文件的符号引用在目标文件被最终链接成可执行文件时,它们须要被正确决议,如果没有找到该符号的定义,链接器就会报符号未定义错误,这种被称为强引用
在处理弱引用时,如果该符号有定义,则链接器将该符号的引用决议;如果该符号未定义,则链接器对于该引用不报错。链接器默认其为0,或一个特殊的值,以便于程序代码能够识别。弱引用和弱符号主要用于库的链接过程
- __attribute__((weakref)) void foo();
-
- int main()
-
- {
-
- if(foo)
- {
-
- foo();
- }
-
- }
如果foo()未定义,则foo函数的地址为0
通过弱引用来判断程序链接的是单线程库还是多线程库
- #include
- #include
-
- int pthread_create(pthread_t*,const pthread_attr_t*,void* (*)(void*)
- {
- __attribute__((weak));
- }
- int main()
- {
- if (pthread_create)
- {
- printf("multi-thread!\n");
- }
- else
- {
- printf("single_thread!\n");
- }
- }