项目根目录提供了一个历史风格的构建脚本 domake:
./domake它本质上只是导出当前目录为 LUA 环境变量,然后调用顶层 Makefile。顶层 Makefile 再分别进入以下三个子目录完成构建:
src/:构建解释器核心库clients/lib/:构建标准库clients/lua/:构建命令行解释器
- 环境信息: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 的可执行文件。
lua 解释器允许两种运行方式
- 直接执行 Lua 文件
./bin/lua test/save.lua- 交互式从标准输入读取脚本行
./bin/lua
print("hello world")- 进入 main() 入口函数
- 注册 clients/lib 文件夹中的标准库函数
- 判断输入来源
- 文件:通过 lua_openfile 打开
- 交互形式:通过 lua_openstring 打开
- lex yylex 进行词法分析
- lua.stx 中 yyparce 进行语法分析
- 生成字节码并且通过 lua_execute 进行执行
- 创建数据
- tabel.c:符号表、常量表、字符串表
- hash.c:lua 中的 tabel 数据类型
- 对虚拟栈以及全局符号表进行 GC 标记,并回收字符串和数组对象
Object是 lua 中的对象结构,对值的统一表示- 虚拟栈负责表达式求值、函数调用
Hash是 lua 中 tabel 结构的底层实现的容器- 全局符号表、常量表、字符串表都是为 lua 解释器提供数据的
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 标签,并没有很深奥的东西。
lua 中很多栈都是采用了 C 中的数组,在通过指针访问的时候很容易超出栈底造成内存不安全;虚拟栈中压入了大量的数据和函数方法需要加以区分。对于这些问题,引入了不对用户可见的 T_MARK 类型。主要用于:
- 栈底哨兵,防止栈的超出
- 函数调用时虚拟栈中的分隔
- 参数边界的定位
src/tabel.c 中设计了三个关键的表用于辅助 lua 的正常运行。
它保存所有全局变量及其当前值。初始化时,解释器会预装入几个内建函数:
typetonumbernextnextvarprintdofiledostring之后,clients/lua/lua.c中又会注册iolib、strlib、mathlib提供的更多标准库函数。
查找全局变量时,lua_findsymbol() 额外维护了一个 searchlist,名字一旦被访问到,就把对应节点移到链表前端,从而加速后续访问。是一种对于访问速度的优化策略。
它保存编译期出现的字符串常量,例如:
- 关键类型名
"number"、"string"、"table" - 源码中的字符串字面量
- 记录字段名
词法分析阶段识别出字符串后,会把字符串放入常量表,并返回一个索引。后续字节码中并不直接嵌入完整字符串,而是通过 PUSHSTRING + 常量索引 的形式引用它。
这样避免了相同字符串的重复创建。
运行时动态产生的字符串不会随便散落在堆里,而是统一交给 lua_createstring() 管理。
运行机制:
- 先查字符串表,看是否已有同内容字符串
- 如果已有,就释放新分配的副本,直接复用旧指针
- 如果没有,就把它插入字符串表
提高了底层数据管理的效率,并且方便后续 GC 对于对象的管理
在 lua_pack() 中触发,其流程非常清晰:
- 从 lua 虚拟栈出发标记对象
- 从全局符号表出发继续标记对象
- 回收未标记的字符串
- 回收未标记的表
- 清空本轮标记,等待下一次 GC
对于字符串的标记有一个设计:字符串分配内存空间时,前面会多预留一个位置作为 GC 标记位,不暴露给普通字符串接口,只会暴露给 GC 操作的函数。
对比 C 语言,lua 最独特的数据结构是 Table(表结构),其底层由 src/hash.c 实现,其结构是:
Hash:哈希表头,记录桶数nhash、桶数组list、GC 标记markNode:链表节点,保存键ref、值val和下一节点next
对于 Node 的设计源自于 Tabel 结构本质上是一个映射:
key -> value
所以每个节点需要:
ref:键对象val:值对象
这里 ref 和 val 都是 Object,键和值本身也都拥有完整的动态类型信息。
为了解决常见的哈希冲突,lua 设计了哈希桶的结构,将冲突的元素放入同一个桶中构成一个链表。
支持两类键进行查找桶索引:
- number:直接取模
- string:逐字符移位累加后取模
这正好对应 lua Table 结构中里最常见的两类索引方式:
- 数组式索引:
t[1] - 字典式索引:
t["name"]
历史版本的做法是:查找失败时顺手创建一个值为 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() |
找不到就创建新节点 |
由于原来未修改的代码读写会创建 nil 节点并且部分操作会将节点改为 nil 节点,对于这种没有意义的空值,遍历时必须主动跳过这些节点,否则就会把“逻辑上不存在的元素”又遍历出来。
这也是 firstnode() 和 lua_next() 中大量判断 tag(&node->val) != T_NIL 的原因。
虽然读写时候创建 nil 节点的 bug 已经被修改了,但是 lua_next 函数还是保留了这种跳过的严谨操作。
lua 的分析会直接生成字节码:
yylex() 负责识别:
- 标识符与关键字
- 数字
- 字符串
- 运算符
- 注释
$debug/$nodebug
例如字符串字面量在识别后不会直接把内容塞进字节码,而是会调用 lua_findconstant() 把它放进常量表,再把常量索引交给语法分析器。
src/yacc/lua.stx 承担两项职责:
- 描述 lua 语法
- 在语法归约时生成字节码
例如算术表达式的语义动作大致就是:
- 先编译左表达式
- 再编译右表达式
- 然后生成
ADDOP、SUBOP等操作码
控制流:
- 先用
PrepJump预留跳转位置 - 语句块编译完以后,再用
code_word_at()回填偏移量
这是单遍编译器的典型写法:边解析,边生成,边回填。
lua.stx 里还维护了:
localvar[]:局部变量表nlocalvar:当前局部变量数量ntemp:表达式求值过程中临时值的数量
帮助生成字节码时,准确知道:
- 某个名字是局部变量还是全局变量
- 当前栈帧还剩多少空间
- 是否需要插入
ADJUST调整栈布局
解释器的最重要的部分位于 src/opcode.c 的 lua_execute()。
lua 采用栈类型的虚拟机。大多数指令并不显式写出寄存器编号,而是默认对栈顶附近的数据操作。
这种设计的优点是:
- 指令格式简单
- 编译器前端更容易生成代码
缺点是:
- 当前栈不能出错
- 调试时必须时刻跟踪
top和base指针,不然难以理解背后发生了什么
虚拟机维护两个最重要的指针:
top:当前栈顶base:当前函数调用帧的基址
进入函数时,base 会移动到参数区;退出函数时,base 再恢复到调用前的位置。会将:
- 返回地址
- 旧的
base - 参数
- 局部变量
- 临时值
全部压在同一个对象栈里。
CALLFUNC 的处理逻辑说明,Lua 函数调用前的栈布局大致是:
函数对象 | T_MARK | 参数1 | 参数2 | ...
进入 lua 函数后:
- 原来“函数对象”的位置会被写成返回地址
- 原来
T_MARK的位置会被写成旧base偏移 base被移动到第一个参数
因此,这种调用操作是对栈空间的复用,很类似于将代码进行反汇编后的地址操作。极大节省了空间占用,但是设计上的细节就多了很多,在阅读代码的时候也是一个难点。
当 CALLFUNC 发现被调用对象是:
T_FUNCTION:跳到 Lua 字节码继续执行T_CFUNCTION:直接调用 C 函数指针
- 通过
lua_reportbug()拼出带文件、函数、行号的信息 - 最后调用
lua_error() - 默认行为是向
stderr输出消息
-- 示例代码
print(1 + 2)printf 'print(1+2)\n' | ./bin/lua主程序 clients/lua/lua.c 会读取这一行,然后调用 lua_dostring()。
lua_dostring() 调用 lua_openstring():
- 记录当前描述
- 设置输入函数为
stringinput - 把行号重置为 1
yylex() 会把 print(1+2) 切成 token 序列
PUSHGLOBAL print
PUSHMARK
PUSH1
PUSH2
ADDOP
CALLFUNC
ADJUST 0
HALT
- 压入
print - 压入
T_MARK - 压入
1 - 压入
2 ADDOP后变成3CALLFUNC识别出print是T_CFUNCTIONlua_print()通过lua_getparam(1)读到参数3- 输出结果
3
旧逻辑中,读取表元素和写入表元素共享同一个“查找或创建节点”的函数。这样一来,查找不存在的节点时会悄悄插入 nil 节点到表里。
当前源码中已经拆成:
lua_hashGet():只读查询lua_hashEnsure():写入前确保节点存在
并且所有调用点也已经随之分流:
PUSHINDEXED、lua_getfield()、lua_getindexed()使用只读查询STOREINDEXED0、STOREINDEXED、STORELIST0、STORELIST、STORERECORD、lua_storefield()、lua_storeindexed()使用创建路径
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;
}当前修复方式是:
- 先完整创建好字符串
- 再把
tag和svalue明确写入当前栈槽 - 最后再执行
top++
避免半初始化对象被 GC 看见。
旧逻辑的读取顺序可能让指针先跨过字符串结尾,再去访问数据,存在越界风险。当前实现改为先检查:
/**
* @brief 输入源为字符串时读取字符。
*
* @details 先递增指向下一个位置再返回原位置;
* `st` 先递增到 `\0` 之后的位置,然后返回原位置的 `\0`。
* @return 读取到的字符。
* @warning 这个函数会导致字符串数组的访问越界行为,
* 需要调用者确保内存安全。
* @note 是否需要注意 st 为 Null 呢?
* 如果追求安全,可以先检查当前指针是否指向空字符。
*/
static int stringinput (void)
{
if(*st == 0) return 0; // 修改了访问越界的 bug,先检查当前指针是否指向空字符。
st++;
return (*(st-1));
}如果压入 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;
}函数信息栈如果在空栈状态下继续弹出,就会发生下溢。
/**
* @brief 将函数信息弹出 function stack。
*
* 在执行 RESET 操作码的时候,
* 将函数对应的文件的文件索引和函数名索引弹出 function stack。
* @note 补充了下溢报错。
*/
void lua_popfunction (void)
{
if (nfuncstack <= 0) {
lua_error("function stack underflow");
nfuncstack = 0;
return;
}
nfuncstack--; // 计数器自减
}旧逻辑中,如果 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;
}原代码只做文件栈弹出,没有把字符串输入指针清空。这会带来悬空指针风险。
/**
* @brief 关闭当前已打开的字符串输入源。
*
* 该函数用于清理通过 lua_openstring 打开的字符串资源。
* @warning 原来代码中仅有 lua_delfile 的调用,
* 会导致 st 指针悬空,造成误用。
*/
void lua_closestring (void)
{
if (st != NULL) // 为提高代码安全性,将 st 指针置为 Null。
{
lua_delfile(); // 将字符串弹出 file stack
st = NULL;
}
}如果字符串表在 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;
}执行以下代码:
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 参与后变成段错误
- 了解哈希表、链表、虚拟栈的运作等内容