前言

终于来到我盼望已久的源码阅读了,但我又开始思考一个我经常问的问题,源码该怎么读?是否要全读一遍呢?比如我们的lua是一个类编译器,有必要去读大家都学过的词法分析和句法分析部分吗?读源码要从入口函数main开始吗?读源码更重要的是读出思想,如果不懂得几种程序的设计模式没有摆脱以往的思想,你会发现java源码,怎么也读不懂,甚至会觉得莫名其妙。实际上理解设计模式的好处,就能体会更加高效的编程,这才是我们学习的目的。
读源码,我们首先应该去读程序所定义的数据结构,在C语言里是struct,而java等面向对象的语言里就是class。在如今模块化的编程时代,我们大部分都有一直面向对象的编程思想,在我看来这是一种优秀的数据组织形式,它将大部分的实体与相应操作全都封装了,对于先整体后局部读源码有许多便利之处。多说无益,我们直接开始lua的源码之旅吧。

lua解释器

我们先来看看官方程序是如何利用lua虚拟机实现解释器的。

main函数

lua的入口函数长这样

lua.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int main (int argc, char **argv) {
int status;
struct Smain s;
lua_State *L = lua_open(); /* create state */
if (L == NULL) {
l_message(argv[0], "cannot create state: not enough memory");
return EXIT_FAILURE;
}
s.argc = argc;
s.argv = argv;
status = lua_cpcall(L, &pmain, &s);
report(L, status);
lua_close(L);
return (status || s.status) ? EXIT_FAILURE : EXIT_SUCCESS;
}

int status;声明一个用来存储返回状态的变量,0表示一切正常,都是C的基本内容了。为什么我可以看出来?看最后一句return (status || s.status) ? EXIT_FAILURE : EXIT_SUCCESS;,0也表示false,这是我们发现了另一个结构体struct Smain s;,它的定义在上面几行:

lua.c
1
2
3
4
5
struct Smain {
int argc;
char **argv;
int status;
};

意思也很明显,用来存储参数和返回状态,不知你有没有一个疑问,为什么会有两个status,实际上,这属于调试范畴的东西,我们使用的lua也是一门程序设计语言,当然也有报错的时候,但程序内部会对于lua的错误会存入debug部分,也就是说两个状态分别对应C的运行状态与lua的运行状态,现在少将点,对于Debug的技术,我想另外写一篇文章,所以以后我们跳过debug了哦。
lua_State *L = lua_open();创建一个虚拟机,我们搞嵌入开发的时候再熟悉不过了。这里我们要注意一点,我们的读的是lua程序的源码(就是指lua.c这个文件),而不是lua这门语言的源码,就比如Smain这个在lua.c里定义的结构体,我们搞嵌入开发实际上是用不了的。我们阅读源码遵循先整体后局部的思想,目的是减少读我们不需要的源码,提高阅读效率,应该理解哦。

1
2
3
4
if (L == NULL) {
l_message(argv[0], "cannot create state: not enough memory");
return EXIT_FAILURE;
}

虚拟机创建失败,则报内存不够的错误,值得注意在C里面并没有try和catch进行专业的错误捕捉,一切基本通过返回值是否为NULL或-1来判断。至于创建虚拟机可能出现的问题是lua源码的事,我们等下再说。l_message()是封装在这个文件的报错处理函数,不是太重要。

1
2
s.argc = argc;
s.argv = argv;

将参数传递给Smain,lua_close(L);关闭虚拟机,这些没什么好说的,我们重点关注中间两句话,这应该是核心部分。

1
2
status = lua_cpcall(L, &pmain, &s);
report(L, status);

lua_cpcall()是封装在lua里的函数,参数会作为一个lightuserdata(实际上就是一个void*,可以随意使用,具体转型要自己写,你自己传的东西还不知道吗)放入栈顶,然后调用pmain函数,调用也是使用lua虚拟机来运行,与lua_call一样调用C的函数,不过可以只在pmain依赖栈来运行,所以你做不了什么返回值操作,只有lua系统的返回值。而report(L, status);则是一个用于打印报错信息的函数,属于Debug部分。

pmain函数

我们来看pmain的实现:

lua.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
static int pmain (lua_State *L) {
struct Smain *s = (struct Smain *)lua_touserdata(L, 1);
char **argv = s->argv;
int script;
int has_i = 0, has_v = 0, has_e = 0;
globalL = L;
if (argv[0] && argv[0][0]) progname = argv[0];
lua_gc(L, LUA_GCSTOP, 0); /* stop collector during initialization */
luaL_openlibs(L); /* open libraries */
lua_gc(L, LUA_GCRESTART, 0);
s->status = handle_luainit(L);
if (s->status != 0) return 0;
script = collectargs(argv, &has_i, &has_v, &has_e);
if (script < 0) { /* invalid args? */
print_usage();
s->status = 1;
return 0;
}
if (has_v) print_version();
s->status = runargs(L, argv, (script > 0) ? script : s->argc);
if (s->status != 0) return 0;
if (script)
s->status = handle_script(L, argv, script);
if (s->status != 0) return 0;
if (has_i)
dotty(L);
else if (script == 0 && !has_e && !has_v) {
if (lua_stdin_is_tty()) {
print_version();
dotty(L);
}
else dofile(L, NULL); /* executes stdin as a file */
}
return 0;
}
1
2
struct Smain *s = (struct Smain *)lua_touserdata(L, 1);
char **argv = s->argv;

从栈顶取出由userdata封装的参数,对于lightuserdata与userdata的区别,其实如果从C语言本身来看都是*void指针,实际上这和垃圾回收有关,我们来看一下lua的数据类型TValue是怎么定义的(lobject.h):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/*
** Union of all Lua values
*/
typedef union {
GCObject *gc;
void *p;
lua_Number n;
int b;
} Value;
/*
** Tagged Values
*/
#define TValuefields Value value; int tt
typedef struct lua_TValue {
TValuefields;
} TValue;

tt用来标识类别,value存储具体数据。在Value里,我们看到两种指针类型GCObject和void,实际上分别对于了userdata和lightuserdata也就是说,后者不纳入lua的垃圾回收器,需要用户自己进行内存回收,而且lua的API只又lua_pushlightuserdata,也就是说你自己在C里创建的东西自己去管理吧。
这里还要讲的是数据在lu虚拟机里的索引,我们看到这里它不像我们一样使用-1来索引栈顶数据,而使用了1,这时我们可能会好奇0是什么,为了理解我们随便取一个索引函数看一下源码(lua_tonumber() in lapi.c):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
static TValue *index2adr (lua_State *L, int idx) {
if (idx > 0) {
TValue *o = L->base + (idx - 1);
api_check(L, idx <= L->ci->top - L->base);
if (o >= L->top) return cast(TValue *, luaO_nilobject);
else return o;
}
else if (idx > LUA_REGISTRYINDEX) {
api_check(L, idx != 0 && -idx <= L->top - L->base);
return L->top + idx;
}
else switch (idx) { /* pseudo-indices */
case LUA_REGISTRYINDEX: return registry(L);
case LUA_ENVIRONINDEX: {
Closure *func = curr_func(L);
sethvalue(L, &L->env, func->c.env);
return &L->env;
}
case LUA_GLOBALSINDEX: return gt(L);
default: {
Closure *func = curr_func(L);
idx = LUA_GLOBALSINDEX - idx;
return (idx <= func->c.nupvalues)
? &func->c.upvalue[idx-1]
: cast(TValue *, luaO_nilobject);
}
}
}
LUA_API lua_Number lua_tonumber (lua_State *L, int idx) {
TValue n;
const TValue *o = index2adr(L, idx);
if (tonumber(o, &n))
return nvalue(o);
else
return 0;
}

index2adr()是最终提供索引结果的函数,并且返回相应的TValue。如果idx>0则返回base+(idx-1)处的值,如果idx>LUA_REGISTRYINDEX则返回top+indx,剩下的我们基本没用,因为在lua.h里我们可以看到#define LUA_REGISTRYINDEX (-10000),也就是说,0返回top,-1返回top-1,1返回base+1,这里我们涉及了lua虚拟机的两个指针top和base,而且top并非指向最上面的元素,而是指向NULL,还记得我们之前用过的lua_gettop()吗?(lapi.c):

1
2
3
4
#define cast_int(i)     cast(int, (i))
LUA_API int lua_gettop (lua_State *L) {
return cast_int(L->top - L->base);
}

这里我们要讲一下base指针了,实际上这存在与函数调用的时候,我们知道lua调用函数的时候都会先将函数入栈,再入参数,实际上调用lua_pcall或lua_call的时候,lua系统会将base指针重新定位为这个函数,所以base+i就会是第i个参数,如果你干过反编译的话,就会发现每一个函数闭包好像都有一个小的虚拟栈,实际上都是lua虚拟机的一部分,base指针以上就是这个小虚拟机,有没有觉得这是一种挺有趣的思想。

1
2
3
4
int script;
int has_i = 0, has_v = 0, has_e = 0;
globalL = L;
if (argv[0] && argv[0][0]) progname = argv[0];

