程序员的自我修养-链接、装载与库(一、二)

目录

一、温故而知新

二、编译和链接


一、温故而知新

1、计算机核心部件:CPU、内存和I/O控制芯片;高速的北桥:CPU、内存和高速的图形设备。低速的南桥:磁盘、USB、键盘、鼠标等,汇总链接到北桥上。

2、计算机系统的软件体系结构的设计是分层次的,每个层次之间需要相互的通信,互相通信就需要协议,我们称其为接口。应用程序和操作系统内核通过中间的桥梁系统调用接口进行交互,系统调用是通过中断来实现的。操作系统的功能是:提供抽象的接口和控制硬件的资源。

3、硬件驱动程序作为操作系统内核的一部分对硬件进行管理。硬盘的基本存储单位是扇区,早期的内存分配机制存在地址空间不隔离、内存使用效率低、程序运行的地址不确定等问题。因此把CPU分配的地址看作一种虚拟地址(虚拟地址往往比物理地址大很多),然后通过某些映射的方法,将这个虚拟地址映射到物理地址,这样就可以解决物理内存地址不足的问题。地址空间分为两种:虚拟地址空间和物理地址空间。物理地址空间是实际存在的地址空间。虚拟地址空间是指虚拟的,想象出来并不存在,每个进程都有自己独立的虚拟地址空间,且每个进程只能访问自己的地址空间。

  • 分段:将虚拟地址空间进行分段(以程序为单位),然后通过映射函数一一映射到物理地址空间。分段的方法解决地址不隔离、不确定,但内存的使用效率较低。会产生大量的外部碎片。

  • 分页:是把虚拟地址空间等分成固定的页,把常用的数据和代码页装载到内存中,不常用的代码和数据保存到磁盘里,需要时从磁盘中取出即可。我们把虚拟空间中的页叫虚拟页,把物理空间的页叫做物理页。每个页我们可以设置其属性。

4、线程基础:线程为轻量级进程,是程序执行流的最小单元,线程由线程ID,指令指针(程序计数器),寄存器集合和堆栈组成。线程可以共享进程的内存空间。线程可以访问进程内存里面的所有数据。每个线程都有自己私有的内存空间(包括栈,线程局部存储和寄存器)。

通过对CPU切换对线程进行调度。线程至少包括三种状态:运行就绪,等待。当运行态的线程的时间片使用完后,该线程会进入到就绪态。线程调度大都都有优先级调度和轮转法的思想。每个线程都拥有各自的优先级,具有高优先级的线程会更早的执行。频繁等待的线程成为I/O密集型线程。很少等待的线程称为CPU密集型线程。

饿死是指线程的优先级相对于其他线程较低,以至于长时间无法被执行,因此导致了该线程进入饿死状态。线程的优先级的改变有三种方式:用户指定优先级,根据进入等待状态的频繁程度提升或降低优先级,长时间得不到执行而提升优先级(饿死状态)。抢占(存在于可以抢占的线程中):线程在用尽时间片之后会被强制的剖夺继续执行的权力,进入就绪状态,这个过程叫做抢占。不可抢占的线程,线程主动放弃执行存在两种情况:第一,线程试图等待某事件。第二,线程主动放弃时间片。

5、线程安全:单指令的操作称为原子的,保证不可更改性,复杂性的数据结构更改原子操作比较麻烦,就需要锁。同步:指在一个线程读取内存时,其他线程不得对这个内存进行读取。同步的最常见的方法是使用锁。几种基本的锁:

  1. 二元信号量是一种最简单的锁,它有两种状态:占用和非占用。它适合只能有一个线程独占访问的资源。当一个线程读取内存时,二元信号量处于占有状态,当这个线程读取完内存时,由另一个线程释放这个二元信号量,以便这个信号量使用。多元信号量简称信号量,信号量是对二元信号量的一种扩展,它允许多个线程对内存进行访问。互斥量和二元信号量很类似,当一个线程获得互斥量对内存数据进行限制,并读取完内存数据后,必须由这个线程对互斥量进行释放。临界区是比互斥量是比更加严格的同步手段。当一个线程创建一个临界区时(也就是该线程获得临界区的锁时),该线程就会进入临界区。临界区的使用范围,只限于本线程。

  2. 读写锁:致力于一种更加特定的场合的同步。对于读取频繁而写入很少时,使用其他锁效率会比较低,读写锁可以避免这个问题,读写锁有两种获取方式,共享的和独占的。条件变量作为一种同步的手段,作用类似于一个栅栏。使用条件变量可以让许多线程一起等待某个事件,当事件发生时,所有的线程可以一起恢复执行。

  3. 可重入(Reentrant)与线程安全:一个函数可重入(递归或多线程访问)表明该函数被重入后不会产生任何不良后果。一个函数可重入必须具备的条件(全部满足): 1)不使用任何(包括局部)静态或全局的非const变量;2)不返回任何(局部)静态或全局的非const变量的指针。3)仅依赖于调用方提供的参数。4)不依赖任何单个资源的锁。5)不调用任何不可重入的函数。

