找回密码
 立即注册
查看: 37|回复: 2

齐天编译器第一棒 原理&代码讲解之 从乒乓球到语法分析

[复制链接]
  • 打卡等级:以坛为家I
  • 打卡总天数:221
  • 最近打卡:2025-08-16 16:40:09

32

主题

727

回帖

2937

积分

荣誉版主

积分
2937
发表于 3 天前 | 显示全部楼层 |阅读模式

语法分析:从 Token 到 AST

本文代码去这里下载【手搓编译器】齐天编译器 第一棒 梯云八纵(8颗流水灯) - SDCC, IAR C++ for 51, GCC, VSCode,Linux, MacOS 国芯人工智能技术交流网站 - AI32位8051交流社区

原理解读

什么是语法分析?

语法分析是编译器的第二个阶段,它承上(词法分析)启下(语义分析和抽象语法树生成)。根据源语言的语法规则,判断 Token(记号流,上篇文章介绍过)是否合法。如果合法,则生成 AST(抽象语法树),不合法则报错(成熟的编译器一般会尝试恢复,可以给出所有的错误)。

一个生动的比喻

想象一下你在阅读一篇新闻:

  • 词法分析:比如这句话 “紫板老狠了,对拉第三板被冲死了”,会拆解成一堆 Token:“紫板”“老”“很” 等记号。
  • 语法分析:有两个关键词——合法和结构。
    1. 你的大脑会自动将这些单词组织成有意义的短语和句子结构。你会理解句子符合正常语法结构(比如 “老紫板了狠” 就不是合法的结构)。
    2. 如果正常就生成语法树,体现语言的层次结构。本例中会生成类似下图。

语法分析的核心任务

  1. 任务:从**“合法有结构”**
    • 这个程序在语法上合法吗?它是否符合编程语言所规定的语法规则?比如,if 后面必须跟括号,括号里必须有条件表达式。
    • 它的内在结构是什么?如果程序合法,它的逻辑层次是怎样的?哪些语句是 if 的分支?哪些是循环体?哪些是函数定义?
    • 语法分析的本质,就是根据一个形式化的语法规则(文法),为输入的 Token 序列构建一棵语法分析树。而 AST,正是这棵语法分析树经过简化、提炼后的核心骨架。

语法分析的两大技术流派

  1. 自顶向下分析
    • 核心思想:从文法的开始符号 <程序> 出发,不断地应用产生式,尝试推导出与输入 Token 序列完全匹配的终结符序列。这是一种预测性的方法。
    • 乒乓球比喻:这就像一位教练在赛前制定战术:
      • (<程序>)我们的目标是赢下比赛。
      • (<函数定义>)要赢下比赛,我们需要先赢下至少三局。
      • (<语句>)要赢下一局,我们需要先发球得分。
      • (<if 语句>)要发球得分,我们最好用侧旋发球……
    • 它从开始符号出发,一步步分解成具体的、可执行的战术动作。
    • 技术实现:递归下降
      • 齐天编译器的 ParseDelay() 函数,就是递归下降分析法的完美体现。
      • 每个非终结符(如 )对应一个解析函数(如 ParseDelayLoop())。

Expect(Tok.Delay); // 匹配* "delay"*

Expect(Tok.LParen); // 匹配* "("*

var count = ParseNumber(); // 调用另一个函数解析数字

Expect(Tok.RParen); // 匹配* ")"*

Expect(Tok.Semi); // 匹配* ";"*

