词法分析器开发(上):关键字与标识符识别、数字与字符串处理、注释与空白符跳过

好,咱们正式开始动手写代码了。词法分析器,说白了就是给 HDL 代码做「分词」。你想想看,我们读 Verilog 或 VHDL 时,眼睛一扫就知道 module 是个关键字,clk 是个标识符,8'hFF 是个数字。但计算机不懂这些,它只认字符流。我们的任务,就是教会计算机做这件事。

我个人习惯把词法分析分成四个核心模块:关键字与标识符数字常量字符串注释与空白。今天咱们先啃前两个,后两个也一并拿下。

核心思路:逐字符扫描,根据当前字符的类型决定进入哪个处理分支。遇到字母或下划线,走标识符/关键字路径;遇到数字,走数字常量路径;遇到引号,走字符串路径;遇到 ///*,走注释跳过路径。

词法分析器核心流程 字符流输入 当前字符类型? 字母/下划线 → 标识符/关键字 数字 → 数字常量 引号 → 字符串 注释 / 空白 → 跳过

一、关键字与标识符识别

这是词法分析里最基础的部分。HDL 的关键字就那么几个——Verilog 的 moduleendmoduleinputoutputwireregalwaysassign 等等。VHDL 的 entityarchitecturesignalprocess 等等。

识别逻辑其实很简单:先按标识符的规则读出一个完整的词,然后查表判断它是不是关键字

我的经验:标识符的规则是「以字母或下划线开头,后跟字母、数字、下划线」。但 HDL 里有个坑——Verilog 的标识符是大小写敏感的,而 VHDL 不敏感。我当年第一次写通用分析器时没注意这个,结果解析 VHDL 时把 Signalsignal 当成了两个不同的标识符,调试了半天才发现问题。

代码实现上,我习惯用一个字典来存关键字集合。Python 里用 set 就行,查找是 O(1) 的。

# 关键字集合(Verilog 示例)
KEYWORDS = {
    'module', 'endmodule', 'input', 'output', 'inout',
    'wire', 'reg', 'assign', 'always', 'posedge', 'negedge',
    'if', 'else', 'case', 'endcase', 'begin', 'end',
    'for', 'while', 'repeat', 'parameter', 'localparam'
}

def read_identifier(source, pos):
    """从 pos 位置开始读取一个标识符或关键字"""
    start = pos
    while pos < len(source) and (source[pos].isalnum() or source[pos] == '_'):
        pos += 1
    word = source[start:pos]
    # 查表判断
    if word in KEYWORDS:
        return ('KEYWORD', word, start, pos)
    else:
        return ('IDENTIFIER', word, start, pos)

嗯,这里要注意:isalnum() 在 Python 里会匹配 Unicode 字母,但 HDL 只认 ASCII。保险起见,我建议用 source[pos].isascii() and (source[pos].isalnum() or source[pos] == '_')

二、数字常量处理

HDL 的数字比普通编程语言复杂得多。Verilog 支持 8'hFF4'b101032'd100 这种带位宽和进制的写法。VHDL 则是 X"FF"B"1010" 这种风格。

我把它拆成两种情况:

  • 简单数字:纯十进制整数,如 1230
  • 带格式数字:位宽 + 单引号 + 进制标识 + 数字部分,如 8'hFF

我曾经踩过的坑:Verilog 里 'hFF 是合法的——省略位宽,只写进制和数值。还有 'b1'd10 这种。我的第一版代码只处理了完整格式,结果解析 'hFF 时报错。后来加了个分支,检测到单引号开头但没有位宽时,默认位宽为 0(表示未指定位宽)。

处理数字的伪代码大致是这样:

def read_number(source, pos):
    """读取数字常量,支持 Verilog 格式"""
    start = pos
    
    # 先尝试读位宽(十进制数字)
    width = 0
    while pos < len(source) and source[pos].isdigit():
        width = width * 10 + int(source[pos])
        pos += 1
    
    # 检查是否有进制标识
    if pos < len(source) and source[pos] == "'":
        pos += 1  # 跳过单引号
        if pos < len(source) and source[pos] in ('h', 'd', 'b', 'o'):
            base_char = source[pos]
            pos += 1
            # 读取数值部分
            num_start = pos
            while pos < len(source) and (source[pos].isalnum() or source[pos] == '_'):
                pos += 1
            value = source[num_start:pos]
            return ('NUMBER', (width, base_char, value), start, pos)
    
    # 没有进制标识,就是纯十进制
    return ('NUMBER', (0, 'd', source[start:pos]), start, pos)

你想想看,为什么我要把数字解析成 (width, base, value) 的元组?因为后续的语法分析阶段需要知道这个数字的位宽和进制,才能做类型检查和常量折叠。提前结构化存储,后面省事很多。

三、字符串处理

HDL 里的字符串主要用于注释、显示语句(如 Verilog 的 $display)和某些属性。处理起来相对简单:遇到双引号开始,读到下一个双引号结束。

但有个细节:转义字符。Verilog 支持 \n\t\\\" 等转义。如果读到反斜杠,下一个字符要原样保留,不能当作普通字符处理。

