文档库 最新最全的文档下载
当前位置:文档库 › 程序员的自我修养3--静态链接

程序员的自我修养3--静态链接

程序员的自我修养3--静态链接
程序员的自我修养3--静态链接

Table of Contents

1、链接最实际的做法---相似段合并 (1)

2、链接的两个步骤 (3)

2.1,空间与地址分配 (3)

2.2,符号解析与重定位。 (6)

2.2.1查看反汇编代码(objdump -d),效果如下, (6)

2.2.2那链接器是怎么知道怎么调整? (7)

2.2.3符号地址修改的过程 (8)

3、解决一个疑问 (9)

4,API与库 (9)

5、链接过程控制 (14)

5.1 控制方法 (14)

5.2最小程序—内嵌汇编、系统调用、自建链接脚本 (15)

5.3 ld链接脚本语法简介 (18)

6、用到的一些命令: (20)

1、链接最实际的做法---相似段合并

有了目标文件之后,接下来的问题就是如何将它们组合起来,形成一个可以使用的程序或者更大的模块,这就是静态链接要解决的问题,静态链接是链接的核心内容。

现在有两个文件:a.c

b.c

编译成目标文件并查看符号表:

可以看出,b.c总共定义了两个全局符号,变量shared和函数swap,a.c中只有一个全局符号main。模块a.c引用了b.c里的两个全局变量。我们接下来要做的就是链接这两个文件,最终形成可执行文件ab.

我们知道可执行文件中的代码段和数据段都是由输入的目标文件中合并而来的。这样就产生了第一个问题,对于多个输入文件,链接器怎样将他们的各段合并到输出文件?

最简单的方案:简单的叠加,即A有5个段,B有5个段,C有5个段,那么可执行文件中将有15个段。这种做法非常浪费空间,因为对于X86硬件来说,段的装载地址和空间对齐的单位是页,也就是4K,也就是说如果段只有1字节,也要占4K空间,这样会造成内存空间大量的内部碎片,所以这并不是好的方案。

最实际的做法:相似段合并,其中.bss段在目标文件和可执行文件中并不占用空间,但是在装载的时候占用地址空间。所以链接器合并各段的时候也要.bss合并,并且分配虚拟空间。

2、链接的两个步骤

空间与地址分配。扫描所有的输入文件,获得它们的各个段的长度、属性和位置。

将输入文件中的符号表中所有的符号定义和符号引用收集起来,统一放到一个全局符号表。

合并相应段,计算出合并后的段的长度和位置,并建立映射关系。

符号解析与定位。这是链接的核心,根据输入文件中的段信息、重定位信息,进行符号解析和重定位、调整代码中的地址。

2.1,空间与地址分配

执行链接操作:

a.o和

b.o的段情况如下:

为输出文件分配‘地址和空间’有两种含义,一种是输出的可执行文件的空间;第二个是在装载后的虚拟地址中的虚拟地址空间。对于.text和.data这样的实际地址中的段在文件和虚拟地址中都要分配空间,但是.bss这样的段,分配空间的意义只限于虚拟地址空间,因为它在文件中没有内容。

以后谈空间分配,就是虚拟空间分配,因为这个关系到链接器后面关于地址的计算步骤,而可执行文件本身的空间分配与链接的过程关系不大。

VMA虚拟地址

LMA加载地址

正常情况下,两个值应该是一样的,但是在有些嵌入式系统中可能不同。我们只关注VMA。

在链接之前,目标文件中所有段的VMA都是0,因为虚拟空间还没有被分配。链接后程序中使用的地址已经是虚拟地址,我们关心VMA和size忽略文件偏移。

必须使用虚拟地址,因为每次装载后在内存中的地址才是真实的物理地址,而且由于分页映射机制,物理地址在运行前不可能被确定!!只能是分段机制进行地址隔离,虚拟映射,搞的好像每个程序都占用3GB的可用内存!编程时使用的或者链接后符号地址、在文件中的偏移地址都不可用,实际地址和虚拟地址的映射由操作系统和MMU负责。

