linux ELF程序的运行

通常我们在bash中启动一个新的程序,它会做两个操作fork+execv,其中fork会创建一个新的子进程来容纳新程序,而execv真正的去load程序去执行它.
通过execv陷入内核中,内核会读取文件头部前128字节,每种文件都有自己的magic,ELF文件的magic:.ELF.内核注册了一些文件处理的钩子,他们逐个尝试处理该格式的文件.
ELF格式的文件会由fs/binfmt_elf.c:load_elf_binary()处理,它解析ELF的头和程序头表,如果是动态链接的文件则尝试找到PT_INTERP类型的头,如果没有找到则程序异常退出.
之后创建当前进程的内存镜像,load程序到内存中并且创建映射页表,除此之外还创建栈,堆的映射,栈的结构稍有复杂,存有环境变量,运行参数还有辅助变量,具体结构参考linux程序是如何运行起来的-ELF.
但是内核接下来不是直接运行改程序而是进而加载动态连接器,最后通过start_thread返回到动态链接器的入口.
动态链接器它比较特殊,它首先是一个动态库格式,但是它还是可执行的.动态链接器接过控制权后,首先进行自举和初始化,主要就是加载依赖库并且初始化dynamic的信息和符号表等.之后构建环境将控制权交给可执行文件的入口点,也就是我们熟悉的main.

动态链接器初始化

  1. 处理环境变量,其中内置了环境变量开关,可以通过LD_SHOW_AUXV=1 whoami来显示辅助变量,还有其他的DT_WARN,DT_DEBUG,DT_AUDIT,DT_VERBOSE,DT_PRELOAD,DT_PROFILE...来查看更多的信息
  2. 初始化主link_map,每个link_map记录dynamic段地址,elf文件中描述的地址和加载地址
  3. 初始化vdso,它是内核提供的一个虚拟的动态库,并没有实际的硬盘存储,内核为它创建内存映射,vdso充当了glibc和系统调用的一个glue层并且加速一部分只读的系统调用,将数据映射到用户空间中而不必陷入系统调用记性上下文切换
  4. 初始化库搜索路径,优先级:RPATH > LD_LIBRARY_PATH >系统库路径/usr/lib
  5. 加载动态库,优先加载LD_PRELOAD和/etc/ld.so.preload中的库,允许用户指定自选库;之后会使用广度优先的方式来加载所有依赖库,包括可执行文件和它的动态库依赖的哭
  6. 对除了解释器自身之外的所有库进行重定位,它有两种模式,一种是在lazy模式,当实际调用的时候才进行重定位操作,没有使用的符号不会进行重定位操作,减少消耗,初始化的速度会快一点;另一种是立即将所有的符号进行重定位,使用时解析符号并且重定位操作延时不确定,有些应用不希望有这种不确定的延时,另一个是可能会出现无法解析符号导致程序异常终止的问题,它期望在程序开始之前就能发现这种错误。

ref:https://www.gnu.org/software/hurd/glibc/startup.html

ld.so的入口点是 _start.
_start (sysdeps/x86_64/dl-machine.h) 
	_dl_start (elf/rtld.c) 初始化bootstrap_map
		_dl_start_final
			_dl_sysdep_start (sysdeps/mach/hurd/dl-sysdep.c)
				 __mach_init可以使用RPC方式调用
				 _hurd_startup 从服务端获取到足够的信息并且调用dl_main
					dl_main (elf/rtld.c) 解析ld.so的参数,加载二进制和库
						_dl_allocate_tls_init.

 _dl_start_user(sysdeps/x86_64/dl-machine.h)运行RTLD_START_SPECIAL_INIT (sysdeps/mach/hurd/x86_64/dl-machine.h)
 	_dl_init_first(sysdeps/mach/hurd/i386/init-first.c)
 		first_init
 				__mach_init
 				_hurd_preinit_hook 初始化lib的全局变量
 		init    设置线程变量,并且尝试初始化线程
 			init1
 				_hurd_init 初始化端口,开启信号处理线程,运行_hurd_subinit
 	dl_init	调用可执行文件的.init_array然后跳转到可执行文件的入口点_start

应用的入口点:_start
__libc_start_main(csu/libc-start.c)初始化libc和atexit
	__libc_csu_init
		_init(sysdeps/x86_64/crti.S) 
			PREINIT_FUNCTION()
		init_array_start 执行.init_array中的函数
		__libc_start_main 跳转到main函数,当main退出时执行exit

我们写main的正确姿势应该是int main(int argc, char **argv, char **MAIN_AUXVEC_DECL),不过_start部分采用了汇编语言编写绕过了C语言的检查,在__libc_start_main进行了参数设置,在main的定义中参数类型不匹配即不访问传入的参数也不会出现访问错误.

ELF的格式

ELF的格式总体上可划分成两种视图:链接视图和加载视图ELF视图
ELF的工具readelf,objdump,objcopy等需要section信息来更详细的解析展示elf细节,但是加载过程中它只关注程序头表,segment等信息.
ELF加载时除了elf header,program header table之外主要关注下面几个段:

  • PT_INTERP段,描述了运行时链接器,它会在运行时在内存中组装可执行程序和它依赖的库,进行重定位和符号解析工作。
  • PT_LOAD段,描述了新程序运行内存的区域信息,通常包括文件中代码、数据+BSS段,BSS在ELF文件中是0大小的,加载时内核关注它的MemSize属性,会将MemSize-FileSize的空间进行清零,也就是全局未初始化数据进行0初始化操作。
  • PT_DYNAMIC段,描述了动态链接程序中动态链接需要的信息,包括依赖库,库搜索路径,符号表位置,字符串表位置和长度,重定位表信息(位置,长度,单个长度),GOT表位置,PLT相关的重定位表信息等

ELF的符号表

ELF的符号代表着程序对外提供的和需要引用的符号,一般有函数和数据两大类,符号有基本的属性:名称和大小、地址,还有一个重要的属性:类型。其中在符号表中并不会真正存储符号的名称而是指向了关联section中的偏移来间接获取名称。

一个static的数据或者函数是LOCAL类型,对外不可见的,外部也不能引用它。
一般默认是GLOBAL类型的,即外部可以引用它,但是不能同时有两个同名的global实现。
weak类型,库中可能提供了通用实现版本并且标记为weak类型,你可以定义一个global类型覆盖它;如果你没有提供global类型的定义时,使用weak版本的实现

EFL的dynamic段

如果一个ELF可执行文件是动态链接格式,在运行时需要进行链接,这时候需要一些动态链接信息。

  1. 动态链接程序引用的符号在其他的库中,DT_NEEDED记录了需要依赖的库的名称
  2. 重定位表信息,动态链接过程需要依赖重定位表,重定位表中有多个重定位项,每一个重定位项中描述了:重定位时需要修改的位置和引用的符号以及重定位计算方式。重定位项需要引用符号也就是.dynsym和.dynstr才能找到引用对象的名称,所以符号表和字符串表的信息都是必须的。这其中主要包括PLT关联的重定位表信息:PLTREL中展示了重定位项类型RELA,重定位表地址JMPREL以及和PLT配合的GOT表的地址(PLTGOT)。GOT不仅包含引用函数的地址,还有引用数据的地址,引用数据的重定位信息存放在RELA类型所指向的重定位表中
  3. 在glibc中还有两个特殊的函数表INIT_ARRAY和FINI_ARRAY,分别是在程序执行前做初始化相关和程序退出后做清理类操作,典型的应用是C++类的构造函数和析构函数
  4. 在符号查找的过程中为了加速查找使用的hash存储GNU_HASH
  5. 其他的动态信息:依赖库的名称DT_SONAME,在指定的路径而不是系统路径下搜索库DT_RPATH,DT_RUNPATH
  6. 动态段信息项最后以DT_NULL项结尾
Dynamic section at offset 0x1d50 contains 27 entries:
  Tag        Type                         Name/Value
 0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]
 0x000000000000000c (INIT)               0x8c8
 0x000000000000000d (FINI)               0x1044
 0x0000000000000019 (INIT_ARRAY)         0x201d40
 0x000000000000001b (INIT_ARRAYSZ)       8 (bytes)
 0x000000000000001a (FINI_ARRAY)         0x201d48
 0x000000000000001c (FINI_ARRAYSZ)       8 (bytes)
 0x000000006ffffef5 (GNU_HASH)           0x298
 0x0000000000000005 (STRTAB)             0x4e8
 0x0000000000000006 (SYMTAB)             0x2c0
 0x000000000000000a (STRSZ)              281 (bytes)
 0x000000000000000b (SYMENT)             24 (bytes)
 0x0000000000000015 (DEBUG)              0x0
 0x0000000000000003 (PLTGOT)             0x201f40
 0x0000000000000002 (PLTRELSZ)           384 (bytes)
 0x0000000000000014 (PLTREL)             RELA
 0x0000000000000017 (JMPREL)             0x748
 0x0000000000000007 (RELA)               0x670
 0x0000000000000008 (RELASZ)             216 (bytes)
 0x0000000000000009 (RELAENT)            24 (bytes)
 0x000000000000001e (FLAGS)              BIND_NOW
 0x000000006ffffffb (FLAGS_1)            Flags: NOW PIE
 0x000000006ffffff9 (RELACOUNT)          3
 0x0000000000000000 (NULL)               0x0

ELF的PLT和GOT

使用PIC方式生成的代码可以减少物理内存的损耗,对于动态库来说意义重大,而PLT和GOT是其中实现的重要一点,另外一点是ld中动态链接过程是如何解析符号的。

一个程序依赖libx1库,libx1库又依赖libx2,一个ELF文件X启动两次会有两个进程存在,libx1和libx2在两个进程中都有映射,如果只是这样简单的进程,两个库的虚拟地址可以完全相同,这样也可以使用相同的物理内存。但是多个可执行文件执行存在N个进程时,ELF文件Y依赖libx2和libx3,ELF文件Z依赖libx1和libx4,这就要求libx3和libx4不能使用libx1,libx2的地址范围,此时我们还可以通过手工定位库的地址,但是当有更多ELF文件和更多的依赖关系时就无法手工处理了,同时也失去了进程的地址空间隔离性。所以相同的库在不同的进程中的地址时随机选择的,大概率来说地址是不同的。

