目标文件有什么
目标文件的格式
PC平台流行的可执行文件格式主要为
-
Windows下:PE
-
Linux下:ELF
目标文件就是源代码编译之后但是未进行链接的那些中间文件(Windows下的.obj和Linux下的.o)
目标文件具体什么样子
目标文件中的内容除了编译后的机器指令代码,还有链接时所需要的一些信息,比如:符号表,调试信息,字符串等,一般目标文件将这些信息按照不同的属性,以“节”的形式进行存储,有时也叫“段”
-
程序源代码编译后的机器指令经常放在代码段(Code Section),代码段常见的名字由“.code”或者“.text”
-
全局变量和局部静态变量数据经常放在数据段(Data Section),数据段一般叫做“.data”
图示:

假设上图的目标文件的格式是ELF,开头是一个“文件头”,描述了整个文件的文件属性,包括文件是否可执行,是静态链接还是动态链接以及入口地址(如果是可执行文件),目标硬件,目标操作系统等信息。
文件头还包括一个段表,它其实是一个描述文件中各个段的数组,段表描述了文件中各个段在文件中的偏移位置以及段的属性等
对照上图:
- 一般C语言的编译后执行语句都编译成机器代码,放在.text段
- 已经初始化的全局变量和局部静态变量放在.data段
- 未初始化的全局变量和局部静态变量放在.bss段(.bss段只是为未初始化的全局变量和局部静态变量预留位置而已,它并没有内容,也不占空间)
程序源代码被编译以后主要分成两段:
-
程序指令和程序数据
-
代码段属于程序指令,而数据段和.bss段属于程序数据
深入理解
以下面这段C程序作为研究的例子:
int printf(const char* format,...);
int global_init_var = 54;
int global_uninit_var;
void func1(int i)
{
printf("%d\n",i);
}
int main(void)
{
static int static_var = 85;
static int static_var2;
int a = 1;
int b;
func1(static_var+static_var2+a+b);
return 0;
}
使用GCC来编译(参数 -c 表示只编译不链接):
gcc -c SimpleSection.c
得到一个SimpleSection.o目标文件,图示:

使用binutils工具objdump来查看object内部的结构:
objdump -h SimpleSection.o
输出如下:

参数-h就是把ELF文件的各个段的基本信息打印出来,也可以使用“objdump -x”把更多的信息打印出来
SimpleSection.o: 文件格式 elf64-x86-64
节:
Idx Name Size VMA LMA File off Algn
0 .text 00000059 0000000000000000 0000000000000000 00000040 2**0
CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
1 .data 00000008 0000000000000000 0000000000000000 0000009c 2**2
CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000004 0000000000000000 0000000000000000 000000a4 2**2
ALLOC
3 .rodata 00000004 0000000000000000 0000000000000000 000000a4 2**0
CONTENTS, ALLOC, LOAD, READONLY, DATA
4 .comment 0000002a 0000000000000000 0000000000000000 000000a8 2**0
CONTENTS, READONLY
5 .note.GNU-stack 00000000 0000000000000000 0000000000000000 000000d2 2**0
CONTENTS, READONLY
6 .eh_frame 00000058 0000000000000000 0000000000000000 000000d8 2**3
CONTENTS, ALLOC, LOAD, RELOC, READONLY, DATA
可以看到,除了基本的代码段,数据段,BSS段,还有
-
只读数据段(.rodata)
-
注释信息段(.comment)
- 堆栈提示段(.note.GNU-stack)
再看几个重要段的属性:
-
Size:段长度
-
File off:段所在位置
每个段的第二行中的CONTENTS, ALLOC, LOAD等表示段的各种属性,CONTENTS表示该段在文件中存在,BSS段中没有CONTENTS,表示它实际上在ELF文件中不存在内容
有一个专门的“size”命令可以查看ELF文件的代码段,数据段以及BSS段的长度(dec:表示三个段长度的和的十进制,hex:表示长度和的16进制)
size SimpleSection.o
图示:

