链接
简述
重定位是将符号引用与符号定义进行链接的过程。因此链接是处理可重定位文件,把它们的各种符号引用和符号定义转换为可执行文件中的合适信息(一般是虚拟内存地址)的过程。
链接又分为静态链接和动态链接,前者是程序开发阶段程序员用 ld
(gcc
实际上在后台调用了 ld
)静态链接器手动链接的过程,而动态链接则是程序运行期间系统调用动态链接器(ld-linux.so
)自动链接的过程。
比如,如果链接到可执行文件中的是静态链接库 libmyprintf.a
,那么 .rodata
节区在链接后需要被重定位到一个绝对的虚拟内存地址,以便程序运行时能够正确访问该节区中的字符串信息。而对于 puts
函数,因为它是动态链接库 libc.so
中定义的函数,所以会在程序运行时通过动态符号链接找出 puts
函数在内存中的地址,以便程序调用该函数。在这里主要讨论静态链接过程,动态链接过程见[《动态符号链接的细节》][100]。
静态链接过程主要是把可重定位文件依次读入,分析各个文件的文件头,进而依次读入各个文件的节区,并计算各个节区的虚拟内存位置,对一些需要重定位的符号进行处理,设定它们的虚拟内存地址等,并最终产生一个可执行文件或者是动态链接库。这个链接过程是通过 ld
来完成的,ld
在链接时使用了一个链接脚本(linker script
),该链接脚本处理链接的具体细节。
由于静态符号链接过程非常复杂,特别是计算符号地址的过程,考虑到时间关系,相关细节请参考 ELF 手册。这里主要介绍可重定位文件中的节区(节区表描述的)和可执行文件中段(程序头描述的)的对应关系以及 gcc
编译时采用的一些默认链接选项。
可执行文件的段:节区重排
下面先来看看可执行文件的节区信息,通过程序头(段表)来查看,为了比较,先把 test.o
的节区表也列出:
$ readelf -S test.o
There are 10 section headers, starting at offset 0xb4:
Section Headers:
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[ 0] NULL 00000000 000000 000000 00 0 0 0
[ 1] .text PROGBITS 00000000 000034 000024 00 AX 0 0 4
[ 2] .rel.text REL 00000000 0002ec 000008 08 8 1 4
[ 3] .data PROGBITS 00000000 000058 000000 00 WA 0 0 4
[ 4] .bss NOBITS 00000000 000058 000000 00 WA 0 0 4
[ 5] .comment PROGBITS 00000000 000058 000012 00 0 0 1
[ 6] .note.GNU-stack PROGBITS 00000000 00006a 000000 00 0 0 1
[ 7] .shstrtab STRTAB 00000000 00006a 000049 00 0 0 1
[ 8] .symtab SYMTAB 00000000 000244 000090 10 9 7 4
[ 9] .strtab STRTAB 00000000 0002d4 000016 00 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings)
I (info), L (link order), G (group), x (unknown)
O (extra OS processing required) o (OS specific), p (processor specific)
$ gcc -o test test.o myprintf.o
$ readelf -l test
Elf file type is EXEC (Executable file)
Entry point 0x80482b0
There are 7 program headers, starting at offset 52
Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
PHDR 0x000034 0x08048034 0x08048034 0x000e0 0x000e0 R E 0x4
INTERP 0x000114 0x08048114 0x08048114 0x00013 0x00013 R 0x1
[Requesting program interpreter: /lib/ld-linux.so.2]
LOAD 0x000000 0x08048000 0x08048000 0x0047c 0x0047c R E 0x1000
LOAD 0x00047c 0x0804947c 0x0804947c 0x00104 0x00108 RW 0x1000
DYNAMIC 0x000490 0x08049490 0x08049490 0x000c8 0x000c8 RW 0x4
NOTE 0x000128 0x08048128 0x08048128 0x00020 0x00020 R 0x4
GNU_STACK 0x000000 0x00000000 0x00000000 0x00000 0x00000 RW 0x4
Section to Segment mapping:
Segment Sections...
00
01 .interp
02 .interp .note.ABI-tag .hash .dynsym .dynstr .gnu.version .gnu.version_r
.rel.dyn .rel.plt .init .plt .text .fini .rodata .eh_frame
03 .ctors .dtors .jcr .dynamic .got .got.plt .data .bss
04 .dynamic
05 .note.ABI-tag
06
可发现,test
和 test.o
,myprintf.o
相比,多了很多节区,如 .interp
和 .init
等。另外,上表也给出了可执行文件的如下几个段(Segment):
PHDR
: 给出了程序表自身的大小和位置,不能出现一次以上。INTERP
: 因为程序中调用了puts
(在动态链接库中定义),使用了动态链接库,因此需要动态装载器/链接器(ld-linux.so
)LOAD
: 包括程序的指令,.text
等节区都映射在该段,只读(R)LOAD
: 包括程序的数据,.data
,.bss
等节区都映射在该段,可读写(RW)DYNAMIC
: 动态链接相关的信息,比如包含有引用的动态链接库名字等信息NOTE
: 给出一些附加信息的位置和大小GNU_STACK
: 这里为空,应该是和GNU相关的一些信息
这里的段可能包括之前的一个或者多个节区,也就是说经过链接之后原来的节区被重排了,并映射到了不同的段,这些段将告诉系统应该如何把它加载到内存中。
链接背后的故事
从上表中,通过比较可执行文件 test
中拥有的节区和可重定位文件(test.o
和 myprintf.o
)中拥有的节区后发现,链接之后多了一些之前没有的节区,这些新的节区来自哪里?它们的作用是什么呢?先来通过 gcc -v
看看它的后台链接过程。
把可重定位文件链接成可执行文件:
$ gcc -v -o test test.o myprintf.o
Reading specs from /usr/lib/gcc/i486-slackware-linux/4.1.2/specs
Target: i486-slackware-linux
Configured with: ../gcc-4.1.2/configure --prefix=/usr --enable-shared
--enable-languages=ada,c,c++,fortran,java,objc --enable-threads=posix
--enable-__cxa_atexit --disable-checking --with-gnu-ld --verbose
--with-arch=i486 --target=i486-slackware-linux --host=i486-slackware-linux
Thread model: posix
gcc version 4.1.2
/usr/libexec/gcc/i486-slackware-linux/4.1.2/collect2 --eh-frame-hdr -m
elf_i386 -dynamic-linker /lib/ld-linux.so.2 -o test
/usr/lib/gcc/i486-slackware-linux/4.1.2/../../../crt1.o
/usr/lib/gcc/i486-slackware-linux/4.1.2/../../../crti.o
/usr/lib/gcc/i486-slackware-linux/4.1.2/crtbegin.o
-L/usr/lib/gcc/i486-slackware-linux/4.1.2
-L/usr/lib/gcc/i486-slackware-linux/4.1.2
-L/usr/lib/gcc/i486-slackware-linux/4.1.2/../../../../i486-slackware-linux/lib
-L/usr/lib/gcc/i486-slackware-linux/4.1.2/../../.. test.o myprintf.o -lgcc
--as-needed -lgcc_s --no-as-needed -lc -lgcc --as-needed -lgcc_s --no-as-needed
/usr/lib/gcc/i486-slackware-linux/4.1.2/crtend.o
/usr/lib/gcc/i486-slackware-linux/4.1.2/../../../crtn.o
从上述演示看出,gcc
在链接了我们自己的目标文件 test.o
和 myprintf.o
之外,还链接了 crt1.o
,crtbegin.o
等额外的目标文件,难道那些新的节区就来自这些文件?
用 ld 完成链接过程
另外 gcc
在进行了相关配置(./configure
)后,调用了 collect2
,却并没有调用 ld
,通过查找 gcc
文档中和 collect2
相关的部分发现 collect2
在后台实际上还是去寻找 ld
命令的。为了理解 gcc
默认链接的后台细节,这里直接把 collect2
替换成 ld
,并把一些路径换成绝对路径或者简化,得到如下的 ld
命令以及执行的效果。
$ ld --eh-frame-hdr \
-m elf_i386 \
-dynamic-linker /lib/ld-linux.so.2 \
-o test \
/usr/lib/crt1.o /usr/lib/crti.o /usr/lib/gcc/i486-slackware-linux/4.1.2/crtbegin.o \
test.o myprintf.o \
-L/usr/lib/gcc/i486-slackware-linux/4.1.2 -L/usr/i486-slackware-linux/lib -L/usr/lib/ \
-lgcc --as-needed -lgcc_s --no-as-needed -lc -lgcc --as-needed -lgcc_s --no-as-needed \
/usr/lib/gcc/i486-slackware-linux/4.1.2/crtend.o /usr/lib/crtn.o
$ ./test
hello, world!
不出所料,它完美地运行了。下面通过 ld
的手册(man ld
)来分析一下这几个参数:
-
--eh-frame-hdr
要求创建一个
.eh_frame_hdr
节区(貌似目标文件test中并没有这个节区,所以不关心它)。 -
-m elf_i386
这里指定不同平台上的链接脚本,可以通过
--verbose
命令查看脚本的具体内容,如ld -m elf_i386 --verbose
,它实际上被存放在一个文件中(/usr/lib/ldscripts
目录下),我们可以去修改这个脚本,具体如何做?请参考ld
的手册。在后面我们将简要提到链接脚本中是如何预定义变量的,以及这些预定义变量如何在我们的程序中使用。需要提到的是,如果不是交叉编译,那么无须指定该选项。 -
-dynamic-linker /lib/ld-linux.so.2
指定动态装载器/链接器,即程序中的
INTERP
段中的内容。动态装载器/链接器负责链接有可共享库的可执行文件的装载和动态符号链接。 -
-o test
指定输出文件,即可执行文件名的名字
-
/usr/lib/crt1.o /usr/lib/crti.o /usr/lib/gcc/i486-slackware-linux/4.1.2/crtbegin.o
链接到
test
文件开头的一些内容,这里实际上就包含了.init
等节区。.init
节区包含一些可执行代码,在main
函数之前被调用,以便进行一些初始化操作,在 C++ 中完成构造函数功能。 -
test.o myprintf.o
链接我们自己的可重定位文件
-
-L/usr/lib/gcc/i486-slackware-linux/4.1.2 -L/usr/i486-slackware-linux/lib -L/usr/lib/ \ -lgcc --as-needed -lgcc_s --no-as-needed -lc -lgcc --as-needed -lgcc_s --no-as-needed
链接
libgcc
库和libc
库,后者定义有我们需要的puts
函数 -
/usr/lib/gcc/i486-slackware-linux/4.1.2/crtend.o /usr/lib/crtn.o
链接到
test
文件末尾的一些内容,这里实际上包含了.fini
等节区。.fini
节区包含了一些可执行代码,在程序退出时被执行,作一些清理工作,在 C++ 中完成析构造函数功能。我们往往可以通过atexit
来注册那些需要在程序退出时才执行的函数。
C++构造与析构:crtbegin.o和crtend.o
对于 crtbegin.o
和 crtend.o
这两个文件,貌似完全是用来支持 C++ 的构造和析构工作的,所以可以不链接到我们的可执行文件中,链接时把它们去掉看看,
$ ld -m elf_i386 -dynamic-linker /lib/ld-linux.so.2 -o test \
/usr/lib/crt1.o /usr/lib/crti.o test.o myprintf.o \
-L/usr/lib -lc /usr/lib/crtn.o #后面发现不用链接libgcc,也不用--eh-frame-hdr参数
$ readelf -l test
Elf file type is EXEC (Executable file)
Entry point 0x80482b0
There are 7 program headers, starting at offset 52
Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
PHDR 0x000034 0x08048034 0x08048034 0x000e0 0x000e0 R E 0x4
INTERP 0x000114 0x08048114 0x08048114 0x00013 0x00013 R 0x1
[Requesting program interpreter: /lib/ld-linux.so.2]
LOAD 0x000000 0x08048000 0x08048000 0x003ea 0x003ea R E 0x1000
LOAD 0x0003ec 0x080493ec 0x080493ec 0x000e8 0x000e8 RW 0x1000
DYNAMIC 0x0003ec 0x080493ec 0x080493ec 0x000c8 0x000c8 RW 0x4
NOTE 0x000128 0x08048128 0x08048128 0x00020 0x00020 R 0x4
GNU_STACK 0x000000 0x00000000 0x00000000 0x00000 0x00000 RW 0x4
Section to Segment mapping:
Segment Sections...
00
01 .interp
02 .interp .note.ABI-tag .hash .dynsym .dynstr .gnu.version .gnu.version_r
.rel.dyn .rel.plt .init .plt .text .fini .rodata
03 .dynamic .got .got.plt .data
04 .dynamic
05 .note.ABI-tag
06
$ ./test
hello, world!
完全可以工作,而且发现 .ctors
(保存着程序中全局构造函数的指针数组), .dtors
(保存着程序中全局析构函数的指针数组),.jcr
(未知),.eh_frame
节区都没有了,所以 crtbegin.o
和 crtend.o
应该包含了这些节区。
初始化与退出清理:crti.o 和 crtn.o
而对于另外两个文件 crti.o
和 crtn.o
,通过 readelf -S
查看后发现它们都有 .init
和 .fini
节区,如果我们不需要让程序进行一些初始化和清理工作呢?是不是就可以不链接这个两个文件?试试看。
$ ld -m elf_i386 -dynamic-linker /lib/ld-linux.so.2 -o test \
/usr/lib/crt1.o test.o myprintf.o -L/usr/lib/ -lc
/usr/lib/libc_nonshared.a(elf-init.oS): In function `__libc_csu_init':
(.text+0x25): undefined reference to `_init'
貌似不行,竟然有人调用了 __libc_csu_init
函数,而这个函数引用了 _init
。这两个符号都在哪里呢?
$ readelf -s /usr/lib/crt1.o | grep __libc_csu_init
18: 00000000 0 NOTYPE GLOBAL DEFAULT UND __libc_csu_init
$ readelf -s /usr/lib/crti.o | grep _init
17: 00000000 0 FUNC GLOBAL DEFAULT 5 _init
竟然是 crt1.o
调用了 __libc_csu_init
函数,而该函数却引用了我们没有链接的 crti.o
文件中定义的 _init
符号。这样的话不链接 crti.o
和 crtn.o
文件就不成了罗?不对吧,要不干脆不用 crt1.o
算了,看看 gcc
额外链接进去的最后一个文件 crt1.o
到底干了个啥子?
$ ld -m elf_i386 -dynamic-linker /lib/ld-linux.so.2 -o \
test test.o myprintf.o -L/usr/lib/ -lc
ld: warning: cannot find entry symbol _start; defaulting to 00000000080481a4
这样却说没有找到入口符号 _start
,难道 crt1.o
中定义了这个符号?不过它给默认设置了一个地址,只是个警告,说明 test
已经生成,不管怎样先运行看看再说。
$ ./test
hello, world!
Segmentation fault
貌似程序运行完了,不过结束时冒出个段错误?可能是程序结束时有问题,用 gdb
调试看看:
$ gcc -g -c test.c myprintf.c #产生目标代码, 非交叉编译,不指定-m也可链接,所以下面可去掉-m
$ ld -dynamic-linker /lib/ld-linux.so.2 -o test \
test.o myprintf.o -L/usr/lib -lc
ld: warning: cannot find entry symbol _start; defaulting to 00000000080481d8
$ ./test
hello, world!
Segmentation fault
$ gdb -q ./test
(gdb) l
1 #include "test.h"
2
3 int main()
4 {
5 myprintf();
6 return 0;
7 }
(gdb) break 7 #在程序的末尾设置一个断点
Breakpoint 1 at 0x80481bf: file test.c, line 7.
(gdb) r #程序都快结束了都没问题,怎么会到最后出个问题呢?
Starting program: /mnt/hda8/Temp/c/program/test
hello, world!
Breakpoint 1, main () at test.c:7
7 }
(gdb) n #单步执行看看,怎么下面一条指令是0x00000001,肯定是程序退出以后出了问题
0x00000001 in ?? ()
(gdb) n #诶,当然找不到边了,都跑到0x00000001了
Cannot find bounds of current function
(gdb) c
Continuing.
Program received signal SIGSEGV, Segmentation fault.
0x00000001 in ?? ()
原来是这么回事,估计是 return 0
返回之后出问题了,看看它的汇编去。
$ gcc -S test.c #产生汇编代码
$ cat test.s
...
call myprintf
movl $0, %eax
addl $4, %esp
popl %ecx
popl %ebp
leal -4(%ecx), %esp
ret
...
后面就这么几条指令,难不成 ret
返回有问题,不让它 ret
返回,把 return
改成 _exit
直接进入内核退出。
$ vim test.c
$ cat test.c #就把return语句修改成_exit了。
#include "test.h"
#include <unistd.h> /* _exit */
int main()
{
myprintf();
_exit(0);
}
$ gcc -g -c test.c myprintf.c
$ ld -dynamic-linker /lib/ld-linux.so.2 -o test test.o myprintf.o -L/usr/lib -lc
ld: warning: cannot find entry symbol _start; defaulting to 00000000080481d8
$ ./test #竟然好了,再看看汇编有什么不同
hello, world!
$ gcc -S test.c
$ cat test.s #貌似就把ret指令替换成了_exit函数调用,直接进入内核,让内核处理了,那为什么ret有问题呢?
...
call myprintf
subl $12, %esp
pushl $0
call _exit
...
$ gdb -q ./test #把代码改回去(改成return 0;),再调试看看调用main函数返回时的下一条指令地址eip
(gdb) l
warning: Source file is more recent than executable.
1 #include "test.h"
2
3 int main()
4 {
5 myprintf();
6 return 0;
7 }
(gdb) break 5
Breakpoint 1 at 0x80481b5: file test.c, line 5.
(gdb) break 7
Breakpoint 2 at 0x80481bc: file test.c, line 7.
(gdb) r
Starting program: /mnt/hda8/Temp/c/program/test
Breakpoint 1, main () at test.c:5
5 myprintf();
(gdb) x/8x $esp
0xbf929510: 0xbf92953c 0x080481a4 0x00000000 0xb7eea84f
0xbf929520: 0xbf92953c 0xbf929534 0x00000000 0x00000001
发现 0x00000001
刚好是之前调试时看到的程序返回后的位置,即 eip
,说明程序在初始化时,这个 eip
就是错误的。为什么呢?因为根本没有链接进初始化的代码,而是在编译器自己给我们,初始化了程序入口即 00000000080481d8
,也就是说,没有人调用 main
,main
不知道返回哪里去,所以,我们直接让 main
结束时进入内核调用 _exit
而退出则不会有问题。
通过上面的演示和解释发现只要把return语句修改为 _exit
语句,程序即使不链接任何额外的目标代码都可以正常运行(原因是不链接那些额外的文件时相当于没有进行初始化操作,如果在程序的最后执行 ret
汇编指令,程序将无法获得正确的 eip
,从而无法进行后续的动作)。但是为什么会有“找不到 _start
符号”的警告呢?通过 readelf -s
查看 crt1.o
发现里头有这个符号,并且 crt1.o
引用了 main
这个符号,是不是意味着会从 _start
进入 main
呢?是不是程序入口是 _start
,而并非 main
呢?
C 语言程序真正的入口
先来看看刚才提到的链接器的默认链接脚本(ld -m elf_386 --verbose
),它告诉我们程序的入口(entry)是 _start
,而一个可执行文件必须有一个入口地址才能运行,所以这就是说明了为什么 ld
一定要提示我们 “_start找不到”,找不到以后就给默认设置了一个地址。
$ ld --verbose | grep ^ENTRY #非交叉编译,可不用-m参数;ld默认找_start入口,并不是main哦!
ENTRY(_start)
原来是这样,程序的入口(entry)竟然不是 main
函数,而是 _start
。那干脆把汇编里头的 main
给改掉算了,看行不行?
先生成汇编 test.s
:
$ cat test.c
#include "test.h"
#include <unistd.h> /* _exit */
int main()
{
myprintf();
_exit(0);
}
$ gcc -S test.c
然后把汇编中的 main
改为 _start
,即改程序入口为 _start
:
$ sed -i -e "s#main#_start#g" test.s
$ gcc -c test.s myprintf.c
重新链接,发现果然没问题了:
$ ld -dynamic-linker /lib/ld-linux.so.2 -o test test.o myprintf.o -L/usr/lib/ -lc
$ ./test
hello, world!
_start
竟然是真正的程序入口,那在有 main
的情况下呢?为什么在 _start
之后能够找到 main
呢?这个看看 alert7 大叔的Before main 分析吧,这里不再深入介绍。
总之呢,通过修改程序的 return
语句为 _exit(0)
和修改程序的入口为 _start
,我们的代码不链接 gcc
默认链接的那些额外的文件同样可以工作得很好。并且打破了一个学习 C 语言以来的常识:main
函数作为程序的主函数,是程序的入口,实际上则不然。
链接脚本初次接触
再补充一点内容,在 ld
的链接脚本中,有一个特别的关键字 PROVIDE
,由这个关键字定义的符号是 ld
的预定义字符,我们可以在 C 语言函数中扩展它们后直接使用。这些特别的符号可以通过下面的方法获取,
$ ld --verbose | grep PROVIDE | grep -v HIDDEN
PROVIDE (__executable_start = 0x08048000); . = 0x08048000 + SIZEOF_HEADERS;
PROVIDE (__etext = .);
PROVIDE (_etext = .);
PROVIDE (etext = .);
_edata = .; PROVIDE (edata = .);
_end = .; PROVIDE (end = .);
这里面有几个我们比较关心的,第一个是程序的入口地址 __executable_start
,另外三个是 etext
,edata
,end
,分别对应程序的代码段(text)、初始化数据(data)和未初始化的数据(bss)(可参考man etext
),如何引用这些变量呢?看看这个例子。
/* predefinevalue.c */
#include <stdio.h>
extern int __executable_start, etext, edata, end;
int main(void)
{
printf ("program entry: 0x%x \n", &__executable_start);
printf ("etext address(text segment): 0x%x \n", &etext);
printf ("edata address(initilized data): 0x%x \n", &edata);
printf ("end address(uninitilized data): 0x%x \n", &end);
return 0;
}
到这里,程序链接过程的一些细节都介绍得差不多了。在[《动态符号链接的细节》][100]中将主要介绍 ELF 文件的动态符号链接过程。
参考资料
- Linux 汇编语言开发指南
- PowerPC 汇编
- 用于 Power 体系结构的汇编语言
- Linux 中 x86 的内联汇编
- Linux Assembly HOWTO
- Linux Assembly Language Programming
- Guide to Assembly Language Programming in Linux
- An beginners guide to compiling programs under Linux
- gcc manual
- A Quick Tour of Compiling, Linking, Loading, and Handling Libraries on Unix
- Unix 目标文件初探
- Before main()分析
- A Process Viewing Its Own /proc//map Information
- UNIX 环境高级编程
- Linux Kernel Primer
- Understanding ELF using readelf and objdump
- Study of ELF loading and relocs
- ELF file format and ABI
- TN05.ELF.Format.Summary.pdf
- ELF文件格式(中文)
- 关于 Gcc 方面的论文,请查看历年的会议论文集
- The Linux GCC HOW TO
- ELF: From The Programmer’s Perspective
- C/C++ 程序编译步骤详解
- C 语言常见问题集
- 使用 BFD 操作 ELF
- bfd document
- UNIX/LINUX 平台可执行文件格式分析
- Linux 汇编语言快速上手:4大架构一块学
- GNU binutils 小结