小项目-词法分析器

小项目-词法分析器

1.理论

一个完整的编译器,大致会经历如下几个阶段

各个阶段的职责,简单描述如下:

  1. 词法分析:对源文件进行扫描,将源文件的字符划分为一个一个的记号(token) (注:类似中文中的分词)。

  2. 语法分析:根据语法规则将 Token 序列构造为语法树。

  3. 对语法树的各个结点之间的关系进行检查,检查语义规则是否有被违背,同时对语法树进行必要的优化,此为语义分析。

  4. 遍历语法树的结点,将各结点转化为中间代码,并按特定的顺序拼装起来,此为中间代码生成。

  5. 对中间代码进行优化

  6. 将中间代码转化为目标代码

  7. 对目标代码进行优化,生成最终的目标程序

以上阶段的划分仅仅是逻辑上的划分。实际的编译器中,常常会将几个阶段组合在一起,甚至还可以能省略其中某些阶段。

1.1词法分析

编译器扫描源文件的字符流,过滤掉字符流中的空格、注释等,并将其划分为一个个的 token,生成 token 序列。

例如下面的语句:

a = value + sum(5, 123); 

将被拆分为11个 token :

a           标识符
=           赋值运算符
value       标识符
+           加号
sum         标识符
(           左括号
5           整数
,           逗号
123         整数
)           右括号
;           分号

这个步骤和中文中分词非常相似:

我/喜欢/美丽动人的/茜茜/。  

本质上,词法分析阶段所做的事情就是模式匹配。判断哪些字符属于标识符,哪些字符属于关键字,哪些字符属于整数...

1.2 有限状态机

那么该如何做模式匹配呢?这就要用到有限状态机了 (注:术语都是纸老虎,有限状态机一般都是用 switch + while + if 语句实现的,按我自己的理解为,还未扫描元素的时候,那个目标串字符串(token)符合所有的字符条件,扫描完第一个元素,判断剩余还满足什么字符串的条件,再扫描,直到可以确定出目标字符串是什么类型的字符串为止)。

  • 单字符 Token,可以直接识别: ; ) ( { } 等
  • 双字符 Token,需要用 if 语句进行判断:+=, -=, *=, ==, !=
  • 多字符 Token,需要用 while 语句一直读取到结束标志符: 标识符,字符串,数字,字符等。

接下来我们重点看一下:如何判断一个Token到底是标识符还是关键字。这里我们采用Trie树的方式进行判断,因为不管是从空间上还是时间上,Trie树的方式都优于哈希表的方式。在逻辑上,我们可以将 C 语言的关键字组织成下面这样的形式:

具体实现的示例:

c 复制代码
switch (first) {
case 'b': return checkKeyword(1, 4, "reak", TOKEN_BREAK);
case 'c': {
	char second = scanner.start[1];
	switch (second) {
	case 'a': return checkKeyword(2, 2, "se", TOKEN_CASE);
	case 'h': return checkKeyword(2, 2, "ar", TOKEN_CHAR);
	case 'o': {
		if (scanner.start[3] == 's') {
			return checkKeyword(3, 2, "st", TOKEN_CONST);
		}
		else {
			return checkKeyword(3, 5, "tinue", TOKEN_CONTINUE);
		}
	}
	}
}
case 'd': {
	char second = scanner.start[1];
	if (second == 'e') {
		return checkKeyword(2, 6, "efault", TOKEN_DEFAULT);
	}
	else {
		if (scanner.start[2] == 'u') {
			return checkKeyword(3, 4, "uble", TOKEN_DOUBLE);
		}
		else {
			return checkKeyword(0, 2, "do", TOKEN_DO);
		}
	}
}
    default:break;
} 

2.效果

该词法分析器既能交互式地运行,也能够处理'.c'文件

交互方式效果为:

对'.c'文件进行词法分析

c 复制代码
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <string.h>

int test(void) {
	fprintf(stderr, "error: xxx.\n");
	char dest[1024];
	int a = 10;
	double b = 0.2;
	char *str = "hello!";

	sprintf(dest, "a = %d, b = %.2f, str = %s", a, b, str);
	puts(dest);

	FILE *fp = fopen("1.txt", "w");
	if (fp == NULL) {
		fprintf(stderr, "failed to open file.\n");
		exit(-1);
	}
	fprintf(fp, "str = %s, a = %d, b = %.2f", str, a, b);
	fclose(fp);

	FILE *fp2 = fopen("1.txt", "r");
	if (fp2 == NULL) {
		fprintf(stderr, "failed to open file.\n");
		exit(-1);
	}
	int var1;
	double var2;
	char var3[1024];
	fscanf(fp2, "str = %[^,], a = %d, b = %lf", var3, &var1, &var2);

	// 格式的读字符串dest
	int num1;
	double num2;
	char str2[1024];
	sscanf(dest, "a = %d, b = %lf, str = %s", &num1, &num2, str2);

	return 0;
}

效果为:

3.代码框架

main.c的代码框架:

c 复制代码
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#include <stdlib.h>

#include "scanner.h"
#include "tools.h"

// main.c中的核心逻辑
static void run(const char *source) {
	initScanner(source);	// 初始化词法分析器
	int line = -1;  // 用于记录当前处理的行号,-1表示还未开始解析
	for (;;) {  
		Token token = scanToken();  // 获取下一个TOKEN
		if (token.line != line) {		// 如果Token中记录行和现在的lin不同就执行换行打印的效果		
			printf("%4d ", token.line); 
			line = token.line; 
		}
		else {		// 没有换行的打印效果
			printf("   | ");
		}
		char *str = convert_to_str(token);
		printf("%s '%.*s'\n", str, token.length, token.start);

		if (token.type == TOKEN_EOF) {
			break;	// 读到TOKEN_EOF结束循环
		}
	}
}
// repl是"read evaluate print loop"的缩写
// repl 函数定义了一个交互式的读取-求值-打印循环(REPL)逻辑
// 它允许用户输入源代码行,逐行进行词法分析,并打印分析结果
// 也就是说启动时没有主动给出一个命令行参数表示文件路径的话,那么就进行交互式界面进行词法分析
static void repl() {
	// TODO
	// 这里应该存在一个死循环,而且要逐行的读取键盘输入fgets
}

// 用户输入文件名,将整个文件的内容读入内存,并在末尾添加'\0'
// 注意: 这里应该使用动态内存分配,因此应该事先确定文件的大小。
static char *readFile(const char *path) {
	// TODO
}

// 该函数表示对传入的文件路径名的字符串进行处理
static void runFile(const char *path) {
	// 处理'.c'文件:用户输入文件名,分析整个文件,并将结果输出
	// 这个代码非常简单,我帮你直接写好
	// 会调用上面的readFile函数,根据文件路径名生成一个包含文件全部字符信息的字符串
	char *source = readFile(path);

	// 调用run函数处理源文件生成的字符串
	run(source);
	// 及时释放资源
	free(source);
}
/*
* 主函数支持操作系统传递命令行参数
* 然后通过判断参数的个数:
* 1.如果没有主动传入参数(argc=1),因为第一个参数总会传入一个当前可执行文件的目录作为命令行参数
* 此时执行repl函数
* 2.如果传递了一个参数(argc=2),说明传递了一个参数,将传递的参数视为某个源代码的路径
* 然后调用runFile函数,传入该源代码文件的路径,处理源文件
*/
int main(int argc, const char *argv[]) {
	if (argc == 1) {
		repl();
	}
	else if (argc == 2) {
		runFile(argv[1]);
	}
	else {
		// 如果主动传入超过一个命令行参数.即参数传递有误,错误处理
		// 告诉用户正确的使用函数的方式
		fprintf(stderr, "Usage: scanner [path]\n");
		exit(1);
	}
	return 0;
}

scanner.h的代码框架,这里面主要是一些结构体的定义

c 复制代码
#ifndef SCANNER_H
#define SCANNER_H

// 定义一个TokenType枚举,用于标记不同种类的Token
typedef enum {
    /* 单字符 Token */
    TOKEN_LEFT_PAREN,        // '(' 左小括号
    TOKEN_RIGHT_PAREN,       // ')' 右小括号
    TOKEN_LEFT_BRACKET,      // '[' 左中括号
    TOKEN_RIGHT_BRACKET,     // ']' 右中括号
    TOKEN_LEFT_BRACE,        // '{' 左大括号
    TOKEN_RIGHT_BRACE,       // '}' 右大括号
    TOKEN_COMMA,             // ',' 逗号
    TOKEN_DOT,               // '.' 点
    TOKEN_SEMICOLON,         // ';' 分号
    TOKEN_TILDE,             // '~' 波浪号

    /* 可能是单字符或双字符的Token */
    TOKEN_PLUS,                  // '+' 加号
    TOKEN_PLUS_PLUS,             // '++' 自增运算符
    TOKEN_PLUS_EQUAL,            // '+=' 加赋运算符
    TOKEN_MINUS,                 // '-' 减号或负号
    TOKEN_MINUS_MINUS,           // '--' 自减运算符
    TOKEN_MINUS_EQUAL,           // '-=' 减赋运算符
    TOKEN_MINUS_GREATER,         // '->' 结构体指针访问
    TOKEN_STAR,                  // '*' 乘号
    TOKEN_STAR_EQUAL,            // '*=' 乘赋运算符
    TOKEN_SLASH,                 // '/' 除号
    TOKEN_SLASH_EQUAL,           // '/=' 除赋运算符
    TOKEN_PERCENT,               // '%' 取模运算符
    TOKEN_PERCENT_EQUAL,         // '%=' 取模赋运算符
    TOKEN_AMPER,                 // '&' 按位与运算符
    TOKEN_AMPER_EQUAL,           // '&=' 按位与赋运算符
    TOKEN_AMPER_AMPER,           // '&&' 逻辑与运算符
    TOKEN_PIPE,                  // '|' 按位或运算符
    TOKEN_PIPE_EQUAL,            // '|=' 按位或赋运算符
    TOKEN_PIPE_PIPE,             // '||' 逻辑或运算符
    TOKEN_HAT,                   // '^' 按位异或运算符
    TOKEN_HAT_EQUAL,             // '^=' 按位异或赋运算符
    TOKEN_EQUAL,                 // '=' 赋值运算符
    TOKEN_EQUAL_EQUAL,           // '==' 等于比较运算符
    TOKEN_BANG,                  // '!' 逻辑非运算符
    TOKEN_BANG_EQUAL,            // '!=' 不等于比较运算符
    TOKEN_LESS,                  // '<' 小于比较运算符
    TOKEN_LESS_EQUAL,            // '<=' 小于等于比较运算符
    TOKEN_LESS_LESS,             // '<<' 左移运算符
    TOKEN_GREATER,               // '>' 大于比较运算符
    TOKEN_GREATER_EQUAL,         // '>=' 大于等于比较运算符
    TOKEN_GREATER_GREATER,       // '>>' 右移运算符

    // 所有的三字符Token都去掉了 >>= <<= 实现它们也没什么特殊的,但会很无聊繁琐,所以就都去掉了
    /* 多字节Token: 标识符、字符、字符串、数字 */
    TOKEN_IDENTIFIER,            // 标识符
    TOKEN_CHARACTER,             // 字符
    TOKEN_STRING,                // 字符串
    TOKEN_NUMBER,                // 数字,包含整数和浮点数

    /* 关键字Token 涉及C99所有关键字 */
    TOKEN_SIGNED, TOKEN_UNSIGNED,
    TOKEN_CHAR, TOKEN_SHORT, TOKEN_INT, TOKEN_LONG,
    TOKEN_FLOAT, TOKEN_DOUBLE,
    TOKEN_STRUCT, TOKEN_UNION, TOKEN_ENUM, TOKEN_VOID,
    TOKEN_IF, TOKEN_ELSE, TOKEN_SWITCH, TOKEN_CASE, TOKEN_DEFAULT,
    TOKEN_WHILE, TOKEN_DO, TOKEN_FOR,
    TOKEN_BREAK, TOKEN_CONTINUE, TOKEN_RETURN, TOKEN_GOTO,
    TOKEN_CONST, TOKEN_SIZEOF, TOKEN_TYPEDEF,

    // 注意:#define #include这样的预处理指令 不是关键字
    // 辅助Token
    // 词法分析阶段也是可以检测出一些错误的 比如$只能在字符和字符串中 比如字符串"acb 缺少右边双引号
    // 词法分析阶段不进行错误处理,只是将错误的Token信息抛出,以待后续统一进行处理
    // 流水线架构每个阶段都可能出错,如果每个阶段都进行错误处理,那代码的可维护性就太差了
    TOKEN_ERROR,                 // 错误Token 词法分析时遇到无法识别的文本
    TOKEN_EOF                    // 文件结束Token 表示源代码已经分析完毕
} TokenType;

// 词法分析器的目的就是生产一个一个的Token对象 
typedef struct {
    TokenType type;		// Token的类型, 取任一枚举值
    // Token的起始字符指针
    const char *start;	// start指向source中的字符,source为读入的源代码。
    int length;		    // length表示这个Token的长度
    int line;		    // line表示这个Token在源代码的哪一行, 方便后面的报错和描述Token
} Token;	// 这个Token只涉及一个字符指针指向源代码的字符信息,没有在内部保存字符数据


// 对 词法分析器Scanner 进行初始化 
void initScanner(const char *source);	// 源代码字符串(这里涉及一个将源码转换成字符串的函数)
// 核心API, 调用scanToken(), 就生产一个Token, 也就是源代码中下一段字符数据的Token
Token scanToken();	// 当Token返回的是TOKEN_EOF时,源文件被消耗完毕,词法分析结束

#endif  // !SCANNER_H

scanner.c的代码框架,词法分析器的实现细节

c 复制代码
#define _CRT_SECURE_NO_WARNINGS

#include <stdbool.h>
#include <string.h>
#include <stdio.h>

#include "scanner.h"

typedef struct {
	const char *start;		// 指向当前正在扫描的Token的起始字符
	const char *current;	// 词法分析器当前正在处理的Token的字符,一开始它就是start开始,直到遍历完整个Token,指向该Token的下一个字符
	int line;		// 记录当前Token所处的行
} Scanner;

// 全局变量结构体对象
static Scanner scanner;

void initScanner(const char *source) {
	// 初始化全局变量Scanner
	scanner.start = source;
	scanner.current = source;
	scanner.line = 1;
}

// 下面我给大家提供了很多会用到的辅助函数,建议使用
// 判断当前字符是不是字母或者下划线
static bool isAlpha(char c) {
	return (c >= 'a' && c <= 'z') ||
		(c >= 'A' && c <= 'Z') ||
		c == '_';
}
// 判断当前字符是不是数字
static bool isDigit(char c) {
	return c >= '0' && c <= '9';
}
// 判断Scanner当前正在处理的字符是不是空字符,判断是不是处理完了
static bool isAtEnd() {
	return *scanner.current == '\0';
}

// curr指针前进一个字符,并返回之前curr指针指向的元素
static char advance() {
	return *scanner.current++;
}
// 查看当前正在处理的字符是什么,curr不动
static char peek() {
	return *scanner.current;
}
// 如果当前正在处理的字符不是空字符,那就瞥一眼下一个字符是什么,curr不动
static char peekNext() {
	if (isAtEnd()) {
		return '\0';
	}
	return *(scanner.current + 1);
}

// 判断当前正在处理的字符是不是符合预期,如果符合curr前进一位
static bool match(char expected) {
	if (isAtEnd()) {
		return false;	// 如果正在处理的是空字符,那就返回false
	}
	if (peek() != expected) {
		return false;	// 如果不符合预期,也返回false
	}
	scanner.current++;
	return true;	// 只有符合预期才会返回true 而且curr会前进一位
}

// 根据传入的TokenType类型来制造返回一个Token
static Token makeToken(TokenType type) {
	Token token;
	token.type = type;
	token.start = scanner.start;
	token.length = (int)(scanner.current - scanner.start);	// 计算Token字符串的长度
	token.line = scanner.line;
	return token;
}

// 遇到不能解析的情况时,我们创建一个ERROR Token. 比如:遇到@,$等符号时,比如字符串,字符没有对应的右引号时。
static Token errorToken(const char *message) {
	Token token;
	token.type = TOKEN_ERROR;
	token.start = message;
	token.length = (int)strlen(message);
	token.line = scanner.line;
	return token;
}

static void skipWhitespace() {
	// 跳过空白字符: ' ', '\r', '\t', '\n'和注释
	// 注释以'//'开头, 一直到行尾
	// 注意更新scanner.line!

	// 提示: 整个过程需要跳过中间的很多字符,所以需要死循环
}

// 用于检查当前扫描的Token的类型是不是type 如果是就返回type
static TokenType checkKeyword(int start, int length, const char *rest, TokenType type) {
	/*
		start: 待检查序列的起始字符下标
			比如要检查关键字break,那么在case b的前提下,需要传入reak来进行检查
			这里start就等于1,scanner.start[1]
		length: 待检查序列的长度,如果检查的是break,就是检查剩余的reak
			需要传入4
		rest指针,待检查的剩余序列字符串,这里直接传入一个字面值字符串就行了
			比如检查break,传入"reak"就好了
		type:你要检查的关键字Token的类型,比如检查break,那就传入Token_BREAK
	*/
	if (scanner.current - scanner.start == start + length &&
		/*
					int memcmp(const void *s1, const void *s2, size_t n);
					这里的参数分别是:

					s1:指向第一块内存区域的指针。
					s2:指向第二块内存区域的指针。
					n:要比较的字节数。
					memcmp 函数会逐字节比较 s1 和 s2 指向的内存区域,直到有不相等的字节或比较了 n 个字节为止。
					如果两个内存区域完全相同,
					则 memcmp 返回 0;如果第一个不同的字节在 s1 中的值小于 s2 中对应的值,返回负数;
					反之,返回正数。
		*/
		memcmp(scanner.start + start, rest, length) == 0) {
		return type;
	}
	return TOKEN_IDENTIFIER;
}

static TokenType identifierType() {
	// 确定identifier类型主要有两种方式:
	// 1. 将所有的关键字放入哈希表中,然后查表确认 
	// Key-Value 就是"关键字-TokenType" 可以做 但存在额外内存占用且效率不如下一个方式好
	// 2. 将所有的关键字放入Trie树(字典查找树)中,然后查表确认
	// Trie树的方式不管是空间上还是时间上都优于哈希表的方式
	// 用switch...switch...if组合构建逻辑上的trie树
	char first = scanner.start[0];	// 该Token的第一个字符
	switch (first) {
	case 'b': return checkKeyword(1, 4, "reak", TOKEN_BREAK);
	case 'c':
	}
	// 没有进switch一定是标识符
	return TOKEN_IDENTIFIER;
}

// 当前Token的开头是下划线或字母判断它是不是标识符Token
static Token identifier() {
	// 判断curr指针当前正在处理的字符是不是 字母 下划线 数字
	while (isAlpha(peek()) || isDigit(peek())) {
		advance();  // 继续前进看下一个字符 直到碰到下一个字符不是字母 下划线 以及数字 结束Token
	}
	// 当while循环结束时,curr指针指向的是该Token字符串的下一个字符
	// 这个函数的意思是: 只要读到字母或下划线开头的Token我们就进入标识符模式
	// 然后一直找到此Token的末尾
	// 但代码运行到这里还不确定Token是标识符还是关键字, 因为它可能是break, var, goto, max_val...
	// 于是执行identifierType()函数,它是用来确定Token类型的
	return makeToken(identifierType());
}

static Token number() {
	// 简单起见,我们将NUMBER的规则定义如下:
	// 1. NUMBER可以包含数字和最多一个'.'号
	// 2. '.'号前面要有数字
	// 3. '.'号后面也要有数字
	// 这些都是合法的NUMBER: 123, 3.14
	// 这些都是不合法的NUMBER: 123., .14(虽然在C语言中合法)
	// 提示: 这个过程需要不断的跳过中间的数字包括小数点,所以也需要循环
}

static Token string() {
	// 字符串以"开头,以"结尾,而且不能跨行
	// 为了简化工作量,简化了字符串
	// 如果下一个字符不是末尾也不是双引号,全部跳过(curr可以记录长度,不用担心)
}

static Token character() {
	// 字符'开头,以'结尾,而且不能跨行
	// 如果下一个字符不是末尾也不是单引号,全部跳过(curr可以记录长度,不用担心)
	// 这两个函数不能说一模一样,也是几乎一样
}

// 处理无法识别的字符
static Token errorTokenWithChar(char character) {
	char message[50];
	// 将无法识别的字符是什么输出
	sprintf(message, "Unexpected character: %c", character);
	return errorToken(message);
}

// Scanner核心逻辑,用于返回一个制作好的Token
Token scanToken() {
	// 跳过前置空白字符和注释
	skipWhitespace();
	// 记录下一个Token的起始位置
	scanner.start = scanner.current;

	// 如果已经处理完毕了 直接返回TOKEN_EOF
	if (isAtEnd()) {
		return makeToken(TOKEN_EOF);
	}

	// curr指针现在指向Token的第二个字符,但这个字符c仍然是第一个字符的值
	char c = advance();

	// 如果Token的第一个字符是字母和下划线就进入标识符的处理模式
	if (isAlpha(c)) {
		return identifier();
	}
	// 如果Token的第一个字符是数字,那就进入数字的处理模式
	if (isDigit(c)) {
		return number();
	}
	// 如果Token的第一个字符既不是数字也不是字母和下划线,那么就switch处理它
	switch (c) {
		// 第一部分: 处理单字符Token
	case '(': return makeToken(TOKEN_LEFT_PAREN);
	case ')': return makeToken(TOKEN_RIGHT_PAREN);
		// ...TODO

			// 可单可双字符的Token处理会稍微复杂一点,但不多
			// 如果Token的第一个字符是+号
	case '+':
		// 如果Token的第二个字符也是+,那就生产++双字符Token返回
		if (match('+')) {
			return makeToken(TOKEN_PLUS_PLUS);
		}
		else if (match('=')) {
			return makeToken(TOKEN_PLUS_EQUAL);
		}
		// ...TODO
	case '"': return string(); // 如果Token的第一个字符是双引号,那就解析出字符串Token返回
	case '\'': return character();	// 如果Token的第一个字符是单引号,那就解析出字符Token返回
	}
	// 如果上述处理都没有处理成功,都没有生成合适的Token,说明该字符无法识别
	return errorTokenWithChar(c);
}

tool.h 与 tool.c 将枚举的int类型转换为char*类型的辅助函数

c 复制代码
//tool.h
#include "scanner.h"
char *convert_to_str(Token token);
c 复制代码
//tool.c
#include "tools.h"

char *convert_to_str(Token token) {
	switch (token.type) {
	case TOKEN_LEFT_PAREN: return "LEFT_PAREN";
	case TOKEN_RIGHT_PAREN: return "RIGHT_PAREN";
	case TOKEN_LEFT_BRACKET: return "LEFT_BRACKET";
	case TOKEN_RIGHT_BRACKET: return "RIGHT_BRACKET";
	case TOKEN_LEFT_BRACE: return "LEFT_BRACE";
	case TOKEN_RIGHT_BRACE: return "RIGHT_BRACE";
	case TOKEN_COMMA: return "COMMA";
	case TOKEN_DOT: return "DOT";
	case TOKEN_SEMICOLON: return "SEMICOLON";
	case TOKEN_TILDE: return "TILDE";
	case TOKEN_PLUS: return "PLUS";
	case TOKEN_PLUS_PLUS: return "PLUS_PLUS";
	case TOKEN_PLUS_EQUAL: return "PLUS_EQUAL";
	case TOKEN_MINUS: return "MINUS";
	case TOKEN_MINUS_MINUS: return "MINUS_MINUS";
	case TOKEN_MINUS_EQUAL: return "MINUS_EQUAL";
	case TOKEN_MINUS_GREATER: return "MINUS_GREATER";
	case TOKEN_STAR: return "STAR";
	case TOKEN_STAR_EQUAL: return "STAR_EQUAL";
	case TOKEN_SLASH: return "SLASH";
	case TOKEN_SLASH_EQUAL: return "SLASH_EQUAL";
	case TOKEN_PERCENT: return "PERCENT";
	case TOKEN_PERCENT_EQUAL: return "PERCENT_EQUAL";
	case TOKEN_AMPER: return "AMPER";
	case TOKEN_AMPER_EQUAL: return "AMPER_EQUAL";
	case TOKEN_AMPER_AMPER: return "AMPER_AMPER";
	case TOKEN_PIPE: return "PIPE";
	case TOKEN_PIPE_EQUAL: return "PIPE_EQUAL";
	case TOKEN_PIPE_PIPE: return "PIPE_PIPE";
	case TOKEN_HAT: return "HAT";
	case TOKEN_HAT_EQUAL: return "HAT_EQUAL";
	case TOKEN_EQUAL: return "EQUAL";
	case TOKEN_EQUAL_EQUAL: return "EQUAL_EQUAL";
	case TOKEN_BANG: return "BANG";
	case TOKEN_BANG_EQUAL: return "BANG_EQUAL";
	case TOKEN_LESS: return "LESS";
	case TOKEN_LESS_EQUAL: return "LESS_EQUAL";
	case TOKEN_LESS_LESS: return "LESS_LESS";
	case TOKEN_GREATER: return "GREATER";
	case TOKEN_GREATER_EQUAL: return "GREATER_EQUAL";
	case TOKEN_GREATER_GREATER: return "GREATER_GREATER";
	case TOKEN_IDENTIFIER: return "IDENTIFIER";
	case TOKEN_CHARACTER: return "CHARACTER";
	case TOKEN_STRING: return "STRING";
	case TOKEN_NUMBER: return "NUMBER";
	case TOKEN_SIGNED: return "SIGNED";
	case TOKEN_UNSIGNED: return "UNSIGNED";
	case TOKEN_CHAR: return "CHAR";
	case TOKEN_SHORT: return "SHORT";
	case TOKEN_INT: return "INT";
	case TOKEN_LONG: return "LONG";
	case TOKEN_FLOAT: return "FLOAT";
	case TOKEN_DOUBLE: return "DOUBLE";
	case TOKEN_STRUCT: return "STRUCT";
	case TOKEN_UNION: return "UNION";
	case TOKEN_ENUM: return "ENUM";
	case TOKEN_VOID: return "VOID";
	case TOKEN_IF: return "IF";
	case TOKEN_ELSE: return "ELSE";
	case TOKEN_SWITCH: return "SWITCH";
	case TOKEN_CASE: return "CASE";
	case TOKEN_DEFAULT: return "DEFAULT";
	case TOKEN_WHILE: return "WHILE";
	case TOKEN_DO: return "DO";
	case TOKEN_FOR: return "FOR";
	case TOKEN_BREAK: return "BREAK";
	case TOKEN_CONTINUE: return "CONTINUE";
	case TOKEN_RETURN: return "RETURN";
	case TOKEN_GOTO: return "GOTO";
	case TOKEN_CONST: return "CONST";
	case TOKEN_SIZEOF: return "SIZEOF";
	case TOKEN_TYPEDEF: return "TYPEDEF";
	case TOKEN_ERROR: return "ERROR";
	case TOKEN_EOF: return "EOF";
	default: return "UNKNOWN";
	}
}

4.实现词法分析器

main.c实现

c 复制代码
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#include "scanner.h"
#include "tools.h"

// main.c中的核心逻辑
static void run(const char *source) {
	initScanner(source);	// 初始化词法分析器
	int line = -1;  // 用于记录当前处理的行号,-1表示还未开始解析
	for (;;) {  
		Token token = scanToken();  // 获取下一个TOKEN
		if (token.line != line) {		// 如果Token中记录行和现在的lin不同就执行换行打印的效果		
			printf("%4d ", token.line); 
			line = token.line; 
		}
		else {		// 没有换行的打印效果
			printf("   | ");
		}
		char *str = convert_to_str(token);
		printf("%s '%.*s'\n", str, token.length, token.start);

		if (token.type == TOKEN_EOF) {
			break;	// 读到TOKEN_EOF结束循环
		}
	}
}
// repl是"read evaluate print loop"的缩写
// repl 函数定义了一个交互式的读取-求值-打印循环(REPL)逻辑
// 它允许用户输入源代码行,逐行进行词法分析,并打印分析结果
// 也就是说启动时没有主动给出一个命令行参数表示文件路径的话,那么就进行交互式界面进行词法分析
static void repl() {
	// TODO
	// 这里应该存在一个死循环,而且要逐行的读取键盘输入fgets
	printf("> ");
	char get_char[1024] = { 0 };
	while (fgets(get_char, 1024, stdin)) {
		run(get_char);
		printf("> ");
	}
}

// 用户输入文件名,将整个文件的内容读入内存,并在末尾添加'\0'
// 注意: 这里应该使用动态内存分配,因此应该事先确定文件的大小。
static char *readFile(const char *path) {
	FILE* fp = fopen(path, "rb");
	if (fp == NULL) {
		fprintf(stderr, "无法打开文件%s", path);
		exit(-1);
	}
	fseek(fp, 0, SEEK_END);
	int len = ftell(fp);
	rewind(fp);//计算出文件长度,并且将指针回退到文件开头

	char* file = calloc(len+1, sizeof(char));
	if (file == NULL) {
		exit(-1);
	}
	fread(file, sizeof(char), len, fp);
	fclose(fp);
	return file;
}

// 该函数表示对传入的文件路径名的字符串进行处理
static void runFile(const char *path) {
	// 处理'.c'文件:用户输入文件名,分析整个文件,并将结果输出
	// 这个代码非常简单,我帮你直接写好
	// 会调用上面的readFile函数,根据文件路径名生成一个包含文件全部字符信息的字符串
	char *source = readFile(path);

	// 调用run函数处理源文件生成的字符串
	run(source);
	// 及时释放资源
	free(source);
}
/*
* 主函数支持操作系统传递命令行参数
* 然后通过判断参数的个数:
* 1.如果没有主动传入参数(argc=1),因为第一个参数总会传入一个当前可执行文件的目录作为命令行参数
* 此时执行repl函数
* 2.如果传递了一个参数(argc=2),说明传递了一个参数,将传递的参数视为某个源代码的路径
* 然后调用runFile函数,传入该源代码文件的路径,处理源文件
*/
int main(int argc, const char *argv[]) {
	if (argc == 1) {
		repl();
	}
	else if (argc == 2) {
		runFile(argv[1]);
	}
	else {
		// 如果主动传入超过一个命令行参数.即参数传递有误,错误处理
		// 告诉用户正确的使用函数的方式
		fprintf(stderr, "Usage: scanner [path]\n");
		exit(1);
	}
	return 0;
}

scanner.c实现

c 复制代码
#define _CRT_SECURE_NO_WARNINGS

#include <stdbool.h>
#include <string.h>
#include <stdio.h>

#include "scanner.h"

typedef struct {
	const char* start;		// 指向当前正在扫描的Token的起始字符
	const char* current;	// 词法分析器当前正在处理的Token的字符,一开始它就是start开始,直到遍历完整个Token,指向该Token的下一个字符
	int line;		// 记录当前Token所处的行
} Scanner;

// 全局变量结构体对象
static Scanner scanner;

void initScanner(const char* source) {
	// 初始化全局变量Scanner
	scanner.start = source;
	scanner.current = source;
	scanner.line = 1;
}

// 下面我给大家提供了很多会用到的辅助函数,建议使用
// 判断当前字符是不是字母或者下划线
static bool isAlpha(char c) {
	return (c >= 'a' && c <= 'z') ||
		(c >= 'A' && c <= 'Z') ||
		c == '_';
}
// 判断当前字符是不是数字
static bool isDigit(char c) {
	return c >= '0' && c <= '9';
}
// 判断Scanner当前正在处理的字符是不是空字符,判断是不是处理完了
static bool isAtEnd() {
	return *scanner.current == '\0';
}

// curr指针前进一个字符,并返回之前curr指针指向的元素
static char advance() {
	return *scanner.current++;
}
// 查看当前正在处理的字符是什么,curr不动
static char peek() {
	return *scanner.current;
}
// 如果当前正在处理的字符不是空字符,那就瞥一眼下一个字符是什么,curr不动
static char peekNext() {
	if (isAtEnd()) {
		return '\0';
	}
	return *(scanner.current + 1);
}

// 判断当前正在处理的字符是不是符合预期,如果符合curr前进一位
static bool match(char expected) {
	if (isAtEnd()) {
		return false;	// 如果正在处理的是空字符,那就返回false
	}
	if (peek() != expected) {
		return false;	// 如果不符合预期,也返回false
	}
	scanner.current++;
	return true;	// 只有符合预期才会返回true 而且curr会前进一位
}

// 根据传入的TokenType类型来制造返回一个Token
static Token makeToken(TokenType type) {
	Token token;
	token.type = type;
	token.start = scanner.start;
	token.length = (int)(scanner.current - scanner.start);	// 计算Token字符串的长度
	token.line = scanner.line;
	return token;
}

// 遇到不能解析的情况时,我们创建一个ERROR Token. 比如:遇到@,$等符号时,比如字符串,字符没有对应的右引号时。
static Token errorToken(const char* message) {
	Token token;
	token.type = TOKEN_ERROR;
	token.start = message;
	token.length = (int)strlen(message);
	token.line = scanner.line;
	printf("");//不写这句打印的第一行会出现问题,如果有大佬可以解决,也希望可以告诉一下我如何解决
	//fflush(stdout);
	//int a;
	return token;
}

static void skipWhitespace() {
	// 跳过空白字符: ' ', '\r', '\t', '\n'和注释
	// 注释以'//'开头, 一直到行尾
	// 注意更新scanner.line!

	// 提示: 整个过程需要跳过中间的很多字符,所以需要死循环
	while (1) {
		char ch = peek();
		switch (ch) {
		case '/':
			if(peekNext()=='/') 
			{ while (advance() != '\n'); 
			scanner.line++;
			}break;
		case ' ': advance(); break;
		case '\r':advance(); break;
		case '\t':advance(); break;
		case '\n':scanner.line++; advance(); break;
		default: scanner.start = scanner.current; return;
		}

	}

}

// 用于检查当前扫描的Token的类型是不是type 如果是就返回type
static TokenType checkKeyword(int start, int length, const char* rest, TokenType type) {
	/*
		start: 待检查序列的起始字符下标
			比如要检查关键字break,那么在case b的前提下,需要传入reak来进行检查
			这里start就等于1,scanner.start[1]
		length: 待检查序列的长度,如果检查的是break,就是检查剩余的reak
			需要传入4
		rest指针,待检查的剩余序列字符串,这里直接传入一个字面值字符串就行了
			比如检查break,传入"reak"就好了
		type:你要检查的关键字Token的类型,比如检查break,那就传入Token_BREAK
	*/
	if (scanner.current - scanner.start == start + length &&
		/*
					int memcmp(const void *s1, const void *s2, size_t n);
					这里的参数分别是:

					s1:指向第一块内存区域的指针。
					s2:指向第二块内存区域的指针。
					n:要比较的字节数。
					memcmp 函数会逐字节比较 s1 和 s2 指向的内存区域,直到有不相等的字节或比较了 n 个字节为止。
					如果两个内存区域完全相同,
					则 memcmp 返回 0;如果第一个不同的字节在 s1 中的值小于 s2 中对应的值,返回负数;
					反之,返回正数。
		*/
		memcmp(scanner.start + start, rest, length) == 0) {
		return type;
	}
	return TOKEN_IDENTIFIER;
}

static TokenType identifierType() {
	// 确定identifier类型主要有两种方式:
	// 1. 将所有的关键字放入哈希表中,然后查表确认 
	// Key-Value 就是"关键字-TokenType" 可以做 但存在额外内存占用且效率不如下一个方式好
	// 2. 将所有的关键字放入Trie树(字典查找树)中,然后查表确认
	// Trie树的方式不管是空间上还是时间上都优于哈希表的方式
	// 用switch...switch...if组合构建逻辑上的trie树
	char first = scanner.start[0];	// 该Token的第一个字符
	switch (first) {
	case 'b': return checkKeyword(1, 4, "reak", TOKEN_BREAK);
	case 'c': {
		char second = scanner.start[1];
		switch (second) {
		case 'a': return checkKeyword(2, 2, "se", TOKEN_CASE);
		case 'h': return checkKeyword(2, 2, "ar", TOKEN_CHAR);
		case 'o': {
			if (scanner.start[3] == 's') {
				return checkKeyword(3, 2, "st", TOKEN_CONST);
			}
			else {
				return checkKeyword(3, 5, "tinue", TOKEN_CONTINUE);
			}
		}
		}
	}
	case 'd': {
		char second = scanner.start[1];
		if (second == 'e') {
			return checkKeyword(2, 6, "efault", TOKEN_DEFAULT);
		}
		else {
			if (scanner.start[2] == 'u') {
				return checkKeyword(3, 4, "uble", TOKEN_DOUBLE);
			}
			else {
				return checkKeyword(0, 2, "do", TOKEN_DO);
			}
		}
	}
	case 'e': {
		char second = scanner.start[1];
		switch (second)
		{
		case 'l': return checkKeyword(1, 3, "lse", TOKEN_ELSE);
		case 'n':return checkKeyword(1, 3, "num", TOKEN_ENUM);
		default:
			break;
		}
	}
	case 'f': {
		char second = scanner.start[1];
		if (second == 'l') {
			return checkKeyword(1, 4, "loat", TOKEN_FLOAT);
		}
		else if (second == 'o') {
			return checkKeyword(1, 2, "or", TOKEN_FOR);
		}
	}
	case 'g': return checkKeyword(1, 3, "oto", TOKEN_GOTO);
	case 'i': {
		char second = scanner.start[1];
		if (second == 'f') {
			return checkKeyword(0, 2, "if", TOKEN_IF);
		}
		else if (second = 'n') {
			return checkKeyword(1, 2, "nt", TOKEN_INT);
		}
	}
	case 'l':return checkKeyword(1, 3, 'ONG', TOKEN_LONG);
	case 'r':return checkKeyword(1, 5, "eturn", TOKEN_RETURN);
	case 's': {
		char second = scanner.start[1];
		switch (second)
		{
		case 'h':return checkKeyword(1, 4, "hort", TOKEN_SHORT);
		case 'i': {
			char third = scanner.start[2];
			if (third == 'g') return checkKeyword(2, 4, 'gned', TOKEN_SIGNED);
			else if (third == 'z') return checkKeyword(2, 4, 'ZEOF', TOKEN_SIZEOF);
		}
				//case 't':return checkKeyword(1,5,"tatic",TOKEN_STATIC )
		case 't': return checkKeyword(1, 5, "truct", TOKEN_STRUCT);
		case 'w':return checkKeyword(1, 5, "witch", TOKEN_SWITCH);
		default:
			break;
		}
	}
	case 't': return checkKeyword(1, 6, "ypedef", TOKEN_TYPEDEF);
	case 'u': {
		char third = scanner.start[2];
		if (third == 'i') return checkKeyword(2, 3, "ion", TOKEN_UNION);
		else if (third == 's')return checkKeyword(2, 6, "signed", TOKEN_UNSIGNED);
	}
	case 'v': return checkKeyword(1, 3, "oid", TOKEN_VOID);
	case 'w':return checkKeyword(1, 4, "hile", TOKEN_WHILE);
	}
	// 没有进switch一定是标识符
	return TOKEN_IDENTIFIER;
}

// 当前Token的开头是下划线或字母判断它是不是标识符Token
static Token identifier() {
	// 判断curr指针当前正在处理的字符是不是 字母 下划线 数字
	while (isAlpha(peek()) || isDigit(peek())) {
		advance();  // 继续前进看下一个字符 直到碰到下一个字符不是字母 下划线 以及数字 结束Token
	}
	// 当while循环结束时,curr指针指向的是该Token字符串的下一个字符
	// 这个函数的意思是: 只要读到字母或下划线开头的Token我们就进入标识符模式
	// 然后一直找到此Token的末尾
	// 但代码运行到这里还不确定Token是标识符还是关键字, 因为它可能是break, var, goto, max_val...
	// 于是执行identifierType()函数,它是用来确定Token类型的
	return makeToken(identifierType());
}

static Token number() {
	// 简单起见,我们将NUMBER的规则定义如下:
	// 1. NUMBER可以包含数字和最多一个'.'号
	// 2. '.'号前面要有数字
	// 3. '.'号后面也要有数字
	// 这些都是合法的NUMBER: 123, 3.14
	// 这些都是不合法的NUMBER: 123., .14(虽然在C语言中合法)
	// 提示: 这个过程需要不断的跳过中间的数字包括小数点,所以也需要循环
	int point_count = 0;
	while (isDigit(peek()) || peek() == '.') {
		char ch = peek();
		if (ch == '.') {
			if (point_count > 1) {
				return errorToken("error number!");//小数点大于一个 不合法的number
			}
			else
				point_count++;
		}
		//if (index == 0 && ch == '.') {//.14这种类型的不合法number
		//	return errorToken("error number!");
		//}
		if (ch == '.' && !isDigit(peekNext())) {//14. 这种类型的不合法number
			return errorToken("error number!");
		}
		advance();
	}
	return makeToken(TOKEN_NUMBER);
}

static Token string() {
	// 字符串以"开头,以"结尾,而且不能跨行
	// 为了简化工作量,简化了字符串
	// 如果下一个字符不是末尾也不是双引号,全部跳过(curr可以记录长度,不用担心)
	while (peek != '\n' && peek != EOF && peek() != '"') {
		advance();
	}
	if (peek() == '"') advance();
	return makeToken(TOKEN_STRING);

}

static Token character() {
	// 字符'开头,以'结尾,而且不能跨行
	// 如果下一个字符不是末尾也不是单引号,全部跳过(curr可以记录长度,不用担心)
	// 这两个函数不能说一模一样,也是几乎一样
	while (peek != '\n' && peek != EOF && peek() != '\'') {
		advance();
	}
	if (peek() == '\'') advance();
	return makeToken(TOKEN_STRING);
}

// 处理无法识别的字符
static Token errorTokenWithChar(char character) {
	char message[50];
	// 将无法识别的字符是什么输出
	sprintf(message, "Unexpected character: %c", character);
	return errorToken(message);
}

// Scanner核心逻辑,用于返回一个制作好的Token
Token scanToken() {
	// 跳过前置空白字符和注释
	skipWhitespace();
	// 记录下一个Token的起始位置
	scanner.start = scanner.current;

	// 如果已经处理完毕了 直接返回TOKEN_EOF
	if (isAtEnd()) {
		return makeToken(TOKEN_EOF);
	}

	// curr指针现在指向Token的第二个字符,但这个字符c仍然是第一个字符的值
	char c = advance();

	// 如果Token的第一个字符是字母和下划线就进入标识符的处理模式
	if (isAlpha(c)) {
		return identifier();
	}
	// 如果Token的第一个字符是数字,那就进入数字的处理模式
	if (isDigit(c)) {
		return number();
	}
	// 如果Token的第一个字符既不是数字也不是字母和下划线,那么就switch处理它
	switch (c) {
		// 第一部分: 处理单字符Token
	case '(': return makeToken(TOKEN_LEFT_PAREN);
	case ')': return makeToken(TOKEN_RIGHT_PAREN);
	case '[': return makeToken(TOKEN_LEFT_BRACKET);
	case ']':return makeToken(TOKEN_RIGHT_BRACKET);
	case '{': return makeToken(TOKEN_LEFT_BRACE);
	case '}':return makeToken(TOKEN_RIGHT_BRACE);
	case ',':return makeToken(TOKEN_COMMA);
	case '.':return makeToken(TOKEN_DOT);
	case ';':return makeToken(TOKEN_SEMICOLON);
	case '~':return makeToken(TOKEN_TILDE);
		// 可单可双字符的Token处理会稍微复杂一点,但不多
		// 如果Token的第一个字符是+号
	case '+':
		// 如果Token的第二个字符也是+,那就生产++双字符Token返回
		if (match('+')) {
			return makeToken(TOKEN_PLUS_PLUS);
		}
		else if (match('=')) {
			return makeToken(TOKEN_PLUS_EQUAL);
		}
		else return makeToken(TOKEN_PLUS);
	case '-':
		if (match('-')) {
			return makeToken(TOKEN_MINUS_MINUS);
		}
		else if (match('=')) {
			return makeToken(TOKEN_MINUS_GREATER);
		}
		else if (match('>')) {
			return makeToken(TOKEN_MINUS_GREATER);
		}
		else return makeToken(TOKEN_MINUS);
		// ...TODO
	case '*':
		if (match('=')) {
			return makeToken(TOKEN_STAR_EQUAL);
		}
		else {
			return makeToken(TOKEN_STAR);
		}
	case '/':
		if (match('=')) {
			return makeToken(TOKEN_SLASH_EQUAL);
		}
		else if (match('/')) skipWhitespace();
		else return makeToken(TOKEN_SLASH);
	case '%':
		if (match('=')) {
			return makeToken(TOKEN_PERCENT_EQUAL);
		}
		else return makeToken(TOKEN_PERCENT);
	case '&':
		if (match('=')) {
			return makeToken(TOKEN_AMPER_EQUAL);
		}
		else if (match('&')) {
			return makeToken(TOKEN_AMPER_AMPER);
		}
		else return makeToken(TOKEN_AMPER);
	case '|':
		if (match('=')) {
			return makeToken(TOKEN_PIPE_EQUAL);
		}
		else if (match('|')) {
			return makeToken(TOKEN_PIPE_PIPE);
		}
		else return makeToken(TOKEN_PIPE);
	case '^':
		if (match('=')) {
			return makeToken(TOKEN_HAT_EQUAL);
		}
		else return makeToken(TOKEN_HAT);
	case '=':
		if (match('=')) {
			return makeToken(TOKEN_EQUAL_EQUAL);
		}
		else return makeToken(TOKEN_EQUAL);
	case '!':
		if (match('=')) {
			return makeToken(TOKEN_BANG_EQUAL);
		}
		else return makeToken(TOKEN_BANG);
	case '<':
		if (match('=')) {
			return makeToken(TOKEN_LESS_EQUAL);
		}
		else if (match('<')) {
			return makeToken(TOKEN_LESS_LESS);
		}
		else return makeToken(TOKEN_LESS);
	case '>':
		if (match('=')) {
			return makeToken(TOKEN_GREATER_EQUAL);
		}
		else if (match('>')) {
			return makeToken(TOKEN_GREATER_GREATER);
		}
		else return makeToken(TOKEN_GREATER);
	case '"': return string(); // 如果Token的第一个字符是双引号,那就解析出字符串Token返回
	case '\'': return character();	// 如果Token的第一个字符是单引号,那就解析出字符Token返回
	}
	// 如果上述处理都没有处理成功,都没有生成合适的Token,说明该字符无法识别
	return errorTokenWithChar(c);
}
相关推荐
五味香2 小时前
Linux学习,ip 命令
linux·服务器·c语言·开发语言·git·学习·tcp/ip
lb36363636363 小时前
整数储存形式(c基础)
c语言·开发语言
浪里个浪的10243 小时前
【C语言】从3x5矩阵计算前三行平均值并扩展到4x5矩阵
c语言·开发语言·矩阵
<但凡.3 小时前
编程之路,从0开始:知识补充篇
c语言·数据结构·算法
f狐0狸x4 小时前
【数据结构副本篇】顺序表 链表OJ
c语言·数据结构·算法·链表
CoderBob4 小时前
【EmbeddedGUI】脏矩阵设计说明
c语言·单片机
浪里个浪的10244 小时前
【C语言】计算3x3矩阵每行的最大值并存入第四列
c语言·开发语言·矩阵
敲敲敲-敲代码5 小时前
游戏设计:推箱子【easyx图形界面/c语言】
c语言·开发语言·游戏
simple_ssn5 小时前
【C语言刷力扣】1502.判断能否形成等差数列
c语言·算法·leetcode
ahadee5 小时前
蓝桥杯每日真题 - 第10天
c语言·vscode·算法·蓝桥杯