第4章:词法分析器设计——Token定义、关键字识别、标识符处理、数字字面量解析
好,咱们今天聊聊词法分析器。说实话,这是整个类型检查器的第一道关卡。你想想看,编译器拿到源代码,第一件事就是把它拆成一个个有意义的“单词”——也就是Token。如果这一步做不好,后面语法分析、类型检查全得崩。
我个人习惯把词法分析器比作“翻译官”。它把程序员写的字符串,翻译成编译器能理解的Token流。嗯,这个比喻虽然简单,但很贴切。
4.1 Token的定义——给每个单词贴标签
Token是什么?说白了就是“类型+值”的组合。比如你写了一个 reg [7:0] data;,词法分析器会把它拆成:
reg→ 关键字Token[→ 左方括号Token7→ 数字字面量Token:→ 冒号Token0→ 数字字面量Token]→ 右方括号Tokendata→ 标识符Token;→ 分号Token
我在项目中遇到过一个问题:有人把Token定义得太细,结果语法分析器要处理几十种Token类型,代码变得又臭又长。我的建议是——够用就好。对于硬件描述语言,我们通常只需要这些:
| Token类型 | 示例 | 说明 |
|---|---|---|
| 关键字 | module, input, output, reg, wire, always, assign | 语言保留字,不可用作标识符 |
| 标识符 | clk, data_bus, counter_8bit | 用户自定义的名称 |
| 数字字面量 | 8'hFF, 42, 3'b101 | 整数、进制数、带位宽的数 |
| 运算符 | +, -, &&, ||, ~ | 算术、逻辑、位运算 |
| 分隔符 | (, ), [, ], ;, , | 结构分隔 |
| 注释 | // 单行, /* 多行 */ | 词法分析时直接丢弃 |
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)。
input 和 output,在端口列表里是关键字,但在某些上下文里可能被误解析。我的建议是——严格按关键字表处理,不要搞特殊化,否则后面语法分析会乱套。
4.3 标识符处理——名字的规矩
标识符就是用户给变量、模块、信号起的名字。硬件描述语言的标识符规则通常是这样:
- 以字母或下划线开头
- 后面可以跟字母、数字、下划线
- 区分大小写(Verilog区分,VHDL不区分)
- 不能和关键字重名
处理标识符时,我习惯用状态机。嗯,状态机这东西在词法分析里特别好用。你看:
状态: START
遇到字母或下划线 → 进入 IDENTIFIER 状态
其他字符 → 按其他Token处理
状态: IDENTIFIER
遇到字母、数字、下划线 → 继续收集字符
遇到其他字符 → 结束,返回Token
我在项目中遇到过一个问题:有人写的标识符特别长,比如 very_long_signal_name_for_debugging_purpose。词法分析器如果没做长度限制,可能会被恶意输入搞崩。我一般限制标识符长度不超过1024个字符,超过就报错。
_ 作为标识符开头,结果和系统内部变量冲突了。后来我规定:以下划线开头的标识符保留给编译器内部使用,用户不能用。这个规矩帮我省了不少麻烦。
4.4 数字字面量解析——进制、位宽、数值
硬件描述语言的数字字面量比普通编程语言复杂得多。你想想看,Verilog里一个数字可以写成 8'hFF、42、3'b101,甚至 'hFF(不指定位宽)。解析起来确实有点头疼。
数字字面量的通用格式是:
[位宽]'[进制][数值]
位宽: 十进制整数(可选)
进制: b(二进制), o(八进制), d(十进制), h(十六进制)
数值: 对应进制的数字序列
解析步骤我一般这样处理:
- 先看有没有数字开头 → 可能是位宽或直接是数值
- 如果遇到
'符号 → 进入进制解析模式 - 根据进制符号读取对应进制的数字
- 如果没有
'→ 默认是十进制 - 计算最终数值,同时记录位宽信息
// 数字解析核心逻辑
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);
}
x 和 z 值。硬件描述语言里,8'hx 表示所有位都是未知态,8'bz 表示高阻态。我建议在数字字面量里单独用一个标志位记录是否有 x 或 z,这样类型检查器后面做常量折叠时能正确处理。
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传给语法分析器,结果语法分析器还得再过滤一遍,纯属浪费。我的做法是——在词法分析阶段就把注释扔掉,眼不见心不烦。
4.6 知识体系总览
下面这张图,是我自己总结的词法分析器知识体系。你看一眼,心里就有数了:
这张图把词法分析器的四个核心模块串起来了。你写代码的时候,就按这个结构来,不会乱。
好了,词法分析器这部分就聊到这儿。记住一句话:词法分析做得好,后面解析没烦恼。把Token定义清楚,关键字识别准确,标识符处理规范,数字解析严谨——你的类型检查器就已经成功了一半。
公众号:蓝海资料掘金营,微信deep3321