Lua连续教程之Lua反射

反射是程序用来检查和修改其自身某些部分的能力。像Lua语言这样的动态语言支持几种反射机制:环境允许运行时观察全局变量;

诸如type和pairs这样的函数允许运行时检查和遍历未知数据结构;诸如load和require这样的函数允许程序在自身中追加代码或更新代码。不过,还有很多方面仍然是缺失的:程序不能检查局部变量,开发人员不能跟踪代码的执行,函数也不知道是被谁调用的,等等。调试库填补了上述缺陷。
调试库是由两类函数组成:自省函数和钩子。自省函数允许我们检查一个正在运行中的程序的几个方面,例如活动函数的额栈、当前正在执行的代码行、局部变量的名称和值。钩子则允许我们跟踪一个程序的执行。
虽然名字里带有”调试“的字眼,但调试库提供的并不是Lua语言的调试器。不过,调试库提供了编写我们自己的调试器所需要的不同层次的所有底层机制。
调试库与其他库不同,必须被慎重地使用。首先,调试库中的某些功能的性能不高。其次,调试库会打破语言的一些固有规则,例如不能从一个局部变量的词法定界范围外访问这个局部变量。虽然调试库作为标准库直接可用,但笔者建议在使用调试库的代码段中显示地加载调试库。

自省机制

调试库中主要的自省函数是getinfo,该函数的第一个参数可以是一个函数或一个栈层次。当为某个函数foo调用debug.getinfo(foo)时,该函数会返回一个包含与该函数有关的一些数据的表。这个表可能具有以下字段:
source: 该字段用于说明函数定义的位置。如果函数定义在一个字符串中(通过调用load),那么source就是这个字符串;如果函数定义在一个文件中,那么source就是使用@作为前缀的文件名。
short_src: 该字段是source的精简版本,对于错误信息十分有用。
linedefined: 该字段是该函数定义在源代码中第一行的行号。
lastlinedefined: 该字段是该函数定义在源代码中最后一行的行号。
what: 该字段用于说明函数的类型。如果foo是一个普通的Lua函数,则为“Lua”;如果是一个C函数,则为“C”;如果是一个Lua语言代码段的主要部分,则为“main”。
name: 该字段是该函数的一个适当的名称,例如保存该函数的全局变量的名称。
namewhat: 该字段用于说明上一个子弹的含义,可能是”global”、”local”、”method”、”filed”或””(空字符串)。空字符串表示Lua原因找不到该函数的名称。
nups: 该字段是该函数的上值的个数。
nparams: 该字段是该函数的参数个数。
isvararg: 该字段表明该函数是否为可变长参数函数。
activelines: 该字段是一个包含该函数所有活跃行的集合。活跃行时指除空行和只包含注释的外行的其他行。
func: 该字段是该函数本身。
当foo是一个C函数时,Lua语言没有多少关于该函数的信息。对于这种函数,只有字段what、name、namewhat、nups和func是有意义的。
当使用给一个数字n作为参数调用函数debug.getinfo(n)时,可以得到有关相应栈层次上活跃函数的数据。栈层次是一个数字,代表某个时刻上活跃的特定函数。调用getinfo的函数A的层次是1,而调用A的函数的层次是2,以此类推。如果n大于栈中活跃函数的数量,那么函数debug.getinfo返回nil。当通过带有栈层次的debug.getinfo查询一个活跃函数时,返回的表中海油两个额外字段:currentline,表示当前该函数正在执行的代码所在的行;istailcall,如果为真则表示函数是被尾调用所调起。
字段name有些特殊。请注意,由于函数在Lua语言中是第一类值,因此函数既可以没有名称也可以有多个名称。Lua语言会通过检查调用该函数的代码来看函数是如何被调用的,进而尝试找到该函数的名称。这种方法只有在以一个数字为参数调用getinfo时才会起作用,即我们只能获取关于某一具体调用的信息。
函数getinfo的效率不高。Lua语言以一种不影响程序执行的形式来保存调试信息,至于获取这些调试信息的效率则是次要的。为了实现更好的性能,函数getinfo有一个可选的第二参数,该参数用于指定希望获取哪些信息。通过这个参数,函数getinfo就不会浪费时间去收集用户不需要的数据。这个参数是一个字符串,其中每个字母代表选择一组字段,如下表所示:


n 选择name和namewhat
f 选择func
S 选择source、short_src、what、linedefined和lastlinedefined
l 选择currentline
L 选择activelines
u 选择nup、nparams和isvararg


下面这个函数演示了函数debug.getinfo的用法,它打印出了活跃栈的栈回溯:

1
2
3
4
5
6
7
8
9
10
11
12
funciton traceback()
for level = 1, math.huge do
local info = debug.getinfo(level,"Sl")
if not info then back end
if info.what == "C" then
print(string.format("%d\tC function",level))
else
print(string.format("%d\t[%s]:%d",level,
info.short_src, info.currentline))
end
end
end

要改进这个函数并不难,只需要让函数getinfo返回更多数据即可。实事上,调试库也提供了这样一个改进版本,即函数traceback。与我们的版本不同的是,函数debug.traceback不会打印结果,而是返回一个包含栈回溯的字符串:
1
2
3
4
> print(debug.traceback())
stack traceback:
stdin:1:in main chunk
[C]:in ?

访问局部变量

我们可以通过函数debug.getlocal来检查任意活跃函数的局部变量。该函数有两个参数,一个是要查询函数的栈层次,另一个是变量的索引。该函数返回两个值,变量名和变量的当前值。如果变量索引大于活跃变量的数量,那么函数getlocal返回nil。如果栈层次无效,则会抛出异常。
Lua语言按局部变量在函数中的出现顺序对它们进行编号,但编号只限于在函数当前作用域中活跃的变量。例如,考虑如下的代码:

1
2
3
4
5
6
7
8
9
10
11
function foo(a,b)
local x
do local c = a - b end
local a = 1
while true do
local name ,value = debug.getlocal(1,a)
if not name then break end
print(name , value)
a = a + 1
end
end

调用foo(10,20)会输出:
1
2
3
4
a	10
b 20
x nil
a 4

索引为1的变量a,索引为2的变量b,索引为3的变量是x,索引为4的变量时内层的a。在getlocal被调用的时候,c已经完成了作用域,而name和value还未出现于作用域。
从Lua5.2开始,值为负的索引获取可变长参数函数的额外参数,索引-1指向第一个额外参数。此时,变量的名称永远是”(*vararg)”。
我们还可以通过函数debug.setlocal改变局部变量的值,该函数的前两个参数与getlocal相同,分别是栈层次和变量索引,而第三个参数是该局部变量的新值。该函数的返回值是变量名,如果变量索引超出了范围则返回nil。

访问非局部变量

测试库还提供了函数getupvalue,该函数允许我们访问一个被Lua函数所使用的非局部变量。与局部变量不同,被一个函数所引用的非局部变量即使在引用它的函数已经不活跃的情况下也会一直存在。因此,函数getupvalue的第一个参数不是栈层次,而是一个函数。函数getupvalue的第二个参数是变量索引,Lua语言按照函数引用非局部变量的顺序对它们编号,但由于一个函数不能用同一个名称访问两个非局部变量,所以这个顺序是无关紧要的。
我们还可以通过函数debug.setupvalue更新非局部变量的值。就像读者可能预想的一样,该函数有三个参数:一个闭包、一个变量索引和一个新值。与函数setlocal一样,该函数返回变量名,如果索引超出范围则返回nil。

示例 获取变量的值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
function getvarvalue(name,level,isenv)
local value
local found = false
level = (level or 1) + 1
--尝试局部变量
for i = 1 , math.huge do
lcoal n, v = debug,getlocal (level,i)
if not n then break end
if n == name then
value = v
found = true
end
end
if found then return "local", value end
--尝试非局部变量
local fun = debug.getinfo(level,"f").func
for i = 1, math.huge do
local n,v = debug.getupvalue(func,i)
if not n then break end
if n == name then return "upvalue", v end
end
if isenv then return "noenv" end --避免循环
--没找到;从环境中获取值
local _, env = getvarvalue("_ENV",level,true)
if env then
return "global" , env[name]
else
return "noenv"
end
end

用法如下:
1
2
> local a = 4; print(getvarvalue("a"))		-- local 4
> a = "xx";print(getvarvalue("a")) -- global xx

参数level指明在那个栈层次中寻找函数,1意味着直接的调用者。代码中多加1将层次纠正为包括getvarvalue自己。
该函数首先查找局部变量。如果有多个局部变量的名称与给定的名称相同,则获取具有最大索引的那个局部变量。因此,函数必须执行完整个循环。如果找不到指定名称的局部变量,那么就查找非局部变量。为了遍历非局部变量,该函数使用debug.getinfo函数获取调用闭包,然后遍历非局部变量。最后,如果还是找不到指定名字的非局部变量,就检索全局变量:该函数递归地调用自己来访问合适的_ENV变量并在相应环境中查找指定的名字。
参数isenv避免了一个诡异的问题。该参数用于说明我们是否处于一个从_ENV变量中查询全局名称的递归调用中。一个不使用全局变量的函数可能没有上值_ENV。在这种情况下,如果我们试图把_ENV当做全局变量来查询,那么由于我们需要_ENV来得到其自身的值,所以可能会陷入无限递归循环。因此,当isenv为真且函数getvarvalue找不到局部变量或上值时,getvarvalue就不应该再尝试全局变量。

访问其他协程

调试库中的所有自省函数都能够接受一个可选的协程作为第一个参数,这样就可以从外部来检查这个协程。例如,考虑使用如下的示例:

1
2
3
4
5
6
7
8
co = coroutine.create(function()
local x = 10
coroutine.yield()
error("some error")
end)

coroutine.resume(co)
print(debug.traceback(co))

对函数traceback的调用作用在协程co上,结果如下:
1
2
3
stack traceback:
[C]:in function 'yield'
temp:3:in function <temp:1>

由于协程和主程序运行在不同的栈上,所以回溯没有跟踪到对函数resume的调用。
当协程引发错误时并不会进行栈展开,这就意味着可以在错误发生后检查错误。继续上面的示例,如果再次唤醒协程,它会提示引起了一个错误:
1
print(coroutine.resume(co))		-- false temp:4:some error

现在,如果输出栈回溯,会得到这样的结果:
1
2
3
stack traceback:
[C]:in function 'error'
temp:4:in function <temp:1>

即使在错误发生后,也可以检查协程中的局部变量:
1
print(debug.getlocal(co,1,1))		-- x 10

钩子

调试库中的钩子机制允许用户注册一个钩子函数,这个钩子函数会在程序运行中某个特定事件发生时被调用。有四种时间能够触发一个钩子:

  • 每当调用一个函数时产生的call事件;
  • 每当函数返回时产生的return事件;
  • 每当开始执行一行新代码时产生的line事件;
  • 执行完制定数量的指令后产生的count事件。
    Lua语言用给一个描述导致钩子函数被调用的事件的字符串为参数来调用钩子函数,包括”call”、”return”、”line”或”count”。对于line事件来说,还有第二个参数,即新行号。我们可以在钩子函数内部调用函数debug.getinfo来获取更多的信息。
    要注册一个钩子,需要用两个或三个参数来调用函数debug.sethook:第一个参数是钩子函数,第二个参数是描述要监控事件的掩码字符串,第三个参数是一个用于描述以何种频度获取count事件的可选数字。如果要监控call、return和line事件,那么需要把这几个事件的首字母放入掩码字符串。如果要监控count事件,则只需要在第三个参数中指定一个计数器。如果要关闭钩子,只需不带任何参数第调用函数sethook即可。
    作为一个简单的示例,以下代码安装了一个简单的跟踪器,它会输出解释器执行的每一行代码:
    1
    debug.sethook(print,"l")
    这句调用只是简单地把函数print安装为一个钩子函数,并告诉Lua语言在line事件发生时调用它。一个更精巧的跟踪器可以使用函数getinfo获取当前文件名并添加到输出中:
    1
    2
    3
    4
    5
    function trace(event,line)
    local s = debug.getinfo(2).short_src
    print(s .. ":" .. line)
    end
    debug.sethook(trace,"l")
    与钩子一起被使用的一个很有用的函数debug.debug。这个简单的函数可以提供一个能够执行任意Lua语言命令的提示符,其等价于如下的代码:
    1
    2
    3
    4
    5
    6
    7
    8
    function debug1()
    while true do
    io.write("debug> ")
    local line = io.read()
    if line == "cont" then break end
    assert(load(line))()
    end
    end
    当用户输入”命令“cont时,函数返回。这种标准的实现十分简单,并且在全局环境中运行命令,位于正在被调试代码的定界范围之外。

调优

除了调试,反射的另外一个常见用法是用于调优,即程序使用资源的行为分析。对于时间相关的调优,最好使用C接口,因为每次钩子调用函数开销太大从而可能导致测试结果无效。不过,对于计数性质的调优,Lua代码就可以做得很好。
性能调优工具的主要数据结构是两个表,其中一个表将函数和它们的调用计数关联起来,另一个表关联函数和函数名。这两个表的索引都是函数自身:

1
2
local Counters = {}
local Names = {}

我们可以在性能分析完成后再获取函数的名称,但是如果能在一个函数F处于活动状态时获取其名称可能会得到更好的结果。这是因为,在函数F处于活动状态时,Lua语言可以通过分析正在调用函数F的代码来找出函数F的名称。
现在,我们定义一个钩子函数,该钩子函数的任务是获取当前正在被调用的函数,并递增相应的计数器,再收集函数名。

示例 用于计算调用次数的钩子

1
2
3
4
5
6
7
8
9
10
local function hook()
local f = debug.getinfo(2,"f").func
local count = Counters[f]
if count == nil then
Counters[f] = 1
Names[f] = debug.getinfo(2,"Sn")
else
Counters[f] = count + 1
end
end

接下来,运行带有钩子的程序。假设我们要分析的程序位于一个文件中,且用户通过参数把该文件名传递个性能分析器,如下:
1
% lua profile main-prog

这样,性能分析器就可以从arg[1]中得到文件名、设置钩子并运行文件:
1
2
3
4
local f = assert(loadfile(arg[1]))
debug.sethook(hook,"c")
f()
debug.sethook()

最后一步是显示结果。
示例 获取一个函数的函数名
1
2
3
4
5
6
7
8
9
10
11
12
function getname (func)
local n = Names[func]
if n.what == "C" then
return n.name
end
local lc = string.format("[%s]:%d",n.short_src, n.linedefined)
if n.what ~= "main" and n.namewhat ~= "" then
return string.format("%s (%s)",lc, n.name)
else
return lc
end
end

有Lua语言中的函数名不是特别确定,所以我们给每个函数再加上位置信息,以file:file这样的形式给出。如果一个函数没有名称,那么就只使用它的位置。如果函数是C函数,那么就只使用它的名称(因为没有位置)。在上述函数定义后,我们输出每个函数及其计数器的值:
1
2
3
for func,count in pairs(Counters) do
print(getname(func),count)
end

沙盒

由于Lua语言通过库函数完成所有与外部世界的通信,因此一旦移除了这些函数也就排除了一个脚本能够影响外部环境的可能。不过尽管如此,我们仍然可能会被消耗大量CPU时间或内存的脚本进行拒绝服务Dos攻击。反射,以调试钩子的形式,提供了一种避免这种攻击的有趣方式。
首先,我们使用count时间钩子来限制一段代码能够执行的指令数。
示例 一个使用钩子的简单沙盒

1
2
3
4
5
6
7
8
9
10
11
12
13
14
local debug = require "debug"
local steplimit = 1000
local count = 0
local function setp()
count = count + 1
if count > steplimit then
error("script uses too much CPU")
end
end

local f = assert(loadfile(arg[1],"t",{}))

debug.sethook(step,"",100)
f()

这个程序加载了指定的文件,设置了钩子,然后运行文件。该程序把钩子设置为监听count事件,使得Lua语言每执行100条执行就调用一次钩子函数。钩子只是递增一个计数器,然后检查其是否超过了某个固定的限制。这样做之后还会有问题么?
当然有问题。我们还必须限制所加载的代码段的大小:一段很长的代码只要被加载就可能消耗尽内存。另一个问题是,程序可以通过少量指令消耗大量的内存。例如:
1
2
local s = "123456789012345"
for i = 1,36 do s = s .. s end

上述的几行代码用不到150行的指令就试图创建一个1字节的字符串。显然,单纯限制指令数量和程序大小是不够的。
一种改进是检查和限制函数step使用的内存。
示例 控制内存使用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
-- 最大能够使用的内存(单位 KB)
local memlimit = 1000

-- 最大能够执行的"steps"
local steplimit = 1000

local function checkmem()
if collectgarbage("count") > memlimit then
error("script uses too much memory")
end
end

local count = 0
local function step()
checkmem()
count = count + 1
if count > steplimit then
error("script uses too much CPU")
end
end

由于通过少量指令就可以消耗很多内存,所以我们应该设置一个很低的限制或以很小的步进来调用钩子函数。更具体地说,一个程序用40行以内的指令就能把一个字符串的大小增加上千倍。因此,我们要么以比40条指令更高的频率调用钩子,要么把内存限制设为我们能够承受的最大值的一千分之一。
一个微妙的问题是字符串标准库。我们可以对字符串调用该库中的所有函数。因此,即使环境中没有这些函数,我们也可以调用它们:字符串常量把它们“走私”到了我们的沙盒中。字符串标准库中没有函数能够影响外部世界,但是它们绕过了我们的指令计数器。字符串标准库中的有些函数对于DoS攻击而言可能会非常危险。
示例 使用钩子阻止对未授权函数的访问
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
local debug = require "debug"

local steplimit = 1000

local count = 0

local validfunc = {
[string.upper] = true,
[string.lower] = true,
... -- 其他授权的函数
}
local function hook(event)
if event == "call" then
local info = debug.getinfo(2,"fn")
if not validfunc[info.func] then
error("calling bad function :" .. (info.name or "?"))
end
end
count = count + 1
if count > steplimit then
error("script uses too much CPU")
end
end

-- 加载代码段
local f = assert(loadfile(arg[1],"t",{}))
debug.sethook(hook,"",100)
f()

在上述代码中,表validfunc表示一个包含程序所能够调用的函数的集合。函数hook使用调试库来访问正在被调用的函数,然后检查函数是否在集合validfunc中。
对于任何一种沙盒的实现而言,很重要的一点是沙盒内允许使用哪些函数。用于数据描述的沙盒可以限制所有或大部分函数;其他的沙盒则需要更加宽容,也许应该对某些函数提供它们自己带限制的实现。