0


将string类对象中的内容格式化到字符串Buffer中时遇到的异常崩溃分析

C++软件异常排查从入门到精通系列教程(专栏文章列表,欢迎订阅,持续更新...) https://blog.csdn.net/chenlycly/article/details/125529931 我们在将变量中的数据格式化到目标字符串buffer中时,可能会遇到崩溃问题,今天我们就来讲述这类问题的一个实例,分享一下问题的排查过程。

1、概述

   将变量的值格式化到字符串buffer中,是代码中常有的事,比如要将变量的值打印到日志中,需要将变量的值先格式化到字符串buffer中,然后调用写日志接口,将字符串打印出来。例如如下的格式化代码:
int nVal = 1;
char* pSzString = "this is a test";
char szDstBuf[512] = { 0 };
sprintf( szDstBuf, "[Print|info] nVal = %d, szString : %s", nVal, pszString );
   在将变量的值格式到字符串buffer中时,如果格式化符与变量类型不一致,可能会引发内存访问违例,导致软件崩溃。比如一个**int整型的变量**(保存了一个很小的整数),结果错误地对应了**%s格式化符**(字符串格式化符),这样格式化函数内部会把这个整型值当成一个字符串首地址去对待,会读取这个内存地址中的字符串,但是这个地址是个很小的地址,即程序访问了64KB范围内的小地址存储区,这个区域是禁止访问的,会触发内存访问违例,系统会直接将进程终止掉。

   如果是格式化符与变量类型不一致,我们一般一眼就能看出来,但有时问题往往没这么简单,可能要深层次地去分析,才能定位问题。今天我们要讲的异常排查实例就有一定的隐蔽性,需要进行较深入的分析才能最终确定问题的原因。

2、初步分析

   取来崩溃时生成的dump文件,使用windbg打开,打开后首先看一下发生的异常Code类型:

我们看到是“Accessviolation-codec0000005”内存访问违例的异常,于是使用.ecxr命令切换到异常上下文(切换到发生异常的那个线程中),查看发生异常的那条汇编指令:

   这条汇编指令是把eax寄存器中的值作为内存地址去读取内存中的值,然后和0作比较。指令中访问的内存地址并没有明显的异常,既不是小于64KB的小内存地址,也不是大于0x80000000(32位程序)的内核态地址,所以并没有明显的异常。

1)64KB以内的内存地址是禁止访问的
2)内核态的内存地址,用户态的代码是禁止访问的

一般我们写的业务代码是运行在用户态的(一般驱动程序的代码是运行在内核态的),用户态的代码是禁止访问内核态的内存地址的。对于32程序,会分配4GB的虚拟内存地址空间,用户态和内核态各占一半,所以小于0x80000000的地址属于用户态的,大于0x80000000的地址是划给内核态的,所以如果用户态的代码访问了大于0x80000000的内核态的地址,就会触发内存访问违例,导致程序崩溃。

   看完发生异常的汇编指令后,在windbg中输入kn命令查看崩溃时的函数调用堆栈:

函数调用堆栈中看到了调用VS2010版本的运行时库msvcr100.dll中的_vsnprintf_s格式化函数,但更上层的某dll模块因为缺乏pdb文件,看不到具体的函数名及代码的行号。

   我们是需要看到该dll中都调用了哪些具体的函数及代码的行号,然后去源文件中去查看对应位置的源代码。于是使用lm查看该dll模块的时间戳:

然后通过时间戳找到对应的pdb文件。

3、进一步分析

   取来pdb文件,将pdb文件的路径设置到windbg中,然后重新输入.ecxr切换到异常的上下文,在输入kn命令查看函数调用堆栈:

   从调用堆栈上看,media***.dll模块中的CAudDecWrapper::SelectAudioPlyDevice函数调用了打印日志的接口,代码行号为2705:

media***!CAudDecWrapper::SelectAudioPlyDevice+0x68e[k:\cbb\media\20210906-saturn\media***\source\audio\auddecoderwrapper.cpp@2705

函数CAudDecWrapper::SelectAudioPlyDevice位于auddecoderwrapper.cpp文件中,行号为2705,所以去对应位置去查看源代码:

   这一行的代码是将tAudioName结构体对象中的strDeviceName格式化到字符串的,到结构体中查看了strDeviceName成员的类型为wstring:

是宽字节的字符串,所以格式化时使用了支持宽字节字符的%ls。

   %ls中的l,类似于_T宏中的L"":
#ifdef _UNICODE
#define _T(x)   L ## x
#else
#define _T(x)   x
#endif

tAudioName.strDeviceName中存放的是字符串,变量对应类型为wstring,即宽字节字符串,所以使用支持宽字符的%ls是没问题的。

4、为啥将wstring类型格式化到字符串buffer中会崩溃呢?

   string和wstring是标准C++类库中提供的处理字符串的类,一般在获取这两个对象中存放的字符串时需要调用这两个类的c_str接口去获取。那么此处的代码应该是没有调用c_str接口导致的?答案基本是肯定的,就是没调用c_str接口导致崩溃的。那为什么不调用c_str接口会导致崩溃的呢?我们要知其然,也要知其所以然!

   要搞清楚崩溃的原因,就要从函数调用时参数通过栈传递以及wstring类的构成来看了。以前我们说过,调用函数时传递的参数,是要将参数内存中的值压到栈上的,被调用函数从栈上拿到传入的参数值的,如下:

   那么对于要将wstring变量中的字符串格式化到字符串buffer中,**应该传入的是待格式化的字符串首地址,如果调用tAudioName.strDeviceName.c_str(),传入的就是字符串的首地址。但此处出问题的代码是传入的是整个wstring对象,那么压入到栈上的内存内容,应该是wstring对象整个内存中的内容。****在使用string或wstring对象时,要获取对象中存放的字符串内容,需要调用c_str()接口!**

   下面我们就来看看wstring类的相关实现,以VS2010版本的wstring类为例,在代码中直接go到wstring的定义处:
typedef basic_string<char, char_traits<char>, allocator<char> >
        string;
typedef basic_string<wchar_t, char_traits<wchar_t>, allocator<wchar_t> >
        wstring;

wstring类使用basic_string模板类实现的,给basic_string模板类传入了wchar_t类型,go到basic_string类定义处,basic_string类又继承了_String_val类:

再往上看,_String_val类还继承了其他类,所以wstring类中肯定有很多数据成员,这些数据成员的值都压到栈上,就乱套了,在最终的格式化函数中到站上去找字符串首地址就不是真正的字符串的首地址,而是wstring类第一个成员变量内存中的内容,所以乱套了。

   至此,大家应该就能知道为啥会产生内放访问违例的异常了。我们再来看看c_str接口的实现,编写如下的测试代码:
wstring str = "Test.exe"
str.c_str();

直接go到c_str接口的实现处:

const _Elem *c_str() const
{    // return pointer to null-terminated nonmutable array
    return (_Myptr());
}

c_str接口中调用_Myptr()接口,再看_Myptr()接口的实现:

// _Myptr函数实现
_Elem *_Myptr()
{   // determine current pointer to buffer for mutable string
    return (this->_BUF_SIZE <= this->_Myres ? this->_Bx._Ptr
                        : this->_Bx._Buf);
}

返回的就是存放字符串的指针变量的值,就是字符串存在内存中的首地址。

5、格式化函数如何从栈上解析出每个格式化符对应的变量内容的?

   至于_vsnprintf_s等格式化函数内部如何是从栈上解析出要被格式化的变量内容的呢?简单来讲,就是通过格式化串中的格式化符,依次到栈上找出对应的待格式化的变量内容的。

   以查看格式化函数sprintf为例,我们编写如下的测试代码:(将变量nVal和字符串"This is a Test"格式化到目标字符串buffer中)
int nVal = 6;
char* szTip = "This is a Test";
char szLog[512] = { 0 };
sprintf( szLog, "nVal=%d, szTip: %s", nVal, szTip);

这些代码不能直接go到sprintf函数中,需要单步调试才能进入sprintf函数:

#ifndef _COUNT_

int __cdecl sprintf (
        char *string,
        const char *format,
        ...
        )
#else  /* _COUNT_ */

#ifndef _SWPRINTFS_ERROR_RETURN_FIX

int __cdecl _snprintf (
        char *string,
        size_t count,
        const char *format,
        ...
        )
#else  /* _SWPRINTFS_ERROR_RETURN_FIX */

int __cdecl _snprintf_c (
        char *string,
        size_t count,
        const char *format,
        ...
        )

#endif  /* _SWPRINTFS_ERROR_RETURN_FIX */

#endif  /* _COUNT_ */

{
        FILE str = { 0 };
        REG1 FILE *outfile = &str;
        va_list arglist;
        REG2 int retval;

        _VALIDATE_RETURN( (format != NULL), EINVAL, -1);

#ifdef _COUNT_
        _VALIDATE_RETURN( (count == 0) || (string != NULL), EINVAL, -1 );
#else  /* _COUNT_ */
        _VALIDATE_RETURN( (string != NULL), EINVAL, -1 );
#endif  /* _COUNT_ */
        va_start(arglist, format);

#ifndef _COUNT_
        outfile->_cnt = MAXSTR;
#else  /* _COUNT_ */
        if(count>INT_MAX)
        {
            /* old-style functions allow any large value to mean unbounded */
            outfile->_cnt = INT_MAX;
        }
        else
        {
            outfile->_cnt = (int)(count);
        }
#endif  /* _COUNT_ */
        outfile->_flag = _IOWRT|_IOSTRG;
        outfile->_ptr = outfile->_base = string;

        retval = _output_l(outfile,format,NULL,arglist);

        if (string == NULL)
            return(retval);

#ifndef _SWPRINTFS_ERROR_RETURN_FIX
        _putc_nolock('\0',outfile); /* no-lock version */

        return(retval);
#else  /* _SWPRINTFS_ERROR_RETURN_FIX */
        if((retval >= 0) && (_putc_nolock('\0',outfile) != EOF))
            return(retval);

        string[0] = 0;
        return -1;
#endif  /* _SWPRINTFS_ERROR_RETURN_FIX */
}

上面的代码最终是调用了_output_l函数,我们到_output_l函数中看到如何通过格式化符找到对应参数的:

   最后,关于函数调用时的参数传递可以查看下面两篇文章:

分析C++软件异常需要掌握的汇编知识汇总https://blog.csdn.net/chenlycly/article/details/124758670C++函数调用栈分布详解https://blog.csdn.net/chenlycly/article/details/121001096

6、总结

   通过排查问题去接触更多的知识点,了解更多之前写代码时未曾了解的深层次内容,在排查问题中学习,在排查问题中成长。所以我们鼓励大家都去参与复杂问题的排查,往往有时复杂的问题,越是有很多细节,有很多之前不了解的知识点,可能会有很大的收获,在这方面我是深有体会的。

   通过问题排查,不仅能学到新的知识点,还能积累排查问题的经验,在后续问题排查中会有更所的思路和手段。排查问题时多关注一些细节,往往细节出真知!
 

本文转载自: https://blog.csdn.net/chenlycly/article/details/126211718
版权归原作者 dvlinker 所有, 如有侵权,请联系我们删除。

“将string类对象中的内容格式化到字符串Buffer中时遇到的异常崩溃分析”的评论:

还没有评论