云计算百科
云计算领域专业知识百科平台

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

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */
#content_views .toc,
/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */
#content_views.markdown_views > p:empty:has(+ .toc),
#content_views.markdown_views > .toc + p:empty,
/* 富文本旧版目录标记 */
#content_views.htmledit_views #main-toc,
#content_views.htmledit_views #hr-toc,
#content_views.htmledit_views p[id*=\”-toc\”] {
display: none !important;
}
/* 目录去掉后,紧跟的首个标题不再多出一块上边距 */
#content_views.markdown_views > .toc + h1,
#content_views.markdown_views > .toc + h2,
#content_views.markdown_views > .toc + h3,
#content_views.markdown_views > .toc + h4,
#content_views.markdown_views > .toc + p:empty + h1,
#content_views.markdown_views > .toc + p:empty + h2,
#content_views.markdown_views > .toc + p:empty + h3,
#content_views.markdown_views > .toc + p:empty + h4 {
margin-top: 0 !important;
}

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

  • Bilibili 同步视频
  • 一、引擎底层基础系统能干什么?
    • 开发环境与编译版本小科普
  • 二、VSSystem 工程:底层工具模块集合
    • 📦 各个核心类职责一览
    • 1\\. VSSystem:封装安全内存拷贝
    • 2\\. VSTimer 游戏时间计时器
    • 3\\. VSFile \\& VSLog 文件与日志系统
    • 4\\. VSThread \\& VSSynchronize 多线程模块
    • 5\\. VSImage 图片加载抽象
  • 三、重头戏:游戏引擎内存管理设计💥
    • 内存管理器基类
    • Debug 内存检测管理器 VSDebugMem 实现原理
    • Allocate 分配逻辑流程
    • Deallocate 释放逻辑流程
    • 获取调用堆栈,定位泄漏代码位置
  • 四、总结