过度优化带来线程加锁也不一定安全:volatile关键字可以阻止过度优化:它阻止编译器为了提高速度将一个变量缓存到寄存器内而不写会。阻止编译器调整操作volatile变量的指令顺序(无法阻止CPU动态调度换序)。

线程模型:线程的并发(多个任务同时执行,cup对任务进行切换)是由多处理器或操作系统调度来实现的。用户使用的是用户级的线程,用户级的线程与内核级的线程并不是一一对应的。用户态多线程的实现方法: 一对一模型(一般使用API和系统调用的线程),多对一模型,多对多模型。

二、编译和链接

linux的程序编译需要四个步骤:预处理,编译,汇编和链接。

1、预处理:源代码文件和相关的头文件被预编译成扩展名为.i的文件。预编译过程主要处理那些源代码文件中的以“#”开头的预编译指令,预编译所做的工作:1)将所有的”#define”删除,并且展开所有的宏定义。(2). 处理所有条件预编译指令,比如”#if”、”#ifdef”、“#elif”、“#else”、”#endif”。(3). 处理”#include”预编译指令,将被包含的文件插入到该项预编译指令的位置。注意:这个过程是递归进行的,也就是说被包含的文件可能还包含其它文件。(4). 删除所有的注释”//”和”/*  */”。(5). 添加行号和文件名标识,比如# 1 “hello.c”,1,以便于编译时编译器产生调试用的行号信息及用于编译时产生编译错误或警告时能够显示行号。 (6). 保留所有的#pragma编译器指令,因为编译器须要使用它们。经过预编译后的.i文件不包含任何宏定义,因为所有的宏已经被展开,并且包含的文件也已经被插入到.i文件中。所以当我们无法判断宏定义是否正确或头文件包含是否正确时,可以查看预编译后的文件来确定问题。

2、编译:编译过程就是把预处理完的文件进行一系列词法分析、语法分析、语义分析及优化后生成相应的汇编代码文件。

3、汇编:汇编器是将汇编代码转变成机器可以执行的指令,每一个汇编语句几乎都对应一条机器指令。所以汇编器的汇编过程相对于编译器来讲比较简单,它没有复杂的语法,也没有语义,也不需要做指令优化,只是根据汇编指令和机器指令的对照表一一翻译就可以了。

4、链接:将一大堆汇编产生的文件链接(模块拼接)起来得到最终的可执行文件,即将各模块间相互引用的部分处理好,使各模块正确衔接,包括地址和空间分配、符号决议(符号/地址绑定)和重定位等。

5、编译过程:编译器是将高级语言翻译成机器语言的一个工具:编译过程一般可分为6步:扫描、语法分析、语义分析、源代码优化、代码生成和目标代码优化。

   1)词法分析:首先源代码程序被输入到扫描器(Scanner),扫描器的任务很简单,它只是简单地进行词法分析,运用一种类似于有限状态机(Finite State Machine)的算法可以很轻松地将源代码的字符序列分割成一系列的记号(Token)。词法分析产生的记号一般可以分为如下几类:关键字、标识符、字面量(包含数字、字符串等)和特殊符号(如加号、等号)。在识别记号的同时,扫描器也完成了其它工作,比如将标识符存放到符号表,将数字、字符串常量存放到文字表等,以备后面的步骤使用。

   2)语义分析:由语义分析器(Semantic Analyzer)来完成。语法分析仅仅是完成了对表达式的语法层面的分析,但是它并不了解这个语句是否真正有意义。比如C语言里面两个指针做乘法运算是没有意义的,但是这个语句在语法上是合法的;比如同样一个指针和一个浮点数做乘法运算是否合法等。编译器所能分析的语义是静态语义(Static Semantic),所谓静态语义是指在编译期可以确定的语义,与之对应的动态语义(Dynamic Semantic)就是只有在运行期才能确定的语义。静态语义通常包括声明和类型的匹配,类型的转换。比如当一个浮点型的表达式赋值给一个整型的表达式时,其中隐含了一个浮点到整型转换的过程,语义分析过程中需要完成这个步骤。比如将一个浮点型赋值给一个指针的时候,语义分析程序会发现这个类型不匹配,编译器将会报错。动态语义一般指在运行期出现的语义相关的问题,比如将0作为除数是一个运行期语义错误。经过语义分析阶段以后,整个语法树的表达式都被标识了类型,如果有些类型需要做隐式转换,语义分析程序会在语法树中插入相应的转换节点。

   3)中间语言生成:现代的编译器有着很多层次的优化,往往在源代码级别会有一个优化过程。这里所描述的源码级优化器(Source Code Optimizer)在不同的编译器中可能会有不同的定义或有一些其它的差异。源代码级优化器会在源代码级别进行优化。其实直接在语法树上作优化比较困难,所以源代码优化器往往将整个语法树转换成中间代码(Intermediate Code),它是语法树的顺序表示,其实它已经非常接近目标代码了。但是它一般跟目标机器和运行时环境是无关的,在不同的编译器中有着不同的形式,比较常见的有:三地址码(Three-address Code)和P-代码(P-Code)。最基本的三地址码是这样的:x = y op z这个三地址码表示将变量y和z进行op操作以后,赋值给x。这里op操作可以是算数运算,比如加减乘除等,也可以是其它任何可以应用到y和z的操作。中间代码使得编译器可以被分为前端和后端。编译器前端负责产生机器无关的中间代码,编译器后端将中间代码转换成目标机器代码。这样对于一些可以跨平台的编译器而言,它们可以针对不同的平台使用同一个前端和针对不同机器平台的数个后端。

4)目标代码生成与优化:源代码级优化器产生中间代码标志着下面的过程都属于编译器后端。编译器后端主要包括代码生成器(Code Generator)和目标代码优化器(Target Code Optimizer)。代码生成器将中间代码转换成目标机器代码,这个过程十分依赖于目标机器,因为不同的机器有着不同的字长、寄存器、整数数据类型和浮点数数据类型等。最后目标代码优化器对目标代码进行优化,比如选择合适的寻址方式、使用位移来代替乘法运算、删除多余的指令等。编译器可以将一个源代码文件编译成一个未链接的目标文件,然后由链接器最终将这些目标文件链接起来形成可执行文件。

6、 链接器:计算机的程序开发并非从一开始就有着这么复杂的自动化编译、链接过程。符号(Symbol)这个概念随着汇编语言的普及迅速被使用,它用来表示一个地址,这个地。址可能是一段子程序(后来发展成函数)的起始地址,也可以是一个变量的起始地址。在一个程序被分隔成多个模块以后,这些模块之间最后如何组合形成一个单一的程序是须解决的问题。模块之间如何组合的问题可以归结为模块之间如何通信的问题,最常见的属于静态语言的C/C++模块之间通信有两种方式,一种是模块间的函数调用,另外一种是模块间的变量访问。函数访问须知道目标函数的地址,变量访问也须知道目标变量的地址,所以这两种方式都可以归结为一种方式,那就是模块间符号的引用。模块间依靠符号来通信类似于拼图版,定义符号的模块多出一块区域,引用该符号的模块刚好少了那一块区域,两者一拼接刚好完美组合。这个模块的拼接过程就是链接(Linking)。

7. 模块拼装-静态链接:每个源代码模块独立地编译,然后按照须要将它们”组装”起来,这个组装模块的过程就是链接(Linking)。链接的主要内容就是把各个模块之间相互引用的部分都处理好,使得各个模块之间能够正确地衔接。从原理上来讲,链接器的工作就是把一些指令对其它符号地址的引用加以修正。链接过程主要包括了地址和空间分配(Address and Storage Allocation)、符号决议(Symbol Resolution)和重定位(Relocation)等这些步骤。符号决议有时候也被叫做符号绑定(Symbol Binding)、名称绑定(Name Binding)、名称决议(Name Resolution),甚至还有叫做地址绑定(Address Binding)、指令绑定(Instruction Binding)。大体上它们的意思都一样,但从细节角度来区分,它们之间还是存在一定区别的,比如”决议”更倾向于静态链接,而”绑定”更倾向于动态链接,即它们所使用的范围不一样。