没有使用PIC时为了进行符号重定位,两个库的地址时不同的,ELF文件需要引用的符号地址也是不同的,需要修改两个进程中ELF文件对应的代码段内容,这样他们就无法共用同一片物理内存而是需要两块物理内存和两个映射。而使用PIC时,不必修改代码段,他们的代码段仍然是相同的,只需要各自有自己的GOT,相对于两份text段的大小,GOT占用的空间更小,这样可以减少物理内存消耗。GOT的读写属性和数据段是相同的,所以他一般放在数据段中。

PLT+GOT当然它会有额外的消耗,通过寄存器中转了一下,从原来直接call fun变为call plt->jmp fun,有额外的取内存操作和跳转操作,但是相对于节省下来的内存使用这样一点代价完全是值得的。

下面我们看一下动态链接中是如何通过PLT和GOT这样一种方式在不同模块间实现函数调用的。

首先我们通过objdump -d fanotify看一下ELF文件中静态的代码片段看一下它的初始状态:

Disassembly of section .plt:                                                        
                                                                                    
00000000000008e0 <__errno_location@plt-0x10>:                                       
 8e0:   ff 35 62 16 20 00       pushq  0x201662(%rip)        # 201f48 <_GLOBAL_OFFSET_TABLE_+0x8>
 8e6:   ff 25 64 16 20 00       jmpq   *0x201664(%rip)        # 201f50 <_GLOBAL_OFFSET_TABLE_+0x10>
 8ec:   0f 1f 40 00             nopl   0x0(%rax)                                                                                                                                                            
                                                                                    
00000000000008f0 <__errno_location@plt>:                                        
 8f0:   ff 25 62 16 20 00       jmpq   *0x201662(%rip)        # 201f58 <_GLOBAL_OFFSET_TABLE_+0x18>
 8f6:   68 00 00 00 00          pushq  $0x0                                     
 8fb:   e9 e0 ff ff ff          jmpq   8e0 <_init+0x18>                         
                                                                                
0000000000000900 <puts@plt>:                                                    
 900:   ff 25 5a 16 20 00       jmpq   *0x20165a(%rip)        # 201f60 <_GLOBAL_OFFSET_TABLE_+0x20>
 906:   68 01 00 00 00          pushq  $0x1                                     
 90b:   e9 d0 ff ff ff          jmpq   8e0 <_init+0x18> 	

readelf -x .got fanotify|head

Hex dump of section '.got':
 NOTE: This section has relocations against it, but these have NOT been applied to this dump.
  0x00201f40 501d2000 00000000 00000000 00000000 P. .............
  0x00201f50 00000000 00000000 f6080000 00000000 ................      0x8f6
  0x00201f60 06090000 00000000 16090000 00000000 ................     0x906 0x916 

main:

e5b:   e8 a0 fa ff ff          callq  900 <puts@plt

GOT的初始状态:前三项中第一项保存的.dynamic的地址,第二项和第三项是0,当文件执行的时候,动态链接器在初始化阶段会初始化GOT前三项:第一项是dynamic的地址,第二项是该ELF的link_map数据结构描述符地址,第三项是_dl_runtime_resolve。其余的GOT项存放的地址是对应的plt项中的地址,例如索引为1的<puts@plt>,它第一条指令地址是0x900,长度为6 bytes,对应的GOT项存放的地址则是0x906即jmpq的下一条地址。

很明显想要调用的是puts函数,在运行时是如何进行重定位操作的?

  1. 一个ELF中调用其他ELF文件中的指令时,不会直接保存它的绝对地址而是跳转到plt中
  2. 第一次plt跳转时,0x8f0 jmpq *0x201662(%rip)取got中保存的地址,此时的地址是0x8f6也就是它的下一条指令地址
  3. 进行压栈操作,将GOT关联的重定位表的索引reloc_index进行入栈,并且跳转到__errno_location@plt-0x10中,准备进行符号解析
  4. __errno_location@plt-0x10中将got中的第二项也就是link_map的地址进行入栈,并跳转到got第三项的地址即_dl_runtime_resolve

_dl_runtime_resolve输入link_map和GOT关联的重定位表的索引reloc_index,它解析符号的过程:

  1. 通过link_map可以访问.dynamic段的信息,获取到.dynstr, .dynsym, .rel.plt的地址
  2. 通过.rel.plt + reloc_index获取到重定位项的信息,之后可以获取到引用符号的名称
  3. 搜索符号的名称,并且将找到的地址赋值给重定位项r_offset的位置,并且直接跳转到函数的位置进行第一次调用
  4. 之后再次调用时GOT中存放的已经是正确链接过后的地址,不需再次符号解析

动态链接过程

Logo

更多推荐