Skip to content

Latest commit

 

History

History
156 lines (97 loc) · 9.93 KB

Readme-zh.md

File metadata and controls

156 lines (97 loc) · 9.93 KB

Part 22:局部变量和函数调用的设计思想

这将是我们编译器编写旅程的第一部分,我不会引入任何新代码。这一次,我需要从程序员的键盘上退一步,从大处着眼。这将让我有机会思考如何实现局部变量(在这部分),然后是函数实参和形参(在下一部分)。

这两个步骤都将涉及到对我们现有编译器的一些重要的添加和更改。我们还必须处理像堆栈帧和寄存器溢出这样的新概念,到目前为止我已经省略了这些概念。

让我们从确定要向编译器添加什么新功能开始。

我们需要什么样的功能

局部和全局变量作用域

现在,我们所有的变量对所有的函数都是全局可见的。我们想为变量添加一个局部作用域,这样每个函数都有自己的变量,其他函数看不到。此外,在递归函数的情况下,同一函数的每个实例都有自己的局部变量。

但是,我只想添加两个作用域:局部和全局。C 实际上为每个复合语句创建了一个新的作用域。在下面的例子中,在三个不同的作用域中有三个不同的 a 变量:

#include <stdio.h>
int a = 2;              // Global scope

int main()
{
  int a= 5;             // Local scope
  if (a > 2) {
    int a= 17;          // Third scope
    printf("%d\n", a);  // Print 17
  }
  printf("%d\n", a);    // Print 5
  return(0);
}

我不打算支持第三个,即内部作用域。两个就够了。

作为局部变量的函数参数

我们还需要支持对一个函数声明零个或多个参数,这些参数需要被视为该函数实例的局部变量。

C 函数是“按值调用”:函数调用者中的实参值被复制到函数的形参中,以便被调用的函数可以使用和修改它们。

介绍堆栈

为了给同一个函数的多个实例创建一个局部作用域,并提供一个存储函数参数的地方,我们需要一个堆栈。在这一点上,如果你不太了解栈,你应该做一些关于它们的背景阅读。我会从维基百科上这篇关于调用栈的文章开始。

假设我们支持的硬件架构之一是运行 Linux 的 Intel x86-64 架构,我们将不得不在该架构上实现函数调用机制。我发现了 Eli Bendersky 关于 x86-64上 的堆栈框架布局的一篇很棒的文章。在继续阅读本文档之前,你肯定需要阅读这份文档!由于 Eli 的文章是公开的,所以我复制了他的栈帧图片和下面寄存器中的函数参数。

long myfunc(long a, long b, long c, long d,
            long e, long f, long g, long h)
{
    long xx, yy, zz;
    ...
}

本质上,在 x86-64 架构上,一些参数的值将在寄存器中传递,一些参数值将被放到堆栈上。我们所有的局部变量都在栈上,但是在栈的基指针下面。

同时,我们希望我们的编译器可以移植到不同的架构。因此,我们需要为不同的架构支持一个通用的函数参数框架,这些架构只使用堆栈、寄存器或两者的组合。

寄存器溢出

到目前为止,我忽略了一些东西,还没有实现,那就是寄存器溢出(register spilling)。我们需要溢出我们已经分配的部分或全部寄存器,这有几个原因。

  • 我们已经用完了可以分配的寄存器,因为只有固定数量的寄存器。我们可以将一个寄存器溢出到堆栈中,这样它就可以自由分配了。
  • 我们需要在函数调用前将所有分配的寄存器和所有带参数的寄存器溢出到堆栈中。这样可以释放它们,使它们可以被调用的函数所使用。

在函数调用返回时,我们需要解除寄存器的溢出,以获取我们需要的值。同样,如果我们溢出一个寄存器使其空闲,那么我们就需要解除其旧值,并在其再次空闲时重新分配。

静态变量

虽然不在要立即实现的内容列表中,但在某些时候我需要分配静态变量。对于局部静态变量,这里会有一些命名问题,但是当我实现所有直接的想法时,我会尽量把这个问题放在心里。

初始化变量

我们应该允许变量在声明时被初始化。对于全局变量,我们可以明确地将它们初始化为一个常数值,例如int x = 7;,但不适用于表达式,为我们没有运行初始化代码的函数上下文

然而,我们应该能够进行局部变量初始化,例如int a= 2, b= a+5;,因为我们可以在函数代码的开头插入变量的初始化代码。

