说明:本文的讨论基于一个运行linux的x86系统环境,使用标准ELF文件格式。讨论集中在32位代码,在x86-64系统上用gcc -m32产生32位代码。若编译时发生/usr/include/features.h:364:25: fatal error: sys/cdefs.h: 没有那个文件或目录,则使用命令sudo apt-get install libc6-dev-i386解决。 ELF文件格式与相关命令详见http://blog.csdn.net/li_xiang_li/article/details/50528307
一. 由源代码到可执行程序经历了什么
源代码:
//main.c
int add(int a,int b);
static int si;//.bss
extern int buf[];
int *copy = &buf[0];//.rel.data
int main()
{
int a = 3;
int b = 5;
int c = add(a,b);//.rel.text
char *s = "hello c";//.rodata
static int si;//.bss
return 0;
}
//add.c
int buf[2];
int add(int a,int b)
{
return (a+b);
}
//makefile(为了简化讨论,makefile文件中没有添加-g选项)
all:main
main:main.o add.o
gcc -o main main.o add.o -m32
main.o:main.c
gcc -c main.c -m32
add.o:add.c
gcc -c add.c -m32
clean:
rm -rf *.o main
从源代码到可执行程序所要经历的过程概述:
源代码(.c .cpp .h)经过c预处理器(cpp)后生成.i文件,编译器(cc1、cc1plus)编译.i文件后生成.s文件,汇编器(as)汇编.s文件后生成.o文件,链接器(ld)链接.o文件生成可执行文件。gcc是对cpp、cc1(cc1plus)、as、ld这些后台程序的包装,它会根据不同的参数要求去调用后台程序。以helloworld程序为例,使用gcc -o hello hello.c时加上-v选项可观察到详细的步骤。也可使用gcc分别进行以上四步骤,预编译gcc -E hello.c -o hello.i,编译gcc -S hello.i -o hello.s,汇编gcc -c hello.s -o hello.o,链接gcc -o hello hello.o
有兴趣的话可以尝试验证以下预处理、编译、汇编、链接的详细过程及结果。使用cpp对c文件进行预处理用vim查看.i文件,使用cc1编译.i文件用vim查看.s文件,使用as汇编.s文件,最终使用ld手动链接相关文件生成可执行文件。(过程较为复杂,可参考-v选项后的结果)
二、 由ELF文件看程序的编译链接
程序的编译涉及词法分析、语法分析、语义分析等过程,详情参见《编译原理》。
1.以下分析一下编译完成之后的ELF文件:
readelf -S add.o查看section header table
由section header table可推测add.c文件代码中并没有实际数据(.data的size大小为0)。add.o中共包含了11个节描述符,每个节描述符大小为40字节(sizeof(Elf32_Shdr)),共440字节,因此在这11个节之后还有440字节的section header table。
readelf -S main.o查看section header table
alloc表示该section在进程地址空间中要分配空间(.text .data .bss都会有这个标志)
main.o中共有14个节描述符,每个节的大小为40字节(sizeof(Elf32_Shdr)),因此这14个节之后还有560字节的section header table。
以上的源代码和关于add.o和main.o的四张图可以看出,.bss确实不占磁盘空间,只是起到占位的作用(两个section table中.bss的size都是0,但源代码中确实定义了相关的变量)。.comment编译器版本信息的size为0x2e不变,ELF Header的大小为0x34字节不变。
2.关于符号表的一些描述:
-
- 符号表的数据结构
-
st_info低4位表示符号的类型,高28位表示符号绑定信息
符号类型:LOCAL(局部符号)GLOBAL(全局符号)WEAK(弱引用)
全局符号的两种情况:1、非static的c函数和非static的全局变2、又称为外部符号,定义在其他模块的函数和变量
局部符号:static的c函数和全局变量,目标文件中节和相应的源文件的名字。
符号绑定信息:NOTYPE(未知符号类型) OBJECT(数据对象,例如变量、数组) FUNC(函数) SECTION(段) FILE(文件名)
st_shndx符号所在段
若符号定义在本目标文件,则该成员表示符号所在的段在段表中的下标
若符号未定义在本文件中,或者有些特殊的符号,有以下含义:ABS该符号包含了一个绝对的值,例如文件名。 COMMON未初始化的全局符号。 UNDEF该符号在本目标文件被引用,但定义在其他目标文件中。
st_value符号值
每个符号都有一个对应的值,目标文件中若是非COMMOM类型的符号,则该符号对应的函数或者变量位于由st_shndx指定的段,偏移st_value的位置(常见如全局变量);目标文件中若是COMMON类型的符号,则st_value表示该符号的对齐属性;可执行文件中st_value表示符号的虚拟地址。
问题:未初始化的全局变量到底存放在.bss中还是存放在符号表的COMMON中?
实际上跟不同的语言和不同的编译器实现有关,有些编译器会将全局未初始化变量存放在.bss段,有些则不存放,只是预留一个未定义的全局符号,等到最终链接成可执行文件的时候再在.bss段分配空间。
-
- 详细查看符号表
-
readelf -s add.o查看符号表
可以从本图中反推代码,add.c类型为FILE,符号所在段为ABS,它是文件名;buf类型为OBJECT,符号所在段在COMMOM中暂时存放,绑定信息为GLOBAL,size为8表示该数据对象大小为8字节,value为4表示该数据对象按照4字节对齐,则代码中buf是一个未初始化的全局变量或数组;add类型为FUNC,符号所在段为.text,绑定信息为GLOBAL,size为13表示该函数指令占13字节,value为0表示该函数相对于段起始位置的偏移量为0,它是函数名。其他符号所在段为1,2,3,5,6,4分别表示对应的section table中的段名,分别为.text .data .bss .note.GNU-stack .eh_frame .comment
readelf -s main.o查看符号表
由本图可以反推源代码,main.c的类型为FILE,符号所在段为ABS,它是文件名;copy的类型是OBJECT,绑定信息为GLOBAL,size大小为4字节,所在段为3对应section table中为.data段,综合来看copy是一个已经初始化的大小为4字节的全局变量或数组;buf类型为NOTYPE,符号所在段为UND,绑定信息为GLOBAL,可见buf是本文件引用的一个外部文件中的符号;main类型为FUNC,符号所在段为1对应在section table中为.text段,绑定信息为GLOBAL,则该符号是一个函数名;add的类型是NOTYPE,符号所在段为UND,绑定信息为GLOBAL,可见add是本文件引用的一个外部文件中的符号。si类型为OBJECT,符号所在段为5对应section table为.bss,size为4,则si是一个大小为4字节的静态变量,同样si.1490也是一个大小为四字节的静态变量,都存放在.bss中。其他符号所在段为1,3,5,6,7,8,9的段自行对应到section table中。
关于si和si.1490的说明:
由于源代码定义了同名的全局静态变量si和局部的静态变量si,一时无法判断变量和符号的对应关系,但经过其他测试代码发现,只有局部的静态变量才会在变量名后加数字,因此可判断源代码中的全局的static int si对应符号表中的si,局部的static int si对应符号表中的si.1490
关于未初始化的全局变量存放在.bss中还是存放在COMMON中的细致说明:
未初始化的全局非static变量,编译后存放在.o文件中符号表的COMMON, 链接后存放在可执行文件的.bss中。
未初始化的全局static变量无论在编译阶段还是链接阶段始终存放在.bss中
三、链接过程简要分析(静态链接)
1、 链接的两步过程概述
第一步:空间分配,将所有可重定位文件(.o)中的同名section合并为一个segment,并按页对齐。调整各个section的偏移量和section本身大小。汇总所有可重定位文件(.o)的符号到全局符号表中。
第二步:符号解析与重定位(符号解析指将每个符号引用刚好和一个符号定义联系起来。重定位指链接器把每个符号定义与一个存储位置联系起来,然后修改所有对这些符号的引用,使得它们指向这个存储位置,从而重定位这些节。)
2、空间分配详解
查看main可执行程序的section table
readelf -S main
首先需要明确的是链接前后程序中所使用的地址已经是程序在进程中的虚拟地址。由add.o和main.o的section table中可以看出它们的Addr列的值都是0x0,因为虚拟空间还没有被分配,所以默认为0。同时这也说明之所以可重定位文件无法执行,是因为虚拟空间没有分配,无法从0x0地址映射到相应的物理地址。
从main的segment table中可以看出,最终.text被分配到地址0x080482f0,大小为0x1b2;.data被分配到地址0x0804a014,大小为0xc;.bss被分配到地址0x0804a020,大小为0x14;.rodata被分配到0x080484b8,大小为0x10。最终可执行文件main中每个segment的size值远远大于可重定位文件add.o和main.o中对应名字的section的size之和,是因为gcc调用的链接器ld隐式的链接了一些和启动有关的其他文件(比如crt1.o、crti.o、crtn.o、crtbegin.o、crtend.o等)。我们可以手动链接指定链接哪些文件,ld -m elf_i386 -e main -o main main.o add.o最终观察可执行文件中的segment的size值恰好是add.o和main.o中对应section的size的和,印证了关于链接时空间的分配。
3、符号解析与重定位详解
3.1、符号解析
3.1.1、概念:把代码中的每个符号引用和符号定义联系起来
链接器解析符号引用的方法是将每个引用与它输入的可重定位目标文件的符号表中一个确定的符号定义联系起来。
3.1.2、源代码main.c中extern int buf[];int *copy = &buf[0];int c = add(a,b);
代码中引用了符号buf和add
源代码add.c中int buf[2];int add(int a,int b){}
源代码main.c中static int si;int *copy = buf[0];
代码中定义了符号buf,add,si,copy
3.1.3、关于符号解析的细节需要参考可重定位文件的符号表(.symtab)
符号解析的根本还是将符号的引用和符号的定义关联起来,main.c中copy和si都是本地(LOCAL)符号,链接器解析起来很容易,因为符号的引用和符号定义都在一个模块之中。若符号不是本模块中的,例如buf和add这样的全局(GLOBAL)符号,则链接器称这些符号为外部符号,假设存在于其他模块中。链接器对这些外部符号的处理(符号决议)有其他的规则,在后面讨论。
3.2、重定位
3.2.1、相关概念:
重定位
指的是根据各目标文件中重定位表(例如.rel.text和.rel.data)中记录的信息来调整.symtab中所记录的符号的地址。
重定位表的数据结构
r_offset重定位符号的偏移
对于可重定位文件,此值是该重定位符号所要修正的位置的第一个字节相对于段起始的偏移
对于可执行文件或共享文件,此值是该重定位符号所要修正的位置的第一个字节的虚拟地址
r_info重定位的类型(低8位)和符号(高24位表示重定位的符号在符号表中的下标,标识被修改的引用应该指向的符号)
重定位类型(告诉链接器如何修改新的引用)
R_386_PC32,相对寻址,CPU使用当前指令中的32位地址加上PC(程序计数器)的值来得到最终的有效值。
R_386_32,绝对寻址,CPU直接使用当前指令中的32位地址作为有效地址
3.2.2、重定位符号引用
汇编器将本地没有定义的符号写入可重定位目标文件的.symtab表,让链接器到其他可重定位目标文件中查找。同理,汇编器对遇到存储位置未知的符号引用时,它也将会将这些符号的信息存于.rel.text和.rel.data表中,告诉链接器将可重定位目标文件合并成可执行文件时如何修改引用。
可使用命令readelf -r main.o查看重定位表
重定位表中指出符号add的地址按照.text段偏移0x29字节处的4字节地址加上当前PC的值作为最终有效值替换到该位置。符号buf按照.data段偏移0字节处的地址为该符号的最终有效地址。
3.3、关于符号解析过程中链接器对于全局符号的处理(符号决议)
3.3.1、相关概念
强符号:函数和已初始化的全局变量
弱符号:未初始化的全局变量
3.3.2、链接规则
1、多个同名强符号存在,链接失败报错,无法选择符号
2、存在同名的一个强符号和多个弱符号,选择强符号
3、 存在同名的多个弱符号,选择占空间大的一个
3.3.3、实例解析
//add.c //sub.c //main.c
int fun(int a,int b) int fun(int a,int b) int main()
{ { {
return a+b; return a-b; int a = fun(5,3);
return 0;
} }
gcc -o main main.c add.c sub.c -m32
图中可以看出,是ld链接器返回一个错误,编译过程是完全不会报错的,正因为两个强符号无法选择,只能报错退出。
//main.c //test.c
int sign; int sign = 5;
int main()
{
printf("%d\n",sign);
return 0;
}
gcc -o main main.c test.c -m32
结果打印 5
强弱符号选强符号
//main.c //test.c
void fun();
signed char n; int n;
int main() void fun()
{ {
n = 255; n = 255;
fun(); }
printf("%d\n",n);
return 0;
}
gcc -o main main.c test.c -m32
最终打印255,两个弱符号选择占空间大的一个
参考:
深入理解计算机系统
程序员的自我修养——链接、装载与库