第一部分 简介
第一章 温故而知新
1.1 从 Hello World说起
对于下面这些问题,你的脑子能够马上反应出一个很清晰又很明确的答案吗?
程序为什么要被编译器编译了才可以运行?
编译器在把C语言程序转换成可以执行的机器码的过程中做了什么,怎么做的?
最后编译出来的可执行文集那里面是什么?除了机器码还有什么?它们怎么存放的,怎么组织的?
#include<stdio.h>是什么意思?把stdio.h包含进来意味着什么?C语言库又是什么?它怎么实现的?
不同的编译器(Microsoft VC,GCC)和不同的硬件平台(x86,SPARC,MIPS,ARM),以及不同的操作系统(Windows,Linux,UNIX,Solaris),最终编译出来的结果一样吗?为什么?
Hello World程序是怎么运行起来的?操作系统是怎么装载它的?它从那儿开始执行,到哪儿结束?main函数之前发生了什么?main函数结束以后又发生了什么?
printf是怎么实现的?它为什么可以有不定数量的参数?为什么它能够在终端上输出字符串?
Hello World程序在运行时,它在内存中是什么样子的?
1.3 站的高,望得远
计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决。
1.5 内存不够怎么办
问题有3个:
1,地址空间不隔离
2,内存使用效率低
3,程序运行的地址不确定
最开始人们使用的是一种叫做 分段(Segmentation)的方法,基本思路是把一段与程序所需要的内存空间大小的虚拟空间映射到某个地址空间。可以解决第一个和第三个问题。用更小粒度的内存分割和映射的方法,使得程序的局部性原理得到充分的利用,大大提高了内存的使用率。这种方法就是分页(Paging)。
分页的基本方法是把地址空间人为地等分成固定大小的页,每一页的大小由硬件决定,或硬件支持多种大小的页,由操作系统选择决定页的大小。
1.6 众人拾柴火焰高
1.6.1 线程基础
线程(Thread),有时被称为轻量型进程(Lightweight Process,LWP),是程序执行流的最小单元。一个标准的线程由线程ID,当前指令指针(PC),寄存器集合和堆栈组成。通常意义上,一个进程由一个到多个线程组成,各个线程之间共享程序的内存空间(包括代码段,数据段,堆等)及一些进程级的资源(如打开文件和信号)。
线程的访问权限
1,栈(尽管并非完全无法被其他线程访问,但一般情况下仍然可以认为是私有的数据)。
2,线程局部存储(Thread Local Storage,TLS)。线程局部存储是某些操作系统为线程单独提供的私有空间,但通常只具有很有限的容量。
3,寄存器(包括PC寄存器),寄存器是执行流的基本数据,因此为线程私有。
线程调度与优先级
在单处理器对应多线程的情况下,并发是一种模拟出来的状态。操作系统会让这些多线程程序轮流执行,每次仅执行一小段时间,这样每个线程就“看起来”在同时执行,这样的一个不断在处理器上切换不同的线程的行为称之为线程调度(Thread Schedule)。
线程调度自多任务操作系统问世以来就不断地被提出不同的方案和算法。现在主流的调度方式尽管各不相同,但都带有优先级调度(Priority Schedule)和轮转法(Round Robin)的痕迹。所谓轮转法,即是之前提到的让各个线程轮流执行一小段时间的方法。这决定了线程之间交错执行的特点。而优先级调度则决定了线程按照什么顺序轮流执行。
在优先级调度的环境下,线程的优先级改变一般有三种方式:
1,用户指定优先级。
2,根据进入等待状态的频繁程度提升或降低优先级。
3,长时间得不到执行而被提升优先级。
Linux的多线程
Linux对多线程的支持颇为贫乏,事实上,在Linux内核中并不存在真正意义上的线程概念。Linux将所有的执行实体(无论是线程还是进程)都称为任务(Task),每一个任务概念都类似于一个单线程的进程,具有内存空间,执行实体,文件资源等。不过,Linux下不同的任务之间可以选择共享内存空间,因此在实际意义上,共享了同一个内存空间的多个任务构成了一个进程,这些任务也就成了这个进程的线程。
第二部分 静态链接
第二章 编译和链接
2.1 被隐藏了的过程
GCC编译可以分解为4个步骤,分别是预处理(Prepressing),编译(Compilation),汇编(Assembly)和链接(Linking)。
2.1.1 预编译
预编译过程主要处理那些源代码文件中的以“#”开始的预编译指令。主要处理规则如下:
1,将所有的#define删除,并且展开所有的宏定义。
2,处理所有条件预编译指令。
3,处理#define预编译指令,将被包含的文件插入到该预编译指令的位置。
4,删除所有的注释 // 和 /* */。
5,添加行号和文件名标识,以便于编译时编译器产生调试用的行号信息及用于编译时产生编译错误或警告时能够显示行号。
6,保留所有的#pragma编译器指令,因为编译器需要使用它们。
2.1.2 编译
就是把预处理完的文件进行一系列词法分析,语法分析,语义分析及优化后产生对应的汇编代码文件,这个过程往往是我们所说的整个程序构建的核心部分,也是最复杂的部分之一。gcc这个命令只是对后台程序的包装,它会根据不同的参数要求去调用预编译编译程序ccl,汇编器as,连接器ld。
2.1.3 汇编
将汇编代码转变成机器可以执行的指令,每一个汇编语句几乎都对应一条机器指令。输出目标文件(Object File)。
2.1.4 链接
将目标文件链接为可执行文件。
2.2 编译器到底做了什么
编译过程一般可以分为6步:扫描,语法分析,语义分析,源代码优化,代码生成和目标代码优化。
2.2.1 词法分析
首先源代码被输入到扫描器(Scanner),扫描器的任务很简单,它只是简单地进行词法分析,运用一种类似于有限状态机(Finite State Machine)的算法可以轻松地将源代码的字符序列分割成一系列的记号(Token)。
2.2.2 语法分析
接下来语法分析器(Grammar Parser)将对由扫描器产生的记号进行语法分析,从而产生语法树(Syntax Tree),整个分析过程采用了上下文无关语法(Context-free Grammar)的分析手段。简单的讲,由语法分析器生成的语法树就是以表达式(Expression)为节点的树。对于不同的编程语言,编译器的开发者只须改变语法规则,而无须为每个编译器编写一个语法分析器,所以它又被称为“编译器编译器”(Compiler Compiler)。
2.2.3 语义分析
接下来进行的是语义分析,由语义分析器(Semantic Analyzer)来完成。仅仅是完成了对表达式的语法层面的分析,但是它并不了解这个语句是否真正有意义。经过语义分析阶段以后,整个语法树的表达式都被标识了类型,如果有些类型需要式转换,语义分析程序会在语法树中插入相应的转换节点。
2.2.4 中间语言生成
使得编译器可以被分为前端和后端。前端负责产生机器无关的中间代码,后端将中间代码转换成目标机器代码。
2.2.5 目标代码生成与优化
编译器后端主要包括代码生成器(Code Gnerator)和目标代码优化器(Target Code Optimizer)。
2.4 模块拼装–静态链接
把每个源代码模块独立编译,然后按照需要将它们“组装”起来,这个组装模块的过程就是链接。链接的主要内容就是把各个模块之间相互引用的部分都处理好,使得各个模块之间能够正常地链接。链接过程主要包括了地址和空间分配(Address and Storge Allocation),符号决议(Symbol Resolution)和重定位(Relocation)等这些步骤。
第三章 目标文件里面有什么
3.1 目标文件的格式
现在PC平台流行的可执行文件格式(Executable)主要是Windows下的PE(Portable Executable)和Linux的ELF(Executable Linkable Format),它们都是COFF(Common file Format)格式的变种。目标文件就是源代码编译后但未进行链接的那些中间文件(Windows 的 .obj 和 Linux 的 .o),它跟可执行文件的内容与结构很相似,所以一般跟可执行文件格式一起采用一种格式存储。
3.2 目标文件是什么样的
程序源代码编译后的机器指令经常被放在代码段(Code Section)里,代码段常见的名字有”.code”或“.text”;全局变量和局部静态变量数据经常放在数据段(Data Section),数据段的一般名字都叫“.data”。
总体来说,程序源代码被编译以后主要分成两种段:程序指令和程序数据。代码段属于程序指令,而数据段和.bss段属于程序数据。
把程序的指令和数据存放分开,主要有以下好处:
1,当程序被装载后,数据和指令分别被映射到两个虚存区域。由于数据区域对于进程来说是可读写的,而指令区域对于进程来说是只读的,所以这两个虚存区域的权限可以被分别设置成可读写和只读。这样可以防止程序的指令被有意或无意地改写。
2,指令区和数据区的分离有利于提高程序的局部性。现代CPU的缓存一般都被设计成数据缓存和指令缓存分离,所以程序的指令和数据被分开存放对CPU的缓存命中率提高有好处。
3,当系统中运行着多个该程序的副本时,它们的指令都是一样的,所以内存中只须要保存一份该程序的指令部分
第四章 静态链接
4.1 空间与地址分配
现在的链接器分配策略都是相似段合并。使用这种方法的链接器一般都采用一种叫 两步链接(Two-pass Linking)的方法。
第一步,空间与地址分配,扫描所有的输入目标文件,获得它们各个段的长度,属性和位置,并且将输入目标文件中的符号表中所有的符号定义和符号引用收集起来,统一放到一个全局符号表。这一步中,链接器将能获得所有输入目标文件的段长度,并且将它们合并,计算出输出文件中各个段合并后的长度与位置,并建立映射关系。
第二步,符号解析与重定位,
使用上面第一步收集到的所有信息,读取输入文件中段的数据,重定位信息,并且进行符号解析与重定位,调整代码中的地址等。事实上第二步时链接过程的核心,特别是重定位过程。
4.2 符号解析与重定位
4.2.1 重定位
4.2.2 重定位表
保存这些与重定位相关的信息,在ELF文件中往往是一个或多个段。
4.2.3 符号解析
其实重定位过程也伴随着符号的解析过程,每个目标文件都可能定义一些符号,也可能引用到定已在其他目标文件的符号。重定位的过程中,每个重定位的入口都是对一个符号的引用,那么当链接器须要对某个符号的引用进行重定位时,它就要确定这个符号的目标地址。这时候链接器就会去查找由所有输入目标文件的符号表组成的全局符号表,找到相应的符号然后进行重定位。
4.2.4 指令修正方式
不同的处理器指令对于地址的格式和方式都不一样。这些寻址方式有以下几方面的区别:
1,近址寻址或远址寻址。
2,绝对寻址或相对寻址。
3,寻址长度为8位,16位、32位或64位。
绝对寻址修正和相对寻址修正的区别就是绝对寻址修正后的地址为该符号的实际地址;相对寻址修正后的地址为符号距离被修正位置的地址差。
4.4 C++相关问题
C++的一些语言特性使之必须由编译器和链接器共同支持才能完成工作。最主要的有两个方面,一个是C++的重复代码消除,还有一个就是全局构造与析构。
4.4.1 重复代码消除
C++编译器在很多时候会产生重复的代码,比如模板(Template),外部内联函数(Extern Inline Function)和虚函数表(Virtual Function Table)都有可能在不同的编译单元里产生相同的代码。
一个比较有效的做法是将每个模板的实例代码都单独地存放在一个段里,每个段只包含一个模板实例。
第五章 WINDOWS PE/COFF
Recent Comments