链接过程控制
绝大部分情况下,我们使用链接器提供的默认链接规则对目标文件进行链接。这在一般情况下是没有问题的,但对于一些特殊要求的程序,比如操作系统内核、BIOS(Basic Input Output System)或一些在没有操作系统的情况下运行的程序(如引导程序Boot Loader,或者嵌入式系统的程序,或者有一些脱离操作系统的硬盘分区软件PQMagic等),以及另外的一些需要特殊的链接过程的程序,如一些内核驱动程序等,它们往往受限于一些特殊的条件,如需要指定输出文件的各个段虚拟地址、段的名称、段存放的顺序等,因为这些特殊的环境,特别是某些硬件条件的限制,往往对程序的各个段的地址有着特殊的要求。
由于整个链接过程有很多内容需要确定:使用哪些目标文件?使用哪些库文件?是否在最终可执行文件中保留调试信息、输出文件格式(可执行文件还是动态链接库)?还要考虑是否要导出某些符号以供调试器、程序本身或其他程序使用等。
操作系统内核,从本质上来讲,它本身也是一个程序。比如Windows的内核ntoskrnl.exe就是一个我们平常看到的PE文件,它的位置位于\WINDOWS\system32\ntoskrnl.exe.很多人误以为Window操作系统的内核很庞大,由很多文件组成。这是一个误解,其实真正的Windows内核就是这个文件。
链接控制脚本
链接器一般都提供多种控制整个链接过程的方法,以用来产生用户所需要的文件。一般链接器有如下三种方法:
- 使用命令行来给链接器指定参数
- 将链接指令存放在目标文件里面,编译器经常会通过这种方法向链接器传递指令。比如VISUAL C++编译器会把链接参数放在PE目标文件的.drectve段以用来传递参数。
- 使用链接控制脚本
由于各个链接器平台的链接控制过程各不相同,我们只能侧重一个平台来介绍。ld链接器的链接脚本功能非常强大,我们接下来以ld作为主要介绍对象。VISUAL C++也允许使用脚本来控制整个链接过程,叫做模块定义文件(Module-Definition File),它们的拓展名一般为.def
ld在用户没有指定链接脚本的时候会使用默认链接脚本。
为了更加精确地控制链接过程,我们可以自己写一个脚本,然后指定该脚本为链接控制脚本。比如可以使用-T参数: $ ld -T link.script
最“小”的程序
为了演示链接的控制过程,我们接着要做一个最小的程序,这个程序的功能是在终端上输出“Hello World!”
- 首先,经典的helloworld使用了printf 函数,该函数是系统C语言库的一部分。为了使用该函数,我们必须在链接时将C语言与程序的目标文件链接产生最终可执行文件。我们希望小程序能够脱离C语言运行库,使得它成为一个独立于任何库的纯正的程序
- 其次,经典的helloworld由于使用了库,所以必须有main函数。我们知道一般程序的入口在库的_start,由库负责初始化后调用main函数来执行程序的主体部分。为了不使用main这个我们已经感到厌烦的函数名,小程序使用nomain作为整个程序的入口。
- 接着,经典的helloworld会产生多个段,为了演示ld链接脚本的控制过程,我们将小程序的所有段都合并到一个叫tinytext的段,注意:这个段是我们任意命名的,是由链接脚本控制链接过程生成的。
char* str = "Hello World!\n";
void print()
{
asm("movl $13,%%edx \n\t"
"movl $0,%%ecx \n\t"
"movl $0,%%ebx \n\t"
"movl $4,%%eax \n\t"
"int $0x80 \n\t"
::"r"(str):"edx", "ecx", "ebx");
}
void exit()
{
asm("movl $42,%ebx \n\t"
"movl $1,%eax \n\t"
"int $0x80 \n\t" );
}
void nomain()
{
print();
exit();
}
这里的print函数使用了Linux的WRITE系统调用,exit函数使用了EXIT系统调用。这里我们使用了GCC内嵌汇编,简单介绍系统调用:系统调用通过0x80中断实现,其中eax为调用号,ebx、ecx、edx等通用寄存器用来传递参数,比如WRITE调用是往一个文件句柄写入数据,如果用C语言来描述它的原型就是:int write(int filedesc, char* buffer, int size);
- WRITE调用的调用号为4,则eax=4。
- filedesc表示被写入的文件句柄,使用ebx寄存器传递,我们这里是要往默认终端(stdout)输出,它的文件句柄为0,即ebx=0。
- buffer表示要写入的缓冲区地址,使用ecx寄存器传递,我们这里要输出字符串str,所以ecx=str。
- size表示要写入的字节数,使用edx寄存器传递,字符串“Hello World!\n”长度为13字节,所以edx=13。
同理,EXIT系统调用中,ebx表示进程退出码(Exit Code),比如我们平时的main程序中的return数值会返回给系统库,由系统库将该数值传递给EXIT系统调用。这样父进程就可以接收到子进程的退出码。EXIT系统调用的调用号为1,即eax=1。
这里要调用EXIT结束进程是因为如果是普通程序,main()函数结束后控制权返回给系统库,由系统库负责调用EXIT,退出进程。我们这里的nomain()结束后系统控制权不会返回,可能会执行到nomain()后面不正常的指令,最后导致进程异常退出。
我们先不急于使用链接脚本,而先使用普通命令行的方式来编译和链接TinyHelloWorld.c:
$ gcc -c -fno-builtin TinyHelloWorld.c
$ ld -static -e nomain -o TinyHelloWorld TinyHelloWorld.o
GCC和ld的参数意义如下:
- -fno-builtin GCC编译器提供了很多内置函数(Built-in Function),它会把一些常用的C库函数替换成编译器的内置函数,以达到优化的功能。exit()函数也是GCC的内置参数之一,所以我们要使用-fno-builtin参数来关闭GCC内置函数功能
- -static 这个参数表示ld将使用静态链接的方式来链接陈旭,而不是使用默认的动态链接的方式
- -e nomain 表示该程序的入口函数为nomain,这个参数就是将ELF文件头的e_entry成员赋值成nomain函数的地址。
- -o TinyHelloWorld 表示指定输出可执行文件名为TinyHelloWorld。
我们得到一个ELF可执行文件,运行后能正确打印“Hello World!”。当我们用objdump或readelf查看时,会发现它有4个段。
鉴于这些段的属性如此相似,原则上讲,我们可以把它们合并到一个段里面,该段的属性是可执行、可读的,包括程序的数据和指令。为了达到这个目的,我们必须使用ld链接脚本来控制链接过程。
使用ld链接脚本
链接控制脚本程序使用一种特殊的语言写成,即ld的链接脚本语言,这种语言并不复杂,只有为数不多的几种操作。
无论是输出文件还是输入文件,它们的主要数据就是文件中的各种段,我们把输入文件中的段成为输入段(Input Sections),输出文件中段成为输出段(Output Sections)。简单来讲,控制链接过程无非是控制输入段如何变成输出段,比如哪些输入端要合并一个输出段,哪些输入段要丢弃;指定输出段的名字、装载地址、属性,等等。
我们先来看看TinyHelloWorld.lds(一般链接脚本以lds作为拓展名ld script):
ENTRY(nomain)
SECTIONS
{
. = 0x08048000 + SIZEOF_HEADERS;
tinytext : { *(.text) *(.data) *(.rodata) }
/DISCARD/ : { *(.comment) }
}
- 第一行指定了程序的入口
- 后面的SECTIONS命令一般是链接脚本的主体,这个命令指定了各种输入段到输出段的变换,SECTIONS后面紧跟着的一堆大括号里面包含了SECTIONS变换规则,其中有三条语句,每条语句一行。第一条是赋值语句,后面两条是段转换规则。含义分别是:
- 将当前虚拟地址设置成0x08048000 + SIZEOF_HEADERS,SIZEOF_HEADERS为输出文件的文件头大小。“.”表示当前虚拟地址。
- 所有输入文件中的名字为“.text”、“.data”或“.rodata”的段依次合并到输出文件的“tinytext”
- 将所有输入文件中的名字为“.comment”的段丢弃,不保存到输出文件中
通过以下命令行启用该链接控制脚本:
$ gcc -c -fno-builtin TinyHelloWorld.c
$ ld -static -T TinyHelloWorld.lds -o TinyHelloWorld TinyHelloWorld.o
我们得到一个ELF可执行文件,运行后能正确打印“Hello World!”。当我们用objdump或readelf查看时,会发现程序除了tinytext之外居然还有其他3个段:段名字符串表(.shstrtab)、符号表(.symtab)和字符串表(.strtab)。在默认情况下,ld链接器在产生可执行文件时会产生这3个段。对于可执行文件来说,符号表和字符串表是可选的,但是段名字符串表为用户保存段名,所以它是必不可少的。
你可以通过ld的-s参数禁止链接器产生符号表,或者用strip命令来去除程序中的符号表。
有人专门研究了如何得到一个最小的ELF可执行文件,最后成果是最小的ELF可执行文件为45个字节。这个程序的功能是以42为进程退出码正常退出进程,没有任何输入和输出。上面的TinyHelloWorld也是以这个特殊的值42作为退出码。
追溯42这个奇怪的数字来源,可能因为《银河系漫游指南》里面的终极电脑给出的关于生命、宇宙级万物的终极答案是42。
ld链接脚本语法简介
ld链接器的链接脚本语法继承于AT&T链接器命令语言的语法,风格有点像C语言,它本身并不复杂。链接脚本由一系列语句组成,语句分两种,一种是命令语句,另外一种是赋值语句。之所以说像C语言,主要有如下几点相似之处:
- 语句之间使用分好作为分割符。
- 表达式与运算符
- 注释和字符引用 使用/* */作为注释。脚本文件中使用到的文件名、格式名或段名等凡是包含分好或其他分隔符的,都要使用双引号将该名字全称引用起来,如果文件名包含引号则无法处理。
赋值语句比较简单,命令语句一般的格式是由一个关键字和紧跟其后的参数所组成的。
其中SECTIONS语句比较复杂,它又包含了一个赋值语句及一些SECTIONS语句所特有的映射规则。其他命令语句都比较简单,毕竟SECTIONS负责指定链接过程的段转换过程,这也是链接的最核心和最复杂的部分。
常用的命令语句如下:
这里只是大概提及几个常用的命令语句格式,更多的命令语句的意义及它们的格式请参照ld的使用手册。
SECTIONS命令语句最基本格式为:
SECTIONS
{
...
secname : { contents }
...
}
- secname表示输出段的段名,secname后面必须由一个空格符,后面紧跟着冒号和一对大括号。
- 大括号里面的contents描述了一套规则和条件,它表示符合这种条件的输入段将合并到这个输出段中。
- 输出段名的命名方法必须符合输出文件格式的要求。
- 有一个特殊的段名叫“/DISCARD/”,如果使用这个名字作为输出段名,那么所有符合条件的输入段都将被丢弃。
- contents中可以包含若干个条件,每个条件之间以空格隔开,它们是或者(||)的关系。
- 条件的写法:
filename(sections)
, 均支持正则表达式。