4、词法分析(Tokenizer):如何将G代码字符串拆分为(指令、数字、字母、注释),处理空白、换行、大小写

词法分析是G代码解析器的第一道关卡,其核心任务是将原始文本字符串转换为具有语义的最小单元——。如果把解析器比作一条流水线,词法分析器就是分拣工,它必须准确识别出每一个“零件”(指令、坐标、参数、注释),并剔除无意义的“杂质”(多余空格、空行)。这一阶段的质量直接决定了后续语法分析的复杂度与鲁棒性。本章将深入探讨G代码词法分析的完整实现路径,涵盖类型定义、状态机设计、大小写归一化策略以及注释与空白处理等关键议题。

原始G代码字符串 N10 G01 X10.5 Y-2.0 F300 ; 注释 预处理 去除空白/换行 统一大小写/剥离注释 词法状态机 逐字符扫描 识别字母/数字/符号 切换状态并生成 流 [N:10] [G:01] [X:10.5] [F:300] 类型分类与状态转换路径 字母指令 G01, M03, T01 字母+数字组合 状态: LETTER → DIGIT 数字参数 X10.5, Y-2.0 支持小数/正负号 状态: SIGN → INT → FRAC 注释 ; 或 ( ... ) 直接跳过或保留 状态: COMMENT 分隔符/换行 空格, TAB, LF 作为边界 状态: WHITESPACE 核心转换规则: ① 字母(A-Z, a-z) → 进入LETTER状态,连续读取后续字母与数字,形成指令字(如G01、M03) ② 数字(0-9)或符号(+/-) → 进入NUMBER状态,支持整数、小数、科学计数法(如1.5e-3) ③ 分号(;)或左括号(() → 进入COMMENT状态,跳过直到行尾或右括号 ④ 空格/TAB/换行 → 作为分隔符,不生成但记录行号

上图展示了词法分析器的整体架构与状态转换关系。从原始字符串到流,需要经过预处理、状态机扫描和分类输出三个主要阶段。下面我们将逐一拆解每个环节的实现细节。

4.1 类型定义与数据结构设计

在动手编写词法分析器之前,必须先定义清晰的类型。G代码的种类相对固定,但设计良好的数据结构能显著降低后续语法分析的复杂度。通常我们将分为以下几类:

类型示例正则表达式(参考)说明
WORDG01, M03, T02[A-Za-z]\d{1,3}字母开头的指令字,字母与数字不可分割
NUMBER10.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(空白)。状态转换逻辑如下:

  1. START状态:读取一个字符,根据其类型跳转到对应状态。如果是字母,进入LETTER;如果是数字或正负号,进入NUMBER;如果是分号或左括号,进入COMMENT;如果是空格/TAB/换行,进入WHITESPACE。
  2. LETTER状态:持续读取字母和数字,直到遇到非字母数字字符。此时生成一个WORD ,并回到START状态处理当前字符(注意不要消耗该字符)。
  3. NUMBER状态:持续读取数字、小数点、正负号、指数符号(e/E),直到遇到非数字字符。生成NUMBER 。
  4. COMMENT状态:如果是分号注释,读取到行尾(遇到换行符)即结束;如果是括号注释,读取到匹配的右括号结束。期间所有字符均不生成。
  5. 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
%

我们的词法分析器需要输出以下流(简化表示):

行号序列
1WORD(%), NEWLINE
2WORD(N10), WORD(G90), WORD(G54), WORD(G00), WORD(X0), WORD(Y0), WORD(S1200), WORD(M03), NEWLINE
3WORD(N20), WORD(G43), WORD(H01), WORD(Z50.0), NEWLINE
4WORD(N30), WORD(G81), WORD(X10.0), WORD(Y-5.0), WORD(Z-10.0), WORD(R2.0), WORD(F150), COMMENT(; 钻孔循环), NEWLINE
5WORD(N40), WORD(G80), NEWLINE
6WORD(N50), WORD(M30), NEWLINE
7WORD(%), 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代码语法(包括括号注释、科学计数法、负号处理)的词法分析器,并为下一章的语法分析打下坚实基础。