一、案例背景
在参与某一项目开发中,发现客户端存在一个偶现的崩溃问题;查看dump信息,只保存了使用VS2008运行程序崩溃时的STMSGEVENT结构变量信息,STMSGEVENT结构中包含的szExtInfo结构信息中strText信息异常,strText是std::string类型的,strText正常情况下应该存在有效内容的。通过进一步分析发现,在客户端代码中,STMSGEVENT申明处附近的结构体频繁使用EPS_ZERO_INIT_OBJECT()宏初始化结构体,EPS_ZERO_INIT_OBJECT()是一个宏定义,实质调用了memset(this, 0, sizeof(*this))去完成初始化操作。
下面是STMSGEVENT与STTEXT结构体、EPS_ZERO_INIT_OBJECT()宏定义:
typedef struct __stMsgEvent
{
STTEXT szExtInfo;
….
__stMsgEvent(void)
{
EPS_ZERO_INIT_OBJECT();
}
}STMSGEVENT;
typedef struct __stText
{
std::string strText;
int iTextSize;
…
}STTEXT;
#define EPS_ZERO_INIT_OBJECT() \
memset(this, 0, sizeof(*this))
分析到这里,稍有经验的C++程序员就会发现问题所在。对于包含string变量的结构体,使用memset初始化结构体会破坏string对象的内存信息,最终导致内存泄露和程序异常。下面就对该现象导致程序异常做详细的分析和验证。
二、浅析对象
注意:以下程序均是在windows7下使用VS2013的Debug/win32模式编译验证。
1.memset简介
memset 函数是内存赋值函数,用来给某一块内存空间进行赋值的。其原型是:
void* memset(void *_Dst, int _Val, size_t _Size);
参数解释:_Dst是目标起始地址,_Val是要赋的值,_Size是要赋值的字节数。
函数解释:将_Dst中当前位置后面的_Size个字节(typedef unsigned int size_t)用_Val 替换,并返回_Dst。
memset函数的作用是在一段内存块中填充某个给定的值,它是对较大的结构体或数组进行清零操作的一种最快方法。
注意:memset是逐字节拷贝的。
2.std::string简介
查看std::string 类型C++源码,std::string类来源于typedef basic_string<char, char_traits<char>, allocator<char> > string;
basic_string类型的继承关系如下:
class basic_string à class _String_alloc à class _String_val à struct _Container_base12;
std::string的成员变量是:
1)struct _Container_base12结构中的_Container_proxy *_Myproxy
_Container_proxy结构定义如下:
struct _Container_proxy
{ // store head of iterator chain and back pointer
_Container_proxy()
: _Mycont(0), _Myfirstiter(0)
{ // construct from pointers
}
const _Container_base12 *_Mycont;
_Iterator_base12 *_Myfirstiter;
};
其中的_Myfirstiter结构为:
struct _Iterator_base12
{ // store links to container proxy, next iterator
…
_Container_proxy *_Myproxy;
_Iterator_base12 *_Mynextiter;
};
这个结构体非常类似于一个链表,存储不同的_Container_proxy对象的指针,管理的操作是标准STL中的迭代器对象。
2)class _String_val中的联合体_Bx
union _Bxty
{ // storage for small buffer or pointer to larger one
value_type _Buf[_BUF_SIZE];
pointer _Ptr;
char _Alias[_BUF_SIZE]; // to permit aliasing
} _Bx;
并且在class _String_val中申明了
enum
{ // length of internal buffer, [1, 16]
_BUF_SIZE = 16 / sizeof (value_type) < 1 ? 1
: 16 / sizeof (value_type)};
value_type为char类型,所以_BUF_SIZE为16。
3)class _String_val中的size_type _Mysize
4)class _String_val中的size_type _Myres
5)std::string对象占内存大小
上面已经了解了std::string类型存在4个继承的成员,首先是_Myproxy指针对象,在32位环境下,占4字节大小;再者是_Bx联合体对象,联合体占内存大小取决于它所有的成员中占用空间最大的一个成员的大小,应该占16字节;另外_Mysize与_Myres分别占用4字节大小,std::string对象大小为28字节。
【代码验证】
#include <string>
#include <iostream>
int main()
{
std::cout << sizeof(std::string) << std::endl;
return 0;
}
结果:28
6)std::string对象内存信息
【代码验证】
#include <string>
#include <iostream>
int main()
{
std::string str = "123456789"; // 1
return 0; // 2
}
上述代码执行到步骤2时,查看VS局部变量窗口,如下图2,可以看到str对象的起始地址为0x0045FE98;
图2 std::string对象局部变量信息
查看str对象内存信息,如下图3:
图3 std::string对象内存信息
图3可以看出std::string成员的内存分布,_Myporxy的内存地址为0x2500c8,字符串内容就保存在str对象的联合体_Buf[_BUF_SIZE]对象中,字符串的大小为_Mysize=9,因为需要预留’\0’结尾字符一个字节,当前总共可用的字符串空间为_Myres=15。
注意:windos7系统采用的是小端模式的内存结构。
3.存储字符串机制
在std::string的简介中,介绍了联合体成员_Bx,而字符串存储和它息息相关,通过分析和调试std::string源码,可以知道,在申明std::string对象时,构造函数会调用
void _Tidy(bool _Built = false, size_type _Newsize = 0)成员函数,使
this->_Myres = this->_BUF_SIZE - 1;
即对象的初始_Myres值为15,那么在首次赋值字符串给对象时,会调用
_Myt& assign(const _Elem *_Ptr, size_type _Count)
{ // assign [_Ptr, _Ptr + _Count)
#if _ITERATOR_DEBUG_LEVEL == 2
if (_Count != 0)
_DEBUG_POINTER(_Ptr);
#endif /* _ITERATOR_DEBUG_LEVEL == 2 */
if (_Inside(_Ptr))
return (assign(*this,
_Ptr - this->_Myptr(), _Count)); // substring
if (_Grow(_Count))
{ // make room and assign new stuff
_Traits::copy(this->_Myptr(), _Ptr, _Count);
_Eos(_Count);
}
return (*this);
}
成员函数,在深度执行调用过程过程中,
bool _Grow(size_type _Newsize, bool _Trim = false)函数中会执行
if (this->_Myres < _Newsize)
_Copy(_Newsize, this->_Mysize); // reallocate to grow
这里判断字符串的大小是否大于_Myres;
1)当字符串大小小于_Myres时,不会执行
_Copy(_Newsize, this->_Mysize); // reallocate to grow
会返回到在Myt& assign(const _Elem *_Ptr, size_type _Count)函数中,继续如下调用:
_Traits::copy(this->_Myptr(), _Ptr, _Count);
其中
value_type *_Myptr()
{ // determine current pointer to buffer for mutable string
return (this->_BUF_SIZE <= this->_Myres
? _STD addressof(*this->_Bx._Ptr)
: this->_Bx._Buf);
}
会返回对象中可用于存储字符串的首地址,这里的
this->_BUF_SIZE <= this->_Myres
条件不成立,返回的是_Buf[_BUF_SIZE]数组地址,_Ptr为字符串的临时地址,_Count为字符串的大小。
该操作会调用
void *memcpy(void *dest, const void *src, size_t n);
直接将字符串拷贝到_Bx联合体的_Buf[_BUF_SIZE]数组中存储。
可以参考std::string简介中的代码验证以及str对象的内存信息。
2)当字符串大小大于等于_Myres时,会先执行
_Copy(_Newsize, this->_Mysize); // reallocate to grow
即调用void _Copy(size_type _Newsize, size_type _Oldlen)函数去执行
_Ptr = this->_Getal().allocate(_Newres + 1);
再往深的调用中会调用_Ty *_Allocate(size_t _Count, _Ty *)该函数去执行
_Ptr = ::operator new(_Count * sizeof (_Ty))
也就申请了新的存储空间,并且该内存大小是_BUF_SIZE的整数倍,并且该内存大小大于等于字符串大小。
再回到void _Copy(size_type _Newsize, size_type _Oldlen)函数中执行
this->_Getal().construct(&this->_Bx._Ptr, _Ptr);
将申请到的_Ptr新内存地址放置到_Bx._Ptr中。
接着执行
this->_Myres = _Newres;
更新对象可用的内存空间大小。
【代码验证】
#include <string>
#include <iostream>
int main()
{
std::string str = "123456789abcdefghlijklmnopqrst"; // 1
return 0; // 2
}
在执行进入步骤1时,查看str对象的起始地址为0x0017FA88,可以单步调试std::string源码进入_Myt& assign(const _Elem *_Ptr, size_type _Count)成员函数中,当执行
this->_Getal().construct(&this->_Bx._Ptr, _Ptr);
完成后,可以看到str对象_Bx联合体所在地址0x0017FA8C中保存了新申请的内存空间地址0x00610100,如下内存图4:
图4 申请内存空间
继续单步调试,会执行到
_Traits::copy(this->_Myptr(), _Ptr, _Count);
此时在value_type *_Myptr()的调用中
this->_BUF_SIZE <= this->_Myres
条件成立,会返回联合体_Bx的_Ptr内容,即新申请的地址;
那么后续会调用void *memcpy(void *dest, const void *src, size_t n);
将字符串拷贝到_Bx联合体的_Ptr指向的地址空间中存储,这里改地址为0x00610100,查看该地址的内存信息,如下图5,该地址起始的30个字节中保存了与代码中初始化一致的字符串。
图5 拷贝字符串至申请的地址空间
当程序执行完步骤1时,可以查看如下内存图6,str对象当前字符串大小为30字节,可用有效的空间为31字节。
图6 更新标识空间大小变量
4._Myproxy指针申请内存
在std::string的简介中了_Myproxy的指针对象,该对象包含了const _Container_base12 *_Mycont与_Iterator_base12 *_Myfirstiter两个指针对象,我们知道在32位机下指针本身是占4字节内存的,那么给_Myproxy分配内存按理说就是8字节大小。
通过分析源码,在std::string对象申明时,会先调用其基类class _String_alloc构造函数,在其深度调用过程中会调用class _String_alloc的void _Alloc_proxy()成员函数
void _Alloc_proxy()
{ // construct proxy from _Alval
typename _Alloc::template rebind<_Container_proxy>::other
_Alproxy;
this->_Myproxy = _Alproxy.allocate(1);
_Alproxy.construct(this->_Myproxy, _Container_proxy());
this->_Myproxy->_Mycont = this;
}
在this->_Myproxy = _Alproxy.allocate(1)的深度调用中会调用_Ty *_Allocate(size_t _Count, _Ty *)该函数去执行
_Ptr = ::operator new(_Count * sizeof (_Ty))
这里_Count为_Alproxy.allocate(1)传进的参数1,_Ty为std:: _Container_proxy类型,所以分配的内存是8字节大小。
可以使用std::string存储字符串机制中的代码验证,当std::string对象初始化调用void _Alloc_proxy()函数执行完this->_Myproxy = _Alproxy.allocate(1)时,可以查看内存图7,0x0018FDA4为str对象的起始地址,新申请到的_Myproxy地址为0x003400B8。
图7 _Myproxy申请地址空间
当调试代码执行this->_Myproxy->_Mycont = this时,查看0x003400B8的内存信息如下图8所示,可以看到_Myproxy对象的_Mycont指向的就是str对象地址0x0018FDA4,_Myfirstier仍然为NULL。
图8 _Myproxy内存信息
在str对象完成初始化和赋值后,对比上面初始化str对象时内存信息与局部变量信息,如下图9。
图9 _Myproxy局部变量地址信息
5.std::string对象析构
C++析构函数是先调用子类的析构函数,再调用基类的析构函数。
1)先调用~basic_string()
~basic_string() _NOEXCEPT
{ // destroy the string
_Tidy(true);
}
在~basic_string()函数中再调用
void _Tidy(bool _Built = false,
size_type _Newsize = 0)
{ // initialize buffer, deallocating any storage
if (!_Built)
;
else if (this->_BUF_SIZE <= this->_Myres)
{ // copy any leftovers to small buffer and deallocate
pointer _Ptr = this->_Bx._Ptr;
this->_Getal().destroy(&this->_Bx._Ptr);
if (0 < _Newsize)
_Traits::copy(this->_Bx._Buf,
_STD addressof(*_Ptr), _Newsize);
this->_Getal().deallocate(_Ptr, this->_Myres + 1);
}
this->_Myres = this->_BUF_SIZE - 1;
_Eos(_Newsize);
}
可以看到,该函数中通过this->_BUF_SIZE <= this->_Myres条件去判断是否需要释放this->_Bx._Ptr的内存。
2)接着调用~_String_alloc()
~_String_alloc() _NOEXCEPT
{ // destroy the object
_Free_proxy();
}
在该函数中在调用
void _Free_proxy()
{ // destroy proxy
typename _Alloc::template rebind<_Container_proxy>::other
_Alproxy;
this->_Orphan_all();
_Alproxy.destroy(this->_Myproxy);
_Alproxy.deallocate(this->_Myproxy, 1);
this->_Myproxy = 0;
}
可以看到该函数完成对_Myproxy内存的释放。
三、异常分析
1.memset初始化std::string对象
上述已经对std::string申明对象过程中,为对象分配内存做了详细的介绍,接下来就看看memset初始化std::string对象带来的影响。
1)使用memset初始化std::string对象
【代码验证】
#include <string>
#include <iostream>
int main()
{
std::string str; // 1
memset(&str,0,sizeof(std::string)); // 2
str = "123456789"; // 3
std::cout << str << std::endl; // 4
return 0; // 5
}
执行完步骤2时,查看str的起始地址为0x001EF790,查看str的内存信息,如下图10:
图10 memset后的str对象内存
由图10可以看出,memset操作确实将str对象所在内存全部清零了。但是值得注意的是,str对象包含的_Myporxy指针地址为0,即NULL,也就是变成了野指针,同时把指明当前可用大小_Myres变量也给清空了,那么这里是否是问题的中心呢?后面慢慢分解哈。
再看看执行到步骤4的str内存图11:
图11 str对象内存信息
图11可以看出,虽然memset初始化了str对象,但在给str赋值时,除了_Myporxy指针地址为NULL外,其他均是正常的,而且在步骤4的打印输出中,也正常输出了结果,难道真的没有其他影响?
我们再回来分析一下,由上面的std::string存储字符串机制可以知道,在字符串赋值时会调用bool _Grow(size_type _Newsize, bool _Trim = false)函数,该函数会执行
if (this->_Myres < _Newsize)
_Copy(_Newsize, this->_Mysize); // reallocate to grow
_Newsize的值就是字符串的大小,验证的代码中,该值应该为9,然而str对象被memset初始化后,其this->_Myres也被清零,而不是申明对象时的15了,那么结果就导致这一步条件判断通过,执行了
_Copy(_Newsize, this->_Mysize); // reallocate to grow
由std::string存储字符串机制可以知道,下面会进行新内存的分配,并且继续执行
this->_Myres = _Newres;
将this->_Myres置为15。
这里应该分配16字节的内存,那么为什么字符串还是被保存在了_Bx联合体的_Buf[_BUF_SIZE]中了?
原因是后续执行了
_Traits::copy(this->_Myptr(), _Ptr, _Count);
此时在value_type *_Myptr()的调用中
this->_BUF_SIZE <= this->_Myres
条件仍然不成立,就会返回联合体_Bx的_Buf[_BUF_SIZE]数组地址,而后续会调用
void *memcpy(void *dest, const void *src, size_t n);
直接将字符串拷贝到_Bx联合体的_Buf[_BUF_SIZE]数组中存储。
也就是新申请到的16字节内存没有使用,当赋值操作全部完成后,this->_Mysize和this->_Myres均恢复了正常。
那么我们来看看当离开str所属作用域时,std::strring析构函数是否能够释放申请的内存呢。
在std::string对象析构中可以知道,在析构std::string对象时,void _Tidy(bool _Built = false, size_type _Newsize = 0)函数也是通过
this->_BUF_SIZE <= this->_Myres
条件去判断是否需要释放this->_Bx._Ptr的内存,由上可知,该条件不成立,那么新申请的内存就得不到释放,会导致内存泄露。
当然,这种异常情况是针对赋值的字符串大小小于16时才会发生。
在void _Free_proxy()函数执行销毁_Myporxy内存时,因为_Myporxy已经为NULL,那么开始在构造函数执行分配给_Myporxy的内存也得不到释放,也导致内存泄露。
通过上面的分析,可以知道使用memset初始化std::string对象会直接带来两方面异常:
使_Myporxy指针地址变成了野指针,在析构时,原本_Myporxy指向的内存得不到释放;
字符串大小小于16时,也会申请新的内存,但新申请的内存不被使用,也得不到释放。
2.memset初始化std::string对象导致迭代器异常
【代码验证】
#include <string>
#include <iostream>
int main()
{
std::string str; // 1
memset(&str, 0, sizeof(std::string)); // 2
str = "123456789"; // 3
std::string::iterator it = str.begin(); // 4
if (it != str.end()) // 5
{
std::cout << *it << std::endl; // 6
}
return 0; // 7
}
由memset初始化std::string对象分析可知,执行完步骤2,str对象的_Myproxy指针已经被置为NULL了,顺利通过了步骤4,当执行步骤5时会出现如下图12错误提示:
图12 迭代器异常
错误提示指出,在VS源码文件xstring文件的第250行存在字符串迭代器异常。
如下void _Compat(const _Myiter& _Right) const即为错误所指提示信息位置:
void _Compat(const _Myiter& _Right) const
{ // test for compatible iterator pair
if (this->_Getcont() == 0
|| this->_Getcont() != _Right._Getcont())
{ // report error
_DEBUG_ERROR("string iterators incompatible");
_SCL_SECURE_INVALID_ARGUMENT;
}
}
可以看到,发生report error错误是由于条件判断引起;
const _Container_base12 *_Getcont() const
{ // get owning container
return (_Myproxy == 0 ? 0 : _Myproxy->_Mycont);
}
原来_Getcont()是返回了_Myproxy = NULL。
这是windows Debug模式下,对于类似的操作,VS会直接给出错误提示。
如果在linux下运行这样的程序,就会出现段错误。
3.检测memset初始化std::string对象导致的内存泄漏
1)检测内存泄漏方法
这里先介绍一下windows下VS运行C/C++工程检测内存泄漏的方法。
Visual Studio 调试器和 C 运行时 (CRT) 库为我们提供了检测和识别内存泄漏的有效方法。VC++ IDE 的默认状态是没有启用内存泄漏检测机制的,也就是说即使某段代码有内存泄漏,Output 窗口也不会输出有关内存泄漏信息。必须手动设定两个最基本的机关来启用内存泄漏检测机制。
一是包含调试堆函数库文件:
#include<crtdbg.h>
通过包含crtdbg.h 头文件,可以将 malloc 和 free 函数映射到其“调试”版本 _malloc_dbg 和 _free_dbg,这些函数会跟踪内存分配和释放。此映射只在调试(Debug)版本(也就是要定义_DEBUG)中有效。
二是在需要检测内存泄漏的地方添加下面这条语句来输出内存泄漏信息:
_CrtDumpMemoryLeaks();
_CrtDumpMemoryLeaks()是打印当前的内存泄漏。注意是“当前”,也就是说当它执行时,所有未销毁的对象均会报内存泄漏。因此尽量让这条语句在程序的最后执行。它所反映的是检测到泄漏的地方。当在调试器下运行程序时,_CrtDumpMemoryLeaks将在Output窗口的 Debug页中显示内存泄漏信息,一般是已Detected memory leaks!开头的部分信息。
最好在代码中增加
#ifdef _DEBUG
#define new new(_NORMAL_BLOCK, __FILE__, __LINE__)
#endif
宏定义,可以输出内存泄漏具体的文件位置信息。
【代码验证】
#include<crtdbg.h>
#ifdef _DEBUG
#define new new(_NORMAL_BLOCK, __FILE__, __LINE__)
#endif
int main()
{
int* p = new int[10];
_CrtDumpMemoryLeaks();
return 0;
}
调试输出窗口输出信息如下图13:
图13 输出内存泄漏信息
上图中指出在main.cpp文件第7行存在申请的起始地址为0x005400B8的40字节堆内存泄漏。
其实也可以在不直接调用_CrtDumpMemoryLeaks()函数的情况下,在main()函数一开始位置通过调用_CrtSetDbgFlag函数设置_crtDbgFlag为_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF,这样系统将在程序结束的时候,自动调用_CrtDumpMemoryLeaks()函数,打印出所有内存泄露的信息。
【代码验证】
#include<crtdbg.h>
#ifdef _DEBUG
#define new new(_NORMAL_BLOCK, __FILE__, __LINE__)
#endif
int main()
{
_CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF);
int* p = new int[10];
return 0;
}
打印的内存泄漏信息与图13是一致的。
另外,在图13中,内存泄漏信息除了打印该内存泄漏的内存分配语句所在的文件名、行号外,我们注意到有一个比较陌生的信息:{147}。这个整数值代表了什么意思呢?其实,它表示block number,泄露内存所在的块号。这个例子,{147}代表了块号为147的内存发生了泄漏。
我们可以在分配块号{147}的内存时,程序中断。以此来定位内存泄露的大概位置。那么就需要在内存分配之前调用_CrtSetBreakAlloc(long lBreakAlloc)该函数来设置断点。
【代码验证】
#include<crtdbg.h>
#ifdef _DEBUG
#define new new(_NORMAL_BLOCK, __FILE__, __LINE__)
#endif
int main()
{
_CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF);
_CrtSetBreakAlloc(147);
int* p = new int[10];
return 0;
}
在运行上述程序时,会等待中断。
2)检测memset初始化std::string对象时内存泄漏
【代码验证】
#include<crtdbg.h>
#include <string>
#ifdef _DEBUG
#define new new(_NORMAL_BLOCK, __FILE__, __LINE__)
#endif
int main()
{
_CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF);
std::string str;
memset(&str,0,sizeof(std::string));
str = "123456789";
return 0;
}
先使用对std::string模块分析,单步调试源码,记录出几个关键的地址信息为:
str对象的起始地址为:0x001EFB3C
开始_Myproxy指针对象指向的地址为:0x005A00B8
赋值字符串时新申请的内存地址为:0x005A0100
下图14是打印的内存泄漏信息,内存泄漏的地址、大小与上述分析是一致的。
图14
四、方案总结
1.memset初始化std::string对象小结
上述已经对使用memset初始化std::string对象发生的异常做了较详细的分析和验证。总结起来就是,memset初始化std::string对象时,按字节顺序将std::string对象所在空间清零,破坏了std::string对象的成员结构,使Myroxy指针资源变为野指针,操作其迭代器会出现不可预知的异常,在存储字符串时可能分配了不必要的内存空间,并且还导致这些内存不能被释放。
2.举一反三
回到memset函数,它设计的初衷是对结构资源连续的空间进行赋值操作,而对于较为复杂和抽象的类型,包括我们自定义的各种业务类型,这些类型中可能存在多层继承关系,那么这些结构资源中就隐藏着虚函数表、虚指针,对于多层聚合、组合的类型也会存在自定义类型的指针对象,这些资源都可能不是连续的空间去存储,如果使用memset去初始化这类资源,自然是违背了memset设计初衷,导致程序出现不可预知的异常。
除了memset函数外,在使用memcpy、_memccpy、memove这些函数操作上述的资源时,也需要注意类似的异常发生。
这里只引出思考,不做详细的验证。