首页
社区
课程
招聘
[翻译]Lua 5.2 字节码和虚拟机
发表于: 2017-11-15 17:58 36831

[翻译]Lua 5.2 字节码和虚拟机

2017-11-15 17:58
36831

by Dirk Laurie

Copyright © 2013. Freely available under the terms of the Lua license.

原文:http://files.catwell.info/misc/mirror/lua-5.2-bytecode-vm-dirk-laurie/lua52vm.html

我写此文是因为只有如此我才能理解它。此篇适合给非常熟悉Lua5.2参考手册(LRM5.2),在至少一台机器(Knuth’s MIX就行了)上有一点机器指令经验,并且想要对Lua5.2源码做些修修补补的读者阅读。

感谢:我从Elijah Frederickson的网站上借鉴了许多(https://github.com/mlnlover11/LuaAssemblyTools) (LAT) ,上面不仅有Lua5.1和Lua5.2的汇编工具,还有一份超完整的极其有用的其他人早期工作的集合,特别是,Kein-Hong Man的No-Frills Introduction to Lua5.1 VM Instructions(NFI),虽然是为Lua5.1而写但仍具极大的参考价值。当然,早晚你都得阅读Lus5.2源码的简练而权威的注释。

一个区块可以被存储在文件或者宿主程序的字符串中。为了执行一个区块,Lua首先为一台虚拟机将区块预编译成指令,然后它用一个解释器为虚拟机执行编译后的代码。

区块也可以被预编译成二进制形式,细节请参考luac。程序的源码形式和编译后的形式可以互转,Lua自动检测文件类型并自动执行。——§3.3.2

上面是LRM5.2中唯一提到”虚拟机”字眼的段落。三个标准库函数处理二进制区块:load、loadfile和string.dump.这就是用户能从官方获取的全部信息。对闭源代码来说这也就是用户能知道的全部了。

但是Lua是开源的。它的源代码有很好的注释,同时我们也可以做逆向工程。让我们从luac开始。

嗯,我们会给它一个Hello World程序,略微修改一下让它更为有趣。第二行之后,终端输入停止(我按Control-D)然后luac的输出开始。

我们稍后讨论这个输出,在这时候我想要你看到这四个清单:稍微注释过的汇编代码,常数,局部变量,外部局部变量。

现在我们回到Lua本身。load同一区块,dump下来获取其字节码,把它写到文件里。我修改(patched)了我的Lua,用三个空格代替了控制台的>,这样你可以直接剪切粘贴这些代码。

luac也可以接受二进制区块。我们可以用它来看看load做了什么。

能观察到区别吗?

即load和luac基本上生成完全一致的代码。

字节码究竟是怎么样的?这是hello.luac的hexdump(用的是我系统的hd程序)。

这里面许多东西是用于调试的,所以来把hello.luac传给luac -s重试一下。

这个格式并没有官方说明,所以需要来做一些逆向工程。需要的材料在Lua的源码里面的几个地方,主要是ldump.c和lundump.c。我亦和NFI和LAT做了交叉检查,不过,估计还有问题,那都是我的错。

代码从18字节的文件头开始,这部分对所有官方Lua5.2编译器编译的代码来说都是一样(包括你的编译器),不管用的是luac或者load还是loadfile。Lua5.1的头部是12字节,与此段的前12字节相似。

字节编码顺序从十进制的1(主要展示其算法)和十六进制的0开始。

这18字节过后是文件中的函数定义。每个函数都由11字节的函数头开始。

然后是一个指令列表,我们稍后会详细讨论其形式。

然后是常量列表。

紧随着是函数原型列表:

最后是外部局部变量列表:

从这开始,两个文件出现了分歧。在剥离后的版本中,去掉的调试信息展现为16个0字节。

在未剥离的版本中,给出了以下信息:

此后的函数也会以同种方式编码。

目前我的探索发现到此。以下领域尚未覆盖:

NFI涵括了这些以及其它未提及的东西,但是是5.1版本的,所以你得参照lundump.c和LAT。

伴随此文件而来的还有两个模块文件:vm52.lua和bytecode.lua。这阶段一开始,我们仅使用numberAt。这个函数将4字节子字符串转成32位数字,使用宿主机器的字节端序。我们从上面了解到dump后的Lua5.2函数在28是一个可变长函数参数,在29是寄存器数量,在30是指令数量,随后是许多的4字节指令。

你能看到字节码文件的字节内容,倒转是因为我的机器是小端字节序。

模块文件vm52.lua不仅有numberAt:它有上面代码定义的四个函数,更重要的,它还有assemble和disassemble。后者做的跟lua -l一样处理dump后的字节码。

继续之前的Lua会话,

以上16进制列表与字节码文件相对应,不过字节是倒转的。这是因为我的系统是小端序的:小端字节序的32位数字真是难以阅读,所以普遍使用的是大端字节序。

所以在分解和显示它们之前,我们把指令字节倒转过来。numberAt函数会做这个事情,如果需要的话:

就像我们看过的,指令是:

我们现在将字节展开成二进制并且分组。如果lopcodes.c指明指令是iABC模式的,我们用(9,9,8,6)分组,定义值为B,C,A,OP;如果指明是iABx或者iAsBx,用(18,8,6)分组,定义值为Bx,A,OP或者sBx,A,OP;如果是iAx,分为(26,6),定义值为Ax,OP。

上面的表格里面,每一位的编号从左到右是31,30,…,0。记号“0+6”意味着位置0 的低6位(表格最右边)。如果指令作为一个Lua数字存储,这几位用bit32:extract(0,6)表示。

1、     OP是操作码(opcode),在0+6(看下面)。

2、     A是所有指令的第一操作数(operand),在6:8,视为无符号数字。最大值是255。

3、     B是模式iABC的第二操作数,在23+9,视为有符号数字。实际的指令未必用到B。

第一位是符号位。如果是1,其它8位为B的绝对值减1。因此没有-0,B的最小值是-256,最大值255。

4、     C是iABC的第三操作数,在14+9,视为像B一样的有符号数字。实际指令未必用到C。

5、     Bx是iABx的第二操作数,在14+18,像B一样视为19位有符号数字,但是不需存储符号位因为Bx总是负的。

6、     sBx意思是“Bx视为18为有符号数字”。

7、     Ax是模式iAx的唯一操作数,在6+26,视为Bx(27位有符号数字,负数,符号位不存储)。

指令的操作码及其功能描述在lopcodes.h的一个enum中。我们以其相关功能进行分组。描述(从lopcodes.h中逐字取出)中使用以下记号,B,Bx等不是指位编码的数字,而是指luac反编译后能看到的数字。

以下几乎逐字抄录自lopcodes.h

你绝对要先读过NFI,然后把这部分仅仅当成文档其它必须理解的部分的要点总结。实际上,它仅包含了一些帮助我自己读懂上面操作指令的东西,所以继续之前重读一下前面是个好主意。

我发现反编译后的Lua代码片段很有帮助,可以弄清楚为什么虚拟机指令可以达到预期的效果。例如:

Lua的C API在一个虚拟堆栈上做一切的事情。从某个角度看,虚拟机仅仅只是一个通向API的接口,提供了一个Lua源码和API间的抽象层。然而,VM会访问lua_State的一个私有域,而C API完全通过文档描述的方式工作。

虚拟机本身并不是基于栈的。它允许独占使用叫做帧(frame)的栈的一部分,但是在帧的内部,VM的寄存器数量是固定的,没有pushing和poping。一个重要的使用上的区别是负索引跟C API的含义不同。它们指向别处存储的常量,不在栈帧里面。

VM可以访问其它活跃栈帧上的外部局部变量。

局部变量是寄存器的别名。luac列表从调试信息中抓取他们的名字,但是指令反编译器做不到这点。创建它们不涉及任何开销。

当我有时间和兴趣的时候,我会添加更多的东西。欢迎各位指正和评论。如果我还活跃的话,你能在lua-l上找到我。


$ luac
luac: no input files given
usage: luac [options] [filenames]
Available options are:
  -l       list (use -l -l for full listing)
  -o name  output to file 'name' (default is "luac.out")
  -p       parse only
  -s       strip debug information
  -v       show version information
  --       stop handling options
  -        stop handling options and process stdin

嗯,我们会给它一个Hello World程序,略微修改一下让它更为有趣。第二行之后,终端输入停止(我按Control-D)然后luac的输出开始。

$ luac -l -l -v -s -
Lua 5.2.1  Copyright (C) 1994-2012 Lua.org, PUC-Rio
local hello = "Hello"
print (hello.." World!")

main <stdin:0,0> (7 instructions at 0x9984970)
0+ params, 4 slots, 1 upvalue, 1 local, 3 constants, 0 functions
    1   [1] LOADK       0 -1    ; "Hello"
    2   [2] GETTABUP    1 0 -2  ; _ENV "print"
    3   [2] MOVE        2 0
    4   [2] LOADK       3 -3    ; " World!"
    5   [2] CONCAT      2 2 3
    6   [2] CALL        1 2 1
    7   [2] RETURN      0 1
constants (3) for 0x9984970:
    1   "Hello"
    2   "print"
    3   " World!"
locals (1) for 0x9984970:
    0   hello   2   8
upvalues (1) for 0x9984970:
0   _ENV    1   0

我们稍后讨论这个输出,在这时候我想要你看到这四个清单:稍微注释过的汇编代码,常数,局部变量,外部局部变量。

现在我们回到Lua本身。load同一区块,dump下来获取其字节码,把它写到文件里。我修改(patched)了我的Lua,用三个空格代替了控制台的>,这样你可以直接剪切粘贴这些代码。

$ lua
Lua 5.2.1  Copyright (C) 1994-2012 Lua.org, PUC-Rio
   func = load 'local  hello = "Hello" print (hello.." World!")'
   chunk = string.dump(func)
   io.open("hello.luac","wb"):write(chunk)

luac也可以接受二进制区块。我们可以用它来看看load做了什么。

$ luac -l -l -v hello.luac
Lua 5.2.1  Copyright (C) 1994-2012 Lua.org, PUC-Rio

main <(string):0,0> (7 instructions at 0x90d0b50)
0+ params, 4 slots, 1 upvalue, 1 local, 3 constants, 0 functions
    1   [1] LOADK       0 -1    ; "Hello"
    2   [1] GETTABUP    1 0 -2  ; _ENV "print"
    3   [1] MOVE        2 0
    4   [1] LOADK       3 -3    ; " World!"
    5   [1] CONCAT      2 2 3
    6   [1] CALL        1 2 1
    7   [1] RETURN      0 1
constants (3) for 0x90d0b50:
    1   "Hello"
    2   "print"
    3   " World!"
locals (1) for 0x90d0b50:
    0   hello   2   8
upvalues (1) for 0x90d0b50:
0   _ENV    1   0

能观察到区别吗?

  •  (string)不同于stdin
  •  四个地方的0x90d0b50和0x9984970不同
  •  除了其中一行,其它都有[1]和[2]的差别

即load和luac基本上生成完全一致的代码。

字节码解剖

字节码究竟是怎么样的?这是hello.luac的hexdump(用的是我系统的hd程序)。

00000000  1b 4c 75 61 52 00 01 04  04 04 08 00 19 93 0d 0a  |.LuaR...........|
00000010  1a 0a 00 00 00 00 00 00  00 00 00 01 04 07 00 00  |................|
00000020  00 01 00 00 00 46 40 40  00 80 00 00 00 c1 80 00  |.....F@@........|
00000030  00 96 c0 00 01 5d 40 00  01 1f 00 80 00 03 00 00  |.....]@.........|
00000040  00 04 06 00 00 00 48 65  6c 6c 6f 00 04 06 00 00  |......Hello.....|
00000050  00 70 72 69 6e 74 00 04  08 00 00 00 20 57 6f 72  |.print...... Wor|
00000060  6c 64 21 00 00 00 00 00  01 00 00 00 01 00 30 00  |ld!...........0.|
00000070  00 00 6c 6f 63 61 6c 20  20 68 65 6c 6c 6f 20 3d  |..local  hello =|
00000080  20 22 48 65 6c 6c 6f 22  20 70 72 69 6e 74 20 28  | "Hello" print (|
00000090  68 65 6c 6c 6f 2e 2e 22  20 57 6f 72 6c 64 21 22  |hello.." World!"|
000000a0  29 00 07 00 00 00 01 00  00 00 01 00 00 00 01 00  |)...............|
000000b0  00 00 01 00 00 00 01 00  00 00 01 00 00 00 01 00  |................|
000000c0  00 00 01 00 00 00 06 00  00 00 68 65 6c 6c 6f 00  |..........hello.|
000000d0  01 00 00 00 07 00 00 00  01 00 00 00 05 00 00 00  |................|
000000e0  5f 45 4e 56 00                                    |_ENV.|

这里面许多东西是用于调试的,所以来把hello.luac传给luac -s重试一下。

00000000  1b 4c 75 61 52 00 01 04  04 04 08 00 19 93 0d 0a  |.LuaR...........|
00000010  1a 0a 00 00 00 00 00 00  00 00 00 01 04 07 00 00  |................|
00000020  00 01 00 00 00 46 40 40  00 80 00 00 00 c1 80 00  |.....F@@........|
00000030  00 96 c0 00 01 5d 40 00  01 1f 00 80 00 03 00 00  |.....]@.........|
00000040  00 04 06 00 00 00 48 65  6c 6c 6f 00 04 06 00 00  |......Hello.....|
00000050  00 70 72 69 6e 74 00 04  08 00 00 00 20 57 6f 72  |.print...... Wor|
00000060  6c 64 21 00 00 00 00 00  01 00 00 00 01 00 00 00  |ld!.............|
00000070  00 00 00 00 00 00 00 00  00 00 00 00 00 00        |..............|

这个格式并没有官方说明,所以需要来做一些逆向工程。需要的材料在Lua的源码里面的几个地方,主要是ldump.c和lundump.c。我亦和NFI和LAT做了交叉检查,不过,估计还有问题,那都是我的错。

代码从18字节的文件头开始,这部分对所有官方Lua5.2编译器编译的代码来说都是一样(包括你的编译器),不管用的是luac或者load还是loadfile。Lua5.1的头部是12字节,与此段的前12字节相似。

字节编码顺序从十进制的1(主要展示其算法)和十六进制的0开始。

1 x00: 1b 4c 75 61
    LUA_SIGNATURE ,来自 lua.h.
5 x04: 52  00
    二进制码表示的十进制52是lua的版本,00表示此字节码兼容于“官方”的PUC-Rio实现。
5+2 x06: 01  04  04  04  08  00
    六个系统的参数。在x386的机器上分别表示little-endian,4字节的integers,4字节的VM指令,4字节的size_t数字,8字节的Lua数字,floating-    point。在字节码文件和Lua解释器之间这些参数必须完全匹配,否则字节码将失效。
7+6 x0c: 19 93 0d 0a 1a 0a
    到这里就是PUC-Rio产出的全部的Lua5.2字节码了。lumdump.h对此的描述是“捕捉转换错误的数据”。可能由二进制码表示的十进制数1993(它的起始年份),Windows行终止符,MS-DOS文本文件终止符,类Unix系统行终止符组成。

这18字节过后是文件中的函数定义。每个函数都由11字节的函数头开始。

13+6 x12:  00 00 00 00
    源码中区块开始的行号。0是主区块。
19+4 x16:  00 00 00 00
    源码中区块终止的行号。0是主区块。
23+4 x1a:  00  01  04
    参数数量,可变长参数标识(varargs),该函数用的寄存器数量(很明显不超过255)。局部变量存储在寄存器,它们应该不超过200个(参考lparser.c)

然后是一个指令列表,我们稍后会详细讨论其形式。

27+3 x1d:  07 00 00 00
    列表有7个指令(小端字节序,就像文件头所说)
30+4*1 x21:  01 00 00 00
    字节码,LOADK 0 -1.
30+4*2 x25:  46 40 40 00
    字节码,GETTABUP 1 0 2.
30+4*3 x29:  80 00 00 00
    字节码,MOVE 2 0.
30+4*4 x2d:  c1 80 00 00
    字节码,LOADK 3 -3.
30+4*5 x31:  96 c0 00 01
    字节码,CONCAT 2 2 3.
30+4*6 x35:  5d 40 00 01
    字节码,CALL 1 2 1.
30+4*7 x39:  1f 00 80 00
    字节码,RETURN 0 1.

然后是常量列表。

58+4 x3d:  03 00 00 00
    共3个常量。
62+4 x41:  04
    第一个常量是string
66+1 x42:  06 00 00 00
    含6字节,
67+4 x46:  48 65 6c 6c 6f 00
    包含了”Hello”和一个终止的0字节。
71+6 x4c:  04  06 00 00 00  70 72 69 6e 74 00
    第二个常量是6字节字符串”print”。
77+5+6 x57:  04  08 00 00 00  20 57 6f 72 6c 64 21 00
    第三个常量是8字节字符串” World!”。

紧随着是函数原型列表:

88+5+8 x64:  00 00 00 00
    无函数原型

最后是外部局部变量列表:

101+4 x68:  01 00 00 00
有1个外部局部变量
105+4 x6c:  01 00
可以在上1级的堆栈中找到,在堆栈的位置0。

从这开始,两个文件出现了分歧。在剥离后的版本中,去掉的调试信息展现为16个0字节。

109+2 x6e:  00 00 00 00
    无源代码
111+4 x72:  00 00 00 00
    行号列表空
115+4 x76:  00 00 00 00
    局部变量名空
119+4 x7a:  00 00 00 00
    外部局部变量名空

在未剥离的版本中,给出了以下信息:

111 x6e:  30 00 00 00
    源码长度48字节。忽略旧的源码,
111+4+48 xa2:  07 00 00 00
    7个行号,忽略旧行号,
163+4+28 xc2:  01 00 00 00
    列出了1个局部变量名。
199 xc6:  06 00 00 00  68 65 6c 6c 6f 00
    变量名hello
199+4+6 xd0:  01 00 00 00
    在指令1进入其作用域
209+4 xd4:  07 00 00 00
    在指令7跳出其作用域
213+4 xd8:  01 00 00 00
    列出1个外部局部变量
217+4 xdc:  05 00 00 00  5f 45 4e 56 00
    变量名_ENV

此后的函数也会以同种方式编码。

目前我的探索发现到此。以下领域尚未覆盖:

  •   非字符串常量如何存储
  •   函数原型的形式为何

NFI涵括了这些以及其它未提及的东西,但是是5.1版本的,所以你得参照lundump.c和LAT。

全面窥探字节码

伴随此文件而来的还有两个模块文件:vm52.lua和bytecode.lua。这阶段一开始,我们仅使用numberAt。这个函数将4字节子字符串转成32位数字,使用宿主机器的字节端序。我们从上面了解到dump后的Lua5.2函数在28是一个可变长函数参数,在29是寄存器数量,在30是指令数量,随后是许多的4字节指令。

所以让我们来看一下。

$ lua -l vm52
Lua 5.2.1  Copyright (C) 1994-2012 Lua.org, PUC-Rio
   func = load 'local  hello = "Hello" print (hello.." World!")'
   chunk = string.dump(func)

   function hasvarargs(chunk) return chunk:sub(28,28):byte()>0 end
   function nstack(chunk) return chunk:sub(29,29):byte() end
   function ninstr(chunk) return vm52.numberAt(chunk,30) end
   function instr(chunk,i) return vm52.numberAt(chunk:sub(30+4*i,33+4*i)) end

   ns = nstack(chunk)
   print ("This function" ..
     (hasvarargs(chunk) and " has a variable number of arguments and" or "") ..
     " uses "..ns.." register"..(ns~=1 and "s" or ""))

   for i=1,ninstr(chunk) do
      print(string.format("%08X",instr(chunk,i)))
   end 
00000001
00404046
00000080
000080C1
0100C096
0100405D
0080001F

你能看到字节码文件的字节内容,倒转是因为我的机器是小端字节序。

模块文件vm52.lua不仅有numberAt:它有上面代码定义的四个函数,更重要的,它还有assemble和disassemble。后者做的跟lua -l一样处理dump后的字节码。

继续之前的Lua会话,

   for i=1,vm52.ninstr(code) do
      print(vm52.disassemble(vm52.instr(code,i)))
   end 
LOADK 0 -1
GETTABUP 1 0 -2
MOVE 2 0
LOADK 3 -3
CONCAT 2 2 3
CALL 1 2 1
RETURN 0 1

指令解剖

以上16进制列表与字节码文件相对应,不过字节是倒转的。这是因为我的系统是小端序的:小端字节序的32位数字真是难以阅读,所以普遍使用的是大端字节序。

所以在分解和显示它们之前,我们把指令字节倒转过来。numberAt函数会做这个事情,如果需要的话:

就像我们看过的,指令是:

00 00 00 01   LOADK 0 -1
00 40 40 46   GETTABUP 1 0 -2
00 00 00 80   MOVE 2 0
00 00 80 c1   LOADK 3 -3
01 00 c0 96   CONCAT 2 2 3
01 00 40 5d   CALL 1 2 1
00 80 00 1f   RETURN 0 1

