从可变参数函数的调用引发异常崩溃一例引发的一些思考 by 20180615

       使用va_list、va_start、va_arg和va_end这组宏实现的可变参函数,是在运行时动态从函数调用堆栈中依次解析出传入的各个可变参数的(解析出可变参数的栈内存地址,读出可变参数的值(读出可变参数内存中的内容))。以C调用约定为例,从汇编代码来看函数调用,在call函数前,依次将各个参数从右到左依次入栈,包括函数声明中的已经固定类型的参数和可变参数。从汇编代码来看一个完整的函数调用的栈分布,先将各个参数压入栈中,然后通过call指令转进被调函数中。call指令内部会做两个操作,一是将主调函数的下条指令作为返回地址压入到栈中,二是实现主调函数到被调函数的跳转。进入到被调函数中后,先将当前的ebp,即主调函数的函数栈基址压入栈中保存下来,以供栈回溯使用(通过ebp值找到函数调用的返回地址)。所以C调用约定的函数在调用时,依次存入栈中的是:从右到左参数入栈 -> 主调函数的返回地址入栈-> 主调函数的ebp栈基址入栈。注意,此处讲的参数入栈,是将参数内容(内存中的内容)拷贝到栈内存中,如果传入的参数是int或者指针,则将int值和指针变量的值存放到栈内存中,如果是个类对象或者结构体对象,则是将对象的数据内容(对象内存中的内容)拷贝到栈上。被调函数可以从栈中直接访问传入的参数内容,源码中被调函数通过函数参数变量访问传入的参数值,其实在汇编代码上看,是访问函数调用时函数参数入栈时对应的栈内存中的内容。

       栈是从大地址向小地址使用的,对于参数从右到左顺序入栈的C调用,右边先入栈的参数(内容)在大地址上,后入栈的参数在小地址上,可变参数全部入栈后,紧接着是前面的固定类型的参数入栈,以第一个固定类型的参数所在的栈地址加上该参数的占用的内存长度,得到可变参数在入栈时的栈内存起始地址,这就是va_start宏实现的功能,将计算出来的可变参数的栈内存首地址设置给va_list变量,然后调用带va_list参数类型的函数完成对可变参数的内容的解析。有了可变参数的在栈上的起始地址,从源代码上看函数声明时的可变参数分别为:可变参数1,可变参数2,可变参数3,此处获取的起始地址其实就是可变参数1的地址,因为C调用是从右到左依次入栈,所以可变参数1是最后一个入栈,在栈的最上面,而栈是从大地址向小地址使用的,所以从可变参数1的栈地址,就可以向下累加值(向大地址),依次得到可变参数2,可变参数3的栈地址,从而访问到传入的可变参数内存中的内容。

       仔细一点的话,可以看到常见的支持可变参数的系统运行时库函数printf、fprintf、sprintf,它们在的声明时都使用了__cdecl C调用约定:

int __cdecl printf ( const char *format, ... );

int __cdecl fprintf( FILE * _File, const char * _Format, ... );

而Windows系统提供的API基本都是__stdcall标准调用,为什么运行库提供的支持可变参数的函数必须是C调用呢?其实是有两个原因的:

(1)标准调用是被调用函数内部来负责清理传入的参数占用的栈内存空间的,C调用则是调用者(主调函数)来负责清理的,因为是可变参数,也只有调用者才知道到底有几个参数,才知道到底该清理多大的栈空间,所以要用C调用。

(2)支持可变函数的参数内部是假定函数参数是从右到左依次入栈的,在此基础上,推算出各个可变参数的栈地址,C调用正好是参数从右到左入栈的。其实这样说应该是因果颠倒了,主要是(1)中的原因决定了要使用C调用,C调用的参数是从右到左依次入栈的,所以决定了在此基础上推算出各个可变参数的栈地址的。

      另外,此处既然说到调用约定,就多说一句。一般我们的windows产品对外提供的SDK的接口都指定为__stdcall标准调用,一定要注意,回调函数也要指定调用约定(当然我们产品内部的一些模块在内部调用时可以不指定调用约定,因为大家统一使用VS编译器默认的C调用,这样也没问题)。如果不指定,在我们SDK内部编译时使用C++编译器默认的C调用,在SDK客户程序中实现回调函数,如果是C#语言,则默认是__stdcall标准调用,这样SDK内部和SDK使用方就不一致了,一旦回调函数调用后就会导致栈不平衡,导致程序崩溃。

        至于可变参数函数的调用引发的异常崩溃的分析,篇幅较长在此不再赘述,有兴趣可以自行去研究,此处只讲一下最终的结论。我们的工程的界面库duilib,其中有个叫CStdString的字符串类,类中包含了两个数据成员:


我们在打印日志的时候,直接将CStdString类对象传给支持可变参数的函数进行字符串的格式化导致了崩溃。所以如果可变参数是个类对象(直接将类对象作为可变参数传入),该对象类中不能有多个数据成员,否则会影响可变参数内存的解析。解决办法是,可以直接调用类中的函数,指定要格式化哪个数据成员。比如CStdString类,调用CStdString::GetData接口,指定CStdString::m_pstr成员参与格式化。

PS:最后附上函数调用栈分布图:(函数A调用函数B)

相关文章
相关标签/搜索