第4章:词法分析器设计——Token定义、关键字识别、标识符处理、数字字面量解析

好,咱们今天聊聊词法分析器。说实话,这是整个类型检查器的第一道关卡。你想想看,编译器拿到源代码,第一件事就是把它拆成一个个有意义的“单词”——也就是Token。如果这一步做不好,后面语法分析、类型检查全得崩。

我个人习惯把词法分析器比作“翻译官”。它把程序员写的字符串,翻译成编译器能理解的Token流。嗯,这个比喻虽然简单,但很贴切。

4.1 Token的定义——给每个单词贴标签

Token是什么?说白了就是“类型+值”的组合。比如你写了一个 reg [7:0] data;,词法分析器会把它拆成:

  • reg → 关键字Token
  • [ → 左方括号Token
  • 7 → 数字字面量Token
  • : → 冒号Token
  • 0 → 数字字面量Token
  • ] → 右方括号Token
  • data → 标识符Token
  • ; → 分号Token

我在项目中遇到过一个问题:有人把Token定义得太细,结果语法分析器要处理几十种Token类型,代码变得又臭又长。我的建议是——够用就好。对于硬件描述语言,我们通常只需要这些:

Token类型 示例 说明
关键字 module, input, output, reg, wire, always, assign 语言保留字,不可用作标识符
标识符 clk, data_bus, counter_8bit 用户自定义的名称
数字字面量 8'hFF, 42, 3'b101 整数、进制数、带位宽的数
运算符 +, -, &&, ||, ~ 算术、逻辑、位运算
分隔符 (, ), [, ], ;, , 结构分隔
注释 // 单行, /* 多行 */ 词法分析时直接丢弃
我的小技巧: 定义Token时,用一个枚举类型把所有Token类型列出来。我习惯在枚举最后加一个 TOKEN_EOF 表示文件结束,这样解析器判断起来特别方便。

4.2 关键字识别——别让用户乱用保留字

关键字是语言的“禁词”。用户不能拿 module 当变量名,也不能用 always 当信号名。识别关键字其实很简单——先按标识符读进来,然后查表。

具体做法是这样的:

// 伪代码示例
Token nextToken() {
    skipWhitespaceAndComments();
    
    if (isLetter(currentChar)) {
        string word = readIdentifier();
        
        // 查关键字表
        if (keywordTable.contains(word)) {
            return Token(KEYWORD, word);
        } else {
            return Token(IDENTIFIER, word);
        }
    }
    
    // ... 其他处理
}

这里有个坑,我曾经踩过:关键字表一定要用哈希表或者有序数组,别用线性查找。你想想看,一个文件里可能有几千个标识符,每个都去线性查一遍,性能直接崩了。我一般用 std::unordered_map 或者自己写个完美哈希,查找时间基本是O(1)。

注意: 硬件描述语言里有些关键字是上下文相关的。比如Verilog里的 inputoutput,在端口列表里是关键字,但在某些上下文里可能被误解析。我的建议是——严格按关键字表处理,不要搞特殊化,否则后面语法分析会乱套。

4.3 标识符处理——名字的规矩

标识符就是用户给变量、模块、信号起的名字。硬件描述语言的标识符规则通常是这样:

  • 以字母或下划线开头
  • 后面可以跟字母、数字、下划线
  • 区分大小写(Verilog区分,VHDL不区分)
  • 不能和关键字重名

处理标识符时,我习惯用状态机。嗯,状态机这东西在词法分析里特别好用。你看:

状态: START
  遇到字母或下划线 → 进入 IDENTIFIER 状态
  其他字符 → 按其他Token处理

状态: IDENTIFIER
  遇到字母、数字、下划线 → 继续收集字符
  遇到其他字符 → 结束,返回Token

我在项目中遇到过一个问题:有人写的标识符特别长,比如 very_long_signal_name_for_debugging_purpose。词法分析器如果没做长度限制,可能会被恶意输入搞崩。我一般限制标识符长度不超过1024个字符,超过就报错。

避坑指南: 我曾经遇到一个案例,用户用 _ 作为标识符开头,结果和系统内部变量冲突了。后来我规定:以下划线开头的标识符保留给编译器内部使用,用户不能用。这个规矩帮我省了不少麻烦。

4.4 数字字面量解析——进制、位宽、数值

硬件描述语言的数字字面量比普通编程语言复杂得多。你想想看,Verilog里一个数字可以写成 8'hFF423'b101,甚至 'hFF(不指定位宽)。解析起来确实有点头疼。

数字字面量的通用格式是:

[位宽]'[进制][数值]

位宽: 十进制整数(可选)
进制: b(二进制), o(八进制), d(十进制), h(十六进制)
数值: 对应进制的数字序列

解析步骤我一般这样处理:

  1. 先看有没有数字开头 → 可能是位宽或直接是数值
  2. 如果遇到 ' 符号 → 进入进制解析模式
  3. 根据进制符号读取对应进制的数字
  4. 如果没有 ' → 默认是十进制
  5. 计算最终数值,同时记录位宽信息
// 数字解析核心逻辑
NumberLiteral parseNumber() {
    int bitWidth = 0;
    int base = 10;
    string digits;
    
    // 尝试读取位宽
    if (isDigit(currentChar)) {
        bitWidth = readDecimalNumber();
    }
    
    // 检查进制前缀
    if (currentChar == '\'') {
        advance();
        base = parseBaseChar(currentChar);  // 'b'=2, 'o'=8, 'd'=10, 'h'=16
        advance();
        digits = readDigitsForBase(base);
    } else {
        // 没有进制前缀,整个数字就是十进制
        digits = readDecimalDigits();
    }
    
    return NumberLiteral(bitWidth, base, digits);
}
我的经验: 数字解析时最容易出错的是 xz 值。硬件描述语言里,8'hx 表示所有位都是未知态,8'bz 表示高阻态。我建议在数字字面量里单独用一个标志位记录是否有 xz,这样类型检查器后面做常量折叠时能正确处理。

4.5 词法分析器的整体流程

好了,我们把上面这些串起来。一个完整的词法分析器,它的核心逻辑是这样的:

while (currentChar != EOF) {
    skipWhitespaceAndComments();
    
    if (isLetter(currentChar) || currentChar == '_') {
        // 标识符或关键字
        string word = readIdentifier();
        TokenType type = keywordTable.contains(word) ? KEYWORD : IDENTIFIER;
        tokens.push_back(Token(type, word));
    }
    else if (isDigit(currentChar) || currentChar == '\'') {
        // 数字字面量
        NumberLiteral num = parseNumber();
        tokens.push_back(Token(NUMBER_LITERAL, num));
    }
    else if (isOperator(currentChar)) {
        // 运算符(可能有多字符运算符如 <=, >=)
        tokens.push_back(parseOperator());
    }
    else if (isDelimiter(currentChar)) {
        // 分隔符
        tokens.push_back(Token(DELIMITER, string(1, currentChar)));
        advance();
    }
    else {
        // 非法字符
        reportError("非法字符: " + currentChar);
    }
}

这个流程看起来简单,但实际实现时有很多细节。比如注释处理,我见过有人把注释当成Token传给语法分析器,结果语法分析器还得再过滤一遍,纯属浪费。我的做法是——在词法分析阶段就把注释扔掉,眼不见心不烦。

核心要点: 词法分析器的目标只有一个——把源代码字符串变成Token流。不要在这个阶段做任何语义分析,比如不要判断变量是否声明过,不要检查类型是否匹配。这些是后面语法分析和类型检查的事。各司其职,代码才能清晰。

4.6 知识体系总览

下面这张图,是我自己总结的词法分析器知识体系。你看一眼,心里就有数了:

词法分析器知识体系 词法分析器 Token定义 类型 + 值 枚举定义 关键字/标识符/数字/运算符/分隔符 关键字识别 哈希表查找 先读标识符 再查关键字表 标识符处理 字母/下划线开头 状态机解析 长度限制 数字字面量 位宽'进制数值 x/z值处理 默认十进制 核心目标:源代码 → Token流 不做语义分析,只做词法拆分 ✅ 注释直接丢弃 ⚠️ 关键字不可作标识符 🔢 数字支持多进制

这张图把词法分析器的四个核心模块串起来了。你写代码的时候,就按这个结构来,不会乱。

好了,词法分析器这部分就聊到这儿。记住一句话:词法分析做得好,后面解析没烦恼。把Token定义清楚,关键字识别准确,标识符处理规范,数字解析严谨——你的类型检查器就已经成功了一半。


公众号:蓝海资料掘金营,微信deep3321