Skip to content

词法分析与 Token

词法分析到底在解决什么问题

源代码一开始只是字符序列。

编译器不适合直接在原始字符串上完成后续所有工作,因为那样后面的阶段会不断重复判断:

  • 当前是不是关键字
  • 这里是不是标识符
  • 这个 = 是赋值还是比较的一部分
  • 字符串字面量在哪里结束

词法分析干的就是这件事:先别急着理解程序,先把源码切成一块一块能认的东西,也就是 Token。

用一段最小代码实际看一眼

例如源码:

int x = 12;

在进入 Lexer 之前,它只是普通字符串。

进入 Lexer 之后,更接近下面这种结果:

KW_INT("int")
IDENTIFIER("x")
ASSIGN("=")
INTEGER("12")
SEMICOLON(";")
EOF

这一步做完之后,编译器终于不用再一边往前挪一边猜“这串字符到底是啥”,而是能直接拿着已经分类好的东西往下走。

你可以把 Token 理解成什么

可以把 Token 理解成“已经初步分类过的词片段”。

例如:

return a + 1;

大致会变成:

  • return:关键字
  • a:标识符
  • +:运算符
  • 1:整数
  • ;:分号

这样后面的 Parser 就不用再猜每个字符片段是什么身份了。

这个项目的 Lexer 是手写的

当前实现没有使用词法生成器,而是手写 Lexer

这种做法的好处很直接:

  • 控制流一眼能看懂
  • 报错位置容易控制
  • 加一个新 Token 类型时改动直接

它的核心流程是:

  1. 跳过空白和注释
  2. 观察当前字符
  3. 根据字符类型选择扫描分支
  4. 产出一个 Token
  5. 继续直到文件结束

如果你去看 src/c_core_compiler/lexer.py,会发现它本质上就是一台很直接的扫描机:

  • 主循环不断往前走
  • 每次先跳过空白和注释
  • 然后根据当前字符决定进入哪条扫描分支

也就是说,这个 Lexer 没想搞什么花活,它就是老老实实让你能看明白“它到底怎么一格一格往前扫”。

它支持扫描哪些东西

从当前实现看,Lexer 支持:

  • 标识符
  • 关键字
  • 十进制整数
  • 字符字面量
  • 字符串字面量
  • 单字符运算符与分隔符
  • 双字符运算符

还支持跳过:

  • 空格、制表、换行
  • // 单行注释
  • /* ... */ 多行注释

这也解释了为什么这一层现在还算好读:项目支持的是受控子集,不是整本 C 语言标准,所以不会一上来就把你拖进最恶心的词法角落。

为什么双字符运算符要先识别

像下面这些:

  • ==
  • !=
  • <=
  • >=
  • &&
  • ||

如果不优先识别,就会被拆成两个单字符 Token。

例如 == 会错误地变成两个 =。所以 Lexer 会先看两个字符能不能组成一个合法运算符,再决定是否走单字符分支。

这类细节看起来小,但恰好最能说明 Lexer 为什么不能糊弄。它的工作不是“差不多切一下”,而是尽量在最前面就把边界切准。

位置信息为什么重要

当前项目里的每个 Token 都会记录行号和列号。

这件事看起来像细节,其实很关键,因为后面的报错都依赖它。

比如:

  • 词法错误要告诉你非法字符在哪
  • 语法错误要告诉你期望的成分在哪里缺失
  • 语义错误也需要指向具体位置

如果前面不记录位置,后面排错体验会很差。

在当前项目里,Token 里保存的行列号会一路被后续阶段用到,所以 Lexer 不只是“产出类别”,还要产出“类别出现的位置”。

字符串和字符字面量为什么单独麻烦一点

因为它们不是“扫到下一个空格就结束”这么简单。

例如:

  • 'a'
  • '\n'
  • "hello"
  • "a\nb"

这里涉及:

  • 起始引号和结束引号
  • 转义字符
  • 未闭合字面量

所以 Lexer 必须单独处理,并且在未闭合时抛出明确错误。

如果你去看源码,会发现这里的处理方式也很直白:

  • 看见起始引号就进入专门扫描函数
  • 遇到反斜杠就知道可能有转义
  • 到文件末尾还没闭合就报错

这正是手写 Lexer 的好处之一:你能非常具体地看到每类字面量是怎么被处理的。

词法阶段并不理解程序结构

需要特别强调:Lexer 只负责“切块”,不负责理解“这些块怎么组成语句或表达式”。

也就是说,Lexer 知道 if 是关键字,知道 ( 是左括号,但它不知道:

  • 这个 if 的条件是什么
  • 这个括号是不是成对匹配
  • 这里是不是合法表达式

这些都属于 Parser 的工作。

如果非要把词法层压成一句人话,那就是:

  • 它负责把“字符”变成“带类别的符号”
  • 但它不负责把“符号”变成“程序结构”

这个阶段的典型输出价值

如果你运行:

python3 -m c_core_compiler input.c --emit-tokens

你看到的是一串已经被分类的 Token。它很适合用来判断:

  • 问题到底出在词法阶段还是语法阶段
  • 某个字面量有没有被正确识别
  • 某个运算符有没有被错误拆分

这也是为什么 Token 输出是一个很重要的调试入口。