AIGC标识 lua-源码带读-02-词法分析与语法解析

Lua 5.4 源码带读 第2篇:词法分析与语法解析

本篇目标

深入理解 Lua 源代码如何被转换为字节码的第一步:从原始文本到 Token 流,再到语法结构。Lua 的解析器有一个独特设计——没有 AST(抽象语法树),解析器在分析语法的同时直接调用代码生成器产生字节码。

前置知识

  • 编译原理基础:词法分析、语法分析、BNF 范式
  • 了解递归下降解析器的概念
  • 阅读过第 1 篇

1. 整体流程

源代码文本
  → [lzio.c] 缓冲输入流(ZIO)
    → [llex.c] 词法分析(lexer)
      → Token 流
        → [lparser.c] 递归下降语法解析
          → [lcode.c] 代码生成器(在解析中直接调用)
            → 字节码(Proto结构体)

注意:lparser.c 直接调用 lcode.c,中间没有 AST 中间表示。这意味着语法分析和代码生成是交织在一起的。


2. 缓冲输入流:lzio.c

2.1 ZIO 结构体

// lzio.h
typedef struct ZIO {
  lua_Reader reader;    // 读取器函数(从文件/内存/缓冲区读取)
  void *data;           // 读取器的私有数据
  const char *n;        // 剩余未处理的字符数
  const char *p;        // 当前位置指针
  lua_State *L;         // 关联的lua_State
} ZIO;

2.2 初始化

// lzio.c
int luaZ_init(lua_State *L, ZIO *z, lua_Reader reader, void *data) {
  z->reader = reader;
  z->data = data;
  z->L = L;
  z->n = NULL;
  z->p = NULL;
  return 0;
}

2.3 读取一个字符

// lzio.c
static int fillbuffer(ZIO *z) {
  size_t size;
  const char *buf;
  buf = z->reader(z->L, z->data, &size);
  if (buf == NULL) return EOZ;  // EOF
  z->n = buf + size;            // 新缓冲区末尾
  z->p = buf;
  return cast_int(*(z->p++));   // 返回第一个字符
}

int luaZ_fill(ZIO *z) {
  // 当前缓冲区为空时调用
  return fillbuffer(z);
}

3. 词法分析器:llex.c

3.1 LexState 结构体

// llex.h
typedef struct LexState {
  int current;          // 当前字符
  int linenumber;       // 当前行号
  int lastline;         // 上一个token的行号
  Token t;              // 当前token
  Token lookahead;      // 预读token(用于处理1个token的向前看)
  struct FuncState *fs; // 当前函数状态
  lua_State *L;
  ZIO *z;               // 缓冲输入流
  Mbuffer *buff;        // token文本缓冲区
  TString *source;      // 源文件名
  // ...
} LexState;

3.2 Token 类型

// llex.h
#define FIRST_KEYWORD TK_AND  // 第一个关键字的值
// 关键字列表:
// and break do else elseif end false for function goto
// if in local nil not or repeat return then true until while

Token 类型分为:

  • 单字符+ - * / { } 等(直接用ASCII值)
  • 多字符== ~= <= >= << >> .. //
  • 关键字if then end function
  • 标识符:用户定义的变量名
  • 字面量:数字、字符串

3.3 llex() 函数——核心扫描

