dlopen程式碼詳解——從ELF格式到mmap

橙子和雪發表於2020-08-31

最近一個月的時間大部分在研究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的實現只挑了主幹研究,其它部分還沒空顧及,一些支撐我得到結論的實驗也沒有放上來。希望能與各路大神深入交流。

相關文章