理解C语言(零) 导读(中):C程序的链接与执行过程- 内存模型
3 链接阶段:何为链接
3.1 简介
链接是将各种代码和数据部分收集起来并合并为一个单一文件的过程,该文件最后被加载到存储器中并运行。
链接可以执行于编译时,由静态链接器完成;也可以执行于加载和运行时,由动态链接器完成。可以看出链接器在软件开发中扮演一个关键的角色,它使得分离编译成为可能。在我们开发一个大型程序的时候,通用的做法是将它分解为更小、更好管理的模块,独立地修改和编译这些模块,当改变其中的一个模块时只需简单地重新编译它,并重新链接应用而无须重新编译其他文件
链接是处在编译器、体系结构和操作系统的交叉点上,它要求理解代码生成、机器语言编程、程序实例化和虚拟存储器,刚好不落在某个通常的计算机系统领域,因而并不是一个描述很具体的议题。但为什么我们强调学习关于链接的知识呢?
有助于你理解链接器如何使用库和模块构造大型程序
有助于你了解链接器在解析全局符号引用的一些陷阱
有助于你理解C语言中变量的作用域是怎样实现的
有助于你理解加载、运行、虚存、页面调度等重要系统概念
有助于你利用共享库和动态链接提供各种服务
3.2 链接过程
链接器处理称为目标文件的二进制文件,它有三种不同的形式:可重定位的、可执行的和共享的,如下:
- 可重定位目标文件:由各种不同的代码和数据节组成,其形式可以在编译时与其他可重定位目标文件合并起来,生成一个可执行目标文件
- 可执行目标文件:由各种段映像字节组成,其形式可以被直接拷贝到内存中并执行
- 共享目标文件:含特殊类型的可重定位目标文件,可以在加载或运行时被动态地加载到内存中并链接
一般来说,链接器程序ld将一系列可重定位目标文件和链接命令参数作为输入,处理目标文件的外部引用和符号重定位,最后生成一个完全链接的可以加载和运行的可执行目标文件。为了构造可执行文件,链接器主要完成两个工作:
- 符号解析:将目标文件中的每个符号引用绑定到一个唯一的定义中
- 重定位:确定每个符号的最终存储器地址,并修改对那些目标符号的引用
下面以现代Unix系统的格式:Executable and Linkable Format-ELF文件讲解链接过程涉及到的几个重点
3.2.1 可重定位目标文件
编译和汇编阶段生成从地址0开始的可重定位目标文件,要记住目标文件其实是一个字节序列,一组字节块的集合,它定义了各种字节块。在Unix中利用READELF工具显示一个目标文件的完整结构,包括ELF头中编码的所有信息,各个节的信息。执行命令readelf -a hello.o
,典型的ELF可重定位目标文件结构如下:
- ELF头: 描述了有关目标文件的整体结构信息:文件类型(可重定位的,可执行的),数据表示方法(大小端表示),program header、section header等的起始地址、文件偏移大小、各段的节个数
- .text: 已编译程序的机器代码
- .rodata: 只读数据,如printf语句中的格式串
- .data: 已初始化的数据。注:局部C变量在运行时保存在栈中,既不出现在.data节中也不出现在.bss节中
- .bss: 未初始化的数据。占位符,区分初始化和未初始化变量是为了空间效率,它并不需要占据空间
- .symtab: 符号表,存放在程序中定义和引用的符号信息(函数和全局变量)
- .rel.text: 代码重定位信息,合并多目标文件时用于.text节的重定位
- .rel.data: 数据重定位信息,合并多目标文件时用于.data节的重定位(被模块引用或定义的任何全局变量)
- .debug: 只有以-g选项调用gcc才能得到这样调试符号表,用于记录定义和引用的局部变量、全局变量、类型定义等条目
- .line: 只有以-g选项调用gcc才能得到一个源程序的行号与.text节中机器指令间的映射
- .strtab: 包含.symtab和.debug节中的符号表,包含源程序代码和行号、局部符号和数据结构描述信息
3.2.2 符号解析和重定位
A. 符号解析
根据上图所示,每个可重定位目标文件m均有一个符号表,它包含m所定义和引用的符号信息,根据ld的上下文,有3种不同的符号
- 由m定义并能被其他模块引用的全局符号,比如非静态的C函数,不带C static属性的全局变量
- 由其他模块定义并被模块m引用的全局符号,称为外部符号,对应于定义在其他模块中的C函数和变量
- 只被模块m定义和引用的本地符号,比如带static属性的C函数和全局变量
如下为main.c的程序代码,并运行如下命令,它将显示该目标文件main.o的符号表信息
void swap();
int buf[2]={1,2};
int main(){
swap();
return 0;
}
可以看出main.o的符号表有11个条目,Bind字段表示符号是本地的还是全局的,Ndx字段表示每个节的整数索引,每个符号都必须和目标文件中的某个节关联,1表示.text节,3表示.data节,UND表示本模块文件未定义的符号,ABS表示不该被重定位的符号,COM表示还未被分配位置的未初始化数据。最后3个条目的意思是全局符号buf是一个位于.data节偏移为0处的8个字节目标,随后的是全局符号main的定义,它表示main是一个位于.text节偏移为0处的36字节函数,最后一个条目是来自对外部符号swap的引用。如果在程序中添加一个带static属性的静态变量,将会增加一个Binding字段为LOCAL,Ndx字段为3的符号a。
所以符号解析的目的就是将每个引用与可重定位目标文件中的符号表一个确定的符号定义联系起来。对于那些目标模块的本地符号的引用,符号解析是非常明确的,但对于不是在当前模块定义而被引用的全局符号(变量和函数名),规定函数和已初始化的全局变量是强符号,未初始化的全局变量是弱符号,使用以下规则来处理:
- 规则1: 不允许有多个强符号,比如两个文件都定义了main函数这是不允许的
- 规则2: 如果有一个强符号和多个弱符号,选择强符号
- 规则3: 如果有多个弱符号,任意选择一个
B. 重定位
链接器程序ld完成了符号解析这一步后,就可以开始重定位了。这一步将要为每个确定定义的符号分配运行时地址。它由两步组成:
- 重定位节和符号定义。 在这一步中,链接器将所有相同类型的节合并为同一类型的新聚合节,
- 重定位节中的符号引用。在这一步中,利用可重定位目标模块中的代码和数据的可重定位条目修改代码节和数据节中对每个符号的引用,使得他们指向正确的运行时地址
重定位的核心在于如何利用ELF重定位条目。每个条目的长度为8字节,其结构如下所示(参考linux v0.11内核中a.out.h中的设计):
struct relocation_info {
int r_address;
unsigned int r_symbolnum:24;
unsigned int r_pcrel:1;
unsigned int r_length:2;
unsigned int r_extern:1;
unsigned int r_pad:4;
};
该结构地址字段r_address是指可重定位项从代码段或数据段开始算起的偏移值。r_symbolnum指定符号表中的符号或段,2bit的长度字段r_length表示被重定位项的长度,0到3分别表示被重定位项的宽度是1、2、4、8字节,标志位r_pcel指出被重定位是一个PC相对的项,即作为一个相对地址用于指令当中。外部标志位r_extern控制r_symbolnum的含义,指明重定位的项是段还是符号。为0,表示普通的重定位项,对应r_symbolnum表示在某个段中寻址定位,为1,表示对外部符号的引用。
具体的重定位算法不打算详细描述,但注意如果要查看重定位过程中的汇编指令,可以利用OBJDUMP工具反汇编.text节中的二进制指令。
3.2.3 可执行目标文件
经过上面链接器程序ld的符号解析和重定位工作最后生成了一个可被加载到内存中运行的可执行目标文件。典型的ELF可执行文件的结构如下:
可执行文件的格式与可重定位目标文件格式类似,ELF头部描述文件的整体结构,它还包括了程序的入口点地址,即程序运行时要执行的第一条执行的地址。.init节中定义了一个小函数,程序初始化时会调用它,.text,.rodata,.data节和之前的文件类似,除了这些节被重定位到他们最终的运行时存储器地址以外。其他的符号表和调试信息只有当编译程序开启了-g选项才有,但是并不加载到存储器中。
执行如下命令:
gcc -o p main.c swap.c
readelf -a p | less
objdump -S p | less
利用READELF查看可执行文件的结构信息,例如程序入口点
利用OBJDUMP查看可执行文件p的汇编代码,显示各个section的地址和汇编信息
这里需要重点强调ELF可执行文件的段头部表,它描述了可执行文件的连续片(文件节)被映射到对应的运行时内存地址的关系。执行命令objdump -x p | less
,Program Header部分对应了文件p的段头部表,如图所示:
注:off- 文件偏移; vaddr/paddr- 虚拟/物理地址; algin- 段对齐; filesz- 目标文件中的段大小; memsz- 存储器中的段大小; flags- 运行时许可
标记红色的框内,说明了可执行文件的内容初始化两个存储器段(就是内存),它告诉了我们代码段和数据段的初始信息,如下
-
第一个LOAD对应代码段,它对齐到一个4KB的边界,有读/执行许可。开始于物理地址0x08048000处,总共的存储器大小是0x5f0字节,并且初始化为可执行文件中对应该段的头0x5f0个字节,它包括ELF头部、段头部表、.init、.text、.rodata节
-
第一个LOAD对应数据段,它对齐到一个4KB的边界,有读/写许可。开始于物理地址0x08049f08处(因为上一个段0x5f0字节小于0x1000(4KB)大小,所以下一个段起始地址为0x08049000+0x00000f08),总共的存储器大小是0x12c字节,并且初始化为可执行文件中对应该段的头0x124个字节,偏移0x448处正是.data节开始,该段中剩下的字节对应于运行时将被初始化为0的.bss数据
3.3 函数库-静态库和共享库
这里所说的函数库是指由若干目标文件按某种格式构成的集合,它分为两种类型:静态库和共享库。应用程序在链接静态库时是将所需的静态库函数嵌入至可执行文件中(并非全部静态库),而在链接共享库时它仅在可执行文件中保存加载目标对象所需的信息,真正调用时才将目标对象加载至内存。
前面已经介绍了GCC如何生成静态和动态链接库,不再赘述,这里主要说明以下几点:
-
静态库由ar工具创建和更新,库文件命名规则为libxxx.a,以lib开头,.a作为文件名后缀。以ANSI C为例,它定义了一组广泛的标准I/O、字符串操作等如atoi、printf、strcpy等,存在libc.a库中,浮点函数存在libm.a库中。它的缺点是程序运行时要求有静态库中所需的对象的一份拷贝,不容易维护和更新
-
共享库在运行时才调用所需的函数库,库文件命名规则为libxxx.so,以lib开头,.so作为文件名后缀。例如调用thread、socket等库,只需要对应添加编译器选项-lthread、-lsocket选项即可(注:始终将-lxxx选项放在编译命令行参数的最右边)。使用ldd命令查看当前可执行文件在运行时所需的共享库。它的优点是每个链接到该共享库的程序都共享它的同一份拷贝(一个.so文件,无需被拷贝或嵌入到可执行文件中,函数库的一个副本可被多个不同的正在运行的进程共享)
-
-fPIC选项编译生成与位置无关代码的共享库,它可在任何地址加载,也可以在运行时被多个进程共享
此外应用程序还可以在运行的任何时刻动态加载共享库,启动时不一定立即加载共享库而是在需要时动态加载共享库。此时共享库称为动态链接库。Linux系统为动态链接库提供了一组API,允许应用程序在运行时加载和链接共享库
#include <dlfcn.h>
void *dlopen(const char *file,int flag) #打开动态链接库,flag参数一般选RTLD_LAZY,表示推迟符号解析到执行库中代码
void *dlsym(void *handle,char *symbol) #打开链接库句柄,取符号的执行地址
int dlclose(void *handle) #关闭共享库
const char *dlerror() #检查动态链接库操作是否失败
利用这些接口我们就可以使用自己开发的动态链接库了,然后再调用共享库里的函数和对象。调用步骤还是比较简单的:打开动态链接库文件,取得调用函数的地址,调用它它们,最后关闭动态链接库。注意编译时的命令行参数-rdynamic
和-ldl
gcc -rdynamic xxx.c -o p -ldl #-rdynamic表示解析共享库中的全局符号,-ldl表示链接dl库
4 执行阶段: 运行时内存模型
4.1 进程和虚拟存储器
一个普通的C程序经过预处理器、编译器、汇编器和链接器后生成一个可执行的目标文件,它由最初的一段ASCII文本文件转化成为一个二进制文件,且这个二进制文件包含加载程序到内存并运行它所需的所有信息。在Shell外壳中输入命令$ >./helloworld
。外壳将会创建一个新的进程,通过操作系统中的execve函数来调用加载器,加载器将可执行文件中的代码和数据从磁盘拷贝到内存中。 让我们对程序和进程做个比较:
- 程序:以物理文件的形式存储在磁盘设备中,源程序会经历编译汇编链接等阶段生成可执行文件,随后被加载到内存中运行
- 进程:运行中的程序的实体。在Linux系统中,每个进程都有各自的生命周期(上下文),在进程的生命周期里都有各自的运行环境及所需的资源(如代码、数据,标识符等信息)
为了运行进程和有效地管理存储器资源,操作系统提供了一种对主存的抽象机制--虚拟存储器(VM)。虚拟存储器为系统中的每个进程维护一个独立的页表,使得每个进程都有一个强大的、通用的、互不干扰的虚拟地址空间。为什么呢?
- 虚拟内存实际上是多级存储机制的一种扩充,它使用磁盘而不是主存来保存运行进程的映像,通过按需页面调度使得多个进程运行于较小的物理内存中
- 虚拟内存机制为进程提供了这样的一种假象:每个进程以为自己占据整个地址空间,独占所有资源。它实际上是:所有进程共享机器的物理内存,当内存用完时就用磁盘保存数据。
- 进程运行时,数据按页在磁盘和内存中进行调度--移入移出,MMU硬件负责把虚拟地址翻译为物理地址,并让一个进程始终运行于系统的内存中。从程序员角度看我们只看到虚拟地址,并不知道自己的进程在磁盘和内存间来回切换.(观察ps和top指令)
4.2 C运行时内存模型
根据上面所描述的,进程在运行时为C程序提供了一个通用的运行时存储器映像,如下图所示:
Linux将这个运行时存储器映像组织成若干段的集合,它主要有两部分:进程虚拟存储器、内核虚拟存储器。进程虚拟存储器有我们熟悉的代码段、数据段、运行时堆、共享库段、用户栈。内核虚拟存储器包括内核中的代码和数据结构、与进程相关的数据结构。以32位系统的可执行文件的运行时存储器映像来说:
- 代码段总是从地址0x08048000处开始,它保存编译程序的机器代码
- data段在接下来的一个4KB对齐的地址处,保存已初始化的全局C变量和静态变量
- bss段记录的是未初始化的全局C变量,事实上它并不占据目标文件的任何空间,只是一个占位符
- 运行时堆在接下来的第一个4KB对齐的地址处,通过调用malloc库向上增长,用于程序的动态内存管理
- 共享库段,用于加载共享库、映射共享内存和文件I/O,使用mmap和unmap函数申请和释放新的内存区
- 用户栈占据进程地址空间的最高部分,并向下增长,用于存放调用过程中的局部变量、函数返回地址、参数
- 内核代码和数据、物理存储器,它们均被映射到所有进程共享的物理页面,这就为内核提供一个便利的方法来访问内存中任何特定的位置。对于每个进程来说他们均是一样的
- 最顶层的内核地址空间包括了与进程有关的数据结构,如页表、内核在进程的上下文结构task_struct和mm结构,内核栈
下面以程序实例来说明下C运行时内存模型各个段的地址划分:
#include <stdio.h>
#include <stdlib.h>
int glob1=120;
int glob2;
extern int etext,edata,end;
int func2(){
int f2_local1,f2_local2;
printf("函数2的局部变量-\t f2_local1: %p \tf2_local2: %p\n",&f2_local1,&f2_local2);
}
int func1(){
int f1_local1,f1_local2;
printf("函数1的局部变量-\t f1_local1: %p \tf1_local2: %p\n",&f1_local1,&f1_local2);
func2();
}
int main(){
int m_local1,m_local2;
int *dyn_addr;
printf("代码段的结束地址: %p\n",&etext);
printf("初始化数据区的结束地址: %p\n",&edata);
printf("未初始化数据区的结束地址: %p\n",&end);
printf("运行时初始可用堆区域的边界地址: %p\n\n",(char *)sbrk(0));
printf("全局变量- glob1: %p \t glob2: %p\n",&glob1,&glob2);
dyn_addr=(int *)malloc(16);
printf("指针变量的值在堆区域- dyn_addr: %p\n",dyn_addr);
printf("函数地址-\t main: %p \tfunc1: %p \tfunc2: %p\n",main,func1,func2);
printf("main函数的局部变量-\t m_local1: %p \tm_local2: %p\n",&m_local1,&m_local2);
func1();
return 0;
}
程序的一次运行结果输出如下:
代码段的结束地址: 0x40085d
初始化数据区的结束地址: 0x60105c
未初始化数据区的结束地址: 0x601068
运行时初始可用堆区域的边界地址: 0x1912000全局变量- glob1: 0x601058 glob2: 0x601060
指针变量的值在堆区域- dyn_addr: 0x1912010
函数地址- main: 0x4006ce func1: 0x40067d func2: 0x400636
main函数的局部变量- m_local1: 0x7fff9221ef38 m_local2: 0x7fff9221ef3c
函数1的局部变量- f1_local1: 0x7fff9221ef10 f1_local2: 0x7fff9221ef14
函数2的局部变量- f2_local1: 0x7fff9221eef0 f2_local2: 0x7fff9221eef4
由结果看出这是一个运行在64位机器上的程序(64位的代码段总是从0x400000),函数地址main,func1,func2的地址均在代码区域中,全局变量glob1的地址在初始化数据区中,全glob2的地址在未初始化数据区中,指针遍历dyn_addr的值在堆区域中,局部变量m_local1、m_local2、f1_local1、f1_local2、f2_local1、f2_local2依次存放在栈(并且可知栈的增长方向是从高地址到低地址,堆的增长方向是从低地址到高地址)