上图忽略了像.comment这种无关紧要的段,本例又没有.bss段,所以只关心代码段和数据段。为什么链接器要将ab的代码段分配到ox08048094,数据段分配到ox08049108,而不是从虚拟地址的0地址开始分配呢?这涉及操作系统的进程虚拟地址空间分配规则,在linux下,ELF可执行文件默认从地址0x08048000开始分配。

段的虚拟地址确定以后,链接器要开始计算各个符号的虚拟地址,因为各个符号在段内位置固定,ld要为每个符号计算一个偏移量。这个很好计算,三个全局符号的地址被计算出来后,全局符号表被更新。

2.2,符号解析与重定位。

因为程序内部要使用虚拟地址,所以要对代码进行调整。

2.2.1查看反汇编代码(objdump -d),效果如下,

对于shared变量引用:Link之前,在a.o中引用shared变量

链接之后,查看符号表:

发现shared的虚拟地址是080490f8。与是查看ab的反汇编代码中对shared的引用:

而a.o对swap的调用:(这是一条近址相对位移调用指令,调用下一条指令,这只是临时的假调用)

在ab中对swap的调用:

可以看出在目标文件中,对shared和swap的引用都是用假地址来代替,真正的地址计算工

作留给了链接器,而在第一步的地址和空间分配后,已经可以确定所有的符号的虚拟地址了。那么链接器可以对每个需要重定位的指令进行地址修正。

2.2.2那链接器是怎么知道怎么调整?

哪些指令是要被调整的呢?这些指令的哪些部分需要调整?怎么调整?ELF 文件中有个叫重定位表的结构专门来保存这些与重定位相关的信息。

对于可重定位文件来说,它必须包含重定位表。重定位表是一个结构体数组,每个结构体描述一个重定位入口,使用objdump –r参数查看重定位表:

我们看到重定位表的内容,意思是在.text段的15偏移量的地方有一个叫shared的变量地址需要重定位,我们查看代码段的命令

偏移量15处正是shared的假地址。Swap同理。

每一个要被重定义的地方都叫一个重定位入口。

2.2.3符号地址修改的过程

应该是,查看a.o的符号表发现有两项GLOBAL类型的所在段属性是UND即未定义,即这两项是需要重定位的,然后在链接的第一步时生成的全局符号表中找这两个项,找不到则报错,找到了则根据重定位表中的偏移找到要修改的重定位点。

具体的过程,因为指令的寻址方式有很多种,所以并不是直接从全局符号表中拿出真实的地址就可以用的!

在重定位表中有一项TYPE:

R_386_32 绝对寻址修正 S+A

R_386_PC32相对寻址修正S+A-P

A代表被修正位置的值,S表示符号的实际地址,P代表被修正的位置。

两种方式的区别在于绝对寻址修正后的地址为该符号的实际地址,相对寻址修正后的地址为被修改指令距符号的距离差。

3、解决一个疑问

在上个文档中我们发现,本该和未初始化的静态局部变量一起放在.bss段的未初始化全局变量,并没有出现在.bss段,而是被标记为COMMON类型的变量,原因是它是一个弱符号,大小并不确定,可能在多个目标文件中都有定义,在链接的过程中可能被强符号取代,也可能被其它比较大的弱符号取代,所以只有在链接后才能最终确定大小,最终还是会在.BSS段分配空间。所以从总体来看,未初始化的全局变量还是被放在BSS段的。

4,API与库

运行库(Run-time Library)是程序运行时需要的支撑库。对于 C/C++ 程序来说,其运行库一定是动态链接库或者共享库,而不是静态库。比如,很多程序的运行需要 C 标准库以及其它一些库的支持,那么 C 标准库或者需要的库就是此程序的运行库。如果在程序连接的时候对所有的库进行的是静态连接,那么程序运行的时候就不需要任何运行库的支持。

