编译器实现之旅——第十五章 函数调用的代码生成器分派函数的实现

在前面几个章节的旅程中,我们实现了一些各有特点的代码生成器分派函数,而在这一章的旅程中,我们将迎来整个代码生成器,乃至整个编译器实现之旅的重头戏:函数调用的实现。现在,就让我们开始吧。

1. 函数调用概观

函数调用是一个复杂的过程,从代码生成器层面看,其涉及到符号表以及多个与之相关的分派函数,此外,函数调用还需要在稍后经由链接器组件进行处理;而从虚拟机层面看,函数调用涉及到了虚拟机的所有寄存器和一些特殊的指令。由此可见,函数调用这一分派函数所涉及到的面之广,是其他分派函数所不能达到的。下面,让我们首先来看看一次函数调用的代码生成,究竟需要经历哪些步骤:

  1. 局部变量压栈
  2. 实参压栈
  3. BP寄存器准备
  4. IP寄存器准备
  5. 函数调用跳转
  6. 实参与局部变量退栈

我们将在下文中逐条的讨论这些步骤的含义与实现。

接下来,我们将上一章中绘制的栈内存结构图中,与函数调用相关的部分展示如下:

+-------+  ...  +-------+-------+-------+-------+-------+-------+-------+-------+-------+---------+-------+  ...
| 索引值 |  ...  | N - 8 | N - 7 | N - 6 | N - 5 | N - 4 | N - 3 | N - 2 | N - 1 |   N   |  N + 1  | N + 2 |  ...
+-------+  ...  +-------+-------+-------+-------+-------+-------+-------+-------+-------+---------+-------+  ...
|   值  |  ...  |   ?   |   ?   |   ?   |   ?   | N - 7 |   ?   |   ?   |   ?   |   ?   | BP(Old) |  IP   |  ...
+-------+  ...  +-------+-------+-------+-------+-------+-------+-------+-------+-------+---------+-------+  ...
                    ^       ^       ^       ^       ^       ^       ^       ^       ^
                    |       |       |       |       |       |       |       |       |
                    f      e[0]    e[1]    e[2]     e       d       c       b      a/BP

也许你早已发现:由于BP在栈中的位置在所有的参数之后,故我们压栈的顺序应当与符号表中变量的编号完全相反。此外,还记得我们在生成符号表的时候提到:一定要先为形参编号,再为局部变量编号吗?这样做,就使得符号表中的变量编号具有了一个非常重要的性质:任何局部变量的编号一定大于任何形参的编号。在此基础上,通过进一步的分析与整理,我们可以得到以下要点:

  1. 我们应当按照符号表中的变量编号从大到小的顺序进行压栈
  2. 实参信息存在于root的第二子节点(如果有)中
  3. 我们可以用符号表中所有变量的数量,减去root的第二子节点(如果有)的所有子节点的数量,即实参的数量,得到局部变量的数量
  4. 在对局部变量进行压栈时,我们需要判定当前这个局部变量是不是数组。如果是,则我们应首先进行数组长度次压栈,为数组内容留出空间;然后,我们应计算数组的第一个元素在栈中的索引值,并将其压栈。怎么计算呢?不难发现,其值等于:SP - 数组长度
  5. 实参不可能是一个数组

上述结论将作为我们实现函数调用的理论基础。

2. 内置函数的实现

在讨论函数调用的各个步骤之前,我们首先来看看内置函数的实现。CMM有两个内置函数:input和output。input函数没有参数,其用于读入一个数字;而output的参数是一个表达式,其用于输出这个表达式的结果。这两个内置函数,分别由IN和OUT指令提供支持。所以,当我们发现函数名为input时,我们直接生成一条IN指令即可;而当我们发现函数名为output时,我们就先为其仅有的一个参数生成代码,再追加一条OUT指令即可。请看:

vector<pair<__Instruction, string>> __CodeGenerator::__generateCallCode(__AST *root, const string &curFuncName) const
{
    /*
        __TokenType::__Call
            |
            |---- __TokenType::__Id
            |
            |---- [__ArgList]
    */

    // xxx = input();
    if (root->__subList[0]->__tokenStr == "input")
    {
        return {{__Instruction::__IN, ""}};
    }
    // output(xxx);
    else if (root->__subList[0]->__tokenStr == "output")
    {
        /*
            __TokenType::__ArgList
                |
                |---- __Expr
        */
        auto codeList = __generateExprCode(root->__subList[1]->__subList[0], curFuncName);

        codeList.emplace_back(__Instruction::__OUT, "");

        return codeList;
    }

    // 未完待续...

3. 局部变量压栈

作为函数调用的第一步,我们首先需要进行的便是局部变量压栈了。根据第一节中得到的第三条要点,我们可以从符号表中分离出所有的局部变量信息。请看:

    // ...接上文中代码

    vector<pair<__Instruction, string>> codeList;

    vector<pair<string, pair<int, int>>> pairList(__symbolTable.at(root->__subList[0]->__tokenStr).size());

    // ..., Local5, Local4, Local3, Param2, Param1, Param0
    for (auto &mapPair: __symbolTable.at(root->__subList[0]->__tokenStr))
    {
        pairList[pairList.size() - mapPair.second.first - 1] = mapPair;
    }

    // We only need local variable here
    int topIdx = pairList.size() - (root->__subList.size() == 2 ?

        // Call function by at least one parameter
        root->__subList[1]->__subList.size() :

        // Call function without any parameter
        0);

    // 未完待续...

上述代码中,我们首先将符号表中各个变量的信息按变量编号从大到小排列,然后通过减法得到了topIdx,这个变量就代表了局部变量在列表中的最大索引值。

接下来,我们进行局部变量压栈。请看:

    // ...接上文中代码

    // Push local variable
    for (int idx = 0; idx < topIdx; idx++)
    {
        // Array
        if (pairList[idx].second.second)
        {
            // Push array content (by array size times)
            for (int _ = 0; _ < pairList[idx].second.second; _++)
            {
                codeList.emplace_back(__Instruction::__PUSH, "");
            }

            /*
                The instruction "ADDR N" calculate the array start pointer (absolute index in SS).

                SS:

                    ... X X X X X X X X X $
                        ^     Size = N    ^
                        |                 |
                     SP - N               SP

            */
            codeList.emplace_back(__Instruction::__ADDR, to_string(pairList[idx].second.second));
            codeList.emplace_back(__Instruction::__PUSH, "");
        }
        // Scalar
        else
        {
            codeList.emplace_back(__Instruction::__PUSH, "");
        }
    }

    // 未完待续...

上述代码中,我们按照已经排列好的顺序,依次为每个局部变量压栈。通过判断当前变量的数组长度,我们可以获知当前变量是不是数组。如果不是,事情就好办了:直接生成一条PUSH指令即可;如果是,则我们就需要首先生成数组长度条PUSH指令,为数组内容留好空间;然后开始计算数组的第一个元素在栈中的索引值。现在,让我们回顾一下虚拟机中"ADDR N"这条指令的实现,不难发现,这条指令计算的是:"AX = SP - N",这不正是我们现在需要的索引值么?所以,我们接下来生成一条"ADDR N"指令,然后再将AX压栈,就大功告成了。

4. 实参压栈

相较于局部变量的压栈,实参的压栈就简单许多了,这要归功于"实参不可能是一个数组"这一性质。由于实参信息存在于root的第二子节点(如果有)中,所以我们可以将这部分的代码生成委托给ArgList节点的分派函数进行。请看:

    // ...接上文中代码

    // Push parameter
    if (root->__subList.size() == 2)
    {
        auto argListCodeList = __generateArgListCode(root->__subList[1], curFuncName);

        codeList.insert(codeList.end(), argListCodeList.begin(), argListCodeList.end());
    }

    // 未完待续...

那么,__generateArgListCode函数又是怎么实现的呢?请看:

vector<pair<__Instruction, string>> __CodeGenerator::__generateArgListCode(__AST *root, const string &curFuncName) const
{
    /*
        __TokenType::__ArgList
            |
            |---- __Expr
            |
            |---- [__Expr]
            .
            .
            .
    */

    vector<pair<__Instruction, string>> codeList;

    for (int idx = root->__subList.size() - 1; idx >= 0; idx--)
    {
        auto exprCodeList = __generateExprCode(root->__subList[idx], curFuncName);

        codeList.insert(codeList.end(), exprCodeList.begin(), exprCodeList.end());
        codeList.emplace_back(__Instruction::__PUSH, "");
    }

    return codeList;
}

ArgList节点含有一至多个Expr子节点,我们要做的便是倒序遍历这些子节点,生成当前子节点的代码;由于Expr节点的代码最终一定会将结果装载入AX中,故我们直接再追加一条PUSH指令即可。

5. 函数调用

终于可以进行函数调用了!但如果你看到这一节下面大片的文字心生畏惧的话,我可以提前给你透露一个小秘密:这一节说了半天,其实就只有一行代码(虽然有大段的注释),请看:

    // ...接上文中代码

    /*
        The instruction "CALL N" perform multiple actions:

        1. SS.PUSH(BP)

            Now the SS is like:

            ... Local5 Local4 Local3 Param2 Param1 Param0 OldBP

        2. BP = SS.SIZE() - 2

            Now the SS is like:

            ... Local5 Local4 Local3 Param2 Param1 Param0 OldBP
                                                     ^
                                                     |
                                                     BP

        3. SS.PUSH(IP)

            Now the SS is like:

            ... Local5 Local4 Local3 Param2 Param1 Param0 OldBP OldIP
                                                     ^
                                                     |
                                                     BP

        4. IP += N

        In addition, the "N" of the "CALL N" is only a function name right now,
        it will be translated to a number later. (See the function: __translateCall)
    */
    codeList.emplace_back(__Instruction::__CALL, root->__subList[0]->__tokenStr);

    // 未完待续...

可见,确实只有一行代码,并且这行代码看上去也没有什么特别之处,其生成了一条形如:"CALL 函数名"的指令。但正如我们已经猜到的那样:这条指令肯定没有看上去这么简单。让我们开始分析它吧。

在局部变量和实参压栈都完成后,我们接下来要做的第一件事是:为LD指令的基石——BP寄存器做准备。说的好像很复杂的样子,实际上这一动作只需要一行类似于"BP = SP"的代码,将BP设定为栈顶就行了。但是,有一件非常重要的事情我们不能忘记:BP的值在"BP = SP"的时候并不是无意义,可以随意覆盖的,而是作为外部函数,如main函数,甚至是这个函数自己(递归调用)的BP存在的。这怎么办?不难想到,如果我们先将旧的BP压栈,保存下来,再进行"BP = SP",问题就解决一半了,因为我们至少没有将旧的BP就这么丢掉,还是能找回来的;在函数调用结束后,我们再想个办法(稍后我们就能看到这个办法)将旧的BP从栈上恢复出来,问题的另一半就也解决了。

接下来,让我们思考这样的一个问题:不难想到,函数调用本质上就是一个跳转动作,但不同于普通的JMP指令,函数调用是一个"回旋镖",在调用结束后,IP是需要回来的。这怎么实现?上文中我们讨论的针对BP的解决方案带给我们灵感:我们也可以通过类似的方案实现这样的"回旋镖"。我们可以在跳转前先将旧的IP压栈,保存下来,再进行跳转;在函数调用结束后,我们再想个办法(同样,稍后我们就能看到这个办法)将旧的IP从栈上恢复出来,让虚拟机这个"回旋镖"从遥远的函数那儿"飞回来"。

当我们兴致勃勃的将旧的IP压栈后,我们很快就又停下了,因为我们又遇到了一个新的问题,而且这个问题似乎很严重:

我们应该去哪?

我们无法回答这个问题。我们现在充其量只知道自己现在在哪,以及,正在调用哪个函数;但是并不知道这个函数在哪,从而,也就不知道应该去哪了。那么,什么时候才能知道应该去哪呢?不难想到:当所有的代码都呈现在我们面前的时候,我们当然就知道应该去哪了。这正是链接器所具有的功能。所以,我们在这里需要暂且生成一条"CALL 函数名"伪指令,并在稍后通过链接器的广阔视野,将这条伪指令变为一条真正的"CALL N"指令。

将上文中的全部内容整合起来,就是"CALL N"指令(虽然它现在还只是"CALL 函数名"伪指令)所进行的全部操作了,总结一下:

  1. 将旧的BP压栈
  2. 根据栈顶设定新的BP
  3. 将旧的IP压栈
  4. 根据N设定新的IP

在这一节的末尾,让我们来讨论CALL指令的好兄弟——RET指令。

RET指令是每个非main函数的最后一条指令。在CALL结束后,我们正是通过这条指令,同时将旧的IP和BP从栈上恢复出来,让虚拟机回到函数调用之前的状态,就好像什么事都没发生一样(除了AX)。说的好像很厉害的样子,实际我们需要做的却很简单:由于我们是先将旧的BP压栈,再将旧的IP压栈,所以RET指令只需要反着来,先将旧的IP退栈,再将旧的BP退栈即可。结合虚拟机的实现我们不难发现,在IP退栈后,紧接着的就是一次"IP++",这就使得IP越过了"CALL N"这条指令,来到了函数调用后的下一条指令上。一切都显得那么自然,不是么?

最后,就让我们来看一段在下一章才会真正出现的"神秘代码",来遥望一番CALL指令与RET指令之间的默契配合吧:

// 为每个非main函数添加最后一条RET指令
if (curFuncName != "main")
{
    /*
        The instruction "RET" perform multiple actions:

        1. IP = SS.POP()

            Now the SS is like:

            ... Local5 Local4 Local3 Param2 Param1 Param0 OldBP

        2. BP = SS.POP()

            Now the SS is like:

            ... Local5 Local4 Local3 Param2 Param1 Param0

        So we still need several "POP" to pop all variables.
        (See the function: __generateCallCode)
    */
    codeList.emplace_back(__Instruction::__RET, "");
}

6. 实参与局部变量退栈

在函数调用结束后,我们需要将先前为了函数调用而进行的压栈全部退栈。那么,我们究竟需要退栈多少次呢?稍加思考,不难得到以下结论:

  1. 对于符号表中的每个变量,不管是不是数组,都需要退栈一次
  2. 如果一个变量是数组,则还应额外退栈数组长度次

由此,我们可以得到以下实现:

    // ...接上文中代码

    // After call, we need several "POP" to pop all variables.
    for (auto &[_, mapVal]: __symbolTable.at(root->__subList[0]->__tokenStr))
    {
        // Any variable needs a "POP"
        codeList.emplace_back(__Instruction::__POP, "");

        // Pop array content (by array size times)
        for (int _ = 0; _ < mapVal.second; _++)
        {
            codeList.emplace_back(__Instruction::__POP, "");
        }
    }

    // 终于不是未完待续了!
    return codeList;
}

至此,函数调用的代码生成器分派函数的实现就全部完成了。但是,我们仍有三个遗留问题尚待解决:

  1. 我们需要为全局变量压栈
  2. main函数需要在程序启动时被自动调用
  3. 我们需要实现一个链接器,以将所有的CALL伪指令转变为一条真正的CALL指令

这些问题,我们都将在下一章的旅程中进行讨论。请看下一章:《全局变量、main函数、代码装载与链接器》。

posted @ 2021-02-19 16:51  樱雨楼  阅读(247)  评论(0编辑  收藏  举报