现代的编译和链接过程也并非想象中的那么复杂。比如我们在程序模块main.c中使用另外一个模块func.c中的函数foo()。我们在main.c模块中每一处调用foo的时候都必须确切知道foo这个函数的地址,但是由于每个模块都是单独编译的,在编译器编译main.c的时候它并不知道foo函数的地址,所以它暂时把这些调用foo的指令的目标地址搁置,等待最后链接的时候由链接器去将这些指令的目标地址修正。如果没有链接器,须要我们手工把每个调用foo的指令进行修正,则填入正确的foo函数地址。当func.c模块被重新编译,foo函数的地址有可能改变时,那么我们在main.c中所有使用到foo的地址的指令将要全部重新调整。这些繁琐的工作将成为程序员的噩梦。使用链接器,你可以直接引用其它模块的函数和全局变量而无需知道它们的地址,因为链接器在链接的时候,会根据你所引用的符号foo,自动去相应的func.o模块查找foo的地址,然后将main.c模块中所有引用到foo的指令重新修正,让它们的目标地址为真正的foo函数的地址。这就是静态链接的最基本的过程和作用。这个地址修正的过程也被叫做重定位(Relocation),每个要被修正的地方叫一个重定位入口(Relocation Entry)。重定位所做的就是给程序中每个这样的绝对地址引用的位置”打补丁”,使它们指向正确的地址。在链接过程中,对其它定义在目标文件中的函数调用的指令须要被重新调整,对使用其它定义在其它目标文件的变量来说,也存在同样的问题。

 目标文件里有什么

目标文件:编译器编译源代码后生成的文件叫做目标文件。从结构上讲,它是编译后的可执行文件格式,只是还没有经过链接过程。

1、目标文件的格式:PC平台上主流的可执行文件格式(Executable)主要有Windows下的PE(Portable Executable)和Linux的ELF(Executable)它们都是COFF(Common file format)格式的变种。

ELF文件类型说明实例
可重定位文件(Relocateable File)包含代码和数据,可以链接成可执行文件或共享目标文件Linux的 .o .a,Windows的.obj .lib
可执行文件(Executeable File)包含可直接执行的程序,它的代表就是ELF可执行文件Linux的/bin/bash文件,Windows的.exe
共享目标文件(Shared Object File)包含代码和数据。链接器可以使用它跟其他可重定位文件或共享目标文件链接,产生新的目标文件。 动态链接器可以将几个共享目标文件与可执行文件结合,作为进程映像的一部分来运行Linux的.so,Windows的.dll
核心转存储文件(Core Dump File)当进程意外终止时,系统可以将该进程的地址空间的内容及终止时的一些其他信息转存储到核心转存储文件

Linux下的core dump

 2、目标文件是什么样:目标文件中的内容至少有编译后的机器指令代码、数据,还包括了链接时所须要的一些信息,比如符号表、调试信息、字符串等。一般目标文件将这些信息按不同的属性,以”节”(Section)的形式存储,有时候也叫”段”(Segment),在一般情况下,它们都表示一个一定长度的区域,基本上不加以区别,唯一的区别是在链接视图和装载视图的时候。

File Header:描述整个文件的属性,包括文件是否可执行,是静态链接还是动态链接以及入口地址(如果是位置相关的可执行文件),目标硬件,目标操作系统等信息,文件头还包括一个段表(Section Table)。.text(or .code) section代码段;data section 已初始化的全局变量和静态变量; .bss section 未初始化的全局变量和静态变量只是为未初始化的全局变量和局部静态变量预留位置而已,它并没有内容,所以它在文件中也不占据空间。总体来说,程序源代码被编译以后主要分程序指令和程序数据。代码段属于程序指令,而数据段和.bss段属于程序数据。数据和指令分段的好处:(1) 一方面是当程序被装载后,数据和指令分别被映射到两个虚存区域。读写权限的设置可以防止程序的指令被有意或无意地改写。(2)对于现代的CPU来说有着极为强大的缓存(Cache)体系,对CPU的缓存命中率提高有好处。(3)其实也是最重要的原因,就是当系统中运行着多个程序的副本时,它们的指令都是一样的,所以内存中只须要保存一份该程序的指令部分。对于指令这种只读的区域来说是这样,对于其它的只读数据也一样,比如很多程序里面带有的图标、图片、文本等资源也是属于可以共享的。当然每个副本进程的数据区域是不一样的,它们是进程私有的。特别是在有动态链接的系统中,可以节省大量的内存。

3. 挖掘SimpleSection.o:

int printf(const char* format, ...);
 
int global_init_var = 84;
int global_uninit_var;
 
void func1(int i)
{
	printf("%d\n", i);
}
 
int main(void)
{
	static int static_var = 85;
	static int static_var2;
 
	int a = 1;
	int b;
	func1(static_var + static_var2 + a + b); 
	return a;
}

gcc -c SimpleSection.c 生成目标文件SimpleSection.o; 执行:$ objdump -h SimpleSection.o ,使用binutils的工具objdump查看目标文件内部的结构,参数”-h”就是把ELF文件的各个段的基本信息打印出来,如下

SimpleSection.o的段除了最基本的代码段(.text)、数据段(.data)和BSS段(.bss)以外,还有只读数据段(.rodata)、注释信息段(.comment)、堆栈提示段(.note.GNU-stack)、.eh_frame段;”CONTENTS”、”ALLOC”等表示段的各种属性;”CONTENTS”表示该段在文件中存在。BSS段没有”CONTENTS”,表示它实际上在ELF文件中不存在内容。”.note.GNU-stack”段虽然有”CONTENTS”,但它的长度为0,是个很古怪的段,认为它在ELF文件中也不存在。那么ELF文件中实际存在的是”.text”、”.data”、”.rodata”、”.comment”、”eh_frame”段。

代码段:挖掘各个段的内容,可以使用objdump的”-s”参数可以将所有段的内容以十六机制的方式打印出来, objdump -d SimpleSection.o  ”-d”参数可以将所有包含指令的段反汇编;

数据段和只读数据段:.data段保存的是那些已经初始化了的全局静态变量和局部静态变量。SimpleSection.c代码里面一共有global_init_var和static_var两个变量,这两个变量每个4个字节,一共8个字节,所以”.data”这个段的大小为8个字节。SimpleSection.c里面在调用”printf”的时候,用到了一个字符串常量”%d\n”,它是一种只读数据,所以它被放到了”.rodata”段,我们可以从输出结果看到”.rodata”这个段的4个字节刚好是这个字符串常量的ASCII字节序,最后以\0结尾。

“.rodata”段:存放的是只读数据,一般是程序里面的只读变量(如const修饰的变量)和字符串常量。有时候编译器会把字符串常量放到”.data”段,而不会单独放在”.rodata”段。“.data”段里的前4个字节,从低到高分别为0x54, 0x00, 0x00, 0x00。这个值刚好是global_init_var,即十进制的84。global_init_var是个4字节长度的int类型,为什么存放的次序是0x54, 0x00, 0x00,, 0x00而不是0x00, 0x00, 0x00, 0x54?这涉及CPU的字节序(Byte Order)的问题,也就是所谓的大端(Big-endian)和小端(Little-endian)的问题。而最后4个字节刚好是static_var的值,即85。

BSS:.bss段存放的是未初始化的全局变量和局部静态变量。global_uninit_var和static_var2就是存放在.bss段,其实更准确的说法是.bss段为它们预留了空间。但可以看到该段的大小只有4个字节,这与global_uninit_var和static_var2的大小的8个字节不符。其实只有static_var2被存放了.bss段,而global_uninit_var却没有被存放在任何段,只是一个未定义的”COMMON符号”。这其实是跟不同的语言与不同的编译器实现有关,有些编译器会将全局的未初始化变量存放在目标文件.bss段,有些则不存放只是预留一个未定义的全局变量符号,等到最终链接成可执行文件的时候再在.bss段分配空间。

其他段

常用段名说明
.rodata1Read Only Data,跟.rodata一样,比如字符串常量、全局const变量
.comment存放编译器版本信息,比如字符串: GCC:(GNU)4.2.0
.debug调试信息
.dynamic动态链接信息
.hash符号哈希表
.line调试时的行号表,即源代码行号与编译后指令对应表
.note额外的编译器信息,比如程序的公司名,发布版本号
.strtabString Table 字符串表,用于存储ELF文件中用到的各种字符串
.symtabSymbol Table 符号表
.shstrtabSection String Table 段名表
.plt .got动态链接的跳转表和全局入口表
.init .fini程序初始化与终结代码段

这些段的名字都是由”.”作为前缀,表示这些表的名字是系统保留的,应用程序也可以使用一些非系统保留的名字作为段名。自定义段:正常情况下,GCC编译出来的目标文件中,代码会被放到”.text”段,全局变量和静态变量会被放到”.data”和”.bss”段。但是有时候你可能希望变量或某些部分代码能够放到你所指定的段中去,以实现某些特定的功能。比如为了满足某些硬件的内存和I/O的地址布局,或者是像Linux操作系统内核中用来完成一些初始化和用户空间复制时出现页错误异常等。GCC提供了一个扩展机制,使得程序员可以指定变量所处的段:__attribute__((section(“FOO”))) int global = 42;在全局变量或函数之前加上”__attribute__((section(“name”)))”属性就可以把相应的变量或函数放到以”name”作为段名的段中。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值