Friday, November 09, 2007

一个Memory Access Violent问题的解决:

问题出现:
最近做一个项目,原来的开发平台是VC6,最近搬到VS2005上并新做一功能,但是在开发期间跑程序的时候经常发生内存越界的错误,提示写访问0x00000010位置的内存。
因为是正在开发的程序,所以很快的定位到了发生错误的代码,是一个对EnterCriticalSection的调用,紧接其后的是一行对两个long型的比较语句,代码如下:

EnterCriticalSection( &sndlock );
if( sndr != sndw ) {
sndno = sndr;
} else {
sndno = -1;
}
sndlock是一个全局变量,一个临界区的结构,用来进行线程互斥的。

EnterCriticalSection函数的定义是:
void EnterCriticalSection(
LPCRITICAL_SECTION lpCriticalSection
);

CRITICAL_SECTION结构的定义是

typedef struct _RTL_CRITICAL_SECTION {
PRTL_CRITICAL_SECTION_DEBUG DebugInfo;

//
// The following three fields control entering and exiting the critical
// section for the resource
//

LONG LockCount;
LONG RecursionCount;
HANDLE OwningThread; // from the thread's ClientId->UniqueThread
HANDLE LockSemaphore;
ULONG_PTR SpinCount; // force size on 64-bit systems when packed
} RTL_CRITICAL_SECTION, *PRTL_CRITICAL_SECTION;
各字段具体含义请查一下MSDN

初步分析:
初步判断问题出在EnterCriticalSection里面。我的第一直觉是有缓冲区溢出,因为以前也遇到过,缓冲区溢出写乱了堆栈,导致栈内保存的EBP,ESP等寄存器被毁坏,函数返回时马上遇到内存越界的错误。可是这一般是发生在自己写的函数里面,而EnterCriticalSection是一个久经革命考验Windows API,不太可能在其内部还有缓冲区溢出。

第一个解决方案:
总结现象,发现并不是每次走到这里都会导致越界,并且出错的几率还蛮小,几乎是2,30比1。考虑到是多线程的程序,有位同事就提出可能出错的时候是临界区正好被别的线程占用了,而正好另一段程序也是在此临界区中做了IO操作,如果临界区资源不可用,当前线程调用EnterCriticalSection就会出问题,并给出了一个解决方案:将其他线程在临界区中的IO操作移出临界区。可是问题就来了,按照MSDN的说法,如果临界区资源被其他线程占用,EnterCriticalSection函数调用会一直阻塞到资源被释放,而绝不应该出现内存越界访问的错误啊。

跟踪:
多线程的界面程序,跟踪定位的比较困难,因为之前也没这方面经验,哪位大大有经验请不吝赐教!幸好这里还是一个比较特殊的位置,正好是进入临界区的代码。
当然在做事情之前放一把狗还是有用的,可是无论是搜EnterCriticalSection还是Windows内核缓冲区溢出都找不到有价值的答案。心存疑惑的马上跟踪汇编代码,
进入EnterCriticalSection之前,先查看一下sndlock结构,这个东西的地址是0x0053870c,再看它的的值,咦?其它成员都正常,可是DebugInfo指针怎么会是0x00000000?我想一定是存放Debug信息的,就算是NULL也问题不大吧。继续跟踪定位到ntdll.dll! 7c958fea(),的确是干了坏事
7C958FE8 mov eax,dword ptr [esi]
7C958FEA inc dword ptr [eax+10h]
此时 eax 为 0x00000000,要对ptr [eax+10h]加个1当然是出错了。
那么问题就肯定出在esi上了,esi此时为0x0053870c,看到没有?正好是sndlock的地址,7C958FE8 mov eax,dword ptr [esi] 这一句话就是把DebugInfo的值放到eax,然后。。。惨剧就发生了。