前两组变量用来存储lua程序的参数解析结果,lua实际还有好几种用法比如lua -v显示版本信息,lua -i启动交互解释器,等同于lua。这不是我们关注的重点。globalL和progname用来存储程序使用的lua虚拟机和程序名,定义在程序前面:

1
2
static lua_State *globalL = NULL;
static const char *progname = LUA_PROGNAME;

接下来是,向虚拟机导入库函数,涉及一些gc操作,主要为了导入后,让库函数不要在回收的对象里。
collectargs()用来匹配参数,选项存储到has_i , has_v, has_e里面,返回脚本文件名(非’-‘开头)在参数组里的索引。
if{}用来判断是否有脚本参数,否则打印使用信息。如果有has_v则打印版本信息。runargs()针对参数,执行相应操作如-e -l等。if (s->status != 0) return 0;用来检验运行是否正常,属于”常规操作”了。
handle_script(L, argv, script);是我们处理lua脚本的主要文件了,后面几句是用来进入交互环境的,核心是dotty(L);函数。

handle_script函数

我们先往上看前一个:

lua.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static int handle_script (lua_State *L, char **argv, int n) {
int status;
const char *fname;
int narg = getargs(L, argv, n); /* collect arguments */
lua_setglobal(L, "arg");
fname = argv[n];
if (strcmp(fname, "-") == 0 && strcmp(argv[n-1], "--") != 0)
fname = NULL; /* stdin */
status = luaL_loadfile(L, fname);
lua_insert(L, -(narg+1));
if (status == 0)
status = docall(L, narg, 0);
else
lua_pop(L, narg);
return report(L, status);
}

int status;const char *fname;
存储状态和脚本文件名,直接过。getargs(L, argv, n);lua_setglobal(L, "arg");主要用来将参数放入lua虚拟机的全局变量arg,getargs返回参数的个数。这里指的是对于lua脚本而言的参数,对于lua程序而言就是脚本名后面的参数。官方也已经给出了说明:

$ lua -la b.lua t1 t2

the table is like this:

arg = { [-2] = “lua”, [-1] = “-la”,
[0] = “b.lua”,
[1] = “t1”, [2] = “t2” }

接下来的句子是用来检测的,一般都会通过,但还是以防万一。luaL_loadfile(L, fname);载入脚本,在栈顶产生一个函数闭包来封装脚本。lua_insert(L, -(narg+1));将栈顶函数下移narg+1,这里有点忘说了,在执行getargs(L, argv, n);后,此时栈已经多了narg个参数和一个包含参数的表,执行lua_setglobal(L, "arg");后栈上实际上还有narg个参数在上面,此时函数与参数在栈上的位置实际反了,所以要执行lua_insert来纠正,其实本身没有必要因为在lua脚本里我们又不能直接调用虚拟机上的参数。
后面比较简单了,如果status正常则直接docall()执行代码,否则弹出函数和参数,通过report()报错。
我们来看看docall()

1
2
3
4
5
6
7
8
9
10
11
12
13
static int docall (lua_State *L, int narg, int clear) {
int status;
int base = lua_gettop(L) - narg; /* function index */
lua_pushcfunction(L, traceback); /* push traceback function */
lua_insert(L, base); /* put it under chunk and args */
signal(SIGINT, laction);
status = lua_pcall(L, narg, (clear ? 0 : LUA_MULTRET), base);
signal(SIGINT, SIG_DFL);
lua_remove(L, base); /* remove traceback function */
/* force a complete garbage collection in case of errors */
if (status != 0) lua_gc(L, LUA_GCCOLLECT, 0);
return status;
}

仔细一看,它写得还挺专业的。先记录status和base的位置,推入traceback函数并移入底部(base处),这个主要用于在lua脚本里来进行debug报错,跳过,我以后专门来讲。然后监听中断信号,的确考虑挺多的。lua_pcall()执行函数。然后就是各种恢复操作了——移除中断,移除traceback,进行垃圾回收。看得我自己都忏愧了,自己用惯了那些自带垃圾回收的语言,写C的时候都没考虑过这么多,不过操作系统实际上也会进行管理,不用过于担心就是了。

dotty函数

这是用来处理交互环境时的函数,代码如下:

lua.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
static void dotty (lua_State *L) {
int status;
const char *oldprogname = progname;
progname = NULL;
while ((status = loadline(L)) != -1) {
if (status == 0) status = docall(L, 0, 0);
report(L, status);
if (status == 0 && lua_gettop(L) > 0) { /* any result to print? */
lua_getglobal(L, "print");
lua_insert(L, 1);
if (lua_pcall(L, lua_gettop(L)-1, 0, 0) != 0)
l_message(progname, lua_pushfstring(L,
"error calling " LUA_QL("print") " (%s)",
lua_tostring(L, -1)));
}
}
lua_settop(L, 0); /* clear stack */
fputs("\n", stdout);
fflush(stdout);
progname = oldprogname;
}

前后都是比较常规的操作,重点在while循环里面。首先会执行loadline(L),这个函数其实挺复杂的,等下我们再说吧,总之最后在栈顶会产生一个无参数的函数,而下面还存储了需要打印信息(后面解释)。然后调用docall()执行这个函数,然后打印需要打印的信息,这是交互环境特有的一个特性,主要用来监听变量信息。比如你之前有一个全局变量a,在交互环境内就可以直接输入a,就可以打印a的值了,其实等同于代码print(a),但更加方便了。
接下来我们看看loadline():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static int loadline (lua_State *L) {
int status;
lua_settop(L, 0);
if (!pushline(L, 1))
return -1; /* no input */
for (;;) { /* repeat until gets a complete line */
status = luaL_loadbuffer(L, lua_tostring(L, 1), lua_strlen(L, 1), "=stdin");
if (!incomplete(L, status)) break; /* cannot try to add lines? */
if (!pushline(L, 0)) /* no more input? */
return -1;
lua_pushliteral(L, "\n"); /* add a new line... */
lua_insert(L, -2); /* ...between the two lines */
lua_concat(L, 3); /* join them */
}
lua_saveline(L, 1);
lua_remove(L, 1); /* remove line */
return status;
}

lua_settop(L, 0);依据base指针移动top指针位置,都是防止出问题的措施,跳过。pushline()用于将用户输入的一行数据压入虚拟机栈,有0和1两个参数,因为C没有bool类型,所以以整数代替,1表示非函数内输入。这里需要讲一下,lua交互环境的运行状态了,它有两种情况,在命令行的表现是以>>>开头,>开头属于可以直接执行的状态,输入一行执行一行,此时参数为1,>>一般在输入function后进入,此时你基本可以任意输入,直到输入end结尾,当然还有出现内部函数的情况,此时参数为0。感觉把后面的都说了,我们继续看吧。
接下来是一个for循环,直到输入一行,这里的一行是个抽象意,处于>状态回车后就是一行,而>>状态需要整个函数闭包输入结束才行。luaL_loadbuffer()载入栈顶的数据,incomplete()根据返回状态,判断是否结束。这就是我之前说的。如果输入的一句话合格的话,就会直接break调,否则开始pushline()处于>>状态的代码,后面部分就是将新读取的字符串拼接了。这里实际上有几个问题没有解决,就是luaL_loadbuffer()对错误返回的结果问题,这个先留到后面,到时候我们会深入分析lua_load函数,最后两句用处不大,直接跳过。这是我们要注意一下,栈顶存在的字符串最后都会被luaL_loadbuffer()掉,也就是说,此函数最后的结果如我们之前所说,是一个函数。

lua编译器

接下来我们将目光投向lua的编译器,但我么需要注意lua并不能编译为操作系统的可执行文件,而是lua虚拟机的字节码文件。

main函数

luac.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int main(int argc, char* argv[])
{
lua_State* L;
struct Smain s;
int i=doargs(argc,argv);
argc-=i; argv+=i;
if (argc<=0) usage("no input files given");
L=lua_open();
if (L==NULL) fatal("not enough memory for state");
s.argc=argc;
s.argv=argv;
if (lua_cpcall(L,pmain,&s)!=0) fatal(lua_tostring(L,-1));
lua_close(L);
return EXIT_SUCCESS;
}

我们发现大部分代码类似于lua解释器。doargs()用来进行参数匹配,同时对于可执行参数就直接执行了,如-v,不过有些信息就直接写入了全局变量。

1
2
3
4
5
6
7
8
#define PROGNAME        "luac"          /* default program name */
#define OUTPUT PROGNAME ".out" /* default output file */
static int listing=0; /* list bytecodes? */
static int dumping=1; /* dump bytecodes? */
static int stripping=0; /* strip debug information? */
static char Output[]={ OUTPUT }; /* default output file name */
static const char* output=Output; /* actual output file name */
static const char* progname=PROGNAME; /* actual program name */

返回值是脚本的位置。fatal()类似于之前的report(),用于调试。argc-=i; argv+=i;用来充新定位参数起始位置为脚本位置。流程与lua解释器差不多,我们转向pmain函数。

