简介一份用C与Qt实现的词法分析器项目面向编译原理课程学习者、需要完成课程设计的本科生以及想了解词法分析内部机制的开发者。项目在Qt图形界面下将源代码转换为关键字、标识符、数字、运算符等标记序列并以图形化方式展示解析结果便于观察从字符流到token生成的完整过程。压缩包共30个文件以cpp/h源码为主涵盖词法核心逻辑、窗口交互与绘图展示模块另有ui界面文件、qrc资源、dot状态图、jpg/png图片、docx课程设计文档及README整包仅571KB。已有188人浏览学习适合作为编译原理课设参考或自学案例。借助源码和文档可以学习正则匹配、状态机及NFA/DFA构建掌握C面向对象设计和Qt界面开发docx文档包含项目介绍、算法描述与实验说明配合dot状态图可快速理解设计思路也便于二次修改和扩展。1. 用 C Qt 写的词法分析器值得拿来当第一个编译原理落地项目把“使用c qt 写的词法分析器.zip”拆开看里面是一个很标准的教学组合C 写的词法扫描核心负责把源代码切成 Token 流Qt 负责把 Token 流可视化成一个可交互的表格界面。它解决的痛点是编译原理课设里最常见的尴尬——flex 生成的扫描器很难嵌进图形界面纯控制台程序又看不出分词对错。适合用 Qt 5.15 MSVC 2019 组合跑课设的本科生也适合想搞懂状态机实现、顺手把 Qt 表格组件用熟练的 C 入门者。下面从实现选型、扫描代码、界面集成排到踩坑记录按能复现的标准讲。2. 词法分析器在 C 里的实现选型手写扫描循环为什么比正则库更合适2.1 std::regex、flex、手写扫描循环三条路线的取舍做词法分析器C 里三条常见路线是用 std::regex 逐条匹配 Token 模式、用 flex 生成扫描器、手写扫描循环。我最初选的是 std::regex图它代码少一行std::regex_match就能识别一个数字。实际跑起来发现两个问题一是 C 的 regex 匹配性能不稳定同一个源码文件跑两遍耗时差异能到一倍多调试时很难判断是算法问题还是库的调度问题二是词法分析要求“最长匹配”比如必须整体读成一个运算符不能先读出再读出用正则做最长匹配需要记忆上一次成功位置代码很快变得绕。flex 是编译原理课程都会提的标准工具生成的 DFA 表格效率高但放进 Qt 界面项目里有两个尴尬生成的 C 文件体积大且 flex 的输入输出都面向文件流要把界面里的QString塞进去还得做一层缓冲区适配。教学型项目的核心诉求是把词法过程讲清楚flex 把最关键的扫描循环替你写完了反而成了黑匣子。我现在的做法是手写扫描循环用游标逐个消费字符按当前字符的类型字母、数字、引号、运算符决定走哪个分支。代码控制在几百行能放进讲课用的幻灯片也能让 Qt 界面层直接调用一个std::vectorToken返回值。手写并不慢词法分析的性能瓶颈从来不在字符判断而在输入输出和数据拷贝把源码读进std::string后扫描几十万字符只是毫秒级的事。2.2 Token 结构设计类型枚举、typeName 映射与行列号词法分析器的输出是 Token 流Token 结构设计直接影响后面语法分析器的写法。我给 Token 定义了七种类型标识符、关键字、数字、运算符、分隔符、字符串、结束符。这里的经验是类型枚举用enum class或带作用域的 enum避免和 Qt 里的其他枚举常量撞名同时给Token加一个静态的typeName方法让 Qt 表格能直接把类型名显示成字符串。struct Token { enum Type : int { IDENTIFIER 0, KEYWORD, NUMBER, OPERATOR, DELIMITER, STRING, END }; Type type; std::string lexeme; // 实际字符串内容 int line; // 起始行 int column; // 起始列 static const char* typeName(Type t) { static const char* names[] { IDENTIFIER, KEYWORD, NUMBER, OPERATOR, DELIMITER, STRING, END }; return names[static_castint(t)]; } };这样设计有两个直接好处表格控件排序时按类型名字符串排就行不需要再维护一张枚举到中文的映射表错误报告里可以直接打Token::typeName(t.type)。注意names数组的顺序必须和上面枚举定义的顺序一一对应我踩过在两组枚举间加了一个类型、忘记改数组导致的越界崩溃所以现在给数组长度加上static_assert。行号从 1 开始列号也从 1 开始这是给语法分析器报错用的约定显示层面也更符合人的直觉。2.3 关键字识别和注释跳过两张表的维护成本关键字和标识符在词法上都是“字母或下划线开头后面跟字母数字下划线”的模式区别只在收集完整个词后查一下关键字表。查表用std::unordered_setstd::string就够了单词数量也就十几个犯不着搞二分查找。关键是这张表要放在匿名命名空间的静态函数里每次tokenize调用只初始化一次不需要每个 Token 都重新构造集合。bool Lexer::isKeyword(const std::string id) const { static const std::unordered_setstd::string keywords { int, float, double, char, void, if, else, while, for, return, struct, class, const, static }; return keywords.find(id) ! keywords.end(); }这段代码看着简单但有两个隐藏细节。第一keywords是函数内静态局部变量C11 保证它的初始化是线程安全的tokenize被界面线程和后台线程同时调也不会出问题。第二我一开始把bool、true、false也放进去了后来发现不同教材对“关键字包含哪些词”的约定不一样干脆把这几个当成标识符免得课设老师觉得你语法集定义得不标准。注释跳过我单独拎出来说是因为它最容易写错。//行注释要处理“最后一行没有换行符”的情况/* */块注释要处理未闭合的情况。常见的错误写法是while (peek() ! \0 peek() ! \n) advance();忘了文件末尾没有\n结果下一次循环读越界。我的做法是空注释和空白字符统一放一个skipWhitespaceAndComments函数里循环条件一律带peek() ! \0双重判断。3. 核心扫描代码从字符流到 Token 流的 C 实现3.1 Lexer 类骨架游标、行列号和 advance 的职责扫完 Token 结构接下来是 Lexer 类本身。类只暴露构造和tokenize两个接口内部维护私有的游标和行列号。所有“读一个字符”的操作都收敛到一个advance()函数里这是整个类最值得抠细节的地方。class Lexer { public: explicit Lexer(const std::string source); std::vectorToken tokenize(); private: std::string src_; size_t pos_ 0; int line_ 1; int col_ 1; char peek() const { return pos_ src_.size() ? \0 : src_[pos_]; } char advance(); void skipWhitespaceAndComments(); };peek()返回\0表示已经到文件末尾这样外层循环统一用peek() ! \0判断不需要每次判断pos_是否越界。advance()的实现里我让换行符单独更新行列号其他字符统一更新列号否则多行代码的 Token 列号会一路累加下去。char Lexer::advance() { if (pos_ src_.size()) return \0; char c src_[pos_]; if (c \n) { line_; col_ 1; } else { col_; } return c; }这套结构的意义在于界面上要能定位 Token 的准确坐标QPlainTextEdit 的setTextCursor跳转需要行列号语法分析器报错也要行列号。不要图省事只在 Token 里存一个全局偏移量Qt 表格里显示行列比显示偏移直觉得多。3.2 标识符与数字的扫描最长匹配原则和边界判断标识符和数字的扫描是词法分析器里最容易翻车的地方。标识符的逻辑很直接首字符是字母或下划线后续字符是字母、数字或下划线。这里有一个标准写法上的坑std::isalpha(c)接收的char在 MSVC 下默认是 signed char遇到扩展 ASCII 码的字节会转成负数行为未定义。安全的写法是std::isalpha(static_castunsigned char(c))我在代码里全部按这个来。数字扫描的关键是最长匹配123要整体作为一个 NUMBER不能扫到1就停123.45要处理小数点1.2.3按大多数语言的词法规则应该被拆成1.2和.3但.3其实也不是合法数字。我的处理是小数点只有在“当前字符是.且下一个字符是数字”时才消费小数部分也只允许出现一次小数点。Token Lexer::scanNumber() { std::string text; int startLine line_; int startCol col_; while (std::isdigit(static_castunsigned char(peek()))) { text advance(); } // 小数点后必须跟数字才并入数字否则单独成运算符 if (peek() . std::isdigit(static_castunsigned char(peekNext()))) { text advance(); // 消费 . while (std::isdigit(static_castunsigned char(peek()))) { text advance(); } } return Token{Token::NUMBER, text, startLine, startCol}; }peekNext()是向前看一个字符的辅助函数同样要处理越界。这个设计的边界效果是输入1.时1被识别成 NUMBER.单独走运算符分支输入1.2.3时1.2是 NUMBER.3因为小数点后跟了数字也被并进一个 NUMBER但这种1.2.3其实不是合法浮点字面量——要不要在这里报错取决于你的语法集规定。教学项目里我倾向于不报留到语法分析阶段的expect(NUMBER)去兜底这样词法层职责更单纯。3.3 字符串、运算符与注释转义、双字符运算符和未闭合处理字符串识别的难点在转义字符。输入a\b中间的反斜杠引号不能被当成字符串结束。我的实现是遇到\时直接连同下一个字符一起吞掉不做解释因为词法分析器不需要把\n转成真正换行那是语法分析和语义层面的活。Token Lexer::scanString() { int startLine line_; int startCol col_; advance(); // 吞掉开头的 std::string text; while (peek() ! \0 peek() ! ) { if (peek() \\) { text advance(); // 反斜杠 text advance(); // 被转义的字符 } else { text advance(); } } if (peek() ) { advance(); // 吞掉收尾的 } return Token{Token::STRING, text, startLine, startCol}; }这里我没有把结束引号存进 lexeme取舍是字符串的原始内容更干净表格里显示不用再手工去引号。但要注意如果未闭合字符串出现在文件末尾循环会正常退出但不会报错。教学项目里我会在类里加一个std::vectorstd::string errors_遇到未闭合就塞一条错误界面把错误列表放在状态栏里提示比直接吞掉更体面。运算符识别同样按最长匹配先看两个字符能否构成、!、、、、||这类双字符运算符能构成就消费两个字符否则只消费一个。这里不要用一堆if else硬编码维护一个双字符集合用查表代替判断。4. Qt 界面层QPlainTextEdit 输入、QTableWidget 输出与文件读写4.1 analyze 主流程QElapsedTimer 计时与 QString::arg 组装界面骨架是典型的工具型窗口左边一个 QPlainTextEdit 装源码右边一个 QTableWidget 分四列展示 Token 的类型、内容、行、列底部一个 QLabel 当状态栏。QPlainTextEdit 比 QTextEdit 更适合纯文本展示高亮和性能都更好“分析”按钮触发analyze槽函数把编辑框内容转成std::string喂给 Lexer。void MainWindow::analyze() { QElapsedTimer timer; timer.start(); const std::string source codeEdit_-toPlainText().toStdString(); Lexer lexer(source); std::vectorToken tokens lexer.tokenize(); fillTable(tokens); setStatus(QString(扫描完成%1 个 Token耗时 %2 ms) .arg(static_castint(tokens.size())) .arg(timer.elapsed())); }QElapsedTimer是 Qt 里做性能验证的标准工具比QTime::currentTime()相减靠谱它不受系统时间修改影响。.arg()链式调用里%1和%2的位置不能换错第一个arg替换%1第二个替换%2混用数字需要数清楚。这里很推荐在状态栏显示毫秒数课设答辩时老师问“性能怎么样”你能直接给数据不用临时猜。4.2 填充 Token 表格setUpdatesEnabled 关闭刷新按类型着色fillTable是界面上最值得优化的函数。新手最常见的写法是一个setItem一个setItem地填表格每插入一行就刷新一次布局几千个 Token 能卡到肉眼可见。我习惯先用setUpdatesEnabled(false)停掉刷新全部填完再启用并调用resizeColumnsToContents()让列宽自适应内容。void MainWindow::fillTable(const std::vectorToken tokens) { tokenTable_-setUpdatesEnabled(false); tokenTable_-setRowCount(static_castint(tokens.size())); for (int i 0; i static_castint(tokens.size()); i) { const Token t tokens[i]; tokenTable_-setItem(i, 0, new QTableWidgetItem(Token::typeName(t.type))); tokenTable_-setItem(i, 1, new QTableWidgetItem(QString::fromStdString(t.lexeme))); tokenTable_-setItem(i, 2, new QTableWidgetItem(QString::number(t.line))); tokenTable_-setItem(i, 3, new QTableWidgetItem(QString::number(t.column))); if (t.type Token::KEYWORD) { tokenTable_-item(i, 0)-setForeground(QBrush(QColor(0, 0, 200))); } else if (t.type Token::NUMBER) { tokenTable_-item(i, 0)-setForeground(QBrush(QColor(200, 0, 0))); } } tokenTable_-setUpdatesEnabled(true); tokenTable_-resizeColumnsToContents(); }这里有两个细节值得说。第一new QTableWidgetItem出来的对象所有权归表格你不需要保存指针更不能手动 delete下一次setRowCount会自动释放旧 item。第二类型列着色用的是setForeground在深色主题下QColor对象会被自动适配但如果你自己拼 HTML 字符串放进 item深色主题下反而看不清。QString::number在这里承担了 int 转字符串的工作同理它也可以处理double转字符串但要注意精度默认保留 6 位有效数字需要更多位数时用QString::number(value, g, 10)指定格式和精度。4.3 打开源码与导出 CSVQTextStream 编码在新旧 Qt 的差异文件读写是词法分析器界面必带的功能不然每次测试都要手工粘贴代码。打开文件用 QFileDialog读取用 QFile QTextStream导出 Token 表用 QFile QTextStream 写 CSV。这两段代码最隐蔽的坑是编码Qt 5 的 QTextStream 默认按 Locale 编码在简体中文 Windows 上读 UTF-8 源码会乱码Qt 6 改了默认编码策略但 API 也变了。void MainWindow::openFile() { const QString path QFileDialog::getOpenFileName( this, 打开源码, QString(), C/C 源码 (*.c *.cpp *.h *.hpp);;所有文件 (*)); if (path.isEmpty()) return; QFile file(path); if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { setStatus(打开失败 file.errorString()); return; } QTextStream in(file); #if QT_VERSION QT_VERSION_CHECK(6, 0, 0) in.setEncoding(QStringConverter::Utf8); #else in.setCodec(UTF-8); #endif codeEdit_-setPlainText(in.readAll()); QFileInfo info(path); setStatus(QString(已打开 %1%2 字节) .arg(info.fileName()) .arg(info.size())); }QFileInfo用来获取文件名和大小是顺手的事不用单独再拼路径字符串。#if QT_VERSION这个预处理器分支我强烈建议留着你把 zip 发给同学对方可能是 Qt 6 环境没有这个分支代码直接编译不过。CSV 导出的思路一样写一行表头type,lexeme,line,column然后逐行读表格 item 拼接字符串注意用QIODevice::WriteOnly | QIODevice::Text打开Text模式会在 Windows 上把换行符转成\r\nExcel 打开才不乱。5. 词法分析器避坑编译失败、中文乱码、数字边界和界面卡死5.1 构建期报 -1: error: dependent多半是 MSVC 和 MinGW 两套 Kit 混用现象Qt Creator 里编译项目构建输出第一行就是-1: error: dependent ..\..\..\..\..\..\qt\5.15.2\msvc2019_64\include\qtwidgets doesnt exist后面跟一串找不到头文件的报错。原因最典型的是机器上同时装了 MinGW 版和 MSVC 版 Qt项目之前用 MinGW Kit 打开过.pro文件或构建目录里缓存了旧路径或者压缩包解压到了带中文的目录qmake 生成的文件路径带中文就断掉了。这个-1:开头的报错是 Qt Creator 的构建步骤返回了无效退出码不是编译器本身的错误信息所以很多人第一次看到完全懵。解决在 Qt Creator 里核对 Kit 选的是“Qt 5.15.2 MSVC2019_64”还是“MinGW 64-bit”两者混用的概率最高。确定用 MSVC 就删掉整个 build 目录重新执行 qmake 再构建路径上把工程挪到纯英文目录比如D:\qt-work\lexer。如果你习惯用 VSCode 配 C 环境看代码但最终构建还是建议回到 Qt Creator 的 Kit 体系里CMake 或 qmake 的路径缓存会让你少踩很多坑。5.2 中文乱码MSVC 的 /utf-8 和 QTextStream 的 codec 是两回事现象界面按钮文字“打开文件”显示成“鎵撳紑鏂囦欢”或者打开的源码文件里中文注释全是乱码但用记事本打开原文件一切正常。原因这实际上是两个独立问题叠在了一起。第一源码里的中文字面量在 MSVC 下默认按 GBK 编译而 Qt Creator 里mainwindow.cpp是 UTF-8 保存的编译器按错了码页。第二读取文件时 QTextStream 在 Qt 5 默认用 Locale 编码读 UTF-8 文件自然乱码。解决.pro文件里加一行QMAKE_CXXFLAGS /utf-8让 MSVC 强制按 UTF-8 读源码文件读写用前面写的setCodec(UTF-8)或setEncoding(QStringConverter::Utf8)。还有一个容易被忽略的小点如果源码里用了u8中文这种 C11 语法返回的是const char*进入 QString 要QString::fromUtf8(...)不能直接QString(u8中文)后者在 MSVC 下还是会按窄字符转换。5.3 1.2.3 被识别成一个数字小数点的消费条件没定死现象输入1.2.3表格里出现一个内容是1.2.3的 NUMBER Token输入a1.时1.连着小数点被吞进数字语法上其实已经错了。原因数字扫描循环写成了while (isdigit(peek()) || peek() .)见小数点就收不检查小数点后面是不是数字。这种写法把1.2.3、1.甚至1...都收成了合法数字。解决按前面 3.2 的写法小数点必须满足“当前是.且下一个是数字”才消费小数部分也只允许一个点。这样1.2.3会被拆成1.2和.3后续语法分析能正常报错。如果你以后要支持1e-3科学计数法这里还要补一个分支e或E后面必须跟数字或正负号否则把e留给标识符扫描。这个边界不写清楚编译器前端调试时会浪费大量时间怀疑是语法分析器的错。5.4 两万行源码一点就假死QTableWidget 卡住了 UI 线程现象粘贴一个两万行的文件进去点分析按钮整个窗口卡住几秒到十几秒标题栏显示“未响应”期间拖都拖不动。原因两层问题叠加。一层是fillTable逐行setItem时每次都触发表格刷新另一层是分析本身在 UI 线程执行tokenize和fillTable串行跑Qt 事件循环被完全阻塞。两万行代码生成的 Token 可能接近十万十万次setItem加上信号开销UI 线程必然卡死。解决先用setUpdatesEnabled(false)包住批量填充这是最小改动把分析逻辑挪到QtConcurrent::run或QThread里完成后用信号回传结果这是更彻底的方案。我在工具型界面里做了两层编辑框限制setMaximumBlockCount(50000)防止粘贴几十万行表格里超过两万行用QTableViewQAbstractTableModel替代QTableWidget后者按需提供数据不会一次性创建十万个 item。课设阶段你做到setUpdatesEnabled(false)加上文件大小限制就够交差了。6. 把 Token 流接到递归下降语法分析器接口与验证方法词法分析器做完后下一步必然是语法分析。递归下降语法分析器的常规接口是三个操作peek()看当前 Token 不消费、next()消费并前进、expect(type)断言当前 Token 是预期类型并消费。给 Lexer 的返回结果做一个轻量包装是这个阶段最划算的投入。class Parser { public: explicit Parser(const std::vectorToken tokens) : tokens_(tokens), pos_(0) {} const Token peek() const { return tokens_[pos_]; } Token next() { return tokens_[pos_]; } bool expect(Token::Type t) { if (peek().type ! t) { error_ 第 std::to_string(peek().line) 行附近期望 Token::typeName(t) 实际是 Token::typeName(peek().type); return false; } pos_; return true; } private: std::vectorToken tokens_; size_t pos_; std::string error_; };验证方法我建议写一个不依赖 Qt 的 main 函数直接构造字符串调用 Lexer 再断言结果比如输入int main() { return 0; }断言 Token 序列前三个类型是 KEYWORD、IDENTIFIER、DELIMITER。这个测试文件放在工程里单独跑不用每次点 Qt 界面验证界面层只负责展示和人工复核。我一开始把测试逻辑全写在按钮槽里后来发现改一次扫描规则要在界面上点五六次效率很低。给语言定一组“坏例”同样重要int 1abc;应该在数字和标识符之间报错abc未闭合字符串要有提示/* comment未闭合注释不能静默吞掉。把这些坏例和好例放在一起做成回归集比手工验证可靠得多。我现在的习惯是每修一个词法边界 bug就往测试集里加一条对应输入防止改数字扫描的同时把标识符扫描弄坏。希望这个从选型到避坑的完整思路帮到你。本文还有配套的精品资源点击获取