ZAllocEx 完整解析(標頭與實作)

前情提要

02 介紹了 ZAllocEx.cpp 的實作邏輯。但那只是「實作檔」——MapleStory 的記憶體分配系統還有一整套標頭體系。本篇補全這個架構。


分配器繼承鏈

ZAllocBase                  ← 定義 Block 大小 enum 與 AllocRawBlocks()
    ↑
ZAllocAnonSelector          ← 一般物件的 GetBlockSize() 策略
ZAllocStrSelector<T>        ← 字串物件的 GetBlockSize() 策略(考慮 ZXStringData 開銷)
    ↑
ZAllocEx<ZAllocAnonSelector>      ← 一般物件記憶體池(單例)
ZAllocEx<ZAllocStrSelector<T>>    ← 字串記憶體池(單例)

ZAllocBase — 原始記憶體區塊分配

ZAllocBase.h 只做一件事:向作業系統申請一整排記憶體區塊,並把它們串成鏈結串列

enum BLOCK_SIZE {
    BLOCK16  = 0,   // 16 bytes
    BLOCK32  = 1,   // 32 bytes
    BLOCK64  = 2,   // 64 bytes
    BLOCK128 = 3,   // 128 bytes
};
 
static PVOID* AllocRawBlocks(UINT uBlockSize, UINT uNumberOfBlocks);

AllocRawBlocks 的記憶體版面:

[前置大小] [鏈結串列頭] [第0塊大小] [第0塊資料] [第1塊大小] [第1塊資料] ...

每個區塊的「前一個地址」存放本區塊大小,這是 Free() 能正確識別大小的關鍵。


ZAllocAnonSelector vs ZAllocStrSelector

兩者都提供 GetBlockSize(nIndex, &nAllocBlocks) 但策略不同:

分配器BLOCK16 的實際大小用途
ZAllocAnonSelector16 bytes一般物件
ZAllocStrSelector<char>16 + sizeof(_ZXStringData) + 1 byteschar 字串
ZAllocStrSelector<wchar_t>32 + sizeof(_ZXStringData) + 2 byteswchar_t 字串

字串分配器多算了 _ZXStringData(含 nRef、nCap、nByteLen 三個欄位)的開銷,因為字串的記憶體版面是:

[_ZXStringData header][actual string data][null terminator]

ZAllocEx 標頭完整結構

ZAllocEx.h 定義了兩個特化版本,都是單例(Singleton)

// 取得一般物件分配器實例
ZAllocEx<ZAllocAnonSelector>::GetInstance()->Alloc(size);
ZAllocEx<ZAllocAnonSelector>::GetInstance()->Free((void**)ptr);
 
// 取得字串分配器實例
ZAllocEx<ZAllocStrSelector<char>>::GetInstance()->Alloc(size);
ZAllocEx<ZAllocStrSelector<wchar_t>>::GetInstance()->Alloc(size);

成員變數(記憶體版面)

BYTE gap0[1];          // 對齊 padding(保持與 MapleStory 原版 struct 一致)
ZFatalSection m_lock;  // 執行緒鎖(僅保留版面,實際用 std::mutex)
LPVOID m_apBuff[4];    // 4 個大小等級各自的可用記憶體頂端指標
LPVOID m_apBlockHead[4]; // 4 個大小等級各自已分配區塊鏈結串列頭

四格記憶體池運作方式

m_apBuff[BLOCK16] ──→ [可用區塊] ──→ [可用區塊] ──→ nullptr
m_apBuff[BLOCK32] ──→ [可用區塊] ──→ nullptr
m_apBuff[BLOCK64] ──→ nullptr   ← 此等級已滿,下次 Alloc 會呼叫 AllocRawBlocks
m_apBuff[BLOCK128]──→ [可用區塊]

Alloc 流程(4 個步驟):

  1. 判斷請求大小屬於哪個等級(16/32/64/128)
  2. 若超過 128 bytes → 直接 HeapAlloc(大物件不走池子)
  3. m_apBuff[等級] 為 null → 呼叫 AllocRawBlocks 補充
  4. 從堆頂取出一個可用區塊,更新 m_apBuff 為下一個

Free 流程:

  1. 讀取 *(ptr - 1) 得知原始大小(MSB 為 1 時取反得真實大小)
  2. 若大於 128 → HeapFree 直接釋放
  3. 否則把該區塊推回對應等級的 m_apBuff 堆頂

為什麼不真正釋放記憶體?

記憶體池的設計哲學:Free 不歸還給 OS,而是放回自己的可用堆。下次 Alloc 同大小物件時直接從堆頂取,速度比 HeapAlloc 快很多。這就是 MapleStory 高頻物件(如封包、字串)能高效運作的原因。


全域 new/delete 覆蓋

ZAllocEx.h 末尾宣告了全域覆蓋:

void* operator new(size_t uSize);
void* operator new[](size_t uSize);
void operator delete(void* p);
void operator delete[](void* p);

實作在 ZAllocEx.cpp(Phase 6 已介紹)。這意味著:專案內所有 new 都走 ZAllocEx<ZAllocAnonSelector>,讓 DLL 分配的物件與 MapleStory 的記憶體管理系統相容。


ZFatalSection — 執行緒鎖

struct ZFatalSectionData {
    void* _m_pTIB;  // Thread Information Block 指標
    int   _m_nRef;  // 引用計數
};
struct ZFatalSection : ZFatalSectionData { /* TODO emulate */ };

MapleStory 原版用 ZFatalSection 做執行緒同步。Ezorsia 保留這個結構只是為了維持正確的記憶體版面大小(0x8 bytes),實際鎖定功能改用 std::mutex

struct 大小驗證

assert_size(sizeof(ZAllocEx<ZAllocAnonSelector>), 0x2C) — 整個分配器結構必須是 44 bytes,才能與 MapleStory 的原版結構對齊。


相關筆記