.Net CLR GC動態獲取函式頭地址,C++的騷操作(慎入)

江湖評談 發表於 2022-06-17
C++ .Net

前言:

太懶了,從沒有在這裡正兒八經的寫過文章。看到一些人的高產,真是慚愧。決定稍微變得不那麼懶。如有疏漏,請指正。
.net的GC都談的很多了,本篇主要是劍走偏鋒,聊聊一些個人認為較為核心的細節方面的問題。至於,標記,計劃,壓縮,清掃這些不在討論之列。

提示:閱讀本篇需要二進位制,彙編,C++以及基礎的位移,邏輯與等知識。

動態函式頭地址的一些概念:

一段記憶體有記憶體的起始地址(暫叫base),記憶體的結束地址,以及記憶體指標當前指向的地址大致的三個概念。而在這段記憶體裡面分配了函式之後,一個函式在記憶體裡面必定有一個函式的起始地址也就是指令(第一個push)所在的地址,稱之為函式頭地址,函式的結束地址也就是指令(ret)所在的地址。在函式裡面做了一些事情,那麼這些可以稱之為函式中間的某個地址。
通過函式中間的某個地址(不固定的)獲取到函式頭地址(固定的)。稱之為動態獲取函式頭地址
硬編碼動態獲取到函式頭地址之後,你就可以得到GC資訊,方法描述符資訊,除錯資訊,異常資訊,回滾資訊,幀棧資訊等等。

C#程式碼:

    static void Main(string[] args)
        {
            GC.Collect();
            Console.ReadLine();
        }

把這段程式碼反彙編一下:

7:         static void Main(string[] args)
     8:         {
00007FFB098C5EC0 55                   push        rbp  
00007FFB098C5EC1 57                   push        rdi  
00007FFB098C5EC2 56                   push        rsi  
00007FFB098C5EC3 48 83 EC 30          sub         rsp,30h  
00007FFB098C5EC7 48 8B EC             mov         rbp,rsp  
00007FFB098C5ECA 33 C0                xor         eax,eax  
00007FFB098C5ECC 48 89 45 28          mov         qword ptr [rbp+28h],rax  
00007FFB098C5ED0 48 89 4D 50          mov         qword ptr [rbp+50h],rcx  
00007FFB098C5ED4 83 3D 95 CB 09 00 00 cmp         dword ptr [7FFB09962A70h],0  
00007FFB098C5EDB 74 05                je          ConsoleApp10.Program.Main(System.String[])+022h (07FFB098C5EE2h)  
00007FFB098C5EDD E8 0E 27 CB 5F       call        00007FFB695785F0  
00007FFB098C5EE2 90                   nop  
     9:             GC.Collect();
00007FFB098C5EE3 E8 70 ED FF FF       call        CLRStub[MethodDescPrestub]@7ffb098c4c58 (07FFB098C4C58h)  
00007FFB098C5EE8 90                   nop  
    10:             Console.ReadLine();
00007FFB098C5EE9 E8 42 FF FF FF       call        CLRStub[MethodDescPrestub]@7ffb098c5e30 (07FFB098C5E30h)  
00007FFB098C5EEE 48 89 45 28          mov         qword ptr [rbp+28h],rax  
00007FFB098C5EF2 90                   nop  
    11:         }
00007FFB098C5EF3 90                   nop  
00007FFB098C5EF4 48 8D 65 30          lea         rsp,[rbp+30h]  
00007FFB098C5EF8 5E                   pop         rsi  
00007FFB098C5EF9 5F                   pop         rdi  
00007FFB098C5EFA 5D                   pop         rbp  
00007FFB098C5EFB C3                   ret  

我們看到地址:00007FFB098C5EC0就是函式頭的地址。00007FFB098C5EFB則是函式結束地址。中間的比如呼叫GC.Collect的地址00007FFB098C5EE3和呼叫Console.ReadLine的地址00007FFB098C5EE9,則可以稱之為中間地址。

如何通過中間的某個地址(可能是00007FFB098C5EE3,也可能是00007FFB098C5EE9,還有可能是中間其它地址)動態的找到函式頭的固定地址呢?

計算公式一:奇偶數的偏移(value-1)

