《程序员的自我修养》读书笔记——程序编译与ELF文件结构

一个源程序到可执行文件需要经过预处理(Prepressing)编译(Compilation)汇编(Assembly)链接(Linking)
预处理:预处理过程主要处理源代码中以“#”开头的预处理指令。比如“#include”、“#define”等,主要处理规则为以下:

  • 将所有的“#define”删除,并展开所有的宏定义
  • 处理所有的条件预编译指令,比如:“#if”、“#ifdef”、“#elif”、“#else”、“#endif”。
  • 处理“include”预编译指令,将被包含的文件插入到该预编译指令的位置。该过程是递归进行的,也就是说有可能所包含的文件中还包含着其他的文件。
  • 删除所有的注释
  • 添加行号和文件名标识,以便于编译时编译器产生调试用的行号信息及用于编译时产生编译错误或警告时能够看到相应的代码行号。
  • 保留所有的#pragma编译器指令,因为编译器需要用到它们。
    所以当我们无法判断宏定义还有包含的文件是否正确时,可以先根据预编译之后的文件来判断。

编译:编译就是对预编译之后的文件进行词法分析、语法分析、语义分析及优化后生成相应的汇编代码。

  • 词法分析:将源代码程序输入到扫描器,扫描器运用一种类似于有限状态机Finite State Machine)的算法,将源代码的字符序列分隔为一系列的记号Token)。例如:
    |记号| 类型 |
    | array | 标识符 |
    | [ | 左方括号 |
  • 语法分析语法分析器Grammar Parser)将对由扫描器产生的记号进行语法分析,从而产生语法树Syntax Tree)。语法树就是以表达式为结点的树。
  • 语义分析:语法分析只是对表达式的语法层面进行分析,判断表达式是否合法,但是根本不了解语句的真正含义。编译器能够分析的语义是静态语义,也就是在编译期就能够确定的语义;只有在运行期才能确定的语义是动态语义。静态语义通常是包括声明和类型的匹配、转换。比如一个浮点型的表达式赋值给一个整型的表达式时,其中就隐含包含了一个浮点型转换到整型的过程,这就需要语义分析来完成。0作为除数则是一个运行期语义的错误。
  • 源码级优化:将可以直接得到结果的表达式进行优化,比如(2+6)这个表达式就不需要运行便可以得到结果,因此编译器会对其进行优化,把语法树转换为中间代码,它实际上就是语法树的顺序表示。
  • 目标代码生成及优化:编译器通过代码生成器Code Generator)和目标代码优化器Target Code Optimizer)对中间代码进行处理,先生成相应的汇编代码,然后对其进行代码优化。

ELF文件(目标文件)结构

编译器编译源代码生成之后的文件叫目标文件,目标文件从结构上来讲,是已经编译后的可执行文件格式,只是还没有经过链接,其中有些符号或者地址还没有得到调整。
程序源代码编译后的机器指令经常被放在代码段Code Section)里,代码段常见的名字有“.code”或“.text”;全局变量和局部静态变量经常放在数据段Data Section),数据段的名称一般是“.data”。结果如下:
在这里插入图片描述
总体来说,程序源代码在编译之后主要分为两种段:程序指令和程序数据。代码段(.text)属于程序指令,而数据段(.data)和.bss段属于程序数据。
把源代码和数据分开有一下的好处:

  • 当程序被装载后,数据和代码指令分别被映射到了两个不同的区域,数据对于进程来说是可读写的,程序代码对于进程来说只是可读的,有效地防止程序在运行过程中恶意修改程序代码。
  • 数据和代码分开之后,能够提高CPU的缓存命中率
  • 最重要的好处:当系统运行多个该程序时,因为数据是可变的,而程序代码是固定不变的,因此多个进程之间可以共享一个程序代码,从而节省了大量的内存空间。

上面只是对目标文件作了概念上的阐述,下面我们开始深入到目标文件的具体细节当中。
在这里插入图片描述
在上图中,.data段保存的是哪些初始化了的全局静态变量和局部静态变量。.rodata段中存放程序中的只读变量,如const修饰的变量和字符串常量。操作系统在加载时,会把.rodata段的属性映射成只读,有效地保证了程序访问存储器的正确性。但有时候编译器会字符串常量放在.data段,这是不同的编译器的部分区别。.bss段存放的是未初始化的全局变量和局部静态变量,更准确来说是.bss段为它们预留了一个空间,而部分编译器并不会把全局的未初始化变量放在.bss段当中,而是为它预留一个变量符号,当最终链接成可执行文件的时候,才会给.bss段中的变量分配地址空间。所以如果是编译单元中可见的静态变量,那的确是存放在.bss段中的。
除了以上段,还有很多的其他段,下图列举了ELF的一些常见段:
在这里插入图片描述
在此,可能有一些读者就会思考一个问题:ELF中段这么多,那可以创建属于自己的段吗?答案是肯定的,GCC提供了一个扩展机制,使程序员可以指定变量所处的段。

__attribute__((section("FOO"))) int global = 42;
__attribute__((section("BAR"))) void foo(){
}

我们在全局变量或者函数之前加上“attribute((section(“section name”))”属性就可以把相应的变量和函数放在指定的段中。

ELF文件头

我们用readelf命令来详细查看ELF文件,得到文件头详细信息:
在这里插入图片描述
在这里插入图片描述
ELF文件头定义了ELF魔数文件机器字节长度数据存储方式版本运行平台ABI版本ELF重定位类型硬件平台硬件平台版本入口地址和长度段表的位置和长度段的数量等。
细心的读者可能会发现,**为什么Magic是从左开始存储的?**这是因为ELF文件头中Data属性指定了数据的存储方式:little endian。这就涉及到了计算机中的字节序知识,下面开始讨论字节序。

字节序

在不同的计算机体系结构中,对于数据(比特、字节、字)的存储和传输机制有所不同,目前通用的字节存储机制主要有两种:大端Big-endian)和小端Little-endian)。
MSBMost Significant Bit/Byte)为最重要的位或最重要的字节。
LSBLeast Significant Bit/Byte)为最不重要的位或最不重要的字节。
Big-endian中数据是从左向右读取与存储,而little-endian中数据是从右向左读取与存储。

这也就很清晰地解释了为什么ELF文件头中的Magic数据是从右向左开始存储的。

本篇文章就分析在此处,后面的内容将在另外一篇中阐述。

发布了14 篇原创文章 · 获赞 1 · 访问量 592

猜你喜欢

转载自blog.csdn.net/qq_35149975/article/details/104029615
今日推荐