使用固定/预分配寄存器的表达式的代码生成

8
我正在使用以下算法(从此回答中借鉴思路:this)来从树中生成代码,我针对x86体系结构进行编写,现在我需要处理使用eax/ebx寄存器作为参数的mul/div指令。
我的问题是:
如何修改此算法以将某个指令的操作数加载到固定寄存器中?例如,对于mul指令,在eax和ebx寄存器中加载左子树和右子树。我的当前实现方式是:将当前节点作为参数传递,并且如果它是MUL或DIV,则根据树的侧面将reg设置为R0或R1,如果分别为LEFT或RIGHT。如果reg正在被使用,则将其推入堆栈并将其标记为释放状态(尚未实现)。当前的实现方式不起作用,因为在emit_load()函数中会出现“assert(r1 != r2)”断言(表示作为参数传递的两个寄存器相等,例如r1 = REG_R0和r2 = REG_R0)。
      void gen(AST *ast, RegSet in_use, AST *root) {
            if(ast->left != 0 && ast->right != 0) {
                Reg spill = NoRegister; /* no spill yet */
                AST *do1st, *do2nd;     /* what order to generate children */
                if (ast->left->n >= ast->right->n) {
                    do1st = ast->left;
                    do2nd = ast->right;
                } else {
                    do1st = ast->right;
                    do2nd = ast->left; }
                gen(do1st, in_use);
                in_use |= 1 << do1st->reg;
                if (all_used(in_use)) {
                    spill = pick_register_other_than(do1st->reg);
                    in_use &= ~(1 << spill);
                    emit_operation(PUSH, spill); 
                }
                gen(do2nd, in_use);
                ast->reg = ast->left->reg
                emit_operation(ast->type, ast->left->reg, ast->right->reg);
                if (spill != NoRegister)
                    emit_operation(POP, spill);
            } else if(ast.type == Type_id || ast.type == Type_number) {
                if(node->type == MUL || node->type == DIV) {
                    REG reg;
                    if(node_side == ASTSIDE_LEFT)  reg = REG_R0; 
                    if(node_side == ASTSIDE_RIGHT) reg = REG_R1;
                    if(is_reg_in_use(in_use, reg)) {
                        emit_operation(PUSH, reg);
                    }

                } else {
                  ast->reg = pick_unused_register(in_use);
                  emit_load(ast);
             }
            } else {
                print("gen() error");
                // error
            }
    }

// ershov numbers
void label(AST ast) {
    if(ast == null)
        return;

    label(ast.left);
    label(ast.right);

    if(ast.type == Type_id || ast.type == Type_number)
        ast.n = 1;
    // ast has two childrens
    else if(ast.left not null && ast.right not null) {      
        int l = ast.left.n;
        int r = ast.right.n;

        if(l == r)
            ast.n = 1 + l;
        else
            ast.n = max(1, l, r);
    }
    // ast has one child
    else if(ast.left not null && ast.right is null)
        ast.n = ast.left.n;
    else
        print("label() error!");
}
1个回答

8

使用像这样的一次性代码生成器,您的选择是有限的。可能更简单的方法是先生成3地址代码或其他线性中间表示,然后再考虑寄存器定位(这就是您要完成的任务的名称)。

尽管如此,您想要做的事情是可行的。但问题在于,您得不到非常高质量的代码。要使其更好,您必须放弃此生成器并重新开始。

您遇到的主要问题是Sethi-Ulman标记不是代码生成算法。它只是一种选择代码生成顺序的方式。您仍然缺少重要的思想。

既然所有问题都解决了,以下是一些要点:

为了暂时保存寄存器而推送和弹出它们会让生活变得困难。原因非常明显。您只能按LIFO顺序访问已保存的值。

如果在堆栈帧中分配可以是寄存器或内存位置的“位置”,则事情变得更容易。内存位置有效地扩展了寄存器文件,使其尽可能大。稍微复杂的是,您需要记住每个函数所需的位置字数,并回溯函数序言以分配该数字。

接下来,实现一个全局操作数堆栈,其中每个堆栈元素都是PLACE。PLACE是一个描述符,告诉已经发出的代码计算的操作数位于何处:寄存器或内存以及如何访问它。(为了获得更好的代码,您还可以允许PLACE是用户变量和/或立即值,但这些PLACE从未由下面描述的PLACE分配器返回。此外,允许的PLACE种类越多,则代码发射器必须处理的情况就越多。)

总体原则是“偷懒”。我们等待发出代码的时间越晚,可用的信息就越多。有了更多信息,就可以生成更好的代码。PLACE堆栈在实现这一点方面做得相当好。

代码生成器不变量是发出将结果PLACE留在操作数堆栈顶部的代码。

您还需要一个PLACE分配器。它跟踪正在使用的寄存器和内存字。如果所有寄存器和当前字都已忙碌,则分配新的内存字。

PLACE分配器中的寄存器可以具有三种可能的状态:FREE、BUSY、PINNED。固定的寄存器是需要保存不能移动的值的寄存器。(我们将在具有特定寄存器要求的指令中使用此选项。)BUSY寄存器是需要移动到不同PLACE的值的寄存器。FREE寄存器不保存任何值。

PLACE分配器中的内存可以是FREE或BUSY。

PLACE分配器至少需要以下入口点:

  1. allocate_register 选择一个空闲寄存器R,将其标记为繁忙(BUSY),并返回R。如果没有可用的空闲寄存器,则分配一个空闲内存单元P,将繁忙寄存器R的内容移动到P中,并返回寄存器R。
  2. pin_register(R) 的操作如下:如果寄存器R已被固定(PINNED),则引发致命错误。如果寄存器R处于繁忙状态(BUSY),则获取一个空闲位置P(可以是寄存器或内存单元),生成代码将R的内容移动到P中,将寄存器R标记为固定(PINNED)并返回。如果寄存器R处于空闲状态(FREE),则仅将其标记为固定(PINNED)并返回。

请注意,固定或分配寄存器R时需要移动其内容,分配器必须更新操作数堆栈中相应的元素。原来的寄存器R必须改为P。为此,分配器维护一个映射,将每个寄存器映射到描述其位置的操作数堆栈PLACE。

完成所有这些后,二进制操作代码生成器将变得简单:

gen_code_for(ast_node) {
  if (ast_node->left_first) {
    gen_code_for(ast_node->left_operand)
    gen_code_for(ast_node->right_operand)
  } else {
    gen_code_for(ast_node->right_operand)
    gen_code_for(ast_node->left_operand)
    swap_stack_top_2()  // get stack top 2 elements in correct order
  }
  emit_code_for(ast_node)
}

代码发射器将按照以下方式工作:
emit_code_for(ast_node) {
  switch (ast_node->kind) {
    case DIV:  // An operation that needs specific registers
      pin_register(EAX) // Might generate some code to make EAX available
      pin_register(EDX) // Might generate some code to make EDX available
      emit_instruction(XOR, EDX, EDX) // clear EDX
      emit_instruction(MOV, EAX, stack(1)) // lhs to EAX
      emit_instruction(DIV, stack(0)) // divide by rhs operand
      pop(2) // remove 2 elements and free their PLACES
      free_place(EDX) // EDX not needed any more.
      mark_busy(EAX)  // EAX now only busy, not pinned.
      push(EAX) // Push result on operand stack
      break;
    case ADD: // An operation that needs no specific register.
      PLACE result = emit_instruction(ADD, stack(1), stack(0))
      pop(2)
      push(result)
      break;
    ... and so on
  }
}

最后,指令发射器必须知道当其操作数具有处理器指令集不支持的类型组合时该如何处理。例如,它可能需要将一个内存位置(PLACE)加载到寄存器中。

emit_instruction(op, lhs, [optional] rhs) {
  switch (op) {
    case DIV:
      assert(RAX->state == PINNED && RDX->state == PINNED)
      print_instruction(DIV, lhs)
      return RAX;
    case ADD:
      if (lhs->kind == REGISTER) {
        print_instruction(ADD, lhs, rhs)
        return lhs
      }
      if (rhs->kind == REGISTER) {
        print_instruction(ADD, rhs, lhs)
        return rhs
      }
      // Both operands are MEMORY
      R = allocate_register // Get a register; might emit some code.
      print_instruction(MOV, R, lhs)
      print_instruction(ADD, R, rhs) 
      return R
      ... and so on ...

我已经省略了许多细节,请问有什么不清楚的吗?

回答 OP 的问题:

你说得对,我打算让 stack(n) 成为操作数栈中第 n 个 PLACE。

语法树的叶子节点只是将计算值的 PLACE 推送到操作数栈上来满足不变量。

如我之前所说,你可以为这些操作数创建特殊类型的 PLACE(用户标记的内存位置和/或立即值),或者 - 更简单也是你提出的方法 - 分配一个寄存器并生成加载该值到该寄存器的代码,然后将寄存器的 PLACE 推送到操作数栈上。更简单的方法会产生不必要的加载指令,并消耗更多的寄存器。例如 x = x + 1 将生成类似以下的代码:

mov esi, [ebp + x]
mov edi, 1
add esi, edi
mov [ebp + x], esi

这里我使用x来表示变量的基指针偏移量。

有了变量和字面值的PLACE,你可以轻松地获得:

mov esi, [ebp + x]
add esi, 1
mov [ebp + x], esi

通过让代码生成器知道分配答案所需放置的位置,您可以获得更好的结果。

add [ebp + x], 1

或者等价于。
inc [bp + x]

通过向代码生成器添加一个参数PLACE *target,描述计算表达式值的最终结果需要放到哪里来完成此操作。如果您当前没有编译表达式,则将其设置为NULL。请注意,使用target,代码生成器不变性会发生变化:除非设置了target,否则表达式结果的PLACE位于操作数栈的顶部。target被设置时,它已经计算到目标PLACE中。
x = x + 1 上如何工作呢?当emit_code_for过程的ASSIGNMENT情况递归调用自身以编译x + 1时,将提供x的 PLACE 作为target。这将向下委托将计算值放入正确的内存位置,即x。对于ADD的情况下,emit_code_for现在会递归调用emit_code_for来评估操作数x1的堆栈。由于我们具有用户变量和立即值的PLACE,因此这些内容将被推送到堆栈上,同时不生成任何代码。现在,ADD发射器必须足够聪明,以知道在堆栈上看到存储器位置L和字面常数C时,如果目标也是L,则可以发出add L,C,并且完成了。
请记住,每当您通过提供更多信息来使代码生成器“更智能”以做出其决策时,它将变得更长、更复杂,因为有更多的情况需要处理。

1
首先,非常感谢!我首先接受了您的答案,因为它是我正在寻找的,并且赏金即将结束。当我回家后,我会仔细阅读并给您我的反馈和问题。 - The Mask
2
@TheMask 我猜这个方法可以行得通。但是它可能会导致代码变得复杂让人头疼。一个内存位置是一个左值,而一个字面量则是右值。这种差异意味着它们实际上是两种不同的地方。将它们都用一个地方来表示(你所称的节点)并不是非常干净的设计,尽管它可能能够正常工作。你实际上可以使用相同类型的地方来同时保存临时变量和用户变量:Kind.MEMORY。堆栈帧偏移量可以涵盖这两种情况。所以你只需要添加一个额外的位置——字面常量。 - Gene
这是我的最后一个问题:在gen_code_for()中,我不处理非二进制运算符,并且如果左右两侧都不为空,则递归调用它,但emit_code_for()仍然被调用。这样做对吗? - The Mask
1
生成器不变量是子表达式值的存储位置在堆栈上。根据您要生成代码的语言,如果所有值都在语句中被分配并且语句具有“void”类型,则意味着最后没有留下任何值。在像C这样的语言中,语句可以具有值,在处理最后一个值后,通常会在堆栈上结束一个位置(因为其值可用但尚未使用),您可以在终止之前手动弹出它。 - Gene
1
对于AST节点(2),它会推送一个立即类型的PLACE。对于x,它会推送一个指向存储x的位置的引用(如果x是静态的,则为帧指针偏移或绝对地址)。对于+,emit_code总是处理顶部两个堆栈位置。在这里,它看到它必须添加一个立即数和一个堆栈引用,并选择最佳的指令来完成此操作。对于x86,它将在一条指令中将x的值加载到寄存器中,然后将立即值添加到寄存器中。它将从堆栈中弹出参数并推送一个寄存器的PLACE。 - Gene
显示剩余20条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接