代码段
使用objdump这个利器来挖掘各个段的内容,objdump的“-s”参数可以将所有段的内容以16进制的方式打印出来,“-d”参数可以将所有包含指令的段反汇编,
objdump -s -d SimpleSection.o
输出内容(省略了无关内容):
Contents of section .text:
0000 554889e5 4883ec10 897dfc8b 45fc89c6 UH..H....}..E...
0010 488d3d00 000000b8 00000000 e8000000 H.=.............
0020 0090c9c3 554889e5 4883ec10 c745f801 ....UH..H....E..
0030 0000008b 15000000 008b0500 00000001 ................
0040 c28b45f8 01c28b45 fc01d089 c7e80000 ..E....E........
0050 0000b800 000000c9 c3 .........
0000000000000000 <func1>:
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: 48 83 ec 10 sub $0x10,%rsp
8: 89 7d fc mov %edi,-0x4(%rbp)
b: 8b 45 fc mov -0x4(%rbp),%eax
e: 89 c6 mov %eax,%esi
10: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi # 17 <func1+0x17>
17: b8 00 00 00 00 mov $0x0,%eax
1c: e8 00 00 00 00 callq 21 <func1+0x21>
21: 90 nop
22: c9 leaveq
23: c3 retq
0000000000000024 <main>:
24: 55 push %rbp
25: 48 89 e5 mov %rsp,%rbp
28: 48 83 ec 10 sub $0x10,%rsp
2c: c7 45 f8 01 00 00 00 movl $0x1,-0x8(%rbp)
33: 8b 15 00 00 00 00 mov 0x0(%rip),%edx # 39 <main+0x15>
39: 8b 05 00 00 00 00 mov 0x0(%rip),%eax # 3f <main+0x1b>
3f: 01 c2 add %eax,%edx
41: 8b 45 f8 mov -0x8(%rbp),%eax
44: 01 c2 add %eax,%edx
46: 8b 45 fc mov -0x4(%rbp),%eax
49: 01 d0 add %edx,%eax
4b: 89 c7 mov %eax,%edi
4d: e8 00 00 00 00 callq 52 <main+0x2e>
52: b8 00 00 00 00 mov $0x0,%eax
57: c9 leaveq
58: c3 retq
Contents of section .text就是.text的数据以16进制方式打印出来的内容
-
最左边一列是偏移量
-
中间4列是16进制内容
-
最右边一列是.text段的ASCII码形式
对照下面的反汇编结果,可以明显看到.text段里包含的正是SimpleSection.c中两个函数func1()和main()的指令
数据段与只读数据段
.data中存放已经初始化了的全局静态变量以及局部静态变量,在SimpleSection.c中就定义了两个这样的变量,global_init_var 和 global_uninit_var,这两个变量每个4个字节,一共刚好8个字节,所以“.data”这个段的大小为8个字节
SimpleSection.c里面调用“printf”时,用到了一个字符串常量“%d\n”,它是一种只读数据,放在了“.rodata”段,可以看到“.rodata”这个段的4个字节刚好是这个字符串常量的ASCII字节序
BSS段
.bss段存放未初始化的全局变量以及局部静态变量
小问题:
static int x1 = 0;
static int x2 = 1;
x1和x2会放在什么段中?
x1会放在.bss中,x2会放在.data中,因为x1为0,可以认为是未初始化的,因为未初始化的都是0,所以被优化掉了放在.bss,节省磁盘空间
其它段
除了.text,.data,.bss这三个最常用的段之外,ELF文件也有可能含有其它的段,用来保存与程序相关的其他信息
图示(截图来自<<程序员的自我修养—链接、装载与库>>):

ELF文件结构描述
ELF目标文件的总体结构图示:

文件头
可以使用readelf命令来详细查看ELF文件:
执行命令:
readelf -h SimpleSection.o
输出:
ELF 头:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
类别: ELF64
数据: 2 补码,小端序 (little endian)
版本: 1 (current)
OS/ABI: UNIX - System V
ABI 版本: 0
类型: REL (可重定位文件)
系统架构: Advanced Micro Devices X86-64
版本: 0x1
入口点地址: 0x0
程序头起点: 0 (bytes into file)
Start of section headers: 1112 (bytes into file)
标志: 0x0
本头的大小: 64 (字节)
程序头大小: 0 (字节)
Number of program headers: 0
节头大小: 64 (字节)
节头数量: 13
字符串表索引节头: 12
可以看出,ELF文件的头中定义了魔数,类别,数据,版本等
ELF魔数
7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
- 最开始的四个字节是所有ELF文件都必须一样的标识码,分别为0x7F,0x45,0x4c,0x46
第一个字节对应的ASCII字符里面的DEL控制符
后面三个字节刚好是ELF三个字母的ASCII码
这四个字节又被称为ELF文件的魔数
接下来的一个字节用来标识ELF文件类的
-
0x01:32位
-
0x02:64位
第六个字节是字节序,规定该文件是大端的还是小端的
第七个字节规定ELF文件的主版本号,一般为1
段表
ELF文件中有各种各样的段,这个段表就是保存这些段的基本属性的结构,编译器,链接器,转载器都是依靠段表来定位和访问各个段的属性的
通过readelf命令来查看段结构:
readelf -S SimpleSection.o
输出:
There are 13 section headers, starting at offset 0x458:
节头:
[号] 名称 类型 地址 偏移量
大小 全体大小 旗标 链接 信息 对齐
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .text PROGBITS 0000000000000000 00000040
0000000000000059 0000000000000000 AX 0 0 1
[ 2] .rela.text RELA 0000000000000000 00000348
0000000000000078 0000000000000018 I 10 1 8
[ 3] .data PROGBITS 0000000000000000 0000009c
0000000000000008 0000000000000000 WA 0 0 4
[ 4] .bss NOBITS 0000000000000000 000000a4
0000000000000004 0000000000000000 WA 0 0 4
[ 5] .rodata PROGBITS 0000000000000000 000000a4
0000000000000004 0000000000000000 A 0 0 1
[ 6] .comment PROGBITS 0000000000000000 000000a8
000000000000002a 0000000000000001 MS 0 0 1
[ 7] .note.GNU-stack PROGBITS 0000000000000000 000000d2
0000000000000000 0000000000000000 0 0 1
[ 8] .eh_frame PROGBITS 0000000000000000 000000d8
0000000000000058 0000000000000000 A 0 0 8
[ 9] .rela.eh_frame RELA 0000000000000000 000003c0
0000000000000030 0000000000000018 I 10 8 8
[10] .symtab SYMTAB 0000000000000000 00000130
0000000000000198 0000000000000018 11 11 8
[11] .strtab STRTAB 0000000000000000 000002c8
000000000000007c 0000000000000000 0 0 1
[12] .shstrtab STRTAB 0000000000000000 000003f0
0000000000000061 0000000000000000 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
L (link order), O (extra OS processing required), G (group), T (TLS),
C (compressed), x (unknown), o (OS specific), E (exclude),
l (large), p (processor specific)
段表的结构是一个数组,数组元素的个数等于段的个数,对于SimpleSection.o来说,段表就是有13个元素的数组
ELF段表这个数组的第一个元素是无效的段描述符,类型为“NULL”,除此之外每个段描述符都对应一个段
链接的接口---符号
链接的过程的本质就是把多个不同的目标文件之间相互“粘”在一起,为了使不同的目标文件之间能够相互粘合,这些目标文件之间必须由固定的规则
在链接中目标文件之后相互粘合实际上就是目标文件之间对地址的引用,即对函数和变量的地址的引用
如目标文件B要用到目标文件A中函数“foo”,那么就称目标文件A定义了函数foo,称目标文件B引用了目标文件A中的函数foo。
在链接中,将函数和变量统称为符号(Symbol),函数名或者变量名就是符号名
可以将符号看做是链接中的粘合剂,整个链接过程是基于符号才能够正确完成,链接过程中很关键的一部分就是符号的管理,每一个目标文件都会有一个相应的符号表,这个表里面记录了目标文件中所用的的所有符号,每个定义的符号有一个对应的值,叫做符号值,对于变量和函数,符号值就是它们的地址
将符号表中所有符号进行分类:
-
定义在本目标文件的全局符号,可以被其他目标文件引用,比如SimpleSection.o里面的“func1”,“main”,“global_init_var”
-
在本目标文件中引用的全局符号,却没有定义在本目标文件,一般叫做外部符号,即符号引用,如SimpleSection.o的“printf”
-
段名,这种符号往往由编译器产生,它的值就是段的起始地址,如SimpleSection.o里面的“.text”,".data"
- 局部符号,这类符号只在编译单元内部可见,比如SimpleSection.o里面的"static_var",和"static_var2"
- 行号信息,即目标文件指令与源代码中代码行的对应关系
使用"nm"来查看SimpleSection.o的符号:
nm SimpleSection.o
输出:
0000000000000000 T func1
0000000000000000 D global_init_var
U _GLOBAL_OFFSET_TABLE_
0000000000000004 C global_uninit_var
0000000000000024 T main
U printf
0000000000000004 d static_var.1802
0000000000000000 b static_var2.1803
ELF符号表结构
使用readelf工具来查看:
readelf -s SimpleSection.o
输出:
Symbol table '.symtab' contains 17 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS SimpleSection.c
2: 0000000000000000 0 SECTION LOCAL DEFAULT 1
3: 0000000000000000 0 SECTION LOCAL DEFAULT 3
4: 0000000000000000 0 SECTION LOCAL DEFAULT 4
5: 0000000000000000 0 SECTION LOCAL DEFAULT 5
6: 0000000000000004 4 OBJECT LOCAL DEFAULT 3 static_var.1802
7: 0000000000000000 4 OBJECT LOCAL DEFAULT 4 static_var2.1803
8: 0000000000000000 0 SECTION LOCAL DEFAULT 7
9: 0000000000000000 0 SECTION LOCAL DEFAULT 8
10: 0000000000000000 0 SECTION LOCAL DEFAULT 6
11: 0000000000000000 4 OBJECT GLOBAL DEFAULT 3 global_init_var
12: 0000000000000004 4 OBJECT GLOBAL DEFAULT COM global_uninit_var
13: 0000000000000000 36 FUNC GLOBAL DEFAULT 1 func1
14: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND _GLOBAL_OFFSET_TABLE_
15: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND printf
16: 0000000000000024 53 FUNC GLOBAL DEFAULT 1 main
-
第一列Num:表示符号表数组下标,从0开始,共17个符号
-
第二列Value就是符号值
-
第三列Size为符号大小
-
第四列与第五列分别为符号类型和绑定信息
-
第六列Vis在C/C++中未使用
-
第七列Ndx:表示该符号所属的段
- 最后一列为符号名称
分析:
- func1与main函数都是定义在SimpleSection.c里面,它们的所在位置为代码段,所以Ndx为1,即在SimpleSection.o里面.text段的下标为1,可以通过readelf -a或者objdump -x验证,它们是函数,所以类型是FUNC,它们是全局可见,所以为GLOBAL,Size表示函数指令所占的字节数,Value表示函数相对于代码段起始位置的偏移量
- printf这个符号,该符号在SimpleSection.c里面被引用,但是没有定义,所以它的Ndx为UND
-
global_init_var是已经初始化的全局变量,被定义在.bss段,下标为3
-
global_uninit_var是未初始化的全局变量,是一个COM类型的符号,但本身并没存在BSS段
-
static_var.1802与static_var2.1803是两个静态变量,绑定属性为LOCAL,即只是在编译单元内部可见
- SECTION类型的符号,它们表示下标为Ndx的段的段名,它们的符号名没有显示,其实它们的符号名就是它们的段名
特殊符号
符号修饰与函数签名
约在20世纪70年代以前,编译器编译源代码产生目标文件时,符号名与相应的变量和函数的名字是一样的。比如一个汇编源代码里面包含了一个函数foo,那么汇编器将它编译成目标文件以后,foo在目标文件中的相对应的符号名也是foo。当后来UNIX平台和C语言发明时,已经存在了相当多的使用汇编编写的库和目标文件。这样就产生了一个问题,那就是如果一个C程序要使用这些库的话,C语言中不可以使用这些库中定义的函数和变量的名字作为符号名,否则将会跟现有的目标文件冲突。比如有个用汇编编写的库中定义了一个函数叫做main,那么我们在C语言里面就不可以再定义一个main函数或变量了。同样的道理,如果一个C语言的目标文件要用到一个使用Fortran语言编写的目标文件,我们也必须防止它们的名称冲突。
为了防止类似的符号名冲突,UNIX下的C语言就规定,C语言源代码文件中的所有全局的变量和函数经过编译以后,相对应的符号名前加上下划线""。而Fortran语言的源代码经过编译以后,所有的符号名前加上"",后面也加上"_"。比如一个C语言函数"foo",那么它编译后的符号名就是"_foo";如果是Fortran语言,就是"foo"。
这种简单而原始的方法的确能够暂时减少多种语言目标文件之间的符号冲突的概率,但还是没有从根本上解决符号冲突的问题。比如同一种语言编写的目标文件还有可能会产生符号冲突,当程序很大时,不同的模块由多个部门(个人)开发,它们之间的命名规范如果不严格,则有可能导致冲突。于是像C++这样的后来设计的语言开始考虑到了这个问题,增加了名称空间(Namespace)的方法来解决多模块的符号冲突问题。
但是随着时间的推移,很多操作系统和编译器被完全重写了好几遍,比如UNIX也分化成了很多种,整个环境发生了很大的变化,上面所提到的跟Fortran和古老的汇编库的符号冲突问题已经不是那么明显了。在现在的Linux下的GCC编译器中,默认情况下已经去掉了在C语言符号前加""的这种方式;但是Windows平台下的编译器还保持的这样的传统,比如Visual C++编译器就会在C语言符号前加"",GCC在Windows平台下的版本(cygwin、mingw)也会加"_"。GCC编译器也可以通过参数选项"-fleading-underscore"或"-fno-leading-underscore"来打开和关闭是否在C语言符号前加上下划线。
C++符号修饰
强大而又复杂的C++拥有类、继承、虚机制、重载、名称空间等这些特性,它们使得符号管理更为复杂。最简单的例子,两个相同名字的函数func(int)和func(double),尽管函数名相同,但是参数列表不同,这是C++里面函数重载的最简单的一种情况,那么编译器和链接器在链接过程中如何区分这两个函数呢?为了支持C++这些复杂的特性,人们发明了符号修饰(Name Decoration)或符号改编(Name Mangling)的机制,
首先出现的一个问题是C++允许多个不同参数类型的函数拥有一样的名字,就是所谓的函数重载;另外C++还在语言级别支持名称空间,即允许在不同的名称空间有多个同样名字的符号。比如下面这段代码:
int func(int);
float func(float);
class C {
int func(int);
class C2 {
int func(int);
};
};
namespace N {
int func(int);
class C {
int func(int);
};
}
这段代码中有6个同名函数叫func,只不过它们的返回类型和参数及所在的名称空间不同。我们引入一个术语叫做函数签名(Function Signature),函数签名包含了一个函数的信息,包括函数名、它的参数类型、它所在的类和名称空间及其他信息。函数签名用于识别不同的函数,就像签名用于识别不同的人一样,函数的名字只是函数签名的一部分。由于上面6个同名函数的参数类型及所处的类和名称空间不同,我们可以认为它们的函数签名不同。在编译器及链接器处理符号时,它们使用某种名称修饰的方法,使得每个函数签名对应一个修饰后名称(Decorated Name)。编译器在将C++源代码编译成目标文件时,会将函数和变量的名字进行修饰,形成符号名,也就是说,C++的源代码编译后的目标文件中所使用的符号名是相应的函数和变量的修饰后名称。C++编译器和链接器都使用符号来识别和处理函数和变量,所以对于不同函数签名的函数,即使函数名相同,编译器和链接器都认为它们是不同的函数。
extern "C"
C++为了兼容C,在符号管理上,C++有一个用来声明或定义一个C的符号的"extern “C” "
关键字用法
extern "C" {
int func(int);
itn var;
}
C++编译器会将在extern"C"的大括号内部的代码当做C语言代码处理,上述代码声明了一个C的函数func,定义了一个整型全局变量var
弱符号与强符号
符号重复定义,多个目标文件中含有相同名字全局符号的定义,那这些目标文件链接时就会出现符号重复定义的错误,如在目标文件A与B中都定义了一个全局符号global,将它们都初始化,那么链接器将A和B进行链接的时候会报错
- 上述这种符号的引用叫强符号
当然有些符号的定义可以被称为弱符号,对于C/C++,编译器默认函数和初始化了的全局变量为强符号,未初始化的全局变量为弱符号
针对强弱符号的概念,链接器会按照以下规则处理与选择被多次定义的全局符号:
-
1 不允许强符号被多次定义(不同的目标文件中不能有同名的强符号),如果有多个强符号定义,则链接器报符号重复定义错误
-
2 如果一个符号在某个目标文件中是强符号,在其他文件都是弱符号,那么选择强符号
-
3 如果一个符号在所有目标文件中都是弱符号,那么选择其中占用空间最大的一个:
比如目标文件A定义全局变量global为int型,占4个字节,目标文件B定义global为double型,占8个字节,那么A与B链接后,符号global占8个字节
参考资料
<<程序员的自我修养—链接、装载与库>>
网友评论