1 引言
我们的项目使用Unreal开发, 项目内内嵌了UnLua. 某天, DS发生了coredump, 堆栈顶层的几个frame, 都落在了Lua的源代码中. 在定位这个coredump的过程中, 我对Lua部分源代码进行了一些研究. 结合案例, 将这部分学习成果做一个输出, 希望帮助到使用Lua, 以及UnLua的同学.
案例介绍
Coredump案例的堆栈如下:
代码语言:javascript复制#0 0x00007fe3d8bfb3d7 in raise () from /lib64/libc.so.6
#1 0x00007fe3d8bfcac8 in abort () from /lib64/libc.so.6
#2 0x00007fe3d8c3df67 in __libc_message () from /lib64/libc.so.6
#3 0x00007fe3d8c46329 in _int_free () from /lib64/libc.so.6
#4 0x0000000003658346 in l_alloc (ud=<optimized out>, ptr=0x274b, osize=6, nsize=18446744073709551615)
#5 0x000000000366b6b3 in luaM_free_ (L=<optimized out>, block=0x274b, osize=10320)
#6 0x0000000003660b33 in luaD_reallocstack (L=0xb7aa328, newsize=1280, raiseerror=1280)
#7 0x0000000003661869 in luaD_growstack (L=0xb7aa328, n=20, raiseerror=1)
#8 luaD_precall (L=0xb7aa328, func=<optimized out>, nresults=0)
#9 0x00000000036820cd in luaV_execute (L=<optimized out>, ci=0xb7ebb50)
#10 0x0000000003661a1e in ccall (L=0xb7aa328, func=0xc93a960, nResults=0, inc=65537)
#11 luaD_callnoyield (L=0xb7aa328, func=0xc93a960, nResults=0)
#12 0x00000000036609ad in luaD_rawrunprotected (L=0xb7aa328, f=0x3655330 <f_call>, ud=0x7ffd1d7e57a0)
根据堆栈, 初步的分析, 大家认为发生了某种内存越界. 我们怀疑过如下逻辑
1. PB Lua Map的使用. 通过阅读代码排除此猜测.
2. 业务逻辑都是落在固定的一个入口. 但此入口近期没有修改过, 排除.
3. 添加保护页. 涉及页面对齐, 可能会掩盖问题, 暂未采用.
初步分析, 突破点: glibc的malloc, free机制
通过查看系统日志, 我们找到越界时的输出:
代码语言:javascript复制Sep 11 03:45:01 ... double free or corruption (!prev): 0x000000C03a360 ***
这里我们需要了解下, glibc内存分配和管理的一些原理.
glibc使用chunk的方式管理内存, 且分配的内存至少8字节对齐. 在分配给用户的实际内存前, 还会有两个管理字段.
代码语言:javascript复制struct malloc_chunk {
INTERNAL_SIZE_T prev_size; /* Size of previous chunk (if free). */
INTERNAL_SIZE_T size; /* Size in bytes, including overhead. */
<...>
其中size是该chunk的大小(包含了这两个管理字段的大小), 由于分配的内存是8字节对齐的, 所以这个size的二进制的低3位, 必然全是0. 所以这三位用做了标志位.
其中P标志位的含义如下:
代码语言:javascript复制chunk-> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
| Size of previous chunk, if allocated | |
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
| Size of chunk, in bytes |M|P|
mem-> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
| User data starts here... .
. .
. (malloc_usable_size() bytes) .
. |
nextchunk-> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
| Size of chunk |
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
The P (PREV_INUSE) bit, stored in the unused low-order bit of the
chunk size (which is always a multiple of two words), is an in-use
bit for the *previous* chunk. If that bit is *clear*, then the
word before the current chunk size contains the previous chunk
size, and can be used to find the front of the previous chunk.
这里简单的讲, P为0表示前面的chunk已经不再使用.
而产生"double free or corruption (!prev)"是由如下的free函数的实现导致的.
代码语言:javascript复制nextchunk = chunk_at_offset(p, size);
/* Lightweight tests: check whether the block is already the
top block. */
if (__builtin_expect (p == av->top, 0))
{
errstr = "double free or corruption (top)";
goto errout;
}
/* Or whether the next chunk is beyond the boundaries of the arena. */
if (__builtin_expect (contiguous (av)
&& (char *) nextchunk
>= ((char *) av->top chunksize(av->top)), 0))
{
errstr = "double free or corruption (out)";
goto errout;
}
/* Or whether the block is actually not marked used. */
if (__builtin_expect (!prev_inuse(nextchunk), 0))
{
errstr = "double free or corruption (!prev)";
goto errout;
}
在free的过程中, 会找到nextchunk, 判断下nextchunk的P位是否为1. 如果为0, 则认为发生了double free.
所以我们得到一个初步正确的结论: 越界是由于Lua的栈溢出导致的!
2 Lua管窥
定位到如上代码后, 我们终于到了文章的正题, 后续的定位过程, 就需要了解Lua的机制和原理了.
Lua作为一个脚本语言, 给人的第一印象是语法简单, 易上手. 正是这个第一印象让一些开发者放松了对它的警惕. 实际上, Lua作为一个完整的计算机语言, 遵循了自己的设计原则, 提供了丰富的语言特性, 比如:
- 闭包, UpValue. 完全支持lambda演算.
- 动态类型, 及弱类型的变量.
- 定义了虚拟机指令, 实现了编译器, 及执行器
- 元表&元方法
- 反射
- 协程
- 栈
如果对这些特性不了解, 很容易会误用. 我们从定位问题的需要, 主要研究了如下特性:
2.1 变量类型
Lua的变量是动态的弱类型, 分为全局变量和局部变量.
动态类型的变量是和静态相对的, 静态类型是指在编译期就确定了变量的数据类型, 并且固定.
弱类型是和强类型相对的, 强类型是指, 一旦变量确定类型, 不经过强制类型转换, 则不可以隐式转换为其他类型.
参考如下代码段, a最开始为字符串类型, 第二个赋值语句将它隐式的转换为了数值类型, 所以说Lua是弱类型. 同时变量类型的未在编译期固定下来, 所以是动态类型.
代码语言:javascript复制local a = "abc";
a = 1;
g_a = {x=1, y="abc"};
local b = a;
Lua变量, 如果不用local修饰, 则为全局变量, 否则是局部变量.
Lua有如下9种数据类型:
代码语言:javascript复制#define LUA_TNIL 0
#define LUA_TBOOLEAN 1
#define LUA_TLIGHTUSERDATA 2
#define LUA_TNUMBER 3
#define LUA_TSTRING 4
#define LUA_TTABLE 5
#define LUA_TFUNCTION 6
#define LUA_TUSERDATA 7
#define LUA_TTHREAD 8
每个具体的变量, 由如下的C语言结构体表示.
代码语言:javascript复制/*
** Union of all Lua values
*/
typedef union Value {
struct GCObject *gc; /* collectable objects */
void *p; /* light userdata */
lua_CFunction f; /* light C functions */
lua_Integer i; /* integer numbers */
lua_Number n; /* float numbers */
} Value;
typedef unsigned char lu_byte;
#define TValuefields Value value_; lu_byte tt_
typedef struct TValue {
TValuefields;
} TValue;
/*
** Entries in a Lua stack. Field 'tbclist' forms a list of all
** to-be-closed variables active in this stack. Dummy entries are
** used when the distance between two tbc variables does not fit
** in an unsigned short. They are represented by delta==0, and
** their real delta is always the maximum value that fits in
** that field.
*/
typedef union StackValue {
TValue val;
struct {
TValuefields;
unsigned short delta;
} tbclist;
} StackValue;
/* index to stack elements */
typedef StackValue *StkId;
从上面的类型定义可以分析得到如下结论: 每个变量由8字节的Value联合体, 1个字节的类型组成,对于栈上的变量, 后续又有2字节的delta字段. 考虑到结构体对齐, 总共16字节.
其中类型tt_, 又可以分为高低4比特. 低位的4比特是上面的类型, 高位的4比特为更具体的修饰.
比如对于LUA_TNUMBER类型, 又分为interger和float. 参见下面的代码:
代码语言:javascript复制#define makevariant(t,v) ((t) | ((v) << 4))
#define LUA_VNUMINT makevariant(LUA_TNUMBER, 0) /* integer numbers */
#define LUA_VNUMFLT makevariant(LUA_TNUMBER, 1) /* float numbers */
#define ttisnumber(o) checktype((o), LUA_TNUMBER)
#define ttisfloat(o) checktag((o), LUA_VNUMFLT)
#define ttisinteger(o) checktag((o), LUA_VNUMINT)
对于我们定位问题, 我们需要进一步的了解到, 不同的数据类型, 对应的联合体的字段.
代码语言:javascript复制#define LUA_TNIL 0
#define LUA_TBOOLEAN 1 // lua_Integer?
#define LUA_TNUMBER 3 // lua_Number lua_Integer
#define LUA_TFUNCTION 6 // lua_CFunction
#define LUA_TSTRING 4 // struct GCObject *gc
#define LUA_TTABLE 5 // struct GCObject *gc
对于字符串型的变量, 使用的是Value联合体的GCObject*指针, 该指针指向如下数据结构.
代码语言:javascript复制#define CommonHeader struct GCObject *next; lu_byte tt; lu_byte marked
typedef struct TString {
CommonHeader;
lu_byte extra; /* reserved words for short strings; "has hash" for longs */
lu_byte shrlen; /* length for short strings */
unsigned int hash;
union {
size_t lnglen; /* length for long strings */
struct TString *hnext; /* linked list for hash table */
} u;
char contents[1];
} TString;
举个例子, 某个Lua变量的地址内容如下:
代码语言:javascript复制0xc93b3c0: 0x80 0xc5 0xfc 0x0c 0x00 0x00 0x00 0x00
0xc93b3c8: 0x44 0x01 0x00 0x00 0x00 0x00 0x00 0x00
我们从第二行第一个字节, 0x44的低四位, 可以知道, 这个变量是一个LUA_TSTRING类型的变量. 那么第一行的8个字节, 就是字符串类型变量对相应的GCObject的地址, 通过小端字节序转换, 可以的得到如下地址. 0x0cfcc580.
查看这个地址的内容:
代码语言:javascript复制0xcfcc580: 0x50 0xc5 0xfc 0x0c 0x00 0x00 0x00 0x00
0xcfcc588: 0x04 0x10 0x00 0x09 0x13 0x2d 0xa7 0x6d
0xcfcc590: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0xcfcc598: 0x33 0x30 0x31 0x30 0x34 0x30 0x30 0x31
0xcfcc5a0: 0x32 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0xcfcc5a8: 0xd1 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0xcfcc5b0: 0x19 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0xcfcc5b8: 0x03 0x45 0xfc 0x0c 0x00 0x00 0x00 0x00
0xcfcc5c0: 0xe0 0xe5 0xaa 0x0b 0x00 0x00 0x00 0x00
0xcfcc5c8: 0xea 0x03 0x00 0xc0 0x37 0x74 0x4a 0x9a
结合上述结构体, 我们能看到第二行中, 0x09, 就是这个字符串的长度. 而字符串内容, 是从第四行开始.
代码语言:javascript复制0xcfcc598: 0x33 0x30 0x31 0x30 0x34 0x30 0x30 0x31
0xcfcc5a0: 0x32
这个utf8的字符串, 从16进制转换过来就是"301050001", 这个正好是我们业务用到的一个id.
小结
关于Lua的变量类型, 以及变量在源码层面的实现, 我们就先介绍这些. 后续问题定位过程中, 我们还会继续用到这些知识.
2.2 栈
前面已经提到, Lua的变量分为局部和全局. 可以认为局部变量都是放在栈上的, 全局变量是放在全局变量表中的. 而这两个数据结构都是保存在Lua状态机里的.
lua状态机
Lua状态机的数据结构如下:
代码语言:javascript复制struct lua_State {
CommonHeader;
lu_byte status;
lu_byte allowhook;
unsigned short nci; /* number of items in 'ci' list */
StkId top; /* first free slot in the stack */
global_State *l_G;
CallInfo *ci; /* call info for current function */
StkId stack_last; /* end of stack (last element 1) */
StkId stack; /* stack base */
UpVal *openupval; /* list of open upvalues in this stack */
<...>
关于Lua状态机, 本文不做过多展开, 可以理解为保存了Lua的整个执行环境. <>里有段话, 说的很好, 摘抄如下:
代码语言:javascript复制Lua标准库没有定义任何C语言全局变量,它将其所有的状态都保存在动态的结构体lua_State中,
Lua 中的所有函数都接收一个指向该结构的指针作为参数.
这种设计使得Lua是可重入的, 并且可以直接用于编写多线程代码.
结合之前的Lua变量的定义, 可以看到Lua的栈, StkId stack, 其实就是一个StackValue数组.
代码语言:javascript复制typedef union StackValue {
<...>
} StackValue;
/* index to stack elements */
typedef StackValue *StkId;
stack是栈底, top是栈顶, stack_last指向了该数组的尾部.
关于栈是数组, 在下面的初始化代码中可见一斑, luaM_newvector的作用就是分配足够的大小数组空间.
代码语言:javascript复制LUA_API lua_State *lua_newstate (lua_Alloc f, void *ud) {
static void stack_init (lua_State *L1, lua_State *L) {
int i; CallInfo *ci;
/* initialize stack array */
L1->stack = luaM_newvector(L, BASIC_STACK_SIZE EXTRA_STACK, StackValue);
L1->tbclist = L1->stack;
for (i = 0; i < BASIC_STACK_SIZE EXTRA_STACK; i )
setnilvalue(s2v(L1->stack i)); /* erase new stack */
L1->top = L1->stack;
L1->stack_last = L1->stack BASIC_STACK_SIZE;
/* initialize first ci */
void *luaM_malloc_ (lua_State *L, size_t size, int tag) {
if (size == 0)
return NULL; /* that's all */
else {
global_State *g = G(L);
void *newblock = firsttry(g, NULL, tag, size);
if (l_unlikely(newblock == NULL)) {
newblock = tryagain(L, NULL, tag, size);
if (newblock == NULL)
luaM_error(L);
}
g->GCdebt = size;
return newblock;
}
}
栈大小
从上面的初始化代码中, 我们可以看到栈数组的初始大小为BASIC_STACK_SIZE EXTRA_STACK.
其中BASIC_STACK_SIZE定义为40, EXTRA_STACK定义为5. EXTRA_STACK这部分, 不是给局部变量直接使用的.
栈的增长
当调用一个新Lua函数或者C库函数时, 会检查当前栈大小是否满足需求. 如果不满足, 则会对栈进行扩展. 扩展的调用堆栈如下
代码语言:javascript复制luaD_reallocstack(lua_State * L, int newsize, int raiseerror) 行 266 C
luaD_growstack(lua_State * L, int n, int raiseerror) 行 310 C
luaD_precall(lua_State * L, StackValue * func, int nresults) 行 592 C
luaV_execute(lua_State * L, CallInfo * ci) 行 1624 C
ccall(lua_State * L, StackValue * func, int nResults, int inc) 行 677 C
部分代码如下:
代码语言:javascript复制int luaD_reallocstack (lua_State *L, int newsize, int raiseerror) {
int oldsize = stacksize(L);
int i;
StkId newstack = luaM_reallocvector(L, NULL, 0,
newsize EXTRA_STACK, StackValue);
lua_assert(newsize <= LUAI_MAXSTACK || newsize == ERRORSTACKSIZE);
if (l_unlikely(newstack == NULL)) { /* reallocation failed? */
if (raiseerror)
luaM_error(L);
else return 0; /* do not raise an error */
}
/* number of elements to be copied to the new stack */
i = ((oldsize <= newsize) ? oldsize : newsize) EXTRA_STACK;
memcpy(newstack, L->stack, i * sizeof(StackValue));
for (; i < newsize EXTRA_STACK; i )
setnilvalue(s2v(newstack i)); /* erase new segment */
correctstack(L, L->stack, newstack);
luaM_freearray(L, L->stack, oldsize EXTRA_STACK);
L->stack = newstack;
L->stack_last = L->stack newsize;
return 1;
}
参考下面的代码逻辑, luaM_reallocvector由于第二个参数为NULL, 所以本质上, 就是调用malloc重新分配一块内存. 分配成功后, 进行拷贝, 然后删除老的栈.
代码语言:javascript复制#define luaM_reallocvector(L, v,oldn,n,t)
(cast(t *, luaM_realloc_(L, v, cast_sizet(oldn) * sizeof(t),
cast_sizet(n) * sizeof(t))))
void *luaM_realloc_ (lua_State *L, void *block, size_t osize, size_t nsize) {
void *newblock;
global_State *g = G(L);
lua_assert((osize == 0) == (block == NULL));
newblock = firsttry(g, block, osize, nsize);
#define firsttry(g,block,os,ns) ((*g->frealloc)(g->ud, block, os, ns))
typedef struct global_State {
lua_Alloc frealloc; /* function to reallocate memory */
void *ud; /* auxiliary data to 'frealloc' */
LUALIB_API lua_State *luaL_newstate (void) {
lua_State *L = lua_newstate(l_alloc, NULL);
if (l_likely(L)) {
lua_atpanic(L, &panic);
lua_setwarnf(L, warnfoff, L); /* default is warnings off */
}
return L;
}
static void *l_alloc (void *ud, void *ptr, size_t osize, size_t nsize) {
(void)ud; (void)osize; /* not used */
if (nsize == 0) {
free(ptr);
return NULL;
}
else
return realloc(ptr, nsize);
}
规避策略
组里其他同学最初的解决办法, 也是直接扩大初始的栈大小, 以减少realloc的发生, 不过这里的修改有个反复. 就是在从函数返回后, 可能会进行缩栈(luaD_shrinkstack).
但这种规避, 并不能避免栈底被越界改写的情况. 如果栈底被改写, 即便避免了crash, 但可能会导致其他的逻辑问题. 所以我个人认为有必要进一步探究下crash的直接原因.
3 进一步的Debug
有了以上的知识背景, 我们就可以进一步的debug.
首先是查看lua_State
代码语言:javascript复制(gdb) p L
$1 = (lua_State *) 0xb7aa328
(gdb) p *L
$2 = {stack_last = 0xc93b370, stack = 0xc938b70, }
查看stack_last 5处的地址, 就是下一处chunk的地址. 为什么是5, 可以回想下栈大小那个小节中EXTRA_STACK的大小.
代码语言:javascript复制0xc93b3c0: 0x80 0xc5 0xfc 0x0c 0x00 0x00 0x00 0x00
0xc93b3c8: 0x44 0x01 0x00 0x00 0x00 0x00 0x00 0x00
在初步分析中, 我们已经知道, 按照malloc的实现, 这里的0x44表明, 前面的chunk已经被释放. 但实际并没有, 所以可以肯定是栈数组尾部发生越界写了.
结合Lua变量类型小节的内容, 我当时猜测这16个字节应该是符合LuaValue的结构, 当时按照Lua String的结构进行分析(0x44里的低位4表明是字符串类型), 得到了一个项目内使用的字符串. 这基本确定了自己的推论.
进一步, 我向上查询多个内存的内容. 发现在这块内存有如下Lua变量.
代码语言:javascript复制0xc93b190: 0x00 0x70 0x72 0x03 0x00 0x00 0x00 0x00
0xc93b198: 0x16 0x44 0x00 0x00 0x00 0x00 0x00 0x00
0x3727000 <Global_Print(lua_State*)>: 0x55 0x41 0x57 0x41 0x56 0x41 0x55 0x41
0x3727008 <Global_Print(lua_State*) 8>: 0x54 0x53 0x48 0x81 0xec 0xa8 0x02 0x00
并结合后续栈上的参数, 发现确实有一处代码使用了UnLua的Global_Print函数, 且使用了超多的参数.
而对于C提供的库函数, Lua分配的栈大小只有20个.
代码语言:javascript复制CallInfo *luaD_precall (lua_State *L, StkId func, int nresults) {
lua_CFunction f;
retry:
switch (ttypetag(s2v(func))) {
case LUA_VCCL: /* C closure */
f = clCvalue(s2v(func))->f;
goto Cfunc;
case LUA_VLCF: /* light C function */
f = fvalue(s2v(func));
Cfunc: {
int n; /* number of returns */
CallInfo *ci;
checkstackGCp(L, LUA_MINSTACK, func); /* ensure minimum stack size */
L->ci = ci = next_ci(L);
ci->nresults = nresults;
ci->callstatus = CIST_C;
ci->top = L->top LUA_MINSTACK;
ci->func = func;
lua_assert(ci->top <= L->stack_last);
虽然对Lua栈的使用, 以及函数压栈的处理不太清楚, 我当时基本断定, 问题出在这里.
后续负责维护基础库的同学接手, 继续分析了该问题. 对于Lua侧的调用, 压栈本身不会造成越界.
而越界的根本原因在于, UnLua的Global_Print函数使用的luaL_tolstring接口会往栈上压一个元素.
代码语言:javascript复制int32 Global_Print(lua_State *L)
{
FString LogPrefix;
FString StrLog;
int32 nargs = lua_gettop(L);
if (nargs < 3)
{
UE_LOG(LogUnLua, Error, TEXT("Global_Print error parameters!"));
return -1;
}
int32 loglevel = lua_tointeger(L, 1);
const char* luaLogType = lua_tostring(L, 2);
const char* logPrefix = lua_tostring(L, 3);
const char* source = lua_tostring(L, 4);
const char* traceback = lua_tostring(L, 5);
if (loglevel < 0 || loglevel > 9)
{
UE_LOG(LogUnLua, Error, TEXT("Global_Print error loglevel=%d"), loglevel);
return -1;
}
for (int32 i = 6; i <= nargs; i)
{
const char* arg = luaL_tolstring(L, i, nullptr);
if (!arg)
在后续的调用层级中, 会使用lua_pushstring, 但此函数中使用api_incr_top进行压栈. 而这个api并不会对栈进行自动扩充. 且我们的未启用lua_assert, 导致崩溃在非预期内的地方.
代码语言:javascript复制LUA_API const char *lua_pushstring (lua_State *L, const char *s) {
lua_lock(L);
if (s == NULL)
setnilvalue(s2v(L->top));
else {
TString *ts;
ts = luaS_new(L, s);
setsvalue2s(L, L->top, ts);
s = getstr(ts); /* internal copy's address */
}
api_incr_top(L);
<...>
#define api_incr_top(L) {L->top ; api_check(L, L->top <= L->ci->top,
"stack overflow");}
这里附带说明下lua_tostring是将栈上的变量本身转换成一个Lua String, 而luaL_tolstring是在栈上压一个新的Lua String. 这样不会修改参数本身, luaD_inctop接口会进行扩栈处理.
解决思路
问题已经清楚了,修改方法也就明确了.
- 可以在每次调用luaL_tolstring时, 尝试check stack, 手动进行扩栈;
- 也可以使用完后, 主动进行一次弹栈;
- 当然也可以判断下参数数量不要超过Lua未C函数分配的栈大小;
- 最好把lua_assert也启用
- ....
栈的使用
在了解了最后的结论后, 发现我对Lua栈的压弹过程还是不太清晰, 我这边又简单探究了下.
我们用如下lua代码演示下Lua栈的使用
代码语言:javascript复制local a = 2;
local b = 3;
function func(x, y)
x = 1;
y = 2;
local u = 2;
local v = 2;
local w = 2;
return x,w
end
local a, b, c, d = func(2, 3);
local e = 3;
print(a);
总结下来就是如下图:
- 可以看到调用函数调用前, 栈上只有a b两个局部变量.
- 调用函数时先将函数本身压栈, 然后压入参数, 开始执行后, 压入函数内局部变量.
- 对于返回值, 仍然是压在栈上
- 函数返回后, 会被返回值赋值给对应的变量 注意这里故意起了同名的a b两个变量, 可以看到lua会再次创建一个新的变量.
当然实际的过程还要更复杂些, 比如UpValue的处理等. 在本篇就不再展开.
4 总结
Lua 93年诞生, 在游戏领域使用广泛. 喜欢它, 不喜欢它的人都可以列举很多理由. 但无论主观倾向如何, 如果项目内已经实实在在的在使用它, 就有必要系统的学习和了解它, 以减少可能的误用带来的风险.
本文以一个案例切入, 只介绍了Lua的栈和变量的部分内容, 顾标题为"管中窥豹看Lua".