北大编译实践踩坑
额外信息
Bison中百分号的作用:Bison中百分号的作用 - Gold_stein - 博客园
Lv0.环境配置
因为win11默认开启的VBS会导致在游戏等高负载场景显卡占用异常,但是win11又已经关闭了单独关闭vbs同时保留hyperv的通道,所以只能选择一劳永逸地将hyperv关闭。这样一来,windows宿主机就无法使用docker环境了,只能把docker安装在linux虚拟机当中,通过ssh来进行远程访问。
备忘:
1.这是 Koopa IR 里的规定, Function
, BasicBlock
, Value
的名字必须以 @
或者 %
开头. 前者表示这是一个 “具名符号”, 后者表示这是一个 “临时符号”.
- 这两者其实没有任何区别, 但我们通常用前者表示 SysY 里出现的符号, 用后者表示你的编译器在生成 IR 的时候生成的符号. 因为
main
是 SysY 里定义的, 所以这个函数叫@main
. 关于符号名称的细节见 Koopa IR 规范.
Lv1.main函数
Lv1.5测试
文档中给出的lex文件并没有处理块注释,需要自己加入定义。
起初我用的是\/\*[\s\S]*?\*\/ 但无论如何也不能正确匹配,询问gpt后给出了非常冗杂的表达式: \/\*([^*]|\*+[^/])*\*+\/ 虽然简单,但多少有些反人类了。
直到最后也没有弄清楚\/\*[\s\S]*?\*\/ 为何报错,返璞归真使用了BlockComment \/\*.*?\*\/的定义,还真跑起来了。
Lv2.
Lv2.2
我选择了dfs的写法,这样写的关键是搞清楚每个函数所接受参数的数据类型。
为了适应这里的输出方式,要把lv1里ast的dump函数从cout改成oss,格式化写入字符串。
Lv3.表达式
Lv3.1
关于算数右移和逻辑右移:
- 逻辑右移 (shr):逻辑右移是将所有位都向右移动,不考虑符号位。在逻辑右移中,最高位也是补零,不论它原来是什么。这意味着逻辑右移对于正数和无符号数的操作是一样的。例如,对于二进制数1101,逻辑右移一位后得到0110。
- 算术右移 (sar):算术右移是有符号整数右移操作,会考虑符号位。在算术右移中,最高位(符号位)保持不变,也就是说,如果原数是正数,最高位仍为0;如果原数是负数,最高位仍为1。这意味着算术右移会保持原数的符号。例如,对于二进制数1101,算术右移一位后得到1110,因为它是一个负数。
- 在baseAST头文件当中,一元运算符从右向左结合,所以这里要先访问子树再访问自身(如下代码)
- 在bison文件中,要注意字符串和字符变量的区别是很严格的,单个字符只能用单引号,不能用双引号,否则会导致syntax error
- class UnaryExpAST : public BaseAST { public: std::unique_ptr<BaseAST> pexp; char uop; std::unique_ptr<BaseAST> uexp; void Dump(std::ostringstream &oss) const override { #ifdef DEBUG oss << "unaryexp "; #endif if (pexp == nullptr) { flag = 1; uexp->Dump(oss); #ifdef DEBUG oss << "uop " << uop << " and uexp "; #endif oss << "%" << cnt << " = "; if (uop == '!') { oss << "eq "; if (!cnt) oss << rootnum << ", 0"; else oss << "%" << cnt - 1 << ", 0"; } if (uop == '-') { oss << "sub 0, "; if (!cnt) oss << rootnum; else oss << "%" << cnt; } oss << "\n"; cnt++; } else pexp->Dump(oss); } };
Lv3.2
1.因为unordered_map和map没有提供koopa.h中各种数据类型对应的哈希函数,导致使用map不能有效地记录信息,为此,我们可以采用记忆化搜索的方案,在解析表达式的过程中记住每一步所使用的寄存器。
除此之外,我们还能采取一种取巧的办法:结构体由于数据类型的特殊性,不能够直接作为map的键值,但是指向它的指针本质上就是一个长整形,我们可以用指向其的指针取而代之,来作为我们map的键值,这样写比上面一种方法更加简单快捷。
2.cal是直接覆盖寄存器,不会新开寄存器,所以cal之后不需要now_using_reg++;
case KOOPA_RBO_SUB: get_both(L, R, l_reg_idx, r_reg_idx); cout << "\tsub\t" << reg_names[now_using_reg - 1] << ", "; CalPrint(l_reg_idx, r_reg_idx); break;
3.常见问题:
在本次编译实践中,有很多地方会出现这样的情况:
有很多的if/switch的条件判断,每个条件都需要执行类似cnt++的操作,可以在每个判断语句块里单独写,也可以总写,千万注意不要两处都写上了
4.BaseAST的koopaIR输出问题:
if (!mul_done && !add_done) { oss << root_mul << ", " << root_add << "\n"; } else if (mul_done && !add_done) { oss << root_add << ", " << "%" << new_mul_cnt - 1 << "\n"; } else if (add_done && !mul_done) { oss << root_mul << ", " << "%" << new_add_cnt - 1 << "\n"; } else oss << "%" << new_add_cnt - 1 << ", " << "%" << new_mul_cnt - 1 << "\n"; cnt++;
第一种情况对应两边都没有进行额外计算,直接引入新操作数的情况。一开始想错了,写的判断条件是!cnt,但稍微复杂一点的表达式就能推翻这个错误判断。
例如:2*3+4*5
先处理4*5,此时cnt已经大于0了,但是在处理2*3时,依然属于第一种情况,所以必须用两个done是否都为假来执行该判断。
Lv3.3
历史遗留问题:
辨析make_unqiue<>() 和 unique_ptr<>();
例子:
auto comp_unit = make_unique<CompUnitAST>(); comp_unit->func_def = unique_ptr<BaseAST>($1);
前者的相当于new () ,只不过得到的是一个智能指针,而非普通指针 ; 后者相当于强制转换类型,即把普通指针转换为智能指针。
tips:在类的成员函数中,只能调用同样为const的成员函数。
例如:
class LorExpAST : public BaseAST { public: void PrintOr(const std::string &a, const std::string &b, std::ostringstream &oss) const { oss << "%" << cnt++ << " = " << "or " << a << ", " << b << "\n"; oss << "%" << cnt << " = " << "ne " << "%" << cnt - 1 << ", 0" << "\n"; cnt++; } std::unique_ptr<BaseAST> lorexp, landexp; void Dump(std::ostringstream &oss) const override { if (lorexp == nullptr) landexp->Dump(oss); else { int old_and_cnt = cnt; landexp->Dump(oss); int root_and = rootnum; bool and_done = old_and_cnt != cnt; int and_cnt = cnt - 1; int old_or_cnt = cnt; lorexp->Dump(oss); int root_or = rootnum; bool or_done = old_or_cnt != cnt; int or_cnt = cnt - 1; std::string or_prd = or_done ? ("%" + std::to_string(or_cnt)) : std::to_string(root_or), and_prd = and_done ? ("%" + std::to_string(and_cnt)) : std::to_string(root_and); PrintOr(or_prd, and_prd, oss); } } };
这里的PrintOr函数必须定义成const,否则会报错。
重点: 引入这一节的新运算符时,我首先采取的策略是长度为一的>和<仍然采用单字符方式读取,其他的运算符长度为二,则新开一种token:
DoubleCharOp "||"|"&&" ……
这种方法是错误的,会导致shift/reduce conflicts (规约冲突)
因为这一节引入的所有的表达式,都有一个统一的子结构: x op y
如果所有长度为2的运算符都被归类为DoubleCharOp,那么就会导致程序在处理这种结构的时候,不知道是往下继续划分还是到此为止,造成冲突。
误区: 因为koopa只支持按位与和按位或,所以起初我在生成riscv时也用的是按位与andi和按位或ori,但是这两种运算的操作数有某种还没遇到的限制,会报错。
但是根据我已经编写的koopa规则,可以得知运算执行到这一步的时候,operands只可能是0或1,这时按位与和与、按位或和或是没有区别的,直接用and和or即可。
注意如果表达式当中两个数都是0,那么就会都用x0寄存器,就导致now_using_register就不会移动,接下来就会导致寄存器之间相互覆盖,因此,一旦在执行完get_both函数之后,发现这个now_using_register没有移动,就需要手动给他加上一。
Lv4
Lv4.1
这一章的ENBF出现了
ConstDecl ::= "const" BType ConstDef {"," ConstDef} ";";
这种可以重复的形式,但是我们知道,实际上语法解释器是不支持用大括号来表示重复出现的,那我们要怎么编写,来实现这一功能呢?
可以用递归的形式来实现:
BlockItemList : BlockItem { auto v = new vector<unique_ptr<BaseAST>>(); v->push_back(unique_ptr<BaseAST>($1)); $$ = move(v); } | BlockItemList BlockItem { auto v = ($1); v->push_back(unique_ptr<BaseAST>($2)); $$ = move(v); } ;
这样,我们就借助一个vector数组实现了重复功能。
起初,我试图用auto来遍历一个智能指针vector:
void ConstDeclCal() const override { for(auto i : constdef) { } }
这样子会报错,因为智能指针是不允许拷贝的,只能mv,这里只需要把i改为引用即可。
Lv4.2
文档里说
“
在遇到 LVal
时, 你需要从符号表中查询这个符号的信息, 然后用查到的结果作为常量求值/IR 生成的结果. 注意, 如下情况属于语义错误:
- 在进行常量求值时, 从符号表里查询到了变量而不是常量.
- 在处理赋值语句时, 赋值语句左侧的
LVal
对应一个常量, 而不是变量. - 其他情况, 如符号重复定义, 或者符号未定义.
”
这里的第一点可能是指1.用变量作为数组的大小,但是目前还没有涉及到数组
2.常量表达式当中不能出现变量
如果需要在多个文件中使用同一个全局变量,那么应该只在main当中进行全局变量的定义,在头文件里用extern进行声明,避免内存的重复分配。
struct没有这种烦恼,因为定义一个struct只是“定义”一种结构,不涉及内存的分配。
历史遗留问题:
用于映射koopa中各个raw元素和相应寄存器(或内存)的map,键值如果用各种不同类型的指针的话,随着实验的规模愈发庞大,要做的特判也就越来越多,可以通过统一使用koopa_raw_value_t作为键值来省去这一麻烦,因为所有的指令最后都归结于value,可以在value的visit函数中执行这一操作,把koopa_raw_value_t作为参数传入到下一层函数当中。
脑瘫错误:
valuechart结构体的内部代码没有写完,导致之后自动补全不能正常生效,还以为是编译器设置出了问题,调试了半天。
关于const函数:传递可移动类型:qualifiers dropped in binding reference of type "std::__cxx11::string &" to initializer of type "const std::__cxx11::string"
为什么会这样报错: 在const函数中,调用了非const的引用,
例如:
#.hpp class A { public: wstring& GetTitle() const; private: wstring title; };
#.cpp wstring& GetTitle() const { return this->title; }
如这里所示,在const函数gettitle中,调用了非const的成员title,而const函数是没有这个权限的。
如果一个结构体内部有返回本身数据类型的函数,可以模仿koopa.h这样写:
struct valuechart; typedef valuechart *valuechart_t; struct valuechart{ ident_map chart; valuechart *father; int32_t depth = 1; valuechart() : father(nullptr) {} valuechart_t ident_search(const std::string &ident) { if(chart.count(ident)) return this; if(father == nullptr) return nullptr; return father->ident_search(ident); } };
先定义一个空的,然后再完整写出来。
也可以这样:
#include <iostream> // 结构体的声明 struct MyStruct { int data; // 成员函数的声明,返回类型为结构体自身类型 MyStruct myFunction() const; // 成员函数的定义 void printData() const { std::cout << "Data: " << data << std::endl; } }; // 成员函数的定义 MyStruct MyStruct::myFunction() const { MyStruct result; result.data = data * 2; // 做一些操作 return result; }
这种写法更适合类
Lv5
这个等级由于多了语句块和作用域的处理,所以新增了许多指向作用域的指针,带来了一些内存管理上的麻烦
尤其是处理新block这里:
auto rootchart = new valuechart(); rootchart = nowchart; nowchart = new valuechart(); nowchart->depth = rootchart->depth + 1; nowchart->father = rootchart; block->Dump(); delete (nowchart); nowchart = rootchart;
因为rootchart需要来回赋值,所以不适合用unique_ptr;
又因为可能会产生死锁问题,所以不适合用shared_ptr;
最后发现还不如手写new,自己加个delete
另外,如果把rootchart定义成全局变量,也有可能在有多重作用域嵌套的情况下,在最后两层产生死锁问题,所以目前这种写法就是最优解。
Lv6
这个level新增了if-else,需要解决else的悬挂问题,解决办法:
把If语句分成两类,一类是已经完成if-else配对的,这样可以保证每个else都有清晰的从属;
另一类是未完成配对的,有机会加上else。
//! Stmt ::= UnmatchedStmt
//! | MatchedStmt
//!
//! UnmatchedStmt ::= "if" "(" Exp ")" MatchedStmt ["else" UnmatchedStmt]
//!
//! MatchedStmt ::= LVal "=" Exp ";"
//! | [Exp] ";"
//! | Block
//! | "if" "(" Exp ")" MatchedStmt "else" MatchedStmt
//! | "return" [Exp] ";";