装载、链接与库阅读笔记

本文详细探讨了计算机硬件结构,包括SMP与多核处理,以及软件体系的分层设计。介绍了操作系统的工作方式,如分时系统和多任务系统,以及物理地址和虚拟地址的概念。接着,深入讨论了线程、线程安全和同步机制。此外,还涵盖了链接和装载过程,包括静态链接、动态链接和运行库的作用。最后,文章提到了内存管理,包括内存布局、堆和程序的装载与执行过程。
摘要由CSDN通过智能技术生成

《程序员的自我修养》 – 装载、链接与库 笔记

硬件结构

三个重要部件:CPU,内存和IO

早期计算机没有复杂图形功能,CPU核心频率也不高,和内存频率一样,它们直接连接到同一个总线Bus上。
为了协调IO与总线之间的速度,也让CPU能和IO设备进行通信,每个设备有一个IO控制器。

后来CPU核心频率提升,内存跟不上CPU速度,产生了和内存频率一致的系统总线,
CPU通过倍频与系统总线通信。为了协调CPU、内存和高速的图形设备,专门设计了一个高速的北桥Northbridge芯片,
使它们可以高速地交换数据。

除了北桥外,还设计了专门处理低速设备的南桥Southbridge芯片,
磁盘、USB、键盘、鼠标等设备都连接到南桥上,由南桥汇总后连接到北桥上。
90年代时,南桥使用ISA低速总线,北桥使用PCI总线(最高速度可到133MHz),但仍不能满足人们需求。
后序发明了AGP、PCI-Express等总线结构。

SMP与多核

由于在制造CPU工艺方面已经接近目前的物理极限,除非CPU制造工艺能有本质的突破,
否则CPU频率将一直在几GHz左右。

随着CPU频率碰到“天花板”,多核处理器越来越普及,其中一种常见的方式即:
对称多处理器 SMP Symmetrical Multi-Processing,简单讲即每个CPU在系统中所处的地位和发挥的功能都是一样的,相互对称。
多核处理器即SMP的简化版,除了缓存共享方面的细微差别,可以把两者视为同一概念。

软件体系结构

系统软件可以分成两块,平台性的如:操作系统内核、驱动程序、运行库和系统工具等;
另外一块是用于程序开发的,如:编译器、汇编器、链接器等开发工具和开发库。

分层

“计算机科学领域的任何问题都可以通过增加一个简介的中间层来解决”

Any problem in computer science can be solved by another layer of indirection.

每个层次间都要相互通信,其通信协议通常称为接口Interface,下层定义接口、
提供接口,上层使用接口。

应用程序接口的提供者是运行库,什么样的运行库提供什么样的应用程序接口API
Linux下的Glibc提供POSIX的API;Windows的运行库提供Windows API,
最常见的32位Windows提供的API被称为Win32。

运行库使用操作系统提供的系统调用接口System call Interface
系统调用接口在实现中常以软件中断 Software Interrupt方式提供,
比如Linux使用0x80号中断作为系统调用接口。

操作系统

分时系统 Time-Sharing System

每个程序运行一段时间后主动让出CPU给其他程序,是的一段时间内每个程序都有机会运行
Windows早期版本(Windows95和Windows NT之前)、
Mac OS X之前的Mac OS都采用这种方式调度程序。

但如果某个程序霸占CPU不放,其他程序只能等着,就像死机了一样。

多任务系统 Multi-tasking

操作系统接管了所有的硬件资源,而且本身运行在一个受硬件保护的级别。
所有的应用程序都以进程 Process的方式运行在比操作系统权限更低的级别,
每个进程都有自己独立的地址空间,进程之间的地址空间相互分离。
CPU由操作系统统一进行分配,每个进程根据优先级高低得到CPU,
但如果运行时间超过限度,操作系统将暂停该进程,分配给其他进程。
即所谓抢占式 Preemptive,操作系统可强制剥夺CPU资源
并且分配给它认为目前最需要的进程,在多个进程间快速切换,
造成很多进程同时都在运行的假象。
几乎所有现代的操作系统都采用这种方式。

物理地址和虚拟地址

物理地址实际存在与计算机中,而且对每个计算机只有唯一的一个。

虚拟地址空间是指虚拟的、人们想象出来的的地址空间,
每个进程都有自己独立的虚拟空间,每个进程只能访问自己的地址空间,
这样就做到了进程的隔离。

分页

分页的基本办法是把地址空间人为地等分成固定大小的页,每一页的大小有硬件决定,
或硬件支持多种固定大小的页,有操作系统决定页的大小。

我们把进程的虚拟地址空间按页分割,把常用的数据和代码装载到内存中,
把不常用的代码和数据保存在磁盘里,需要用时再拿出来即可。

以页为单位存取和交换数据非常方便,硬件本身就支持这种以页为单位的操作方式。

保护也是页映射的目的之一,每个页可以设置权限属性。

虚拟存储的实现需要硬件的支持,几乎所有的硬件采用内存管理单元 MMU
来进行页映射。在页映射模式下,CPU发出的是虚拟地址,经过MMU转换之后变成了物理地址

线程

线程 Thread有时也被称为轻量级进程 Lightweight Process, LWP
是程序执行流的最小单元。一个标准的线程由线程ID、当前指令指针PC、寄存器集合
和堆栈组成。

通常,一个进程由一个到多个线程组成,各个线程之间共享程序的内存空间
(包括代码段、数据段、堆等)以及一些进程级的资源(打开文件和信号)。

线程访问权限

线程拥有自己的私有存储空间,包括

  • 栈(并非完全无法被其他线程访问,但仍可认为是私有数据)
  • 线程局部存储 (Thread Local Storage, TLS)。 通常只有很有限的容量
  • 寄存器
线程调度和优先级

线程总是“并发”执行的。当线程数量小于处理器数量时,线程并发是真正的并发,
不同的线程运行在不同的处理器上,彼此互不相干。但线程数量大于处理器数量时,
操作系统让线程程序轮流执行,并发是模拟出来的,这种行为称为
线程调度 Thread Schedule

线程都拥有各自的线程优先级 Thread Priority,具有高优先级的线程会更早执行
,低优先级的线程常要等到系统中没有高优先级的可执行线程存在时才能运行。

频繁等待的线程称为IO密集型线程 IO Bound Thread,很少等待的线程称为
CPU密集型线程 CPU Bound Thread

线程优先级的改变

  • 用户指定优先级
  • 根据进入等待时间的频繁程度提升或降低优先级
  • 长时间得不到执行而被提升优先级

线程用尽时间片后会被强制剥夺执行的权利,进入就绪状态,这被称为
抢占 Preemption

写时复制 Copy on Write , COW

两个认为可以同事自由地读取内存,但任意一个认为试图对内存进行修改时,
内存就会复制一份提供给修改方单独使用,以免影响到其他的任务使用。

线程安全
同步与锁

锁 Lock是一种非强制机制,每一个线程在访问数据或资源前先获取锁,
在访问结束之后释放锁

二元信号量 Binary Semaphore是最简单的一种锁,只有两种状态:占用和非占用。
适合只能被唯一一个线程独占访问的资源。

多元信号量 Semaphore,一个初始值为N的信号量允许N个线程并发访问。

互斥量 Mutex和二元信号量类似,但信号量允许任意线程获取并释放,
互斥量则“解铃还须系铃人”

临界区 Critical Section是比互斥量更严格的同步手段。互斥量和信号量在系统的
任何进程里都是可见的,而临界区的作用范围仅限于本进程,其他进程无法获取该锁。
其他性质相同。

读写锁 Read-Write Lock,当多线程只是读取数据而不修改时,不使用同步手段
并不会造成问题,一旦某个线程对数据进行修改,就必须使用同步手段避免出错。
读写锁有两种获取方式:共享的 Shared独占的 Exclusive

条件变量 Condition Variable类似于栅栏。线程可以等待条件变量,
线程也可以唤醒条件变量。

编译器做了什么

将高级语言翻译成机器语言,避免直接编写机器代码提高了开发效率和通用性。

做了六步:扫描、语法分析、语义分析、源代码分析、代码生成和目标代码优化

SourceCode --(Scanner)-> Tokens --(Parser)-> SyntaxTree --(SemanticAnalyzer)->
 CommentedSyntaxTree --(SourceCodeOptimizer)-> IntermediateRepresentation
 --(CodeGenerator)-> TargetCode --(CodeOptimizer)-> FinalTargetCode

syntax n. 句法,句法规则

现代的编译器可以将一个源代码文件编译成一个未链接的目标文件
然后由链接器最终将这些目标文件链接起来形成可执行文件

静态链接

当一个系统十分复杂的时候,人们不得不将一个复杂的系统逐步分割成小的系统
以达到各个突破。因此人们把每个源代码模块独立地编译,然后按照需要将它们
“组装”起来,这个组装的过程即链接Linking

链接的主要过程就是把各个模块之间相互引用的部分都处理好,
使得各个模块之间能够正确地衔接。

链接的过程主要包括了地址和空间分配Address and Storage Allocation
符号决议Symbol Resolution重定位Relocation

每个模块的源代码(eg: .c)文件经过编译器编译成目标文件(一般拓展名.o .obj)
目标文件和库(Library)一起链接形成最终可执行文件。
最常见的库即
运行时库(Runtime Library)
,它是支持程序运行的基本函数的集合。
库其实是一组目标文件的包,就是一些最常用的代码编译成目标文件后打包存放。

目标文件

现在PC平台流行的可执行文件格式Executable主要是
Windows下的PE(Portable Executable)和Linux的ELF(Executable Linkable Format),
它们都是COFF(Common File Format)格式的变种。

不光是可执行文件 exe elf按照可执行文件格式存储,动态链接库(DLL,Dynamic
Linking Library)(.dll .so)静态链接库(Static Linking Library)(.lib .a)

文件都按照可执行文件格式存储。

目标文件长什么样

一般目标文件按不同的属性,以节Section的形式存储,有时也叫段Segment
一般情况下,它们都代表一个一定长度的区域。

程序源代码编译后的机器指令经常被放在代码段Code Section,代码段的常见名字
有".code"和".text";全局变量和局部静态变量经常放在数据段Data Section
数据段的名字一般都叫".data"。

ELF文件的开头是一个“文件头”,包括是否可执行、静态链接还是动态链接及入口地址、
目标硬件等信息。文件头还包括一个段表Section Table,段表其实就是一个描述
文件中各个段的数组。段表描述了文件中各个段在文件中的偏移位置及段的属性等,
从段表里就可获得每个段的所有信息。

一般C语言编译后执行语句都编译成机器代码,保存在.text段;已初始化的全局变量和
局部静态变量都保存在.data段;未初始化的全局变量和局部变量一般放在一个.bss段。
未初始化的全局变量和局部变量默认值都是零,在.data段给它们分配空间没有必要。
程序运行时它们会占用空间,而且可执行文件必须记录所有未初始化的全局变量和
局部静态变量的大小总和,记为.bss段。
.bss段只是为未初始化的全局变量和局部静态变量预留位置,没有内容,
在文件中不占据空间。

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

// 只编译不链接
gcc -c file.c

// 查看目标文件的结构和内容
objdump -h file.o

// 查看elf文件的代码段、数据段和BSS段长度
size file.o
// simple_section.c文件

// gcc -c simple_section.c

int printf(const char* format, ...);

int global_init_var = 84;
int global_uninit_var;

void func1(int i)
{
	printf("%d\n", i);
}

int main(int argc, char *argv[])
{
	static int static_var = 85;
	static int static_var2;

	int a = 1;
	int b;

	func1(static_var + static_var2 + a + b);

	return a;
}
段表

描述了可执行文件各个段的信息,比如每个段的段名、段的长度、在文件中的偏移、
读写权限及段的其他属性。编译器、链接器和装载器都是依靠段表来定位和访问
各个段的属性的。

// 查看elf段表
readelf -S file.o


There are 14 section headers, starting at offset 0x418:

Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
  [ 0]                   NULL             0000000000000000  00000000
       0000000000000000  0000000000000000           0     0     0
  [ 1] .text             PROGBITS         0000000000000000  00000040
       0000000000000061  0000000000000000  AX       0     0     1
  [ 2] .rela.text        RELA             0000000000000000  000002f8
       0000000000000078  0000000000000018   I      11     1     8
  [ 3] .data             PROGBITS         0000000000000000  000000a4
       0000000000000008  0000000000000000  WA       0     0     4
  [ 4] .bss              NOBITS           0000000000000000  000000ac
       0000000000000008  0000000000000000  WA       0     0     4
  [ 5] .rodata           PROGBITS         0000000000000000  000000ac
       0000000000000004  0000000000000000   A       0     0     1
  [ 6] .comment          PROGBITS         0000000000000000  000000b0
       000000000000001c  0000000000000001  MS       0     0     1
  [ 7] .note.GNU-stack   PROGBITS         0000000000000000  000000cc
       0000000000000000  0000000000000000           0     0     1
  [ 8] .note.gnu.pr[...] NOTE             0000000000000000  000000d0
       0000000000000030  0000000000000000   A       0     0     8
  [ 9] .eh_frame         PROGBITS         0000000000000000  00000100
       0000000000000058  0000000000000000   A       0     0     8
  [10] .rela.eh_frame    RELA             0000000000000000  00000370
       0000000000000030  0000000000000018   I      11     9     8
  [11] .symtab           SYMTAB           0000000000000000  00000158
       0000000000000138  0000000000000018          12     8     8
  [12] .strtab           STRTAB           0000000000000000  00000290
       0000000000000061  0000000000000000           0     0     1
  [13] .shstrtab         STRTAB           0000000000000000  000003a0
       0000000000000074  0000000000000000           0     0     1
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
  L (link order), O (extra OS processing required), G (group), T (TLS),
  C (compressed), x (unknown), o (OS specific), E (exclude),
  D (mbind), l (large), p (processor specific)
重定位表

可以发现段表中有一个"rela.text"段,它是一个重定位表Relocation Table
链接器在处理目标时,要对目标文件中的某些部位进行重定位,即代码段和数据段中
那些对绝对地址的引用的位置。

字符串表

ELF文件中用到了很多字符串,如段名、变量名等。字符串的长度往往是不定的,
用固定的结构来表示它有些困难。一种常见的做法是把字符串集中起来存放到一个表,
使用字符串在表中的偏移来引用字符串。

链接的接口–符号

链接的本质就是将多个目标文件拼接到一起,形成一个整体。
在链接中,目标文件之间相互拼合实际上是目标文件之间对地址的引用,
即对函数和变量的地址的引用。
在链接中,将函数和变量统称为符号Symbol,函数名和变量名就是
符号名Symbol Name

链接中很重要的一部分就是符号的管理,每一个目标文件都会有一个相应的
符号表Symbol Table,这个表里面记录了目标文件中所用到的所有符号。
每个定义的符号有一个对应的值,符号值Symbol Value,对于函数和变量来说,
符号值就是它们的地址。

extern “C”

C++为了与C兼容,在符号的管理上,C++有一个用来声明或定义一个C的符号的
extern "C"关键字用法:

extern "C" {
	int func(int)
	int var;
}

C++编译器会将大括号内部的奶妈当做C代码处理。

无论是可执行文件、目标文件或库,它们实际上都是一样基于段的文件或是这种文件的
集合。程序的源代码经过编译后,按照代码和数据分别存放到相应的段中,
编译器(汇编器)还会将一些辅助性的信息,如符号、重定位信息等也按照表的方式
放到目标文件当中,通常情况下,一个表就是一个段。

静态链接

空间和地址分配

现在链接器一般采用一种两部链接Two-pass Linking的方法。整个链接分两步。

第一步 空间和地址分配 扫描所有的输入目标文件,获得它们的各个段长度、属性
和位置,并且将输入目标文件中的符号表中所有的符号定义和符号引用收集起来,
统一放到一个全局符号表。

第二部 符号解析与重定位 使用第一步收集到的所有信息,读取输入文件中段的
数据、重定位信息,并且进行符号解析与重定位、调整代码中的地址等。
第二部是链接的核心,特别是重定位。

使用ld链接器将"a.o"和"b.o"链接起来:

ld a.o b.o -e main -o ab
  • -e main表示将main函数作为程序入口,ld链接器默认程序入口_start
  • -o ab 输出文件名ab,默认a.out
函数级别链接

VISUAL C++编译器提供一个编译选项叫函数级别链接Functional-Level Linking
让所有的函数像模板函数一样,单独保存到一个段中,当链接器要用到某个函数时,
将它合并到输出文件中,对那些没有用到的函数抛弃。
(模板函数则将用到的多余的段去除,只留一份)

减小输出文件的长度,但减慢编译和链接过程,且所有目标函数都有独立的段,
段的数量大大增加,重定位更加复杂,目标文件也变得相对较大。

GCC也有相应机制:-ffunction-sections-fdata-sections,分别将函数和变量
保持到独立的段中。

C++与ABI

是否有可能将MSVC编译出的目标文件和GCC编译出的目标文件链接到一起?

两个目标文件连接到一起需满足以下条件:采用相同的目标文件格式、拥有同样的符号
修饰标准、变量的内存分布方式相同、函数的调用方式相同等。

其中我们将符号修饰标准、变量的内存分布方式相同、函数的调用方式相同等
这些跟可执行代码二进制兼容性相关的内容称为ABI Application Binary Interface

静态库链接

程序如何使用操作系统提供的API。在一般情况下,一种语言的开发环境往往附带
语言库Language Library,这些库就是对操作系统API的封装。
printf函数对字符串进行一些处理后,最后都会调用系统API,
在linux下,它是"write"系统调用,在windows下,它是"WriteConsole"系统API。

一个静态库可以简单看成一组目标文件的集合,即很多目标文件经过压缩打包后
形成的一个文件。

静态运行库里一个目标文件只包含一个函数

比如lib.a里printf.o只有printf()函数、strlen.o只有strlen()函数,why?

链接器链接静态库的时候以目标文件为单位,引用了静态库中的printf()函数,
那么链接器就会把库中包含printf()函数的目标文件链接进来,如果很多函数
都放在同一个目标文件当中,可能很多没有用的函数会一起链接,造成空间的浪费。

一旦输入段的最终地址被确定,接下来就可以进行符号的解析与重定位,
链接器会把各个输入目标文件中对于外部符号的引用进行解析,把每个段中需重定位的
指令和数据进行“修补”,是它们都指向正确的位置。

可执行文件的装载与进程

程序和进程的区别

程序是一个静态的概念,就是一些预先编译好的指令和数据集合的一个文件;
进程则是一个动态的概念,是程序运行时的一个过程。

程序是菜谱,CPU是厨师,计算机相关的其他硬件就是厨具,进程就是整个炒菜的过程。
计算机按照程序的指示把输入数据加工成输出数据,就好像人按照菜谱将原料做成菜。

装载的方式

程序执行时需要的指令和数据必须在内存中才能正常运行,最简单的方法就是将所需要
的指令和数据全部装入内存。但很多情况下程序所需的内存数量大于物理内存,直接的
方法是增加内存,但内存昂贵且稀有。
后来研究发现,程序运行时有局部性原理,所以可以将程序最常用的部分驻留在内存,
而将一些不常用的数据存放在磁盘里,这就是动态装入的基本原理。

覆盖装入

程序员在写程序时将程序分割成若干块,然后编写一个晓得辅助代码来管理这些模块
何时应该驻留内存而何时应该被替换。这个辅助代码即覆盖管理器(Overlay Manager)

页映射

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

将内存和所有磁盘中的数据和指令按照“页Page”为单位划分成若干个页,
以后所有的装载和操作的单位就是页。规定的页大小有4096B,8192B,2MB,4MB等。

按程序的需要将所用到的页搬入内存,当内存不足时,通过一定的算法(如最少使用
LUR,先进先出FIFO)放弃原先的页,再搬入新页。

装载过程
进程的建立

一个进程最关键的特征是拥有独立的虚拟地址空间,这使它有别于其他进程。
创建一个进程,装载相应的可执行文件并运行:

1. 创建虚拟空间
一个虚拟空间由一组页映射函数将虚拟空间的各个页映射至相应的物理空间,
创建一个虚拟空间实际上并不是创建空间,而是创建映射函数所需要的相应数据结构。

2. 读取可执行文件头,建立虚拟空间与可执行文件的映射关系
上面一步的页映射关系函数是虚拟空间到物理内存的映射关系,这一步所做是虚拟空间
与可执行文件的映射关系。当程序发生页错误时,操作系统将从物理内存中分配一个
屋里也,然后将该“缺页”从磁盘中读取到内存中,再设置缺页的虚拟也和物理页的
映射关系,这样程序得以运行。

但当操作系统捕获到缺页错误时,它应知道程序当前所需要的页在可执行文件中的
哪一个位置。这就是虚拟空间与可执行文件之间的映射关系。

由于可执行文件在装载时实际上是被映射的虚拟空间,所以可执行文件又被称为
映像文件Image

3. 将CPU指令寄存器设置成可执行文件入口,启动运行
操作系统将控制权转交给进程,由此进程开始执行。

Linux下可以通过查看/proc来查看进程的虚拟空间分布。

进程虚拟地址空间:操作系统通过给进程空间划分出一个个VMA来管理进程的虚拟空间;
基本原则是将相同权限属性的、有相同映像文件的段映射成一个VMA;
一个进程基本上可以分为如下几种VMA区域:

  • 代码VMA,只读、可执行;有映像文件
  • 数据VMA,可读写、可执行;有映像文件
  • 堆VMA,可读写、可执行;无映像文件,匿名,可向上扩展
  • 栈VMA,可读写、不可执行;无映像文件,匿名,可向下扩展

动态链接

使用动态链接可以更加有效地利用内存和磁盘资源,可以更加方便地维护升级程序,
如果使用静态链接对程序进行更新、部署,一旦有任何模块更新,整个程序就要
重新链接、发布给用户。使用动态链接可以让程序的重用变得更加可行和有效。

动态链接:不对那些组成程序的目标文件进行链接,等到程序运行时才进行链接。
链接过程推迟到了运行时。
它不仅节省内存,还可以减少物理页面的换入换出,增加CPU缓存的命中率。
使程序的更新也更加容易,只需用新的目标文件覆盖掉旧的,程序下次运行时
新版本的目标文件会被自动装载到内存并链接起来。

在linux系统中,ELF动态链接文件被称为动态共享对象DSO,Dynamic Shared Object
,它们一般都是以“.so”为拓展名的一些文件。在windows中,动态链接文件被称为
动态链接库Dynamic Linking Library,即以“.dll”为拓展名的文件。

/* program1.c */
#include "Lib.h"

int main(int argc, char *argv[])
{
	foobar(1);
	return 0;
}

/* program2.c */
#include "Lib.h"

int main(int argc, char *argv[])
{
	foobar(2);
	return 0;
}
/* Lib.c */
#include <stdio.h>

void foobar(int i)
{
	printf("Printing from Lib.so %d\n", i);
}
/* Lib.h */
#ifndef LIB_H
#define LIB_H

void foobar(int i);

#endif

/* 运行结果 */
(base) ➜  self_cultivation ./program1 
Printing from Lib.so 1
(base) ➜  self_cultivation ./program2
Printing from Lib.so 2

程序模块program1.c被编译成program1.o时,编译器还不知道foobar()的地址。
当链接器将program1.o链接成可执行文件时,必须确定program1.o中所引用的
foobar()函数的性质。
若foobar()是一个定义于其他静态目标模块中的函数,那么链接器会按照静态链接
的规则,将program1.o中的foobar地址引用重定位;若foobar()是一个定义在某个动态
共享对象中的函数,链接器就会将这个符号的引用标记为一个动态链接的符号,
不对它进行地址重定位,把这个过程留到装载时再进行。

Lib.so中保存了完整的符号信息,把Lib.so也作为链接的输入文件之一,
链接器在解析符号时就可以知道foobar是一个定义在Lib.so的动态符号。
这样链接器就可以对foobar的引用做特殊处理,使它称为一个对动态符号的引用。

program1除了使用Lib.so外,还用到了动态链接形式的C语言运行库
它是linux下的动态链接器。动态链接器与普通共享对象一样被映射到了进程的
地址空间,在系统开始运行program1之前,首先会把控制权交给动态链接器,
由它完成所有的动态链接工作后在把控制权交给program1,然后开始执行。

装载时重定位地址无关码是解决绝对地址引用的问题的两个方法,
装载时重定位的缺点是无法共享代码段,但是它的运行速度较快;
而地址无关码的缺点是运行速度较慢,但可以实现代码段在各个进程之间的共享。

地址无关码

共享对象在被装载时,如何确定它在进程虚拟空间中的位置,换句话说,就是让
共享对象在可以在任意位置装载。首先想到的就是静态链接中的重定位,即:
在链接时,对所有绝对地址的引用不作重定位,把这一步推迟到装载时。

比如一个程序在编译时假设被装载的目标地址为0x1000,但操作系统发现这个地址
已被占用,从0x4000开始有块合适的空间,那么程序可以被装载到0x4000,
程序指令或数据中所有绝对引用只要都加上0x3000的偏移量就可以。

静态链接中的重定位是链接时重定位Link Time Relocation,现在这种情况
被称为装载时重定位Load Time Relocation,在windows中也叫做
基址重置Rebasing

在动态链接模块被装载映射至空间后,指令部分是在多个进程间共享的,
但装载时重定位需要修改指令,所以没有办法做到同一份指令被多个进程共享,
因为指令重定位后对于每个进程来说都是不同的。当然,动态链接库中的可修改数据
部分对于不同的进程来说有多个副本,它们可以采用装载时重定位的方法解决。

目标很简单:希望程序模块中共享的指令部分在装载时不需要因为装载地址的改变
而改变。所以实现的基本想法就是把指令中需要修改的部分分离出来,
和数据部分放在一起,这样指令部分可以保持不变,数据部分在每个进程中都有一个
副本。这种方案被称为地址无关代码PIC, Position-independent Code

共享模块中的地址引用方式根据是否跨模块、指令引用还是数据访问可分为四种。

  • 1.模块内部函数调用、跳转
  • 2.模块内部数据访问,如模块中定义的全局变量、静态变量
  • 3.模块外部的函数调用、跳转等
  • 4.模块外部的数据访问,如其他模块中定义的全局变量
各种地址引用方式
指令跳转、调用数据访问
模块内部1.相对跳转和调用2.相对地址访问
模块外部3.间接跳转和调用(GOT)4.间接访问(GOT)
  1. 同一模块内的指令相对位置都是固定的,对系统来讲,可以是相对地址调用,
    或是基于寄存器的相对调用
  2. 指令中不能包含数据的绝对地址,只能用相对寻址。一个模块前面一般是
    若干页的代码(.text),后面紧跟若干页的数据(.data)。先设法获取当前的PC值,
    然后再加上一个偏移量就可以访问相应变量了
  3. 也可采用下面所说GOT方法,只不过对应相中存放目标函数的地址
  4. 模块间的访问地址要到装载完成后才能确定。ELF的做法是在数据段中建立一个
    指向这些变量的指针数组,也称为全局偏移表Global Offset Table, GOT)
    当代码需要引用该全局变量时,可以通过GOT中相应项间接引用
    (链接器在装载模块的时候会查找每个变量所在的地址,然后填充GOT中的各个项,
    GOT本身是放在数据段的,可以在模块装载时被修改,且每个进程都可以有独立的副本
    相互不受影响)
共享模块的全局变量

当一个模块引用了定义在共享对象的全局变量的时候,如下,
编译器编译时,它无法根据上下文判断这个变量是在同一个模块中还是在另外一个
共享对象中,它会产生跨模块代码。

extern int global;
int func()
{
	global = 1;
}
一个共享对象`lib.so`中定义了一个全局变量G,进程A和进程B都使用了`lib.so`,
当进程A改变这个全局变量G的值时,进程B中的G是否也会受到影响?

不会。当lib.so被加载是,它的数据段在每个进程都有独立的副本,这样看,共享对象
中的全局变量和定义在程序内部的变量没有什么区别。A进程和B进程访问的都是自己的
那个副本。
若A和B是同一个进程中的两个线程,那么它们访问的是同一个进程地址空间,也就是
同一个lib.so副本,所以此时对G的修改互相看得见。
延迟绑定PLT

动态链接必然会牺牲掉一部分的性能,主要原因是动态链接下对于全局变量的
数据访问要进行复杂的GOT定位,然后间接寻址;模块间的调用也要先定位GOT,
然后再进行间接跳转。另外一个减慢运行速度的原因是动态链接的链接工作是在
运行时完成的,程序开始执行时,动态链接器都要进行一次链接工作。
在一个程序运行过程中,可能很多函数在程序执行完时都不会用到,如果一开始
就把所有函数都链接好实际上一种浪费。ELF采用了一种延迟绑定Lazy Binding
的做法,基本思想是当函数第一次被用到时才进行绑定(符号查找、重定位等)

windows动态链接

动态链接机制对windows系统来说十分重要,整个系统本身即基于动态链接机制,
windows的API也以DLL的形式提供给程序开发者,而不像linux等系统是以
系统调用作为操作系统的最终入口。DLL比linux下的ELF共享库更加复杂,
提供的功能也更完善。

共享库版本

兼容更新:所有的更新只是在原有的共享库基础上添加一些内容,
所有原有的接口都保持不变

不兼容更新:共享库更新改变了原有的接口,使用该共享库原有接口的程序
不能正常运行

共享库版本命名

linux共享库文件命名规则:

libname.so.x.y.z

最前面使用前缀“lib”、中间是库的名字和猴嘴“.so”,最后面三个数字组成版本号。
“x”表示主版本号Major Version Number,“y”表示次版本号Minor Version
Number
,“z”表示发布版本号Release Version Number

主版本号表示库的重大升级,不同主版本号之间的库不兼容。

次版本号表示库的增量升级,增加一些新的接口符号,且保持原来的符号不变。
在主版本号相同的情况下,高的次版本号向后兼容低的次版本号的库。

发布版本号表示库的一些错误的修正、性能的改进等,并不添加任何新的接口,
也不对接口进行更改。

SO-NAME

每个共享库都有一个对应的“SO-NAME”,即共享库的文件名去掉次版本号和发布版本号,
保留主版本号,SO-NAME相同的两个共享库,次版本号高的兼容次版本号低的。
linux系统中,系统为每个共享库在它所在的目录创建一个跟“SO-NAME”相同的且
指向它的软链接。

建立SO-NAME的目的是,使得所有依赖于某个共享库的模块在编译、链接和运行时,
都使用共享库的SO-NAME,而不使用详细的版本号。

内存

程序内存布局

操作系统默认会将一部分内存挪给内核使用,应用程序无法直接访问这一段内存,
这一段内存被称为内核空间,windows在默认情况下会将高地址的2GB分配给内核使用,
linux默认将高地址的1GB空间分配给内核(在X64架构下已不是如此)

关于windows:内核内存地址空间

用户使用剩下的2GB或3GB空间,称为用户空间,在用户空间,有许多地址区间有特殊
作用:

  • 栈:用于维护函数调用上下文,离开栈,函数的调用就没法实现。栈通常在
    用户空间的最高地址处分配,一般有数兆字节大小
  • 堆:堆用来容纳应用程序动态分配的内存区域,程序使用malloc或new分配内存时,
    得到的内存即来自堆里。堆通常位于栈的下方,某些时候,堆也可能没有固定统一
    的存储区域。堆一般比栈大很多。
  • 可执行文件映像:可执行文件在内存里的映像,有装载器在装载时将可执行文件
    的内存读取或映射到这里。
  • 保留区:对内存中收到保护而禁止访问的内存区域的总称。
程序出现"段错误"或者"非法操作,该内存地址不能read/write"

这是非法指针解引用造成的错误。当指针指向一个不允许读或写的内存地址,
而程序试图利用指针来读或写该地址的时候,就会出现这个错误。
在linux和windows的内存布局中,有些地址是时钟不能读写的,如0地址。
还有些地址是读写之前需要获取这些地址的读写权,或者某些地址一开始并没有
映射到实际的物理内存,应用程序必须事先请求将这些地址映射到实际的物理地址,
之后才能读写。普遍原因有两种:

1. 将指针初始化为NULL,之后没有给它合理的值就开始使用。
2. 没有初始化栈上的指针,指针的值一般是随机数。
栈与调用惯例

在计算机系统中,栈是一个具有可入栈出栈、先入后出属性的动态内存区域。
程序可以将数据从栈顶弹出,也可以将数据压入栈中。

在经典的操作系统中,栈总是向下增长的。压栈的操作使栈顶地址变小,
弹出操作使栈顶地址增大。

栈保存了一个函数调用所需要的维护信息,堆栈帧Stack Frame或者
活动记录Activate Record。堆栈帧一般包括如下几方面内容:

  • 函数的返回地址和参数
  • 临时变量:函数的非静态局部变量以及编译器自动生成的其他临时变量
  • 保存的上下文:包括在函数调用前后需要保持不变的寄存器

一个i386下的函数总是这样调用:

  • 把所有或一部分参数压入栈中,如果有其他参数没有入栈,那么使用某些特定
    的寄存器传递
  • 把当前指令的下一条指令的地址压入栈中
  • 跳转到函数体运行
调用惯例

函数的调用方和被调用方对于函数如何调用需有一个明确的规定,双方都遵守同样的
规定,函数才能正确地调用,这种规定被称为调用惯例Calling Convention
一般会规定以下几个方面的内容:

函数参数的传递顺序和方式:最常见的是通过栈传递,调用方将参数入栈,
函数自身从栈中将参数取出,对于多个参数的函数,要规定压栈顺序,
有些惯例还允许使用寄存器传递参数,提高性能。

栈的维护方式:函数调用完后,要弹出入栈的参数,保持栈在函数调用前后一致,
这个弹出的工作可以由调用方完成,也可以由函数完成。

名字修饰(Name-mangling)的策略:在C语言里,存在多个调用惯例,默认使用的
是cdecl,任何一个没有显式指定调用惯例的函数都是cdecl惯例。

函数的返回值传递
struct A func2(int i);

int func1()
{
	struct obj = func2(0);
}

当较大的对象作为函数的返回值时,调用方会先开辟一片区域,并将这片空间的一部分
作为传递返回值的临时对象temp;将temp对象的地址作为隐藏参数传递给函数;
函数运行完后将数据拷贝给temp对象,并将temp对象的地址传出;函数返回后,
调用方再通过传出的temp对象地址获得返回值。
(不同的编译器、平台、调用惯例都会产生不同的实现方法,这不是唯一的情况)

产生这样的临时对象会有额外的开销,避免大对象作为函数返回值。

堆与内存管理

堆是一块巨大的内存空间,常常占据整个虚拟空间的绝大部分。程序可以请求一块
连续内存并自由地使用,这块内存在程序主动放弃之前都会保持有效。

int main()
{
	char *p = (char*)malloc(1000);
	/* use p as an array of size 1000 */
	free(p);
}

malloc如何实现?一种可行方式是把进程的内存管理交给操作系统内核,内核去提供
一个系统调用,让程序自由申请内存。但实际上这样做性能比较差,每次程序申请
或释放堆空间都需要进行系统调用,系统调用的开销是比较大的。

比较好的做法是程序向操作系统申请一块适当大小的对空间,然后由程序自己管理
这块空间,具体来说,管理对空间分配的往往是程序的运行库。运行库相当于向操作
系统“批发”了一块比较大的堆空间,然后“零售”给程序使用。当“售完”或程序有大量
的内存需求时,再根据实际需求向操作系统“进货”。

堆分配算法

堆分配算法有很多种,有很简单的,也有很复杂、适用于高性能或其他特殊要求的场景

空闲链表:将堆中各个空闲的块按照链表的方式连接起来,当用户请求一块空间时,
可以遍历整个链表,知道找到合适大小的块并将它拆分;当用户释放空间时,
将它合并到空闲链表中。

位图:将整个对划分为大量的块block,每个块大小相同。当用户申请空间的
时候,总是分配整数个块给用户,第一个块称为已分配区域的头head
其余的称为分配区域的主题body。可以使用一个整数数组来记录块的使用情况,
每个块只有头/主体/空闲三种状态,因此仅需两位即可表示一个块,因此称为位图。

对象池:如果每一次分配的空间大小都一样,那么就可以按照这个每次请求分配
的大小作为一个单位,把整个堆空间划分为大量的小块,每次请求的时候只需要找到
一个小块就可以了。
对象池的管理方法可以采用空闲链表,也可以采用位图。实际上很多很多现实应用中,
堆分配算法往往是采用多种算法复合而成的。

运行库

入口函数和程序初始化

程序并非从main开始运行,在main函数开始执行时,有很多工作已经完成了。
在操作系统装载程序之后,首先运行某些别的代码,准备好main函数执行所需的环境,
而且负责调用main函数。

运行这些代码的函数称为入口函数入口点Entry Point。程序的入口点实际上
是一个程序的初始化和结束部分,往往是运行库的一部分,一个典型程序运行步骤:

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

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

在windows里,可以直接在系统-高级-环境变量查询当前的环境变量,在linux下,
直接在命令行里输入export即可。系统-高级-环境变量

运行库与IO

IO的全称是Input/Output,输入和输出。IO代表了计算机与外界的交互,交互的对象
可以是人或者其他设备。

对于程序来说,IO指代了与外界的交互,包括文件、管道、网络、命令行、信号等。
广义地讲,IO指代任何操作系统理解为“文件”的事物。许多操作系统,包括linux和
windows,都将各种具有输入和输出概念的实体(包括设备、磁盘文件、命令行等)
统称为文件。

C语言文件操作通过一个FILE结构的指针来进行。在操作系统层面上,文件操作也有
类似于FILE的概念。在linux里,叫做文件描述符File Descriptor,而在Windows
里,叫做句柄Handle,用户通过某个函数打开文件获得句柄,
此后用户操纵文件皆通过该句柄进行。

C/C++运行库

任何一个C程序背后都有一套庞大的代码来进行支撑,使得它可以正常运行。
这样一个代码集合称为运行时库Runtime Library。C语言的运行库即被称为
C运行库 CRT

一个C语言运行库大致包含了如下功能:

  • 启动与退出:包括入口函数即入口函数所依赖的其他函数等
  • 标准函数:有C语言标准规定的C语言标准库所拥有的函数实现
  • IO:IO功能的封装和实现
  • 堆:堆的封装和实现
  • 语言实现:语言中的一些特殊功能的实现
  • 调试:实现调试功能的代码

在这些运行库组成成分中,C语言标准库占据了主要地位。

系统调用

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

windows系统是完全基于DLL机制的,它通过DLL对系统调用进行了包装,形成了所谓的
windows API。无论程序是直接进行系统调用,还是通过运行库,最终还是会到达
系统调用这个层面上。

系统调用介绍

在现代操作系统中,程序运行时,本身没有权利访问系统资源。系统有限的资源
有可能被多个不同的应用程序同事访问,如果不加以保护,各个应用程序难免产生冲突。
所以操作系统将可能产生冲突的系统资源给保护起来,组织应用程序直接访问。
这些资源包括文件、网络、IO、各种设备等。无论在windows还是在linux下,
程序员都没有机会擅自去访问硬盘的某扇区上面的数据,而必须通过文件系统;
也不能任意修改文件,所有这些操作都必须经由操作系统所规定的方式进行。

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

系统调用涵盖的功能很广,有程序运行必须的支持,创建/退出进程和线程、
进程内存管理,也有对系统资源的访问,也可能有对图形界面的操作支持,如
windows下的GUI机制。

系统调用作为重要的接口,它的定义十分重要。首先每个调用的含义、参数、行为
都需要有严格而清晰的定义,这样应用程序才能正确地使用它;
其次它必须保持稳定和向后兼容,如果某次系统更新导致系统调用接口发生改变,
新的接口与之前的完全不同,那么之前所有能正常运行的程序都将无法使用。
所以操作系统的系统调用往往从一开始定义后就基本不作改变,而仅仅增加新的
系统调用接口,以保持向后兼容。

系统调用的弊端
  1. 使用不变。操作系统提供的系统调用接口往往过于原始,程序员需了解很多和
    操作系统相关的小细节。如果没有很好的包装,使用起来不方便。
  2. 各个操作系统之间不兼容。

为了解决这个问题,运行库作为加层挺身而出:

  • 使用简便。本身就是语言级别的,设计相对比较友好
  • 形式统一。运行库都有它的标准,叫做标准库,凡是所有遵循这个标准的运行库
    理论上都是相互兼容的,不会随着操作系统和编译器改变而改变

但是运行库也有缺陷,比如C语言的运行库为了保证多个平台之间能够相互通用,
它只能取各个平台之间功能的交集。

特权级和中断

中断号是很有限的,才做系统不会用一个中断号来对应一个系统调用,而倾向于用一个
或少数几个中断号来对应所有的系统调用,操作系统还有一个系统调用号,它来表明
是哪一个系统调用,这个系统调用号通常就是系统调用在系统调用表中的位置。

现代CPU通常可以在多种不同的特权级别下执行指令,现代操作系统中,通常也有两种
特权级别,分别为用户模式User Mode内核模式Kernel Mode,也被称为
用户态内核态。由于有多种特权模式存在,操作系统可以让不同的代码
运行在不同的模式上,以限制它们的权利,提高稳定性和安全性。普通程序运行在
用户模式下,诸多操作将受到限制,如访问硬件设备、开关中断、改变特权模式等。

系统调用运行在内核态,应用程序基本运行在用户态。操作系统一般通过中断
来从用户态切换到内核态。

通常意义上,中断有两种类型,一种称为硬件中断,它来自于硬件事件的发生,
如电源掉电、按键被按下等。另一种称为软件中断,软件中断通常是一条指令,
带有一个参数记录中断号,使用这条指令用户可以手动出发某个中断并执行其
中断处理程序。

中断号是很有限的,才做系统不会用一个中断号来对应一个系统调用,而倾向于用一个
或少数几个中断号来对应所有的系统调用,操作系统还有一个系统调用号,它来表明
是哪一个系统调用,这个系统调用号通常就是系统调用在系统调用表中的位置。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值