分享

(转载)GCC_ELF:程序的启动阶段动态链接器 都干了些什么?

 happy123god 2012-05-07

一个动态链接的可执行文件包含了所有的链接器信息。运行时动态链接器要重定位文件,并解析未定义的符号。
  • 文件的.dynsym节,即动态符号表,包含了一个文件导入(imported)或导出(exported)的所有的符号。
  • 文件的.dynstr节,即动态符号字符串表,包含了上述符号的名字字符串。
  • 文件的.hash(或是.gnu.hash)节,包含的是一个哈希表,连接器可以用它更快的查找符号。
  • 文件的.dynamic节,也即DYNAMIC段(segment),包含的是动态链接器所需的文件的信息。这个段是包含在数据段里的,但ELF程序头表(program header table)包含到它的链接。所以链接器可以很快找到它。
.dynamic节也是一个列表,里面包含一个数值、一个指针、加上这个条目的类型标签。这些类型标签有的只在可执行文件里有,有的只在库里有,有的则是共有的。

  • NEEDED: 这个文件所依赖的库名。(通常可执行文件都有,有时库也有。可以多次出现)
  • SONAME: "shared object name", 链接器要用到的文件的名字(库中才有)。
  • SYMTAB, STRTAB, HASH, SYMENT, STRSZ,: point to the symbol table, associated string and hash tables, size of a symbol table entry, size of string table. (共有的)
  • PLTGOT: points to the GOT, or on some architectures to the PLT (共有的)
  • REL, RELSZ, and RELENT or RELA, RELASZ, and RELAENT: pointer to, number of, and size of relocation entries. REL entries don't contain addends, RELA entries do. (Both.)
  • JMPREL, PLTRELSZ, and PLTREL: pointer to, size, and format (REL or RELA) of relocation table for data referred to by the PLT. (Both.)
  • INIT and FINI: pointer to initializer and finalizer routines to be called at program startup and finish. (Optional but usual in both.)
  • A few other obscure types not often used.
一个程序的结构也可以如这样描述,先是头,然后是只读区,然后是读写区,最后是BSS区。但BSS区通常不存在。

read-only pages: 
.hash 
.dynsym 
.dynstr 
.plt 
.text 
.rodata

read-write pages: 
.data 
.got 
.dynamic

.bss

一、文件的加载

这一过程有点长,但很直观。

首先:启动动态链接器

一个程序启动之后,系统先把文件内容映射到内存中,然后.....

首先,系统通过.interp节找到动态链接器,并将其加载到内存空间,再启动链接器。还要把一个辐助向量传到动态链接器的栈空间里:

AT_PHDR, AT_PHENT, and AT_PHNUM:程序头表的地址,每个表项的大小和表项数。

AT_ENTRY: 程序的入口地址

AT_BASE: 动态链接器加载的基地址

然后,动态链接器找到自己的.got节,其中的第一条目指向动态链接器自己的.dynamic节。然后动态链接器先重定位自己的变量和必要函数。使自己可用。(动态链接器ld.so的重要函数都以_dt_开头,一段特殊的代码会直接找到这种字符串开头的符号,并解析它们)

最后动态链接器初始化一系统的符号表,其中有指针指向动态链接器自己的符号表和程序的符号表。从概念上来讲,一个进行的程序文件和所有的库是共享一个符号表的。但是链接器不没有把所有的符号表混合在一起,而是用一个链表把他们串起来。每个文件都有自己的hash表。链接器查找符号时只计算一次哈希值,然后逐个哈希表去匹配。

然后找到需要的库

动态链表器自己的初始化完成之后,它就开始为程序加载库文件。程序的program header table有一个条目指向程序的DYNAMIC段,也即.dynamic节。它里面包含的是动态链接信息。包含一个DT_STRTAB项,指向.dynstr节,和多个ST_NEEDED项,是需要的库,其名字指向字符号表(.dynstr)中的偏移:

例子:

readelf -d test得到的.dynamic节条目表

readefl -S test得到的节头表

找到需要的库名字之后,动态链接器就会去系统里找相同名字的库文件,这是一个相当复杂的过程。比如说在上面的例子中,需要的库文件是libc.so.6(C库,版本6),找它的过程需要去系统的库路径个逐个搜索。可能找到的库名字也不完全一样,比如说可能会找到lib.so.6.2,即后面还带了更小的版本号。

链接器查找库文件的位置包括如下几处:

  • .dynamic节可能包括一个DT_RPATH类型的条目,指明的是一系列冒号分隔的路径。这是通过链接编辑器(link editor)在程序链接阶段加进去的。
  • 如果系统定义了环境变量LD_LIBRARY_PATH, 同上,也会进这里面去找。(但安全起见,如果程序是sset-uid的,这一步会跳过。)
  • 查找库缓存文件 /etc/ld.so.conf 里面有库文件名也有路径名,如果在这里能找到,就在这里找。大多数的库都是在这里找到的。
  • 如果上面三步都没找到,那就找 /usr/lib.要是还找不到就出错退出了。
找到需要的库之后,动态链接器找到库文件,打开它,把它映射进内存。读它的文件头,进而找到程序头,再找到.dynamic节所在的DYNAMIC段,然后通过.dynamic节,把库中的符号表加到全局符号表链中,如果这个库还依赖其它的库,就再加载其他的库。

最终,动态链接器找到了所有的库,并且拥有了一个逻辑上的全局符号表。

初始化共享库

动态链接器重新访问每个库,处理它的重定位条目,添写其GOT(全局偏移表),并对数据区的符号进行重定位。加载阶段的重定义包括:

  • R_386_GLOB_DAT, 用于初始化指向定义于其他库中的符号地址的GOT项。
  • R_386_32, a non-GOT 对其它库中静态数据的引用。
  • R_386_RELATIVE, 本地定义的数据或字符串
  • R_386_JMP_SLOT, 初始化用于PLT的GOT条目,下面详述。
如果库文件中有.init节,则会调用这一节,来对库进行一些库自己的初始化。如是有.fini节,那么退出时会执行。执行完.init库的初始化就完了,可以使用了。

惰型过程链接(原名是lazy procedure linkage,没找到通用的翻译方法,自己取的名字)

为什么说是惰型的呢?因为这种链接方式很拖拉,不到用时不链接。过程链接是说这种链接是面向函数的。

有些函数,在程序运行过程中很少会调用,比如说错误处理什么的。函数依赖的库还会依赖其他库,但依赖的其它库的函数也很少会用到。如果所有的都加载的话,可能你系统里的所有库都必须加载到内存中去。那机器就没法跑了。

所以对于不常用的函数,我们在不用他的时候,就不加载他所有的库。用时再说。

这样也能加快程序启动时间,很多库都不用加载了嘛。

但那些没有解析的符号怎么办?先不管,留着用到时再绑定。但这些符号可不能放到原来的地方了,我们需要另外的处理方法,和存储方法。

存储方法叫Procedure Linkage Table.PLT。每个动态绑定的程序或库都有一个plt表。里面包含的条目是程序或库调用的非本地例程。当程序或库调用到这个例程的时候,都会跳入到plt的相应条目中去,相应的条目都是几条简单的指令,如果这个函数例程还没有绑定,就是调用动态链接器进行绑定。如是不是第一次调用,那么函数就己经绑定了,就是直接跳转到函数中去。


Figure 3:
 PLT structure in x86 code 
Special first entry


PLT0: pushl GOT+4 
jmp *GOT+8

Regular entries, non-PIC code:


PLTn: jmp *GOT+m 
push #reloc_offset 
jmp PLT0

Regular entries, PIC code:


PLTn: jmp *GOT+m(%ebx) 
push #reloc_offset 
jmp PLT0

PLT中的第一个条目叫做PLT0,它的代码与其他PLTn不同。PLT0就是那个调用动态链接器的代码。但第一次调用某个函数时,PLTn都会跳到PLT0上来,然后用PLT0的代码来调用动态链接器。

在程序装载阶段,动态链接器会在GOT中自动加上两个条目:在GOT的第二个字上,和第三个字上,即GOT+4和GOT+8。依次放入的是库鉴定代码(这个代码可以知道当前要调用一个未解析的符号的程序或库是什么)和动态链接器的符号解析函数地址。每个PLTn条目第一个指令都是一个跳转指令,跳转到*GOT+m上去。每个PLTn条目都对应着一个GOT上的条目,那个GOT上的条目初始化时是一个指向这个PLT上跳转指令的下一条指令,是push指令,也就是说开始时是PLTn跳到GOT+m后马上跳回来执行下面的push指令(这块可以看看上面的指令图)。push指令就是push #reloc_offset,它入栈的是重定向表中的一个条目,这个条目的类型为 R_386_JMP_SLOT。这个条目的目地地址是符号表中一个符号,这个符号表条目的value项正指向GOT+m,也就是说现将要解析的符号解析为GOT+m.

这种安排很诡怪。但也很巧妙。当第一次进入PLTn时,第一条指令跳到GOT+m后什么也没做就返回来接着执行push指令了。这个push指令将要解析的符号的重定位表上的条目偏移入栈。它的目的地址是符号表中的条目,条目的目的地址则是GOT+m,下一步我们把解析出来的真正地址填到GOT+m就可以了。

PLTn的第三条指令就是jump PLT0,

PLT0: pushl GOT+4 

jmp *GOT+8
这里将鉴定程序或库的代码入栈,然后调用GOT+8指定的符号解析例程。
这里有一点东西要多说一下:
当我们调用一个call指令的时候,它将eip入栈,然后跳到调用的函数里,函数返回时将eip出栈,接着执行eip所指位置的指令。
在这里也用了这个小技巧,这里先入栈的是#reloc_offset,然后入栈的是GOT+4,然后jump到符号解析例程。
想想:符号解析例程返回时会有什么操作?就是出栈一个数,然后按这个数指定的位置执行指令,也就是说这里GOT+4里的内容会pop eip。
这样,进入符号解析例程之后,符号解析例程在链在一起的那些符号表里找到要解析的符号,然把符号地址存到GOT+m里,返回。然后先pop出库签定例程,确定是哪个程序做的调用,然再pop出重定位表,去重定位表中重新调用那个函数,这时再进入PLTn时,就直接接跳到GOT+m到跳到相应的函数上去了,不会再执行PLTn中的下两条指令了。

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多