表达式求值(一):词法分析(Lexical Analysis)
1. 问题背景:为什么需要词法分析?
表达式求值并不是一次性完成的,而是一个典型的“分阶段处理”问题。
词法分析的目标,是将原始字符串转化为结构化、可被后续算法处理的 Token 序列。
2. 表达式语言的最小定义
表达式的样式类似:
"0x80100000+ ($a0 +5)*4 - *( $t1 + 8) + number"
2.1 支持的 Token 类型
- 整数(十进制 / 十六进制)
- 寄存器
$eax - 运算符
+ - * / == - 括号
- 变量
3. Token 的设计
typedef struct token {
int type;
char str[32];
} Token;
解释:
type:token 类型str:原始字符串(用于后续转换)
3.1 什么时候保存原始字符串
对于仅仅记录类型不够的 token,就需要保存其原始字符串。例如整数、寄存器以及变量等需要知道其具体数值的 token。
4. 基于正则表达式的 Token 识别策略
4.1 为什么用 regex
在 Token 的识别场景中选择正则表达式,核心是其能以简洁、可维护、精准的方式解决 “文本模式匹配” 问题,相比纯手动字符串遍历 / 字符判断,适配 Token 提取的核心需求。
1. 简洁描述复杂的 Token 格式规则
Token 识别的核心是 “按格式特征区分不同类型”(如十六进制、寄存器、数字、标识符等),正则可将复杂的格式约束浓缩为一行表达式,避免大量冗余的 if-else/ 字符遍历代码:
- 例 1:识别十六进制
0x80100000→ 正则0[xX][0-9a-fA-F]+一行描述 “0x/0X 前缀 + 1 + 个十六进制字符” 的规则,无需手动判断每个字符是否为0/x/0-9a-f; - 例 2:识别寄存器
$a0/$t1→ 正则\$(0|[a-zA-Z]+[0-9]*)精准描述 “$前缀 + 仅$0 纯数字 / 其他字母开头 + 可选数字” 的约束,替代数十行字符判断逻辑; - 例 3:识别十进制数字 → 正则
[0-9]+替代isdigit()循环遍历。
2. 统一的规则管理,易扩展 / 维护
Token 类型会随需求扩展(如新增标识符、解引用符、比较符等),基于正则可将所有 Token 规则集中管理(如 rules 数组),新增 / 修改 Token 只需调整正则表达式,无需改动核心匹配逻辑:
// 集中管理的规则数组,新增 Token 仅需加一行
static struct rule rules[] = {
{" +", TK_NOTYPE}, // 空格
{"0[xX][0-9a-fA-F]+", TK_HEX}, // 十六进制
{"[0-9]+", TK_DEC}, // 十进制
{"\\$(0|[a-zA-Z]+[0-9]*)", TK_REG}, // 寄存器
{"[a-zA-Z_][a-zA-Z0-9_]*", TK_IDENT}, // 标识符(如 number)
// 新增 Token 仅需追加规则
};
相比纯手动遍历(新增 Token 需修改核心匹配函数,易引入 bug),正则规则的 “声明式” 特性大幅降低维护成本。
3. 精准区分易混淆的 Token 格式
Token 识别的关键挑战是 “避免格式重叠导致的误匹配”,正则可通过规则优先级 + 精准模式解决:
- 优先级:将十六进制规则放在十进制前,避免
0x80100000被拆分为0(十进制)+x+80100000(十进制); - 精准度:通过
\$(0|[a-zA-Z]+[0-9]*)区分合法寄存器($0/$a0)和非法格式($123/$b0),避免纯遍历的 “一刀切” 判断。
4.2 匹配顺序的重要性
POSIX 正则引擎(regcomp/regexec)遵循「按规则数组顺序遍历 + 最长左匹配 + 首次匹配即终止」的核心逻辑,错误的匹配顺序会直接导致 Token 识别错误(如拆分合法 Token、误判 Token 类型),甚至整个词法分析流程失效。匹配顺序的关键价值在:
4.2.1 先理解正则匹配的底层规则
在基于规则数组的 Token 识别中,引擎的执行逻辑是:
- 从文本当前位置开始,按规则数组的顺序逐个尝试正则匹配;
- 若某条规则匹配成功(且满足 “最长左匹配”),则将该段文本标记为对应 Token 类型,跳过已匹配文本,从下一个位置重新开始匹配;
- 若规则匹配失败,则继续尝试下一条规则,直到遍历完所有规则(无匹配则报错)。
简言之:先定义的规则拥有 “匹配优先权”,这是顺序重要性的核心根源。
4.2.2 核心匹配顺序原则(结合 Token 识别场景)
根据 Token 类型的格式特征,需遵循以下 4 条核心原则,确保识别精准:
原则 1:特殊 / 长格式规则 → 通用 / 短格式规则
逻辑:包含更多格式约束的 “特殊 Token”(如十六进制、多字符运算符),必须优先于 “通用 Token”(如十进制、单字符运算符),避免长格式被拆分为短格式。
-
典型场景 1:十六进制(0x80100000)优先于十进制(
[0-9]+)✅ 正确顺序:
0[xX][0-9a-fA-F]+(十六进制)→[0-9]+(十进制)
❌ 错误顺序:十进制在前 → 0x80100000 会被拆分为 0(十进制)+ x(无匹配)+ 80100000(十进制),完全错误。 -
典型场景 2:多字符运算符(
==)优先于单字符运算符(=)
✅ 正确顺序:==(TK_EQ)→=(TK_ASSIGN)
❌ 错误顺序:单字符在前 → == 会被拆分为两个 =(TK_ASSIGN),而非一个 ==(TK_EQ)。
原则 2:带前缀的规则 → 无前缀的通用标识符
逻辑:有固定前缀的 Token(如寄存器 $a0),需优先于普通标识符(如 number),避免前缀被拆分、主体被误判为标识符。
-
典型场景:寄存器(
\$(0|[a-zA-Z]+[0-9]*))优先于标识符([a-zA-Z_][a-zA-Z0-9_]*)✅ 正确顺序:TK_REG → TK_IDENT
❌ 错误顺序:标识符在前 →
$a0会被拆分为$(无匹配)+a0(TK_IDENT),丢失寄存器类型。
原则 3:忽略类规则(如空格)前置,但不影响核心 Token
逻辑:空格、制表符等无需作为 Token 的文本,需放在规则数组最前面 —— 匹配后标记为 TK_NOTYPE,直接跳过,避免空格被误判为其他 Token,同时不抢占核心 Token 的匹配优先级。
原则 4:运算符规则后置(无格式冲突时)
逻辑:运算符(+/-/*//)格式简单(单字符),且与数字、寄存器、标识符无格式重叠,可放在核心 Token 规则之后,避免抢占合法 Token 的匹配机会。
- 例外:一元运算符(如
-123的负号、*$t1的解引用)无需调整顺序 —— 正则的 “最长左匹配” 会优先匹配数字 / 寄存器,自然区分一元 / 二元运算符。
5. regex 规则表的组织方式(核心)
回答: 为什么是“数组”,而不是一个大正则?
在 Token 识别场景中,选择「规则数组(多段小正则 + 绑定 Token 类型)」而非「单个大正则」,并非语法偏好,而是由 Token 识别核心需求(匹配文本 + 精准分类) 和工程化要求(可控、可扩展、可调试) 决定的。
单个大正则仅能实现 “匹配所有合法 Token”,但无法解决 “类型映射、顺序可控、维护扩展” 等核心问题,而规则数组是适配该场景的工程化最优解。
5.1 核心矛盾:单个大正则解决不了 Token 识别的核心诉求
Token 识别的目标不是 “找出所有合法文本片段”,而是 “找出片段 + 给每个片段打唯一的 Token 类型标签”。单个大正则的致命缺陷是:能匹配,但无法直接关联 Token 类型,而规则数组通过 “正则模式 + 类型标识” 的绑定,完美解决这一核心问题
5.2 深度拆解:为什么单个大正则不可行?
5.2.1 问题 1:类型映射冗余且易出错
单个大正则需通过 “分组” 包含所有 Token 模式,但匹配后需手动遍历所有分组,判断 “哪个分组匹配成功”,再映射为 Token 类型
5.2.2 问题 2:匹配顺序完全失控
单个大正则的分组顺序≠匹配优先级,正则引擎会按 “最长左匹配”“贪婪匹配” 规则自行决定匹配顺序,必然导致 Token 拆分错误。
反例:十六进制 vs 十进制的匹配失控
单个大正则分组顺序为 ([0-9]+)|(0[xX][0-9a-fA-F]+)(十进制在前),引擎会优先匹配 [0-9]+,导致 0x80100000 被拆分为 0(十进制)+ x80100000(无匹配);即使调整分组顺序为 (0[xX][0-9a-fA-F]+)|([0-9]+),部分 POSIX 引擎仍会因 “最长匹配” 逻辑误判,无法兼容所有环境。
而规则数组可严格按 “十六进制→十进制” 的顺序尝试匹配,完全规避该问题。
6. 词法分析主流程(make_token)
- 从字符串起始位置开始
- 尝试用所有 regex 规则进行匹配
- 找到第一个成功匹配的规则
- 生成 Token 并前进输入指针
- 重复直到字符串结束
7. 一元运算符的特殊处理
- 什么时候是减号?
- 什么时候是解引用?
在处理 token 的时候,会发现例如-和*其实均有两层含义:
-:负数(一元运算符)和减号(二元运算符)*:解引用(一元运算符)和乘号(二元运算符)
一元运算符(负号 -、解引用 *)是词法分析阶段的隐含难点,核心矛盾在于:单个 -/* 的字符格式无法直接区分其语义(一元 / 二元),必须结合上下文位置特征做初步分类,而最终语义需由语法分析阶段确认。
8. 错误处理与边界情况
8.1 无法匹配任何规则:词法分析的 “硬错误”
8.1.1 触发场景
当解析指针指向的字符 / 子串,遍历完所有正则规则后均无法匹配(即 make_token 中 i == NR_REGEX),本质是输入中存在 “无规则可匹配的字符 / 子串”
8.1.2 识别逻辑(基于 make_token 代码)
外层循环中,内层遍历所有规则后未找到匹配项(i == NR_REGEX),判定为 “无法匹配任何规则”,触发错误处理。
8.2 Token 过长:缓冲区溢出的 “隐形风险”
Token 的字符串长度超出预设缓冲区(tokens[nr_token].str 的固定长度为32)。
8.2.2 识别逻辑
正则匹配成功(找到对应规则),但匹配到的子串长度 substr_len 超过 tokens[nr_token].str 的最大容量,或超过系统 / 业务定义的 Token 长度上限(如约定数字最长 32 位)。
8.2.3 处理策略(核心:防御溢出 + 明确报错)
Token 过长会导致缓冲区溢出(内存越界),是严重安全问题,需严格处理:
- 严格模式:识别到长度超限后,打印 “Token 过长” 错误(标注 Token 类型、长度、上限),终止解析;。
8.3 非法字符:从 “词法规则” 理解本质
很多开发者易混淆 “非法字符” 与 “无法匹配规则”,核心需明确:非法字符的本质是 “无正则规则可匹配的字符 / 子串”,是 “无法匹配任何规则” 的子集,但需区分两类 “非法”:
8.3.1 词法层面的 “字符非法”(真正的非法字符)
指字符本身不在任何正则规则的匹配范围内,是最直接的非法场景:
- 示例:
@、#、%、&等特殊符号(无对应规则); - 识别:触发 “无法匹配任何规则” 错误;
- 处理:直接报错终止。
这也是词法分析的核心预警功能。
8.3.2 语义层面的 “格式合法但内容非法”(非词法错误)
指字符 / 子串能匹配正则规则(格式合法),但不符合业务语义约束(如不在合法列表中),词法分析不处理此类 “非法”,需留到语义分析阶段:
- 示例 1:
$x99能匹配寄存器正则(格式合法),但不在regs数组中(语义非法); - 示例 2:
0xg80100000中的g是十六进制规则外的字符(词法非法),但0x80100000格式合法(词法合法); - 边界:词法分析仅负责 “格式是否匹配规则”,语义合法性由后续阶段判断。
9. 测试驱动开发:如何验证词法分析正确性?
词法分析是编译器 / 解释器前端的基础,其正确性直接决定后续语法分析、求值的可靠性。
采用测试驱动开发(TDD) 验证词法分析,核心是 “脱离后续模块独立验证、用结构化用例覆盖场景、批量自动化校验”—— 这是工程化开发的核心思维,而非仅满足 “功能能用” 的刷题思维。
9.1 为什么要独立测试词法分析?
词法分析(make_token)的核心职责是 “将字符串转为 Token 序列”,独立测试它的价值在于:
9.1.1 不依赖后续模块(parser/eval),降低调试耦合
若等到 “词法 + 语法 + 求值” 全流程联调时才发现错误,无法快速定位是 “Token 生成错误” 还是 “语法解析错误”。独立测试词法分析:
- 直接调用
make_token,无需依赖未实现的expr(求值)、语法解析模块; - 聚焦 “Token 序列是否符合预期”,排除其他模块干扰。
9.1.2 快速定位错误,提升调试效率
词法错误是 “源头错误”,若 Token 生成错误,后续所有逻辑都会偏离预期。独立测试可:
- 精准定位 “哪个表达式生成了错误的 Token”“Token 类型 / 字符串不匹配”;
- 提前暴露边界场景(如超长 Token、非法字符、混合空格),避免错误扩散到后续模块。
9.1.3 覆盖全场景,保证代码健壮性
独立测试可针对性覆盖词法分析的所有核心场景(数字、寄存器、运算符、混合空格、非法字符等),而非仅靠 “手动输几个例子” 验证,符合工业级代码的可靠性要求。
9.2 Mock 测试环境设计
直接将expr.c复制到一个测试目录,重命名为mock_test.c,然后:
-
Mock 基础环境:定义极简宏(如
Log/panic/ARRLEN),替代复杂的日志 / 断言框架,保证测试代码可独立编译运行。示例:// 1. Mock基础宏:极简且通用,无外部依赖 #define ARRLEN(arr) (sizeof(arr) / sizeof((arr)[0])) // 数组长度计算 #define Log(...) printf(__VA_ARGS__), printf("\n") // 简易日志 #define panic(fmt, ...) do { printf("PANIC: " fmt, ##__VA_ARGS__); assert(0); } while (0) // 错误终止 -
直接调用
make_token:跳过expr(求值)等后续逻辑,直接传入测试表达式,触发 Token 生成; -
打印 + 比对 Token 序列:
- 打印生成的 Token(类型 + 字符串),便于人工调试;
- 自动比对 “实际 Token 序列” 与 “预期 Token 序列”,实现自动化校验。
9.3 表驱动测试用例(工程化核心)
表驱动测试的核心是 “将测试用例抽象为数据结构,用通用函数批量校验”,而非为每个用例写重复的校验代码 —— 这是工程思维与 “逐例手写测试” 的核心区别。
9.3.1 测试用例数据结构设计
封装 “输入表达式 + 预期输出” 为结构化数据,覆盖 “输入、预期 Token 数量、预期 Token 类型、预期 Token 字符串”,可灵活扩展:
typedef struct {
const char *name; // 测试用例名称(便于定位失败用例)
const char *expr; // 测试表达式(输入)
int expected_nr_token; // 预期Token数量
int *expected_types; // 预期Token类型数组
const char **expected_strs; // 预期Token字符串数组(仅针对需存储字符串的Token)
} TestCase;
设计价值:新增测试用例只需 “填充数据”,无需修改校验逻辑,符合 “开闭原则”。
9.3.2 通用测试函数(复用核心逻辑)
编写 run_test_case 函数,封装所有测试用例的通用校验逻辑,避免重复代码:
9.3.3 批量运行测试套件
编写 run_all_tests 函数,批量运行所有测试用例并统计结果,实现自动化校验:
9.3.4 核心测试场景覆盖(工程化思维)
测试用例需覆盖词法分析的全场景,而非仅简单案例,典型场景:
| 测试场景 | 示例表达式 | 验证点 |
|---|---|---|
| 基础数字 + 运算符 | 1+2+3 |
Token 数量、类型、字符串匹配 |
| 混合空格 | 1 + 2 + 3 |
空格被忽略,Token 序列与无空格一致 |
| 括号 + 多数字 | (1+315)/4- 3*428 + 8 |
括号、运算符、数字的 Token 生成正确性 |
| 十六进制 + 十进制混合 | (0x1234AfdE + 12345 + 0123) / 0x12345 - 12 |
十六进制 Token 识别、0 开头十进制处理 |
| 寄存器 + 标识符 + 解引用 | 0x80100000+ ($a0 +5)*4 - *( $t1 + 8) + number |
寄存器、标识符、一元 * 的 Token 生成 |
| 非法字符 / 超长 Token | 123@456/0x801000008010000080100000 |
错误处理逻辑(报错、终止解析) |
| Token数量超过数组上限 | 多于 tokens 数组大小的表达式 | 错误处理逻辑(报错、终止解析) |
10. 小结:Lexical 阶段的职责边界
将线性的字符流,转换为语义明确的 Token 流,而不关心运算优先级或求值结果。

浙公网安备 33010602011771号