Memory.cpp 解析:讀寫記憶體工具函式
本篇定位
這是 Phase 3 的第 2 篇。假設你已讀過 01_什麼是記憶體位址與指標,理解指標和地址的基本概念。 本篇解析
Memory.cpp/Memory.h— 它是登入器裡的「瑞士刀」,提供一組通用的記憶體讀寫函式,讓其他模組不必每次都手寫指標操作。
一、為什麼需要 Memory.cpp?
直接使用 C++ 指標操作記憶體完全可行,但有幾個問題:
// 直接寫法 — 可以,但危險且難讀
*(DWORD*)(0x009F0000 + offset) = newValue;
// 問題1:型別轉換每次都要重寫
// 問題2:offset 錯了會直接 crash,沒有任何提示
// 問題3:遍布全專案的指標魔法數字,難以維護Memory.cpp 解決了這些問題:封裝成通用函式,寫一次、到處呼叫,並加入基本的安全保護。
二、Memory.h:宣告介面
#pragma once
#include <Windows.h>
namespace Memory {
// 從指定地址讀取 T 型別的值
template<typename T>
T Read(DWORD address);
// 向指定地址寫入 T 型別的值
template<typename T>
void Write(DWORD address, T value);
// 修改記憶體保護屬性後寫入(用於寫入程式碼段)
template<typename T>
void WriteProtected(DWORD address, T value);
// 在目標地址寫入 NOP 指令(填 0x90)
void Nop(DWORD address, int size);
// 讀取指標鏈的最終值
DWORD ReadMultiPointer(DWORD baseAddress, std::vector<DWORD> offsets);
}
template<typename T>是什麼?C++ 的「模板(Template)」讓同一個函式可以處理不同型別。 呼叫
Memory::Read<DWORD>(addr)讀 4 byte,Memory::Read<BYTE>(addr)讀 1 byte,Memory::Read<float>(addr)讀浮點數——同一個函式,型別不同。 不需要為每個型別寫不同的函式。
三、Read:讀取記憶體值
template<typename T>
T Memory::Read(DWORD address) {
return *(T*)address;
// (T*)address → 把 address 這個數字當成「指向 T 型別」的指標
// *(T*)address → 去那個地址把 T 大小的資料讀出來
}呼叫範例
// 讀取 DWORD(4 byte)
DWORD someValue = Memory::Read<DWORD>(0x009F0000);
// 讀取 BYTE(1 byte)
BYTE flagByte = Memory::Read<BYTE>(0x009F0004);
// 讀取 float(4 byte,浮點數)
float gameSpeed = Memory::Read<float>(0x00A12345);
// 讀取指標的指標(兩層間接)
DWORD* objPtr = Memory::Read<DWORD*>(0x009F0010);
DWORD hpVal = Memory::Read<DWORD>((DWORD)objPtr + 0x0C);注意:不做邊界檢查
如果
address是無效地址(如nullptr、未映射的頁面),讀取會觸發 Access Violation,導致遊戲崩潰。在呼叫前請確認地址有效。
四、Write:寫入記憶體值
template<typename T>
void Memory::Write(DWORD address, T value) {
*(T*)address = value;
}呼叫範例
// 寫入一個 DWORD
Memory::Write<DWORD>(0x009F0000, 9999);
// 寫入一個 BYTE
Memory::Write<BYTE>(0x009F0004, 0x01); // 設定某個 flag 為 true
// 修改遊戲的解析度寬度(Client.cpp 裡的用法)
Memory::Write<int>(Client::ResolutionWidth_Addr, 1920);五、WriteProtected:繞過記憶體保護寫入程式碼段
這是登入器最常用到的進階函式,因為安裝 Hook 就是在程式碼段裡寫入跳轉指令:
template<typename T>
void Memory::WriteProtected(DWORD address, T value) {
DWORD oldProtect;
// 第一步:把目標記憶體的保護屬性改成「可讀可寫可執行」
VirtualProtect(
(LPVOID)address, // 目標地址
sizeof(T), // 要修改的大小(和我們要寫的資料一樣大)
PAGE_EXECUTE_READWRITE, // 新的保護屬性
&oldProtect // 儲存舊的保護屬性(之後還原用)
);
// 第二步:寫入值
*(T*)address = value;
// 第三步:還原保護屬性
VirtualProtect((LPVOID)address, sizeof(T), oldProtect, &oldProtect);
}為什麼需要 VirtualProtect?
Windows 把記憶體分成不同「保護屬性」的頁面(每頁 4096 byte):
| 頁面類型 | 保護屬性 | 說明 |
|---|---|---|
| 程式碼段(.text) | PAGE_EXECUTE_READ | 只能執行和讀取,不能寫入 |
| 資料段(.data) | PAGE_READWRITE | 可以讀取和寫入 |
| 堆積(Heap) | PAGE_READWRITE | 可以讀取和寫入 |
函式的機器碼位於程式碼段,預設不能寫入。如果我們直接 *(BYTE*)funcAddr = 0xE9(JMP 指令),Windows 會給我們一個 Access Violation。
VirtualProtect 臨時把保護改成可寫 → 寫入 → 再改回去,完成整個操作。
為什麼要還原保護屬性?
留著
PAGE_EXECUTE_READWRITE不還原的話,某些防作弊系統(甚至 Windows 本身的 DEP,Data Execution Prevention)會把「可讀可寫又可執行」的頁面視為可疑。還原保護讓我們的修改看起來更「正常」。
六、Nop:用 NOP 填滿指令(無效化程式碼)
NOP(No Operation)是 x86 的指令 0x90,讓 CPU「空轉一個 cycle,什麼都不做」。
void Memory::Nop(DWORD address, int size) {
DWORD oldProtect;
VirtualProtect((LPVOID)address, size, PAGE_EXECUTE_READWRITE, &oldProtect);
// 把 size 個 byte 全部填成 0x90(NOP)
memset((void*)address, 0x90, size);
VirtualProtect((LPVOID)address, size, oldProtect, &oldProtect);
}NOP 的實際用途
假設遊戲有一段「檢查背包已滿就阻止撿取」的程式碼:
原始機器碼:
0x00401000: 85 C0 TEST eax, eax ; 檢查是否為空
0x00401002: 74 0A JE 0x0040100E ; 如果是,跳到阻止邏輯
用 NOP 填滿 0x00401002 開始的 2 個 byte:
修改後:
0x00401000: 85 C0 TEST eax, eax
0x00401002: 90 90 NOP / NOP ; JE 消失了,程式直線執行不再跳躍
這樣就讓那個條件跳轉永遠不會發生。
在 MapleEzorsia-v2 中,NOP 常用來關閉遊戲的 CRC 完整性檢查,讓被修改過的客戶端能正常運行。
七、ReadMultiPointer:跟隨指標鏈
動態物件的地址每次都不同,必須從已知靜態地址出發,逐層追蹤:
DWORD Memory::ReadMultiPointer(DWORD baseAddress, std::vector<DWORD> offsets) {
DWORD addr = Memory::Read<DWORD>(baseAddress); // 讀第一層指標的值
for (size_t i = 0; i < offsets.size() - 1; i++) {
addr = Memory::Read<DWORD>(addr + offsets[i]); // 加上偏移,讀下一層
}
return addr + offsets.back(); // 最後一個 offset 加上去,得到目標地址
}實際呼叫範例
假設角色 HP 的位置需要三層指標:
靜態基址:0x00C4EB48
→ 讀出:0x02A5F000(第一層物件指標)
→ + 0x44 → 0x02A5F044
→ 讀出:0x03BC0200(第二層物件指標)
→ + 0x0C → 得到 HP 值的地址
DWORD hpAddress = Memory::ReadMultiPointer(
0x00C4EB48, // 靜態基址
{ 0x44, 0x0C } // 偏移清單(最後一個是終點偏移)
);
DWORD hp = Memory::Read<DWORD>(hpAddress); // 讀 HP 值八、整體架構圖
graph TD A["其他模組<br/>(Client.cpp / dllmain.cpp)"] -->|呼叫| B[Memory::Read<T>] A -->|呼叫| C[Memory::Write<T>] A -->|呼叫| D[Memory::WriteProtected<T>] A -->|呼叫| E[Memory::Nop] A -->|呼叫| F[Memory::ReadMultiPointer] D -->|臨時解除保護| G[VirtualProtect] D -->|寫入| H[目標記憶體地址] G -->|還原保護| H E -->|填 0x90| H B -->|直接讀取| H C -->|直接寫入| H
九、常見問題
Q:為什麼用
DWORD而不是int表示地址?
DWORD是 Windows 定義的「32 位元無符號整數(unsigned 32-bit integer)」,地址不可能是負數,用無符號更安全。int是有符號整數,理論上可以表示負數,用來放地址容易在邊界情況出問題。
Q:模板函式定義要放在 .h 裡嗎?
是的。C++ 模板函式必須在標頭檔中定義(或 include 對應的 .inl/.tpp 檔),因為編譯器需要在看到呼叫點時就知道完整實作才能展開。如果只在 .cpp 裡定義,其他 .cpp include 時只看到宣告,連結階段會報「未定義符號」錯誤。
Q:WriteProtected 是執行緒安全的嗎?
不是。如果兩個執行緒同時呼叫 WriteProtected 對同一段記憶體操作,可能造成保護屬性被覆蓋或資料競爭。在登入器啟動時(單一執行緒初始化階段)使用最安全,避免在多執行緒環境中並發呼叫。
延伸閱讀
- 01_什麼是記憶體位址與指標 — 本篇的前置知識
- 03_AddyLocations.h_地址表解析 — 提供給 Memory 函式使用的地址常數
- 01_什麼是函式掛鉤(Hooking) — Memory::WriteProtected 是安裝 Hook 的核心工具
- 02_dllmain.cpp_DLL入口點 — 了解 Memory 函式在哪些地方被呼叫