链接器主要有两个作用,一是将若干输入文件(.o文件)根据一定规则合并为一个输出文件(例如ELF格式的可执行文件);一是将符号与地址绑定(当然加载器也要完成这一部分工作)。关于链接器的工作机制可以参考《Linker and Loader》一书,本文只关心它的第一个功能,即如何根据一定规则将一个或多个输入文件合并成输出文件。这里的“一定规则”是通过链接脚本描述的。链接器有一个编译到其二进制代码中的默认链接脚本,大多数情况下使用它链接输入文件并生成目标文件。当然,我们也可以提供自定义的脚本以精确控制目标文件的格式,如同Linux内核做得那样,链接器“- T”参数用于指定自定义的脚本文件。
链接脚本有自己的一套语法,本文无意对它进行过多论述,后文描述vmlinux_32.lds.S内容时会对内核用到的语法进行解释。如果你希望了解完整的脚本语法,可以阅读参考文献1。
说起链接器,ELF文件格式通常是绕不开的,介绍它的文档多不胜数。实际上,对于了解链接脚本,我们完全没必要去学习ELF的具体格式,有一个全局的视图就足够了(当然,了解ELF格式会让事情变得轻松,你可以很轻易的将脚本中的某些元素和ELF格式中的一些字段联系起来,例如后面看到的PHDRS关键字就很容易和ELF的程序头部表关联)。
图1. 链接器视图overview(摘自《ELF文件格式分析》,滕启明)
图1展示了从链接器的角度,如何看待输入文件和输出文件的视图。左边的“链接视图”对应输入文件,它为链接器提供的主要内容是section(节区)。随便找一个.o文件,通过objdump后可以找到类似下面的内容:
10 .init 00000030 080482b4 080482b4 000002b4 2**2
CONTENTS, ALLOC, LOAD, READONLY, CODE
11 .plt 00000050 080482e4 080482e4 000002e4 2**2
CONTENTS, ALLOC, LOAD, READONLY, CODE
12 .text 0000019c 08048340 08048340 00000340 2**4
CONTENTS, ALLOC, LOAD, READONLY, CODE
这里的.init、.plt、.text就是section名。不同的.o文件可以有相同的section,例如.text。编译器在生成.o文件时会根据所生成二进制的不同性质把它们放入相应的section中。例如函数编译后的二进制代码通常放到.text,而const关键字修饰的全局数组会放到.rodata中。GCC有除了默认的section,例如.text、.data、.bss、.debug、.dynsym等,也支持用户自定义section,在后面的内容中我们可以看到Linux大量使用GCC的扩展__attribute__ ((section(“section_name”))生成自定义section。
链接器在进行链接时,会根据链接脚本从输入的.o文件中挑选出感兴趣的section,把它们合并生成新的section,这些新产生的section归属于目标文件的某个segment(段),并出现在目标文件中。例如file1.o和file2.o分别有两个.text,它们在链接后生产的目标文件也会有一个.text,而这个.text既是由file1.o和file2.o的.text合并而来的。这里提到了segment的概念,见图1的右部“执行视图”。Segment可以看作一组具有相同属性(或部分相同属性)的section的集合,属性是指“读、写、执行”(通常用rwx或rwe表示)。例如.text通常存放的是代码编译后的二进制,它具有r-x权限;.rodata存放是的只读数据,如常量字符串,它通常具有r—权限(实际上也可以具有x权限,例如用一个全局const数组存放可执行的机器码);那么在生成目标文件时,.text和.rodata就可以通过一个具有r-x属性的text segment来包含它们,这就是我们通常说的“文本段”。经常看到有朋友在C版问“常量字符串的地址为什么在文本段?”、“常量字符串放哪儿?”之类的问题,其实写一个简单的程序,例如:
int main()
{
printf(“%s\n”, “hello world”);
}
用gcc –S编译后可以看到:
.section .rodata
.LC0:
.string "hello, world"
这里常量字符串”hello, world”放到了.rodata section,链接后该section通常会和.text section一起放到目标文件的text segment中,这就是为什么字符串地址和main()函数的地址如此相近的原因。
Segment在ELF术语中称为program headers,用来描述整个目标文件以什么样的方式加载到内存中,方式是指加载的地址、segment的长度和属性等等。用objdump –p命令可以查看目标文件的segment,当然你也可以在通过objdump –Dx得到内容中找到它们。其内容如下所示(类似):
Program Header:
PHDR off 0x00000034 vaddr 0x08048034 paddr 0x08048034 align 2**2
filesz 0x00000100 memsz 0x00000100 flags r-x
INTERP off 0x00000134 vaddr 0x08048134 paddr 0x08048134 align 2**0
filesz 0x00000013 memsz 0x00000013 flags r--
LOAD off 0x00000000 vaddr 0x08048000 paddr 0x08048000 align 2**12
filesz 0x0000055c memsz 0x0000055c flags r-x
LOAD off 0x0000055c vaddr 0x0804955c paddr 0x0804955c align 2**12
filesz 0x000000fc memsz 0x00000104 flags rw-
DYNAMIC off 0x00000570 vaddr 0x08049570 paddr 0x08049570 align 2**2
filesz 0x000000c8 memsz 0x000000c8 flags rw-
NOTE off 0x00000148 vaddr 0x08048148 paddr 0x08048148 align 2**2
filesz 0x00000044 memsz 0x00000044 flags r--
EH_FRAME off 0x000004e8 vaddr 0x080484e8 paddr 0x080484e8 align 2**2
filesz 0x0000001c memsz 0x0000001c flags r--
到这里,我们可以简单的对链接器的工作做一个概括。链接器从输入的.o文件中挑选出感兴趣的section(注意,我们再次提到了“感兴趣的section”。是的,并不是所有出现在.o文件中的section都会出现在最后的目标文件中。在后面我们会看到Linux如何把它不感兴趣的section排除在外),根据链接脚本提供的规则生成新的section,再根据新section的属性把它们分为不同的segment。
目标文件加载到内存的过程实际上就是若干不同segment被加载到内存的过程,下一节我们会看到Linux内核image是如何划分segment的。