游戏引擎学习第326天: Vararg Functions
在WinMain()中,我们发现票据系统的最后一个元素(last 指针)没有被正确重置。纹理操作队列(texture queue)有first和last两个指针,目前只重置了first,而last仍然指向之前已经处理过的元素。由于未重置last,后续的纹理加载尝试都会卡在已经在空闲列表(free list)中的节点上,导致这些纹理无法重新加载。这就解释了为什么部分纹理显示为空白,因为它们被错误地留
回顾并为今天做好准备
今天我们回顾了昨天的进度,并为今天的工作做好了准备。我们几乎完成了让纹理下载变为同步操作,以便 OpenGL 驱动能够更好地处理它们。在之前的流程中,发现了一个 bug,但没有时间去调试。我们已经完成了所有代码的录入,但在回顾过程中,突然想到了可能导致这个 bug 的原因。
如果猜测正确,今天的调试可能会很快完成;如果猜测错误,就需要花更多时间去排查问题。整体来看,今天的工作可能会很快,也可能会比较耗时。我们打算以源代码为基础来继续工作,确保所有功能按预期运行。
总结来说,主要目标是:
- 继续检查和修复昨天未解决的 bug
- 确认纹理下载同步后 OpenGL 的处理是否正常
- 准备今天的调试和测试流程
- 根据源代码进行详细排查和验证
整个过程的重点是保证同步纹理下载正确执行,同时定位并解决潜在的 bug,以便后续开发顺利进行。
win32_game.cpp:查看代码并记录 bug 是什么α
在 win32_game.cpp
中,我们回顾了之前的流程,重点在于纹理操作的处理和票据系统的管理。主要流程如下:
- 我们在处理纹理操作之前,首先开始了票据互斥(ticket mutex),以保证在多线程环境下操作的安全性。
- 在获取互斥锁之后,我们抓取了所有等待处理的纹理操作列表,确保可以对它们进行批量管理。
- 查看代码后,我们确认了 bug 的来源,问题可能就在于处理纹理操作的顺序或释放机制上。
- 在结束票据互斥操作(end ticket mutex)之后,我们检查是否有新的纹理上传请求(texture ups)出现。
- 如果有纹理上传,我们立即处理这些操作,并将处理完的纹理对象放回空闲列表(free list),以便重复使用。
- 通过这个流程,我们发现了在同步纹理处理和票据管理之间可能存在的逻辑问题,这也是导致之前 bug 出现的原因。
总结来说,问题集中在多线程环境下对纹理操作列表的管理,以及在票据互斥锁释放前后的处理顺序上,需要确保所有待处理的纹理都被正确处理并且安全地放回空闲列表。
运行游戏并演示 bug
在运行游戏并演示 bug 的过程中,我们观察到部分纹理成功加载,但有些纹理没有正确显示,表现为空白的白色纹理。
问题的核心在于,有些纹理在加载过程中被中断或未完成加载,导致最终渲染时出现缺失。虽然票据互斥(ticket mutex)的机制本身看起来运行正常,没有明显问题,但纹理加载仍然出现了异常。这表明 bug 并非互斥锁本身出错,而可能与纹理处理的顺序、上传状态的检查或者列表管理有关。
总体来看,主要现象是:
- 已加载的纹理正常显示
- 未正确加载的纹理显示为空白
- 票据互斥机制没有明显异常
- 问题可能出在纹理加载流程或状态判断逻辑上
这为接下来的调试指明了方向,需要重点关注纹理上传的完整性和列表管理的正确性。
win32_game.cpp:让 WinMain()
重置票据系统中的最后一个元素
在 WinMain()
中,我们发现票据系统的最后一个元素(last 指针)没有被正确重置。纹理操作队列(texture queue)有 first
和 last
两个指针,目前只重置了 first
,而 last
仍然指向之前已经处理过的元素。
由于未重置 last
,后续的纹理加载尝试都会卡在已经在空闲列表(free list)中的节点上,导致这些纹理无法重新加载。这就解释了为什么部分纹理显示为空白,因为它们被错误地留在空闲列表中而未被处理。
总结来看:
- 纹理队列有
first
和last
指针,需要同时管理 - 之前只重置了
first
,导致后续加载受阻 - 未重置
last
会让纹理操作持续停留在空闲列表中,无法被重新加载 - 正确做法是,在重置队列时同时重置
first
和last
,保证所有待处理的纹理都能顺利上传
这个修正基本解决了之前导致纹理加载失败的核心问题。
win32_game.cpp
game_opengl.cpp:让 OpenGLManageTextures()
不返回任何内容
在 OpenGLManageTextures()
中,我们对纹理管理逻辑进行了调整,使其不再返回最后一个元素(last)。主要思路如下:
- 在处理纹理队列时,我们已经可以获取到队列的
first
和last
指针,因此OpenGLManageTextures()
没有必要再返回last
,减少了冗余。 - 处理流程为:首先获取队列的第一个纹理(first),同时也可以确认最后一个纹理(last),确保队列至少有一个元素时,处理逻辑才会继续。
- 在确认队列有元素后,执行所有纹理上传和管理操作。由于知道了队列的最后一个元素,整个流程更简洁,也减少了函数间的依赖。
- 这个改动保证了纹理管理的完整性,同时让接口更加清晰,不必再依赖返回值来传递
last
指针。
总结来说,核心是简化纹理管理逻辑,通过直接访问队列的首尾指针来完成操作,不再依赖函数返回值传递最后一个元素,保证了队列处理的正确性和接口的简洁性。
game_opengl.cpp>OpenGLManageTexture
game_opengl.h
win32_game.cpp
运行游戏,看到纹理下载都是同步的,并查看性能分析器
运行游戏后,我们确认之前的 bug 已经修复,纹理下载流程现在都是同步的。理论上,这样能够避免在特定显卡(例如 NVIDIA 或部分 ATI 显卡)上出现的纹理加载异常问题。
接着进入性能分析环节,结果显示了一些新的情况:
- 在 线程模式(threads mode) 下,运行速度非常快,性能表现良好。
- 在 帧模式(frames mode) 下,运行速度却极其缓慢,性能下降明显。
进一步暂停并检查时钟显示后,发现性能瓶颈出现在 精灵排序(sprite sorting) 上。 - 在调试显示(debug display)中,排序操作完全没有必要,因为这些精灵并不需要严格的顺序。
- 大量无意义的排序操作让帧分析时的开销变得异常高,导致整体运行缓慢。
- 解决思路是:在调试模式下直接关闭排序,这样既能减少消耗,也不会影响渲染正确性。
总结来说: - 纹理下载的同步机制已经生效,核心问题得到解决。
- 新发现的性能瓶颈主要来自调试显示中多余的精灵排序操作。
- 通过在调试模式中关闭排序,可以显著提升帧模式下的性能表现。
切换到 -O2
优化,运行游戏,再次查看性能分析器
在 build.bat
中切换到 -O2
优化后,我们重新运行了游戏并再次查看性能分析器,重点关注优化对运行效率的影响。
观察结果如下:
- 可以明显看到,在优化后,大部分时间仍然花费在帧显示(frame display)相关操作上。
- 在大多数情况下,帧时间表现稳定,说明同步纹理下载机制基本运作良好。
- 但是在某些切换点(changeover point),仍然会出现偶发性的坏帧(bad frame)。例如,在一次测试中,
OpenGLRenderCommands
占用了 72.6 毫秒,远高于正常范围。 - 性能分析界面未能清晰展示这些单帧卡顿(single frame hitch),导致调试时难以定位问题。这说明当前的性能显示工具本身存在不足,无法直观地标记和突出显示这些异常帧。
- 从表现来看,整体运行效率得到了优化,但零星的长帧依然存在,需要进一步改进渲染命令或工具显示方式,以便更好地捕捉这些性能瓶颈。
总结来说: - 切换到
-O2
优化确实提升了整体性能,帧率更加平稳。 - 仍然存在偶发的单帧卡顿,需要深入排查
OpenGLRenderCommands
的执行过程。 - 性能分析工具的显示效果有限,难以追踪单帧异常,需要改进以便更好地辅助调试。
考虑对像素缓冲对象做一些优化,以减少传输时间
在运行游戏和分析性能时,我们注意到纹理传输过程中仍然会出现帧卡顿(frame hitch),尤其是在纹理需要下载的瞬间。虽然整体运行平滑,但切换点依然会引发明显的长帧现象,这正是同步下载方式带来的隐患。
为了解决这个问题,我们考虑引入 像素缓冲对象(Pixel Buffer Object, PBO) 来优化数据传输:
- 目前的同步下载方式,会在纹理传输时产生额外的阻塞,导致渲染过程中出现单帧延迟。
- PBO 能够将数据直接分配到 GPU 空间,减少 CPU 与 GPU 之间的复制和等待,从而缩短传输时间。
- 使用 PBO 还可能帮助避免额外的内存拷贝操作,这一点对性能提升可能很关键。
- 这种方式有望消除切换点的长帧问题,使得帧率更加平稳,过渡过程不再出现肉眼难以察觉但确实存在的性能波动。
除此之外,我们还考虑增加一个检测机制: - 在每一帧渲染完成后记录耗时,如果出现明显的异常长帧(outlier),就弹出提示,例如显示“前两帧出现卡顿”。
- 这样可以更直观地监控和验证优化效果。
总结来说: - 引入 PBO 有助于减少纹理传输带来的阻塞,从根本上优化性能。
- 即便 bug 已经解决,长帧问题依然存在,需要进一步改进。
- 增加长帧检测提示工具,可以更好地追踪和定位渲染中的性能问题。
“bug 已经消失”β
在修复 bug 后,纹理下载已经从异步方式切换为同步方式,整体上 bug 已经消失。但新的问题随之出现:虽然运行结果可靠了,但在特定时刻会产生帧卡顿,这让人对性能表现并不完全满意。
具体情况如下:
- 同步下载机制的优缺点:
- 优点是稳定可靠,纹理不会再出现丢失或空白。
- 缺点是容易在纹理传输时造成瞬间的性能停顿,导致画面出现小幅卡顿。
- 在游戏中的表现差异:
- 对于普通场景中较小的纹理,这种卡顿几乎可以被掩盖在帧渲染内,不会造成明显影响。
- 但在过场动画(cutscene)中,大纹理的下载却非常显眼,显卡在执行
glTexImage2D
时会立即触发传输,导致整帧延迟,表现为明显的掉帧。
- 可能的原因推测:
- 驱动并没有提前准备或并行执行纹理上传,而是等到调用
glTexImage2D
时才真正触发数据拷贝。 - 数据拷贝的瓶颈可能在 CPU 到 GPU 的传输阶段,也可能在 GPU 内部将数据写入显存的过程。
- 如果主要瓶颈在内存拷贝,那么使用 像素缓冲对象(PBO) 可以解决问题,因为 PBO 可以让上传过程异步化,把数据先搬运到 GPU 缓冲区,再由 GPU 自行完成上传。
- 驱动并没有提前准备或并行执行纹理上传,而是等到调用
- 后续方向:
- 短期内,这种同步方式在小纹理加载中问题不大,可以继续使用。
- 长期来看,需要优化 cutscene 等大纹理加载流程,可能要引入 PBO 或更精细的预加载策略,避免在关键时刻发生整帧卡顿。
总结来说:bug 已经消失,纹理加载变得稳定,但同步机制带来了新的性能隐患。小纹理加载尚可接受,大纹理加载在过场中造成明显卡顿。根本解决思路是尝试引入 PBO 或调整纹理上传策略,以减少 CPU/GPU 传输造成的帧延迟。
todo.txt:更新待办事项列表,查看问题并确定如何修复 “CLANG 兼容性” 问题1
在整理 todo.txt
时,我们更新了待办事项列表,并顺便检查了 “CLANG 兼容性” 的相关问题,主要内容如下:
待办事项更新
- 帧视图(frame view)无法显示坏帧:需要修复,让单帧卡顿能够被正确标记和显示。
- 禁用排序:调试显示中的精灵排序操作会消耗大量性能,在调试模式下没有必要,需要关闭。
- 编辑系统相关任务:仍有部分功能需要补充。
- 图形实体显示问题:需要决定如何为实体绘制图形。
- 区域投影和控制台项目:部分内容已经完成,但仍然要在清单中跟踪。
- 纹理下载的优化:加入“像素缓冲对象(Pixel Buffer Objects, PBO)”作为潜在优化手段,避免同步传输造成的性能瓶颈。
CLANG 兼容性问题
- 主要问题集中在
printf
系列函数和宏定义上。snprintf_s
在 CLANG 下不受支持,需要替换。- 一些函数宏在 CLANG 下无法使用,需要重新设计或规避。
- 短期解决方案:
- 将
snprintf_s
等依赖 CRT 的函数替换为跨平台实现,例如vsnprintf
或自定义实现。
- 将
- 长期目标:
- 完全摆脱对 C 运行时库的依赖,设计并实现自定义的
printf
系统。 - 重新检查所有代码中涉及
printf
、sprintf
、snprintf
等的使用位置,统一替换为自定义方案。
- 完全摆脱对 C 运行时库的依赖,设计并实现自定义的
总结
- 待办事项清单已补充与性能和渲染相关的问题,特别是 PBO 的引入。
- CLANG 兼容性问题主要是格式化输出函数不受支持,需要逐步替换为自定义实现。
- 整体方向是减少对平台和运行时的依赖,提高跨平台兼容性。
win32_game.cpp:移除 HandleDebugCycleCounters()
、Win32DebugDrawVertical()
及其他 _snprintf_s()
的实例
在整理 win32_game.cpp
时,我们对调试相关代码进行了清理和优化,主要操作如下:
- 移除不再使用的函数
HandleDebugCycleCounters()
:已经不再使用,直接从代码中彻底删除。Win32DebugDrawVertical()
:调试绘制垂直线相关功能,也不再需要,删除。
- 清理
_snprintf_s()
的调用- 搜索代码中所有
snprintf_s
的使用位置,发现它们主要用于调试信息输出,例如播放光标(play cursor)相关调试。 - 将这些调用删除或替换,避免依赖
_snprintf_s
,为后续实现自定义格式化输出做准备。
- 搜索代码中所有
- 总体思路
- 清理所有冗余的调试代码,将其统一管理到常规调试系统中。
- 保持代码简洁,减少对 Windows 特有函数的依赖,为跨平台兼容性和自定义调试系统铺路。
总结来说,我们彻底清理了不再使用的调试函数和_snprintf_s
调用,使代码更加整洁,并为后续实现统一调试和跨平台输出奠定基础。
game_debug.cpp:查看我们目前在 _snprintf_s()
中使用的功能,以便实现自己的 printf()
在分析 game_debug.cpp
时,我们重点查看了 _snprintf_s()
当前在调试系统中使用的功能,以便设计自定义的 printf()
实现。主要发现和思路如下:
- 现有使用情况
_snprintf_s()
主要用于打印各种调试值,涉及的格式化标记主要有:%s
:字符串%f
:浮点数%d
:有符号整数%u
:无符号整数
- 综合来看,大部分格式化需求可以归纳为
%s
、%f
、%d
和%u
四类,方便集中实现。
- 实现自定义 printf 的目标
- 需要实现一个能够处理
%s
、%f
、%d
、%u
的格式化函数。 - 其核心目的是替代
_snprintf_s()
,减少对 C 运行时库的依赖,同时保证调试输出功能完整。
- 需要实现一个能够处理
- 其他相关函数
TOI
(文本到整数转换)也在调试系统中被使用,并且实现较为简单,可以轻松复制。_snprintf_s
与TOI
是调试系统中真正需要保留的两类功能,其他冗余调用可以去除。
总结来说:
- 调试系统中
_snprintf_s()
的使用集中且可控,主要涉及%s
、%f
、%d
、%u
。 - 自定义
printf()
的实现可以针对这些格式标记进行,替代_snprintf_s()
,减少平台依赖。 TOI
功能简单,也需要保留并实现。
game_debug.cpp:尝试确定当 PipeCount == 1
时 DebugParseName()
应该做什么
在分析 game_debug.cpp
中的 DebugParseName()
时,我们注意到当 PipeCount == 1
时,函数似乎什么也不做。具体情况如下:
- 现象
- 当
PipeCount
等于 1 时,函数内部没有任何操作,直接跳过。 - 这一行为看起来有些奇怪,似乎存在一个空行或未处理的分支。
- 当
- 可能原因
- 这可能是历史遗留的代码逻辑,或者早期的实现被删除,但函数的结构保留了这个空分支。
- 目前没有明确的文档或注释解释为什么当
PipeCount == 1
时不执行任何操作。
- 思考与处理方向
- 理论上,可以通过版本管理系统(如 GitHub)查看历史修改记录,弄清空分支的由来。
- 在没有明确需求的情况下,这个分支暂时保持空,但需要注意它在后续功能中可能会产生未定义行为。
总结来说:
DebugParseName()
在PipeCount == 1
时不执行操作,表现为一个空分支。- 这一行为看起来不合理,但目前仍保留,需要进一步确认是否有历史原因或功能需求。
- 可以考虑使用版本管理工具追踪该分支的历史,判断是否需要补充逻辑。
game_shared.h:引入 S32FromZ()
在 game_shared.h
中,我们引入了 S32FromZ()
,用于替代之前的 TOI
(文本到整数转换)功能,具体情况如下:
- 功能目的
- 将字符串或文本数据转换为 32 位整数(S32),类似于之前的
TOI
功能。 - 统一实现后,可以在整个代码中复用,避免重复实现。
- 将字符串或文本数据转换为 32 位整数(S32),类似于之前的
- 使用场景
- 在
game_opengl.cpp
中解析版本号时,需要将文本转换为整数。 - 原本使用局部实现的
TOI
,现在可以统一调用S32FromZ()
,返回对应的整数结果。
- 在
- 实现思路
S32FromZ()
接收一个字符指针或字符串作为输入。- 内部逻辑与原本的
TOI
类似,但封装为共享函数,方便在多个模块中调用。 - 调用位置只需替换原来的
TOI
为S32FromZ()
即可,返回的整数值保持一致。
总结来说:
S32FromZ()
是TOI
的通用替代实现,用于将字符串转换为 32 位整数。- 统一实现后提高了代码复用性和可维护性。
- 具体使用时只需调用
S32FromZ()
,即可获取对应整数,替代原有局部实现。
game_opengl.cpp
game_shared.h
关于打印浮点数的一些说明
在讨论调试输出和自定义 printf()
的实现时,我们对打印浮点数 %f
做了一些说明,主要内容如下:
- 整数和字符串打印相对简单
%d
(有符号整数)和%u
(无符号整数)的数值输出实现相对容易。%s
(字符串)输出几乎不需要复杂处理,直接打印即可。
- 浮点数
%f
输出的复杂性- 打印浮点数比整数复杂得多,涉及精度、舍入、科学计数法等问题。
- 传统方法实现
%f
输出非常繁琐,容易出现性能或精度问题。 - 近年来出现了一些新的算法,可以在不复杂到不可用的情况下实现浮点数打印。
- 尽管现在可能有简化的方法,但实现仍然比整数或字符串打印复杂得多,需要专门处理。
- 当前处理策略
- 暂时先不实现复杂的浮点数打印,只记录这个问题。
- 等有合适方案时再针对
%f
做完整实现,优先保证整数和字符串打印的可用性。
总结来说:
- 自定义
printf()
对整数和字符串的实现容易,但浮点数输出%f
是难点。 - 当前阶段先保留问题,以后再引入合适算法处理浮点数输出。
game_shared.h:临时引入 FormatString()
和 FormatStringList()
在 game_shared.h
中,我们临时引入了 FormatString()
和 FormatStringList()
,目的是替代 _snprintf_s()
,为自定义调试输出和格式化字符串提供基础功能。主要内容如下:
FormatString()
功能- 用于处理单个格式化字符串,将目标缓冲区(dest)、格式字符串(format)以及其他必要参数传入。
- 可作为
_snprintf_s()
的直接替代,实现基础的字符串和数值输出。
FormatStringList()
功能- 用于处理变长参数列表(variable argument list),类似
va_list
机制。 - 通过调用
FormatString()
来逐个处理参数,实现完整的格式化输出功能。 - 便于在调试系统中直接替换
_snprintf_s()
,支持多参数格式化输出。
- 用于处理变长参数列表(variable argument list),类似
- 实现思路与细节
- 结合 C/C++ 默认调用约定(calling convention)和可变参数机制(varargs),保证参数正确传递。
- 先提供基本接口和功能,再逐步完善对不同格式标记(如
%s
、%d
、%f
等)的处理。 - 初步目标是让调试输出能够运行,后续再优化对浮点数
%f
和其他复杂格式的支持。
总结来说:
FormatString()
和FormatStringList()
是_snprintf_s()
的替代方案,用于调试系统的格式化输出。FormatStringList()
处理变长参数,通过FormatString()
实现实际输出。- 设计目标是保证调试输出可用,同时逐步扩展对复杂格式的支持。
game_shared.h
调试器:查看 _crt_va_start()
、_crt_va_arg()
和 _crt_va_end()
的定义
在调试器中,我们查看了 _crt_va_start()
、_crt_va_arg()
和 _crt_va_end()
的定义,主要内容和理解如下:
- 作用概览
- 这三个宏用于处理 C/C++ 的可变参数(varargs)。
_crt_va_start()
:初始化可变参数列表,指向第一个可变参数的位置。_crt_va_arg()
:获取当前可变参数的值,并移动指针到下一个参数。_crt_va_end()
:结束可变参数处理,释放相关资源(如果有)。
- 实现细节
- 宏内部会调用底层的辅助宏或函数,将参数地址、类型和调用约定结合起来处理。
- 这些定义并不复杂,主要是对参数指针的偏移和类型转换进行封装,使可变参数在不同平台上可正确访问。
- 可以通过查看宏展开的实际代码,理解其底层操作逻辑,例如如何计算参数偏移、如何确保类型安全等。
- 实际应用
- 在自定义
FormatStringList()
实现中,这些宏提供了获取变长参数的手段。 - 利用
_crt_va_start()
、_crt_va_arg()
和_crt_va_end()
可以依次访问传入的所有参数,实现类似printf()
的功能。
总结来说:
- 在自定义
- 这三个宏是 C/C++ 处理可变参数的核心工具,通过指针操作和类型转换实现。
- 理解其工作原理,可以为自定义格式化输出函数(如
FormatStringList()
)提供基础。 - 宏本身并不神秘,核心是参数的地址计算和访问顺序控制。
黑板讲解:可变参数列表
在黑板讲解中,我们对可变参数列表(variable argument list)的工作原理进行了详细说明,主要内容如下:
- 函数调用与栈
- 正常函数调用时,参数如果能放进寄存器,就直接放寄存器,否则放在程序栈上。
- 栈是一块连续内存,函数调用时将参数和局部变量压入栈中,函数返回时弹出栈恢复。
- 栈的增长方向通常向低地址方向(在 x64 上栈向下增长)。
- 每次调用函数,栈增长;返回时,栈恢复。
- 可变参数函数
- 函数声明如
int foo(int X, ...)
,省略号表示后续有可变数量的参数。 - 调用可变参数函数时,额外参数会像普通参数一样压入栈中(或按 ABI 放入寄存器)。
- 调用者通常负责在函数返回后清理栈上的额外参数,确保栈指针恢复到调用前位置。
- 函数声明如
- 栈上的参数访问
- 普通参数有固定名字,编译器知道它们在栈上的偏移量,因此可以直接访问。
- 可变参数没有固定名字,编译器无法预先知道它们的数量和类型。
- 这些参数按顺序压栈,函数通过指针偏移或者可变参数宏(如
va_list
)来依次访问它们。
- 以
printf
为例- 格式字符串之后的所有参数都是可变参数,按顺序压入栈中。
- 函数内部使用
va_start()
、va_arg()
、va_end()
等宏,通过偏移量逐一访问参数,实现格式化输出。
总结来说:
- 可变参数函数通过栈或寄存器传递额外参数,调用者负责清理。
- 编译器无法直接用名字访问这些参数,需要通过指针或宏来遍历。
- 理解栈结构和参数压入顺序,是实现自定义
printf
或FormatStringList()
的关键。
黑板讲解:va_start
和 va_end
在黑板讲解中,我们进一步说明了 va_start
和 va_end
的工作原理,主要内容如下:
- 宏的作用
va_start
、va_end
等宏是 C 标准库提供的工具,用于访问可变参数。- 它们通过获取最后一个具名参数的地址来确定可变参数在栈上的起始位置。
- 栈上的参数访问
- 编译器通常会将最后一个具名参数强制放在栈上(而非寄存器),以便可变参数宏知道从哪里开始访问。
- 使用宏时,可以根据预期类型向前推进指针,逐个访问栈上的参数。
- 比如
printf
的格式字符串%d
告诉函数下一个参数是一个整数,应取多少字节并解释为整数类型。
- 与普通内存访问的类比
- 可变参数访问本质上是按内存偏移读取数据,类似于在普通内存中按偏移访问结构体或数组。
- 格式字符串相当于一个指南,告诉函数每个参数的类型和大小,从而正确地从栈上提取值。
总结来说:
va_start
用最后一个具名参数定位可变参数起点。va_arg
按类型和格式逐一访问栈上的参数。va_end
结束可变参数访问。- 可变参数访问本质是栈的顺序扫描,格式字符串提供类型信息,类似于对内存的有序解析。
game_shared.h:查看可变参数代码和 _INTSIZEOF
在 game_shared.h
中,我们分析了可变参数处理的实现细节,主要内容如下:
- 可变参数的起始定位
va_start
宏使用最后一个具名参数的地址作为可变参数的起点。- 它会向前查找,并根据参数类型的大小进行偏移,以确定下一个参数的位置。
_INTSIZEOF
的作用- 可变参数宏不会直接使用类型大小,而是用
_INTSIZEOF
。 _INTSIZEOF
会将类型大小向上对齐到最近的整数边界(integer boundary)。- 这是因为 C 语言标准规定,可变参数在栈上总是以整数单位分配空间,即使传递的参数小于一个整数大小,也会占用一个整数单元。
- 可变参数宏不会直接使用类型大小,而是用
va_arg
的功能va_arg
根据指定类型,从栈上取出对应的参数。- 它会将指针移动相应的字节数,同时返回原来位置的数据,类似于栈上按类型偏移访问内存。
va_end
的作用va_end
本质上是一个清理操作,通常在 MSVC 和 LLVM 上不做实际工作。- 它的作用是保证在某些编译器或平台上,调用结束时可以做必要的资源释放或整理。
- 总结
- 可变参数访问本质上是栈的顺序扫描。
_INTSIZEOF
保证了栈偏移符合整数对齐规范。va_start
定位起点,va_arg
逐个访问参数,va_end
做清理工作。- 整个机制与普通内存按类型访问类似,只不过这里是根据栈上的偏移和类型大小来遍历参数。
1. _ADDRESSOF(v)
#define _ADDRESSOF(v) (&(v))
- 获取变量
v
的地址。 - 作用:在可变参数处理中,需要知道最后一个具名参数的内存地址,作为参数列表遍历的起点。
2. _INTSIZEOF(n)
#define _INTSIZEOF(n) ((sizeof(n) + sizeof(int) - 1) & ~(sizeof(int) - 1))
- 将类型
n
的大小向上对齐到整数边界。 - 解释:
sizeof(n)
:类型n
的实际大小(字节)。+ sizeof(int) - 1
:保证对齐。& ~(sizeof(int)-1)
:向下取整到最近的整数倍(保证对齐)。
- 作用:可变参数在栈上按整数边界对齐,保证每次读取参数时内存对齐正确。
3. _crt_va_start(ap, v)
#define _crt_va_start(ap, v) (ap = (va_list)_ADDRESSOF(v) + _INTSIZEOF(v))
- 初始化可变参数列表
ap
。 v
是函数最后一个具名参数。- 思路:
- 取最后一个具名参数的地址。
- 加上它占用的对齐字节数,得到第一个可变参数的起始地址。
- 作用:准备开始读取可变参数。
4. _crt_va_arg(ap, t)
#define _crt_va_arg(ap, t) (*(t *)((ap += _INTSIZEOF(t)) - _INTSIZEOF(t)))
- 从参数列表
ap
中获取下一个类型为t
的参数。 - 解析:
ap += _INTSIZEOF(t)
:将指针向前移动一个对齐后的类型大小。- _INTSIZEOF(t)
:回退到本次参数的起始位置。*(t *)(...)
:把这个地址转换为t*
并解引用得到参数值。
- 作用:每次调用都返回一个参数,并让
ap
指向下一个参数的位置。
5. _crt_va_end(ap)
#define _crt_va_end(ap) (ap = (va_list)0)
- 清理可变参数列表,把
ap
置为0
。 - 在 MSVC 风格下,通常不做实际操作,只是标记参数列表已结束。
总结:
这五个宏是实现 C 风格可变参数(如printf
)的基础工具:
- 获取最后一个具名参数地址
- 对齐参数大小
- 初始化参数列表
- 按类型读取参数
- 清理参数列表
game_shared.h
我们来逐行分析这段代码,并结合可变参数和格式化输出来理解它的作用:
1. struct Format_dest
struct Format_dest {
uint32 size;
char *At;
};
- 定义了一个结构体,用来表示一个输出缓冲区:
size
:缓冲区剩余可写的字节数。At
:当前写入的位置指针。
2. FormatStringList
函数
uint32 FormatStringList(uint32 DestSize, char *DestInit, char *Format, va_list ArgList)
- 作用:处理带有可变参数的格式化字符串,将结果写入缓冲区。
- 参数:
DestSize
:目标缓冲区大小。DestInit
:目标缓冲区起始指针。Format
:格式化字符串(类似printf
的"Hello %d"
)。ArgList
:可变参数列表(通过_crt_va_start
/va_start
初始化)。
3. 初始化输出缓冲区
Format_dest Dest = {DestSize, DestInit};
- 创建一个
Format_dest
实例,用于追踪输出缓冲区的当前位置和剩余大小。
4. 遍历格式字符串
char *At = Format;
while (At[0]) {
if (At[0] == '%') {
} else {
OutChar(&Dest, At[0]);
}
}
- 遍历
Format
字符串:- 遇到
'%'
:预留处理格式化参数(例如%d
,%s
等)。 - 非
'%'
:直接输出字符到缓冲区,使用OutChar
函数。
- 遇到
- 注意:
OutChar
可能会:- 将字符写入
Dest.At
- 更新
Dest.At
指针 - 减少
Dest.size
目前对'%'
后的处理为空,还没有实现对可变参数的解析。
- 将字符写入
5. 写入字符串结束符
if (Dest.size) {
Dest.At = 0;
} else {
Dest.At[-1] = 0;
}
- 在缓冲区末尾写入
\0
:- 如果缓冲区还有剩余空间,直接写在当前指针位置。
- 如果缓冲区已满,用最后一个字符的位置覆盖写入
\0
(防止溢出)。
6. FormatString
函数
uint32 FormatString(uint32 DestSize, char *Dest, char *Format, ...) {
va_list ArgList;
va_start(ArgList, Format);
uint32 Result = FormatStringList(DestSize, Dest, Format, ArgList);
va_end(ArgList);
return Result;
}
- 作用:提供可变参数接口,调用
FormatStringList
进行实际格式化。 - 流程:
va_start
初始化ArgList
,从Format
后面的参数开始读取。- 调用
FormatStringList
将参数写入缓冲区。 va_end
清理ArgList
。- 返回结果(当前实现中函数没有明确返回值,可能应返回写入的字符数量)。
7. 总结
- 这是一个简化的
printf
风格函数实现:FormatString
:对外接口,支持可变参数。FormatStringList
:核心函数,遍历格式化字符串并处理字符。- 目前
'%'
的处理未完成,还不能处理%d
,%s
等格式化输出。
- 输出缓冲区使用
Format_dest
结构追踪写入位置和剩余空间。 - 已实现:
- 字符直接输出
- 缓冲区边界检查
- 字符串结尾写入
\0
如果你需要,我可以帮你画一个执行流程示意图,直观展示va_list
和缓冲区写入过程,尤其是字符输出和%
参数的处理方式。
game_shared.h:实现 FormatString()
和 FormatStringList()
1. 可变参数处理拆分为两部分
- 目的:让
FormatString
可以被两种场景调用:- 直接传入可变参数。
- 已经拥有
va_list
的函数内部调用。
- 流程:
- 使用
va_start
/va_end
创建参数列表。 - 将参数列表传入
FormatStringList
进行实际处理。
- 使用
2. FormatStringList
的核心处理逻辑
- 遍历格式化字符串的每个字符。
- 判断字符是否为
%
:- 如果是
%
,说明接下来是一个格式化占位符,需要解析(目前先不做具体转换)。 - 如果不是
%
,则直接输出到目标缓冲区。
- 如果是
- 通过一个
Format_dest
结构管理输出:size
:剩余缓冲区空间。At
:当前写入位置。
- 输出逻辑封装在
OutChar(&Dest, c)
中,确保:- 更新写入位置指针。
- 减少剩余空间计数。
3. 缓冲区溢出与结尾处理
- 确保缓冲区总是以
\0
结尾:- 如果还有剩余空间,直接写入
\0
。 - 如果空间已满,覆盖最后一个字符写入
\0
。
- 如果还有剩余空间,直接写入
- 这样保证即使输出超过缓冲区,也不会导致未终止字符串。
4. 初步实现策略
- 当前阶段只处理普通字符输出:
%
字符暂时只识别,不做转换。- 后续可以添加对
%d
、%s
等格式化输出的支持。
- 使用
Format_dest
管理缓冲区,简化空间计算:- 已写入字节数 = 当前指针
At
- 起始指针DestInit
。 - 可直接通过结构体追踪剩余空间和当前位置。
- 已写入字节数 = 当前指针
5. 总结
- 设计思路:
- 将可变参数处理和实际输出分离。
- 使用缓冲区结构统一管理写入位置与大小。
- 安全写入,确保字符串总是 null 终止。
- 当前实现仅做字符复制,格式化占位符
%
的解析和类型处理留作后续扩展。
如果需要,我可以帮你画一个流程图,直观展示:
FormatString
调用va_start
- 参数传入
FormatStringList
- 遍历字符
- 写入缓冲区
- 处理
%
占位符 - 最终写入
\0
game_shared.h:引入 OutChar()
1. 引入 OutChar()
函数
- 功能:向输出缓冲区写入单个字符。
- 实现思路:
- 通过
Format_dest
结构管理输出缓冲区。 - 写入字符后,更新指针
At
并减少剩余空间size
。
- 通过
- 目标:简化输出逻辑,先实现基础功能,再扩展到可变参数解析和格式化输出。
2. 初步处理流程
- 在遍历格式化字符串时:
- 对普通字符直接调用
OutChar()
写入。 - 对
%
符号暂时不做解析,保留扩展接口。
- 对普通字符直接调用
- 这样可以保证字符输出到缓冲区的逻辑可用,同时不受可变参数解析复杂性的干扰。
3. 可变参数宏使用注意
- 遇到
VA_START
报错“实际参数过多”问题:- 原因是包含了不同版本的头文件:
varargs.h
为旧版本宏。stdarg.h
为新标准宏。
- 使用标准宏后,参数匹配正确。
- 原因是包含了不同版本的头文件:
- 这提醒我们在实现可变参数函数时,需要确保使用对应的标准宏和头文件。
4. 总结
OutChar()
是输出缓冲区的基础写入工具。- 当前实现只处理字符写入,不处理格式化参数。
- 确认可变参数宏匹配正确,避免头文件版本冲突导致的错误。
- 基础功能就绪后,再处理
%
占位符解析和可变参数读取。
game_shared.h
game_debug.cpp:用 FormatString()
替换所有 _snprintf_s()
实例
我们对这段内容进行详细总结:
1. 替换 _snprintf_s()
为 FormatString()
- 所有
_snprintf_s()
的实例都被替换为自定义的FormatString()
调用。 - 原先
_snprintf_s()
使用上存在一些奇怪的处理:- 需要传递两次缓冲区大小。
- 对某些类型需要不同的参数数量。
- 替换后:
- 使用
FormatString()
可以直接处理完整的size_t
或uint32
。 - 简化了调用方式,移除了 CRT 特定依赖。
- 使用
2. 自定义缓冲区管理
- 引入类似于
Format_dest
的结构,用于管理输出缓冲区:- 记录缓冲区大小和当前写入位置。
- 确保写入过程中不会越界。
- 在缓冲区末尾始终保持 null 终止符。
- 这一结构不仅用于
FormatString()
,也可以复用在其他字符串拼接或输出场景中。
3. 优化思路
- 通过统一使用
FormatString()
,避免了_snprintf_s()
的各种奇怪限制。 - 输出缓冲区管理变得系统化:
- 可以将整个应用中字符串输出统一到一个“流式缓冲区”概念。
- 可以安全地拼接字符串而无需担心越界。
- 这种方式也便于未来完全脱离 CRT,实现独立的字符串格式化函数。
4. 总结
- 移除
_snprintf_s()
相关的冗余和不一致逻辑。 - 使用自定义
FormatString()
替代,实现可变参数格式化输出。 - 引入缓冲区管理结构保证安全输出。
- 为整个项目提供统一、可控、可复用的字符串输出机制。
game_shared.h
问答环节
就是这个吗?
我们发现打印浮点数存在问题,目前没有一个既简单又完全正确的方法可以输出浮点数。存在一些方法可以快速但不完全准确地打印浮点数,但这些方法可能会产生误差。为了解决这个问题,需要去阅读相关论文,理解其算法,并尝试实现一种既高效又能保证正确性的浮点数打印方法。总之,当前可用的方案要么速度快但不精确,要么精确但实现复杂,需要进一步研究才能找到最佳方案。
今天我们做了什么的简短回顾?尤其是早上六点,我很难跟上
今天我们主要开始替换 _snprintf_s()
,目的是减少对 C 运行时库的依赖。这么做可以让我们以后在 Linux 上编译变得更容易,同时也降低了对 CRT 的耦合。
GitHub 的 “Blame” 功能可以查看文件的修改历史
在 GitHub 上可以使用 “Blame” 功能查看文件的修改历史,通过它可以看到每一行代码的变更记录。git blame
是 Git 提供的命令行工具,用于查看文件每一行的最后修改记录。它可以显示每行代码是谁在什么时候修改的,以及对应的提交 ID。
基本用法示例:
git blame <文件名>
输出信息包括:
- 提交哈希(commit hash)
- 作者姓名(author)
- 修改时间(date)
- 代码行内容(line of code)
常用选项: -L <开始行>,<结束行>
:仅显示指定行范围的修改记录-p
:以更详细的格式显示,包括作者邮箱、时间戳等-e
:显示作者邮箱而不是姓名
示例:
git blame -L 10,20 game_debug.cpp
这会显示 game_debug.cpp
第 10 行到第 20 行的修改历史。
它的主要用途是:
- 找出谁修改了某行代码
- 了解某行代码的修改历史和原因
- 追踪 bug 或理解代码演变过程
Dest.At[-1]
中的 -1 是什么作用?
在 C 语言中,Dest.At[-1]
表示访问指针 Dest.At
当前指向位置的前一个元素。这是因为数组下标语法本质上是指针运算:ptr[n]
等价于 *(ptr + n)
。
具体来说:
Dest.At
是一个指向缓冲区当前位置的指针。Dest.At[-1]
等价于*(Dest.At - 1)
,也就是访问指针前一个位置的内存。- 这通常用于在缓冲区已经写满或循环结束时,把最后一个位置覆盖或修改,比如确保字符串以
\0
结尾。
可以理解为:通过下标可以向前或向后偏移任意整数,计算方式是偏移量 × 元素大小
,然后访问那个地址。
我想开始看这个系列 —— 有没有办法在 Mac OSX 上轻松编译/运行这些代码?
在 Mac OS X 上直接编译和运行这些代码比较困难,因为目前没有维护的 macOS 平台移植版本。移植工作量很大,需要重写或适配平台层的代码,所以暂时不会进行官方移植。
不过,有一些第三方实现可以参考:
- 在私有 GitHub 仓库中,有人提供了基于 SDL 的 macOS 移植版本,可以查看这些示例。
- 也存在一些较旧但仍可用的 SDL 移植版本,例如 Game Penguin 的 macOS SDL 端口,可以作为参考。
更多推荐
所有评论(0)