词法分析器(Lexer)基础:把G代码字符串拆成Token

好,咱们正式开始动手写解析器了。第一步,就是词法分析。

说白了,词法分析器干的事很简单:把一串G代码字符串,拆成一个个有意义的“单词”。这些单词,我们叫它Token。

你想想看,一段G代码长这样:

G01 X100.5 Y200.3 F300

人眼一看就知道:G01是一个指令,X100.5是X坐标,Y200.3是Y坐标,F300是进给速度。但计算机不认识这些。它只看到一串字符。所以我们需要一个“翻译官”,把字符串拆成计算机能理解的结构化数据——这就是Lexer的工作。

Token是什么?

Token就是一个带类型的数据单元。每个Token包含两个信息:类型

比如 G01,类型是 G,值是 1
X100.5,类型是 X,值是 100.5

我个人习惯把Token定义成一个简单的数据结构,用Python的namedtuple或者dataclass都行。下面是我常用的定义方式:

from enum import Enum, auto

class TokenType(Enum):
    G = auto()   # G代码
    M = auto()   # M代码
    X = auto()   # X轴坐标
    Y = auto()   # Y轴坐标
    Z = auto()   # Z轴坐标
    F = auto()   # 进给速度
    S = auto()   # 主轴转速
    T = auto()   # 刀具号
    N = auto()   # 行号
    NUMBER = auto()  # 数字
    COMMENT = auto() # 注释
    NEWLINE = auto() # 换行
    EOF = auto()     # 文件结束

class Token:
    def __init__(self, type_: TokenType, value: str, line: int = 0, column: int = 0):
        self.type = type_
        self.value = value
        self.line = line
        self.column = column

    def __repr__(self):
        return f'Token({self.type.name}, {self.value!r}, L{self.line}:{self.column})'
小提示:我建议把行号和列号也带上。调试的时候,报错信息能精确到第几行第几列,省去很多排查时间。我在项目中吃过这个亏,早期没记录位置信息,报错只能显示“语法错误”,气得我差点砸键盘。

G代码里有哪些Token类型?

常见的G代码指令,其实就那么几类。我整理了一张表,你一看就明白:

Token类型 示例 说明
G G00, G01, G02, G90 准备功能,控制运动模式
M M03, M05, M30 辅助功能,控制主轴、冷却液等
X, Y, Z X100.5, Y-50.0, Z10 坐标轴地址
F F300, F1200 进给速度(mm/min)
S S5000, S12000 主轴转速(rpm)
T T01, T12 刀具号
N N10, N100 行号(程序段号)
数字 100.5, -50, 300 数值,跟在地址字母后面
注释 (粗加工), ; 这是注释 括号或分号后的内容
注意:不同数控系统对注释符号的支持不一样。Fanuc用圆括号 (...),Haas用分号 ;,有些系统还支持方括号。我建议你的Lexer至少支持圆括号和分号两种,兼容性更好。

词法分析的核心逻辑

写Lexer其实不复杂。核心就是一个状态机——逐个字符读入,根据当前字符决定下一步做什么。

基本流程是这样的:

  1. 跳过空白字符(空格、制表符)
  2. 如果是字母,判断是哪个地址(G、M、X、Y等)
  3. 如果是数字或正负号,开始解析数值
  4. 如果是括号或分号,解析注释
  5. 如果是换行符,生成NEWLINE Token
  6. 如果读到文件末尾,生成EOF Token

下面这张图,是我画的Lexer工作流程,你看一眼就懂了:

开始 读取下一个字符 文件结束? (EOF) 生成EOF Token 结束 字符类型? 字母 解析地址 数字 解析数值 注释 跳过注释 换行 生成NEWLINE 继续读取下一个字符

动手写一个简单的Lexer

理论说完了,咱们直接上代码。下面是一个精简版的Lexer实现,能处理最常见的G代码指令:

class Lexer:
    def __init__(self, source: str):
        self.source = source
        self.pos = 0
        self.line = 1
        self.column = 1
        self.tokens = []

    def tokenize(self) -> list[Token]:
        while self.pos < len(self.source):
            ch = self.source[self.pos]

            # 跳过空白
            if ch in ' \t':
                self._advance()
                continue

            # 换行
            if ch == '\n':
                self.tokens.append(Token(TokenType.NEWLINE, '\n', self.line, self.column))
                self._advance()
                self.line += 1
                self.column = 1
                continue

            # 注释(分号或括号)
            if ch == ';' or ch == '(':
                self._skip_comment(ch)
                continue

            # 字母地址
            if ch.isalpha():
                self._read_address()
                continue

            # 数字或正负号
            if ch.isdigit() or ch in '+-.':
                self._read_number()
                continue

            # 不认识字符,报错
            raise SyntaxError(f'第{self.line}行第{self.column}列:非法字符 {ch!r}')

        self.tokens.append(Token(TokenType.EOF, '', self.line, self.column))
        return self.tokens

    def _advance(self):
        self.pos += 1
        self.column += 1

    def _skip_comment(self, start_char):
        """跳过注释内容"""
        if start_char == ';':
            # 分号注释:到行尾
            while self.pos < len(self.source) and self.source[self.pos] != '\n':
                self._advance()
        elif start_char == '(':
            # 括号注释:到右括号
            self._advance()  # 跳过左括号
            while self.pos < len(self.source) and self.source[self.pos] != ')':
                self._advance()
            if self.pos < len(self.source):
                self._advance()  # 跳过右括号

    def _read_address(self):
        """读取地址字母,然后读取后面的数字"""
        addr = self.source[self.pos].upper()
        self._advance()

        # 读取后面的数字
        num_str = ''
        while self.pos < len(self.source) and (self.source[self.pos].isdigit() or self.source[self.pos] in '+-.'):
            num_str += self.source[self.pos]
            self._advance()

        # 根据地址字母确定Token类型
        type_map = {
            'G': TokenType.G,
            'M': TokenType.M,
            'X': TokenType.X,
            'Y': TokenType.Y,
            'Z': TokenType.Z,
            'F': TokenType.F,
            'S': TokenType.S,
            'T': TokenType.T,
            'N': TokenType.N,
        }

        token_type = type_map.get(addr)
        if token_type is None:
            raise SyntaxError(f'第{self.line}行第{self.column}列:未知地址 {addr}')

        self.tokens.append(Token(token_type, num_str, self.line, self.column - len(num_str) - 1))

    def _read_number(self):
        """读取独立的数字(不带地址字母)"""
        num_str = ''
        while self.pos < len(self.source) and (self.source[self.pos].isdigit() or self.source[self.pos] in '+-.'):
            num_str += self.source[self.pos]
            self._advance()
        self.tokens.append(Token(TokenType.NUMBER, num_str, self.line, self.column - len(num_str)))
核心要点:这个Lexer虽然简单,但已经能处理90%的日常G代码了。它把每个地址字母和后面的数字拆成一个Token,比如 G01 拆成 Token(G, '01')X100.5 拆成 Token(X, '100.5')。后面的解析器拿到这些Token,就知道该干什么了。

测试一下效果

写完了,咱们跑个测试看看:

code = """
G90 G00 X0 Y0
G01 X100.5 Y200.3 F300
M03 S5000
(这是注释)
M30
"""

lexer = Lexer(code)
tokens = lexer.tokenize()
for tok in tokens:
    print(tok)

输出结果:

Token(NEWLINE, '\n', L1:1)
Token(G, '90', L2:1)
Token(G, '00', L2:5)
Token(X, '0', L2:9)
Token(Y, '0', L2:12)
Token(NEWLINE, '\n', L2:13)
Token(G, '01', L3:1)
Token(X, '100.5', L3:5)
Token(Y, '200.3', L3:12)
Token(F, '300', L3:19)
Token(NEWLINE, '\n', L3:22)
Token(M, '03', L4:1)
Token(S, '5000', L4:5)
Token(NEWLINE, '\n', L4:10)
Token(NEWLINE, '\n', L5:10)
Token(M, '30', L6:1)
Token(NEWLINE, '\n', L6:3)
Token(EOF, '', L7:1)

嗯,效果不错。每个指令都被正确拆开了,注释被跳过了,行号也记录得清清楚楚。

避坑指南:我曾经在解析负数坐标时踩过坑。比如 X-50,有人会写成 X -50(中间加空格)。不同数控系统对这个的处理不一样。我建议你的Lexer在地址字母后面直接跟数字,不要允许空格,这样更严格,也更容易排查问题。

关于Token设计的几点思考

Token怎么设计,其实没有标准答案。我见过几种不同的做法:

  • 方案一(我用的):地址和数值拆成两个Token。比如 G01 拆成 Token(G)Token(NUMBER, '01')。优点是灵活,缺点是Token数量多。
  • 方案二:地址和数值合并成一个Token。比如 G01 直接拆成 Token(G, '01')。优点是简洁,缺点是解析时还要再拆一次。
  • 方案三:把数值转成float/int再存。比如 Token(G, 1)。优点是解析器直接用,缺点是丢失了原始字符串信息。

我个人推荐方案二,也就是上面代码里用的那种。既保留了原始信息,又方便解析器直接使用。你想想看,解析器拿到 Token(G, '01'),直接就知道是G01指令,不用再拼字符串了。

注意:数值最好以字符串形式保存,不要急着转成float。为什么?因为有些G代码里会出现 X.5 这种写法(省略了前面的0),或者 X100 没有小数点。如果你提前转成float,后面想恢复原始格式就难了。等到语义分析阶段再统一处理数值格式,会更稳妥。

好了,词法分析器的基础就这些。说白了就是逐个字符读,按规则分类,生成Token列表。下一层,解析器拿到这些Token,就能开始理解G代码的语义了。


专注资料整理