读书笔记:游戏引擎架构第二版 目的:了解存储空间如何分配,多种C/C++变量类型如何运作,需要认识的C/C++的内存布局。 一 可执行映像 当生成C/C++ 程序时,链接器创建可执行文件,这种流行的可执行文件格式 称为可执行与可链接格式。一些平台的可执行文件格式.elf 作为扩展名,windows上的可执行文件格式使用.exe作为扩展名。 无论是哪种文件格式,可执行文件总是包含程序的部分映像,程序执行时这部分映像会放在内存中。之所以被称为部分映像是因为:由于程序除了把可执行映像置于内存中,一般也会分配额外的内存。 可执行映像被分为几个相连的块,这些块被称为段或者节(section)。每个操作系统的可执行文件布局方式都有些差异,同一个操作系统的不同可执行文件也会有些微差别。一般来说,映像文件最少有以下四个段组成: ·代码段:包含程序中定义的全部函数的可执行机器码; ·数据段:包含全部 获初始化的全局以及静态变量。链接器会为这些变量分配所需的内存,其内存布局将会和程序执行时完全一样,并且链接器会填入适当的初始值。 ·BSS段:包含程序中定义的所有未初始化的全局变量和静态变量。(C/C++中明确定义,任何为初始化的全局变量和静态变量皆为零。)不过,与其在BSS段存储可能很大块的零值,链接器只需要简单的存储所需零值的字节个数,足以安置此段内未初始化的全局以及静态变量。当操作系统载入程序时,就会保留BSS段所需的字节个数,并为该部分内存填入0,之后调用程序进入点(比如main())。 ·只读数据段:rodata段。包含程序中定义的只读(常量)全局变量。比如所有浮点常量以及所有的const 关键字声明的全局对象实例就属于只读数据段。注意:通常编译器把整数常量,看作是明示常量,并且直接把明示常量插进机器码中,明示常量直接占用代码段的存储空间,不存储于只读数据段。 全局变量,是指由所有函数以及类声明外的文件作用域定义的变量。按照是否被初始化决定存储于数据段还是BSS段。
static关键字可以把全局变量或者函数指明为内部链接,使其不显露在其他的翻译单元。但是除此之外,可以用static关键字来声明置于函数内的全局变量。函数静态变量的词法作用域只在他定义的函数之内(即变量的名字只能在函数内见到)。变量会在第一次调用其函数时被初始化(而不像文件域静态变量,在main()调用前已经被初始化了)。但是,以可执行映像内存布局来说,函数静态变量和文件域静态变量并没有什么区别,都是根据是**否被初始化而分别存储于数据段或者BSS段的**。
二 程序堆栈 当可执行程序被载入内存并运行的时候,操作系统会保留一块称为程序堆栈的内存。 当调用函数的时候,一块连续的内存会被压入栈,这个内存块被称为堆栈帧。比如函数a()调用了b(),函数b()的新堆栈帧就会被压入a()堆栈帧之上,当b()返回的时候,他的堆栈帧就会被弹出,并在b()之后的位置继续执行a()。 堆栈帧存储三类数据: ·堆栈帧存储调用函数的返回地址。当函数返回的时候,就可以凭这个数据继续执行调用方的函数; ·堆栈帧保存相关CPU寄存器的内容。凭借这个过程被调用方可以使用任何觉得合适的寄存器,不需要担心调用方所需的数据被覆盖。当函数返回时,各个寄存器就会还原到调用方可以继续执行的状态。如果函数有返回值,该值就会存储在指定的寄存器中,使调用方能够使用,但是其他寄存器会恢复原来的值。 ·堆栈帧还包括函数里所有的局部变量(自动变量)。凭借这个过程,每个函数调用都各自保持一组私有的局部变量集合。甚至函数对自己递归回掉也是一样的道理。(实际上,一些局部变量会分配使用cpu寄存器,不是存在堆栈帧。但是这些变量在大部分情况下,运作方式如何使用堆栈帧) 堆栈帧的压入和弹出操作,一般会通过调整一个cpu寄存器的值来实现,这个寄存器被称为堆栈指针。 当含有自动变量的函数返回时,他的堆栈帧会被舍弃,那么该函数内所有自动变量被视为不在存在。从技术上来说,这些变量所占有的内存仍然在已经被舍弃的堆栈帧中,当调用下一个函数这些变量所占有的内存就很可能被覆盖。 常见错误就是返回局部变量的地址。只有程序立即使用返回的地址,并且在使用期间不调用其他的函数,才有可能不出问题。大多数情况,这类代码会导致程序崩溃,特别难调试。 三 动态分配的堆 之前说讲的都是程序中的数据如何被存储为全局 静态或者局部变量。全局静态变量分配于可执行映像里,而局部变量则分配于程序堆栈之中。这两种存储方式都是静态的定义的,这意味着,其所需的内存大小与布局在编译链接程序时就能知道的。可能有时候不能再编译期完全知悉程序的内存需求。程序经常需要动态的分配额外的内存。程序经常需要动态的分配的额外的内存。 为了提供动态分配内存功能,操作系统会为每个运行进程维护一块内存,可以调用malloc()(或者操作系统的专用函数,比heapAlloc())中分配,稍后调用free()(或者操作系统的专用函数,比如HeapFree())把内存交还。这个内存块称为堆内存块或者自由存储。当动态分配内存时,有时候称为分配得到的内存时置于堆中的。 注:从技术上来说,堆是C语言和操作系统的术语,而自由存储是C++中通过new和delete动态分配和释放对象的抽象概念。基本上,所有C++编译器预设会使用堆去实现自由存储。但是程序员可通过重载操作符,改用其他内存实现自由存储,例如全局变量做的对象池。 四 成员变量 C中struct 和C++中Class都可以用来把变量组成逻辑单元。二者的声明并不占用内存。这些声明仅用数据布局的描述,如果一个磨具用来制作struct 或者class的实例。 当声明了一个struct或class时,就能以和基本数据类型相同的任何方式进行分配(定义)。 比如: ·作为自动变量,置于程序堆栈上: void someFunction() { Foo localFoo(); } ·作为全局变量,文件静态变量或者函数静态变量; Foo gFoo; static Foo sFoo; void someFuntion() { static Foo sLocalFoo; } ·动态地从自由存储中分配。在此情况下,存储数据地址的指针或者引用 五 类的静态成员 根据的上下文,static关键字有许多不同的含义。 ·当用于文件作用域时,static 意味着“限制变量或者函数的可见性,只有本.cpp文件才能使用该变量或者函数”。 ·当用于函数作用域时,static 意味着“变量为全局,非自动,只在本函数内函数可见”。 ·当用于struct或者class声明时,static意味着“该变量非一般成员变量,而是类似全局变量”。 注意:当static用于class声明时,并不控制该变量的可见性(文件作用域才会)。反而,其用途时区分正常的每个实例变量,以及行为像全局变量的每个类(per-class)变量。 类静态变量的可见性 时通过声明的public: private: protected: 关键字决定的。类静态变量自动包含于其被定义的class或struct命名空间里。若在class或struct以外使用这些变量,必须加入class或者struct的名字以消除歧义(例如Foo::sVarName); 如何extern的一般全局变量,类声明内的类静态变量并不占用内存,必须在一个.cpp文件内定义类静态变量以分配内存。 比如: //foo.h class Foo { public: static F32 sClassStatict;//不分配内存; } F32 Foo::sClassStatic =-1.0f; // 定义内存和初始化; 六 对象内存的布局 对齐和包裹 特别留意struct 和class 在内存中的布局时,就能想清楚,如果大小不一样的数据成就交错放置,布局应该有什么变化。 比如 struct InefficientPacking { U32 mU1; //32位 4字节对齐 char* mP6;//32 4 F32 mF2;//32 4 I32 mI4;//32 4 U8 mB3;//8 1 bool mB5;//8 1 } 编译器简单地吧数据成员尽可能地紧凑的包裹在一起,然而这毕竟不是常态。相反,编译器通常会在布局中留下空隙。(一些编译器会通过#pragma pack或者命令行设置不留空隙,但预设行为会在数据成员之间留空隙) 为什么编译器会留下空隙呢?原因在于,事实上每种数据类型都有天然的对齐方式,提供CPU高效的从内存中进行进行读写。数据对象的对齐是指,其内存地址是否时为对齐字节大小的倍数(通常是2的幂)。 ·1字节对齐的对象,可放在任何地址; ·2字节对齐的对象,只可以放在偶数地址,即地址最低有效半字节。0x0,0x2,0x4,0x8,0xA,0xC或者0xE; ·4字节对齐的对象,只可以4的倍数的地址(0x0,0x4,0x8,0xC) ·16字节对齐的对象,只可以放在16倍数的地址(即地址最低有效半字节为0x0)
1Byte=8Bit
对齐是很重要的。因为现在很多处理器实际上只能正常的读写已对齐的数据块。例如,程序从0x6A341174地址读取32位(4字节)的整数,内存控制器就可以愉快的载入数据,因为地址是4位字节对齐的(这个例子的最低有效半字节为0x4)。可是若要从0x6A341173载入32位整数,内存控制器就需要读入两个4字节块:一块位于0x6A341170,一块位于0x6A341174。之后,还需要通过掩码和移位操作取得32位整数的两部分,在用逻辑or操作把两部分合并,并把结果写入CPU的目标寄存器。
一些微处理器甚至不做这些处理。如果读写非对齐数据,读出来或者写进入的可能只是随机数。对于另一些微处理器,程序甚至会崩溃(PS2就是这类“零容忍”的例子)。
各数据类型有不同的对齐需求。作为一个良好的经验法则,数据类型应该需要其字节大小的对齐。比如,32位值通常需要4字节对齐,16位通常需要2字节对齐,8位可以在任何位对齐)。在支持SIMD矢量数学的CPU中,每个SIMD寄存器喊32个4字节浮点数,共128位(16字节)。包含4个浮点数的SIMD矢量通常需要16字节对齐。
在class或者struct中,当把较小的数据类型(如8位的bool)放置于较大类型的(如32位的float)之间时,编译器便会启用填充(pad),以保证所有的成员都是正常对齐的。
注意:上面的InefficientPacking 结构体预期的是18字节,但是实际上是20字节。这是由于在末端加进了两个字节的填充。编译器加上这种填充,使结构作为数组类型的时候仍能够维持正确的对齐。换而言之,**如果定义此结构的数组,并且其首个元素是对齐的,那么结构末端的填充保证了所有之后的数组元素都是正确对齐的**。
有些人的写法可能会在结构体上主动添加明确的填充:比如增加 U8 _pad[2];这个变量作为明确的填充;
六 C++的内存布局 在内存布局上,C++的类有别于C的结构体之处有两点——继承与函数。 当B类继承自A类,内存里B类的数据成员会紧接着A类数据成员之后。每个新的派生类都会简单的把其数据成员附加到末端,即使类之间可能因为对齐而加入填充。(多重继承都比较混乱,例如会在派生类的内存布局中包含同一基类的多个版本。对于游戏程序员来说通常会避免使用多重继承) 注:在C++中,struct和Class的区别只在于预设的成员可见性,struct的预设成员可见性为public,而class则为private。C++中的struct一样可以有继承和虚函数。 多态的核心——虚函数表。 当类含有或者继承了一个或者多个虚函数时,那么就会在类的布局里添加4字节(若目标硬件采用64位则是8字节),通常会加在类的布局的最前端。这4字节或者8个字节称为虚表指针,因为此4字节代表一个指针,指向名为虚函数表的数据结构。 在每个类的虚函数表里,包含该类的声明或者继承而来的所有虚函数指针。每个(含有虚函数的)具体类都具有一个虚函数表,并且这些类的实例都会有虚表指针指向该虚函数的表。 多态的核心——虚函数表。因为它使程序员在编写代码时无须考虑代码是和哪个具体类进行沟通的。 比例:Shape为基类,Circle,Rectangle以及Triangle为派生类。假设Shape定义了名为Draw()的虚函数,而所有的派生类都重载了此函数,提供了个别的实现。包括Circle::Draw(),Rectangle::Draw()以及Triangle::Draw(),任何继承自Shape的类,其虚函数表都有Draw()函数的条目,但条目会指向具体类的函数实现。Circle类的虚函数表包含指向Rectangle::Draw()的指针,而Triangle类的虚函数表则包含指向Triangle::Draw()的指针。假如有一个指向Shape的指针(Shape *pShape),要调用其虚函数Draw(),代码可先对其虚表指针解引用,取得虚函数表,再从表中找到Draw()的条目,就可以调用。若pShape只想一个Circle的类的实例,结果就是调用Circle::Draw()。以此类推。
|