程序员的自我修养--链接、装载和库读书笔记

第一章 温故而知新

从 Hello World说起

1
2
3
4
5
6
7
#include<stdio.h>
int mian()
{
printf("Hello World\n");
return 0;
}
  • Hello World 被输出的整个流程的思考…

万变不离其宗

  • 计算机的关键部件:中央处理器CPU,内存和I/O控制芯片
  • 北桥和南桥的概念
  • SMP(对称多处理器)和多核

站得高,望得远

  • 系统软件分类:平台性的(系统工具类)和开发类的(IDE,汇编器,链接器等)
  • 计算机软件体系结构

    从上到下
    应用-->运行时库-->操作系统内核-->硬件
    
  • 运行时库使用系统接口一般使用软件中断实现

  • 硬件相关接口(驱动程序)即硬件规格

操作系统做什么

不让CPU打盹
  • 分时系统,多任务系统
  • 进程的概念,进程有自己的地址空间,互相隔离。根据优先级由操作系统统一分配CPU运行时间
  • 抢占式的分配方式
  • CPU在多个进程间快速切换,从而造成很多进程在同时运行的假象。
设备驱动
  • 硬件驱动程序的开发由硬件生产商完成。
  • 硬盘的结构,每个扇区512k

    扇片-磁道(65536)-扇区(1024)
    

内存不够怎么办

直接顺序分配的问题:

  1. 地址空间不隔离
  2. 内存使用率低
  3. 程序运行的地址不确定

解决方法:添加中间层(虚拟地址)

关于隔离
  • 虚拟地址空间和物理地址空间
  • 虚拟地址空间是指虚拟的、人们想象出来的地址空间,其实他并不存在,每个进程都有自己独立的虚拟空间,而且每个进程只能访问自己的地址空间,这样就有效的做到了进程隔离。
分段
  • 将内存分为多段,每段对应一个进程。
  • 物理地址空间和虚拟地址空间相对应。
  • 分段使用效率低
分页
  • 每页大小为4K
  • MMU页面映射

众人拾柴火焰高

线程基础
  • 线程有时被称为轻量级进程。
  • 由线程ID,当前指令指针(PC),寄存器集合和堆栈组成。
  • 一个进程由一个到多个线程组成,每个线程共享进程的内存空间(代码段,数据段,堆等)。
  • 线程的私有存储空间包括栈,线程局部存储(TLS)和寄存器。
  • 线程的访问权限有私有权限和线程共享权限两种。
  • 私有权限包括局部变量,函数的参数和TLS数据
  • 线程的状态包括运行,就绪和等待。
  • 处于运行中的线程拥有一段可以执行的时间称为时间片。
  • 线程调度方案包括优先级调度和轮转法调度。
  • I/O密集型线程(频繁等待的线程)优先级更容易改变
  • CPU密集型线程(很少等待的线程)容易饿死其他低优先级的线程
  • 改变优先级的方式有三种:

    1.用户指定优先级
    2.根据进入等待状态的频繁程度提升或降低优先级
    3.长时间得不到执行而被提升优先级
    
  • 可抢占线程(一般为可抢占)和不可抢占线程(线程利用率低)
线程安全
  • 多线程访问一个共享资源时
  • 原子操作(单指令操作)
  • 同步锁(Synchronization)
  • 二元信号量是一种简单的锁,只有两种状态(占有和非占有)适合只能被一个线程独占的资源。
  • Semaphore(信号量)N个信号量允许N个线程并发访问
  • 互斥量(Mutex)和二元信号量很类似,不同处是互斥量需要在哪个线程获取必须在哪个线程释放。而信号量可以被任意线程获取并释放。
  • 临界区是比互斥量更加严格的同步手段。临界区的作用范围只在本进程。其他进程无法获取该锁。而互斥量可以被其他线程获取(但不能被其他线程释放)。
  • 读写锁。其他线程可读但是不可写。
  • 条件变量,使用条件变量可以让许多线程等待同一个条件的发生。
多线程内部情况

用户线程和内核线程(CPU)的对应关系

  • 一对一,线程并发是真正的并发,线程数受内核限制,上线文切换开销大。
  • 多对一,如果一个用户线程堵塞,其他用户线程也会阻塞。
  • 多对多,结合了上面两种的优点。(常用)

第二章 编译和链接

编译和链接合并到一起的过程叫构建(Build)

被隐藏了的过程

helloworld输出可以分解为4个步骤,分别是 预处理 编译 汇编 链接

预编译
  • 将所有的 #deifne 删除 并展开所有的宏定义
  • 处理所有的条件编译指令 比如#if #else #ifdef #endif 等等。
  • 处理#include预编译指令 将所包含的文件插入到预编译指令的位置
  • 删除所有的注释 // / / 等等
  • 添加行号和文件名标识
  • 保留所有#pragma编译器指令
编译

编译过程就是把预处理完成的文件进行一系列词法分析,语法分析,语义分析及优化后生成相应的汇编代码文件。

汇编

汇编器是将汇编代码转变成机器可以执行的指令,每一个汇编语句几乎都对应一条机器指令

链接

模块的拼接过程 下面还会详细讲。

编译器做了什么

  • 直观角度讲,编译器就是将高级语言翻译为机器语言。
  • 流程:词法分析->语法分析->语义分析->源代码优化->代码生成>目标代码优化.
词法分析
  • 扫描源代码 生成一系列记号(Token)
  • 记号分类:关键字、标识符、字面量(包括数字,字符串)、特殊符号(+、=)。
  • 每种记号存入对应的表内
  • 词法规则可以自定义
语法分析
  • 对上面的词法表进行语法分析、生成语法树
  • 整个分析过程采用上下文无关语法
  • 语法树以表达式为节点
  • 语法树是一种二叉树的应用(个人理解)
语义分析
  • 编译器所能分析的语义是静态语义
  • 静态语义通常包括声明和类型的匹配,类型的转换。
  • 经过语义分析,语法树被标识了类型
  • 语义分析判断该语法是否合法(个人理解)
中间语言生成
  • 直接在语法树上面优化比较困难,所以源代码优化器会先将语法树转换为中间代码
  • 中间代码是设备无关的
  • 编译器前端负责产生机器无关的中间代码,后端负责将中间代码转换成目标机器代码。
  • 跨平台编译器就是有一个前端和多个后端的组合
目标代码生成与优化
  • 编译器后端主要包括代码生成器和目标代码优化器

链接器

  • 重新计算各个目标的地址过程被叫做重定向。
  • 模块的拼接过程在这里被叫做链接
静态链接
  • 链接的过程主要包括:地址和空间分配、符号决议、重定向。
  • 目标文件和库一起链接生成可执行文件
  • 库其实是一种编译后的目标文件
  • 目标文件之间的函数和变量的访问在链接过程中被重定向。

第三章 目标文件里有什么?

编译器编译源代码后生成的文件叫做目标文件,目标文件从结构上讲是已经编译后的可执行文件格式,只是还没有经过链接的过程,其中可能还有一些符号或者有些地址还没有被调整。

目标文件的格式

  • windows下是叫PE-COFF文件格式,Linux下叫ELF文件。
  • 动态链接库和静态链接库也是以上格式。

目标文件是什么样的

  • 目标文件由各种段(Section)组成。
  • 段类型有.text .data .bss .commment等
  • .text主要存放源代码
  • .data主要存放已初始化的全局变量和局部变量
  • .bss存放未初始化的全局和静态变量
数据段和指令段分开存储的好处:
  • 读写权限的不同,数据段是可读写的,指令段是只读的,当程序被装载后,数据和指令会被映射到不同的虚存区域。这样可以防止程序的指令被有意或者无意的改写。
  • 提高了缓存的命中率
  • 最主要的原因:当系统运行多个该程序的副本时,它们的指令都是一样的,所以在内存中只需要保存一份程序的指令部分,而数据会有多份。

挖掘SimpleSection.o

  • .rodata存放的是只读数据(ROM)。
  • 部分编译器不存放为初始化变量,只是预留一个未定义的全局变量符号,等到最终链接成可执行文件的时候再在.bss段分配空间
其他段
  • .rodata
  • .comment
  • .debug
  • .dynamic
  • .hash
  • .line
  • .note
  • .strtab
  • .symtab
  • .shstrtab
  • .plt
  • .got
  • .init
  • .fini

ELF文件结构描述

文件头
  • ELF魔数
  • 文件机器字节长度
  • 数据存储方式
  • 版本
  • 运行平台
  • ABI版本
  • ELF重定位类型
  • 硬件平台
  • 硬件平台版本
  • 入口地址
  • 程序头入口和长度
  • 段表的位置和长度
  • 段的数量
段表
  • 段表就是保存各个段的基本属性的结构
  • 每个段的基本属性保存在一个叫段描述符的结构体里。
  • .rel.data和.rel.text 重定向的段。
  • .strtabl和.shstrtab 字符串表和段表字符串表。

链接的接口–符号

  • 在链接中,目标文件之间的相互拼合实际上是目标文件之间对地址的引用,我们将函数和变量统称为符号,函数名和变量名就是符号名。
  • 每一个目标文件都有一个相应的符号表,这个表里面记录了目标文件中所用到的所有的符号
  • 每个对应的符号有一个对应值叫做符号值,对于变量和函数来说,符号值就是他们的地址。
符号类型
  • 全局符号
  • 外部符号
  • 段名
  • 局部符号
  • 行号信息
符号表结构
  • symtab
  • 每个符号对应一个结构体(Elf32_Sym)
特殊符号
  • 程序起始地址
  • text段结束地址
  • data段结束地址
  • 程序结束地址
  • 以上地址都是程序被装载时的虚拟地址
符号修饰和函数签名
  • 加前缀下划线和后缀下划线
  • 通过命名空间
  • 函数签名信息包括:函数名、参数类型、所在类、命名空间等。
extren “C”

extern修饰的代码段编译器会按C语言的编译规则来处理

强符号和弱符号
  • 函数和初始化了的全局变量为强符号
  • 未初始化的变量为弱符号

第四章 静态链接

当我们有两个目标文件时,如何将它们链接起来形成一个可执行文件?这个过程中发生了什么?这基本上就是链接的核心内容:静态链接

空间地址分配

对于多个输入目标文件,链接器如何将它们的各个段合并到输出文件?

按序叠加
  • 缺点:浪费空间,文件零散。
  • 段的装载地址和空间对齐单位是,每一页有4096个字节。
相似段合并

我们这里谈的空间地址分配只关注虚拟地址空间的分配,不关心输出在可执行文件中的空间

两步链接:

  • 空间与地址分配

  • 符号解析与重定向

符号地址的确定

存储在符号表内

符号解析与重定向

静态链接的核心内容是符号解析和重定向

重定向

重定向需要参考重定位表

重定位表

每一个要被重定位的地方叫一个重定位入口,重定位入口的偏移表示该入口在要被重定位的段中的位置。

符号解析

重定位的过程中,每一个重定位的入口都是对一个符号的引用,那么当链接器需要对某个符号的引用进行重定位时,它就要确认这个符号的目标地址,这个时候链接器就会去查找所有目标文件的符号表,这个过程叫做符号解析。

指令修正方式

不同的处理器指令对于地址的格式和方式都不一样。

  • 绝对寻址修正

  • 相对寻址修正

区别:绝对地址修正后的地址为该符号的实际地址;相对寻址修正后的地址为符号距离被修正位置的地址差

COMMON块

  • 存放弱符号的块

  • 当不同的目标文件需要的COMMON块的空间大小不一致时,以最大的那块为准。

C++相关问题

重复代码消除
  • 模板、外部内联函数、虚函数表都有可能在不同的编译单元生成相同的代码

弊端:

  • 空间的浪费
  • 地址较易出错
  • 指令运行效率较低

函数级别链接的作用就是让所有的函数都像模板一样单独的保存到一个段里面。

全局构造与析构

c++的全局对象构造函数在main()之前被执行,c++的全局对象析构函数在main()之后被执行

  • .init段 全局构造

  • .fini段 全局析构

c++与ABI

ABI:符号修饰标准,变量内存布局方式,函数调用方式等这些跟可执行代码二进制兼容性相关的内容称为ABI

静态库链接

将多个静态库链接到一个可执行文件中的过程。

链接过程控制

链接控制脚本
  • 使用命令行来给链接器指定参数

  • 将链接指令存放在目标文件里面

  • 使用链接控制脚本

c++把这种控制脚本叫做模块定义文件,它们的扩展名一般为.def

使用ld链接脚本

控制链接过程无非是控制输入段如何变成输出段,比如哪些输入段要合并一个输出段,哪些输入段要丢弃,指定输出段的名字,装载地址,属性等。

有人专门研究过最小的ELF可执行文件的大小为45个字节。

ld链接脚本语法简介
  • 语句之间使用;号作为分隔符

  • 表达式与运算符

  • 注释和字符引用

BFD库

BFD库是一个GNU项目,它的目标就是希望通过一种统一的接口来处理不同的目标文件格式。

本章小结

本章介绍了静态链接中的第一个步骤,即目标文件在被连接成最终可执行文件时,输入目标文件的各个段是如何被合并到输出文件中的,链接器如何为他们分配在输出文件中空间和地址。

第六章 可执行文件的装载

进程虚拟空间地址

程序和进程的区别

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

每个程序运行起来后,都有自己的虚拟地址空间,这个虚拟地址空间的大小由计算机的硬件平台决定,具体由CPU的位数决定。

32位的虚拟地址空间为2的32次方,64位的虚拟地址空间为2的64次方。。。

装载方式

覆盖装入(out)

没有发明虚拟存储前应用广泛,现在已经基本被淘汰了。

页映射

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

映射过程已页为单位进行,硬件规定的页的大小有4096字节,8192字节,2MB,4MB等。

装载过程

进程的建立

进程最关键的特征是它拥有独立的虚拟地址空间,这使得它有别于其他进程。

  • 创建一个独立的虚拟地址空间
  • 读取可执行文件,建立虚拟空间与可执行文件的映射关系
  • 将CPU的指令寄存器设置成可执行文件的入口地址,启动运行
进程栈初始化

进程在启动的时候,须知道一些进程运行的环境(环境变量)和进程的运行参数。
这也是main函数的2个参数的来源,argc 为参数数量 argv
为参数字符串指针数组

ipa的大致启动过程

  • 读取ipa文件头部
  • 分配虚拟内存空间
  • 建立虚拟内存与ipa文件的映射关系
  • 加载动态库
  • 初始化runtime
  • initialzers文件
  • 创建主线程,启动进程