操作系统 API提供在这个操作系统上与任何东西互操作的能力:文件、内存、时钟、网络、图形、各种外设等等。API 通常还提供许多工具类的功能:操纵字符串、各种数据类型、时间日期等等。每个API函数的实现可能由一个系统调用实现,也可以由多个系统调用实现,当然,也可以不使用系统调用,系统调用是内核留给用户的接口,以C库API的方式进行封装。而一些语言库就是对操作系统API的封装。大致层次:应用程序--语言库--C运行库(API提供者)--系统调用—内核服务。在LINUX中C语言标准库是个例外,它本身就是提供API的C运行库的一部分。比如printf函数在linux下是一个write系统调用,而在window下则是一个writeconsole系统API。

世界上最通用的操作系统 API 其实是传统Unix 的 POSIX 接口(可移植操作系统接口),标准 C 的标准库其实就是这个接口的子集,所有类 Unix 操作系统所提供的操作系统 API,几乎都被称为 libc(对 C 库的传统称呼),所有操作系统所提供的自然操作系统接口都是以 C 语言执行库的方式提供的。Windows 操作系统上提供的 Windows API 与POSIX 不同,但也是 C 函数库。

因为无论是windows还是Linux系统API都是C语言接口,而且一般以动态库(运行库)的方式提供,所以又称为C运行库。它们通过或不通过系统调用实现保罗万象的功能。而且一般包含

C标准库的静态库和动态库(C语言标准运行库),是C标准库的超集,也就是说glibc和MSVCRT都对其进行了丰富的扩展。比如像线程操作这样的函数并不是C标准库的一部分。

glibc提供一组头文件和一组库文件,最基本、最常用的 C标准库函数和系统函数在libc.so库文件中,几乎所有C程序的运行都依赖于libc.so,有些做数学计算的C程序依赖于libm.so,以后我们还会看到多线程的C程序依赖于libpthread.so。以后我说libc时专指libc.so这个库文件,而说glibc时指的是glibc提供的所有库文件

上图依然正确:第一层,应用程序调用API,展开了说就是,应用程序用某种语言编程,利用了该语言提供的语言库,这个语言库通过API又调用了C运行库,C运行库经过系统调用申请内核服务。

可以想象,用户不能直接调用内核,从而有了应用程序….。shell程序(解释器)可以提供直接调用内核的接口,想必也是将用户命令解析成系统API,然后调用内核。

glibc的发布版本主要由两部分组成,一部分是头文件,比如stdio.h、stdlib.h等,它们往往位于/usr/include;另外一部分则是库的二进制文件部分。二进制部分主要的就是C语言标准库,它有静态和动态两个版本。动态的标准库它位于/lib/libc.so.6;而静态标准库位于/usr/lib/libc.a。事实上glibc除了C标准库之外,还有几个辅助程序运行的运行库,这几个文件可以称得上是真正

的“运行库”。它们就是/usr/lib/crt1.o、/usr/lib/crti.o和/usr/lib/crtn.o。虽然它们都很小,但这几个文件都是程序运行的最关键的文件。

静态C标准库/usr/lib/libc.a是目标文件的集合,如printf.o/scanf.o/fread.o/fwrite.o等1400多个目标文件,我们编写一个hello world程序,我们需要链接libc.a中的printf.o形成可执行文件,这个解释似乎很完美。

gcc –c hello.c

ar –x licb.a

ld hello.o printf.o

链接却失败了,原因是printf.o 依赖其他的目标文件:stdio.o,vfprint.o,很不幸,这两个目标文件还依赖其它的目标文件。理论上我们可以最终收集齐并链接成功。手动链接代价太大。幸好

