< 程序员的自我修养 > 读书笔记

程序员的自我修养 -- 链接、装载与库

程序员可以不自己造轮子,但是对轮子的构造了解的越详细越好,这就是程序员的自我修养吧。

学习偏底层的知识可能无法对日常的学习工作产生直接的影响,但会带来潜移默化的帮助:

技术优劣取决于需求。

基本功永不过时。

Table of Contents

ch1 温故而知新

计算机软硬件基本结构。

硬件的关键部件:

CPU频率低 -> CPU 倍频 -> 多核 CPU。

早期计算机没有复杂的图形功能,CPU核心频率不高,等于内存的频率,因而它们都直接连接在一根总线(Bus)上。

北桥,南桥。

随着技术的发展,CPU频率有了很大的提升,加上图形功能的要求,此时CPU频率是内存频率的倍数,内存频率跟不上而保持与总线频率一致,CPU以倍频方式与总线进行通信。3D游戏和多媒体的发展促使了图形芯片的诞生,而图形芯片需要CPU和内存之间大量交换数据,I/O总线实在太慢,人们因而设计了高速的北桥芯片方便高速交换数据。

北桥运行速度高,如果低速设备也直接连在北桥,无疑会造成非常复杂的设计。因而引入南桥专门处理低速设备。

通过增加CPU数量来提升速度,也就是多核CPU。

硬件处理能力是有限的,操作系统需要做的,就是除了提供抽象接口之外,管理硬件资源。

硬件的抽象,在 UNIX 系统中,访问硬件同访问文件方式一样。

文件系统。磁盘存储方式。物理扇区,逻辑扇区。

不能让 CPU 一次只能运行一个程序 -> 多任务(multi-tasking)系统:

  1. 每个应用程序都以进程(Process)的方式运行。
  2. 每个进程有自己独立的地址空间,进程之间的地址空间相互隔离
  3. CPU 由系统根据进程优先级高低统一分配
  4. 抢占式(Preemptive)的 CPU 分配机制可以让系统强制剥夺 CPU 资源并分配给它认为目前最需要的进程

如果分配给每个进程的时间都很短,CPU 在多个进程间快速的切换,就能造成很多进程同时运行的假象。

如何将计算机的物理内存分配给多个程序使用。增加中间层,使用间接的地址访问方法。

进程 -> 虚拟地址(中间层) -> 物理地址。

分段(Segmentation):基本思路就是程序需要多少内存,就设置多大的虚拟内存并映射到某个物理地址空间,只要程序访问虚拟空间的地址超出了设置的内存大小,硬件自动判断为非法访问,拒绝请求。这样它实现了地址隔离,同时程序不需要重定位,不需要关心物理地址的变化(因为只需要按照虚拟空间的内存分配来编写程序)。没有解决内存使用效率不高的问题。分段对内存区域的映射还是按照程序为单位,如果内存不足,依然会以整个程序为单位进行换入换出,造成大量磁盘访问操作从而严重影响速度。

分页(Paging):分页的就是把地址空间人为地等分成固定的页,以页为单位进行数据的存取和交换。通过一个叫MMU(Memory Management Unit)的部件来实现页映射。

线程,轻量级进程。程序执行流的最小单元。

一个进程包含多个线程,每个线程由线程ID、当前指令指针(PC)、寄存器集合和堆栈组成,各个线程之间共享程序的内存空间和进程级的资源,同时又互不干扰的并发执行。

多线程比多进程更自由;相对于多进程,多线程在数据共享方面效率高很多。

在线程调度中,线程至少有三种状态:

线程调度:优先级调度,轮转法。

IO 密集型更容易得到优先级提升。

Linux 将所有执行实体(无论是进程还是线程)都称为任务(Task),不同任务可以选择共享内存空间。实际意义上,多个共享内存空间的任务组成了一个进程,这里面每个任务也就是这个进程里的线程。

Linux 中通过 fork 复制当前进程。

线程安全,保证共享数据在多线程并发时的一致性。

原子操作,单指令,线程安全,但只适用于简单的场景。

同步与锁。

同步,就是一个线程还没访问完一个数据,其他线程不能再对该数据进行访问,从本质上讲,保证了对数据的访问是原子化的。

锁是一种常见的同步机制,每个线程在访问数据前先 acquire 锁,访问结束后 release 锁;在锁已经被占用的时候试图获取锁时,线程会等待,直到锁被释放。

二元信号量(Binary Semaphore),只有两种状态:占用和非占用。占用时其他线程无法获取锁,直到被释放转为非占用。非占用时第一个线程自然可以获取锁。

如果资源能够允许多个线程并发访问,那就是多元信号量(Semaphore)。显然二元信号量是特殊情况。一个初始值为N的信号量允许N个线程并发访问:

--semaphore;
if (semaphore < 0) {
  current thread waiting;
} else {
  继续执行,访问完资源后,释放信号量
  ++semaphore;
  if (semaphore < 1) {
    唤醒一个等待中的线程
  }
}

互斥锁(Mutex):类似二元信号量,区别在于其要求哪个线程获取了 mutex,哪个线程就要负责释放它。

读写锁(Read-Write Lock):对于读次数 >> 写次数的情况适用。

条件变量(Condition Variable):实现事件通知机制。一个条件变量可以被多个线程等待。当某个事件发生时,条件变量被唤醒,可以通知其他线程恢复执行。

多线程模型(线程的实际并发执行是由操作系统完成的):

(这里的 X 对 X 指的是 User Thread 和 Kernel Thread 的对应,注意这里的 Kernel Thread 不是 linux 内核里的 kernel_thread)


ch2 编译和链接

IDE 往往提供的 Build (构建)功能,实际包括编译和链接。

预编译,生成 .i 文件

gcc -E hello.c -o hello.i

编译:

gcc -S hello.i -o hello.S

现代版本的 GCC 把预编译和编译合并成一个步骤,使用 cc1 程序完成:

cc1 hello.c

gcc 这个命令是各种后台程序的包装(cc1, 汇编器 as,链接器 ld)。

汇编:

gcc -c hello.c -o hello.o

链接:

