×

读书笔记 - 《程序员的自我修养》

96
SeanCST
2016.11.24 14:32* 字数 27639

一、温故而知新

1. 内存不够怎么办

  • 内存简单分配策略的问题
    • 地址空间不隔离
    • 内存使用效率低
    • 程序运行的地址不确定
  • 关于隔离 : 分为 虚拟地址空间 和 物理地址空间
  • 分段 : 把一段程序所需要的内存空间大小映射到某个地址空间
  • 分页 : 把地址空间人为地等分成固定大小的页,每一页大小由硬件决定,或硬件支持多种大小的页,由操作系统决定页的大小,目前几乎所有的 PC 上的操作系统都使用 4KB 大小的页。
    • 虚拟页 VP
    • 物理页 PP
    • 磁盘页 DP

2. 线程

  • 线程基础
    • 一个标准的线程由线程 ID当前指令指针(PC)寄存器集合堆栈组成
    • 线程私有
      • 栈 局部变量
      • 函数的参数
      • 线程局部存储 TLS 数据
      • 寄存器
    • 线程之间共享
      • 全局变量
      • 堆上的数据
      • 函数里的静态变量
      • 程序代码
      • 打开的文件
    • 线程调度中,线程通常拥有至少三种状态
      • 运行
      • 就绪
      • 等待
  • 线程安全
    • 原子操作
    • 同步与锁
    • 防止 CPU 过度优化
  • 多线程的内部情况——三种线程模型
    • 一对一模型 (用户线程 内核线程)
    • 多对一模型
    • 多对多模型

二、编译和链接

1. 被隐藏了的过程

  • 预编译 : 主要处理那些源代码文件中的以 “#” 开始的预编译指令,过程相当于 gcc -E hello.c -o hello.i
  • 编译 : 把预处理完的文件进行一系列词法分析、语法分析、语义分析及优化后生产相应的汇编代码文件,过程相当于 gcc -S hello.c -o hello.s,现在版本的 GCC 把预编译和编译两个步骤合并成一个步骤,使用一个叫做 cc1 的程序来完成这两个步骤
  • 汇编 : 将汇编代码转变成机器可以执行的指令,每一个汇编语句都对应一条机器指令,过程相当于 gcc -c hello.s -o hello.o
  • 链接 : 将一大堆 .o 目标文件链接起来才可以得到 a.out

2. 编译器

  • 将高级语言翻译成机器语言的一个工具
  • 编译过程一般可分为 6 步:扫描、语法分析、语义分析、源代码优化、代码生成、目标代码优化
    • 词法分析 : 源代码程序被输入到扫描器,运用一种类似于有限状态机的算法轻松地将源代码的字符序列分割成一系列的记号,记号一般分为:关键字、标识符、字面量、特殊符号
    • 语法分析 : 对由扫描器产生的记号进行分析,从而产生语法树(以表达式为节点的树),整个分析过程采用了上下文无关语法
    • 语义分析 : 静态语义和动态语义,经过语义分析阶段后,整个语法树的表达式都被标识了类型
    • 中间语言生成 : 源代码优化器会在源代码级别进行优化,将整个语法树转换成中间代码,使得编译器可以被分为前端和后端,前端负责产生机器无关的中间代码,后端将中间代码转换成目标机器代码。中间代码有很多类型,比较常见的有:三地址码、P - 代码
    • 目标代码生成与优化 : 编译器后端包括 代码生成器(将中间代码转换成目标机器代码) 和 目标代码优化器(对目标代码进行优化,比如选择合适的寻址方式、使用位移来代替乘法运算、删除多余的指令等)

3. 静态链接

  • 链接的主要内容 : 把各个模块之间相互引用的部分都处理好,使得各个模块之间能够正确地链接
  • 过程主要包括 : 地址和空间分配、符号决议、重定位等步骤
  • 每个模块的源代码文件经过编译器编译成目标文件,目标文件和库一起链接形成最终的可执行文件
  • 最常见的库就是运行时库,它是 支持程序运行的基本函数的集合,库其实是一组目标文件的包
  • 重定位 : 编译器没法确定地址的情况下,先将指令的目标地址置为 0,等待链接器将目标文件链接起来的时候再将其修正

三、目标文件

1. 目标文件的格式

  • 目标文件就是源代码编译后但未进行链接的那种中间文件,它跟可执行文件的格式几乎是一样的,可广义的看成同一种类型的文件,在 Windows 下可把它们统称为 PE-COFF 格式,在 Linux 下可把它们统称为 ELF 文件
  • 可执行文件、动态链接库即静态链接库文件都按照可执行文件格式存储。可执行文件格式 :主要是 Windows 下的 PELinux 的 ELF,都是 COFF 格式的变种
  • 静态链接库和动态链接库都是按照可执行文件格式存储
  • ELF 文件归为 4 类 :
    • 可重定位文件 : 如 .o / .obj
    • 可执行文件 : 如 .exe
    • 共享目标文件 : 如 .so / .dll
    • 核心转储文件 : Linux 下的 core dump

2. 目标文件

  • 包含 : 编译后的机器指令代码、数据、链接时所需要的一些信息(如 符号表、调试信息、字符串等)
  • 目标文件将这些信息以“段”的形式存储
  • 程序源代码被编译后主要分成两种段:
    • 程序指令 : 代码段
    • 程序数据 :
      • 数据段 : 已初始化的 全局变量和局部静态变量
      • .bss 段 : 为 未初始化的 全局变量和局部静态变量 预留位置,并没有内容,所以在文件中也不占据空间
    • 数据和指令分段的好处:
      • 数据区域是可读写的,指令区域是只读的,防止程序的指令被改写
      • 对 CPU 的缓存命中率提高有好处
      • 当系统中运行着多个该程序的副本时,指令等资源都是共享的,只需保存一份,而每个副本的数据区域是不一样的,是进程私有的,可节省大量空间

3. 挖掘目标文件

  • objdump -h SimpleSection.o : 查看各种目标文件的结构和内容
  • size SimpleSection.o : 查看 ELF 文件的代码段、数据段和 BSS 段的长度
  • 代码段
  • 数据段和只读数据段
    • .data 段保存的是那些已经初始化了的全局变量和局部静态变量
    • .rodata 段存放的是制度数据,一般是程序里面的只读变量和字符串常量
  • BSS 段 : .bss 段存放的是 未初始化的全局变量和局部静态变量,.bss 段为它们预留了空间,但有些编译器不存放全局的未初始化变量,只是预留一个未定义的全局变量符号,等到最终链接成可执行文件的时候再在 .bss 段分配空间,但是编译单元内部可见的静态变量的确是存放在 .bss 段的
  • 其他段
    • 这些段的名字都是由 . 作为前缀,表示这些表的名字是系统保留的,应用程序也可以使用一些非系统保留的名字作为段名
    • 自定义段 : 在全局变量或函数之前加上 __attribute__((section("name"))) 属性就可以把相应的变量或函数放到以 name 作为段名的段中

4. ELF 文件结构描述

  • 文件头
    • readelf -h 详细查看 ELF 文件
    • 文件头重定义了 ELF 魔数(最前面的 “Magic” 的 16 个字节,被 ELF 标准规定用来标识 ELF 文件的平台属性,用来确认文件的里类型)、文件机器字节长度、数据存储方式、版本、运行平台、ABI 版本、ELF 重定位类型、硬件平台、硬件平台版本、入口地址、程序头入口和长度、段表的位置和长度、段的数量等
  • 段表
    • 保存各个段的基本属性的结构,描述了 ELF 各个段的信息,如每个段的段名、长度、在文件中的偏移、读写权限、段的其他属性
    • 是一个以 Elf32_Shdr 结构体为元素的数组,元素的个数等于段的个数
    • 段的名字只是在链接和编译过程中有意义,对于编译器和链接器来说,主要决定段的属性的是 段的类型段的标志位
      • 段的类型 相关常量以 SHT_ 开头
      • 段的标志位 表示该段在进程虚拟地址空间中的属性,如是否可写、是否可执行等,相关常量以 SHF_ 开头
  • 重定位表 : 链接器在处理目标文件时,须要对目标文件中的某些部位进行重定位,即代码段和数据段中那些对绝对地址的引用的位置
  • 字符串表
    • 把字符串集中起来存放到一个表,然后使用字符串在表中的偏移来引用字符串
    • 字符串表(保存普通的字符串) 和 段表字符串表(保存段表中用到的字符串)

5. 链接的接口 —— 符号

  • 在链接中,将函数和变量统称为符号,函数名或变量名就是符号名
  • 每一个目标文件都会有一个相应的符号表,每个定义的符号有一个对应的值,叫做符号值
  • 符号表中的所有符号进行分类 :
    • 定义在本目标文件的全局符号
    • 在本目标文件中引用的全局符号(外部符号)
    • 段名,它的值就是该段的起始地址
    • 局部符号
    • 行号信息
  • ELF 符号表结构 :ELF 符号表往往是文件中的一个段,段名一般叫 .symtab,是一个 Elf32_Sym 结构的数组
    • st_name 符号名
    • st_value 符号对应的值
    • st_size 符号大小
    • st_info 符号类型和绑定信息
    • st_other (没用)
    • st_shndx 符号所在的段 : 如果符号定义在本目标文件中,那么这个成员表示符号所在段表的下表
  • 特殊符号 :使用 ld 作为链接器来链接生产可执行文件时,他会为我们定义很多特殊的符号,可以直接声明并且引用它
  • 符号修饰与函数签名
    • 防止不同目标文件中的符号名冲突,如 C++ 增加了名称空间的方法来解决多模块的符号冲突问题
    • C++ 符号修饰
    • 函数签名 : 包含一个函数的信息,包括函数名、参数类型、类和名称空间及其他信息
  • extern "C" : C++ 会将在其的大括号内的代码当做 C 语言代码处理
  • 弱符号与强符号
    • 对于 C/C++ 语言来说,编译器默认函数和初始化了的全局变量为强符号,未初始化的全局变量为弱符号,也可以通过 GCC 的 __attribute__((weak)) 来定义任何一个强符号为弱引用,强、弱符号不是针对符号的引用,只针对定义
    • 不允许强符号被多次定义、如果一个符号在某个目标文件中是强符号而在其他文件中都是弱符号,那么选择强符号
    • 弱引用与强引用 :对于未定义的弱引用,链接器不认为它是一个错误,默认为 0 或一个特殊的值,而对于未声明的强引用,链接器会报符号未定义错误

四、静态链接

1. 空间与地址分配

  • 链接过程 :将几个输入目标文件加工后合并成一个输出文件
  • 按序叠加 :会造成内存空间大量的内部碎片
  • 相似段合并 :将相同性质的段合并到一起
    • 链接器为目标分配地址和空间 :对于有实际数据的段,两者都要分配空间;对于 ".bss" 这样的段只局限于分配虚拟地址空间
      • 在输出的可执行文件中的空间
      • 在装载后的虚拟地址中的虚拟地址空间
    • 两步链接
      • 空间与地址分配
      • 符号解析与重定位
  • 符号地址的确定 :各个符号在段内的相对位置是固定的,链接器须要给每个符号加一个偏移量,使它们能够调整到正确的虚拟地址

