Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

24 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Lua 1.1 技术剖析报告

written by Quirkybrain


1. 环境搭建与编译运行

1.1 编译与构建方式

项目根目录提供了一个历史风格的构建脚本 domake

./domake

它本质上只是导出当前目录为 LUA 环境变量,然后调用顶层 Makefile。顶层 Makefile 再分别进入以下三个子目录完成构建:

  • src/:构建解释器核心库
  • clients/lib/:构建标准库
  • clients/lua/:构建命令行解释器

1.2 现代 Linux / GCC 环境下的兼容问题与处理

  • 环境信息:Ubuntu 24.04 LTS

为了方便进行改动和测试,本次修改每一层的 MakeFile 文件,写出了路径的具体值,可以直接调用顶层的 MakeFile。

问题 原因 当前处理方式
ar ruvl 过时 旧版静态库打包命令不适合现代工具链 改为 ar rcs
旧式动态库构建方式不兼容 过去的 ld 调用方式与现代平台差异较大 改为 $(CC) -shared -o $@ $(OBJS)
调试困难 旧工程默认偏优化构建,不利于定位问题 改为CFLAGS= -g -Wall -O0 -fPIC -I$(INC) $(DEFS)
main 返回类型过时 现代 C 规范要求 main 返回 int clients/lua/lua.c 中改为 int main(...)
floatingpoint.h 在现代系统不可用 历史头文件已不再需要 clients/lib/iolib.c 中移除
stdin/stdout 静态初始化不兼容 宏/静态初始化方式在现代编译器下不稳定 改为先声明 in/out,再在 iolib_open() 中赋值

修改上述信息后可进行编译并生成 lua 的可执行文件。

1.3 运行方式

lua 解释器允许两种运行方式

  1. 直接执行 Lua 文件
./bin/lua test/save.lua
  1. 交互式从标准输入读取脚本行
./bin/lua
print("hello world")

2. 整体架构

2.1 流程分析

  1. 进入 main() 入口函数
  2. 注册 clients/lib 文件夹中的标准库函数
  3. 判断输入来源
    1. 文件:通过 lua_openfile 打开
    2. 交互形式:通过 lua_openstring 打开
  4. lex yylex 进行词法分析
  5. lua.stx 中 yyparce 进行语法分析
  6. 生成字节码并且通过 lua_execute 进行执行
  7. 创建数据
    1. tabel.c:符号表、常量表、字符串表
    2. hash.c:lua 中的 tabel 数据类型
  8. 对虚拟栈以及全局符号表进行 GC 标记,并回收字符串和数组对象

2.2 核心数据结构

  • Object是 lua 中的对象结构,对值的统一表示
  • 虚拟栈负责表达式求值、函数调用
  • Hash是 lua 中 tabel 结构的底层实现的容器
  • 全局符号表、常量表、字符串表都是为 lua 解释器提供数据的

3. 核心机制剖析

3.1 动态数据类型实现

lua 的动态类型实际是一个标签空间用于表示类型,一个数值空间用于存储数据。

  • Type:enum 用于表示当前的数据类型
  • Value:union 用同一块内存空间能够存储不同类型的数值
  • Object:一个数据对象,本质是一个 Type 类型的 tag 标签与一个 Value 类型的 value 数值构成的 struct

这种动态类型的设计带来了很大的方便与优势

统一数值表示 (封装的思想)

巧妙通过 union struct 的组合将 C 语言中不同的类型 (int, float, char* 以及其它指针类型) 进行了封装,对 lua 仅暴露了 Objuct 的简单类型接口。lua 解释器不需要再担心不同类型的区分与存储困难,只需要通过 tag 进行区分放在栈中的是什么 lua 数值。是设计中的封装的思想。

解释器识别类型

在代码中大部分的判断都是对于 tag 标签的。例如:

  • 算术运算会检查对象 tag == T_NUMBER
  • 字符串拼接需要对象是或者能转换成 T_STRING 类型
  • tabel 进行索引操作需要被索引的对象是 T_ARRAY 类型
  • 函数的调用需要对象是 T_CFUNCTIONT_FUNCTION 类型

由此可知 lua 中动态类型的识别只是在运行时判断了 tag 标签,并没有很深奥的东西。

T_MARK 类型的作用

lua 中很多栈都是采用了 C 中的数组,在通过指针访问的时候很容易超出栈底造成内存不安全;虚拟栈中压入了大量的数据和函数方法需要加以区分。对于这些问题,引入了不对用户可见的 T_MARK 类型。主要用于:

  • 栈底哨兵,防止栈的超出
  • 函数调用时虚拟栈中的分隔
  • 参数边界的定位

3.2 符号表、常量表、字符串表

src/tabel.c 中设计了三个关键的表用于辅助 lua 的正常运行。

1. 全局符号表 lua_table

它保存所有全局变量及其当前值。初始化时,解释器会预装入几个内建函数:

  • type
  • tonumber
  • next
  • nextvar
  • print
  • dofile
  • dostring 之后,clients/lua/lua.c 中又会注册 iolibstrlibmathlib 提供的更多标准库函数。

查找全局变量时,lua_findsymbol() 额外维护了一个 searchlist,名字一旦被访问到,就把对应节点移到链表前端,从而加速后续访问。是一种对于访问速度的优化策略。

2. 常量表 lua_constant

它保存编译期出现的字符串常量,例如:

  • 关键类型名 "number""string""table"
  • 源码中的字符串字面量
  • 记录字段名

词法分析阶段识别出字符串后,会把字符串放入常量表,并返回一个索引。后续字节码中并不直接嵌入完整字符串,而是通过 PUSHSTRING + 常量索引 的形式引用它。

这样避免了相同字符串的重复创建。

3. 字符串驻留表 lua_string

运行时动态产生的字符串不会随便散落在堆里,而是统一交给 lua_createstring() 管理。

运行机制:

  1. 先查字符串表,看是否已有同内容字符串
  2. 如果已有,就释放新分配的副本,直接复用旧指针
  3. 如果没有,就把它插入字符串表

提高了底层数据管理的效率,并且方便后续 GC 对于对象的管理

3.3 GC 机制

lua_pack() 中触发,其流程非常清晰:

  1. 从 lua 虚拟栈出发标记对象
  2. 从全局符号表出发继续标记对象
  3. 回收未标记的字符串
  4. 回收未标记的表
  5. 清空本轮标记,等待下一次 GC

对于字符串的标记有一个设计:字符串分配内存空间时,前面会多预留一个位置作为 GC 标记位,不暴露给普通字符串接口,只会暴露给 GC 操作的函数。

3.4 Table 结构的实现

对比 C 语言,lua 最独特的数据结构是 Table(表结构),其底层由 src/hash.c 实现,其结构是:

  • Hash:哈希表头,记录桶数 nhash、桶数组 list、GC 标记 mark
  • Node:链表节点,保存键 ref、值 val 和下一节点 next

对于 Node 的设计源自于 Tabel 结构本质上是一个映射:

key -> value

所以每个节点需要:

  • ref:键对象
  • val:值对象

这里 refval 都是 Object,键和值本身也都拥有完整的动态类型信息。

1. 冲突处理方式:拉链法

为了解决常见的哈希冲突,lua 设计了哈希桶的结构,将冲突的元素放入同一个桶中构成一个链表。

2. 两类桶索引的计算

支持两类键进行查找桶索引:

  • number:直接取模
  • string:逐字符移位累加后取模

这正好对应 lua Table 结构中里最常见的两类索引方式:

  • 数组式索引:t[1]
  • 字典式索引:t["name"]

3. 本次修改的最大 bug 即对 Table 类型读写的分离

历史版本的做法是:查找失败时顺手创建一个值为 nil 的节点。这样做对写操作有帮助,但对读操作是危险的,因为“读一个不存在的键”应该只是返回一个,表示不存在,但是创建 nil 节点会改变原来表的结构,并且我们可以尝试运行以下脚本明白其危害:

-- 现代计算机内存大,我们可以在终端进行限制可用内存的大小
-- 通过如下命令进行限制
-- ulimit -v 51200
-- 再执行这个脚本就会在 i = 1013538 时产生 not enough memory 的错误
-- 这是由于不断读取不存在的节点,lua 就会不断创建 nil 的僵尸节点,而表一直被使用,GC 无法识别并清除,僵尸节点不断消耗堆内存直到占用完。
bug_table = @{}

print("开始执行恶意的不断读取操作...")

i = 1
dummy = 0

while 1 do
    
    dummy = bug_table[i]
    if i = 100000 then
        print("已分配 10 万个无用的 nil 节点...")
    elseif i = 500000 then
        print("已分配 50 万个无用的 nil 节点...")
    elseif i = 1000000 then
        print("已分配 100 万个无用的 nil 节点...")
    elseif i = 1500000 then
        print("已分配 150 万个无用的 nil 节点...")
    elseif i = 2000000 then
        print("已分配 200 万个无用的 nil 节点...")
    elseif i = 3000000 then
        print("已分配 300 万个无用的 nil 节点...")
    end
    i = i + 1
end

修改后已经把旧逻辑拆成两条路径:

路径 当前接口 行为
只读查询 lua_hashGet() 找不到直接返回 NULL,不创建节点
写入确保存在 lua_hashEnsure() 找不到就创建新节点

4. next 操作

由于原来未修改的代码读写会创建 nil 节点并且部分操作会将节点改为 nil 节点,对于这种没有意义的空值,遍历时必须主动跳过这些节点,否则就会把“逻辑上不存在的元素”又遍历出来。

这也是 firstnode()lua_next() 中大量判断 tag(&node->val) != T_NIL 的原因。

虽然读写时候创建 nil 节点的 bug 已经被修改了,但是 lua_next 函数还是保留了这种跳过的严谨操作。

3.4 词法分析、语法分析和操作码生成

lua 的分析会直接生成字节码:

1. lex.c:把字符流切成 Token

yylex() 负责识别:

  • 标识符与关键字
  • 数字
  • 字符串
  • 运算符
  • 注释
  • $debug / $nodebug

例如字符串字面量在识别后不会直接把内容塞进字节码,而是会调用 lua_findconstant() 把它放进常量表,再把常量索引交给语法分析器。

2. lua.stx:语法规则中直接发射字节码

src/yacc/lua.stx 承担两项职责:

  • 描述 lua 语法
  • 在语法归约时生成字节码

例如算术表达式的语义动作大致就是:

  • 先编译左表达式
  • 再编译右表达式
  • 然后生成 ADDOPSUBOP 等操作码

控制流:

  1. 先用 PrepJump 预留跳转位置
  2. 语句块编译完以后,再用 code_word_at() 回填偏移量

这是单遍编译器的典型写法:边解析,边生成,边回填

3. 局部变量与临时值管理

lua.stx 里还维护了:

  • localvar[]:局部变量表
  • nlocalvar:当前局部变量数量
  • ntemp:表达式求值过程中临时值的数量

帮助生成字节码时,准确知道:

  • 某个名字是局部变量还是全局变量
  • 当前栈帧还剩多少空间
  • 是否需要插入 ADJUST 调整栈布局

3.5 虚拟机执行:lua_execute() 的主循环

解释器的最重要的部分位于 src/opcode.clua_execute()

1. 栈式虚拟机

lua 采用栈类型的虚拟机。大多数指令并不显式写出寄存器编号,而是默认对栈顶附近的数据操作。

这种设计的优点是:

  • 指令格式简单
  • 编译器前端更容易生成代码

缺点是:

  • 当前栈不能出错
  • 调试时必须时刻跟踪 topbase 指针,不然难以理解背后发生了什么

2. topbase

虚拟机维护两个最重要的指针:

  • top:当前栈顶
  • base:当前函数调用帧的基址