我们现在将字节展开成二进制并且分组。如果lopcodes.c指明指令是iABC模式的,我们用(9,9,8,6)分组,定义值为B,C,A,OP;如果指明是iABx或者iAsBx,用(18,8,6)分组,定义值为Bx,A,OP或者sBx,A,OP;如果是iAx,分为(26,6),定义值为Ax,OP。

    B    Bx   C         A      OP
000000000000000000  00000000 000001   LOADK 0 -1       (A,Bx)
000000000 100000001 00000001 000110   GETTABUP 1 0 -2  (A,B,C)
000000000 000000000 00000010 000000   MOVE 2 0         (A,B) 
000000000000000010  00000011 000001   LOADK 3 -3       (A,Bx)
000000010 000000011 00000010 010110   CONCAT 2 2 3     (A,B,C)
000000010 000000001 00000001 011101   CALL 1 2 1       (A,B,C)
000000001 000000000 00000000 011111   RETURN 0 1       (A,B)

上面的表格里面,每一位的编号从左到右是31,30,…,0。记号“0+6”意味着位置0 的低6位(表格最右边)。如果指令作为一个Lua数字存储,这几位用bit32:extract(0,6)表示。

1、     OP是操作码(opcode),在0+6(看下面)。

2、     A是所有指令的第一操作数(operand),在6:8,视为无符号数字。最大值是255。

3、     B是模式iABC的第二操作数,在23+9,视为有符号数字。实际的指令未必用到B。

第一位是符号位。如果是1,其它8位为B的绝对值减1。因此没有-0,B的最小值是-256,最大值255。

4、     C是iABC的第三操作数,在14+9,视为像B一样的有符号数字。实际指令未必用到C。

5、     Bx是iABx的第二操作数,在14+18,像B一样视为19位有符号数字,但是不需存储符号位因为Bx总是负的。

6、     sBx意思是“Bx视为18为有符号数字”。

7、     Ax是模式iAx的唯一操作数,在6+26,视为Bx(27位有符号数字,负数,符号位不存储)。

指令的操作码及其功能描述在lopcodes.h的一个enum中。我们以其相关功能进行分组。描述(从lopcodes.h中逐字取出)中使用以下记号,B,Bx等不是指位编码的数字,而是指luac反编译后能看到的数字。

R(A), R(B), R(C)
    寄存器A,B或者C的数值。
Kst(Bx)
    常数|Bx|。
KPROTO[Bx]
    函数原型|Bx|。
RK(B), RK(C)
    寄存器B或者C的值;如果B或者C是负数,常数|B|或者|C|。
FPF
    每次刷新的Fields。阅读NFI的解释。
UpValue
    外部局部变量B的值
Pc
    程序计数器:下一条被执行的指令的编号
closure
    创建一个闭包(函数加上外部局部变量)。

加载参数

$ luac
luac: no input files given
usage: luac [options] [filenames]
Available options are:
  -l       list (use -l -l for full listing)
  -o name  output to file 'name' (default is "luac.out")
  -p       parse only
  -s       strip debug information
  -v       show version information
  --       stop handling options
  -        stop handling options and process stdin

嗯,我们会给它一个Hello World程序,略微修改一下让它更为有趣。第二行之后,终端输入停止(我按Control-D)然后luac的输出开始。

$ luac -l -l -v -s -
Lua 5.2.1  Copyright (C) 1994-2012 Lua.org, PUC-Rio
local hello = "Hello"
print (hello.." World!")

main <stdin:0,0> (7 instructions at 0x9984970)
0+ params, 4 slots, 1 upvalue, 1 local, 3 constants, 0 functions
    1   [1] LOADK       0 -1    ; "Hello"
    2   [2] GETTABUP    1 0 -2  ; _ENV "print"
    3   [2] MOVE        2 0
    4   [2] LOADK       3 -3    ; " World!"
    5   [2] CONCAT      2 2 3
    6   [2] CALL        1 2 1
    7   [2] RETURN      0 1
constants (3) for 0x9984970:
    1   "Hello"
    2   "print"
    3   " World!"
locals (1) for 0x9984970:
    0   hello   2   8
upvalues (1) for 0x9984970:
0   _ENV    1   0

我们稍后讨论这个输出,在这时候我想要你看到这四个清单:稍微注释过的汇编代码,常数,局部变量,外部局部变量。

现在我们回到Lua本身。load同一区块,dump下来获取其字节码,把它写到文件里。我修改(patched)了我的Lua,用三个空格代替了控制台的>,这样你可以直接剪切粘贴这些代码。

$ lua
Lua 5.2.1  Copyright (C) 1994-2012 Lua.org, PUC-Rio
   func = load 'local  hello = "Hello" print (hello.." World!")'
   chunk = string.dump(func)
   io.open("hello.luac","wb"):write(chunk)

luac也可以接受二进制区块。我们可以用它来看看load做了什么。

$ luac -l -l -v hello.luac
Lua 5.2.1  Copyright (C) 1994-2012 Lua.org, PUC-Rio

main <(string):0,0> (7 instructions at 0x90d0b50)
0+ params, 4 slots, 1 upvalue, 1 local, 3 constants, 0 functions
    1   [1] LOADK       0 -1    ; "Hello"
    2   [1] GETTABUP    1 0 -2  ; _ENV "print"
    3   [1] MOVE        2 0
    4   [1] LOADK       3 -3    ; " World!"
    5   [1] CONCAT      2 2 3
    6   [1] CALL        1 2 1
    7   [1] RETURN      0 1
constants (3) for 0x90d0b50:
    1   "Hello"
    2   "print"
    3   " World!"
locals (1) for 0x90d0b50:
    0   hello   2   8
upvalues (1) for 0x90d0b50:
0   _ENV    1   0

能观察到区别吗?

  •  (string)不同于stdin
  •  四个地方的0x90d0b50和0x9984970不同
  •  除了其中一行,其它都有[1]和[2]的差别

即load和luac基本上生成完全一致的代码。

字节码解剖

字节码究竟是怎么样的?这是hello.luac的hexdump(用的是我系统的hd程序)。

00000000  1b 4c 75 61 52 00 01 04  04 04 08 00 19 93 0d 0a  |.LuaR...........|
00000010  1a 0a 00 00 00 00 00 00  00 00 00 01 04 07 00 00  |................|
00000020  00 01 00 00 00 46 40 40  00 80 00 00 00 c1 80 00  |.....F@@........|
00000030  00 96 c0 00 01 5d 40 00  01 1f 00 80 00 03 00 00  |.....]@.........|
00000040  00 04 06 00 00 00 48 65  6c 6c 6f 00 04 06 00 00  |......Hello.....|
00000050  00 70 72 69 6e 74 00 04  08 00 00 00 20 57 6f 72  |.print...... Wor|
00000060  6c 64 21 00 00 00 00 00  01 00 00 00 01 00 30 00  |ld!...........0.|
00000070  00 00 6c 6f 63 61 6c 20  20 68 65 6c 6c 6f 20 3d  |..local  hello =|
00000080  20 22 48 65 6c 6c 6f 22  20 70 72 69 6e 74 20 28  | "Hello" print (|
00000090  68 65 6c 6c 6f 2e 2e 22  20 57 6f 72 6c 64 21 22  |hello.." World!"|
000000a0  29 00 07 00 00 00 01 00  00 00 01 00 00 00 01 00  |)...............|
000000b0  00 00 01 00 00 00 01 00  00 00 01 00 00 00 01 00  |................|
000000c0  00 00 01 00 00 00 06 00  00 00 68 65 6c 6c 6f 00  |..........hello.|
000000d0  01 00 00 00 07 00 00 00  01 00 00 00 05 00 00 00  |................|
000000e0  5f 45 4e 56 00                                    |_ENV.|

这里面许多东西是用于调试的,所以来把hello.luac传给luac -s重试一下。

00000000  1b 4c 75 61 52 00 01 04  04 04 08 00 19 93 0d 0a  |.LuaR...........|
00000010  1a 0a 00 00 00 00 00 00  00 00 00 01 04 07 00 00  |................|
00000020  00 01 00 00 00 46 40 40  00 80 00 00 00 c1 80 00  |.....F@@........|
00000030  00 96 c0 00 01 5d 40 00  01 1f 00 80 00 03 00 00  |.....]@.........|
00000040  00 04 06 00 00 00 48 65  6c 6c 6f 00 04 06 00 00  |......Hello.....|
00000050  00 70 72 69 6e 74 00 04  08 00 00 00 20 57 6f 72  |.print...... Wor|
00000060  6c 64 21 00 00 00 00 00  01 00 00 00 01 00 00 00  |ld!.............|
00000070  00 00 00 00 00 00 00 00  00 00 00 00 00 00        |..............|

这个格式并没有官方说明,所以需要来做一些逆向工程。需要的材料在Lua的源码里面的几个地方,主要是ldump.c和lundump.c。我亦和NFI和LAT做了交叉检查,不过,估计还有问题,那都是我的错。

代码从18字节的文件头开始,这部分对所有官方Lua5.2编译器编译的代码来说都是一样(包括你的编译器),不管用的是luac或者load还是loadfile。Lua5.1的头部是12字节,与此段的前12字节相似。

字节编码顺序从十进制的1(主要展示其算法)和十六进制的0开始。

1 x00: 1b 4c 75 61
    LUA_SIGNATURE ,来自 lua.h.
5 x04: 52  00
    二进制码表示的十进制52是lua的版本,00表示此字节码兼容于“官方”的PUC-Rio实现。
5+2 x06: 01  04  04  04  08  00
    六个系统的参数。在x386的机器上分别表示little-endian,4字节的integers,4字节的VM指令,4字节的size_t数字,8字节的Lua数字,floating-    point。在字节码文件和Lua解释器之间这些参数必须完全匹配,否则字节码将失效。
7+6 x0c: 19 93 0d 0a 1a 0a
    到这里就是PUC-Rio产出的全部的Lua5.2字节码了。lumdump.h对此的描述是“捕捉转换错误的数据”。可能由二进制码表示的十进制数1993(它的起始年份),Windows行终止符,MS-DOS文本文件终止符,类Unix系统行终止符组成。

这18字节过后是文件中的函数定义。每个函数都由11字节的函数头开始。

13+6 x12:  00 00 00 00
    源码中区块开始的行号。0是主区块。
19+4 x16:  00 00 00 00
    源码中区块终止的行号。0是主区块。
23+4 x1a:  00  01  04
    参数数量,可变长参数标识(varargs),该函数用的寄存器数量(很明显不超过255)。局部变量存储在寄存器,它们应该不超过200个(参考lparser.c)

然后是一个指令列表,我们稍后会详细讨论其形式。

27+3 x1d:  07 00 00 00
    列表有7个指令(小端字节序,就像文件头所说)
30+4*1 x21:  01 00 00 00
    字节码,LOADK 0 -1.
30+4*2 x25:  46 40 40 00
    字节码,GETTABUP 1 0 2.
30+4*3 x29:  80 00 00 00
    字节码,MOVE 2 0.
30+4*4 x2d:  c1 80 00 00
    字节码,LOADK 3 -3.
30+4*5 x31:  96 c0 00 01
    字节码,CONCAT 2 2 3.
30+4*6 x35:  5d 40 00 01
    字节码,CALL 1 2 1.
30+4*7 x39:  1f 00 80 00
    字节码,RETURN 0 1.

然后是常量列表。

58+4 x3d:  03 00 00 00
    共3个常量。
62+4 x41:  04
    第一个常量是string
66+1 x42:  06 00 00 00
    含6字节,
67+4 x46:  48 65 6c 6c 6f 00
    包含了”Hello”和一个终止的0字节。
71+6 x4c:  04  06 00 00 00  70 72 69 6e 74 00
    第二个常量是6字节字符串”print”。
77+5+6 x57:  04  08 00 00 00  20 57 6f 72 6c 64 21 00
    第三个常量是8字节字符串” World!”。

紧随着是函数原型列表:

88+5+8 x64:  00 00 00 00
    无函数原型

最后是外部局部变量列表:

101+4 x68:  01 00 00 00
有1个外部局部变量
105+4 x6c:  01 00
可以在上1级的堆栈中找到,在堆栈的位置0。

从这开始,两个文件出现了分歧。在剥离后的版本中,去掉的调试信息展现为16个0字节。

109+2 x6e:  00 00 00 00
    无源代码
111+4 x72:  00 00 00 00
    行号列表空
115+4 x76:  00 00 00 00
    局部变量名空
119+4 x7a:  00 00 00 00
    外部局部变量名空

在未剥离的版本中,给出了以下信息:

111 x6e:  30 00 00 00
    源码长度48字节。忽略旧的源码,
111+4+48 xa2:  07 00 00 00
    7个行号,忽略旧行号,
163+4+28 xc2:  01 00 00 00
    列出了1个局部变量名。
199 xc6:  06 00 00 00  68 65 6c 6c 6f 00
    变量名hello
199+4+6 xd0:  01 00 00 00
    在指令1进入其作用域
209+4 xd4:  07 00 00 00
    在指令7跳出其作用域
213+4 xd8:  01 00 00 00
    列出1个外部局部变量
217+4 xdc:  05 00 00 00  5f 45 4e 56 00
    变量名_ENV

此后的函数也会以同种方式编码。

目前我的探索发现到此。以下领域尚未覆盖:

  •   非字符串常量如何存储
  •   函数原型的形式为何

NFI涵括了这些以及其它未提及的东西,但是是5.1版本的,所以你得参照lundump.c和LAT。

全面窥探字节码

伴随此文件而来的还有两个模块文件:vm52.lua和bytecode.lua。这阶段一开始,我们仅使用numberAt。这个函数将4字节子字符串转成32位数字,使用宿主机器的字节端序。我们从上面了解到dump后的Lua5.2函数在28是一个可变长函数参数,在29是寄存器数量,在30是指令数量,随后是许多的4字节指令。

所以让我们来看一下。

$ lua -l vm52
Lua 5.2.1  Copyright (C) 1994-2012 Lua.org, PUC-Rio
   func = load 'local  hello = "Hello" print (hello.." World!")'
   chunk = string.dump(func)

   function hasvarargs(chunk) return chunk:sub(28,28):byte()>0 end
   function nstack(chunk) return chunk:sub(29,29):byte() end
   function ninstr(chunk) return vm52.numberAt(chunk,30) end
   function instr(chunk,i) return vm52.numberAt(chunk:sub(30+4*i,33+4*i)) end

   ns = nstack(chunk)
   print ("This function" ..
     (hasvarargs(chunk) and " has a variable number of arguments and" or "") ..
     " uses "..ns.." register"..(ns~=1 and "s" or ""))

   for i=1,ninstr(chunk) do
      print(string.format("%08X",instr(chunk,i)))
   end 
00000001
00404046
00000080
000080C1
0100C096
0100405D
0080001F

你能看到字节码文件的字节内容,倒转是因为我的机器是小端字节序。

模块文件vm52.lua不仅有numberAt:它有上面代码定义的四个函数,更重要的,它还有assemble和disassemble。后者做的跟lua -l一样处理dump后的字节码。

继续之前的Lua会话,

   for i=1,vm52.ninstr(code) do
      print(vm52.disassemble(vm52.instr(code,i)))
   end 
LOADK 0 -1
GETTABUP 1 0 -2
MOVE 2 0
LOADK 3 -3
CONCAT 2 2 3
CALL 1 2 1
RETURN 0 1

指令解剖

以上16进制列表与字节码文件相对应,不过字节是倒转的。这是因为我的系统是小端序的:小端字节序的32位数字真是难以阅读,所以普遍使用的是大端字节序。

所以在分解和显示它们之前,我们把指令字节倒转过来。numberAt函数会做这个事情,如果需要的话:

就像我们看过的,指令是:

00 00 00 01   LOADK 0 -1
00 40 40 46   GETTABUP 1 0 -2
00 00 00 80   MOVE 2 0
00 00 80 c1   LOADK 3 -3
01 00 c0 96   CONCAT 2 2 3
01 00 40 5d   CALL 1 2 1
00 80 00 1f   RETURN 0 1

我们现在将字节展开成二进制并且分组。如果lopcodes.c指明指令是iABC模式的,我们用(9,9,8,6)分组,定义值为B,C,A,OP;如果指明是iABx或者iAsBx,用(18,8,6)分组,定义值为Bx,A,OP或者sBx,A,OP;如果是iAx,分为(26,6),定义值为Ax,OP。

    B    Bx   C         A      OP
000000000000000000  00000000 000001   LOADK 0 -1       (A,Bx)
000000000 100000001 00000001 000110   GETTABUP 1 0 -2  (A,B,C)
000000000 000000000 00000010 000000   MOVE 2 0         (A,B) 
000000000000000010  00000011 000001   LOADK 3 -3       (A,Bx)
000000010 000000011 00000010 010110   CONCAT 2 2 3     (A,B,C)
000000010 000000001 00000001 011101   CALL 1 2 1       (A,B,C)
000000001 000000000 00000000 011111   RETURN 0 1       (A,B)

上面的表格里面,每一位的编号从左到右是31,30,…,0。记号“0+6”意味着位置0 的低6位(表格最右边)。如果指令作为一个Lua数字存储,这几位用bit32:extract(0,6)表示。

1、     OP是操作码(opcode),在0+6(看下面)。

2、     A是所有指令的第一操作数(operand),在6:8,视为无符号数字。最大值是255。

3、     B是模式iABC的第二操作数,在23+9,视为有符号数字。实际的指令未必用到B。

第一位是符号位。如果是1,其它8位为B的绝对值减1。因此没有-0,B的最小值是-256,最大值255。

4、     C是iABC的第三操作数,在14+9,视为像B一样的有符号数字。实际指令未必用到C。

5、     Bx是iABx的第二操作数,在14+18,像B一样视为19位有符号数字,但是不需存储符号位因为Bx总是负的。

6、     sBx意思是“Bx视为18为有符号数字”。

7、     Ax是模式iAx的唯一操作数,在6+26,视为Bx(27位有符号数字,负数,符号位不存储)。

指令的操作码及其功能描述在lopcodes.h的一个enum中。我们以其相关功能进行分组。描述(从lopcodes.h中逐字取出)中使用以下记号,B,Bx等不是指位编码的数字,而是指luac反编译后能看到的数字。

R(A), R(B), R(C)
    寄存器A,B或者C的数值。
Kst(Bx)
    常数|Bx|。
KPROTO[Bx]
    函数原型|Bx|。
RK(B), RK(C)
    寄存器B或者C的值;如果B或者C是负数,常数|B|或者|C|。
FPF
    每次刷新的Fields。阅读NFI的解释。
UpValue
    外部局部变量B的值
Pc
    程序计数器:下一条被执行的指令的编号
closure
    创建一个闭包(函数加上外部局部变量)。

加载参数

$ luac -l -l -v -s -
Lua 5.2.1  Copyright (C) 1994-2012 Lua.org, PUC-Rio
local hello = "Hello"
print (hello.." World!")

main <stdin:0,0> (7 instructions at 0x9984970)
0+ params, 4 slots, 1 upvalue, 1 local, 3 constants, 0 functions
    1   [1] LOADK       0 -1    ; "Hello"
    2   [2] GETTABUP    1 0 -2  ; _ENV "print"
    3   [2] MOVE        2 0
    4   [2] LOADK       3 -3    ; " World!"
    5   [2] CONCAT      2 2 3
    6   [2] CALL        1 2 1
    7   [2] RETURN      0 1
constants (3) for 0x9984970:
    1   "Hello"
    2   "print"
    3   " World!"
locals (1) for 0x9984970:
    0   hello   2   8
upvalues (1) for 0x9984970:
0   _ENV    1   0

我们稍后讨论这个输出,在这时候我想要你看到这四个清单:稍微注释过的汇编代码,常数,局部变量,外部局部变量。

现在我们回到Lua本身。load同一区块,dump下来获取其字节码,把它写到文件里。我修改(patched)了我的Lua,用三个空格代替了控制台的>,这样你可以直接剪切粘贴这些代码。

$ lua
Lua 5.2.1  Copyright (C) 1994-2012 Lua.org, PUC-Rio
   func = load 'local  hello = "Hello" print (hello.." World!")'
   chunk = string.dump(func)
   io.open("hello.luac","wb"):write(chunk)

luac也可以接受二进制区块。我们可以用它来看看load做了什么。

$ luac -l -l -v hello.luac
Lua 5.2.1  Copyright (C) 1994-2012 Lua.org, PUC-Rio

main <(string):0,0> (7 instructions at 0x90d0b50)
0+ params, 4 slots, 1 upvalue, 1 local, 3 constants, 0 functions
    1   [1] LOADK       0 -1    ; "Hello"
    2   [1] GETTABUP    1 0 -2  ; _ENV "print"
    3   [1] MOVE        2 0
    4   [1] LOADK       3 -3    ; " World!"
    5   [1] CONCAT      2 2 3
    6   [1] CALL        1 2 1
    7   [1] RETURN      0 1
constants (3) for 0x90d0b50:
    1   "Hello"
    2   "print"
    3   " World!"
locals (1) for 0x90d0b50:
    0   hello   2   8
upvalues (1) for 0x90d0b50:
0   _ENV    1   0

能观察到区别吗?

  •  (string)不同于stdin
  •  四个地方的0x90d0b50和0x9984970不同
  •  除了其中一行,其它都有[1]和[2]的差别

即load和luac基本上生成完全一致的代码。

字节码解剖

字节码究竟是怎么样的?这是hello.luac的hexdump(用的是我系统的hd程序)。

00000000  1b 4c 75 61 52 00 01 04  04 04 08 00 19 93 0d 0a  |.LuaR...........|
00000010  1a 0a 00 00 00 00 00 00  00 00 00 01 04 07 00 00  |................|
00000020  00 01 00 00 00 46 40 40  00 80 00 00 00 c1 80 00  |.....F@@........|
00000030  00 96 c0 00 01 5d 40 00  01 1f 00 80 00 03 00 00  |.....]@.........|
00000040  00 04 06 00 00 00 48 65  6c 6c 6f 00 04 06 00 00  |......Hello.....|
00000050  00 70 72 69 6e 74 00 04  08 00 00 00 20 57 6f 72  |.print...... Wor|
00000060  6c 64 21 00 00 00 00 00  01 00 00 00 01 00 30 00  |ld!...........0.|
00000070  00 00 6c 6f 63 61 6c 20  20 68 65 6c 6c 6f 20 3d  |..local  hello =|
00000080  20 22 48 65 6c 6c 6f 22  20 70 72 69 6e 74 20 28  | "Hello" print (|
00000090  68 65 6c 6c 6f 2e 2e 22  20 57 6f 72 6c 64 21 22  |hello.." World!"|
000000a0  29 00 07 00 00 00 01 00  00 00 01 00 00 00 01 00  |)...............|
000000b0  00 00 01 00 00 00 01 00  00 00 01 00 00 00 01 00  |................|
000000c0  00 00 01 00 00 00 06 00  00 00 68 65 6c 6c 6f 00  |..........hello.|
000000d0  01 00 00 00 07 00 00 00  01 00 00 00 05 00 00 00  |................|
000000e0  5f 45 4e 56 00                                    |_ENV.|

这里面许多东西是用于调试的,所以来把hello.luac传给luac -s重试一下。

00000000  1b 4c 75 61 52 00 01 04  04 04 08 00 19 93 0d 0a  |.LuaR...........|
00000010  1a 0a 00 00 00 00 00 00  00 00 00 01 04 07 00 00  |................|
00000020  00 01 00 00 00 46 40 40  00 80 00 00 00 c1 80 00  |.....F@@........|
00000030  00 96 c0 00 01 5d 40 00  01 1f 00 80 00 03 00 00  |.....]@.........|
00000040  00 04 06 00 00 00 48 65  6c 6c 6f 00 04 06 00 00  |......Hello.....|
00000050  00 70 72 69 6e 74 00 04  08 00 00 00 20 57 6f 72  |.print...... Wor|
00000060  6c 64 21 00 00 00 00 00  01 00 00 00 01 00 00 00  |ld!.............|
00000070  00 00 00 00 00 00 00 00  00 00 00 00 00 00        |..............|

这个格式并没有官方说明,所以需要来做一些逆向工程。需要的材料在Lua的源码里面的几个地方,主要是ldump.c和lundump.c。我亦和NFI和LAT做了交叉检查,不过,估计还有问题,那都是我的错。

代码从18字节的文件头开始,这部分对所有官方Lua5.2编译器编译的代码来说都是一样(包括你的编译器),不管用的是luac或者load还是loadfile。Lua5.1的头部是12字节,与此段的前12字节相似。

字节编码顺序从十进制的1(主要展示其算法)和十六进制的0开始。

1 x00: 1b 4c 75 61
    LUA_SIGNATURE ,来自 lua.h.
5 x04: 52  00
    二进制码表示的十进制52是lua的版本,00表示此字节码兼容于“官方”的PUC-Rio实现。
5+2 x06: 01  04  04  04  08  00
    六个系统的参数。在x386的机器上分别表示little-endian,4字节的integers,4字节的VM指令,4字节的size_t数字,8字节的Lua数字,floating-    point。在字节码文件和Lua解释器之间这些参数必须完全匹配,否则字节码将失效。
7+6 x0c: 19 93 0d 0a 1a 0a
    到这里就是PUC-Rio产出的全部的Lua5.2字节码了。lumdump.h对此的描述是“捕捉转换错误的数据”。可能由二进制码表示的十进制数1993(它的起始年份),Windows行终止符,MS-DOS文本文件终止符,类Unix系统行终止符组成。

这18字节过后是文件中的函数定义。每个函数都由11字节的函数头开始。

13+6 x12:  00 00 00 00
    源码中区块开始的行号。0是主区块。
19+4 x16:  00 00 00 00
    源码中区块终止的行号。0是主区块。
23+4 x1a:  00  01  04
    参数数量,可变长参数标识(varargs),该函数用的寄存器数量(很明显不超过255)。局部变量存储在寄存器,它们应该不超过200个(参考lparser.c)

然后是一个指令列表,我们稍后会详细讨论其形式。

27+3 x1d:  07 00 00 00
    列表有7个指令(小端字节序,就像文件头所说)
30+4*1 x21:  01 00 00 00
    字节码,LOADK 0 -1.
30+4*2 x25:  46 40 40 00
    字节码,GETTABUP 1 0 2.
30+4*3 x29:  80 00 00 00
    字节码,MOVE 2 0.
30+4*4 x2d:  c1 80 00 00
    字节码,LOADK 3 -3.
30+4*5 x31:  96 c0 00 01
    字节码,CONCAT 2 2 3.
30+4*6 x35:  5d 40 00 01
    字节码,CALL 1 2 1.
30+4*7 x39:  1f 00 80 00
    字节码,RETURN 0 1.

然后是常量列表。

58+4 x3d:  03 00 00 00
    共3个常量。
62+4 x41:  04
    第一个常量是string
66+1 x42:  06 00 00 00
    含6字节,
67+4 x46:  48 65 6c 6c 6f 00
    包含了”Hello”和一个终止的0字节。
71+6 x4c:  04  06 00 00 00  70 72 69 6e 74 00
    第二个常量是6字节字符串”print”。
77+5+6 x57:  04  08 00 00 00  20 57 6f 72 6c 64 21 00
    第三个常量是8字节字符串” World!”。

紧随着是函数原型列表:

88+5+8 x64:  00 00 00 00
    无函数原型

最后是外部局部变量列表:

101+4 x68:  01 00 00 00
有1个外部局部变量
105+4 x6c:  01 00
可以在上1级的堆栈中找到,在堆栈的位置0。

从这开始,两个文件出现了分歧。在剥离后的版本中,去掉的调试信息展现为16个0字节。

109+2 x6e:  00 00 00 00
    无源代码
111+4 x72:  00 00 00 00
    行号列表空
115+4 x76:  00 00 00 00
    局部变量名空
119+4 x7a:  00 00 00 00
    外部局部变量名空

在未剥离的版本中,给出了以下信息:

111 x6e:  30 00 00 00
    源码长度48字节。忽略旧的源码,
111+4+48 xa2:  07 00 00 00
    7个行号,忽略旧行号,
163+4+28 xc2:  01 00 00 00
    列出了1个局部变量名。
199 xc6:  06 00 00 00  68 65 6c 6c 6f 00
    变量名hello
199+4+6 xd0:  01 00 00 00
    在指令1进入其作用域
209+4 xd4:  07 00 00 00
    在指令7跳出其作用域
213+4 xd8:  01 00 00 00
    列出1个外部局部变量
217+4 xdc:  05 00 00 00  5f 45 4e 56 00
    变量名_ENV

此后的函数也会以同种方式编码。

目前我的探索发现到此。以下领域尚未覆盖:

  •   非字符串常量如何存储
  •   函数原型的形式为何

NFI涵括了这些以及其它未提及的东西,但是是5.1版本的,所以你得参照lundump.c和LAT。

全面窥探字节码

伴随此文件而来的还有两个模块文件:vm52.lua和bytecode.lua。这阶段一开始,我们仅使用numberAt。这个函数将4字节子字符串转成32位数字,使用宿主机器的字节端序。我们从上面了解到dump后的Lua5.2函数在28是一个可变长函数参数,在29是寄存器数量,在30是指令数量,随后是许多的4字节指令。

所以让我们来看一下。

$ lua -l vm52
Lua 5.2.1  Copyright (C) 1994-2012 Lua.org, PUC-Rio
   func = load 'local  hello = "Hello" print (hello.." World!")'
   chunk = string.dump(func)

   function hasvarargs(chunk) return chunk:sub(28,28):byte()>0 end
   function nstack(chunk) return chunk:sub(29,29):byte() end
   function ninstr(chunk) return vm52.numberAt(chunk,30) end
   function instr(chunk,i) return vm52.numberAt(chunk:sub(30+4*i,33+4*i)) end

   ns = nstack(chunk)
   print ("This function" ..
     (hasvarargs(chunk) and " has a variable number of arguments and" or "") ..
     " uses "..ns.." register"..(ns~=1 and "s" or ""))

   for i=1,ninstr(chunk) do
      print(string.format("%08X",instr(chunk,i)))
   end 
00000001
00404046
00000080
000080C1
0100C096
0100405D
0080001F

你能看到字节码文件的字节内容,倒转是因为我的机器是小端字节序。

模块文件vm52.lua不仅有numberAt:它有上面代码定义的四个函数,更重要的,它还有assemble和disassemble。后者做的跟lua -l一样处理dump后的字节码。

继续之前的Lua会话,

   for i=1,vm52.ninstr(code) do
      print(vm52.disassemble(vm52.instr(code,i)))
   end 
LOADK 0 -1
GETTABUP 1 0 -2
MOVE 2 0
LOADK 3 -3
CONCAT 2 2 3
CALL 1 2 1
RETURN 0 1

指令解剖

以上16进制列表与字节码文件相对应,不过字节是倒转的。这是因为我的系统是小端序的:小端字节序的32位数字真是难以阅读,所以普遍使用的是大端字节序。

所以在分解和显示它们之前,我们把指令字节倒转过来。numberAt函数会做这个事情,如果需要的话:

就像我们看过的,指令是:

00 00 00 01   LOADK 0 -1
00 40 40 46   GETTABUP 1 0 -2
00 00 00 80   MOVE 2 0
00 00 80 c1   LOADK 3 -3
01 00 c0 96   CONCAT 2 2 3
01 00 40 5d   CALL 1 2 1
00 80 00 1f   RETURN 0 1

我们现在将字节展开成二进制并且分组。如果lopcodes.c指明指令是iABC模式的,我们用(9,9,8,6)分组,定义值为B,C,A,OP;如果指明是iABx或者iAsBx,用(18,8,6)分组,定义值为Bx,A,OP或者sBx,A,OP;如果是iAx,分为(26,6),定义值为Ax,OP。

    B    Bx   C         A      OP
000000000000000000  00000000 000001   LOADK 0 -1       (A,Bx)
000000000 100000001 00000001 000110   GETTABUP 1 0 -2  (A,B,C)
000000000 000000000 00000010 000000   MOVE 2 0         (A,B) 
000000000000000010  00000011 000001   LOADK 3 -3       (A,Bx)
000000010 000000011 00000010 010110   CONCAT 2 2 3     (A,B,C)
000000010 000000001 00000001 011101   CALL 1 2 1       (A,B,C)
000000001 000000000 00000000 011111   RETURN 0 1       (A,B)

上面的表格里面,每一位的编号从左到右是31,30,…,0。记号“0+6”意味着位置0 的低6位(表格最右边)。如果指令作为一个Lua数字存储,这几位用bit32:extract(0,6)表示。

1、     OP是操作码(opcode),在0+6(看下面)。

2、     A是所有指令的第一操作数(operand),在6:8,视为无符号数字。最大值是255。

3、     B是模式iABC的第二操作数,在23+9,视为有符号数字。实际的指令未必用到B。

第一位是符号位。如果是1,其它8位为B的绝对值减1。因此没有-0,B的最小值是-256,最大值255。

4、     C是iABC的第三操作数,在14+9,视为像B一样的有符号数字。实际指令未必用到C。

5、     Bx是iABx的第二操作数,在14+18,像B一样视为19位有符号数字,但是不需存储符号位因为Bx总是负的。

6、     sBx意思是“Bx视为18为有符号数字”。

7、     Ax是模式iAx的唯一操作数,在6+26,视为Bx(27位有符号数字,负数,符号位不存储)。

指令的操作码及其功能描述在lopcodes.h的一个enum中。我们以其相关功能进行分组。描述(从lopcodes.h中逐字取出)中使用以下记号,B,Bx等不是指位编码的数字,而是指luac反编译后能看到的数字。

R(A), R(B), R(C)
    寄存器A,B或者C的数值。
Kst(Bx)
    常数|Bx|。
KPROTO[Bx]
    函数原型|Bx|。
RK(B), RK(C)
    寄存器B或者C的值;如果B或者C是负数,常数|B|或者|C|。
FPF
    每次刷新的Fields。阅读NFI的解释。
UpValue
    外部局部变量B的值
Pc
    程序计数器:下一条被执行的指令的编号
closure
    创建一个闭包(函数加上外部局部变量)。

加载参数

$ lua
Lua 5.2.1  Copyright (C) 1994-2012 Lua.org, PUC-Rio
   func = load 'local  hello = "Hello" print (hello.." World!")'
   chunk = string.dump(func)
   io.open("hello.luac","wb"):write(chunk)

luac也可以接受二进制区块。我们可以用它来看看load做了什么。

$ luac -l -l -v hello.luac
Lua 5.2.1  Copyright (C) 1994-2012 Lua.org, PUC-Rio

main <(string):0,0> (7 instructions at 0x90d0b50)
0+ params, 4 slots, 1 upvalue, 1 local, 3 constants, 0 functions
    1   [1] LOADK       0 -1    ; "Hello"
    2   [1] GETTABUP    1 0 -2  ; _ENV "print"
    3   [1] MOVE        2 0
    4   [1] LOADK       3 -3    ; " World!"
    5   [1] CONCAT      2 2 3
    6   [1] CALL        1 2 1
    7   [1] RETURN      0 1
constants (3) for 0x90d0b50:
    1   "Hello"
    2   "print"
    3   " World!"
locals (1) for 0x90d0b50:
    0   hello   2   8
upvalues (1) for 0x90d0b50:
0   _ENV    1   0

能观察到区别吗?

  •  (string)不同于stdin
  •  四个地方的0x90d0b50和0x9984970不同
  •  除了其中一行,其它都有[1]和[2]的差别

即load和luac基本上生成完全一致的代码。

字节码解剖

字节码究竟是怎么样的?这是hello.luac的hexdump(用的是我系统的hd程序)。

00000000  1b 4c 75 61 52 00 01 04  04 04 08 00 19 93 0d 0a  |.LuaR...........|
00000010  1a 0a 00 00 00 00 00 00  00 00 00 01 04 07 00 00  |................|
00000020  00 01 00 00 00 46 40 40  00 80 00 00 00 c1 80 00  |.....F@@........|
00000030  00 96 c0 00 01 5d 40 00  01 1f 00 80 00 03 00 00  |.....]@.........|
00000040  00 04 06 00 00 00 48 65  6c 6c 6f 00 04 06 00 00  |......Hello.....|
00000050  00 70 72 69 6e 74 00 04  08 00 00 00 20 57 6f 72  |.print...... Wor|
00000060  6c 64 21 00 00 00 00 00  01 00 00 00 01 00 30 00  |ld!...........0.|
00000070  00 00 6c 6f 63 61 6c 20  20 68 65 6c 6c 6f 20 3d  |..local  hello =|
00000080  20 22 48 65 6c 6c 6f 22  20 70 72 69 6e 74 20 28  | "Hello" print (|
00000090  68 65 6c 6c 6f 2e 2e 22  20 57 6f 72 6c 64 21 22  |hello.." World!"|
000000a0  29 00 07 00 00 00 01 00  00 00 01 00 00 00 01 00  |)...............|
000000b0  00 00 01 00 00 00 01 00  00 00 01 00 00 00 01 00  |................|
000000c0  00 00 01 00 00 00 06 00  00 00 68 65 6c 6c 6f 00  |..........hello.|
000000d0  01 00 00 00 07 00 00 00  01 00 00 00 05 00 00 00  |................|
000000e0  5f 45 4e 56 00                                    |_ENV.|

这里面许多东西是用于调试的,所以来把hello.luac传给luac -s重试一下。

00000000  1b 4c 75 61 52 00 01 04  04 04 08 00 19 93 0d 0a  |.LuaR...........|
00000010  1a 0a 00 00 00 00 00 00  00 00 00 01 04 07 00 00  |................|
00000020  00 01 00 00 00 46 40 40  00 80 00 00 00 c1 80 00  |.....F@@........|
00000030  00 96 c0 00 01 5d 40 00  01 1f 00 80 00 03 00 00  |.....]@.........|
00000040  00 04 06 00 00 00 48 65  6c 6c 6f 00 04 06 00 00  |......Hello.....|
00000050  00 70 72 69 6e 74 00 04  08 00 00 00 20 57 6f 72  |.print...... Wor|
00000060  6c 64 21 00 00 00 00 00  01 00 00 00 01 00 00 00  |ld!.............|
00000070  00 00 00 00 00 00 00 00  00 00 00 00 00 00        |..............|

这个格式并没有官方说明,所以需要来做一些逆向工程。需要的材料在Lua的源码里面的几个地方,主要是ldump.c和lundump.c。我亦和NFI和LAT做了交叉检查,不过,估计还有问题,那都是我的错。

代码从18字节的文件头开始,这部分对所有官方Lua5.2编译器编译的代码来说都是一样(包括你的编译器),不管用的是luac或者load还是loadfile。Lua5.1的头部是12字节,与此段的前12字节相似。

字节编码顺序从十进制的1(主要展示其算法)和十六进制的0开始。

1 x00: 1b 4c 75 61
    LUA_SIGNATURE ,来自 lua.h.
5 x04: 52  00
    二进制码表示的十进制52是lua的版本,00表示此字节码兼容于“官方”的PUC-Rio实现。
5+2 x06: 01  04  04  04  08  00
    六个系统的参数。在x386的机器上分别表示little-endian,4字节的integers,4字节的VM指令,4字节的size_t数字,8字节的Lua数字,floating-    point。在字节码文件和Lua解释器之间这些参数必须完全匹配,否则字节码将失效。
7+6 x0c: 19 93 0d 0a 1a 0a
    到这里就是PUC-Rio产出的全部的Lua5.2字节码了。lumdump.h对此的描述是“捕捉转换错误的数据”。可能由二进制码表示的十进制数1993(它的起始年份),Windows行终止符,MS-DOS文本文件终止符,类Unix系统行终止符组成。

这18字节过后是文件中的函数定义。每个函数都由11字节的函数头开始。

13+6 x12:  00 00 00 00
    源码中区块开始的行号。0是主区块。
19+4 x16:  00 00 00 00
    源码中区块终止的行号。0是主区块。
23+4 x1a:  00  01  04
    参数数量,可变长参数标识(varargs),该函数用的寄存器数量(很明显不超过255)。局部变量存储在寄存器,它们应该不超过200个(参考lparser.c)

然后是一个指令列表,我们稍后会详细讨论其形式。

27+3 x1d:  07 00 00 00
    列表有7个指令(小端字节序,就像文件头所说)
30+4*1 x21:  01 00 00 00
    字节码,LOADK 0 -1.
30+4*2 x25:  46 40 40 00
    字节码,GETTABUP 1 0 2.
30+4*3 x29:  80 00 00 00
    字节码,MOVE 2 0.
30+4*4 x2d:  c1 80 00 00
    字节码,LOADK 3 -3.
30+4*5 x31:  96 c0 00 01
    字节码,CONCAT 2 2 3.
30+4*6 x35:  5d 40 00 01
    字节码,CALL 1 2 1.
30+4*7 x39:  1f 00 80 00
    字节码,RETURN 0 1.

然后是常量列表。

58+4 x3d:  03 00 00 00
    共3个常量。
62+4 x41:  04
    第一个常量是string
66+1 x42:  06 00 00 00
    含6字节,
67+4 x46:  48 65 6c 6c 6f 00
    包含了”Hello”和一个终止的0字节。
71+6 x4c:  04  06 00 00 00  70 72 69 6e 74 00
    第二个常量是6字节字符串”print”。
77+5+6 x57:  04  08 00 00 00  20 57 6f 72 6c 64 21 00
    第三个常量是8字节字符串” World!”。

紧随着是函数原型列表:

88+5+8 x64:  00 00 00 00
    无函数原型

最后是外部局部变量列表:

101+4 x68:  01 00 00 00
有1个外部局部变量
105+4 x6c:  01 00
可以在上1级的堆栈中找到,在堆栈的位置0。

从这开始,两个文件出现了分歧。在剥离后的版本中,去掉的调试信息展现为16个0字节。

109+2 x6e:  00 00 00 00
    无源代码
111+4 x72:  00 00 00 00
    行号列表空
115+4 x76:  00 00 00 00
    局部变量名空
119+4 x7a:  00 00 00 00
    外部局部变量名空

在未剥离的版本中,给出了以下信息:

111 x6e:  30 00 00 00
    源码长度48字节。忽略旧的源码,
111+4+48 xa2:  07 00 00 00
    7个行号,忽略旧行号,
163+4+28 xc2:  01 00 00 00
    列出了1个局部变量名。
199 xc6:  06 00 00 00  68 65 6c 6c 6f 00
    变量名hello
199+4+6 xd0:  01 00 00 00
    在指令1进入其作用域
209+4 xd4:  07 00 00 00
    在指令7跳出其作用域
213+4 xd8:  01 00 00 00
    列出1个外部局部变量
217+4 xdc:  05 00 00 00  5f 45 4e 56 00
    变量名_ENV

此后的函数也会以同种方式编码。

目前我的探索发现到此。以下领域尚未覆盖:

  •   非字符串常量如何存储
  •   函数原型的形式为何

NFI涵括了这些以及其它未提及的东西,但是是5.1版本的,所以你得参照lundump.c和LAT。

全面窥探字节码

伴随此文件而来的还有两个模块文件:vm52.lua和bytecode.lua。这阶段一开始,我们仅使用numberAt。这个函数将4字节子字符串转成32位数字,使用宿主机器的字节端序。我们从上面了解到dump后的Lua5.2函数在28是一个可变长函数参数,在29是寄存器数量,在30是指令数量,随后是许多的4字节指令。

所以让我们来看一下。

$ lua -l vm52
Lua 5.2.1  Copyright (C) 1994-2012 Lua.org, PUC-Rio
   func = load 'local  hello = "Hello" print (hello.." World!")'
   chunk = string.dump(func)

   function hasvarargs(chunk) return chunk:sub(28,28):byte()>0 end
   function nstack(chunk) return chunk:sub(29,29):byte() end
   function ninstr(chunk) return vm52.numberAt(chunk,30) end
   function instr(chunk,i) return vm52.numberAt(chunk:sub(30+4*i,33+4*i)) end

   ns = nstack(chunk)
   print ("This function" ..
     (hasvarargs(chunk) and " has a variable number of arguments and" or "") ..
     " uses "..ns.." register"..(ns~=1 and "s" or ""))

   for i=1,ninstr(chunk) do
      print(string.format("%08X",instr(chunk,i)))
   end 
00000001
00404046
00000080
000080C1
0100C096
0100405D
0080001F

你能看到字节码文件的字节内容,倒转是因为我的机器是小端字节序。

模块文件vm52.lua不仅有numberAt:它有上面代码定义的四个函数,更重要的,它还有assemble和disassemble。后者做的跟lua -l一样处理dump后的字节码。

继续之前的Lua会话,

   for i=1,vm52.ninstr(code) do
      print(vm52.disassemble(vm52.instr(code,i)))
   end 
LOADK 0 -1
GETTABUP 1 0 -2
MOVE 2 0
LOADK 3 -3
CONCAT 2 2 3
CALL 1 2 1
RETURN 0 1

指令解剖

以上16进制列表与字节码文件相对应,不过字节是倒转的。这是因为我的系统是小端序的:小端字节序的32位数字真是难以阅读,所以普遍使用的是大端字节序。

所以在分解和显示它们之前,我们把指令字节倒转过来。numberAt函数会做这个事情,如果需要的话:

就像我们看过的,指令是:

00 00 00 01   LOADK 0 -1
00 40 40 46   GETTABUP 1 0 -2
00 00 00 80   MOVE 2 0
00 00 80 c1   LOADK 3 -3
01 00 c0 96   CONCAT 2 2 3
01 00 40 5d   CALL 1 2 1
00 80 00 1f   RETURN 0 1

我们现在将字节展开成二进制并且分组。如果lopcodes.c指明指令是iABC模式的,我们用(9,9,8,6)分组,定义值为B,C,A,OP;如果指明是iABx或者iAsBx,用(18,8,6)分组,定义值为Bx,A,OP或者sBx,A,OP;如果是iAx,分为(26,6),定义值为Ax,OP。

    B    Bx   C         A      OP
000000000000000000  00000000 000001   LOADK 0 -1       (A,Bx)
000000000 100000001 00000001 000110   GETTABUP 1 0 -2  (A,B,C)
000000000 000000000 00000010 000000   MOVE 2 0         (A,B) 
000000000000000010  00000011 000001   LOADK 3 -3       (A,Bx)
000000010 000000011 00000010 010110   CONCAT 2 2 3     (A,B,C)
000000010 000000001 00000001 011101   CALL 1 2 1       (A,B,C)
000000001 000000000 00000000 011111   RETURN 0 1       (A,B)

上面的表格里面,每一位的编号从左到右是31,30,…,0。记号“0+6”意味着位置0 的低6位(表格最右边)。如果指令作为一个Lua数字存储,这几位用bit32:extract(0,6)表示。

1、     OP是操作码(opcode),在0+6(看下面)。

2、     A是所有指令的第一操作数(operand),在6:8,视为无符号数字。最大值是255。

3、     B是模式iABC的第二操作数,在23+9,视为有符号数字。实际的指令未必用到B。

第一位是符号位。如果是1,其它8位为B的绝对值减1。因此没有-0,B的最小值是-256,最大值255。

4、     C是iABC的第三操作数,在14+9,视为像B一样的有符号数字。实际指令未必用到C。

5、     Bx是iABx的第二操作数,在14+18,像B一样视为19位有符号数字,但是不需存储符号位因为Bx总是负的。

6、     sBx意思是“Bx视为18为有符号数字”。

7、     Ax是模式iAx的唯一操作数,在6+26,视为Bx(27位有符号数字,负数,符号位不存储)。

指令的操作码及其功能描述在lopcodes.h的一个enum中。我们以其相关功能进行分组。描述(从lopcodes.h中逐字取出)中使用以下记号,B,Bx等不是指位编码的数字,而是指luac反编译后能看到的数字。

R(A), R(B), R(C)
    寄存器A,B或者C的数值。
Kst(Bx)
    常数|Bx|。
KPROTO[Bx]
    函数原型|Bx|。
RK(B), RK(C)
    寄存器B或者C的值;如果B或者C是负数,常数|B|或者|C|。
FPF
    每次刷新的Fields。阅读NFI的解释。
UpValue
    外部局部变量B的值
Pc
    程序计数器:下一条被执行的指令的编号
closure
    创建一个闭包(函数加上外部局部变量)。

加载参数

$ luac -l -l -v hello.luac
Lua 5.2.1  Copyright (C) 1994-2012 Lua.org, PUC-Rio

main <(string):0,0> (7 instructions at 0x90d0b50)
0+ params, 4 slots, 1 upvalue, 1 local, 3 constants, 0 functions
    1   [1] LOADK       0 -1    ; "Hello"
    2   [1] GETTABUP    1 0 -2  ; _ENV "print"
    3   [1] MOVE        2 0
    4   [1] LOADK       3 -3    ; " World!"
    5   [1] CONCAT      2 2 3
    6   [1] CALL        1 2 1
    7   [1] RETURN      0 1
constants (3) for 0x90d0b50:
    1   "Hello"
    2   "print"
    3   " World!"
locals (1) for 0x90d0b50:
    0   hello   2   8
upvalues (1) for 0x90d0b50:
0   _ENV    1   0

能观察到区别吗?

  •  (string)不同于stdin
  •  四个地方的0x90d0b50和0x9984970不同
  •  除了其中一行,其它都有[1]和[2]的差别

即load和luac基本上生成完全一致的代码。

字节码解剖

字节码究竟是怎么样的?这是hello.luac的hexdump(用的是我系统的hd程序)。

00000000  1b 4c 75 61 52 00 01 04  04 04 08 00 19 93 0d 0a  |.LuaR...........|
00000010  1a 0a 00 00 00 00 00 00  00 00 00 01 04 07 00 00  |................|
00000020  00 01 00 00 00 46 40 40  00 80 00 00 00 c1 80 00  |.....F@@........|
00000030  00 96 c0 00 01 5d 40 00  01 1f 00 80 00 03 00 00  |.....]@.........|
00000040  00 04 06 00 00 00 48 65  6c 6c 6f 00 04 06 00 00  |......Hello.....|
00000050  00 70 72 69 6e 74 00 04  08 00 00 00 20 57 6f 72  |.print...... Wor|
00000060  6c 64 21 00 00 00 00 00  01 00 00 00 01 00 30 00  |ld!...........0.|
00000070  00 00 6c 6f 63 61 6c 20  20 68 65 6c 6c 6f 20 3d  |..local  hello =|
00000080  20 22 48 65 6c 6c 6f 22  20 70 72 69 6e 74 20 28  | "Hello" print (|
00000090  68 65 6c 6c 6f 2e 2e 22  20 57 6f 72 6c 64 21 22  |hello.." World!"|
000000a0  29 00 07 00 00 00 01 00  00 00 01 00 00 00 01 00  |)...............|
000000b0  00 00 01 00 00 00 01 00  00 00 01 00 00 00 01 00  |................|
000000c0  00 00 01 00 00 00 06 00  00 00 68 65 6c 6c 6f 00  |..........hello.|
000000d0  01 00 00 00 07 00 00 00  01 00 00 00 05 00 00 00  |................|
000000e0  5f 45 4e 56 00                                    |_ENV.|

这里面许多东西是用于调试的,所以来把hello.luac传给luac -s重试一下。

00000000  1b 4c 75 61 52 00 01 04  04 04 08 00 19 93 0d 0a  |.LuaR...........|
00000010  1a 0a 00 00 00 00 00 00  00 00 00 01 04 07 00 00  |................|
00000020  00 01 00 00 00 46 40 40  00 80 00 00 00 c1 80 00  |.....F@@........|
00000030  00 96 c0 00 01 5d 40 00  01 1f 00 80 00 03 00 00  |.....]@.........|
00000040  00 04 06 00 00 00 48 65  6c 6c 6f 00 04 06 00 00  |......Hello.....|
00000050  00 70 72 69 6e 74 00 04  08 00 00 00 20 57 6f 72  |.print...... Wor|
00000060  6c 64 21 00 00 00 00 00  01 00 00 00 01 00 00 00  |ld!.............|
00000070  00 00 00 00 00 00 00 00  00 00 00 00 00 00        |..............|

这个格式并没有官方说明,所以需要来做一些逆向工程。需要的材料在Lua的源码里面的几个地方,主要是ldump.c和lundump.c。我亦和NFI和LAT做了交叉检查,不过,估计还有问题,那都是我的错。

代码从18字节的文件头开始,这部分对所有官方Lua5.2编译器编译的代码来说都是一样(包括你的编译器),不管用的是luac或者load还是loadfile。Lua5.1的头部是12字节,与此段的前12字节相似。

字节编码顺序从十进制的1(主要展示其算法)和十六进制的0开始。

1 x00: 1b 4c 75 61
    LUA_SIGNATURE ,来自 lua.h.
5 x04: 52  00
    二进制码表示的十进制52是lua的版本,00表示此字节码兼容于“官方”的PUC-Rio实现。
5+2 x06: 01  04  04  04  08  00
    六个系统的参数。在x386的机器上分别表示little-endian,4字节的integers,4字节的VM指令,4字节的size_t数字,8字节的Lua数字,floating-    point。在字节码文件和Lua解释器之间这些参数必须完全匹配,否则字节码将失效。
7+6 x0c: 19 93 0d 0a 1a 0a
    到这里就是PUC-Rio产出的全部的Lua5.2字节码了。lumdump.h对此的描述是“捕捉转换错误的数据”。可能由二进制码表示的十进制数1993(它的起始年份),Windows行终止符,MS-DOS文本文件终止符,类Unix系统行终止符组成。

这18字节过后是文件中的函数定义。每个函数都由11字节的函数头开始。

13+6 x12:  00 00 00 00
    源码中区块开始的行号。0是主区块。
19+4 x16:  00 00 00 00
    源码中区块终止的行号。0是主区块。
23+4 x1a:  00  01  04
    参数数量,可变长参数标识(varargs),该函数用的寄存器数量(很明显不超过255)。局部变量存储在寄存器,它们应该不超过200个(参考lparser.c)

然后是一个指令列表,我们稍后会详细讨论其形式。

27+3 x1d:  07 00 00 00
    列表有7个指令(小端字节序,就像文件头所说)
30+4*1 x21:  01 00 00 00
    字节码,LOADK 0 -1.
30+4*2 x25:  46 40 40 00
    字节码,GETTABUP 1 0 2.
30+4*3 x29:  80 00 00 00
    字节码,MOVE 2 0.
30+4*4 x2d:  c1 80 00 00
    字节码,LOADK 3 -3.
30+4*5 x31:  96 c0 00 01
    字节码,CONCAT 2 2 3.
30+4*6 x35:  5d 40 00 01
    字节码,CALL 1 2 1.
30+4*7 x39:  1f 00 80 00
    字节码,RETURN 0 1.

然后是常量列表。

58+4 x3d:  03 00 00 00
    共3个常量。
62+4 x41:  04
    第一个常量是string
66+1 x42:  06 00 00 00
    含6字节,
67+4 x46:  48 65 6c 6c 6f 00
    包含了”Hello”和一个终止的0字节。
71+6 x4c:  04  06 00 00 00  70 72 69 6e 74 00
    第二个常量是6字节字符串”print”。
77+5+6 x57:  04  08 00 00 00  20 57 6f 72 6c 64 21 00
    第三个常量是8字节字符串” World!”。

紧随着是函数原型列表:

88+5+8 x64:  00 00 00 00
    无函数原型

最后是外部局部变量列表:

101+4 x68:  01 00 00 00
有1个外部局部变量
105+4 x6c:  01 00
可以在上1级的堆栈中找到,在堆栈的位置0。

从这开始,两个文件出现了分歧。在剥离后的版本中,去掉的调试信息展现为16个0字节。

109+2 x6e:  00 00 00 00
    无源代码
111+4 x72:  00 00 00 00
    行号列表空
115+4 x76:  00 00 00 00
    局部变量名空
119+4 x7a:  00 00 00 00
    外部局部变量名空

在未剥离的版本中,给出了以下信息:

111 x6e:  30 00 00 00
    源码长度48字节。忽略旧的源码,
111+4+48 xa2:  07 00 00 00
    7个行号,忽略旧行号,
163+4+28 xc2:  01 00 00 00
    列出了1个局部变量名。
199 xc6:  06 00 00 00  68 65 6c 6c 6f 00
    变量名hello
199+4+6 xd0:  01 00 00 00
    在指令1进入其作用域
209+4 xd4:  07 00 00 00
    在指令7跳出其作用域
213+4 xd8:  01 00 00 00
    列出1个外部局部变量
217+4 xdc:  05 00 00 00  5f 45 4e 56 00
    变量名_ENV

此后的函数也会以同种方式编码。

目前我的探索发现到此。以下领域尚未覆盖:

  •   非字符串常量如何存储
  •   函数原型的形式为何

NFI涵括了这些以及其它未提及的东西,但是是5.1版本的,所以你得参照lundump.c和LAT。

全面窥探字节码

伴随此文件而来的还有两个模块文件:vm52.lua和bytecode.lua。这阶段一开始,我们仅使用numberAt。这个函数将4字节子字符串转成32位数字,使用宿主机器的字节端序。我们从上面了解到dump后的Lua5.2函数在28是一个可变长函数参数,在29是寄存器数量,在30是指令数量,随后是许多的4字节指令。

所以让我们来看一下。

$ lua -l vm52
Lua 5.2.1  Copyright (C) 1994-2012 Lua.org, PUC-Rio
   func = load 'local  hello = "Hello" print (hello.." World!")'
   chunk = string.dump(func)

   function hasvarargs(chunk) return chunk:sub(28,28):byte()>0 end
   function nstack(chunk) return chunk:sub(29,29):byte() end
   function ninstr(chunk) return vm52.numberAt(chunk,30) end
   function instr(chunk,i) return vm52.numberAt(chunk:sub(30+4*i,33+4*i)) end

   ns = nstack(chunk)
   print ("This function" ..
     (hasvarargs(chunk) and " has a variable number of arguments and" or "") ..
     " uses "..ns.." register"..(ns~=1 and "s" or ""))

   for i=1,ninstr(chunk) do
      print(string.format("%08X",instr(chunk,i)))
   end 
00000001
00404046
00000080
000080C1
0100C096
0100405D
0080001F

你能看到字节码文件的字节内容,倒转是因为我的机器是小端字节序。

模块文件vm52.lua不仅有numberAt:它有上面代码定义的四个函数,更重要的,它还有assemble和disassemble。后者做的跟lua -l一样处理dump后的字节码。

继续之前的Lua会话,

   for i=1,vm52.ninstr(code) do
      print(vm52.disassemble(vm52.instr(code,i)))
   end 
LOADK 0 -1
GETTABUP 1 0 -2
MOVE 2 0
LOADK 3 -3
CONCAT 2 2 3
CALL 1 2 1
RETURN 0 1

指令解剖

以上16进制列表与字节码文件相对应,不过字节是倒转的。这是因为我的系统是小端序的:小端字节序的32位数字真是难以阅读,所以普遍使用的是大端字节序。

所以在分解和显示它们之前,我们把指令字节倒转过来。numberAt函数会做这个事情,如果需要的话:

就像我们看过的,指令是:

00 00 00 01   LOADK 0 -1
00 40 40 46   GETTABUP 1 0 -2
00 00 00 80   MOVE 2 0
00 00 80 c1   LOADK 3 -3
01 00 c0 96   CONCAT 2 2 3
01 00 40 5d   CALL 1 2 1
00 80 00 1f   RETURN 0 1

我们现在将字节展开成二进制并且分组。如果lopcodes.c指明指令是iABC模式的,我们用(9,9,8,6)分组,定义值为B,C,A,OP;如果指明是iABx或者iAsBx,用(18,8,6)分组,定义值为Bx,A,OP或者sBx,A,OP;如果是iAx,分为(26,6),定义值为Ax,OP。

    B    Bx   C         A      OP
000000000000000000  00000000 000001   LOADK 0 -1       (A,Bx)
000000000 100000001 00000001 000110   GETTABUP 1 0 -2  (A,B,C)
000000000 000000000 00000010 000000   MOVE 2 0         (A,B) 
000000000000000010  00000011 000001   LOADK 3 -3       (A,Bx)
000000010 000000011 00000010 010110   CONCAT 2 2 3     (A,B,C)
000000010 000000001 00000001 011101   CALL 1 2 1       (A,B,C)
000000001 000000000 00000000 011111   RETURN 0 1       (A,B)

上面的表格里面,每一位的编号从左到右是31,30,…,0。记号“0+6”意味着位置0 的低6位(表格最右边)。如果指令作为一个Lua数字存储,这几位用bit32:extract(0,6)表示。

1、     OP是操作码(opcode),在0+6(看下面)。

2、     A是所有指令的第一操作数(operand),在6:8,视为无符号数字。最大值是255。

3、     B是模式iABC的第二操作数,在23+9,视为有符号数字。实际的指令未必用到B。

第一位是符号位。如果是1,其它8位为B的绝对值减1。因此没有-0,B的最小值是-256,最大值255。

4、     C是iABC的第三操作数,在14+9,视为像B一样的有符号数字。实际指令未必用到C。

5、     Bx是iABx的第二操作数,在14+18,像B一样视为19位有符号数字,但是不需存储符号位因为Bx总是负的。

6、     sBx意思是“Bx视为18为有符号数字”。

7、     Ax是模式iAx的唯一操作数,在6+26,视为Bx(27位有符号数字,负数,符号位不存储)。

指令的操作码及其功能描述在lopcodes.h的一个enum中。我们以其相关功能进行分组。描述(从lopcodes.h中逐字取出)中使用以下记号,B,Bx等不是指位编码的数字,而是指luac反编译后能看到的数字。

R(A), R(B), R(C)
    寄存器A,B或者C的数值。
Kst(Bx)
    常数|Bx|。
KPROTO[Bx]
    函数原型|Bx|。
RK(B), RK(C)
    寄存器B或者C的值;如果B或者C是负数,常数|B|或者|C|。
FPF
    每次刷新的Fields。阅读NFI的解释。
UpValue
    外部局部变量B的值
Pc
    程序计数器:下一条被执行的指令的编号
closure
    创建一个闭包(函数加上外部局部变量)。

加载参数

00000000  1b 4c 75 61 52 00 01 04  04 04 08 00 19 93 0d 0a  |.LuaR...........|
00000010  1a 0a 00 00 00 00 00 00  00 00 00 01 04 07 00 00  |................|
00000020  00 01 00 00 00 46 40 40  00 80 00 00 00 c1 80 00  |.....F@@........|
00000030  00 96 c0 00 01 5d 40 00  01 1f 00 80 00 03 00 00  |.....]@.........|
00000040  00 04 06 00 00 00 48 65  6c 6c 6f 00 04 06 00 00  |......Hello.....|
00000050  00 70 72 69 6e 74 00 04  08 00 00 00 20 57 6f 72  |.print...... Wor|
00000060  6c 64 21 00 00 00 00 00  01 00 00 00 01 00 30 00  |ld!...........0.|
00000070  00 00 6c 6f 63 61 6c 20  20 68 65 6c 6c 6f 20 3d  |..local  hello =|
00000080  20 22 48 65 6c 6c 6f 22  20 70 72 69 6e 74 20 28  | "Hello" print (|
00000090  68 65 6c 6c 6f 2e 2e 22  20 57 6f 72 6c 64 21 22  |hello.." World!"|
000000a0  29 00 07 00 00 00 01 00  00 00 01 00 00 00 01 00  |)...............|
000000b0  00 00 01 00 00 00 01 00  00 00 01 00 00 00 01 00  |................|
000000c0  00 00 01 00 00 00 06 00  00 00 68 65 6c 6c 6f 00  |..........hello.|
000000d0  01 00 00 00 07 00 00 00  01 00 00 00 05 00 00 00  |................|
000000e0  5f 45 4e 56 00                                    |_ENV.|

这里面许多东西是用于调试的,所以来把hello.luac传给luac -s重试一下。

00000000  1b 4c 75 61 52 00 01 04  04 04 08 00 19 93 0d 0a  |.LuaR...........|
00000010  1a 0a 00 00 00 00 00 00  00 00 00 01 04 07 00 00  |................|
00000020  00 01 00 00 00 46 40 40  00 80 00 00 00 c1 80 00  |.....F@@........|
00000030  00 96 c0 00 01 5d 40 00  01 1f 00 80 00 03 00 00  |.....]@.........|
00000040  00 04 06 00 00 00 48 65  6c 6c 6f 00 04 06 00 00  |......Hello.....|
00000050  00 70 72 69 6e 74 00 04  08 00 00 00 20 57 6f 72  |.print...... Wor|
00000060  6c 64 21 00 00 00 00 00  01 00 00 00 01 00 00 00  |ld!.............|
00000070  00 00 00 00 00 00 00 00  00 00 00 00 00 00        |..............|

这个格式并没有官方说明,所以需要来做一些逆向工程。需要的材料在Lua的源码里面的几个地方,主要是ldump.c和lundump.c。我亦和NFI和LAT做了交叉检查,不过,估计还有问题,那都是我的错。

代码从18字节的文件头开始,这部分对所有官方Lua5.2编译器编译的代码来说都是一样(包括你的编译器),不管用的是luac或者load还是loadfile。Lua5.1的头部是12字节,与此段的前12字节相似。

字节编码顺序从十进制的1(主要展示其算法)和十六进制的0开始。

1 x00: 1b 4c 75 61
    LUA_SIGNATURE ,来自 lua.h.
5 x04: 52  00
    二进制码表示的十进制52是lua的版本,00表示此字节码兼容于“官方”的PUC-Rio实现。
5+2 x06: 01  04  04  04  08  00
    六个系统的参数。在x386的机器上分别表示little-endian,4字节的integers,4字节的VM指令,4字节的size_t数字,8字节的Lua数字,floating-    point。在字节码文件和Lua解释器之间这些参数必须完全匹配,否则字节码将失效。
7+6 x0c: 19 93 0d 0a 1a 0a
    到这里就是PUC-Rio产出的全部的Lua5.2字节码了。lumdump.h对此的描述是“捕捉转换错误的数据”。可能由二进制码表示的十进制数1993(它的起始年份),Windows行终止符,MS-DOS文本文件终止符,类Unix系统行终止符组成。

这18字节过后是文件中的函数定义。每个函数都由11字节的函数头开始。

13+6 x12:  00 00 00 00
    源码中区块开始的行号。0是主区块。
19+4 x16:  00 00 00 00
    源码中区块终止的行号。0是主区块。
23+4 x1a:  00  01  04
    参数数量,可变长参数标识(varargs),该函数用的寄存器数量(很明显不超过255)。局部变量存储在寄存器,它们应该不超过200个(参考lparser.c)

然后是一个指令列表,我们稍后会详细讨论其形式。

27+3 x1d:  07 00 00 00
    列表有7个指令(小端字节序,就像文件头所说)
30+4*1 x21:  01 00 00 00
    字节码,LOADK 0 -1.
30+4*2 x25:  46 40 40 00
    字节码,GETTABUP 1 0 2.
30+4*3 x29:  80 00 00 00
    字节码,MOVE 2 0.
30+4*4 x2d:  c1 80 00 00
    字节码,LOADK 3 -3.
30+4*5 x31:  96 c0 00 01
    字节码,CONCAT 2 2 3.
30+4*6 x35:  5d 40 00 01
    字节码,CALL 1 2 1.
30+4*7 x39:  1f 00 80 00
    字节码,RETURN 0 1.

然后是常量列表。

58+4 x3d:  03 00 00 00
    共3个常量。
62+4 x41:  04
    第一个常量是string
66+1 x42:  06 00 00 00
    含6字节,
67+4 x46:  48 65 6c 6c 6f 00
    包含了”Hello”和一个终止的0字节。
71+6 x4c:  04  06 00 00 00  70 72 69 6e 74 00
    第二个常量是6字节字符串”print”。
77+5+6 x57:  04  08 00 00 00  20 57 6f 72 6c 64 21 00
    第三个常量是8字节字符串” World!”。

紧随着是函数原型列表:

88+5+8 x64:  00 00 00 00
    无函数原型

最后是外部局部变量列表:

101+4 x68:  01 00 00 00
有1个外部局部变量
105+4 x6c:  01 00
可以在上1级的堆栈中找到,在堆栈的位置0。

从这开始,两个文件出现了分歧。在剥离后的版本中,去掉的调试信息展现为16个0字节。

109+2 x6e:  00 00 00 00
    无源代码
111+4 x72:  00 00 00 00
    行号列表空
115+4 x76:  00 00 00 00
    局部变量名空
119+4 x7a:  00 00 00 00
    外部局部变量名空

在未剥离的版本中,给出了以下信息:

111 x6e:  30 00 00 00
    源码长度48字节。忽略旧的源码,
111+4+48 xa2:  07 00 00 00
    7个行号,忽略旧行号,
163+4+28 xc2:  01 00 00 00
    列出了1个局部变量名。
199 xc6:  06 00 00 00  68 65 6c 6c 6f 00
    变量名hello
199+4+6 xd0:  01 00 00 00
    在指令1进入其作用域
209+4 xd4:  07 00 00 00
    在指令7跳出其作用域
213+4 xd8:  01 00 00 00
    列出1个外部局部变量
217+4 xdc:  05 00 00 00  5f 45 4e 56 00
    变量名_ENV

此后的函数也会以同种方式编码。

目前我的探索发现到此。以下领域尚未覆盖:

  •   非字符串常量如何存储
  •   函数原型的形式为何

NFI涵括了这些以及其它未提及的东西,但是是5.1版本的,所以你得参照lundump.c和LAT。

全面窥探字节码

伴随此文件而来的还有两个模块文件:vm52.lua和bytecode.lua。这阶段一开始,我们仅使用numberAt。这个函数将4字节子字符串转成32位数字,使用宿主机器的字节端序。我们从上面了解到dump后的Lua5.2函数在28是一个可变长函数参数,在29是寄存器数量,在30是指令数量,随后是许多的4字节指令。

所以让我们来看一下。

$ lua -l vm52
Lua 5.2.1  Copyright (C) 1994-2012 Lua.org, PUC-Rio
   func = load 'local  hello = "Hello" print (hello.." World!")'
   chunk = string.dump(func)

   function hasvarargs(chunk) return chunk:sub(28,28):byte()>0 end
   function nstack(chunk) return chunk:sub(29,29):byte() end
   function ninstr(chunk) return vm52.numberAt(chunk,30) end
   function instr(chunk,i) return vm52.numberAt(chunk:sub(30+4*i,33+4*i)) end

   ns = nstack(chunk)
   print ("This function" ..
     (hasvarargs(chunk) and " has a variable number of arguments and" or "") ..
     " uses "..ns.." register"..(ns~=1 and "s" or ""))

   for i=1,ninstr(chunk) do
      print(string.format("%08X",instr(chunk,i)))
   end 
00000001
00404046
00000080
000080C1
0100C096
0100405D
0080001F

你能看到字节码文件的字节内容,倒转是因为我的机器是小端字节序。

模块文件vm52.lua不仅有numberAt:它有上面代码定义的四个函数,更重要的,它还有assemble和disassemble。后者做的跟lua -l一样处理dump后的字节码。

继续之前的Lua会话,

   for i=1,vm52.ninstr(code) do
      print(vm52.disassemble(vm52.instr(code,i)))
   end 
LOADK 0 -1
GETTABUP 1 0 -2
MOVE 2 0
LOADK 3 -3
CONCAT 2 2 3
CALL 1 2 1
RETURN 0 1

指令解剖

以上16进制列表与字节码文件相对应,不过字节是倒转的。这是因为我的系统是小端序的:小端字节序的32位数字真是难以阅读,所以普遍使用的是大端字节序。

所以在分解和显示它们之前,我们把指令字节倒转过来。numberAt函数会做这个事情,如果需要的话:

就像我们看过的,指令是:

00 00 00 01   LOADK 0 -1
00 40 40 46   GETTABUP 1 0 -2
00 00 00 80   MOVE 2 0
00 00 80 c1   LOADK 3 -3
01 00 c0 96   CONCAT 2 2 3
01 00 40 5d   CALL 1 2 1
00 80 00 1f   RETURN 0 1

我们现在将字节展开成二进制并且分组。如果lopcodes.c指明指令是iABC模式的,我们用(9,9,8,6)分组,定义值为B,C,A,OP;如果指明是iABx或者iAsBx,用(18,8,6)分组,定义值为Bx,A,OP或者sBx,A,OP;如果是iAx,分为(26,6),定义值为Ax,OP。

    B    Bx   C         A      OP
000000000000000000  00000000 000001   LOADK 0 -1       (A,Bx)
000000000 100000001 00000001 000110   GETTABUP 1 0 -2  (A,B,C)
000000000 000000000 00000010 000000   MOVE 2 0         (A,B) 
000000000000000010  00000011 000001   LOADK 3 -3       (A,Bx)
000000010 000000011 00000010 010110   CONCAT 2 2 3     (A,B,C)
000000010 000000001 00000001 011101   CALL 1 2 1       (A,B,C)
000000001 000000000 00000000 011111   RETURN 0 1       (A,B)

上面的表格里面,每一位的编号从左到右是31,30,…,0。记号“0+6”意味着位置0 的低6位(表格最右边)。如果指令作为一个Lua数字存储,这几位用bit32:extract(0,6)表示。

1、     OP是操作码(opcode),在0+6(看下面)。

2、     A是所有指令的第一操作数(operand),在6:8,视为无符号数字。最大值是255。

3、     B是模式iABC的第二操作数,在23+9,视为有符号数字。实际的指令未必用到B。

第一位是符号位。如果是1,其它8位为B的绝对值减1。因此没有-0,B的最小值是-256,最大值255。

4、     C是iABC的第三操作数,在14+9,视为像B一样的有符号数字。实际指令未必用到C。

5、     Bx是iABx的第二操作数,在14+18,像B一样视为19位有符号数字,但是不需存储符号位因为Bx总是负的。

6、     sBx意思是“Bx视为18为有符号数字”。

7、     Ax是模式iAx的唯一操作数,在6+26,视为Bx(27位有符号数字,负数,符号位不存储)。

指令的操作码及其功能描述在lopcodes.h的一个enum中。我们以其相关功能进行分组。描述(从lopcodes.h中逐字取出)中使用以下记号,B,Bx等不是指位编码的数字,而是指luac反编译后能看到的数字。

R(A), R(B), R(C)
    寄存器A,B或者C的数值。
Kst(Bx)
    常数|Bx|。
KPROTO[Bx]
    函数原型|Bx|。
RK(B), RK(C)
    寄存器B或者C的值;如果B或者C是负数,常数|B|或者|C|。
FPF
    每次刷新的Fields。阅读NFI的解释。
UpValue
    外部局部变量B的值
Pc
    程序计数器:下一条被执行的指令的编号
closure
    创建一个闭包(函数加上外部局部变量)。

加载参数

00000000  1b 4c 75 61 52 00 01 04  04 04 08 00 19 93 0d 0a  |.LuaR...........|
00000010  1a 0a 00 00 00 00 00 00  00 00 00 01 04 07 00 00  |................|
00000020  00 01 00 00 00 46 40 40  00 80 00 00 00 c1 80 00  |.....F@@........|
00000030  00 96 c0 00 01 5d 40 00  01 1f 00 80 00 03 00 00  |.....]@.........|
00000040  00 04 06 00 00 00 48 65  6c 6c 6f 00 04 06 00 00  |......Hello.....|
00000050  00 70 72 69 6e 74 00 04  08 00 00 00 20 57 6f 72  |.print...... Wor|
00000060  6c 64 21 00 00 00 00 00  01 00 00 00 01 00 00 00  |ld!.............|
00000070  00 00 00 00 00 00 00 00  00 00 00 00 00 00        |..............|

这个格式并没有官方说明,所以需要来做一些逆向工程。需要的材料在Lua的源码里面的几个地方,主要是ldump.c和lundump.c。我亦和NFI和LAT做了交叉检查,不过,估计还有问题,那都是我的错。

代码从18字节的文件头开始,这部分对所有官方Lua5.2编译器编译的代码来说都是一样(包括你的编译器),不管用的是luac或者load还是loadfile。Lua5.1的头部是12字节,与此段的前12字节相似。

字节编码顺序从十进制的1(主要展示其算法)和十六进制的0开始。

1 x00: 1b 4c 75 61
    LUA_SIGNATURE ,来自 lua.h.
5 x04: 52  00
    二进制码表示的十进制52是lua的版本,00表示此字节码兼容于“官方”的PUC-Rio实现。
5+2 x06: 01  04  04  04  08  00
    六个系统的参数。在x386的机器上分别表示little-endian,4字节的integers,4字节的VM指令,4字节的size_t数字,8字节的Lua数字,floating-    point。在字节码文件和Lua解释器之间这些参数必须完全匹配,否则字节码将失效。
7+6 x0c: 19 93 0d 0a 1a 0a
    到这里就是PUC-Rio产出的全部的Lua5.2字节码了。lumdump.h对此的描述是“捕捉转换错误的数据”。可能由二进制码表示的十进制数1993(它的起始年份),Windows行终止符,MS-DOS文本文件终止符,类Unix系统行终止符组成。

这18字节过后是文件中的函数定义。每个函数都由11字节的函数头开始。

13+6 x12:  00 00 00 00
    源码中区块开始的行号。0是主区块。
19+4 x16:  00 00 00 00
    源码中区块终止的行号。0是主区块。
23+4 x1a:  00  01  04
    参数数量,可变长参数标识(varargs),该函数用的寄存器数量(很明显不超过255)。局部变量存储在寄存器,它们应该不超过200个(参考lparser.c)

然后是一个指令列表,我们稍后会详细讨论其形式。

27+3 x1d:  07 00 00 00
    列表有7个指令(小端字节序,就像文件头所说)
30+4*1 x21:  01 00 00 00
    字节码,LOADK 0 -1.
30+4*2 x25:  46 40 40 00
    字节码,GETTABUP 1 0 2.
30+4*3 x29:  80 00 00 00
    字节码,MOVE 2 0.
30+4*4 x2d:  c1 80 00 00
    字节码,LOADK 3 -3.
30+4*5 x31:  96 c0 00 01
    字节码,CONCAT 2 2 3.
30+4*6 x35:  5d 40 00 01
    字节码,CALL 1 2 1.
30+4*7 x39:  1f 00 80 00
    字节码,RETURN 0 1.

然后是常量列表。

58+4 x3d:  03 00 00 00
    共3个常量。
62+4 x41:  04
    第一个常量是string
66+1 x42:  06 00 00 00
    含6字节,
67+4 x46:  48 65 6c 6c 6f 00
    包含了”Hello”和一个终止的0字节。
71+6 x4c:  04  06 00 00 00  70 72 69 6e 74 00
    第二个常量是6字节字符串”print”。
77+5+6 x57:  04  08 00 00 00  20 57 6f 72 6c 64 21 00
    第三个常量是8字节字符串” World!”。

紧随着是函数原型列表:

88+5+8 x64:  00 00 00 00
    无函数原型

最后是外部局部变量列表:

101+4 x68:  01 00 00 00
有1个外部局部变量
105+4 x6c:  01 00
可以在上1级的堆栈中找到,在堆栈的位置0。

从这开始,两个文件出现了分歧。在剥离后的版本中,去掉的调试信息展现为16个0字节。

109+2 x6e:  00 00 00 00
    无源代码
111+4 x72:  00 00 00 00
    行号列表空
115+4 x76:  00 00 00 00
    局部变量名空
119+4 x7a:  00 00 00 00
    外部局部变量名空

在未剥离的版本中,给出了以下信息:

111 x6e:  30 00 00 00
    源码长度48字节。忽略旧的源码,
111+4+48 xa2:  07 00 00 00
    7个行号,忽略旧行号,
163+4+28 xc2:  01 00 00 00
    列出了1个局部变量名。
199 xc6:  06 00 00 00  68 65 6c 6c 6f 00
    变量名hello
199+4+6 xd0:  01 00 00 00
    在指令1进入其作用域
209+4 xd4:  07 00 00 00
    在指令7跳出其作用域
213+4 xd8:  01 00 00 00
    列出1个外部局部变量
217+4 xdc:  05 00 00 00  5f 45 4e 56 00
    变量名_ENV

此后的函数也会以同种方式编码。

目前我的探索发现到此。以下领域尚未覆盖:

  •   非字符串常量如何存储
  •   函数原型的形式为何

NFI涵括了这些以及其它未提及的东西,但是是5.1版本的,所以你得参照lundump.c和LAT。

全面窥探字节码

伴随此文件而来的还有两个模块文件:vm52.lua和bytecode.lua。这阶段一开始,我们仅使用numberAt。这个函数将4字节子字符串转成32位数字,使用宿主机器的字节端序。我们从上面了解到dump后的Lua5.2函数在28是一个可变长函数参数,在29是寄存器数量,在30是指令数量,随后是许多的4字节指令。

所以让我们来看一下。

$ lua -l vm52
Lua 5.2.1  Copyright (C) 1994-2012 Lua.org, PUC-Rio
   func = load 'local  hello = "Hello" print (hello.." World!")'
   chunk = string.dump(func)

   function hasvarargs(chunk) return chunk:sub(28,28):byte()>0 end
   function nstack(chunk) return chunk:sub(29,29):byte() end
   function ninstr(chunk) return vm52.numberAt(chunk,30) end
   function instr(chunk,i) return vm52.numberAt(chunk:sub(30+4*i,33+4*i)) end

   ns = nstack(chunk)
   print ("This function" ..
     (hasvarargs(chunk) and " has a variable number of arguments and" or "") ..
     " uses "..ns.." register"..(ns~=1 and "s" or ""))

   for i=1,ninstr(chunk) do
      print(string.format("%08X",instr(chunk,i)))
   end 
00000001
00404046
00000080
000080C1
0100C096
0100405D
0080001F

你能看到字节码文件的字节内容,倒转是因为我的机器是小端字节序。

模块文件vm52.lua不仅有numberAt:它有上面代码定义的四个函数,更重要的,它还有assemble和disassemble。后者做的跟lua -l一样处理dump后的字节码。

继续之前的Lua会话,

   for i=1,vm52.ninstr(code) do
      print(vm52.disassemble(vm52.instr(code,i)))
   end 
LOADK 0 -1
GETTABUP 1 0 -2
MOVE 2 0
LOADK 3 -3
CONCAT 2 2 3
CALL 1 2 1
RETURN 0 1

指令解剖

以上16进制列表与字节码文件相对应,不过字节是倒转的。这是因为我的系统是小端序的:小端字节序的32位数字真是难以阅读,所以普遍使用的是大端字节序。

所以在分解和显示它们之前,我们把指令字节倒转过来。numberAt函数会做这个事情,如果需要的话:

就像我们看过的,指令是:

00 00 00 01   LOADK 0 -1
00 40 40 46   GETTABUP 1 0 -2
00 00 00 80   MOVE 2 0
00 00 80 c1   LOADK 3 -3
01 00 c0 96   CONCAT 2 2 3
01 00 40 5d   CALL 1 2 1
00 80 00 1f   RETURN 0 1

我们现在将字节展开成二进制并且分组。如果lopcodes.c指明指令是iABC模式的,我们用(9,9,8,6)分组,定义值为B,C,A,OP;如果指明是iABx或者iAsBx,用(18,8,6)分组,定义值为Bx,A,OP或者sBx,A,OP;如果是iAx,分为(26,6),定义值为Ax,OP。

    B    Bx   C         A      OP
000000000000000000  00000000 000001   LOADK 0 -1       (A,Bx)
000000000 100000001 00000001 000110   GETTABUP 1 0 -2  (A,B,C)
000000000 000000000 00000010 000000   MOVE 2 0         (A,B) 
000000000000000010  00000011 000001   LOADK 3 -3       (A,Bx)
000000010 000000011 00000010 010110   CONCAT 2 2 3     (A,B,C)
000000010 000000001 00000001 011101   CALL 1 2 1       (A,B,C)
000000001 000000000 00000000 011111   RETURN 0 1       (A,B)

上面的表格里面,每一位的编号从左到右是31,30,…,0。记号“0+6”意味着位置0 的低6位(表格最右边)。如果指令作为一个Lua数字存储,这几位用bit32:extract(0,6)表示。

1、     OP是操作码(opcode),在0+6(看下面)。

2、     A是所有指令的第一操作数(operand),在6:8,视为无符号数字。最大值是255。

3、     B是模式iABC的第二操作数,在23+9,视为有符号数字。实际的指令未必用到B。

第一位是符号位。如果是1,其它8位为B的绝对值减1。因此没有-0,B的最小值是-256,最大值255。

4、     C是iABC的第三操作数,在14+9,视为像B一样的有符号数字。实际指令未必用到C。

5、     Bx是iABx的第二操作数,在14+18,像B一样视为19位有符号数字,但是不需存储符号位因为Bx总是负的。

6、     sBx意思是“Bx视为18为有符号数字”。

7、     Ax是模式iAx的唯一操作数,在6+26,视为Bx(27位有符号数字,负数,符号位不存储)。

指令的操作码及其功能描述在lopcodes.h的一个enum中。我们以其相关功能进行分组。描述(从lopcodes.h中逐字取出)中使用以下记号,B,Bx等不是指位编码的数字,而是指luac反编译后能看到的数字。

R(A), R(B), R(C)
    寄存器A,B或者C的数值。
Kst(Bx)
    常数|Bx|。
KPROTO[Bx]
    函数原型|Bx|。
RK(B), RK(C)
    寄存器B或者C的值;如果B或者C是负数,常数|B|或者|C|。
FPF
    每次刷新的Fields。阅读NFI的解释。
UpValue
    外部局部变量B的值
Pc
    程序计数器:下一条被执行的指令的编号
closure
    创建一个闭包(函数加上外部局部变量)。

加载参数

1 x00: 1b 4c 75 61
    LUA_SIGNATURE ,来自 lua.h.
5 x04: 52  00
    二进制码表示的十进制52是lua的版本,00表示此字节码兼容于“官方”的PUC-Rio实现。
5+2 x06: 01  04  04  04  08  00
    六个系统的参数。在x386的机器上分别表示little-endian,4字节的integers,4字节的VM指令,4字节的size_t数字,8字节的Lua数字,floating-    point。在字节码文件和Lua解释器之间这些参数必须完全匹配,否则字节码将失效。
7+6 x0c: 19 93 0d 0a 1a 0a
    到这里就是PUC-Rio产出的全部的Lua5.2字节码了。lumdump.h对此的描述是“捕捉转换错误的数据”。可能由二进制码表示的十进制数1993(它的起始年份),Windows行终止符,MS-DOS文本文件终止符,类Unix系统行终止符组成。

这18字节过后是文件中的函数定义。每个函数都由11字节的函数头开始。

13+6 x12:  00 00 00 00
    源码中区块开始的行号。0是主区块。
19+4 x16:  00 00 00 00
    源码中区块终止的行号。0是主区块。
23+4 x1a:  00  01  04
    参数数量,可变长参数标识(varargs),该函数用的寄存器数量(很明显不超过255)。局部变量存储在寄存器,它们应该不超过200个(参考lparser.c)

然后是一个指令列表,我们稍后会详细讨论其形式。

27+3 x1d:  07 00 00 00
    列表有7个指令(小端字节序,就像文件头所说)
30+4*1 x21:  01 00 00 00
    字节码,LOADK 0 -1.
30+4*2 x25:  46 40 40 00
    字节码,GETTABUP 1 0 2.
30+4*3 x29:  80 00 00 00
    字节码,MOVE 2 0.
30+4*4 x2d:  c1 80 00 00
    字节码,LOADK 3 -3.
30+4*5 x31:  96 c0 00 01
    字节码,CONCAT 2 2 3.
30+4*6 x35:  5d 40 00 01
    字节码,CALL 1 2 1.
30+4*7 x39:  1f 00 80 00
    字节码,RETURN 0 1.

然后是常量列表。

58+4 x3d:  03 00 00 00
    共3个常量。
62+4 x41:  04
    第一个常量是string
66+1 x42:  06 00 00 00
    含6字节,
67+4 x46:  48 65 6c 6c 6f 00
    包含了”Hello”和一个终止的0字节。
71+6 x4c:  04  06 00 00 00  70 72 69 6e 74 00
    第二个常量是6字节字符串”print”。
77+5+6 x57:  04  08 00 00 00  20 57 6f 72 6c 64 21 00
    第三个常量是8字节字符串” World!”。

紧随着是函数原型列表:

88+5+8 x64:  00 00 00 00
    无函数原型

最后是外部局部变量列表:

101+4 x68:  01 00 00 00
有1个外部局部变量
105+4 x6c:  01 00
可以在上1级的堆栈中找到,在堆栈的位置0。

从这开始,两个文件出现了分歧。在剥离后的版本中,去掉的调试信息展现为16个0字节。

109+2 x6e:  00 00 00 00
    无源代码
111+4 x72:  00 00 00 00
    行号列表空
115+4 x76:  00 00 00 00
    局部变量名空
119+4 x7a:  00 00 00 00
    外部局部变量名空

在未剥离的版本中,给出了以下信息:

111 x6e:  30 00 00 00
    源码长度48字节。忽略旧的源码,
111+4+48 xa2:  07 00 00 00
    7个行号,忽略旧行号,
163+4+28 xc2:  01 00 00 00
    列出了1个局部变量名。
199 xc6:  06 00 00 00  68 65 6c 6c 6f 00
    变量名hello
199+4+6 xd0:  01 00 00 00
    在指令1进入其作用域
209+4 xd4:  07 00 00 00
    在指令7跳出其作用域
213+4 xd8:  01 00 00 00
    列出1个外部局部变量
217+4 xdc:  05 00 00 00  5f 45 4e 56 00
    变量名_ENV

此后的函数也会以同种方式编码。

目前我的探索发现到此。以下领域尚未覆盖:

  •   非字符串常量如何存储
  •   函数原型的形式为何

NFI涵括了这些以及其它未提及的东西,但是是5.1版本的,所以你得参照lundump.c和LAT。

全面窥探字节码

伴随此文件而来的还有两个模块文件:vm52.lua和bytecode.lua。这阶段一开始,我们仅使用numberAt。这个函数将4字节子字符串转成32位数字,使用宿主机器的字节端序。我们从上面了解到dump后的Lua5.2函数在28是一个可变长函数参数,在29是寄存器数量,在30是指令数量,随后是许多的4字节指令。

所以让我们来看一下。

$ lua -l vm52
Lua 5.2.1  Copyright (C) 1994-2012 Lua.org, PUC-Rio
   func = load 'local  hello = "Hello" print (hello.." World!")'
   chunk = string.dump(func)

   function hasvarargs(chunk) return chunk:sub(28,28):byte()>0 end
   function nstack(chunk) return chunk:sub(29,29):byte() end
   function ninstr(chunk) return vm52.numberAt(chunk,30) end
   function instr(chunk,i) return vm52.numberAt(chunk:sub(30+4*i,33+4*i)) end

   ns = nstack(chunk)
   print ("This function" ..
     (hasvarargs(chunk) and " has a variable number of arguments and" or "") ..
     " uses "..ns.." register"..(ns~=1 and "s" or ""))

   for i=1,ninstr(chunk) do
      print(string.format("%08X",instr(chunk,i)))
   end 
00000001
00404046
00000080
000080C1
0100C096
0100405D
0080001F

你能看到字节码文件的字节内容,倒转是因为我的机器是小端字节序。

模块文件vm52.lua不仅有numberAt:它有上面代码定义的四个函数,更重要的,它还有assemble和disassemble。后者做的跟lua -l一样处理dump后的字节码。

继续之前的Lua会话,

   for i=1,vm52.ninstr(code) do
      print(vm52.disassemble(vm52.instr(code,i)))
   end 
LOADK 0 -1
GETTABUP 1 0 -2
MOVE 2 0
LOADK 3 -3
CONCAT 2 2 3
CALL 1 2 1
RETURN 0 1

指令解剖

以上16进制列表与字节码文件相对应,不过字节是倒转的。这是因为我的系统是小端序的:小端字节序的32位数字真是难以阅读,所以普遍使用的是大端字节序。

所以在分解和显示它们之前,我们把指令字节倒转过来。numberAt函数会做这个事情,如果需要的话:

就像我们看过的,指令是:

00 00 00 01   LOADK 0 -1
00 40 40 46   GETTABUP 1 0 -2
00 00 00 80   MOVE 2 0
00 00 80 c1   LOADK 3 -3
01 00 c0 96   CONCAT 2 2 3
01 00 40 5d   CALL 1 2 1
00 80 00 1f   RETURN 0 1

我们现在将字节展开成二进制并且分组。如果lopcodes.c指明指令是iABC模式的,我们用(9,9,8,6)分组,定义值为B,C,A,OP;如果指明是iABx或者iAsBx,用(18,8,6)分组,定义值为Bx,A,OP或者sBx,A,OP;如果是iAx,分为(26,6),定义值为Ax,OP。

    B    Bx   C         A      OP
000000000000000000  00000000 000001   LOADK 0 -1       (A,Bx)
000000000 100000001 00000001 000110   GETTABUP 1 0 -2  (A,B,C)
000000000 000000000 00000010 000000   MOVE 2 0         (A,B) 
000000000000000010  00000011 000001   LOADK 3 -3       (A,Bx)
000000010 000000011 00000010 010110   CONCAT 2 2 3     (A,B,C)
000000010 000000001 00000001 011101   CALL 1 2 1       (A,B,C)
000000001 000000000 00000000 011111   RETURN 0 1       (A,B)

上面的表格里面,每一位的编号从左到右是31,30,…,0。记号“0+6”意味着位置0 的低6位(表格最右边)。如果指令作为一个Lua数字存储,这几位用bit32:extract(0,6)表示。

1、     OP是操作码(opcode),在0+6(看下面)。

2、     A是所有指令的第一操作数(operand),在6:8,视为无符号数字。最大值是255。

3、     B是模式iABC的第二操作数,在23+9,视为有符号数字。实际的指令未必用到B。

第一位是符号位。如果是1,其它8位为B的绝对值减1。因此没有-0,B的最小值是-256,最大值255。

4、     C是iABC的第三操作数,在14+9,视为像B一样的有符号数字。实际指令未必用到C。

5、     Bx是iABx的第二操作数,在14+18,像B一样视为19位有符号数字,但是不需存储符号位因为Bx总是负的。

6、     sBx意思是“Bx视为18为有符号数字”。

7、     Ax是模式iAx的唯一操作数,在6+26,视为Bx(27位有符号数字,负数,符号位不存储)。

指令的操作码及其功能描述在lopcodes.h的一个enum中。我们以其相关功能进行分组。描述(从lopcodes.h中逐字取出)中使用以下记号,B,Bx等不是指位编码的数字,而是指luac反编译后能看到的数字。

R(A), R(B), R(C)
    寄存器A,B或者C的数值。
Kst(Bx)
    常数|Bx|。
KPROTO[Bx]
    函数原型|Bx|。
RK(B), RK(C)
    寄存器B或者C的值;如果B或者C是负数,常数|B|或者|C|。
FPF
    每次刷新的Fields。阅读NFI的解释。
UpValue
    外部局部变量B的值
Pc
    程序计数器:下一条被执行的指令的编号
closure
    创建一个闭包(函数加上外部局部变量)。

加载参数

13+6 x12:  00 00 00 00
    源码中区块开始的行号。0是主区块。
19+4 x16:  00 00 00 00
    源码中区块终止的行号。0是主区块。
23+4 x1a:  00  01  04
    参数数量,可变长参数标识(varargs),该函数用的寄存器数量(很明显不超过255)。局部变量存储在寄存器,它们应该不超过200个(参考lparser.c)

然后是一个指令列表,我们稍后会详细讨论其形式。

27+3 x1d:  07 00 00 00
    列表有7个指令(小端字节序,就像文件头所说)
30+4*1 x21:  01 00 00 00
    字节码,LOADK 0 -1.
30+4*2 x25:  46 40 40 00
    字节码,GETTABUP 1 0 2.
30+4*3 x29:  80 00 00 00
    字节码,MOVE 2 0.
30+4*4 x2d:  c1 80 00 00
    字节码,LOADK 3 -3.
30+4*5 x31:  96 c0 00 01
    字节码,CONCAT 2 2 3.
30+4*6 x35:  5d 40 00 01
    字节码,CALL 1 2 1.
30+4*7 x39:  1f 00 80 00
    字节码,RETURN 0 1.

然后是常量列表。

58+4 x3d:  03 00 00 00
    共3个常量。
62+4 x41:  04
    第一个常量是string
66+1 x42:  06 00 00 00
    含6字节,
67+4 x46:  48 65 6c 6c 6f 00
    包含了”Hello”和一个终止的0字节。
71+6 x4c:  04  06 00 00 00  70 72 69 6e 74 00
    第二个常量是6字节字符串”print”。
77+5+6 x57:  04  08 00 00 00  20 57 6f 72 6c 64 21 00
    第三个常量是8字节字符串” World!”。

紧随着是函数原型列表:

88+5+8 x64:  00 00 00 00
    无函数原型

最后是外部局部变量列表:

101+4 x68:  01 00 00 00
有1个外部局部变量
105+4 x6c:  01 00
可以在上1级的堆栈中找到,在堆栈的位置0。

从这开始,两个文件出现了分歧。在剥离后的版本中,去掉的调试信息展现为16个0字节。

109+2 x6e:  00 00 00 00
    无源代码
111+4 x72:  00 00 00 00
    行号列表空
115+4 x76:  00 00 00 00
    局部变量名空
119+4 x7a:  00 00 00 00
    外部局部变量名空

在未剥离的版本中,给出了以下信息:

111 x6e:  30 00 00 00
    源码长度48字节。忽略旧的源码,
111+4+48 xa2:  07 00 00 00
    7个行号,忽略旧行号,
163+4+28 xc2:  01 00 00 00
    列出了1个局部变量名。
199 xc6:  06 00 00 00  68 65 6c 6c 6f 00
    变量名hello
199+4+6 xd0:  01 00 00 00
    在指令1进入其作用域
209+4 xd4:  07 00 00 00
    在指令7跳出其作用域
213+4 xd8:  01 00 00 00
    列出1个外部局部变量
217+4 xdc:  05 00 00 00  5f 45 4e 56 00
    变量名_ENV

此后的函数也会以同种方式编码。

目前我的探索发现到此。以下领域尚未覆盖:

  •   非字符串常量如何存储
  •   函数原型的形式为何

NFI涵括了这些以及其它未提及的东西,但是是5.1版本的,所以你得参照lundump.c和LAT。

全面窥探字节码

伴随此文件而来的还有两个模块文件:vm52.lua和bytecode.lua。这阶段一开始,我们仅使用numberAt。这个函数将4字节子字符串转成32位数字,使用宿主机器的字节端序。我们从上面了解到dump后的Lua5.2函数在28是一个可变长函数参数,在29是寄存器数量,在30是指令数量,随后是许多的4字节指令。

所以让我们来看一下。

$ lua -l vm52
Lua 5.2.1  Copyright (C) 1994-2012 Lua.org, PUC-Rio
   func = load 'local  hello = "Hello" print (hello.." World!")'
   chunk = string.dump(func)

   function hasvarargs(chunk) return chunk:sub(28,28):byte()>0 end
   function nstack(chunk) return chunk:sub(29,29):byte() end
   function ninstr(chunk) return vm52.numberAt(chunk,30) end
   function instr(chunk,i) return vm52.numberAt(chunk:sub(30+4*i,33+4*i)) end

   ns = nstack(chunk)
   print ("This function" ..
     (hasvarargs(chunk) and " has a variable number of arguments and" or "") ..
     " uses "..ns.." register"..(ns~=1 and "s" or ""))

   for i=1,ninstr(chunk) do
      print(string.format("%08X",instr(chunk,i)))
   end 
00000001
00404046
00000080
000080C1
0100C096
0100405D
0080001F

你能看到字节码文件的字节内容,倒转是因为我的机器是小端字节序。

模块文件vm52.lua不仅有numberAt:它有上面代码定义的四个函数,更重要的,它还有assemble和disassemble。后者做的跟lua -l一样处理dump后的字节码。

继续之前的Lua会话,

   for i=1,vm52.ninstr(code) do
      print(vm52.disassemble(vm52.instr(code,i)))
   end 
LOADK 0 -1
GETTABUP 1 0 -2
MOVE 2 0
LOADK 3 -3
CONCAT 2 2 3
CALL 1 2 1
RETURN 0 1

指令解剖

以上16进制列表与字节码文件相对应,不过字节是倒转的。这是因为我的系统是小端序的:小端字节序的32位数字真是难以阅读,所以普遍使用的是大端字节序。

所以在分解和显示它们之前,我们把指令字节倒转过来。numberAt函数会做这个事情,如果需要的话:

就像我们看过的,指令是:

00 00 00 01   LOADK 0 -1
00 40 40 46   GETTABUP 1 0 -2
00 00 00 80   MOVE 2 0
00 00 80 c1   LOADK 3 -3
01 00 c0 96   CONCAT 2 2 3
01 00 40 5d   CALL 1 2 1
00 80 00 1f   RETURN 0 1

我们现在将字节展开成二进制并且分组。如果lopcodes.c指明指令是iABC模式的,我们用(9,9,8,6)分组,定义值为B,C,A,OP;如果指明是iABx或者iAsBx,用(18,8,6)分组,定义值为Bx,A,OP或者sBx,A,OP;如果是iAx,分为(26,6),定义值为Ax,OP。

    B    Bx   C         A      OP
000000000000000000  00000000 000001   LOADK 0 -1       (A,Bx)
000000000 100000001 00000001 000110   GETTABUP 1 0 -2  (A,B,C)
000000000 000000000 00000010 000000   MOVE 2 0         (A,B) 
000000000000000010  00000011 000001   LOADK 3 -3       (A,Bx)
000000010 000000011 00000010 010110   CONCAT 2 2 3     (A,B,C)
000000010 000000001 00000001 011101   CALL 1 2 1       (A,B,C)
000000001 000000000 00000000 011111   RETURN 0 1       (A,B)

上面的表格里面,每一位的编号从左到右是31,30,…,0。记号“0+6”意味着位置0 的低6位(表格最右边)。如果指令作为一个Lua数字存储,这几位用bit32:extract(0,6)表示。

1、     OP是操作码(opcode),在0+6(看下面)。

2、     A是所有指令的第一操作数(operand),在6:8,视为无符号数字。最大值是255。

3、     B是模式iABC的第二操作数,在23+9,视为有符号数字。实际的指令未必用到B。

第一位是符号位。如果是1,其它8位为B的绝对值减1。因此没有-0,B的最小值是-256,最大值255。

4、     C是iABC的第三操作数,在14+9,视为像B一样的有符号数字。实际指令未必用到C。

5、     Bx是iABx的第二操作数,在14+18,像B一样视为19位有符号数字,但是不需存储符号位因为Bx总是负的。

6、     sBx意思是“Bx视为18为有符号数字”。

7、     Ax是模式iAx的唯一操作数,在6+26,视为Bx(27位有符号数字,负数,符号位不存储)。

指令的操作码及其功能描述在lopcodes.h的一个enum中。我们以其相关功能进行分组。描述(从lopcodes.h中逐字取出)中使用以下记号,B,Bx等不是指位编码的数字,而是指luac反编译后能看到的数字。

R(A), R(B), R(C)
    寄存器A,B或者C的数值。
Kst(Bx)
    常数|Bx|。
KPROTO[Bx]
    函数原型|Bx|。
RK(B), RK(C)
    寄存器B或者C的值;如果B或者C是负数,常数|B|或者|C|。
FPF
    每次刷新的Fields。阅读NFI的解释。
UpValue
    外部局部变量B的值
Pc
    程序计数器:下一条被执行的指令的编号
closure
    创建一个闭包(函数加上外部局部变量)。

加载参数

27+3 x1d:  07 00 00 00
    列表有7个指令(小端字节序,就像文件头所说)
30+4*1 x21:  01 00 00 00
    字节码,LOADK 0 -1.
30+4*2 x25:  46 40 40 00
    字节码,GETTABUP 1 0 2.
30+4*3 x29:  80 00 00 00
    字节码,MOVE 2 0.
30+4*4 x2d:  c1 80 00 00
    字节码,LOADK 3 -3.
30+4*5 x31:  96 c0 00 01
    字节码,CONCAT 2 2 3.
30+4*6 x35:  5d 40 00 01
    字节码,CALL 1 2 1.
30+4*7 x39:  1f 00 80 00
    字节码,RETURN 0 1.

然后是常量列表。

58+4 x3d:  03 00 00 00
    共3个常量。
62+4 x41:  04
    第一个常量是string
66+1 x42:  06 00 00 00
    含6字节,
67+4 x46:  48 65 6c 6c 6f 00
    包含了”Hello”和一个终止的0字节。
71+6 x4c:  04  06 00 00 00  70 72 69 6e 74 00
    第二个常量是6字节字符串”print”。
77+5+6 x57:  04  08 00 00 00  20 57 6f 72 6c 64 21 00
    第三个常量是8字节字符串” World!”。

紧随着是函数原型列表:

88+5+8 x64:  00 00 00 00
    无函数原型

最后是外部局部变量列表:

101+4 x68:  01 00 00 00
有1个外部局部变量
105+4 x6c:  01 00
可以在上1级的堆栈中找到,在堆栈的位置0。

从这开始,两个文件出现了分歧。在剥离后的版本中,去掉的调试信息展现为16个0字节。

109+2 x6e:  00 00 00 00
    无源代码
111+4 x72:  00 00 00 00
    行号列表空
115+4 x76:  00 00 00 00
    局部变量名空
119+4 x7a:  00 00 00 00
    外部局部变量名空

在未剥离的版本中,给出了以下信息:

111 x6e:  30 00 00 00
    源码长度48字节。忽略旧的源码,
111+4+48 xa2:  07 00 00 00
    7个行号,忽略旧行号,
163+4+28 xc2:  01 00 00 00
    列出了1个局部变量名。
199 xc6:  06 00 00 00  68 65 6c 6c 6f 00
    变量名hello
199+4+6 xd0:  01 00 00 00
    在指令1进入其作用域
209+4 xd4:  07 00 00 00
    在指令7跳出其作用域
213+4 xd8:  01 00 00 00
    列出1个外部局部变量
217+4 xdc:  05 00 00 00  5f 45 4e 56 00
    变量名_ENV

此后的函数也会以同种方式编码。

目前我的探索发现到此。以下领域尚未覆盖:

  •   非字符串常量如何存储
  •   函数原型的形式为何

NFI涵括了这些以及其它未提及的东西,但是是5.1版本的,所以你得参照lundump.c和LAT。

全面窥探字节码

伴随此文件而来的还有两个模块文件:vm52.lua和bytecode.lua。这阶段一开始,我们仅使用numberAt。这个函数将4字节子字符串转成32位数字,使用宿主机器的字节端序。我们从上面了解到dump后的Lua5.2函数在28是一个可变长函数参数,在29是寄存器数量,在30是指令数量,随后是许多的4字节指令。

所以让我们来看一下。

$ lua -l vm52
Lua 5.2.1  Copyright (C) 1994-2012 Lua.org, PUC-Rio
   func = load 'local  hello = "Hello" print (hello.." World!")'
   chunk = string.dump(func)

   function hasvarargs(chunk) return chunk:sub(28,28):byte()>0 end
   function nstack(chunk) return chunk:sub(29,29):byte() end
   function ninstr(chunk) return vm52.numberAt(chunk,30) end
   function instr(chunk,i) return vm52.numberAt(chunk:sub(30+4*i,33+4*i)) end

   ns = nstack(chunk)
   print ("This function" ..
     (hasvarargs(chunk) and " has a variable number of arguments and" or "") ..
     " uses "..ns.." register"..(ns~=1 and "s" or ""))

   for i=1,ninstr(chunk) do
      print(string.format("%08X",instr(chunk,i)))
   end 
00000001
00404046
00000080
000080C1
0100C096
0100405D
0080001F

你能看到字节码文件的字节内容,倒转是因为我的机器是小端字节序。

模块文件vm52.lua不仅有numberAt:它有上面代码定义的四个函数,更重要的,它还有assemble和disassemble。后者做的跟lua -l一样处理dump后的字节码。

继续之前的Lua会话,

   for i=1,vm52.ninstr(code) do
      print(vm52.disassemble(vm52.instr(code,i)))
   end 
LOADK 0 -1
GETTABUP 1 0 -2
MOVE 2 0
LOADK 3 -3
CONCAT 2 2 3
CALL 1 2 1
RETURN 0 1

指令解剖

以上16进制列表与字节码文件相对应,不过字节是倒转的。这是因为我的系统是小端序的:小端字节序的32位数字真是难以阅读,所以普遍使用的是大端字节序。

所以在分解和显示它们之前,我们把指令字节倒转过来。numberAt函数会做这个事情,如果需要的话:

就像我们看过的,指令是:

00 00 00 01   LOADK 0 -1
00 40 40 46   GETTABUP 1 0 -2
00 00 00 80   MOVE 2 0
00 00 80 c1   LOADK 3 -3
01 00 c0 96   CONCAT 2 2 3
01 00 40 5d   CALL 1 2 1
00 80 00 1f   RETURN 0 1

我们现在将字节展开成二进制并且分组。如果lopcodes.c指明指令是iABC模式的,我们用(9,9,8,6)分组,定义值为B,C,A,OP;如果指明是iABx或者iAsBx,用(18,8,6)分组,定义值为Bx,A,OP或者sBx,A,OP;如果是iAx,分为(26,6),定义值为Ax,OP。

    B    Bx   C         A      OP
000000000000000000  00000000 000001   LOADK 0 -1       (A,Bx)
000000000 100000001 00000001 000110   GETTABUP 1 0 -2  (A,B,C)
000000000 000000000 00000010 000000   MOVE 2 0         (A,B) 
000000000000000010  00000011 000001   LOADK 3 -3       (A,Bx)
000000010 000000011 00000010 010110   CONCAT 2 2 3     (A,B,C)
000000010 000000001 00000001 011101   CALL 1 2 1       (A,B,C)
000000001 000000000 00000000 011111   RETURN 0 1       (A,B)

上面的表格里面,每一位的编号从左到右是31,30,…,0。记号“0+6”意味着位置0 的低6位(表格最右边)。如果指令作为一个Lua数字存储,这几位用bit32:extract(0,6)表示。

1、     OP是操作码(opcode),在0+6(看下面)。

2、     A是所有指令的第一操作数(operand),在6:8,视为无符号数字。最大值是255。

3、     B是模式iABC的第二操作数,在23+9,视为有符号数字。实际的指令未必用到B。

第一位是符号位。如果是1,其它8位为B的绝对值减1。因此没有-0,B的最小值是-256,最大值255。

4、     C是iABC的第三操作数,在14+9,视为像B一样的有符号数字。实际指令未必用到C。

5、     Bx是iABx的第二操作数,在14+18,像B一样视为19位有符号数字,但是不需存储符号位因为Bx总是负的。

6、     sBx意思是“Bx视为18为有符号数字”。

7、     Ax是模式iAx的唯一操作数,在6+26,视为Bx(27位有符号数字,负数,符号位不存储)。

指令的操作码及其功能描述在lopcodes.h的一个enum中。我们以其相关功能进行分组。描述(从lopcodes.h中逐字取出)中使用以下记号,B,Bx等不是指位编码的数字,而是指luac反编译后能看到的数字。

R(A), R(B), R(C)
    寄存器A,B或者C的数值。
Kst(Bx)
    常数|Bx|。
KPROTO[Bx]
    函数原型|Bx|。
RK(B), RK(C)
    寄存器B或者C的值;如果B或者C是负数,常数|B|或者|C|。
FPF
    每次刷新的Fields。阅读NFI的解释。
UpValue
    外部局部变量B的值
Pc
    程序计数器:下一条被执行的指令的编号
closure
    创建一个闭包(函数加上外部局部变量)。

加载参数

58+4 x3d:  03 00 00 00
    共3个常量。
62+4 x41:  04
    第一个常量是string
66+1 x42:  06 00 00 00
    含6字节,
67+4 x46:  48 65 6c 6c 6f 00
    包含了”Hello”和一个终止的0字节。
71+6 x4c:  04  06 00 00 00  70 72 69 6e 74 00
    第二个常量是6字节字符串”print”。
77+5+6 x57:  04  08 00 00 00  20 57 6f 72 6c 64 21 00
    第三个常量是8字节字符串” World!”。

紧随着是函数原型列表:

88+5+8 x64:  00 00 00 00
    无函数原型

最后是外部局部变量列表:

101+4 x68:  01 00 00 00
有1个外部局部变量
105+4 x6c:  01 00
可以在上1级的堆栈中找到,在堆栈的位置0。

从这开始,两个文件出现了分歧。在剥离后的版本中,去掉的调试信息展现为16个0字节。

109+2 x6e:  00 00 00 00
    无源代码
111+4 x72:  00 00 00 00
    行号列表空
115+4 x76:  00 00 00 00
    局部变量名空
119+4 x7a:  00 00 00 00
    外部局部变量名空

在未剥离的版本中,给出了以下信息:

111 x6e:  30 00 00 00
    源码长度48字节。忽略旧的源码,
111+4+48 xa2:  07 00 00 00
    7个行号,忽略旧行号,
163+4+28 xc2:  01 00 00 00
    列出了1个局部变量名。
199 xc6:  06 00 00 00  68 65 6c 6c 6f 00
    变量名hello
199+4+6 xd0:  01 00 00 00
    在指令1进入其作用域
209+4 xd4:  07 00 00 00
    在指令7跳出其作用域
213+4 xd8:  01 00 00 00
    列出1个外部局部变量
217+4 xdc:  05 00 00 00  5f 45 4e 56 00
    变量名_ENV

此后的函数也会以同种方式编码。

目前我的探索发现到此。以下领域尚未覆盖:

  •   非字符串常量如何存储
  •   函数原型的形式为何

NFI涵括了这些以及其它未提及的东西,但是是5.1版本的,所以你得参照lundump.c和LAT。

全面窥探字节码

伴随此文件而来的还有两个模块文件:vm52.lua和bytecode.lua。这阶段一开始,我们仅使用numberAt。这个函数将4字节子字符串转成32位数字,使用宿主机器的字节端序。我们从上面了解到dump后的Lua5.2函数在28是一个可变长函数参数,在29是寄存器数量,在30是指令数量,随后是许多的4字节指令。

所以让我们来看一下。

$ lua -l vm52
Lua 5.2.1  Copyright (C) 1994-2012 Lua.org, PUC-Rio
   func = load 'local  hello = "Hello" print (hello.." World!")'
   chunk = string.dump(func)

   function hasvarargs(chunk) return chunk:sub(28,28):byte()>0 end
   function nstack(chunk) return chunk:sub(29,29):byte() end
   function ninstr(chunk) return vm52.numberAt(chunk,30) end
   function instr(chunk,i) return vm52.numberAt(chunk:sub(30+4*i,33+4*i)) end

   ns = nstack(chunk)
   print ("This function" ..
     (hasvarargs(chunk) and " has a variable number of arguments and" or "") ..
     " uses "..ns.." register"..(ns~=1 and "s" or ""))

   for i=1,ninstr(chunk) do
      print(string.format("%08X",instr(chunk,i)))
   end 
00000001
00404046
00000080
000080C1
0100C096
0100405D
0080001F

你能看到字节码文件的字节内容,倒转是因为我的机器是小端字节序。

模块文件vm52.lua不仅有numberAt:它有上面代码定义的四个函数,更重要的,它还有assemble和disassemble。后者做的跟lua -l一样处理dump后的字节码。

继续之前的Lua会话,

   for i=1,vm52.ninstr(code) do
      print(vm52.disassemble(vm52.instr(code,i)))
   end 
LOADK 0 -1
GETTABUP 1 0 -2
MOVE 2 0
LOADK 3 -3
CONCAT 2 2 3
CALL 1 2 1
RETURN 0 1

指令解剖

以上16进制列表与字节码文件相对应,不过字节是倒转的。这是因为我的系统是小端序的:小端字节序的32位数字真是难以阅读,所以普遍使用的是大端字节序。

所以在分解和显示它们之前,我们把指令字节倒转过来。numberAt函数会做这个事情,如果需要的话:

就像我们看过的,指令是:

00 00 00 01   LOADK 0 -1
00 40 40 46   GETTABUP 1 0 -2
00 00 00 80   MOVE 2 0
00 00 80 c1   LOADK 3 -3
01 00 c0 96   CONCAT 2 2 3
01 00 40 5d   CALL 1 2 1
00 80 00 1f   RETURN 0 1

我们现在将字节展开成二进制并且分组。如果lopcodes.c指明指令是iABC模式的,我们用(9,9,8,6)分组,定义值为B,C,A,OP;如果指明是iABx或者iAsBx,用(18,8,6)分组,定义值为Bx,A,OP或者sBx,A,OP;如果是iAx,分为(26,6),定义值为Ax,OP。

    B    Bx   C         A      OP
000000000000000000  00000000 000001   LOADK 0 -1       (A,Bx)
000000000 100000001 00000001 000110   GETTABUP 1 0 -2  (A,B,C)
000000000 000000000 00000010 000000   MOVE 2 0         (A,B) 
000000000000000010  00000011 000001   LOADK 3 -3       (A,Bx)
000000010 000000011 00000010 010110   CONCAT 2 2 3     (A,B,C)
000000010 000000001 00000001 011101   CALL 1 2 1       (A,B,C)
000000001 000000000 00000000 011111   RETURN 0 1       (A,B)

上面的表格里面,每一位的编号从左到右是31,30,…,0。记号“0+6”意味着位置0 的低6位(表格最右边)。如果指令作为一个Lua数字存储,这几位用bit32:extract(0,6)表示。

1、     OP是操作码(opcode),在0+6(看下面)。

2、     A是所有指令的第一操作数(operand),在6:8,视为无符号数字。最大值是255。

3、     B是模式iABC的第二操作数,在23+9,视为有符号数字。实际的指令未必用到B。

第一位是符号位。如果是1,其它8位为B的绝对值减1。因此没有-0,B的最小值是-256,最大值255。

4、     C是iABC的第三操作数,在14+9,视为像B一样的有符号数字。实际指令未必用到C。

5、     Bx是iABx的第二操作数,在14+18,像B一样视为19位有符号数字,但是不需存储符号位因为Bx总是负的。

6、     sBx意思是“Bx视为18为有符号数字”。

7、     Ax是模式iAx的唯一操作数,在6+26,视为Bx(27位有符号数字,负数,符号位不存储)。

指令的操作码及其功能描述在lopcodes.h的一个enum中。我们以其相关功能进行分组。描述(从lopcodes.h中逐字取出)中使用以下记号,B,Bx等不是指位编码的数字,而是指luac反编译后能看到的数字。

R(A), R(B), R(C)
    寄存器A,B或者C的数值。
Kst(Bx)
    常数|Bx|。
KPROTO[Bx]
    函数原型|Bx|。
RK(B), RK(C)
    寄存器B或者C的值;如果B或者C是负数,常数|B|或者|C|。
FPF
    每次刷新的Fields。阅读NFI的解释。
UpValue
    外部局部变量B的值
Pc
    程序计数器:下一条被执行的指令的编号
closure
    创建一个闭包(函数加上外部局部变量)。

加载参数

88+5+8 x64:  00 00 00 00
    无函数原型

最后是外部局部变量列表:

101+4 x68:  01 00 00 00
有1个外部局部变量
105+4 x6c:  01 00
可以在上1级的堆栈中找到,在堆栈的位置0。

从这开始,两个文件出现了分歧。在剥离后的版本中,去掉的调试信息展现为16个0字节。

109+2 x6e:  00 00 00 00
    无源代码
111+4 x72:  00 00 00 00
    行号列表空
115+4 x76:  00 00 00 00
    局部变量名空
119+4 x7a:  00 00 00 00
    外部局部变量名空

在未剥离的版本中,给出了以下信息:

111 x6e:  30 00 00 00
    源码长度48字节。忽略旧的源码,
111+4+48 xa2:  07 00 00 00
    7个行号,忽略旧行号,
163+4+28 xc2:  01 00 00 00
    列出了1个局部变量名。
199 xc6:  06 00 00 00  68 65 6c 6c 6f 00
    变量名hello
199+4+6 xd0:  01 00 00 00
    在指令1进入其作用域
209+4 xd4:  07 00 00 00
    在指令7跳出其作用域
213+4 xd8:  01 00 00 00
    列出1个外部局部变量
217+4 xdc:  05 00 00 00  5f 45 4e 56 00
    变量名_ENV

此后的函数也会以同种方式编码。

目前我的探索发现到此。以下领域尚未覆盖:

  •   非字符串常量如何存储
  •   函数原型的形式为何

NFI涵括了这些以及其它未提及的东西,但是是5.1版本的,所以你得参照lundump.c和LAT。

全面窥探字节码

伴随此文件而来的还有两个模块文件:vm52.lua和bytecode.lua。这阶段一开始,我们仅使用numberAt。这个函数将4字节子字符串转成32位数字,使用宿主机器的字节端序。我们从上面了解到dump后的Lua5.2函数在28是一个可变长函数参数,在29是寄存器数量,在30是指令数量,随后是许多的4字节指令。

所以让我们来看一下。

$ lua -l vm52
Lua 5.2.1  Copyright (C) 1994-2012 Lua.org, PUC-Rio
   func = load 'local  hello = "Hello" print (hello.." World!")'
   chunk = string.dump(func)

   function hasvarargs(chunk) return chunk:sub(28,28):byte()>0 end
   function nstack(chunk) return chunk:sub(29,29):byte() end
   function ninstr(chunk) return vm52.numberAt(chunk,30) end
   function instr(chunk,i) return vm52.numberAt(chunk:sub(30+4*i,33+4*i)) end

   ns = nstack(chunk)
   print ("This function" ..
     (hasvarargs(chunk) and " has a variable number of arguments and" or "") ..
     " uses "..ns.." register"..(ns~=1 and "s" or ""))

   for i=1,ninstr(chunk) do
      print(string.format("%08X",instr(chunk,i)))
   end 
00000001
00404046
00000080
000080C1
0100C096
0100405D
0080001F

你能看到字节码文件的字节内容,倒转是因为我的机器是小端字节序。

模块文件vm52.lua不仅有numberAt:它有上面代码定义的四个函数,更重要的,它还有assemble和disassemble。后者做的跟lua -l一样处理dump后的字节码。

继续之前的Lua会话,

   for i=1,vm52.ninstr(code) do
      print(vm52.disassemble(vm52.instr(code,i)))
   end 
LOADK 0 -1
GETTABUP 1 0 -2
MOVE 2 0
LOADK 3 -3
CONCAT 2 2 3
CALL 1 2 1
RETURN 0 1

指令解剖

以上16进制列表与字节码文件相对应,不过字节是倒转的。这是因为我的系统是小端序的:小端字节序的32位数字真是难以阅读,所以普遍使用的是大端字节序。

所以在分解和显示它们之前,我们把指令字节倒转过来。numberAt函数会做这个事情,如果需要的话:

就像我们看过的,指令是:

00 00 00 01   LOADK 0 -1
00 40 40 46   GETTABUP 1 0 -2
00 00 00 80   MOVE 2 0
00 00 80 c1   LOADK 3 -3
01 00 c0 96   CONCAT 2 2 3
01 00 40 5d   CALL 1 2 1
00 80 00 1f   RETURN 0 1

我们现在将字节展开成二进制并且分组。如果lopcodes.c指明指令是iABC模式的,我们用(9,9,8,6)分组,定义值为B,C,A,OP;如果指明是iABx或者iAsBx,用(18,8,6)分组,定义值为Bx,A,OP或者sBx,A,OP;如果是iAx,分为(26,6),定义值为Ax,OP。

    B    Bx   C         A      OP
000000000000000000  00000000 000001   LOADK 0 -1       (A,Bx)
000000000 100000001 00000001 000110   GETTABUP 1 0 -2  (A,B,C)
000000000 000000000 00000010 000000   MOVE 2 0         (A,B) 
000000000000000010  00000011 000001   LOADK 3 -3       (A,Bx)
000000010 000000011 00000010 010110   CONCAT 2 2 3     (A,B,C)
000000010 000000001 00000001 011101   CALL 1 2 1       (A,B,C)
000000001 000000000 00000000 011111   RETURN 0 1       (A,B)

上面的表格里面,每一位的编号从左到右是31,30,…,0。记号“0+6”意味着位置0 的低6位(表格最右边)。如果指令作为一个Lua数字存储,这几位用bit32:extract(0,6)表示。

1、     OP是操作码(opcode),在0+6(看下面)。

2、     A是所有指令的第一操作数(operand),在6:8,视为无符号数字。最大值是255。

3、     B是模式iABC的第二操作数,在23+9,视为有符号数字。实际的指令未必用到B。

第一位是符号位。如果是1,其它8位为B的绝对值减1。因此没有-0,B的最小值是-256,最大值255。

4、     C是iABC的第三操作数,在14+9,视为像B一样的有符号数字。实际指令未必用到C。

5、     Bx是iABx的第二操作数,在14+18,像B一样视为19位有符号数字,但是不需存储符号位因为Bx总是负的。

6、     sBx意思是“Bx视为18为有符号数字”。

7、     Ax是模式iAx的唯一操作数,在6+26,视为Bx(27位有符号数字,负数,符号位不存储)。

指令的操作码及其功能描述在lopcodes.h的一个enum中。我们以其相关功能进行分组。描述(从lopcodes.h中逐字取出)中使用以下记号,B,Bx等不是指位编码的数字,而是指luac反编译后能看到的数字。

R(A), R(B), R(C)
    寄存器A,B或者C的数值。
Kst(Bx)
    常数|Bx|。
KPROTO[Bx]
    函数原型|Bx|。
RK(B), RK(C)
    寄存器B或者C的值;如果B或者C是负数,常数|B|或者|C|。
FPF
    每次刷新的Fields。阅读NFI的解释。
UpValue
    外部局部变量B的值
Pc
    程序计数器:下一条被执行的指令的编号
closure
    创建一个闭包(函数加上外部局部变量)。

加载参数

101+4 x68:  01 00 00 00
有1个外部局部变量
105+4 x6c:  01 00
可以在上1级的堆栈中找到,在堆栈的位置0。

从这开始,两个文件出现了分歧。在剥离后的版本中,去掉的调试信息展现为16个0字节。

109+2 x6e:  00 00 00 00
    无源代码
111+4 x72:  00 00 00 00
    行号列表空
115+4 x76:  00 00 00 00
    局部变量名空
119+4 x7a:  00 00 00 00
    外部局部变量名空

在未剥离的版本中,给出了以下信息:

111 x6e:  30 00 00 00
    源码长度48字节。忽略旧的源码,
111+4+48 xa2:  07 00 00 00
    7个行号,忽略旧行号,
163+4+28 xc2:  01 00 00 00
    列出了1个局部变量名。
199 xc6:  06 00 00 00  68 65 6c 6c 6f 00
    变量名hello
199+4+6 xd0:  01 00 00 00
    在指令1进入其作用域
209+4 xd4:  07 00 00 00
    在指令7跳出其作用域
213+4 xd8:  01 00 00 00
    列出1个外部局部变量
217+4 xdc:  05 00 00 00  5f 45 4e 56 00
    变量名_ENV

此后的函数也会以同种方式编码。

目前我的探索发现到此。以下领域尚未覆盖:

  •   非字符串常量如何存储
  •   函数原型的形式为何

NFI涵括了这些以及其它未提及的东西,但是是5.1版本的,所以你得参照lundump.c和LAT。

全面窥探字节码

伴随此文件而来的还有两个模块文件:vm52.lua和bytecode.lua。这阶段一开始,我们仅使用numberAt。这个函数将4字节子字符串转成32位数字,使用宿主机器的字节端序。我们从上面了解到dump后的Lua5.2函数在28是一个可变长函数参数,在29是寄存器数量,在30是指令数量,随后是许多的4字节指令。

所以让我们来看一下。

$ lua -l vm52
Lua 5.2.1  Copyright (C) 1994-2012 Lua.org, PUC-Rio
   func = load 'local  hello = "Hello" print (hello.." World!")'
   chunk = string.dump(func)

   function hasvarargs(chunk) return chunk:sub(28,28):byte()>0 end
   function nstack(chunk) return chunk:sub(29,29):byte() end
   function ninstr(chunk) return vm52.numberAt(chunk,30) end
   function instr(chunk,i) return vm52.numberAt(chunk:sub(30+4*i,33+4*i)) end

   ns = nstack(chunk)
   print ("This function" ..
     (hasvarargs(chunk) and " has a variable number of arguments and" or "") ..
     " uses "..ns.." register"..(ns~=1 and "s" or ""))

   for i=1,ninstr(chunk) do
      print(string.format("%08X",instr(chunk,i)))
   end 
00000001
00404046
00000080
000080C1
0100C096
0100405D
0080001F

你能看到字节码文件的字节内容,倒转是因为我的机器是小端字节序。

模块文件vm52.lua不仅有numberAt:它有上面代码定义的四个函数,更重要的,它还有assemble和disassemble。后者做的跟lua -l一样处理dump后的字节码。

继续之前的Lua会话,

   for i=1,vm52.ninstr(code) do
      print(vm52.disassemble(vm52.instr(code,i)))
   end 
LOADK 0 -1
GETTABUP 1 0 -2
MOVE 2 0
LOADK 3 -3
CONCAT 2 2 3
CALL 1 2 1
RETURN 0 1

指令解剖

以上16进制列表与字节码文件相对应,不过字节是倒转的。这是因为我的系统是小端序的:小端字节序的32位数字真是难以阅读,所以普遍使用的是大端字节序。

所以在分解和显示它们之前,我们把指令字节倒转过来。numberAt函数会做这个事情,如果需要的话:

就像我们看过的,指令是:

00 00 00 01   LOADK 0 -1
00 40 40 46   GETTABUP 1 0 -2
00 00 00 80   MOVE 2 0
00 00 80 c1   LOADK 3 -3
01 00 c0 96   CONCAT 2 2 3
01 00 40 5d   CALL 1 2 1
00 80 00 1f   RETURN 0 1

我们现在将字节展开成二进制并且分组。如果lopcodes.c指明指令是iABC模式的,我们用(9,9,8,6)分组,定义值为B,C,A,OP;如果指明是iABx或者iAsBx,用(18,8,6)分组,定义值为Bx,A,OP或者sBx,A,OP;如果是iAx,分为(26,6),定义值为Ax,OP。

    B    Bx   C         A      OP
000000000000000000  00000000 000001   LOADK 0 -1       (A,Bx)
000000000 100000001 00000001 000110   GETTABUP 1 0 -2  (A,B,C)
000000000 000000000 00000010 000000   MOVE 2 0         (A,B) 
000000000000000010  00000011 000001   LOADK 3 -3       (A,Bx)
000000010 000000011 00000010 010110   CONCAT 2 2 3     (A,B,C)
000000010 000000001 00000001 011101   CALL 1 2 1       (A,B,C)
000000001 000000000 00000000 011111   RETURN 0 1       (A,B)

上面的表格里面,每一位的编号从左到右是31,30,…,0。记号“0+6”意味着位置0 的低6位(表格最右边)。如果指令作为一个Lua数字存储,这几位用bit32:extract(0,6)表示。

1、     OP是操作码(opcode),在0+6(看下面)。

2、     A是所有指令的第一操作数(operand),在6:8,视为无符号数字。最大值是255。

3、     B是模式iABC的第二操作数,在23+9,视为有符号数字。实际的指令未必用到B。

第一位是符号位。如果是1,其它8位为B的绝对值减1。因此没有-0,B的最小值是-256,最大值255。

4、     C是iABC的第三操作数,在14+9,视为像B一样的有符号数字。实际指令未必用到C。

5、     Bx是iABx的第二操作数,在14+18,像B一样视为19位有符号数字,但是不需存储符号位因为Bx总是负的。

6、     sBx意思是“Bx视为18为有符号数字”。

7、     Ax是模式iAx的唯一操作数,在6+26,视为Bx(27位有符号数字,负数,符号位不存储)。

指令的操作码及其功能描述在lopcodes.h的一个enum中。我们以其相关功能进行分组。描述(从lopcodes.h中逐字取出)中使用以下记号,B,Bx等不是指位编码的数字,而是指luac反编译后能看到的数字。

R(A), R(B), R(C)
    寄存器A,B或者C的数值。
Kst(Bx)
    常数|Bx|。
KPROTO[Bx]
    函数原型|Bx|。
RK(B), RK(C)
    寄存器B或者C的值;如果B或者C是负数,常数|B|或者|C|。
FPF
    每次刷新的Fields。阅读NFI的解释。
UpValue
    外部局部变量B的值
Pc
    程序计数器:下一条被执行的指令的编号
closure
    创建一个闭包(函数加上外部局部变量)。

加载参数

109+2 x6e:  00 00 00 00
    无源代码
111+4 x72:  00 00 00 00
    行号列表空
115+4 x76:  00 00 00 00
    局部变量名空
119+4 x7a:  00 00 00 00
    外部局部变量名空

在未剥离的版本中,给出了以下信息:

111 x6e:  30 00 00 00
    源码长度48字节。忽略旧的源码,
111+4+48 xa2:  07 00 00 00
    7个行号,忽略旧行号,
163+4+28 xc2:  01 00 00 00
    列出了1个局部变量名。
199 xc6:  06 00 00 00  68 65 6c 6c 6f 00
    变量名hello
199+4+6 xd0:  01 00 00 00
    在指令1进入其作用域
209+4 xd4:  07 00 00 00
    在指令7跳出其作用域
213+4 xd8:  01 00 00 00
    列出1个外部局部变量
217+4 xdc:  05 00 00 00  5f 45 4e 56 00
    变量名_ENV

此后的函数也会以同种方式编码。

目前我的探索发现到此。以下领域尚未覆盖:

  •   非字符串常量如何存储
  •   函数原型的形式为何

NFI涵括了这些以及其它未提及的东西,但是是5.1版本的,所以你得参照lundump.c和LAT。

全面窥探字节码

伴随此文件而来的还有两个模块文件:vm52.lua和bytecode.lua。这阶段一开始,我们仅使用numberAt。这个函数将4字节子字符串转成32位数字,使用宿主机器的字节端序。我们从上面了解到dump后的Lua5.2函数在28是一个可变长函数参数,在29是寄存器数量,在30是指令数量,随后是许多的4字节指令。

所以让我们来看一下。

$ lua -l vm52
Lua 5.2.1  Copyright (C) 1994-2012 Lua.org, PUC-Rio
   func = load 'local  hello = "Hello" print (hello.." World!")'
   chunk = string.dump(func)

   function hasvarargs(chunk) return chunk:sub(28,28):byte()>0 end
   function nstack(chunk) return chunk:sub(29,29):byte() end
   function ninstr(chunk) return vm52.numberAt(chunk,30) end
   function instr(chunk,i) return vm52.numberAt(chunk:sub(30+4*i,33+4*i)) end

   ns = nstack(chunk)
   print ("This function" ..
     (hasvarargs(chunk) and " has a variable number of arguments and" or "") ..
     " uses "..ns.." register"..(ns~=1 and "s" or ""))

   for i=1,ninstr(chunk) do
      print(string.format("%08X",instr(chunk,i)))
   end 
00000001
00404046
00000080
000080C1
0100C096
0100405D
0080001F

你能看到字节码文件的字节内容,倒转是因为我的机器是小端字节序。

模块文件vm52.lua不仅有numberAt:它有上面代码定义的四个函数,更重要的,它还有assemble和disassemble。后者做的跟lua -l一样处理dump后的字节码。

继续之前的Lua会话,

   for i=1,vm52.ninstr(code) do
      print(vm52.disassemble(vm52.instr(code,i)))
   end 
LOADK 0 -1
GETTABUP 1 0 -2
MOVE 2 0
LOADK 3 -3
CONCAT 2 2 3
CALL 1 2 1
RETURN 0 1

指令解剖

以上16进制列表与字节码文件相对应,不过字节是倒转的。这是因为我的系统是小端序的:小端字节序的32位数字真是难以阅读,所以普遍使用的是大端字节序。

所以在分解和显示它们之前,我们把指令字节倒转过来。numberAt函数会做这个事情,如果需要的话:

就像我们看过的,指令是:

00 00 00 01   LOADK 0 -1
00 40 40 46   GETTABUP 1 0 -2
00 00 00 80   MOVE 2 0
00 00 80 c1   LOADK 3 -3
01 00 c0 96   CONCAT 2 2 3
01 00 40 5d   CALL 1 2 1
00 80 00 1f   RETURN 0 1

我们现在将字节展开成二进制并且分组。如果lopcodes.c指明指令是iABC模式的,我们用(9,9,8,6)分组,定义值为B,C,A,OP;如果指明是iABx或者iAsBx,用(18,8,6)分组,定义值为Bx,A,OP或者sBx,A,OP;如果是iAx,分为(26,6),定义值为Ax,OP。

    B    Bx   C         A      OP
000000000000000000  00000000 000001   LOADK 0 -1       (A,Bx)
000000000 100000001 00000001 000110   GETTABUP 1 0 -2  (A,B,C)
000000000 000000000 00000010 000000   MOVE 2 0         (A,B) 
000000000000000010  00000011 000001   LOADK 3 -3       (A,Bx)
000000010 000000011 00000010 010110   CONCAT 2 2 3     (A,B,C)
000000010 000000001 00000001 011101   CALL 1 2 1       (A,B,C)
000000001 000000000 00000000 011111   RETURN 0 1       (A,B)

上面的表格里面,每一位的编号从左到右是31,30,…,0。记号“0+6”意味着位置0 的低6位(表格最右边)。如果指令作为一个Lua数字存储,这几位用bit32:extract(0,6)表示。

1、     OP是操作码(opcode),在0+6(看下面)。

2、     A是所有指令的第一操作数(operand),在6:8,视为无符号数字。最大值是255。

3、     B是模式iABC的第二操作数,在23+9,视为有符号数字。实际的指令未必用到B。

第一位是符号位。如果是1,其它8位为B的绝对值减1。因此没有-0,B的最小值是-256,最大值255。

4、     C是iABC的第三操作数,在14+9,视为像B一样的有符号数字。实际指令未必用到C。

5、     Bx是iABx的第二操作数,在14+18,像B一样视为19位有符号数字,但是不需存储符号位因为Bx总是负的。

6、     sBx意思是“Bx视为18为有符号数字”。

7、     Ax是模式iAx的唯一操作数,在6+26,视为Bx(27位有符号数字,负数,符号位不存储)。

指令的操作码及其功能描述在lopcodes.h的一个enum中。我们以其相关功能进行分组。描述(从lopcodes.h中逐字取出)中使用以下记号,B,Bx等不是指位编码的数字,而是指luac反编译后能看到的数字。

R(A), R(B), R(C)
    寄存器A,B或者C的数值。
Kst(Bx)
    常数|Bx|。
KPROTO[Bx]
    函数原型|Bx|。
RK(B), RK(C)
    寄存器B或者C的值;如果B或者C是负数,常数|B|或者|C|。
FPF
    每次刷新的Fields。阅读NFI的解释。
UpValue
    外部局部变量B的值
Pc
    程序计数器:下一条被执行的指令的编号
closure
    创建一个闭包(函数加上外部局部变量)。

加载参数

111 x6e:  30 00 00 00
    源码长度48字节。忽略旧的源码,
111+4+48 xa2:  07 00 00 00
    7个行号,忽略旧行号,
163+4+28 xc2:  01 00 00 00
    列出了1个局部变量名。
199 xc6:  06 00 00 00  68 65 6c 6c 6f 00
    变量名hello
199+4+6 xd0:  01 00 00 00
    在指令1进入其作用域
209+4 xd4:  07 00 00 00
    在指令7跳出其作用域
213+4 xd8:  01 00 00 00
    列出1个外部局部变量
217+4 xdc:  05 00 00 00  5f 45 4e 56 00
    变量名_ENV

此后的函数也会以同种方式编码。

目前我的探索发现到此。以下领域尚未覆盖:

  •   非字符串常量如何存储
  •   函数原型的形式为何

NFI涵括了这些以及其它未提及的东西,但是是5.1版本的,所以你得参照lundump.c和LAT。

全面窥探字节码

伴随此文件而来的还有两个模块文件:vm52.lua和bytecode.lua。这阶段一开始,我们仅使用numberAt。这个函数将4字节子字符串转成32位数字,使用宿主机器的字节端序。我们从上面了解到dump后的Lua5.2函数在28是一个可变长函数参数,在29是寄存器数量,在30是指令数量,随后是许多的4字节指令。

所以让我们来看一下。

$ lua -l vm52
Lua 5.2.1  Copyright (C) 1994-2012 Lua.org, PUC-Rio
   func = load 'local  hello = "Hello" print (hello.." World!")'
   chunk = string.dump(func)

   function hasvarargs(chunk) return chunk:sub(28,28):byte()>0 end
   function nstack(chunk) return chunk:sub(29,29):byte() end
   function ninstr(chunk) return vm52.numberAt(chunk,30) end
   function instr(chunk,i) return vm52.numberAt(chunk:sub(30+4*i,33+4*i)) end

   ns = nstack(chunk)
   print ("This function" ..
     (hasvarargs(chunk) and " has a variable number of arguments and" or "") ..
     " uses "..ns.." register"..(ns~=1 and "s" or ""))

   for i=1,ninstr(chunk) do
      print(string.format("%08X",instr(chunk,i)))
   end 
00000001
00404046
00000080
000080C1
0100C096
0100405D
0080001F

你能看到字节码文件的字节内容,倒转是因为我的机器是小端字节序。

模块文件vm52.lua不仅有numberAt:它有上面代码定义的四个函数,更重要的,它还有assemble和disassemble。后者做的跟lua -l一样处理dump后的字节码。

继续之前的Lua会话,

   for i=1,vm52.ninstr(code) do
      print(vm52.disassemble(vm52.instr(code,i)))
   end 
LOADK 0 -1
GETTABUP 1 0 -2
MOVE 2 0
LOADK 3 -3
CONCAT 2 2 3
CALL 1 2 1
RETURN 0 1

指令解剖

以上16进制列表与字节码文件相对应,不过字节是倒转的。这是因为我的系统是小端序的:小端字节序的32位数字真是难以阅读,所以普遍使用的是大端字节序。

所以在分解和显示它们之前,我们把指令字节倒转过来。numberAt函数会做这个事情,如果需要的话:

就像我们看过的,指令是:

00 00 00 01   LOADK 0 -1
00 40 40 46   GETTABUP 1 0 -2
00 00 00 80   MOVE 2 0
00 00 80 c1   LOADK 3 -3
01 00 c0 96   CONCAT 2 2 3
01 00 40 5d   CALL 1 2 1
00 80 00 1f   RETURN 0 1

我们现在将字节展开成二进制并且分组。如果lopcodes.c指明指令是iABC模式的,我们用(9,9,8,6)分组,定义值为B,C,A,OP;如果指明是iABx或者iAsBx,用(18,8,6)分组,定义值为Bx,A,OP或者sBx,A,OP;如果是iAx,分为(26,6),定义值为Ax,OP。

    B    Bx   C         A      OP
000000000000000000  00000000 000001   LOADK 0 -1       (A,Bx)
000000000 100000001 00000001 000110   GETTABUP 1 0 -2  (A,B,C)
000000000 000000000 00000010 000000   MOVE 2 0         (A,B) 
000000000000000010  00000011 000001   LOADK 3 -3       (A,Bx)
000000010 000000011 00000010 010110   CONCAT 2 2 3     (A,B,C)
000000010 000000001 00000001 011101   CALL 1 2 1       (A,B,C)
000000001 000000000 00000000 011111   RETURN 0 1       (A,B)

上面的表格里面,每一位的编号从左到右是31,30,…,0。记号“0+6”意味着位置0 的低6位(表格最右边)。如果指令作为一个Lua数字存储,这几位用bit32:extract(0,6)表示。

1、     OP是操作码(opcode),在0+6(看下面)。

2、     A是所有指令的第一操作数(operand),在6:8,视为无符号数字。最大值是255。

3、     B是模式iABC的第二操作数,在23+9,视为有符号数字。实际的指令未必用到B。

第一位是符号位。如果是1,其它8位为B的绝对值减1。因此没有-0,B的最小值是-256,最大值255。

4、     C是iABC的第三操作数,在14+9,视为像B一样的有符号数字。实际指令未必用到C。

5、     Bx是iABx的第二操作数,在14+18,像B一样视为19位有符号数字,但是不需存储符号位因为Bx总是负的。

6、     sBx意思是“Bx视为18为有符号数字”。

7、     Ax是模式iAx的唯一操作数,在6+26,视为Bx(27位有符号数字,负数,符号位不存储)。

指令的操作码及其功能描述在lopcodes.h的一个enum中。我们以其相关功能进行分组。描述(从lopcodes.h中逐字取出)中使用以下记号,B,Bx等不是指位编码的数字,而是指luac反编译后能看到的数字。

R(A), R(B), R(C)
    寄存器A,B或者C的数值。
Kst(Bx)
    常数|Bx|。
KPROTO[Bx]
    函数原型|Bx|。
RK(B), RK(C)
    寄存器B或者C的值;如果B或者C是负数,常数|B|或者|C|。
FPF
    每次刷新的Fields。阅读NFI的解释。
UpValue
    外部局部变量B的值
Pc
    程序计数器:下一条被执行的指令的编号
closure
    创建一个闭包(函数加上外部局部变量)。

加载参数


[招生]科锐逆向工程师培训(2024年11月15日实地,远程教学同时开班, 第51期)

上传的附件:
收藏
免费 2
支持
分享
最新回复 (4)
雪    币: 2307
活跃值: (1013)
能力值: (RANK:350 )
在线值:
发帖
回帖
粉丝
2
https://github.com/feicong/lua_re
2017-11-16 08:57
0
雪    币: 6112
活跃值: (1212)
能力值: (RANK:30 )
在线值:
发帖
回帖
粉丝
3
感谢分享
2017-11-23 15:07
0
雪    币: 407
活跃值: (566)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
4
谢谢分享
2017-12-7 13:48
0
雪    币: 858
活跃值: (11)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
5
好文章
2019-1-28 12:34
0
游客
登录 | 注册 方可回帖
返回
//