// llex.c - 简化的主扫描函数
int llex(LexState *ls, SemInfo *seminfo) {
  const char *buf;
  size_t capacit;
  int res;

  luaZ_resetbuffer(ls->buff);

  for (;;) {  // 跳过空白字符和注释
    switch (ls->current) {
      case '\n': {  // 换行:增加行号
        incLINE(ls);
        break;
      }
      case '-': {   // 可能是注释
        next(ls);
        if (ls->current != '-') return '-';
        // 是注释,跳过
        next(ls);
        if (ls->current == '[') {  // 可能是嵌套注释
          int s = skipSep(ls);
          if (s >= 0) {
            read_long_string(ls, NULL, s);  // 嵌套注释
          } else { /* ... */ }
        } else {
          while (!is_newline(ls->current) && ls->current != EOZ)
            next(ls);  // 单行注释
        }
        break;
      }
      case '[': {   // 可能是长字符串 [[...]]
        int s = skipSep(ls);
        if (s >= 0) {
          seminfo->ts = read_long_string(ls, NULL, s);
          return TK_LSTRING;
        }
        break;
      }
      case '=': {   // == 或 =
        next(ls);
        if (ls->current != '=') return '=';
        next(ls);
        return TK_EQ;
      }
      case '~': {   // ~= 或 ~
        next(ls);
        if (ls->current != '=') return '~';
        next(ls);
        return TK_NE;
      }
      case '<': {   // <=, <, <<, <>
        next(ls);
        if (ls->current == '=') { next(ls); return TK_LE; }
        else if (ls->current == '<') { next(ls); return TK_SHL; }
        else return '<';
      }
      // ... 更多操作符
      case '"': case '\'': {  // 字符串字面量
        seminfo->ts = read_string(ls, ls->current);
        return TK_FLT;  // 或 TK_SHR/LNGSTR
      }
      default: {
        if (lisalpha(ls->current)) {
          TString *ts;
          // 识别标识符或关键字
          ts = read_name(ls);
          seminfo->ts = ts;
          // 检查是否为关键字
          res = get_keyword(ts);
          if (res >= 0) return res;  // 返回关键字token
          return TK_NAME;
        }
        else if (isdigit(ls->current)) {
          // 识别数字
          read_numeral(ls, seminfo);
          return (ls->t.token == TK_FLT) ? TK_FLT : TK_INT;
        }
        // ... 其他字符
      }
    }
  }
}

3.4 字符串字面量处理

// llex.c
static TString *read_string(LexState *ls, int sep) {
  // sep = '"' 或 '\'' 或长字符串分隔符
  StringBuilder *sb = &ls->lsb;
  int c;
  // 扩展缓冲区
  luaZ_buffgrow(ls->buff, 32);
  // 逐字符读取直到匹配的闭合符
  while ((c = llex(ls)) != sep) {
    if (c == EOZ) lexerror(ls, "unfinished string", 0);
    // 处理转义序列 \n, \t, \xHH, \uXXXX 等
  }
  // 创建TString对象
  return luaS_newlstr(ls->L, luaZ_buffer(ls->buff),
                       luaZ_bufflen(ls->buff));
}

3.5 数字解析

// llex.c
static void read_numeral(LexState *ls, SemInfo *seminfo) {
  TValue obj;
  const char *expo = "Ee";
  int first = ls->current;
  luaZ_buffremove(ls->buff, 0);
  // 支持前缀0x(十六进制)
  if (first == '0' && check_next2(ls, "xX")) {
    expo = "Pp";  // 十六进制用P/p
  }
  for (;;) {
    // 读取数字、小数点、指数部分
    if (check_next2(ls, expo)) {
      check_next2(ls, "-+");  // 指数符号
    }
  }
  // 转换为数值
  if (luaO_str2num(luaZ_buffer(ls->buff), &obj)) {
    if (ttisinteger(&obj)) {
      seminfo->i = ivalue(&obj);
    } else {
      // 浮点数
      seminfo->r = fltvalue(&obj);
    }
  }
}

3.6 预读机制

// llex.h
void luaX_lookahead(LexState *ls) {
  lua_assert(ls->lookahead.token == TK_EOS);
  ls->lookahead.token = llex(ls, &ls->lookahead.seminfo);
}

// llex.c
void luaX_next(LexState *ls) {
  ls->lastline = ls->linenumber;
  if (ls->lookahead.token != TK_EOS) {
    // 使用预读的token
    ls->t = ls->lookahead;
    ls->lookahead.token = TK_EOS;
  } else {
    ls->t.token = llex(ls, &ls->t.seminfo);
  }
}

