内存越界

  • Post author:
  • Post category:其他

前段时间线上报障PPCache2.0版本的BTPPC经常core掉,花了几天的时间,终于找到了问题并予以解决,结合本次遇到的问题和以前的一些经验简单写个东西,和大家一起分享一下关于内存越界方面的bug定位方法。

内存问题的几种情况

根据之前的经验,常见的内存越界主要有以下几种:

一、             栈越界。这种问题一般不太常见,一般有点经验的程序员都不会出现这种错误。我曾经犯过的错误是在一个函数当中定义了一个超大的栈变量数组,导致程序崩溃,这种问题容易复现和定位。

二、             内存越界。这种问题比较常见,我们以前在讨论时经常提到的内存问题大部分都是这种情况,常见的出错方式有:

1、  内存拷贝越界。例如strcpysprintf等对内存进行简单拷贝的函数,由于这些函数只负责将源数据拷贝到目标内存,并不会检查目标地址是否合法,因此往往会由于目标内存地址分配太小导致拷贝越界。

2、  不良的编码习惯导致内存越界。有些内存操作函数是安全的,比如strncpy,memcpy,snprintf等函数,都是内存安全的,但是如果在编码时不注意保护,一样会出现内存越界的情况。比如使用strncpy函数,但没有将目标数组的最后一个变量置为0,如果源数据数组大小超过目标数组大小,仍然会出现访问内存或者释放内存时越界的情况。

3、  系统库文件定义的数据结构越界。系统库文件中定义一些数组,会有一些默认的大小,我们在使用时往往不太注意这些限制,在正常情况下,我们不关心默认大小往往不会出现问题,但是当出现异常情况时,就会造成内存越界访问或者改写,导致程序出现崩溃,这种问题一般比较难于定位。比如fd_set结构,系统默认的大小是1024,我们在编程时一般不太注意这个值的大小,程序在运行时如果压力不大也不会导致链接数超过1024,但是一旦一个特殊的条件导致连接数暴涨时,程序就会出现莫名其妙的崩溃了。

三、             野指针。这里提到的野指针不是通常所指的没有初始化的指针,没初始化的情况比较容易处理,看core文件就能马上定位。我说的这种野指针是指一块内存多个线程共享,由于没有加锁等原因,线程1释放了内存,此时线程2也拿到了这块内存地址,但他不知道线程1已经释放了内存,当这块内存被释放后立即被线程N申请使用,然后线程2开始改写这块内存。这种情况会带来两种情况,如果这块内存是预分配的,就会出现数据错误的情况;如果这块内存是栈变量地址,出现的问题可能就很难定位了。

分析定位问题的方法

一、栈越界。这类问题已经说了,程序往往立刻会core掉,容易定位。

二、内存越界。

1、  内存拷贝越界。一般这种拷贝都是字符串,一旦找到越界的内存,再看看越界内存前面一段内存的数据,很容易看出来是什么数据造成的越界,也比较容易定位是哪部分代码造成的问题了。如果说字符串不明显,可以专门查找strcpysprintf等函数,通过分析代码来判断是否存在越界的可能性,再通过加日志方式,复现问题后验证猜测。

2、  不良编码习惯导致的越界。定位问题方法同内存拷贝越界。

3、  系统库变量越界的问题是非常不容易定位的,原因是在代码里面不会有明显的数组大小痕迹,往往容易忽略这类数组;另外我们在操作这类数组时不是直接使用拷贝函数,而是使用一些宏定义来修改,这类宏定义数组特征不明显,一般不会作为定位问题的目标;再有就是当越界问题发生时,被改写的内存中数据也没什么特征可循。

三、野指针。野指针问题也不太容易定位,举两个例子,第一个例子是咱们的ppstreamppc曾经遇到的问题,ppc在服务时经常莫名其妙的导致客户端出现无法播放的情况,通过抓包分析发现,当客户端播放中断时,ppc回给客户端的数据与保存在磁盘上的数据不一致,从而导致客户端无法继续播放。经过一番折腾,最后发现产生问题的原因是由于一块内存被两个线程同时使用,后一个线程把前一个线程的数据覆盖了。当然这个例子里面的内存不存在释放问题,而是由于内存使用不合理,导致了多个线程在调用同一个函数时错误的使用了同一块内存。第二个例子不是咱们经历过的,而是我在网上看到的,后面也会介绍到定位这个问题的经过。这个例子里面是就是前面我讲到的,由于一个指针被一个线程释放,但另外一个线程却又对其进行了修改,从而导致出现莫名的错误。

 

以上提到的几类问题的定位都比较难,根据我的经验,主要还是采用一些“土方法”来定位问题,这些方法包括代码review、打日志、注释代码。

1)代码review主要适用于对代码比较熟悉,了解出现问题前后修改的代码包括哪些,通过比较代码来分析产生问题的原因。但这种方法也有局限,比如有些问题早就存在了,之所以没有爆发是由于没有遇到触发条件,而一旦条件具备了,问题就会显现出来,这种问题通过代码review不会有什么效果。本次BTPPC出现的问题就属于这种情况,单从代码上来看,无法看出来产生问题的原因。

