深刻了解tcmalloc(一):windows環境下無縫攔截技術初探

概述:

         又到了一個總結提煉的階段,此次想具體聊聊遊戲引擎中使用的內存管理模塊tcmalloc組件的使用心得。項目的前期曾經遇到過內存瓶頸,特別是windows系統下的客戶端程序在經歷長時間運行以後會出現內存佔用率很高疑似泄漏的現象,排查了好久都沒有找到緣由,甚至一度沒法定位問題出自遊戲腳本層仍是引擎層,後來在引擎中連接了tcmalloc組件,經過實時dump程序的內存信息最終找出了泄漏的元兇。tcmalloc的另外一個優點就是經過高效率內存分配來提升遊戲運行時性能,不得不說在使用tcmalloc以後,整個遊戲的穩定性和效率都有了很大的提高。爲了從此更有效和穩定地使用tcmalloc組件,就在這裏深刻剖析一下這個神器。TcmallocGoogle Perftools中的一個組件,提供一整套高效健壯的內存管理方案,比傳統glibc的內存分配和釋放要快數倍;其次,基於tcmalloc之上的heapprofiler能夠實時dump程序中heap的使用信息,是一個很好的檢測內存泄漏的輔助工具;同時tcmalloc的使用又是極其方便,只須要在編譯時增長一個連接選項,就能夠無縫攔截(patch)原生操做系統運行庫中的內存分配和釋放接口,而無需修改已經完成的項目工程代碼,大大減小移植整合的成本。windows

    在windows平臺下,tcmalloc能夠經過靜態庫或者動態庫(DLL)的形式嵌入到工程裏面,這裏將主要 分析tcmalloc如何DLL動態連接到工程裏面,同時將重點剖析一下tcmalloc如何在不改變工程原有代碼的前提下無縫地攔截windows原生內存管理接口。api

配置步驟:

DLL形式連接進入工程的主要步驟以下:首先從官網下載並解壓gperftools包,下載地址爲:http://code.google.com/p/gperftools/downloads/list,現有的版本是2.1;打開並編譯gperftools-2.1目錄下的gperftools.sln;編譯經過後,在build輸出目錄下生成libtcmalloc_minimal.dll和對應的lib文件;將libdll文件拷貝到工程編譯目錄下,並在連接選項中添加兩個配置,以下圖所示:additional dependencies(附加依賴項): libtcmalloc_minimal.dll force symbol references(強制符號引用):__tcmalloc (64bit 系統);從新編譯連接後,exe運行時tcmalloc將在程序靜態變量初始化階段攔截全部原生內存管理接口。sass

無縫連接原理剖析:

要理解tcmalloc如何無縫攔截底層運行庫(runtime library)中的內存管理函數,首先須要理解windows平臺下的可執行文件和 exe加載流程。windows平臺下的可執行文件是以PE(Portable Executable)格式存在的,由各個不一樣的段組織而成,如.data .text .rsrc等,其中.text段包含了模塊內全部代碼的二進制輸出,相應的函數調用是以數據結構

call XXXXXXXXapp

的彙編指令存在,其中XXXXXXXX表示程序運行時的函數虛擬地址。因爲本模塊PE文件各個段的佈局和相應加載地址是在連接時決定,因此對於本模塊內的函數調用能夠在連接時就計算獲得相應的函數地址,以下圖所示,對某.text段中的.func函數進行調用,.func函數地址XXXXXXX能夠經過將該段的加載地址和函數在段內的偏移二者相加獲得:ide

對於隱式(implicit)連接的DLL模塊,因爲連接器沒法在連接階段獲得DLL模塊中各個段的佈局和加載信息,因此沒法直接計算獲得具體函數地址。若是其餘模塊須要調用DLL內的函數,PE文件經過一種稱爲引用地址數據表(Import Address Table, IAT)的數據結構間接指向這些函數,在連接階段連接器簡單在IAT中寫入各個函數的symbol,而相應的call指令也變成了以下形式:函數

CALL DWORD PTR [XXXXXXXX]工具

