1. 程式人生 > >dlopen程式碼詳解——從ELF格式到mmap

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

最近一個月的時間大部分在研究glibc中dlopen的程式碼,基本上對整個流程建立了一個基本的瞭解。由於網上相關資料比較少,走了不少彎路,故在此記錄一二,希望後人能夠站在我這個矮子的肩上做出精彩的成果。 # ELF格式簡介 dlopen是用來載入ELF檔案中的共享物件(shared object,下文簡稱為so)的。ELF檔案有多種類別,通過其header中0x10處的兩個位元組標識,[參考Wikipedia](https://en.wikipedia.org/wiki/Executable_and_Linkable_Format#File_header)。ELF的header中還包含了一些額外資訊如指令集、作業系統資訊等等,在本文中不會涉及。 可以把一個ELF檔案分為4塊:header、program header(phdr) table、section header(shdr) table、sections。下圖將其解釋地比較清楚了: ![](https://img2020.cnblogs.com/blog/1815209/202008/1815209-20200830221336052-1228446588.png) 其中,最重要的概念就是phdr與shdr,它們分別對應著segment與section這兩個在dlopen過程中至關重要的概念,可以使用以下命令檢視: ``` bash 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。 ```bash 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: ```c 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 ```c void * dlopen (const char *file, int mode) { return __dlopen (file, mode, RETURN_ADDRESS (0)); } ``` 為使用者提供呼叫的介面,呼叫實際進行工作的函式__dlopen ## __dlopen ```c 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 ```c 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 ```c 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 ```c 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 ```c 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 ```c 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 ```c 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的風險,[見出處](https://stackoverflow.com/questions/16524895/proc-pid-maps-shows-pages-with-no-rwx-permissions-on-x86-64-linux)。這也是上文中“洞”的由來。 在筆者所做的實驗中,所有so都只有兩個LOAD segment,一個是可執行的,另一個是不可執行的,包含的section見上文輸出。然而,在某些系統上,可能會有其它的聚合方式,詳見[這個例子](https://stackoverflow.com/questions/57761007/why-an-elf-executable-could-have-4-load-segments)。這與系統產生ELF檔案的實現有關。 # 關於link_map link_map是用來儲存ELF檔案的資料結構,其詳細定義可以在include/link.h下找到。 dlopen返回的開啟的so的handle。這個handle是一個可以被其它libdl函式使用的介面,如dlsym,dlclose。需要注意它與so不儲存在一起,也不是so在記憶體中的基地址。 # 結語 時間倉促,dlopen的實現只挑了主幹研究,其它部分還沒空顧及,一些支撐我得到結論的實驗也沒有放上來。希望能與各路大神深入