3. 词法分析器(Tokenizer)实现:将G代码字符串拆分为Token流
好,咱们进入正题。上一章我们搭好了项目骨架,这一章要干的事很纯粹——把G代码那串字符串,拆成一个个有意义的Token。
说白了,Tokenizer就是给G代码做分词。你想想看,一段G代码长这样:G01 X100 Y200 F500,计算机不认识这串字符,它得先知道“G01”是一个指令,“X100”是一个坐标。Tokenizer就是干这个的。
3.1 Token是什么?先定义清楚
我习惯先把数据结构定好,再写逻辑。Token说白了就是一个“带标签的小纸片”,上面写着:我是什么类型,我的值是什么。
咱们先定义Token的类型枚举:
// token_types.h
typedef enum {
TOKEN_UNKNOWN, // 未知类型
TOKEN_G_CODE, // G指令,如 G01
TOKEN_M_CODE, // M指令,如 M03
TOKEN_ADDRESS, // 地址字,如 X, Y, Z, F, S
TOKEN_NUMBER, // 数值,如 100, 200.5
TOKEN_COMMENT, // 注释,如 (这是一段注释)
TOKEN_NEWLINE, // 换行
TOKEN_EOF // 文件结束
} TokenType;
然后定义Token结构体:
// token.h
typedef struct {
TokenType type;
char value[64]; // 原始字符串
double num_val; // 如果是数值,存这里
int line; // 行号,方便报错
int column; // 列号
} Token;
3.2 Tokenizer的核心流程
Tokenizer的逻辑其实不复杂,就是逐个字符扫描,根据字符决定下一步。我画了个流程图,你看一眼就明白了:
这个流程其实很直观。我当年第一次写Tokenizer时,犯了个低级错误——没处理连续空格。结果G代码里多个空格,程序就崩了。嗯,从那以后我养成了习惯:先处理空白字符,再处理其他。
3.3 核心代码实现
咱们直接上代码。Tokenizer的核心函数就一个:get_next_token(),每次调用返回一个Token。
// tokenizer.c
#include "tokenizer.h"
#include <ctype.h>
#include <string.h>
#include <stdio.h>
// 内部状态
static const char* src; // 源代码字符串
static int pos; // 当前扫描位置
static int line; // 当前行号
static int column; // 当前列号
void tokenizer_init(const char* source) {
src = source;
pos = 0;
line = 1;
column = 1;
}
Token get_next_token() {
Token token = {0};
token.line = line;
token.column = column;
// 跳过空白字符
while (src[pos] != '\0' && (src[pos] == ' ' || src[pos] == '\t')) {
pos++;
column++;
}
// 处理文件结束
if (src[pos] == '\0') {
token.type = TOKEN_EOF;
strcpy(token.value, "EOF");
return token;
}
// 处理换行
if (src[pos] == '\n') {
token.type = TOKEN_NEWLINE;
strcpy(token.value, "\\n");
pos++;
line++;
column = 1;
return token;
}
// 处理注释 ( 到 )
if (src[pos] == '(') {
int start = pos;
while (src[pos] != '\0' && src[pos] != ')') {
pos++;
column++;
}
if (src[pos] == ')') {
pos++; // 跳过 )
column++;
}
token.type = TOKEN_COMMENT;
int len = pos - start;
strncpy(token.value, &src[start], len);
token.value[len] = '\0';
return token;
}
// 处理字母(G/M 指令或地址字)
if (isalpha(src[pos])) {
token.type = TOKEN_ADDRESS; // 默认是地址字
token.value[0] = src[pos];
token.value[1] = '\0';
char ch = src[pos];
pos++;
column++;
// 如果是 G 或 M,后面可能跟数字
if ((ch == 'G' || ch == 'M') && isdigit(src[pos])) {
int start = pos;
while (isdigit(src[pos])) {
pos++;
column++;
}
int len = pos - start;
char num_str[16];
strncpy(num_str, &src[start], len);
num_str[len] = '\0';
token.num_val = atoi(num_str);
snprintf(token.value, sizeof(token.value), "%c%s", ch, num_str);
token.type = (ch == 'G') ? TOKEN_G_CODE : TOKEN_M_CODE;
}
return token;
}
// 处理数字(包括小数点)
if (isdigit(src[pos]) || src[pos] == '.') {
int start = pos;
while (isdigit(src[pos]) || src[pos] == '.') {
pos++;
column++;
}
int len = pos - start;
strncpy(token.value, &src[start], len);
token.value[len] = '\0';
token.num_val = atof(token.value);
token.type = TOKEN_NUMBER;
return token;
}
// 未知字符
token.type = TOKEN_UNKNOWN;
token.value[0] = src[pos];
token.value[1] = '\0';
pos++;
column++;
return token;
}
\0就停止,同时报个警告。你写的时候一定要处理这种异常情况。
3.4 测试一下Tokenizer
代码写完了,咱们得跑一跑。我习惯写个简单的测试程序:
// test_tokenizer.c
#include "tokenizer.h"
#include <stdio.h>
int main() {
const char* gcode = "G01 X100 Y200.5 F500\n"
"M03 S3000\n"
"(这是注释)\n";
tokenizer_init(gcode);
Token token;
do {
token = get_next_token();
printf("Line %d, Col %d: Type=%d, Value='%s', NumVal=%.2f\n",
token.line, token.column,
token.type, token.value, token.num_val);
} while (token.type != TOKEN_EOF);
return 0;
}
运行结果应该是这样的:
Line 1, Col 1: Type=1, Value='G01', NumVal=1.00
Line 1, Col 5: Type=3, Value='X', NumVal=0.00
Line 1, Col 6: Type=4, Value='100', NumVal=100.00
Line 1, Col 10: Type=3, Value='Y', NumVal=0.00
Line 1, Col 11: Type=4, Value='200.5', NumVal=200.50
Line 1, Col 16: Type=3, Value='F', NumVal=0.00
Line 1, Col 17: Type=4, Value='500', NumVal=500.00
Line 1, Col 20: Type=6, Value='\n', NumVal=0.00
Line 2, Col 1: Type=2, Value='M03', NumVal=3.00
Line 2, Col 5: Type=3, Value='S', NumVal=0.00
Line 2, Col 6: Type=4, Value='3000', NumVal=3000.00
Line 2, Col 10: Type=6, Value='\n', NumVal=0.00
Line 3, Col 1: Type=5, Value='(这是注释)', NumVal=0.00
Line 3, Col 7: Type=6, Value='\n', NumVal=0.00
Line 4, Col 1: Type=7, Value='EOF', NumVal=0.00
看到没?每一段都被正确拆开了。G01被识别为G_CODE,X、Y、F是ADDRESS,后面的数字是NUMBER。注释也被完整保留。
3.5 处理边界情况
实际项目中,G代码不会这么规整。我遇到过各种奇葩情况:
- 大小写混用:有人写
g01 x100,有人写G01 X100。我的做法是统一转大写处理。 - 多余空格:
G01 X 100,两个空格甚至更多。咱们的代码已经处理了连续空格。 - 空行:连续多个换行。每个换行都会生成一个TOKEN_NEWLINE,解析器那边需要处理连续换行的情况。
- 分号注释:有些G代码用
;表示注释。我建议也支持一下,加个判断就行。
3.6 性能考量
Tokenizer的性能其实不用太担心,G代码文件一般不大。但如果你要处理超大文件(比如几十万行的3D打印G代码),有几点可以优化:
| 优化点 | 说明 |
|---|---|
| 减少内存分配 | Token结构体用栈分配,不要用malloc |
| 字符串拷贝 | 可以用指针+长度代替strcpy,减少拷贝 |
| 预分配缓冲区 | 如果知道最大Token长度,用固定数组 |
| 状态机优化 | 用switch-case代替if-else链 |
不过说实话,对于咱们这个课程项目,上面的实现已经够用了。先把功能跑通,优化是后面的事。
好,Tokenizer就讲到这里。下一章咱们要进入Parser阶段,把Token流变成语法树。嗯,那才是真正有意思的部分。