pmain函数

luac.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
static int pmain(lua_State* L)
{
struct Smain* s = (struct Smain*)lua_touserdata(L, 1);
int argc=s->argc;
char** argv=s->argv;
const Proto* f;
int i;
if (!lua_checkstack(L,argc)) fatal("too many input files");
for (i=0; i<argc; i++)
{
const char* filename=IS("-") ? NULL : argv[i];
if (luaL_loadfile(L,filename)!=0) fatal(lua_tostring(L,-1));
}
f=combine(L,argc);
if (listing) luaU_print(f,listing>1);
if (dumping)
{
FILE* D= (output==NULL) ? stdout : fopen(output,"wb");
if (D==NULL) cannot("open");
lua_lock(L);
luaU_dump(L,f,writer,D,stripping);
lua_unlock(L);
if (ferror(D)) cannot("write");
if (fclose(D)) cannot("close");
}
return 0;
}

前三句好说,下一句我们见到了一个新的数据类型Proto,这个我们或许要深入了解一下了。不过在此之前我们先读完全部内容吧,其实影响不会太大。lua_checkstack()见多了,用来保证还能继续往栈里写数据。接下来对所有合格参数都执行一遍luaL_loadfile(),可见luac还可以批量编译,而且还会打包为一个文件,此时lua虚拟机栈上已经有相应数量的函数了。combine()有点复杂,还涉及字节码,我们先放一放,总之它将我们栈上的函数打包为了一个Proto。listing用于打印字节码信息,对应-l参数,dumping默认开启,用于输出字节码文件,可以使用-p取消。里面一个重要的函数是luaU_dump(L,f,writer,D,stripping);,它将proto文件f打包到输出流writer上去,stripping表示是否有调试信息,默认不包含。

Proto结构体

想必你已经听过许多遍,lua执行过程是先将lua编译为字节码,再在虚拟机上执行。实际上,我们之前学过的用CAPI去操作虚拟机,实际上,这些操作有一个对应的集合,就是字节码。在执行lua_load后,虚拟机会将脚本封装为一个函数,这个函数即包含了脚本编译后的字节码,我们留到以后说,而Proto则是函数的超集,或者说栈上TValue的最后结果是Proto,实际上这里有比较复杂的指向关系,我们先跳过,我们去看一看Proto吧:

luac.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/*
** Function Prototypes
*/
typedef struct Proto {
CommonHeader;
TValue *k; /* constants used by the function */
Instruction *code;
struct Proto **p; /* functions defined inside the function */
int *lineinfo; /* map from opcodes to source lines */
struct LocVar *locvars; /* information about local variables */
TString **upvalues; /* upvalue names */
TString *source;
int sizeupvalues;
int sizek; /* size of `k' */
int sizecode;
int sizelineinfo;
int sizep; /* size of `p' */
int sizelocvars;
int linedefined;
int lastlinedefined;
GCObject *gclist;
lu_byte nups; /* number of upvalues */
lu_byte numparams;
lu_byte is_vararg;
lu_byte maxstacksize;
} Proto;

CommonHeader;gclist与垃圾回收相关,我们下次专门讲。*k存储函数里的常量,*code存储函数内的指令,**p是函数内的函数数组,lua函数套函数应该不少见。*lineinfo存储源码行号,*source存储源码,主要用于debug。*locvars存储本地变量,就是有local申明的变量,注意这里存储的只是相关信息,如名称,它的值实际存在lua虚拟机里,等我们熟悉字节码操作后,就能更深理解存储机制了。*upvalues存储上下文变量,主要存在于多重函数嵌套里面。一个函数的upvalue指的是,它之前级函数的local函数,这里已经有许多涉及字节码的理念了,先放放。int sizeupvalues;int sizek;/* size of k */int sizecode;int sizelineinfo;int sizep;/* size of p */int sizelocvars;存储各个变量的大小,注意不是个数,linedefinedlastlinedefined;是函数的始末行号,用于调试。后面分别是upval个数,参数个数,是否有可变参数,栈最高用到多少。实际上,把Proto当成一个函数也没有问题。

luaU_dump函数

int luaU_dump (lua_State* L, const Proto* f, lua_Writer w, void* data, int strip)这个和lua_load类似,具体内容等我讲luac反编译再说。data提供输出流,w提供输出的具体函数,f就是需要打包的内容,strip表示是否打包调试信息。

字节码

为了更加深入了解lua的函数和,lua代码究竟如何在虚拟机上运行的,我们就必需深入了解一下字节码了。

字节码大全

虽然没有必要但还是讲一下,lua的所有字节码和它们是如何影响lua虚拟机的运行的。操作码有两部分组成,操作和操作数。对于操作数有三种情况,一是虚拟栈索引(记为v),另一种是函数里里的常数表索引(记为c),最后就是布尔数(只要0和1,记为b),在lua里所有立即数据和名称都会计入函数的常量表,如local a = 3里的a和3。

关于索引的问题

每一个函数,都会有base指针的移动,在字节码里是从0开始索引,不过还有一点,就是如果有参数的话,就会占用相应参数个位置。

赋值操作

以后我在后面标一个符号,表示索引的种类。OP_MOVE(A v, B v) A=BOP_LOADK(A v, B c) A=BOP_LOADBOOL(A v, B b, C b) A=B,if(C) pc++,这个指令看似复杂,但实际就是进行布尔赋值或运算的时候会用到,pc就是程序计数器,pc++即跳过下一条指令。自己用对lua的and or语句编译一下,就能发现其中的奥秘。OP_LOADNIL(A v, B nil) A=nil,这个比较特别,B基本都是0。

表相关

接下来会有几个需要的表,upval表和global表。OP_GETUPVAL(A v, B c) A=upval[B]OP_SETUPVAL(A v, B c) upval[B] = AOP_GETGLOBAL(A v, B c) A=global[B]OP_SETGLOBAL(A v, B c) global[B]=AOP_GETTABLE(A v, B v, C c) A=B[C]OP_SETTABLE(A v, B c, C v) A[B]=C
OP_NEWTABLE(A v, B c, C c)创建一个表,array大小为B,hash大小为C,后面一般紧跟相应数量的OP_LOAD*和OP_SETTABLE来填数据,最后OP_SETLIST(A v, B c, C c),B和C同上,有时opcode是联合使用的,取决于编译器load_*的内部实现。OP_SELF(A v, B v, C c),在lua传输self表时特有的一个函数,注意的是这个表在表里是独立存在的,self的变量与表里的变量是不同的,self类似于表私有的变量。

运算

OP_ADD(A v, B v, C v) A=B+COP_SUB(A v, B v, C v) A=B-COP_MUL(A v, B v, C v) A=B*COP_DIV(A v, B v, C v) A=B/COP_MOD(A v, B v, C v) A=B%COP_POW(A v, B v, C v) A=B^COP_UNM(A v, B v) A= -BOP_NOT(A v, B v) A= not BOP_LEN(A v, B v) A=#BOP_CONCAT(A v, B v, C v) A= B..C,我们发现所有的运算都在栈上运算,就如我之前所说,在lua里所有的变量值要么存储在栈上,要么在全局表里面。而函数闭包,只存储变量的名称,或立即数据,因为它是需要静态存储到文件里的,lua其实还有优化编译,如对于a = 2+5,lua会直接存储a和7而不是a,2和5。

结构控制

OP_JMP(0, B) pc+=B,这里还要讲一下pc指向的是将要执行的指令,一般指向下一条,在这里B是具体整数,一般是正数。OP_EQ(A b, B v, C v) if((B==C)~=A)then pc++,这里实际通过A分开了==和!=,下面同理,OP_LT(A b, B v, C v) if((B<C)~=A)then pc++OP_LE(A b, B v, C v) if((B<=C)~=A)then pc++,有关lua代码如何转化为字节码,有些什么巧妙之处,我还在反编译那里讲。OP_TEST(A v, B b) if not(A==B)then pc++OP_TESTSET(A v, B v, C b) if(B==C)then A=B else pc++,这个主要用在复杂的逻辑表达式里。OP_FORLOOP(A v, B) A+=v(A+2)用于for循环,一般会在栈上产生三个值,一个是我们常用的索引,最后一个步长,v(A+2)表示相对A位置加2处的值,B用于指示跳转,一般是负数表示回退。OP_FORPREP(A v, B) A-=v(A+2),pc+=BOP_TFORLOOP(A v, C) v(A+3),…,v(A+2+C)=A(v(A+1),v(A+2))一般配合使用来遍历表,C表示返回值个数,一般为3,用过pairs和ipairs吧。对于while和repeat,只要jmp和比较其实就能实现了。

函数

