程序调试手记—解决Stack Overflow问题

前言

程序员最痛苦的事莫过于深陷于BUG的泥潭,我也没少在这上面摔跤。这里,我把自己的一些经验教训总结出来,涉及的内容包括死循环、死锁、内存泄漏以及内存访问错误等,如果能对朋友们有所帮助,那就再好不过了。不过,我不打算按照循序渐进的方式来撰写这些文章 ,而是想到哪写到哪,也许到最后才会形成一个完整的系列。

本节将以一个真实例子讲述如何在VC6环境下调试“Stack Overflow”错误。

问题浮现

我负责维护前任同事的开发的一个DLL,这是一个用于网络通信的中间件,今天在应用程序断开与服务器的连接时突然报错,而且屡试不爽。调试发现出错总是在一个类的析构函数中出现,其类似代码如下:

class Bar
{
public:
    ~Bar()
    {
        stringstream ss;
        ss << "~Bar" << 123;
        cout << ss.str(); // 这里 出错
    }
};

出错时的函数调用栈为:

memcpy(unsigned char * 0x1da822a9, unsigned char * 0x00000000, unsigned long 1) line 331
std::char_traits<char>::copy(char * 0x1da822a9, const char * 0x00000000, unsigned int 1) line 194 + 20 bytes
std::basic_string<char,std::char_traits<char>,std::allocator<char> >::assign(const char * 0x00000000, unsigned int 1) line 134 + 20 bytes
std::basic_string<char,std::char_traits<char>,std::allocator<char> >::basic_string<char,std::char_traits<char>,std::allocator<char> >(const char * 0x00000000, unsigned int 1, const std::allocator<char> & {...}) line 48 + 43 bytes
std::basic_stringbuf<char,std::char_traits<char>,std::allocator<char> >::str() line 36 + 73 bytes
std::basic_stringstream<char,std::char_traits<char>,std::allocator<char> >::str() line 262 + 31 bytes

可以看出,basic_stringbuf在执行str()时,将NULL指针传递给了basic_string的构造函数,最终导致后来的memcpy出错,而理论上这里的指针是不该为NULL的。尽管VC6使用的STL库存在不少问题,但这样的代码无论如何也不该出错啊!难道是内存被改写?但从其它成员变量的值看这种可能性很小。

疑点重重,为了弄清真相,我写了前面那段代码对STL进行调试和分析。地球人都知道P.J. Plauger这家伙写的STL那叫一个乱,搞不懂他都是怎样记那些变量名的,代码排版也是一个团糟。花了半个上午,总算弄明白了,结论是STL没有问题,为此我还动用了google以确认有没有人存在同样的问题。

这下让我更加郁闷,如果是内存覆盖那就非常麻烦了,因为根据以往的经验,要在这样一个庞大的多线程程序中找出是哪个地方改写了内存那简直是比登天还难。(后来证明我在此犯了一个小错误,那就是忽略了函数调用栈的深度)

出现转机

事情到后来出现了转机,同样的程序在另外一个同事的机器上报错时给出了一条重要信息:“Unhandled exception xxx.exe: 0xC00000FD: Stack Overflow.”。

这相当于指明了问题所在,不过我还是有一点小小的疑问,为什么我的机器上没有这样的错误提示呢? 没有提示,或许是系统环境的问题,我有办法让问题出现,于是F5运行程序,选择菜单“Debug/Exceptions”,在列表框中找到“Stack Overflow”,Action改为“Stop always”,如下:

在执行同样的操作后,果然出现了“Stack Overflow”异常。

接下来,我便把注意力集中到函数调用栈的分析上。按快捷键Alt+F7调出Call Stack窗口(或者通过菜单“View/Debug Windows/Call Stack”进行选择),看得出调用栈确实很深,由于窗口显示的内容有限,都找不到第一个函数了。

发现问题

刚开始以为是存在死循环,但仔细查看后并没有发现问题,程序的的执行很正常。再一次对调用栈和相关代码进行深入分析后,我终于发现了端倪。原程序使用了boost里的智能指针来构建消息队列,当消息队列过长时,最后析构的时候就会造成调用层次过深,为此,我又编写了如下一段代码作测试:

#include <iostream>
using namespace std;

#include <boost/shared_ptr.hpp> // 使用boost库的智能指针
using namespace boost;

struct Message
{
    Message(int index = 0)
    {
        m_index = index;
    }
    ~Message() // 析构函数
    {
        cout << "~Message: " << m_index << endl;
    }
    
    shared_ptr<Message> m_pNext; // 指向消息队列中的下一个消息
    int m_index;
};


int main(int argc, char* argv[])
{
    shared_ptr<Message> pHead = shared_ptr<Message>( new Message(0) );
    shared_ptr<Message> pCur = pHead;
    for(int i=1; i<2000; ++i) // 这里构建一个长度为2000的消息队 列
    {
        shared_ptr<Message> pNext = shared_ptr<Message>(&n bsp;new Message(i) );
        pCur->m_pNext = pNext;
        pCur = pNext;
    }
    return 0;
}

编译后F5运行,“Stack Overflow”果然出现,这时我的心便一下轻松下来。

如果不亲自试一试,你很难察觉上面的代码有什么问题。 事实上,这确实是一段很正常的C++程序,从逻辑上分析绝对没有问题,那错误是如何发生的呢?

这得从C++的析构函数说起,先回顾一下以下知识:

1. 析构函数会在对象生命期结束时被自动调用。
2. 含有成员变量的类在自身析构函数调用结束后,将按照成员变量声明顺序的逆序依次调用其析构函数。
3. 具有继承体系的类在自身析构函数调用结束后,将按照基类声明顺序的逆序依次调用其析构函数。

再来分析前面的代码,当main函数执行到return 0时,智能指针pCur的析构函数会被调用,这没有什么问题,接着pHead的析构函数被调用,由于这时pHead所指向的Message(1)的引用计数降为0,所以它会被释放,因而其析构函数被调用,根据规则2,接下来 Message(1).m_pNext会析构,这样便开始了消息队列的遍历,下面是一个周期的函数调用栈:

Message::~Message() line 53 + 8 bytes
Message::`scalar deleting destructor'(unsigned int 0x00000001) + 37 bytes
boost::checked_delete(Message * 0x0044e920) line 34 + 28 bytes
boost::checked_deleter<Message>::operator()(Message * 0x0044e920) line 52 + 9 bytes
boost::detail::sp_counted_base_impl<Message *,boost::checked_deleter<Message> >::dispose() line 265
boost::detail::sp_counted_base::release() line 147 + 13 bytes
boost::detail::shared_count::~shared_count() line 382
boost::shared_ptr<Message>::~shared_ptr<Message>() + 40 bytes

这一切都是那么合情合理,但是我们不能忽视函数栈空间的大小。学过编译原理就知道,函数栈空间是用于存放局部变量、函数返回地址以及函数参数等数据的内存区域,其大小是有限的(VC6默认是1MB)。当局部变量占用空间太大,或者函数调用层次太深就会出现“Stack Overflow”的情况。最经常出现的错误有以下两种:

1. 局部数组变量空间太大,如下:

int main(int argc, char* argv[])
{
    char stack_overflow[1024*1024*5];
    stack_overflow[0] = 1;
    return 0;
}

解决这类问题的办法有两个,一是增大栈空间(后文中有详细描述),二是改用动态分配,使用堆(heap)而不是栈(stack)。

2. 函数出现无限递归调用,如下:

void infinite_loop()
{
    infinite_loop();
}

int main(int argc, char* argv[])
{
    infinite_loop();
    return 0;
}

实际应用中,谁也不会直接编写如此愚蠢的代码,但往往由于粗心大意或者函数间的相互调用而造成了这样的结果。解决的办法当然是消除BUG。

解决办法

回过头来,再来看我们的问题,其原因主要还是由于忽略了boost智能指针在析构时的影响而造成的(这确实容易容忍忽视)。明白了问题所在,解决问题的方法便很多,如下:

1. 增大栈空间

调出“Project/Settings/Link”选项卡,选择Output,其中的Stack allocations的reserve值便是栈空间所用大小(见下图),VC6中默认为1MB,根据实际情况将其加大然后重新编译即可,具体说明可参见MSDN中的/stack选项。这个方案对于一些问题来说简单可行,但不能满足我这里的需求。

这里再多提一点,增大栈空间还有一个更简单的方法,那就是使用VC附带的EDITBIN工具,它可以直接增大可执行程序的栈空间,而不用重新编译程序,其使用方法如下:

EDITBIN /STACK:reserve[,commit] [files]

2. 限制队列长度

这个方法不能满足我的应用需求,因此不可行。

3. 改用其它方式实现消息队列

即不使用boost::shared_ptr,改用原始指针或者std::list来构建消息队列。但我的程序的模型比前面给出的测试代码要复杂得多,牵扯到其它方面的因素,因此该方法也不太可行。

4. 断开消息队列

这是我最后采用的方案,析构时的迭代之所以会发生,是源于boost::shared_ptr的引用计数原理,只要将消息链断开就不会有这样的问题 。针对前面的测试例子,只要在return 0前面加上如下代码即可避免“Stack Overflow”:

    pCur = pHead;
    for(i=1; i<2000; ++i) // 依次断 开消息链
    {
        pHead = pCur->m_pNext;
        pCur->m_pNext.reset();
        pCur = pHead;
    }

总结

本节内容虽然是根据一个实际例子提出的,但是其解决方法还是具有一定一般性,比如通过Link选项增大栈空间以及使用EDITBIN增大可执行文件栈空间等。

另外一个收获就是要特别注意对智能指针构建的队列的析构处理。

原文地址:https://www.cnblogs.com/buffer/p/1408411.html