ld -static crt1.o crti.o ......

实际链接了一大堆东西。

人们把每个源代码模块独立地编译,然后按照须要将它们"组装"起来,这个组装模块的过程就是链接(Linking)。

编译器是什么?一个将高级语言翻译成机器语言的工具。

链接器的年龄比编译器长。

链接过程主要包括了地址和空间分配(Address and Storage Allocation)、符号决议(Symbol Resolution)和重定位(Relocation)等这些步骤。

符号决议有时候也被叫做符号绑定(Symbol Binding)、名称绑定(Name Binding)、名称决议(Name Resolution),甚至还有叫做地址绑定(Address Binding)、指令绑定(Instruction Binding)的,大体上它们的意思都一样,但从细节角度来区分,它们之间还是存在一定区别的,比如"决议"更倾向于静态链接,而"绑定"更倾向于动态链接,即它们所使用的范围不一样。在静态链接,我们将统一称为符号决议。

重定位(Relocation):重新计算各个目标的地址的过程。

若引用其他文件中的变量,在将该文件编译成目标文件时,先将该变量目标地址置为0,等待链接再将该地址进行修正。这个地址修正的过程也被叫做重定位(Relocation),每个要被修正的地方叫一个重定位入口(Relocation Entry)。重定位所做的就是给程序中每个这样的绝对地址引用的位置"打补丁",使它们指向正确的地址。

符号(Symbol)这个概念随着汇编语言的普及迅速被使用,它用来表示一个地址,这个地址可能是一段子程序(后来发展成函数)的起始地址,也可以是一个变量的起始地址。

模块之间如何组合的问题可以归结为模块之间如何 通信 的问题。通信方式:函数调用,变量访问,都需要知道相应的地址。模块间通过符号引用来实现通信,也就是要找到对应的地址。

ch3 目标文件里有什么

目标文件(.o 或 .obj),从结构上来说,它就是编译后的 可执行文件格式 ,只是还没有经过链接,其中 可能有些符号地址还没有被调整

可执行文件格式,Windows 下 PE(Portable Executable)和 Linux 下的 ELF(Executable Linkable Format)。它们都是 COFF (Common file format) 的变种。

Linux下命令: file <filename> 显示出对应文件的类型

目标文件包含的内容:编译后的机器指令代码、数据,还有链接时要的一些信息(比如符号表、调试信息、字符串等)。

一般目标文件把上述信息按不同的属性,以节(section)的形式存储。有时也叫段(segment)

代码段常见的名字有 “.code” “.text”,编译后的机器指令就放在代码段。

数据段:一般名字都是 “.data”,已初始化全局变量和局部静态变量数据放在这里。

ELF文件的开头是一个”文件头”,它描述了整个文件的文件属性(是否可读可写可执行,是静态链接还是动态链接及入口地址)、目标硬件、目标操作系统等信息。

文件头还有一个段表(section table)。描述文件各个段的数组(各个段在文件中的偏移和属性)。

关于bss段:未初始化的全局变量和静态局部变量一般放在一个”.bss”段的地方。它只是为未初始化的全局变量和静态局部变量预留位置而已,并没有内容,在文件中不占空间。

总体来说:程序源代码被编译后主要分成两种段:程序指令(代码段),程序数据(数据段,bss段)。

为什么要将程序指令和程序数据分开放?

objdump 查看各种目标文件的结构和内容, objdump –h main.o -h 表示把ELF文件的各个段的基本信息打出来

readelf -- 专门针对ELF文件格式的解析器

ELF文件结构:

链接的接口 -- 符号(Symbol):

特殊符号:ld链接器产生可执行文件时,会给我们定义很多符号(没有在自己的程序中定义),但是可以直接声明并且引用它,我们称之为特殊符号。

符号修饰与函数签名:c++增加了名称空间(namespace)的方法来解决多模块之间的符号冲突问题。c++为了与c兼容,在符号的管理上,c++有一个用来声明或定义一个C的符号extern”C”的关键字用法,可以让c++的名称修饰机制不作用。

弱符号与强符号,弱引用与强引用。弱符号和弱引用对库来说非常有用,比如库中定义的弱符号可以被用户定义的强符号所覆盖。

调试信息:在gcc编译时加上 -g 参数就会在产生的目标文件里面加上调试信息。目标文件会多一些 debug 段。


ch4 静态链接

空间地址分配有按序叠加和相似段合并两种方法,一般都使用相似段合并的方法。最后的可执行文件当中包含了可重定位的.o文件里面的所有指令。

按序叠加会导致有很多零散的段,非常浪费空间。对于 x86 的硬件,段的装载地址和空间的对齐单位是页,即 4096 字节,如果按序叠加,即使一个段只有 1 个字节,也需要占用 4096 字节。

现代链接器一般使用两步链接:

为什么需要重定位?

指令位置分类:

在程序设计编译链接过程会给程序一个运行地址,而且必须给编译连接器指定这个地址,最后得到的二进制程序是和指定的链接地址相关的,这个地址叫做”链接地址”。所以我们在程序编译时其实就已经知道程序将来运行时的地址,这个地址叫做”运行地址”,运行地址和链接地址相关,但是不一定是同一个,程序执行时必须放在指定的链接地址下,否则不能运行,这些程序指令就是位置相关代码。

链接地址和运行地址有时候不能相同,而且不能全部使用位置无关指令,则需要重定位来解决该问题。

在位置无关代码执行完毕之前和位置相关代码开始之前,必须将代码搬移到链接地址上去,否则后面的位置相关代码将会出错,所以才需要进行重定位。

符号解析。我们目标文件中用到的符号可能被定义在其他目标文件,所以要将目标文件链接起来。最常见的错误之一:undefined symbol,就是链接时符号未定义,一般是链接时缺少某个库。

重复代码消除,例如 C++ 的模板技术使得模板 可以在多个源文件中分别实例化 但是编译器并不能知道它在多处被同一种数据类型实例化,所以现在主流编译器例如GNU 的做法是在每一个目标文件中对于一个模板的同一种实例化使用一种相同的名称,这样在链接阶段,链接器会检查这些重复的段并只保留一份。

函数级别链接: 通常的链接过程都是文件或者编译单元级别的链接,但是当只需要使用某个目标为见中的一个函数或变量的时候,就需要全部包含该文件,导致体积很大,编译器为此专门提供了函数级别的链接,与重复代码消除和相似,编译器将所有函数都想模板函数一样单独保存到一个段中,需要的时候再将其包含到输出文件,其他的则直接抛弃,这虽然较小的最终文件的体积但是由于段的数目增减,减慢了编译和链接的过程。GCC使用 -fdata-sections-ffunction-sections 可以将变量或者函数分别保存到独立的段中。

全局构造与析构: 全局对象的构造在main函数之前执行,全局对象的析构在main函数之后哦执行,Linux下的入口函数是 _start ,用于在main执行前进行初始化。

为了 使得不同平台的目标文件兼容,即可以相互链接 ,这些文件必须有一致的ABI(Application Binary Interface),即二进制兼容,ABI内容包括符号修饰标准,变量内存布局,函数调用方式等等。厂商不希望用户看见自己的源代码所以会提供二进制版本,所以二进制兼容在大型项目中变得很重要。

API 和 ABI 的区别,API 描述源代码级别的接口,ABI 描述二进制级别的接口。比如,C++ 对象内存布局(Object Memory Layou)是 C++ ABI 的一部分。

ABI 的存在意义:人们一直希望不用修改代码就能实现程序的重用。

C++ 一直为人诟病的一大原因就是其 ABI 兼容性差。

静态库:可以看成一组目标文件的集合。

一个静态库文件(.a)是由许多.o文件合并而来的,linux下使用 ar -t xx.a 可以查看 .a 文件中包含的 .o 文件。

链接过程控制,有链接控制脚本。

BFD(Binary File Descriptor libary)库:是基于所有硬件平台(不同的处理器和目标文件格式)的一个抽象层,基于BFD可以不用关心具体的硬件格式,而进行统一操作,因为BFD中已经包含了这些CPU和可执行文件的格式信息。BFD 本身是 binutils 项目的一个子项目。


ch5 Windows PE/COFF

windows上的目标文件为COFF格式,而可执行文件是PE格式,PE又是COFF格式衍生出来的, 所以将这类文件统称为PE/COFF格式

64位的Windows中对PE文件格式做了一点小小的修改,叫做PE32+格式,只是将32位的字段换成了64位而已。

由于 PE 文件在装载的时候被直接映射到进程的虚拟空间中运行,它是进程的虚拟空间的映象,所以PE可执行文件很多时候被称为映象文件(Image File)。

COFF 文件的符号表几乎跟 ELF 文件的符号表一样,主要就是符号名、符号类型、所在的位置。

PE/COFF 文件与 ELF 文件非常相似,它们都是基于段的结构的二进制文件格式。


ch6 可执行文件的装载与进程

关于程序和进程的比喻:程序就是菜谱,是一个静态概念, 进程就是菜,是一个动态概念。

程序的寻址空间由CPU的位数决定,所以32位下的程序寻址空间是2^32位,即4GB, 64位下是17179869184 GB, 32位下C语言指针的长度是4字节,64位系统下长度是8字节。

对于一个32位的程序,寻址空间虽然是4GB,但是程序并不能全部使用,例如在Linux下,1GB是留给操作系统的,剩下的3GB给进程,且这3GB内存程序也不能完全使用,还有一部分给其他用途; 在Windows上,默认情况下2GB留给系统,2GB留给进程。

64 位寻址空间在现在来看,几乎是无限的。 但历史总会嘲弄人,或许有一天我们会觉得 64 位的地址空间很小

Linux 下的 Segmentation fault,多为进程访问了未经允许的地址。

1995年Pentium Pro CPU使用 PAE (Physical Address Extension),将地址线扩充到了36位,所以理论上计算机可以寻址的空间变成了64G, 但是进程的寻址空间仍然是4G(32位系统下指针是4个字节)

为了使应用程序能使用超过32位的内存,Windows上可以使用AWE(Address Window Extension)的方式, 在Linux上可以使用mmap(),但是这只是一种补救32地址线的方法, 在原来16位的DOS上也曾有过这样的做法。

程序执行时所需要的指令和数据必须在内存中才能够正常运行,最简单的办法就是将程序运行时所需要的指令和数据全部装入内存中,这就是最简单的静态装入的办法。

程序运行时是有 局部性原理 的,我们可以将程序最常用的部分驻留在内存中。这就是动态装载的基本思想。

为了提高内存的使用效率,采用动态装入的办法,动态装入由两种方式,第一种是 覆盖装入 ,即每次将要使用的模块装入内存,不使用的调出,这样,调用的程序可以共享同一块内存区域,使用之前先要将程序的所有调用关系组织成一个树状结构。但是这种方式需要保证两点, (1) 调用路径上的模块都应该存在,(2) 不能存在跨树调用。第二种是 页映射 的方式,即将内存和文件存在的磁盘空间都划分成一个一个的页(通常是4KB),在程序使用的时候将相应的页调入,决定页面替换的算法由先进先出(FIFO)和最近最长使用算法等。

页映射是虚拟存储机制的一部分,随着虚拟存储的发明而诞生。

现在大部分操作系统采用的是页映射的方法进行程序装载。页映射并不是一下把程序的所有数据和指令都装入内存,而是将内存和所有磁盘中的数据和指令按照”页(Page)”为单位划分成若干个页, 以后所有的装载和操作的单位就是页 。目前一般的页大小为4K=4096字节。装载管理器负责控制程序的装载问题,当运行到的某条指令不在内存的时候,会将该指令所在的页装载到内存中的一个地方,然后继续程序的运行。如果内存中已经没有位置,装载管理器会根据一定的算法放弃某个正在使用的页,并用新的页来替代,然后程序可以继续运行。

页映射的好处:充分利用了程序的局部性原理。

从操作系统的角度来看,一个进程最关键的特征是它有独立的虚拟地址空间,这使得它有别于其他进程。

进程的建立过程:

ELF文件的链接视图和执行视图,链接视图是按照 Section 分配,执行视图又是 Segment ,Segment 是将相同属性(只读,可读写,可读可执行)的 Section 作为一个 Segment 。 一般在链接的时候说"段"指的就是Section,在装载的时候说"段"指的是Segment

ELF可执行文件和动态链接文件都有一个结构叫 程序头表 ,保存着程序被装载时候的Segment信息,而静态目标文件没有这个程序头表,因为目标文件不需要被装载。

操作系统会通过给进程空间划分出一个个的 VMA (Virtual Memory Area) 来管理进程的虚拟空间, 基本原则是将相同权限属性,有相同映像文件的映射成一个VMA ,一般包含四个区域:代码VMA,数据VMA,堆VMA,栈VMA,栈通常也叫堆栈。

一个进程在刚开始运行的时候,操作系统会预先把系统的 环境变量命令行参数 传递到进程的堆栈(栈)中,在main函数开始执行的时候,main函数的两个参数 argsargv[] 两个参数就是从这里传递进来的,分别表示命令参数的数量和指向命令行传入参数的指针数组。

Linux 下 ELF 文件的装载过程:bash 进程调用 fork 创建一个新的进程,新的进程调用 execve() 调用执行指定的 ELF 文件,原先的 bash 进程继续返回等待刚才启动的新进程结束,然后继续等待用户输入命令。进入 execve() 系统调用之后,Linux 内核就开始真正的装载工作。

fork() ->
execve() ->
sys_execve() /*系统调用,用于参数检查和复制*/ ->
do_execve()/*读取文件头部的128字节,决定执行程序,如果第一行是#!则会解析这之后的字符串,以确定解释器的路径,例如#!/usr/bin/python*/ ->
load_elf_binary() ->
do_execve() ->
sys_execve()/*从内核态返回用户态*/

ch7 动态链接

静态链接缺点:

动态链接:不对组成程序的目标文件进行链接,等到程序要运行时才进行链接。把链接这个过程推迟到了运行时再进行。

动态链接的优点:

DLL hell 是指 Windows 系统上动态库的新版本覆盖旧版本,且新版本不能兼容旧版本的问题。(新旧模块之间接口不兼容)

动态链接还有一个特点就是程序在运行时可以动态地选择加载各种程序模块,这个优点就是后来被人们用来制作程序的 插件(Plug-in) 。比如某个公司开发完成了某个产品,它按照一定的规则制定好程序的接口,其他公司或开发者可以按照这种接口来编写符合要求的动态链接文件。该产品程序可以动态地载入各种由第三方开发的模块,在程序运行时动态地链接,实现程序功能的扩展。

动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有的程序模块都链接成一个个单独的可执行文件。

在Linux系统中,ELF动态链接文件被称为动态共享对象(DSO,Dynamic Shared Objects),简称共享对象,它们一般都是以“.so”为扩展名的一些文件;而在Windows系统中,动态链接文件被称为动态链接库(Dynamical Linking Library),它们通常就是我们平时很常见的以“.dll”为扩展名的文件。

在Linux中,常用的C语言库的运行库glibc,文件名叫做“libc.so”。整个系统只保留一份C语言库的动态链接文件“libc.so”,而所有的C语言编写的、动态链接的程序都可以在运行时使用它。当程序被装载的时候,系统的动态链接器会将程序所需要的所有动态链接库(最基本的就是libc.so)装载到进程的地址空间,并且将程序中所有未决议的符号绑定到相应的动态链接库中,并进行重定位工作。程序与libc.so之间真正的链接工作是由 动态链接器 完成的,而不是静态链接器ld完成的。也就是说,动态链接是把链接这个过程从本来的程序装载前被推迟到了装载的时候。

动态链接比静态链接会有略微的性能损失,但其在空间上的节省和升级时的灵活性,是相当值得的。

静态链接文件是 链接时重定位 ,动态链接文件是 装载时重定位 ,又叫基址重置;但是装载时重定位的一个大问题是 无法实现多个进程的公用 ,解决办法是 地址无关代码(PIC) ,先将将so文件分为四部分:1) 模块内部的函数调用 2) 模块内部的数据访问, 3) 模块外部的函数调用 4) 模块外部的数据访问。 编译器实际上没法知道一个函数或者变量是来自外部还是外部,所以编译器拓展 _declspec(dllimport) 用于指定来自外部或者内部。

如何判断一个动态共享目标文件(DSO)是否是PIC的代码: readelf -d xx. so | grep TEXTREL 如果没有任何输出则是PIC的,因为TEXTREL表示代码段重定位表地址,PIC不存在这个地址。

如果代码不是地址无关的,它就不能被多个进程共享,也就失去了节省内存的优点。

-fPIC 表示编译为位置独立的代码, 不用此选项的话编译后的代码是位置相关的所以动态载入时是通过代码拷贝的方式来满足不同进程的需要,而不能达到真正代码段共享的目的

为了防止DSO文件在运行时链接过程耗费太多的时间,采用延迟绑定(Lazy Binding)机制。即在第一次用到的时候进行地址绑定。

可执行文件动态链接的过程时,操作系统启动动态链接器,即加载ld.so文件并将控制权交给它,它将可执行文件需要的共享文件动态加载完之后,控制权再交给可执行文件。

动态链接器自举(bootstrap):动态链接器本身也是一个共享对象,所以要求动态链接器共享对象本身不依赖任何其他的共享对象,且其需要的全局变量和静态变量的重定位工作有它自己完成,对于第二个要求,需要一段精妙的代码完成,这被称为自举。

动态库的装载通过一系列由动态链接器提供的 API 完成:

dlopen 用来打开一个动态库,并将其加载到进程的地址空间,完成初始化过程。

dlopen 第一个参数是被加载动态库的路径。如果是绝对路径,函数会尝试直接打开此动态库,如果是相对路径,函数会按以下顺序查找该动态库文件:

  1. 查找环境变量LD_LIBRARY_PATH指定的目录
  2. 查找由 /etc/ld.so.cache里面指定的共享库路径
  3. /lib, /usr/lib

