第一节 GNU Tools开发工具简介
GNU开发工具
为了有效地进行嵌入式开发,至少需要了解和掌握如下几类工具:
编译开发工具:即能够把一个源程序编译生成一个可执行程序的软件,如gcc等。
调试工具:即能够对执行程序进行源码或汇编级调试的软件,如gdb等。
软件工程工具:用于协助多人开发或大型软件项目的管理的软件,如make、cvs等。
具体来说,我们需要对如下软件有一定了解:
(1)GCC
很多人把GCC看成只是一个C编译器,其实GCC是GNU Compiler Collection的简称,目前,GCC可以支持C、C++、ADA、Object C、JAVA、Fortran、PASCAL等多种高级语言。GCC主要包括如下一些工具。
cpp,GNU预处理器
gcc,符合ISO标准的C编译器
g++,符合ISO标准的C++编译器
gcj,gcj是GCC的java前端,可以生成执行速度更快的二进制本地执行码,而不是java byte code。gcj为把java程序编译成机器代码提供了试验性的支持。要做到这点,用户还需要安装相关的java运行时库。
gnat,是GCC的GNU ADA 95前端,该软件包括开发工具、文档及ADA 95编译器。
(2)binutils
binutils是一组二进制工具程序集,它包括addr2line、ar、as、gprof、ld、nm、objcopy、objdump、ranlib、size、strings、strip等工具,是辅助GCC的主要软件。
as,GNU汇编器(Assembler),用于把汇编代码转换成二进制代码,并存放到一个object文件中。
ld,GNU链接器(Linker),主要用于确定相对地址,把多个object文件、起始代码段、库等链接起来,并最终形成一个可执行文件。
addr2line,把执行程序中的地址映射到源文件中的对应行。
ar,创建归档文件(Archive)、修改/替换库中的object文件,向库中添加/提取object文件。
c++filt,解码C++符号名。
nm,列出object文件中的符号名。
objcopy,复制和转换object文件。
objdump,用来显示对象文件的信息。
ranlib,根据归档文件(Archive)中内容建立索引。
readelf,显示elf格式执行文件中的各种信息。
size,显示object文件和执行文件各节(Section)的大小。
strings,显示可执行文件中字符串常量。
strip,去掉执行文件中多余的信息(如调试信息),可以减少执行文件的大小。
gprof,用来显示调用图表档案数据。
(3)gdb
gdb是GNU调试器,它允许调试用C、C++或其它语言编写的程序。它的基本运行方式是在一个shell环境下用命令行方式调试程序和显示数据。如果加上一些图形前端(如DDD等软件),则可以在此一个更方便的图形环境下调试程序。
(4)make
GNU make是一个用来控制可执行程序的生成过程,从其它的源码程序文件中生成可执行程序的GNU工具。GNU make允许用户生成和安装软件包,而无需了解生成、安装软件包的具体执行过程。
(5)diff/diff3/sdiff
diff/diff3/sdiff是比较文本差异的工具,也可以用来产生补丁。
(6)patch
patch是补丁安装程序,可根据diff生成的补丁来更新程序。
(7)CVS(Concurrent Version System)
CVS是一个版本控制系统。它能够记录文件的修改历史(通常但并不总 是包括源码)。CVS只存储版本之间的区别,而不是你创建的文件的每一个版本。CVS还保留一个记录改变者、改变时间以及改变原因的日志。CVS对于管理 发行版本和控制在多个作者间同时编辑源码文件很有帮助。CVS为一个层次化的目录提供版本控制,目录由修改控制的文件组成,而不是在一个目录中为一组文件 提供版本控制。这些目录和文件可以被合并起来构成一个软件发行版本。
第二节 binutils开发工具
binutils是一组二进制工具程序集,它包括addr2line、ar、as、gprof、ld、nm、objcopy、objdump、ranlib、size、strings、strip等工具,下面分别介绍其中一些常用的软件。
nm工具:
nm软件主要功能是列出目标文件中的符号,这样可以帮助程序员定位和分析执行程序和目标文件中的符号信息和它的属性。如果没有目标文件作为参数被列出,nm假定目标文件为”a.out”,通过下面的命令可以得到nm的一般帮助信息#nm -h。
对于每一个符号,nm将显示下面的内容:
符号的值:用某种基数表示的数值。默认情况下用十六进制表示。
符号的类型:至少要用到下面的类型。也可以用其它类型,这依赖于目标文件的格式。
1. A:符号的值是绝对值,并且不会被将来的链接所改变。
2. B:符号位于未初始化数据部分(被认为是BSS)。
3. C:符号是公共的。公共符号是未初始化的数据。在链接时,多个公共符号可能以相同的名字出现。如果符号在其他地方被定义,该符号会被当作未定义的引用来处理。
4. D:符号位于已初始化数据部分。
5. G:符号位于小型对象的已初始化数据部分,相对于大型的全局array,一些对象文件格式允许对小型数据对象更有效的存取,例如全局的int型变量。
6. I:符号是另一个符号的间接引用。这是对很少用到的a.out目标文件格式的GNU扩展。
7. N:符号是调试符号。
8. R:符号位于只读数据部分。
9. S:符号位于小型对象的未初始化数据部分。
10. T:符号位于文本(代码)部分。
11. U:符号未被定义。
12. W:符号是弱定义符号(Weak Symbol),也称弱符号。当一个弱定义符号和一个已经定义的普通符号链接时,使用该已定义的普通符号不会引起错误。当一个未定义的弱符号被链接且该符号未被定义时,该weak符号的值被无错误的变为0。
13. -:符号是一个a.out目标文件中的stabs符号。这种情况下,接下来要打印的值是stabs other域,stabs desc域,以及stab类型。stabs符号用于保留调试信息。
14. ?:符号类型是未知的,或目标文件格式特殊。
15. o:符号名
nm的命令行参数选项的长格式和短格式是等价的,下面列出了可供选择的命令行参数格式。
1. -A/-o/--print-file-name: 在找到的各个符号的名字前加上输入文件名(或归档文件元素),而不是在此文件中的所有符号前只出现一次输入文件名。
2. -a/--debug-syms:显示所有的调试符号,即使是仅用于调试的符号。通常情况下这些符号是不被列出的。
3. -B:等价于“--format=bsd”(为了兼容MIPS nm)。
4. -C/--demangle:将低级符号名解码成用户级名字。另外去除任何由系统预先生成的初始的下划线,这样可以使C++函数名具有可读性。
5. --no-demangle:不解码低级符号名。这是默认的处理方式。
6. -D/--dynamic:显示动态符号而不是普通符号。该选项仅对动态目标文件有意义,例如特定类型的共享库。
7. -f format/--format = format:使用format格式输出,format可以是bsd、sysv或posix。默认为bsd。仅当format的第一个字符是有意义的,可以是大写或小写。
8. -g/--extern-only:只显示外部符号。
9. -l/--line-numbers:对每个符号,使用调试信息去试图找到文件名和行号。对于已定义的符号,查找符号地址的等号。对于未定义符号,查找指向符号重定位入口的行号。如果可以找到行号信息,在其他符号信息之后显示行号信息。
10. -n/-v/--numeric-sort:按符号对应地址的顺序排序,而不是按符号名的字符顺序。
11. -p/--no-sort:不以任何顺序对符号进行排序,按目标文件中遇到的符号顺序显示。
12. -P/--portablility:使用POSIX.2标准输出格式代替默认的输出格式。等价于“-f posix”。
13. -s/--print-armap:当列出归档文件中成员的符号时,包含索引,即名字和包含该名字定义的模块的映射(通过ar或ranlib保存在归档文件中)。
14. -r/--reverse-sort:反转排序的顺序(按数字或字母顺序)显示。
15. --size-sort:按大小排列符号顺序。该大小是按照一个符号的值与它下一个符号的值进行计算的。
16. -t radix/--radix = radix:使用radix进制显示符号值。radix只能为“d”表示十进制、“o”表示八进制或“x”表示十六进制。
17. --target = bfdname:指定一个目标代码的格式,而非使用系统默认格式。
18. -u/--undefined-only:仅显示没有定义的符号(那些每个目标文件的外部符号)。
19. -defined-only:仅显示每个目标文件中已经定义的符号。
20. -V/--version:显示nm的版本号然后退出。
21. --help:显示nm的所有选项然后退出。
下面介绍一个简单的使用,按照我们刚才介绍ar命令时的例子,执行如下命令:
#nm test.o
输出结果如下:
U Add
00000000 T main
U Minus
U printf
执行命令:
#nm add.o
输出结果如下:
00000000 T Add
执行命令:
#nm minus.o
输出结果如下:
00000000 T Minus
命 令nm test.o的输出说明了test.o定义了main函数,但没有定义Add、Minus和printf函数符号。命令nm add.o的输出说明add.o定义了Add函数符号。命令nm minus.o的输出说明了minus.o定义了Minus函数符号。test.o中没有定义但使用了printf函数符号,printf函数实际上定义 在libc.a库中。
objdump工具:
objdump显示一个或多个目标文件的信息。由其选项来控制显示哪些特定的信息。这些信息只对那些编写编译工具的程序员有帮助,而对那些只想让 自己编写的程序编译和运行起来的程序员来说没有更多意义。但在嵌入式系统级开发时,通过这个软件可以方便地查看执行文件或库文件的信息。如我们可以通过 objdump软件反汇编执行程序,看到执行程序的汇编格式。
当目标文件是归档文件时,objdump显示的是归档文件中每个成员文件的信息。选项的长格式和短格式是等价的。下面描述了作为可选择的参数格式(除“-l”之外,至少要给出一个参数选项)。
1. -a/--archive- header:如果任何一个由object-file指定的文件是归档文件,则显示该归档文件的头信息(类似于“ls -l”的格式)。除了“ar -tv”能显示的信息外,“objdump -a”还可以显示每个归档文件成员的目标文件格式。
2. --adjust-vma=offset: 当转储信息时,首先给所有的节地址加上一个偏移量offset。如果节地址对应不上符号表时,就可以使用该选项。当节被放在特殊的地址,而采用的是一种不 能表示节地址的格式,例如a.out,就会发生节地址对应不上符号表的情况。
3. -b bfdname/--target = bfdname:指定目标文件的目标代码格式为bfdname。该选项可能不是必需的,因为objdump能够识别很多格式。如命令“objdump -b oasys -m vax -h fu.o”执行后,将显示节头中的概要信息。“-b oasys”表示用的是Oasys编译器产生的目标文件格式。“-m vax”表示目标文件是VAX计算机上的目标文件。
4. -C/--demangle:将低级符号名解码成用户级名字。另外去除任何由系统预先生成的初始的下划线,这样可以使得C++函数名具有可读性。
5. --debugging:显示调试信息。该选项试图解析保存在文件中的调试信息并且用C语言风格的语法打印这些信息。仅能对特定类型的调试信息实现这个功能。
6. -d/--disassemble:显示目标文件中的机器指令使用的汇编语言。该选项仅仅反汇编那些应该含有指令机器码的节。
7. -D/--disassemble-all:类似于“-d”,但是反汇编所有节的内容。该选项仅对那些应该含有指令机器码的节有意义。
8. --prefix-addresses:反汇编时,打印每行的完整地址。这是一种更古老的反汇编格式。
9. --disassemble-zeroes:通常情况下,反汇编的输出会跳过大块的零。该选项将指引反汇编器去反汇编那些大块的零,就像处理其他数据一样。
10. -EB/-EL/--endian={big|little}:指定目标文件的字节顺序。该选项只会影响反汇编。当反汇编像S-records这样没有描述字节顺序的文件格式时,该选项是有用的。
11. -f/--file-header:显示每个由object-file指定的所有目标文件的文件头概要信息。
12. -h/--section- header/--header:显示目标文件的节头概要信息。ld使用了“-Ttext”、“-Tdata”或“-Tbss”这些选项时,可能会使得目 标文件的各个节被重定向到非标准的地址。这样通过这个选项,可以查出目标文件的各节的起始地址。然而,一些像a.out这样的目标文件格式并没有存储文件 段的起始地址。在这些情况下,尽管ld正确地重定位了每个节。但是使用“objdump -h”列出文件节头时,并不能显示其正确地址。
13. --help:显示objdump的所有选项的概要信息然后退出。
14. -i/--info:列表显示所有对“-b”或“-m”可用的体系结构和目标格式。
15. -j name/--section=name:只显示由name指定的节。
16. -l/--line-numbers:用对应于目标代码的文件名和行号来标注要显示的信息(使用调试信息),仅仅和-d、-D或-r一起使用才有效。
17. -m machine/--architecture = machine:当反汇编由object-file指定的目标文件时,标明所使用的体系结构。当反汇编一个像S-records这样本身并没有描述体系结 构信息的文件的时候,这个选项是有用的。可以用-i选项列出可用的体系结构。
18. -p/--private-headers:显示目标文件格式的特定信息。要显示的信息依赖于目标文件的格式。对于某些目标文件格式,没有附加的信息可供显示。
19. -r/--reloc:显示文件的重定位入口。如果和“-d”或者“-D”一起使用,重定位部分以反汇编后的格式显示出来。
20. -R/-dynamic-reloc:显示文件的动态重定位入口,仅仅对于动态目标文件有意义,例如特定类型的共享库。
21. -s/--full-contents:显示任何指定节的全部内容。
22. -S/--source:尽可能显示与反汇编混和的源代码。隐含了“-d”参数。
23. --show-raw-insn:反汇编机器指令的时候,用十六进制和符号形式同时显示机器指令码。然而并非所有的目标都能这样正确地出来。
24. --no-show-raw-insn:反汇编机器指令的时候,不显示指令类型。这是指定--prefix-addresses选项时的默认设置。
25. --stabs: 显示任何指定节的全部内容。显示ELF格式目标文件中的.stab、.stab.index和.stab.excl节的内容。一般用于Solaris操作 系统。在其他大部分执行文件格式中,调试符号表入口与链接符号交织在一起,并在打开了“--yms”参数选项时,在objdump的输出中是可见的。
26. --start-address = address:从指定地址开始显示数据,该选项影响打开-d、-r和-s选项时的输出。
27. --stop-address = address:显示数据直到指定地址为止,该选项影响打开-d、-r和-s选项时的输出。
28. -t/--syms:显示文件中的符号表入口。类似于“nm”程序提供的信息。
29. -T/--dynamic-syms:显示文件中的动态符号表入口。仅仅对于动态目标文件有意义,例如特定类型的共享库。类似于打开了“-D”选项的“nm”程序提供的信息。
30. --version:显示objdump的版本号然后退出。
31. -x/--all-header:显示所有可用的头信息,包括符号表和重定位入口,使用“-x”等价于指定了“-a -f -h -r -t”参数。
32. -w/--wide:对超过80列的输出设备指定一些行的格式。
对于刚才生成的test执行文件,我们执行
$objdump -f test
显示执行文件文件头概要信息。
使用“-d”或“-D”参数反汇编生成的目标代码:
$objdump -d add.o
readelf工具:
readelf软件显示一个或多个ELF格式的目标文件信息。可通过各种参数选项来控制readelf软件显示目标文件中的特定信息。
size工具: List section sizes and total size
size显示一个目标文件或者链接库文件中的目标文件的各个段的大小。
1、输出格式
size有两种输出格式,一种为"sysv",另一种为"berkeley",默认为berkeley的格式。第一种格式可以用"-A"或者"--format=sysv"指定,第二种格式用"-B"或"--format=berkeley"指定
2、数字输出格式
有三种格式,octal, decimal及hex,对应的参数为"-o",
"-d"及"-x",也可以用"--radix=8","--radix=10"及"--radix=16"指定
3、汇总多个文件的各个段合计长度
"-t" 或者"--total",合计值将在最后输出。
ar工具: Create, modify, and extract from archives
ar用来管理一种文档。这种文档中可以包含多个其他任意类别的文件。这些被包含的文件叫做这个文档的成员。ar用来向这种文档中添加、删除、解出成员。成员的原有属性(权限、属主、日期等)不会丢失。
实际上通常只有在开发中的目标连接库是这种格式的,所以尽管不是,我们基本可以认为ar是用来操作这种目标链接库(.a文件)的。
1、创建库文件
我 不知道怎么创建一个空的库文件。好在这个功能好像不是很需要。通常人们使用“ar cru liba.a a.o"这样的命令来创建一个库并把a.o添加进去。"c"关键字告诉ar需要创建一个新库文件,如果没有指定这个标志则ar会创建一个文件,同时会给出 一个提示信息,"u"用来告诉ar如果a.o比库中的同名成员要新,则用新的a.o替换原来的。但是我发现这个参数也是可有可无的,可能是不同版本的ar 行为不一样吧。实际上用"ar -r liba.a a.o"在freebsd5上面始终可以成功。
2、加入新成员
使用"ar -r liba.a b.o"即可以将b.o加入到liba.a中。默认的加入方式为append,即加在库的末尾。"r"关键字可以有三个修饰符"a", "b"和"i"。
"a"表示after,即将新成员加在指定成员之后。例如"ar -ra a.c liba.a b.c"表示将b.c加入liba.a并放在已有成员a.c之后;
"b"表示before,即将新成员加在指定成员之前。例如"ar -rb a.c liba.a b.c";
"i"表示insert,跟"b"作用相同。
3、列出库中已有成员
"ar -t liba.a"即可。如果加上"v"修饰符则会一并列出成员的日期等属性。
4、删除库中成员
"ar -d liba.a a.c"表示从库中删除a.c成员。如果库中没有这个成员ar也不会给出提示。如果需要列出被删除的成员或者成员不存在的信息,就加上"v"修饰符。
5、从库中解出成员
"ar -x liba.a b.c"
6、调整库中成员的顺序
使用"m"关键字。与"r"关键字一样,它也有3个修饰符"a","b", "i"。如果要将b.c移动到a.c之前,则使用"ar -mb a.c liba.a b.c"
第三节 链接器ld
ld软件的作用是把各种目标文件(.o文件)和库文件链接在一起,并定位数据和函数地址,最终生成执行程序。ld软件识别一种用链接命令语言 (Linker Command Language)表示的链接描述(Linker Script)文件来显式地控制链接的过程。通过BFD(Binary Format Description)库,ld可以读取和操作COFF、ELF、a.out等各种执行文件格式的目标文件。ld的一个简单使用例子如下:
$ld -o hello /lib/crt0.o hello.o -lc
它 的意思是把crt0.o、hello.o和库文件libc链接起来产生可执行程序hello。这里的crt0.o是应用程序编译连接时需要的启动文件,在 程序连接阶段被链接,主要工作是初始化应用程序栈,初始化程序的运行环境和在程序退出时清除和释放资源。libc库中包含了C程序中常用的函数实现,如 printf、gets等。
gcc可以间接地调用ld,这主要是通过给gcc传递一个参数-Wl(是“ld”的“l”,而不是“123”的“1”)来完成的,放到Wl后面的参数gcc不会处理,而是交给ld进行处理。例如:
$gcc -Wl,--startgroup foo.o bar.o -Wl--endgroup
等同于执行如下的ld命令:
$ld --startgroup foo.o bar.o --endgroup
ld具有许多选项,执行如下命令:
$ld --help
就可以列出ld常用的选项(下面的选项列表中,“,”表示“或”的意思):
链接命令语言(Linker Scripts)表示的链接描述文件显式地控制了ld的链接过程。ld命令选项-T FILE, --script FILE指定了链接描述文件名。链接描述文件描述了各个输入文件的各节如何映射到输出文件的各节中,并控制输出文件中各个节或符号的内存布局。如果不指定 链接描述文件,则ld会使用一个默认的描述文件来产生执行文件。
目标文件是由多个节(Section)构成的,如data节一般保存 了有初值的全局变量,text节保存了执行代码,bss节保存了无初值的全局变量。目标文件中的每节有名字和大小,而且节可以标识为loadable,这 表示这个节中的内容可以加载到内存中,如果节不是loadable或allocatable,则这种节可能包含调试信息。通过如下命令可以看到执行程序 test的各个节的信息:
$objdump -h test
每个有loadable或allocatable标识的输出节有两种地 址,一种地址是VMA(Virtual Memory Address),这种地址是输出文件运行时节的运行地址;另一种地址是LMA(Load Memory Address),这种地址是加载输出文件时节的加载地址。一般情况下,这两种地址是相同的,但在嵌入式系统中,经常存在运行地址和加载地址不一致的情 况,例如把输出文件加载到开发板的Flash存储器中(地址由LMA指定),但运行时,要把Flash存储器中的输出文件复制到SDRAM中运行(地址由 VMA指定)。
每个目标文件有许多符号,每个符号有一个名字和一个地址,一个符号可以是定义的(Defined)或未定义的(Undefined)。一个符号可以是函数名、全局变量、静态变量等。可通过nm命令或objdump -t命令来查看目标文件或执行文件中符号信息。
链接描述文件是一个文本文件,它主要由一系列的命令组成,每个命令可以是一个带参数的关键字或赋值语句。各个命令通过分号分隔。“/*”和“*/”之间的字符是注释。
链接描述文件的命令主要包括如下几类:
设置入口点(Entry Point)的命令(可执行程序的第一条执行指令称为入口点)
处理文件的命令
处理文件格式的命令
其它命令
下面就一些常用命令进行介绍。
ENTRY(Symbol):设置symbol的值为可执行程序的入口点。ld有多种方法设置可执行程序的入口点,它按如下的顺序来确定程序的入口点:
1) ld的命令行选项-e指定的值
2) ld的链接描述文件中的ENTRY(symbol)指定的值
3) .text节的起始地址
4) 入口点为0
INCLUDE filename:包含其他名为filename的链接描述文件
INPUT(file, file, …):指定多个输入文件名
GROUP(file, file, …):需要重复搜索符号定义的多个输入文件名
OUTPUT(filename):指定输出文件名
SEARCH_DIR(path):指定输入文件搜索路径
STARTUP(filename):指定第一个链接的输入文件名
OUTPUT_FORMAT(bfdname):指定输出文件的BFD格式
TARGET(bfdname):指定输入文件的BFD格式
简单赋值语句(或称为符号赋值命令):
symbol = expression;
symbol += expression;
symbol -= expression;
symbol *= expression;
symbol /= expression;
symbol <<= expression;
symbol >>= expression;
symbol &= expression;
symbol |= expression;
PROVIDE(symbol=expression):定义了一个变量symbol,且它的值为expression,这个变量可以被输入文件引用。
MEMORY命令:MEMORY命令在用于嵌入式系统的链接描述文件中经常出现,它描述了各个内存块的起始地址和大小,它的格式如下:
MEMORY
{
name[(attr)]:ORIGIN = origin,LENGTH = len
。。。
}
name可以理解为描述的内存块名字(region)。len表示这个内存块的大小。attr表示内存块的属性,它的值可以是:
R:只读
W:可读/写
X:可执行
A:可分配
I:用于初始化的内存,初始化后,可以回收为空闲内存
!:表示非,和上述各个值配合使用。
SECTIONS命令告诉ld如何把输入文件的各个输出节映射到输出文件的各个输入节中,SECTIONS命令的格式如下:
SECTIONS
{
sections-command
sections-command
。。。
}
每个SECTIONS相关的命令可以是如下命令:
ENTRY命令
符号赋值(Symbol Assignment)命令
输出文件节描述(Output Section Description)
覆盖描述(Overlay Description)
输出文件节描述的格式如下:
section [address][(type)] : [AT(lma)]
{
output-section-command
output-secton-command
。。。
}[>region][AT>lma_region][:phdr :phdr …][=fillexp]
上述“[”和“]”之间的属性是可选的,一般可以不用。常用属性介绍如下:
1、address:指定输出节的起始地址
2、type:每个输出节可以有一个属性type,属性的格式如下:
(type_keyword)
type_keyword可以是如下值:
NOLOAD:表示这个节在输出文件运行时是不加载到内存中的。(Not loadable)
DESCT、COPY、INFO、OVERLAY:这几个值的意思都是一样的,都表示这个节在输出文件运行时是不分配内存的(Not Allocatable)
3、>region:表示输出节的起始地址和大小由MEMORY命令中的region定义指定。下面是一个简单的例子:
MEMORY{rom:ORIGIN=0x1000,LENGTH=0x1000}
SECTION{ROM:{*(.text)} > rom}
输出文件节描述可以包括如下命令:
符号赋值(symbol assignment)命令
输入文件节描述
输出节数据值定义
输出节关键字(一般很少用到)
输出文件节的名字一般包括.text、.data、.bss等常见的节名字,也可以定义其他一些特定的名字。如果输出节的名字是“/DISCARD/”,它表示所有映射到这个输出节的输入节都不会在输出文件中存在。输入文件描述的格式如下:
filename(Sectionname)
filename可以由通配符“*”表示,即表示所有的输入文件,也可以是多个或单个输入文件的名字。Sectionname是输入文件中输入节的名字。下面是一些例子:
data.o(.data)
*(.text)
*(.init)
链接描述文件例子
链接描述文件一般都比较简单,可能只包含一个链接命令:SECTIONS。假设ld要输出的一个执行程序包含初始化数据 的.data节,未初始化数据的.bss节和包含代码的.text节,而且ld处理的输入目标文件也只包含这3个节,则这个简单的链接描述文件例子如下所 示:
SECTIONS
{
.=0x1000000;
.text:{*(.text)}
.=0x8000000;
.data:{*(.data)}
.bss:{*(.bss)}
}
第一行是关键字SECTIONS,紧接着是一系列的符号赋值,而输出文件中的节描述包含在“(”和“)”中。
大 括号中的第一条语句是一个赋值语句,它表示起始的加载地址为0x1000000,这里“.”表示当前位置的地址。第二条语句定义了输出文件的.text节 包含的内容,*(.text)表示把所有输入文件中的.text收集起来,构成输出文件的.text内容。“*”表示ld命令行上描述的所有输入文件。第 三条语句是一个地址赋值语句,它定义了输入文件接下来的.data节的起始地址为0x8000000。而输入文件的.bss节的起始地址为 0x8000000加上所有输入文件的.data节的大小总和。
下面是用于描述用于某个嵌入式设备的uClinux内核布局的链接描述文件(“//”后的语句是附加的注释,在实际的文件中并不存在)
//标注嵌入式设备中各个内存块的地址划分情况
MEMORY
{
//表示flash中断向量表的起始地址为0x01000000,长度为0x00400
romvec:ORIGIN=0x01000000,LENGTH=0x00400
//标注flash的起始地址为0x01000400,长度为0x011fffff - 0x01000400
flash:ORIGIN=0x01000400,LENGTH=0x011fffff - 0x01000400
//标注flash的结束的位置在0x011fffff
eflash:ORIGIN=0x011fffff,LENGTH=1
//标注ram中中断向量表的起始地址为0x00000000,长度为1024
ramvec:ORIGIN=0x00000000,LENGTH=1024
//标注ram其他的可用的内存,长度为4M-1K
ram:ORIGIN=0x00000400,LENGTH=0x003fffff - 0x00000400
//标注ram内存的结束位置
eram:ORIGIN=0x003fffff,LENGTH=1
}
SECTIONS
{
//定义输出文件的.ramvec节
.ramvec:
{
//设定一个变量_ramvec来代表当前的位置,即ramvec节的开始处
_ramvec=.;
}>ramvec
//把该节定义到MEMORY中定义的ramvec所代表内存块中,即从0x00000000开始的1024
//字节
//定义输出文件的.data节
.data:
{
//设定一个变量_data_start来代表当前的位置,即data节的开始处
_data_start=.;
//把所有输入文件中的.data节的数据放在此处
*(.data)
//定义edata变量的值为.data节的结束地址
edata=.;
//把edata按16位对齐
edata=ALIGN(0x10);
}>ram
//把.data节的内容放到ram定义的MEMORY中。
//定义未初始化数据的.bss节
.bss
{
//记录该节的起始位置
_bss_start=ALIGN(0x10);
//记录.bss节的结束位置
_data_end=ALIGN(0x10);
//把所有.bss数据放在此处
*(.bss)
//把所有COMMON的数据放在此处
*(COMMON)
//记录.bss节结束的位置,16位对齐
end=ALIGN(0x10);
//记录.bss节结束的位置,16位对齐
_end=ALIGN(0x10);
}>ram
//把.bss节的内容放到MEMORY中定义的ram所代表的内存块中
//定义输出文件中的.eram节
.eram:
{
//记录该节结束的位置
_ramend=.;
}eram
//把.eram节的内容放到MEMORY中定义的eram所代表的内存块中
//定义输出文件的.romvec节,用于记录flash中中断向量表
.romvec:
{
//记录该节的位置
_romvec=.;
}>romvec
//将该节内容放到MEMORY中定义的romvec中
//定义输出文件的.text节,即程序段
.text:
{
//记录.text的起始位置
text_start=.;
//将所有输入文件的.text节的内容放到这里
*(.text)
//记录.text节结束的位置
_etext=.;
//标志.text节中的数据结束的位置
_data_rom_start=ALIGN(4);
}>flash
//把.text节的内容放到MEMORY中定义的flash表示的内存块中
//定义flash结束的节
.eflash:
{
//记录当前的位置
_flashend=.;
}>eflash
//记录该节放入MEMORY中的位置
}
//描述文件结束
第四节 链接
链接就是将不同部分的代码和数据收集和组合成一个单一文件的过程,这个文件可以被加载到存储器并执行。链接可以执行于编译时,也就是在源代码被翻 译成机器代码时;也可以执行于加载时,也就是程序被加载器加载到存储器并执行时;甚至执行于运行时,由应用程序来执行。在早期的计算机系统中,链接是手动 执行的,在现代系统中,链接是由叫做链接器的程序自动执行的。
链接器在软件开发中扮演一个重要角色,因为它使得分离编译成为可能。我们不用将一 个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模块中的一个时,我们 只要简单地重新编译它,并将它重新链接到应用上,而不必重新编译其他文件。
为什么要学习链接和知识呢?
理解链接器将帮助你构造大型程序。构造大型程序的程序员常会遇到由于缺少模块、缺少库或者不兼容的库版本引起的链接器错误。除非你理解链接器是如何解析引用、什么是库以及链接器是如何使用库来解析引用的,否则这类错误将令你感到迷惑和挫败。
理 解链接器将帮助你避免一些危险的编程错误。Unix链接器解析符号引用时所做的决定可以不动声色地影响你的程序的正确性。在默认情况下,错误地定义多个全 局变量的程序将通过链接器,而不产生任何警告信息,由此得到的程序会产生令人迷惑的运行时行为,而且非常难调试。我们将向你展示这是如何发生的,以及该如 何避免它。
理解链接将帮助你理解语言的作用域规则是如何实现的。例如,全局和局部变量之间的区别是什么?当你定义一个具有静态属性的变量或者函数时,到底实际意味着什么?
理解链接将帮助你理解其他重要的系统概念。链接器产生的可执行目标文件在重要的系统功能中扮演着关键角色,比如加载和运行程序、虚拟存储器、分页和存储器映射。
理 解链接将使你能够开发共享库。多年来,链接都被认为是相当简单和无趣的。然而,随着共享库和动态链接在现代操作系统中日益加强的重要性,链接成为了一个复 杂的过程,它为知识丰富的程序员提供了强大的能力。比如,许多软件产品使用共享库在运行时来升级压缩封装的二进制程序。还有,大多数Web服务器都依赖于 共享库的动态链接来提供动态内容。
1、编译器驱动程序
考虑下面的C程序,它包括两个源文件:main.c和swap.c。函数main()调用swap(),它交换外部全局数组buf中的两个元素。一般认为,这是一种奇怪的交换数字的方式,但它将作为一个示例,帮助我们说明关于链接是如何工作的一些重要知识点。
/* main.c */
void swap();
int buf[2] = {1, 2};
int main()
{
swap();
return 0;
}
/*swap.c*/
extern int buf[];
int bufp0 = &buf[0];
int *bufp1;
void swap()
{
int temp;
bufp1 = &buf[1];
temp = *bufp0;
*bufp0 = *bufp1;
*bufp1 = temp;
}
大多数编译系统提供编译驱动程序(compiler driver),它为用户,根据需求调用语言预处理器、编译器、汇编器和链接器。比如,要用GNU编译系统构造示例程序,我们就需要通过在shell中输入下面的命令行来调用GCC驱动程序:
# gcc -O2 -g -o p main.c swap.c
驱动程序首先运行C预处理器(cpp),它将C源程序main.c翻译成一个ASCII码的中间文件main.i:
cpp [other arguments] main.c /tmp/main.i
接下来,驱动程序运行C编译器(cc1),它将main.i翻译成一个ASCII汇编语言文件为main.s:
cc1 /tmp/main.i main.c -O2 [other arguments] -o /tmp/main.s
然后,驱动程序运行汇编器(as),它将main.s翻译成一个可重定位目标文件(relocatable object file)main.o:
as [other arguments] -o /tmp/main.o /tmp/main.s
驱动程序经过相同的过程生成swap.o。最后,它运行链接器程序ld,将main.o和swap.o以及一些必要的系统目标文件组合起来,创建一个可执行的目标文件p:
ld -o p [system object files and args] /tmp/main.o /tmp/swap.o
要运行可执行文件p,我们在命令行上输入它的名字
# ./p
shell调用一个在操作系统中叫做加载器(loader)的函数,它拷贝可执行文件p中的代码和数据到寄存器,然后将控制转移到这个程序的开头。
2、静态链接
像Unix ld程序这样的静态链接器以一组可重定位目标文件和命令行参数作为输入,生成一个完全链接的可以加载和运行的可执行目标文件做为输出。输入的可重定位目标 文件由各种不同的代码和数据节(section)组成,指令在一个节中,初始化的全局变量在另一个节中,而未初始化的变量又在另一个节中。
为了创建可执行文件,链接器必须完成两个主要任务:
符号解析(symbol resolution)。目标文件定义和引用符号。符号解析的目的是将每个符号引用和一个符号定义联系起来。
重定位(relocation)。编译器和汇编器生成从地址零开始的代码和数据节。链接器通过把每个符号定义与一个存储器位置联系起来,然后修改所有对这些符号的引用,使得它们指向这个存储器位置,从而重定位这些节。
接下来的内容将更详细地描述这些任务。要记住关于链接器的一些基本事实:目标文件纯粹是字节块的集合。在这些块中,有些包含程序代码,有些则包含程 序数据,而其他的则包含指导链接器和加载器的数据结构。链接器将这些块链接起来,确定被链接块的运行时位置,并修改代码和数据块中的各种位置。链接器对目 标机器了解甚少,产生目标文件的编译器和汇编器已经完成了大部分工作。
3、目标文件
目标文件有三种形式:
可重定位目标文件。包含二进制代码和数据,其形式可以在编译时与其他可重定位目标文件合并起来,创建一个可执行目标文件。
可执行目标文件。包含二进制代码和数据,其形式可以被直接拷贝到存储器并执行。
共享目标文件。一种特殊类型的可重定位目标文件,可以在加载或运行时,被动态地加载到存储器并链接。
编译器和汇编器生成可重定位目标文件(包括共享目标文件),链接器生成可执行目标文件。
各个系统之间,目标文件格式都不相同。第一个从 贝尔实验室诞生的Unix系统使用的是a.out格式。System V Unix的早期版本使用的是COFF(Common Object File format,一般目标文件格式)。Windows使用的是COFF的一个变种,叫做PE(Portable Executable)格式。现代Unix系统,比如Linux,还有System V Unix后来的版本,各种BSD Unix,以及SUN Solaris,使用的是Unix ELF(Executable and Linkable Format)。尽管我们讨论集中在ELF上,但是不管是哪种格式,基本的概念是相似的。
4、可重位目标文件
下图展示了一个典型的ELF可重定位目标文件。
ELF头
.text
.rodata
.data
.bss
.symtab
.rel.text
.rel.data
.debug
.line
.strtab
节头部表
ELF头以一个16字节的序列开始,这个序列描述了字的大小和生成该文件的系统的字节顺序。ELF头剩下的部分包含帮助链接器解析和解释目标文件的 信息。其中包括ELF头的大小、目标文件的类型(比如,可重定位、可执行或可共享的)、机器类型(比如,IA32)、节头部表(section header table)的文件偏移,以及节头部表中的表目大小和数量。不同节的位置和大小是由节头部表描述的,其中目标文件中每个节都有一个固定大小的表目 (entry)。
夹在ELF头和节头部表之间的都是节。一个典型的ELF可重定位目标文件包括下面几个节:
.text:已编译程序的机器代码。
.rodata:只读数据,比如printf语句中的格式串和开关(switch)语句的跳转表。
.data:已经初始化的全局C变量。局部C变量在运行时被保存在栈中,既不出现在.data节中,也不出现在.bss节中。
.bss:未初始化的全局C变量。在目标文件中这个节不占据实际的空间,它仅仅是一个占位符。目标文件区分初始化和未初始化变量是为了空间效率:在目标文件中,未初始化变量不需要占据任何实际的磁盘空间。
.symtab: 一个符号表(symbol table),它存放在程序中被定义和引用的函数和全局变量的信息。一些程序员错误地认为必须通过-g选项来编译一个程序,得到符号表信息。实际上,每个 可重定位目标文件在.symtab中都有一张符号表。然而,和编译器中的符号表不同,.symtab符号表不包含局部变量的表目。
.rel.text: 当链接器把这个目标文件和其他文件结合时,.text节中的许多位置都需要修改。一般而言,任何调用外部函数或者引用全局变量的指令都需要修改。另一方 面,调用本地函数的指令则不需要修改。注意,可执行目标文件中并不需要重定位信息,因此通常省略,除非使用者显式地指示链接器包含这些信息。
.rel.data:被模块定义或引用的任何全局变量的信息。一般而言,任何已初始化全局变量的初始值是全局变量或外部定义函数的地址都需要被修改。
.debug:一个调试符号表,其有些表目是程序中定义的局部变量和类型定义,有些表目是程序中定义和引用的全局变量,有些是原始的C源文件。只有以-g选项调用编译驱动程序时,才会得到这张表。
.line:原始C源程序中的行号和.text节中机器指令之间的映射。只有以-g选项调用编译驱动程序时,才会得到这张表。
.strtab:一个字符串表,其内容包括.symtab和.debug节中的符号表,以及节头部中的节名字。字符串表就是以null结尾的字符串序列。
为什么未初始化数据节称为.bss?
它起始于IBM 704汇编语言中“块存储开始(Block Storage Start)”指令的首字母缩写,并沿用至今。一个记住区分.data和.bss节的简单方法是把“bss”看成“更好地节省空间(Better Save Space)”的缩写。
5、符号和符号表
每个可重定位目标模块m都有一个符号表,它包含m所定义和引用的符号的信息。在链接器的上下文中,有三种不同的符号:
由m定义并能被其它模块引用的全局符号。全局链接器符号对应于非静态的C函数以及被定义为不带C的static属性的全局变量。
由其它模块定义并被模块m引用的全局符号。这些符号称为外部符号(external),对应于定义在其他模块中的C函数和变量。
只被模块m定义和引用的本地符号。有的本地链接器符号对应于带static属性的C函数和全局变量。这些符号在模块m中的任何地方都是可见的,但是不能被其他模块引用。目标文件中对应于模块m的节和相应的源文件的名字也能获得本地符号。
认识到本地链接器符号和本地程序变量的不同是很重要的。.symtab中的符号表不包括对应于本地非静态程序变量的任何符号。这些符号在运行时在栈中被管理,链接器对此类符号不感兴趣。
有趣的是,定义为带有C static属性的本地过程变量是不在栈中管理的。取而代之,编译器在.data和.bss中为每个定义分配空间,并在符号表中创建一个有惟一名字的本地链接器符号。比如,假设在同一模块中的两个函数分别定义了一个静态本地变量x:
int f()
{
static int x = 0;
return x;
}
int g()
{
static int x = 1;
return x;
}
在这种情况下,编译器在.bss中为两个整数分配空间,并引出(export)两个惟一的本地链接器符号给汇编器。比如,它可以用x.1表示函数f中定义的x,而用x.2表示函数g中定义的x。
符号表是由汇编器构造的,使用编译器输出到汇编语言.s文件中的符号。.symtab节中包含ELF符号表。这张符号表包含一个关于表目的数组。下面是每个表目(entry)的格式:
typedef struct{
int name; /*string table offset*/
int value; /*section offset, or VM address*/
int size; /*object size in bytes*/
char type:4, /*data, func, section, or src file name(4 bits)*/
binding:4; /*local or global(4 bits)*/
char reserved; /*unused*/
char section; /*section header index, ABS, UNDEF, or COMMON*/
}Elf_Symbol;
name是字符串表中的字节偏移量,指向符号的名字,该名字是以null结尾的字符串名字。
value是符号的地址。对于可重定位的模块来说,value是距定义目标的节的起始位置的偏移。对于可执行目标文件来说,该值是一个绝对运行时地址。
size是目标的大小(以字节计算)。
type通常要么是数据,要么是函数。符号表还可以包含各个节的表目,以及对应原始源文件的路径名的表目。所以这些目标的类型也有些不同。
binding域表示符号是本地的还是全局的。
每 个符号都与目标文件的某个节相关联,由section域表示,该域也是一个到节头表的索引。有三个特殊的伪节,它们在节头表中是没有表目的:ABS 代表 不该被重定位的符号,UNDEF代表未定义的符号(比如,在本目标模块中引用,但是却在其他地方定义的符号),而COMMON表示还未被分配位置的未初始 化的数据目标。对于COMMON符号,value域给出对齐请求,而size给出最小的大小。
6、符号解析
链接器解析符号引用的方法是将每个引用与它输入的可重定位目标文件的符号表中一个确定的符号定义联系起来。对于那些引用定义在相同模块中的本地符 号的引用,符号解析是非常简单明了的。编译器只允许每个模块中的每个本地符号只有一个定义。编译器确保静态本地变量,它们也会有本地链接器符号,拥有惟一 的名字。
不过,对全局符号的引用解析就棘手得多。当编译器遇到一个不是在当前模块中定义的符号(变量或函数名)时,它会假设该符号是在其他某个 模块中定义的,生成一个链接器符号表表目,并把它交给链接器处理。如果链接器在它的任何输入模块中都找不到这个被引用的符号,它就输出一条(通常很难阅 读)错误信息并终止。
对全局符号的解析很棘手,还因为相同的符号会被多个目标文件定义,在这种情况中,链接器必须要么标志一个错误,要么以某种 方式选出一个定义并抛弃其他定义。Unix系统采纳的方法包括编译器、汇编器和链接器之间的协作,这样也可能给不知情的程序员带来一些令人烦恼的问题。
链接器如何解析多处定义的全局符号呢?
在编译时,编译器输出每个全局符号给汇编器,或者是强(strong),或者是弱(weak),而汇编器把这个信息隐含地编码在可重定位目标文件的符号表里。函数和已初始化的全局变量是强符号,未初始化的全局变量是弱符号。
根据强弱符号的定义,Unix链接器使用下面的规则来处理多处定义的符号:
规则1:不允许有多个强符号
规则2:如果有一个强符号和多个弱符号,那么选择强符号。
规则3:如果有多个弱符号,那么从这些弱符号中任意选择一个。
与静态库链接
迄今为止,我们都假设链接器读取一组可重定位目标文件,并把它们链接起来,成为一个输出的可执行文件。实际上,所有的编译系统都提供一种机制,将 所有相关的目标模块打包为一个单独的文件,称为静态库,它也可以做链接器的输入。当链接器构造一个输出的可执行文件时,它只拷贝静态库里被应用程序引用的 目标模块。
在Unix系统中,静态库以一种称为存档(archive)的特殊文件格式存放在磁盘中,存档文件是一组连接起来的可重定位目标文件的集合,有一个头部描述每个成员目标文件的大小和位置。存档文件名由后缀.a标识。
虽然静态库是很有用而且很重要的工具,但是它们同时也是程序员迷惑的源头,因为Unix链接器使用它们解析外部引用的方式令人困惑。在符号解析阶 段,链接器从左到右按照它们在编译器驱动程序命令行上出现的相同顺序来扫描可重定位目标文件和存档文件。在这次扫描中,链接器维持一个可重定位目标文件的 集合E,这个集合中的文件会被合并起来形成可执行文件,和一个未解析的符号(也就是引用了但没有定义的符号)集合U,以及一个在前面输入文件中已定义的符 号集合D。初始地,E、U、D都是空的。
对于命令行上的每个输入文件f,链接器会判断f是一个目标文件还是一个存档文件。如果f是一个目标文件,那么链接器把f添加到E,修改U和D来反映f中符号定义和引用,并继续下一个输入文件。
如 果f是一个存档文件,那么链接器就尝试匹配U中未解析的符号和由存档文件成员定义的符号。如果某个存档文件成员m,定义了一个符号来解析U中的一个引用, 那么就将m加到E中,并且链接器修改U和D来反映m中的符号定义和引用。对存档文件中所有成员目标文件都反复进行这个过程,直到U和D都不再发生变化。在 此时,任何不包含在E中的成员目标文件都被丢弃,而链接器将继续到下一个输入文件。
如果当链接器完成对命令行上输入文件的扫描后,U是非空的,那么链接器就会输出一个错误并终止。否则,它会合并和重定位E中的目标文件,从而构建输出的可执行文件。
不幸的是,这种算法会导致一些令人困惑的链接时错误,因为命令行上的库和目标文件的顺序非常重要。如果在命令行中,定义一个符号的库出现在引用这个符号的目标文件之前,那么引用就不能被解析,链接会失败。
7、重定位
一旦链接器完成了符号解析这一步,它就把代码中的每个引用和确定的一个符号定义(也就是,它的一个输入目标模块中的一个符号表表目)联系起来了。 在此时,链接器就知道它的输入目标模块中的代码节和数据节的确切大小。现在就可以开始重定位步骤了。在这个步骤里,将合并输入模块,并为每个符号分配运行 时地址。重定位由两步组成:
重定位节和符号定义。在这一步中,链接器将所有相同类型的节合并为同一类型的新的聚合节。例如,来自输入模块 的.data节被全部合并成一个节,这个节成为输出的可执行目标文件的.data节。然后,链接器将运行时存储器地址赋给新的聚合节,赋给输入模块定义的 每个节,以及赋给输入模块定义的每个符号。当这一步完成时,程序中的每个指令和全局变量都有惟一的运行时存储器地址了。
重定位节中的符号引用。在这一步中,链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。为了执行这一步,链接器依赖于称为重定位表目(relocation entry)的可重定位目标模块中的数据结构,我们接下来将会描述这种数据结构。
重定位表目
当汇编器生成一个目标模块时,它并不知道数据和代码最终将放在存储器的什么位置。它也不知道这个模块引用的任何外部定义的函数或者全局变量的位 置。所以,无论何时汇编器遇到对最终位置未知的目标引用,它就会生成一个重定位表目(relocation entry),告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。代码的重定位表目放在.rel.text中。已初始化数据的重定位表目放 在.rel.data中。下面是ELF重定位表目的格式。
typedef struct{
int offset; /*offset of the reference to relocate*/
int symbol:24, /*symbol the reference should point to*/
type:8; /*relocation type*/
}Elf32_Rel;
offset是需要被修改的引用的节偏移。symbol标识被修改引用应该指向的符号。type告诉链接器如何修改新的引用。
ELF定义了11种不同的重定位类型,有些相当隐秘。我们只关心其中两种最基本的重定位类型:
R_386_PC32: 重定位一个使用32位PC相关的地址引用。一个PC相关地址就是距程序计数器(PC)的当前运行时值的偏移量。当CPU执行使用PC相关寻址的指令时,它 就将在指令中编码的32位值加上PC的当前运行时值,得到有效地址(例如,call指令的目标),PC值通常是存储器中下一条指令的地址。
R_386_32:重定位一个使用32位绝对地址的引用。通过绝对地址,CPU直接使用在指令中编码的32位值作为有效地址,不需要进一步修改。
8、可执行目标文件
我们已经看到链接器是如何将多个目标模块合并成一个可执行目标文件的。我们的C程序,开始是二组ASCII文本文件,已经被转化为一个二进制文件,且这个二进制文件包含加载程序到存储器并运行它所需要的所有信息。下图是一个典型的ELF可执行文件中的种类信息。
ELF头部
段头表
.init
.text
.rodata
.data
.bss
.symtab
.debug
.line
.strtab
节头表
可执行目标文件的格式类似于可重定位目标文件格式。ELF头部描述文件的总体格式,它还包括程序的入口点(entry point),也就是当程序运行时要执行的第一条指令的地址。.text、.rodata和.data节和可重定位目标文件中的节是相似的,除了这些节已 经被重定位到它们最终的运行时存储器地址以外。.init节定义了一个小函数,叫做init,程序的初始化代码会调用它。因为可执行文件是完全链接的(已 经被重定位了),所以它不再需要.rel节。
ELF可执行文件被设计为很容易加载到存储器,连续的可执行文件的组块(chunks)被映射到连续的存储器段。段表头(segment header table)描述了这种映射关系。
9、加载可执行目标文件
要运行可执行目标文件p,我们可以在命令行中输入它的名字:#./p,因为p不是一个内置的shell命令,所以shell会认为p是一个可执行 目标文件,通过调用某个驻留在存储器中称为加载器的操作系统代码来为我们运行之。任何Unix程序都可以通过调用execve函数来调用加载器。加载器将 可执行目标文件中的代码和数据从磁盘拷贝到存储器中,然后通过跳转到程序的第一条指令,即入口点,来运行该程序。这个将程序拷贝到存储器中并运行的过程叫 做加载(loading)。
每个Unix程序都有一个运行时存储器映像,如下图所示:
在Linux系统中,代码段总是从地 址0x08048000处开始。数据段是在接下来的下一个4KB对齐的地址处。运行时堆在接下来的读写段之后的第一个4KB对齐的地址处,并通过 malloc库往上增长。开始于地址0x40000000处的段是为共享库保留的。用户栈总是从地址0xbfffffff处开始,并向下增长(向低存储器 地址方向增长)。从栈的上部开始于地址0xc0000000处的段是为操作系统驻留存储器的部分(也就是内核)的代码和数据保留的。
当加载器运 行时,它创建如上图所示的存储器映像。在可执行文件中段头表的指导下,加载器将可执行文件的相关内容拷贝到代码和数据段。接下来,加载器跳转到程序的入口 点,就是符号_start的地址。在_start地址处的启动代码(startup code)是在目标文件ctrl.o中定义的,对所有的C程序都是一样的。下面的代码展示了在每个C程序中ctrl.o启动例程的伪代码,注意:没有显示 将每个函数的参数压入栈中的代码。:
0x080480c0 <_start>:
call _libc_init_first /* entry point in .text*/
call _init /* startup code in .text */
call atexit /* startup code in .init */
call main /* startup code in .text */
call _exit /* returns control to OS */
/* control never reaches here */
在从.text和.init节中调用了初始化例程后,启动代码调用atexit例程,这个程序附加了一系列在应用调用exit函数时应该调用的程 序。exit函数运行atexit注册的函数,然后通过调用_exit将控制返回给操作系统。接着,启动代码调用应用程序的main程序,这就开始执行我 们的C代码了。在应用程序返回之后,启动代码调用_exit程序,它将控制返回给操作系统。
加载器是如何工作的呢?
Unix系统中的 每个程序都运行在一个进程上下文中,这个进程上下文有自己的虚拟地址空间。当shell运行一个程序时,父shell进程生成一个子进程,它是父进程的一 个复制品。子进程通过execve系统调用启动加载器。加载器删除子进程已有的虚拟存储器段,并创建一组新的代码、数据、堆和栈段。新的栈和堆段被初始化 为0。通过将虚拟地址空间中的页映射到可执行文件的页大小的组块,新的代码和数据段被初始化为可执行文件的内容。最后,加载器跳转到_start地址,它 最终会调用应用程序的main函数。除了一些头部信息,在加载过程中没有任何从磁盘到存储器的数据拷贝。直到CPU引用一个被映射的虚拟页,才会进行拷 贝,此时,操作系统利用它的页面调度机制自动将页面从磁盘传送到存储器。
10、动态链接共享库
静态库有一此明显的缺点。静态库和所有的软件一样,需要定期维护更新。如果一个应用程序员想使用一个库的最新版本,他们必须以某种方式了解到该库的更新情况,然后显式地将他们的程序与新的库重新链接。
另一个问题是几乎每个C程序都使用标准I/O函数,比如printf和scanf。在运行时,这些函数代码会被复制到每个运行进程的文本段中。在一个运行50-100个进程的典型系统上,这会是对存储资源的极大浪费。
共享库是致力于解决静态库缺陷的一个现代创新产物。共享库是一个目标模块,在运行时,可以加载到任意的存储器地址,并在存储器中和一个程序链接起来。这个过程称为动态链接,是由一个叫动态链接器的程序来执行的。
共享库也称为共享目标,在Unix系统中通常用.so后缀来表示。微软的操作系统大量地利用了共享库,它们称为DLL(动态链接库)。
共 享库的“共享”在两个方面有所不同。首先, 在任何给定的文件系统中,对于一个库只有一个.so文件。所有引用该库的可执行目标文件共享这个.so文件中的代码和数据,而不是像静态库的内容那样被拷 贝和嵌入到引用它们的可执行的文件中。其次,在存储器中,一个共享库的.text节只有一个副本可以被不同的正在运行的进程共享。下图给出了一个动态链接 过程的例子。为了构造共享库libvector.so,我们会调用编译器,给链接器如下特殊指令:
unix > gcc -shared -fPIC -o libvector.so addvec.c multvec.c
-fPIC选项指示编译器生成与位置无关的代码(下面将讨论这个问题)。-shared选项指示链接器创建一个共享的目标文件。
一旦我们创建了这个库,我们随后就要将它链接到程序中。
unix > gcc -o p2 main2.c ./libvector.so
这样就创建了一人可执行目标文件p2,而此文件的形式使得它在运行时可以和libvector.so链接。基本的思路是当创建可执行文件时,静态执行一些链接,然后在程序加载时,动态完成链接过程。
认识到这一点是很重要的:在此时刻,没有任何libvector.so的代码和数据节被真的拷贝到可执行文件p2中。取而代之的是,链接器拷贝了一些重定位和符号表信息,它们使得运行时可以解析对libvector.so中代码和数据的引用。
当 加载器加载和运行可执行文件p2时,它利用了前面一节讨论过的技术,加载部分链接的可执行文件p2,接着,它注意到p2包含一个.interp节,这个节 包含动态链接器的路径名,动态链接器本身就是一个共享目标(比如,在Linux系统上的LD-LINUX.SO)。加载器不再像它通常那样将控制传递给应 用,取而代这的是加载和运行这个动态链接器。
然后,动态链接器通过执行下面的重定位完成链接任务:
重定位libc.so的文本和数据到某个存储器段,在IA32/Linux系统中,共享库被加载到从地址0x40000000开始的区域中。
重定位libvector.so的文本和数据到另一个存储器段。
重定位p2中所有对由libc.so和libvector.so定义的符号的引用。
最后,动态链接器将控制传递给应用程序,从这个时刻开始,共享库的位置就固定了,并且在程序执行的过程中都不会改变。
11、从应用程序中加载和链接共享库
到此刻为止,我们已经讨论了在应用程序执行之前,即应用程序被加载时,动态链接器加载和链接共享库的情景。然而,应用程序还可能在它运行时要求动态链接器加载和链接任意的共享库,而无需在编译时链接那些库到应用中。
动态链接是一项强大有用的技术。下面是一些现实世界中的例子:
分发软件。微软Windows应用的开发者常常利用共享库来分发软件更新。他们生成一个共享库的新版本,然后用户可以下载,并用它替代当前的版本。下一次他们运行应用程序时,应用将自动链接和加载新的共享库。
构 建高性能Web服务器。许多Web服务器生成动态内容,比如个性化的Web页面、账户余额和广告标语。早期的Web服务器通过使用fork和execve 创建一个子进程,并在该子进程的上下文中运行CGI程序,来生成动态内容。然而,现代高性能的Web服务器可以使用基于动态链接的更有效和完善的方法来生 成动态内容。其思路是将生成动态内容的每个函数打包在共享库中。当一个来自Web浏览器的请求到达时,服务器动态地加载和链接适当的函数,然后直接调用 它,而不是使用fork和execve在子进程的上下文中运行函数。函数会一直缓存在服务器的地址空间中,所以只要一个简单的函数调用就可以处理随后的请 求了。这对一个繁忙的网站来说是有很大影响的。更进一步,可以在运行时,无需停止服务器,更新已存在的函数,以及添加新的函数。
像Linux和Solaris这样的Unix系统,为动态链接器提供了一个简单的接口,允许应用程序在运行时加载和链接共享库。
#include <dlfcn.h>
void *dlopen(const char *filename, int flag);
若成功则为指向句柄的指针,若出错则为NULL。
dlopen 函数加载和链接共享库filename。用以前带RTLD_GLOBAL选项打开的库解析filename中的外部符号。如果当前可执行文件是带 -rdynamic选项编译的,那么对符号解析而言,它的全局符号也是可用的。flag参数必须要么包括RTLD_NOW,该标志告诉链接器立即解析对外 部符号的引用,要么包括RTLD_LAZY标志,该标志指示链接器推迟符号解析直到执行来自库中的代码时。这两个值中的任意一个都可以和 RTLD_GLOBAL标志取或。
#include <dlfcn.h>
void *dlsym(void *handle, char *symbol);
若成功返回指向符号的指针,若出错则为NULL。
dlsym函数的输入是一个指向前面已经打开共享库的句柄和一个符号名字,如果该符号存在,就返回符号的地址,否则返回NULL。
#include <dlfcn.h>
int dlclose(void *handle);
若成功返回0,若出错返回1。
如果没有其他共享库还在使用这人共享库,dlclose函数就卸载该共享库。
#include <dlfcn.h>
const char *dlerror(void);
如果前面对dlopen、dlsym或dlclose的调用失败,则为错误消息,如果前面的调用成功,则为NULL。
dlerror函数返回一个字符串,它描述的是调用dlopen、dlsym或者dlclose函数时发生的最近的错误,如果没有错误发生,就返回NULL。
下面的程序展示了我们如何利用这个接口动态链接我们的libvector.so共享库,然后调用它的addvec程序。要编译这个程序,我们将以下面的方式调用GCC:
unix > gcc -rdynamic -O2 -o p3 main3.c -ldl
/* main3.c */
#include <stdio.h>
#include <dlfcn.h>
int x[2] = {1, 2};
int y[2] = {3, 4};
int z[2];
int main()
{
void *handle;
void (*addvec)(int *, int *, int *, int);
void *error;
/*dynamically load the shared library that contains addvec() */
handle = dlopen(“./libvector.so”, RTLD_LAZY);
if(!handle)
{
fprintf(stderr, “%s/n”, dlerror());
exit(1);
}
/*get a pointer to the addvec() function we just loaded */
addvec = dlsym(handle, “addvec”);
if((error = dlerror()) != NULL)
{
fprintf(stderr, “%s/n”, error);
exit(1);
}
/*Now we can call addvec() just like any other function */
addvec(x, y, z, 2);
printf(“z = [%d %d]/n”, z[0], z[1]);
/*unload the shared library */
if(dlclose(handle) < 0)
{
fprintf(stderr, “%s/n”, dlerror());
exit(1);
}
return 0;
}
12、与位置无关的代码(PIC)
共享库的一个主要目的就是允许多个正在运行的进程共享存储器中相同的库代码,因而节约宝贵的存储器资源。那么,多个进程是如何共享一个程序的一个 拷贝的呢?一种方法是给每个共享库分配一个事先预备的专用的地址空间组块(chunk),然后要求加载器总是在这个地址加载共享库。虽然这种方法很简单, 但是它也造成了一些严重的问题。首先,它对地址空间的使用效率不高,因为即使一个进程不使用这个库,那部分空间还是会被分配出来。第二,它也难以管理。我 们将不得不保证没有组块会重叠。每次当一个库修改了之后,我们必须确认它的已分配的组块还适合它的大小。如果不适合了,我们必须找一个新的组块。并且,如 果我们创建了一个新的库,我们还必须为它寻找空间。随着时间的进展,假设在一个系统中有了成百个库和各种版本的库,就很难避免地址空间分裂成大量小的、未 使用而又不再能使用的小洞。甚至更糟的是,对每个系统而言,从库到存储器内分配都是不同的,这就引起了更多令人头痛的管理问题。
一种更好的方法是编译库代码,使得不需要链接器修改库代码,就可以在任何地址加载和执行这些代码,这样的代码叫与位置无关的代码(position-independent code, PIC)。用户对GCC使用-fPIC选项指示GNU编译系统生成PIC代码。
在一个IA32系统中,对同一个目标模块中过程的调用是不需要特殊处理的,因为引用是PC相关的,已知偏移量,就已经是PIC了。然而,对外部定义的过程调用和对全局变量的引用通常不是PIC,因为它们都要求在链接时重定位。
PIC数据引用
编 译器通过运用以下有趣的事实来生成对全局变量的PIC引用:无论我们在存储器中的何处加载一个目标模块(包括共享目标模块),数据段总是分配为紧随在代码 段后面。因此,代码段中任何指令和数据段中任何变量之间的距离都是一个运行时常量,与代码段和数据段的绝对存储器位置是无关的。
为了运用这个事 实,编译器在数据段开始的地方创建了一个表,叫做全局偏移量表(global offset table, GOT)。GOT包含每个被这个目标模块引用的全局数据目标的表目。编译器还为GOT中每个表目生成一个重定位记录。在加载时,动态链接器会重定位GOT 中的每个表目,使得它包含正确的绝对地址,每个引用全局数据的目标模块都有一张自己的GOT。