初识linux(16) 动静态库(手搓动静态库!)-CSDN博客
完成了对动静态库使用的学习,现在浅显理解下动态库加载的原理。
1. 宏观认知
磁盘中的应用程序main和动态库libmystdio.so先加载到内存中
加载到内存后,OS会形成对应的task_struct结构体,task_struct中有对应的mm_struct来管理该进程的虚拟内存。
通过页表的虚拟地址将库映射到堆栈之间的共享区
这样,库中的所有方法就能在进程自己的mm_struct找到。
当正文代码执行到库函数的时候,在共享区找到对应的内容,从而跳转到库中执行并返回。
有了宏观的认识,就能具体学习这个过程了。
2. 可执行程序—ELF格式
size myexe
size + 可执行程序 ,查看一个可执行程序的属性。说明一个可执行程序也有自己的规范化的属性,也是被OS组织管理起来的。
ELF即可执行与可链接格式(Executable and Linkable Format),myexe就是一个ELF文件。类似于win中的.exe,ELF是Linux及Unix中可执行文件的主要可执行文件格式。
可重定位文件(xxx.o)、可执行文件(myexe)、共享文件(xxx.so)、内核转存文件(不作重点理解)都是ELF文件。这一点与win中的.exe不一样。
重点:进程=代码+数据+内核数据结构
text-代码
data-数据
text:可执行代码段的大小,这里是
2324
字节。它包含了程序的机器指令。data:已初始化的全局变量和静态变量所占用的内存大小,这里是
588
字节。bss:未初始化的全局变量和静态变量所占用的内存大小,这里是
4
字节。这个段在程序运行时会被初始化为0或空。
ELF文件 结构:
其中最重要的是section(节)部分,ELF⽂件的各种信息和 数据都存储在不同的节中,如代码节存储了可执⾏代码,数据节存储了全局变量和静态数据等。
而链接的过程,就是将各个文件相同属性的部分合体(包括静态库),3个.text形成一个更大的可执行代码段,3个.data形成一个更大的.data的section
ELF 头 (ELF header) :描述⽂件的主要特性。其位于⽂件的开始位置,它的主要⽬的是定位⽂件的其他部分、以及整个可执行文件的整体信息。readelf -h 可执行程序
以上指令用于查看可执行程序的elf header
![]()
Program Table Header
列举了所有有效的段(segments)和他们的属性。表⾥记着每个段的开始的位置和位移(offset)、⻓度,毕竟这些段,都是紧密的放在⼆进制⽂件中, 需要段表的描述信息,才能把他们每个段分割开。维护了程序的“执行时视图”,用于指导加载器如何将程序加载到内存中,以创建进程映像。这是一个维护“段”的结构如果以上描述让你迷茫,不要着急,后文我们会进一步说明。
此处引出了一个重要的思想:在Linux中,对于任何一个文件来说,文件的内容就可以类比为一个一维数组,访问其内容就是通过「起始值+偏移量+内容大小(可选))」进行获取,所以对于上面所有的类型来说也是如此,当其需要加载到内存时也是通过对应的方式加载需要的内容。比如LOAD表示需要加载进内存的段,第一排表示从0000开始偏移,大小是0d24;
也就是说:
任何一个文件的位置及区间都可以用偏移量+大小来标识
最后一块:
readelf -S(大写) myexe
阅读每个section及其对应的内容信息。
能看到好几个老朋友:
理解段与节:
⼀个ELF会有多种不同的Section,在加载到内存的时候,也会进⾏Section合并,形成segment(段) 也就是在mm_struct中看到的:代码段、已初始化数据区、未初始化数据区等等•合并原则:相同属性,⽐如:可读,可写,可执⾏,需要加载时申请空间等.•这样,即便是不同的Section,在加载到内存中,可能会以segment的形式,加载到⼀起•很显然,这个合并⼯作也已经在形成ELF的时候,合并⽅式已经确定了,具体合并原则被记录在了ELF的 程序头表(Program header table) 中Section合并的主要原因是为了减少⻚⾯碎⽚,提⾼内存使⽤效率。如果不进⾏合并,假设⻚⾯⼤⼩为4096字节(内存块基本⼤⼩,加载,管理的基本单位),如果.text部分为4097字节,.init部分为512字节,那么它们将占⽤3个⻚⾯,⽽合并后,它们只需2个⻚⾯。此外,操作系统在加载程序时,会将具有相同属性的section合并成⼀个⼤的segment,这样就可以实现不同的访问权限,从⽽优化内存管理和权限访问控制。
可以说,section是segment的一个小单位,所以在宏观上,段比节要大一点,数量要少一点
3. 地址空间与 程序从加载到执行
3.1 ELF加载到执行
目前为止,我们对上图的认知不过是将以前加载进内存的文件的格式更加清晰了(ELF)
CPU执行进程,需要知道进程下一步的虚拟地址(如PC指针和EIP寄存器都在使用),磁盘中的可执行文件中的每一条指令都是有地址的,其采用的编址方法(编译器编译代码的时候采用的)叫做“平坦模式”,其中整个地址空间被看作是单一的、连续的线性空间。在这种模式下,所有代码和数据都位于一个大的、平坦的地址范围内,没有分段或分区的概念。可以理解为每一个可执行程序都是从0x00000开始编码的。
类似于下图:
磁盘中的内容加载到内存时,每一条指令也会具备真实的物理地址
可以通过以下代码反汇编查看汇编代码来查看可执行程序的虚拟地址。
objdump -S 可执行程序名
最左侧的就是ELF文件的虚拟地址,也叫逻辑地址(起始地址+偏移量),只不过此时的起始地址都是00000。
所以:虚拟地址机制,不光光OS要⽀持,编译器也要⽀持
加载到内存之前会先建立PCB,建立PCB的时候需要初始化mm_struct和vm_area_struct(维护mm_struct中具体的内存指向)
mm_struct和vm_area_struc的初始化数据是从哪里来的?
答:也是从ELF的各个segment来的!
理论上来说,虚拟内存中的地址和磁盘中被由编译器产生的逻辑地址在数值上是一样的。但是在实际执行中,数值可能会不一样,但是每一部分的数值的相对大小都是一样的。
每个segment有⾃⼰的起始地址和⾃⼰的⻓度,⽤来初始化内核结构中的[start, end]等范围数据,另外在⽤详细地址,填充⻚表.
同时,CPU在获取命令的时候,PC(EIP)指针拿到的其实也是虚拟地址。
这么说的理由是:
名字叫做_start的section作为整个代码区的开始,可以看到其对应的地址:4005d0
myexe的ELF Header中的Entry point address也是这个地址。
当EIP拿到这条地址之后:
CR3存的是物理地址,EIP存的是虚拟地址
每一条指令都有自己的长度(翻译成机器码之后),在程序内部也都是直接使用虚拟地址。
因此编译器在编译程序的时候,完全不用考虑整个真实物理地址,让操作系统和编译器解耦。
3.2 再理解vm_area_struct
前面提到,虚拟地址空间初始化时会由ELF文件中的内容对指定区域进行初始化,但是并没有看到ELF文件中存在对栈、堆和共享区进行初始化的部分,这些部分如何进行的初始化就是下面需要讨论的问题
mm_struct中有一个vm_area_struct的链表,管理了许多vm_area_struct。理论上来说,每一个vm_area_struct就去维护一个段的内容。
真正的栈、堆、共享区等都是一个一个的vm_area_struct对象,由自己的vm_start和vm_end标记各个区域,所以,CPU在访问栈、堆和共享区时实际上访问的也是对应的
vm_area_struct
对象的虚拟地址,在页表中也存在着这些虚拟地址和物理地址的映射。实际运行中,添加一个库就变成了链表中增加一个vm_area_struct;当一个段太大的时候,甚至可以一次只加载一部分的section,先形成vm_area_struct对象,最后再根据实际运行需求加载剩下的部分——以此实现section的懒加载。
甚至,整个系统可以什么都不加载,只加载一个可加载程序ELF Header的entry部分。
4. 动态库的加载
4.1 地址重定位
.so文件本身也是ELF文件的一种,在形成.o并且被打包成.so的过程中,OS也要管理整个系统。
可能也会去形成一个类似的结构体,来管理各个库的信息,比如每当一个进程引用了一个库,ref_count就++等等。
内存中加载了一个库,堆栈之间的共享区就会维护一个新的libstart和libend来维护这个库的
虚拟地址
比如我们的代码中要用一个lib.so:printf,这个函数的位置是0x100
当要call这个函数的时候,就把libc.so换成库的虚拟起始地址,把prinf换成0x100,
通过起始地址+偏移量的方法来映射页表找到并执行这个方法。
库中每⼀个⽅法的偏移量地址我们也知道•所以,想访问库中任意⽅法,只需要知道库的起始虚拟地址+⽅法偏移量即可定位库中的⽅法
看似上面的思路好像没问题,实际上,虚拟地址空间的代码区是不可写的,也就是说,如果进程的代码加载到虚拟地址空间就不无法再更改其中的内容,那么此时又是如何做到使用动态库加载到内存之后的虚拟地址替换进程调用动态库代码的位置的内容呢?
补充,并不是库中的所有内容都会被加载,而是会通过file指针去加载会用到的部分。
4.2 GOT
GOT(Global Offset Table):是一个数据结构,它存储了动态链接符号(如函数或变量)的地址。当动态库首次加载时,动态链接器会更新GOT中的条目以指向正确的内存位置
got存在数据区.data中,即got本来是一个单独的section,在合并的时候和data合并成同一个seagment, 也可以说是专门在数据区中预留了一个区域来存这个全局偏移表。
可以说,got存的就是二次定位之后,该代码模块会用到的所有库函数的虚拟地址,这样在代码区执行call指令的时候就能去got表中查到lib.so的起始地址,这样一来虚拟地址就完整了。
所以,一个动态库之所以可以只加载一次而可以被任何进程所调用,本质就是因为这个GOT表,只需要知道这个GOT表的地址和对应库的下标,即可调用对应动态库中的内容
5.小结
静态链接的出现,提⾼了程序的模块化⽔平。对于⼀个⼤的项⽬,不同的⼈可以独⽴地测试和开发⾃⼰的模块。通过静态链接,⽣成最终的可执⾏⽂件。•我们知道静态链接会将编译产⽣的所有⽬标⽂件,和⽤到的各种库合并成⼀个独⽴的可执⾏⽂件, 其中我们会去修正模块间函数的跳转地址,也被叫做编译重定位(也叫做静态重位)。•⽽动态链接实际上将链接的整个过程推迟到了程序加载的时候。⽐如我们去运⾏⼀个程序,操作系 统会⾸先将程序的数据代码连同它⽤到的⼀系列动态库先加载到内存,其中每个动态库的加载地址 都是不固定的,但是⽆论加载到什么地⽅,都要映射到进程对应的地址空间,然后通过.GOT⽅式进 ⾏调⽤(运⾏重定位,也叫做动态地址重定位)。