一个bug引起的思考
最近碰到一个bug,是在一个log模块,在使用vsprintf_s
函数时发生access deny错误。奇怪的是在Debug模式下没有问题,切换到Release模式下就会重现。我把问题简单化后的代码如下:
1 |
|
代码很简单。使用vs2017 在debug模式下编译,成功运行,当然输出的字符串有问题,先暂时忽略。在release下直接报如下的错误
当然了,因为上面的代码是简化后的代码,所以直接看输出就能定位到是变长参数带来的内存访问问题。
我在debug实际代码的过程中,开始并没有发现问题的根源。而是纠结在为什么debug模式能work而release不行。于是面向stackoverflow编程(Common reasons for bugs in release version not present in debug mode)。在试过了修改release模式下的配置,使得尽量与debug下一致(比如使用相同的Runtime Library,不启用优化等),无果。后来看到一个答案,大致意思是, 除了性能上的影响之外,debug和release模式配置无影响,debug working而release not working的原因很可能是bug其实一直都在,只是在debug下没有注意到一些细节,应该去分析代码。
再次去debug下分析代码,单步调试到vsprintf_s,发现格式化输出的字符串有一个变量明显不对(为什么开始没有注意到!!!),类似上面的简单化代码的输出效果。定位到问题就好了,这个应该是变长参数传递的锅。再次面向stackoverflow编程(Trouble with va_list c++)。
you can’t portably extract
std::string
from variadic function arguments. Only trivial types are fully supported, and for strings you have to usechar*
.std::string
is not trivial, because it has non-trivial constructor and destructor. Some compilers do support non-trivial types as arguments for such functions, but others do not, so you shouldn’t try this.
The last, but not the least: variadic functions have no place in C++ world, even for assignments.
大致意思是在可变参数传递中只支持简单类型(trivial types), std::string
传递会有问题。果不其然,查看call stack的调用关系,其中有一步,变长参数传递中,有一个参数直接传递的std::string
类型。将其修改调用string的c_str()
方法,问题解决。
这个问题解决了,心里有了另外的问题,变长参数是如何实现的呢?
c中的变长参数实现
C语言中提供了变长函数声明,使用省略号...
(ellipses)表示参数是可变的。通过位于stdarg.h
头文件中定义的三个宏va_start, va_end, va_args
和一个类型va_list
来实现。先看个例子。
1 |
|
其中test_sum
定义中第一个形参为后面的参数个数,第二个形参为可变参数。之所以需要第一个形参,是因为在while
循环中去通过va_arg
去取得参数。那么问题来了,函数的参数是如何传递的呢?
可参考我的另外一边文章: 什么是调用惯例。
在C中通过cdecl方式,参数是从右至左的顺序入栈。以test_sum(3,1,2,3)
调用为例,参数入栈后结果示意。注意,栈是高地址向低地址增长的。
address | stack |
---|---|
high_addr | 3 |
2 | |
1 | |
3 | |
low_addr(top) | ret_addr |
下面来看下几个宏定义的声明和定义。摘自MSDN
1 | type va_arg( |
定义摘录自stdarg.h
和vadefs.h
。
1 | typedef char* va_list; |
从源码可以看出:
va_list
只是char*
的别称而已。va_start
的实现有使用了_INTSIZEOF
,_ADDRESSOF
两个宏定义。先不管,望文生义结合va_start
的声明,可以推测va_start
是通过可变参数中前一个参数的地址得到第一个可变参数地址。va_arg
通过va_list
指针获取下一个参数的地址,并转化为相应的typeva_end
将va_list
置为空。
接下来看下_INTSIZEOF
, _ADDRESSOF
两个宏定义。
1 |
_ADDRESSOF
很明显就是取地址_INTSIZEOF
是对int类型所占字节数进行对齐。
举个例子
1 | void test_INTSIZEOF() { |
字符串结尾的’\0’字符也会被记为一个字节长度。由例子很容易理解_INTSIZEOF
宏的作用。
那么问题又来了,为什么要做这个字节对齐处理呢?简单的说,因为参数在入栈的时候就是这样存放的,更详细的理解,请参考wiki: Data structure alignment
其实变长参数我们遇到最多的应该是printf
了。在格式化一些输出时,printf是如何知道参数的个数呢?在上面的演示中,我们是手动传递了参数个数,作为第一个参数的。
答案就在于格式化字符中。例如"%s age is %d"
,通过这个格式化字符串,可以知道需要两个参数, 类型分别是”%s”与”%d”。
c++中变长参数实现
根据stackoverflow上的回答
The last, but not the least: variadic functions have no place in C++ world, even for assignments.
c语言中对变长参数的实现在C++中是有更modern的实现的.所以我有对C++中的实现做了一番总结.主要参考了《C++ PRIMER 5TH Edition》。以下基于C++ 11 标准。
使用initializer_list
对于可变参数是同一类型,可以使用C++11新增的一个类型initialize_list
。举例如下:
1 |
|
使用可变参数模板template<typename… Args>
在C++中可变参数被称为参数包(parameter package), 存在两种:模板参数包,表示零个或多个模板参数;函数参数包,表示零个或多个函数参数。举例如下:
1 | //Args 是模板参数包,args是函数参数包 |
可变参数函数通常是递归的。第一部调用处理包中的第一个实参,然后用剩余实参调用自身。举个例子:
1 | template<typename T> |
当我们需要知道包中有多少元素时,可以使用sizeof...
运算符。
对于一个参数包,在模板实例化的时候,是对包里的每一个元素进行扩展。包扩展(package expend)是对省略号前面的pattern进行扩展。比如上面的foo声明。
1 | //declaration |
编译器通过类型推断,分别实例化生成对应的声明:
1 | //参数包const Args&... 被扩展,可以理解为此时的pattern为const Args& |
心得
在troobleshooting的过程中还是要多注意细节,多观察一些预期的变量是否正确。当然了,c/c++的知识还要加强啊。