深入了解tcmalloc(一):windows环境下无缝拦截技术初探
2019獨角獸企業重金招聘Python工程師標準>>>
概述:
???????? 又到了一個總結提煉的階段,這次想具體聊聊游戲引擎中使用的內存管理模塊tcmalloc組件的使用心得。項目的前期曾經遇到過內存瓶頸,特別是windows系統下的客戶端程序在經歷長時間運行之后會出現內存占用率很高疑似泄漏的現象,排查了很久都沒有找到原因,甚至一度無法定位問題出自游戲腳本層還是引擎層,后來在引擎中鏈接了tcmalloc組件,通過實時dump程序的內存信息最終找出了泄漏的元兇。tcmalloc的另一個優勢就是通過高效率內存分配來提高游戲運行時性能,不得不說在使用tcmalloc之后,整個游戲的穩定性和效率都有了很大的提升。為了今后更有效和穩定地使用tcmalloc組件,就在這里深入剖析一下這個神器。Tcmalloc是Google Perftools中的一個組件,提供一整套高效健壯的內存管理方案,比傳統glibc的內存分配和釋放要快數倍;其次,基于tcmalloc之上的heapprofiler可以實時dump程序中heap的使用信息,是一個很好的檢測內存泄漏的輔助工具;同時tcmalloc的使用又是極其方便,只需要在編譯時增加一個鏈接選項,就可以無縫攔截(patch)原生操作系統運行庫中的內存分配和釋放接口,而無需修改已經完成的項目工程代碼,大大減少移植整合的成本。
? ? 在windows平臺下,tcmalloc可以通過靜態庫或者動態庫(DLL)的形式嵌入到工程里面,這里將主要?分析tcmalloc如何DLL動態鏈接到工程里面,同時將重點剖析一下tcmalloc如何在不改變工程原有代碼的前提下無縫地攔截windows原生內存管理接口。
配置步驟:
以DLL形式鏈接進入工程的主要步驟如下:首先從官網下載并解壓gperftools包,下載地址為:http://code.google.com/p/gperftools/downloads/list,現有的版本是2.1;打開并編譯gperftools-2.1目錄下的gperftools.sln;編譯通過后,在build輸出目錄下生成libtcmalloc_minimal.dll和對應的lib文件;將lib和dll文件拷貝到工程編譯目錄下,并在鏈接選項中添加兩個配置,如下圖所示:additional dependencies(附加依賴項): libtcmalloc_minimal.dll; force symbol references(強制符號引用):__tcmalloc (64bit 系統);重新編譯鏈接后,exe運行時tcmalloc將在程序靜態變量初始化階段攔截所有原生內存管理接口。
無縫鏈接原理剖析:
要理解tcmalloc如何無縫攔截底層運行庫(runtime library)中的內存管理函數,首先需要理解windows平臺下的可執行文件和 exe加載流程。windows平臺下的可執行文件是以PE(Portable Executable)格式存在的,由各個不同的段組織而成,如.data .text .rsrc等,其中.text段包含了模塊內所有代碼的二進制輸出,相應的函數調用是以
call XXXXXXXX
的匯編指令存在,其中XXXXXXXX表示程序運行時的函數虛擬地址。由于本模塊PE文件各個段的布局和相應加載地址是在鏈接時決定,所以對于本模塊內的函數調用可以在鏈接時就計算得到相應的函數地址,如下圖所示,對某.text段中的.func函數進行調用,.func函數地址XXXXXXX可以通過將該段的加載地址和函數在段內的偏移兩者相加得到:
對于隱式(implicit)鏈接的DLL模塊,由于鏈接器無法在鏈接階段得到DLL模塊中各個段的布局和加載信息,所以無法直接計算得到具體函數地址。如果其他模塊需要調用DLL內的函數,PE文件通過一種稱為引用地址數據表(Import Address Table, IAT)的數據結構間接指向這些函數,在鏈接階段鏈接器簡單在IAT中寫入各個函數的symbol,而相應的call指令也變成了如下形式:
CALL DWORD PTR [XXXXXXXX]
其中[XXXXXXXX]表示.func函數在IAT中相應slot的地址,如下圖所示,XXXXXXXX值是由IAT表的加載地址和.func的slot index兩者相加得到的:
? ? 當這個可執行文件加載運行時,windows的程序加載器(Loader)負責解析這個PE文件格式,將文件中的各個數據段和代碼段映射到進程的地址空間,同時通過遍歷IMAGE_IMPORT_DESCRIPTOR 段,將所有隱式鏈接的DLL都加載到內存中,同時更新各個IAT中的slot,寫入symbol所對應函數所在的內存地址,這樣就保證了指令CALL DWORD PTR [XXXXXXXX]可以正確地調用到其他模塊中的函數。
???????? 內存管理模塊一般由操作系統底層運行庫(runtime library)或第三方庫提供,以動態或者靜態的方式鏈接入可執行文件,攔截這些函數的方法一般有兩種:
1)對需要攔截的內存管理函數,修改所有本地對其call指令的目的地址和IAT slot中可能引用到的間接函數地址,將它們指向新的替換函數地址,如下圖A所示:
圖A,main module是可執行文件,module B是底層運行庫或者實現了內存管理的第三方庫,module A是tcmalloc,tcmalloc需要攔截所有module的IAT表中原來調用module B中malloc的slot,同時還要攔截所有module B中本地調用malloc的call指令,將他們都攔截到tcmalloc中相應的替換函數。
2)直接修改需要攔截的內存管理函數實現,將函數空間的前幾個bytes修改成一個跳轉指令,跳轉到新函數的地址空間,如下圖B所示:
圖B,tcmalloc保留所有module的IAT內容和本地call指令,只修改module B中malloc的實現,將最前面bytes修改成一個jmp指令,將程序的指令流跳轉到tcmalloc中相應的提替換函數。
? ? ? ?Google的tcmalloc組件正是以第二種方式無縫攔截了內存管理函數,修改原有目標函數的前kRequiredTargetPatchBytes(5)字節,將程序強制跳轉到tcmalloc自己的內存管理函數。當然tcmalloc更為周到地考慮了以下幾點:
tcmalloc接管了底層運行庫和第三方庫中的整套內存管理方案,攔截了各模塊中所有的內存管理函數:malloc, free, realloc, calloc, new, newArray, delete, deleteArray, newNothrow, newArrayNothrow, kDeleteNothrow, deleteArrayNothrow, msize, expand, callocCrt
準確區分程序運行時各個內存空間的分配者,嚴格遵循由誰分配則由誰負責釋放的原則,程序在tcmalloc攔截前申請分配的內存空間由原始內存釋放函數進行釋放,在tcmalloc攔截后申請分配的內存空間由tcmalloc的內存釋放函數進行釋放,保證整個程序運行正確性和最終dump信息的準確性;
保證每個內存管理函數只會被攔截一次,對某些DLL中export forwarding的內存管理函數,tcmalloc會遍歷整個export鏈找到最終的實現函數進行攔截;
對于顯示(explict)鏈接的DLL庫,tcmalloc通過攔截loadLibrary, LoadLibraryExW, FreeLibrary等module操作函數來做到攔截這些模塊中的內存管理函數
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的整個攔截流程:
target_function:需要被攔截的目標函數地址,譬如運行庫的malloc函數地址;
replacement_function:tcmalloc中用來替換被攔截函數的新函數地址,譬如tcmalloc中的Perftools_malloc函數就是攔截運行庫malloc函數后的替換函數;
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_stub2)??重新計算該指令相對跳轉偏移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_stub2)??重新計算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?addressmemcpy(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?instructionmemcpy(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 + 1)byte地址空間;JMP指令后還跟著幾條trampoline指令,用來處理preamble_stub和target_function的地址空間間隔超過4G的情況,這里不做過多介紹。target_function最前面kRequiredStubJumpBytes字節用一個JMP指令替代,跳轉到tcmalloc的replacement_function的地址空間。從中可以看到preamble_stub的作用其實有兩個:
?當tcmalloc攔截原始的內存管理函數后,如果需要調用target_function函數,譬如釋放tcmalloc攔截前已經分配的內存空間,則只需要call preamble_stub就可以實現。
?當需要unpatch內存管理函數時,只需要對preamble_stub前kRequiredStubJumpBytes字節內的指令進行patch的逆操作,并拷貝回target_function的空間就可以了。
相關文件:
tcmalloc中主要有4個文件涉及到函數攔截邏輯,分別如下:
patch_functions.cc:無縫攔截所有DLL中的內存管理函數和windows kernel32模塊內針對heap進行操作的函數。
preamble_patcher.cc:主要實現指令的反匯編邏輯,判斷各指令類型和計算地址符在指令中的偏移;將RawPatchWithStub進行了包裝,檢查三個地址空間的有效性和準確性;針對module中的export forwarding情況進行處理,根據JMP指令找到真正的target_function實現函數 (ResolveTarget)。
preamble_patcher_with_stub.cc:主要實現了對單個函數的攔截功能(前面已經介紹)。
libc_override.h:tcmalloc中有關函數攔截的函數定義。
?
以下將主要介紹patch_functions.cc中如何對module進行攔截的流程。
相關數據結構:
在介紹流程之前,先簡單介紹一下patch_functions.cc中主要涉及的幾個重要數據結構:
?
LibcInfo:這個類與需要被攔截的module一一對應,該類通過成員函數patch對module中所有內存管理函數進行攔截,需要攔截的函數都定義在enum中:
enum?{kMalloc,?kFree,?kRealloc,?kCalloc,kNew,?kNewArray,?kDelete,?kDeleteArray,kNewNothrow,?kNewArrayNothrow,?kDeleteNothrow,?kDeleteArrayNothrow,//?These?are?windows-only?functions?from?malloc.hk_Msize,?k_Expand,//?A?MS?CRT?"internal"?function,?implemented?using?_calloc_implk_CallocCrt,kNumFunctions};這個類還有有三個重要的數據成員:
function_name_:記錄了所有需要被攔截的函數名,可以通過調用windows函數GetProcAddress得到需要被攔截的函數地址;
static_fn_:用于靜態鏈接的庫,動態鏈接時不會用到,在這里不做介紹;
windows_fn_: 需要被攔截的函數地址,即前面提到的target_function,這個成員變量是在函數PopulateWindowsFn內進行賦值的,該函數通過遍歷function_name_找到module中所有需要攔截的函數地址,并通過調用PreamblePatcher::ResolveTarget()函數遍歷module的export 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。該類有兩個重要的數據成員:
origstub_fn_:保存了攔截后target_function的調用地址,即上面提到的各個被攔截函數相對應的preamble_stub地址。
perftools_fn_:保存了tcmalloc的替換函數,如下所示:
?
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只會被加載一次,所以WindowsInfo在tcmalloc中也是以單例形式存在的。
?
ModuleEntryCopy:該類保存每個module被加載到內存后的加載信息,包括該module的加載地址和module的大小;在LibcInfo被PopulateWindowsFn前,該類還負責保存module中需要被攔截函數的函數地址。
攔截流程:
? ? ? tcmalloc究竟是在何時對函數進行了攔截?一切還得從文章最開始所提到的兩個配置講起,使用tcmalloc時只需要在程序中添加兩項配置:additional dependencies: libtcmalloc_minimal.dll; force symbol references:__tcmalloc; 其中第一項是配置任何DLL都所必需的步驟,而第二個選項是由于在實際工程里面不會顯式調用tcmalloc模塊內的函數,而導致編譯器在編譯優化階段忽略整個tcmalloc模塊,所以需要強制引入一個該模塊內的符號,即__tcmalloc,告訴編譯器tcmalloc是工程所依賴的模塊,對于32位的系統只需要強制引入符號_tcmalloc即可。其實__tcmalloc在tcmalloc里面只是一個空函數,不起任何作用,那么哪里才是tcmalloc攔截的真正入口?那得從另一個類TCMallocGuard說起,在文件Tcmalloc.cc中定義了一個TCMallocGuard的靜態對象module_enter_exit_hook
TCMallocGuard::TCMallocGuard()?{if?(tcmallocguard_refcount++?==?0)?{....ReplaceSystemAlloc();????//?defined?in?libc_override_*.htc_free(tc_malloc(1));......} }看到函數調用ReplaceSystemAlloc()時,謎底已經揭曉,這正是tcmalloc攔截內存管理函數的入口,所有的無縫操作都是從這里開始,在程序初始化靜態變量module_enter_exit_hook之后,在正式跳轉到main函數之前。
下面是tcmalloc攔截一個函數時的調用堆棧:
其中函數PatchAllModules會調用windows 函數EnumProcessModules遍歷已經加載到內存的所有module,并且重復調用RawPatchWithStub對每個內存管理函數進行攔截。
???????? 最后還需要指出的一點是tcmalloc還攔截了windows的LoadLibrary函數,當每次有新的module顯式加載到程序時,都會調用PatchAllModules函數,對新加入的module內可能存在的內存管理函數進行攔截。
轉載于:https://my.oschina.net/u/877348/blog/272066
總結
以上是生活随笔為你收集整理的深入了解tcmalloc(一):windows环境下无缝拦截技术初探的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 梦到和前男友吵架打架是什么意思
- 下一篇: 梦到做生意赔了什么意思