OP_CALL(A v, B, C) v(A),…v(A+C-2)=A(v(A+1),…v(A+B-1)),B-1和C-1分别是参数和返回值个数,与lua_call使用差不多,但有一个减一操作,即2表示一个参数,有些用处,但现在就当是规定吧。OP_TAILCALL(A v, B, C) return A(v(A+1),…v(A+B-1))类似上一个,用于函数的快速返回。OP_RETURN(A v, B) return v(A+1),…v(A+B-1)标准的函数返回方式。OP_CLOSE(A v) 清除栈上>=A的所有值,OP_CLOSURE(A v,B)取出函数内的闭包到栈上,B是索引,还记得Proto内的Proto,闭包就存在里面。OP_VARARG(A v, B) v(A),…v(A+B-1)=…用来取出B个函数的可变参数到栈上。

字节码总结

至此,我们浏览了所有的字节码,只是为了有个大概的印象,实际还是得结合具体的编译结果来看,一般成块出现的,我们可以使用指令luac -l [字节码文件]来查看。对于字节码,我们需要记住,所有要操作的数据都要先入栈,返回值也存在栈上,其实这也就方便了我们通过CAPI来实现C语言与lua的数据交互,当然还可以通过全局变量表。同时我们也看到了,函数Proto并没有存具体的数据,除了那些即时产生的量,而是存储了变量名等变量的特征,我们要清楚,函数是静态存储动态执行的,所以变量的具体值不在函数里也是理所应当的。

函数

前面讲了函数的一个重要组成部分Instruction *code即字节码,是时候该从新正视一下函数了,函数属于TValue里Value的GCObject。我们来看一下这条定义链。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
//lobject.h
/*
** Union of all Lua values
*/
typedef union {
GCObject *gc;
void *p;
lua_Number n;
int b;
} Value;
/*
** Closures
*/
#define ClosureHeader \
CommonHeader; lu_byte isC; lu_byte nupvalues; GCObject *gclist; \
struct Table *env
typedef struct CClosure {
ClosureHeader;
lua_CFunction f;
TValue upvalue[1];
} CClosure;
typedef struct LClosure {
ClosureHeader;
struct Proto *p;
UpVal *upvals[1];
} LClosure;
typedef union Closure {
CClosure c;
LClosure l;
} Closure;

//lstate.h
/*
** Union of all collectable objects
*/
union GCObject {
GCheader gch;
union TString ts;
union Udata u;
union Closure cl;
struct Table h;
struct Proto p;
struct UpVal uv;
struct lua_State th; /* thread */
};

我们看到有Closure和Proto两个,我们使用lua_call一般针对的是Closure,而Proto出现在这里是因为它也是要回收的对象。实际上对于函数有两种,一个是C闭包,还有一个是lua闭包,它们都可以作为函数供lua_call来调用,所以闭包了一个Closure作为统一对象。Proto则是LClosure(lua闭包函数)的主要部分。lua_CFunction f;的申明在lua.h里,格式如下typedef int (*lua_CFunction) (lua_State *L);。垃圾回收还是老话,以后再讲。

combine函数

在前面,我们提到了一个combine(L, int)函数,用来将栈上的函数返回为为一个Proto,我们看看它:

luac.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
static const Proto* combine(lua_State* L, int n)
{
if (n==1)
return toproto(L,-1);
else
{
int i,pc;
Proto* f=luaF_newproto(L);
setptvalue2s(L,L->top,f); incr_top(L);
f->source=luaS_newliteral(L,"=(" PROGNAME ")");
f->maxstacksize=1;
pc=2*n+1;
f->code=luaM_newvector(L,pc,Instruction);
f->sizecode=pc;
f->p=luaM_newvector(L,n,Proto*);
f->sizep=n;
pc=0;
for (i=0; i<n; i++)
{
f->p[i]=toproto(L,i-n-1);
f->code[pc++]=CREATE_ABx(OP_CLOSURE,0,i);
f->code[pc++]=CREATE_ABC(OP_CALL,0,1,1);
}
f->code[pc++]=CREATE_ABC(OP_RETURN,0,1,0);
return f;
}
}

如果只有一个函数,则直接返回toproto(L,-1);,这个的定义链如下:

1
2
3
4
5
6
//luac.h
#define toproto(L,i) (clvalue(L->top+(i))->l.p)
//lua.h
#define check_exp(c,e) (e)
#define ttisfunction(o) (ttype(o) == LUA_TFUNCTION)
#define clvalue(o) check_exp(ttisfunction(o), &(o)->value.gc->cl)

