词法分析器开发(上):关键字与标识符识别、数字与字符串处理、注释与空白符跳过
好,咱们正式开始动手写代码了。词法分析器,说白了就是给 HDL 代码做「分词」。你想想看,我们读 Verilog 或 VHDL 时,眼睛一扫就知道 module 是个关键字,clk 是个标识符,8'hFF 是个数字。但计算机不懂这些,它只认字符流。我们的任务,就是教会计算机做这件事。
我个人习惯把词法分析分成四个核心模块:关键字与标识符、数字常量、字符串、注释与空白。今天咱们先啃前两个,后两个也一并拿下。
核心思路:逐字符扫描,根据当前字符的类型决定进入哪个处理分支。遇到字母或下划线,走标识符/关键字路径;遇到数字,走数字常量路径;遇到引号,走字符串路径;遇到 // 或 /*,走注释跳过路径。
一、关键字与标识符识别
这是词法分析里最基础的部分。HDL 的关键字就那么几个——Verilog 的 module、endmodule、input、output、wire、reg、always、assign 等等。VHDL 的 entity、architecture、signal、process 等等。
识别逻辑其实很简单:先按标识符的规则读出一个完整的词,然后查表判断它是不是关键字。
我的经验:标识符的规则是「以字母或下划线开头,后跟字母、数字、下划线」。但 HDL 里有个坑——Verilog 的标识符是大小写敏感的,而 VHDL 不敏感。我当年第一次写通用分析器时没注意这个,结果解析 VHDL 时把 Signal 和 signal 当成了两个不同的标识符,调试了半天才发现问题。
代码实现上,我习惯用一个字典来存关键字集合。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'hFF、4'b1010、32'd100 这种带位宽和进制的写法。VHDL 则是 X"FF"、B"1010" 这种风格。
我把它拆成两种情况:
- 简单数字:纯十进制整数,如
123、0 - 带格式数字:位宽 + 单引号 + 进制标识 + 数字部分,如
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 之前执行