ld链接器会帮助我们处理这一切繁琐的事物:自动寻找需要的符号,并从libc.a中解压出来,追中得到可执行文件。是不是说将libc.a和hello.o链接起来就OK了?实际情况恐怕还是令人失望的。当我们编译和链接一个普通的C程序的时候,不仅要用到C语言库libc.a,还有一些辅助性的目标文件和库。使用gcc –verbose将所有的编译链接过程打印出来:

第一步:用cc1程序(C语言编译器)完成编译成汇编文件—ccIcaseCz.s

第二步:用as程序将.s文件汇编成目标文件ccNmgnnQ.o

第三步:用collection2(ld的封装)将包括libc.a的目标文件链接起来形成可执行文件。

最后执行可执行文件a.out

最后有一个疑问,为什么静态库里一个目标文件只包含一个函数?为什么这么组织。

链接器在链接的时候是以目标文件为单位的,如果很多函数放在同一个目标文件中,如果只用到printf,很多没有用的函数都会被链接到输出文件。

5、链接过程控制

绝大部分情况下,我们使用链接器提供的默认规则进行链接。但是有一些特殊的程序,如:

操作系统内核、

BIOS

一些在没有操作系统的情况下运行的程序(bootloader/嵌入式系统程序等)

内核驱动等

它们往往受限于一些特殊条件,如需要指定输出文件的各段虚拟地址、段的名称、段的存放顺序等,因为这些特殊的环境,特别是硬件条件的限制,往往对程序的各段地址有特殊的要求。整个

链接过程还要很多需要确定的:使用哪些目标文件?使用哪些库文件?是否保留调试信息?输出文件格式?导出某些符号以供调试器、程序本身或者其它程序使用?

5.1 控制方法

链接器大致提供了三种方式控制链接过程:

?命令行参数

?链接指令放在目标文件中,编译器经常使用这种方法向链接器传递参数。PE目标文件的.drectve段以用来传递参数。

?使用链接控制脚本,也是最灵活、最强大的链接控制方法。重点介绍的方法

其实如果用户不指定链接脚本时,使用的是默认的链接脚本。是用ld –verbose 查看默认脚本。Visual c++将这种控制脚本叫做模块定义文件,扩展名为.def。

默认的ld脚本存放在/usr/lib/ldscript下:

在Intel IA32下的普通可执行ELF文件的链接脚本为elf_i386.x;IA32下的共享库的链接脚本为elf_i386.xs等。当然,为了更精确的控制链接过程,我们可以自己写一个脚本,然后指定这个脚本为控制脚本。

Ld –T link.script

5.2最小程序—内嵌汇编、系统调用、自建链接脚本

最终目标是在屏幕上打印出hello world 但是不用C库函数。如果使用库的话,就必须有main函数,我们知道程序的入口在库的__start,由库负责初始化以后调用main函数来执行程序的主体部分,为了不使用main这个让我们感到厌烦的函数名,我们的小程序使用

nomain作为程序入口。

经典的helloworld程序会产生很多段,main程序的指令部分会产生.text段、字符串常量会放在只读数据段或者数据段,还有C库的一些段。为了演示ld链接脚本的控制过程,我们将小程序的所有段都合并到一个叫tinytext的段。

使用gcc内嵌汇编编程printf函数使用linux的Write系统调用,exit函数使用linux的EXIT系统调用。系统调用使用OX80中断实现,其中eax为调用号。

对于print函数调用write系统调用eax值为4,是因为write调用号为4。Edx,ecx,ebx用来传参。用C描述该系统调用的原型就是:

Int write(int filedesc,char* buffer,int size)

Filedesc表示被写入的文件句柄,用ebx寄存器传值,默认为0,即stdout

字符串长度为12,用edx传参。

Ecx表示要写入的缓冲区地址即str

在EXIT系统调用中,ebx表示进程的退出码,为42.

先使用普通的命令行来编译、链接。

-e参数是表明程序的入口地址为nomain,它会把elf文件文件头中的入口地址修改为nomain 函数的虚拟地址。

太神奇了!!怎么这么喜欢看到helloworld呢!

如果把链接过程比作一台计算机,ld就是CPU,目标文件、库文件就是输入,可执行文件就是

输出,链接控制脚本就是程序。脚本使用一种特殊的语言写成,即链接脚本语言,这种语言并不复杂,只有为数不多的几种操作。

无论是输入文件还是输出文件,主要的数据就是文件中的各段,我们把输入文件中的段成为输入段,输出文件中的段称为输出段。控制的过程无非是输入段如何变成输出段:

哪些输入段要合并成一个输出段?

哪些输入段要丢弃?

指定输出段的名称、装载地址、属性等。

我们新建tinyhello.lds作为链接脚本

ENTRY指定程序的入口为nomain函数

后面的SECTIONS命令一般是链接脚本的主体,这个命令指定了输入段到输出段的变换规则。

第一行是赋值语句,.代表当前的虚拟地址,因为这条语句后紧跟着tinytext段,所以虚拟地址为…。它将当前虚拟地址设置为一个巧妙的值,一边装载时页映射更为方便。

第二句是段转换规则,将输入文件中所有名为.text/.data/.rodata的段依次合并到tinytext里。最后一句顾名思义。

我们使用这个脚本链接,并运行

查看段表,符合预期。

5.3 ld链接脚本语法简介

Ld链接器的链接脚本语言语法继承于AT&T链接器命令语言的语法,风格有点像C,它本身并不复杂。链接脚本由一系列语句组成。语句分两种:命令语句和赋值语句。

?语句之间使用分号作为分隔符,原则上讲语句之间都要用分号作为分隔符,但是对于命令语句来说也可以使用换行来结束该句。赋值语句必须以;结束。

?表达式与运算符。脚本语言的语句中可以使用C语言类似的表达式和运算操作符,比如:+ - * 、 += -= *=等,甚至包括| >> <<这些位操作。

?注释和字符引用使用/**/作为注释。脚本文件中使用到的文件名、格式名或者段名等凡是包含分号或其它分隔符,用双引号括起来。如果包含引号,无法处理。

SECTION 命令负责指定链接过程和段转换过程,也是链接最核心和最复杂的部分。

一些常用的命令:

ENTRY(symbol)指定符号symbol的值为入口地址。入口地址即进程执行的第一条用户空间的指令在进程地址空间的地址。它被设定在ELF文件头中。Ld有很多中方法可以设定入口地址,优先级顺序如下:

?Ld命令行–e参数

?链接脚本中的ENTRY命令

?如果定义了__start符号,使用__start符号值

?如果存在.text段,使用.text段的第一字节的地址

?使用0

STARTUP(filename)将文件filename作为链接过程中的第一个输入文件。

SEARCH_DIR(path)将路径path添加到ld链接器的库查找路径

INPUT(file,file…)

INPUT(file file…)将指定文件作为链接过程中的输入文件

INCLUDE filename将指定文件包含进本链接脚本。PROVIDE(symbol)在链接脚本中定义特殊符号,该符号可以在程序中被引用。

更多的命令参见ld使用手册。

SECTIONS命令语句最基本的格式为:

SECTIONS

{

...

Secname : { contents }

}

Content的写法如下:

Filename(sections)举例子:

?File1.o(.data)

?File1.o(.data, .rodata)没有逗号也行

?File.o所有段都符合

?*(.data)所有输入文件中名字为.data的文件符合条件。还可以使用正则表达式中的其它规则。

6、用到的一些命令:

ld a.o b.o -e main -o ab 链接a和b两个目标文件,指定入口地址和输出文件名称objdump -d a.o 反汇编

objdump -r a.o查看重定位表

gcc -static --verbase -fno-builtin hello.c静态链接而不是默认的动态,去除优化,打印整个过程

gcc –verbose 查看默认的链接脚本

Ld –T link.script 指定脚本

相关文档