我們先來看下函式頭地址:00007FFB098C5EC0,在記憶體裡面的儲存數值。

CLR的操作是:
value-1 =(00007FFB098C5EC0 - base) & 31 >>2+1
base:是函式所在記憶體的起始地址
value-1:是計算的結果

這個value-1的結果要麼是1,要麼是5,為啥?仔細分析下。一般的來說,base也就是函式所在的記憶體的起始地址末尾兩位元組一般都是 00 00。也就是說 00007FFB098C5EC0 - base 的結果一定是0xnnnnnnnnnnnn5EC0。n表示未知數。因為上面的公式&31,所以只需要關注最後兩個位元組就可以了。

回到上面為啥value-1等於1或者5呢?不能等於其它。5EC0中C0的二進位制是:
1100 0000。把它&31,結果是0。0>>2還是0。然後加上1,結果也就是value-1等於1.

那麼5是怎麼來的呢?我們注意看,0xC是能被2整除的偶數。如果是不能被2整除的奇數,比如0xD的話,低位的向左第五位必定為1,其它的四位因為函式頭的起始地址處在被2整除的地址上,其它四位也就是低4位都是0,右移2之後一定是4,然後 4+1 等於5。

所以低位向左第五位如果是偶數,則value-1為1,如果是奇數則value-1為5。不能有其它,此處大家可以自行驗證。

關於計算公式參考:https://github.com/dotnet/runtime/blob/main/src/coreclr/vm/codeman.cpp

計算公式二:0的個數的32位索引

標題頭的意思是:以0的個數表示有幾個32

還是按照上面來,此處函式頭的起始地址是:00007FFB098C5EC0。這裡的計算公式略有不同:

value-2 = 28 - (00007FFB098C5EC0 - base) >> 5 & 7 << 2

同樣:
base:表示函式所在記憶體的起始地址
value-2 則是此公式計算的結果

因為此公式右移的是5,而且base最後兩位一般為0。所以只需要看最後一位元組也就是C0即可。

1100 0000 右移5位,結果為0110,也就是6。6&7等於6,6左移2,結果為0x18。十進位制的24。然後28-24 ==4。value-2的結果為4。

公式一計算得出的value-1的值為1。因為C0的C是偶數。所以為1。
公式二計算得出的value-2的值為4。

value = value-1 << value-2
value就是最終函式頭地址:00007FFB098C5EC0在記憶體裡面儲存的形式,二進位制表示就是:0001 0000。十進位制的:16 。十六進位制的:0x10 。

關於計算公式參考:https://github.com/dotnet/runtime/blob/main/src/coreclr/vm/codeman.cpp

中間地址計算動態找出函式頭:

此處中間地址取GC.Collect的地址:00007FFB098C5EE3。

startPos = (00007FFB098C5EE3 - base) >> 5,此處取GC.Collect地址的最後兩位5EE3 >> 5。結果為:startPos = 0x2F7。

首先從記憶體裡面取出公式二里面計算的value值:0x10。然後套用公式二的value-2的計算:

Result = 28 -(00007FFB098C5EE3 - base) >> 5 & 7 << 2
很明顯Result的結果為 0
把tmp = value >> Result 。
結果tmp == 0x10。

 if (tmp)
    {
        startPos--;
        while (!(tmp & NIBBLE_MASK))
        {
            tmp = tmp >> NIBBLE_SIZE;
            startPos--;
        }
        return base + POSOFF2ADDR(startPos, tmp & NIBBLE_MASK);
    }

NIBBLE_MASK:0xf
POSOFF2ADDR: startPos << 5 + (tmp -1 ) << 2

因為tmp為0x10,所以startPos--。 2f7-1 == 2f6 。然後因為 !(tmp & NIBBLE_MASK) 所以 tmp = tmp >> NIBBLE_SIZE; 也就是 tmp == 1。

那麼結果就是 base + 2f6 << 5 + (1 -1) << 2
用n表示未知數 0xnnnnnnnnnnnn5EC0。剛好是函式頭的地址。

此方法適用於任何一箇中間地址動態獲取函式頭地址。

過程

