写在前面:一款光鲜酷炫的游戏画面背后,离不开一套稳定可靠的底层基础框架✨。很多开发者埋头钻研图形渲染、着色器,却忽略了引擎的 "地基" 部分。日志、时间、线程、文件 IO、内存管理这些看似平淡无奇的模块,恰恰决定了引擎的稳定性、跨平台能力以及排错效率。本文基于游戏引擎底层实现思路,聊聊游戏引擎基础系统该如何设计,重点剖析游戏引擎中至关重要的内存泄漏检测内存管理器。
Bilibili 同步视频
一、引擎底层基础系统能干什么?
游戏引擎底层基础系统,本质是对操作系统能力做一层抽象封装。不同操作系统(Windows、Android、iOS)提供的系统 API 各不相同,如果上层业务直接调用平台原生接口,同样一套游戏逻辑就要写多套适配代码,维护成本会爆炸📈。
我们从各个平台抽象出通用能力集合:
-
基础能力:图像显示、音频播放、输入输出、网络通信
-
并发能力:线程创建、线程同步控制
-
工具系统:日志打印、游戏时间统计、数学库、容器数据结构
-
资源操作:内存分配释放、文件读写、图片解析
通过封装隔离平台差异,对外输出一套统一接口。上层渲染、物理、游戏逻辑模块完全不需要关心底层跑在什么操作系统上。
开发环境与编译版本小科普
示例引擎开发环境选用 Visual Studio 2017,内置 DirectX 开发套件。引擎一般会区分多种编译版本,不同版本取舍调试信息、运行性能:
| 版本 | 特性说明 |
|---|---|
| Debug | 完整调试信息,包含日志、断言,运行速度慢,用于开发调试 |
| Test | 兼顾调试信息与运行效率,部分项目用于内部测试 |
| Release | 保留控制台、日志等调试工具,开启部分代码优化 |
| Shipping | 最终发行纯净版本,移除调试组件,最大化运行性能,对外发布游戏使用 |
小提示:工程编译输出文件存放于
bin目录,文件名后缀带d代表 Debug 版本。开发可以搭配 VA AssistX 插件快速检索代码文件,Everything 工具检索磁盘资源,大幅提升开发效率。
二、VSSystem 工程:底层工具模块集合
VSSystem是这套引擎的底层核心工程,全部封装在命名空间VSEngine2下,内部由一个个职责单一工具类组成,全部做平台隔离,跨平台编译依靠条件编译宏区分 Windows、移动端实现。
📦 各个核心类职责一览
| 类名 | 核心功能 |
|---|---|
| VSTimer | 游戏时间管理,获取毫秒时间、统计 FPS 帧率 |
| VSThread | 线程基类,封装线程创建、启停,管理线程优先级、运行状态 |
| VSSynchronize | 线程同步组件:临界区、互斥锁、信号量、事件对象 |
| VSSystem | 全局工具函数:内存拷贝、字符串处理等通用工具 |
| VSMemManager | 内存管理器基类,定义内存分配释放抽象接口 |
| VSLog | 日志输出,继承 VSFile,把日志格式化写入磁盘文件 |
| VSFile | 文件读写底层封装:打开、读写、移动文件指针、刷新缓冲区 |
| VSImage | 图片解析抽象基类,派生支持 BMP、TGA 图片加载 |
| VSSingleton | 通用 C++ 单例模板类 |
1. VSSystem:封装安全内存拷贝
不同平台内存拷贝函数行为存在差异,直接调用系统 API 存在越界风险,所以做一层封装,示例代码:
cpp
inline bool VSemcpy (void *pDest,const void *pSrc, unsigned int uiCountSize,
unsigned int uiDestBufferSize=0)
{
//入参合法性校验
if(!pDest || !pSrc || !uiCountSize)
return false;
//未指定目标缓冲区大小,则默认等于拷贝字节数
if(!uiDestBufferSize)
uiDestBufferSize=uiCountSize;
//调用微软安全版memcpy_s,返回0代表拷贝成功
return (memcpy_s (pDest,uiDestBufferSize,pSrc,uiCountSize)==0);
}
关键说明:
memcpy_s会校验目标缓冲区边界,防止内存越界,跨平台移植时我们可以替换对应平台安全拷贝实现,上层调用代码完全不用改动。
2. VSTimer 游戏时间计时器
游戏离不开时间系统,我们需要获取游戏运行时长、每帧时间切片、FPS 帧率。Windows 下提供两套高精度时间 API:
-
QueryPerformanceFrequency:高精度性能计数器,精度极高; -
timeGetTime:毫秒级定时器,精度相对低,兼容性好。
类核心伪代码结构:
cpp
class VSSYSTEM API VSTimer
{
bool m_bUseLargeTime; //是否启用高精度计时器
int64 m_int64OneSecondTicks; //1秒对应的滴答计数
int64 m_int64TimeTickStartCounts; //启动时滴答计数值
unsigned long m_ulTimeStart; //timeGetTime起始时间
int m_iFrameCount;
double m_fFPS; //帧率FPS
double m_fTime,m_fLastTime,m_fTimeSlice; //游戏时间、上一帧时间、帧间隔
public:
void InitGameTime(); //游戏启动,初始化时间系统
double GetGamePlayTime(); //获取游戏已经运行的时间
void UpdateFPS(); //每帧调用,更新FPS统计
static VSTimer *ms_pTimer;
};
使用流程:游戏启动调用InitGameTime(),游戏主循环每一帧执行UpdateFPS(),即可拿到帧间隔与帧率数据。
3. VSFile & VSLog 文件与日志系统
VSFile封装磁盘文件全部基础操作:打开关闭、读写字节、移动文件指针、读取一行文本、刷新缓冲区。 VSLog继承VSFile,在文件读写能力之上,增加格式化日志输出,游戏运行中各类警告、错误信息都可以持久化保存到日志文件,程序崩溃之后可以通过日志回溯问题。
cpp
class VSSYSTEM API VSLog:public VSFile
{
public:
bool Open (const TCHAR *pFileName);
//可变参数格式化写日志
bool WriteInfo(const TCHAR *pcString,...)const;
};
4. VSThread & VSSynchronize 多线程模块
游戏引擎大量工作需要多线程处理:资源加载、物理计算、异步 IO。 VSThread作为线程基类,使用者继承并重写虚函数Run()实现线程业务逻辑。
线程有 3 种状态:TS_START运行、TS_SUSPEND挂起、TS_STOP停止,同时支持Low/Normal/High三种优先级。
-
构造函数创建线程,默认处于挂起状态;
-
调用
Start(),内部静态回调函数ThreadProc会执行Run(); -
调用
Stop(),通过内部VSEvent事件对象通知线程安全退出。
VSSynchronize封装 Windows 四种同步原语:临界区、互斥量、信号量、事件,解决多线程资源竞争问题。
5. VSImage 图片加载抽象
定义抽象基类VSImage,定义统一接口Load(从文件加载)、LoadFromBuffer(从内存缓存加载)、GetPixel获取像素。派生VSBMPImage、VSTGAImage分别解析 BMP、TGA 图片。
面向抽象编程的好处:后续新增 PNG、JPG 支持,只需要新增派生类,上层调用加载图片的代码不需要修改。
三、重头戏:游戏引擎内存管理设计💥
很多 C++ 游戏项目血泪教训:直接裸用系统
new/delete,到项目中后期会频繁遇到莫名崩溃、内存持续上涨。多人协作项目中,内存泄漏、野指针、缓冲区越界,属于疑难杂症,很多问题只在特定游戏场景触发,复现极其困难。
引擎自研内存管理器,两大核心目标:
-
提升内存分配释放性能,减少内存碎片;
-
检测内存泄漏、越界写、野指针,统计各个模块内存占用,定位内存问题根源。
内存管理器基类
抽象分配、释放接口,Debug 模式下使用调试内存管理器,Release 模式替换高性能内存管理器:
cpp
class VSSYSTEM API VSMemManager
{
public:
//分配内存:size大小,alignment字节对齐,bIsArray标记是否数组内存
virtual void *Allocate (unsigned int uiSize, unsigned int uiAlignment,bool bIsArray)=0;
//释放内存
virtual void Deallocate (char *pcAddr, unsigned int uiAlignment,bool bIsArray) =0;
};
Debug 内存检测管理器 VSDebugMem 实现原理
Debug 版本内存管理器VSDebugMem,核心思路:给每一块分配出去的内存加上元信息头、前后魔数保护边界,用双向链表记录全部内存块,同时捕获调用堆栈。
Mermaid 示意图展示内存块完整布局:

图表说明:每一次内存申请,实际分配内存 = Block 元信息 + BeginMask 标记 + 用户内存 + EndMask 标记。前后
0xDEADCODE魔数作为保护哨兵。一旦业务代码发生缓冲区越界写,就会覆盖魔数;释放内存的时候校验魔数不匹配,直接断言报错,立刻捕获越界 BUG。
所有内存块依靠 Block 的m_pPrev、m_pNext串联双向链表,管理器只维护链表头指针m_pHead、尾指针m_pTail,遍历链表即可找出程序退出还没有释放的内存块,定位内存泄漏。
Block 结构体,保存每一块内存完整溯源信息:
cpp
class Block
{
public:
void *pAddr[CALLSTACK_NUM]; //申请内存时的函数调用堆栈地址
unsigned int m_uiStackInfoNum; //堆栈层数
unsigned int m_uiSize; //申请内存字节大小
bool m_bIsArray; //是否数组内存
bool m_bAlignment; //是否开启字节对齐
Block *m_pPrev; //双向链表前驱
Block *m_pNext; //双向链表后继
};
Allocate 分配逻辑流程
-
计算扩展总内存大小(Block + 开始魔数 + 用户内存 + 结束魔数);
-
调用底层
malloc申请整块内存; -
填充 Block 元信息,捕获当前函数调用堆栈;
-
将 Block 节点插入双向链表;
-
填充前后 BeginMask、EndMask 魔数;
-
返回用户可用内存起始地址(注意:返回指针跳过 Block 与 BeginMask)。
Deallocate 释放逻辑流程
-
根据传入用户指针,回退偏移找到 BeginMask 魔数,校验魔数完整性;
-
继续向前偏移拿到 Block 元信息头;
-
校验 Block 标记(是否数组标记、对齐标记,防止
new[]搭配普通delete错误); -
校验尾部 EndMask 魔数是否被篡改;
-
将 Block 从双向链表移除;
-
调用 free 释放整块内存。
⚠️重点提示:生产 Debug 内存管理器代码还需要补充多线程锁保护(多线程同时分配内存会破坏双向链表)、完整的统计计数:new 次数、delete 次数、现存内存块、历史峰值内存大小。
获取调用堆栈,定位泄漏代码位置
光发现内存泄漏还不够,我们需要知道是哪一行代码申请内存没有释放 。Windows 平台借助系统组件dbghelp.dll,这套 DLL 提供符号解析能力:输入函数调用地址,可以反推出源代码文件名、行号。
核心用到几个导出函数:
-
SymInitialize:初始化符号解析环境; -
StackWalk64:遍历线程调用栈; -
SymGetLineFromAddr64:根据指令地址,获取源代码文件与行号。
获取栈帧的底层原理简单聊聊: C/C++ 函数调用的时候,栈上会保存栈基址ebp,栈帧结构层层嵌套。每一个栈帧的 ebp 保存上一层调用函数 ebp,ebp+4 存储函数返回地址(也就是上层调用这一个函数的指令地址)。我们通过汇编读取寄存器 ebp,循环向上遍历,就拿到完整函数调用链地址。
简化获取栈帧示例代码:
cpp
DWORD _ebp,_esp;
//内联汇编读取寄存器ebp、esp
_asm mov _ebp, ebp;
_asm mov _esp, esp;
for (unsigned int index=0;index < CALLSTACK_NUM;index++)
{
//取出返回地址
void *pAddr=(void*) ULongToPtr(*(((DWORD*) ULongToPtr(_ebp))+1));
if(!pAddr) break;
pBlock->pAddr[index] = pAddr;
pBlock->m_uiStackInfoNum++;
//向上一层栈帧
_ebp=*(DWORD*) ULongToPtr(_ebp);
}
拿到一堆指令地址后,交给 dbghelp.dll 接口,翻译成可读的文件名、代码行号,当检测内存泄漏时,直接打印堆栈,开发者就知道哪段代码忘记释放内存。
四、总结
游戏引擎底层基础系统就像大楼地基,平时业务开发很少直接关注,但是一旦地基存在缺陷,上层所有模块都会遭殃😱。
-
通过封装操作系统 API,提供跨平台统一接口,隔离 Windows、Android、iOS 平台差异;
-
时间、线程同步、文件日志、图片解析,构成引擎最基础工具集;
-
Debug 内存管理器依靠双向链表 + 前后魔数保护 + 调用堆栈捕获,高效定位内存泄漏、缓冲区越界;
-
dbghelp.dll符号解析,把机器指令地址还原为源代码位置,给排错提供强有力支撑。

拓展思考:上面这套内存管理器只适合 Debug 调试,追求运行性能的 Release 版本,不会记录完整堆栈、不会每块内存增加大量额外元数据,会采用内存池、分级分配器方案,在分配速度、内存碎片做优化。