其中[XXXXXXXX]表示.func函數在IAT中相應slot的地址,以下圖所示,XXXXXXXX值是由IAT表的加載地址和.funcslot index二者相加獲得的:佈局

    當這個可執行文件加載運行時,windows的程序加載器(Loader)負責解析這個PE文件格式,將文件中的各個數據段和代碼段映射到進程的地址空間,同時經過遍歷IMAGE_IMPORT_DESCRIPTOR 段,將全部隱式連接的DLL都加載到內存中,同時更新各個IAT中的slot,寫入symbol所對應函數所在的內存地址,這樣就保證了指令CALL DWORD PTR [XXXXXXXX]能夠正確地調用到其餘模塊中的函數。性能

         內存管理模塊通常由操做系統底層運行庫(runtime library)或第三方庫提供,以動態或者靜態的方式連接入可執行文件,攔截這些函數的方法通常有兩種:

1)對須要攔截的內存管理函數,修改全部本地對其call指令的目的地址和IAT slot中可能引用到的間接函數地址,將它們指向新的替換函數地址,以下圖A所示:

Amain module是可執行文件,module B是底層運行庫或者實現了內存管理的第三方庫,module Atcmalloctcmalloc須要攔截全部moduleIAT表中原來調用module Bmallocslot,同時還要攔截全部module B中本地調用malloccall指令,將他們都攔截到tcmalloc中相應的替換函數。

2)直接修改須要攔截的內存管理函數實現,將函數空間的前幾個bytes修改爲一個跳轉指令,跳轉到新函數的地址空間,以下圖B所示:

B,tcmalloc保留全部moduleIAT內容和本地call指令,只修改module Bmalloc的實現,將最前面bytes修改爲一個jmp指令,將程序的指令流跳轉到tcmalloc中相應的提替換函數。

       Googletcmalloc組件正是以第二種方式無縫攔截了內存管理函數,修改原有目標函數的前kRequiredTargetPatchBytes5)字節,將程序強制跳轉到tcmalloc本身的內存管理函數。固然tcmalloc更爲周到地考慮瞭如下幾點:

  1. tcmalloc接管了底層運行庫和第三方庫中的整套內存管理方案,攔截了各模塊中全部的內存管理函數:malloc, free, realloc, calloc, new, newArray, delete, deleteArray, newNothrow, newArrayNothrow, kDeleteNothrow, deleteArrayNothrow, msize, expand, callocCrt

  2. 準確區分程序運行時各個內存空間的分配者,嚴格遵循由誰分配則由誰負責釋放的原則,程序在tcmalloc攔截前申請分配的內存空間由原始內存釋放函數進行釋放,在tcmalloc攔截後申請分配的內存空間由tcmalloc的內存釋放函數進行釋放,保證整個程序運行正確性和最終dump信息的準確性;

  3. 保證每一個內存管理函數只會被攔截一次,對某些DLLexport forwarding的內存管理函數,tcmalloc會遍歷整個export鏈找到最終的實現函數進行攔截;

  4. 對於顯示(explict)連接的DLL庫,tcmalloc經過攔截loadLibrary, LoadLibraryExW, FreeLibrarymodule操做函數來作到攔截這些模塊中的內存管理函數

  5. tcmalloc考慮了unpatch的過程,上層程序能夠經過適當的操做,恢復到原始運行庫提供的內存管理方案,因此tcmalloc實現中不只要修改目標函數的內容,還須要將被修改前的內容進行保存,在適當的時候進行還原。

單一函數攔截流程:

下面從tcmalloc如何攔截單個內存管理函數開始介紹,文件preamble_patcher_with_stub.cc中的函數

SideStepError PreamblePatcher::RawPatchWithStub(void* target_function, void* replacement_function, unsigned char* preamble_stub, unsigned long stub_size, unsigned long* bytes_needed)

實現了對單個函數的攔截邏輯,整個流程中涉及了三個相當重要的變量,他們指向的三個地址空間,理解這三個地址空間含義也就理解了tcmalloc的整個攔截流程:

  1. target_function:須要被攔截的目標函數地址,譬如運行庫的malloc函數地址;

  2. replacement_functiontcmalloc中用來替換被攔截函數的新函數地址,譬如tcmalloc中的Perftools_malloc函數就是攔截運行庫malloc函數後的替換函數;

  3. preamble_stub:用來存放目標函數起始幾個bytes內容的空間,這個空間是tcmalloc經過函數AllocPageNear額外申請的,具體有兩個做用將下面介紹;

這個三個變量對應函數的前三個參數,函數的後兩個參數相對比較簡單:

stub_size:表示preamble_stub內存塊的總大小;

bytes_needed:做爲返回值,傳遞給函數的調用者該攔截過程實際佔用preamble_stub的字節數。

