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.54.5 * 2 这两个表达式,应该处理哪个呢?这涉及到运算符的优先级问题,按照常识,应当先处理乘法。

只要把优先级高的规则写在前面,就优先处理它。在上面的表格中,规则“表达式 → 乘号 → 表达式”写在“表达式 → 加号 → 表达式”的上面,所以先处理乘法。

首先替换乘法:

类型名(int) → 变量名(value) → 赋值符号(=) → 表达式(3) → 加号(+) → 表达式(4.5 * 2) → 分号(;)

之后替换加法:

类型名(int) → 变量名(value) → 赋值符号(=) → 表达式(3 + 4.5 * 2) → 分号(;)

最后,可以看到以上内容正是“赋值语句”的规则,所以以上内容最终被替换为“赋值语句”,从而这一行被识别为合法的赋值语句。

提示

可以看到,这种递归调用的方法效率很低,所以编译器并不会这么做。至于编译器是如何处理的,快去选修编译原理吧!

PA 1.2

在本节中,需要实现表达式的词法、语法分析这两个步骤:

  1. 对于词法分析,只需要定义每个词法的正则表达式,会有专门的工具,根据正则表达式生成词法分析的处理程序;
  2. 对于语法分析,我们不使用任何外部工具,只需实现手册中描述的递归算法,计算出表达式的值即可。

PA1主要实现一个简单的调试器sdb, 代码基本位于目录$NEMU_HOME/src/monitor/sdb, 本节需要实现的代码位于文件$NEMU_HOME/src/monitor/sdb/expr.c.

识别token

框架使用了词法分析工具,只需要定义各种类型的token以及它们的正则表达式即可。要点有二:

  1. 添加枚举类型TK_XXX. 注意TK_NOTYPE被定义为256, 这保证了TK_XXX不会与ASCII字符冲突;
  2. 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)的逻辑大致如下:

  1. 找出主运算符,它在数组tokens的下标记作r;
  2. 调用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到此结束。

posted @ 2024-10-07 20:36  overxus  阅读(54)  评论(0编辑  收藏  举报