Lua
实现了一个安全的运行环境、一套自动内存管理机制、优秀的字符串处理能力和动态大小数据的处理功能。
我们都知道,只要应用程序加入lua
解析器的功能,就能解析lua
脚本。那lua
脚本是怎样执行的?
我们通常用dofile
去打开编译一个lua
脚本。当应用程序调用dofile
后,在执行完脚本后,才能后到主程序中,这lua
脚本相当于一个函数。要等函数执行完才能回到主程序中。
在lua
脚本中,一般有变量,函数,这些东西都保存在一个常规的table
中,这个table
称为“环境”。
loadfile、dofile、require
loadfile——只加载编译,不运行
dofile——执行
require——只执行一次
用require
函数只能加载一次,因为它的特性是:
1、require函数会搜索目录加载文件
2、require会判断是否文件已经加载避免重复加载同一文件
但当有一些特殊的需求需要反复加载某个lua
文件,那如何实现反复加载一个lua
文件?
二次加载前加这一句:
1 | package.loaded[luafile] = nil |
环境相关的变量
这里首先分析几个与环境相关的特殊变量一Global
表 、 env
表 、 registry
表以及UpValue
。
关于前3个表,需要注意以下几点:
Global
表存放在lua State
结构体中也称为G
表 。 每个lua State
结构体都有一个对应的G
表。 不用多说,这个表就是存放全局变量的。env
表存放在Closure
结构体中,也就是每个函数有自己独立的一个环境 。registry
表是全局唯一的,它存放在global_State
结构体中,这个结构体在整个运行环境中只有一个。
查找一个全局变量的操作,其实更精确地说,是在当前函数的env
表中查找 :
1 | case OP_GETGLOBAL: { |
可以看到,这两个操作都是到函数对应的Closure
指针中的env
表去查询数据 。 这里仍然需要提醒一下前面提到的一点,即使对一个没有任何函数的代码而言,分析完毕之后都对应一个Closure
。 因此,这里提到的“当前函数环境”,指的不一定是某一个具体的函数,也可能是一个Lua
文件 。
Lua
提供了几个API
来读取当前函数的环境,分别是getfenv
和setfenv
。
因此,如果执行以下代码:
1 | setfenv(1,{}) |
实际上找不到Lua
标准库提供的print
函数,并且会提示报错attempt to call global ’a’(a nil value)
。 原因就是首先使用 setfenv
函数将当前函数的env
表置为一个空表,此时在当前函数的env
表中查找不到这个名字的函数。
下面来看看函数的env
表是如何创建的 。
在创建一个Closure
对象时,都会调用getcurrenv
函数来获取当前的环境表:
1 | static Table *getcurrenv (lua_State *L) { |
在创建一个新的Closure
时,会调用这个函数返回的结果,对新的Closure
的环境进行赋值。这里可以看出, env
表会逐层继承。
接着来看看registry
表的作用,该表存放在global_State
结构体中,因此里面的内容可供多个lua State
访问 。 另外,这个表只能由C
代码访问,Lua
代码不能访问 。 除此之外,它和普通的表没有什么区别 。
但是需要注意的是,使用普通的对表进行赋值的API
对registry
表进行赋值时,应该使用字符串类型的键。LuaAPI
中对外提供了接口lua_ref
、lua_unref
于和lua_getref
,用于提供在registry
表中存取唯一的数字键。 通过这组API
,使用者不需要关心给某个需要存放到registry
表的数据如何分配一个全局唯一的键,由Lua
解释器自己来保证这一点:
1 |
|
接着来看看这里面lual ref
和luaL_unref
函数的实现。 需要说明的是,在调用luaL_ref
函数之前,需要存放的数据已经位于栈顶:
1 | LUALIB_API int luaL_ref (lua_State *L, int t) { |
这里的设计其实很巧妙,仅使用一个数组就模拟了一个链表的实现,其原理如下:
FREELIST_REF
用于保存当前registry
表中可用键的索引,每次需要存储之前,都会先到这里拿到当前存放的值。- 如果拿出来的值是0 ,说明当前的
hashlist
中还没有数据,直接返回当前registry
表的数据量作为新的索引 。 - 当调用
lual unref
释放一个索引值的时候,将该索引值返回FREELIST REF
链表中 。
下图演示了分配可用索引前后freelist
的变化
最后来看UpValue
。 前面谈到,registry
表提供的是全局变量的存储, env
表提供的是函数内全局变量的存储,而UpValue
用于提供函数内静态变量的存储,这些变量存储的地方,倒不是某个特殊的表,其实就是换算成对应的UpValue
的索引值来访问函数的UpValue
数组而已。
接着我们来看一个关键的函数index2adr
,这个函数集中处理了所有索引值转换为栈地址值的操作,不论该索引是栈上元素的索引,还是前面这几种特殊变量的索引:
1 |
1 | static TValue *index2adr (lua_State *L, int idx) { |
这段代码的逻辑主要是根据传人的idx
的几种情况,分别返回不同的值。
- 如果 idx >O ,那么以 idx值为索引,返回基于 lua State的 base指针的值,也就是相对于战底向上的偏移值。
- 如果 idx>LUA_REGISTRYINDEX ,则以 idx值为索引,返回基于 l ua_State的top指针的值,也就是相对于钱顶向下的偏移值。
- 如果是LUA_REGISTRYINDEX ,那么返回 registry表。
- 如果是LUA ENVIRONINDEX ,那么返回当前函数的env表。
- 如果是LUA GLOBALSINDEX ,那么返回Global表。
- 如果以上都不符合,那么将根据情况返回当前函数的叩value数组中的值。
模块
这一节将讲解Lua
模块相关的知识点,首先介绍模块的加载、编写等原理,然后介绍热更新原理。
模块的加载
在Lua
内部,所有模块的注册都在linit.c
的函数lual_openlibs
中提供。 可以看到,它依次访问lualibs
数组中的成员,这些成员定义了每个模块的模块名及相应的模块注册函数,依次调用每个模块的注册函数完成模块的注册 :
1 | static const luaL_Reg lualibs[] = { |
结构体lual_Reg
有两个变量,分别是模块名以及模块初始化函数。 可以看到,第一个模块是base
模块,其模块名是一个空字符串,因此访问这个模块的函数不需要加模块名前缀,比如我们熟悉的print
函数就是属于这个模块的 。 这就是在调用print
函数时,不需要在前面加模块名前缀的原因 。 这里就以base
模块为例来讲解模块的注册过程。
加载base
模块最终会调用 base_open
函数,下面我们看看这个函数里面最核心的几行代码:
1 | static void base_open (lua_State *L) { |
最开始的两句首先将 LUA_GLOBA LSINDEX对应的值压人拢中,接着调用 lua_setglobal(L ,二C ”) ; , e n 当在 lua_State 的 l_gt表中查找工C”时,查找到的是索引值为 LUA_GLOBALSINDEX的表 。如果觉得有点绕,可以简单理解为,在C表满足这个等式_G = _G [二G”] 。 也就是这个叫_G的表内部有一个key为二G”的表是指向自己的 。 可以在Lua命令行中执行print(_G )和 print(_G [”_G”])看看输出结果,来验证一下这个结论。
我猜想这么处理的理由是 : 为了让G表和其他表使用同样的机制 。 查找变量时,最终会一直顺着层次往上查到G表中,这是很自然的事情 。 所以,为了也能按照这个机制顺利地查找到自己,于是在G表中有一个同名成员指向自己 。
好了,前两句的作用已经分析完毕,其结果有以下两个 :
- _G = _G [”_G”]
- G表的值压入函数枝中方便后面的调用 。
所以,这个G表的注册操作需要在所有模块注册之前进行。
在第63 1行中, base_fu n cs也是一个lual_Reg数组,上面的操作会将base_funcs数组中的函数注册到G表中,但是里面还有些细节需要看看。 这个操作最终会调用函数luaI←openlib:
1 | LUALIB_API void luaI_openlib (lua_State *L, const char *libname, |
注册这些函数之前,首先会到registry [二LOADED"]表中查找该库,如果不存在,则在G表中查找这个库,若不存在则创建一个表。
因此,不管是Lua内部的库还是外部使用require引用的库,首先会到 registry [”一LOADED ”] 中存放该库的表。 最后,再遍历传进来的函数指针数组,完成库函数的注册。
比如,注册as . print时,首先将print函数绑定在一个函数指针上,再去l_registry[_LOADED]和G表中查询名为OS的库是否存在,不存在则创建一个表,即 :
1 | G[”OS"] = {} |
紧跟着注册print函数,即: G [”os ”][ ” print ”]=待注册的函数指针。
这样在调用os . print(1)时,首先根据OS到G表中查找对应的表,再在这个表中查找print成员得到函数指针,最后完成函数的调用 。
模块的编写
在定义Lua模块时,第一句代码一般都是module(xxx ) 。 module调用的对应C函数是loadlib.c中的函数ll_module:
1 | static int ll_module (lua_State *L) { |
代码的前半部分首先根据module(XXX)中的模块名去registry [“_LOADED”]表中查找,如果找不到,则创建一个新表,这个表为_G [” xxx叮= registry [二LOADED ”][ “XXX "] 。 换言之,这个名为xxx 的模块本质上是一个表,这个表存储了这个模块中的所有变革-以及函数,它既可以通过一G [” xxx”]来访问,也可以通过registry [二 LOADED ”][ “XXX ”]来访问 。
紧跟着,在modi nit 函数中,将这个表的成员 K NAME 、 PACKAGE分别赋值。
最后,调用 setfenv将该模块对应的环境置空 。 根据前面的分析, setfenv将该模块对应的环境置空就是将这个模块分析完毕之后返回的Closure对应的env环境表置空 。 这意味着,前面的所有全局变量都看不见了,比如下面的代码中 :
1 | myprint=print |
这里首先将全局函数printl赋值给全局变量myprint ,第二行代码可以正常调用这个函数。但当调用module声明 test模块之后,在此之前的全局变量myprint被清空,第四行代码调用myprint函数时就会报错,错误信息是attempt to call global ‘myprint’(a nil value),因为此时已经查不到这个变量了 。
如果写下的是module(xxx,package . seeall)呢?它将会调用后面的dooptions 函数并且最后调用 package.seeall对应的处理函数:
1 | static int ll_seeall (lua_State *L) { |
这个函数就两个作用 : 一个是创建该模块对应表的metatable , 另一个是将meta表的 index指向 G表。 也就是说,所有在该模块中找不到的变量都会去 G表中查找 。 可以看到,这里的操作并不会把环境表清空 。 因此,如果把前面的代码改成这样,就可以正确执行:
1 | myprint=print |
根据前面对module函数的分析,得出以下几个结论。
- 创建模块时会创建一个表,该表挂载在registry [ ”一LOADED ' ’ ]、_G [模块名]下 。 自然而然地,该模块中的变量(函数也是一种变量)就会挂载到这个表里面 。
- 在 module 函数的参数中写下 package.seeall将会创建该表的 metatable ,同时该表的index将指向 G表。 简单地说,这个模块将可以看到所有全局环境下的变量(这里再提醒一次,函数也是一种变量) 。
明白了 module 背后的作用,再来看看 require 函数,它对应的处理函数是 loadlib.c 中的ll_require 函数,这个函数做了如下几件事情 。
- 首先在 registry[ “_LOADED”]表中查找该库,如果已存在,说明是已经加载过的模块,不再重复加载直接返回。
- 在当前环境表中查找 loaders变量,这里存放的是所有加载器组成的数组 。 在 Lua代码中,有4个loader :加载时,会依次调用 loaders数组中的四种 loader 。 如果加载的结果在Lua找中返回的是函数(前面提过,分析完Lua源代码文件,返回的是Closure ),那么说明加载成功,不再继续往下调用其他的 loader加载模块 。
1
2static const lua_CFunction loaders[] =
{loader_preload, loader_Lua, loader_C, loader_Croot, NULL};
最后,调用lua call函数尝试加载该模块。 加载之前,在L回校中压入一个哨兵值sentinel,如果加载完毕之后这个值没有被改动过,则说明加载完毕,将registry [ ”_LOADED”]赋值为true表示加载成功 。
模块的热更新原理
能很好地支持代码热更新机制,是开发时选择使用脚本语言的原因之一 。 热更新的好处很在于,能在不重启程序或者发布新版本的情况下更新脚本,给调试和线上解决问题带来很大的便利,对开发效率有很大的提升 。
下面就来谈谈如何实现热更新 。先简单回顾之前提过的模块和lrequire机制 。 Lua内部提供了一个require 函数来实现模块的加载,它做的事情主要有以下几个。
- 在registry [二LOADED”]表中判断该模块是否已经加载过了,如果是则返回,避免重复加载某个模块代码 。
- 依次调用注册的 loader来加载模块 。
- 将加载过的模块赋值给registry [”一LOADED "]表。
而如果要实现Lua的代码热更新,其实也就是需要重新加载某个模块,因此就要想办法让Lua虚拟机认为它之前没有加载过。 查看Lua代码可以发现 , registry [”一LOADED ”]表实际上对应的是package.loaded表,这在以下函数中有体现:因此,事情就很简单了,需要提供require_ex函数,可以把它理解为require的增强版 。 使用这个函数,可以动态更新某个模块的代码:1
2
3
4
5
6LUALIB_API int luaopen_package (lua_State *L) {
int i;
/* create new type _LOADLIB */
luaL_newmetatable(L, "_LOADLIB");
lua_pushcfunction(L, gctm);
lua_setfield(L, -2, "__gc");这个函数做的事情一目了然 。 首先,判断是否曾经加载过这个模块,如果有,则打印一条日志,表示需要重新加载某个模块,然后将该模块原来在表中注册的值赋空,然后再次调用require进行模块的加载和注册。1
2
3
4
5
6
7
8干unction require_ex( _mname )
print( string .format("require_ex =喃5 ”,_mname) )
if package.loaded[_mname] then
print( string.format(”require_ex module[ %s] reload”,_mname))
end
package .loaded[_mname] = nil
require( _mname )
end
一般热更新都是函数的实现,所以需要对全局变量做一些保护 。 比如,当前某全局变量为 100 ,表示某个操作已经进行了 100次,它不能因为热更新重置为0 ,所以要对这些不能改变的全局变量做一个保护,最简单的方式就是这样 :
1 | a = a or o |
这个原理很简单,只有当前a这个变量没有初始值的时候才会赋值为0 ,而后面不管这个Lua文件被加载多少次, a者~J之会因为重新加载了Lua代码而发生改变 。
从lua-5.1.1中分离出来的lenv实现代码
reference
《lua设计与实现》
lua-5.1.1源码