预读解决的歧义:f(a, b) vs f(a, b) = c。解析器需要看 ( 之后的 token 来判断是函数调用还是赋值。


4. 语法解析器:lparser.c

4.1 设计特点

Lua 的语法解析器是一个递归下降解析器(Recursive Descent Parser)。与大多数语言解析器不同,它不生成 AST,而是在解析过程中直接调用 lcode.c 生成字节码。

这带来了:

  • 优点:更简单、内存使用更少、编译速度极快
  • 缺点:无法独立进行语法优化、难以实现复杂的代码变换

4.2 核心入口

// lparser.c
void luaY_parser(lua_State *L, LexState *ls, Proto *zb, const char *chunkname) {
  FuncState fs;
  BlockCnt bl;
  // 初始化FunctionState
  fs.f = addprototype(L, zb, chunkname);  // 创建Proto
  fs.f->is_vararg = 1;  // chunk是可变参数函数
  fs.nci = 0;
  // 初始化词法分析器
  ls->fs = &fs;
  fs.prev = NULL;
  fs.ls = ls;
  fs.bl = NULL;
  fs.pc = 0;
  fs.lasttarget = 0;
  fs.jpc = NO_JUMP;
  fs.nk = 0;
  fs.np = 0;
  fs.nlocvars = 0;
  fs.nactvar = 0;
  fs.nups = 0;
  fs.nworkvar = 0;
  // 开始解析
  enterblock(&bl);
  luaX_next(ls);
  statlist(ls);  // 解析语句列表
  leaveblock(&fs);
  // 生成返回指令
  luaK_ret(&fs, 0, 0);
  // 最终检查
  luaK_finish(&fs, ls->t.token != TK_EOS);
}

4.3 FuncState——解析过程中的状态

// lparser.h
typedef struct FuncState {
  Proto *f;           // 正在构建的函数原型
  struct FuncState *prev;  // 外层函数(嵌套解析)
  LexState *ls;       // 词法分析器
  struct BlockCnt *bl; // 当前块状态
  int pc;             // 当前已生成的指令数
  int lasttarget;     // 上一个跳转目标
  int jpc;            // 待回填的跳转指令链
  int nk;             // 常量表中已有的常量数
  int np;             // 子函数原型数
  int nlocvars;       // 局部变量数
  int nactvar;        // 活跃局部变量数
  int nups;           // 上值数
  int nworkvar;       // 工作变量数
} FuncState;

prev 字段形成了一个链表——当解析器遇到嵌套函数定义时,创建新的 FuncState 并压入链表;内层函数解析完毕后恢复外层。

4.4 BlockCnt——块状态

// lparser.h
typedef struct BlockCnt {
  struct BlockCnt *prev;  // 外层块
  int firstlabel;         // 第一个标签
  int firstgoto;          // 第一个跳转
  lu_byte nactvar;        // 进入块时的活跃变量数
  lu_byte inscope;        // 是否在作用域内
  lu_byte upval;          // 是否有上值引用
  lu_byte verbose;        // 调试信息
} BlockCnt;

4.5 语法规则到函数的映射

Lua 的每条语法规则对应一个解析函数:

语法规则 解析函数 作用
block block() 解析语句块
statement statement() 解析单条语句
expr expr() 解析表达式(优先级爬坡)
subexpr subexpr(ls, limit) 表达式递归解析
primaryexp primaryexp() 基本表达式(变量、函数调用、括号)
funcbody funcbody() 解析函数体
assignment assignment() 解析赋值
ifstat ifstat() 解析 if-elseif-else-end
whilestat whilestat() 解析 while 循环
forstat forstat() 解析 for 循环
fornum fornum() 数值 for
forlist forlist() 泛型 for
retstat retstat() 解析 return
funcall functioncall() 解析函数调用
listfields listfields() 解析表的列表部分
recfields recfields() 解析表的记录部分
field field() 解析单个表字段

4.6 表达式优先级

// lparser.c - 表达式解析
// 表达式优先级(从低到高):
// 1. or       (8)
// 2. and      (7)
// 3. < > <= >= ~= == (6)
// 4. | (位或) (5)
// 5. ~ (位异或)(4)
// 6. & (位与) (3)
// 7. << >>    (2)
// 8. .. (连接)(1)
// 9. + -      (0)
// 10. * / % // (前缀)
// 11. not # - ~ (一元)
// 12. ^ (幂)  (前缀)
// 13. 数字/字符串/nil/true/false/function/table/...) (原子)

static const int priority[] = {  /* order must match binary method names */
  /* OPR_AND */ {6, 6},
  /* OPR_OR */ {5, 5},
  /* OPR_CONCAT */ {3, 4},
  /* OPR_ADD */ {8, 8},
  /* OPR_SUB */ {8, 8},
  /* OPR_MUL */ {9, 9},
  /* OPR_DIV */ {9, 9},
  /* OPR_MOD */ {9, 9},
  /* OPR_POW */ {14, 13},
  /* OPR_IDIV */ {9, 9},
  /* OPR_BAND */ {6, 7},
  /* OPR_BXOR */ {8, 8},
  /* OPR_BOR */ {4, 5},
  /* OPR_SHL */ {7, 7},
  /* OPR_SHR */ {7, 7},
  /* OPR_UNM */ {12, 12},
  /* OPR_BNOT */ {12, 12},
  /* OPR_LEN */ {12, 12},
};

4.7 subexpr() 函数

// lparser.c - 简化的表达式解析
static int subexpr(LexState *ls, int limit) {
  // 尝试前缀表达式(一元运算符)
  int op = get_unop(ls->t.token);
  if (op >= 0) {
    next(ls);
    int v = subexpr(ls, unary_priority);
    // 生成一元操作指令
  }
  // 尝试原子表达式
  primaryexp(ls, &v);
  // 处理二元运算符
  int leftop = get_binop(ls->t.token);
  while (leftop >= 0 && priority[leftop][0] > limit) {
    next(ls);
    int right = subexpr(ls, priority[leftop][1]);
    // 生成二元操作指令
    leftop = get_binop(ls->t.token);
  }
  return leftop;  // 返回未处理的运算符(-1表示完成)
}

5. 局部变量处理

// lparser.c
static void new_localvar(LexState *ls, TString *name) {
  FuncState *fs = ls->fs;
  Proto *f = fs->f;
  int idx = fs->nactvar++;  // 分配栈槽位
  luaC_objbarrier(ls->L, f, name);
  // 存储变量信息(用于调试)
  luaX_newstring(ls, luaStrData(name), luaStrLen(name));
  // 设置变量名
  // 进入作用域时:nactvar 增加
  // 退出作用域时:nactvar 恢复
}

5.1 变量查找

// lparser.c
static int searchvar(FuncState *fs, TString *n) {
  // 从当前函数开始,逐层向外查找
  // 1. 局部变量:检查 nactvar 范围内的变量
  // 2. 如果找不到,标记为上值引用
  for (int i = fs->nactvar - 1; i >= 0; i--) {
    if (eqstr(n, getstr(f->locvars[i].varname)))
      return i;  // 找到,返回栈槽位
  }
  return -1;  // 未找到
}

6. 本篇小结

概念 要点
缓冲输入 ZIO 提供统一的输入抽象
词法分析 llex.c 将源代码文本转为 Token 流
语法解析 lparser.c 递归下降解析,不生成 AST
代码生成 lcode.c 在解析过程中直接产生字节码
关键设计 语法分析和代码生成交织在一起
局部变量 栈上分配,作用域通过 nactvar 控制
嵌套函数 FunctionState 链表管理
预读 lookahead 机制处理1个token的向前看

思考题

  1. 为什么 Lua 选择不在解析阶段生成 AST?这带来了什么好处和代价?
  2. Lua 的递归下降解析器如何处理左递归语法(如 expr → expr + expr)?
  3. 词法分析器的 lookahead(预读)机制是为了解决什么语法歧义?
  4. 如果要在 Lua 中新增一种语法(如三元运算符 a ? b : c),需要修改哪些源文件?
  5. read_long_string 如何处理 [[...]][=[...]=] 两种格式?
  6. BlockCnt 中的 upval 字段标记了什么?它在什么时候被设置?

思考题解答

1. 为什么 Lua 不生成 AST?

好处

  • 编译速度极快:没有构建和遍历AST的开销,代码在解析的同时就生成字节码
  • 内存使用极少:AST节点需要额外的内存分配和指针链接,而直接生成字节码只需要Proto结构体
  • 实现简单:整个lparser.c + lcode.c 只有约1500行代码,对比Python的Parser (~5000行) + AST (~8000行) 小得多
  • 代码紧凑:适合嵌入式场景,编译器本身占用资源少

代价

  • 无法做复杂的编译期优化:没有AST就无法进行模式匹配、常量传播、死代码消除等优化
  • 代码变换困难:无法实现宏展开、语法糖转换等需要在AST层面操作的功能
  • 调试困难:没有AST结构,调试器难以显示完整的语法树
  • 扩展语法困难:新增语法需要修改解析器代码,而不是单独写一个AST变换pass

Lua的选择:对于嵌入式脚本语言,编译速度和内存使用比编译期优化更重要。Lua通过极简的编译器实现了"够用"的性能。

2. 如何处理左递归?

递归下降解析器天然不能处理直接左递归(expr → expr + term),因为会导致无限递归。Lua的解决方案是优先级爬坡(Precedence Climbing)

// 递归下降处理左递归的经典方法
// subexpr(ls, limit) 解析优先级大于limit的表达式

static int subexpr(LexState *ls, int limit) {
  // 1. 先解析一元运算符(前缀)
  int op = get_unop(ls->t.token);
  if (op >= 0) {
    next(ls);
    subexpr(ls, UNARY_PRIORITY);  // 一元运算符优先级最高
  }

  // 2. 解析原子表达式(数字、字符串、括号等)
  primaryexp(ls, &v);

  // 3. 处理二元运算符(后缀)
  int leftop = get_binop(ls->t.token);
  while (leftop >= 0 && priority[leftop][0] > limit) {
    next(ls);
    // 递归解析右操作数,优先级 >= 左操作符的右结合优先级
    subexpr(ls, priority[leftop][1]);
    // 生成二元操作指令
    leftop = get_binop(ls->t.token);
  }
  return leftop;
}

工作原理

  • priority[op][0] 是左结合优先级
  • priority[op][1] 是右结合优先级
  • 右操作数递归调用时传入右结合优先级,实现了左结合性
  • 例如 1 + 2 + 3:解析 1 后遇到 +,右操作数递归调用 subexpr(ls, 8),遇到第二个 + 时因为 8 <= 8 而停止,让第二个 + 回到外层处理

幂运算 ^ 是右结合priority[OPR_POW] = {14, 13},左优先级14,右优先级13,这使得 a^b^c 解析为 a^(b^c)

3. 预读机制解决什么歧义?

预读解决的核心歧义是表达式和赋值的区分

-- 歧义场景1:函数调用 vs 赋值
f(a, b)       -- 函数调用
f(a, b) = c   -- 赋值给f(a,b)的返回值

-- 解析器在看到 f(a,b) 后需要看下一个token
-- 如果是 '=' → 赋值
-- 如果是 '(' 或其他 → 函数调用

具体歧义:

  • f(a, b) vs f(a, b) = 1:解析器在解析完 f(a, b) 后需要判断是函数调用还是赋值目标
  • a or b vs a and b:需要预读来确定是 or 还是 and
  • function(a) ... end vs (function(a) ... end):需要预读来区分匿名函数定义和函数调用

Lua只使用1个token的预读(lookahead),这比需要任意多个token的语法(如C++的某些歧义)简单得多。这也是Lua语法设计的一个原则——保持LL(1)或接近LL(1)的解析能力。

4. 新增三元运算符需要修改哪些文件?

假设新增语法 a ? b : c,需要修改:

文件 修改内容
llex.h 定义新token TK_QMARK?)和 TK_COLON: 已有)
llex.c 在词法扫描中识别 ? 字符,返回对应token
lparser.h 可能需要新的表达式类型枚举
lparser.c subexpr() 中处理 ? 的优先级,在 expr()subexpr() 中添加三元表达式的解析逻辑
lcode.c 添加代码生成逻辑:条件表达式 + 跳转到两个分支 + phi合并
lopcodes.h 如果需要新指令,定义操作码(通常不需要,可用已有指令组合)
lopcodes.c 如果有新指令,在opmodes表中添加条目
lua.h 可能需要添加元方法支持(如果 ? 需要元方法)