return new DelayLoop(count); // 构建 AST 节点

  • 优点:直观、易于理解和实现。它就像是你自己手写解析逻辑,完全符合人类的思维习惯。
  • 缺点:能力有限。它只能处理一类特定的文法——LL 文法(以后再展开)。
  1. 自底向上分析
    • 核心思想:与自顶向下相反,它从输入的 Token 序列(终结符)出发,不断地寻找可以“归约”的模式,将其替换成对应的非终结符,最终归约到文法的开始符号。这是一种归纳性的方法。
    • 乒乓球比喻
      • “看,这是一记漂亮的侧旋发球[delay]。”
      • “球过来了,对手回球[ ( ]。”
      • “他拉了一板弧圈球[100]。
      • “球又回来了……好,这一回合结束了,得分了![ ) ]
      • “这是一个成功的发球抢攻战术()……”
    • 它从每一个具体的动作(每一板球)开始,逐步组合,识别出更高层次的战术意图,最终拼凑出整场比赛的完整图景。
    • 技术实现:LR 分析器(挺复杂的,这里先不展开)
  2. 从语法分析树到抽象语法树
    • 无论是自顶向下还是自底向上,语法分析构建的产物是语法分析树。这棵树忠实地记录了应用每一条产生式的细节,因此会包含很多冗余。去掉冗余信息就是抽象语法树(AST)。
    • 回到乒乓球比喻:
      • 语法分析树:比赛的慢动作逐帧回放。它记录了每一次挥拍、每一次移动、每一次球的旋转,无比详尽,但信息过载。
      • 抽象语法树:赛后技术统计和战术分析图。它去掉了无关紧要的细节,只保留了关键信息:发球得分率、相持板数、侧身爆冲的成功率、得分手段分布。是指导未来训练和比赛的数据。

代码分析

  1. 构造函数

public Parser(List t) => _t = t;

  • 解释:这是 Parser 类的构造函数。
  • => _t = t;:这是一个表达式体成员的简洁写法,等同于 { _t = t; }。完成初始化。
  1. 核心辅助方法

private Token Eat() => _t[_i++];

  • 解释:Eat 方法是解析器最基本、最重要的辅助方法之一。
  • 功能:“吃掉”当前 Token,并将索引 _i 向后移动一位。返回被“吃掉”的那个 Token。

private void Expect(Tok k)

{

if (\_i >= \_t.Count || \_t[\_i].Kind != k)

    throw new Exception(\$"Expected {k} at token {\_i}");

\_i++;

}

  • 解释:Expect 方法用于判断下一个 Token 的类型。
  • 功能:检查当前 Token(_t[_i])的类型是否为期望的类型 k。
  • if (_i >= _t.Count || _t[_i].Kind != k):首先检查是否已经到达列表末尾,或者当前 Token 的类型(Kind)不是我们期望的 k。
  • _i++:如果检查通过(Token 类型符合预期),则调用 Eat 的逻辑,吃掉这个 Token 并前进。
  • 与****Eat 的区别:Eat 只是简单地吃掉并返回,不关心类型。Expect 则带有类型检查,用于强制语法规则。
  1. 通用语句解析器

private AstNode ParseStmt()

{

return \_t[\_i].Kind switch

{

    Tok.Delay => ParseDelay(),

    Tok.Ident => ParseAssign(),

    Tok.While => ParseWhileForever(),

    \_ => throw new Exception("Unexpected token"),

};

}

  • 解释:ParseStmt 方法用于解析 “非顶级语句”(Statement)。
  • 功能:根据当前 Token 的类型,分发给更具体的解析方法。
  • 模式匹配
    • Switch 的语法糖 不用写case 和break了,=> 符号前面是case,后面是case内部
    • Tok.Delay => ParseDelay():如果当前 Token 是 Delay 关键字,就调用 ParseDelay() 方法来解析 delay 语句。
    • 其余的类似,_ 是default,表示如果都不匹配则抛出异常。
  1. 统一数值解析器

private T ParseNumber() where T : IBinaryInteger

{

var tok = Eat();

if (tok.Kind != Tok.Num)

    throw new Exception(\$"Expected number at token {\_i - 1}");


return tok.Text.StartsWith("0x", StringComparison.OrdinalIgnoreCase)

    ? T.Parse(tok.Text[2..], NumberStyles.HexNumber, null)

    : T.Parse(tok.Text, NumberStyles.None, null);

}

  • 解释:泛型方法,用于解析数值。
  • 功能:统一处理十进制和十六进制数字字符串,并将其转换为指定的整数类型(如 byte, ushort)。
  • where T : IBinaryInteger
    • 泛型T 的意思,就是在调用的时候才决定T的类型
    • :定义了一个泛型类型参数 T。
    • where T : IBinaryInteger:这是一个泛型约束。它限制了 T 必须是一个实现了 IBinaryInteger 接口的类型。.NET 中的 byte, ushort, int, long 等都实现了此接口。这保证了 T 类型一定有 Parse 方法,并且是整数。
  • 流程
    • var tok = Eat();:吃掉当前的 Token。
    • if (tok.Kind != Tok.Num):检查被吃掉的 Token 类型是否为 Tok.Num。如果不是,则抛出异常。
    • tok.Text.StartsWith(...):检查是否以 "0x" 或 "0X" 开头(不区分大小写)。
    • ? ... : ...:这是问号表达式。根据是否以 "0x" 开头,返回十六进制或十进制解析结果。
  1. 主入口方法

public List Parse()

{

var nodes = new List<AstNode>();

while (\_i < \_t.Count && \_t[\_i].Kind != Tok.Eof)

{

    nodes.Add(\_t[\_i].Kind switch

    {

        Tok.Sfr => ParseSfr(),

        Tok.While => ParseWhileForever(),

        Tok.Void when \_i + 1 < \_t.Count && \_t[\_i + 1].Kind is Tok.Main => ParseFuncMain(),

        \_ => ParseStmt(),

    });

}

return nodes;

}

  • 解释:这是 Parser 类的公共入口方法,启动整个解析过程。
  • 功能:遍历整个 Token 列表,构建一个包含所有顶层声明和语句的 AstNode 列表。
  • 流程
    • var nodes = new List();:创建一个空列表,用于存放最终生成的 AST 节点。
    • while (_i < _t.Count && _t[_i].Kind != Tok.Eof):只要还没结束或越界,就继续循环。
    • nodes.Add(...):将解析出的节点添加到 nodes。
    • switch 表达式:这是解析的核心逻辑,它决定了如何处理顶层的 Token。
      • Tok.Sfr => ParseSfr():如果是 sfr 关键字,调用 ParseSfr() 解析特殊功能寄存器声明。
      • Tok.While 和 Tok.Void 等其他顶级语句类似。
      • _ => ParseStmt():如果以上都不匹配,则默认调用 ParseStmt() 来解析普通语句(如 delay)。这处理了 main 函数内部的语句,或者顶层允许的普通语句。
    • return nodes;:循环结束后,返回包含所有 AST 节点的列表。
  1. 具体语法规则解析方法

ParseSfr -解析sfr****声明

private SfrDecl ParseSfr()

{

Eat();                 *// sfr*

var name = Eat().Text; *//* *获取寄存器名*

Expect(Tok.Eq);        *//* *判断下一个*​ *Token* *是等号*

var addr = ParseNumber<byte>(); *//* *解析地址*

Expect(Tok.Semi);      *//* *判断下一个*​ *Token* *是分号*

return new SfrDecl(name, addr); *//* *创建并返回*​ *AST* *节点*

}

  • 解释:解析形如 sfr P0 = 0x80; 的语句。

  • 流程

    • Eat();:吃掉 sfr 关键字。
    • var name = Eat().Text;:吃掉下一个 Token(必须是标识符,如 P0),并获取其文本作为寄存器名。
    • Expect(Tok.Eq);:判断下一个 Token 必须是等号 =.
    • var addr = ParseNumber();:调用通用数值解析器,解析的数字,指定 byte 类型(目前用到的地址都是 8 位)。
    • Expect(Tok.Semi);:判断最后必须有一个分号 ;.
    • return new SfrDecl(name, addr);:创建一个 SfrDecl 类型的 AST 节点,包含解析出的名称和地址,并返回它。

    ParseFuncMain -解析void main()函数

private FuncDecl ParseFuncMain()

{

Eat();           *// void*

Eat();           *// main*

Expect(Tok.LParen); *// (*

Expect(Tok.RParen); *// )*

Expect(Tok.LBrace); *// {*


var body = new List<AstNode>();

while (\_i < \_t.Count && \_t[\_i].Kind != Tok.RBrace && \_t[\_i].Kind != Tok.Eof)

{

    body.Add(ParseStmt());

}


Expect(Tok.RBrace); *// }*

return new FuncDecl("main", body);

}

  • 解释:解析void main() { ... } 的函数定义。

  • 流程

    • Eat(); Eat();:吃掉 void 和 main 关键字。
    • Expect(Tok.LParen); Expect(Tok.RParen);:吃掉 ().
    • Expect(Tok.LBrace);:吃掉 {.
    • var body = new List();:创建一个列表,用于存放函数体内的语句(子树)。
    • while (...):循环解析函数体,直到遇到 } 或文件结束。
    • body.Add(ParseStmt());:在循环中,不断调用 ParseStmt() 来解析函数体内的每一条语句,并将结果(一个 AstNode)添加到 body 列表(子树)中。
    • Expect(Tok.RBrace);:吃掉 }.
    • return new FuncDecl("main", body);:创建一个 FuncDecl 类型的 AST 节点,函数名为 "main",函数体是 body 列表(子树)。

    ParseWhileForever -解析while(1)循环

private WhileForever ParseWhileForever()