def read_string(source, pos):
    """读取字符串常量,处理转义"""
    start = pos
    pos += 1  # 跳过开头的双引号
    result = []
    
    while pos < len(source):
        ch = source[pos]
        if ch == '\\':
            # 转义字符,下一个字符直接保留
            pos += 1
            if pos < len(source):
                result.append('\\' + source[pos])
                pos += 1
        elif ch == '"':
            pos += 1  # 跳过结尾的双引号
            return ('STRING', ''.join(result), start, pos)
        else:
            result.append(ch)
            pos += 1
    
    # 如果走到这里,说明字符串没有闭合
    raise SyntaxError(f"字符串未闭合,位置 {start}")

个人习惯:字符串内容我保留原始形式,不做转义展开。比如 "Hello\nWorld" 我存成 Hello\nWorld 而不是真正的换行。为什么?因为后续的代码格式化工具可能需要保留原始转义序列。这个决策让我少改了好几次代码。

四、注释与空白符跳过

这部分其实最「无聊」,但也最容易出 bug。HDL 有两种注释:

  • 单行注释// 开头,到行尾结束
  • 多行注释/* */ 包裹,可以跨行

空白符包括空格、制表符 \t、换行 \n、回车 \r。这些在词法分析阶段直接跳过,不产生 token。

实现上,我习惯写一个 skip_whitespace_and_comments 函数,返回跳过后的新位置。

def skip_whitespace_and_comments(source, pos):
    """跳过空白符和注释,返回新的位置"""
    while pos < len(source):
        ch = source[pos]
        
        # 跳过空白符
        if ch in (' ', '\t', '\n', '\r'):
            pos += 1
            continue
        
        # 检查注释
        if ch == '/' and pos + 1 < len(source):
            next_ch = source[pos + 1]
            if next_ch == '/':
                # 单行注释,跳到行尾
                pos += 2
                while pos < len(source) and source[pos] != '\n':
                    pos += 1
                continue
            elif next_ch == '*':
                # 多行注释,找到 */
                pos += 2
                while pos + 1 < len(source):
                    if source[pos] == '*' and source[pos + 1] == '/':
                        pos += 2
                        break
                    pos += 1
                continue
        
        # 不是空白也不是注释,退出
        break
    
    return pos

我曾经踩过的坑:多行注释嵌套的问题。Verilog 标准规定 /* /* */ */ 是不合法的——注释不能嵌套。但有些工程师写代码时会不小心嵌套。我的分析器遇到这种情况会报错,而不是默默接受。后来我发现,有些第三方工具会生成嵌套注释的代码,于是加了一个配置选项,允许用户选择「严格模式」或「宽松模式」。

还有一个容易被忽略的点:注释中的关键字要不要识别?答案是否定的。注释里的任何内容都不应该被当作代码处理。所以跳过注释的代码必须在识别关键字之前执行。这个顺序很重要,我见过有人把顺序搞反,结果注释里的 module 被当成了关键字,整个解析就乱了。

五、整合:词法分析器的主循环

有了上面四个模块,主循环就很简单了:

def tokenize(source):
    """词法分析主函数"""
    tokens = []
    pos = 0
    
    while pos < len(source):
        # 先跳过空白和注释
        pos = skip_whitespace_and_comments(source, pos)
        if pos >= len(source):
            break
        
        ch = source[pos]
        
        if ch.isalpha() or ch == '_':
            # 标识符或关键字
            token = read_identifier(source, pos)
        elif ch.isdigit() or ch == "'":
            # 数字常量(单引号开头表示省略位宽的数字)
            token = read_number(source, pos)
        elif ch == '"':
            # 字符串
            token = read_string(source, pos)
        else:
            # 其他符号(运算符、分隔符等),后续章节处理
            token = ('SYMBOL', ch, pos, pos + 1)
            pos += 1
        
        tokens.append(token)
        pos = token[3]  # 更新位置
    
    return tokens

你看,整个流程其实很清晰。先跳过没用的东西,然后根据第一个字符决定走哪条路。每条路都返回一个 token,包含类型、值、起始位置和结束位置。

我个人习惯把位置信息也存下来。为什么?因为后续的语法分析报错时,可以精确指出「第几行第几列出错了」。这个信息在调试时太有用了。

Token 类型 示例 说明
KEYWORD module HDL 保留关键字
IDENTIFIER my_wire 用户定义的名称
NUMBER 8'hFF 数字常量,含位宽和进制
STRING "hello" 字符串常量
SYMBOL ; 运算符或分隔符

嗯,到这里,词法分析器的核心模块就搭起来了。你可能会问:为什么叫「上」?因为还有「下」——运算符、分隔符、特殊符号的处理,以及错误恢复机制。那些内容我们后面再聊。

最后说一句:写词法分析器时,测试用例一定要覆盖边界情况。比如空文件、只有注释的文件、数字后面紧跟标识符(如 8'hFFabc 是否合法?)、字符串中包含引号等等。我每次写完都会跑一遍自己积累的测试集,大概 200 多个用例,跑通了才敢说「基本可用」。

核心要点回顾:

  • 标识符和关键字共享读取逻辑,用查表区分
  • 数字处理要支持位宽和进制,注意省略位宽的写法
  • 字符串要处理转义字符,保留原始序列
  • 注释和空白在词法分析阶段直接丢弃,不产生 token
  • 跳过注释必须在识别 token 之前执行

专注资料整理