攔截流程具體以下:

while (讀取目標函數的內容偏移量(preamble_bytes)小於kRequiredTargetPatchBytes) {
    // 反彙編獲得相應的指令類型
    InstructionType instruction_type =
        disassembler.Disassemble(target + preamble_bytes, cur_bytes);
    if (IT_JUMP == instruction_type) {
           // 若是是跳轉指令
           1)  將指令類型字節碼拷貝到preamble_stub
           2)  從新計算該指令相對跳轉偏移original_jump_dest - stub_jump_from,並拷貝到preamble_stub,保證該遷移後的指令在執行時可以正確跳轉到原來指令應該跳轉到的目的地址,若是原目的地址在須要遷移kRequiredTargetPatchBytes的字節內,則還須要再一次從新計算相對跳轉偏移到新的目的地址。
    } else if (IT_GENERIC == instruction_type) {
      if (IsMovWithDisplacement(target + preamble_bytes, cur_bytes)) {
        // 若是是mov displace指令
         1)  將指令類型字節碼拷貝到preamble_stub
         2)  從新計算mov的目的地址,邏輯與上面處理跳轉指令相似
      } else {
        // 其餘普通指令
        1)將整個指令簡單copy到preamble_stub
      }
    }
    // 將讀取目標函數內容的指針向後偏移剛剛copy的指令字節數
    preamble_bytes += cur_bytes;
  }
  if (NULL != bytes_needed)
     // 計算preamble_stub會被佔用的字節數
    *bytes_needed = stub_bytes + kRequiredStubJumpBytes
        + required_trampoline_bytes;
 
  // Now, make a jmp instruction to the rest of the target function (minus the
  // preamble bytes we moved into the stub) and copy it into our preamble-stub.
  // find address to jump to, relative to next address after jmp instruction (註釋很清晰,很少解釋)
  int relative_offset_to_target_rest
      = ((reinterpret_cast<unsigned char*>(target) + preamble_bytes) -
         (preamble_stub + stub_bytes + kRequiredStubJumpBytes));
 
  // jmp (Jump near, relative, displacement relative to next instruction)
  //在preamble_stub的最後添加一條特殊jmp指令ASM_JMP32REL,其目的是保證上面所提到的2,5需求能正確實現,當程序須要調用原來target_function時,在執行preamble_stub最前幾個bytes指令後可以成功跳轉到原來target_function空間在kRequiredStubJumpBytes後的指令序列繼續執行
  preamble_stub[stub_bytes] = ASM_JMP32REL;
  // copy the address
  memcpy(reinterpret_cast<void*>(preamble_stub + stub_bytes + 1),
         reinterpret_cast<void*>(&relative_offset_to_target_rest), 4);
 
  // Inv: preamble_stub points to assembly code that will execute the
  // original function by first executing the first cbPreamble bytes of the
  // preamble, then jumping to the rest of the function.
 
  // Overwrite the first 5 bytes of the target function with a jump to our
  // replacement function.
  // (Jump near, relative, displacement relative to next instruction)
  // 全部準備工做結束,萬事俱備開始真正攔截,往目標函數的前kRequiredStubJumpBytes寫入一個跳轉指令,跳轉到tcmalloc的替換函數
  target[0] = ASM_JMP32REL;
 
  // Find offset from instruction after jmp, to the replacement function.
  // 計算tcmalloc替換函數的相對地址
  int offset_to_replacement_function =
      reinterpret_cast<unsigned char*>(replacement_function) -
      reinterpret_cast<unsigned char*>(target) - 5;
  // complete the jmp instruction
  memcpy(reinterpret_cast<void*>(target + 1),
         reinterpret_cast<void*>(&offset_to_replacement_function), 4);
  // 圓滿完成

下圖是攔截以後三個空間所包含的內容:

圖中黃色部分表示tcmalloc所作的修改,preamble_stub最初的kRequiredStubJumpBytes字節內容是target_function最前面kRequiredStubJumpBytes字節內的指令通過相對地址重計算後的替代指令;kRequiredStubJumpBytes字節後面跟着一條JMP指令用來跳轉到target_function中第(kRequiredStubJumpBytes + 1byte地址空間;JMP指令後還跟着幾條trampoline指令,用來處理preamble_stubtarget_function的地址空間間隔超過4G的狀況,這裏不作過多介紹。target_function最前面kRequiredStubJumpBytes字節用一個JMP指令替代,跳轉到tcmallocreplacement_function的地址空間。從中能夠看到preamble_stub的做用其實有兩個:

  1.  tcmalloc攔截原始的內存管理函數後,若是須要調用target_function函數,譬如釋放tcmalloc攔截前已經分配的內存空間,則只須要call preamble_stub就能夠實現。

  2.  當須要unpatch內存管理函數時,只須要對preamble_stubkRequiredStubJumpBytes字節內的指令進行patch的逆操做,並拷貝回target_function的空間就能夠了。

相關文件:

tcmalloc中主要有4個文件涉及到函數攔截邏輯,分別以下:

  1. patch_functions.cc:無縫攔截全部DLL中的內存管理函數和windows kernel32模塊內針對heap進行操做的函數。

  2. preamble_patcher.cc:主要實現指令的反彙編邏輯,判斷各指令類型和計算地址符在指令中的偏移;RawPatchWithStub進行了包裝,檢查三個地址空間的有效性和準確性;針對module中的export forwarding狀況進行處理,根據JMP指令找到真正的target_function實現函數 (ResolveTarget)

  3. preamble_patcher_with_stub.cc:主要實現了對單個函數的攔截功能(前面已經介紹)。

  4. libc_override.htcmalloc中有關函數攔截的函數定義。

 

如下將主要介紹patch_functions.cc中如何對module進行攔截的流程。

相關數據結構:

在介紹流程以前,先簡單介紹一下patch_functions.cc中主要涉及的幾個重要數據結構:

 

LibcInfo這個類與須要被攔截的module一一對應,該類經過成員函數patchmodule中全部內存管理函數進行攔截,須要攔截的函數都定義在enum中:

enum {
    kMalloc, kFree, kRealloc, kCalloc,
    kNew, kNewArray, kDelete, kDeleteArray,
    kNewNothrow, kNewArrayNothrow, kDeleteNothrow, kDeleteArrayNothrow,
    // These are windows-only functions from malloc.h
    k_Msize, k_Expand,
    // A MS CRT "internal" function, implemented using _calloc_impl
    k_CallocCrt,
    kNumFunctions
  };

這個類還有有三個重要的數據成員:

  1. function_name_:記錄了全部須要被攔截的函數名,能夠經過調用windows函數GetProcAddress獲得須要被攔截的函數地址;

  2. static_fn_:用於靜態連接的庫,動態連接時不會用到,在這裏不作介紹;

  3. windows_fn_ 須要被攔截的函數地址,即前面提到的target_function,這個成員變量是在函數PopulateWindowsFn內進行賦值的,該函數經過遍歷function_name_找到module中全部須要攔截的函數地址,並經過調用PreamblePatcher::ResolveTarget()函數遍歷moduleexport forwarding鏈找到真正的target_function實現。


LibcInfoWithPatchFunctions該類繼承自LibcInfo,經過Template來具體對應一個須要被攔截的module,因爲每一個module均可能有本身的內存管理函數和須要攔截的替換函數,tcmalloc經過顯示的定義一堆

static LibcInfoWithPatchFunctions<0> main_executable;
static LibcInfoWithPatchFunctions<1> libc1;
static LibcInfoWithPatchFunctions<2> libc2;
static LibcInfoWithPatchFunctions<3> libc3;
...

來表示各個加載到內存的module。該類有兩個重要的數據成員:

  1. origstub_fn_:保存了攔截後target_function的調用地址,即上面提到的各個被攔截函數相對應的preamble_stub地址。

  2. perftools_fn_:保存了tcmalloc的替換函數,以下所示:

  static void* Perftools_malloc(size_t size) __THROW;
  static void Perftools_free(void* ptr) __THROW;
  static void* Perftools_realloc(void* ptr, size_t size) __THROW;
  static void* Perftools_calloc(size_t nmemb, size_t size) __THROW;
  static void* Perftools_new(size_t size);
  static void* Perftools_newarray(size_t size);
  static void Perftools_delete(void *ptr);              
  static void Perftools_deletearray(void *ptr);
  static void* Perftools_new_nothrow(size_t size, const std::nothrow_t&) __THROW;
  static void* Perftools_newarray_nothrow(size_t size, const std::nothrow_t&) __THROW;
  static void Perftools_delete_nothrow(void *ptr, const std::nothrow_t&) __THROW;
  static void Perftools_deletearray_nothrow(void *ptr, const std::nothrow_t&) __THROW;
  static size_t Perftools__msize(void *ptr) __THROW;
  static void* Perftools__expand(void *ptr, size_t size) __THROW;

 

WindowsInfo該類與LibcInfo十分類似,但它主要負責攔截windows kernel32中針對heap進行操做的函數,須要攔截的函數都定義在enum中:

enum {
    kHeapAlloc, kHeapFree, kVirtualAllocEx, kVirtualFreeEx,
    kMapViewOfFileEx, kUnmapViewOfFile, kLoadLibraryExW, kFreeLibrary,
    kNumFunctions
  };

下來的代碼定義了與其相對應的tcmalloc替換函數:

WindowsInfo::FunctionInfo WindowsInfo::function_info_[] = {
  { "HeapAlloc", NULL, NULL, (GenericFnPtr)&Perftools_HeapAlloc },
  { "HeapFree", NULL, NULL, (GenericFnPtr)&Perftools_HeapFree },
  { "VirtualAllocEx", NULL, NULL, (GenericFnPtr)&Perftools_VirtualAllocEx },
  { "VirtualFreeEx", NULL, NULL, (GenericFnPtr)&Perftools_VirtualFreeEx },
  { "MapViewOfFileEx", NULL, NULL, (GenericFnPtr)&Perftools_MapViewOfFileEx },
  { "UnmapViewOfFile", NULL, NULL, (GenericFnPtr)&Perftools_UnmapViewOfFile },
  { "LoadLibraryExW", NULL, NULL, (GenericFnPtr)&Perftools_LoadLibraryExW },
  { "FreeLibrary", NULL, NULL, (GenericFnPtr)&Perftools_FreeLibrary },

     tcmalloc攔截這些windows api並非爲了接管windows自身的heap操做邏輯,而是爲了對內存操做進行計數。每一個替換函數裏面都簡單調用了origstub_fn所指向的原有windows api實現,只是在每一個api調用先後增長一些計數hook。由於kernel32只會被加載一次,因此WindowsInfotcmalloc中也是以單例形式存在的。

 

ModuleEntryCopy該類保存每一個module被加載到內存後的加載信息,包括該module的加載地址和module的大小;在LibcInfoPopulateWindowsFn前,該類還負責保存module中須要被攔截函數的函數地址。


攔截流程:

      tcmalloc到底是在什麼時候對函數進行了攔截?一切還得從文章最開始所提到的兩個配置講起,使用tcmalloc時只須要在程序中添加兩項配置:additional dependencies: libtcmalloc_minimal.dll; force symbol references:__tcmalloc; 其中第一項是配置任何DLL都所必需的步驟,而第二個選項是因爲在實際工程裏面不會顯式調用tcmalloc模塊內的函數,而致使編譯器在編譯優化階段忽略整個tcmalloc模塊,因此須要強制引入一個該模塊內的符號,即__tcmalloc,告訴編譯器tcmalloc是工程所依賴的模塊,對於32位的系統只須要強制引入符號_tcmalloc便可。其實__tcmalloctcmalloc裏面只是一個空函數,不起任何做用,那麼哪裏纔是tcmalloc攔截的真正入口?那得從另外一個類TCMallocGuard提及,在文件Tcmalloc.cc中定義了一個TCMallocGuard的靜態對象module_enter_exit_hook

TCMallocGuard::TCMallocGuard() {
  if (tcmallocguard_refcount++ == 0) {
         ....
    ReplaceSystemAlloc();    // defined in libc_override_*.h
    tc_free(tc_malloc(1));
    ......
  }
}

看到函數調用ReplaceSystemAlloc()時,謎底已經揭曉,這正是tcmalloc攔截內存管理函數的入口,全部的無縫操做都是從這裏開始,在程序初始化靜態變量module_enter_exit_hook以後,在正式跳轉到main函數以前。

下面是tcmalloc攔截一個函數時的調用堆棧:

其中函數PatchAllModules會調用windows 函數EnumProcessModules遍歷已經加載到內存的全部module,而且重複調用RawPatchWithStub對每一個內存管理函數進行攔截。

         最後還須要指出的一點是tcmalloc還攔截了windowsLoadLibrary函數,當每次有新的module顯式加載到程序時,都會調用PatchAllModules函數,對新加入的module內可能存在的內存管理函數進行攔截。

相關文章
相關標籤/搜索