词法分析器(Lexer)基础:从字符流到Token流
大家好,我是你们的讲师。今天咱们聊聊编译器里最基础、也最实在的一个模块——词法分析器,也就是Lexer。
说实话,我刚开始接触硬件描述语言编译器时,觉得词法分析这步挺简单的。不就是把代码拆成单词嘛?后来真动手写起来才发现,坑不少。嗯,咱们今天就把这块地基打牢。
Token的定义与分类
先问个问题:你写了一段Verilog代码,计算机怎么理解它?
答案很简单——它不认识。计算机只认识0和1。所以我们需要一个翻译官,把人类写的文本转成机器能处理的结构化数据。这个结构化数据,就是Token。
Token是什么?说白了,就是源代码里最小的、有意义的语法单元。比如关键字module、运算符+、分号;,这些都是Token。
我在项目中遇到过一种情况:有人把endmodule写成了end_module。Lexer不认识这个Token,直接报错。你想想看,这种低级错误要是等到综合阶段才发现,那得多浪费时间。
Token的分类,我一般分成这几类:
| Token类型 | 示例 | 说明 |
|---|---|---|
| 关键字 | module, input, output, wire, reg | 语言保留字,有特殊含义 |
| 标识符 | clk, rst_n, data_bus | 用户自定义的名称 |
| 运算符 | +, -, &, |, ~ | 各种运算符号 |
| 分隔符 | ;, (, ), {, } | 用来分隔代码结构 |
| 字面量 | 8'hFF, 32'd100, "hello" | 数字、字符串等常量 |
| 注释 | // 单行注释, /* 块注释 */ | 通常被Lexer丢弃 |
核心要点:每个Token通常包含两个信息——Token类型和Token值。比如module这个Token,类型是KEYWORD,值是"module"。
正则表达式基础
好,现在我们知道要识别什么了。那怎么描述这些Token的规则呢?正则表达式就是干这个的。
正则表达式,说白了就是一种模式匹配的语言。你给它一个模式,它告诉你字符串是否符合这个模式。
举个例子,标识符的规则是什么?在Verilog里,标识符必须以字母或下划线开头,后面可以跟字母、数字、下划线。用正则表达出来就是:
[a-zA-Z_][a-zA-Z0-9_]*
这个表达式什么意思?[a-zA-Z_]表示第一个字符必须是字母或下划线,[a-zA-Z0-9_]*表示后面可以跟零个或多个字母、数字、下划线。
我个人习惯把常用的正则表达式记在脑子里:
- 数字字面量:
[0-9]+'[bBdDhHoO][0-9a-fA-F_]+—— 这是Verilog特有的基数表示法 - 字符串:
"[^"]*"—— 双引号括起来的任意字符 - 单行注释:
//[^\n]*—— 从//到行尾 - 块注释:
/\*[\s\S]*?\*/—— 注意这里用了非贪婪匹配
小技巧:写正则的时候,记得考虑边界情况。我曾经因为没处理好数字里的下划线(比如8'hFF_AA),导致Lexer把FF_AA当成标识符了。这种bug特别隐蔽。
有限状态自动机(FSA)原理
正则表达式描述的是规则,但计算机怎么执行这些规则呢?这就轮到有限状态自动机登场了。
有限状态自动机,简称FSA。名字听着高大上,其实原理很简单:
- 你有一组状态(State)
- 你有一个当前状态
- 每读入一个字符,根据当前状态和字符,决定下一个状态
- 如果最终停在某个接受状态,说明匹配成功
我画了一张图,帮你理解这个过程:
你看,这个自动机只有3个状态,但已经能处理标识符的识别了。实际项目中的FSA会复杂得多,但原理是一样的。
注意:FSA分为确定性(DFA)和非确定性(NFA)两种。DFA每个输入只有一个确定的下一个状态,实现起来简单高效。NFA则可能有多个选择,需要回溯。实际编译器里,我们通常把NFA转成DFA再实现。
从字符流到Token流的转换过程
好了,理论讲完了。咱们看看实际怎么干。
整个转换过程,我习惯分成三步:
- 读取字符:从源文件里一个一个读字符
- 状态转移:根据当前状态和读入的字符,决定下一步
- 生成Token:当识别出一个完整的Token时,把它加入Token流
给你看一段伪代码,这是我当年写第一个Lexer时的核心逻辑:
function lex(source_code):
tokens = []
pos = 0
while pos < len(source_code):
ch = source_code[pos]
// 跳过空白和注释
if is_whitespace(ch):
pos += 1
continue
if ch == '/' and source_code[pos+1] == '/':
skip_line_comment()
continue
// 识别关键字或标识符
if is_letter(ch) or ch == '_':
start = pos
while is_letter(source_code[pos]) or is_digit(source_code[pos]) or source_code[pos] == '_':
pos += 1
word = source_code[start:pos]
if is_keyword(word):
tokens.append(Token(KEYWORD, word))
else:
tokens.append(Token(IDENTIFIER, word))
continue
// 识别数字
if is_digit(ch):
// 处理数字字面量,包括基数表示法
...
// 识别运算符和分隔符
if ch in operators:
tokens.append(Token(OPERATOR, ch))
pos += 1
continue
// 遇到不认识的东西
error("非法字符: " + ch)
pos += 1
return tokens
这段代码看着简单,但实际写起来要注意很多细节。我记得有一次,我处理Verilog的always块时,忘了考虑@(posedge clk)里的括号匹配,结果Lexer把posedge当成了标识符的一部分。这种问题,调试起来特别头疼。
实战建议:写Lexer的时候,一定要先画状态图。哪怕只是草稿,也能帮你理清思路。我见过太多人上来就写代码,结果状态转移逻辑一团糟。
最后说一个我踩过的坑:最长匹配原则。什么意思呢?比如module这个字符串,如果Lexer读到mod就停下来,那它会被识别成标识符,而不是关键字。正确的做法是,尽可能多地读字符,直到不能再匹配为止。这就是最长匹配。
嗯,词法分析器的基础就这些。记住,Lexer是编译器的第一道关卡,它做得好不好,直接影响后面所有阶段。别小看它。