dlsym 函数是运行时装载的核心部分,我们通过这个函数找到所需要的符号。

每次调用dlopen, dlsym, dlclose之后都可以调用dlerror()来判断上一次调用是否成功。

dlclose() 与dlopen作用相反,关闭打开的句柄,卸载已加载的某个模块。系统会维持一个加载引用计数器,每次使用dlopen加载某个模块时,相应的计数器加1,每次使用dlclose卸载某个模块时,相应的计数器减1。只有当计数器值减到0时,模块才被真正地卸载掉。


ch8 Linux 共享库的组织

护一个共享库的兼容性非常困难,因为此处的兼容性是指“ABI”兼容性。C++ 的 ABI 兼容性非常差。

Linux 下有一套规则来命名系统中的每一个共享库,它规定共享库的文件名规则为:

libname.so.x.y.z

其中x是主版本号,表示库的重大升级,不同主版本号的库之间不兼容,对于主版本升级的库,原有的应用程序必须重新编译;或者保留旧版的共享库。

y是次版本号,表示库的增量升级,即增加一些新的接口符号,且保持原本的符号不变。高的次版本号的库向后兼容低的次版本号的库。

z是发布版本号,表示库的一些错误的修正、性能的改进等,并不添加任何新的接口,也不对接口进行修改。相同主版本号、次版本号的共享库,不同的发布版本号之间完全兼容。

Linux 采用 SO-NAME 机制来记录共享库的依赖关系。所谓依赖关系是指共享库的文件名去掉次版本号与发布版本号,仅保留主版本号。在linux系统中系统会为每个共享库在它所在的目录创建一个跟“SO-NAME”相同的并且指向它的软链接,这个软链接的目的在于总是指向主版本号相同而次版本号与发布版本号最新的共享库。如此一来就产生了两个方面的优势:

  1. 对于所有依赖于它的程序,仅需要记录这一SO-NAME即可,而不需要保留编译时所使用的共享库,如此一来就节省了大量的磁盘空间。
  2. 当共享库升级时,就可以直接将新版的共享库替换掉旧版,同时修改软链接SO-NAME即可。

对于形如 libXXX.so.2.6.1 的库,在链接时我们可以使用 -lXXX 来指定。

Linux 中存在一个 ldconfig 的程序。ldconfig 的用途:主要是在默认搜寻目录( /lib/usr/lib )以及动态库配置文件 /etc/ld.so.conf 内所列的目录下, 搜索出可共享的动态链接库 (lib*.so*),进而创建出动态装入程序(ld.so)所需的连接和缓存文件,缓存文件默认为 /etc/ld.so.cache ,此文件保存已排好序的动态链接库名字列表。如果安装了新的共享库,ldconfig 会为其创建相应的软链接。

SO-NAME 没有解决 次版本号交会问题 。 Linux 下的 Glibc 支持一种叫 基于符号的版本机制 ,但没有被广泛应用。

Linux 遵循FHS(File Hierarchy Standard)标准对共享库进行存放,主要位于以下三个位置:

程序在链接的时候首先从 ld.so.cache 中查找,然后再到 ld.so.conf 的路径里边去找。

改变共享库查找路径最简单的方法是使用 LD_LIBRARY_PATH 环境变量,这个环境变量中包含的路径相当于链接时GCC的 -L 参数。

动态链接器的查找顺序:

LD_PRELOAD 这一环境变量可以指定预先装载的一些共享库甚或是目标文件。这一环境变量指定的文件比 LD_LIBRARY_PATH 所指定的文件还要优先,同时无论程序是否依赖于他们,该环境变量所指定的共享库或目标文件都会被装载。

正常情况下应该避免使用 LD_LIBRARY_PATH 和 LD_PRELOAD。

共享库的创建:最关键的是使用 gcc 的两个参数 -shared-fPIC。还有一个常用编译选项是 -Wl ,这个参数可以将指定的参数传递给链接器。书中给出的一个例子是“-Wl,-soname,-my_soname”用于指定输出共享库的SO-NAME。

在开发过程中,你可能要测试新的共享库,但是又不希望影响现有的程序正常运行,可以用 LD_LIBRARY_PATH 指定共享库的查找路径。还有一种方法是使用链接器的 -rpath 选项(或者GCC的 -Wl,-rpath ),这种方法可以指定链接产生的目标程序的共享库查找路径。

清除符号信息:正常情况下编译出来的共享库或可执行文件里面带有符号信息和调试信息,这些信息在调试时非常有用,但是对于最终发布的版本来说,这些符号信息用处并不大,并且使得文件尺寸变大。我们可以使用一个叫 strip 的工具清除掉共享库或可执行文件的所有符号和调试信息:

strip libfoo.so

共享库的安装:最简单的办法就是将共享库复制到某个标准的共享库目录,如 /lib/usr/lib 等,然后运行 ldconfig 即可。不过上述方法往往需要系统的root权限,如果没有,则无法往 /lib/usr/lib 等目录添加文件,也无法运行 ldconfig 程序。也可以通过建立相应的SO-NAME软链接方法,并告诉编译器和程序如何查找该共享库等,以便于编译器和程序都能够正常运行。建立SO-NAME的办法也是使用 ldconfig ,只不过需要指定共享库所在的目录(ldconfig -n shared_lib_dir)。在编译程序时,也需要指定共享库的位置,GCC提供了两个参数 -L-l ,分别用于指定共享库搜索目录和共享库。也可以使用 -rpath 参数。

共享库构造和析构函数:如果希望共享库在被装载时能够进行一些初始化工作,比如打开文件、网络连接等,使得共享库里面的函数接口能够正常工作。GCC提供了一种共享库的构造函数,只要在函数声明时加上 __attribute__((constructor)) 的属性,即指定该函数为共享库构造函数,拥有这种属性的函数会在共享库加载时被执行,即在程序的main函数之前执行。如果我们使用dlopen()打开共享库,共享库构造函数会在dlopen()返回之前执行。与共享库构造函数相对应的是析构函数,我们可以使用在函数声明时加上 __attribute__((destructor)) 的属性,这种函数会在main()函数执行完毕之后执行(或者是程序调用exit()时执行)。如果共享库是在运行时加载的,那么我们使用dlclose()来卸载共享库时,析构函数将会在dlclose()返回之前执行。值得注意的是,如果我们使用了这种析构或构造函数,那么必须使用系统默认的标准运行库和启动文件,即不可以使用GCC的 -nostartfiles-nostdlib 这两个参数。因为这些构造和析构函数是在系统默认的标准运行库或启动文件里面被运行的,如果没有这些辅助构造,它们可能不会被运行。可以指定某个构造或者析构函数的优先级:

void __attribute__((constructor(5))) init_func1();
void __attribute__((constructor(10))) init_func1();

优先级数字越小的函数越先执行。先构造的后析构。

注意这种语法是 GCC 提供的,其他编译器可能没实现。

共享库脚本:前面所提到的共享库都是动态链接的ELF共享对象文件(.so),事实上,共享库还可以是符合一定格式的链接脚本文件。通过这种脚本文件,我们可以把几个现有的共享库通过一定的方式组合起来,从用户的角度看就是一个新的共享库,比如我们可以把 C 运行库和数学库组合成一个新的库 libfoo.so

GROUP( /lib/libc.so.6 /lib/libm.so.2 )

ch9 Windows下的动态链接

DLL和EXE文件实际上是一个概念,都是PE格式的二进制文件,不同的是在PE文件头部将两者区分。

一个DLL在不同的进程中拥有不同的私有数据版本,DLL的代码不是地址无关的。

一个PE文件被装载时,其进程地址空间的起始地址就是基地址,任何一个PE文件都有一个优点装载的基地址,即Image Base。

Windows可以将DLL的数据段设为共享,这就意味着一个DLL中有两个数据段,一个进程间共享,另一个私有。

我们使用 __declspec(dllexport) 表示该符号是从本DLL导出的符号。__declspec(dllimport) 表示该符号是从别的DLL导入的符号。

DLL支持显式运行时链接,Windows提供了3个API为:

C++ 与动态链接,C++ 编写共享库会有很多问题,微软提出了 COM(Component object model),主要为了解决程序开发中遇到的兼容性问题。

DLL hell,早期 windows 缺少有效的DLL版本控制机制。


ch10 内存

程序的环境由三部分组成:

内存是程序运行的介质。

内存空间,寻址能力看是多少位的系统,32位系统理论上有 4 GB 的寻址能力。

栈:维护函数调用的上下文,离开了栈,函数调用就没法实现。

堆:容纳应用程序动态分配的内存区域。一般空间比栈大很多。

栈通常在高地址处分配,内存增长方向由高到低;堆通常存在于栈的下方,内存增长方向由低到高。

段错误:一般是非法指针解引用造成的。

没有栈就没有函数,没有局部变量。

堆栈帧(Stack Frame):栈保存的一个函数调用所需要的维护信息。ebp 寄存器指向函数调用的一个固定的位置。

windows 函数里有些函数的指令有一些特殊的插入内容,允许“替换”,这种替换机制可以用来实现钩子(Hook),可以在某些时刻截获特定函数的调用。

调用惯例(Calling Convention),函数的调用方和被调用方对于函数如何调用需要有一个明确的约定,只有双方都遵守同样的约定,函数才能被正确的调用:

除了函数参数的传递之外,函数与调用方的另一个交互方式就是返回值。在返回不同字节大小的返回值编译器的处理方式不一样。

声名狼藉的 C++ 的返回对象,C++ 返回对象存在多余拷贝的问题,解决方法有“返回值优化”等。

关于堆存在的必要性:栈上的数据在函数返回的时候就会被释放,全局变量没办法动态产生(只能在编译时确定)。堆一般是一块较大的动态申请的内存空间。

运行库相当于是向操作系统“批发”了一块较大的堆空间,然后“零售”给程序用。当全部“售完”或程序有大量的内存需求时,再根据实际需求向操作系统“进货”。当然运行库在向程序零售堆空间时,必须管理它批发来的堆空间,不能把同一块地址出售两次,导致地址的冲突。于是运行库需要一个算法来管理堆空间,这个算法就是 堆的分配算法

Linux 中进程堆管理:

malloc 申请的内存,在程序结束后会不会存在?

内存泄漏(Memory Leak)是指程序中己动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。比如在一个很长的循环里没有及时释放堆上申请的内存,会造成浪费。

堆分配算法:


ch11 运行库

操作系统装载程序之后,首先运行的代码并不是main的第一行,而是某些别的代码,这些代码负责准备好main函数执行所需要的环境,并且负责调用main函数。

运行这些代码的函数称为入口函数或入口点(Entry Point)。

程序的入口点实际上是一个程序的初始化和结束部分,它往往是运行库的一部分。一个典型的程序运行步骤大致如下:

  1. 操作系统在创建进程后,把控制权交到了程序的入口,这个入口往往是运行库中的某个入口函数
  2. 入口函数对运行库和程序运行环境进行初始化,包括堆、I/O、线程、全局变量构造,等等
  3. 入口函数在完成初始化之后,调用main函数,正式开始执行程序主体部分
  4. main函数执行完毕以后,返回到入口函数,入口函数进行清理工作,包括全局变量析构、堆销毁、关闭I/O等,然后进行系统调用结束进程

环境变量 是存在于系统中的一些公用数据,任何程序都可以访问。通常来说,环境变量存储的都是一些系统的公共信息,例如系统搜索路径、当前OS版本等。环境变量的格式为 key=value 的字符串,C语言里可以使用 getenv 这个函数来获取环境变量信息。

glibc的程序入口为 _start(这个入口是由ld链接器默认的链接脚本所指定的,也可以通过相关参数设定自己的入口)。_start 由汇编实现,并且和平台相关。

MSVC CRT入口函数:MSVC的CRT默认的入口函数名为 mainCRTStartup

CRT, C Runtime.

运行库与 I/OIO 的全称是 Input/Output ,即输入和输出。对于计算机来说,IO 代表了计算机与外界的交互,交互的对象可以是人或其它设备。而对于程序来说,IO 覆盖的范围还要宽广一些。一个程序的I/O指代了程序与外界的交互 ,包括文件、管道、网络、命令行、信号等。更广义地讲,I/O指代任何操作系统理解为 文件 的事务。许多操作系统,包括Linux和Windows,都将各种具有输入和输出概念的实体----包括设备、磁盘文件、命令行等----统称为文件,因此这里所说的文件是一个广义的概念。对于一个任意类型的文件,操作系统会提供一组操作函数,这包括打开文件、读文件、写文件、移动文件指针等。C语言文件操作是通过一个FILE结构的指针来进行的。在操作系统层面上,文件操作也有类似于FILE的一个概念,在Linux里,这叫做 文件描述符 (File Descriptor),而在Windows里,叫做 句柄 (Handle)(以下在没有歧义的时候统称为句柄)。用户通过某个函数打开文件以获得句柄,此后用户操作文件皆通过该句柄进行。 设计这么一个句柄的原因在于句柄可以防止用户随意读写操作系统内核的文件对象 。无论是Linux还是Windows,文件句柄总是和内核的文件对象相关联的,但如何关联细节用户并不可见。内核可以通过句柄来计算出内核里文件对象的地址,但此能力并不对用户开放。I/O初始化的职责:首先I/O初始化函数需要在用户空间中建立stdin、stdout、stderr及其对应的FILE结构,使得程序进入main之后可以直接使用printf、scanf等函数。

C语言运行库:任何一个C程序,它的背后都有一套庞大的代码来进行支撑,以使得该程序能够正常运行。这套代码至少包括入口函数,及其所依赖的函数所构成的函数集合。当然,它还理应包括各种标准库函数的实现。这样的一个代码集合称之为运行时库(Runtime Library)。而C语言的运行库,即被称为C运行库(CRT)。

C语言标准库:美国国家标准协会(American National Standards Institute, ANSI)在1983年成立了一个委员会,旨在对C语言进行标准化,此委员会所建立的C语言标准被称为ANSI C。第一个完整的C语言标准建立于1989年,此版本的C语言标准称为C89。

glibc与MSVC CRT:运行库是平台相关的,因为它与操作系统结合得非常紧密。C语言的运行库从某种程度上来讲是C语言的程序和不同操作系统平台之间的抽象层,它将不同的操作系统API抽象成相同的库函数。比如我们可以在不同的操作系统平台下使用fread来读取文件,而事实上fread在不同的操作系统平台下的实现是不同的,但作为运行库的使用者我们不需要关心这一点。Linux和Windows平台下的两个主要C语言运行库分别为glibc(GNU C Library)和MSVCRT(Microsoft Visual C Run-time)。值得注意的是,像线程操作这样的功能并不是标准的C语言运行库的一部分,但是glibc和MSVCRT都包含了线程操作的库函数 。比如glibc有一个可选的pthread库中的pthread_create()函数可以用来创建线程;而MSVCRT中可以使用_beginthread()函数来创建线程。所以glibc和MSVCRT事实上是标准C语言运行库的超集,它们各自对C标准库进行了一些扩展。

CRT的多线程困扰:

CRT改进:

线程局部存储实现:TLS的用法很简单,如果要定义一个全局变量为TLS类型的,只需要在它定义前加上相应的关键字即可。对于GCC来说,这个关键字就是 __thread 。对于MSVC来说,相应的关键字为 __declspec(thread) 。一旦一个全局变量被定义成TLS类型的,那么每个线程都会拥有这个变量的一个副本,任何线程对该变量的修改都不会影响其它线程中该变量的副本。

缓冲(Buffer):缓冲最为常见于IO系统中,设想一下,当希望向屏幕输出数据的时候,由于程序逻辑的关系,可能要多次调用printf函数,并且每次写入的数据只有几个字符,如果每次写数据都要进行一次系统调用,让内核向屏幕写数据,就明显过于低效,因为 系统调用的开销是很大的,它要进行上下文切换、内核参数检查、复制等,如果频繁进行系统调用,将会严重影响程序和系统的性能 。一个显而易见的可行方案是将对控制台连续的多次写入放在一个数组里,等到数组被填满之后再一次性完成系统调用写入,实际上这就是缓冲最基本的想法。

所谓flush一个缓冲,是指对写缓冲而言,将缓冲内的数据全部写入实际的文件,并将缓冲清空,这样可以保证文件处于最新的状态。之所以需要flush,是因为写缓冲使得文件处于一种不同步的状态,逻辑上一些数据已经写入了文件,但实际上这些数据仍然在缓冲中,如果此时程序意外地退出(发生异常或断电等),那么缓冲里的数据将没有机会写入文件,flush可以在一定程度上避免这样的情况发生。


ch12 系统调用与API

系统调用(System Call)是应用程序(运行库也是应用程序的一部分)与操作系统内核之间的接口,它决定了应用程序是 如何与内核打交道的 。无论程序是直接进行系统调用,还是通过运行库,最终还是会到达系统调用这个层面上。

在现代的操作系统里,程序运行的时候,本身是没有权利访问多少系统资源的。由于系统有限的资源有可能被多个不同的应用程序同时访问,因此,如果不加以保护,那么各个应用程序难免产生冲突。所以现代操作系统都将可能产生冲突的系统资源给保护起来,阻止应用程序直接访问。这些系统资源包括文件、网络、IO、各种设备等。

举个例子,无论在Windows下还是Linux下,程序员都没有机会擅自去访问硬盘的某扇区上面的数据,而必须通过文件系统;也不能擅自修改任意文件,所有的这些操作都必须经由操作系统所规定的方式来进行,比如我们使用fopen去打开一个没有权限的文件就会发生失败。此外,有一些行为,应用程序不借助操作系统是无法办到或不能有效地办到的。

为了让应用程序有能力访问系统资源,也为了让程序借助操作系统做一些必须由操作系统支持的行为,每个操作系统都会提供 一套接口 ,以供应用程序使用。这些接口往往通过 中断 来实现,比如Linux使用0x80号中断作为系统调用的入口,Windows采用0x2E号中断作为系统调用入口。

系统调用覆盖的功能很广,有程序运行所必需的支持,例如创建/退出进程和线程、进程内存管理,也有对系统资源的访问,例如文件、网络、进程间通信、硬件设备的访问,也可能有对图形界面的操作支持,例如Windows下的GUI机制。

操作系统的系统调用往往从一开始定义后就基本不做改变,而仅仅是增加新的调用接口,以保持向后兼容。

系统调用的弊端:系统调用完成了应用程序和内核交流的工作,事实上,包括Linux,大部分操作系统的系统调用都有两个特点:

  1. 使用不便:操作系统提供的系统调用接口往往 过于原始 ,程序员需要了解很多与操作系统相关的细节。如果没有进行很好的包装,使用起来不方便;
  2. 各个操作系统之间系统调用 不兼容 :首先Windows系统和Linux系统之间的系统调用就基本上完全不同,虽然它们的内容很多都一样,但是定义和实现大不一样。即使是同系列的操作系统的系统调用都不一样,比如Linux和UNIX就不相同。

“解决计算机的问题可以通过增加层来实现”,于是 运行时库 (Runtime Library)挺身而出,它作为系统调用与程序之间的一个抽象层。

运行时库将不同的操作系统的系统调用包装为统一固定的接口,使得同样的代码,在不同的操作系统下都可以直接编译,并产生一致的效果。这就是源代码级上的可移植性。但是运行库也有运行库的缺陷,比如C语言的运行库为了保证多个平台之间能够相互通用,于是它只能 取各个平台之间功能的交集

特权级与中断:现代的CPU常常可以在多种截然不同的特权级别下执行指令,在现代操作系统中,通常也据此有两种特权级别,分别为 用户模式(User Mode)内核模式(Kernel Mode) ,也被称为用户态和内核态。

系统调用是运行在内核态的,而 应用程序基本都是运行在用户态的 。用户态的程序如何运行内核态的代码呢?操作系统一般是通过中断(Interrupt)来从用户态切换到内核态

什么是中断呢?中断是一个硬件或软件发出的请求,要求CPU暂停当前的工作转手去处理更加重要的事情

中断一般具有两个属性:

不同的中断具有不同的中断号而同时一个中断处理程序一一对应一个中断号。

在内核中,有一个数组称为 中断向量表(Interrupt Vector Table) ,这个数组的第n项包含了指向第n号中断的中断处理程序的指针。

当中断到来时,CPU会暂停当前执行的代码,根据中断的中断号,在中断向量表中找到对应的中断处理程序,并调用它。中断处理程序执行完成之后,CPU会继续执行之前的代码。

通常意义上,中断有两种类型,一种称为 硬件中断 ,这种中断来自于硬件的异常或其它事件的发生,如电源掉电、磁盘被按下等。另一种称为 软件中断 ,软件中断通常是一条指令(i386下是int),带有一个参数记录中断号,使用这条指定用户可以手动触发某个中断并执行其中断处理程序。由于中断号是很有限的,操作系统不会舍得用一个中断号来对应一个系统调用,而更倾向于用一个或少数几个中断号来对应所有的系统调用。

基于int的Linux的经典系统调用实现:

  1. 触发中断:首先当程序在代码里调用一个系统调用时,是以一个函数的形式调用的。
  2. 切换堆栈:在实际执行中断向量表的第0x80号元素所对应的函数之前,CPU首先还要进行栈的切换。在Linux中,用户态和内核态使用的是不同的栈,两者各自负责各自的函数调用,互不干扰。但在应用程序调用0x80号中断时,程序的执行流程从用户态切换到内核态,这时程序的当前栈必须也相应地从用户栈切换到内核栈。从中断处理函数中返回时,程序的当前栈还要从内核栈切换回用户栈。
  3. 中断处理程序:在int指令合理地切换了栈之后,程序的流程就切换到了中断向量表中记录的0x80号中断处理程序。内核里的系统调用函数往往以sys_加上系统调用函数名来命名,例如sys_fork、sys_open等。

Linux的新型系统调用机制:

Windows API,Application Programming Interface,即应用程序编程接口。Windows API是指Windows操作系统提供给应用程序开发者的最底层的、最直接与Windows打交道的接口。在Windows操作系统下,CRT是建立在Windows API之上的。另外还有很多对Windows API的各种包装库,MFC就是很著名的一种以C++形式封装的库。

Windows的最底层接口是Windows API。Windows API是Windows编程的基础,尽管Windows的内核提供了数百个系统调用(Windows又把系统调用称作系统服务(System Service)),但是出于种种原因,微软并没有将这些系统调用公开,而在这些系统调用之上,建立了这样一个API层,让程序员只能调用API层的函数,而不是如Linux一般直接使用系统调用。

Windows API概览:Windows API是以DLL导出函数的形式暴露给应用程序开发者的。它被包含在诸多的系统DLL内,规模上非常庞大。微软把这些Windows API DLL导出函数的声明的头文件、导出库、相关文件和工具一起提供给开发者,并让它们成为Software Development Kit(SDK)。

Windows API现在的数量已经十分庞大,它们按照功能被划分成了几大类别:

由于Windows API所提供的接口还是相对比较原始的,所以直接使用API进行程序开发往往效率较低。 Windows系统在API之上建立了很多应用模块,这些应用模块是对Windows API的功能的扩展

为什么要使用Windows API:系统调用实际上是 非常依赖于硬件结构 的一种接口,它受到硬件的严格控制,比如寄存器的数量、调用时的参数传递、中断号、堆栈切换等,都与硬件密切相关。如果硬件结构稍微发生改变,大量的应用程序可能就会出现问题(特别是那些与CRT静态链接在一起的)。为了尽量隔离硬件结构的不同而导致的程序兼容性问题,Windows系统把系统调用包装了起来,使用DLL导出函数作为应用程序的唯一可用的接口暴露给用户。除了隔离硬件结构不同之外,Windows本身也有可能使用不同版本的内核,所以系统调用的接口自然也是不一样的。


参考

https://blog.csdn.net/fengbingchun/article/details/102230252