ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用C++/Qt手写词法分析器:DFA状态表与界面实战详解

用C++/Qt手写词法分析器:DFA状态表与界面实战详解 简介这是一个用C与Qt实现的词法分析器工程面向编译原理课程设计、编译器入门者以及需要在图形界面中调试词法分析过程的开发者。资源共30个文件以cpp/h源码为核心包含词法分析主逻辑、Qt窗口交互、图形展示与测试模块另含界面与工程配置、自动机dot图、文档及图片素材压缩包仅571KB轻量且目录清晰。已有188人学习。整套代码覆盖从源代码读取、按关键字/标识符/数字/运算符等规则切分token到自动机状态图构建与GUI可视化的完整链路。除可运行工程外还提供编译原理期末课设文档、nfa/dfa与最小化dfa图方便对照理论理解实现细节。代码模块划分明确适合逐步拆解词法分析器的骨架设计无论是复习编译原理、完成课设还是为后续语法分析准备输入都可作完整参考样例。1. 用 C/Qt 写词法分析器不只是交作业「使用c qt 写的词法分析器.zip」这类压缩包十有八九是编译原理课设的产物也可能是从某个工具链里抽出来的词法模块。它解决的是编译前端的第一件事把一串裸字符流拆成关键字、标识符、数字、字符串、运算符这些有名字的 token然后交给下一步语法分析。相比纯控制台版本用 Qt 包装的价值在于你能肉眼看到 token 切分过程、逐字检查状态跳转这对调试 DFA 表来说比 printf 高到不知道哪里去了。适合两类人正在写编译原理课程设计、不想再糊一个 C 语言控制台版的学生以及想在 C 工程里接一套可调试词法组件的开发者。拿到它重点不是解压跑通而是把「状态表怎么设计、界面和解析怎么分工、发布带不带插件」这三件事想清楚。2. 词法分析器核心从正则到 DFA再落到 C 代码2.1 先选路手写识别器还是表驱动 DFA词法分析器常见做法有两条路。一条是拿 flex 这类生成器写 .l 文件自动生成 C 代码另一条是自己手写状态机。这个标题是「用 C/Qt 写」意味着不依赖 flex那你面对的其实是从正则表达式到 DFA 的手工落地过程。我一般在课设和小型工具里都选手写表驱动 DFA而不是把正则挨个 if 判断。原因很直接正则逐个匹配的问题是回溯和优先级。比如识别int和integer如果你先匹配int再匹配标识符integer会被切成int加eger顺序调错就翻车。DFA 把所有关键字放一张状态图里每读一个字符只做一次状态查表天然避免了回溯也天然处理了最长匹配。有人会问那我要不要先写个正则引擎再转 NFA 转 DFA这属于给自己加戏。一个词法分析器覆盖的 token 类型通常不超过 30 种手工画状态图完全可控。真要自动生成那是另一个项目了。先把表驱动的手写 DFA 做对后面想加自动生成再升级不迟。2.2 状态枚举与转移表管住关键字、标识符和数字核心数据结构就两个枚举加一张二维表。TokenType 描述切出来的东西是什么类型DfaState 描述当前处于哪个状态。表的结构是「当前状态 × 输入字符类别 → 下一状态」字符类别先粗分几类避免表大得没法看。enum class TokenType { Keyword, // int, return, if 等 Identifier, // 用户自定义标识符 IntLiteral, // 整数 FloatLiteral, // 浮点数 StringLiteral,// 字符串 Operator, // - * / 等 Punctuation, // ; , ( ) { } Unknown }; enum class CharClass { Letter, // [a-zA-Z_] Digit, // [0-9] Dot, // . Quote, // OpChar, // - * / ! Space, // 空格、\t、\n Other }; enum class DfaState { Start, // 起始状态 InId, // 正在拼标识符/关键字 InNum, // 正在拼数字 InStr, // 正在拼字符串 InOp, // 正在拼运算符 Done // 一个 token 已完整识别 };// 状态转移表行是当前状态列是字符类别值是下一个状态 DfaState transitionTable[4][7] { // Letter Digit Dot Quote OpChar Space Other /* Start */ { InId, InNum, InNum, InStr, InOp, Start, Start }, /* InId */ { InId, InId, Done, Done, Done, Done, Done }, /* InNum */ { Done, InNum, InNum, Done, Done, Done, Done }, /* InStr */ { InStr, InStr, InStr, Done, InStr, InStr, InStr } };这里有个关键设计转移表只处理三类核心状态InOp不在表里是因为运算符需要单独按最大匹配处理。比如、你会希望一次识别成双字符运算符而不是两个单字符。所以我一般把运算符放在另一个小表里单独查这样状态表保持小而清晰。参数说明字符类别是 tokenize 主循环里每次读一个字符后先算出来的Letter类我把下划线_也归进去了这符合大多数语言标识符规则。Dot单独成一类是为了区分1.5和.运算符如果你不打算支持浮点数把Dot并进OpChar就行。Start行的Other落到Start表示直接跳过无法识别的字符你也可以把它改成记录错误位置而不是静默忽略。2.3 主循环与最长匹配一个字符决定 token 边界有了表tokenize 主循环就只是一个状态机遍历。每次读一个字符查表得下一状态同时把这个字符存进当前 token 缓冲区。当状态从InId/InNum/InStr掉回Done时说明读到了一个不该属于当前 token 的字符那这个字符要回退不消费掉。while (!source.empty()) { Token tok; tok.preview source; // 保存快照方便回退 std::string lexeme; DfaState current DfaState::Start; for (size_t i 0; i source.size(); i) { char ch source[i]; CharClass cc classify(ch); DfaState next transitionTable[(int)current][(int)cc]; if (next DfaState::Done) { // 当前字符不属于这个 token回退一字符 source tok.preview.substr(i); break; } current next; lexeme.push_back(ch); if (current DfaState::InStr ch ) { // 字符串闭合直接结束 source source.substr(i 1); break; } } tok.lexeme lexeme; tok.type classifyToken(tok.lexeme); // 查关键字表 数字格式判断 tokens.push_back(tok); }CharClass classify(char ch) { if (std::isalpha(ch) || ch _) return CharClass::Letter; if (std::isdigit(ch)) return CharClass::Digit; if (ch .) return CharClass::Dot; if (ch ) return CharClass::Quote; if (ch || ch - || ch * || ch / || ch || ch || ch ) return CharClass::OpChar; if (std::isspace(ch)) return CharClass::Space; return CharClass::Other; }逻辑说明source是一个字符串流每次成功切出一个 token 就把消费掉的部分从流里删掉。preview存的是进入这个 token 前的流快照一旦发现next Done说明当前字符不该被吞掉用preview.substr(i)回退保证下一个 token 从正确位置开始。这个回退逻辑是最容易写错的地方——新手经常在状态为Done时把字符丢掉导致两个相邻运算符或「标识符后紧跟括号」这类场景切片错位。classifyToken里做的事是查std::unordered_setstd::string关键字表命中返回Keyword否则类型为Identifier数字先看是否含.分FloatLiteral和IntLiteral。这里注意一个隐藏坑1e5这种科学计数法如果状态图里没画E/e的处理1e5会被拆成数字1、标识符e5。要不要支持取决于你的语言规范建议在classify里把e/E归到哪一类、以及转移表里InNum能不能接受e提前决定。3. Qt 界面怎么接从 QPlainTextEdit 到实时 token 高亮3.1 三个面板的分工与 Designer 布局Qt 版本的可视化部分通常由三块组成左边是QPlainTextEdit作为源码输入区右边是QTableWidget展示 token 列表每行一个 token三列类型、词素、行号底部是QTextEdit或QPlainTextEdit显示错误信息。这种「输入—表格—日志」的布局在 Qt Designer 里拖出来只需要十分钟。我用 Designer 时习惯先把三个控件摆好再用QVBoxLayout/QHBoxLayout把它们的父子关系理顺而不是直接写代码设置 geometry。理由很简单geometry 是绝对定位窗口一拉大就错位布局管理器是相对定位跑 what you see is what you get。如果你要手动改界面记住setCentralWidget之后一定要setLayout不然控件叠在一起。3.2 信号槽解析textChanged 别直接触发全量重扫这里有一个性能陷阱。QPlainTextEdit的textChanged信号在你每敲一个字符时都会触发如果每次都全量重新 tokenize大文件会卡到你怀疑人生。常见做法是结合QTimer做防抖设置 300ms 的setSingleShot(true)用户停止敲击后才真正触发解析。// 头文件声明 class MainWindow : public QMainWindow { Q_OBJECT private: QPlainTextEdit* sourceEdit; QTableWidget* tokenTable; QTimer* debounceTimer; void doTokenize(); private slots: void onSourceChanged(); }; // 源文件实现 MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) { debounceTimer new QTimer(this); debounceTimer-setSingleShot(true); debounceTimer-setInterval(300); connect(sourceEdit, QPlainTextEdit::textChanged, this, MainWindow::onSourceChanged); connect(debounceTimer, QTimer::timeout, this, MainWindow::doTokenize); } void MainWindow::onSourceChanged() { debounceTimer-start(); // 每次改动重启计时器 } void MainWindow::doTokenize() { QString code sourceEdit-toPlainText(); // 注意Qt 5 中 QString 到 std::string 的转换要用 toStdString() std::string src code.toStdString(); auto tokens tokenize(src); fillTokenTable(tokens); }参数说明setInterval(300)是可调的。300ms 在「敲一个暂停一下」和「快速粘贴大段代码」之间取了一个平衡你如果希望更跟手可以降到 150ms但大文件会有明显卡顿。真正的重型优化是把 tokenize 放进QThread但课设到QTimer这层已经完全够用——除非你要在 IDE 里实时高亮几万行代码否则别过早引入线程。提示QString::toStdString()在 Qt 5 中按 UTF-8 转换。如果你的源码文件是 GBK 编码且没转成 UTF-8这里会出现乱码 token后面避坑章会细说。3.3 语法高亮把 token 流变成彩色文本Qt 自带的QSyntaxHighlighter类是为 QTextEdit/ QPlainTextEdit 设计的词法高亮框架它内部会监控文本变化并自动重绘。但注意它的粒度是「块block」级重绘对大文件性能并不好。你的项目已经有完整的 token 流了直接用QTextCursor分段设置颜色格式更直接。void MainWindow::applyHighlight(const std::vectorToken tokens, QPlainTextEdit* editor) { QTextCursor cursor(editor-document()); cursor.movePosition(QTextCursor::Start); QTextCharFormat keywordFmt; keywordFmt.setForeground(QColor(#0033CC)); keywordFmt.setFontWeight(QFont::Bold); QTextCharFormat numFmt; numFmt.setForeground(QColor(#008800)); QTextCharFormat strFmt; strFmt.setForeground(QColor(#CC0000)); for (const auto tok : tokens) { cursor.movePosition(QTextCursor::Start); // 按行号和偏移量定位这里简化成顺序移动 for (int i 0; i tok.line - 1; i) cursor.movePosition(QTextCursor::Down); cursor.setPosition(cursor.position() tok.col); cursor.movePosition(QTextCursor::Right, QTextCursor::KeepAnchor, tok.lexeme.size()); switch (tok.type) { case TokenType::Keyword: cursor.mergeCharFormat(keywordFmt); break; case TokenType::IntLiteral: case TokenType::FloatLiteral: cursor.mergeCharFormat(numFmt); break; case TokenType::StringLiteral: cursor.mergeCharFormat(strFmt); break; default: break; } } }这段代码的关键在于KeepAnchor会把从起点到移动后的位置之间的文本选中mergeCharFormat只对选中区域生效。每次调用要重新从文档头开始定位因为每次光标移动后 position 都变了所以这里用行号和列号重新找位置才算可靠。上面代码是简化写法真实项目里应当记录每个 token 的绝对偏移量直接setPosition(offset)一次性定位别像我这样循环 Down 移动行数多的时候会慢。这套直接在doTokenize()末尾调用即可。注意它和QSyntaxHighlighter是两套机制用了QSyntaxHighlighter就别再用 cursor 覆盖不然两个高亮逻辑互相打架。4. 工程怎么组织CMake Qt 5.15 的目录结构与编译参数4.1 CMakeLists.txt别再用 qmake 一条道走到黑虽然 Qt 官方对 qmake 支持得很顺手但 CMake 在 Qt6 和 Qt5.15 的过渡期已经成为事实标准。这个项目的 CMakeLists.txt 我一般写成这样可以直接抄cmake_minimum_required(VERSION 3.16) project(Lexer VERSION 1.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) set(CMAKE_AUTORCC ON) set(CMAKE_AUTOUIC ON) find_package(Qt5 COMPONENTS Widgets REQUIRED) add_executable(lexer_gui src/main.cpp src/mainwindow.cpp src/mainwindow.h src/lexer.cpp src/lexer.h src/token.h ) target_link_libraries(lexer_gui PRIVATE Qt5::Widgets)逻辑说明CMAKE_AUTOMOC ON是关键。凡是你头文件里写了Q_OBJECT宏的类moc 编译器会自动为它生成元对象代码包括信号槽的索引和connect运行时查找。如果不开 AUTOMOC链接时会报一堆undefined reference to vtable这是新手最常见的 CMake 翻车点。AUTOUIC和AUTORCC同理分别处理 Designer 的.ui文件和 Qt 资源.qrc文件。find_package(Qt5 COMPONENTS Widgets REQUIRED)只拉了 Widgets 模块如果你的界面用了图表、网络或打印要在 COMPONENTS 后面逐个加。千万注意别漏REQUIRED——漏了它Qt 找不到时 CMake 不会立刻报错后面链接阶段才炸排错成本高得多。4.2 Qt 库与编译器的匹配MSVC 还是 MinGWQt 5.15.2 官方安装包里自带两个编译器分支MSVC 2019 64-bit 和 MinGW 8.1.0 64-bit。这是最容易踩大坑的地方——两个分支的预编译库完全不通用你装了 MSVC 版 Qt 就必须用 MSVC 编译器装了 MinGW 版 Qt 就必须用 MinGW 编译器。混用的恶果是链接器报cannot mix incompatible Qt library后面避坑章专门聊。选 MSVC 还是 MinGW我多数时候选 MSVC原因是 Qt 官方对 MSVC 版测试最充分而且 Visual Studio 的调试器体验碾压 gdb。但 MSVC 有两个前置条件必须装对应版本的 Visual Studio2019、2022 都行并且 Qt 包版本要匹配得上——比如你装了 MSVC2019 版 Qt 5.15.2用 VS2022 的编译器也能链接上但用 VS2017 就报工具集不兼容。MinGW 的优点是装完 Qt 就能用不用拖一个好几个 GB 的 Visual Studio适合笔记本内存紧张的场景。# CMake 配置阶段指定套件以 Windows MSVC 为例 cmake -S . -B build -G Visual Studio 17 2022 -A x64 \ -DCMAKE_PREFIX_PATHC:/Qt/5.15.2/msvc2019_64 # 构建 cmake --build build --config ReleaseCMAKE_PREFIX_PATH指向 Qt 安装目录里的套件文件夹注意路径分隔符在 CMake 里建议用正斜杠。-A x64告诉生成器构建 64 位程序如果你编译 32 位还得装 32 位 Qt 包别指望同一个库跨位数。我用这组命令在全新 Windows 机器上配过无数次环境最快一次从装 VS 到跑通 demo 是 25 分钟。麻烦的从来不是 CMake 配置而是 Qt 套装和编译器不匹配导致的玄学报错——那个报错信息看起来像库文件损坏实际就是版本岔了。5. 避坑编译、运行、发布最常踩的 4 个坑5.1 链接报错 fatal: cannot mix incompatible Qt library现象编译通过链接时报fatal: cannot mix incompatible Qt library (version ex50601) with this library或者是unknown module(s) in qt: webenginewidgets一看就是 Qt 库版本和安装的不对劲。原因这个报错九成是因为你用 MinGW 编译器链接了 MSVC 编译的 Qt 库或者反过来。Qt 的预编译动态库内部符号格式不同MSVC 用 COFFMinGW 用 PE 但 ABI 细节不同链接器在符号匹配阶段直接拒了。剩下的一成是 Qt 版本号不一致比如你系统里同时装了 Qt 5.15.2 和 Qt 6.xfind_package找到了 5.15 但你项目里有人硬改了Qt6::Widgets的路径。解决先查 Qt 安装目录C:/Qt/5.15.2/下面如果只有msvc2019_64文件夹就必须用 MSVC 编译器只有mingw81_64就只用 MinGW。然后启动 CMake 时用-G明确指定生成器。实在搞不清编译器分支在 Qt Creator 里新建一个测试工程看它默认给你配的套件Kit是什么跟着套件走最稳。另外把unknown module(s)也归进这一类webenginewidgets需要单独勾选安装默认的 Qt 桌面组件不包含它回到安装包维护器勾选 WebEngine 重新装。5.2 运行时报 qt.qpa.plugin: could not find the qt platform plugin linuxfb现象程序编译成功双击 exe 或者写./lexer_gui运行终端冒出来qt.qpa.plugin: Could not find the Qt platform plugin linuxfb in...界面不出来。原因Qt 的 GUI 程序启动时需要加载平台插件。Windows 上是platforms/qwindows.dllLinux 上可能是platforms/libqxcb.so或libqlinuxfb.so。这个报错在 Linux 嵌入式板子上尤其常见——树莓派这类环境 Qt 编译时只开了linuxfb插件或者运行时QT_QPA_PLATFORM环境变量被设成了linuxfb而插件不在库里。Windows 上出现这个报错多半是发布时拷漏了platforms目录或路径里有中文。解决先看环境变量QT_QPA_PLATFORM是不是被设过echo $QT_QPA_PLATFORM有值就unset掉。在 Windows 发布目录下确认 exe 旁边有platforms/qwindows.dll没有就用官方windeployqt自动拷。Linux 桌面环境跑嵌入式 Qt 应用时装libqt5gui5依赖之外qt5-qpa-plugins这个包名也要装上。这个报错本身不涉及代码逻辑排查顺序永远是插件目录 → 环境变量 → 插件依赖的 xcb 库。5.3 中文编码源码是 UTF-8控制台却是 GBK现象输入的源码里写int 变量 1;词法分析器把中文标识符切成一个个Unknowntoken或者 Qt 编辑器里看起来正常但传给std::string后全乱码。原因Windows 简体中文环境下MSVC 编译器默认假定源码文件是 GBK/GB2312而 Qt 5 源文件和QString默认 UTF-8。两层编码混在一起QString::toStdString()出来的是 UTF-8 字节但你的词法分析器按 ASCII 逐字节读中文变多字节后每个字节都落不进Letter字符类。中文标识符要支持的话classify()需要把ch 0的字节当成标识符一部分来处理。解决工程内统一编码推荐三步走。第一所有源文件在 Visual Studio 里另存为 UTF-8 with BOM避免 MSVC 误判或者在 CMake 里加add_compile_options(/utf-8)强制编译器按 UTF-8 读源文件。第二从QString转出时别用toStdString()改用toUtf8().toStdString()显式表达编码。第三词法层如果明确不支持非 ASCII 标识符就在classify里直接跳过并记录错误而不是让它变成Unknown悄悄流进后续阶段。支持中文标识符本身不复杂但要清楚多字节字符的边界判断跟单字节 ASCII 完全不同。5.4 发布后换台机器就闪退事件查看器报 0000005现象程序在自己电脑上跑得好好的拷到同事电脑双击闪退Windows 事件查看器里是Access Violation (0xc0000005)或者c0000005崩溃码。原因Qt 程序默认是动态链接缺Qt5Core.dll、Qt5Gui.dll、Qt5Widgets.dll大概率直接报「找不到 DLL」——那还好排查。闪退这种 0xc0000005 通常缺的是插件或依赖库platforms/qwindows.dll里依赖了libEGL.dll和libGLESv2.dll如果只拷了 Qt 主库没带这两个 OpenGL 相关的依赖程序启动时加载平台插件失败不算甚至先闪退再报错。另一种可能是目标机器缺Microsoft Visual C Redistributable运行库这是最常见的血泪经验。解决官方工具windeployqt会把你需要的 Qt 库和插件一次性拷到 exe 旁边这是标准做法。但 windeployqt 只认 Qt 自己的依赖不认你项目里第三方 DLL所以发布前手动在 exe 所在目录逐层检查一遍。VC 运行库不是 Qt 管的windeployqt 不会帮你装目标机器是干净系统的话记得把vc_redist.x64.exe一起带上。验证发布包是否完整的最快办法在干净的虚拟机或另一台没装 Qt 的机器上跑一次。6. 给词法分析器装上第二只眼转储 DFA 状态与接入语法分析到这里项目能跑、界面能点亮、token 能彩色显示工作其实只完成了一半。词法分析器最容易被忽视的价值是调试可见性。我习惯加一个「DFA 追踪」模式在界面上放一个QCheckBox勾上后把每个字符读入时的「当前状态 → 输入字符 → 下一状态」追加到日志区同时在状态图上高亮。这个功能对排查最长匹配问题特别有用——你一眼能看出来是不是在状态就被切割了。if (debugMode) { logTextEdit-append( QString(状态 %1 读到 %2 → 状态 %3) .arg(stateName(current)) .arg(QChar(ch)) .arg(stateName(next)) ); }更值钱的一步是把tokenize()的输出接到一个简易递归下降解析器上。词法分析器永远不在真空中干活它服务的下一站是语法分析。你不需要在这个阶段写一个完整编译器做一个能解析「声明语句 赋值表达式」的最小架子就够了。这样做的价值在于检验 token 流的边界是否真的干净int a 1;能不能一个不差地切成 6 个 tokena后面有没有吞掉空格分号是不是被正确归为Punctuation而不是Operator——这些只有被下一层消费时才会暴露。bool parseDeclaration(const std::vectorToken tokens, size_t pos) { if (tokens[pos].type ! TokenType::Keyword) return false; pos; if (tokens[pos].type ! TokenType::Identifier) return false; pos; if (tokens[pos].lexeme ! ) return false; pos; if (!parseExpression(tokens, pos)) return false; return tokens[pos].lexeme ;; }这个架子顺带确立了一个规范词法分析器只提供Token流不负责报「缺少分号」这种事。把语法层面的错误留给语法层词法层只报「无法识别的字符」。保持这个边界你的TokenType枚举表就不会被各种语义判断撑爆。最后说一个我的习惯每次写完一个词法分析器一定留一个test_cases.txt和自动化测试入口里面放十几条边界输入——空文件、只有注释、连续运算符、长标识符、字符串内包含关键字。改状态表前先跑一遍改完再跑一遍比在界面里手点可靠得多。这个习惯救过我多次尤其是改关键字表后忘了同步状态转移表的时候。希望帮到你。本文还有配套的精品资源点击获取
返回列表