库制作与原理
什么是库
库是写好的现有的,成熟的,可以复用的代码。现实中每个程序都要依赖很多基础的底层库,不可能每个⼈的代码都从零开始,因此库的存在意义非同寻常
本质上来说库是⼀种可执行代码的⼆进制形式,可以被操作系统载⼊内存执行。库有两种: 静态库 动态库
静态库的制作
.a静态库本质上是一个归档文件,不需要使用者解包,而用gcc/g++直接进行链接即可
ar 是 gnu 归档⼯具, rc 表⽰ (replace and create)
如果我们要连接任何非c/c++标准库(包括其他外部或我们自己写的,都需要指明 -L -l
动态库
库运行搜索路径
系统找不到
静态库为什么不存在问题?
静态库在链接时,直接把库的实现直接拷贝到可执行程序里,一旦形成可执行程序,就不需要依赖静态库
解决
拷贝.so ⽂件到系统共享库路径下
向系统共享库路径下建⽴同名软连接
更改环境变量: LD_LIBRARY_PATH(os运行程序,要查找动态库,也会在该环境变量下查找动态库(LD_LIBARY_PATH,经常都是空的)
更改系统的配置文件 /etc/ld.so.conf.d/
结论
动静态库同时提供,gcc/g++默认使用动态库
如果使用静态,只能-static,一旦-static就必须存在对应的静态库
在Linux系统下,默认情况安装的大部分库,默认都有先安装的是动态库
库:应用程序=1:n
目标文件
o/obj:可重定位目标文件
编译:编译的过程其实就是将我们程序的源代码翻译成CPU能够直接运⾏的机器代码。
在编译之后会⽣成扩展名为 .o 的⽂件,它们被称作⽬标⽂件。要注意的是如果我们
修改了⼀个原⽂件,那么只需要单独编译它这⼀个,⽽不需要浪费时间重新编译整个⼯程。⽬标⽂件是⼀个⼆进制的⽂件,⽂件的格式是 ELF ,是对⼆进制代码的⼀种封装。
FILE文件
动静态库,可执行文件,.o文件都是FILE格式的
,以一定的格式放入到二进制文件中的
ELF从形成到加载轮廓
FILE形成可执行
step-1:将多份 C/C++ 源代码,翻译成为⽬标 .o ⽂件 •
step-2:将多份 .o ⽂件section进⾏合并
⼀个ELF会有多种不同的Section,在加载到内存的时候,也会进⾏Section合并,形成segment
• 合并原则:相同属性,⽐如:可读,可写,可执⾏,需要加载时申请空间等.
• 这样,即便是不同的Section,在加载到内存中,可能会以segment的形式,加载到一起
• 很显然,这个合并⼯作也已经在形成ELF的时候,合并⽅式已经确定了,具体合并原则被记录在了ELF的 程序头表(Program header table) 中
为什么要将section合并成为segment
• Section合并的主要原因是为了减少⻚⾯碎⽚,提⾼内存使⽤效率。
• 此外,操作系统在加载程序时,会将具有相同属性的section合并成⼀个⼤的segment,这样就可以实现不同的访问权限,从⽽优化内存管理和权限访问控制
链接视图(Linking view) - 对应节头表 Section header table
◦ ⽂件结构的粒度更细,将⽂件按功能模块的差异进⾏划分,静态链接分析的时候⼀般关注的
是链接视图,能够理解 ELF ⽂件中包含的各个部分的信息。
执⾏视图(execution view) - 对应程序头表 Program header table
◦ 告诉操作系统,如何加载可执⾏⽂件,完成进程内存的初始化。⼀个可执⾏程序的格式中,⼀定有 program header table
理解连接与加载
静态连接
我们可以看到这⾥的call指令,它们分别对应之前调⽤的printf和run函数,但是你会发现他们的跳转地址都被设成了0。其实就是在编译 hello.c 的时候,编译器是完全不知道 printf 和 run 函数的存在的.因此,编辑器只能将这两个函数的跳转地址先暂时设为0。
这个地址会在哪个时候被修正?链接的时候!为了让链接器将来在链接时能够正确定位到这些被修正的地址,在代码块(.data)中还存在⼀个重定位表,这张表将来在链接的时候,就会根据表⾥记录的地址将其修正。
两个.o的代码段合并到了⼀起,并进⾏了统⼀的编址
链接的时候,会修改.o中没有确定的函数地址,在合并完成之后,进⾏相关call地址,完成代码调用
所以链接其实就是将编译之后的所有⽬标⽂件连同⽤到的⼀些静态库运⾏时库组合,拼装成⼀个独⽴的可执⾏⽂件。其中就包括我们之前提到的地址修正,当所有模块组合在⼀起之后,链接器会根据我们的.o⽂件或者静态库中的重定位表找到那些需要被重定位的函数全局变量,从⽽修正它们的地址。这其实就是静态链接的过程
ELF加载
⼀个ELF程序,在没有被加载到内存的时候,本来就有地址,当代计算机⼯作的时候,都采⽤"平坦
模式"进⾏⼯作。所以也要求ELF对⾃⼰的代码和数据进⾏统⼀编址,下⾯是 objdump -S 反汇编
之后的代码
就是ELF的虚拟地址,其实,严格意义上应该叫做逻辑地址(起始地址+偏移量), 但是我们认为起始地址是0.也就是说,其实虚拟地址在我们的程序还没有加载到内存的时候,就已经把可执⾏程序进⾏统⼀编址了.
进程mm_struct、vm_area_struct在进程刚刚创建的时候,初始化数据从哪⾥来的?从ELF各个
segment来,每个segment有⾃⼰的起始地址和⾃⼰的⻓度,⽤来初始化内核结构中的[start, end]
等范围数据,另外在⽤详细地址,填充⻚表.
所以:虚拟地址机制,不光光OS要⽀持,编译器也要⽀持.
动态链接和动态库加载
进程如何看到动态库
进程间如何共享的
动态链接
静态链接最⼤的问题在于⽣成的⽂件体积⼤,并且相当耗费内存资源。随着软件复杂度的提升,我们的操作系统也越来越臃肿,不同的软件就有可能都包含了相同的功能和代码,显然会浪费⼤量的硬盘空间。
这个时候,动态链接的优势就体现出来了,我们可以将需要共享的代码单独提取出来,保存成⼀个独⽴的动态链接库,等到程序运⾏的时候再将它们加载到内存,这样不但可以节省空间,因为同⼀个模块在内存中只需要保留⼀份副本,可以被不同的进程所共享。
动态链接实际上将链接的整个过程推迟到了程序加载的时候。
C/C++程序中,当程序开始执⾏时,它⾸先并不会直接跳转到 main 函数。实际上,程序的⼊⼝点
是 _start ,这是⼀个由C运⾏时库(通常是glibc)或链接器(如ld)提供的特殊函数。 在 _start 函数中,会执⾏⼀系列初始化操作,这些操作包括:
1. 设置堆栈:为程序创建⼀个初始的堆栈环境。
2. 初始化数据段:将程序的数据段(如全局变量和静态变量)从初始化数据段复制到相应的内存位
置,并清零未初始化的数据
3. 动态链接:这是关键的⼀步, _start 函数会调⽤动态链接器的代码来解析和加载程序所依赖的
动态库(shared libraries)。动态链接器会处理所有的符号解析和重定位,确保程序中的函数调
⽤和变量访问能够正确地映射到动态库中的实际地址
4. 调⽤ __libc_start_main :⼀旦动态链接完成, _start 函数会调⽤__libc_start_main (这是glibc提供的⼀个函数)。 __libc_start_main 函数负责执⾏⼀些额外的初始化⼯作,⽐如设置信号处理函数、初始化线程库(如果使⽤了线程)等。
5. 调⽤ main 函数:最后, __libc_start_main 函数会调⽤程序的 main 函数,此时程序的执
⾏控制权才正式交给⽤⼾编写的代码。
6. 处理 main 函数的返回值:当 main 函数返回时, __libc_start_main 会负责处理这个返回
值,并最终调⽤ _exit 函数来终⽌程序
全局偏移量表GOT
所以:动态链接采⽤的做法是在 .data (可执⾏程序或者库⾃⼰)中专⻔预留⼀⽚区域⽤来存放函数
的跳转地址,它也被叫做全局偏移表GOT,表中每⼀项都是本运⾏模块要引⽤的⼀个全局变量或函数的地址。
• 因为.data区域是可读写的,所以可以⽀持动态进⾏修改
1. 由于代码段只读,我们不能直接修改代码段。但有了GOT表,代码便可以被所有进程共享。但在不同进程的地址空间中,各动态库的绝对地址、相对位置都不同。反映到GOT表上,就是每个进程的每个动态库都有独⽴的GOT表,所以进程间不能共享GOT表。
2. 在单个.so下,由于GOT表与 .text 的相对位置是固定的,我们完全可以利⽤CPU的相对寻址来找到GOT表。
3. 在调⽤函数的时候会⾸先查表,然后根据表中的地址来进⾏跳转,这些地址在动态库加载的时候会被修改为真正的地址。
4. 这种⽅式实现的动态链接就被叫做 PIC 地址⽆关代码 。换句话说,我们的动态库不需要做任何修改,被加载到任意内存地址都能够正常运⾏,并且能够被所有进程共享,这也是为什么之前我们给编译器指定-fPIC参数的原因,PIC=相对编址+GOT。
库间依赖
不仅仅有可执⾏程序调⽤库
• 库也会调⽤其他库!!库之间是有依赖的,如何做到库和库之间互相调⽤也是与地址⽆关的呢?
• 库中也有.GOT,和可执⾏⼀样!这也就是为什么⼤家为什么都是ELF的格式!
由于动态链接在程序加载的时候需要对⼤量函数进⾏重定位,这⼀步显然是⾮常耗时的。为了进⼀步降低开销,我们的操作系统还做了⼀些其他的优化,⽐如延迟绑定,或者也叫PLT(过程连接表(Procedure Linkage Table))。与其在程序⼀开始就对所有函数进⾏重定位,不如将这个过程推迟到函数第⼀次被调⽤的时候,因为绝⼤多数动态库中的函数可能在程序运⾏期间⼀次都不会被使⽤到。