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;

个人习惯:我一般会把value和num_val都存着。value用来调试输出,num_val用来后续计算。别省这个空间,调试的时候你会感谢我的。

3.2 Tokenizer的核心流程

Tokenizer的逻辑其实不复杂,就是逐个字符扫描,根据字符决定下一步。我画了个流程图,你看一眼就明白了:

开始扫描字符 读取当前字符 ch ch 是什么? 空格/制表符 跳过,继续扫描 回到读取字符 字母(G/M/X/Y...) 生成 ADDRESS 或 G/M_CODE 继续扫描下一个字符 数字/小数点 生成 NUMBER Token 继续扫描 注释 ( ... ) 跳过注释内容

这个流程其实很直观。我当年第一次写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;
}

我曾经踩过的坑:注释处理时,如果G代码文件里注释没闭合(只有左括号没有右括号),程序会一直扫描到字符串末尾。后来我加了边界检查,遇到\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代码用;表示注释。我建议也支持一下,加个判断就行。

我的建议:在Tokenizer阶段,尽量保留原始信息,不要做太多语义处理。比如注释里的内容,虽然解析器用不到,但调试时很有用。我一般会把注释原样保留在Token里。

3.6 性能考量

Tokenizer的性能其实不用太担心,G代码文件一般不大。但如果你要处理超大文件(比如几十万行的3D打印G代码),有几点可以优化:

优化点 说明
减少内存分配 Token结构体用栈分配,不要用malloc
字符串拷贝 可以用指针+长度代替strcpy,减少拷贝
预分配缓冲区 如果知道最大Token长度,用固定数组
状态机优化 用switch-case代替if-else链

不过说实话,对于咱们这个课程项目,上面的实现已经够用了。先把功能跑通,优化是后面的事。

好,Tokenizer就讲到这里。下一章咱们要进入Parser阶段,把Token流变成语法树。嗯,那才是真正有意思的部分。