3.2 C++中的数据、代码和内存

本文是Game Engine Architecture的一部分
免责声明

3.2.1 数字的表示

在游戏引擎的开发中, 数字是我们所做的一切东西能运转起来的基础和关键(也包括一般情况下的软件开发)。每一位软件工程师都应了解计算机是如何表示和存储数字的。这一章会带给你阅读本书剩余章节需要的基本知识。

3.2.1.1 数字的进制

对大部分人来说,最常见的是十进制表示法。这种表示法使用了10个各不相同的数字(0到9),并且一个十进制数,从右到左它的每一个数字分别表示10的0至n-1次幂。例如,
7803 =(〖7×10〗^3 )+(〖8×10〗^2 )+(〖0×10〗^1 )+(〖3×10〗^0 )=7000+800+0+3。

在计算机科学中,整数和实数需要存储在计算机的内存中。并且我们知道,计算机用二进制存储数字,意味着只有0和1两个数字是可用的。我们称之为二进制表示法,因为二进制数从右到左的每一个数字分别表示2的0至n-1次幂;计算机科学家有时会用一个”0b”前缀来表示二进制数字。例如,0b1101=(〖1×2〗^3)+(〖1×2〗^2)+(〖0×2〗^1)+(〖1×2〗^0)=8+4+0+1=13。

另外一个通用的表示法是十六进制表示法。这种表示法使用了数字0到9和字母A到F;字母A到F分别代替了十进制数10到15。在C和C++中,用0x前缀来表示十六进制数。十六进制表示法之所以流行是因为计算机通常把数据分组存储,每组有8个位,也被称为字节。而由于一个十六进制数刚好占了4位,所以两个十六进制数等于一个字节。例如,0xFF=0b11111111=255 是单个字节可以存储的最大的数。十六进制数里的每一个数字,从右到左分别表示16的0至n-1次幂。如此,可以举一个例子来说明:0xB052=(11×〖16〗^3 )+(0×〖16〗^2 )+(5×〖16〗^1 )+(2×〖16〗^0 )=11×4096+0×256+5×16+2×1=45,138

3.2.1.2有符号和无符号整数

在计算机科学中,有符号和无符号的整数同时被使用。当然,“无符号整数”确实有点用词不当——在数学上,全数自然数的范围是从0(或1)到正无穷,但整数是从负无穷到正无穷。但在这本书中我们还是坚持用计算机术语“有符号整数”和“无符号整数”这两个词。

大部分现代个人计算机和游戏机,使用32位或64位宽的整数来做数学运算会表现得更出色(尽管8和16位整数也被大量用在游戏编程中)。要表示一个32位无符号整数,我们只需简单地用二进制来编码该值(见上文)。一个32位无符号整数的取值范围是0x00000000(0)到0XFFFFFFFF(4,294,967,295)。

要用32位表示一个有符号整数,我们需要一种能区分正/负数的方法。一种简单的方法就是把最高有效位保留下来作为符号位——当该位是0时,值为正;当是1时,值为负。这时我们能用剩余的31位来表示数字的绝对值大小,而绝对值的取值范围将被减半(但允许每一个绝对值有正负两种格式,包括0)。

大部分微处理器使用一种更轻量、更高效的技术——二进制补码表示法,来编码负整数。这种表示法中,数字0只有一种表示方法,尽管0 的符号位有两种表示形式(正0和负0)。在32位的二进制补码中,0xFFFFFFFF等于-1,是二进制补码表示法中最大的负数。最大有效位被设为1的任何数都是负数。所以,从0x00000000(0)到0x7FFFFFFF(2,147,483,647)表示正整数,而0x800000000(-2,147,483,648)到0xFFFFFFFF(-1)表示负整数。

3.2.1.3定点表示法

整数很适合用来表示全数,但对于分数和无理数,我们需要一个不同的格式来表示小数点这个概念。计算机科学家早期的解决方法是使用定点表示法。这种表示法中,可以任意选择使用多少个位来表示整数部分,而其余位用来表示小数部分。当我们从左到右数(即,从最高有效位到最低有效位),整数部分的每一位表示2的x次幂(…,16,8,4,2,1),而小数位则表示2的x次幂的倒数(1⁄2, 1⁄4, 1⁄8, 1⁄16,…)。
例如,用定点表示法(32位宽,且有一个符号位)来表示-173.25,16位用于表示整数部分,15位用于表示分数部分。我们首先把符号位、整数位部分、小数位部分分别转换成二进制等式(符号位是0b1,173=0b 0000 0000 1010 1101,0.25=1/4=0b 010 0000 0000 0000),然后把这3个值打包到一个32位的整数中,最终结果就是0x8056A000。如图3.4所示。
 图3.4。整数部分16位、分数部分15位的定点表示法。

定点表示法的问题是,它限制了可以表示的数字范围和小数部分可以达到的精度。考虑一个32位的定点数(1个符号位,整数部分16位,小数部分15位)的值,这种格式能表示的数字大小幅度是正负65,535(范围不是很大)。为了解决这个问题,我们采用了浮点表示法

3.2.1.4. 浮点表示法

在浮点表示法中,分数位的起点位置是任意的(根据指数的大小来确定)。一个浮点数分成三个部分:尾数(包含了小数点左右两侧的数字)、指数(指定小数点的位置)、符号位(表示正负)。在内存中有各种不同的标准来编排这三个部分,而最常见的标准是 IEEE-754。在该标准中,一个32位的浮点数的最高有效位是符号位,其次是占8位的指数,最后是占23位的尾数。

假如符号位是s,指数部分是e,尾数是m,那么浮点数v等于:v=〖s×2〗^((e-127))×(1+m) 。

符号位s有两个取值:+1或 -1。指数e被偏移了127以致负指数可以更容易地表示。以隐含的1(实际上这个1并没有储存在内存中)开头的尾数,它使用的剩余的位,会以2的幂次的倒数解释。这暗示了尾数其实是1+m,m是存储在尾数部分的小数。

图3.5。IEEE-754标准中的32位浮点数的格式。

例如,图3.5中的浮点数0.15625,因为它的s=0(暗示该浮点数是正数),e=0b0111 1100=124,并且m=0b0100…=0×2^(-1)+1×2^(-2)=1⁄4,因此,

浮点数绝对值范围和精度之间的权衡

浮点数的精度越大,绝对值的范围越小;反之亦然。这是因为尾数部分的位数量是固定的,这些位必须被数字的整数部分和小数部分共享。如果把更多的位用来表示更大的绝对值,那么就只有更少的位可以被用来保证小数的精确度。在物理层面,通常用有效数字这个词来描述这个概念。(http://en.wikipedia.org/wiki/Significant_digits)

要了解绝对值范围和精度之间的权衡,让我们来看下32位浮点数可能取到的最大值:FLT_MAX≈3.403×〖10〗^38,在32位IEEE浮点数格式下是0x7F7F FFFF。让我们再作以下分解:

  •      一个用23位来表示尾数的32位浮点数的最大绝对值是0x00FF FFFF,或说是连续的24个二进制1(尾数是23个1,再加上隐含的前置1)。
  •      在IEEE-754格式中,值为255的指数有特殊含义——它被用于一些特殊的值,如“非数字值”(NaN)和“无穷”(INF)——所以它不能被用来表示常规的浮点数。于是,8位指数的最大取值是254。再经过隐式的偏移(减127)后,它的值被限定在[-127,127]。

所以FLT_MAX=0x00FF FFFF×2^127=0xFFFF FF00 0000 0000 0000 0000 0000 0000。

换句话说,FLT_MAX的24个二进制数1被往左偏移了127位,而在尾数部分的最低有效位后面还有127 – 23 = 104个隐含的二进制0(或者104/4 = 26个十六进制0)。在我们的32位的浮点数中,这些后置0不对应任何实际的位——他们是因为指数的关系而凭空产生的。如果FLT_MAX减去一个小的数字(这里的“小”是指任何少于26个十六进制数组成的浮点数),结果仍然会是FLT_MAX,这是因为FLT_MAX的26个最低有效十六进制数字实际上并不存在!

当一个浮点数的绝对值大小小于1的时候会出现反面效果。在这种情况下,指数是一个很大的负值,导致有效数字往反向偏移,从而出现精度丢失。在更高的精确度和表示更大的绝对值之间,我们需要做一个权衡。总的来说,在我们的浮点数中有效数字(或有效位)总个数是相同的,指数可以被用来把那些有效位偏移到更高或更低的范围。

另一个要注意的是,在零和最小非零值之间有一个微小的差距(可以用任何浮点表示法来表示)。最小的非零值FLT_MIN是2^(-126)≈1.175×〖10〗^(-38),用十六进制表示是0x00900000(即,指数是0x01或者是-126当减去偏移值127后;并且尾数都是0,除了前置的1)。没有其他方法能表示一个比1.175×〖10〗^(-38)还要小的非零绝对值,因为下一个最小的有效数值就是0。等而言之,当使用浮点表示法的时候,实数线被量化了。

对于一个特定的浮点数表示法,当满足等式1+ε≠1的时候,ε被定义为最小的浮点数。对于一个有23位精度的IEEE-754浮点数,ε的值是2^(-23)(≈1.192×〖10〗^(-7))。ε的最大有效数值正好落在1.0的范围内,因此想通过增加任何比ε小的值来逼近1.0是没有任何作用的。换言之,当我们尝试补充只有23位的尾数的值时,任何用来减少ε的值的新的位会“砍断”。

有限的精度和ε的概念,对游戏软件具有实际的影响。例如,假设当我们用一个浮点变量来跟踪绝对游戏时间(秒)。在这个时钟变量的大小变得如此之大,以致即使增加1/30秒也改变不了它的值之前,我们的游戏究竟能运行多久?答案是大约12.9天。这比大多数游戏的运行时间都长,所以我们也许可以侥幸地在一个游戏中正常地使用一个32位的浮点数作为秒计时器。但更重要的是,需要能够理解浮点格式的限制,从而使我们可以预测潜在的问题,并且在必要的时候可以采取措施避免出现问题。

IEE浮点数位技巧

See[7], Section 2.1,一些真实有用的IEEE浮点数“位技巧“可以使浮点数计算快如闪电。

3.2.1.5 原子数据类型

正如你所知道的,C和C ++提供了许多原子数据类型。在这些数据类型的相对大小和符号问题上,C和C++标准提供了指引,但每个编译器可以自由地、略有不同地定义这些类型,以便在目标硬件上提供最高的性能。

  • 字符(char)。一个字符通常占8位,一般来说存放一个ASCII或者UTF-8字符8位已经足够大(见Section 5.4.4.1)。一些编译器定义了字符是有符号的,而其他一些编译器缺省情况下使用无符号字符。
  • 整型(int),短整型(short),长整型(long)。一个int被用来放置一个对目标平台来说,最高效大小的有符号整数。一般情况下奔腾系列PC机的int是32位宽。short在许多机器上是16位。Long的位长度依赖于硬件,通常是32位或64位。
  • 单精度浮点型(float)。在现代的大部分编译器中,float是32位IEEE-754浮点数。
  • 双精度浮点型(double)。double类型的精度双倍于IEEE-754浮点数(即,64位)。
  • 布尔型(bool)。布尔型变量只有真(true)/假(false)两种值。布尔变量的大小在不同的编译器和硬件架构下基本相同。Bool从来不会只用一个位来实现,但有一些编译器定义bool型为8位大小而其他的编译器则是32位。

编译器定义的大小类型

标准C/C++中的原子数据类型被设计为可移植的,因此它们是非特异性的。然而,在许多软件工程的工作中,包括游戏引擎编程,知道一个特定的变量究竟有多宽是很重要的。Visual Studio C/C++编译器定义了以下用于声明具有显式位宽度的变量的扩展关键字:__ int8,__ INT16,__ int32和__int64。

SIMD 类型

许多现代计算机和游戏机上的CPU都有一个特殊的算术逻辑单元(ALU),被称为向量处理器矢量单元。向量处理器支持一种可并行处理的单指令、多数据形式(SIMD),在其中进行的并行计算操作只用一个机器语言指令。为了让矢量单元可以处理数据,两个或多个单位数据被打包到一个64位或128位的CPU寄存器。在游戏编程中,最常用的SIMD寄存器格式,打包4个32位IEEE-754浮点数到一个128位的SIMD寄存器。该格式使我们能够比SISD(单指令、单数据)ALU更加有效地进行计算(如向量的点积和矩阵乘法)。

每个微处理器的SIMD指令集的名称各不相同,针对这些微处理器的编译器,使用自定义的语法用来声明SIMD变量。例如,对于奔腾类型的CPU,有一套被称为SSE(SIMD流指令扩展)的SIMD指令集,Microsot Visual Studio编译器为用户提供了内建数据类型__ m128来表示4个float大小的 SIMD 单位量。在PlayStation3和Xbox 360上使用的PowerPC型号的CPU,它的SIMD指令集被称为Altivec, GNU C ++编译器使用语法vector float来声明一个被打包的4个float的SIMD变量。我们将在4.7节中更详细地讨论SIMD编程是如何工作的。

可移植的类型

大多数编译器都拥有自己的“规定了大小”的各种数据类型,这些类型有相似的语义,但语法上仍有一些区别。由于这些编译器之间的差异,大多数游戏引擎通过定义自己的原子数据类型,来实现源代码的可移植性。例如,在Naughty Dog的游戏引擎中,我们使用下面的原子类型:

  • F32 是32位的IEEE-754浮点型。
  • U8,I8,U16,I16,U32,I32,U64,I64分别是无符号、有符号的8,16,32,64位的整数。
  • U32F和I32F分别是“速度快”的无符号和有符号的32位值。虽然这两个数据类型包含的都是32位的值, 但实际占用了64位内存。这使得PS3的核心——基于PowerPC的处理器(称为PPU)可以直接读写这些变量到64位的寄存器,比起32位变量的读写,会得到速度上的显著提升。
  • VF32 表示一个由4个float变量组成的SIMD值。

Ogre的原子数据类型

Ogre定义了自己的一套原子类型。Ogre::unit8,Ogre::uint16和Ogre::uint32是基本的无符号整型。

Ogre::Real 定义了一个实数浮点型。它通常被定义为32位的大小(相当于一个float),但通过改变预处理器宏OGRE_DOUBLE_PRECISION使之等于1, 它可以在全局范围内被重新定义为64位宽(就像一个double)。改变Ogre::Real的位宽度大小,一般只出现在当一个游戏对双精度数学运算有特别要求时。图形芯片(GPUs)总是用32位或16位浮点数做数学运算;当用单精度浮点数时,CPU / FPU做数学运算会更快,并且SIMD向量指令也是工作在包含4个32位float型的128位的寄存器之上。因此,大多数游戏往往更倾向于用单精度浮点数做运算。

数据类型Ogre::uchar,Ogre::ushort,Ogre::uint和Ogre::ulong分别是C++中的unsigned char,unsigned short和unsigned long的速记符号。因此,他们并没有比他们各自对应的原生C / C ++类型更有用。
Ogre::Radian和Ogre::Degree是特别有趣的。这两个类是一个简单类型Ogre::Real的封装。这两个类型的主要作用是,允许硬编码的字面常量的角度单位可以更加文档化(如,90度肯定能比1.57弧度更直观地表示直角),并提供两个单位系统之间的自动转换。此外,Ogre:: Angle代表了当前程序的默认的角度单元的角度大小。当OGRE应用程序第一次启动时,程序员可以自定义缺省值是‘弧度’还是‘度’。
也许令人惊讶的是,OGRE并不提供一些固定大小的原子数据类型,而这类型些在其他游戏引擎是司空见惯的。例如,它没有定义有符号的8,16或64位整型。如果你正在基于Ogre编写一个游戏引擎,有一天你可能会发现自己正在手动定义这些类型。

3.2.1.6 多字节值和字节序

大于8位(一个字节)宽的值被称为多字节量。在任何使用了16位或更大位宽的整数和浮点数的软件项目里这很常见。例如,4660=0x1234的整数值,用0X12和0x34两个字节表示。我们称0X12为最高有效字节(MSB),0x34为最低有效字节(LSB)。在一个32位变量里,如0xABCD1234,MSB是0xAB,LSB是0x34。同样的概念也适用于64位整数、32位和64位浮点值。

多字节的整数,可以用以下两种方式之一存放到内存中,并且不同的微处理器所选择的存储方法可能会有不同(见图3.6)。

 图3.6.0Xabcd1234的高字节序和低字节序表示法。

  • 低字节序(little-endian)。如果微处理器是在较低的内存地址存储多字节值的最低有效字节(LSB),而不是最高有效字节(MSB),我们称该处理器是低字节序的。在低字节序的机器上,整数0xABCD1234将按照连续的字节0x34,0X12,0xCD,0xAB存放。
  • 高字节序(Big-endian)。如果微处理器是在较低的内存地址存储多字节值的最高有效字节(MSB),而不是最低有效字节(LSB),我们称该处理器是高字节序的。在高字节序的机器上,整数0xABCD1234将按照连续的字节0xAB,0xCD,0X12,0x34存放。

大部分的程序员都不需要去考虑字节排列顺序。然而,当你是一个游戏程序员时,排列顺序可能会变成你身边的一根刺。这是因为游戏通常是在使用Intel奔腾处理器(低字节序)的PC或Linux机器上运行的,但运行在一个游戏主机上时可能会出问题,如Wii,Xbox 360,PS3——这三个主机使用的都是PowerPC处理器(它使用的字节顺序是可以被配置的,但默认情况下是高字节序)的某一个变种。现在想象会发生什么,当你用你的游戏引擎(使用了Intel处理器)生成待使用的数据文件,然后尝试在PowerPC处理器上加载这个数据文件。任何写进该数据文件的多字节值将按照低字节序格式的存储。但是,当游戏引擎读取该文件,它希望文件里的所有数据是按照高字节序格式存储的。结果会如何?当你写下0xABCD1234,却被读成0x3412CDAB,这显然不是你所希望的!

对于这个问题,至少有两个解决方案:

  1. 你可以把所有的数据文件转存成文本文件,并把所有的多字节数字存储成一系列有序的十进制数,每一个字符(一个字节)对应一个数字。这将是一种磁盘空间的低效用法,但这样确实可行。
  2. 你可以使用一些字节交换工具,在把数据存放到二进制文件之前做一次处理。这样做的话,你可以确保数据文件使用了目标微处理器(游戏机)的字节顺序,即使是运行在一台使用相反字节顺序的机器上。

整数的端字节交换

一个整数的端字节交换,其实并不复杂。您只需从整数值的最高有效字节的位置开始遍历,与对应的最低有效字节交换;继续这样子处理,直到到达中间点。例如,0xA7891023经过两次交换就变成了0x231089A7。

唯一棘手的部分是要知道哪些字节需要交换。比方说,你正在把一个C结构的内容或C ++类对象从内存写到文件。要正确地转换这些数据的字节顺序,你需要跟踪每个数据成员在结构体中的位置和它的大小,并根据它的大小作适当的交换。例如,这个结构:

struct Example
{
U32 m_a;
U16 m_b;
U32 m_c;
};

可能会被这样子写入到一个数据文件:

void writeExampleStruct(Example& ex, Stream& stream)
{
stream.writeU32(swapU32(ex.m_a));
stream.writeU16(swapU16(ex.m_b));
stream.writeU32(swapU32(ex.m_c));
}

交换函数可能是下面这样:

inline U16 swapU16(U16 value)
{
return ((value & 0x00FF) << 8)
| ((value & 0xFF00) >> 8);
}
inline U32 swapU32(U32 value)
{
return ((value & 0x000000FF) << 24)
| ((value & 0x0000FF00) << 8)
| ((value & 0x00FF0000) >> 8)
| ((value & 0xFF000000) >> 24);
}

你不能简单地把示例对象当做一个字节数组,并且盲目地使用一个只具备通用目的的函数来交换字节。我们需要知道哪些数据成员需要交换和每个成员的位宽度;并且每个数据成员必须单独交换。

浮点数端字节交换

让我们简要地看下浮点数端字节交换与整数端字节交换的不同。正如我们所看到的,IEEE- 754浮点值有一个详细的内部的结构,包括一些尾数位,一些指数位,和一个符号位。然而,你可以像整数一样对它做端字节交换,因为字节始终是字节。你可以使用C++的reinterpret_cast操作符操作一个浮点数指针,把浮点数重新解释为整数;这称为类型双关。但当严格别名被启用时,类型双关会导致编译器做优化的时候出现bug。(参见http://www.cocoawithlove.com/2008/04/using-pointers-to-recast-in-c-is-bad.html是对这个问题的很好的描述)。一个更方便的方法是使用一个union,如下:

union U32F32
{
U32 m_asU32;
F32 m_asF32;
};
inline F32 swapF32(F32 value)
{
U32F32 u;
u.m_asF32 = value;
// endian-swap as integer
u.m_asU32 = swapU32(u.m_asU32);
return u.m_asF32;
}

3.2.2 声明,定义和链接

3.2.2.1 编译单元再探

正如我们在第2章中所看到的,一个C或C ++程序由编译单元构成。编译器每次只编译一个.cpp文件,每一个由经过编译后输出的文件称为目标文件(.o或.obj)。.cpp文件是编译器做编译操作的最小单位。一个目标文件中不仅包含了.cpp文件中定义的所有函数编译后的机器代码,还包括所有的全局变量和静态变量。此外,目标文件可能还包含未被解析的函数引用以及在其他.cpp文件中定义的全局变量。

 图3.7.两个编译单元中的未被解析的外部引用。

 图3.8.在成功链接后外部引用全部被正常解析。

 图3.9.两个最常见的链接错误。

编译器每次只对一个编译单元进行操作,所以每当操作一个编译单元时,遇到一个引用了外部的全局变量或函数的引用,编译器必须相信、假设这个存有疑问的实体是确实存在的,如图3.7所示。把所有的目标文件合成一个最终的可执行映像是链接器的工作。在合成的过程中,链接器读取所有的目标文件,并尝试解决、解析目标文件之间所有的交叉引用。如果成功完成链接操作,一个包含所有的函数、全局变量和静态变量的可执行映像就生成了,并且所有跨编译单元的引用已被妥善解决。如图3.8。

连接器的主要工作是解决外部引用,而在这件工作上,它只可能产生两种类型的错误:

  1. 外部引用的目标可能不存在,在这种情况下,链接器会生成一个“未解决的符号(unresolved symbol)”错误。
  2. 链接器可能会找到一个以上的具有相同的名称的变量或函数,在这种情况下,它会产生一个“多重定义的符号(multiply defined symbol)”错误。

这两种情况如图3.9所示。

3.2.2.2 声明与定义

在C和C ++语言,变量和函数在使用前必须先声明和定义。理解C和C++中声明和定义之间的差别非常重要。

  • 声明是一个对象或函数的描述。它为编译器提供了实体的名称和它的数据类型或函数签名(即,返回类型和参数类型)。
  • 而在另一方面,定义描述了程序中一段独一无二的内存区域。这块内存可能包含一个变量,或结构或类的一个实例,或一个函数的机器代码。

换句话说,声明是对实体的引用,而定义是实体本身。定义总是一种声明,但反过来,情况就并非总是如此了 —— 你可以在C和C ++中写一个纯粹的声明,但又不是一个定义。

定义一个函数的方法是,在函数签名后面直接写上函数的内容,并用大括号括上:

//foo.cpp
// definition of the max() function
int max(int a, int b)
{
return (a > b) ? a : b;
}
// definition of the min() function
int min(int a, int b)
{
return (a <= b) ? a : b;
}

在头文件中可以提供函数的纯声明,使它可以被用在其他编译单元中(或在同一个编译单元的下文中)。做法就是,写一个函数签名并在后面加一个分号,前面是一个可选的前缀extern:

//foo.h
extern int max(int a, int b); // a function declaration
int min(int a, int b); // also a declaration (the
// ‘e xtern’ is optional/
// assumed)

定义类/结构体的变量或实例的方法是,在变量或实例名字的前面写上数据类型,以及一个可选的数组说明符(用方括号):

//foo.cpp
// All of these are variable definitions:
U32 gGlobalInteger = 5;
F32 gGlobalFloatArray[16];
MyClass gGlobalInstance;

在一个编译单元里定义的一个全局变量,若使用extern关键字来声明,就可以被用在其他编译单元中,

//foo.h
// These are all pure declarations:
extern U32 gGlobalInteger;
extern F32 gGlobalFloatArray[16];
extern MyClass gGlobalInstance;

多重声明和定义

毫不奇怪, 在一个C / C++程序中任何特定的数据对象或函数可以有多个相同的声明, 但每个对象或函数只能有一个定义。如果一个编译单元存在两个或更多相同的定义,编译器会注意到多个实体具有相同的名称,进而会报告一个错误。如果不同的编译单元中存在两个或更多相同的定义,编译器将无法识别该问题,因为它一次只编译一个编译单元。但在这种情况下,链接器就会给我们一个“多重符号定义“错误,当链接器试图处理相互引用时。

头文件里的定义和内联

把定义放在头文件里是一个危险的做法。其原因应该是很明显的:如果包含一个定义的一个头文件,被多个.cpp文件包含, 必然会生成一个“多重定义的符号”的链接错误。

内联函数里的定义是这个规则的一个例外,因为每次调用一个内联函数会产生这个函数的机器代码的新的拷贝,并且该段代码拷贝会直接嵌入到调用函数的机器代码段中。事实上,内联函数定义必须放置在头文件中,如果他们被不止一个编译单元使用。请注意,在.h文件里的内联关键字inline它并不足以标记一个函数声明,并且使在.cpp文件里该函数的实体代码可以被拷贝到其他的.cpp文件中。编译器必须能够“看到”函数的主体以内联它。例如:

//foo.h
// This function definition will be inlined properly.
inline int max(int a, int b)
{
return (a > b) ? a : b;
}
// This declaration cannot be inlined because the
// compiler cannot “see” the body of the function.
inline int min(int a, int b);
//foo.cpp
// The body of min() is effectively “hidden” from the
// compiler, and so it can ONLY be inlined within
// foo.cpp.
int min(int a, int b)
{
return (a <= b) ? a : b;
}

inline关键字只是给编译器的一个提示。它对每一个内联函数做成本/效益分析,衡量内联该函数会导致的机器代码量的增加与所能获得的潜在的性能优势,并且对于内不内联这个函数编译器有最终的决定权。有些编译器提供了一些语法,像 __forceinline,允许程序员绕过编译器的成本/效益分析,强制让函数直接内联。

3.2.2.3 链接

在C和C++中,每个定义都有一个称为链接的属性。有外部链接的定义除了在原来的编译单元,该定义对其他编译单元也是可见的并且是可以引用的。一个有内部链接的定义可以只在自己的编译单元内可见,但不能被其他编译单元链接。这个属性被叫做链接,是因为它规定了连接器是否可以交叉引用该实体这个问题。因此,从某种意义上说,在编译单元中的C++类定义里的public:和private:关键字就相当于在定义链接类型。

默认情况下,定义都有外部链接。static关键字用来把一个定义的链接方式改为内部链接。请注意,在两个或更多的不同.cpp文件里的两个或更多个相同的静态定义,链接器会认为是不同的实体(就好像它们被赋予了不同的名称),这样这些同类就不会产生“多重定义的符号”错误。下面是一些例子:

//foo.cpp
// This variable can be used by other .cpp files
// (external linkage).
U32 gExternalVariable;
// This variable is only usable within foo.cpp (internal
// linkage).
static U32 gInternalVariable;
// This function can be called from other .cpp files
// (external linkage).
void externalFunction()
{
// ...
}
// This function can only be called from within foo.cpp
// (internal linkage).
static void internalFunction()
{
// ...
}
//bar.cpp
// This declaration grants access to foo.cpp’s variable.
extern U32 gExternalVariable;
// This ‘gInternalVariable’ is distinct from the one
// defined in foo.cpp – no error. We could just as
// well have named it gInternalVariableForBarCpp – the
// net effect is the same.
static U32 gInternalVariable;
// This function is distinct from foo.cpp’s
// version – no error. It acts as if we had named it
// internalFunctionForBarCpp().
static void internalFunction()
{
// ...
}
// ERROR – multiply defined symbol!
void externalFunction()
{
// ...
}

从技术层面来讲,声明是不具有链接属性的,因为这些链接属性在可执行文件映像中并不分配任何存储空间;因此,在编译单元的时候,根本无需考虑是否允许连接器交叉引用该片存储空间。一个声明仅仅是某个单元里定义的某个实体的引用。然而,有内部链接的声明有时候更有利,是因为这种声明可以确定只适用于它出现的编译单元。如果我们允许放宽我们的术语的严谨性,那么所有声明总是有内部链接——没有办法可以在多个cpp文件里交叉引用一个唯一的内部链接声明。(如果我们把一个声明放到头文件中,多个cpp文件可以“看到”这个声明,而每个引用都会产生一个该声明变量的实体拷贝,每一个拷贝都有一份在编译单元内的内部链接。)这也告诉了我们为什么头文件里会允许内联函数定义:这是因为内联函数默认是有内部链接的,就好像他们已经被声明为静态的。如果多个.cpp文件#include了一个包含一个内联函数定义的头文件,每个编译单元将获得该内联函数代码段的私有拷贝,并且不会有“符号多重定义”错误的产生。链接器将每个拷贝看做一个独立的实体。

3.2.3 C / C++内存布局

一个用C或C++编写的程序会将程序数据储存在内存中的各个地方。为了理解内存是如何分配的、各种类型的C / C++变量是如何工作的,我们需要理解一个C / C++程序的内存布局。

3.2.3.1 可执行映像

当一个C / C++程序被构建,链接器会创建出一个可执行文件。大多数类unix的操作系统,包括许多游戏机中的系统,采用一个流行的可执行文件格式,称为可执行和链接格式(executable and linking format, ELF)。因此在这些系统中可执行文件都有一个.elf扩展名。Windows可执行的格式类似于ELF格式; 在Windows中可执行文件有一个.exe的扩展名。无论可执行文件的格式如何,它总是包含程序的一部分映像,并且当它运行时该映像总驻留在内存中。之所以说是一部分映像,是因为程序通常在运行时分配内存,但除此之外还有一部分内存是本来就在可执行映像里的。

可执行映像内部被切分为连续的程序段。每个操作系统的布局方式并不一致,甚至相同的操作系统中的不同可执行文件在内存布局上可能还稍有不同。但一个可执行映像通常由至少以下四个部分组成:

  1. 文本段。有时被称为代码段,这个块包含程序定义的所有函数的可执行机器代码。
  2. 数据段。这个段包含了所有初始化了的全局和静态变量。存放所有全局变量的内存,是按照程序运行时的状态被预先准确布局的,并且已为每个全局变量赋予了适当的初始值。所以当可执行文件被加载到内存中,这些早已被初始化了的全局和静态变量就可以被访问了。
  3. BSS段。“BSS”是一个过时的名字,它代表”Block started by symbol”。该片段包含所有未初始化的全局和静态变量。C和C++语言显式地定义任何未被初始化的全局或静态变量的初始值为0。与其在BSS段中储存一个可能会非常庞大的只包含0值的块,链接器仅仅储存了所有未初始化的全局和静态变量需要的0值字节的数量。当可执行文件被加载到内存,操作系统预留BSS段所请求的0值字节数,并在调用程序的入口点之前填入0值到整个段。(如main()或. WinMain())。
  4. 只读数据段。有时也被称为rodata段,这个段包含任何由程序定义的只读的(常量)全局数据。例如,所有的浮点数常量(例如,Pi=3.141592f),所有用const关键字声明的全局对象实例(例如,const Foo gReadOnlyFoo;)。注意,整数常量(例如,const int kMaxMonsters= 255)通常被编译器用作清单常量,也就是说它们被直接嵌入在所有调用了它们的机器代码段中。这类常量会被放到文本段,但他们并不存在于只读数据段。

全局变量,即在文件范围中、但在任何函数或类声明以外定义的变量,被存储在数据或BSS段,具体只取决于他们是否已经被初始化。下面的全局变量将存储在数据段,因为它已经被初始化:

//foo.cpp
F32 gInitializedGlobal = -2.0f;

而下面的全局对象, 将在BSS段给出的对它的定义的基础上,被操作系统分配内存并初始化为0,因为程序员没有对它进行初始化:

//foo.cpp
F32 gUninitializedGlobal;

我们已经看到,可以使用static关键字给一个全局变量或函数定义内部链接,这意味着它对其他编译单元来说是“看不见”的。static关键字也可以被用来声明一个函数中的全局变量。一个函数里的静态变量因为词法作用域规则,限制了只在声明了它的函数内可见 (即,变量的名字只能在函数里被“看到”)。当函数第一次被调用时,该静态变量被初始化(而不是在main()前,如文件范围级别的静态变量)。但在可执行映像中的内存布局,一个函数静态变量的行为与一个文件静态全局变量完全相同——它要么存储在数据段要么存储在BSS段,取决于它是否已经被初始化。

void readHitchhikersGuide(U32 book)
{
static U32 sBooksInTheTrilogy = 5; // data segment
static U32 sBooksRead; // BSS segment
// ...
}

3.2.3.2 程序栈

当一个可执行程序被加载到内存中并运行,操作系统会为程序栈保留一块内存区域。每当一个函数被调用时,栈内存的一个连续区域被压入栈——我们称此内存块为一个栈帧。如果函数a()调用另一个函数b(),一个新的属于b()的栈帧会被压进a()的栈帧之上。当b()返回时,它的栈帧被弹出,并且程序会从a()离开的地方继续执行。栈帧存储三种类型的数据:

  1. 它存储调用函数的返回地址,这样当被调用的函数返回时,程序就可以继续执行调用函数的剩余代码。
  2. 所有相关的CPU寄存器的内容都保存在栈帧中。这使得新函数可以以它合适的任何方式去使用寄存器,而不必担心覆盖调用函数所需要的数据。返回到调用函数时,寄存器的状态会被恢复,使调用函数可以继续执行。被调用的函数的返回值,如果有的话,通常是留在一个特定的寄存器,以便调用函数可以检索,而其他寄存器会恢复到原来的值。
  3. 栈帧还包含函数内部声明的所有局部变量,也被称为自动变量。这使得每一个不同的函数调用,可以自己维护这些局部变量的一份私有拷贝,即使是那些调用自身的递归函数。(在实践中,一些局部变量实际上是被分配到CPU的寄存器中,而不是存储在栈帧中,但在大多数情况下,这些变量是被分配在函数的栈帧内)。例如:


图3.10 栈帧

void someFunction()
{
U32 anInteger;
// ...
}

栈帧的入栈和出栈操作,通常是通过调节CPU中一个单一的寄存器的值(栈指针)来实现的。图3.10说明了当以下这些函数被执行时的情况:

void c()
{
U32 localC1;
// ...
}
F32 b()
{
F32 localB1;
I32 localB2;
// ...
c(); // call function c()
// ...
return localB1;
}
void a()
{
U32 aLocalsA1[5];
// ...
F32 localA2 = b(); // call function b()
// ...
}

当一个包含局部变量的函数返回时,它的栈帧被丢弃,并且该函数内的所有局部变量被当作不再存在。从技术层面来说,这些变量所占用的内存仍然存留在废弃的栈帧——但是该内存将很有可能会被另外一个函数调用所覆盖。返回一个局部变量的地址这种常见性的错误,像这样:

U32* getMeaningOfLife()
{
U32 anInteger = 42;
return &anInteger;
}

可能会得到你想要得到的值,如果你立即使用返回的指针并且在此期间不调用任何其他的功能。但情况往往没有这么美好,像这样的代码会导致崩溃——而且是以难以调试的方式。

3.2.3.3 动态分配堆

到目前为止,我们已经看到了一个程序的数据可以被存储为全局或静态变量或局部变量。全局变量和静态变量被分配在可执行文件的映像中。局部变量在程序栈上被分配。这些两种存储类型是被静态定义的,也就是说,当程序代码被编译和链接的时候,内存的大小和布局已经知道了。然而,一个程序的内存需求往往不能在编译时期确定。一个程序通常需要动态地分配额外的内存。

要允许动态内存分配,操作系统需要维护一个内存块,使得程序可以通过调用malloc()分配得到内存,并通过调用free()释放内存,使内存返回到内存池中以供其他程序使用。此内存块被称为堆内存,或是自由存储区。当我们动态分配内存时,我们有时会说该内存驻留在堆上。

在C ++中,全局new和delete运算符用于从堆中分配和释放内存。但要小心,C++类可以重载这些操作符来自定义分配内存的方式,甚至是全局的new和delete运算符也是可以被重载的,所以你不能简单地认为,new运算符总是从堆中分配内存。

我们将在第6章中更深入地讨论动态内存分配。如需了解详细信息,请参阅http://en.wikipedia.org/wiki/Dynamic_memory_allocation

3.2.4 成员变量

C结构和C++类中可以把变量划分成多个逻辑单元。重要的是要记住,一个类或结构的声明并不分配内存。这仅仅是一个数据布局的描述——用一刀切的方法来分离该结构或类的成员实例。例如:

struct Foo // struct declaration
{
U32 mUnsignedValue;
F32 mFloatValue;
bool mBooleanValue;
};

一旦一个结构或类被声明,该类的实例可以以分配一个原子数据类型的内存一样的方式被分配内存(定义),例如,

  • 作为程序栈上的自动变量;
void someFunction()
{
Foo localFoo;
// ...
}
  • 作为一个全局静态、文件范围静态或函数内静态的变量;
Foo gFoo;
static Foo sFoo;
void someFunction()
{
static Foo sLocalFoo;
// ...
}
  • 从堆中动态分配。在这种情况下,用于保存数据地址的指针或引用,本身可以被分配为一个局部、全局、静态甚至是动态的变量。
Foo* gpFoo = NULL; // global pointer to a Foo
void someFunction()
{
// allocate a Foo instance from the heap
gpFoo = new Foo;
// ...
// allocate another Foo, assign to local
// pointer
Foo* pAnotherFoo = new Foo;
// ...
// allocate a POINTER to a Foo from the heap
Foo** ppFoo = new Foo*;
(*ppFoo) = pAnotherFoo;

3.2.4.1. 类内部的静态成员

正如我们已经看到的,static关键字有很多不同的含义,具体取决于上下文:

  • 在文件范围内使用时,静态的意思是“限制该变量或函数的可见性,使其只能在.cpp文件内可见。”
  • 在函数范围内使用时,静态的意思是“这个变量是全局的,而不是局部的,但它只能在这个函数内可见”
  • 在一个结构或类里面使用时,静态的意思是“这个变量不是一个普通的成员变量,而更像是一个全局变量。”

注意,当在类内部声明一个静态变量,它没有控制变量的可见性(就如同它使用的是文件范围)——相反,它可以区分每个类实例的成员变量和每个如同全局变量一样的类变量。一个类的静态变量的可见性,是通过在类内部用public:,protected:或private:这三个关键字的声明来决定的。类的静态变量会自动包含在类或结构的名称空间中。所以当它在类或结构外部使用时,必须前置类或结构的名称来消除变量名的歧义。(如,Foo::sVarName)。

像用extern来声明一个普通的全局变量一样,在一个类中声明一个类的静态变量并不分配内存。类内部的静态变量必须在.cpp文件中定义后才会有内存分配。例如:

//foo.h
class Foo
{
public:
static F32 sClassStatic; // allocates no
// memory!
};
//foo.cpp
F32 Foo::sClassStatic = -1.0f; // define memory and
// init

3.2.5 对象在内存中的布局

让类和结构的内存布局可视化是很有用的。这通常是很简单的,我们可以简单地画一个结构或类的框图,用水平线分隔数据成员。下图的structFoo就是这样一个例子,如图3.11所示。

struct Foo
{
U32 mUnsignedValue;
F32 mFloatValue;
I32 mSignedValue;
};

数据成员的大小非常重要,应该在你的图表中标示出来。这并不难实现,用每个数据成员的宽度来表明它的大小(位数)——即,一个32位的整数大约是一个8位整数的宽度的4倍(见图3.12)。

struct Bar
{
U32 mUnsignedValue;
F32 mFloatValue;
bool mBooleanValue; // diagram assumes this is 8 bits
};

3.2.5.1 对齐和包装

当我们开始更仔细地思考结构和类在内存中的布局时,我们可能会开始好奇,当大小较小的数据成员中间穿插着较大的成员,会发生什么事。例如:

struct InefficientPacking
{
U32 mU1; // 32 bits
F32 mF2; // 32 bits
U8 mB3; // 8 bits
I32 mI4; // 32 bits
bool mB5; // 8 bits
char* mP6; // 32 bits
};

你可能会想象,编译器只是简单地将数据成员打包丢进内存,并且放得尽可能地紧密。然而这只是通常的情况。反而,编译器通常会在内存布局中留下一些“洞”,如图3.13所示。(一些编译器允许程序员通过使用预处理器指令请求不留下这些空洞,像#pragma pack,或通过命令行选项;但编译器默认的行为是简单地隔开所有的成员,如图3.13所示)。

为什么编译器要留这些“空洞”?原因在于,每个数据类型都有一个值得重视的对齐需求,以允许CPU更高效地读取和写入内存。一个数据对象的对齐,是指它在存储器中的地址是否是它的大小的倍数(通常是2的n次幂):

  • 以1个字节对齐的对象可以驻留在任何内存地址。
  • 以2个字节对齐的对象,仅驻留在偶数地址(即地址的最低有效四位是0x0,0X2,0x4,0x8,0xA,0xC,0xE)。
  • 4字节对齐的对象,仅驻留在4的倍数的地址(即地址的最低有效四位的是0x0,0x4,0x8, 0xC)。
  • 一个16字节对齐的对象,仅驻留在16的倍数的地址(即地址的最低有效四位是0x0)。

对齐是非常重要的,因为许多现代的处理器,实际上只可以读取和写入正确对齐的数据块。例如,如果一个程序请求从地址0x6A341174读取一个32位(4字节)整数,存储控制器将会轻松地加载数据,因为地址是四字节对齐的。(在这种情况下,其最低有效的半个字节为0x4)。然而,如果是从地址0x6A341173加载一个32位的整数,存储器控制器需要独出两个4字节的块:一个在0x6A341170,另外一个在0x6A341174。然后,它必须mask和偏移这两个32位整数的一部分,然后用逻辑OR运算,将两部分合并并放到CPU的目的地寄存器中。如图3.14所示。

 

一些微处理器甚至更懒。如果您发送一个读(或写)没有对齐的数据的请求,你可能只会得到一些无用的数据,或者,你的程序可能会完全崩溃!(PlayStation 2就是一个不容许未对齐的数据的著名的例子。)

不同的数据类型有不同的对齐要求。一个比较好的经验法则是,数据类型应该它的字节宽度对齐。例如,32位的值,一般要求按四字节对齐,16位的值则按两个字节对齐,8位的值可以存储在任何地址(单字节对齐)。支持SIMD向量运算的CPU,它的每一个SIMD寄存器包含4个32位浮点数,一共有128位(16字节)。四个浮点数的SIMD向量通常有一个16字节的对齐要求。

这把我们带回到布局结构中有“空洞”的低效率打包问题上,如图3.13所示。当较小的数据类型,如在一个结构或类里面,一些8位的布尔值中间穿插了较大的类型,如32位整数或者浮点数,编译器会在其中填充“空洞”,以确保所有的东西都被正确对齐。当声明你的数据结构时,多考虑一下对齐和包装的问题,是一个好主意。通过重新排列上面这个例子中的低效结构体的成员变量,我们可以消除一些填充空洞,如下图所示,在图3.15中:

struct MoreEfficientPacking
{
U32 mU1; // 32 bits (4-byte aligned)
F32 mF2; // 32 bits (4-byte aligned)
I32 mI4; // 32 bits (4-byte aligned)
char* mP6; // 32 bits (4-byte aligned)
U8 mB3; // 8 bits (1-byte aligned)
bool mB5; // 8 bits (1-byte aligned)

 

在图3.15中,结构体的大小是20个字节,不是我们所期望的18字节,因为在最后的两个字节是填充字节。此填充会由编译器增加,以确保在数组上下文当中的结构体的适当对齐。也就是说,如果一个结构数组被定义,并且数组的第一个元素已经被对齐,编译器的填充操作会保证所有后续元素也将被正确对齐。要让结构体之间的对齐更优良,就相当于是要让它的成员变量之间达到最大的对齐。在上面的例子中,最大的成员变量对齐是4个字节,所以整个结构体应该是四字节对齐。我平时喜欢在结构体最后面显式增加空洞,使浪费的空间可见并且显式表明,例如这样:

struct BestPacking
{
U32 mU1; // 32 bits (4-byte aligned)
F32 mF2; // 32 bits (4-byte aligned)
I32 mI4; // 32 bits (4-byte aligned)
char* mP6; // 32 bits (4-byte aligned)
U8 mB3; // 8 bits (1-byte aligned)
bool mB5; // 8 bits (1-byte aligned)
U8 _pad2]; // explicit padding
};

3.2.5.2 C++类的内存布局

有两件事让C++类在内存布局方面有点不同于C结构,那就是继承虚函数

当类B继承自类A,B的数据成员只会紧随在A的数据成员后面出现,如图3.16所示。每一个新的派生类简单地把它的数据成员附加在基类成员的后面,尽管内存对齐需求可能会导致类与类之间有一些填充字节。(多重继承确实有些怪诞的东西,像包括一个基类的多个拷贝的的派生类的内存布局。我们这里不会讨论细节,因为游戏程序员通常倾向于避免多重继承。)

如果一个类包含或继承了一个或多个虚函数,那么额外会有四个字节(字节数取决于目标硬件)被添加到该类的内存布局中,通常是在布局的开始位置。这四个字节被统称为虚表指针vptr,因为它们包含一个指向一个数据结构(被称为虚函数表vtbl)的指针。一个特定类的虚表包含了所有指向它声明或继承的虚拟函数地址的指针。每个具体类都有自己的虚表, 而每个类对象都有一个指向该虚表的指针。

虚函数表是多态的核心,因为它允许编写代码时无需知道是在操作哪个特定的具体类。返回到无处不在的Shape基类和Circle、Rectangle、Triangle子类这个例子,让我们想象一下,基本Shape定义了一个虚拟函数Draw ()。派生类都重写(override)了这个函数,提供不同的实现:Circle::Draw()、Rectangle::Draw()和Triangle::Draw()。任何继承自Shape基类的子类的虚拟表,都包含一个Draw()函数的入口,但该入口将指向不同的函数实现,取决于具体的子类。Circle的虚表将包含一个指向Circle::Draw()的指针,而Rectangle的虚表将指向Rectangle::Draw()和Triangle的虚表将指向Triangle::Draw()。给定一个Shape类指针(Shape* pShape),它指向了任意的Shape类对象,要调用Shape类的Draw()函数,只需要对虚表指针解引用,查找Draw()函数在该虚表的入口。当pShape指向Circle类对象调用的就是Circle::Draw();当pShape指向Rectangle类对象调用的就是Rectangle::Draw();当pShape指向Triangle类对象调用的就是Triangle::Draw()。

下文的代码摘录说明了这些概念。注意,基类Shape定义了两个虚函数,SetId()和Draw(),后者被声明为纯虚函数。(这意味着Shape没有提供默认实现的Draw()函数,派生类必须重写它,如果他们想被实例化的话)类Circle继承自Shape,增加一些数据成员和函数来管理它的中心和半径,并且重写了Draw()函数,如图3.17。类Triangle也继承自Shape,它增加了一个Vector3数组对象来存储它的三个顶点,并添加一些函数来获取和设置单个顶点。类Triangle重写了Draw(),为了便于说明它也重写了SetId()。Triangle类的内存布局如图3.18所示。

class Shape
{
public:
virtual void SetId(int id) { m_id = id; }
int GetId() const { return m_id; }
virtual void Draw() = 0; // pure virtual – no impl.
private:
int m_id;
};
class Circle : public Shape
{
public:
void SetCenter(const Vector3& c) { m_center=c; }
Vector3 GetCenter() const { return m_center; }
void SetRadius(float r) { m_radius = r; }
float GetRadius() const { return m_radius; }
virtual void Draw()
{
// code to draw a circle
}
private:
Vector3 m_center;
float m_radius;
};
class Triangle : public Shape
{
public:
void SetVertex(int i, const Vector3& v);
Vector3 GetVertex(int i) const { return m_vtx[i]; }
virtual void Draw()
{
// code to draw a triangle
}
virtual void SetId(int id)
{
Shape::SetId(id);
// do additional work specific to Triangles...
}
private:
Vector3 m_vtx[3];
};
// -----------------------------
void main(int, char**)
{
Shape* pShape1 = new Circle;
Shape* pShape2 = new Triangle;
// ...
pShape1->Draw();
pShape2->Draw();
// ...
}