进入函数时,base 会移动到参数区;退出函数时,base 再恢复到调用前的位置。会将:

  • 返回地址
  • 旧的 base
  • 参数
  • 局部变量
  • 临时值

全部压在同一个对象栈里。

3. 函数调用布局

CALLFUNC 的处理逻辑说明,Lua 函数调用前的栈布局大致是:

函数对象 | T_MARK | 参数1 | 参数2 | ...

进入 lua 函数后:

  • 原来“函数对象”的位置会被写成返回地址
  • 原来 T_MARK 的位置会被写成旧 base 偏移
  • base 被移动到第一个参数

因此,这种调用操作是对栈空间的复用,很类似于将代码进行反汇编后的地址操作。极大节省了空间占用,但是设计上的细节就多了很多,在阅读代码的时候也是一个难点。

4. C 函数与 Lua 函数共用调用机制

CALLFUNC 发现被调用对象是:

  • T_FUNCTION:跳到 Lua 字节码继续执行
  • T_CFUNCTION:直接调用 C 函数指针

5. 错误处理模式

  • 通过 lua_reportbug() 拼出带文件、函数、行号的信息
  • 最后调用 lua_error()
  • 默认行为是向 stderr 输出消息

4. 追踪解释器工作

示例

-- 示例代码
print(1 + 2)

1. 输入

printf 'print(1+2)\n' | ./bin/lua

主程序 clients/lua/lua.c 会读取这一行,然后调用 lua_dostring()

2. 输入源注册

lua_dostring() 调用 lua_openstring()

  • 记录当前描述
  • 设置输入函数为 stringinput
  • 把行号重置为 1

3. 词法分析阶段

yylex() 会把 print(1+2) 切成 token 序列

4. 语法分析与字节码生成

PUSHGLOBAL  print
PUSHMARK
PUSH1
PUSH2
ADDOP
CALLFUNC
ADJUST 0
HALT

5. 虚拟机执行

  1. 压入 print
  2. 压入 T_MARK
  3. 压入 1
  4. 压入 2
  5. ADDOP 后变成 3
  6. CALLFUNC 识别出 printT_CFUNCTION
  7. lua_print() 通过 lua_getparam(1) 读到参数 3
  8. 输出结果 3

5. 已有修改与 Bug 复盘

5.1 查询与创建逻辑分离(部分见 3.4.3 中分析)

原问题

旧逻辑中,读取表元素和写入表元素共享同一个“查找或创建节点”的函数。这样一来,查找不存在的节点时会悄悄插入 nil 节点到表里。

当前修复

当前源码中已经拆成:

  • lua_hashGet():只读查询
  • lua_hashEnsure():写入前确保节点存在

并且所有调用点也已经随之分流:

  • PUSHINDEXEDlua_getfield()lua_getindexed() 使用只读查询
  • STOREINDEXED0STOREINDEXEDSTORELIST0STORELISTSTORERECORDlua_storefield()lua_storeindexed() 使用创建路径

5.2 针对 test 文件中 save.lua 报错的修改

原问题

lua_pushstring() 的 GC 时序段错误

/**
* @brief 将 string 类型对象压入 lua stack。
*
* @return 压栈成功则返回 0;压栈失败(lua stack 溢出)则返回 1。
* 
* @warning bug:使用 test.lua 文件中的 save.lua 触发段错误。
*          错误类型判断:
*            时序错误。
*          错误原因分析:
*          调用链(string 类型为例):
*            lua_type() -> lua_pushstring() -> 
*            lua_createstring() -> lua_pack() -> 
*            lua_markobject()
*          在 lua_pushstring 函数中在栈创建了对象,对 tag 进行了标记;
*          压栈过程中触发了 GC,然后 GC 扫栈时读到了一个 tag 已经是 T_STRING;
*          但字符串指针还没真正写好的半初始化对象;
*          最后在 markstring(svalue(o)) 这里解引用野指针,导致段错误。
* 
*          时序:
*            理想情况下,先进行赋值后指针上移动;
*            所以赋值的时候这个对象并未压入栈中,GC 扫描不到。
*            但是函数的赋值和 top++ 的完成顺序属于未定义行为;
*            存在先完成 top++ (即完成压栈),再 GC 进行扫描导致崩溃的情况。
*/
int lua_pushstring (char *s)
{
// lua stack 溢出
if ((top-stack) >= MAXSTACK-1)
{
 lua_error ("stack overflow");
 return 1;
}

char* str;
str = lua_createstring(lua_strdup(s));

if (str == NULL) {
 return 1;
}

tag(top) = T_STRING;
svalue(top) = str;
top++;
//  tag(top) = T_STRING; 
//  svalue(top++) = lua_createstring(lua_strdup(s)); // 赋值并将栈顶指针(top)向上移动
return 0;
}

