当前位置:   article > 正文

编译、链接和库(1)_编译链接与库

编译链接与库

静态编译过程的简单拆解

#include <stdio.h>
int main(int argc,char *argv[])
{
	printf("hello world\n");
	return 0;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
root@:~# gcc hello.c -o hello
  • 1

上面是最简单的一段C语言代码,编译成hello可执行文件后,./hello就可以打印出hello world。当我们执行gcc编译hello.c时,编译器其实进行了大量的操作以便生成我们想要的可执行文件。

简单来说,执行过程可分为

hello.c
hello.i
hello.s
hello.o
hello
源文件
预处理cpp
编译cc1
汇编as
链接ld
可执行文件

cpp,cc1,as,ld分别为预处理器,编译器,汇编器,链接器。是gcc工具链在不同的编译阶段调用的编译工具。

  • 预处理阶段的主要工作
    头文件展开,宏定义替换。处理所有#号开头的语句
    // /**/ 所有注释删除,并给文件添加行号和文件标志符,以便在后续编译过程中报错时可以打印出错行号和文件名
    gcc -E hello.c -o hello.i 编译仅执行到预处理阶段
  • 编译阶段
    编译阶段的处理过程较为复杂,主要包括语法分析,词法分析,语义分析和一些代码优化最后生成对应汇编文件
    gcc -S hello.c -o hello.s 执42行到编译阶段
  • 汇编阶段
    汇编阶段工作相对简单,因为每条汇编语句几乎都对应了一条机器语言,可以一对一进行翻译,生成未经重定位的二进制文件
    gcc -c hello.c -o hello.o 执行到汇编阶段
  • 链接
    将hello.o和其依赖的库文件链接成真正的可执行文件 hello,比如上面代码的printf函数是定义在libc.a静态库中,当进行静态编译的时候,链接器就要把libc.a中的printf.o文件和hello.o文件链接到一起,可以理解为展开到同一个文件中,然后生成hello

ps:file hello 可以查看当前文件的格式,编译平台和链接方式。通常x86平台用gcc 编译如果没有用-static参数,默认是动态编译。

ELF文件解析

#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;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18

我们使用一个比较有代表性的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									段表字符串下标
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20

这里需要特别说明一下的是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)

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43

一共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
   ...
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

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
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25

.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  ................
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

所以在其他段中需要引用字符串的位置只要保存一个相对于.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
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

静态链接过程

为了更清晰的描述静态链接的过程,我们弃用上一节的C源文件,使用以下两个.c程序作为示例

//test1.c
extern int add(int);
int i = 2;
int main(void)
{
    int local = 1;
    local += add(i);
    return 0;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
//test2.c
int add(int num)
{
    return num+1;
}
  • 1
  • 2
  • 3
  • 4
  • 5

首先将源文件编译为.o文件 gcc -c test1.c test2.c
到这里,让我们思考一个问题,链接的过程到底干了哪些事情呢?它到底是如何合并.o文件的?
在这里插入图片描述
如上图所示,链接器会将所有输入文件的同名段合并到一起,组成新的ELF文件。这个过程又可以细化成以下几部分:

  • 扫描所有输入文件的文件头,解析获取他们各个section的信息,包括大小和属性和偏移量等,建立新的符号表。这个过程如果发现有UNDEFINE的符号在新的全局符号表中依然找不到定义,会报符号未定义错误。
  • 计算输入文件中各个段合并后的长度和偏移量,建立新的映射关系,生成新的section。对各section的address属性进行赋值,映射到具体的虚拟地址空间。
  • 读取输入文件中的重定位信息,对符号引用位置进行地址重定位。
    下面从代码的角度做简单的演示

$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 可以解压出所有目标文件。

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/你好赵伟/article/detail/160553
推荐阅读
相关标签
  

闽ICP备14008679号