转化一下就是toproto(L,-1) := &(L->top-1)->value.gc->cl->l.p,取出TValue的value(Value)的gc(GCObject)的cl(Closure)的l(LClosure)的p(Proto),的确是一个复杂的过程,但这也是为了统一处理。
对于函数大于1的情况也可以看一看,其实就是将每个函数执行的代码,再封装到一个Proto里面。Proto* f=luaF_newproto(L);创建一个Proto。setptvalue2s(L,L->top,f); incr_top(L);虽然复杂,但其实就是将Proto封装为TValue并放入栈顶的操作。f->source=luaS_newliteral(L,"=(" PROGNAME ")");f->maxstacksize=1;用于给Proto补充信息,栈只要1格内存来存Closure就足够了。pc=2*n+1;标识需要的字节码个数,一个文件两个,最后加一个return,f->code=luaM_newvector(L,pc,Instruction);创建Instruction的数组,f->sizecode=pc;最后记录到Proto里面。f->p=luaM_newvector(L,n,Proto*);创建存储每个文件Proto的数组,f->sizep=n;记录子Proto的个数(Proto可以包含数个子Proto),pc=0;计数器归位,准备记录字节码。

1
2
3
4
5
6
7
8
for (i=0; i<n; i++)
{
f->p[i]=toproto(L,i-n-1);
f->code[pc++]=CREATE_ABx(OP_CLOSURE,0,i);
f->code[pc++]=CREATE_ABC(OP_CALL,0,1,1);
}
f->code[pc++]=CREATE_ABC(OP_RETURN,0,1,0);
return f;

记录字节码,并将构造的Proto返回。我们可以看到,所谓编译多个文件就是将每个文件都执行一遍。

lua_call函数

接下来就是要讲讲,我们最重要的执行函数了,也是我们虚拟机运行的核心,对于每一个Proto里面的字节码,执行的主体就是我们的lua_call函数了。

1
2
3
4
5
6
7
8
9
10
LUA_API void lua_call (lua_State *L, int nargs, int nresults) {
StkId func;
lua_lock(L);
api_checknelems(L, nargs+1);
checkresults(L, nargs, nresults);
func = L->top - (nargs+1);
luaD_call(L, func, nresults);
adjustresults(L, nresults);
lua_unlock(L);
}

lua_lock(L);lua_unlock(L);顾名思义就是用来锁住L,防止操作还没结束就被其它线程改变,但其实在lua里并没有实现此方法,而是定义宏来提供接口,只把lua当一次性脚本的话,不用过于关注。其实如果遇到如api_checknelemscheckresultsadjustresults之类的函数,都可以不用在意,与lua_lock()类似,不改源码的话,基本没什么用。func = L->top - (nargs+1);把函数取出来,luaD_call(L, func, nresults);才是真正来执行的函数。

ldo.c
1
2
3
4
5
6
7
8
9
10
11
12
void luaD_call (lua_State *L, StkId func, int nResults) {
if (++L->nCcalls >= LUAI_MAXCCALLS) {
if (L->nCcalls == LUAI_MAXCCALLS)
luaG_runerror(L, "C stack overflow");
else if (L->nCcalls >= (LUAI_MAXCCALLS + (LUAI_MAXCCALLS>>3)))
luaD_throw(L, LUA_ERRERR); /* error while handing stack error */
}
if (luaD_precall(L, func, nResults) == PCRLUA) /* is a Lua function? */
luaV_execute(L, 1); /* call it */
L->nCcalls--;
luaC_checkGC(L);
}

if (++L->nCcalls >= LUAI_MAXCCALLS) {主要用于检测当前嵌套函数调用个数有没有超标,少用递归的话基本不会出问题。luaD_precall(L, func, nResults)预调用,如果func是C闭包的话就直接执行并返回PCRC,如果是lua闭包就做好运行准备,比如修改虚拟机的CallInfo,调整base指针之类的,这些是编程细节,不是我们学习的重点,最后会返回PCRLUA,然后接着执行luaV_execute(L, 1),这个函数是执行字节码的真正函数,实现的话就是一个个去比对然后执行,没什么看头。luaD_precall其实还会返回一个与协程相关的PCRYIELD。L->nCcalls--;函数嵌套减一,luaC_checkGC(L);垃圾回收操作。
至于lua_pcall它最终也是通过luaD_call实现调用,只不过增加了保护措施,防止程序中断,而lua_cpcall则与lua_pcall有类似的实现方式,最终仍是回到luaD_call,就不过多赘述了。

后记

至此lua源码的大致框架和核心部分已经叙述完了,但其实还没有结束,比如Debug,GC机制,协程的实现等都没有讲到。任何源码都是庞大的,其实我们没必要面面俱到,而应先了解其基本框架,再去了解我们想知道的部分就足够了。不要忘了,我们读源码不是要折磨自己,而是想学习优秀的范式。