X

曜彤.手记

随记,关于互联网技术、产品与创业

吉 ICP 备10004938号

《程序员的自我修养 — 链接、装载与库》读书笔记(一)


本文内容主要摘自《程序员的自我修养 — 链接、装载与库》一书。

第一章:温故而知新

  1. (Page:30)传统的采用 PCI/ISA 及南北桥(北桥负责高速芯片,南桥负责低速芯片)设计的硬件架构。通过南北桥设计来解决高速设备(CPU、内存以及图形设备等)与低速设备(磁盘、USB、键盘以及鼠标等)的协作问题。
  2. (Page:31)对称多处理器 SMP 与多核处理器的区别:前者指每一个 CPU 在系统中所处的地位和所发挥的功能都是一样的,是相互对称的。而后者常应用在个人电脑中,指将多个处理器打包在一起,这些处理器之间共享比较昂贵的缓存部件,只保留多个核心,相当于 SMP 的简化版。
  3. (Page:33)系统调用接口在实现中往往以软件中断的方式提供。
  4. (Page:34)几种 CPU 使用策略:
  1. (Page:35)硬件的驱动程序通常由硬件厂商提供,然后只要符合特定操作系统指定的接口和框架,便可在该操作系统上使用。
  2. (Page:38)进程间的隔离:进程有独立的虚拟地址空间(VMA),且每个进程只能访问自己的地址空间。其大小与计算机地址总线的长度有关,比如 32 位机器,有 32 条(实际是 36 条)地址线,便对应 4G 可寻址大小。
  3. (Page:39)内存分段:进程在其虚拟地址空间内使用的地址最后会由硬件映射到实际物理内存空间的地址。对应用程序来说,可以使用的内存是连续且透明的(不用关心最后映射到物理内存的位置)。但分段的方式是以整个进程为单位进行的,若内存不足,被换入换出到磁盘的都是整个程序,效率低下
  4. (Page:40)内存分页:进程虚拟空间(虚拟页)、物理空间(物理页)与磁盘(磁盘页)之间通过分页来管理资源。通过分页,我们可以把常用的数据和代码页装载到内存中,把不常用的部分保存在磁盘中,但需要用到时再取出即可。不仅如此,每个页也可以设置独立的权限属性,以保护操作系统和进程。对于大多数 CPU 来说,会使用 MMU(Memory Management Unit,一般被集成在 CPU 内部)来进行“页映射”即:转换 CPU 发出的虚拟地址到物理地址。
  5. (Page:42)一个标准的线程是由:线程 ID、当前指令指针(PC)、寄存器集合以及堆栈组成。线程的私有资源:栈、TLS 线程局部存储资源以及某些寄存器。一般来说,一个进程由一个到多个线程组成,各个线程之间共享程序的内存空间(代码段、数据段、堆等)和一些进程级资源(打开文件和信号等)。
  6. (Page:44)线程的三种状态(所有运行的线程都只能从“就绪”状态进入):
  1. (Page:44)线程调度:优先级调度(根据线程优先级进行调度)与轮转法(各个线程轮流执行一小段时间)。
  2. (Page:45)IO 密集型线程总是比 CPU 密集型线程容易得到优先级的提升。因为当一个 CPU 密集型的线程获得较高优先级时,其他的低优先级进程就很可能被“饿死”。而为了避免“饿死”,调度系统也会逐步提升那些等待了过长时间的得不到执行的线程的优先级。
  3. (Page:48)一般把单指令的 CPU 操作称为“原子的”。
  4. (Page:49)常见的同步与锁策略:

多元信号量(Binary Semaphore):允许多个线程并发访问资源。一个初始值为 N 的信号量允许 N 个线程并发访问。线程访问资源时先获取信号量:

访问完资源后,线程将释放信号量,进行如下操作:

互斥量(Mutex):资源仅同时允许一个线程访问。与信号量不同的是,互斥量要求哪个线程获取哪个线程负责释放这个锁,其他线程无法干涉。

