Skip to content

About

Learning and exploring Lua 1.1 source code

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

24 Commits

Folders and files

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_CFUNCTION 或 T_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 中又会注册 iolib、strlib、mathlib 提供的更多标准库函数。

查找全局变量时,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:值对象

这里 ref 和 val 都是 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 语法
  • 在语法归约时生成字节码

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

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

控制流:

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

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

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

lua.stx 里还维护了:

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

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

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

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

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

1. 栈式虚拟机

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

这种设计的优点是:

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

缺点是:

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

2. top 与 base

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

  • 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 识别出 print 是 T_CFUNCTION
  7. lua_print() 通过 lua_getparam(1) 读到参数 3
  8. 输出结果 3

5. 已有修改与 Bug 复盘

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

原问题

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

当前修复

当前源码中已经拆成:

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

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

  • PUSHINDEXED、lua_getfield()、lua_getindexed() 使用只读查询
  • STOREINDEXED0、STOREINDEXED、STORELIST0、STORELIST、STORERECORD、lua_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. 再把 tag 和 svalue 明确写入当前栈槽
  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_execute 中 NOTOP 乱码

执行以下代码:

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

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages