例行前言:
本篇不是学习课程时的笔记,是重看这本书时的简记。对于学习本课程的同学,未涉及的内容不代表考试不涉及,部分省略的部分是在该课程的讨论课中学习的(PIC,放出了我在讨论课中的PPT作为参考),核心内容是链接的过程(符号解析与重定位)。本章与其他章关联的内容有但不限于:程序的加载运行与虚拟存储器、程序控制流章节的相关知识。
计算机系统-链接
本章主要介绍链接工作,链接的过程,并介绍了相关的一些概念(可重定位目标文件,程序的加载执行等)。介绍了静态库,动态库的使用。
1.概述
链接是将各种代码和数据部分组合成一个文件的过程,这个文件可被加载到存储器并执行。链接工作由链接器来完成。链接使一个大型应用可以分解为多个模块,独立的进行修改和编译,当一个模块改变时,只需要重新编译该模块,然后重新链接,而不必重编译其他文件。
假设一个程序由swap.c和main.c两个文件构成,其编译为一个可执行的文件可以使用以下指令:
gcc -g -o p main.c swap.c
实际的过程包含预处理,编译,汇编,链接:
cpp main.c -o main.i
cc1 main.i -o main.s
as main.s -o main.o
//..swap.c也经历以上过程,得到swap.o
ld main.o swap.o p
swap.o和main.o是两个可重定位目标文件,接下来先说明目标文件和可重定位目标文件,在本章的最后,还会介绍可执行目标文件,以及可执行目标文件加载到存储器的过程。
目标文件
目标文件一共有三种格式:
- 可重定位目标文件:包含二进制代码和数据,可以与其他可重定位目标文件合并成一个可执行目标文件
- 可执行目标文件:可直接被载入存储器执行的文件
- 共享目标文件:特殊的可重定位目标文件,可以在加载或者运行时被动态链接到存储器并链接
各个系统之间的目标文件格式都不同,但基本概念是相同的。接下来重点介绍Unix的可重定位目标文件ELF,这是本章所介绍的链接工作需要处理的文件。
可重定位目标文件ELF
一个典型的ELF可重定位目标文件的格式如下,并说明了其中的各个部分的作用,在这一节还没有说明链接需要哪些信息,可以在学习后面的部分之后,再回到这一节对照ELF文件中的各个部分。
- ELF头:机器类型,节头部表的位置及条目数量和大小,目标文件类型等信息
- .text:程序的机器代码
- .rodata:只读数据
- .data:初始化的全局变量。局部变量在栈中管理,不出现在该节或.bss中
- .bss:未初始化的全局变量,只是一个占位符,不占用实际空间
- .symtab:符号表。存放程序中定义和引用的函数和全局变量的信息。不包含局部变量
- .rel.text:一个.text节中位置的列表,代码中会有一些需要重定位的符号(全局变量,外部函数等),这个节记录了这些需要重定位的符号的位置和一些其他信息
- .rel.data:被模块引用或定义的全局变量的重定位信息。任何已初始化的全局变量,如果初始值是全局变量的地址或外部函数的地址,都需要修改
- .debug、.line:行号和调试符号表,编译时有-g才会存在
- .strtab:一个字符串表,包含节头部中的节名字等
2.静态链接
链接器需要完成的任务是将一组可重定位目标文件合并为一个可执行目标文件。为了构造可执行目标文件,链接器完成两个任务:
- 符号解析:将每个符号引用和一个符号定义联系起来
- 重定位:将每个符号定义与一个存储器位置联系起来,并修改所有对符号的引用,使它们指向存储器中的正确位置
符号解析
符号解析的工作是将每个符号引用和一个符号定义联系起来。符号引用和定义的信息存储在符号表中。
符号和符号表
一共有三种类型的符号:
- 当前模块定义的被其他模块引用的全局符号(非静态函数和非静态全局变量)
- 当前模块引用的由其他模块定义的全局符号,这些符号称为外部符号。
- 只被当前模块定义和引用的本地符号。
符号表中不包含局部变量,这些变量在栈中,不需要在链接中处理和管理。不过static属性的本地过程变量不在栈中,而是本地符号。
符号表由一个个条目构成,以main.o的符号表中的几个条目来说明符号表条目的构成:
- Num:条目序号
- Value:距离定义符号的节的起始位置的偏移
- Size:符号目标的大小
- Type:符号是数据/函数
- Bind:表示符号是本地的还是全局的
- Ndx:符号所在的节的索引,1表示.text,3表示.data
- Name:符号名
前两条是符号定义,最后一条是符号引用,其Ndx为UND,类型为NOTYPE,是在swap.c中的定义的,是一个外部符号。
符号解析
对于每个符号引用,链接器只需要扫描所有的可重定位目标文件的符号表,就应该能找到其定义,建立引用与定义之间的联系,如果没有找到,则会产生链接错误。更复杂的情况是在多个文件中有对一个符号的多个定义,链接器如何解析多重定义的全局符号,是符号解析中要解决的重要问题,接下来介绍这个问题的处理方式。
每个全局符号是强或弱的。函数和已初始化的全局变量是强符号,未初始化的全局变量是弱符号。
Unix链接器使用以下规则来处理多重定义的符号:
- 不允许多个强符号
- 如果有一个强符号和多个弱符号,选择强符号
- 如果有多个弱符号,选择任意一个
在编写程序中,必须遵守这三条规则,否则可能会有很难察觉的错误,因为编译器不会表明其检测到了多个定义。可以用GCC-fno-common选项调用链接器,让编译器输出对多重定义的警告。以下是一个多重符号定义带来的问题:
//foo3.c
void f(void);
int x = 15213;
int main(){
f();
printf("x=%d\n",x);
return 0;
}
//bar3.c
double x;
void f(){ x = -0.0; }
x会采取强定义,成为int型数据,但在f()中,作为double赋值了8个字节,这导致x之后的四个字节被写入了错误的数据。
重定位
完成符号解析后,链接器可以开始重定位了。重定位有两步:
- 重定位节和符号定义:链接器将相同类型的节合并为同一类型的新的聚合节。此时,每一个节中的指令和全局变量都有唯一的运行时存储器地址。
- 重定位节中的符号引用:修改代码节和数据节中对每个符号的引用,使它们指向正确的运行时地址。这是根据重定位条目完成的。
重定位节和符号定义比较简单,可以直接看下图:
现在在可重定位目标文件中使用相对地址的符号都有一个确定的运行时地址了。先说明重定位符号引用依据:重定位条目。
重定位条目在.rel.text和.rel.data中,一个重定位条目的格式如下:
- offset:需要重定位的引用的节偏移
- symbol:需要重定位的符号
- type:如何修改引用,即重定位的类型
ELF定义了许多种不同的重定位类型,只关注两种最基本的重定位:
- R_386_PC32:重定位一个使用32位PC地址的相对引用。PC相对地址是距程序计数器PC的当前运行值的偏移量。CPU执行PC相对寻址的指令时,给指令中的32位值加上当前PC,得到有效地址
- R_386_32:重定位一个使用32位绝对地址的引用。通过绝对寻址,CPU直接用指令中编码的32位值作为有效地址
接下来给出重定位的伪代码,然后分别说明这两种重定位是怎么实现的。此时已经完成了重定位的第一步,每个节和符号已经有运行时地址了。假设s是节的地址,r是一个重定位条目,ADDR(s)是s的运行时存储器地址:
foreach section s{
foreach relocation entry r{
refptr = s + r.offset; //需要重定位的引用位置
if(r.type==R_386_PC32){
refaddr = ADDR(s)+r.offset; //运行时的引用位置
*refptr = (unsigned)(ADDR(r.symbol)+*refptr-refaddr);
}
if(r.type==R_386_32){
*refptr = (unsigned)(ADDR(r.symbol)+*refptr);
}
}
}
重定位PC相对引用
假设main.o中调用了swap程序中的swap():
/*relocation*/
6: e8 fc ff ff ff call 7<main+0x7> //一开始的时候,refptr即call后面的内容为0xfffffffc = -4
7: R_386_PC32 swap
在0x7处有一个重定位,他的实际运行时地址为refaddr = ADDR(s) + r.offset。
修改引用,一开始*refptr是-4,这个初值设定的原因是:当执行CALL指令时,PC的值已经是PC+4了,而PC相对引用是当前指令的PC+相对偏移,所以要-4修正,此时就可以计算相对偏移了:
*refptr = (unsigned) (ADDR(r.symbol) + *refptr - refaddr) //refptr指向需要修改的引用
= (unsigned) (符号地址 - 4 - 引用地址)
假设call指令的位置在重定位后的80483ba,swap的位置(ADDR(r.symbol))在80483c8,重定位计算后的*refptr = 0x9。当执行到CALL时,PC = 80483ba + 4 =0x80483bf,用此时的PC+9就可以得到0X80483c8。
|总结
重定位相对引用是将原来的引用替换为一个相对偏移量,计算引用的实际地址(例如上例的swap()地址),应该是PC+偏移量-4,因为执行call指令时,PC已经加4了,所以要修正,但是执行call指令时,希望只执行一个简单的加法,因此在重定位时将偏移量-4,也就是*refptr的初始值设置为-4,然后计算时加上这个值。
重定位绝对引用
重定位绝对引用只需要直接将引用替换为ADDR(r.symbol)就可以了,*refptr一开始为0。
*refptr = (unsigned)(ADDR(r.symbol)+*refptr);
静态库
编译系统支持将相关的模块的一组可重定位目标文件打包为一个文件,用作链接器的输入,这个文件称为静态库。使用静态库使一些常用的函数可以打包在一起,既方便修改,又不需要在编译链接时添加太多的文件名。
Unix系统中,静态库的文件格式是存档(archive),是一组可重定位目标文件的集合,可以使用以下命令创建库和使用库。
# 得到静态库
gcc -c addvec.c multvec.c
ar rcs libvector.a addvec.o multvec.o #产生libvector.a静态库
# 编译与链接
gcc -c main.c
gcc -static main.o /libvector.a
-static告诉编译器,链接器构建一个完全链接可加载到存储器中运行的可执行目标文件,不需要进一步链接。
3.动态链接与共享库
加载与链接共享库
静态库的缺点是需要定期维护更新,并显式将程序与更新的库重新链接,并且对于一些基本的静态库,每个程序都要将其代码复制到自己的代码段中,造成了空间的极大浪费。
共享库是解决静态库缺陷的产物。共享库是一个目标模块,可以在运行时,加载到任意的存储器地址,并和一个在存储器中的程序链接起来,这个过程也称为动态链接,由动态链接器完成。
Unix系统中,共享库用.so后缀表示,在windows中为DLL文件。
共享库不需要拷贝到使用它的可执行文件中,在存储器中可以被不同的运行的进程共享(映射到不同的虚拟地址,这一点可以在学习虚拟存储器章节后理解)。构建并使用一个共享库可以使用以下的命令:
gcc -shared -fPIC -o libvector.so addvec.c multvec.c
gcc main.c ./libvector.so
共享库的链接不拷贝数据和代码,只拷贝一些重定位和符号表信息,在运行时用于解析引用。
当加载器加载和运行可执行文件时,加载部分链接的文件,加载器会注意到可执行文件有一个.interp节,包含动态链接器的路径,加载器会加载和运行动态链接器,然后动态链接器会完成重定位任务:
- 重定位共享库的数据段和代码段到一个存储器段
- 重定位所有对共享库定义的符号的引用
完成后,动态链接器将控制传递给应用程序,然后就可以正常的执行了。
PIC-与位置无关的代码
这一部分内容在讨论课中探讨。PIC.pptx https://www.aliyundrive.com/s/ZX5JrBxdJtE 。
4.程序的加载执行
链接器将多个目标模块合并为一个可执行目标文件,接下来看看这个文件是什么样的格式,又是怎样加载到存储器中运行的。
可执行目标文件
可执行目标文件和可重定位目标文件的格式很相似,只是不需要.rel节,因为重定位已经完成了。.init节定义了一个_init函数,进行程序的初始化。可执行目标文件易于加载,连续的片被映射到连续的存储器段。
加载可执行目标文件
运行可执行目标文件时,shell会调用存储器来运行这个文件(execve函数调用加载器),加载器将代码和数据拷贝到存储器,跳转到程序的入口开始运行。
Unix程序有固定的运行时存储器映像,各个部分的排布如下:(注意:这些地址都是虚拟地址)
加载器运行时,按照这个映像以及段头部表的信息,将相关内容拷贝到存储器,然后加载器跳转到符号_start地址处的启动代码,所有c程序都有这个启动代码,调用一系列函数进行初始化等工作:
atexit注册了一系列程序终止时应该调用的函数。执行结束后_exit运行atexit注册的函数,最后将控制交给操作系统。