词法分析器(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)
  • 你有一个当前状态
  • 每读入一个字符,根据当前状态和字符,决定下一个状态
  • 如果最终停在某个接受状态,说明匹配成功

我画了一张图,帮你理解这个过程:

标识符识别的有限状态自动机 S0 起始 S1 接受 S2 错误 字母/下划线 字母/数字/下划线 其他字符 S0: 起始状态,等待第一个字符 S1: 接受状态,已识别出合法标识符 S2: 错误状态,遇到非法字符

你看,这个自动机只有3个状态,但已经能处理标识符的识别了。实际项目中的FSA会复杂得多,但原理是一样的。

注意:FSA分为确定性(DFA)和非确定性(NFA)两种。DFA每个输入只有一个确定的下一个状态,实现起来简单高效。NFA则可能有多个选择,需要回溯。实际编译器里,我们通常把NFA转成DFA再实现。

从字符流到Token流的转换过程

好了,理论讲完了。咱们看看实际怎么干。

整个转换过程,我习惯分成三步:

  1. 读取字符:从源文件里一个一个读字符
  2. 状态转移:根据当前状态和读入的字符,决定下一步
  3. 生成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是编译器的第一道关卡,它做得好不好,直接影响后面所有阶段。别小看它。


专注资料整理