17.5 程序的加载
可执行文件只有加载到内存以后才能被 CPU 执行。早期的程序加载十分简陋,加载的基本过程就是把程序从外部存储器中读取到内存中的某个位置。随着硬件 MMU 的诞生,多进程、多用户、虚拟存储的操作系统出现以后,可执行文件的加载过程变得非常复杂。
17.5.1 进程的虚拟地址空间
每个程序被运行起来以后,它将拥有自己独立的虚拟地址空间(Virtual Address Space),这个虚拟地址空间的大小由计算机的硬件平台决定,具体地说是由 CPU 的位数决定的。
硬件决定了地址空间的最大理论上限,即硬件的寻址空间大小,比如 32 位的硬件平台决定了虚拟地址空间的地址为 4GB。
程序在运行的时候处于操作系统的监管下,操作系统为了达到监控程序运行等一系列目的,进程的虚拟空间都在操作系统的掌握之中。进程只能使用那些操作系统分配给进程的地址,如果访问未经允许的空间,那么操作系统就会捕获到这些访问,将进程的这种访问当作非法操作,强制结束进程。
对于 32 位平台,只能使用 4GB 的虚拟空间,其中操作系统本身用去了一部分,对于 Linux 来说,进程在执行的时候,可用的虚拟空间不超过 3GB。只有使用 64 位平台才能让进程使用更多的虚拟空间。
17.5.2 加载的方式
程序执行时所需要的指令和数据必须在内存中才能够正常运行,最简单的办法就是将程序运行所需要的指令和数据全都装入内存中,这样程序就可以顺利运行,这就是最简单的静态装入的办法。
为了让更多更大的程序得以运行而不停地增加内存是不实际的,最好在不添加内存的情况下让更多的程序运行起来,尽可能有效地利用内存。
根据程序运行时的 局部性原理,可以将程序最 常用 的部分 驻留 在内存中,而将一些 不太常用 的数据存放在 磁盘 里面,这就是 动态加载 的基本原理。
覆盖装入(Overlay)和页映射(Paging)是两种很典型的动态加载方法,它们所采用的思想都差不多,原则上都是利用了程序的局部性原理。动态装入的思想是程序用到哪个模块,就将哪个模块装入内存,如果不用就暂时不装入,存放在磁盘中。
覆盖加载
覆盖装入的方法把挖掘内存潜力的任务交给了程序员,程序员在编写程序的时候必须手工将程序分割成若干块,然后编写一个小的辅助代码来管理这些模块何时应该驻留内存而何时应该被替换掉。
覆盖装入在没有发明虚拟存储之前使用比较广泛,现在已经几乎被 淘汰 了。
页映射
页映射是虚拟存储机制的一部分,它随着虚拟存储的发明而诞生。
页映射也不是一下子就把程序的所有数据和指令都装入内存,而是将内存和所有磁盘中的数据和指令划分成若干个页,以后所有的加载和操作的单位就使用页。
假设加载由加载管理器来控制。当进程需要读取硬盘中的页,而没有可用虚拟空间时,加载管理器必须判断清除内存中的哪些页。如果选择清除最早分配的内存页,则称之为 FIFO;如果选择清除很少访问到的内存页,则为 LUR,最少使用算法。
实际上,这个所谓的加载管理器就是现代的操作系统的存储管理器。目前几乎所有主流操作系统都是按照该方式加载可执行文件的。
17.5.3 从操作系统角度看可执行文件的加载
可执行文件中的页可能被装入内存中的任意页,如果程序使用物理地址直接进行操作,那么每次页被装入时都需要进行重定位。而有了硬件的地址转换和页映射机制,操作系统动态加载可执行文件的方式跟静态加载有了很大的区别。
进程的建立
从操作系统的角度来看,一个进程最关键的特征是它拥有 独立的虚拟地址空间,这使得它有别于其他进程。
很多时候一个程序被执行同时都伴随着一个新的进程的创建:创建一个进程,加载可执行文件并执行。
在有虚拟存储的情况下,上述过程最开始只需要做三件事情:
- 创建一个独立的 虚拟地址空间
- 读取可执行文件头,并且建立虚拟空间与可执行文件的 映射 关系
- 将 CPU 的指令 寄存器 设置成可执行文件的 入口地址,启动 运行
由于可执行文件在加载时实际上是被映射的虚拟空间,所以可执行文件很多时候又被叫做 映像文件(Image)。
可执行文件与其进程的虚拟空间的映射关系,只是保存在操作系统内部的一个数据结构。Linux 中将进程虚拟空间中的一个段叫做虚拟内存区域(VMA, Virtual Memory Area)。操作系统在内部保存这种结构,为当程序执行发生段错误时,它可以通过查找这样的一个数据结构来定位错误页在可执行文件中的位置。
页错误
当 CPU 开始打算执行入口地址的指令时,如果发现该页面是个空页面,它就认为这是一个页错误(Page Fault)。
CPU 将控制权交给操作系统,操作系统有专门的页错误处理例程来处理这种情况。此时,操作系统会查询代表映射关系的数据结构,找到空页面所在的 VMA,计算出相应的页面在可执行文件中的偏移,然后在物理内存中分配一个物理页面,将进程中该虚拟页与分配的物理页之间建立映射关系,然后把控制权再还回给进程,进程从刚才页错误的位置重新开始执行。
随着进程的执行,页错误也会不断地产生,操作系统也会为进程分配相应的物理页面来满足进程执行的需求。有时进程所需要的内存会超过可用的内存数量,特别是在有多个进程同时执行的时候,这时候操作系统就需要精心组织和分配物理内存,甚至有时候应将分配给进程的物理内存暂时收回。
17.5.4 进程虚拟空间分布
ELF 文件链接视图和执行视图
在一个正常的进程中,可执行文件中包含的往往不止代码段,还有数据段、BSS 等,所以映射到进程虚拟空间的往往不止一个段。当段的数量增多时,就会产生 空间浪费 的问题。每个段在映射时的长度都是系统页长度的整数倍;如果不是,其多余部分也会占用一个页。
操作系统加载可执行文件时,并不关心可执行文件各个段所包含的实际内容,只关心与加载相关的问题,主要是段的权限(可读、可写、可执行)。
ELF 文件中,段的权限往往只有为数不多的几种组合,基本上是三种:
- 可读可执行的段:代码段
- 可读可写的段:数据段和 BSS 段
- 只读的段:只读数据段
对于 相同权限的段,把它们 合并 到一起当作一个段进行映射。这样可以把原先的多个段当做一个整体进行映射,明显地减少页面内部碎片,节省内存空间。这个称为 Segment
,它表示一个或多个属性类似的 Section
,可以认为 Section 是链接时的概念,Segment 是加载时的概念。链接器会把属性相似的 Section 放在一起,然后系统会按照这些 Section 组成的 Segment 来映射,并加载可执行文件。
栈和堆
进程的虚拟地址空间中除了被用来映射可执行文件的各个 Segment 之外,还有包括栈(Stack)和堆(Heap)的空间,一个进程中的栈和堆在也是以虚拟内存区域(VMA, Virtual Memrory Area)的形式存在。操作系统通过给进程空间划分出一个个的 VMA 来管理进程的虚拟空间,基本原则是将相同权限属性的、有相同映像文件的映射成一个 VMA,一个进程基本可以分为如下几种 VMA 区域:
- 代码 VMA,权限只读,可执行,有映像文件。
- 数据 VMA,权限可读写,可执行,有映像文件。
- 堆 VMA,权限可读写,可执行,无映像文件,匿名,可向上扩展
- 栈 VMA,权限可读写,不可执行,无映像文件,匿名,可向下扩展
17.4 库
Library
库是编译好的代码,可以被程序复用。库简化了程序员的工作,它们提供可重用的函数、类、数据结构等,而这些是之前由其他程序员编写的,现在可以被其他程序员所使用。
例如,如果要构建的程序需要进行数学运算,你无需再编写数学函数,只需要使用库中现有的函数就可以了。
Linux 中常见的库有:
- libc :标准的 C 程序库
- glibc:GNU 版本的标准 C 程序库
- libcurl:多协议文件传输库
- libcrypt:用于加密、hash
Linux 支持两类的库:
- 静态库:编译期间被静态捆绑到程序中,扩展名为
.a
或.lib
- 动态库:程序启动时加载,在运行时绑定。扩展名为
.so
17.4.1 静态库
- 文件名通常为
libxxx.a
- 静态函数库在编译时会 整合 到可执行程序中,因此最终文件比较大
- 编译成功的可执行文件可以 独立运行,无需读取外部函数库
- 如果函数库升级,整个可执行文件须重新编译
17.4.2 共享库
Linux 大多使用共享库。
- 文件名通常为
libxxx.so
- 共享库在编译时,仅在程序中保存一个指针,指向函数库。只有在可执行文件需要要使用函数库时,程序才去读取。文件比较小。
- 共享库编译的程序不能独立运行,依赖固定路径下的函数库。
- 函数库升级后,可执行文件无需重新编译。
大多数操作系统把解析共享库作为程序加载过程的一部分。在这些系统上,可执行文件包含一个叫做 import directory
的表,该表的每一项包含一个库的名字。根据表中记录的名字,加载程序在硬盘上搜索需要的库,然后将其加载到内存中,之后,用库的地址来更新可执行程序。
可执行程序根据更新后的库信息,调用库中的函数或引用库中的数据。这种类型的动态加载称为加载时(load-time)加载,Windows 和 Linux 均采用这种方式。加载程序在加载应用软件时要完成的最复杂的工作之一就是加载时链接。
可以动态链接的函数库,在 Windows 上叫动态链接库 Dynamic Link Library(DLL),在 UNIX 或 Linux 上叫 Shared Library
,共享库。
库文件是预先编译、链接好的可执行文件,存储在计算机的硬盘上。大多数情况下,同一时间多个应用可以使用一个库的同一个副本,操作系统不需要加载这个库的多个实例。
动态链接的最大 缺点 是:可执行程序的运行 依赖 于单独存储的库文件。如果库文件被删除、移动、重命名或者被替换为不兼容的版本,可执行程序就无法正常工作,即常说的 DLL-hell。
共享库的命名
为了使用方便,共享库通常使用库名来命名,也叫 soname。实际上 soname 是指向其文件名的 绝对路径 的符号链接。
如 libc 的库名为 libc.so.6
:
lib
前缀c
C 程序so
共享库6
版本号
该库的文件名为 /lib64/libc.so.6
。
17.4.3 共享库的加载
共享库是由程序 ld.so
及 ld-linux.so.x
加载的,其中 x 代表版本号。在 Linux 中,/lib/ld-linux.so.x
会查找并加载程序所有的共享库。
程序可以使用其库名或文件名来加载共享库。共享库通常位于 /usr/local/lib
,/usr/local/lib64
,/usr/lib
,/usr/lib64
目录中,系统启动需要的库位于 /lib64/
,/lib/
目录中,内核的库位于 /lib/modules/
中。也允许程序把库安装到自定义的位置。
库的路径在配置文件 /etc/ld.so.conf
中定义,通常默认只有一条语句 include ld.so.conf.d/*.conf
,因此内核会加载 /etc/ld.so.conf.d/
目录中的所有配置文件。
按照这个思路,软件包的维护者或程序员就会把他们的自定义库的路径加到搜索列表中:
在 /etc/ld.so.conf.d/
目录中新建一个自己的配置文件,如 mariadb-x86_64.conf
,其内容为自定义库的路径 /usr/lib64/mysql
。这样,mysql 的库路径就被加到系统库路径中了。
17.4.4 共享库高速缓存
把共享库载入高速缓存内存中,这样,程序在需要调用共享库时可以直接访问内存,提高效率。
在 /etc/ld.so.conf
中指定共享库所在的目录,由 ldconfig
程序把 /etc/ld.so.conf
中指定的目录中的共享库读入高速缓存。将共享库映射保存在 /etc/ld.so.cache
文件中。
/etc/ld.so.cache
这个文件是个二进制文件,其作用为缓存一系列映射:共享库文件名 到其 完整路径 的映射。如libXt.so.6
–/usr/lib/x86_64-linux-gnu/libXt.so.6
这样的映射。有了这个映射表就可以快速找到程序所需的共享库。每次往
/usr/lib/
等系统目录安装新的共享库时,都需要运行ldconfig
命令来更新该缓存文件。
可以通过创建 /etc/ld.so.conf.d/*.conf
文件,使 ldconfig 在更新缓存时自动添加自定义配置文件中指定的目录中的共享库。
ldconfig
语法
ldconfig [-f conf] [ -C cache]
ldconfig [-p]
-f conf
手动指定配置文件 conf,从中读取共享库路径
-C cache
手动指定缓存文件 cache
-p
列出当前所有共享库 (即 /etc/ld.so.cache
的共享库)
范例:手动指定配置文件
~]# vim /etc/ld.so.conf.d/neo.conf
/usr/lib64/mysql
~]# ldconfig
以上操作会把 Mariadb 的共享库读入高速缓存。
17.4.5 管理共享库
使用 ldd
程序来分析可执行文件中含有哪些共享库。
ldd [-vdr] [filename]
-v
:列出所有信息
-d
:重新检查共享库,并列出丢失的
-r
:列出 ELF 的错误内容
范例一:查看指定程序的共享库
~]# ldd /usr/bin/passwd
libpam.so.0 => /lib64/libpam.so.0 (0x00007f5e683dd000) # PAM
libpam_misc.so.0 => /lib64/libpam_misc.so.0 (0x00007f5e681d8000)
libaudit.so.1 => /lib64/libaudit.so.1 (0x00007f5e67fb1000) # SELinux
libselinux.so.1 => /lib64/libselinux.so.1 (0x00007f5e67d8c000) # SELinux
........
范例二:查看指定共享库的相关共享库
~]# ldd -v /lib64/libc.so.6
/lib64/ld-linux-x86-64.so.2 (0x00007f7acc68f000)
linux-vdso.so.1 => (0x00007fffa975b000)
Version information: # -v 选项会显示其他版本信息
/lib64/libc.so.6:
ld-linux-x86-64.so.2 (GLIBC_2.3) => /lib64/ld-linux-x86-64.so.2
ld-linux-x86-64.so.2 (GLIBC_PRIVATE) => /lib64/ld-linux-x86-64.so.2
升级安装 RPM 软件时,可以先用 ldd
来检查共享库之间的依赖关系。用 -v
参数可以了解该共享库来自于哪个软件。