3. 词法分析器实现:手写一个Verilog词法分析器

好,咱们直接进入正题。词法分析器,说白了就是编译器的“眼睛”。它负责把Verilog源码那长长的一串字符,拆成一个个有意义的“单词”——也就是Token。我当年第一次写这东西的时候,觉得不就是字符串匹配嘛,有啥难的?结果被一堆边界条件折磨得够呛。今天咱们就手撸一个简单的,把坑都踩一遍。

3.1 词法分析器到底在干啥?

先看个例子:

module counter(input clk, output reg [3:0] q);

人眼一看就知道:module是关键字,counter是标识符,input是关键字,clk是标识符……但计算机不懂啊。词法分析器的工作就是把这些“单词”一个个识别出来,并打上标签。

我习惯把词法分析器想象成一个流水线工人:

  • 从字符流里一个一个读字符
  • 根据当前字符决定下一步怎么走
  • 凑成一个完整的Token后,打包送出去
  • 然后继续读下一个

核心原则:最长匹配。比如 input 不能只匹配到 in 就停,必须读完整个关键字。

3.2 整体架构:状态机思维

词法分析器本质上是一个有限状态自动机(FSA)。你想想看,每读一个字符,状态就变一次。我画了一张图,帮你理清思路:

词法分析器状态机核心流程 起始状态 读字符 判断 字母/下划线 标识符/关键字 数字 数字字面量 // 或 /* 注释 其他符号 运算符/分隔符 输出Token 继续

这张图看着简单,但实际写代码时,每个分支里还有子状态。比如数字字面量,你得区分十进制、十六进制、二进制……嗯,咱们后面细说。

3.3 关键字与标识符:最基础的部分

Verilog的关键字大概有100多个,比如 moduleendmoduleinputoutputwireregalwaysassign 等等。识别逻辑其实很简单:

  1. 如果当前字符是字母或下划线,开始收集
  2. 持续收集直到遇到非字母、非数字、非下划线的字符
  3. 查表:如果收集到的字符串在关键字表里,就是关键字;否则就是标识符

我的小技巧:关键字表用哈希表存,查找O(1)。我早期用线性查找,编译大文件时明显感觉慢。后来改成哈希表,速度提升很明显。

看一段核心代码(C语言风格伪代码):

// 识别标识符或关键字
if (is_alpha(ch) || ch == '_') {
    char buf[256];
    int i = 0;
    buf[i++] = ch;
    while (is_alnum(ch = next_char()) || ch == '_') {
        buf[i++] = ch;
    }
    buf[i] = '\0';
    unget_char(ch);  // 多读的字符还回去
    
    // 查关键字表
    TokenType type = lookup_keyword(buf);
    if (type == TOK_KEYWORD) {
        return make_token(TOK_KEYWORD, buf);
    } else {
        return make_token(TOK_IDENTIFIER, buf);
    }
}

这里有个细节:回退字符。你读到了非标识符字符,说明标识符结束了,但那个字符属于下一个Token,得“还回去”。我刚开始写的时候忘了这茬,结果下一个Token的第一个字符丢了,调试了半天。

3.4 数字字面量:坑最多的地方

Verilog的数字表示比C语言复杂多了。它支持:

格式 示例 说明
十进制 123 普通整数
带位宽 8'd255 8位十进制数255
十六进制 16'hFF00 16位十六进制数
二进制 4'b1010 4位二进制数
八进制 12'o777 12位八进制数
实数 3.14159 浮点数

解析带位宽的数字时,格式是 [位宽]'[进制][数值]。比如 8'd255

  • 先读到 8,别急着当数字处理
  • 看到 ',知道后面跟进制标识
  • d 表示十进制,h 表示十六进制,b 表示二进制,o 表示八进制
  • 然后解析后面的数值部分

我曾经踩过的坑:数字里允许出现下划线作为分隔符,比如 8'd255 也可以写成 8'd2_55。下划线要忽略掉,但不能把它当成非法字符。还有,'d 前面的位宽可以省略,比如 'd255 也是合法的。这些边界情况,测试用例一定要覆盖全。

3.5 字符串与注释

字符串:以双引号开头,遇到下一个双引号结束。中间支持转义字符,比如 \n\t\\\" 等。我习惯用一个标志位记录是否在转义模式中。

注释:Verilog有两种注释:

  • 单行注释:// 开头,读到换行符结束
  • 多行注释:/* 开头,*/ 结束

多行注释有个坑:注释不能嵌套。比如 /* /* */,第一个 */ 就结束了注释,后面的 */ 就成了非法字符。我见过有人写嵌套注释然后编译报错,一脸懵。

3.6 运算符与分隔符

Verilog的运算符有单字符的(如 +-*),也有多字符的(如 <=>===!=&&||)。解析时要遵循最长匹配原则:

// 伪代码示例
if (ch == '&') {
    char next = peek_char();
    if (next == '&') {
        consume();  // 吃掉第二个 &
        return make_token(TOK_LOGICAL_AND, "&&");
    } else if (next == '=') {
        // &=
    } else {
        return make_token(TOK_BITWISE_AND, "&");
    }
}

分隔符包括括号、花括号、方括号、分号、逗号、点号等。这些比较简单,每个字符对应一个Token就行。

3.7 实战建议:从简单到复杂

如果你要自己实现一个词法分析器,我建议按这个顺序来:

  1. 先搞定标识符和关键字(最简单)
  2. 再处理数字字面量(注意各种进制)
  3. 然后处理运算符和分隔符
  4. 最后处理字符串和注释
  5. 别忘了空白字符和换行——直接跳过就行

测试驱动开发:每写完一个模块,立刻写测试用例。我习惯准备一个小的Verilog文件,里面包含所有语法元素,然后跑一遍看输出对不对。特别是边界情况,比如空文件、只有注释的文件、数字后面紧跟标识符的情况。

嗯,词法分析器这部分就讲到这里。记住一个原则:读一个字符,判断,决定下一步。状态机思维贯穿始终。代码写完后,多跑几个真实的设计文件,你会发现很多意想不到的情况——这就是经验积累的过程。