南京大学计算机系统基础实验 ICSPA(2023)PA 1 阶段 2

表达式求值的作用是让我们能够在 NEMU 里进行数学运算,为后续的监视点功能提供基础。

根据讲义的描述,NEMU 会将我们输入的表达式分解为一个个的 token,然后识别每个 token 的类型并存储下来,如果是数值还需要把 token 的具体内容也存下来。这样计算机就能够理解我们输入的内容。后面再根据 token 来生成表达式树,从而进行递归求值,这一过程就是 BNF

我们需要实现的,就是编写 token 的识别规则。

我们可以根据讲义找到 make_token() 函数,在该函数的函数体中,最先可以找到一个 TODO

./pics/image.png

分析代码可以发现,这里其实是匹配完 token 之后对 token 进行操作的地方(比如,如果 token 是数字则还需要把具体内容也存下来),并不是 token 匹配规则的地方。

我们现在需要做的是补充 token 的识别规则,应当在 rules[] 里实现。查看 rules[] 的定义就可以看到真正需要补充代码的地方:

./pics/image-1.png

rules[].regex
在讲义中提到过一下“正则表达式的元字符”,再结合目前已经有的 .regex 的内容可以知道,.regex 应当是一个正则表达式的匹配规则。
正则表达式元字符
正则表达式的元字符,指的是在正则表达式中,我们编写的用的来指代需要匹配的字符的字符。例如 *+? 等。完整列表可以参考菜鸟教程

但是在 make_token() 中并没有发现任何地方使用 rules[] 来匹配 token,那么 NEMU 是怎么进行匹配的呢?

分析代码可知,在 NEMU 初始化的时候,会调用 init_regex() 来将我们在 rules[] 里编写的匹配规则进行处理,并将正则表达式初始化到 re[] 数组中,而 re[] 数组在 regexec() 中被作为参数传入,从而进行 token 的匹配。

因此,我们根据已有的格式,分别在上面提到的两个地方,将讲义中提到的

  • 十进制整数
  • +, -, *, /
  • (, )
  • 空格串(一个或多个空格)

的正则表达式匹配规则补充完成。

接下来,还需要在循环中将匹配到的 token 添加到 tokens[] 数组中,给接下来的环节使用。需要额外注意的是,TK_NUM 还需要将字符串的值复制到 tokens[].str 中;而 TK_NOTYPE 代表空格,其实可以直接忽略,不添加进去。

接下来需要完成表达式的计算,从而实现 p 指令。递归求值可以用分治的思想来解决。

分治的思想是“将问题划分为完全相同但是规模更小的子问题”。在这里,我们将题目概括为求一个表达式的值,那么规模更小的问题就是求一个子表达式的值。我们从右到左遍历当前表达式的每一个 token,然后找到优先级最低的运算符,分别求出这个运算符左边和右边的“子表达式”的值,再将其进行运算就可以得出当前表达式的值。

我们平时进行运算的时候都是从优先级高的开始,为什么这里是从优先级低的开始呢?而且还是从右到左,也和平时从左到右不一样。这是因为我们进行的是递归求值,表达式最终会从递归最底部开始计算,然后将结果返回给上一层,上一层再开始计算。所以越深层应当是越先需要计算的运算符,也就是优先级越高的运算符。

整个递归求值的过程可以分成以下步骤:

  1. 判断表达式是否被括号包围。
  2. 从右到左找到优先级最低的第一个运算符(需要考虑括号)。
  3. 递归求得子表达式的值,计算当前表达式的值并返回。

利用递归分值的思想,按照讲义中说的方法写就好了,没有什么太多需要理解思考的地方。

实现带有负数的算术表达式的求值 (选做)

其实现在不做的话,下一章也要做了。

实现方式是通过判断 - 符号之前的 token 是什么类型的,就可以判断当前 - 是什么类型了。

讲义的方法是,先实现一个能够生成随机表达式的 gen-expr 程序,然后将生成的表达式直接写入一个 c 文件里赋值给一个变量,然后输出这个变量就好了。相当于是利用了 gcc 编译器的表达式求值的功能来为我们自己写的表达式求值进行验证。

重复运行 gen-expr,将其生成的表达式和表达式对应的值写入到一个文件中,再修改 NEMU,让其直接从该文件中读取表达式和值,然后调用 expr() 进行求值,并比较正确性。

从文件中读取有很多种方法,我使用的方法是直接用 Linux 的管道符 < 进行输入。找到 NEMU 编译生成的可执行文件后,手动运行,并使用管道符将 input 文件输入即可。这样的好处是不需要修改太多的代码。

除 0 的行为与过滤

运行代码就可以发现,如果出现了除 0 错误,编译器会报一个警告:

./pics/image-2.png

并且计算得到的值是 Nan,可以利用这个特征来进行过滤。

观察 gen_expr.c 的代码可以发现,其使用 fscanf() 来从进程中读取计算后的结果,而该函数的返回值和 scanf() 是一样的,返回成功读取的变量个数。利用这个就可以进行除 0 的过滤了:检测到成功读取的个数不为 0 就直接 continue。

处理'\n'
我在使用文件输入的时候发现,第一行的数据能够正确计算,但是第二行总是会在行尾匹配失败。调试发现,如果使用文件读入的话,会将 '\n' 也读取进来,需要额外处理这种情况。
表达式过长导致的计算错误(待解决)

我在验证的时候经常发现一个问题:如果表达式非常长,那么大概率我的表达式求值计算出来的数值和答案不一样。

由于表达式过于长,手动计算过于复杂,故放弃。我想了很多可能的问题,结果也都不是。

我在尝试进行复现的时候,发现直接运行代码会导致栈溢出错误,我猜测可能是表达式过长导致 gcc 的编译器出现错误。

PA 1 阶段 2 到此结束。