4、词法分析(Tokenizer):如何将G代码字符串拆分为(指令、数字、字母、注释),处理空白、换行、大小写
词法分析是G代码解析器的第一道关卡,其核心任务是将原始文本字符串转换为具有语义的最小单元——。如果把解析器比作一条流水线,词法分析器就是分拣工,它必须准确识别出每一个“零件”(指令、坐标、参数、注释),并剔除无意义的“杂质”(多余空格、空行)。这一阶段的质量直接决定了后续语法分析的复杂度与鲁棒性。本章将深入探讨G代码词法分析的完整实现路径,涵盖类型定义、状态机设计、大小写归一化策略以及注释与空白处理等关键议题。
上图展示了词法分析器的整体架构与状态转换关系。从原始字符串到流,需要经过预处理、状态机扫描和分类输出三个主要阶段。下面我们将逐一拆解每个环节的实现细节。
4.1 类型定义与数据结构设计
在动手编写词法分析器之前,必须先定义清晰的类型。G代码的种类相对固定,但设计良好的数据结构能显著降低后续语法分析的复杂度。通常我们将分为以下几类:
| 类型 | 示例 | 正则表达式(参考) | 说明 |
|---|---|---|---|
| WORD | G01, M03, T02 | [A-Za-z]\d{1,3} | 字母开头的指令字,字母与数字不可分割 |
| NUMBER | 10.5, -2.0, 300 | [+-]?\d+(\.\d+)?([eE][+-]?\d+)? | 数值参数,支持小数与科学计数法 |
| COMMENT | ; 钻孔循环, ( 注释 ) | ;.*|\(.*?\) | 分号注释到行尾,括号注释可跨行 |
| NEWLINE | \n, \r\n | \r?\n | 行结束符,用于行号追踪 |
| WHITESPACE | 空格, TAB | [ \t]+ | 分隔符,通常不生成 |
| EOF | 文件结束 | — | 标记输入流结束 |
在实际工程中,我们通常使用一个结构体或类来表示,至少包含三个字段:type(类型枚举)、value(原始字符串或解析后的数值)、line(所在行号)。行号对于错误报告和调试至关重要,例如当语法分析器发现某个坐标值缺失时,可以精确提示“第12行:X参数缺少数值”。
工程建议:对于NUMBER类型的,建议同时保存原始字符串和解析后的浮点值。因为某些场景下(如圆弧插补的I、J、K参数)需要高精度原始值,而某些场景(如进给率F)需要直接使用数值计算。预先解析可以避免后续重复转换。
4.2 预处理:空白、换行与注释的剥离策略
预处理并非词法分析的必需步骤,但合理的预处理能大幅简化状态机的设计。常见的预处理操作包括:
- 统一换行符:将
\r\n和\r统一转换为\n,避免不同操作系统带来的差异。 - 去除首尾空白:每行首尾的多余空格不影响语义,可以提前去除。
- 注释剥离:分号注释(
;)直接删除到行尾;括号注释((...))可能跨行,需要特殊处理。 - 大小写归一化:G代码指令字母通常不区分大小写,统一转换为大写(或小写)可以减少后续匹配的复杂度。
需要注意的是,预处理并非“一刀切”。例如,括号注释内部可能包含小写字母或特殊字符,如果直接转为大写会破坏注释内容。因此,更稳妥的做法是在状态机中处理注释,而不是在预处理阶段简单替换。下面给出一个预处理与状态机结合的伪代码示例:
function preprocess(rawText):
# 统一换行符
text = rawText.replace("\r\n", "\n").replace("\r", "\n")
# 按行处理,剥离分号注释
lines = text.split("\n")
cleanLines = []
for line in lines:
semicolonIdx = line.find(";")
if semicolonIdx != -1:
line = line[:semicolonIdx]
cleanLines.append(line)
# 重新拼接,保留换行符
return "\n".join(cleanLines)
但上述代码无法处理跨行的括号注释。对于跨行注释,必须在状态机中维护一个“是否在注释内”的布尔标志。当遇到 ( 时进入注释状态,直到遇到匹配的 ) 才退出,期间所有字符(包括换行符)都被忽略。
4.3 状态机实现:逐字符扫描与生成
词法分析的核心是一个有限状态机(FSM)。我们定义以下几个状态:START(初始)、LETTER(字母)、NUMBER(数字)、COMMENT(注释)、WHITESPACE(空白)。状态转换逻辑如下:
- START状态:读取一个字符,根据其类型跳转到对应状态。如果是字母,进入LETTER;如果是数字或正负号,进入NUMBER;如果是分号或左括号,进入COMMENT;如果是空格/TAB/换行,进入WHITESPACE。
- LETTER状态:持续读取字母和数字,直到遇到非字母数字字符。此时生成一个WORD ,并回到START状态处理当前字符(注意不要消耗该字符)。
- NUMBER状态:持续读取数字、小数点、正负号、指数符号(e/E),直到遇到非数字字符。生成NUMBER 。
- COMMENT状态:如果是分号注释,读取到行尾(遇到换行符)即结束;如果是括号注释,读取到匹配的右括号结束。期间所有字符均不生成。
- WHITESPACE状态:连续读取空白字符,遇到第一个非空白字符时回到START状态。空白本身不生成,但换行符需要生成NEWLINE (用于行号追踪)。
这里有一个关键细节:回退(unread)机制。当在LETTER状态遇到数字时,数字属于指令的一部分(如G01中的“01”),但当在NUMBER状态遇到字母时(如“10X”),字母应该属于下一个。因此,状态机需要支持“放回”当前字符,让下一个从该字符开始解析。实现上通常使用一个pushback缓冲区或索引回退。
经验之谈:不要试图在词法分析阶段处理语法错误。例如,遇到“G01 X10 Y”这样的字符串,Y后面缺少数值,这是语法分析器的工作。词法分析器只需要正确识别出WORD(Y)和EOF即可。保持词法分析器的“单纯”有助于降低耦合度。
4.4 大小写归一化与特殊字符处理
G代码标准中,指令字母(G、M、T、S、F等)不区分大小写,但参数值中的字母(如X、Y、Z)通常也是大写。为了简化后续匹配,我们可以在生成WORD 时统一转为大写。但需要注意:注释内容不应被转换。因此,大小写转换应在生成时进行,而不是在预处理阶段全局替换。
另一个容易忽略的是负号与正号。在NUMBER状态中,正负号只能出现在数字的开头(如“-2.0”),不能出现在数字中间(如“2-3”)。如果遇到“2-3”,应该解析为两个:NUMBER(2) 和 NUMBER(-3)。这要求状态机在NUMBER状态中遇到正负号时,必须结束当前数字并开始新的数字。
此外,G代码中偶尔会出现小数点开头的数字(如“.5”表示0.5)。虽然不常见,但词法分析器应当支持这种写法。在NUMBER状态中,如果遇到小数点且之前没有数字,应将其视为“0.”的开头。
4.5 实战案例:解析一段完整的钻孔循环G代码
下面我们通过一个完整的案例来演示词法分析的全过程。假设输入文件内容如下:
%
N10 G90 G54 G00 X0 Y0 S1200 M03
N20 G43 H01 Z50.0
N30 G81 X10.0 Y-5.0 Z-10.0 R2.0 F150 ; 钻孔循环
N40 G80
N50 M30
%
我们的词法分析器需要输出以下流(简化表示):
| 行号 | 序列 |
|---|---|
| 1 | WORD(%), NEWLINE |
| 2 | WORD(N10), WORD(G90), WORD(G54), WORD(G00), WORD(X0), WORD(Y0), WORD(S1200), WORD(M03), NEWLINE |
| 3 | WORD(N20), WORD(G43), WORD(H01), WORD(Z50.0), NEWLINE |
| 4 | WORD(N30), WORD(G81), WORD(X10.0), WORD(Y-5.0), WORD(Z-10.0), WORD(R2.0), WORD(F150), COMMENT(; 钻孔循环), NEWLINE |
| 5 | WORD(N40), WORD(G80), NEWLINE |
| 6 | WORD(N50), WORD(M30), NEWLINE |
| 7 | WORD(%), EOF |
注意第4行中的Y-5.0,词法分析器将其识别为一个WORD(Y)和一个NUMBER(-5.0),而不是一个单独的。这是因为字母和数字在G代码中具有不同的语义角色:字母是地址字,数字是参数值。这种拆分方式为语法分析器提供了清晰的输入。
在实现过程中,我们还需要处理%符号。它通常出现在程序开头和结尾,表示程序开始/结束。词法分析器可以将其视为一个特殊的WORD ,或者单独定义PERCENT类型。具体取决于后续语法分析的需求。
常见误区:不要将X10.0整体作为一个。虽然从人类阅读角度看它是一个“坐标”,但在G代码语法中,X是地址字,10.0是数值。如果合并为一个,语法分析器将不得不再次拆分,增加了不必要的复杂度。正确的做法是保持“字母”和“数字”分离。
4.6 常见误区与性能优化
在实现词法分析器时,开发者常犯以下几个错误:
- 忽略行号追踪:没有记录所在行号,导致后续错误报告无法定位。
- 过度预处理:在预处理阶段将所有字母转为大写,导致注释内容被破坏。
- 状态机状态遗漏:没有处理“数字后紧跟字母”的情况(如“10X”),导致解析错误。
- 性能瓶颈:使用字符串拼接逐字符构建值,在大型文件(数万行)时效率低下。建议使用
StringBuilder或字符数组。
对于性能优化,一个实用的技巧是批量读取。不要逐字符从文件读取,而是将整个文件加载到内存(或使用缓冲流),然后使用索引遍历字符数组。这样可以避免大量的I/O调用。对于特别大的文件(超过100MB),可以考虑分块读取,但需要处理块边界处的截断问题。
另一个优化点是避免重复正则匹配。虽然正则表达式可以简化状态机的编写,但性能较差。手写状态机通常比正则表达式快3-5倍。如果使用正则表达式,建议预编译并复用Pattern对象。
总结复盘:词法分析器的核心目标不是“理解”G代码,而是“切分”G代码。它应该保持无状态(除了注释标志和行号),不关心指令的含义。一个设计良好的词法分析器应该具备以下特征:① 输入一个字符串,输出一个列表;② 每个包含类型、值、行号;③ 处理所有合法输入而不崩溃;④ 对非法输入给出明确的错误位置。在完成本章学习后,你应该能够独立实现一个支持完整G代码语法(包括括号注释、科学计数法、负号处理)的词法分析器,并为下一章的语法分析打下坚实基础。