链接是将代码和数据片段整合成一个可以被加载(复制)到内存中执行的文件。现代操作系统中,链接是由链接器自动执行的。链接最大的作用是分离编译,在编写大型应用程序时,不需要再编译出一个巨大的源文件,可以把代码编译成比较小的,单独的模块。文件修改时只需要重新编译单独的模块就可以了,不需要重新编译整个文件。
下图为通过静态链接将两个C源文件编译成可执行目标文件的过程。
静态链接:将可重定位目标文件组合成可执行目标文件。
静态链接的两个主要工作为符号解析和重定位。
在介绍符号解析和重定位之前,先介绍一下目标文件。现代linux和unix系统使用可执行可链接格式(ELF)。目标文件有三种形式:可重定位目标文件,可执行目标文件,共享目标文件(特殊类型的可重定位目标文件)。
下图展示了可重定位目标文件的格式
ELF头以一个16字节的描述了生成该文件的系统的字的大小和字节顺序的序列开始,除此之外,ELF头中还包含帮助链接器语法分析和解释目标文件的信息。其中包括ELF头的大小,目标文件的类型,机器类型(x86-64),节头部表的文件偏移,节头部表中条目的大小和数量。
在ELF头和节头部表之间的都是节。一个典型的ELF可重定位目标文件包含下面几个节。
.text:已编译程序的机器代码。
.rodata:只读数据。
.data:以初始化的全局和静态C变量。
.bss:为初始化的全局和静态C变量,以及所有被初始化为0的全局或静态变量。不占据实际的空间,仅仅用做占位。运行时,在内存中分配这些变量。
.symtab:符号表,存放在程序中定义和引用的函数和全局变量的信息。
.rel.text:一个.text节中位置的列表,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。
.strtab:一个字符串表,内容包括.symtab和.debug节中的符号表,以及节头部中的节名字。
linux下的readelf可以方便的阅读elf文件。mac系统下使用brew update && brew install binutils
,然后用greadelf和gobjdump
。
下面是使用greadelf查看的so文件:
PS:这不是一个可重定位目标文件,作者暂时没有linux系统,找了一个共享目标文件。总体的结构是差不多的。
greadelf -a libapp.so
ELF Header:
Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
Class: ELF32
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: DYN (Shared object file)
Machine: ARM
Version: 0x1
Entry point address: 0x0
Start of program headers: 52 (bytes into file)
Start of section headers: 5668992 (bytes into file)
Flags: 0x5000200, Version5 EABI, soft-float ABI
Size of this header: 52 (bytes)
Size of program headers: 32 (bytes)
Number of program headers: 10
Size of section headers: 40 (bytes)
Number of section headers: 12
Section header string table index: 11
Section Headers:
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[ 0] NULL 00000000 000000 000000 00 0 0 0
[ 1] .note.gnu.bu[...] NOTE 00001000 001000 000020 00 WA 0 0 4
[ 2] .bss PROGBITS 00001020 001020 00000c 00 WA 0 0 4
[ 3] .text PROGBITS 00002000 002000 003420 00 AX 0 0 4096
[ 4] .rodata PROGBITS 00006000 006000 005f60 00 A 0 0 16
[ 5] .text PROGBITS 0000c000 00c000 32e540 00 AX 0 0 4096
[ 6] .rodata PROGBITS 0033b000 33b000 22c230 00 A 0 0 16
[ 7] .dynstr STRTAB 00567230 567230 000085 00 A 0 0 1
[ 8] .dynsym DYNSYM 005672b8 5672b8 000060 10 A 7 1 4
[ 9] .hash HASH 00567318 567318 000038 04 A 8 0 4
[10] .dynamic DYNAMIC 00568000 568000 000030 08 WA 7 0 4
[11] .shstrtab STRTAB 00000000 568030 000050 00 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),
D (mbind), y (purecode), p (processor specific)
There are no section groups in this file.
Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
PHDR 0x000034 0x00000034 0x00000034 0x00160 0x00160 R 0x4
LOAD 0x000000 0x00000000 0x00000000 0x00194 0x00194 RW 0x1000
LOAD 0x001000 0x00001000 0x00001000 0x0002c 0x0002c RW 0x1000
LOAD 0x002000 0x00002000 0x00002000 0x03420 0x03420 R E 0x1000
LOAD 0x006000 0x00006000 0x00006000 0x05f60 0x05f60 R 0x1000
LOAD 0x00c000 0x0000c000 0x0000c000 0x32e540 0x32e540 R E 0x1000
LOAD 0x33b000 0x0033b000 0x0033b000 0x22c350 0x22c350 R 0x1000
NOTE 0x001000 0x00001000 0x00001000 0x00020 0x00020 RW 0x4
LOAD 0x568000 0x00568000 0x00568000 0x00030 0x00030 RW 0x1000
DYNAMIC 0x568000 0x00568000 0x00568000 0x00030 0x00030 RW 0x4
Section to Segment mapping:
Segment Sections...
00
01
02 .note.gnu.build-id .bss
03 .text
04 .rodata
05 .text
06 .rodata .dynstr .dynsym .hash
07 .note.gnu.build-id
08 .dynamic
09 .dynamic
Dynamic section at offset 0x568000 contains 6 entries:
Tag Type Name/Value
0x00000004 (HASH) 0x567318
0x00000005 (STRTAB) 0x567230
0x0000000a (STRSZ) 133 (bytes)
0x00000006 (SYMTAB) 0x5672b8
0x0000000b (SYMENT) 16 (bytes)
0x00000000 (NULL) 0x0
There are no relocations in this file.
There are no unwind sections in this file.
Symbol table '.dynsym' contains 6 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 00000000 0 NOTYPE LOCAL DEFAULT UND
1: 00001000 32 FUNC GLOBAL DEFAULT 1 _kDartSnapshotBuildId
2: 00002000 13344 FUNC GLOBAL DEFAULT 3 _kDartVmSnapshot[...]
3: 00006000 24416 FUNC GLOBAL DEFAULT 4 _kDartVmSnapshotData
4: 0000c000 0x32e540 FUNC GLOBAL DEFAULT 5 _kDartIsolateSna[...]
5: 0033b000 0x22c230 FUNC GLOBAL DEFAULT 6 _kDartIsolateSna[...]
Histogram for bucket list length (total of 6 buckets):
Length Number % of total Coverage
0 2 ( 33.3%)
1 3 ( 50.0%) 60.0%
2 1 ( 16.7%) 100.0%
No version information found in this file.
Displaying notes found in: .note.gnu.build-id
Owner Data size Description
GNU 0x00000010 NT_GNU_BUILD_ID (unique build ID bitstring)
Build ID: 5a1633c81abc0aff96b21be358b0c2ab
可以直接被复制到内存中执行。
特殊类型的可重定位目标文件,可以被动态链接。后面的动态链接一节会详细介绍,又名共享库。
链接器的两个主要工作为符号解析和重定位。在介绍他们之前我们需要先介绍一下符号表。
符号表在可重定位目标模块m的.symtab节中,包含了m中定义和引用的符号的信息。主要有三种不同的符号。
符号表中包含了一个对象的数据,对象格式如下。
分别解释一下每个字段的意义:
下面是使用greadelf
查看的so
文件的符号表:
Symbol table '.dynsym' contains 6 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 00000000 0 NOTYPE LOCAL DEFAULT UND
1: 00001000 32 FUNC GLOBAL DEFAULT 1 _kDartSnapshotBuildId
2: 00002000 13344 FUNC GLOBAL DEFAULT 3 _kDartVmSnapshot[...]
3: 00006000 24416 FUNC GLOBAL DEFAULT 4 _kDartVmSnapshotData
4: 0000c000 0x32e540 FUNC GLOBAL DEFAULT 5 _kDartIsolateSna[...]
5: 0033b000 0x22c230 FUNC GLOBAL DEFAULT 6 _kDartIsolateSna[...]
_kDartSnapshotBuildId
是一个位于.note.gnu.bu
节中00001000处的32字节全局函数。
readelf用一个整数索引来表示每个节,Ndx=1表示.note.gnu.bu
节,Ndx=3表示.text
节。可以查阅上面的3.1节的Section Headers:
链接器解析符号引用的方法是将每个引用与可重定位目标文件的符号表中的定义关联起来。简单来说就是将引用和定义关联起来。我们来看一下引用和定义分别是什么。
c语言中是没有类的概念的,使用其他文件中定义的全局变量可以理解为引用,本文件创建的变量可以理解为定义。
符号解析将目标文件中的每个全局符号都绑定到一个唯一的定义。
局部符号的解析比较容易,因为编译器只允许一个文件中每个局部符号有一个定义。
全局符号的解析要复杂一些,涉及到强弱符号。
强符号:已初始化的全局变量。
弱符号:未初始化的全局变量。
全局符号解析的原则:
静态库:将一组目标文件组合成一个文件,可以用作链接器的输入。当链接器构造可执行文件时,只复制静态库里被应用程序引用的目标模块。
重定位就是合并输入模块,为每个符号分配运行时地址,主要分为两部。
重定位节和符号定义:
链接器将所有相同类型的节合并为同一类型的可执行目标文件中的新的聚合节。
重定位节中的符号引用:
链接器根据可重定位目标模块中的名为重定位条目的数据结构,修改代码节和数据节中对每个符号的引用,使他们指向正确的运行时地址。
当汇编器生成一个目标模块时,它并不知道数据和代码最终将放在什么位置。
汇编器遇到对最终位置未知的目标引用,就会生成一个重定位条目,用来告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。
代码的重定位条目放在.rel.text
节中,已初始化数据的重定位条目放在.rel.data
中。
重定位条目的数据结构:
两种基本的重定位类型:
链接器的重定位算法伪代码:
加载器将可执行目标文件中的代码和数据从磁盘复制到内存中,通过跳转到程序的第一条指令或入口点来运行该程序。
是一个目标模块,在运行或加载时,可以加载到任意的内存地址,并和一个在内存中的程序链接起来。
在运行或加载时,将共享库加载到内存,并和一个在内存中的程序链接起来。
执行动态链接。在linux系统中通常用.so
后缀。
静态库的内容会被复制和嵌入到引用它们的可执行文件中。
.so
文件只有一份,所有引用该库的可执行目标文件共享此文件。
当创建可执行文件时,静态执行一些链接,在程序加载时,动态完成链接过程。链接器复制了一些重定位和符号表信息,使得他们可以在运行时解析对libvector.so
中代码和数据的引用。
加载器发现prog21
包含一个.interp
节,这个节中包含动态链接器的路径名。加载器加载和运行动态链接器。然后动态链接器执行以下重定位完成链接任务:
libc.so
的文本和数据到某个内存段。libvector.so
的文本和数据到另一个内存段。prog21
中对libc.so
和libvector.so
定义的符号的引用。动态链接不仅可以在编译时进行,也可以在应用程序运行时进行。linux系统为动态链接器提供了一个dlopen
方法,允许应用程序在运行时加载和链接共享库。
共享库的主要功能为允许多个正在运行的进程共享内存中相同的库代码。
实现多个进程共享程序中一个副本有两种方式:
位置无关代码(PIC):
可以加载而无需重定位的代码。
如何生成PIC不展开说了。
文章结构看似有些杂乱,其实是根据操作系统的链接工作流程展开的,在介绍每一种技术前都会先介绍前置技术。
以一段《深入理解计算机系统》中的链接总结作为结尾:
链接可以在编译时由静态编译器来完成,也可以在加载时和运行时由动态链接器来完成。链接器处理称为目标文件的二进制文件,它有三种不同的形式:可重定位的,可执行的和共享的。可重定位的目标文件由静态链接器合并成一个可执行的目标文件,它可以加载到内存中并执行。共享目标文件(共享库)是在运行时由动态链接器链接和加载的,或者隐含地在调用程序被加载和开始执行时,或者根据需要在程序调用dlopen库的函数时。
链接器的两个主要任务是符号解析和重定位,符号解析将目标文件中的每个全局符号都绑定到一个唯一的定义,而重定位确定每个符号的最终内存地址,并修改对那些目标的引用。
静态链接器是由像GCC这样的编译驱动程序调用的。它们将多个可重定位目标文件合并成一个单独的可执行目标文件。多个目标文件可以定义相同的符号。
加载器将可执行文件的内容映射到内存,并运行这个程序。链接器还可能生成部分链接的可执行目标文件,这样的文件中有对定义在共享库中的例程和数据的未解析的引用。在加载时,加载器将部分链接的可执行文件映射到内存,然后调用动态链接器,它通过加载共享库和重定位程序中的引用来完成链接任务。
被编译为位置无关代码的共享库可以加载到任何地方,也可以在运行时被多个进程共享。为了加载,链接和访问共享库的函数和数据,应用程序也可以在运行时使用动态链接器。
这篇文章的战线真的很长,而且写到现在也不是最终版,不过自己确实写不动了,没有什么动力继续了。原计划还要补齐Android linker的代码解析,通过代码来描述android操作系统是如何链接程序的。
机缘巧合来写这篇文章,不得不说在一开始这些知识对我来说是巨大的挑战,在最初学习的时候自己非常诧异,字和字组合在一起,怎么就看不懂呢?不过自己还是没有放弃,一遍一遍的读,一遍一遍的看,书读百遍其意自现,从一开始的迷茫,到逐渐理解,到最后融会贯通。“山重水复疑无路,柳暗花明又一村”。花了多少时间来读,真正掌握的那一刻就有多大的快乐。
这段时间的收获很大,除了掌握了操作系统如何链接程序之外。对如何阅读经典计算机书籍也有了新的理解,读书要拆解,先对总体的概念和流程有模糊的了解,在细化的去学习具体的步骤和过程,具体的步骤学习过了一个阶段之后,再来读总体的概念和流程,再重复这一步骤补齐所有的盲区,知识也就融汇于心了。
我觉得这世界上的所有知识都这样,正常的理解力加上耐心和持续的动力解决所有问题。 重要的是了解自己哪里不会,也就是找到方向,剩下的耐心还是很重要的,这篇文章我就没写完,哈哈。人的潜力果然是无限的。
不过怎么说呢,离了文章也还是记不起来。。。计算机基础知识如果不用,真的记不住。。。以后慢慢探索如何记住。。。或许学习更重要的是潜移默化的影响?
“我想要什么?”,永远明确这一点。