2)打日志。打日志的目的是初步定位问题出现时是由于哪些函数引起的,一般情况下这种粗略的定位效果还是比较明显,虽然有时多线程同时工作对精确定位具体函数会造成一定的影响,但总体上来说,通过加日志方式能够初步定位问题函数。

3)注释代码。无论是通过日志方式还是仅仅依靠猜测,可以先把嫌疑代码注释掉,再来复现问题,如果代码注释掉后问题不出现了,那么可以说明注释掉的代码就是引起问题关键,但此时未必就可以下结论说问题找到了,还需要进一步分析注释掉的代码为什么会造成问题的出现,而这种分析往往也存在很多难点。

上述的方法之所以定义为“土方法”,是因为要经过反复的修改代码、重编译、复现问题、分析问题,最终找到产生问题的原因和解决问题的方法,工作量比较大。

这次我在分析BTPPCcore问题时,由于定位问题比较艰难,因此也上网尝试找一些捷径,其中有一片文章写的不错,推荐给大家,这篇文章在百度文库上,名称叫做《走下神坛的内存调试器定位多线程内存越界问题实践总结》,文章里面提到了多种定位内存越界的方法,当然这些方法在本次定位问题上都没有起到应有的作用,后面我会谈到使用其中一些方法后遇到的问题。这篇文章中要解决的问题就是上面我说的那个比较难定位的野指针问题,大家如果有兴趣不妨到网上看一下这篇文章。

定位BTPPC的过程

下面介绍一下这次定位问题的经过,希望对大家今后定位问题能够起到一点提示的作用,同时也希望通过这次的定位问题过程,能够起到抛砖引玉的作用,请大家一起分享一下自己在解决内存越界问题上的经验,我会把大家回复的宝贵经验更新到这篇文章中,作为大家今后解决类似问题的一个参考。

先来说说我使用的几种不太成功的定位方法,这些方法在我推荐的那篇文章中都有提到。

第一种是代码review,前面已经提到,通过review一些可能产生问题的代码,并没有找到问题点。

第二种是使用valgrind,我在使用这种方法时遇到的问题是程序运行一下就会退出,根本等不到程序core的时候,按照命令说明,当valgrind达到错误数量上限后会自动退出,可以通过加—no-limit参数强制其不退出,但是经过测试没有效果,我也懒得深究其具体原因,主要的理由是我觉得这个方法对于检查一些内存泄露或许有些功效,但对于这种内存非法访问未必能起到效果,因此很快就放弃了。

第三种方法尝试使用gdbwatch功能,这个功能有个弊端,就是设置观察点的个数有限制,我试了一下好像最多只能设置5个观察点,多了就会失败,我也尝试设置了use-hw-watch-points,但是没有效果,不知道是不是我的设置方法有问题,大家可以给出更好的建议。但是不管观察点是否可以设置更多,这种方法应该对于定位问题没有太大帮助,原因是观察点相当于一个断点,只有程序运行到这个断点并且发现观察的变量发生变化后才会引发中断,而当被观察的变量发生变化时是不会发生中断的,因此我们只能说在最快的时间内发现了变量或者内存被修改,但是却不能发现是谁把变量修改了。

第四种方法就是推荐文章中提到的终极神器,mprotect + backtrace + libsigsegv,这种方法需要修改代码,但是改动量不大,应该说这种改动如果真能找到问题还是很值得的。这个方法的原理很简单,用mprotect把内存设置为只读,当内存被修改后立刻会引起SIGSEGV,使用libsigsegv库接管SIGSEGV信号的处理,在处理时使用backtrace把当时的调用栈保存下来,这样通过查看调用栈信息就会找到在哪个环节修改了内存。听起来这个方法确实非常诱人,文章中也确实利用这个神器找到了问题,但是我在使用这个方法时还是没有得到想要的结果,原因是SIGSEGV信号确实产生了,但是libsigsegv库并没有接管SIGSEGV信号,因此自然也看不到调用栈的情况了。我也没有花太多的时间来研究为什么会失败,libsigsegv库提供的资料和网上能够找到的经验也很少,因此尝试几次失败后我就放弃了。我分析libsigsegv库没有接管SIGSEGV的原因可能是文章中提到的内存是他自己分配的内存,libsigsegv库提供的示例代码也是使用mmap分配的内存,我测试了例子代码,是libsigsegv库是可以接管SIGSEGV信号的。而BTPPC被改写的内存属于线程栈变量,从内存类型上看是有区别的,或许这种类型的内存非法访问产生的SIGSEGV信号libsigsegv库不能接管,大家如果有兴趣可以自己尝试一下如何能够把这个终极神器用到咱们的工作中,或许还是我自己使用的方法不正确,导致没有达到预期的效果。

