X

曜彤.手记

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

吉 ICP 备10004938号

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


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

第一章:温故而知新

  1. (Page:6)传统的采用 PCI/ISA(ISA Bridge 连接在 I/O 总线上)及南北桥(北桥负责高速芯片,南桥负责低速芯片)设计的硬件架构。通过南北桥设计来解决高速设备(CPU、内存以及图形设备等)与低速设备(磁盘、USB、键盘以及鼠标等)之间的协作问题。
  2. (Page:7)对称多处理器 SMP / 多核处理器的:前者指每一个 CPU 在系统中所处的地位和所发挥的功能都是一样的,是相互对称的。而后者常应用在个人电脑中,指将多个处理器打包在一起,这些处理器之间共享比较昂贵的缓存部件(比如 L3),只保留多个核心,相当于 SMP 的简化版。
  3. (Page:10)系统调用接口在实现中往往是以“软中断”的方式提供(软中断则通常作为CPU指令集中的一个指令,以可编程的方式直接指示这种运行信息切换,并将处理导向一段中断处理代码)。
  4. (Page:10)几种 CPU 使用策略:
  1. (Page:11)硬件驱动程序通常由硬件厂商提供,只要符合特定操作系统指定的接口和框架,便可在该操作系统上使用。
  2. (Page:15)进程间隔离:进程有独立的虚拟地址空间(VAS),且每个进程只能访问自己的地址空间。其大小与计算机地址总线的长度有关,比如 32 位机器,有 32 条(实际是 36 条)地址线,对应 4GB 可寻址大小。
  3. (Page:16)内存分段与分页
  1. (Page:19)线程基础

- 多线程的优势

- 线程基本组成

标准线程由:线程 ID当前指令指针(PC)寄存器集合以及堆栈组成。其中,栈、TLS 线程局部存储资源,以及某些寄存器(逻辑 PC)为线程私有。一般来说,一个进程由一个到多个线程组成,各个线程之间共享程序的内存空间(代码段、数据段、堆等)和一些进程级资源(打开文件和信号等)。

- 线程三种状态

- 线程调度

IO 密集型线程总是比 CPU 密集型线程容易得到优先级的提升。因为当一个 CPU 密集型的线程获得较高优先级时,其他的低优先级进程就很可能被“饿死”。而为了避免饿死,调度系统也会逐步提升那些等待了过长时间的得不到执行的线程的优先级(比如基于“彩票调度”)。

- 线程安全

- 线程模型

第二章:编译和链接

  1. (Page:39)预编译阶段(C/C++):主要处理 “#” 开始的预编译指令。
clang++ -E ./main.cc -std=c++17 -o main.i # -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
...
  1. (Page:41).o 文件由汇编器输出,是在进行链接前的中间文件,一般称为“目标文件”。
  2. (Page:42)编译基本流程
  1. (Page:48)“链接”过程主要包括:

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

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

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

file main # main: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
  1. (Page:59)ELF 数据、指令分段存放的意义:
  1. (Page:64)ELF 文件主要结构(Section):

其他段结构

  1. (Page:68)在 GCC 中,可以使用 “attribute((section(“FOO”)))” 的方式来指定变量所处的段(Clang 不支持该方式)。
  2. (Page:85)ld 在其链接脚本中提供的一些程序可以使用的特殊符号(具体定义取决于不同的平台及链接器):

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

#include <iostream>
extern "C" {
  extern char __executable_start[];
}
int main(int argc, char** argv) {
  /*
    默认的链接脚本里指定符号 __executable_start 的默认值为程度在 VAS 中的载入基地址,即:
    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;
  return 0;
}
  1. (Page:87)在现代 Linux 下的 GCC/Clang 编译器中,默认情况下已经去掉了在 C 语言符号前加 “_” 的名称修饰方式,但 Windows 平台下的编译器还保持着这样的传统。
  2. (Page:88)可以使用 c++filt 工具来查看被 Name Mangling 处理过的符号的原始形态。
  3. (Page:95)DWARF(Debug With Arbitrary Record Format)调试信息格式。可以用 strip 来去除 ELF 中的调试信息。

第四章:静态链接

  1. (Page:99)通常对于 X86 硬件来说,段(Segment)的装载地址和空间的对齐单位是“”,因此对输入目标文件进行“按序叠加”的方式并不适合,因为这样会产生很多零碎的段,在对齐的情况下,可能会浪费一定内存空间。正常情况下,链接器会选择“相似段合并”的方式。
  2. (Page:100)空间分配包含两个概念:
  1. (Page:101)两步链接
  1. (Page:102)通常情况下 VMA(Virtual Memory Address)与 LMA(Load Memory Address)的值应该是一致的。
  2. (Page:107)ELF 中每一个需要符号重定位的段都有一个对应的重定位表(如:.text 对应的 .rel.text,.data 对应的 .rel.data)。
  3. (Page:111)COMMON 块的概念来自早期的 Fortran,早期的 Fortran 没有动态分配空间的机制,程序员必须事先声明它所需要的临时使用空间大小。Fortran 把这种空间叫做 COMMON 块,当不同的目标文件需要的 COMMON 块空间大小不一致时,以最大的那块为准。链接器在处理弱符号时会采用与 COMMON 块一样的机制,选择同名符号中占用空间最大的那个。直接导致需要 COMMON 机制的原因是编译器和链接器允许不同类型的弱符号存在,但本质原因还是由于链接器不支持符号类型。对于 C 代码中的未初始化的全局变量,编译器会将其默认放置于 COMMON 块中,而在最终的可执行文件中,这些符号仍会被存放于 .bss 段。而一旦一个未初始化的全局变量不是以 COMMON 块的形式存在,那么它默认就相当于一个强符号
  4. (Page:113)编译器及链接器对 C++ 模板实例化的优化:使用 Link Once 段。即在每一个编译单元中将每一个模板的实例代码都单独地存放在一个段里,且每个段只包含一个模板实例。链接器在最后链接时便可以区分这些相同的模板实例段,然后进行合并优化。
  5. (Page:114)函数级别链接:为目标文件中的每一个函数都设置一个单独的段,在链接时去掉没有使用到的段,以减小可执行文件的大小。
  6. (Page:114)Linux 系统下一般程序的入口是 “_start”。C++ 全局对象的构造函数在 main 函数之前被执行,析构函数在 main 函数之后被执行。这两个函数对应 ELF 文件的两个特殊的段(其中的代码由 “Glibc” 安排执行):
  1. (Page:115)如果要使两个编译器编译出来的目标文件能够相互链接,那么它们需要满足如下条件:采用同样的目标文件格式、拥有相同的符号修饰标准、变量的内存分布方式相同、函数调用方式相同等。上述这些跟可执行代码二进制兼容性相关的内容组成了 ABIABI 的兼容程度比 API 更为严格。C/C++ 语言的 ABI 由以下方面决定:

- C

- C++

  1. (Page:117)LSB(Linux Standard Base)标准以及 Intel Itanium C++ ABI 的出现提高了 Linux 发行版之间的 ABI 兼容性(并未完全改善),并使软件应用程序甚至可以二进制形式(ABI 兼容)在任何兼容系统上运行。
  2. (Page:118)在一个静态库文件(.a)中实际上包含了多个属于同一种类/目录的目标文件(.o)。可以使用 ar 命令来查看静态库中包含的目标文件。
  3. (Page:123)一般链接器可采用如下三种方式控制链接过程:
  1. (Page:128)ld 有多种方法设置程序入口地址,即“进程执行的第一条用户空间的指令在进程地址空间的地址”:
  1. (Page:131)BFD(Binary File Descriptor Library)提供了一种统一的接口来处理不同的目标文件格式(抽象层),其将目标文件抽象成一个统一的模型来进程处理。目前,GNU 下的 gcc、ld、gdb 以及 binutils 均使用 BFD 来间接处理目标文件,而非直接操作目标文件。

第五章:Windows PE/COFF

(略)

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

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

  1. (Page:161)ELF 文件中,段权限的几种组合(可读、可写、可执行三种权限的组合):
  1. (Page:161)ELF 可执行文件中会以 “Segment” 的形式来组织各个 Section 在虚拟内存区域中的分布,比如将某些权限相同的 Section 合并在一个 Segment 中,然后再按“页”进行组织,这样可以在某种程度上节省内存空间。
  2. (Page:164)描述 “Section” 结构的表叫做段表(Section Header Table),即“链接视图”的视角;而描述 “Segment” 结构的表叫做程序头表(Program Header Table),即“执行视图”的视角。可以通过 readelf -l 来查看 ELF 可执行文件内的程序头表。

  1. (Page:165)ELF 共享库文件内也具有由 Segment 段组成的程序头表。
  2. (Page:166)Linux 下查看进程的虚拟地址空间分布:cat /proc/<pid>/maps
  3. (Page:166)进程在运行时所使用的堆和栈也被存放在其 VMA 中。其中:

最后的 “[vdso]” 是一个内核的模块,位于 VAS 的高地址段。进程可以通过它来与内核进行交互。

  1. (Page:167)一个进程可以分为如下几种类型的 VMA 区域:
  1. (Page:169)在某些 Unix 系统下,装载进程数据的一个物理内存页面可能包含两个甚至多个 Segment 的数据,以减少内存碎片。此时,一个物理页会被映射两遍到进程的 VAS 中,使得上一个段的结束地址和下一个段的开始地址被分配在不同的页中p_vaddr 除以对齐属性的余数等于 p_offset 除以对齐属性的余数)。

  1. (Page:172)进程初始化后会将环境变量参数以及传递给该进程的参数存放到进程 VAS 的中。然后将其中位于栈顶的参数个数和参数指针传递给应用 main 函数的两个参数(argc,argv)。esp/rsp 指针将一直指向 VAS 中的进程栈顶

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

0000000000401130 <main>:
  401130:    55                       push   %rbp
  401131:    48 89 e5                 mov    %rsp,%rbp
  401134:    48 83 ec 20              sub    $0x20,%rsp
  401138:    c7 45 fc 00 00 00 00     movl   $0x0,-0x4(%rbp)
  40113f:    89 7d f8                 mov    %edi,-0x8(%rbp)  ; argc - [edi - 0x8].
  401142:    48 89 75 f0              mov    %rsi,-0x10(%rbp)  ; argv - [rsi - 0x10].
  401146:    48 8d 45 f8              lea    -0x8(%rbp),%rax
  40114a:    48 05 f8 ff ff ff        add    $0xfffffffffffffff8,%rax
  401150:    48 89 45 e8              mov    %rax,-0x18(%rbp)
  401154:    48 8b 45 e8              mov    -0x18(%rbp),%rax
  401158:    48 8b 00                 mov    (%rax),%rax
  40115b:    48 8b 30                 mov    (%rax),%rsi
  40115e:    48 bf 04 20 40 00 00     movabs $0x402004,%rdi
  401165:    00 00 00 
  401168:    b0 00                    mov    $0x0,%al
  40116a:    e8 c1 fe ff ff           callq  401030 <printf@plt>
  40116f:    31 c9                    xor    %ecx,%ecx
  401171:    89 45 e4                 mov    %eax,-0x1c(%rbp)
  401174:    89 c8                    mov    %ecx,%eax
  401176:    48 83 c4 20              add    $0x20,%rsp
  40117a:    5d                       pop    %rbp
  40117b:    c3                       retq   
  40117c:    0f 1f 40 00              nopl   0x0(%rax)
#include <stdio.h>
#include <string.h>

int main(int argc, char** argv) {
  char*** argvAddr = (char***) ((char*) &argc - 0x8);  // pointer to the stack cell stroing pointer to char**.
  printf("%s\n", **argvAddr);  // argv[0].
  return 0;
}
  1. (Page:172)esp 寄存器(X86-64 下为 rsp 寄存器)
  1. (Page:173)Linux 装载 ELF 过程:
  1. (Page:173)eip 寄存器(X86-64 下为 rip 寄存器):CPU 通过 eip/rip 寄存器读取即将要执行的指令。每次 CPU 执行完相应的汇编指令之后,eip/rip 寄存器的值就会增加。


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