《游戏引擎架构》笔记3:游戏软件工程基础
本文是阅读《游戏引擎架构》第二章"专业工具”进行的归纳和总结,部分内容经过了AI的扩展。
- 菱形继承
- 结构:
1 | A |
- 问题1:
- 基类A成员有多份冗余(数据冗余)
- 二义性(访问A的成员歧义)
- 解决1:
- 虚继承
1 | class B : virtual public A {}; |
- 问题2:指针基类类型转换复杂,因为有多个vtable指针。
- 解决2:避免类似菱形继承这样的继承,可以选择继承多个没有父类的类。
-
宏是预处理器的文本替换,跨C/C++作用域和命名空间范围,命名空间里要小心宏的跨作用域行为。
-
定点数和浮点数
-
定点数:运算速度快,计算结果稳定,表示范围极小,大数小数不能同时存,固定比例缩放,极易溢出,无法自然表示极小/极大数值
-
浮点数:数值范围超大,能表示极小到极大数,适用通用计算,运算相对慢,存在精度丢失,大小数混合计算误差累积
- 基本数据类型
char:8位,不同编译器还分有符号和无符号int/short/long:int在32位系统是32位,64位系统是64位。short比int小,多数机器位16位,long大于等于int为32位或64位。float/double:多数为32位/64位。bool:不同编译器实现不同8位/32位,不会实现为1位。- 其他:
Visual Studio的C/C++需要知道某些变量的确切大小,定义扩展关键字声明特定位数变量:__int8、__int16、__int32、__int64。- C++11在
<cstdint>头文件定义了std::int8_t、std::int16_t、std::int32_t、std::int64_t、std::uint8_t、std::uint16_t、std::uint32_t、std::uint64_t,标准化特定大小整数类型。 - 游戏引擎也可以自己定义基本数据类型。比如Radian和Degree来包装描述角度单位。
- 小端序和大端序问题解决办法:
- 所有数据以文字方式写入文件。多字节数值以一串十进制数字或十六进制数字,每数字一个字节写入。缺点是浪费空间。
- 先用工具转换数据字节序,然后再把转换后的数据写进二进制文件。
- 采用固定字节序的文件,程序读取文件按需转换(比如JPEG固定以大端序存储)。
- 浮点数
float32使用union保存数据而不是进行强制转换int32后改变字节序。
- 链接规范:内部链接(
static)和外部链接(extern)
- 全局变量 / 普通全局函数
- 不加
static→ 外部链接 - 加
static→ 内部链接
- 不加
- 局部变量
- 永远无链接,只在函数内生效
const常量const int x:内部链接extern const int x:改成外部链接
extern作用- 声明外部链接的符号,告诉编译器:定义在别的文件
- 映像文件的内存布局:
- 代码段:可执行机器码
- 数据段:初始化全局及静态变量
- BSS段:未初始化全局和静态变量。C/C++明确定义,任何未初始化的全局变量和静态变量皆为零。不过与其在BSS段存储可能很大块的零值,链接器只需简单地存储所需零值的字节个数,足以安置此段内未初始化的全局及静态变量。当操作系统载入程序时,便会保留BSS段所需的字节个数,并为该部分内存填入零,之后才调用程序进入点。
- 只读数据段:只读(常量)全局变量。所有浮点常量(如
const float kPi =3.141592f;)及所有用const关键字声明的全局对象实例(如const Foo gReadOnlyFoo;)就隶属此段。编译器通常把整数常量(如const int kMaxMonsters =255;)视为明示常量(manifest constant),并且直接把明示常量插进机器码中。明示常量直接占用代码段的存储空间,而不存储于只读数据段。
- 对象的内存布局
extern声明的全局变量以及类声明内的类静态变量并不占用内存,必须在一个.cpp文件内定义类静态变量以分配内存。- 对齐和填充:
数据对象的对齐是指其内存地址是否为对齐字节大小的倍数(通常是2的幂次):- 1字节对齐的对象,可置于任何地址。
- 2字节对齐的对象,地址必须是2的倍数。
- 4字节对齐的对象,地址必须是4的倍数。
- 包含4个浮点数的SIMD矢量通常需要16字节对齐。
- 结构体末端可能加入填充字节,其对齐需求等同于其成员中的最大对齐需求。
- C++类的内存布局:
- 类的派生类会简单地把其数据成员附加到末端,即使类之间可能因对齐而加入填充。
- 虚函数会在类的布局添加4字节/8字节,通常放在类的最前端,称为虚表指针,指向名为虚函数表的数据结构,各个类的虚函数表包含该类声明或继承来的所有虚函数指针。每个非抽象类类有一个虚函数表,类的实例用虚表指针指向该虚函数表。
- 错误处理
- 异常会有一些开销:调用帧会变大,堆栈的开解通常较单纯返回函数慢。一个好的替代方案是在出错时返回错误值,并且显式处理这些错误。
- 断言:下面是在
Windows平台给的一个宏定义的断言例子:
1 |
|
- 链接器对代码的内存布局的影响:
- 单个函数的机器码几乎总是置于连续的内存中。在绝大多数情况下,链接器不会把一个函数切开,并在中间放置另一个函数。
- 编译器和链接器按函数在翻译单元源代码(.cpp文件)中的出现次序排列内存布局。即位于一个翻译单元内的函数总是置于连续内存中。即链接器永不会把已编译的翻译单元切开,中间插入其他翻译单元的代码。
- 降低指令缓存不命中率:
- 高效能代码的体积越小越好,体积以机器码指令数目为单位。(编译器和链接器会负责把函数置于连续内存中。)
- 若要调用某函数,就把该函数置于最接近调用函数的地方,最好是紧接调用函数的前后,而不要把该函数置于另一翻译单元(因为这样会完全无法控制两个函数的距离)。
- 谨慎地使用内联函数。内联小型函数能增进效能。然而,过多的内联会增大代码体积,使性能关键代码再不能完全装进缓存。
- CPU指令流水线问题:
- 数据依赖造成的流水线停顿(气泡)。
- 分支预测。
- load-hit-store:如浮点数转换至整数,然后把结果用于之后的运算时产生停顿,因为CPU在将
float转换至int时,无法直接把数据从浮点寄存器传送至整数寄存器。因此,该结果必须从浮点寄存器写进内存,然后再从内存读进整数寄存器。