GC是CLR里面一个重要的模块,跟上一篇:NET9异常(CLR)原理(顶阶技术)里面介绍的异常模块一样,属于CLR里的顶阶技术。它管控托管堆的分配,销毁。.NET9 GC同样有所改进,本篇看下GC标记的原理。
简单的例子:
public class Program
{
static void Main(string[] args)
{
try
{
throw new Exception();
}
catch(Exception ex)
{
Console.WriteLine(ex.Message);
}
GC.Collect();
Console.ReadLine();
}
}
try里面new了一个Exception,这种分配的堆必然会产生一个对象地址值,此值会被放到rbp或者rsp所在的偏移地址。后面有一个GC.Collect()进行垃圾回收,标记阶段会标记此处栈空间的实例对象,用以观察此处new的实例。ASM如下:
00007FFF1335F740 55 push rbp
//此处省略,便于观看
00007FFF1335F771 48 B9 38 88 2B 13 FF 7F 00 00 mov rcx,7FFF132B8838h
00007FFF1335F77B E8 30 B4 D5 5E call JIT_TrialAllocSFastMP_InlineGetThread (07FFF720BABB0h)
00007FFF1335F780 48 89 45 F0 mov qword ptr [rbp-10h],rax
00007FFF1335F784 48 8B 4D F0 mov rcx,qword ptr [rbp-10h]
00007FFF1335F788 FF 15 82 80 F3 FF call qword ptr [7FFF13297810h]
00007FFF1335F78E 48 8B 4D F0 mov rcx,qword ptr [rbp-10h]
00007FFF1335F792 E8 F9 A3 8E 5E call IL_Throw (07FFF71C49B90h)
00007FFF1335F797 CC int 3
00007FFF1335F798 FF 15 72 68 51 00 call qword ptr [7FFF13876010h]
00007FFF1335F79E 90 nop
00007FFF1335F79F FF 15 63 76 93 00 call qword ptr [7FFF13C96E08h]
00007FFF1335F7A5 48 89 45 D8 mov qword ptr [rbp-28h],rax
00007FFF1335F7A9 90 nop
00007FFF1335F7AA 90 nop
00007FFF1335F7AB 48 83 C4 50 add rsp,50h
00007FFF1335F7AF 5D pop rbp
00007FFF1335F7B0 C3 ret
地址00007FFF1335F77B处对new Exception进行了快速路径实例分配,结果存储到[rbp-10h]的内存处。而这里的-10h等于16进制的0xFFFFFFF0。GC标记阶段,会从GCInfo里面取到[rbp-10h]这个地址处的实例对象进行标记。而在此之前,首先要做的就是对0xFFFFFFF0进行加密形式的内存存储。
JIT在对C#代码进行编译的时候,会进行空间的分配。比如rbp-10h这个-10h空间。这个空间怎么来的呢?
JIT有一个编译类Compiler,此类里面包含了lvaTable变量,它是一个LclVarDsc结构体。LclVarDsc里面包含了lvStkOffs私有字段,这个字段即是分配这个空间的。
class LclVarDsc
{
private int:
lvStkOffs;
}
class Compiler
{
LclVarDsc varDesc;
}
可以通过以下函数定位lvStkOffs,看它何时被赋值,如何赋值
void SetStackOffset(int offset)
{
lvStkOffs = offset;
}
//小技巧提示,一般来说CLR里面的给某某字段赋值都是setXXX这种形式
//如果不是这种形式,可以通过值更改的断点方式查找某个字段何时被赋值,如何赋值
GcSlotId GcInfoEncoder::GetStackSlotId( INT32 spOffset, GcSlotFlags flags, GcStackSlotBase spBase )
{
#ifdef _DEBUG
_ASSERTE( !m_IsSlotTableFrozen );
#endif
if( m_NumSlots == m_SlotTableSize )
{
GrowSlotTable();
}
_ASSERTE( m_NumSlots < m_SlotTableSize );
_ASSERTE(GC_SP_REL != spBase || spOffset >= 0);
_ASSERTE( (flags & (GC_SLOT_IS_REGISTER | GC_SLOT_IS_DELETED)) == 0 );
if (!(TargetOS::IsApplePlatform && TargetArchitecture::IsArm64))
{
_ASSERTE((spOffset % TARGET_POINTER_SIZE) == 0);
}
m_SlotTable[ m_NumSlots ].Slot.Stack.SpOffset = spOffset;
m_SlotTable[ m_NumSlots ].Slot.Stack.Base = spBase;
m_SlotTable[ m_NumSlots ].Flags = flags;
GcSlotId newSlotId;
newSlotId = m_NumSlots++;
return newSlotId;
}
上面的偏移0xFFFFFFF0是如何加密到GCInfo的呢?先来看一段代码:
//gcinfoencoder.cpp:1700
currentNormStackSlot = NORMALIZE_STACK_SLOT(pSlotDesc->Slot.Stack.SpOffset)
//小技巧提示:以上字段SpOffset因为在gcinfoencoder.cpp里面有多个,可以把所有包含SpOffset都打上条件断点(注意是条件断点)进行观察。
//而不是只打某一个或者几个(需要全部),这样可能观察不到。
pSlotDesc->Slot.Stack.SpOffset即是0xFFFFFFF0,NORMALIZE_STACK_SLOT是把SpOffset右移3位结果是:0x1ffffffe,但CLR里面的这个结果是:0xfffffffe,注意看最高位是1和f的区别,什么原因,目前没有发现,此处记录下。但不影响GCInfo的结果。
此后会通过以下代码把0xfffffffe写入到GCInfo
//gcinfoencoder.cpp:1707
GCINFO_WRITE_VARL_S(m_Info1, currentNormStackSlot, STACK_SLOT_ENCBASE, UntrackedSlotSize);
注意参数里面的宏STACK_SLOT_ENCBASE值为6,后面需要用到这个为6的值。GCINFO_WRITE_VARL_S宏调用了函数EncodeVarLengthSigned如下:
int BitStreamWriter::EncodeVarLengthSigned( SSIZE_T n, UINT32 base )
{
_ASSERTE((base > 0) && (base < BITS_PER_SIZE_T));
size_t numEncodings = size_t{ 1 } << base;
for(int bitsUsed = base+1; ; bitsUsed += base+1)
{
size_t currentChunk = ((size_t) n) & (numEncodings-1);
size_t topmostBit = currentChunk & (numEncodings >> 1);
n >>= base; // signed arithmetic shift
if((topmostBit && (n == (SSIZE_T)-1)) || (!topmostBit && (n == 0)))
{
// The topmost bit correctly represents the sign
Write( currentChunk, base+1 ); // This sets the extension bit to zero
return bitsUsed;
}
else
{
Write( currentChunk | numEncodings, base+1 );
}
}
}
经过上面的各种位计算,写入的结果是Write( currentChunk, base+1 )这一行代码,写入的值即是currentChunk==0x000000000000003e,写入的bit位数base(6)+1==7。也就是说,0xfffffffe会加密成了0x000000000000003e,占据7个bit位,写入到GCInfo。
但此处加密还没完,Write函数又进行了位计算
void BitStreamWriter::Write( size_t data, UINT32 count )
{
//为了便于观察,此处省略部分代码
WriteInCurrentSlot( data, count );
m_FreeBitsInCurrentSlot -= count;
}
Write函数里面调用了WriteInCurrentSlot,它这里面又进行了位计算。
inline void WriteInCurrentSlot( size_t data, UINT32 count )
{
data &= ((size_t)-1 >> (BITS_PER_SIZE_T - count));
data <<= (BITS_PER_SIZE_T - m_FreeBitsInCurrentSlot);
*m_pCurrentSlot |= data;
}
m_FreeBitsInCurrentSlot代表的是当前的64个bit位里面还剩余多少个bit位。传入的参数data值是0x000000000000003e,经过位计算,最终的代码: *m_pCurrentSlot |= data;此时的data为0x00f8000000000000。也就是说最终会把0x00f8000000000000这个数值写入到m_pCurrentSlot地址所指向的值里面相应的bit位上。
那么GC标记的时候,就会通过解码的形式把0x00f8000000000000这个数值给它解码成0xFFFFFFF0,以获取到new Exception实例的地址。
上面JIT对new Exception对象地址的地址进行了GC加密,在调用例子里面的GC.Collect的时候,会对GC进行解密。比如本例通过解码的形式把0x00f8000000000000这个数值给它解码成0xFFFFFFF0,以获取到new Exception实例的地址,看下此过程。
看下解码的代码,这里的slotIndex==6。也就是上面所说的宏STACK_SLOT_ENCBASE值为6。实际上是通过STACK_SLOT_ENCBASE占据的bit位数取到值。从GCInfo bit位获取。ReportStackSlotToGC函数:
const GcSlotDesc* pSlot = slotDecoder.GetSlotDesc(slotIndex);
GetSlotDesc函数如下:
const GcSlotDesc* GcSlotDecoder::GetSlotDesc(UINT32 slotIndex)
{
//为了便于观察,这个函数里面省略了很多。它其实是一个循环。
INT32 normSpOffsetDelta = (INT32) m_SlotReader.DecodeVarLengthUnsigned(STACK_SLOT_DELTA_ENCBASE);
INT32 normSpOffset = normSpOffsetDelta + NORMALIZE_STACK_SLOT(m_pLastSlot->Slot.Stack.SpOffset);
m_pLastSlot->Slot.Stack.SpOffset = DENORMALIZE_STACK_SLOT(normSpOffset);
return m_pLastSlot;
}
pSlot->Slot.Stack.SpOffset里面包含的就是-10h也即是0xFFFFFFF0,为寄存器rbp的偏移量。
因为new Exception对象的地址在[rbp-10h]处,上面的GC解码获取到了-10h偏移但是没有获取到rbp寄存器的值,所以这里依然需要获取它。
代码:
ULONGLONG **ppRax = &pRD->pCurrentContextPointers->Rax;
return (OBJECTREF*)*(ppRax + regNum);
pRD在GC的时候进行栈帧的遍历获取的
StackWalkAction Thread::StackWalkFramesEx(
PREGDISPLAY pRD, // virtual register set at crawl start
PSTACKWALKFRAMESCALLBACK pCallback,
VOID *pData,
unsigned flags,
PTR_Frame pStartFrame
)
{
StackFrameIterator iter;
if (iter.Init(this, pStartFrame, pRD, flags) == TRUE)
{
while (iter.IsValid())
{
retVal = MakeStackwalkerCallback(&iter.m_crawl, pCallback, pData DEBUG_ARG(iter.m_uFramesProcessed));
if (retVal == SWA_ABORT)
{
break;
}
retVal = iter.Next();
if (retVal == SWA_FAILED)
{
break;
}
}
}
SET_THREAD_TYPE_STACKWALKER(pStackWalkThreadOrig);
}
return retVal;
}
函数MakeStackwalkerCallback的参数&iter.m_crawl是CrawlFrame类型,CrawlFrame里面包含了pRD。pRD->pCurrentContextPointers->Rax意即当前的栈帧的排列Rax寄存器相对于pCurrentContextPointers的偏移地址。+5取值(*(ppRax + regNum),regNum==5)转换成OBJECTREF*。对这个结果进行取值+spOffset,最终的结果就是new Exception对象的地址
pObjRef = (OBJECTREF*)(*pFrameReg + spOffset)
也即是说rbp寄存器的值,根据当前栈帧的Rax偏移地址进行取值。这个值加上上面解码的偏移值0xFFFFFFF0,最终就找到了new Exception对象的地址。然后对它进行一个标记。
取到了最终的new Exception对象地址之后,就可以获取它的MethodTable,
MethodTable *RawGetMethodTable() const
{
return m_pMethTab;
}
然后把m_pMethTab的最后一位赋值为1,即为标记
#define GC_MARKED (size_t)0x1
void SetMarked()
{
_ASSERTE(RawGetMethodTable());
RawSetMethodTable((MethodTable *) (((size_t) RawGetMethodTable()) | GC_MARKED));
}
结尾
上面的东西较多,这里依然需要梳理下。JIT进行编译的时候,会计算出实例化对象地址的偏移量,比如-10h。然后把这个偏移量进行加密存入到GCInfo。在GC垃圾回收的时候,从GCInfo里面取出偏移量的加密位进行解码。然后找到rbp寄存器所在的地址,两者进行相加,结果就是new Excepiton对象的地址。此后对对象进行一个标记。一般的观察通过ReportStackSlotToGC函数进行倒推即可。
如下流程: