【动态库的加载】【进程地址空间(三)】
目录
- 1. 宏观看待动态库的加载
- 2. 进程地址空间第二讲
- 2.1 程序没有加载前的地址
- 2.2 程序加载后的地址
- 3. 动态库的地址
再谈进程地址空间时,【Linux】动静态库 我们先讲一个关于动态库是如何加载的话题,再引入进程地址空间,再次谈论该话题。
1. 宏观看待动态库的加载
当一个可执行程序被运行时,操作系统要为其创建内核数据结构 PCB、进程地址空间 和 页表,然后再把该程序的代码和数据从外设加载到内存中。可执行程序也是一个文件,只要是文件,就有路径和 inode,那么就可以通过路径找到该文件所在分区,再根据 inode 编号找到所在分组。而在加载可执行程序之前,需要先找到该文件,就可以通过其路径和 inode 找到其文件属性 + 文件内容,然后把它们加载到内存。
换言之,动态库也是一个文件,也有路径和 inode。当我们的程序需要访问动态库的代码时,那么这个动态库也需要被加载到内存。但是进程之间是相互独立的,一个进程是无法直接访问另一个进程的代码和数据的!所以动态库经过页表映射到进程地址空间中的共享区,而我们自己的进程的代码是位于正文代码区的,当我们的代码想调用动态库的方法时,只需要调整到共享区,执行动态库的代码,执行完后原路返回即可。
也可以理解为,建立映射后,我们执行的任何代码,都是在我们的进程地址空间中执行的!
接着,我们需要建立一个共识,一个进程是可能加载多个动态库的,反之,一个动态库也可能跟多个进程产生关联。所以在系统运行中一定会存在多个动态库,系统中有许多进程、文件,因此它们都需要被管理起来,同理,动态库也需要被操作系统所管理起来,换言之,操作系统对所有动态库的加载情况是非常清楚的。所以如果后续有另一个进程想要访问相同的动态库,操作系统能够分辨该库是否已经被加载,如果已经被加载到内存了,只需要在该进程的页表中做一下映射关系,该进程就能够跳转执行该库的代码。
-
C 标准库 libc.so 中有一个全局变量 errno,如果有多个进程共同访问这个库,并且自己的程序运行时都出问题了,即 errno 被写入了。它们访问的是同一个库,那么多进程之间会互相影响吗??
共享区是位于堆栈之间的一段空间,属于用户空间。所以只要是对共享数据做写入操作,都会导致进程发生写时拷贝!我们之前讲用户缓冲区时就有过一个案例,在调用各种 fprint、fwrite 之后 fork() 创建子进程,C 式接口的输出信息输出了两次,因为缓冲区刷新的本质也是写入,所以进程对缓冲区做了写时拷贝,而缓冲区也是位于共享区中的!
2. 进程地址空间第二讲
2.1 程序没有加载前的地址
-
程序编译完成之后,运行之前,程序内部有地址的概念吗?
程序内部有地址。在 vs 编译调试时,如果你转到汇编上,你就可以看到编译之后的每条代码,都是有地址的,包括函数名、变量名,在编译之后都是以地址的形式进行地址,调用一个函数,会转变为 call 一个函数的地址。再诸如 C++ 的继承多态中的虚函数表,在编译时就已经为虚函数表分配地址了,包括表内有各种派生类的方法的地址。
并且,在编译时为各段代码分配的地址,该地址是有分段的,诸如进程地址空间中分成了代码区、未初始化、已初始化等等各种区域,这是为了方便后续加载代码设计的,也就是说编译器,也是要考虑操作系统的(编译器编译的程序是要被操作系统加载的,所以需要照顾该程序加载到内存的问题,即进程地址空间的分段)。而在编译时给程序分配的地址,是虚拟地址!只不过如果该程序还没有被加载,我们更多的称为 逻辑地址。
objdump -S a.out
2.2 程序加载后的地址
程序加载后,就变为进程,所以探讨程序加载后的地址,其实就是探讨进程的地址。
当一个程序加载到内存,它的代码和数据在内存中也一定要占据内存空间(不管是从语言代码的角度看待,还是汇编指令的角度),这个程序的每一条指令 / 代码都有相应的 物理地址 ,但是这与上述说的,程序在编译完后,还没被加载的时候就已经有地址这件事不冲突,加载到内存后,代码存储在物理内存上需要空间,因此它有对应的物理地址,但是这个地址不是代码数据内部的地址,只是它存储在物理内存的位置而已,该程序内部各条指令 / 代码还是采用的虚拟地址(就好比每个学生都有自己的学号,等到了考场上,每个考生都有一个座位号,但不影响你有自己的学号这件事,座位号自己记录你的位置,学号才是你这个人的代表)。
-
当进程的 PCB、进程地址空间、页表等结构都创建完成之后,该进程如何执行第一条指令呢
当一个程序被编译完成之后,程序内部的表头就已经存储了一个 entry 程序入口地址的信息,这个入口地址也是逻辑地址(因为编译后就有这个地址了,还没加载到内存,所以不可能是物理地址)。而 CPU 为了知道下一次执行哪一条指令,在 CPU 内部维护了一个 PC 寄存器,用于存储下一条执行的指令地址。而当一个程序被加载到内存时,其入口地址就已经被加载到 CPU 内部的 PC 寄存器中了。而因为在编译后,入口地址就是虚拟地址了,因此 CPU 可以直接访问这个虚拟地址,然后开始执行该程序。
接着,顺着这个虚拟地址到页表中寻址映射的物理地址,如果此时发现该程序还没有建立物理地址的映射(即该程序还没有被加载到内存中),那么就发生缺页中断,等程序的代码和数据加载到内存中了,每条指令天然的就有了物理地址,加上程序内部自己的虚拟地址,就能够完成对页表的虚拟地址到物理地址的映射!
顺着程序中的代码执行下去,当下一条指令遇到函数调用处,其代码会被解释为 call 一个地址,假设 call 400450,那么 CPU 读取到的指令就是地址,这个地址是代码编译完后形成的地址!因此 CPU 读取到的地址也是虚拟地址!然后 CPU 再顺着该虚拟地址到进程地址空间中寻址,接着通过页表映射到物理地址,再次访问物理地址。后续可能还有调用函数,CPU 还是读取到虚拟地址,然后再顺着虚拟地址映射到物理地址进行访存,这不就是一个环吗! 执行该程序一套操作下来,你应该要知道,CPU 读取到的地址,全部都是虚拟地址!
所以现在也就能够进一步理解,编译器再设计的时候,就已经考虑到进程地址空间了,编译后形成程序内部的地址,即虚拟地址,等程序变为进程时,内部的地址直接套用即可,这也是编译器与操作系统互相协同最重要的表现之一。
3. 动态库的地址
- 绝对地址:进程地址空间中规定的 0x0000 0000 ~ 0xFFFF FFFF 这样的地址,称为绝对地址
- 相对地址:比如 int a 变量在虚拟地址中的地址为 0x11223344,int b 变量在距离 a 变量地址之后的 4 字节,即0x11223348,这样的描述称为相对地址。
现假设可执行程序 a.out 中调用了 libc.so 库的 printf 方法,所以当可执行程序运行起来,加载到内存之后,执行到调用处,在虚拟地址映射物理地址时,发现动态库还没有被加载到内存中,于是缺页中断,等动态库加载完成并且在页表建立中建立了映射关系之后,进而根据 cpu 读取到的 printf 的地址(虚拟地址 0x11223344)跳转到共享区执行库的方法。
-
现在的问题是:动态库要映射到共享区的哪个地方呢??
当我们的程序编译完成后,假如给动态库 libc.so 分配了 0x11223344 这样的地址,即地址已经被硬编码到程序内部了,所以当程序调用 printf 时,只能映射到虚拟地址中的 0x11223344,换言之,在虚拟地址中,动态库就必须被加载到 0x11223344 的位置处,如果不加载到这个位置处,将来程序跳转时,就找不到动态库。但是当系统加载了多个库时,凭什么保证 0x11223344 处一定加载的是 libc.so 这个动态库,可能还有其它库也同时被加载到内存呢??换言之,操作系统可无法保证哪个库一定被加载到固定的地方。所以在虚拟内存中,库的加载是任意位置的。
所以在编译时,会让库函数不采用绝对编址,只采用偏移量表示每个函数在库中的偏移位置,进而找到对应的库函数。所以当动态库被任意的加载到虚拟地址的某一处时,操作系统只需要记住这个库的起始地址 start 即可, 当程序执行时调用库函数,程序即可根据 start + 编译时库函数形成的偏移量,即可跳转到对应库的对应方法处!
讲 【Linux】动静态库 时,对如何编译形成动态库,当时有一个选项:
-fPIC
产生与位置无关码,产生与位置无关码的意思就是告诉编译器,不要再采用绝对编制了,直接用偏移量对库函数进行编制! -
静态库为什么不谈加载,不谈与位置无关这些概念?
现在我们就对动静态库的区别的理解更进一步了,为什么只有动态库有加载的概念,因为静态库是拷贝策略,自己的程序调用了库方法,用什么我拷什么即可,需要的库方法已经成为我的可执行程序的一部分了,所以没有静态库需要加载的说法。而当我们的程序被加载到内存时,在程序内部,任何指令可是都已经硬编址好的了,方法都已经拷贝到我自己的程序内部了,哪一条指令加载到虚拟地址的哪一地址处,都是清清楚楚的,还需要用什么偏移量?!还需要怕找不到库的问题吗?!(库方法就在你程序里面)
如果感觉该篇文章给你带来了收获,可以 点赞👍 + 收藏⭐️ + 关注➕ 支持一下!
感谢各位观看!