写在前面:一款光鲜酷炫的游戏画面背后,离不开一套稳定可靠的底层基础框架✨。很多开发者埋头钻研图形渲染、着色器,却忽略了引擎的 “地基” 部分。日志、时间、线程、文件 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 存在越界风险,所以做一层封装,示例代码:

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:毫秒级定时器,精度相对低,兼容性好。

  • 类核心伪代码结构:

    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,在文件读写能力之上,增加格式化日志输出,游戏运行中各类警告、错误信息都可以持久化保存到日志文件,程序崩溃之后可以通过日志回溯问题。

    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 模式替换高性能内存管理器:

    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 示意图展示内存块完整布局:

    #mermaid-svg-8vCuQu1ed7WVuzv2{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-8vCuQu1ed7WVuzv2 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-8vCuQu1ed7WVuzv2 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-8vCuQu1ed7WVuzv2 .error-icon{fill:#552222;}#mermaid-svg-8vCuQu1ed7WVuzv2 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-8vCuQu1ed7WVuzv2 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-8vCuQu1ed7WVuzv2 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-8vCuQu1ed7WVuzv2 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-8vCuQu1ed7WVuzv2 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-8vCuQu1ed7WVuzv2 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-8vCuQu1ed7WVuzv2 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-8vCuQu1ed7WVuzv2 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-8vCuQu1ed7WVuzv2 .marker.cross{stroke:#333333;}#mermaid-svg-8vCuQu1ed7WVuzv2 svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-8vCuQu1ed7WVuzv2 p{margin:0;}#mermaid-svg-8vCuQu1ed7WVuzv2 .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-8vCuQu1ed7WVuzv2 .cluster-label text{fill:#333;}#mermaid-svg-8vCuQu1ed7WVuzv2 .cluster-label span{color:#333;}#mermaid-svg-8vCuQu1ed7WVuzv2 .cluster-label span p{background-color:transparent;}#mermaid-svg-8vCuQu1ed7WVuzv2 .label text,#mermaid-svg-8vCuQu1ed7WVuzv2 span{fill:#333;color:#333;}#mermaid-svg-8vCuQu1ed7WVuzv2 .node rect,#mermaid-svg-8vCuQu1ed7WVuzv2 .node circle,#mermaid-svg-8vCuQu1ed7WVuzv2 .node ellipse,#mermaid-svg-8vCuQu1ed7WVuzv2 .node polygon,#mermaid-svg-8vCuQu1ed7WVuzv2 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-8vCuQu1ed7WVuzv2 .rough-node .label text,#mermaid-svg-8vCuQu1ed7WVuzv2 .node .label text,#mermaid-svg-8vCuQu1ed7WVuzv2 .image-shape .label,#mermaid-svg-8vCuQu1ed7WVuzv2 .icon-shape .label{text-anchor:middle;}#mermaid-svg-8vCuQu1ed7WVuzv2 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-8vCuQu1ed7WVuzv2 .rough-node .label,#mermaid-svg-8vCuQu1ed7WVuzv2 .node .label,#mermaid-svg-8vCuQu1ed7WVuzv2 .image-shape .label,#mermaid-svg-8vCuQu1ed7WVuzv2 .icon-shape .label{text-align:center;}#mermaid-svg-8vCuQu1ed7WVuzv2 .node.clickable{cursor:pointer;}#mermaid-svg-8vCuQu1ed7WVuzv2 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-8vCuQu1ed7WVuzv2 .arrowheadPath{fill:#333333;}#mermaid-svg-8vCuQu1ed7WVuzv2 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-8vCuQu1ed7WVuzv2 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-8vCuQu1ed7WVuzv2 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-8vCuQu1ed7WVuzv2 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-8vCuQu1ed7WVuzv2 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-8vCuQu1ed7WVuzv2 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-8vCuQu1ed7WVuzv2 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-8vCuQu1ed7WVuzv2 .cluster text{fill:#333;}#mermaid-svg-8vCuQu1ed7WVuzv2 .cluster span{color:#333;}#mermaid-svg-8vCuQu1ed7WVuzv2 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-8vCuQu1ed7WVuzv2 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-8vCuQu1ed7WVuzv2 rect.text{fill:none;stroke-width:0;}#mermaid-svg-8vCuQu1ed7WVuzv2 .icon-shape,#mermaid-svg-8vCuQu1ed7WVuzv2 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-8vCuQu1ed7WVuzv2 .icon-shape p,#mermaid-svg-8vCuQu1ed7WVuzv2 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-8vCuQu1ed7WVuzv2 .icon-shape .label rect,#mermaid-svg-8vCuQu1ed7WVuzv2 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-8vCuQu1ed7WVuzv2 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-8vCuQu1ed7WVuzv2 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-8vCuQu1ed7WVuzv2 :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

    Block元信息头堆栈信息|内存大小|是否数组|链表前后指针

    BeginMask起始魔数0xDEADCODE

    业务可用内存区域上层new拿到的指针指向这里

    EndMask结束魔数0xDEADCODE

    图表说明:每一次内存申请,实际分配内存 = Block 元信息 + BeginMask 标记 + 用户内存 + EndMask 标记。前后0xDEADCODE魔数作为保护哨兵。一旦业务代码发生缓冲区越界写,就会覆盖魔数;释放内存的时候校验魔数不匹配,直接断言报错,立刻捕获越界 BUG。

    所有内存块依靠 Block 的m_pPrev、m_pNext串联双向链表,管理器只维护链表头指针m_pHead、尾指针m_pTail,遍历链表即可找出程序退出还没有释放的内存块,定位内存泄漏。

    Block 结构体,保存每一块内存完整溯源信息:

    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,循环向上遍历,就拿到完整函数调用链地址。

    简化获取栈帧示例代码:

    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符号解析,把机器指令地址还原为源代码位置,给排错提供强有力支撑。

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

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

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 游戏引擎原理与实践 04:拆解引擎基础系统与内存管理
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!