最小修改集llex.c + lparser.c + lcode.c 三个文件,不需要修改VM。

三元运算符可以用已有指令实现:

-- a ? b : c 的字节码
eval a
OP_TEST R[a] 0        -- if not a then goto else
JMP +N                  -- 跳到else分支
eval b                 -- then分支
JMP +M                  -- 跳过else分支
eval c                 -- else分支
-- 结果在栈顶

5. read_long_string 如何处理两种格式?

[[...]][=[...]=] 的处理逻辑:

// llex.c
static void read_long_string(LexState *ls, SemInfo *seminfo, int sep) {
  // sep 是等号数量:[[ ]] 的 sep=0,[=[ ]=] 的 sep=1
  int cont = 0;

  while (1) {
    int c = luaZ_get(ls->z);  // 读取下一个字符
    switch (c) {
      case '[': {
        // 检查是否是嵌套的长字符串开头
        if (skip_sep(ls) == sep) {
          // 是嵌套的,继续读取(只增加嵌套计数)
          cont++;
          luaZ_buffremove(ls->buff, 0);
        } else {
          // 不是嵌套的,把 [ 写入缓冲区
        }
        break;
      }
      case ']': {
        // 检查是否是闭合
        if (skip_sep(ls) == sep) {
          if (cont == 0) {
            // 匹配成功,结束
            return;
          }
          // 嵌套的闭合,减少嵌套计数
          cont--;
          luaZ_buffremove(ls->buff, 0);
        }
        break;
      }
      case EOZ:
        // 文件结束,未找到闭合
        lexerror(ls, "unfinished long string", 0);
        break;
      default: {
        // 普通字符,直接写入缓冲区
      }
    }
  }
}

