4. 语法分析器实现:上下文无关文法、递归下降解析、AST构建、错误恢复

好,咱们继续。上一章我们把词法分析器搞定了,它能吐出一个个的 Token 流。但 Token 流就像一堆散落的零件,你得把它们组装成有意义的句子。这就是语法分析器要干的事。

说白了,语法分析就是检查这些 Token 的顺序,是否符合我们定义的“语法规则”。我刚开始接触这块时,觉得这东西很玄乎,后来发现,其实就是把自然语言里的“主谓宾”结构,翻译成计算机能理解的“表达式、语句、程序”结构。

4.1 上下文无关文法:G代码的“语法书”

要检查语法,首先得有一本“语法书”。在编译原理里,这本书就叫上下文无关文法(Context-Free Grammar, CFG)。为什么叫“上下文无关”?因为一个语法结构的合法性,只取决于它自身的构成,跟它出现在哪里没关系。

举个例子,一个 G01 指令,不管它是在程序开头还是结尾,它的语法结构都是固定的:

G01 X100.5 Y200.3 F500

它不会因为前面有个 M03 就变成别的意思。这就是“上下文无关”的核心思想。

我习惯用 BNF(巴科斯范式) 来描述 G 代码的语法。比如,一个简单的直线插补指令可以定义为:

<直线插补> ::= "G01" <坐标值>+ <进给率>?
<坐标值> ::= <X轴坐标> | <Y轴坐标> | <Z轴坐标>
<X轴坐标> ::= "X" <数字>
<进给率> ::= "F" <数字>

你看,这个定义非常清晰。它告诉我们:一个合法的直线插补,必须以 G01 开头,后面跟着至少一个坐标值,进给率是可选的。

核心要点: 上下文无关文法是我们构建语法分析器的蓝图。它用一套递归的规则,定义了什么样的 Token 序列是合法的程序。

4.2 递归下降解析:最直观的解析方式

有了文法,怎么实现解析器?我个人最喜欢,也最推荐初学者上手的方式,就是递归下降解析(Recursive Descent Parsing)。

为什么?因为它非常直观。你几乎可以把 BNF 文法直接翻译成代码。每个非终结符(比如 <直线插补>)都对应一个函数。函数内部,就按照文法规则,依次调用其他函数或匹配 Token。

来看看代码,你就明白了:

// 伪代码:解析一个直线插补指令
bool parseLinearInterpolation() {
    // 规则:<直线插补> ::= "G01" <坐标值>+ <进给率>?
    
    // 1. 匹配 "G01"
    if (!matchToken("G01")) {
        error("期望 G01,但得到了 " + currentToken);
        return false;
    }
    
    // 2. 解析至少一个坐标值
    if (!parseCoordinate()) {
        error("G01 后至少需要一个坐标值");
        return false;
    }
    // 循环解析更多的坐标值
    while (parseCoordinate()) {
        // 继续解析
    }
    
    // 3. 可选地解析进给率
    if (peekToken() == "F") {
        parseFeedRate();
    }
    
    return true;
}

// 解析一个坐标值
bool parseCoordinate() {
    if (peekToken() == "X" || peekToken() == "Y" || peekToken() == "Z") {
        advanceToken(); // 消费掉 X/Y/Z
        if (!matchToken(NUMBER)) {
            error("坐标值后需要数字");
            return false;
        }
        return true;
    }
    return false; // 不是坐标值,返回 false
}

你看,代码结构和 BNF 规则几乎一一对应。这就是递归下降的魅力——文法即代码。

我的小技巧: 写递归下降解析器时,每个函数只做一件事。比如 parseCoordinate() 只负责解析一个坐标,不要在里面混入进给率的逻辑。这样调试起来会轻松很多。

4.3 AST构建:从线性到树形

解析器不能只检查语法对不对,它还得把解析结果保存下来,供后续的语义分析和代码生成使用。这个保存的结构,就是抽象语法树(Abstract Syntax Tree, AST)。

AST 和 Token 流最大的区别是:Token 流是线性的,而 AST 是树形的。它去掉了语法中不必要的细节(比如分号、括号等),只保留程序的核心逻辑结构。

比如,对于这段 G 代码:

G01 X100 Y200
G02 X300 Y200 I100 J0

它的 AST 大概长这样:

程序 直线插补 (G01) X: 100 Y: 200 F: 无 圆弧插补 (G02) X: 300 Y: 200 I: 100 J: 0

构建 AST 的过程,就是在递归下降解析的同时,创建对应的节点对象。每个 parseXxx() 函数,都返回一个代表该语法结构的 AST 节点。

// 构建 AST 节点
AstNode* parseLinearInterpolation() {
    // 创建一个直线插补节点
    AstNode* node = createNode(NODE_LINEAR_INTERP);
    
    matchToken("G01"); // 消费 G01
    
    // 解析坐标,并添加到子节点列表
    while (AstNode* coord = parseCoordinate()) {
        addChild(node, coord);
    }
    
    // 解析可选的进给率
    if (peekToken() == "F") {
        AstNode* feed = parseFeedRate();
        addChild(node, feed);
    }
    
    return node;
}

我曾经踩过的坑: 一开始我图省事,把 AST 节点设计得特别“重”,里面塞满了各种临时信息。结果后期维护时,一个节点改了,十几个地方都要跟着改。后来我学乖了,AST 节点只保留最核心的结构信息,具体数值计算放到后面的语义分析阶段去做。

4.4 错误恢复:让解析器更“皮实”

解析器在实际使用中,一定会遇到错误的 G 代码。如果一遇到错误就崩溃退出,那用户体验就太差了。好的解析器应该具备错误恢复(Error Recovery)能力——发现错误后,能尽量跳过错误区域,继续解析后面的内容,争取一次性报出所有错误。

我常用的策略是恐慌模式(Panic Mode)。具体做法是:当解析器遇到无法处理的 Token 时,就疯狂地向前消费 Token,直到遇到一个“同步标记”(Synchronizing Token),比如行尾 \n 或者下一个指令的关键字 G、M 等。

举个例子:

// 错误恢复示例
void parseBlock() {
    while (currentToken != EOF) {
        // 尝试解析一个语句
        if (!parseStatement()) {
            // 解析失败!进入恐慌模式
            error("语法错误,跳过当前行");
            
            // 疯狂消费 Token,直到遇到行尾或下一个指令
            while (currentToken != "\n" && 
                   currentToken != "G" && 
                   currentToken != "M" && 
                   currentToken != EOF) {
                advanceToken();
            }
            
            // 如果停在行尾,再消费掉它
            if (currentToken == "\n") {
                advanceToken();
            }
        }
    }
}

这样做的好处是,即使中间有一行代码写错了,解析器也能继续检查后面的代码。你想想看,对于一个几百行的 G 代码程序,如果因为第一行有个拼写错误,就导致后面所有语法检查都停了,那得多耽误事。

我的建议: 错误恢复的“同步标记”选择很关键。选得太少,恢复慢;选得太多,容易误跳过有效代码。我个人习惯把 G 代码的指令关键字(G、M、T、S 等)和行尾符作为主要的同步标记。这个需要根据你实际解析的 G 代码风格来调整。

嗯,到这里,语法分析器的核心部分就讲完了。从上下文无关文法,到递归下降解析,再到 AST 构建和错误恢复,每一步都有它存在的道理。下一节,我们会把这些 AST 节点串起来,看看如何构建一个完整的程序结构树。