词法分析器(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 | 数值,跟在地址字母后面 |
| 注释 | (粗加工), ; 这是注释 | 括号或分号后的内容 |
(...),Haas用分号 ;,有些系统还支持方括号。我建议你的Lexer至少支持圆括号和分号两种,兼容性更好。
词法分析的核心逻辑
写Lexer其实不复杂。核心就是一个状态机——逐个字符读入,根据当前字符决定下一步做什么。
基本流程是这样的:
- 跳过空白字符(空格、制表符)
- 如果是字母,判断是哪个地址(G、M、X、Y等)
- 如果是数字或正负号,开始解析数值
- 如果是括号或分号,解析注释
- 如果是换行符,生成NEWLINE Token
- 如果读到文件末尾,生成EOF Token
下面这张图,是我画的Lexer工作流程,你看一眼就懂了:
动手写一个简单的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)))
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指令,不用再拼字符串了。
X.5 这种写法(省略了前面的0),或者 X100 没有小数点。如果你提前转成float,后面想恢复原始格式就难了。等到语义分析阶段再统一处理数值格式,会更稳妥。
好了,词法分析器的基础就这些。说白了就是逐个字符读,按规则分类,生成Token列表。下一层,解析器拿到这些Token,就能开始理解G代码的语义了。