一、动态库的编译
在前面对库的基本知识和应用进行了初步的学习,同时对库文件如何分析有针对性的学习了几个命令。现在开始正式学习一下动态库的整体的生命周期过程中是如何运转的。首先当然是编译,编译器提供了对动态库编译的支持,目前主流的c++/c编译器就那么几种,gcc,cl(vs),clang,曾经也有过宝兰德的TC和BCC,还有一些大公司自有的如INTEL的ICC,另外就是嵌入式里有一些ARM的专用编译器。 目前基本学习c++主流的两大平台,基本就是以cl和gcc为主。大家也可以看到从开始到现在,基本就是在这两个平台中间交替使用,基本以GCC为主。 动态库的编译和普通的可执行文件不同在于,它不是最终的可执行文件,所以其中有相当一部分的地址是不需要链接的,严格的说是不需要在库的阶段链接的。因为不是讲具体的编译原理,所以这里不展开深入分析编译的过程和内容。只要明白,通过编译器就可以把源码转换成库文件即可。至于其中的预处理,优化等会在后面高级篇的编译原理中进行详细的分析。在这个过程中,gcc提供了一系列的参数,可以让编译期产生相应的编译结果,比如使用下列参数:
gcc -E -P -i srcfile -o xxx.i //通过传递参数生成预处理结果 -E -P 前者生成预处理内容;后者简化无用的数据内容
gcc -S srcfile -o xxx.s //生成汇编代码,需要注意的是AT&t 和INTEL汇编的不同
下面看一下前面的例程使用-S命令的后的结果:
.file "compare.cpp"
.text
.section .rodata
.type _ZStL19piecewise_construct, @object
.size _ZStL19piecewise_construct, 1
_ZStL19piecewise_construct:
.zero 1
.local _ZStL8__ioinit
.comm _ZStL8__ioinit,1,1
.text
.globl _Z7Compareii
.type _Z7Compareii, @function
_Z7Compareii:
.LFB1521:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
subq $16, %rsp
movl %edi, -4(%rbp)
movl %esi, -8(%rbp)
movl -4(%rbp), %eax
cmpl -8(%rbp), %eax
jle .L2
movl -4(%rbp), %eax
movl %eax, %edi
call Display
movl -4(%rbp), %eax
jmp .L3
.L2:
movl -8(%rbp), %eax
movl %eax, %edi
call Display
movl -8(%rbp), %eax
.L3:
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE1521:
.size _Z7Compareii, .-_Z7Compareii
.type _Z41__static_initialization_and_destruction_0ii, @function
_Z41__static_initialization_and_destruction_0ii:
.LFB2002:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
上文已经提到过编译的目标文件的内容,各个节的作用和相关的数据分析。这里就不再赘述。但是编译有一个问题,特别是随着应用场景越来越复杂,大量的编译目标文件最终融合拼接,地址的重定位,都无法实现,而这种无法实现,是无法保证目标文件直接进入内存后是否可以运行。所以,这就需要进一步的相关变量函数的重定位,也就需要链接器出场了。
二、动态库的链接
也许会有同学提出,不能实现一个整体的编译器,把这些都搞在一起么,弄这么多事儿干啥。答案是可以的,而且实际就有。但是,在程序里有一句名言,如果问题不好解决,就加一层。用在这里,同样适用。为了保证二进制代码的复用,把链接器单独划分出来很有必要。
1、定位库 链接首先要找到库的位置,也就是说把库文件发现并能够分析。这个比较简单,一般来说有几种常见的方式: 在链接构建时使用链接选项 -L -l rpath;这也是小题目中定位库的方法。 但是很多初学Linux编程的人都需要注意,在上述编译通过后,运行却出现错误找不到相关的库,这时需要在下面重置一下: 使用环境变量LD_LIBRARY_PATH,但是要刻ldconfig;使用系统默认的目录,比如/lib,/lib64,/usr/lib64等;使用runpath选项; 这个问题已经重复过几次了,一定要注意。
2、地址重定位 在编译中提到了,不同的目标文件中的变量和函数的调用会产生重定位的问题,其实链接器第一步只是把不同的目标文件中的相同的段或者说节的内容拼接起来,就好像一个勤劳的园丁,把相同的花朵种植在一起。然后将其映射到指定的地址。这里需要说明的是,这里的地址,不是物理地址,而平坦的虚拟地址。
3、引用链接 在实际编译程序时,经常会遇到,“link error:xxx 函数找不到”之类的链接错误,这其实就是链接器没有在上面的节中找到相关的函数或者变量之类的定义。一般到这个地步,基本说明程序本身的语法错误是没有了。
在C语言中,相对于c++来说,由于没有改名机制,链接要稍微简单一些。至少对程序员分析时来说,要简单一些。 一般来说,同一个目标文件中,这种现象比较好解决,就是相对于起始地址的偏移量大小的问题。主要是跨目标文件时,这是一个很不好控制的问题。看下面的例子:
//add.c
#include <stdio.h>
#include <stdlib.h>
int EXT_DATA = 100;
int Add(int a,int b)
{
a += EXT_DATA;
return a+b;
}
//main.c
#include <stdlib.h>
#include <stdio.h>
int Add(int,int);
extern int EXT_DATA;
int main()
{
int result = Add(100,100);
result = result + EXT_DATA;
printf("cur add result:%d\n",result);
}
使用下面的命令编译:
gcc -c add.c main.c
gcc -o link add.o main.o
分别的编译的目的是为了下面反汇编方便。
使用objdump命令来反汇编: objdump -D -M intel main.o
main.o: 文件格式 elf64-x86-64
Disassembly of section .text:
0000000000000000 <main>:
0: 55 push rbp
1: 48 89 e5 mov rbp,rsp
4: 48 83 ec 10 sub rsp,0x10
8: be 64 00 00 00 mov esi,0x64
d: bf 64 00 00 00 mov edi,0x64
12: e8 00 00 00 00 call 17 <main+0x17> ---注意此处
17: 89 45 fc mov DWORD PTR [rbp-0x4],eax
1a: 8b 45 fc mov eax,DWORD PTR [rbp-0x4]
1d: 89 c6 mov esi,eax
1f: bf 00 00 00 00 mov edi,0x0
24: b8 00 00 00 00 mov eax,0x0
29: e8 00 00 00 00 call 2e <main+0x2e>
2e: b8 00 00 00 00 mov eax,0x0
33: c9 leave
34: c3 ret
objdump -D -M intel link
000000000040050f <main>:
40050f: 55 push rbp
400510: 48 89 e5 mov rbp,rsp
400513: 48 83 ec 10 sub rsp,0x10
400517: be 64 00 00 00 mov esi,0x64
40051c: bf 64 00 00 00 mov edi,0x64
400521: e8 cc ff ff ff call 4004f2 <Add> ------对应
400526: 89 45 fc mov DWORD PTR [rbp-0x4],eax
400529: 8b 45 fc mov eax,DWORD PTR [rbp-0x4]
40052c: 89 c6 mov esi,eax
40052e: bf d4 05 40 00 mov edi,0x4005d4
400533: b8 00 00 00 00 mov eax,0x0
400538: e8 c3 fe ff ff call 400400 <printf@plt>
40053d: b8 00 00 00 00 mov eax,0x0
400542: c9 leave
400543: c3 ret
400544: 66 2e 0f 1f 84 00 00 nop WORD PTR cs:[rax+rax*1+0x0]
40054b: 00 00 00
40054e: 66 90 xchg ax,ax
objdum -D -M intel add.o
00000000004004f0 <frame_dummy>:
4004f0: eb 8e jmp 400480 <register_tm_clones>
00000000004004f2 <Add>:
4004f2: 55 push rbp
4004f3: 48 89 e5 mov rbp,rsp
4004f6: 89 7d fc mov DWORD PTR [rbp-0x4],edi
4004f9: 89 75 f8 mov DWORD PTR [rbp-0x8],esi
4004fc: 8b 05 2e 0b 20 00 mov eax,DWORD PTR [rip+0x200b2e] # 601030 <EXT_DATA>
400502: 01 45 fc add DWORD PTR [rbp-0x4],eax
400505: 8b 55 fc mov edx,DWORD PTR [rbp-0x4]
400508: 8b 45 f8 mov eax,DWORD PTR [rbp-0x8]
40050b: 01 d0 add eax,edx
40050d: 5d pop rbp
40050e: c3 ret
objdump -x -j .data link
SYMBOL TABLE:
0000000000601020 l d .data 0000000000000000 .data
0000000000601030 g O .data 0000000000000004 EXT_DATA ------------全局变量
0000000000601020 w .data 0000000000000000 data_start
0000000000601034 g .data 0000000000000000 _edata
0000000000601020 g .data 0000000000000000 __data_start
0000000000601028 g O .data 0000000000000000 .hidden __dso_handle
0000000000601038 g O .data 0000000000000000 .hidden __TMC_END__
重定向的目标也就是把偏移地址加上后,发现会重新指定了地址,就是Add这个函数的地址。后面的库文件printf也是如此。
不同的目标文件的属性不同,但在上文的分析知道,其主要的内容大致相仿,都包含.data节,.bss节等。所以上面的链接情况同样也适应于动态库。在编译技术中可以采用一些技术(比如预设一些地址空间)配合在链接时的应用。总体上来说,不同的编译器和链接器包括下面的装载器,基本的原理都是类似或者说相同的,但在细节的实现和对中间代码(IR)的处理,有着各自的特点。 这里不再继续深入展开链接器的工作相关内容,有兴趣的可以查看相关资料。没兴趣的话,知道这点差不多也能搞事情了。
三、装载库
一般来说,在类Linux平台上,都是通过Shell来执行可执行的程序,在类UNIX环境中,起初是没有线程的概念的,所以Shell仍然是没用创建子进程的方式来启动一个子进程并继承相关的内存重新在内存中生成一份拷贝,然后由内核对其进行初始化,只保留有用的部分,这时,装载器开始把相关的二进制数据加载到初始化后的相关内存中。 从原则上来说,装载器除了装载,啥事儿也不干,实际情况,却不完全是这样。先看一下装载器的工作流程: 1、装载说明 在上面的链接器中,要精确的来判断和匹配相关的节和数据信息,而装载器则不需要,它只是把相关的数据拷贝到内存中,基本的数据组合和属性设置即可,当然,夹带一些私货这是人之常情。一般来说,如果这个程序是高度自耦合的或者说自闭合的,这事儿就好办。因为加载二进制数据到内存处理就可以了,它不需要使用任何其它库或者仅仅使用系统库。但是前面提到过为什么会产生动态库,因为如果这样做的话,会产生大量的重复的代码。 2、加载 内核装载器在二进制文件查找一个PT_INTERP类型的segment。只要找到它,就可以检索相关的库的信息。按信息寻找,就可以逐一将相关的库找到并进行处理。 3、查找二进制入口 在写程序的时候儿,需要一个main函数做为所有程序的启动入口,装载器在装载二进制数据时,也要查找一下入口,这个就是ELF文件中的e_entry字段的值。通过反汇编可以看到,这个值只是代码段的首地址,而基本上,这个地址就是_start函数的首地址。 入口地址找到后,就可以通过其进一步的进行线程的创建和相关初始化的操作。最终就是把所有的数据加载到进程中,把程序运行起来。
四、总结
在这里需要指出的是,编译的可执行程序是从main函数中启动的,这是程序员们接受的一个普遍的观点。但是,从内核角度来看,它不并是首先执行的代码,真正的可执行代码的启动部分,就是由链接器添加到内存映射中的。它有两种形式即crt0,crt1。前者只是单纯的执行入口代码而后者提供了类似启动程序前后的相关细节处理的任务。而这也恰恰是普通执行文件和动态加的之间的区别,因为库不做为单独执行的手段,没有这块代码是很正常的。 这篇文章仅仅是很简要的把初步的编译、链接和装载的过程分析了一下,没办法展开的原因是任何一项只是稍微的展开,没有十几倍以上的篇幅根本无法说清楚,所以这里只能点到而止。而且重点还是以c++编写调试动态库为导向,介绍这些的目的,是从底层对库的整个工作机理有一个把握,可以更好的解决编写和运行库时遇到的问题。