本文是本人读《程序员的自我修养》一书,夹杂本人理解与感受写成的长篇连载,主要以本书节奏为主线,不保证一定与该书章节前后顺序一致。不定期更新...
- 开篇
开篇打算从一个C 语言版本的hello world程序入手。
#include
int main()
{
printf("hello world\r\n");
return 0;
}
我假设你写这个程序花掉了两分半钟,你十分清楚每一行代码的行为。当你在VC里面按下F7后,它就被编译了一个a.exe的文件,你再按F5,就像发生了神奇的魔法,在一个弹出来的黑框框里面出现了hello world,就像有一股你看不见的力量,在你不知道的地方向你说话,“hello world”,多么亲切!但你还是挺费解的,按下F7发生了什么,按下F5又发生了什么?里里外外包含了众多的细节,你当然可以将这些细节托管给你的编程环境和运行环境,但若你想了解隐藏在背后的东西,一些实质的东西,这篇文章或者能提供一些帮助。这些细节包含:C语言,编译链接,执行环境。
这篇文章不打算花大量的篇幅讲解C语言,有太多优秀的书本,blog有详细的分析,此文只打算花下面一段话简要说说,就当是正式内容开始前的一个热身。说到C语言,相信大家都不会反驳,它是进入大学后第一个被指定学习的编程语言,由Dennis Ritchie发明,他和Ken ThompsonKen Thompson一起发明了UNIX。C语言是系统级语言,中低级语言。C语言发展到现在,最新的C标准是C99,即最新的ANSI C,是2000年采纳的,其规范早已确定并极少有完善。Antoine de Saint Expuery说过:“无一分可增不叫完美,无一分可减才完美”,它只定义了24个头文件和为数不多的类型,操作符,设计思路产生了深远的影响,说起KISS原则,想必UNIX世界和linux世界的程序员无不知晓!Keep it Simple, stupid.学好C语言好处众多,最好举例的就是C系语言太通俗了,C++,java,php都在各自领域发挥重大作用,C语言还是深入理解UNIX/LINUX的必经之路。
好啦,如果你不精通C语言,也不要紧,本文不会牵扯到C的过多细节,如果存在,我也会想办法解释清楚的。
根据紧凑原则,你会希望从下面的章节看到。。。呃,编译链接和执行环境。嗯,是的,让我们从编译和链接开始!
- 编译和链接
1.编译
2.静态链接
/* a.c */
extern int shared;
int main()
{
int a = 100;
swap(&a,&shared);
}
/* b.c */
int shared = 1;
void swap(int *a,int *b)
{
*a ^= *b ^= *a ^= *b;
}
/* x.c */
int global = 0x00000111;//256 + 16 + 1
/* y.c */
#include
char global;
int main()
{
printf("value = %d,size = %d \r\n",(int)global,sizeof(global));
}
以上讲了一些链接的原理,说了这么多,到底什么是静态链接呢?实际上,静态链接中的“静态”相对于动态而言,指的是在运行之前全部链接完毕。假设a.o用到了b.o里面的某一个符号,或者某一个函数,根据上面讲的两步链接法,静态链接器就需要将a.o与b.o的所有段合并,以此将a.o里面末明确的符号确定下来。可知,静态链接是以目标文件为单位的。
linux下的lib.a静态库文件是由ar压缩程序将成百上千个目标文件压缩组成的,这些目标文件包括:fread.o,fwrite.o,date.o,time.o,printf.o,scanf.o等等,之所以一个目标文件只有一个函数,是因为静态链接以目标文件为单位进行链接,若将多个函数同时组织在一个目标文件中,则在静态链接时,除了会将有用的函数链接进来也会将没有用的函数链接进来,这样会增大最终输出文件的尺寸。
由于,我们就可以总结静态链接的优点与缺点了。优点是,输出文件包含所有的输入文件的指令,数据,它可以独立于被链接的文件执行。缺点是,输出文件一般会比较大,因为它可以被链接了无用的指令,数据。另外,一旦输出文件依赖的目标文件发生变化,还得重新进行链接,对于程序的发布,流传造成很大的不便。另一个缺点是,想想看,假设有一个lib.o是一个系统级的标准的静态链接库,在操作系统上运行N个程序都需要链接到它,则每个程序都会包含一个lib.o,那么,在内存中,lib.o就会存在N份,这对于内存是极大的浪费。
既然静态链接有这么多的缺点,那有没有好的解决方案呢?有,那就是动态链接。
3.动态链接
动态链接是相对于静态链接而言的:静态链接在运行前进行链接,动态链接是在运行时进行链接。
运行时链接意味着有更好的程序可扩展性和兼容性,因为某个动态链接库发生变化,不会导致重新链接,只需要将其替换即可。另一方面我们还希望动态链接库在节省内存上面有一番作为。windows下的动态链接库是dll(dynamic link library),linux下是so(share object),从so的取名来看,它带有共享的意思。如果我们能让动态链接库在多个程序中共享,那也就节省了内存。
那么动态链接如何实现呢?
首先,运行时链接,生成可执行文件时不链接。
再次,可执行文件运行时动态链接库在内存中只会存在一份,多个程序之间共享。
声明一下,在静态链接中的重定位叫链接时重定位;下文中可能也会出现多次“链接重定位”,是指在动态链接中对于末决议符号进行虚拟内存地址绑定,希望大家不要误解。
动态链接库和静态链接库的文件结构并没有什么不同,它们的不同只是在于链接的时期不同。链接的方式都是由链接器发现依赖关系,将所需要的目标文件依次载入内存,进行符号虚拟地址重定位。动态链接由动态链接器完成,程序每次运行都需要重新进行链接,性能上有一点损失,由于动态链接过程可以进行优化,如延迟绑定的技术,可以使损失尽可能小,和静态链接相比,性能损失在5%以下,这点性能损失用来换取程序在空间上的节省和程序可扩展与兼容性来说是微不足道的。
再者,动态链接库中有一个符号表,它表示了符号信息,数据结构可以考察ELF文件结构定义中的符号表数据结构。所以,进行动态链接时,链接器查看到程序中存在末决议的符号,就会试图去动态链接库中链接。
考虑一下动态链接过程中进程的内存分布。首先,程序本身是首先要被映射到进程空间中的,它所依赖的共享库也要被映射进来,最后 ,动态链接器也要被链接进来,动态链接器实际上也是一个动态链接库,在linux下为ld-2.6.so。那么,这些动态链接库被载入进程的内存空间的地址如何决议?显然,由于一个程序可能需要载入多个共享库,所以载入的地址不能是一个固定地址,它是系统根据进程有效地址依次载入的。下面谈谈动态链接过程中如何解决重定位问题。
最容易想到的一个方法是:各个库文件被载入到进程空间后,地址都是确定的,而又由于各个库文件中明确定义的符号的地址相对于库文件首地址来说都是确定的,即,一旦库文件在内存中的地址确定了,那么明确定义的符号的地址也就确定了,那么只需要遍历全局符号表,将所有符号地址重定位到某个确定的值即可。此法为“装载时重定位”。
你看出这有一个很大的问题了吗?首先,重定位需要更改共享库的指令。再者,前面谈到了,库文件载入进程中的内存地址是不确定的,则各进程会对共享库的内存空间进行“只对自己适用的更改”,那么一旦如此更改,其它的进程就使用不了共享库了!所以此方法是行不通的!除非你要求共享库对于每个进程都有一个副本,或者共享库中不共享的数据部分(每个进程都会复制一份来自共享库数据的副本)也可能采用这种重定位方法!
我们再考虑另一种方法,地址无关代码(PIC, Position-independent Code),它可以解决动态链接过程重定位问题。地址无关代码的中心思想是将要更改的指令放入数据段里面。因为共享的数据段对于进程都有一份,数据不共享,指令共享。那么,各个进程对自己的数据段进行修改,将代码更改为适应自己的版本,这也叫做地址无关代码。字面上的意思是:“对于多个进程都会用到的共享库,不同的进程将共享库载入到不同的地址,其指令对于每个进程都是有效的,与共享库在进程的地址是无关的”。那我们再思考一下此方案能不能实现。先考虑下列四种需求:1.模块内部数据访问;2.模块内部函数调用;3.模块之间数据访问;4.模块之间函数调用;对应地代码:
static int a;
extern int a;
extern void ext();
void bar()
{
a = 1; //模块内部数据访问
b = 2; //模块之间数据访问
}
void foo()
{
bar(); //模块内部函数调用
ext(); //模块之间函数调用
}
对于1.由于同一个模块内明确定义的符号,它们的相对地址都是确定的,进一步的,在执行期,以页的形式将指令和数据载入内存,页之间的相对地址确定,那么,当前执行的指令与任何一个符号的相对地址都是确定的,只需要用当前指令的地址加上固定的偏移量就可以了。当前指令的地址很容易就可以获得,如利用call指令的方法。因为执行call指令后,下一条指令的地址会被压到栈顶,而esp寄存器始终指向栈顶,只需要在调用call后立即再执行 mov (%esp),%ecx。等call返回时,ecx寄存器中就是当前指令的地址。
对于2.道理同上,同模块内的函数调用毫无压力。
对于3.模块之间数据访问。这也是比较容易做到的,因为共享库的数据区在进程中有一个副本,其中的符号总可以得到一个正确的地址,且各个共享库互不干扰的享有这些符号,虽然某个变量被跨模块访问,但这些模块总还在一个进程里面,它们的地址相互来说是有效的,可以毫无压力的访问。具体可以这样做,对每个模块,如果有末决议的数据符号,则生成一个全局偏移表GOT,这是一个数组,存放指向这些末决议符号的偏移。详细来说的话,若 a.so里面用到定义在其它模块里的变量符号:extern int global;它在b.so中被明确定义。那么,a.so文件中的.data段就会存在一个GOT,设第一项就是global.那么,在动态链接时,链接器发现global定义在b.so中并通过将b.so被载入的内存地址加上它在b.so中的偏移量得到真实的虚拟内存地址x,并再使用1中的方法,通过当前指令的地址找到GOT的地址,将它的第一项的值填为x,它即是真实的global地址,到此,就解决了模块之间的数据访问。
实现模块间数据访问看起来有这么多句话,其实思想极其简单,因为变量没定义在当前模块中,则在当前模块中肯定需要对此变量重定位!而由于共享库的共享作用,不可直接在指令中更改变量的地址,因为这样的话会导致其它进程访问不到正确的数据!那么,只能在模块中不被共享的区域存放变量的地址,并在执行时按这样的通用规则获得变量地址:让所有进程获得(它自己的)GOT,再获得变量地址,这样所有的进程都可以正确寻址,也就实现了模块间数据访问。这其实应证了一句名言:“一切的问题都可以通过增加一个中间件解决”。
对于4.模块之间函数调用。毫无压力,只需要将3中的数据地址换成函数地址即可。
还有一个微妙的问题需要考虑:共享模块的全局变量问题。在主模块(编译生成可执行文件的模块)里面有如下代码:
extern int global;
int foo()
{
global = 1;
}
编译器就搞不清楚了,这个global是定义在主模块的其它文件里面,还是定义在其它模块里面呢?如果定义在主模块里面,直接就能重定位其地址。但如果在其它模块里面,则必须等到链接时才能重定位地址。但是,主模块不是共享库,不会存在GOT这个“中间件”,那么,该如何解决重定位问题呢?此时,就有另一个替代的中间件了,bss 段,熟悉吧,它本来是指末初始化的全局变量,静态变量存放的段。为了让主模块编译(静态)链接通过,解决方法是在bss段中定义一个global变量,让主模块链接到此global变量。接下来,在运行期,动态链接时,还要解决共享库中的global与bss段中的global重定义问题,只需要让共享库中的global指向bss中的global同时把共享库中的初值搬运过来。如果主模块中没有引用global,只在共享库中引用,那么就退化为模块之间的数据访问,直接使用地址无关代码解决。
既然说到了动态链接,就不得不说一下延迟绑定。延迟绑定的出现是为了解决动态链接相比静态链接效率较低的问题,因为一个动态链接库被程序载入时,需要确定末决议的符号,每次启动那个程序时都需要重复那些动作,程序启动速度比较慢,效率相对静态链接来说比较低。延迟绑定就是为了解决这个问题,其核心思想是:只要必要的时候进行链接必要的符号,一开始并不链接所有符号。必要指的是“运行时期用到的符号才有必要链接”。
“延迟绑定”由用户决定什么时候绑定某符号(一般是函数符号)的,也即显示绑定。一般会通过调用一个系统库函数完成这个操作,如win32 API里面就是 LoadLibrary,原型为:HMODULE WINAPI LoadLibrary( _In_ LPCTSTR l pFileName),它接受一个路径名以表示动态链接库,返回一个动态链接库句柄。此函数将动态链接库映射到当前的进程空间中。如果需要决议某一个函数地址,则需要使用win32 API的GetProcAddress,原型为:FARPROC GetProcAddress( HMODULE hModule,LPCSTR lpProcName),接受一个动态链接库句柄和一个字符串以表示函数名,返回的是函数指针。
那么显示链接是如何实现的呢?我们来回忆一下,如果是单纯的动态链接,则是通过各共享库中的GOT实现的。那么,显示链接则是通过在GOT与动态链接器之间加上一个中间层PLT,它实现地址跳转。每个外部函数都在PLT中有一个相应的项,到真正显示加载某个函数时,才去决议该函数符号的地址,并将其填充到PLT相应的表项中,以后使用该函数时直接从PLT中取出。
设在模块A中使用模块B中的bar函数,使用显示链接,则编译器对此实现的代码是:
jmp *(X)
push n
push moduleID
jump _dl_runtime_resolve
末执行显示链接前,X为push n这条指令的地址,相当于跳过该行代码向后面执行,push n,push moduleID,n是重定位表中的索引,以指示某需要重定位的符号,moduleID是共享库惟一标志,如句柄,地址等待,这两句将这两个值压入栈当成resolve的参数,进行函数地址决议,决议的过程大概是:1.依动态链接步骤,将此符号载入到进程空间(如果还没有载入到进程空间的话)2.将上一步得到的函数地址填充到当前模块的GOT表中,如果没有GOT表,则说明是主模块,则在BBS段中生成一个全局符号将其赋值为该地址并让所有相同的符号指向它。完成这些后,X会被填充为bar@GOT,它表示bar在GOT中的表项地址,有了GOT表项中的地址,则可以正确决议出函数的地址了。可以看出,首次显示链接后,之后再链接该函数就可以正确跳转到函数地址了。
运行
1.装载
2.动态链接
3.再谈库
4.内存管理
5.系统调用
6.如何实现一个库
...不定期更新