使用libsigsegv库的原因是这个库已经封装好了接管SIGSEGV信号的过程,我们只需要做很少的工作就能够达到处理SIGSEGV信号的目的。还有其他方法能够实现接管SIGSEGV信号的处理,但是相对来讲都比较复杂,这次在定位问题时我没有去尝试,大家如果有兴趣可以搞一搞,接管了这个信号后,再结合backtrace函数,对于定位内存问题必然可以起到事半功倍的效果。

下面就来说一说本次定位BTPPC的过程吧。

首先,描述一下问题现象,在河北铁通上线BTPPC2.0版本后,程序运行几分钟就会core掉,并且非常频繁。经过查看core文件,发现是BTPPC启动了16个工作线程,还有一个主线程和另外一个线程,共计18个线程。这16个工作线程在工作循环中要处理peerlist队列,每个线程处理一个peerlist队列,这些peerlist队列保存在大小为16的数组中,每个线程有一个编号,从015,这个编号作为数组的下标,当出现core时,这个编号的值被改动,超过了15,因此引起访问越界导致程序core掉。

下一步通过加日志,发现在core发生以前,实际上程序已经不能正常工作了,前面提到的0-15的编号,在程序运行中已经有多个变量被修改为0,也就是说很多线程都在处理数组下标为0peerlist,由于访问数组在合法范围内,没有引起core,但此时程序的处理是错误的。只有当某个时刻,编号变量的数值被修改为超过15时才会引起core产生。

继续加日志,在工作线程和主线程的每个处理函数前后监控这16个编号变量在哪个时刻会出现变化,由于这些变量是在工作线程中定义的,不能直接访问变量名称,好在如果代码没有重新编译,每次启动程序时这些变量的内存地址是固定的,因此可以通过打日志的方式来监控这些地址中的内容是否发生变化。通过日志显示,大多数时候都是执行完Iterate这个函数后,某些线程中的编号变量值就被改变了,因此猜测导致内存被修改的函数是Iterate

下一步的工作是验证猜测的正确性。采用的方法是注释代码,如果把Iterate函数注释掉,确实程序在很长时间内不会再core掉。进一步在Iterate函数中寻找引起问题的地方,方法也是通过注释代码,逐渐缩小包围圈,最后定位到一行代码上,这行代码是一个赋值操作。只要不执行这行代码,程序就正常工作,一旦执行赋值操作程序很快就会core掉。定位工作已经精确到一行代码上,看起来似乎问题已经找到了,这行代码如下:

                   *ipaddr = e->in_addrs[n].s_addr;

但是左看右看,这行代码都不会有什么问题,唯一可能出错的就是n超过了in_addrs的大小导致内存访问越界产生core,但是当程序core时并不是停在这里,我又反复测试,确认这个赋值并没有什么问题,显然虽然我们定位到了这行代码,但他并不是产生core的原因。

下一步的工作就是要继续找问题,既然这行代码开启与关闭会导致程序出现不同的结果,因此这个赋值操作一定会影响到程序的运行。进一步分析后发现,这个赋值操作中的值是tracker的地址,而一旦tracker地址有效,程序会启动对该tracker的工作流程,有一个btTracker类来负责执行这个流程。至此我终于明白,找到这行代码并没有找到开启问题谜盒的钥匙,只是打开了另外一个房间的大门,我们还需要在这个房间中继续寻找开启谜盒的钥匙。

下一步的工作看起来挺轻松了,因为已经知道了具体是哪个类引起的问题,但是在定位过程中才发现根本不是那么回事,现在总结起来之所以会这么难,还是由于我已经有了一个先入为主的思想,由于是内存非法修改,我们自然会把关注重点放到我们编写的代码上,看看是不是内存拷贝、内存释放等方面出现了问题。通过代码review没有找到问题;注释发送报文代码、注释接收报文及处理报文代码、注释释放资源代码,都没有任何效果,还是会出错。只有当让connect函数直接返回,不执行建链过程时,才不会发生core的情况,这种情况和不对tracker地址进行赋值是一样的效果。因此基本上可以确定建链的行为会导致程序core掉,但至于为什么还不是非常清楚。

进一步通过增加加日志,发现当建链行为发生时,连接数增长非常快,很快就达到了1000以上。再增加日志,发现这些链接大部分并没有进行正常的交互行为,通过netstat查看,发现大量链接处于SYN_SENT状态,原因是torrent服务器压力太大,无法接收这些请求。而代码中还是对这些连接进行了FD_SET的操作,至此才开始考虑是不是fd_set产生了问题,通过查找资料,发现fd_set默认的数组大小是1024,当超过这个值时,就有出现内存越界的可能性了。通过限制btTracker建链的数量,控制socket fd的数量,此时btppc可以正常运行,不再出现core掉的情况。

至此,导致btppc出现core的原因已经找到,解决办法也有不止一个,可以通过控制socket的数量避免连接数超过1024引起越界;也可以通过增大这个默认值来避免数组越界,这个默认值在/usr/include/bits/typesizes.h定义,名称是__FD_SETSIZE


版权声明:本文为maikeerdai原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。