当前修复方式是:

  1. 先完整创建好字符串
  2. 再把 tagsvalue 明确写入当前栈槽
  3. 最后再执行 top++

避免半初始化对象被 GC 看见。

5.3 边界与栈安全修复

1. stringinput() 越界读取(见 details 对原逻辑注释)

旧逻辑的读取顺序可能让指针先跨过字符串结尾,再去访问数据,存在越界风险。当前实现改为先检查:

/**
 * @brief 输入源为字符串时读取字符。
 *
 * @details 先递增指向下一个位置再返回原位置;
 *          `st` 先递增到 `\0` 之后的位置,然后返回原位置的 `\0`。
 * @return 读取到的字符。
 * @warning 这个函数会导致字符串数组的访问越界行为,
 *          需要调用者确保内存安全。
 * @note 是否需要注意 st 为 Null 呢?
 *       如果追求安全,可以先检查当前指针是否指向空字符。
 */
static int stringinput (void)
{
 if(*st == 0) return 0; // 修改了访问越界的 bug,先检查当前指针是否指向空字符。
 st++;
 return (*(st-1));
}

2. lua_pushnil() 缺失 top++

如果压入 nil 后不推进栈顶,那么表面上“压栈成功”,实际上栈状态没有变化,会导致后续调用看到错误的栈布局。

/**
 * @brief 将 nil 类型对象压入 lua stack。
 *
 * @return 压栈成功则返回 0;压栈失败(lua stack 溢出)则返回 1。
 */
int lua_pushnil (void)
{
 // lua stack 溢出
 if ((top-stack) >= MAXSTACK-1)
 {
  lua_error ("stack overflow");
  return 1;
 }
 // tag(top) = T_NIL; // 这里可能是一个bug,没有进行top++
 tag(top++) = T_NIL;
 return 0;
}

3. lua_popfunction() 的下溢保护

函数信息栈如果在空栈状态下继续弹出,就会发生下溢。

/**
 * @brief 将函数信息弹出 function stack。
 *
 * 在执行 RESET 操作码的时候,
 * 将函数对应的文件的文件索引和函数名索引弹出 function stack。
 * @note 补充了下溢报错。
 */
void lua_popfunction (void)
{
 if (nfuncstack <= 0) {
  lua_error("function stack underflow");
  nfuncstack = 0;
  return;
 }
 nfuncstack--; // 计数器自减
}

5.4 其余小问题

1. lua_dostring() 的失败路径清理

旧逻辑中,如果 lua_parse() 失败,字符串输入源不会被关闭。

/**
 * @brief 执行 lua 脚本字符串。
 *
 * 将给定的字符串作为 lua 代码,解析并执行全局代码,
 * 执行结束后自动关闭字符串源头。
 *
 * @param string 脚本字符串。
 * @return 执行成功则返回 0;执行失败则返回错误码 1。
 * @note 原版在此处发生错误时未释放内存,现已修复。
 */
int lua_dostring (char *string)
{
 if (lua_openstring (string)) return 1;
 if (lua_parse ())
 {
  lua_closestring(); // 添加:释放指向字符串源的指针
  return 1;
 }
 lua_closestring();
 return 0;
}

2. lua_closestring() 置空 st

原代码只做文件栈弹出,没有把字符串输入指针清空。这会带来悬空指针风险。