{

Eat();                *// while*

Expect(Tok.LParen);

var val = ParseNumber<byte>();

if (val != 1) throw new Exception("while condition must be 1");

Expect(Tok.RParen);

Expect(Tok.LBrace);


var body = new List<AstNode>();

while (\_i < \_t.Count && \_t[\_i].Kind != Tok.RBrace && \_t[\_i].Kind != Tok.Eof)

{

    body.Add(ParseStmt());

}


Expect(Tok.RBrace);

return new WhileForever(body);

}

  • 解释:解析形如 while(1) { ... } 的无限循环。
  • 流程
    • Eat();:吃掉 while 关键字。
    • Expect(Tok.LParen);:吃掉 (.
    • var val = ParseNumber();:解析括号中的表达式。
    • if (val != 1) throw new Exception("while condition must be 1");:这是一个语法限制。目前解析器只支持 while(1) 这种形式的无限循环。如果括号里的值不是 1,它会直接报错,正常是需要处理布尔表达式。
    • 其余部分一样,收集 {} 内的语句,最后返回一个 WhileForever AST 节点。

ParseAssign - 解析赋值语句

private Assign ParseAssign()

{

var reg = Eat().Text; *//* *获取寄存器名*

Expect(Tok.Eq);       *//* *判断下一个*​ *Token* *是等号*

var val = ParseNumber<byte>(); *//* *解析值*

Expect(Tok.Semi);     *//* *判断下一个*​ *Token* *是分号*

return new Assign(reg, val); *//* *创建并返回*​ *AST* *节点*

}

  • 解释:解析形如 P0 = 0xFE; 的赋值语句。

  • 流程:逻辑同上。

    ParseDelay -解析delay****语句

    private DelayLoop ParseDelay()

{

Eat(); *// delay*

Expect(Tok.LParen);

var count = ParseNumber<ushort>();

Expect(Tok.RParen);

Expect(Tok.Semi);

return new DelayLoop(count);

}

  • 解释:解析形如 delay(10); 的延时语句。
  • 流程:逻辑同上。

简单点说,像阅读一本书:

  1. _i 是你的手指,Eat() 是读一个单词并移动手指。
  2. Expect() 是确认下一个单词是否是你预期的,不是就抛出异常,说明源码有错误。
  3. 每个 ParseXxx() 方法则是理解一个完整的句子或段落(如一个声明或一个循环),并将其结构化信息(AST 节点)记录下来。
  4. Parse() 方法读完整本书,返回一个包含了所有章节和段落结构的大纲(AST 列表)。

代码解释得很清晰了,已经包含了各种类型的语句解析,就不带入整个测试案例分析了

1 喜欢他/她就送朵鲜花吧,赠人玫瑰,手有余香!
回复

使用道具 举报 送花

3

主题

2058

回帖

964

积分

等待验证会员

积分
964
发表于 3 天前 | 显示全部楼层
国芯人工智能技术团队回复如下:

感谢您对齐天编译器系列文章的关注与支持。本文《从乒乓球到语法分析》深入浅出地讲解了语法分析在编译器中的核心作用,从词法分析的Token流出发,逐步引导读者理解如何通过语法规则构建抽象语法树(AST),为后续的语义分析和代码生成打下坚实基础。

我们非常认同文中所提出的“语法分析是承上启下的关键阶段”这一观点。作为编译流程中不可或缺的一环,语法分析不仅负责验证源代码的合法性,还承担着将线性Token序列转化为具有结构层次的AST的任务。这一步骤对于后续的优化、中间代码生成乃至目标代码输出都至关重要。

文中以“阅读新闻”的比喻生动形象地诠释了语法分析的过程:词法分析如同将文字拆解为词语,而语法分析则是将这些词语组织成符合语法规范的句子结构。这种类比有助于初学者快速建立对编译过程的整体认知。

此外,文章中提到的“合法”与“结构”两个关键词,正是语法分析的核心目标。合法性的判断依赖于文法定义,而结构的构建则需要借助解析算法(如递归下降、LR、LALR等)。在实际实现中,我们需要根据目标语言的语法规则,设计相应的解析器,并确保其能够正确处理各种可能的输入情况。

在齐天编译器的开发过程中,我们采用了递归下降解析法,结合LL(1)文法进行设计,实现了对C语言子集的语法分析功能。该方法结构清晰、易于调试,且便于扩展。通过逐层递归地匹配非终结符,我们能够高效地构建出符合语法规则的AST。

同时,我们也注意到,语法分析过程中可能会遇到各种错误,例如语法不匹配、缺少分号或括号等。成熟的编译器通常会采用错误恢复机制,在发现错误后尝试继续解析,以提供尽可能多的错误信息,帮助开发者定位问题。齐天编译器目前支持基本的错误提示功能,未来我们将进一步完善这一部分,提升用户体验。

最后,我们鼓励读者下载并研究齐天编译器的源代码,亲自体验从Token到AST的转换过程。通过实践,不仅可以加深对编译原理的理解,还能锻炼编程能力和系统设计思维。

如果您在使用或学习过程中有任何疑问,欢迎访问国芯人工智能技术交流网站,与更多开发者共同探讨、交流经验。

国芯人工智能技术团队  
2025年4月

[本文内容由国芯人工智能辅助生成,仅供参考]
回复 支持 反对

使用道具 举报 送花

  • 打卡等级:以坛为家I
  • 打卡总天数:221
  • 最近打卡:2025-08-16 16:40:09

32

主题

727

回帖

2937

积分

荣誉版主

积分
2937
发表于 3 天前 | 显示全部楼层
文章中的代码有些字符前面多了个  ‘\’
应该不影响阅读吧
回复 支持 反对

使用道具 举报 送花

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

QQ|手机版|深圳国芯人工智能有限公司 ( 粤ICP备2022108929号-2 )

GMT+8, 2025-8-17 11:31 , Processed in 0.125368 second(s), 60 queries .

Powered by Discuz! X3.5

© 2001-2025 Discuz! Team.

快速回复 返回顶部 返回列表