想法与实现

好的,这些就是此时在我的设计师脑海中浮现的想法和问题。以下是我认为我将如何实现其中的一些。

局部符号

让我们从局部变量和全局变量的区别开始。全局变量必须对所有函数可见,但是局部变量只对一个函数可见。

SubC 使用一个符号表来存储关于局部和全局变量的信息。全局变量在一端分配,局部变量在另一端存储。有代码确保中间两端没有冲突。我喜欢这个想法,因为我们对每个符号都有一组唯一的符号槽号,而不管它的范围。

就局部符号优先于全局符号而言,我们可以首先搜索符号表的局部端,如果我们没有找到符号,那么我们可以搜索全局端。而且,一旦我们解析完一个函数,我们就可以简单地清除符号表的局部端。

存储类

C 有存储类的概念,我们必须至少实现其中一些类。SubC 实现了几个存储类:

/* storage classes */
enum {
        CPUBLIC = 1,            // publicly visible symbol
        CEXTERN,                // extern symbol
        CSTATIC,                // static symbols in global context
        CLSTATC,                // static symbols in local context
        CAUTO,                  // non-static local identifiers
        CSPROTO,                // function prototype
        CMEMBER,                // field of a struct/union
        CSTCDEF                 // unused
};

用于符号表中的每个符号。我想我可以修改和使用这个。但我可能支持较少的存储类类型。

函数原型

每个函数都有一个原型:它拥有参数的数量和每个参数的类型。我们需要这些参数来确保函数调用的参数与函数参数的类型和数量匹配。

在某个地方,我需要记录每个函数的参数列表和类型。我们还可以支持在实际声明函数本身之前声明函数的原型。

现在,我们要把它放在哪里?我可以为函数原型创建一个单独的数据结构。我不想在我们的语言中支持二维数组,但我们需要每个函数的原始类型列表。

所以,我的想法是这样的。我们已经将 S_FUNCTION 作为现有符号表元素的类型。我们可以在每个符号表条目中有一个“参数数量”字段来存储函数所具有的参数数量。然后,我们可以立即用每个函数参数的符号表条目跟随该符号。

当我们解析函数的参数列表时,我们可以在全局符号部分添加参数来记录函数的原型。同时,我们也可以在局部符号部分添加参数作为条目,因为它们将被函数本身用作局部变量。

当我们需要确定函数调用的参数列表是否与函数的原型匹配时,我们可以找到函数的全局符号表条目,然后将符号表中的以下条目与参数列表进行比较。

最后,在搜索全局符号时,我们可以通过加载函数的“参数数量”字段轻松跳过函数的参数条目,并跳过这么多符号表条目。

将参数保存在寄存器中:不可能

实际上,我是在尝试实现以上内容之后写这一部分的,所以我回来重新审视一下设计。我认为我们可以将参数保存到寄存器中:这将使对它们的访问更快,并使堆栈帧更小。但由于这个原因,这并不总是可能的。请考虑以下代码:

void myfunction(int a) {        // a is a parameter in a register
  int b;                        // b is a local variable on the stack

  // Call a function to update a and b
  b= function2(&a);
}

如果 a 参数在寄存器中,我们就不能用 & 操作符得到它的地址。因此,我们必须将它复制到内存的某个地方。并且,由于形参是函数的局部变量,我们需要将它复制到堆栈中。

有一段时间,我有了遍历 AST 的想法,寻找树中哪些参数需要有真实地址,但后来我想起我遵循了KISS原则: keep it simple, stupid! 所以我将把所有的参数从寄存器中复制到堆栈中。

局部变量的位置

一旦参数或局部变量被复制或放置在堆栈中,我们将如何确定它们在堆栈中的位置?为此,我将在每个本地符号表条目中添加一个posn字段。这将指示栈帧基指针下方变量的偏移量。

看看 C 的 BNF 语法,函数声明列表(即函数参数列表)位于局部变量的声明列表之前,这位于语句列表之前。

这意味着,当我们解析参数和局部变量时,我们可以在解析语句列表之前确定它们在堆栈中的位置。

总结与展望

我想,在开始我们编译器编写旅程的下一部分之前,就设计而言,这就是我想做的全部事情了。我将在下一部分单独处理局部变量,并尝试在随后的部分添加函数调用和参数。但是,要实现所有新提出的特性,可能需要三个或更多的步骤。我们拭目以待。