读书笔记 ——《程序员的自我修养-链接、装载与库》 Domi●Cat

目录

这本书大概可以分为三个比较重要的部分:

  1. 静态链接
  2. 装载和动态链接
  3. 库与运行库

第一章:简介

本章主要是简介。

  1. 介绍了计算机硬件架构的发展,比如:总线的变化、南北桥芯片、单核CPU到多核CPU的发展。

  2. 介绍了软件体系结构,比如:软件系统的分层、接口、API等等。

    • 接口(interface),即中间层,接口的下层为接口提供者,接口的上层为接口的使用者。每个中间层是对它下层的包装和扩展。
    • 应用程序编程接口(Application Programming Interface),简称API,应用程序的接口提供者是运行库,比如Linux的Glibc库提供POSIX的API。
    • 系统调用接口(System Call Interface),它的使用者就是运行库,提供者是操作系统。
    • 硬件接口,操作系统内核与硬件之间的中间层。

    一个大概的流程:应用程序(例如浏览器)–(API)–> 运行库 –(系统调用)–> 操作系统内核 –(硬件接口)–> 硬件

  3. 介绍了操作系统

    我们目前使用最多的是多任务系统,操作系统接管所有的硬件资源,并且系统本身运行在一个收硬件保护的级别。 所有的应用程序都是以进程(process)运行(级别低于操作系统),有自己独立的地址空间(这里涉及到虚拟内存地址)。

    关于内存地址空间,分为:虚拟地址空间(Virtual Address Space)和 物理地址空间(Phy sical Address Space)。 目前,使用分页(Paging)的方法来划分并管理虚拟地址空间(通常一页为4KB)。

    把在虚拟内存中的页称为虚拟页(Virtual Page),在物理内存中的称为物理页(Physical Page),在磁盘中的页称为磁盘页(Disk Page)。

    注意:虚拟存储的实现需要依靠硬件来实现,几乎所有的硬件都采用一个叫MMU(Memory Management Unit)的部件来进行页映射。MMU一般集成在CPU内部。

  4. 介绍了线程,包括:线程的概念、线程调度、线程安全、用户线程和内核线程之间的映射关系等。

    线程:被称为轻量级进程(LightWeight Process),是程序流执行的最小单元,一个标准的线程包括:线程ID、当前指令指针PC、寄存器集合、堆栈。

    一个进程内的所有线程共享进程的内存空间(包括代码段、数据段、堆等)以及一些进程级的资源(如打开的文件和信号)。

    进程和线程的关系如下图:

    进程和线程的关系

    线程的权限如下图: 线程的权限

    • 线程调度

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

      • 运行(running),此时线程正在执行
      • 就绪(ready),此时线程可以立即执行,但CPU已经被其他线程占用
      • 等待(waiting),此时线程正在等待某一事件(通常是IO或者同步)发生,无法执行。

      线程状态切换

      一般把频繁等待的线程称为IO密集型线程,把很少等待的线程称为CPU密集型线程,因为频繁进入等待的线程意味着,它不需要将CPU时间片耗尽。

    • 线程安全,包括:竞争和原子操作、同步和锁、可重入
    • 线程模型,用户实际使用的线程并不是内核线程,而是存在于用户态的用户线程。对于用户来说,如果有三个线程在同时执行,而对于内核来说,很有可能只有一个线程。

第二章:编译和链接

  1. 编译过程可以分解为4个步骤:预处理(Prepressing)、编译(Compilation)、汇编(Assembly)、链接(Linking)。

    假如有一个c程序hello.c,代码如下:

     #include <stdio.h>
     #define HELLO "hello,world!"
     int main(){
         printf("%s\n", HELLO);
         return 0;
     }
    
    • 预处理

      使用命令:gcc -E hello.c -o hello.i,将源文件进行预处理操作。预处理主要处理源代码中以“#”开始的预处理指令。 比如:#include, #define, #if等。

      主要处理规则如下:

      1. 将所有的#define删除,并展开所有的宏定义;
      2. 处理所有的条件预处理指令;
      3. 处理#include,将被包含的文件插入到该预处理指令的位置;
      4. 删除所有的注释;
      5. 添加行号和文件名标示,例如:# 2 "hello.c" 2,以便于编译时让编译器产生调试用的行号信息等;
      6. 保留所有的#pragam编译指令,因为编译器要使用它们
    • 编译

      使用命令:gcc -S hello.i -o hello.s, 编译过程就是把预处理过的文件,进行一系列的词法分析、语法分析、语义分析,优化并生成相应的汇编代码文件。

    • 汇编

      使用命令:gcc -c hello.s -o hello.o,将汇编代码转换成机器可以执行的指令。

    • 链接

      使用命令:ld -static hello.o ......代表一系列需要链接的库,这里不一一列举了。最后生成真正可执行的二进制程序。

  2. 编译器会对预处理的源文件进行词法分析、语法分析、语义分析,这里我没有去深入了解,简单过一下即可。

  3. 链接的主要内容:把各个模块之间相互引用的部分都处理好,使得各个模块之间能够正确的衔接。 从原理上来讲,链接器的工作就是把一些指令对其他符号地址的引用加以修正

  4. 链接的过程可以分为:地址和空间分配(Address ans Storage Allocation)、符号决议(Symbol Resolution)和重定位(Relocation)。 符号决议也被叫做符号绑定,只不过“决议”倾向于静态链接,“绑定”倾向于动态链接。

  5. .o.a文件的关系:前者称为目标文件,是源代码(.c或者.cpp)编译后的二进制文件,而后者则是多个目标文件的打包,并加上了一些索引。

  6. 重定位过程示例:

    假如有一个全局变量var,在目标文件A中,如果要在目标文件B中访问这个变量,例如:var=42(汇编指令为movl $0x2a, var), 编译后的机器指令为:C705 00000000 2A000000,在编译B目标文件时,因为无法确定var变量的目标地址,所以mov的目标地址暂时置为00000000, 等到链接器连接A和B后,将变量var的地址确定下来(假如为0x10000000),最终的机器指令变为:C705 10000000 2A000000

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

所有分析都基于下面的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(void){
    static int static_var = 85;
    static int static_var2;

    int a = 1;
    int b;
    func1(static_var +  static_var2 + a + b);
    return 0;
}
  1. 现在PC平台流行的可执行文件格式主要分为:windows下的PE(Portable Executable)和linux下的ELF(Executable Linkable Format),它们都是COFF(Common File format)的变种。

  2. ELF文件标准采用的ELF格式的文件归类:

    ELF文件类型说明实例
    可重定位文件(Relocatable File)这类文件包含了代码和数据,可以被用来链接成可执行文件或共享目标文件,静态链接库也可以归为这一类Linux的.o、.a
    Windows的.obj
    可执行文件(Executable File)这类文件包含了可以直接执行的程序,它的代表就是ELF可执行文件,它们一般都没有扩展名Linux的/bin/bash文件
    Windows的.exe文件
    共享目标文件(Shared Object File)这类文件包含了代码和数据,可以在以下两种情况下使用。
    1、链接器可以使用这种文件跟其他的可重定位文件和共享目标文件链接,生成新的目标文件。
    2、动态链接器可以将几种这种共享目标文件与可执行文件结合,作为检查映像的一部分来运行。
    Linux的.so
    Windows的.DLL
    核心转储文件(Core Dump File)当进程意外终止时,系统可以将该进程的地址空间的内容终止时的一些其他信息转储到core dump文件中Linux的core dump
  3. 目标文件包括:机器指令代码、数据、符号表、调试信息、字符串等,它们都是以的形式存储,例如:机器指令存储在代码段,全局变量和局部静态变量存储在数据段

  4. 一个简单的目标文件段结构:

    程序与目标文件

  5. ELF文件的开头是一个“文件头”(ELF Header),它描述了整个文件的文件属性,包括文件是否可以执行、是静态链接还是动态链接、入口地址(如果是可执行文件)、目标硬件、目标操作系统等信息。 文件头还有一个段表,描述了文件中各个端在文件中的偏移位置以及段的属性等。

  6. ELF基本结构如下图:

    ELF基本结构

  7. 使用命令:readelf -h simple.o,可以查看目标文件的ELF文件头结构,各个字段的意义在这里不做详细的描述了。比较重要的一个字段是Start of section headers,它表示段表的偏移位置。

  8. 使用命令:readelf -S simple.o,可以查看目标文件的段表信息。

  9. 重定位表,一般以”.rela”为前缀,例如.rela.text,它就表示代码段的重定位表。那些需要对代码段中的绝对地址引用的位置,都记录在重定位表中。

  10. 在链接中,我们将函数和变量统称为符号(Symbol),函数名或变量名就是符号名(Symbol Name)。 每个符号有一个对应的值,叫做符号值(Symbol Value),对于变量和函数来说,符号值就是他们的地址。

  11. 符号的分类:

    • 定义在本目标文件的全局符号,可以被其他目标文件引用,例如:全局变量、全局函数。
    • 本目标文件中引用的全局符号,但是没有定义在本目标文件中,一般叫外部符号(External),例如,C语言中的关键字extern int a
    • 段名,由编译器产生,他的值就是段的起始地址。
    • 局部符号,只在编译单元内部可见。例如:局部变量,它们对于链接过程没有作用,链接器会忽略它们。
    • 行号信息,即目标文件指令与源代码行的对应信息
  12. 使用命令:readelf -s simple.o或者objdump -t simple.o,可以查看目标文件的符号表信息。

  13. C++的符号签名机制:所有符号都以_Z开头,对于嵌套的名字(在命名空间或者在类里面的),后面紧跟N,然后是各个命名空间和类的名字,且在每个名字前加上名字字符串长度,再以 E结尾。 对于带参数的函数,它的参数列表会紧跟在E的后面,例如:int参数就是字母i,float参数就是字母f

    函数签名

  14. 为了C++与C语言兼容,C++提供了extern "C"的关键字用法:

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

    所有在extern "C"大括号内的代码,都是以C语言的名称修饰机制来修饰。

  15. 对于C/C++语言来说,编译器默认函数和已初始化的全局变量为强符号,未初始化的全局变量为弱符号。强符号和弱符号都是针对于定义来说。强符号不能被重复定义。

  16. 强引用和弱引用,对于为定义的符号,强引用会直接报未定义错误,而弱引用不认为是错误,并默认其为0或者一个特殊的值,以便程序代码能够识别。弱引用和弱符号主要用于库的链接过程