上一篇文章说到了从源代码到可执行文件的整个过程,程序最后的阶段是将各个目标文件链接成可执行文件,现在来仔细来看看目标文件的具体的结构,目标文件存放了哪些信息。
可执行文件格式
目前不同平台下的可执行文件格式各有不同,Windows下为PE(Portable Executable),Linux下为ELF(Executable Linkable Format),Mac OS下为Mach-O(Mach-Object)。
每当操作系统加载一个文件进入内存时,会读取文件的头几个字节,用以判断文件的类型,以便使用不同的程序去加载此文件。这头几个字节的数据是可以看做不同文件的标识符,在操作系统中称为魔数(Magic)
下面是3种主流系统的可执行文件的魔数
文件 | 魔数 | 说明 |
---|---|---|
PE | MZ(0x4d5a) | Windows下的可执行文件 |
ELF | \x7FELF(0x7F454C46) | Linux下的可执行文件 |
PE | 0xfeedface(32位) 0xfeedfacf(64位) | Mach-O下的可执行文件 |
值得一说的是三种主流操作系统中使用到可执行程序的格式都是基于COFF(Common file fomart)格式的变种,三种格式从文件的内容和结构上很相似。
UN*X实际上标准化了一个通用的可移植的二进制格式,称为ELF(Executable Linkable Format),也便是目前linux使用的二进制格式,但作为纯正Unix的Mac OS却并不知这种格式,它单独维护一个称为Mach-O的格式,这也是苹果系统当前的历史包袱,它源于Mac OS系统的前身NeXTSTEP系统。
数据段与代码段
在中之前的文章中有提及到,源代码编译成目标文件后,代码中的信息会按不同属性以节(section)的形式存储,有时也称为段(segment),如编译后的程序代码存储在代码段(linux称.text )中,全局变量,静态变量存放在数据段中(elf中称为.data)。
以下c程序,编译成目标文件后,数据存储的位置
//hello.c
#include <stdio.h>
int global_uninit_val;
int global_init_val =123;
static int static_val =456;
int main()
{
int a = 1;;
printf("helloworld\n");
return 1;
}
下面是在linux平台下,数据和对应节点的情况。
C语言语句编译成机器代码后数据都存放在代码段中,已初始化的全局变量int global_init_val =123;
和局部静态变量static int static_val =456
都存放在数据段中。为初始化的全局变量则存放在.bss段中。
总体而言,程序源代码编译成目标文件后主要分成两种段,代码段(.data)和数据段(.data和.bss段)。
下面以一个非常简单的helloworld程序来在macos和linux下的可执行文件的结构来探究一下COFF文件的格式。
macos 中可以使用otool命令查看Mach-O的文件
而linux 也可以使用readelf工具查看ELF文件
Mac OS
在MaoOS 下使用gcc编译成目标文件后
gcc -c hello.c
使用jtool工具查看目标文件具体结构
./jtool --pages hello.o
输出结果
0x0-0x0 Indirect Symbol Table
0x270-0x318
0x270-0x2a4 .__text
0x2a4-0x2a8 .__data
0x2a8-0x2b4 .__cstring
0x2b8-0x2d8 .__compact_unwind
0x2d8-0x318 .__eh_frame
0x330-0x370 Symbol Table
0x370-0x3a4 String Table
linux
在linux 下使用gcc编译成目标文件后
gcc -c hello.c
使用objdump工具查看文件格式
objdump -h hello.o
hello.o: file format elf32-i386
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00000024 00000000 00000000 00000034 2**2
CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
1 .data 00000004 00000000 00000000 00000058 2**2
CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000000 00000000 00000000 0000005c 2**2
ALLOC
3 .rodata 0000000b 00000000 00000000 0000005c 2**0
CONTENTS, ALLOC, LOAD, READONLY, DATA
4 .comment 0000002b 00000000 00000000 00000067 2**0
CONTENTS, READONLY
5 .note.GNU-stack 00000000 00000000 00000000 00000092 2**0
CONTENTS, READONLY
6 .eh_frame 00000038 00000000 00000000 00000094 2**2
CONTENTS, ALLOC, LOAD, RELOC, READONLY, DATA
可以看到linux和mac系统下都有数据段和代码段,仅仅是命名的方式有些许的不一样,代码段mac称为__text,linux下称为.text。数据段mac称为__data,linux称为.data 。
.bss段是什么?
BSS是(Block Started by Symbol)的缩写,是专门用来存储未初始化的全局变量和为初始化的静态变量的一块内存区域。有人便会有疑问,既然有专门存放数据的数据段(.data),那么.bss段的存在意义是什么?
这是因为未初始化的全局或静态变量因加载程序时未知其实际的值,程序其实不必为其分配内存空间,而且.bss段可被读写的,所以其实并不需要像.data段一样,编译成目标文件后马上为其分配空间,这种做法可以优化文件大小,无需分配过多的空间。
其他段
除了常用的.data,.text,.bss段之外,目标文件还需要其他的段用于保存于程序相关的其他数据。比如字符串如何存储,程序的符号如何存储。
下面以linux的elf格式来继续探讨其他的段内容。
使用readelf命令查看所有的表
readelf -S hello.o
There are 13 section headers, starting at offset 0x12c:
Section Headers:
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[ 0] NULL 00000000 000000 000000 00 0 0 0
[ 1] .text PROGBITS 00000000 000034 000024 00 AX 0 0 4
[ 2] .rel.text REL 00000000 00043c 000010 08 11 1 4
[ 3] .data PROGBITS 00000000 000058 000004 00 WA 0 0 4
[ 4] .bss NOBITS 00000000 00005c 000000 00 WA 0 0 4
[ 5] .rodata PROGBITS 00000000 00005c 00000b 00 A 0 0 1
[ 6] .comment PROGBITS 00000000 000067 00002b 01 MS 0 0 1
[ 7] .note.GNU-stack PROGBITS 00000000 000092 000000 00 0 0 1
[ 8] .eh_frame PROGBITS 00000000 000094 000038 00 A 0 0 4
[ 9] .rel.eh_frame REL 00000000 00044c 000008 08 11 8 4
[10] .shstrtab STRTAB 00000000 0000cc 00005f 00 0 0 1
[11] .symtab SYMTAB 00000000 000334 0000d0 10 12 9 4
[12] .strtab STRTAB 00000000 000404 000035 00 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings)
I (info), L (link order), G (group), T (TLS), E (exclude), x (unknown)
O (extra OS processing required) o (OS specific), p (processor specific)
其中第一列指的是列的序号,第二列是段名称,第三列是段类型,
符号表
编译器最后一步是将不同的目标文件结合到一起。在链接中,目标文件之间的相互拼合实际上是目标文件对地址的引用,具体到C语言,是函数和变量的引用。比如上面hello.c例子中,hello.c中的main函数引用到了stdio.h中的printf函数。在hello.c生成目标文件时,调用printf的跳转暂时是无法知道具体的地址,在编译器将所有目标文件链接成执行文件时,将跳转printf的地址替换成真正printf实际地址。
在链接中,我们将函数和变量统一称为符号(Symbol),函数名或者变量名就是符号。
正因为链接时符号作为各个目标文件的的链接的主要的依据,因此管理好目标文件的符号非常重要。在可执行文件中将符号统一交由符号表(Symbol Table)进行管理。
linux下使用readelf -s hello.o
工具打印hello.o的符号表
Symbol table '.symtab' contains 13 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 00000000 0 NOTYPE LOCAL DEFAULT UND
1: 00000000 0 FILE LOCAL DEFAULT ABS hello.c
2: 00000000 0 SECTION LOCAL DEFAULT 1
3: 00000000 0 SECTION LOCAL DEFAULT 3
4: 00000000 0 SECTION LOCAL DEFAULT 4
5: 00000000 0 SECTION LOCAL DEFAULT 5
6: 00000000 0 SECTION LOCAL DEFAULT 7
7: 00000000 0 SECTION LOCAL DEFAULT 8
8: 00000000 0 SECTION LOCAL DEFAULT 6
9: 00000004 4 OBJECT GLOBAL DEFAULT COM global_uninit_val
10: 00000000 4 OBJECT GLOBAL DEFAULT 3 global_init_val
11: 00000000 36 FUNC GLOBAL DEFAULT 1 main
12: 00000000 0 NOTYPE GLOBAL DEFAULT UND puts
可以看到符号表中不仅包含有全局、静态函数的符号,main函数的符号,也有hello.c文件名的符号。
其中第三列指出的符号的类型,FILE(文件)、NOTYPE(未知)、GLOBAL(全局符号),FUNC(函数)
对于函数与变量而言,符号就是他们的地址。他们有以下几个特点:
1. 定义本目标文件中的全局符号,可以被其他目标文件引用,如main,global_uninit_val等
2. 在本目标文件中引用,在其他目标文件定义的全局符号,称为外部符号
C、C++语言的符号修饰
C函数或变量编译成目标文件后,会将符号保存至符号表中,且符号是用于链接同一个函数或变量的唯一标志,也就是说相同的一个程序中不可以拥有两个相同函数的实现。
但这种方式导致了另外一个问题,一旦C程序变得庞大,函数或者全局变量的命名重名变得难以避免。当引用到其他的库时,需要时刻小心函数命名以防出现函数重名便需要非常的小心。这是C函数的一个历史包袱,为避免这种情况,一般的C函数库都加上特定的前缀进行区分。
但这种原始简单的区分方式只能暂时避免符号重名的情况,并不能根本的解决这问题。为解决这个问题,目前大部分新出的语言都提出了称为命名空间的方式用以解决这个问题,同样作为C语言的升级版C++也通过支持命名空间(namespace)的方式解决符号冲突的问题。
我们知道C++语言支持函数重载,也支持两个不同类中可以声明相同函数名的函数。这其实是通过符号修饰(name decoration),或称符号改编(name mangling)。
具体的做法是使用额外的修饰符对函数和变量进行修饰。
首先声明几个重载函数以及不同类,不同命名空间的例子
int func(int a){ return 1;};
int func(int a,int b){ return 1;};
int func(float a){ return 1;};
class ClassA{
public:
int func(int a){return 1;};
};
class ClassB{
public:
int func(int a){return 1;};
};
namespace myNS {
int func(int){return 1;};
class ClassC{
public:
int func(int a){return 1;};
};
}
以最复杂的在myNS命名空间里面的ClassC 类的func函数的符号作为例子,看看是如何进行符号修饰的。
现将源码编译成目标文件
g++ -c hello.cpp
再使用nm命令查看目标文件中包含的符号信息
nm hello.o
虽然是macos和linux都是以g++进行编译,经过修饰后的符号大体相同,仅在类命名中有些许差异。
命名修饰以”_Z”或”__Z”开头,以”E”包含函数命名空间以及对应类,最终以函数函数结束。
其中命名空间以”N”开头,接以命令空间名称字母数,再以命名空间以结尾。
类声明类似命名空间,linux下以”C”开头(MacOS无C开头),接以类名字母数,再以类名结尾。
函数签名 | Linux符号名 | MacOS符号名 |
---|---|---|
int func(int a) | _Z4funci | __Z4funci |
int func(int a,int b) | _Z4funcii | __Z4funcii |
int func(float a) | _Z4funcf | __Z4funcf |
int ClassA::func(int a) | _ZNC6ClassA4funcEi | __ZN6ClassA4funcEi |
int ClassB::func(int a) | _ZNC6ClassB4funcEi | __ZN6ClassB4funcEi |
int myNS::func(int a) | _ZN4myNS4funcEi | __ZN4myNS4funcEi |
int myNS::ClassC::func(int a) | _ZN4myNSC6ClassC4funcEi | __ZN4myNS6ClassC4funcEi |
这种通过添加符号将函数/变量的符号进行修饰的过程称为“函数签名”。函数签名包含函数名,函数命名空间,类名,参数类型。
C++函数兼容C函数
因为C++程序默认会将函数进行函数签名,以生成更详细的符号。但一种情况是若不想使用C++的函数签名,如上例中 int func(int a)
并不需要将其编译成待函数签名的符号,普通的C函数符号就可以满足要求。
为解决这种情况,C++新增了 extern “C” 用法,告诉编译器,被包围的这段代码不需要进行函数签名,而是编译成普通的C函数符号。
#ifdef __cplusplus
extern "C" {
#endif
int func(int a){ return 1;};
#ifdef __cplusplus
}
#endif
小结
本文章从目标文件的大致结构说到常用的数据段,代码段,再从符号表中剥离出C和C++符号表生成的不同,但目标文件的还有其他非常多的知识点以供我们去探究,下篇文章继续探究