dlopen代码详解——从ELF格式到mmap


最近一个月的时间大部分在研究glibc中dlopen的代码,基本上对整个流程建立了一个基本的了解。由于网上相关资料比较少,走了不少弯路,故在此记录一二,希望后人能够站在我这个矮子的肩上做出精彩的成果。

ELF格式简介

dlopen是用来加载ELF文件中的共享对象(shared object,下文简称为so)的。ELF文件有多种类别,通过其header中0x10处的两个字节标识,参考Wikipedia。ELF的header中还包含了一些额外信息如指令集、操作系统信息等等,在本文中不会涉及。
可以把一个ELF文件分为4块:header、program header(phdr) table、section header(shdr) table、sections。下图将其解释地比较清楚了:

其中,最重要的概念就是phdr与shdr,它们分别对应着segment与section这两个在dlopen过程中至关重要的概念,可以使用以下命令查看:

readelf -S lib1.so  #查看section信息
There are 33 section headers, starting at offset 0x20f8:

Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
  [ 0]                   NULL             0000000000000000  00000000
       0000000000000000  0000000000000000           0     0     0
  [ 1] .note.gnu.build-i NOTE             00000000000001c8  000001c8
       0000000000000024  0000000000000000   A       0     0     4
  [ 2] .gnu.hash         GNU_HASH         00000000000001f0  000001f0
       0000000000000050  0000000000000000   A       3     0     8
  [ 3] .dynsym           DYNSYM           0000000000000240  00000240
       0000000000000198  0000000000000018   A       4     1     8
  [ 4] .dynstr           STRTAB           00000000000003d8  000003d8
       00000000000000c5  0000000000000000   A       0     0     1
      ......

每一个section中存放不同用途的数据,以“.”开头,比如我们熟悉的.text,.data,.bss。

readelf -l lib1.so  #查看segment信息
Elf file type is DYN (Shared object file)
Entry point 0x600
There are 7 program headers, starting at offset 64

Program Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  LOAD           0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x00000000000007cc 0x00000000000007cc  R E    0x200000
  LOAD           0x0000000000000e00 0x0000000000200e00 0x0000000000200e00
                 0x0000000000000230 0x0000000000000288  RW     0x200000
  DYNAMIC        0x0000000000000e10 0x0000000000200e10 0x0000000000200e10
                 0x00000000000001d0 0x00000000000001d0  RW     0x8
  NOTE           0x00000000000001c8 0x00000000000001c8 0x00000000000001c8
                 0x0000000000000024 0x0000000000000024  R      0x4
  GNU_EH_FRAME   0x000000000000072c 0x000000000000072c 0x000000000000072c
                 0x0000000000000024 0x0000000000000024  R      0x4
  GNU_STACK      0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000000 0x0000000000000000  RW     0x10
  GNU_RELRO      0x0000000000000e00 0x0000000000200e00 0x0000000000200e00
                 0x0000000000000200 0x0000000000000200  R      0x1

 Section to Segment mapping:
  Segment Sections...
   00     .note.gnu.build-id .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt .init .plt .plt.got .text .fini .rodata .eh_frame_hdr .eh_frame 
   01     .init_array .fini_array .dynamic .got .got.plt .data .bss 
   02     .dynamic 
   03     .note.gnu.build-id 
   04     .eh_frame_hdr 
   05     
   06     .init_array .fini_array .dynamic .got 

详细地显示了每个segment的类型、虚拟地址、物理地址、占文件空间(FileSiz)占内存空间(MemSiz)、保护模式、对齐信息,以及每一个segment包含哪些section
一句话概括,不同意义的信息存储在不同的section中,数个section聚合为一个segment。在加载时,我们只关心segment。

dlopen的代码结构

