2024 NJU PA1.2
本节主要实现表达式求值功能,它属于编译器的前端部分,与计算机系统关联不大(当然,从另一个角度来说关联很大)。如果以后不打算选修编译原理,作个简单了解也不错。
编译器前端
假设程序中有以下语句:
int value = 3 + 4.5 * 2;
词法分析
编译器首先进行词法分析,将语句拆分为一个个有意义的最小单元,这个最小单元叫token, 目前还没有比较贴切的中文翻译,所以下文统一使用token一词。(好像有个名为“词元”的翻译)。
通过词法分析,以上语句被拆分如下(以下箭头没有任何含义,只是为了便于区分,当作空格即可):
类型名(int) → 变量名(value) → 赋值符号(=) → 整数(3) → 加号(+) → 浮点数(4.5) → 乘号(*) → 整数(2) → 分号(;)
语法分析
接下来是语法分析。语法分析根据一定的规则,识别词法分析处理的结果。假设有以下规则:
名称 | 规则 |
赋值语句 | 类型名 → 变量名 → 赋值符号 → 表达式 → 分号 |
表达式 |
表达式 → 乘号 → 表达式 表达式 → 加号 → 表达式 整数 浮点数 |
这个表格的意思是:右边的长串构成了左边的概念。例如,对于“表达式”这一概念,“整数”是表达式,“浮点数”也是表达式,另外还出现了递归定义:“表达式 * 表达式”同样也是表达式。
有了规则,就可以处理词法分析的输出:只要观察到右边的长串,就将其替换为左边的概念。这个过程很像“消消乐”。
首先,整数和浮点数都是表达式,可以将它们替换为表达式:
类型名(int) → 变量名(value) → 赋值符号(=) → 表达式(3) → 加号(+) → 表达式(4.5) → 乘号(*) → 表达式(2) → 分号(;)
接下来面临一个问题:以上语句同时包含 3 + 4.5
和 4.5 * 2
这两个表达式,应该处理哪个呢?这涉及到运算符的优先级问题,按照常识,应当先处理乘法。
只要把优先级高的规则写在前面,就优先处理它。在上面的表格中,规则“表达式 → 乘号 → 表达式”写在“表达式 → 加号 → 表达式”的上面,所以先处理乘法。
首先替换乘法:
类型名(int) → 变量名(value) → 赋值符号(=) → 表达式(3) → 加号(+) → 表达式(4.5 * 2) → 分号(;)
之后替换加法:
类型名(int) → 变量名(value) → 赋值符号(=) → 表达式(3 + 4.5 * 2) → 分号(;)
最后,可以看到以上内容正是“赋值语句”的规则,所以以上内容最终被替换为“赋值语句”,从而这一行被识别为合法的赋值语句。
提示
可以看到,这种递归调用的方法效率很低,所以编译器并不会这么做。至于编译器是如何处理的,快去选修编译原理吧!
PA 1.2
在本节中,需要实现表达式的词法、语法分析这两个步骤:
- 对于词法分析,只需要定义每个词法的正则表达式,会有专门的工具,根据正则表达式生成词法分析的处理程序;
- 对于语法分析,我们不使用任何外部工具,只需实现手册中描述的递归算法,计算出表达式的值即可。
PA1主要实现一个简单的调试器sdb, 代码基本位于目录$NEMU_HOME/src/monitor/sdb
, 本节需要实现的代码位于文件$NEMU_HOME/src/monitor/sdb/expr.c
.
识别token
框架使用了词法分析工具,只需要定义各种类型的token以及它们的正则表达式即可。要点有二:
- 添加枚举类型
TK_XXX
. 注意TK_NOTYPE
被定义为256, 这保证了TK_XXX
不会与ASCII字符冲突; - 在
rules
数组中添加相应的规则(正则表达式)。
目前需要实现的token有:加减乘除、括号、整数(包括十进制和十六进制),注意PA不涉及浮点数。
首先添加需要自行定义的token类型:
enum {
TK_NOTYPE = 256, TK_EQ,
/* TODO: Add more token types */
TK_HEX, // 十六进制整数
TK_UINT, // 十进制整数
};
然后添加各类token的正则表达式:
rules[] = {
{" +", TK_NOTYPE}, // spaces
{"\\+", '+'}, // plus
{"==", TK_EQ}, // equal
{"-", '-'}, // 减号
{"\\*", '*'}, // 乘号
{"/", '/'}, // 除号
{"\\(", '('}, // 左括号
{"\\)", ')'}, // 右括号
{"0x[0-9AaBbCcDdEeFf]+", TK_HEX}, // 十六进制整数
{"[0-9]+", TK_UINT}, // 十进制整数
};
之后,还需要保存识别出的token到数组tokens
,此数组定义于函数make_token
上方:
typedef struct token {
int type;
char str[32];
} Token;
static Token tokens[32] __attribute__((used)) = {};
static int nr_token __attribute__((used)) = 0;
可以看到,结构体Token
只有type
(token类型)、str
(对应的字符串)两个成员。用于记录token的数组tokens
定义为32,nr_token
记录数组tokens
保存token的个数。在函数make_token
中有个TODO
, 那里便是写入到数组tokens
的位置。
对于大多数token,记录其类型到Token.type
即可;只有十六进制和十进制的整数,需要额外将字符串保存到Token.str
中。
switch (rules[i].token_type) {
case TK_NOTYPE: break;
case TK_HEX:
case TK_UINT:
Assert(substr_len < 32, "token should less than 32 characters"); // 检查字符串是否超出数组Token.str的容量
strncpy(tokens[nr_token].str, substr_start, substr_len); // 复制字符串到Token.str
tokens[nr_token].str[substr_len] = '\0'; // strncpy不会向末尾添加\0, 因此需要手动添加
case '+':
case '-':
case '*':
case '/':
case '(':
case ')':
Assert(nr_token < 32, "token should less than 32"); // 检查是否超过数组tokens的容量
tokens[nr_token].type = rules[i].token_type; // 保存token的类型
nr_token++;
break;
default:
Assert(false, "unknow token type %d", rules[i].token_type); // 未知的token类型
}
以上代码利用了switch
语句“只要不break
就一直向下执行”的特性,另外添加了部分检查错误的逻辑,仅供参考。
表达式求值
这里不涉及语法分析的内容,需要通过递归计算出数组tokens
的值。文件末尾定义了函数expr
, 此函数接收一个字符串表达式,并返回计算结果,参数bool* success
记录是否成功调用。此函数中有个TODO
, 需要在这里实现表达式求值的功能,定义函数eval_expr
实现这一逻辑:
word_t eval_expr(int p, int q, bool *success);
此函数计算tokens[p ... q]
对应表达式的值,同样地,success
用于记录是否计算成功。从而,只要在expr
中调用此函数即可:
word_t expr(char *e, bool *success) {
...
/* TODO: Insert codes to evaluate the expression. */
return eval_expr(0, nr_token-1, success);
}
现在最重要的是实现函数eval_expr
, 具体算法已经在手册中给出,eval(p, q)
的逻辑大致如下:
- 找出主运算符,它在数组
tokens
的下标记作r
; - 调用
eval_expr(p, r-1)
和eval_expr(r+1, q)
, 根据主运算符的类型计算整个表达式的结果。
以下实现仅供参考:
word_t eval_expr(int p, int q, bool *success) {
if (p > q) { // 索引错误
*success = false;
return 0;
}
else if (p == q) { // 单个token求值,token可能是十六进制整数、十进制整数
*success = true;
word_t result = 0;
switch (tokens[p].type) {
case TK_HEX:
sscanf(tokens[p].str, "%x", &result);
return result;
case TK_UINT:
sscanf(tokens[p].str, "%d", &result);
return result;
default:
Assert(false, "error token type %d", tokens[p].type);
}
}
else if (is_paired(p, q)) { // token被括号包围
return eval_expr(p+1, q-1, success);
}
// else
*success = true;
int r = find_main_operator_index(p, q); // 找到主运算符的下标r
if (r < 0) { // 未找到主运算符
*success = false;
return 0;
}
word_t value_left = eval_expr(p, r-1, success); // 计算主运算符左侧表达式
if (*success == false) {
return 0;
}
word_t value_right = eval_expr(r+1, q, success); // 计算主运算符右侧表达式
if (*success == false) {
return 0;
}
switch (tokens[r].type) { // 根据主运算符的类型计算表达式结果
case '+': return value_left + value_right;
case '-': return value_left - value_right;
case '*': return value_left * value_right;
case '/': return value_left / value_right;
default: assert(0); // 未知运算符,直接终止程序
}
}
把手册中描述的内容用代码翻译一遍即可,代码中使用了另外两个函数:
is_paired(p, q)
:此函数检查tokens[p ... q]
是否被一对大括号包围;find_main_operator_index(p, q)
:此函数寻找tokens[p ... q]
中主运算符的下标。
括号匹配是入门级算法,无需赘述:
static bool is_paired(int p, int q) {
if (tokens[p].type != '(' && tokens[q].type != ')') {
return false;
}
int n_left = 0;
for (int i = p+1; i <= q-1; i++) {
if (tokens[i].type == '(') {
n_left++;
}
else if (tokens[i].type == ')') {
n_left--;
if (n_left < 0) {
return false;
}
}
}
return n_left == 0;
}
寻找主运算符按照手册描述的步骤实现即可,定义辅助函数prority
确定运算符的优先级:
static int priority(int operator) {
// 主运算符的优先级越高,数值越小
switch (operator) {
case '+':
case '-': // 加减法的优先级高于乘除法,因为它们在表达式中后于乘除法计算
return 0;
case '*':
case '/':
return 1;
default:
assert(0);
}
}
static int find_main_operator_index(int p, int q) {
int main_operator_index = -1; // 保存找到的主操作符在tokens数组的下标
int main_operator = -1; // 保存找到的主操作符
int n_left = 0;
for (int i = p; i <= q; i++) {
int operator = tokens[i].type;
switch (operator) {
case '(':
n_left++;
break;
case ')':
n_left--;
break;
case '+':
case '-':
case '*':
case '/':
if (n_left == 0 && // 主运算符不在括号内
(main_operator_index == -1 || // 首次找到主运算符
priority(operator) <= priority(main_operator))) { // 当前找到的主运算符优先级更高
main_operator_index = i;
main_operator = operator;
}
break;
default:
break;
}
}
return main_operator_index;
}
实现指令p
指令名称 | 功能 | 示例 | 备注 |
p [expr] |
计算表达式expr 的值 |
p 2 * (3 + 5) |
同PA 1.1,打开文件$NEMU_HOME/src/monitor/sdb/sdb.c
, 在cmd_table
中添加相应项并实现函数cmd_p
:
static int cmd_p(char *args) {
if (args == NULL) {
printf("error: no expression given\n");
return 0;
}
bool success = false;
word_t value = expr(args, &success);
if (!success) {
printf("error: wrong expression %s\n", args);
return 0;
}
printf("%u\n", value);
return 0;
}
由于表达式求值的逻辑位于函数expr
, 在cmd_p
中调用此函数即可。
完成上述代码后,进入目录$NEMU_HOME
并重新编译,如果一切顺利,测试指令p
,例如p 2 * (3 + 5)
,应该能看到以下输出:
其中,蓝色输出是解析token的过程:依次匹配到了 2,空格,*,…… 最后输出结果为16,一眼扫过去,结果是对的。
生成测试用例?
感兴趣的可以做一做,之后你不大可能会使用自己实现的sdb, 时间紧迫的可以先跳过,这个算是支线任务,不影响主剧情。
OK,PA1.2到此结束。