4. 语法分析基础:上下文无关文法、产生式、推导与归约

好,咱们进入语法分析这个环节。说实话,词法分析再复杂,也就是个“认字”的过程。但语法分析不一样,它要“懂句子”。你写了一大段 Verilog 代码,编译器怎么知道 always @(posedge clk) 后面该跟什么?怎么判断你漏了个 end

嗯,这背后全靠一套严谨的数学工具——上下文无关文法。我个人觉得,搞懂这个,才算真正摸到了编译器的门槛。

4.1 为什么是“上下文无关”?

先说说这个名字。你想想看,自然语言里,“我吃苹果”和“苹果吃我”,词都一样,但意思完全反了。为什么?因为“苹果”不能当主语去“吃”人。这取决于上下文。

但 Verilog 不一样。一个 module 关键字,不管它出现在文件开头,还是嵌套在另一个模块里(虽然 Verilog 不允许嵌套),它的语法结构是固定的。它后面必须跟一个标识符,然后是一对括号,等等。它不需要知道“外面是谁”。

这就是“上下文无关”。每个语法结构的合法性,只取决于它自身的组成规则,不依赖外部环境。我在写解析器的时候,最爽的一点就是:我可以把每个语法单元独立地分析,不用担心“隔壁老王”影响了它。

核心要点: 上下文无关文法(CFG)是描述编程语言语法的最佳工具。它足够强大,能描述嵌套结构(比如 if-else 嵌套),又足够简单,能让我们用程序自动解析。

4.2 产生式:语法的“配方”

文法怎么描述规则?靠产生式。说白了,就是一条条“替换规则”。

比如,一个简单的 Verilog 模块声明,可以写成这样:

module_declaration ::= "module" identifier "(" port_list ")" ";" module_item* "endmodule"

这个 ::= 读作“定义为”。左边是一个非终结符(可以继续展开),右边是它的定义,里面可以包含终结符(比如 "module" 这个关键字,不能再拆了)和其他非终结符。

我习惯把产生式想象成乐高积木的说明书。一个“汽车”积木,说明书上写着:它由“底盘”、“车身”、“轮子”组成。而“轮子”又由“轮胎”和“轮毂”组成。这就是一层层的产生式。

在 Verilog 里,我们会有成百上千条这样的产生式。比如:

  • always_construct ::= "always" statement
  • statement ::= blocking_assignment | nonblocking_assignment | if_statement | case_statement | ...
  • if_statement ::= "if" "(" expression ")" statement ("else" statement)?

你看,statement 可以展开成很多种可能。这就是语法的“分支”。

4.3 推导:从“开始”到“句子”

有了产生式,我们怎么生成一段合法的代码?这个过程叫推导

我们从文法的开始符号(比如 source_text)出发,不断用产生式的右边替换左边的非终结符,直到全部变成终结符(也就是具体的 token)。

举个例子,假设我们有这么几条极简的产生式:

E ::= E "+" E
E ::= "id"

我们要推导出 id + id。过程如下:

  1. E 开始。
  2. 使用第一条产生式:E 替换为 E "+" E
  3. 现在我们有 E "+" E。左边的 E 使用第二条产生式,替换为 "id"
  4. 得到 "id" "+" E
  5. 右边的 E 同样替换为 "id"
  6. 最终得到 "id" "+" "id"

这个过程,就是最左推导(每次都替换最左边的非终结符)。你写代码的时候,其实就是在做推导——只不过你脑子里想的是“我要写一个加法表达式”,然后你写下了 a + b

我的经验: 调试解析器时,我经常手动做推导,来验证我的产生式是不是写对了。特别是遇到复杂的嵌套结构,比如多层 if-else,手动推导一遍,能发现很多隐藏的歧义。

4.4 归约:推导的逆过程

推导是生成代码,那归约就是解析代码。它是推导的逆过程。

编译器拿到一串 token(比如 id, +, id),它要反向操作:把右边的符号组合起来,替换成左边的非终结符,直到归约回开始符号。

还是上面那个例子。解析器看到 id + id

  1. 它看到第一个 id,发现可以归约为 E(因为 E ::= "id")。
  2. 现在栈里有 E, +, id
  3. 它看到第二个 id,同样归约为 E
  4. 现在栈里有 E, +, E
  5. 它发现这匹配 E ::= E "+" E,于是把这三个符号归约为一个 E
  6. 归约完成!

这就是一个移进-归约的过程。解析器一边读 token(移进),一边看能不能把栈顶的几个符号归约成一个非终结符。

避坑指南: 我曾经在写一个表达式解析器时,遇到了“移进-归约冲突”。就是解析器不知道该先移进下一个 token,还是先把栈顶的归约掉。比如 id + id + id,是先把 id + id 归约成 E,还是先移进第三个 id?这涉及到运算符的结合性。如果不处理,解析器就会报错。解决办法是给产生式定义优先级和结合性。

4.5 知识体系总览

说了这么多,咱们用一张图来总结一下这四者的关系。我画了个 SVG,帮你理清脉络。

上下文无关文法 (CFG) 产生式 定义替换规则 非终结符 → 终结符/非终结符 推导 从开始符号出发 生成句子 (代码) 归约 从句子 (token流) 出发 反向替换回开始符号 解析树 / 语法树 推导/归约的图形化表示 体现层次结构 由...组成 基于...进行 正向应用 逆过程 记录过程

这张图你看懂了吗?上下文无关文法是总纲,它由一组产生式构成。我们既可以用产生式正向推导出合法的代码,也可以反向归约代码,并在这个过程中构建出一棵解析树

嗯,说白了,语法分析就是“照着规则玩拼图”。规则(产生式)定好了,编译器就能判断你拼得对不对。

4.6 为什么这对 Verilog 解析器很重要?

你可能要问:我写 Verilog 代码,知道这些理论有什么用?

用处大了。Verilog 的语法其实挺复杂的。它有模块声明、端口列表、连续赋值、过程块(always)、各种语句(if、case、for)、表达式、运算符优先级……如果没有上下文无关文法,解析器根本没法写。

我记得有一次,我需要解析一个带参数的模块实例化。那个语法在 Verilog 标准里写得很绕。我直接照着标准里的产生式,一条条翻译成解析代码,一次就通过了。这就是理论的力量。

而且,当你理解了推导和归约,你就能明白为什么编译器会报“语法错误”。它其实就是归约到某一步,发现栈顶的符号没法匹配任何产生式的右边了。这时候,它就知道“出错了”。

一句话总结: 上下文无关文法是语法的“宪法”,产生式是“法律条文”,推导是“立法过程”,归约是“执法过程”。搞懂这四者,你就掌握了语法分析的命脉。

专注资料整理