临界区(Critical Section):比互斥量更加严格的同步手段。把临界区的锁的获取称为进入临界区,锁的释放称为离开临界区。临界区的作用域仅限于本进程,其他进程无法获取该锁

读写锁:锁分为两种方式:共享的和独占的。可用于优化读取频繁,而仅仅偶尔写入的情况。

条件变量:可以让多个线程一起等待某个事件的发生,当事件发生时(条件变量被唤醒),所有的线程可以一起恢复执行。

  1. (Page:51)成为可重入函数(表明该函数每次被重入后都不会产生任何不良后果,尤其是在多线程竞争的情况下)的要求:
  1. (Page:53)可以通过 C++ 中的 memory_order 对象来控制 CPU 对内存栅栏的插入。
  2. (Page:53)内存模型:表示机器指令是以什么样的顺序被处理器执行的。
  3. (Page:53)三种线程模型:

第二章:编译和链接

  1. (Page:62)预编译阶段:
clang++ -E ./main.cc -std=c++17 -o main.ii # -E 表示只进行预编译;

经过预编译后的中间文件内容:

# 1 "./main.cc"
# 1 "" 1
# 1 "" 3
# 418 "" 3
# 1 "" 1
# 1 "" 2
# 1 "./main.cc" 2
# 1 "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/../include/c++/v1/memory" 1 3
# 652 "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/../include/c++/v1/memory" 3
# 1 "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/../include/c++/v1/__config" 1 3
# 57 "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/../include/c++/v1/__config" 3
# 874 "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/../include/c++/v1/__config" 3
namespace std { inline namespace __1 { } }
# 653 "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/../include/c++/v1/memory" 2 3
# 1 "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/../include/c++/v1/type_traits" 1 3
# 405 "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/../include/c++/v1/type_traits" 3
# 1 "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/../include/c++/v1/__config" 1 3
# 406 "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/../include/c++/v1/type_traits" 2 3
# 1 "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/../include/c++/v1/cstddef" 1 3
# 37 "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/../include/c++/v1/cstddef" 3
# 1 "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/../include/c++/v1/__config" 1 3
# 38 "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/../include/c++/v1/cstddef" 2 3
# 1 "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/../include/c++/v1/__cxx_version" 1 3
# 104 "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/../include/c++/v1/__cxx_version" 3
# 1 "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/../include/c++/v1/__config" 1 3
# 105 "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/../include/c++/v1/__cxx_version" 2 3
# 108 "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/../include/c++/v1/__cxx_version" 3
# 39 "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/../include/c++/v1/cstddef" 2 3
# 42 "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/../include/c++/v1/cstddef" 3
...
  1. (Page:64).o 文件由汇编器输出,是在进行链接前的中间文件,一般称为“目标文件”。
  2. (Page:70)词法分析 -> 语法分析 -> 语义分析(分析语句是否有意义。检测声明和类型是否匹配、类型的转换有效性等)-> 生成中间 IR -> 优化(基于 IR)-> 生成目标代码 -> 优化(基于目标代码优化指令生成);
  3. (Page:74)链接的过程主要包括:地址和空间分配、符号决议以及重定位。

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

  1. (Page:79)常见的可执行文件格式 PE(Portable Executable)以及 ELF(Executable Linkable Format)均是 COFF(Common File Format)的变种。其他不常见的格式还有 OMF(Object Module Format)等格式。
  2. (Page:79)动态链接库(.dll / .so)以及静态链接库(.lib / .a)也都按照可执行文件格式存储。即:Windows 下为 PE/COFF 格式,Linux 下为 ELF 格式。其中静态链接库主要是把多个目标文件捆绑在一起形成一个文件,再加上一些索引
  3. (Page:80)四种 ELF 文件格式:

可以使用 file 命令来查看文件类型:

file main # main: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
  1. (Page:81)ELF 文件的主要 Section 结构:
  1. (Page:83)ELF 数据和指令分段存放的意义:
  1. (Page:91)在 GCC 中,可以使用 “attribute((section(“FOO”)))” 的方式来指定变量所处的段(Clang 不支持该方式)。
  2. (Page:108)ld 在其链接脚本中提供的一些程序可以使用的特殊符号(具体定义取决于不同的链接器):

以上地址均指进程 VMA 的虚拟地址。使用方式如下:

#include <iostream>
extern "C" {
  extern char __executable_start[];
}
int main(int argc, char** argv) {
  /*
    默认的链接脚本里指定符号 __executable_start 的默认值为程度在 VMA 中的载入基地址,即 32 位:0x08048000,64 位:0x400000;
    ...
    {
      PROVIDE (__executable_start = SEGMENT_START("text-segment", 0x400000)); . = SEGMENT_START("text-segment", 0x400000) + SIZEOF_HEADERS;
    ...
  */
  std::cout << std::hex << __executable_start << std::endl;  // "ELF";
  return 0;
}
  1. (Page:110)在现代 Linux 下的 GCC/Clang 编译器中,默认情况下已经去掉了在 C 语言符号前加 “_” 的方式。
  2. (Page:111)使用 c++filt 来查看被 Name Mangling 处理过的符号的原始形态。
  3. (Page:118)DWARF(Debug With Arbitrary Record Format)调试信息格式。可以用 strip 来去除 ELF 中的调试信息。

第四章:静态链接

  1. (Page:122)通常对于 X86 硬件来说,段(Segment)的装载地址和空间的对齐单位是页,也就是 4096 字节。
  2. (Page:123)空间分配包含两个概念:一个是诸如 .bss 段在 ELF 文件中不占用空间,这是指从文件存储的角度。而当 ELF 被装载后,.bss 需要在虚拟内存地址中分配对应大小的内存空间,这是从运行时的 VMA 的角度。
  3. (Page:124)两步链接:
  1. (Page:125)通常情况下 VMA(Virtual Memory Address)与 LMA(Load Memory Address)的值应该是一致的。
  2. (Page:130)ELF 中每一个需要符号重定位的段都有一个对应的重定位表(比如:.text 对应的 .rel.text,.data 对应的 .rel.data)。
  3. (Page:134)COMMON 块的概念来自早期的 Fortran,早期的 Fortran 没有动态分配空间的机制,程序员必须事先声明它所需要的临时使用空间大小。Fortran 把这种空间叫做 COMMON 块,当不同的目标文件需要的 COMMON 块空间大小不一致时,以最大的那块为准
  4. (Page:135)链接器在处理弱符号时会采用与 COMMON 块一样的机制,选择同名符号中占用空间最大的那个。直接导致需要 COMMON 机制的原因是编译器和链接器允许不同类型的弱符号存在,但本质原因还是由于链接器不支持符号类型。对于 C 代码中的未初始化的全局变量,编译器会将其默认放置于 COMMON 块中,而在最终的可执行文件中,这些符号仍会被存放于 .bss 段。而一旦一个未初始化的全局变量不是以 COMMON 块的形式存在,那么它默认就相当于一个强符号
  5. (Page:136)编译器及链接器对 C++ 模板实例化的优化:使用 Link Once 段。即在每一个编译单元中将每一个模板的实例代码都单独地存放在一个段里,且每个段只包含一个模板实例。链接器在最后链接时便可以区分这些相同的模板实例段,然后进行合并优化。
  6. (Page:137)函数级别链接:为目标文件中的每一个函数都设置一个单独的段,在链接时去掉没有使用到的段,以减小可执行文件的大小。GCC 中有这样的选项可以将每个函数和变量分别保持到独立的段中。
  -fdata-sections         Place each data in its own section (ELF Only)
  -fdebug-types-section   Place debug types in their own section (ELF Only)
  1. (Page:137)C++ 全局对象的构造函数在 main 函数之前被执行,析构函数在 main 函数之后被执行。Linux 系统下一般程序的入口是 “_start”。
  2. (Page:138)ELF 文件的两个特殊的段:
  1. (Page:138)如果要使两个编译器编译出来的目标文件能够相互链接,那么它们需要满足如下条件:采用同样的目标文件格式、拥有相同的符号修饰标准、变量的内存分布方式相同、函数调用方式相同等。上述这些跟可执行代码二进制兼容性相关的内容组成了 ABIABI 的兼容程度比 API 更为严格
  2. (Page:140)目标文件二进制兼容的几个方面:

C

C++

  1. (Page:140)LSB(Linux Standard Base)标准的出现将提高 Linux 发行版之间的兼容性,并使软件应用程序甚至可以二进制形式(ABI 兼容)在任何兼容系统上运行。
  2. (Page:142)在一个静态库文件(.a)中实际上包含了多个属于同一种类/目录的目标文件(.o)。我们可以使用 ar 命令来查看静态库中含有的目标文件。
  3. (Page:146)一般链接器采用如下三种方式来控制链接过程:
  1. (Page:147)使用 ld -verbose 可以查看链接器的默认内部控制脚本。
  2. (Page:149)main 函数在 return 语句执行时会将返回的值传递给 EXIT 系统调用。
  3. (Page:152)ld 有多种方法设置程序入口地址,即进程执行的第一条用户空间的指令在进程地址空间的地址:
  1. (Page:154)BFD(Binary File Descriptor Library)库为 ELF 标准与具体的目标文件类型之间加入了一个抽象层,隔离了类型和标准之间的差异。开发文档链接:文档
  2. (Page:157)PE(Portable Executable)文件与 ELF 同属于从 COFF(Common Object File Format)发展而来的。一般而言,Windows 上的可执行文件采用 PE 格式,而目标文件仍为 COFF 格式。

第五章:Windows PE/COFF

(略)

第六章:可执行文件的装载与进程

  1. (Page:173)CPU 的位数决定了每个进程虚拟地址空间(VAS)的最大理论上限。比如32位为 4GB 大小。从程序的角度来看,在大部分情况下(除了早期的 MSC 体系)我们可以通过判断 C 语言程序中指针所占用的空间来计算虚拟地址空间的大小。
  2. (Page:174)“Segment Fault” 错误通常是因为进程访问了未经操作系统授权的地址。
  3. (Page:175)PAE(Physical Address Extension):通过适当地扩展地址总线来获得可以访问更多物理内存的能力。但同时要保持应用程序当前的虚拟地址空间大小,通过使用“窗口映射”的方式来将 VAS 中的一块内存区域映射到扩展的物理内存上。在 Linux 下,可以通过 mmap() 系统调用来实现
  4. (Page:176)内存动态装载即将程序中最常用的那部分内部驻留在内存中,而将不太常用的部分暂时存放在磁盘中。主要分为以下两种方法:
  1. (Page:179)进程的创建步骤:
  1. (Page:182)Linux 中将进程虚拟地址空间(VAS)中的一个段叫做虚拟内存区域(VMA, Virtual Memory Area)。
  2. (Page:182)VMA 的实际物理内存分配是在发生“页错误”时才会进行的。在这一步中需要 MMU 的帮助来管理 VMA 到实际物理内存的转换关系,这其中也将涉及到 TLB 对地址转换规则的缓存。
  3. (Page:182)在完成上述进程创建步骤后,操作系统会通过检测“页错误”来不断装载需要的页内容。

  1. (Page:184)ELF 文件中,段权限的几种组合(可读、可写、可执行三种权限的组合):
  1. (Page:184)ELF 可执行文件中会以 “Segment” 的方式来组织各个 Section 在 VMA 中的分布,比如将某些权限相同的 Section 合并在一个 Segment 中,然后按页进行组织,这样可以在某种程度上节省内存空间。

  1. (Page:186)描述 “Section” 属性的结构叫做段表(Section Header Table),即“链接视图”的视角;而描述 “Segment” 结构的叫做程序头表(Program Header Table),即“执行视图”的视角。可以通过 readelf -l 来查看 ELF 可执行文件内的程序头表。
  2. (Page:188)ELF 共享库文件内也具有由 Segment 段组成的程序头表。
  3. (Page:188)在 Segment 中,BSS 段可能会被合并到其他 Section 中,而只需要增加该段对应 Segment 的 “p_memse” 大小即可(在 VMA 中的占用长度)。
  4. (Page:189)Linux 下查看进程的虚拟地址空间分布:
cat /proc/<pid>/maps
  1. (Page:189)进程在运行时所使用的堆和栈也被存放在其 VMA 中。其中第一列是 VMA 的地址范围;第二列是 VMA 的权限;第三列表示 VMA 对应 Segment 在映像文件中的偏移。最后的 “[vdso]” 是一个内核的模块,进程可以通过它来与内核进行交互。

  1. (Page:190)一个进程可以分为如下几种 VMA 区域:
  1. (Page:194)在某些 Unix 系统下,装载进程数据的一个物理内存页面可能包含两个甚至多个 Segment 的数据,以减少内存碎片。
  2. (Page:195)进程初始化后会将环境变量参数以及传递给该进程的参数存放到进程虚拟空间(VAS)的中。然后将其中位于栈顶的参数个数和参数指针传递给应用 main 函数的两个参数(argc,argv)。ESP 指针将一直指向 VAS 中的进程栈顶

因此,根据进程中的栈内存分布,我们便可以直接利用 argc 参数的地址来访问随后位于栈中的其他参数以及环境变量(X86_64 Linux 存储环境变量的方式有所变化。X86_64 下函数调用会通过诸如 rdi / rsi 寄存器传递,main 函数内会将对应的 argc / argv 参数再存放到栈上的相邻位置):

0000000000401130 main:
  401130:    55                       push   rbp
  401131:    48 89 e5                 mov    rbp, rsp
  401134:    48 83 ec 20              sub    rsp, 0x20
  401138:    c7 45 fc 00 00 00 00     mov    DWORD PTR [rbp-0x4], 0x0
  40113f:    89 7d f8                 mov    DWORD PTR [rbp-0x8], edi  ; argc -> [rbp-0x8];
  401142:    48 89 75 f0              mov    QWORD PTR [rbp-0x10], rsi  ; argv -> [rbp-0x10];
  401146:    48 8d 45 f8              lea    rax, [rbp-0x8]  ; ABS 地址值 rbp-0x8 -> rax;
  40114a:    48 89 45 e8              mov    QWORD PTR [rbp-0x18], rax  ; ABS 地址值 rbx-0x8 -> [rbp-0x18];
  40114e:    48 8b 45 e8              mov    rax, QWORD PTR [rbp-0x18]  ; ABS 地址值 rbx-0x8 -> rax;
  401152:    8b 30                    mov    esi, DWORD PTR [rax]  ; argc -> esi(rsi);
  401154:    48 bf 04 20 40 00 00     movabs rdi, 0x402004  ; "%d\n";
...
#include <iostream>

extern char** environ;  // used for accessing env variables;
int main(int argc, char **argv) {
  // argc + argv; 
  // argc is passed by reference;
  auto pArgv = reinterpret_cast<char**>(*(&argc + sizeof(int)));
  for (auto i = 0; i < argc; ++i) {
    std::cout << *(argv + i) << std::endl;
  }
  return 0;
}
  1. (Page:195)ESP 寄存器(X86_64 下为 RSP 寄存器)
  1. (Page:196)Linux 装载 ELF 过程:
  1. (Page:198)EIP 寄存器(X86_64 下为 RIP 寄存器):CPU 通过 EIP 寄存器读取即将要执行的指令。每次 CPU 执行完相应的汇编指令之后,EIP 寄存器的值就会增加。


这是文章底线,下面是评论
  暂无评论,欢迎勾搭 :)