dlopen定义在头文件dlfcn.h中,但其实现横跨了dlfcn/与elf/两个文件夹,且涉及了多个文件与函数,相当复杂。下面简单分析其调用流程:
(in dlfcn/dlopen.c)dlopen -> __dlopen -> dlopen_doit -> (in elf/dl-open.c) _dl_open -> dl_open_worker -> (in dl-load.c) _dl_map_object -> _dl_map_object_from_fd
(in elf/dl-map-segments.h) _dl_map_segments -> __mmap -> 系统调用
这样分配的原因可能是,dlfcn文件夹下的文件被编译为libdl.so,而elf文件夹下的文件部分被编译成ld.so,部分被编译为libc.so。有些接口与成员只能在ld.so内被使用,如下面的例子:
In include/link.h:

struct link_map
  {
    /* These first few members are part of the protocol with the debugger.
       This is the same format used in SVR4.  */

    ElfW(Addr) l_addr;		/* Difference between the address in the ELF
				   file and the addresses in memory.  */
    char *l_name;		/* Absolute file name object was found in.  */
    ElfW(Dyn) *l_ld;		/* Dynamic section of the shared object.  */
    struct link_map *l_next, *l_prev; /* Chain of loaded objects.  */

    /* All following members are internal to the dynamic linker.
       They may change without notice.  */

    /* This is an element which is only ever different from a pointer to
       the very same copy of this type for ld.so when it is used in more
       than one namespace.  */
    struct link_map *l_real;
    ......

所以,因为在libdl.so中不能访问到某些元素,决定了dlopen不能只在dlfcn/下实现,所以真正的工作需要elf/中的文件进行实现,类似于帮助dlopen干活的工人,即dl_open_worker。而dlfcn/中的部分主要负责配置参数与错误处理。

dlopen实现详解

注:此处只对dlopen的主干进行解释,没有涉及边界条件以及次要部分(如加载一个so的依赖等)

dlopen

void *
dlopen (const char *file, int mode)
{
  return __dlopen (file, mode, RETURN_ADDRESS (0));
}

为用户提供调用的接口,调用实际进行工作的函数__dlopen

__dlopen

struct dlopen_args
{
  /* The arguments for dlopen_doit.  */
  const char *file;
  int mode;
  /* The return value of dlopen_doit.  */
  void *new; //返回一个地址,即加载完成之后返回handle的地址
  /* Address of the caller.  */
  const void *caller;
};

void *
__dlopen (const char *file, int mode DL_CALLER_DECL)
{
# ifdef SHARED
  if (!rtld_active ())
    return _dlfcn_hook->dlopen (file, mode, DL_CALLER);
# endif

  struct dlopen_args args; //准备下一步调用的参数,装在这个struct中
  args.file = file;
  args.mode = mode;
  args.caller = DL_CALLER;

# ifdef SHARED
  return _dlerror_run (dlopen_doit, &args) ? NULL : args.new; //_dlerror_run是用来错误处理的外层函数,接受一个函数指针与一个dlopen_args
  //在这个函数内部,dlopen_doit接受以参数args运行,在其执行结束之后取出args.new
# else
  if (_dlerror_run (dlopen_doit, &args))
    return NULL;

  __libc_register_dl_open_hook ((struct link_map *) args.new); //与libc内部调用dlopen有关,非主干内容
  __libc_register_dlfcn_hook ((struct link_map *) args.new);

  return args.new;
# endif
}

dlopen_doit

static void
dlopen_doit (void *a)
{
  struct dlopen_args *args = (struct dlopen_args *) a;

  if (args->mode & ~(RTLD_BINDING_MASK | RTLD_NOLOAD | RTLD_DEEPBIND
		     | RTLD_GLOBAL | RTLD_LOCAL | RTLD_NODELETE
		     | __RTLD_SPROF))
    _dl_signal_error (0, NULL, NULL, _("invalid mode parameter"));

  args->new = GLRO(dl_open) (args->file ?: "", args->mode | __RTLD_DLOPEN,
			     args->caller,
			     args->file == NULL ? LM_ID_BASE : NS,
			     __dlfcn_argc, __dlfcn_argv, __environ); //GLRO为预编译命令,此处调用_dl_open
  //调用结束之后将args->new配置好
}

_dl_open

struct dl_open_args //同样是承载参数的结构
{
  const char *file;
  int mode;
  /* This is the caller of the dlopen() function.  */
  const void *caller_dlopen;
  struct link_map *map;
  /* Namespace ID.  */
  Lmid_t nsid;

  /* Original value of _ns_global_scope_pending_adds.  Set by
     dl_open_worker.  Only valid if nsid is a real namespace
     (non-negative).  */
  unsigned int original_global_scope_pending_adds;

  /* Original parameters to the program and the current environment.  */
  int argc;
  char **argv;
  char **env;
};

void *
_dl_open (const char *file, int mode, const void *caller_dlopen, Lmid_t nsid,
	  int argc, char *argv[], char *env[])
{
  ......

  struct dl_open_args args;
  args.file = file;
  args.mode = mode;
  args.caller_dlopen = caller_dlopen;
  args.map = NULL;
  args.nsid = nsid;
  args.argc = argc;
  args.argv = argv;
  args.env = env;
  
  struct dl_exception exception;
  int errcode = _dl_catch_exception (&exception, dl_open_worker, &args); //与上面的_dlerror_run类似,是一个接受参数并处理错误的wrapper

dl_open_worker

static void
dl_open_worker (void *a)
{
  struct dl_open_args *args = a; //创建临时变量承载参数
  const char *file = args->file;
  int mode = args->mode;
  struct link_map *call_map = NULL;
  ......
  /* Load the named object.  */
  struct link_map *new; //创建一个新的link_map,用来存放要加载的so
  args->map = new = _dl_map_object (call_map, file, lt_loaded, 0,
				    mode | __RTLD_CALLMAP, args->nsid); //开始将so映射到内存中去
  ......
}

_dl_map_object

struct link_map *
_dl_map_object (struct link_map *loader, const char *name,
		int type, int trace_mode, int mode, Lmid_t nsid)
{
  ......
  //主要在寻找是否存在已经打开了的so,如果有,直接将对应的link_map返回
  return _dl_map_object_from_fd (name, origname, fd, &fb, realname, loader,
				 type, mode, &stack_end, nsid); //用一个fd开始进行内存映射

_dl_map_object_from_fd

struct link_map *
_dl_map_object_from_fd (const char *name, const char *origname, int fd,
			struct filebuf *fbp, char *realname,
			struct link_map *loader, int l_type, int mode,
			void **stack_endp, Lmid_t nsid)
{
  ......
  {
    /* Scan the program header table, collecting its load commands.  */
    struct loadcmd loadcmds[l->l_phnum]; //loadcmd中每一个元素对应elf中的一个segment,所以它的长度等于elf中phdr的个数
    size_t nloadcmds = 0; //并非loadcmd的长度,而是LOAD类segment的个数,见下文
    bool has_holes = false; 

    for (ph = phdr; ph < &phdr[l->l_phnum]; ++ph)
      switch (ph->p_type)
	{
        case PT_DYNAMIC: //别的类型的segment,可以无视
            ......
        case PT_PHDR:
            ......
        case PT_LOAD: //最重要的类型,每一个LOAD segment都要被加载进内存
            ......
          struct loadcmd *c = &loadcmds[nloadcmds++]; //只有PT_LOAD类型才会增加nloadcmds
	  c->mapstart = ALIGN_DOWN (ph->p_vaddr, GLRO(dl_pagesize));  //获得映射的开始地址,由于直接与虚拟内存对应,需要页对齐
	  c->mapend = ALIGN_UP (ph->p_vaddr + ph->p_filesz, GLRO(dl_pagesize)); //获取结束地址
	  c->dataend = ph->p_vaddr + ph->p_filesz; //filesz与memsz只在一种情况时不同,见下文。
	  c->allocend = ph->p_vaddr + ph->p_memsz; 
	  c->mapoff = ALIGN_DOWN (ph->p_offset, GLRO(dl_pagesize));

          if (nloadcmds > 1 && c[-1].mapend != c->mapstart) // 当一个LOAD类型的开始地址与上一个LOAD的结束地址不同时,判定为有洞
	    has_holes = true;
          /* Now process the load commands and map segments into memory.
          This is responsible for filling in:
          l_map_start, l_map_end, l_addr, l_contiguous, l_text_end, l_phdr
          */
          errstring = _dl_map_segments (l, fd, header, type, loadcmds, nloadcmds,
				  maplength, has_holes, loader); //将整理好的loadcmds作为参数,开始进行真正的映射
        }
  }
  ......
}

这里的switch与上文中讲的segment的类型相对应,不同的segment对应不同的操作。只有segment类型为PT_LOAD的才会放到loadcmds中,加载到内存中去。loadcmds也是在这里配置完毕的。

_dl_map_segments

static __always_inline const char *
_dl_map_segments (struct link_map *l, int fd,
                  const ElfW(Ehdr) *header, int type,
                  const struct loadcmd loadcmds[], size_t nloadcmds,
                  const size_t maplength, bool has_holes,
                  struct link_map *loader)
{
  ......
  ElfW(Addr) mappref
        = (ELF_PREFERRED_ADDRESS (loader, maplength,
                                  c->mapstart & GLRO(dl_use_load_bias))
           - MAP_BASE_ADDR (l)); //mmap的第一个参数接受一个preferred location,一般来说这个值都是0,即由OS决定基地址

  l->l_map_start = (ElfW(Addr)) __mmap ((void *) mappref, maplength,
                                            c->prot,
                                            MAP_COPY|MAP_FILE,
                                            fd, c->mapoff); //注意此处MAP_FIXED flag没有打开,不会分配到固定地址
  ......
  if (has_holes)
        {
          /* Change protection on the excess portion to disallow all access;
             the portions we do not remap later will be inaccessible as if
             unallocated.  Then jump into the normal segment-mapping loop to
             handle the portion of the segment past the end of the file
             mapping.  */
          if (__glibc_unlikely
              (__mprotect ((caddr_t) (l->l_addr + c->mapend),
                           loadcmds[nloadcmds - 1].mapstart - c->mapend,
                           PROT_NONE) < 0)) //使用mprotect改变上文中提到的“洞”的访问权限为不允许任何访问
            return DL_MAP_SEGMENTS_ERROR_MPROTECT;
        }
  while (c < &loadcmds[nloadcmds])
    {
      if (c->mapend > c->mapstart //mapend > mapstart是expected behavior
          /* Map the segment contents from the file.  */
          && (__mmap ((void *) (l->l_addr + c->mapstart),
                      c->mapend - c->mapstart, c->prot,
                      MAP_FIXED|MAP_COPY|MAP_FILE, //后续的segment被映射到固定的地址,从前一个的结束地址开始
                      fd, c->mapoff)
              == MAP_FAILED)) //当mmap出错时,退出;否则就是正常的mmap loadcmds中下一个segment
        return DL_MAP_SEGMENTS_ERROR_MAP_SEGMENT;
      ......
      if (c->allocend > c->dataend) //这个条件用来判断是否进入了最后一个LOAD
        {
          /* Extra zero pages should appear at the end of this segment,
             after the data mapped from the file.   */ //在最后一个segment中,没有被用到的部分用0填充
          ElfW(Addr) zero, zeroend, zeropage;

          zero = l->l_addr + c->dataend; //.data section的结束
          zeroend = l->l_addr + c->allocend; //.bss section的结束
          zeropage = ((zero + GLRO(dl_pagesize) - 1)
                      & ~(GLRO(dl_pagesize) - 1)); //.data section结束地址的下一页的开始地址
          if (zeroend < zeropage)
            /* All the extra data is in the last page of the segment.
               We can just zero it.  */
            zeropage = zeroend;

          if (zeropage > zero)
            {
              /* Zero the final part of the last page of the segment.  */
              if (__glibc_unlikely ((c->prot & PROT_WRITE) == 0))
                {
                  /* Dag nab it.  */
                  if (__mprotect ((caddr_t) (zero
                                             & ~(GLRO(dl_pagesize) - 1)),
                                  GLRO(dl_pagesize), c->prot|PROT_WRITE) < 0)
                    return DL_MAP_SEGMENTS_ERROR_MPROTECT;
                }
              memset ((void *) zero, '\0', zeropage - zero);
              if (__glibc_unlikely ((c->prot & PROT_WRITE) == 0))
                __mprotect ((caddr_t) (zero & ~(GLRO(dl_pagesize) - 1)),
                            GLRO(dl_pagesize), c->prot);
            }

          if (zeroend > zeropage) //当.bss section的长度超过最后一页的剩余长度时,此时需要新增若干页,需要再次调mmap
            {
              /* Map the remaining zero pages in from the zero fill FD.  */
              caddr_t mapat;
              mapat = __mmap ((caddr_t) zeropage, zeroend - zeropage,
                              c->prot, MAP_ANON|MAP_PRIVATE|MAP_FIXED, //MAP_ANON打开,因为建立的映射不对应于任何一个fd
                              -1, 0);
              if (__glibc_unlikely (mapat == MAP_FAILED))
                return DL_MAP_SEGMENTS_ERROR_MAP_ZERO_FILL;
            }
        }
     ++c; //loadcmds中下一条命令
    }

这是最重要,最复杂的一个函数,也是dlopen最底层的系统调用。它的工作流程如下:

  1. 没有特殊情况时,mappref为0,由OS自行选择基地址,并将其返回
  2. 后续的segment紧接着这个地址进行映射
  3. 到达最后一个segment时,需要处理allocend和dataend的情况,由.bss section引起

此处结合ELF文件的格式,讲解为什么.bss section有这样的情况:
回顾上文中lib1.so的phdr table:

Program Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  LOAD           0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x00000000000007cc 0x00000000000007cc  R E    0x200000
  LOAD           0x0000000000000e00 0x0000000000200e00 0x0000000000200e00
                 0x0000000000000230 0x0000000000000288  RW     0x200000
  DYNAMIC        0x0000000000000e10 0x0000000000200e10 0x0000000000200e10
                 0x00000000000001d0 0x00000000000001d0  RW     0x8
  NOTE           0x00000000000001c8 0x00000000000001c8 0x00000000000001c8
                 0x0000000000000024 0x0000000000000024  R      0x4
  GNU_EH_FRAME   0x000000000000072c 0x000000000000072c 0x000000000000072c
                 0x0000000000000024 0x0000000000000024  R      0x4
  GNU_STACK      0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000000 0x0000000000000000  RW     0x10
  GNU_RELRO      0x0000000000000e00 0x0000000000200e00 0x0000000000200e00
                 0x0000000000000200 0x0000000000000200  R      0x1

只有第二个LOAD中出现了FileSiz != MemSiz的情况。这是因为,在ELF中需要存储全局变量的初始值,而由于.bss没有初始值,默认被初始化为0,所以不会在ELF中存储,使得变量在文件中占用的大小(FileSiz)小于运行时占用的内存空间(MemSiz)。在加载到内存中时,使用这个特征判断是否到达了最后一个LOAD segment。
同时,可以注意到两个LOAD之间的虚拟地址(即加载到虚拟内存中时的偏移量,上文中的VirtAddr)差距很大,这是因为想要尽量保证可执行的部分与不可执行的部分相差尽可能大,从而最小化溢出时可能造成的写掉.text的风险,见出处。这也是上文中“洞”的由来。

在笔者所做的实验中,所有so都只有两个LOAD segment,一个是可执行的,另一个是不可执行的,包含的section见上文输出。然而,在某些系统上,可能会有其它的聚合方式,详见这个例子。这与系统产生ELF文件的实现有关。

关于link_map

link_map是用来存储ELF文件的数据结构,其详细定义可以在include/link.h下找到。
dlopen返回的打开的so的handle。这个handle是一个可以被其它libdl函数使用的接口,如dlsym,dlclose。需要注意它与so不存储在一起,也不是so在内存中的基地址。

结语

时间仓促,dlopen的实现只挑了主干研究,其它部分还没空顾及,一些支撑我得到结论的实验也没有放上来。希望能与各路大神深入交流。