内存布局
约 3728 字大约 12 分钟
2025-01-23
给定一个命名空间的模型和每个命名实体的类型信息,编译器可以执行存储布局。这个过程分为两个步骤。首先,编译器必须将每个实体分配到一个逻辑数据区。这个决定取决于实体的生命周期及其可见性。其次,对于每个逻辑数据区,编译器会为该区域中的每个实体分配一个从数据区起点开始的偏移量。
存储类别和数据区 编译器可以根据它们的生命周期对需要存储的值进行分类。大多数编程语言允许程序员在至少以下存储类别中创建值:自动(automatic)、静态(static)和不规则(irregular)。编译器根据其生命周期、存储类别和可见性将特定变量名映射到存储区(见上一节内存中值的放置)。
自动变量
自动变量 a 的生命周期与其声明作用域的生命周期相同。因此,它可以存储在作用域的局部数据区中。例如,如果 a 在过程 p 中声明,则编译器可以将 a 存储在 p 的局部数据区中。(如果作用域包含在 p 中,编译器可以在 p 的局部数据区内为该作用域预留空间。)如果 a 是局部的、标量的且无歧义,编译器可以选择将其存储在寄存器中(见上一节内存中值的放置)。
为了管理过程 p 的执行,编译器必须确保每次调用 p 都有一个小的存储块来保存呼叫和返回过程所需控制信息。这个激活记录(AR)还将包含作为参数传递给 p 的参数。原则上以及实际上,当控制进入一个过程时创建 AR,并在控制退出该过程时释放。
提示
激活记录(Activation record) 一段内存区域,用于保存控制信息和过程调用的局部数据区 我们将“激活”和“调用”视为同义词。
静态变量
静态变量 s 的生命周期从程序执行期间首次定义 s 开始,直到最后一次使用 s 的值为止。s 的首次定义和最后使用可以在执行过程中覆盖一段短暂的时间;也可能会贯穿整个执行过程。静态属性通常被实现为从执行开始一直持续到结束。
编程语言通过各种可见性约束来支持静态变量。全局变量是静态的;它的可见性跨越多个、非嵌套的过程。在过程内声明的静态变量具有过程范围的可见性(包括嵌套的作用域);该变量在其值跨多次调用过程中保持不变,很像一个全局变量。C 语言使用 static 来创建文件级别的可见性;其值在整个执行期间都是活跃的,但仅对定义在同一文件内的过程可见。
编译器为静态变量创建独立的数据区。原则上,程序可以为每个静态变量实现单独的数据区;或者,也可以将它们全部合并到一个区域中。编译器开发者必须制定一种原理,以确定静态变量如何映射到各个数据区。一种简单的方法是为每段代码文件创建一个单一的静态数据区,并依赖编译器的名字解析机制来强制执行可见性约束。
编译器通常使用汇编语言结构来创建和初始化静态数据区,因此分配、初始化和释放实际上没有运行时成本。编译器必须以允许系统链接器将所有对给定全局名称的引用映射到同一存储位置的方式来创建全局数据区——这就是“全局”可见性的含义。
不规则变量
一些值的生命周期受程序控制,也就是说,代码显式地为它们分配空间。(释放可能是隐式的或显式的。)关键的区别在于,分配和释放发生在与任何特定过程的生命周期无关的时间点,并且有可能在单次执行中多次发生。
编译器的运行时支持库必须提供一种机制来分配和释放这些不规则实体。系统通常使用运行时堆(heap)来实现这样的目的。堆的控制可以通过像LINUX中的malloc和free这样的调用显式进行。或者,也可以通过垃圾回收或引用计数等技术进行隐式管理。
虽然实际实体的存储可能位于堆上,源代码通常需要一个名称来开始引用或引用链。因此,一个链表可能由一个自动局部变量组成,比如root,它包含指向列表第一个元素的指针。root需要在寄存器或局部数据区中有空间,而各个列表元素则可能在堆上分配。
优化可以延长临时值的生命周期。如果代码重新计算 b xc,编译器可能会保留其值而不是计算两次。
虚拟内存布局
这个应该读过CSAPP或OSTEP之类的书应该算比较了解的了,不展开说了。
下面是计算机中虚拟内存的各种视角 从编译器的角度来看,这个虚拟地址空间就是整个图景。然而,现代计算机系统通常以交错的方式执行许多程序。操作系统将多个虚拟地址空间映射到处理器支持的单一物理地址空间中。图5.15展示了这个更大的图景。每个程序在其自己的虚拟地址空间中被隔离;每个程序都可以表现得好像它有自己的机器一样。
存储分配
数组存储分配
这个没什么好说的了。
字符串
两种常见的表示方式是空终止字符串和带有长度字段的字符串。空终止字符串,如左所示,使用一个元素的向量,并以指定的字符串结束标记来标识字符串的末尾。
右侧所示的显式长度表示方式,是在单独的字段中存储长度值。这两种布局在空间需求上略有不同;空终止字符串需要一个额外的元素来标记字符串的末尾,而显式长度表示则需要一个足够大的整数来保存最大字符串长度。
这两种表示方式之间的真正差异在于计算字符串长度的成本。对于空终止字符串,成本是O(n),其中n是字符串的长度;而在显式长度字符串中,相同的操作成本为O(1)。这一差异会影响到其他需要知道长度的操作,比如连接操作。它在范围检查中也起着关键作用
结构体与对象
编译器还必须为结构体和对象执行布局。大多数语言将结构体声明的内部视为一个新的作用域。程序员可以使用任意名称作为字段名,并且作用域规则将确保正确的解释。结构体声明中的每个字段都在结构体内分配空间;编译器必须为每个字段在结构体的表示中分配一个偏移量。
编程语言对于结构体声明文本是否也定义了结构体的布局有不同的处理方式。无论选择哪一种,都有充分的理由支持。如果声明决定了布局,那么编译器会按照声明给字段分配偏移量。如果由编译器控制结构体布局,则它可以分配偏移量以消除浪费的空间,使用数据区域布局的技术。
对象记录的内存布局(Object Records)
在面向对象的语言中,每个对象都有其自己的对象记录(OR)。由于对象的生命周期是不规则的,OR 通常位于堆上。OR 包含由对象的类指定的数据成员,以及指向其类和,在许多实现中,指向类的方法向量的指针。有了继承,OR 必须包含从其超类继承的数据成员,并且能够访问其超类的代码成员。
对象布局的主要复杂性来源于超类方法应当能够在子类对象上工作这一事实。为了确保这种互操作性,子类对象布局必须为来自超类的数据成员分配一致的偏移量。在单继承的情况下,前缀布局策略实现了这个目标。子类对象布局使用超类对象布局作为前缀。来自超类链中祖先的数据成员保留一致的偏移量;而当前类的数据成员被添加到 OR 布局的末尾。
为了减少存储需求,大多数实现将方法向量存储在类的 OR 中,而不是在每个对象的 OR 中保存一份副本。下图显示了两个 colorPoint 实例以及类的 OR 的 ORs。直接将 CPOne 和 CPTwo 的 OR 链接到 ColorPoint 的方法向量减少了空间需求,而没有直接的成本。当然,方法向量中的偏移量在整个继承层次链中必须保持一致;同样,在单一继承环境中,前缀布局表现良好。
重要
在编译器构造中,细节非常重要。例如,考虑两个类,α 和它的子类 β。当编译器布局 β 的对象记录时,是否包含 α 的私有成员?由于它们是私有的,β 类的对象不能直接访问它们。 但是,如果 α 提供了读取或写入这些私有成员的公共方法,那么 β 类的对象就需要从 α 中获取那些私有成员。同样地,如果对象记录(OR)布局在没有这些私有成员的情况下发生变化,为了确保 β 类的对象记录中的公共成员有正确的偏移量(即使没有机制可以读取它们的值),这些私有成员可能是必要的。
多重继承的内存布局
多继承使对象记录(OR)的布局变得复杂。超类方法的编译代码使用基于该超类的对象记录布局的偏移量。不同的直接超类可能会对其成员分配冲突的偏移量。为了调和这些竞争的偏移量,编译器必须采用稍微复杂的方案:它必须对来自不同超类的方法使用不同的对象记录指针。
将存储布局引入翻译过程
编译器设计者在翻译过程中面临一个选择。她可以选择设计编译器尽可能多地在语法驱动阶段完成翻译,或者设计它在翻译期间构建初始中间表示(IR),并依赖于后续对IR的遍历来完成翻译。存储布局的时机直接关系到这个选择。
- 一些语言要求所有变量必须在任何可执行语句出现之前声明。编译器可以在处理声明时收集所有的类型和符号信息。在处理第一条可执行语句之前,它可以执行存储布局,这允许它为引用生成具体的代码。
- 如果语言需要声明,但不指定顺序,编译器可以在解析期间建立符号表,并发出带有抽象引用的IR。解析之后,它可以执行类型推断,然后进行存储布局。接着它可以细化IR,使引用更加具体化。
- 如果语言不要求声明,编译器就必须构建带有抽象引用的IR。然后编译器可以对IR执行更复杂的(可能是迭代的)类型推断,继之以存储布局。最后,它可以细化IR,使引用更加具体化。
这些方法之间的选择取决于源语言的规则以及编译器设计者的偏好。多遍方法可能会简化编译器本身的代码。
对其与补充(Alignment Restrictions and Padding)
这个也是特别熟悉了
当编译器布局一个数据区域时,它必须满足所有的对齐限制。为了获得正确的对齐,它可能需要在值之间插入空的空间。图(a)显示了一个简单四个变量例子的长度和约束条件。图(b)展示了如果编译器按字母顺序为它们分配偏移量所得到的布局。它使用了十六个字节,并且在填充上浪费了六个字节。图(c)展示了一种替代布局,使用十个字节而没有填充。在这两种情况下,在内存中的下一个实体之前可能会浪费一些空间。
为了创建图(c)中的布局,编译器可以为给定的数据区域构建一个名称列表,并按照它们的对齐限制从大到小排序。然后,它可以按照排序后的顺序为名称分配偏移量。如果它需要插入填充以达到下一个名称的对齐边界,它可以用来自列表末尾的小边界名称填充那个空间。
章节回顾
编译器必须决定每个运行时实体的存储位置及其存储何时被分配。编译器根据实体的生命期和可见性来做这个决定。它将名称分类为存储类。对于具有可预测生命期的对象,存储类指导这些决定。 编译器通常会将具有不可预测生命期的项放在运行时堆上。基于堆的实体是显式分配的;通常,对基于堆的实体的引用涉及通过具有常规生命期的变量进行一级间接寻址。
更新日志
70541
-更新语法制导翻译于