简介:面向编译原理初学者与课程实验需求者,这份资料围绕手工构造TINY语言词法分析器展开,采用C/C++实现,帮助理解从源代码字符流到标记序列的扫描过程,适合课程设计、实验复现与DFA状态机练习。压缩包共10个文件,约779KB,包含cpp源代码、tny测试样例、docx实验报告以及可执行文件与工程配置,便于直接编译运行、对照样例验证并阅读设计说明。已有1332人学习下载,具备一定参考热度。读者可据此梳理TINY语言的关键字、标识符、常量与运算符识别规则,结合有限状态自动机设计思路完成状态转移与标记分类,并通过多组tny样例检查输出结果;实验报告还可提供步骤记录、问题处理与结果验证线索,适合作为词法分析器实现与排错的参考。
1. 从一段 TINY 源码开始:词法分析器先解决什么问题
拿到一份 C/C++ 手工构造 TINY 语言词法分析器的源码,最容易出现的误判是把它当成“正则表达式练习”。打开样例文件,里面是read n; fact := 1; repeat ... until n = 0;这样的文本,人眼看到的是关键字、标识符、整数和运算符,编译器前端拿到的却只是一串字节。词法分析器要做的,是把这串字节切成有类型的 token,丢掉空白和注释,同时把行号列号记下来,供后面的语法分析和报错使用。
这份源码适合正在做编译原理课程设计、想手写 scanner 而不是直接上 lex/flex 的 C/C++ 开发者;也适合已经写过一遍、但想看看别人怎么组织 Token 结构、状态机和错误恢复的人。TINY 语言够小,规则不复杂,正好可以把“字符流到 token 流”这条链路完整跑一遍。
下面按“能编译、能跑样例、能改规则”的顺序拆。先看工程骨架,再看扫描循环,最后落到手工 DFA 和二次开发入口。
2. 工程骨架与数据流:token、扫描器、驱动三条线怎么搭
2.1 目录结构与 Makefile:先让项目能编译
这个项目的代码量不大,但分层比较清楚:token.h定义词法单元,scanner.h/.cpp负责扫描,main.cpp只做驱动和输出。常见目录结构如下,文件名不是死的,但只要保持“头文件放 include、实现放 src、样例放 tests”的思路,后面改起来不会乱。
| 路径 | 作用 |
|---|---|
include/token.h | TokenType 枚举、Token 结构体、类型名转换 |
include/scanner.h | Scanner 类声明,对外只暴露next() |
src/token.cpp | token_type_name的实现 |
src/scanner.cpp | 空白跳过、标识符、数字、运算符、错误恢复 |
src/main.cpp | 读命令行参数、打开文件、循环取 token 并打印 |
tests/sample01.tiny | 最小可运行样例 |
Makefile | 编译、链接、清理 |
Makefile 我一般写成下面这样,够小项目用,也不容易把编译参数漏掉:
CXX ?= g++ CXXFLAGS ?= -std=c++17 -Wall -Wextra -g TARGET := tinylex SRCS := src/main.cpp src/scanner.cpp src/token.cpp OBJS := $(SRCS:.cpp=.o) $(TARGET): $(OBJS) $(CXX) $(CXXFLAGS) -o $@ $^ %.o: %.cpp $(CXX) $(CXXFLAGS) -Iinclude -c $< -o $@ clean: rm -f $(OBJS) $(TARGET)这段逻辑是:CXX允许外部换编译器,CXXFLAGS打开警告和调试信息,SRCS列源文件,OBJS用替换规则把.cpp换成.o。-Iinclude让#include "token.h"能找到头文件。参数上,$@是目标名,$^是所有依赖,$<是第一个依赖,Makefile 的自动变量用熟了比手写一长串命令稳。注意命令行前面必须是 Tab,不是四个空格,这是 Makefile 最常见的翻车点之一。
2.2 Token 结构与词法单元分类
TINY 的词法单元不多,关键字有if、then、else、end、repeat、until、read、write,运算符有+、-、*、/、=、<、>,界符有(、)、;、,,赋值是:=,注释用{ ... }。把这些分类落到枚举里,后面语法分析器接 token 时就不用猜字符串。
#pragma once #include <string> enum class TokenType { // 关键字 KW_IF, KW_THEN, KW_ELSE, KW_END, KW_REPEAT, KW_UNTIL, KW_READ, KW_WRITE, // 标识符与字面量 ID, INTEGER, // 运算符 PLUS, MINUS, STAR, SLASH, EQ, LT, GT, ASSIGN, // 界符 LPAREN, RPAREN, SEMI, COMMA, // 特殊 END_OF_FILE, ERROR }; struct Token { TokenType type; std::string lexeme; // 原始词素,报错和语法分析都要用 int line; // token 首字符所在行,从 1 开始 int col; // token 首字符所在列,从 1 开始 }; const char* token_type_name(TokenType t);逻辑上,type给程序判断,lexeme保留原文,line/col定位。很多人只存类型不存词素,等到语法分析要报“第 3 行第 7 列附近的:=写成了:”时就抓瞎。参数上,行号列号记录的是 token 首字符位置,不是结束位置;END_OF_FILE也要作为一个正常 token 输出,语法分析器需要一个明确的终止符。ERROR不是异常,而是让主循环继续跑的恢复手段。
2.3 从源文件到 token 流的调用链
main.cpp不负责扫描细节,只做三件事:检查参数、打开文件、循环调用Scanner::next()。这样扫描器可以拿std::istringstream做单元测试,不必依赖真实文件。
#include <fstream> #include <iostream> #include "scanner.h" int main(int argc, char** argv) { if (argc != 2) { std::cerr << "usage: tinylex <source.tiny>\n"; return 2; } std::ifstream in(argv[1]); if (!in) { std::cerr << "cannot open " << argv[1] << "\n"; return 2; } Scanner scanner(in); for (;;) { Token tok = scanner.next(); std::cout << token_type_name(tok.type) << " " << tok.lexeme << " " << tok.line << ":" << tok.col << "\n"; if (tok.type == TokenType::END_OF_FILE) break; } return 0; }这段逻辑是标准驱动循环:argc/argv取源文件路径,ifstream打开失败就返回 2,Scanner持有输入流引用,next()每次返回一个 token,打印到标准输出,遇到END_OF_FILE退出。参数上,返回码 2 表示用法或文件错误,正常扫描返回 0;错误信息走stderr,token 流走stdout,后面写回归脚本时不会混在一起。循环条件用END_OF_FILE,不要用in.eof(),eof()的置位时机是读取失败之后,很容易多吐一个空 token。
2.4 最小运行闭环:编译、传参、看输出
样例文件不需要复杂,能覆盖标识符、数字、赋值、注释和关键字就够了:
{ 计算阶乘的 TINY 片段 } read n; fact := 1; repeat fact := fact * n; n := n - 1; until n = 0; write fact;编译和运行用两条命令:
make ./tinylex tests/sample01.tiny如果 Makefile 写对了,第一条会生成tinylex,第二条会输出类似下面的 token 流:
KW_READ read 2:1 ID n 2:6 SEMI ; 2:7 ID fact 3:1 ASSIGN := 3:6 INTEGER 1 3:9 ... END_OF_FILE 9:1看输出时先确认三件事:read是关键字不是标识符,:=是一个 token 不是:和=,注释整行被跳过但行号没有乱。这三件事对了,说明扫描主链路基本通了。make clean可以清掉.o和可执行文件重来;如果提示Permission denied,检查是不是漏了./;如果输出为空,先确认样例路径和文件编码,别一上来就改状态机。
3. 核心扫描循环拆解:一个字符一个字符把 TINY 程序啃下来
3.1 跳过空白与注释的状态处理
扫描器每次进入next()先跳过空白和注释,再决定当前 token 从哪个字符开始。空白包括空格、Tab、回车和换行;注释从{到},中间内容全部丢弃。这里的关键不是“跳过”,而是跳过之后行号列号仍然正确。
void Scanner::skip_space_and_comment() { for (;;) { int c = peek(); if (c == ' ' || c == '\t' || c == '\r' || c == '\n') { get(); continue; } if (c == '{') { int start_line = line_; get(); // 吃掉 '{' while (peek() != '}' && peek() != EOF) { get(); } if (peek() == '}') { get(); } else { has_pending_error_ = true; pending_error_line_ = start_line; } continue; } break; } }逻辑上,peek()只看不推进,get()推进并更新行列。遇到空白就吃掉继续循环;遇到{记录起始行,然后一直吃到}或文件尾;如果吃到 EOF 还没见到},设置一个待报错标记,回到next()时返回ERRORtoken。参数上,start_line是注释起始行,报错时比报文件尾行更有用;has_pending_error_和pending_error_line_是 Scanner 的成员,避免在skip里直接返回 token 破坏接口。
提示:注释未闭合不要用
exit()直接退出,词法分析器应该尽量恢复,让后面的 token 继续被检查。
3.2 标识符与保留字的查表逻辑
标识符和关键字在字符级别长得一样,都是以字母或下划线开头,后面跟字母、数字或下划线。区别只在“收集完整词素之后查不查得到关键字表”。所以扫描函数先把整个词素吃进来,再查表,不能边吃边判断。
Token Scanner::scan_identifier() { int start_line = line_; int start_col = col_; std::string text; while (true) { int c = peek(); if (std::isalpha(c) || std::isdigit(c) || c == '_') { text.push_back(get()); } else { break; } } static const std::unordered_map<std::string, TokenType> keywords = { {"if", TokenType::KW_IF}, {"then", TokenType::KW_THEN}, {"else", TokenType::KW_ELSE}, {"end", TokenType::KW_END}, {"repeat", TokenType::KW_REPEAT}, {"until", TokenType::KW_UNTIL}, {"read", TokenType::KW_READ}, {"write", TokenType::KW_WRITE}, }; auto it = keywords.find(text); if (it != keywords.end()) { return {it->second, text, start_line, start_col}; } return {TokenType::ID, text, start_line, start_col}; }逻辑上,循环条件覆盖字母、数字、下划线,保证abc123不会被切成abc和123;收集完再查keywords,查到返回对应关键字类型,查不到返回ID。参数上,start_line/start_col在进入函数时记录,循环中get()会推进列号,所以不能用当前line_/col_当 token 位置。关键字表用static const只初始化一次,避免每次扫描都构造 map。大小写是否敏感要看语言定义,TINY 常见做法是敏感,所以IF会当成标识符;如果要做大小写不敏感,查表前转小写,但lexeme仍保留原文。
3.3 数字、运算符与界符的识别
数字只识别非负整数,TINY 通常没有浮点。遇到123abc这种输入,不要硬切成INTEGER 123和ID abc,直接返回ERROR更容易排查,因为这种写法在语法分析阶段也会炸,不如在词法阶段就报出来。
Token Scanner::scan_number() { int start_line = line_; int start_col = col_; std::string text; while (std::isdigit(peek())) { text.push_back(get()); } int c = peek(); if (std::isalpha(c) || c == '_') { while (std::isalnum(peek()) || peek() == '_') { text.push_back(get()); } return {TokenType::ERROR, text, start_line, start_col}; } return {TokenType::INTEGER, text, start_line, start_col}; }逻辑上,先吃连续数字,再看下一个字符是不是字母或下划线;如果是,把后面一段也吃进来一起报错,避免主循环在123abc上反复进出。参数上,start_line/start_col仍是首字符位置;peek()返回int,不要用char接,否则 EOF 和 0xFF 容易混。运算符和
本文还有配套的精品资源,点击获取