弯路:
当然过程不是这样的一帆风顺的,同事猜测是临界区资源被占用导致内存越界之后,为了证明他的观点“错误”,我在进入EnterCriticalSection之前,手动修改了sndlock结构,把LockCount从0xffffffff改为0x00000000,把RecursionCount从0改为1,把OwningThread改为界面主线程的的句柄号,然后再进入EnterCriticalSection函数,还真的是每次都会导致出错。怎么跟MSDN说的不一样嘛,难道真的有bug?
为了证明Windows API没有错误,我另外谢了一个程序,模拟一个线程占用了临界区资源后,另一个线程调用EnterCriticalSection,但是结果一切正常,没有发生越界错误。
真是奇怪了,老子来了招野蛮的,两个程序一起跑,从调用EnterCriticalSection的地方单步跟踪反汇编,最后才找出来esi指向的DebugInfo指针不一样,一个为0x00000000,另一个为可访问的内存。

罪魁祸首:
那么是谁把sndlock.DebugInfo搞成了0x00000000呢?我又一次使出了野蛮大法,先查找所有sndlock,然后全部设置断点,跑程序,发现居然调用了两次InitializeCriticalSection,可是调用InitializeCriticalSection再DeleteCriticalSection ,然后重新初始化临界区InitializeCriticalSection也不会出问题啊(我在自己的小程序里面已经试验过了)。那么就把sndlock.DebugInfo放到Watch窗口里面,看它什么时候变的。
其中的过程就不多说,最后反正是定位在一行这样的代码上
time( (time_t*)&begtm );
对,就是它改了sndlock.DebugInfo,可是为什么呢?这个函数跟sndlock有什么关系?

真相大白:
那么我们就来看看它们有什么关系,此begtm是一long型的全局变量,看看begtm的地址0x00538708,就比sndlock结构的地址少4!!!那么很可能time函数溢出了,废话少说,让我们看看time()函数的定义(用VS的Goto Define):

static __inline time_t __CRTDECL time(time_t * _Time)
{
return _time64(_Time);
}
那么参数类型time_t *又是什么呢:

#ifndef _TIME_T_DEFINED
#ifdef _USE_32BIT_TIME_T
typedef __time32_t time_t; /* time value */
#else
typedef __time64_t time_t; /* time value */
#endif
#define _TIME_T_DEFINED /* avoid multiple def's of time_t */
#endif
定位到的是typedef __time64_t time_t这一行,那么__time64_t又是什么类型呢?
同样使用VS的Goto Define:
typedef __int64 __time64_t; /* 64-bit time value */

看到了吗?就是它,我们的begtm定义的时候是long型的,也就是占用4byte,而此时的time函数会把它的参数当64位的整形处理,就是说把0x00538708以后的8个字节都修改了,而这个时间的整形表示是不大于2的32次方减一的,所以紧跟在begtm后面的我们的小可怜sndlock.DebugInfo就被无情的强暴成了0x00000000(此处略去关于bigendian littleendian介绍若干)

解决方案:
凶手找到了,那我们就来搞定它把,time()函数说需要time_t*做参数,那我们就给它time_t*吧。修改全局变量begtm的声明为
time_t begtm;
重新编译,连接,测试。没有问题。就算在进入EnterCriticalSection之前,手动修改sndlock结构同样不会造成越界错误,这个线程只是痴痴的等在那里,永远不回来。。。

另一个解决办法是,编译的时候定义一下_USE_32BIT_TIME_T宏,使用32位的时间值。不过我觉得还是修改程序比较好,程序本来就有错嘛。

那么为什么在VC6的环境下不出错呢?可能您已经想到了,VC6的环境下的time_t是定义成32位整形的,正好是long的长度,不会出任何问题。

经验和教训:
1.程序差错的时候不能放过任何细微的地方,如果在发现DebugInfo为0x00000000的时候不放过,就可能少走后面的弯路,更快的找到问题所在。
2.不能得过且过,遇到问题要深入分析。如果我按照同事的观点,把IO放到临界区外,此处进入临界区的时候,资源被占用的机会变少,的确会减少错误发生的次数,但是却不能完全解决,遇到合适的时机,错误发生的条件仍然会满足。如果就因此放弃调查,可能今后就酿成重要事故。
3.调用函数的时候,最好根据函数定义的参数类型调用,不能看到time_t是long typedef过来的就直接声明为long型。如果以后编译参数改变或者编译器改变就很容易出现莫名其妙的错误。
4.基础知识还是很重要的,特别是做C或者C++的东西。