赞
踩
#include <stdio.h>
int main(int argc,char *argv[])
{
printf("hello world\n");
return 0;
}
root@:~# gcc hello.c -o hello
上面是最简单的一段C语言代码,编译成hello可执行文件后,./hello
就可以打印出hello world。当我们执行gcc编译hello.c时,编译器其实进行了大量的操作以便生成我们想要的可执行文件。
简单来说,执行过程可分为
cpp,cc1,as,ld分别为预处理器,编译器,汇编器,链接器。是gcc工具链在不同的编译阶段调用的编译工具。
ps:file hello 可以查看当前文件的格式,编译平台和链接方式。通常x86平台用gcc 编译如果没有用-static参数,默认是动态编译。
#include <stdio.h> __attribute__((section("hello"))) int special = 5; __attribute__((section("function"))) void func(void){ return; } int globalval; int globalval2 = 2; int globalval3; int main() { static int localval; static int localval2 = 3; static int localval3; printf("hello world\n"); return 0; }
我们使用一个比较有代表性的C源文件做说明用例。将上述test1.c编译为test.o。我们就获得了一个ELF格式的目标文件(可重定位,后续会展开和可执行ELF文件的差别)。
执行一下file命令可以看到test.o的基本信息,是一个x86-64架构的可重定位64bit 小端ELF文件。
使用$ readelf -h test.o
查看ELF头
ELF Header: Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 ELF魔数 Class: ELF64 Data: 2's complement, little endian Version: 1 (current) OS/ABI: UNIX - System V ABI Version: 0 Type: REL (Relocatable file) 文件类型 Machine: Advanced Micro Devices X86-64 CPU平台属性 Version: 0x1 ELF版本号 Entry point address: 0x0 入口虚拟地址 Start of program headers: 0 (bytes into file) Start of section headers: 1216 (bytes into file) 段表在文件中的偏移 Flags: 0x0 Size of this header: 64 (bytes) ELF文件头大小 Size of program headers: 0 (bytes) Number of program headers: 0 Size of section headers: 64 (bytes) 段表描述符大小 Number of section headers: 16 段表描述符数 Section header string table index: 15 段表字符串下标
这里需要特别说明一下的是ELF的入口虚拟地址,该地址为操作系统将ELF文件各段加载到内存后,开始执行程序的第一行代码的虚拟地址。这里因为ELF文件还未进行链接,所以入口地址为0x0。段表相关的说明将在后面展开。
使用$ readelf -S test.o
查看elf文件的段表结构
There are 16 section headers, starting at offset 0x4c0: Section Headers: [Nr] Name Type Address Offset Size EntSize Flags Link Info Align [ 0] NULL 0000000000000000 00000000 0000000000000000 0000000000000000 0 0 0 [ 1] .text PROGBITS 0000000000000000 00000040 0000000000000020 0000000000000000 AX 0 0 1 [ 2] .rela.text RELA 0000000000000000 000003d8 0000000000000030 0000000000000018 I 13 1 8 [ 3] .data PROGBITS 0000000000000000 00000060 0000000000000008 0000000000000000 WA 0 0 4 [ 4] .bss NOBITS 0000000000000000 00000068 0000000000000008 0000000000000000 WA 0 0 4 [ 5] hello PROGBITS 0000000000000000 00000068 0000000000000004 0000000000000000 WA 0 0 4 [ 6] function PROGBITS 0000000000000000 0000006c 000000000000000b 0000000000000000 AX 0 0 1 [ 7] .rodata PROGBITS 0000000000000000 00000077 000000000000000d 0000000000000000 A 0 0 1 [ 8] .comment PROGBITS 0000000000000000 00000084 000000000000002b 0000000000000001 MS 0 0 1 [ 9] .note.GNU-stack PROGBITS 0000000000000000 000000af 0000000000000000 0000000000000000 0 0 1 [10] .note.gnu.propert NOTE 0000000000000000 000000b0 0000000000000020 0000000000000000 A 0 0 8 [11] .eh_frame PROGBITS 0000000000000000 000000d0 0000000000000058 0000000000000000 A 0 0 8 [12] .rela.eh_frame RELA 0000000000000000 00000408 0000000000000030 0000000000000018 I 13 11 8 [13] .symtab SYMTAB 0000000000000000 00000128 0000000000000228 0000000000000018 14 15 8 [14] .strtab STRTAB 0000000000000000 00000350 0000000000000084 0000000000000000 0 0 1 [15] .shstrtab STRTAB 0000000000000000 00000438 0000000000000083 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)
一共16个段表结构和ELF头中的Number of section headers相匹配,Section[0]是无效段,可以忽略,下面从.text段开始介绍:
.text 通常意义上的代码段。段的长度为0x20byte,段的类型PROGBITS意味着该段为代码段或数据段,段的标志位Flags为AX,A意味着Alloc,X意味着Exe,代表该段在进程空间中需要分配空间,并且可以被执行。对编译器和链接器来说,真正有意义的是Types属性和Flags属性,而不是段的名字。这意味着即使你将.text改成任意的名字,只有属性不变,它都还是代码段。Offset为该段在文件中的偏移,Address为该段被加载后在进程地址空间中的虚拟地址,只有link后才有意义,当前为0。
.rela.text 它是一个重定位表,包含.text段的重定位信息。我们注意到上面的main函数中调用了printf()函数。但是当前编译器并不知道printf函数的地址,它是C语音的标准库函数。所以编译器会生成一个.rela.text段来记录这些信息,以便在链接的时候找到printf符号的定义后对函数地址进行重定位。当前binary文件调用的printf的地址为0x0,可以用objdump反汇编查看。
.data 保存已初始化的全局变量和静态变量,初始化为零的全局变量和局部变量会被编译器当做未初始化处理,被保存在bss段。.data段的size为0x8byte,也就是保存了变量globalval2和localval2。flags为WA,即为地址可写并且需要分配内存。
.bss 保存未初始化的全局变量和静态变量。按照这个结论它应该保存四个变量,也就是size为0x10才对,但是ELF中size只有8byte。这里globalval和globalval3并没有被保存在bss中,只是在.symtab段中预留了两个带有COMMON属性的全局符合。这涉及到编译器的链接过程,可以暂时不用深究,只要知道等到链接结束生成最后的可执行文件时,这两个变量确实被放到了bss段就可以了。
$ readelf -s test.o
Symbol table '.symtab' contains 23 entries:
Num: Value Size Type Bind Vis Ndx Name
...
17: 0000000000000004 4 OBJECT GLOBAL DEFAULT COM globalval
18: 0000000000000000 4 OBJECT GLOBAL DEFAULT 3 globalval2
19: 0000000000000004 4 OBJECT GLOBAL DEFAULT COM globalval3
...
hello&function 这两个段很特别,这不是编译器自己生成的section。而是我们手动在代码中自定义的段,用来演示在ELF中追加section的方法。我们在内核的代码中经常会看到这样的定义,比如.init.xx之类的。将特殊功能的代码放到指定的段是个很常见的使用方法。
.rodata 保存只读数据,比如我们在printf中打印的字符串
.comment 注释信息段
.symtab 符号表,保存我们定义和使用的所有符号,包括所有的变量名,函数名,文件名。链接过程正是基于符号才能完成,我们需要去链接的所有文件中寻找当前.o中引用到的所有符号完成代码重定位过程,Ndx属性为UND即为需要重定位的符号。同样也要展开我们自己定义的符号给其他文件进行重定位。属性为LOCAL的符号为内部符号,即目标文件外部不可见。属性为GLOBAL的符号对外部展开。下面是完整的符号表。
$ readelf -s test.o
Symbol table '.symtab' contains 23 entries: Num: Value Size Type Bind Vis Ndx Name 0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND 1: 0000000000000000 0 FILE LOCAL DEFAULT ABS test1.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: 0000000000000000 0 SECTION LOCAL DEFAULT 6 7: 0000000000000000 0 SECTION LOCAL DEFAULT 7 8: 0000000000000000 4 OBJECT LOCAL DEFAULT 4 localval3.1800 9: 0000000000000004 4 OBJECT LOCAL DEFAULT 3 localval2.1799 10: 0000000000000004 4 OBJECT LOCAL DEFAULT 4 localval.1798 11: 0000000000000000 0 SECTION LOCAL DEFAULT 9 12: 0000000000000000 0 SECTION LOCAL DEFAULT 10 13: 0000000000000000 0 SECTION LOCAL DEFAULT 11 14: 0000000000000000 0 SECTION LOCAL DEFAULT 8 15: 0000000000000000 4 OBJECT GLOBAL DEFAULT 5 special 16: 0000000000000000 11 FUNC GLOBAL DEFAULT 6 func 17: 0000000000000004 4 OBJECT GLOBAL DEFAULT COM globalval 18: 0000000000000000 4 OBJECT GLOBAL DEFAULT 3 globalval2 19: 0000000000000004 4 OBJECT GLOBAL DEFAULT COM globalval3 20: 0000000000000000 32 FUNC GLOBAL DEFAULT 1 main 21: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND _GLOBAL_OFFSET_TABLE_ 22: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND printf
.strtab 字符串表,保存所有符号名。虽然我们在.symtab的打印中看到了所有的符号名,但在真实的二进制文件中并不是这样存储的,所有的符号字符串的ASCII码被保存在.strtab段中。.symtab的每个成员只保留了一个symbol名在strtab的索引,类似数组的下标。这样做的目的是因为字符串的长度是不定的,我们很难在symtab中用固定的格式去保存它。查看test.o的二进制格式内容可以看到如下一段:
00000350: 00 74 65 73 74 31 2e 63 00 6c 6f 63 61 6c 76 61 .test1.c.localva
00000360: 6c 33 2e 31 38 30 30 00 6c 6f 63 61 6c 76 61 6c l3.1800.localval
00000370: 32 2e 31 37 39 39 00 6c 6f 63 61 6c 76 61 6c 2e 2.1799.localval.
00000380: 31 37 39 38 00 73 70 65 63 69 61 6c 00 66 75 6e 1798.special.fun
00000390: 63 00 67 6c 6f 62 61 6c 76 61 6c 00 67 6c 6f 62 c.globalval.glob
000003a0: 61 6c 76 61 6c 32 00 67 6c 6f 62 61 6c 76 61 6c alval2.globalval
000003b0: 33 00 6d 61 69 6e 00 5f 47 4c 4f 42 41 4c 5f 4f 3.main._GLOBAL_O
000003c0: 46 46 53 45 54 5f 54 41 42 4c 45 5f 00 70 72 69 FFSET_TABLE_.pri
000003d0: 6e 74 66 00 00 00 00 00 0b 00 00 00 00 00 00 00 ntf.............
000003e0: 02 00 00 00 07 00 00 00 fc ff ff ff ff ff ff ff ................
所以在其他段中需要引用字符串的位置只要保存一个相对于.strtab头地址的偏移量,就可以很容易解析出对应的内容。
.shstrtab 段表字符串表功能和字符串表类似,不过该段只是用来保存所有的段名字符串,这里需要回头说明一下在ELF头中出现的属性Section header string table index:15 段表字符串表下标。它意味着.shstrtab 在所有sections条目中的下标位置,即:
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
...
[15] .shstrtab STRTAB 0000000000000000 00000438
0000000000000083 0000000000000000 0 0 1
为了更清晰的描述静态链接的过程,我们弃用上一节的C源文件,使用以下两个.c程序作为示例
//test1.c
extern int add(int);
int i = 2;
int main(void)
{
int local = 1;
local += add(i);
return 0;
}
//test2.c
int add(int num)
{
return num+1;
}
首先将源文件编译为.o文件 gcc -c test1.c test2.c
到这里,让我们思考一个问题,链接的过程到底干了哪些事情呢?它到底是如何合并.o文件的?
如上图所示,链接器会将所有输入文件的同名段合并到一起,组成新的ELF文件。这个过程又可以细化成以下几部分:
$ld -static -e main test1.o test2.o -o test
ld命令就是我们在第一节中讲到的链接器,-e main的意思是将程序的入口地址设置为main函数,使用这条奇怪的命令的缘由后续会进行说明。
$readelf -S test
从上图中我们可以看出,新的.text段的长度正好是test1.o和text2.o中Size的总和。下面是合成后的.symtab,可以看到输入文件的符号已经全部包含在内(local变量因为并未真正使用,已经在编译阶段被优化掉)
到目前为止,一切看起来都很美好,链接过程确实如我们预想的一样进行。但是如果这个时候我们使用./text
来运行这个文件,会发现我们辛苦编译出来的二进制文件完全无法运行起来。如果对比一下使用ld命令链接出的可执行文件和使用gcc命令链接出的可执行文件会发现,ELF文件的大小存在的巨大的差距。而这些缺失的内容就是文件无法运行的原因。
事实上,gcc在链接的过程做的事远比我们想象的复杂,它还会去链接libc.a等链接器认为运行必要的库文件。在这个过程中会有大量的代码和变量被展开到ELF中。
这里我们需要思考一下main()是如何被执行起来的,它的参数是谁传进来的?在main()执行结束的return 0;又是return给谁的?
我们执行test的命令是./test。这个命令其实是传递给你当前所在的shell进程的。shell会解析用户输入的命令。发现这是一条执行程序的命令后,shell会立即fork出一个子进程,然后调用exec系统调用去加载要执行的elf文件,并且将当前用户输入的命令行参数传递给exec,这也就是main函数参数的由来。可执行文件默认的入口函数是_start,它是glibc系统库的一部分,之后这个fork出的子进程就会进入glibc的代码去完成一些初始化环境的必要工作,然后调用用户的main函数。而在main结束之后,也是返回到glibc的代码中,glibc会根据拿到的返回值来判断main函数是否是正常退出,从而在调用exit()系统调用结束子进程的时候传递不同的参数来通知shell父进程。当然在我们使用静态链接的过程中,glibc库的代码早已经整合在可执行文件中了。所有我们之前编译出的test文件完全无法运行,因为它没有链接glibc库,我们仅仅是为了展示链接过程才使用最简单的方式直接用ld进行多目标文件链接,而-e main 命令行参数的目的是修改程序的入口函数为main。因为ld默认入口函数为_start,如果不进行修改根本无法完成链接过程。
静态库可以看成一组目标文件的集合,由一系列目标文件压缩打包后获得。以libc.a为例,目录位置:/usr/lib/x86_64-linux-gnu
ar -t libc.a
可以查看其包含了哪些库文件。
ar -x libc.a 可以解压出所有目标文件。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。