2. 符号解析与重定位

  • 重定位
    • 源代码被编译成目标文件时,编译器不知道定义在其他目标文件中的符号地址,所以编译器暂时把地址 0 看作是该变量的地址,该函数的地址也是一个临时的假地址
    • 链接器在完成地址和空间分配之后就可以确定所有符号的虚拟地址了,链接器可以根据富豪的地址对每个需要重定位的指令进行地位修正,call 指令是一条近址相对位移调用指令,它后面跟的是调用指令的下一条指令的偏移量
  • 重定位表
    • objdump -r 每个要被重定位的 ELF 段都有一个对应的重定位表,也就是 ELF 文件中的一个段
    • 每个要被重定位的地方叫一个重定位入口,重定位入口的偏移表示该入口在要被重定位段中的位置
  • 符号解析 :重定位过程中,每个重定位的入口都是对一个符号的引用,重定位过程中,链接器会去查找由所有输入目标文件的符号表组成的全局符号表,找到对应的符号后进行重定位
  • 指令修正方式
    • 绝对寻址修正 S + A :修正后的地址为该符号的实际地址
    • 相对寻址修正 S + A - P :修正后的地址为该符号距离被修正位置的地址差

3. COMMON 块

  • 编译器将未初始化的全局/静态变量作为弱符号处理,链接时存在多个同名的弱符号,链接后输出文件中以最大的那个为准
  • 编译时将弱符号标记为 COMMON 类型,由于该若符号最终所占的空间大小是未知的,所以无法为该弱符号在 BSS 段分配空间,但是链接器在链接过程后确定了一个若符号的最终大小,所以它可以在最终输出文件的 BSS 段为其分配空间

4. C++ 相关问题

  • 重复代码消除
    • C++ 编译器在很多时候会产生重复的代码,如模板、外部内联函数、虚函数表都有可能在不同的编译单元生成相同的代码
    • 一个比较有效的做法就是将每个示例代码都单独地存放在一个段里,每个段只包含一个实例,链接器在最终链接的时候可以区分这些相同的实例段,然后将他们合并入最后的代码段
    • 函数级别链接 :一个编译选项,让所有的函数都保存到一个段里面,链接器须要用到某个函数时,才将它合并到输出文件中
  • 全局构造与析构
    • 在 main 函数被调用之前,为了使程序能够顺利执行,要先初始化进程执行环境,如堆分配初始化、线程子系统等
    • C++ 的全局对象的构造函数 在 main 之前被执行,析构函数在 main 之后被执行
    • .init 段里保存的是可执行指令,它构成了进程的初始化代码; .fini 段保存着进程终止代码指令
  • C++ 与 ABI
    • 使两个编译器编译出来的目标文件能互相链接,则两个目标文件须满足:采用同样的目标文件格式、拥有同样的符号修饰标准、变量的内存分布方式相同、函数的调用方式相同,等等
    • ABI (Application Binary Interface) :符号修饰标准、变量内存布局、函数调用方式等这些跟可执行代码二进制兼容性相关的内容
    • 硬件、编程语言、编译器、链接器、操作系统等都会影响 ABI
  • 静态库链接
    • 一个静态库可以简单地看成一组目标文件的集合,即很多目标文件经过压缩打包后形成的一个文件
    • 编译和链接一个普通 C 程序的时候,不仅要用到 C 语言库 libc.a ,而且还有其他一些辅助性质的目标文件和库。中间步骤:
      • 调用 cc1 程序,实际就是 GCC 的 C 语言编译器,将源文件编译成一个临时的汇编文件
      • 调用 as 程序,as 程序是 GNU 的编译器,将临时的汇编文件汇编成临时目标文件
      • GCC 调用 collet2 程序来完成最后的链接,collet2 可以看做是 ld 链接器的一个包装
  • 链接过程控制 :对于一些特殊要求的程序如操作系统内核、BIOS 或一些在没有操作系统的情况下运行的程序,以及另外的一些须要特殊的链接过程的程序,往往受限于一些特殊的条件,对程序的各个段的地址有着特殊的要求,须要进行连接过程控制
    • 链接控制脚本
      • 使用命令行来给链接器指定参数,如 ld 的 -o 、-e
      • 将链接指令放在目标文件里面,编译器京城会通过这种方法想链接器传递指令
      • 使用链接控制脚本
    • “小”程序
      • -fno-builtin :关闭 GCC 内置函数功能
      • -stati :ld 将使用静态方式来链接程序
      • -e nomain :该程序的入口函数为 nomain
    • 使用 ld 链接脚本
      • 简单来讲,链接控制过程就是控制输入端如何变成输出段,比如哪些输入端要合并成一个输出段,哪些输入段要丢弃;指定输入段的名字、装载地址、属性等
      • 链接控制脚本是控制链接过程的“程序”,使得链接过程以“程序”要求的方式将输入加工成所需要的输出结果,一般链接脚本都以 lds 作为扩展名
    • ld 链接脚本语法简介
      • 链接脚本由一系列语句组成,一种是命令语句,另外一种是赋值语句
        • 命令语句 :ENTRY(symbol)STARTUP(filename)SEARCH_DIR(path)INPUT(file,file,...)INCLUDE filenamePROVIDE(symbol)SECTIONS
      • 语句之间使用分号 ; 作为分割符
      • 表达式与运算符 :可以使用 C 语言类似的表达式和运算操作符
      • 注释和字符引用 :使用 /**/ 作为注释
  • BFD 库 (Binary File Descriptor library)
    • 五花八门的软硬件平台基础导致每个平台都有它独特的目标文件格式,即使同一个格式在不同的软件平台都有着不同的变种,导致编译器和链接器很难处理不同平台之间的目标文件
    • BFD 库 :一个 GNU 项目,目标是希望通过一种统一的借口来处理不同的目标文件格式,通过操作抽象的目标文件模型就可以实现操作所有 BFD 支持的目标文件格式
    • GNU 汇编器 GAS、链接器 ld、调试器 GBD 及 binutils 的其他工具都通过 BFD 库来处理目标文件,而不是直接操作目标文件,将编译器和链接器本身同具体的目标文件格式隔离开

五、Windows PE / COFF

1. Windows 的二进制文件格式 PE / COFF

  • PE :Protable Executable ,与 ELF 同根同源,都是由 COFF 格式发展而来的
  • 讨论 Windows 平台上的文件结构时,目标文件默认为 COFF 格式,而可执行文件为 PE 格式
  • 也采用基于段的格式

2. PE 的前身 —— COFF

  • COFF 文件结构 :由头文件及后面的若干个段组成,再加上文件末尾的符号表、调试信息的内容
    • 文件头包括
      • 描述文件总体结构和属性的映像头
      • 描述文件中包含的段的属性的段表 :是一个类型为 “IMAGE_SECTION_HEADER”结构的数组,数组里面每个元素代表一个段,用来描述每个段的属性
    • 段的内容与 ELF 中几乎一样,两个 ELF 文件不存在的段 :“.drectve”段 和 “.debug$S”段

3. 链接指示信息

  • 内容是编译器传递给链接器的指令
  • 段名后面就是段的属性,最后一个属性是标志位 “flags”,即 IMAGE_SECTION_HEADERS里面的 Characteristics 成员
  • 输出信息中紧随其后的是该段在文件中的原始数据

4. 调试信息

  • COFF 文件中所有以 “.debug” 开始的段都包含着调试信息
    • “.debug$S” 符号相关的调试信息段
    • “.debug$P” 包含预编译头文件相关的调试信息段
    • “.debug$T” 包含类型相关的调试信息段

5. 符号表

  • COFF 文件的符号表包含的内容跟 ELF 文件的符号表一样,主要就是符号名、符号的类型、所在的位置
  • 符号表的输出结果从左到右 :符号的编号、符号的大小、符号所在的位置、符号类型、符号的可见范围、符号名

6. Windows 下的 ELF —— PE

  • PE 文件是基于 COFF 的扩展

    • 文件的最开始部分不是 COFF 文件头,而是 DOS MZ 可执行文件格式的文件头和桩代码
    • 原来的 COFF 文件头中的 IMAGE_FILE_HEADER 部分扩展成了 PE 文件头结构 IMAGE_NT_HEADERS。包括了原来的 “Image Header” 及新增的 PE 扩展头部文件
  • DOS 下的可执行文件格式是 “MZ” 格式,与 Windows 下的 PE 不同,虽然它们使用相同的扩展名 “.exe”

  • IMAGE_NT_HEADERS 是 PE 真正的文件头,包含了一个标记和两个结构体,标记是一个常量,结构体是映像头和 PE 扩展头部结构

  • 为了区别,Windows 中把 32 位的 PE 文件格式叫做 PE32,把 64 位的 PE 文件格式叫做 PE32+

  • PE 数据目录

    • 在 Windows 装载 PE 文件时需要很快的找到一些装载所需要的数据结构如导入表、导出表、资源。重定位表等,这些常用的数据的位置和长度都被保存在了一个叫数据目录的结构里
    • DataDirectory 数组里面每一个元素都对应一个包含一定含义的表,每个结构有两个成员,是虚拟地址以及长度

六. 可执行文件的装载与进程

1. 进程虚拟地址空间

  • 每个程序被运行起来以后,它将拥有自己独立的虚拟地址空间,大小由计算机的硬件平台决定,具体地说是由 CPU 的位数决定的,比如 32 位的硬件平台决定了虚拟地址空间的地址为 0 到 2 ^ 32 - 1,即 0x00000000 ~ 0xFFFFFFFF,也就是常说的 4GB 虚拟空间大小;而 64 位的硬件平台具有 64 位寻址能力,它的虚拟地址空间达到了 2 ^ 64 - 1,总共 17179864184GB
  • 32 位 Linux 下,整个 4GB 被划分成两部分,其中操作系统本身用去了一部分:从地址 0xC0000000 ~ 0xFFFFFFFF,共 1GB。剩下的从 0x00000000 ~ 0xBFFFFFFF 共 3GB。从原则上讲(其实进程并不能完全使用这 3GB 的虚拟空间,其中有一部分是预留给其它用途的),我们的进程最多可以使用 3GB 的虚拟空间,整个进程在执行的时候,所有的代码、数据包括通过 C 语言 malloc() 的那个方法申请的虚拟空间之和不可以超过 3GB
  • 对于 Windows 操作系统,进程虚拟地址空间划分是操作系统占用 2GB,进程只剩下 2GB 空间,所以有个启动参数可以将操作系统占用的虚拟地址空间减少到 1GB
  • PAE :从硬件层面上来讲,原先的 32 位地址线只能访问最多 4GB 的物理内存,但是如果扩展至 36 位地址线之后, Intel 修改了页映射的方式,使得新的映射方式可以访问到更多的物理内存,这个地址扩展方式叫做 PAE

2. 装载的方式

  • 覆盖装入
    • 编写程序的时候手工将程序分割成若干块。然后编写一个小的辅助代码来管理这些模块何时应该驻留内存何时应该被替换掉,这个小的辅助代码就是覆盖管理器
    • 在有多个模块的情况下,需要手工将模块按照它们之间的调用依赖关系组织成树状结构
      • 树状结构中从任何一个模块到树的根模块都叫调用路径,当该模块被调用时,整个调用路径上的模块都必须在内存中
      • 禁止跨树间调用,任何一个模块不允许跨过树状结构进行调用
  • 页映射
    • 将内存和所有磁盘中的数据和指令按照“页”为单位划分为若干个页,以后所有的装载和操作的单位就是页,目前硬件规定的页的大小有 4096 字节、8192 字节、2MB、4MB 等,最常见的 Intel IA32 处理器一般都使用 4096 字节的页
    • 有很多算法解决选择哪个页来替换,如 FIFO、LUR 等

3. 从操作系统角度看可执行文件的装载

  • 进程的建立
    • 从操作系统的角度来看,一个进程最关键的特征是它拥有独立的虚拟地址空间
    • 创建一个进程,然后装载相应的可执行文件并且执行 的过程最开始做的事分三步 :
      • 创建虚拟地址空间 :虚拟空间到物理内存的映射关系
      • 读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系 :虚拟空间与可执行文件的映射关系
        • 映射关系是保存在操作系统内部的一个数据结构,Linux 中将进程虚拟空间中的一个段叫做虚拟内存区域(VMA),Windows 中将这个叫做虚拟段
      • 将 CPU 指令寄存器设置成可执行文件入口,启动运行
  • 页错误
    • 上面的步骤之后,操作系统只是通过可执行文件头部的信息建立起可执行文件和进程虚存之间的映射关系而已
    • CPU 真正开始执行时,会发现程序的入口地址是一个空页面,认为这是一个页错误,操作系统有专门的页错误处理例程来处理,将查询前面提到的装载过程的第二步建立的数据结构,然后找到空页面所在的 VMA,计算出相应的页面在可执行文件中的偏移,在物理内存中分配一个物理页面,将进程中该虚拟页与分配的物理页之间建立映射关系,然后把控制权再还给进程,进程从刚才页错误的位置重新开始执行

4. 进程虚存空间分布

  • ELF 文件链接视图和执行视图

    • ELF 文件被映射时,是以系统的页长度作为单位的,如果每个段都占用整数倍个页的长度,浪费内存空间。因此,装载时,对于相同权限的段,把它们合并到一起当做一个段进行映射
    • ELF 可执行文件引入了 “segment” 的概念,一个 “segment” 包含一个或多个属性类似的 “section”,装载时将他们看作一个整体一起映射,使得映射以后在进程虚存空间中只有一个相应的 VMA,减少了页面内部碎片,节省了内存空间
    • 描述 “segment” 的结构叫做程序头,描述了 ELF 文件该如何被操作系统映射到进程的虚拟空间
  • 堆和栈

    • 操作系统通过使用 VMA 来对进程的地址空间进行管理,很多情况下,一个进程中的栈和堆分别有一个对应的 VMA
    • 操作系统通过给进程空间划分出一个个 VMA 来管理进程的虚拟空间,基本原则是将相同权限属性、有相同映像文件的映射成一个 VMA,一个进程基本上可以分为如下几种 VMA 区域 :代码 VMA、数据 VMA、堆 VMA、栈 VMA
  • 堆得最大申请数量

    • Linux 下虚拟地址空间分给进程本身的是 3GB(Windows 默认是 2GB)
    • 具体数值会受到操作系统版本、程序本身大小、用到的动态/共享库数量、大小、程序栈数量、大小等,甚至可能每次运行的结果都不同
  • 段地址对齐

    • 可执行文件最终是要被操作系统装载运行的,这个装载的过程一般是通过虚拟内存的页映射机制完成的,映射过程中,页是映射的最小单位
    • 为了解决每个段分开映射所带来的浪费磁盘空间的问题,可以让各个段接壤部分共享一个物理页面,然后将该物理页面分别被映射两次,系统将他们映射到两份虚拟地址空间,其他的页都按照正常的页粒度进行映射,系统将 ELF 文件头也看作是系统的一个段,将其映射到进程的地址空间,好处是进程中的某一段区域就是整个 ELF 文件的映像,对于一些须访问 ELF 头文件的操作可以直接通过读写内存地址空间进行
    • 从某种角度看,好像是整个 ELF 文件从文件最开始到某个点结束,被逻辑上分成了以 4096 字节为单位的若干个块,每个块都被装载到物理内存中,对于那些位于两个段中间的快,它们将会被映射两次
    • 在 ELF 文件中,对于任何一个可装载的 “segment”,它的 p_vaddr 除以对其属性的余数等于 p_offset 除以对齐属性的余数
  • 进程栈初始化

    • 进程刚开始启动的时候,须知道一些进程运行的环境,最基本的就是系统环境变量和进程的运行参数,常见的做法是操作系统在进程启动前将这些信息提前保存到进程的虚拟空间的栈中
    • 进程在启动之后,程序的库部分会把堆栈里的初始化信息中的参数信息传递给 main() 函数,也就是 main() 函数的两个 argc 和 argv 两个参数,这两个参数分别对应这里的命令行参数数量和命令行参数字符串指针数组
  • Linux 内核装载 ELF 过程简介

    • 在用户层面,bash 进程会调用 fork() 系统调用穿件一个新的进程,然后新的进程调用 execve() 系统调用执行指定的 ELF 文件,原先的 bash 进程继续返回等待刚才启动的新进程结束 ,然后继续等待用户输入命令
    • 在进入 execve() 系统调用之后,Linux 内核就开始进行真正的装载工作
    • 主要步骤 :
      • 检查 ELF 可执行文件格式的有效性
      • 寻找动态链接的 “.interp” 段,设置动态链接路径
      • 根据 ELF 可执行文件的程序头表的描述,对 ELF 文件进行映射
      • 初始化 ELF 进程环境
      • 将系统调用的返回地址修改成 ELF 可执行文件的入口,这个入口取决于程序的链接方式,对于静态链接 ELF 可执行文件,这个程序入口就是 ELF 文件的文件头中的 e_entry 所指的地址;对于动态链接的 ELF 可执行文件,程序入口点是动态链接器
  • Windows PE 的装载

    • PE 文件中,链接器在生成可执行文件时,往往将所有的段尽可能合并,所以一般只有代码段、数据段、只读数据段和 BSS 等为数不多的几个段
    • RVA 相对虚拟地址,是相对于 PE 文件的装载基地址的一个偏移地址
    • 基地址 :每个 PE 文件在装载时都会有一个装载目标地址

七、动态链接

1. 为什么要动态链接

  • 动态链接 :链接过程推迟到了运行时再进行
  • 解决了共享目标文件多个副本浪费磁盘和内存空间的问题
  • 方升级程序库或程序共享某个模块时,新版本的目标文件会被自动装载到内存并且链接起来,使得各个模块更加独立,耦合度更小
  • 加强程序的可扩展性,程序在运行时可以动态地选择加载各种程序模块,后来被人们利用来制作程序的插件
  • 加强程序的兼容性,动态链接库相当于在程序和操作系统之间增加了一个中间层,从而消除了程序对不同平台之间以来的差异性
  • 基本思想 :把程序按照模块拆分为各个相对独立部分,在程序运行时才将它们连接在一起形成一个完整的程序,而不是像静态链接一样,把所有程序模块都链接成一个单独的可执行文件
  • Linux 系统中,ELF 动态链接文件被称为动态共享对象,简称共享对象,一般都是以 “.so” 为扩展名的一些文件;Windows 系统中,动态链接文件被称为动态链接库,通常是以 “.dll” 为扩展名的文件
  • 程序被装载的时候,系统的动态链接器会将程序所需要的所有动态链接库装载到进程的地址空间,并且将程序中所有未决议的符号绑定到相应的动态链接库中,并进行重定位工作。动态链接把链接这个过程从本来的程序装载钱被推迟到了装载的时候。

2. 简单的动态链接

  • 动态链接下,一个程序被分成若干个文件,有程序的主要部分,即客户性文件和程序所依赖的共享对象,把这些部分称为模块,即动态链接下的可执行文件和共享对象都可以看作是程序的一个模块
  • 如果函数是一个定义在某个动态共享对象中的函数,那么链接器就会将这个符号的引用标记为一个动态链接的符号,不对它进行地址重定位,把这个过程留到装载时再运行
  • 共享对象的最终装载地址在编译时是不确定的,而是在装载时,装载器根据当前地址空间的空闲情况,动态分配一块足够大小的虚拟地址空间给对应的共享对象

3. 地址无关代码

  • 固定装载地址的困扰

    • 静态共享库的做法是将程序的各种模块统一交给操作系统来管理,操作系统在某个特定的地址划分出一些地址块,为那些已知的模块预留足够的空间
    • 地址冲突问题、静态共享库升级问题
    • 解决 :让共享对象在任意地址加载,共享对象在编译时不能假设自己在进程虚拟地址空间中的位置
  • 装载时重定位

    • 在链接时,对所有绝对地址的引用不作重定位,而把这一步推迟到装载时再完成,一旦模块装载地址确定,即目标地址确定,那么系统就对程序中的绝对地址引用进行重定位
    • 静态链接时的重定位叫做链接时重定位,现在这种叫做装载时重定位,在 Windows 中又被叫做基址重置
    • 装载时重定位不适合用来解决共享对象所存在的问题
  • 地址无关代码 PIC

    • 装载时重定位是解决动态模块中的有绝对地址引用的办法之一,但是指令部分无法在多个进程之间共享,失去了动态链接节省内存的优势
    • 地址无关代码 :把指令中哪些需要修改的部分分离出来,跟数据部分放在一起,这样指令部分可以保持不变,而数据部分可以在每个进程中拥有一个副本,程序模块中共享的指令部分在装载时不需要因为装载地址的改变而改变
    • 把共享对象模块中的地址引用按照是否为跨模块分为模块内部引用和模块外部引用,按照不同的引用方式又可以分为指令引用和数据访问
      • 类型一 模块内部调用或扩展 :可以使相对地址调用,或者是基于寄存器的相对调用,这种指令是不需要重定位的
      • 类型二 模块内部数据访问 :任何一条指令与它需要访问的模块内部数据之间的相对位置是固定的,只需要对于当前指令加上固定的偏移量就可以达到访问相应变量的目的(PC 值加上一个偏移量)。模块在编译时可以确定模块内部变量相对于当前指令的偏移
      • 类型三 模块间数据访问 :ELF 的做法是在数据段里面建立一个指向这些变量的指针数组,也被称为全局偏移表 GOT,当代码需要引用全局变量时,也可以通过 GOT 中相对应的项间接引用。在编译时确定 GOT 相对于当前指令的偏移,然后通过得到 PC 值后加上一个偏移量,根据变量地址在 GOT 中的偏移就可以得到变量的地址,是的 GOT 做到指令的地址无关
      • 模块四 模块间调用、跳转 :与数据访问类似,GOT 中相应的项保存的是目标函数的地址,当模块要调用目标函数时,可以通过 GOT 中的项进行间接跳转
    • 使用 "-fPIC" 和 "-fpic" 参数来产生地址无关代码
  • 共享模块的全局变量问题

    • 当一个模块引用了一个定义在共享对象的全局变量的时候,编译器在编译这个模块时,无法根据上下文判断变量是定义在同一个模块的其他目标文件还是定义在另外一个共享对象之中,即无法判读是否为跨模块间的调用
    • 解决办法 :把所有使用这个变量的指令都指向位于可执行文件中的那个副本,ELF 共享库在编译时,默认都把定义在模块内部的全局变量当做定义在其他模块的全局变量,通过 GOT 来实现变量的访问,该变量在运行时实际上最终就只有一个实例
    • 特殊需求 :多进程共享全局变量叫做“共享数据段”、多个线程访问不同的全局变量副本叫做“线程私有存储”
  • 数据段地址无关性

    • 对于数据段来说,它在每个进程都以一份独立的副本,并不担心被进程改变,可以选择装载时重定位的方法来解决数据段中绝对地址引用问题
    • 对于共享对象来说,如果数据段有绝对地址引用,那么编译器和链接器就会产生一个重定位表。当动态链接器装载共享对象时,如果发现该共享对象有重定位入口,那么动态链接器就会对该共享对象进行重定位
    • 对于可执行文件来说,默认情况下,如果可执行文件是动态链接的,那么 GCC 会使用 PIC 方法来产生可执行文件的代码段部分,一边与不同的进程能够共享代码段

4. 迟延绑定 PLT

  • 动态链接比静态链接慢

    • 动态链接下对于全局和静态的数据访问都要进行复杂的 GOT 定位,然后间接寻址;对于模块间的调用也要先定位 GOT,然后进行间接跳转
    • 动态链接的链接工作在运行时完成,即程序开始执行时
  • 延迟绑定实现

    • 当函数第一次被用到时才进行绑定,如果没有用到则不进行绑定
    • ELF 使用 PLT 的方法来实现,调用某个外部模块的函数时,通常做法是通过 GOT 中相应的项进行间接跳转,PLT 在这个过程中间又增加了一层间接跳转,只有使用到该函数才跳转到 GOT 来完成符号解析和重定位工作

5. 动态链接相关结构

  • 动态链接情况下,可执行文件的装载与静态链接情况基本一样,首先操作系统会读取可执行文件的头部,检查文件的合法性,然后从头部中的 “Program Header” 中读取每个 “segment” 的虚拟地址、文件地址和属性,并将它们映射到进程虚拟空间的相应位置。但是,可执行文件依赖于很多共享对象,对于很多外部符号的引用还处于无效地址的状态,即还没有跟相应的共享对象中的实际位置链接起来。所以在映射完可执行文件之后,操作系统会先启动一个动态链接器。操作系统同样通过映射的方式将它将它加载到进程的地址空间中,加载完动态链接之后,就将控制权交给动态链接器的入口地址,当动态链接器得到控制权之后,它开始执行一系列自身的初始化操作,然后根据当前的环境参数,开始对可执行文件进行动态链接工作。当所有动态链接工作完成以后,动态链接器会将控制权转交到可执行文件的入口地址,程序开始执行

  • “.interp” 段

    • 动态链接器是由是由 ELF 可执行文件决定的,在动态链接的 ELF 可执行文件中,有一个专门的段叫做 “.interp” 段
    • “.interp” 里面保存的是一个字符串,就是可执行文件所需要的动态链接器的路径,在 Linux 中,操作系统在对可执行文件的进行加载的时候,它回去寻找装载盖可执行文件所需要相应的动态链接器,即 “.interp” 段指定的路径的共享对象
  • “.dynamic” 段

    • 动态链接 ELF 中最重要的结构应该是 “.dynamic” 段,这个段里面保存了动态链接器所需要的基本信息,比如依赖于哪些共享对象、动态链接符号表的位置、动态链接重定位表的位置、共享对象初始化代码的地址
    • “.dynamic” 段可以看成是动态链接下 ELF 文件的“文件头”
  • 动态符号表

    • 为了表示动态链接模块之间的符号导入导出关系,ELF 专门有一个叫做动态符号表的段来保存这些信息,这个段的段名通常叫做 “.dynsym”,只保存了与动态链接相关的符号,对于那些模块内部的符号,比如模块私有变量则不保存,而 “.symtab” 中往往保存了所有的符号
    • 动态符号字符串表,用于保存符号名的字符串表,类似于静态链接时的符号字符串表 “.strtab”
    • 动态链接下,需要在程序运行时查找符号,为了加快符号的查找过程,往往还有辅助的符号哈希表 “.hash”
  • 动态链接重定位表

    • 对于使用 PIC 技术的可执行文件或共享对象来说,虽然它们的代码段不需要重定位(因为地址无关),但是数据段还包含了绝对地址的引用,因为代码段中与绝对地址相关的部分被分离了出来,变成了 GOT,而 GOT 实际上是数据段的一部分
    • 动态链接重定位相关结构
      • 动态链接的文件中,有重定位表叫做 “.rel.dyn” 和 “.rel.plt”,前者实际上是对数据引用的修正,它所修正的位置位于 “.got” 以及数据段,后者是对函数引用的修正,它所修正的位置位于 “.got.plt”
      • 共享对象的数据段是没有办法做到地址无关的,它可能会包含绝对地址的引用,对于这种绝对地址的引用,我们必须在装载时将其重定位
      • 导入函数从 “.rel.plt” 到了 “.rel.dyn”,参数字符串常量的地址在 PIC 时不需要重定位而非 PIC 时需要重定位,因为 PIC 时,这个字符串可以看做是欧痛的全局变量,地址是可以通过 PIC 中相对当前指令的位置加上一个固定偏移计算出来的;而在非 PIC 中,代码段不再使用这种相对于当前指令的 PIC 方法,而是采用绝对地址寻址,所以它需要重定位
  • 动态链接时进程堆栈初始化信息

    • 动态链接器需要知道关于可执行文件和本进程的一些信息,这些信息往往由操作系统传递给动态链接器,保存在进程的堆栈里面,在进程初始化的时候,堆栈里面保存了关于进程执行环境和命令行参数等信息,还保存了动态链接器所需要的一些辅助信息数组
    • 辅助信息数组位于环境变量指针的后面

6. 动态链接步骤和实现

  • 启动动态链接器本身

    • 动态链接器不可以依赖于其他任何共享对象
    • 动态链接器本身所需要的全局和静态变量的重定位有它本身完成,动态链接器必须在启动的时候有一段非常精巧的代码可以完成这项工作又不能用到全局和静态变量,这种具有一定限制条件的启动代码往往被称为自举
    • 动态链接器入口地址即是自举代码的入口,当操作系统将进程控制权交给动态链接器时,动态链接器的自举代码即开始执行执行。自举代码首先找到自己的 GOT,而 GOT 的第一个入口保存的即是 “.dynamic” 段的偏移地址,由此找到了动态链接器本身的 “.dynamic” 段,获得动态链接器本身的重定位表和符号表等,从而得到动态链接器本身的重定位入口,先将它们全部重定位
    • 实际上在动态链接器的自举代码中,动态链接器本身的函数也不能调用,因为使用 PIC 模式变异的共享对象,对于模块内部的函数调用采用的跟模块外部函数调用一样的方式,即使用 GOT/PLT 的方式,所以在 GOT/PLT 没有被重定位之前,自举代码不可以使用任何全局变量,也不能调用函数
  • 装载共享对象

    • 完成基本自举以后,动态链接器将可执行文件和链接器本身的符号表都合并到一个符号表当中,可称为全局符号表

    • 链接器开始寻找可执行文件所依赖的共享对象,“.dynamic” 段中有一种类型的入口是 DT_NEEDED,它所指出的是该可执行文件(或贡共享对象)所依赖的共享对象,由此链接器可以列出可执行文件所需要的所有共享对象,并将这些共享对象的名字放入一个装载集合中

    • 链接器开始从集合中取出一个所需要的共享对象的名字,然后将它相应的代码段和数据段映射到进程空间中

    • 如果这个 ELF 共享对象还依赖于其他共享对象,则将所依赖的共享对象的名字放到装载集合中,循环直到所有所依赖的共享对象都被装载进来为止,可以看作一个图

    • 全局符号介入 :当一个符号需要被加入全局符号表时,如果相同的符号名已经存在,则后加入的符号被忽略 * 全局符号介入与地址无关代码 :模块内部调用或跳转的处理时,如果内部函数由于全局符号介入被其他模块的同名函数覆盖,如果采用相对地址调用,那个相对地址部分就需要重定位,与共享对象的地址无关性矛盾,所以只能当做模块外部符号处理。解决办法 :把内部函数编程编译单元私有函数,即使用 “static” 关键字

  • 重定位和初始化

    • 链接器开始重新遍历可执行文件和每个共享对象的重定位表,将它们的 GOT/PLT 中的每个需要重定位的位置进行修正
    • 重定位完成之后,如果某个共享对象有 “.init” 段,那么动态链接器会执行 “.init” 段中的代码,用以实现共享对象特有的初始化过程,相应的还可能有 “.finit” 段,进程退出时会执行
    • 完成重定位和初始化之后,所有的准备工作就完成可,所需要的共享对象已经装载并链接完成了,动态链接器将控制权转交给程序的入口并开始执行
  • Linux 动态链接器实现

    • 对于静态链接的可执行文件来说,程序的入口就是 ELF 文件头重的 e_entry 指定的入口;对于动态连接的可执行文件,内核会分析它的动态链接器地址(在 “.interp” 段),将动态链接器映射至进程地址空间,然后把控制权交给动态链接器
    • 动态链接器是个非常特殊的共享对象,它不仅是个共享对象,还是个可执行的程序
    • Linux 内核在执行 execve() 时不关心目标 ELF 文件是否可执行,直接是简单按照程序头表里的描述对文件进行装载然后把控制权转交给 ELF 入口地址,所以共享课和可执行文件实际上没什么区别,除了文件头的标志位和扩展名有所不同
    • 动态链接器本身是静态链接的
    • 动态链接器本身可以是 PIC 也可以不是,但是用 PIC 会简单一些
    • 动态链接器可以被当做可执行文件运行,装载地址跟一般的共享对象没区别,即为 0x00000000。这是一个无效的装载地址,内核在装载它时,会为其选择一个合适的装载地址

7. 显式运行时链接

  • 显式运行时链接,有时候也叫作运行时加载,就是让程序自己在运行时控制加载指定的模块,并且可以在不需要该模块时将其卸载。一般的共享对象不需要进行任何修改就可以进行运行时装载,这种共享对象往往被叫做动态装载库
  • 使得程序的模块组织变得灵活,可以用来实现一些诸如插件、驱动等功能,只有程序需要用到某个插件或驱动的时候才会将相应的模块装载进来,而不需要在一开始就见它们全部装载进来,减少了程序启动时间和内存使用;并且程序可以在运行的时候加载某个模块,使得程序本身不必重新启动而实现模块的增加、删除、更新等
  • 动态装载库的装载是通过一系列动态链接器提供的 API,具体的讲共有 4 个函数:打开动态库(dlopen)、查找符号(dlsym)、错误处理(dlerror)、关闭动态库(dlclose)
    • dlopen()
      • 用来打开一个动态库,并将其加载到进程的地址空间,完成初始化过程
      • 参数 :filename(被加载动态库的路径,为空则返回全局符号表的句柄)、flag(函数符号的解析方式)
      • 返回值 :被加载模块的句柄,后面使用 dlsym 或者 dlclose 时要用到
    • dlsym()
      • 是运行时装载的核心部分,通过这个函数来找到所需要的符号
      • 参数 :handle(动态库的句柄)、symbol(所要查找的符号的名字)
      • 返回值 :查找到的符号
      • 符号优先级 :多个同名符号冲突时,现装入的符号优先,这种优先级方式称为装载序列
      • 如果在全局符号表中进行符号查找,则 dlsym() 使用的是装载序列,如果对某个 dlsym() 打开的共享对象进行符号查找,那么采用一种叫做依赖序列的优先级
    • dlerror()
      • 在调用其他几个函数时,用来判断上一次调用是否成功
      • 如果返回 NULL,则表示上一次调用成功;否则返回相应的错误消息
    • dlclose()
      • 将一个已经加载的模块卸载
      • dlopen() 和 dlclose() 使用计数器

八、Linux 共享库的组织

1. 共享库版本

  • 共享库兼容性

    • 兼容更新

    • 不兼容更新

      • 导出函数的行为发生改变,调用这个函数以后产生的结果和以前不一样
      • 导出函数被删除
      • 导出数据的结构发生变化
      • 导出函数的接口发生变化
    • 导出接口为 C++ 的共享库兼容非常困难

  • 共享库版本命名

    • Linux 规定共享库的文件命名规则必须如下 :libname.so.x.y.z
    • 最前面使用前缀 “lib”、中间是库的名字和后缀 “.so”,最后面跟着的是三个数字组成的版本号。“x” 表示主版本号(库的重大升级),“y” 表示次版本号(库的增量升级),“z” 表示发布版本号(库的错误的修正、性能的改进等)
  • SO-NAME

    • 程序必须记录被依赖的共享库的名字和主版本号
    • SO-NAME :共享库的文件名去掉次版本号和发布版本号,保留主版本号
    • “SO-NAME” 的两个相同共享库,次版本号大的兼容次版本号小的
    • 系统会为每个共享库在它所在的目录创建一个跟 “SO-NAME” 相同的并且指向它的软链接,实际上这个软链接会指向目录中主版本号相同、次版本号和发布版本号最新的共享库。目的 :使得所有依赖某个共享库的模块,在编译、链接和运行时,都使用共享库的 SO-NAME,而不使用详细的版本号
    • 编译输出 ELF 文件时,将被依赖的共享库的 SO-NAME 保存到 “.dynamic” 中,这样当链接器进行共享库依赖文件查找时,就会根据系统中各种共享库目录中的 SO-NAME 软链接自动定向到最新版本的共享库
    • SO-NAME 表示一个库的接口,接口不向后兼容,SO-NAME 就发生变化

2. 符号版本

  • 基于符号的版本机制

    • 次版本号交会问题没有因为 SO-NAME 而解决,当某个程序依赖于较高的次版本号的共享库,而运行于较低次版本号的共享库系统时,就可能产生缺少某些符号的错误
    • 解决次版本号交会问题 :让每个导出和导入的符号都有一个相关联的版本号,做法类似于名称修饰的方法,一个共享库每一次次版本号升级,都能给那些在新的次版本号中添加的全局符号打上相应的标记,可以清楚地看到共享库中的每个符号都拥有相同的标签
  • Solaris 中的符号版本机制

    • ld 链接器为共享库新增了版本机制和范围机制
    • 版本机制定义一些符号的集合,这些集合本身都有名字,每个集合都包含一些指定的符号,除了可以拥有符号之外,一个集合还可以包含另外一个集合
    • 范围机制 :共享库外部的应用程序或其他的共享库将无法访问这些符号,可以保护那些共享库内部的公用实用函数,但是共享库的作者又不希望共享库的使用者能够有意或无意地访问这些函数
    • 是对 SO-NAME 机制保证共享库主版本号一致的一种非常好的补充
  • Linux 中的符号版本 :允许同一个名称的符号存在多个版本

3. 共享库系统路径

  • FHS(File Hierarchy Standard)标准规定了一个系统中的系统文件应该如何存放,包括各个目录的结构、组织和作用
    • /lib :存放最关键和基础的共享库
    • /usr/lib :保存一些非系统运行时所需要的关键性的共享库,还包含了开发时可能会用到的静态库、目标文件等
    • /usr/local/lib :放置一些跟操作系统本身并不十分相关的库

4. 共享库查找过程

  • 启动动态链接器
  • 动态链接的模块所依赖的模块路径保存在 “.dynamic” 段里面,由 DT_NEED 类型的项表示
  • Linux 系统中有一个叫做 ldconfig 的程序,为共享目录下的各个共享库创建、删除或更新相应的 SO-NAME,将这些收集起来,集中存放,建立缓存,大大加快了共享库的查找过程

5. 环境变量

  • LD_LIBRARY_PATH :可以临时改变某个应用程序的共享库查找路径,而不会影响系统中的其他程序。默认情况为空,如果为某个进程设置了,那么进程启动时,动态链接器在查找共享库时,会首先查找指定的目录
  • LD_PRELOAD :指定预先装载的一些共享库甚或是目标文件
  • LD_DEBUG :打开动态链接器的调试功能,会在运行时打印出各种有用的信息

6. 共享库的创建和安装

  • 共享库的创建 :与创建一般共享对象的过程基本一致,最关键的是使用 GCC 的两个参数,即 “-shared” 和 “-fPIC”
  • 清除符号信息 :使用一个叫 “strip” 的工具清除掉共享库或可执行文件的所有符号和调试信息,也可使用 ld 的 “-s” 和 “-S” 参数使得链接器生成输出文件时就不产生符号信息
  • 共享库的安装 :将共享库复制到某个标准的共享库目录,如 /lib、/usr/lib 等,然后运行 ldconfig 即可。不过需要系统的 root 权限,可以通过建立相应的 SO-NAME 软链接,告诉编译器和程序如何查找该共享库等
  • 共享库构造和析构函数
    • 在函数声明时加上 __attribute__((constructor)) 的属性,指定为共享库构造函数,会在共享库加载时被执行,即在程序的 main 函数之前执行
    • 在函数声明时加上 __attribute__((destructor)) 的属性,指定为共享库析构函数,会在程序的 main() 函数执行完毕之后执行
    • 如果有多个构造函数,执行顺序是没有规定的,可以指定某个构造或析构函数的优先级,构造函数优先级小的先运行,析构函数相反
  • 共享库版本
    • 共享库还可以是符合一定格式的链接脚本文件
    • 一个或多个输入文件以一定的格式经过变换之后形成一个输出文件

九、Windows 下的动态链接

1. DLL 简介

  • DLL 即动态链接库,相当于 Linux 下打共享对象

  • Windows 下的 DLL 文件和 EXE 文件实际上是一个概念,都是 PE 格式的二进制文件

  • 进程地址空间和内存管理 :DLL 的代码并不是地址无关的,所以它在某些情况下可以被多个进程共享

  • 基地址和 RVA(相对地址)

    • 当一个 PE 文件被装载时,其进程地址空间中的起始值就是基地址,对于任何一个 PE 文件来说,它都有一个优先装载的基地址,这个值就是 PE 文件头中的 Image Base
    • WIndows 在装载 DLL 时,会先尝试把它装载到由 Image Base 指定的虚拟地址,若该地址已经被其他模块占用,那 PE 装载器会选用其他空闲地址,而相对地址就是一个地址相对于基地址的偏移
  • DLL 共享数据段

    • 使用 DLL 来实现进程间通信
    • Windows 允许将 DLL 的数据段设置成共享的,即任何进程都可以共享该 DLL 的同一份数据段
    • 常见的做法是将一些需要进程间共享的变量分离出来,放到另外一个数据段中,然后将这个数据段设置成进程间可共享的,也就是说一个 DLL 中有两个数据段,一个进程间共享,另外一个私有
    • 为安全考虑,DLL 共享数据段来实现进程间通信应该尽量避免
  • DLL 的简单例子

    • DLL 需要显式地告诉编译器需要导出某个符号,否则编译器默认所有符号都不导出
    • 可以通过 __declspec 属性关键字来修饰某个函数或者变量,当使用 __declspec(dllexport) 时表示该符号是从本 DLL 导出的符号,__declspec(dllimport) 表示该符号是从别的 DLL 导入的符号
    • 可以使用 “.def” 文件来声明导入导出符号,类似于 ld 链接器的链接脚本文件
  • 创建 DLL :使用编译器 cl 进行编译 :参数 /LDd 表示生产 Debug 版的 DLL,不加任何参数则表示生产 EXE 可执行文件,可以使用 /LD 来编译生成 Release 版的 DLL

  • 使用 DLL

    • 程序使用 DLL 的过程其实是引用 DLL 中的导出函数和符号的过程,即导入过程
    • “.lib” 文件中并不真正包含 “.c” 文件的代码和数据,是用来描述 “.dll” 的导出符号,包含了 链接时所需要的导入符号以及一部分“桩代码”,以便将程序与 DLL 粘在一起,这样的 “.lib” 文件被称为导入库
  • 使用模块定义文件

    • .def 文件在链接过程中的作用与链接脚本文件在 ld 链接过程中的作用类似,是用于控制链接过程,为链接器提供有关链接程序的导出符号、属性以及其他信息
    • 好处 :可以控制导出符号的符号名;可以将导出函数重新命名;当一个 DLL 语言被多个语言编写的模块使用时,采用这种方法导出一个函数往往会很有用 ;可以控制一些链接的过程,可以控制输出文件的默认堆大小、输出文件名、各个段的属性、默认堆栈大小、版本号等
  • DLL 显式运行时链接

    • LoadLibrary :用来装载一个 DLL 到进程的地址空间,功能与 dlopen 类似
    • GetProcAddress :用来查找某个符号的地址,与 dlsym 类似
    • FreeLibrary :用来卸载某个已加载的模块,与 dlclose 类似

2. 符号导出导入表

  • 导出表

    • 当一个 PE 需要将一些函数或变量提供给其他 PE 文件使用时,把这种行为叫做符号导出,最典型的情况就是一个 DLL 将符号导出给 EXE 文件使用
    • 所有的符号被集中存放在了被称为导出表的结构中,提供了一个符号名与符号地址的映射关系
    • 导出表的最后 3 个成员指向的是 3 个数组,他们是到处地址表(EAT)、符号名表、名字序号对应表
      • 序号 :一个函数导出的符号就是函数在 EAT 中的地址下标加上一个 Base 值
      • 使用序号导入导出省去了函数名查找过程,函数名表也不需要保存在内存中了,但是一个函数的序号可能会改变
      • 现在 DLL 基本都直接使用符号名作为导入导出,进行动态链接时,动态链接器在函数名表中进行二分查找,找到后在名字序号对应表中找到所对应的序号,减去 Base 值,然后在 EAT 中找到对应下标下标的元素
  • EXP 文件

    • 链接器在创建 DLL 时与静态链接一样采用两遍扫描过程
    • 第一遍会遍历所有的目标文件并且收集所有导出符号信息并且创建 DLL 的导出表,链接器会把这个导出表放到一个临时的目标文件叫做 “.edata” 的段中,这个目标文件就是 EXP 文件
    • 第二遍,链接器把这个 EXP 文件当做普通目标文件一样,与其他输入的目标文件链接在一起并且输出 DLL,这时 EXP 文件中的 “.edata” 段也就会被输出到 DLL 文件中并且成为导出表
  • 导出重定向

    • 将某个符号重定向到另外一个 DLL
    • 正常情况下,导出表的地址数组中包含的是函数的 RVA,但是如果这个 RVA 指向的位置位于导出表中,那么表示这个符号被重定向了,被重定向了的符号的 RVA 并不代表该函数的地址,而是执行一个 ASCII 的字符串,这个字符串在导出表中,是符号重定向后的 DLL 文件名和符号名
  • 导入表

    • 如果某个程序中使用到了来自 DLL 的函数或者变量,那么这种行为叫做符号导入
    • 某个 PE 文件被加载时,Windows 加载器的其中一个任务就是将所有需要导入的函数地址确定并且将导入表中的元素调整到正确的地址,以实现动态链接
    • 导入地址数组 IAT :每个元素对应一个被导入的符号,元素的值在不同的情况下有不同的含义,动态链接器刚完成映射还没有开始重定位和符号解析时,IAT 中的元素值表示相对应的导入符号的序号或者是符号名;当 Windows 的动态链接器在完成该模块的链接时,元素值会被动态链接器改写成真正的符号地址。导入地址数组与 ELF 中的 GOT 非常相似
    • 对于 32 位的 PE 来说,如果最高位被置 1,那么低 31 位值就是导入符号的序号值;如果没有,那么元素的值是指向一个 RVA
    • 对于 Windows 来说,它的动态链接器其实是 Windows 内核的一部分,所以它可以随心所欲地修改 PE 装载以后的任意一部分内容,包括内容和它的页面属性;在装载时,将导入表所在的位置的页面改写成可读写的,一旦导入表的 IAT 被改写完,再将这些页面设回只读属性
  • 导入函数的调用

    • PE DLL 的代码段并不是地址无关的,使用了一种叫做重定基地址的方法
    • 链接器在链接时会将导入函数的目标地址导向一小段桩代码,由这个桩代码再将控制权交给 IAT 中真正的目标地址
    • 编译器在产生导入库的时候,同一个导出函数会产生两个符号的定义,一个指向桩代码,一个指向函数在 IAT 中的位置

3. DLL 优化

  • DLL 的代码段和数据段本身并不是地址无关的,默认需要被装载到由 ImageBase 指定的目标地址中,被占用就需要装载到其他得知,引起整个 DLL 的 Rebase

  • 重定基地址

    • PE 的 DLL 中的代码段并不是地址无关的,也就是说它在被装载时有一个固定的目标地址,就是 PE 里面所谓的基地址。默认情况 PE 文件将被装载到这个基地址,一般来说,EXE 的基地址默认为 0x00400000,而 DLL 文件基地址默认为 0x10000000
    • 解决共享对象的地址冲突问题 :Windows PE 采用的是装载时重定位的方法,在 DLL 模块装载时,如果目标地址被占用,那么操作系统就会为它分配一块新的空间,并且将 DLL 装载到该地址,对于每个绝对地址引用都进行重定位,所有这些需要重定位的地方只需要加上一个固定的差值,也就是说加上一个目标装载地址与实际装载地址的差值
    • 由于 DLL 内部的地址都是基于基地址的,或者是相对于基地址的 RVA,那么所有需要重定位的地方都只需要加上一个固定差值,PE 里面把这种特殊的重定位过程又叫做重定基地址,好处是比 ELF 的 PIC 机制有着更快的运行速度,因为 PE 的 DLL 对数据段的访问不需要通过类似于 GOT 的机制,对于外部数据和函数的引用不需要每次都计算 GOT 的位置
    • 改变默认基地址 :MSVC 的链接器提供了指定输出文件的基地址的功能,可以在链接时使用 link 命令中的 “/BASE” 参数指定基地址
    • 系统 DLL :Windows 在安装时就把一块地址分配给了系统 DLL,调整这些 DLL 的基地址使得它们相互之间不冲突,从而在装载时就不需要进行进行重定基址了
  • 序号

    • 一个 DLL 中每一个导出的函数都有一个对应的序号,一个导出函数甚至可以没有函数名,但必须有一个唯一的序号
    • 一般来说,那些仅供内部使用的导出函数,只有序号没有函数名,外部使用者无法推测它的含义和使用方法,以防止误用
    • 现在的 DLL 中,导出函数表的函数名是经过排序的,所以查找可以使用二分查找法,所以综合来看,一般情况下不推荐使用序号作为导入导出的手段
  • 导入函数绑定

    • DLL 绑定 :对绑定的程序的导入符号进行遍历查找,找到以后就把符号的运行时的目标地址写入到被绑定程序的导入表内
    • DLL 绑定的地址失效 :被依赖的 DLL 更新导致 DLL 的导出函数地址发生变化;被依赖的 DLL 在装载时发生重定基址,导致 DLL 的装载地址与被绑定时不一致

4. C++ 与动态链接

  • 使用 C++ 编写 DLL 时很容易遇到兼容性问题
  • 使用 C++ 编写动态链接库,要尽量遵循 :
    • 所有的几口函数都应该是抽象的,所有的方法都应该是纯虚的
    • 所有的全局函数都应该使用 extern "C" 来防止名字修饰的不兼容
    • 不要使用 C++ 标准库 STL
    • 不要使用异常
    • 不要使用虚析构函数,可以创建一个 destroy() 方法并且重载 delete 操作符并且调用 destroy()
    • 不要再 DLL 里面申请内存,而且在 DLL 外释放
    • 不要在接口中使用重载方法(Overloaded Methods,一个方法多重参数)

5. DLL HELL

  • 总的来说,有三种可能的原因导致了 DLL Hell 的发生 :

    • 由使用旧版本的 DLL 替代原来一个新版本的 DLL 而引起,在安装时将一个旧版的 DLL 覆盖掉一个更新版本的 DLL
    • 由新版 DLL 中的函数无意发生改变而引起
    • 由新版 DLL 的安装引入一个新 BUG
  • 解决 DLL Hell 的方法 :

    • 静态链接 :避免使用动态链接,运行程序是就不再依赖 DLL 了,但是会丧失动态链接带来的好处
    • 防止 DLL 覆盖 :使用 Windows 文件保护技术来缓解
    • 避免 DLL 冲突 :让每个程序拥有一份自己依赖的 DLL,把问题 DLL 的不同版本放到该应用程序的文件夹中,而不是系统文件夹中
    • .NET 下 DLL Hell 的解决

十、 内存

1. 程序的内存布局

  • 32 位的系统里,内存空间拥有 4GB(2^32)的寻址能力,被称为平坦的内存模型,整个内存是哟个统一的地址空间
  • 实际上内存仍然在不同的地址区间上有着不同的地位,例如大多数 OS 会将 4GB 的内存空间中的一部分挪给内核使用,应用程序无法直接访问这一段内存,这一部分内存地址被称为内核空间,Windows 在默认情况下会将高地址的 2GB 空间分配给内核(也可配置为 1GB),Linux 默认情况下将高地址的 1GB 空间分配给内核
  • 用户使用的剩下的 2GB 或 3GB 的内存空间称为用户空间,一般来讲,应用程序使用的内存空间里有如下“默认”的区域 :
    • 栈 :用于维护函数调用的上下文,离开了栈函数调用就没法实现;通常在用户空间的最高地址处分配,通常有数兆字节的大小
    • 堆 :用来容纳应用程序动态分配的内存区域,当程序使用 malloc 或 new 分配内存时,得到的内存来自堆里;通常位于栈的下方(低地址方向),在某些时候,堆也有可能没有固定统一的存储区域。堆一般比栈大很多
    • 可执行文件映像 :存储着可执行文件在内存里的映像
    • 保留区 :不是一个单一的内存区域,而是对内存中受到保护而禁止访问的内存区域的总称

2. 栈与调用惯例

  • 什么是栈

    • 栈被定义为一个特殊的容器,先进后出
    • 栈保存了一个函数调用所需要的维护信息,这场被称为堆栈帧或活动记录,堆栈帧一般包括如下几方面内容 :
      • 函数的返回地址和参数
      • 临时变量 :包括函数的非静态局部变量以及编译器自动生成的其他临时变量
      • 保存的上下文 :包括在函数调用前后需要保持不变的寄存器
    • 在 i386 中,一个函数的活动记录用 ebp 和 esp 这两个寄存器划定范围
      • esp 寄存器始终指向栈的顶部没同时也就执行了当前函数活动记录的顶部

      • ebp 寄存器指向了函数活动记录的一个固定位置,又被成为帧指针,在参数之后的数据(包括参数)即是当前函数的活动记录,ebp 固定在哪个位置,不随函数的执行而变化;固定不变的 ebp 可以用来定位函数活动记录中的各个数据,在 ebp 之前首先是这个函数的返回地址,再往前是压入栈中的参数;ebp 所直接指向的数据是调用该函数前 ebp 的值,这样在函数返回的时候,ebp 可以通过读取这个值恢复到调用前的值

      • 把 ebp 压入栈中,是为了在函数返回的时候便与恢复以前的 ebp 值;之所以可能要保存一些寄存器,在于编译器可能要求某些寄存器在调用前后保持不变,那么函数就可以在调用开始时将这些寄存器的值压入栈中,结束后再取出

      • i386 标准函数进入和退出指令序列,基本的形式为 :

          push ebp      // 保存 ebp
          mov ebp, esp  // 让 ebp 指向目前的栈顶
          sub esp, x    // 在栈上开辟一块空间
          [push reg1]   // 保存寄存器
          ...
          [push regn]
          
          // 函数实际内容
          mov eax, x   // 通过寄存器传递返回值
          
          [pop reg1]    // 从栈上恢复寄存器
          ...
          [pop regn]
           mov esp, ebp // 恢复进入函数前的 esp 和 ebp
           pop ebp       
           ret          // 返回
        
  • 调用惯例 :函数的调用方和被调用方对于函数如何调用要有一个明确的规定,只有双方都遵守,函数才能被正确地调用

    • 函数参数的传递顺序和方式 :最常见的一种是通过栈传递
    • 栈的维护方式 :函数将参数压栈之后,函数体会被调用,此后需要将被压入栈的参数全部弹出,以使得栈在函数调用前后保持一致
    • 名字修饰的策略 :不同的调用惯例有不同的名字修饰策略
    • cdecl 是 C 语言默认的调用惯例 :参数传递顺序为从右至左的顺序压参数入栈,出栈方为函数调用方,名字修饰为直接在函数名称前加一个下划线
  • 函数返回值传递

    • eax 是传递返回值的通道,函数将返回值存储在 eax 中,返回后函数的调用方再读取 eax
    • eax 本身只有 4 个字节,返回大的返回值需要 :
      • 首先 main 函数在栈上额外开辟了一片空间,并将这块空间的一部分作为传递返回值的临时对象,这里称为 temp
      • 将 temp 对象的地址作为隐藏参数传递给函数
      • 函数将数据拷贝给 temp 对象,并将 temp 对象的地址用 eax 传出
      • 函数返回之后,main 函数将 eax 指向的 temp 对象的内容拷贝给 n
    • 如果返回值类型尺寸太大,C 语言在函数返回时会使用一个临时的栈上内存区域作为中转,结果返回值会被拷贝两次

3. 堆与内存管理

  • 什么是堆
    • 堆是一块巨大的内存空间,常常占据整个虚拟空间的绝大部分,在这片空间里,程序可以请求一块连续内存,并自由地使用,这块内存在程序主动放弃之前都会一直保持有效
    • 如果每次程序申请或者释放对空间都需要进行系统调用,系统调用的性能开销是很大的,频繁操作严重影响性能,比较好的做法就是程序向操作系统申请一块适当大小的堆空间,然后程序自己管理这块空间,管理者堆空间分配的往往是程序的运行库
  • Linux 进程堆管理
    • 提供两个系统调用 :
      • 一个是 brk() 系统调用,作用实际上是设置进程数据段的结束地址,可以扩大或者缩小数据段(Linux 下数据段和 BSS 合并在一起统称数据段)
      • 另一个是 mmap(),作用是向操作系统申请一段虚拟地址空间,这段虚拟地址空间可以映射到某个文件,当它不将地址空间映射到某个文件时,又称这块空间为匿名,匿名空间就可以拿来作为堆空间
    • 从理论可以推论,2.6 版的 Linux 的 malloc 的最大空间申请数应该在 2.9G 左右(可执行文件占去一部分、0x08040000 之前的地址占去一部分、栈占去一部分、共享库占去一部分)
  • Windows 进程堆管理
    • Windows 的进程将地址空间分配给了各种 EXE、DLL 文件、堆、栈
    • 每个线程的栈都是独立的,所以一个进程中有多少个线程,就应该有多少个对应的栈,对于 Windows 来说,每个线程默认的栈大小是 1MB,在线程启动时,系统会为它在进程地址空间中分配相应的空间作为栈
    • Windows 系统提供了一个 API 叫做 VirtualAlloc(),用来向系统申请空间,与 Linux 下的 mmap 非常相似,实际上申请的空间不一定只用于堆,仅仅是想系统预留了一块虚拟地址,应用程序可以按照需要随意使用
    • 使用 VirtualAlloc() 函数申请空间时,系统要求空间大小必须为页的整数倍
    • 在 Windows 中,有基于对管理器实现的分配的算法,对管理器提供了一套与堆相关的 API 可以用来创建、分配、释放和销毁堆空间
    • 通过 Windows 进程地址空间分布,可知一个进程中能够分配给堆用的空间不是连续的,所以当一个堆的空间已经无法再扩展时,必须创建一个新的堆,运行库的 malloc 函数已经解决了这一切
    • 进程中可能存在多个堆,但是一个进程中能够分配的最大堆空间取决于最大的那个堆
  • 堆分配算法
    • 空闲链表
      • 实际上就是把堆中各个空闲的块按照链表的方式连接起来,当用户请求一块空间时,可以遍历整个列表,直到找到适合大小的块并且将它拆分;当用户释放空间时将它合并空闲链表中
      • 但是一旦链表被破坏,或者记录长度的那 4 字节被破坏,整个对就无法正常工作
    • 位图
      • 核心思想是将整个堆分配为大量的块,每个块的大小相同,当用户请求内存的时候,总是分配整数个块的空间给用户,第一个块称为已分配区域的头,其余的称为已分配区域的主体
      • 可以用一个整数数组来记录块的使用情况,由于每个块只有头/主体/空闲三种状态,因此仅需要两位即可表示一个块,因此称为位图
      • 优点 :
        • 速度快
        • 稳定性好
        • 块不需要额外信息
      • 缺点
        • 分配内存的时候容易产生碎片
        • 如果对很大,或者设定一个快很小,那么位图将会很大,可能失去 cache 命中率的优势,也会浪费一定的空间
    • 对象池
      • 如果每一次分配的空间大小都一样,那么就可以按照这个每次请求分配的大小作为一个单位,把整个堆空间划分为大量的小块,每次请求的时候只需要找到一个小块就可以了
      • 每次总是只请求一个单位的内存,因此请求得到满足的速度非常快,无需查找一个足够大的空间

十一、 运行库

1. 入口函数和程序初始化

  • 程序从 main 开始吗

    • 操作系统装载程序之后,首先运行的代码并不是 main 的第一行,而是某些别的代码,这些代码负责准备好 main 函数执行所需要的环境,并且负责调用 main 函数这时才可以在 main 函数里大胆地写各种代码 :申请内存、使用系统调用、触发异常、访问 I/O。在 main 返回之后,会记录 main 函数的返回值,调用 atexit 注册的函数,然后结束进程
    • 运行这些代码的函数称为入口函数或入口点,程序的入口点实际上是一个程序的初始化和结束部分,往往是运行库的一部分
    • 典型的程序运行步骤大致如下 :
      • 操作系统在创建进程后,把控制权交到了程序的入口,这个入口往往是运行库中的某个入口函数
      • 入口函数对运行库和程序环境进行初始化,包括堆、I/O 、线程、全局变量构造,等等
      • 入口函数在完成初始化之后,调用 main 函数,正式开始执行程序主体部分
      • main 函数执行完毕以后,返回到入口函数,入口函数进行清理工作,包括全局变量析构、堆销毁、关闭 I/O 等,然后进行系统调用结束进程
  • 入口函数如何实现

    • GLIBC 入口函数
    • MSVC CRT 入口函数
      • 程序一开始堆还没有被初始化,alloca 是唯一可以不使用堆得动态分配机制,可以在栈上分配任意大小的空间,并在函数返回的时候会自动释放,就好像局部变量一样
  • 运行库与 I/O

    • 一个程序的 I/O 指代了程序与外界的交互,包括文件、管道、网络、命令行、信号等
    • 广义地讲,I/O 指代任何操作系统理解为“文件”的事物,许多 OS 都将各种具有输入和输出概念的实体——包括设备、磁盘文件、命令行等——统称为文件
    • C 语言文件操作是通过一个 FILE 结构的指针来进行的
    • OS 层面上,文件操作也有类似于 FILE 的一个概念,在 Linux 里,叫做文件描述符,在 Windows 里,叫做句柄。用户通过某个函数打开文件以获得句柄,然后用户操纵文件皆通过该句柄进行,因为句柄可以防止用户随意读写操作系统内核的文件对象,文件句柄总是和内核的文件对象相关联的
  • MSVC CRT 的入口函数初始化

    • 系统堆初始化 :由函数 _heap_init 完成,调用了 HeapCreate 这个 API 创建了一个系统堆
    • I/O 初始化
      • 在 MSVC 中,FILE 结构中最重要的一个字段 _file_file 是一个整数,通过它可以访问到内部文件句柄表中的某一项
      • 在 Windows 中,用户态使用句柄来访问内核文件对象
      • 访问文件时,必须要从 FILE 结构转换到操作系统的句柄
      • MSVC 的 I/O 初始化就是要构造二维的打开文件表
      • _ioinit 函数初始化了 _pioinfo 数组的第一个二级数组,接下来,将一些预定义的打开文件给初始化,包括:
        • 从父进程继承的打开文件句柄,可以选择继承自己的打开文件句柄
        • OS 提供的标准输入输出
      • MSVC 的 I/O 初始化主要进行了如下几个工作 :
        • 建立打开文件表
        • 如果能够继承自父进程,那么从父进程获取继承的句柄
        • 初始化标准输入输出

2. C/C++ 运行库

  • C 语言运行库

    • 使程序能够正常运行,至少包括入口函数,及其所依赖的函数所构成的函数集合,还理应包括各种标准库函数的实现,这样的一个代码集合称之为运行时库,C 语言的运行库被称为 C 运行库
    • 一个 C 语言运行库大致包含了如下功能 :
      • 启动与退出
      • 标准函数
      • I/O
      • 语言实现
      • 调试
  • C 语言标准库

    • 例如 :标准输入输出、文件操作、字符操作、字符串操作、数学函数、资源管理、格式转换、时间/日期、断言、各种类型上的常数,还有一些特殊的操作如:变长参数、非局部跳转
  • glibc 与 MSVC CRT

3. 运行库与多线程

  • CRT 的多线程困扰

    • 线程的访问权限

      • 实际运用中线程也拥有自己的私有存储空间
        • 线程局部存储 TLS
        • 寄存器
      • 从 C 程序员的角度来看 :
        • 线程私有 :
          • 局部变量
          • 函数的参数
          • TLS 数据
        • 线程之间共享(进程所有):
          • 全局变量
          • 堆上的数据
          • 函数里的静态变量
          • 程序代码
          • 打开文件
    • 多线程运行库

      • 提供多线程操作的接口
      • C 运行库本身要能够在多线程的环境下正确运行
  • CRT 改进

    • 使用 TLS
    • 加锁
    • 改进函数调用方式 :修改所有线程不安全的函数的参数列表,改成某种线程安全的版本
  • 线程局部存储实现

    • 一旦一个全局变量被定义成 TLS 类型的,那么每个线程都会拥有这个变量的一个副本,然和线程对该变量的修改都不会影响其他线程中该变量的副本
    • 使用 __declspec(thread) 定义一个线程私有变量的时候,编译器会把这些变量放到 PE 文件的 “.tls” 段中。当系统启动一个新的线程时,它会从进程的堆中,分配一块足够大小的空间,然后把 “.tls” 段中的内容复制到这块空间,于是每个线程都有自己独立的一个 “.tls” 副本
    • 对于每个 Windows 线程来说,系统都会建立一个关于线程信息的结构,叫做线程环境块,保存了现成的堆栈地址、线程 ID 等相关信息
    • 显式 TLS
    • _beginthread() 是对 CreateThread() 的包装,当使用 CRT 时,尽量使用 _beginthread/_beginthreadex()/_endthread()/_endthreadex() 这组函数来创建线程

4. C++ 全局构造函数与析构

  • C++ 入口函数需要在 main 的前后完成全局变量的构造与析构

  • glibc 全局构造与析构

    • _start -> __libc_start_main -> __libc_csu_init -> _init -> __do_global_ctors_aux
    • _init 调用了 __do_global_ctors_aux 函数,它不属于 glibc,而是来自于 GCC 提供的一个目标文件 crtbegin.o,负责构造的函数来自于 GCC
    • __CTOR_LIST__ 数组里面存放的就是全局对象的构造函数的指针
    • 对于每个编译单元,GCC 编译器会遍历其中所有的全局对象,生成一个特殊的函数,这个特殊函数的左右就是对本编译单元里的所有全局对象进行初始化
    • 把每个目标文件的复杂全局/静态对象构造的函数地址放在一个特殊的段里面,让链接器把这些特殊的段收集起来,收集齐所有的全局构造函数后就可以在初始化的时候进行构造了
    • 每个目标文件的 .ctors 段会被合并为一个 .ctors 段,拼接起来的 .ctors 段成为了一个函数指针数组,每一个元素都指向一个目标文件的全局构造函数
    • glibc 的全局构造函数是放置在 .ctors 段里的
    • 为了保证全局对象构造和析构的顺序(先构造后析构),链接器必须包装所有的 “.dtor” 段的合并顺序必须是 “.ctors” 的严格反序,后来采用一种新的做法是通过__cxa_atexit() 在 exit() 函数中注册进程退出回调函数来实现析构
    • 全局对象的构建和析构都是由运行库完成的
  • MSVC CRT 的全局构造与析构

    • mainCRTStartup -> _initterm
    • _initterm 遍历所有的函数指针并且调用
    • MSVC CRT 的全局构造实现在机制上与 Glibc 基本是一样的,不过名字略有不同
    • MSVC CRT 析构 :通过 atexit() 实现全局析构

5. fread 实现

  • fread 最终是通过 Windows 的系统 API :ReadFile() 来实现对文件的读取的;fread 有 4 个参数,功能是尝试从文件流 stream 里读取 count 个大小为 elementSize 个字节的数据,存储在 buffer 里,返回实际读取的字节数

  • 缓冲

    • 如果每次读或写数据都进行一次系统调用,让内核读写数据,系统开销很大,要进行上下文切换、内核参数检查、复制等,会严重影响程序和系统的性能
    • 行缓冲和全缓冲
  • fread_s

    • fread 将所有的工作都转交给了 _fread_s
    • fread_s 的参数比 fread 多了一个 bufferSize,用于指定参数 buffer 的大小,而 fread 只有 SIZE_MAXfread_s 可以指定这个参数以防止越界
    • fread_s 首先对各个参数检查,然后使用 _lock_str 对文件进行加锁,以防止多个线程同时读取文件而导致缓冲区不一致
  • fread_nolock_s

    • 所有的线索最终都指向 _read 函数,它主要负责两件事 :
      • 从文件中读取数据
      • 对文本模式打开的文件,转换回车符
  • _read

    • _read 函数在每次读取管道和设备数据的时候必须先检查 pipech,以免漏掉一个字节
    • ReadFile 是一个 Windows API 函数,由 Windows 系统提供,作用和_read 类似,用于从文件里读取数据
  • 文本换行

    • _read 要为以文本模式打开的文件转换回车符
    • 首先检查文件是否以文本模式打开,再进行 “\r\n” 之类的转换
  • fread 回顾

    • fread
    • fread_s 增加缓冲溢出保护,加锁
    • _fread_nolock_s 循环读取、缓冲
    • _read 换行符转换
    • ReadFile Windows 文件读取 API

十二、 系统调用与 API

1. 系统调用介绍

  • 什么是系统调用
    • 为了让应用程序有能力访问系统资源,也为了让程序借助操作系统做一些必须由操作系统支持的行为,每个操作系统都会提供一套接口,以供应用程序使用,这些接口往往通过中断来实现
    • 涵盖程序运行所必须的支持、系统资源的访问、对图形界面的操作支持等
    • 需要保持稳定和向后兼容
    • Windows 与应用程序的最终接口是 API
  • Linux 系统调用
    • x86 下,系统调用有 0x80 中断完成,各个通用寄存器用于传递参数,EAX 寄存器用于表示系统调用的接口号
    • 包括进程处理、读写文件、权限管理、定时器、信号、网络等
  • 系统调用的弊端
    • 使用不便
    • 各个操作系统之间系统调用不兼容
    • 解决办法 :使用运行库为系统调用和程序之间的一个抽象层

2. 系统调用原理

  • 特权级与中断
    • 现代 OS 中,通常有两种特权级别 :用户模式和内核模式,也被称为用户态和内核态
    • 系统调用是运行在内核态的,而应用程序基本都是运行在用户态的
    • 操作系统一般通过中断来从用户态切换到内核态
    • 中断是一个硬件或软件发出的请求,要求 CPU 暂停当前的工作转手去处理更加重要的事
    • 中断一般具有两个属性:中断号和中断处理程序;不同的中断具有不同的中断号,一个中断处理程序一一对应一个中断号
  • 基于 int 的 Linux 的经典系统调用实现
    • 触发中断 :利用宏
    • 切换堆栈 :调用中断时,程序的执行会在用户态和内核态之间切换,程序的当前栈也在用户栈和内核栈之间切换,当前栈指的是 ESP 的值所在的栈空间,寄存器 SS 的值还应该指向当前栈所在的页
    • 中断处理程序
    • 用户调用系统调用时,根据系统调用参数数量不同,依次将参数放入 EBX、ECX、EDX、ESI、EDI、EBP 这 6 个寄存器中传递,进入系统调用的服务程序 system_call 的时候,调用了一个宏 SAVE_ALL 来保存各个寄存器
  • Linux 的新型系统调用机制
    • 使用 ldd 来获取一个可执行文件的共享库的依赖情况,会看到 linux-gate.so.1 没有与任何实际的文件相对应,是用于支持新型系统调用的“虚拟”共享库,并不存在实际的文件,只是操作系统生成的一个虚拟动态共享库
    • 新型系统调用指令 :sysenter,调用之后系统会直接跳转到某个寄存器指定的函数执行,并自动完成特权级转换、堆栈切换等功能;在参数传递方面,新型的系统调用与使用 int 的系统调用完全一样

3. Windows API

  • Windows API 是指 Windows 操作系统提供给应用程序开发者最底层的、最直接与 Windows 打交道的接口,在 Windows OS 下,CRT 是建立在 Windows API 之上的,MFC 是很著名的一种以 C++ 形式封装的库
  • 概述 :Windows API 是以 DLL 导出函数的形式暴露给应用程序开发者的
  • 为什么要使用 Windows API(放着系统调用不用,在 CRT 和系统调用之间增加一层 Windows API 层):系统调用实际上是非常依赖于硬件结构的一种接口,受到硬件的严格限制,比如寄存器的数量、调用时的参数传递、中断号、堆栈切换等,都与硬件密切相关,如果硬件结构稍微发生改变,大量的应用程序可能就会出现问题。 所以 Windows OS 把系统调用包装了起来,使用 DLL 导出函数作为应用程序的唯一可用的接口暴露给用户
  • API 与子系统
    • 子系统又称为 Windows 环境子系统,简称子系统
    • 子系统又是 Windows 架设在 API 和应用程序之间的另一个中间层,是用来为各种不同平台的应用程序创建与它们兼容的运行环境

十三、 运行库实现

1. C 语言运行库

  • 实现 Mini CRT,它应该具备 CRT 的基本功能以及遵循几个基本设计原则 :
    • 应该以 ANSI C 的标准库为目标,尽量做到与其接口一致
    • 具有自己的入口函数
    • 基本的进程相关操作
    • 支持堆操作
    • 支持基本的文件操作
    • 支持基本的字符串操作
    • 支持格式化字符串和输出操作
    • 支持 atexit() 函数
    • 应该是跨平台的
    • 实现应该尽量简单
  • 开始
    • 从入口函数开始
      • 程序的最初入口点不是 main 函数,而是由运行库为其提供的入口函数,主要负责 :准备好程序运行环境及初始化运行库、调用 main 函数执行程序主体、清理程序运行后的各种资源
      • 运行库为所有程序提供的入口函数应该相同,在链接程序时须要指定该入口函数名
      • 须要确定入口函数的函数原型,包括函数名、输入参数及返回值
      • 初始化主要负责好程序运行的环境,包括准备 main 函数的参数、初始化运行库,包括堆、IO 等,结束部分主要负责清理程序运行资源
    • main 函数 :argc、argv
    • CRT 初始化 :主要是堆和 IO 部分
    • 结束部分 :调用由 atexit() 注册的退出回调函数、实现结束进程
  • 堆的实现
    • 实现一个以空闲链表算法为基础的堆空间分配算法
    • 为了简单,堆空间大小固定为 32MB,初始化之后空间不再扩展或缩小
    • 采用 VirtualAlloc 向系统直接申请 32MB,由自己的对分配算法实现 malloc
    • Linux 平台下,使用 brk 将数据段结束地址向后调整 32MB,将这块空间作为堆空间
  • IO 与文件操作
    • 仅实现基本的文件操作,包括 fopen、fread、fwrite、fclose、fseek
    • 不实现缓冲机制
    • 不对 Windows 下的换行机制进行转换
    • 支持三个标准的输入输出 stdin、stdout、stderr
    • 在 Windows 下,文件基本操作可以使用 API
    • Linux 不像 Windows 那样有 API 接口,必须使用内嵌汇编实现 open、read、write、close、seek 这几个系统调用
    • fopen 时只区分“r”、“w”、“+”这几种模式及它们的组合,不对文本模式和二进制模式进行区分,不支持追加模式(“a”)
  • 字符串相关操作 :无须涉及任何与内核交互
  • 格式化字符串 :实现 printf

2. 如何使用 Mini CRT

  • Mini CRT 也将以库文件和头文件的形式提供给用户,可以建立一个 minicrt.h 的头文件,然后将所有相关的常数定义、宏定义,以及 Mini CRT 所实现的函数声明等放在该头文件里,当用户使用时,仅需要 #include "minicrt.h" 即可
  • MiniCRT 仅依赖与 Kernel32.DLL,的确绕过了 MSVC CRT 的运行库 msvcr90.dll

3. C++ 运行库实现

  • 在 Mini CRT 的基础上实现一个支持 C++ 的运行库
  • 遵循以下原则 :
    • 尽量简化设计,尽量符合 C++ 标准库的规范
    • 对于可以直接在头文件实现的模块尽量在头文件中实现
    • 可以在 Windows 和 Linux 上同事运行,因此对于平台相关部分要使用条件编译分别实现
    • 模板是不需要运行库支持的,它的实现依赖于编译器和链接器
  • new 与 delete :在堆上分配空间
  • C++ 全局构造与析构 :实现依赖于编译器、链接器和运行库三者共同的支持和协作
  • atexit 实现 :由它注册的函数会在进程退出前,在 exit() 函数中调用;实现的原因是 所有全局对象的析构函数都是通过 atexit() 或其类似函数来注册的,以达到在程序退出时执行的目的
  • 入口函数修改 :把对 do_global_ctors()mini_crt_call_exit_routine 的调用加入到 entry() 和 exit() 函数中去
  • stream 和 string

4. 如何使用 Mini CRT++

读书笔记
Web note ad 1