我們在C#原始碼中呼叫GC.Collect會執行以下幾個步驟:
1.GC.Collect()
2.GCScanRoot()
3.EECodeInfo.Init(暫存器Rip)
4.FindMethodCode(暫存器Rip)
5.通過FindMethodCode找到函式頭地址,然後通過函式頭的地址-8。得到的就是EHinfo,DebugInfo,GCinfo,MethodDesc,UwndInfo資訊
6.通過GCinfo找到根物件
7.通過根物件遍歷所有物件
8.在這些物件中找到非存活物件,然後進行回收

這個過程過於複雜,省略了很多與本節主題無關的東西。我們看到FindMethodCode就是獲取到函式頭的地址的函式。

公式一和二的參考如下:

公式一:

void EEJitManager::NibbleMapSetUnlocked(HeapList * pHp, TADDR pCode, BOOL bSet)
{
    CONTRACTL {
        NOTHROW;
        GC_NOTRIGGER;
    } CONTRACTL_END;

    // Currently all callers to this method ensure EEJitManager::m_CodeHeapCritSec
    // is held.
    _ASSERTE(m_CodeHeapCritSec.OwnedByCurrentThread());

    _ASSERTE(pCode >= pHp->mapBase);

    size_t delta = pCode - pHp->mapBase;

    size_t pos  = ADDR2POS(delta);
    DWORD value = bSet?ADDR2OFFS(delta):0;

    DWORD index = (DWORD) (pos >> LOG2_NIBBLES_PER_DWORD);
    DWORD mask  = ~((DWORD) HIGHEST_NIBBLE_MASK >> ((pos & NIBBLES_PER_DWORD_MASK) << LOG2_NIBBLE_SIZE));

    value = value << POS2SHIFTCOUNT(pos);

    PTR_DWORD pMap = pHp->pHdrMap;

    // assert that we don't overwrite an existing offset
    // (it's a reset or it is empty)
    _ASSERTE(!value || !((*(pMap+index))& ~mask));

    // It is important for this update to be atomic. Synchronization would be required with FindMethodCode otherwise.
    *(pMap+index) = ((*(pMap+index))&mask)|value;
}

公式二:

TADDR EEJitManager::FindMethodCode(RangeSection * pRangeSection, PCODE currentPC)
{
    LIMITED_METHOD_DAC_CONTRACT;

    _ASSERTE(pRangeSection != NULL);

    HeapList *pHp = dac_cast<PTR_HeapList>(pRangeSection->pHeapListOrZapModule);

    if ((currentPC < pHp->startAddress) ||
        (currentPC > pHp->endAddress))
    {
        return NULL;
    }

    TADDR base = pHp->mapBase;
    TADDR delta = currentPC - base;
    PTR_DWORD pMap = pHp->pHdrMap;
    PTR_DWORD pMapStart = pMap;

    DWORD tmp;

    size_t startPos = ADDR2POS(delta);  // align to 32byte buckets
                                        // ( == index into the array of nibbles)
    DWORD  offset   = ADDR2OFFS(delta); // this is the offset inside the bucket + 1

    _ASSERTE(offset == (offset & NIBBLE_MASK));

    pMap += (startPos >> LOG2_NIBBLES_PER_DWORD); // points to the proper DWORD of the map

    // get DWORD and shift down our nibble

    PREFIX_ASSUME(pMap != NULL);
    tmp = VolatileLoadWithoutBarrier<DWORD>(pMap) >> POS2SHIFTCOUNT(startPos);

    if ((tmp & NIBBLE_MASK) && ((tmp & NIBBLE_MASK) <= offset) )
    {
        return base + POSOFF2ADDR(startPos, tmp & NIBBLE_MASK);
    }

    // Is there a header in the remainder of the DWORD ?
    tmp = tmp >> NIBBLE_SIZE;

    if (tmp)
    {
        startPos--;
        while (!(tmp & NIBBLE_MASK))
        {
            tmp = tmp >> NIBBLE_SIZE;
            startPos--;
        }
        return base + POSOFF2ADDR(startPos, tmp & NIBBLE_MASK);
    }
}

你也可以直接參考:
https://github.com/dotnet/runtime/blob/main/src/coreclr/vm/codeman.cpp


微信公眾號:jianghupt. QQ群:676817308