编译器实现之旅——第十五章 函数调用的代码生成器分派函数的实现
在前面几个章节的旅程中,我们实现了一些各有特点的代码生成器分派函数,而在这一章的旅程中,我们将迎来整个代码生成器,乃至整个编译器实现之旅的重头戏:函数调用的实现。现在,就让我们开始吧。
1. 函数调用概观
函数调用是一个复杂的过程,从代码生成器层面看,其涉及到符号表以及多个与之相关的分派函数,此外,函数调用还需要在稍后经由链接器组件进行处理;而从虚拟机层面看,函数调用涉及到了虚拟机的所有寄存器和一些特殊的指令。由此可见,函数调用这一分派函数所涉及到的面之广,是其他分派函数所不能达到的。下面,让我们首先来看看一次函数调用的代码生成,究竟需要经历哪些步骤:
- 局部变量压栈
- 实参压栈
- BP寄存器准备
- IP寄存器准备
- 函数调用跳转
- 实参与局部变量退栈
我们将在下文中逐条的讨论这些步骤的含义与实现。
接下来,我们将上一章中绘制的栈内存结构图中,与函数调用相关的部分展示如下:
+-------+ ... +-------+-------+-------+-------+-------+-------+-------+-------+-------+---------+-------+ ...
| 索引值 | ... | 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在栈中的位置在所有的参数之后,故我们压栈的顺序应当与符号表中变量的编号完全相反。此外,还记得我们在生成符号表的时候提到:一定要先为形参编号,再为局部变量编号吗?这样做,就使得符号表中的变量编号具有了一个非常重要的性质:任何局部变量的编号一定大于任何形参的编号。在此基础上,通过进一步的分析与整理,我们可以得到以下要点:
- 我们应当按照符号表中的变量编号从大到小的顺序进行压栈
- 实参信息存在于root的第二子节点(如果有)中
- 我们可以用符号表中所有变量的数量,减去root的第二子节点(如果有)的所有子节点的数量,即实参的数量,得到局部变量的数量
- 在对局部变量进行压栈时,我们需要判定当前这个局部变量是不是数组。如果是,则我们应首先进行数组长度次压栈,为数组内容留出空间;然后,我们应计算数组的第一个元素在栈中的索引值,并将其压栈。怎么计算呢?不难发现,其值等于:SP - 数组长度
- 实参不可能是一个数组
上述结论将作为我们实现函数调用的理论基础。
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 函数名"伪指令)所进行的全部操作了,总结一下:
- 将旧的BP压栈
- 根据栈顶设定新的BP
- 将旧的IP压栈
- 根据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. 实参与局部变量退栈
在函数调用结束后,我们需要将先前为了函数调用而进行的压栈全部退栈。那么,我们究竟需要退栈多少次呢?稍加思考,不难得到以下结论:
- 对于符号表中的每个变量,不管是不是数组,都需要退栈一次
- 如果一个变量是数组,则还应额外退栈数组长度次
由此,我们可以得到以下实现:
// ...接上文中代码
// 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;
}
至此,函数调用的代码生成器分派函数的实现就全部完成了。但是,我们仍有三个遗留问题尚待解决:
- 我们需要为全局变量压栈
- main函数需要在程序启动时被自动调用
- 我们需要实现一个链接器,以将所有的CALL伪指令转变为一条真正的CALL指令
这些问题,我们都将在下一章的旅程中进行讨论。请看下一章:《全局变量、main函数、代码装载与链接器》。