关于 ELF 文件想知道的事

1.起源

1.1 高级语言 vs 汇编语言 vs 机器语言

平时写代码的过程中,都知道高级语言需要编译成机器语言才能被执行,但是原因是什么呢?

你是否对既定的事实也抱有好奇的态度。很多时候,它是什么远没有它为什么重要。

在这里插入图片描述

语言本身其实是一种沟通的工具:

  • 高级语言是写给人看的,通过阅读高级语言(go、c、c++ 等)编写的源码,可以方便的理解一段程序的处理逻辑。
  • 汇编语言是机器语言便于记忆的书写格式,可理解为助记符。
  • 机器语言是用二进制代码表示的计算机能直接识别和执行的一种机器指指令系统令的集合。

你现在看到的和所写的其实是一代又一代人的智慧结晶。想象一下现在让你再回到基于机器指令写代码的年代,直接写最右边 code.o 的代码,你会不会抓狂,反正我是头大了……

在这里插入图片描述

1.2 机器语言和 ELF 文件

并非 ELF 文件需要我们,而是我们需要 ELF 文件。既然写好的高级语言需要编译成机器语言才能够被机器运行,那我们可以合理的推测 ELF 文件中必然包含翻译过后的机器语言。

注:ELF 是 the Executable and Linkable Format 的缩写。

问:除了被翻译后的机器语言,ELF 文件还需要包括什么呢?或者由你设计 ELF 文件你会如何设计的?

答:既然机器语言是 ELF 文件最细粒度的组成部分,那么我们能否对其进行抽象,将具有相同属性的机器语言进行聚合。 所以一个完整的 ELF 文件其实包括以下几个部分。

  • ELF 文件 Header —— 用来标识自己
  • 按照 Segment 规则分类的机器语言 —— 供系统运行 ELF 文件时使用的(由 Program Header Table 进行描述)
  • 按照 Section 规则分类的机器语言 —— 供编译器链接时使用的(由 Section Header Table 进行描述)
    在这里插入图片描述

