游戏引擎原理与实践 04:拆解引擎基础系统与内存管理

写在前面:一款光鲜酷炫的游戏画面背后,离不开一套稳定可靠的底层基础框架✨。很多开发者埋头钻研图形渲染、着色器,却忽略了引擎的 "地基" 部分。日志、时间、线程、文件 IO、内存管理这些看似平淡无奇的模块,恰恰决定了引擎的稳定性、跨平台能力以及排错效率。本文基于游戏引擎底层实现思路,聊聊游戏引擎基础系统该如何设计,重点剖析游戏引擎中至关重要的内存泄漏检测内存管理器。

Bilibili 同步视频

游戏引擎原理与实践 04:拆解引擎基础系统与内存管理

一、引擎底层基础系统能干什么?

游戏引擎底层基础系统,本质是对操作系统能力做一层抽象封装。不同操作系统(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:

  1. QueryPerformanceFrequency:高精度性能计数器,精度极高;

  2. 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,到项目中后期会频繁遇到莫名崩溃、内存持续上涨。多人协作项目中,内存泄漏、野指针、缓冲区越界,属于疑难杂症,很多问题只在特定游戏场景触发,复现极其困难。

引擎自研内存管理器,两大核心目标:

  1. 提升内存分配释放性能,减少内存碎片;

  2. 检测内存泄漏、越界写、野指针,统计各个模块内存占用,定位内存问题根源。

内存管理器基类

抽象分配、释放接口,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 分配逻辑流程

  1. 计算扩展总内存大小(Block + 开始魔数 + 用户内存 + 结束魔数);

  2. 调用底层malloc申请整块内存;

  3. 填充 Block 元信息,捕获当前函数调用堆栈;

  4. 将 Block 节点插入双向链表;

  5. 填充前后 BeginMask、EndMask 魔数;

  6. 返回用户可用内存起始地址(注意:返回指针跳过 Block 与 BeginMask)。

Deallocate 释放逻辑流程

  1. 根据传入用户指针,回退偏移找到 BeginMask 魔数,校验魔数完整性;

  2. 继续向前偏移拿到 Block 元信息头;

  3. 校验 Block 标记(是否数组标记、对齐标记,防止new[]搭配普通delete错误);

  4. 校验尾部 EndMask 魔数是否被篡改;

  5. 将 Block 从双向链表移除;

  6. 调用 free 释放整块内存。

⚠️重点提示:生产 Debug 内存管理器代码还需要补充多线程锁保护(多线程同时分配内存会破坏双向链表)、完整的统计计数:new 次数、delete 次数、现存内存块、历史峰值内存大小。

获取调用堆栈,定位泄漏代码位置

光发现内存泄漏还不够,我们需要知道是哪一行代码申请内存没有释放 。Windows 平台借助系统组件dbghelp.dll,这套 DLL 提供符号解析能力:输入函数调用地址,可以反推出源代码文件名、行号。

核心用到几个导出函数:

  1. SymInitialize:初始化符号解析环境;

  2. StackWalk64:遍历线程调用栈;

  3. 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 接口,翻译成可读的文件名、代码行号,当检测内存泄漏时,直接打印堆栈,开发者就知道哪段代码忘记释放内存。

四、总结

游戏引擎底层基础系统就像大楼地基,平时业务开发很少直接关注,但是一旦地基存在缺陷,上层所有模块都会遭殃😱。

  1. 通过封装操作系统 API,提供跨平台统一接口,隔离 Windows、Android、iOS 平台差异;

  2. 时间、线程同步、文件日志、图片解析,构成引擎最基础工具集;

  3. Debug 内存管理器依靠双向链表 + 前后魔数保护 + 调用堆栈捕获,高效定位内存泄漏、缓冲区越界;

  4. dbghelp.dll符号解析,把机器指令地址还原为源代码位置,给排错提供强有力支撑。

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

相关推荐
All for pursuit.1 小时前
【设计-1】208.实现Trie (前缀树)
数据结构·c++·算法·leetcode
weixin_307779131 小时前
C++代码实现MATLAB中的crossval函数功能
开发语言·c++·算法·matlab
青少儿编程课堂2 小时前
背包问题(0/1 背包与完全背包)解题精讲——动态规划入门
c++·python·算法·bfs·信息学竞赛
倒头就睡的小比特2 小时前
C++多态
c++
潼心1412o4 小时前
C++初阶(长期更新)第5讲:类和对象(中)
开发语言·c++
weixin_307779134 小时前
C++代码实现MATLAB中的normalize函数功能
开发语言·c++·算法·matlab
(Charon)5 小时前
【C++面试】手写String类:同时实现拷贝构造与移动构造
c++·算法
程序员老陆5 小时前
通过std::unique_ptr初始化std::shared_ptr
开发语言·c++
西西弗Sisyphus7 小时前
Qt6 实现 会动的界面像物理世界一样
c++·qt