3. 词法分析器实现:Token定义、正则表达式匹配、状态机设计、错误处理

好,咱们进入正题。词法分析器,说白了就是给G代码做“分词”的。你想想看,一整段G代码字符串,机器怎么理解?它得先拆成一个个有意义的“单词”,也就是Token。这一步做不好,后面解析语法、生成刀路全是白搭。我早年刚接触CNC时,就吃过这个亏——自己写了个简陋的分词器,结果遇到带小数点的坐标直接崩了。嗯,从那以后,我对词法分析器就格外上心。

3.1 Token定义:给每个“单词”贴标签

Token是什么?就是源代码里最小的、有独立含义的单元。比如 G01 X100.5 F200,拆开就是:G、01、X、100.5、F、200。每个单元都得有个类型标签。

我个人习惯把Token分成这几大类:

Token类型 示例 说明
ADDRESS G, M, X, Y, Z, F, S, T 地址字,表示指令或轴
NUMBER 01, 100.5, -3 数值,包括整数和浮点数
COMMENT (钻孔循环), ; 注释 括号或分号后的注释
EOL \n, \r\n 行结束符
EOF 文件结尾 输入流结束
UNKNOWN @, #, 乱码 无法识别的字符
小技巧: 我建议把 ADDRESS 和 NUMBER 分开定义。别图省事合成一个“指令Token”。为什么?因为后续语法分析时,你需要区分“哪个轴”和“移动多少”,分开处理逻辑更清晰。

代码里我一般这么定义:

// C# 示例
public enum TokenType
{
    ADDRESS,    // G, M, X, Y, Z, F, S, T
    NUMBER,     // 数值
    COMMENT,    // 注释
    EOL,        // 换行
    EOF,        // 结束
    UNKNOWN     // 非法
}

public class Token
{
    public TokenType Type { get; set; }
    public string Value { get; set; }
    public int Line { get; set; }
    public int Column { get; set; }
}

3.2 正则表达式匹配:用模式说话

正则表达式是词法分析的好帮手。你不需要手写一堆if-else去判断每个字符,直接定义好模式,让引擎去匹配就行。

常用的几个正则模式:

  • 地址字: [GgMmXxYyZzFfSsTt] —— 注意大小写都要支持,有些老旧机床生成的代码全是小写。
  • 数值: -?\d+(\.\d+)? —— 支持负数和小数。我记得有一次客户反馈说“我的代码里坐标没小数点”,结果发现是整数格式,这个正则正好能兜住。
  • 注释: \([^)]*\) 或 ;.* —— 括号注释和分号注释都要处理。
  • 空白: \s+ —— 空格、制表符直接跳过。
注意: 正则匹配的顺序很重要!一定要把 COMMENT 和 NUMBER 放在 ADDRESS 前面匹配。否则遇到 X100.5,可能会先把 X 匹配成地址,然后 100.5 匹配成数字——这没问题。但如果你先匹配地址,再匹配数字,顺序对了。反过来,如果先匹配数字,100.5 会被吃掉,X 就落单了。

实际匹配时,我习惯用“最长匹配”原则。比如 G01,你不能只匹配到 G 就停,得继续看后面是不是数字。所以流程是:

  1. 从当前位置开始,尝试所有正则模式。
  2. 选择匹配长度最长的那个模式。
  3. 如果长度相同,按优先级选择(比如关键字优先于标识符)。
  4. 生成Token,移动指针。

3.3 状态机设计:让解析有章可循

正则匹配虽然方便,但遇到复杂情况(比如字符串、嵌套注释)就容易乱。这时候状态机就派上用场了。

G代码的词法分析其实不算复杂,我一般设计成这几个状态:

  • START: 初始状态,准备读取下一个字符。
  • IN_ADDRESS: 正在读取地址字。
  • IN_NUMBER: 正在读取数值。
  • IN_COMMENT: 正在读取注释。
  • IN_ERROR: 遇到非法字符。

下面这张图是我常用的状态机结构,你可以参考一下:

START ADDRESS NUMBER COMMENT ERROR 字母 数字/负号 左括号/分号 非法字符 数字 非数字 非数字 右括号/换行

状态机的核心逻辑其实很简单:

// 伪代码示例
while (currentChar != EOF)
{
    switch (currentState)
    {
        case State.START:
            if (IsLetter(currentChar))
                currentState = State.IN_ADDRESS;
            else if (IsDigit(currentChar) || currentChar == '-')
                currentState = State.IN_NUMBER;
            else if (currentChar == '(' || currentChar == ';')
                currentState = State.IN_COMMENT;
            else if (IsWhitespace(currentChar))
                Skip(); // 跳过空白
            else
                currentState = State.IN_ERROR;
            break;

        case State.IN_ADDRESS:
            if (IsDigit(currentChar))
                // 继续读取数字部分
            else
                EmitToken(TokenType.ADDRESS);
                currentState = State.START;
            break;

        // ... 其他状态处理
    }
}
核心要点: 状态机的好处是“一次只关心一个状态”。你不需要在全局层面考虑所有可能性,每个状态只处理自己的转移逻辑。代码可读性高,也容易调试。

3.4 错误处理:别让程序默默崩溃

词法分析阶段的错误处理,说白了就是“尽早发现,明确报告”。我见过太多解析器遇到非法字符直接抛异常,连个行号都不给。你想想看,操作工拿到报错信息,只知道“解析失败”,鬼知道哪里错了。

我一般这样处理:

  • 非法字符: 记录错误信息(行号、列号、非法字符),生成一个 UNKNOWN Token,继续解析。不要停!因为后面可能还有更多错误,一次性报出来效率更高。
  • 未闭合注释: 如果遇到文件结尾但注释还没结束,报“未闭合的注释”。
  • 数值格式错误: 比如 X..5 这种,小数点重复了。我会在数值解析时做校验,发现异常就报错。
避坑指南: 我曾经遇到过一个案例,某台老机床生成的G代码里,地址字后面直接跟了字母(比如 G0A)。严格来说这是非法格式,但机床本身能跑。怎么办?我建议在词法分析阶段先按标准报错,同时提供一个“宽松模式”开关,让用户选择是否容忍这类问题。别自作主张替用户做决定。

错误信息的格式我建议统一:

// 错误信息格式
[行号:列号] 错误类型: 详细描述

// 示例
[12:5] 非法字符: '@',已忽略
[34:2] 数值格式错误: 多余的小数点
[56:1] 未闭合的注释: 缺少右括号

这样操作工拿到报错,直接去对应行看一眼就明白了。嗯,细节决定成败。

个人经验: 词法分析器的错误处理,我建议做成“可恢复的”。也就是说,遇到错误不要直接退出,而是记录错误、生成一个占位Token、继续解析。这样一次运行就能发现所有词法错误,而不是改一个错、跑一次、再改一个错、再跑一次。效率能提升好几倍。