实验三:三代编译器实验
一、实验要求
详细实验要求请参考文件《Lab3实验说明和要求.pdf》。
二、文件
共包括五个文件:
-
main_without_arg.cpp
是可以在本地直接编译使用的源文件,是用于测试输入输出结果的文件。
注意每次输入结束后,需要另起一行,并在该行输入+++(3个加号)才能终止输入并显示输出。
-
main.cpp
是根据实验平台提交要求修改了输入输出方式的文件,是最终提交的文件之一。
-
F.h
用于声明函数,是最终提交的文件之二。
-
F.cpp
用于具体定义 F.h 中的函数,是最终提交的文件之三。
-
CMakeLists.txt
用于组装文件,是最终提交的文件之四。
三、实验思路
1、大致流程
以下仅介绍我的流程,据我所知,我身边的人做法就各不相同,所以做法其实是很多的。
- 词法分析:预处理数据,对输入分词,把句子变成一系列的单词,这样就转化为了好处理的形式。我在①
tokenization
函数中做了这个工作。 - 语法分析:分析成分,识别每个单词的词性。
- 划分函数:由于 lab3 开始出现自定义函数,所以我们需要能够②识别出有几个函数、每个函数名是什么、函数从哪里开始又到哪里结束,函数的返回值类型。
- 划分句子:在每个函数的内部存在许多语句,我将其根据分号划分为一个个句子。
- 处理句子:③逐行识别句子的含义,并给出对应的汇编语句。
2、稍微精细一点的介绍
2.1 ① 分词
-
作用:对文本进行分词
-
接受的输入:待处理的字符串
-
返回值:一个处理后的字符串
-
该函数的实现原理是:扫描两遍字符串。第一次扫描,只要遇到了符号(除了
_
),⑤就在符号的前后各添加一个空格;第二次扫描,使用字符流保存单词。使用字符流的好处是可以自动去除空格。 -
下面是一个实现第一次扫描的参考代码:
string tokenization(string text1){ string text2 = ""; for (char i: text){ if (不是字母、数字或下划线) { text2 += (" "+i+" "); } else text2 += i; } return text2; }
-
下面是一个实现第二次扫描的参考代码:
stringstream sstream; sstream << text2; string stemp = ""; vector<string> text3; while (sstream >> stemp) { text3.push_back(s0); }
-
例如: text1 = "int main(){int a;a=1;return 0;}" 经过上述处理后: text2 = "int main ( ) { int a ; a = 1 ; return 0 ; } " text3 = ["int", "main", "(", ")", "{", "}", "int", "a", ";", "a", "=", "1", ";", "return", "0", ";", "}"] 注意 text1 和 text2 是字符串, 而 text3 是一个数组, 它的每一项都是一个单词.
-
注:该分词方法是 lab2 的文章发布后有一个同学与我讨论了这个方法,我觉得很好用,这次我就使用了这种。
2.2 ② 识别函数体
- 识别出有几个函数:大括号有几对,就有几个函数
- 函数从哪里开始又到哪里结束:按右大括号划分
- 每个函数名是什么:程序的开头的第二个单词,或者右大括号往后的第二个单词,这些都是函数名
- 函数的返回值类型:返回值可能是
void
或者int
,注意到这两种情况对应的汇编输出略有不同 - 顺带一提,你完全不需要把
main
函数和其他函数分开处理,它们并没有任何不同
2.3 ③ 逐行识别句子的含义
- 这是最难编写的部分,大概也是最重要的部分
- 在定义函数时:
- 可能带有传入参数
- 在处理函数内语句时,最简单的有:
- 遇到了
int
,说明要定义局部变量,可能带有初始化,该初始化是一个④表达式 - 遇到了
println_int
,说明要call printf
,括号里的是一个④表达式 - 遇到了
return
,准备leave\nret\n
,对于void
函数没有返回值;对于int
函数的返回值是一个④表达式
- 遇到了
- 在处理函数内语句时,如果不属于以上三种情况,那么
- 是仅调用函数,每一项的传参是一个④表达式
- 如果不是仅调用函数,那必是赋值语句,该句子的第一个单词必是标识符,第二个单词必是等号,第三个及之后是一个④表达式
2.4 ④ 表达式的处理
-
这里先对“表达式”下一个简要的定义:它可能是数字、变量、⑥调用函数或包含计算的一个式子,以下都是表达式:
1 a 2*3 f(f(4)) # f是一个自定义的函数
注意到,对于
f(f(4))
来说,内部的f(4)
也是一个表达式;对于f(4)
来说,内部的4
又是一个表达式。 -
注意句子和表达式是不同的,句子是带分号的那种,表达式本质上只是一个结果或者说一个值,例如:
int a; 是句子, 它不含有表达式 int a=1; 是句子, 它含有表达式 1 return 0; 是句子, 它含有表达式 0 a=f(1,f(2,3)); 是句子, 它含有表达式 f(1,f(2,3)), 该表达式又含有表达式 1 和表达式 f(2,3), 对于后者, 它又含有表达式 2 和表达式 3
-
观察③中的描述,出现了大量含有“表达式”的描述,并且考虑到表达式的内部可能又是表达式,因此一个比较好的做法可能是单独写一个函数专门用来处理表达式,下面我给出一个参考框架:
void expression(vector<string> E) { // 如果表达式的长度是 1, 可能是一个数字或一个变量 if (E.size()==1) { if (是数字) { 对数字的处理; } else { 对变量的处理; } } // 否则是仅调用函数或带计算的式子 else { // 如果是仅调用函数 if (仅调用函数) { 对函数的处理; ----------⑦ } // 是涉及运算 else { // 考虑运算符的优先级 对运算的处理; } } return; } 注: 我写这个函数的长度是 206 行.
单独定义函数的好处是,遇到表达式,直接调用这个函数;如果遇到了表达式的内部是表达式,则递归调用这个函数。
2.5 一些对你可能有用汇编语句
假设 a 和 b 的值已经提前分别存储在 eax 和 ebx 中,并且需要把计算的结果放在 eax 中。
# a + b
add eax, ebx
# a - b
sub eax, ebx
# a * b
imul eax, ebx
# a / b
cdq
idiv ebx
# a % b
cdq
idiv ebx
mov eax, edx
# a < b
cmp eax, ebx
setl al
movzx eax, al
# a > b
cmp eax, ebx
setg al
movzx eax, al
# a <= b
cmp eax, ebx
setle al
movzx eax, al
# a >= b
cmp eax, ebx
setge al
movzx eax, al
# a == b
cmp eax, ebx
sete al
movzx eax, al
# a != b
cmp eax, ebx
setne al
movzx eax, al
# a & b
and eax, ebx
# a | b
or eax, ebx
# a ^ b
xor eax, ebx
# a && b ----------⑧
# a&&b 的意思是,当且仅当a和b都不为0时,结果为1;否则结果为0.
# 实现的方法很多,这里是一个例子
test eax, eax
jz false1
test ebx, ebx
jz false2
mov eax, 1
jmp done
false1:
false2:
mov eax, 0
done:
# a || b ----------⑧
# a||b 的意思是,当且仅当a和b都为0时,结果为0;否则结果为1.
# 实现的方法很多,这里是一个例子:
test eax, eax
jnz true1
test ebx, ebx
jnz true2
mov eax, 0
jmp done
true1:
true2:
mov eax, 1
done:
# -a
# 也可以看作是0-a,转化为减法
neg eax
# !a
test eax, eax
setz al
movzx eax, al
# c = ~a
not eax
2.6 运算符优先级
其实网上很容易搜得到,但是上回就有人来问我优先级,所以我也一并贴出:
( -
= ~
= !
) > ( *
= /
= %
) > ( +
= -
) > ( >
= <
= >=
= <=
) > ( ==
= !=
) > &
> ^
> |
> &&
> ||
2.7 调用函数的传参
函数调用是这样的:参数是从后往前压栈的(这样调用函数的时候弹栈的顺序就是从前往后的)
一个参考做法如下,例如 f(1+2, 3*4, !!5, g(6, 7))
,先划分,找到划分的位置分别是 1, 5, 9, 13, 20
f ( 1 + 2 , 3 * 4 , ! ! 5 , g ( 6 , 7 ) )
----------------------------------------------------
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
然后从后往前,依次是 14-19, 10-12, 6-8, 2-4,它们每一个都是表达式。
四、实验注意
-
该部分主要阐述本人在做该实验时踩过的坑
-
这次提交方式和 lab2 一样都需要写 CMakeList 文件,不同之处是在声讨之下从 gitlab 提交改为了压缩包提交,个人认为交压缩包还是更方便一点。
-
⑤:如果你使用了我的分词方法,注意在第二次扫描时处理两个符号应该连在一起作为一个单词共现的情形,它们是
>=, <=, !=, ==, &&, ||
-
⑥:对于是否仅调用函数的判断,我注意到很多人的做法是:
if (第一个单词是函数名 && 第二个单词是左括号 && 分号的前一个单词是右括号)
然而该条件判断不完全正确,例如下面的反例存在:
func ( 1 ) + func ( 2 ) ;
显然,它的第一个单词是函数名
func
,第二个单词是(
,分号前面的单词是)
,但它并不是仅函数调用,而是一个带计算的式子。那么正确的判断条件是怎样的呢?提示一下:你需要考虑括号,具体请你自己思考。
-
⑦:对于多元函数,我们为了传递参数,就需要将每个参数分别处理并依次传入,例如:
funct(1, 2, 3) # 函数原型: int funct(int a, int b, int c);
对应的汇编过程可能是:
push 3 push 2 push 1 call funct
为了能够分辨各个参数,许多人的做法是按照
,
分割,从而f(1,2,3)
的参数被分割成1
,2
,3
。这种方法也存在问题,考虑多元函数的嵌套,下面的反例存在:
g(1, g(2, 3)) // 函数原型: int g(int a, int b);
此时你再试试用
,
分割,结果是:1
,g(2
,3)
,显然划分结果中的g(2
和3)
都是不可被正确识别的。那么正确的划分应该怎样做呢?提示一下:你需要考虑括号,具体请你自己思考。
-
-
是一个很特别的符号,务必记得正确地区分它究竟是双目运算符还是单目运算符。 -
⑧:我之前没有学过汇编语言,后来我稍微地学了一些,算是大概把这个看懂了。
以计算 a&&b 为例,我在上面给出的语句是:
test eax, eax jz false1 // 看这里 test ebx, ebx jz false2 // 看这里 mov eax, 1 jmp done false1: // 看这里 false2: // 看这里 mov eax, 0 done:
test eax, eax
和jz false1
的意思是判断 eax 是否为 0,如果是 0,则跳转到false1:
这一行开始执行; 同样,test ebx, ebx
和jz false2
的意思是判断 ebx 是否为 0,如果是 0,则跳转到false2:
这一行开始执行;jmp done
的意思是跳转到done:
这一行开始执行。我参考了这一篇:x86 汇编手册快速入门
需要注意的是,可以多处跳转到同一个入口,例如:
test eax, eax jz false // 看这里 test ebx, ebx jz false // 看这里 mov eax, 1 jmp done false: mov eax, 0 done:
是可以正确运行的。
然而,例如有两个
&&
运算,写成test eax, eax jz false test ebx, ebx jz false mov eax, 1 jmp done false: // 看这里 mov eax, 0 done: // 看这里 test eax, eax jz false test ebx, ebx jz false mov eax, 1 jmp done false: // 看这里 mov eax, 0 done: // 看这里
存在多个跳转的入口,这样就会报错。
对于
a||b
的情况类似,不再赘述。 -
注意传参和局部变量的区别,它们在栈里的位置有些不同,局部变量是
ebp-
,而传参是ebp+
五、测试
平台上已经为你准备了 8 道测试用例,你可以自己测测看,这次我就不写用例了。
注:哥们不卖代码。