纠结如我,画图的时候就在想是不是所有 ELF 文件的布局都像上图所示,ELF header 和 Program Header Table 在文件头,Section Header Table 在文件尾?查看了几个编译后的 ELF 文件有些是符合上述规则,有些则不然。所以结论其实是:(ps 英文翻译水平实在不咋地,截图貌似解释的会更清楚一些

在这里插入图片描述

2. 编译(从 section 到 segment)

2.1 为什么定义两种不同视角的概念

因为 ELF 文件是一类文件的简称,所以抽象出来的是更加通用的能力而非针对某个具体类型的定制化能力。ELF 包括以下四类:

  • 如果是可执行文件,则必须包含 Program Header Table,用于在程序运行时创建内存中的程序镜像
  • 如果是链接时被使用的文件,则必须包含 Section Header Table,用于在链接时合并相似的 section

在这里插入图片描述

2.2 section 和 segment 是什么

2.2.1 section

其实吧,我觉得 section 简单理解就是将源码编译以后的指令、数据等按照某种类型、属性特征进行划分存储的方式。比如:

  • bss section
    • 类型 :SHT_NOBITS 变量存储的值不会占用文件磁盘空间
    • 属性:SHF_ALLOC + SHF_WRITE 运行时需要分配存储空间且具有写权限
  • data section
    • 类型:SHT_PROGBITS 该 section 保存被进程定义的数据,其意义和格式由进程解释
    • 属性:SHF_ALLOC + SHF_WRITE 运行时需要分配存储空间且具有写权限
  • text section
    • 类型:SHT_PROGBITS 该 section 保存被进程定义的数据,其意义和格式由进程解释
    • 属性:SHF_ALLOC + SHF_EXECINSTR 运行时需要分配存储空间且具有执行权限

类型解释英文参考:

SHT_NOBITS:A section of this type occupies no space in the file but otherwise resemebles SHT_PROGBITS. Although this section contains no bytes, the sh_offset member contains the conceptual file offset.

SHT_PROGBITS:The sections holds information defined by the program, whose format and meaning are determined solely by the program.

在这里插入图片描述

2.2.2 segment

一个项目包括多个源文件,从宏观上看,编译、链接是将多个源码文件编译后的 *.o 链接成一个 .out 可执行文件的过程;从微观上看,编译其实生成 sections 数组的过程,链接其实就是合并多个 sections 数组并将其映射成 segments 的过程,具体 sections 的合并过程如下图所示。

思考:为什么做多个 .o 文件的相同属性做合并产生 .out 可执行文件,而不是连续拼接多个 .o 为一个 .out 可执行文件

在这里插入图片描述

**为什么需要 segments 映射 **

  • 因为程序需要被解释为进程才能够被利用,而程序被解释为进程的过程就是将磁盘上的程序加载到内存,所以操作系统加载程序就是按照 segment header table 内容进行存储镜像加载的。(ps 其实有点像说明书

sections 到 segments 的映射

  • 猜测 sections 到 segments 的映射是在链接器的内部是有一定规则实现的,但是我暂时没有找到规律点也木有查到相关资料,此处留一个疑问吧,一个具体的映射实例如下图所示:

在这里插入图片描述

3. 链接以后我便成为了你

segment 其实是一个逻辑的概念,本质就是一个映射关系的数据结构,记录了 segment 和 section 的关系。而链接过后 section 便以 segment 的属性被使用,也就是链接以后我(section)便成为了你(segment)。

注:此处强行文艺了一波,其实 section 和 segment 的映射关系就是 1:1 或者 n : 1 或者 0:1(ps 嗯,就是 0,比如上图的 00 这里虽然有 segment 但是没有实际的 section,仅做保留和占位

3.1 链接发展历史

存储空间和 CPU 是限制计算机运行程序数量和速度的两类瓶颈因素。而链接的发展就是围着这如何充分利用存储空间这一核心点进行的

3.1.1 静态链接 + 静态装入

静态链接、静态装入的做法是将所有目标文件链接成一个可执行文件,随后在创建进程时将该可执行文件全部加载到内存。

在这里插入图片描述

注:数据其实包括了数据和指令两个部分……

上图的策略的缺点:

  • 浪费磁盘存储空间
  • 浪费内存存储空间

注:A、B 程序即使依赖了一个相同的第三方库 S,这个 S 也需要在磁盘和内存中各存储一份

3.1.2 静态链接 + 动态装入

静态链接、动态装入的做法是将所有目标文件链接成一个可执行文件,但是在创建进程的时候采用了动态装入的策略,即一个函数只有当它被调用时,其所在的模块才会被装入内存。

在这里插入图片描述

上图的策略的缺点:

  • 仍旧浪费了磁盘存储空间
  • 部分节省了内存的存储空间

注:对于动态装入的策略,对于那些没有被使用到的代码是永远不会被装入内存的。

问:什么是没有被使用的,没有被使用不就应该在链接的时候不会被链接进来才对?

答:此处应该说的不是模块,应该是异常分支的路径,比如一段代码里有大量的错误处理函数,其实这部分错误处理原则上来说是不会被加载到内存中的。

3.1.3 动态链接 + 动态装入

动态链接、动态装入的做法

  • 动态链接:是对程序依赖的动态库链接时,不链接真正执行的指令,而是用 Stub(桩)的策略,在程序真正运行时才会去翻译 Stub(桩)为真正的指令(ps 这个思想在很多程序语言的设计上也有,比如 golang 的 Interface、rpc 通信中的 mock client
  • 动态装入:仍旧是按需加载指令或数据到内存中

在这里插入图片描述

此处,假设 add 这个模块为进程 1,进程 2 都依赖的一个共享库,这样在其实在对于赖 add 这个模块的程序编译的时候,仅编译 Stub(桩)到 ELF 文件中。而运行时则采用:

  • 先在内存中查找是否有其他程序依赖了这个共享库,如有,则直接翻译对应指令;如无,则进行下一步查找
  • 从磁盘上加载共享库到内存中使用

上面策略的优点:

  • 最大限度的节省了内存和磁盘空间
  • 采用共享库的策略,但是需要额外指定共享库的存储路径

3.2 动态链接本质

如下图所示进程 1 和进程 2 采用了动态链接的策略,使用了相同的共享库,但是这个库被两个进程定位不同的地址上,在进程 1 中,库从 9k 开始,在进程 2 中,库从 17k 开始。

由于库是共享的,所以

  • 不能采用写时复制的策略,为两个进程分别一份库的数据到内存,这违反了共享库节省存储空间的策略。

  • 不能采用装载的时候的重定位策略,将库重定位到进程 1 中 9k 对应的地址空间中,这会导致进程 2 无法完成动态加载的过程

一个更通用的解决是:在编译共享库的时候,用一个特殊的编译选项告知编译器,不要使用绝对地址的指令。而是,只能使用相对地址。即,使用向前 (向后)跳转 N 个字节的指令。无论共享库被加载虚拟地址空间中的什么位置。这种指令都是可以正确执行的。

在这里插入图片描述

所以,动态链接的核心就是共享库,而共享库其实就是如何编译出与地址无关的指令。( ps 在代码中不要使用绝对地址,而是使用相对偏移量的代码

碎碎念

写在最后面,因为最近花了很多力气去看 ELF 相关的东西,但是好像实际工作中使用它的场景很少。所以最近就一直在思考是不是花同样的力气去看其他的知识会更好,不过最近偶然看到《允许自己虚度时光》里的一段话突然就想通了。「不必对每件事情都期待结果,享受满足好奇的过程就好。」

原文如下:

我慢慢明白了为什么我不快乐,因为我总是期待一个结果。看一本书期待它让我变得深刻,吃饭游泳期待它让我一斤斤瘦下来,发一条短信期待它被回复,对人好期待它回应也好,写一个故事说一个心情期待它被关注被安慰,参加一个活动期待换来充实丰富的经历。这些预设的期待如果实现了,长舒一口气。如果没实现呢?自怨自艾。可是小时候也是同一个我,用一个下午的时间看蚂蚁搬家,等石头开花,小时候不期待结果,小时候哭笑都不打折。

参考资料

Guess you like

Origin blog.csdn.net/phantom_111/article/details/106891952