处理逻辑

  1. 记录分隔符的等号数量 sep[[ 的 sep=0,[=[ 的 sep=1)
  2. 逐字符读取,遇到 [ 时检查是否是嵌套的长字符串(等号数量匹配则嵌套+1)
  3. 達到 ] 时检查是否是闭合(等号数量匹配则嵌套-1,嵌套为0时结束)
  4. 如果遇到 EOZ(文件结束)则报错
  5. 嵌套支持:[[ ... [[ ... ]] ... ]] 是合法的,内层的 [[ 不会被当作闭合

为什么需要等号匹配:避免 [[ 字符串中出现 ] 时被误判为闭合。例如 [[if (a[1]) then]] 中的 ] 不应该结束字符串。

6. BlockCnt 中的 upval 字段

upval 字段标记当前块中是否引用了外部局部变量(需要创建上值)。

什么时候被设置

// lparser.c
static void markupval(FuncState *fs) {
  BlockCnt *bl = fs->bl;
  while (bl != NULL) {
    bl->upval = 1;  // 标记当前块及所有外层块
    bl = bl->prev;
  }
}

当解析器遇到一个变量引用时(如 a),如果变量不是当前块的局部变量,就会调用 markupval()

具体场景

local x = 1
function foo()
  print(x)  -- 引用了外部变量x,需要上值
end

当解析器在 foo() 函数体中遇到 x 时:

  1. foo 的局部变量中找不到 x
  2. 标记 foo 所在块的 upval = 1
  3. 继续向外传播,直到找到 x 的定义块

用途

  • 决定是否需要为当前函数创建上值
  • 影响 leaveblock() 时的行为:如果 upval=1,需要生成 OP_CLOSE 指令
  • 帮助调试器确定变量的来源

没有 upval 优化:如果块中没有引用外部变量,就不需要为该块创建上值,节省内存和运行时开销。

posted @ 2026-09-04 17:26  IcarusLee  阅读(3)  评论(0)    收藏  举报