/**
 * @brief 关闭当前已打开的字符串输入源。
 *
 * 该函数用于清理通过 lua_openstring 打开的字符串资源。
 * @warning 原来代码中仅有 lua_delfile 的调用,
 *          会导致 st 指针悬空,造成误用。
 */
void lua_closestring (void)
{
 if (st != NULL) // 为提高代码安全性,将 st 指针置为 Null。
 {
  lua_delfile(); // 将字符串弹出 file stack
  st = NULL;
 }
}

3. lua_createstring() 溢出后的释放

如果字符串表在 GC 后仍然溢出,旧版本会直接报错返回,却没有释放传入的新字符串,造成内存泄漏。

/**
 * @brief 创建新的字符串。
 *
 * 先在字符串表进行查找,如果已经存在了这个字符串,释放传入的字符串,
 * 如果不存在,先检查是否有溢出的危险或者触发 GC,
 * 如果有则先进行垃圾回收并且检查是否还有溢出风险,
 * 如果有则返回 Null,没有则将传入的字符串加入字符串表的结尾。
 *
 * @param s 已经分配了内存(并带有标记位前缀)的字符串。
 * @return 如果成功则返回唯一字符串指针;如果失败则返回 Null。
 * @note 原版本在进行垃圾回收后如果字符串表仍然即将溢出,没有释放字符串 s 的内存空间;
 *       调用 lua_createstring 的地方传入的 s 都是通过 calloc 分配的堆内存,
 *       加入表中也不手动释放,就会导致内存失去跟踪。
 */
char *lua_createstring (char *s)
{
 int i;
 // 如果字符串为空则返回空
 if (s == NULL) return NULL;

 // 遍历字符串表,查找是否已经存在
 for (i=0; i<lua_nstring; i++)
  // 如果在表中找到这个字符串
  if (streq(s,lua_string[i]))
  {
   // 释放传入的字符串 s
   free(s-1);
   // 将存在的唯一字符串指针返回
   return lua_string[i];
  }

 // 如果实例计数器到达阈值(触发GC)或者字符串表即将溢出
 if (lua_nentity == lua_block || lua_nstring >= MAXSTRING-1)
 {
  lua_pack (); // 触发 GC
  // 如果字符串表仍然即将溢出
  if (lua_nstring >= MAXSTRING-1)
  {
   // 抛出 lua_error
   lua_error ("string table overflow");
   free (s-1); // 原版本在这里会造成内存泄漏
   return NULL;
  }
 }
 // 将传入的字符串加入字符串表的结尾并将字符串计数器自增
 lua_string[lua_nstring++] = s;
 lua_nentity++; // 实例计数器自增
 return s;
}

4. lua_executeNOTOP 乱码

执行以下代码:

a = nil
print(a) -- 正确得到 nil

a = not nil
print(a) -- 得到垃圾值

当前修改:

   // 逻辑非
   case NOTOP:
    // 如果栈顶对象是 nil 类型(假)那么设置成 number 类型(真),不是 nil 则设置成 nil
    // tag(top-1) = tag(top-1) == T_NIL ? T_NUMBER : T_NIL;
    // 应该将 nil 反成数字 1 表示真,而不是乱码
    if (tag(top-1) == T_NIL)
    {
     tag(top-1) = T_NUMBER;
     nvalue(top-1) = 1;
    } else
    {
     tag(top-1) = T_NIL;
    }
   break; 

修改后将 not nil 设置成 1, 与部分函数中的操作相同。


收获

从这次实践中,我最大的收获是更具体地理解了:

  • lua 的动态类型在 C 中是如何落地的
  • 表为什么本质上是哈希表
  • 函数调用为什么最终会退化成栈布局操作
  • 垃圾回收为什么必须依赖对象可达性
  • 一个看似很小的时序错误,为什么会在 GC 参与后变成段错误
  • 了解哈希表、链表、虚拟栈的运作等内容

About

Learning and exploring Lua 1.1 source code

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages