大内高手调试手段及原理.docx
- 文档编号:25579309
- 上传时间:2023-06-09
- 格式:DOCX
- 页数:69
- 大小:320.28KB
大内高手调试手段及原理.docx
《大内高手调试手段及原理.docx》由会员分享,可在线阅读,更多相关《大内高手调试手段及原理.docx(69页珍藏版)》请在冰豆网上搜索。
大内高手调试手段及原理
大内高手--调试手段及原理
知其然也知其所以然,是我们《大内高手》系列一贯做法,本文亦是如此。
这里我不打算讲解如何使用boundschecker、purify、valgrind或者gdb,使用这些工具非常简单,讲解它们只是多此一举。
相反,我们要研究一下这些工具的实现原理。
本文将从应用程序、编译器和调试器三个层次来讲解,在不同的层次,有不同的方法,这些方法有各自己的长处和局限。
了解这些知识,一方面满足一下新手的好奇心,另一方面也可能有用得着的时候。
从应用程序的角度
最好的情况是从设计到编码都扎扎实实的,避免把错误引入到程序中来,这才是解决问题的根本之道。
问题在于,理想情况并不存在,现实中存在着大量有内存错误的程序,如果内存错误很容易避免,JAVA/C#的优势将不会那么突出了。
对于内存错误,应用程序自己能做的非常有限。
但由于这类内存错误非常典型,所占比例非常大,所付出的努力与所得的回报相比是非常划算的,仍然值得研究。
前面我们讲了,堆里面的内存是由内存管理器管理的。
从应用程序的角度来看,我们能做到的就是打内存管理器的主意。
其实原理很简单:
对付内存泄露。
重载内存管理函数,在分配时,把这块内存的记录到一个链表中,在释放时,从链表中删除吧,在程序退出时,检查链表是否为空,如果不为空,则说明有内存泄露,否则说明没有泄露。
当然,为了查出是哪里的泄露,在链表还要记录是谁分配的,通常记录文件名和行号就行了。
对付内存越界/野指针。
对这两者,我们只能检查一些典型的情况,对其它一些情况无能为力,但效果仍然不错。
其方法如下(源于《Comparingandcontrastingtheruntimeerrordetectiontechnologies》):
l首尾在加保护边界值
Header
Leadingguard(0xFC)
Userdata(0xEB)
Tailingguard(0xFC)
在内存分配时,内存管理器按如上结构填充分配出来的内存。
其中Header是管理器自己用的,前后各有几个字节的guard数据,它们的值是固定的。
当内存释放时,内存管理器检查这些guard数据是否被修改,如果被修改,说明有写越界。
它的工作机制注定了有它的局限性:
只能检查写越界,不能检查读越界,而且只能检查连续性的写越界,对于跳跃性的写越界无能为力。
l填充空闲内存
空闲内存(0xDD)
内存被释放之后,它的内容填充成固定的值。
这样,从指针指向的内存的数据,可以大致判断这个指针是否是野指针。
它同样有它的局限:
程序要主动判断才行。
如果野指针指向的内存立即被重新分配了,它又被填充成前面那个结构,这时也无法检查出来。
从编译器的角度
boundschecker和purify的实现都可以归于编译器一级。
前者采用一种称为CTI(compile-timeinstrumentation)的技术。
VC的编译不是要分几个阶段吗?
boundschecker在预处理和编译两个阶段之间,对源文件进行修改。
它对所有内存分配释放、内存读写、指针赋值和指针计算等所有内存相关的操作进行分析,并插入自己的代码。
比如:
Before
if(m_hsession)gblHandles->ReleaseUserHandle(m_hsession);
if(m_dberr)deletem_dberr;
After
if(m_hsession){
_Insight_stack_call(0);
gblHandles->ReleaseUserHandle(m_hsession);
_Insight_after_call();
}
_Insight_ptra_check(1994,(void**)&m_dberr,(void*)m_dberr);
if(m_dberr){
_Insight_deletea(1994,(void**)&m_dberr,(void*)m_dberr,0);
deletem_dberr;
}
Purify则采用一种称为OCI(objectcodeinsertion)的技术。
不同的是,它对可执行文件的每条指令进行分析,找出所有内存分配释放、内存读写、指针赋值和指针计算等所有内存相关的操作,用自己的指令代替原始的指令。
boundschecker和purify是商业软件,它们的实现是保密的,甚至拥有专利的,无法对其研究,只能找一些皮毛性的介绍。
无论是CTI还是OCI这样的名称,多少有些神秘感。
其实它们的实现原理并不复杂,通过对valgrind和gcc的boundschecker扩展进行一些粗浅的研究,我们可以知道它们的大致原理。
gcc的boundschecker基本上可以与boundschecker对应起来,都是对源代码进行修改,以达到控制内存操作功能,如malloc/free等内存管理函数、memcpy/strcpy/memset等内存读取函数和指针运算等。
Valgrind则与Purify类似,都是通过对目标代码进行修改,来达到同样的目的。
Valgrind对可执行文件进行修改,所以不需要重新编译程序。
但它并不是在执行前对可执行文件和所有相关的共享库进行一次性修改,而是和应用程序在同一个进程中运行,动态的修改即将执行的下一段代码。
Valgrind是插件式设计的。
Core部分负责对应用程序的整体控制,并把即将修改的代码,转换成一种中间格式,这种格式类似于RISC指令,然后把中间代码传给插件。
插件根据要求对中间代码修改,然后把修改后的结果交给core。
core接下来把修改后的中间代码转换成原始的x86指令,并执行它。
由此可见,无论是boundschecker、purify、gcc的boundschecker,还是Valgrind,修改源代码也罢,修改二进制也罢,都是代码进行修改。
究竟要修改什么,修改成什么样子呢?
别急,下面我们就要来介绍:
管理所有内存块。
无论是堆、栈还是全局变量,只要有指针引用它,它就被记录到一个全局表中。
记录的信息包括内存块的起始地址和大小等。
要做到这一点并不难:
对于在堆里分配的动态内存,可以通过重载内存管理函数来实现。
对于全局变量等静态内存,可以从符号表中得到这些信息。
拦截所有的指针计算。
对于指针进行乘除等运算通常意义不大,最常见运算是对指针加减一个偏移量,如++p、p=p+n、p=a[n]等。
所有这些有意义的指针操作,都要受到检查。
不再是由一条简单的汇编指令来完成,而是由一个函数来完成。
有了以上两点保证,要检查内存错误就非常容易了:
比如要检查++p是否有效,首先在全局表中查找p指向的内存块,如果没有找到,说明p是野指针。
如果找到了,再检查p+1是否在这块内存范围内,如果不是,那就是越界访问,否则是正常的了。
怎么样,简单吧,无论是全局内存、堆还是栈,无论是读还是写,无一能够逃过出工具的法眼。
代码赏析(源于tcc):
对指针运算进行检查:
void*__bound_ptr_add(void*p,intoffset)
{
unsignedlongaddr=(unsignedlong)p;
BoundEntry*e;
#ifdefined(BOUND_DEBUG)
printf("add:
0x%x%d\n",(int)p,offset);
#endif
e=__bound_t1[addr>>(BOUND_T2_BITS+BOUND_T3_BITS)];
e=(BoundEntry*)((char*)e+
((addr>>(BOUND_T3_BITS-BOUND_E_BITS))&
((BOUND_T2_SIZE-1)< addr-=e->start; if(addr>e->size){ e=__bound_find_region(e,p); addr=(unsignedlong)p-e->start; } addr+=offset; if(addr>e->size) returnINVALID_POINTER;/*returnaninvalidpointer*/ returnp+offset; } staticvoid__bound_check(constvoid*p,size_tsize) { if(size==0) return; p=__bound_ptr_add((void*)p,size); if(p==INVALID_POINTER) bound_error("invalidpointer"); } 重载内存管理函数: void*__bound_malloc(size_tsize,constvoid*caller) { void*ptr; /*weallocateonemorebytetoensuretheregionswillbe separatedbyatleastonebyte.Withtheglibcmalloc,itmay beinfactnotnecessary*/ ptr=libc_malloc(size+1); if(! ptr) returnNULL; __bound_new_region(ptr,size); returnptr; } void__bound_free(void*ptr,constvoid*caller) { if(ptr==NULL) return; if(__bound_delete_region(ptr)! =0) bound_error("freeinginvalidregion"); libc_free(ptr); } 重载内存操作函数: void*__bound_memcpy(void*dst,constvoid*src,size_tsize) { __bound_check(dst,size); __bound_check(src,size); /*checkalsoregionoverlap*/ if(src>=dst&&src bound_error("overlappingregionsinmemcpy()"); returnmemcpy(dst,src,size); } 从调试器的角度 现在有OS的支持,实现一个调试器变得非常简单,至少原理不再神秘。 这里我们简要介绍一下win32和linux中的调试器实现原理。 在Win32下,实现调试器主要通过两个函数: WaitForDebugEvent和ContinueDebugEvent。 下面是一个调试器的基本模型(源于: 《DebuggingApplicationsforMicrosoft.NETandMicrosoftWindows》) voidmain(void) { CreateProcess(...,DEBUG_ONLY_THIS_PROCESS,...); while(1==WaitForDebugEvent(...)) { if(EXIT_PROCESS) { break; } ContinueDebugEvent(...); } } 由调试器起动被调试的进程,并指定DEBUG_ONLY_THIS_PROCESS标志。 按Win32下事件驱动的一贯原则,由被调试的进程主动上报调试事件,调试器然后做相应的处理。 在linux下,实现调试器只要一个函数就行了: ptrace。 下面是个简单示例: (源于《Playingwithptrace》)。 #include #include #include #include #include etc.*/ intmain(intargc,char*argv[]) {pid_ttraced_process; structuser_regs_structregs; longins; if(argc! =2){ printf("Usage: %s argv[0],argv[1]); exit (1); } traced_process=atoi(argv[1]); ptrace(PTRACE_ATTACH,traced_process, NULL,NULL); wait(NULL); ptrace(PTRACE_GETREGS,traced_process, NULL,®s); ins=ptrace(PTRACE_PEEKTEXT,traced_process, regs.eip,NULL); printf("EIP: %lxInstructionexecuted: %lx\n", regs.eip,ins); ptrace(PTRACE_DETACH,traced_process, NULL,NULL); return0; } 由于篇幅有限,这里对于调试器的实现不作深入讨论,主要是给新手指一个方向。 以后若有时间,再写个专题来介绍linux下的调试器和ptrace本身的实现方法。 大内高手—惯用手法 《POSA》中根据模式粒度把模式分为三类: 架构模式、设计模式和惯用手法。 其中把分层模式、管道过滤器和微内核模式等归为架构模式,把代理模式、命令模式和出版-订阅模式等归为设计模式,而把引用计数等归为惯用手法。 这三类模式间的界限比较模糊,在特定的情况,有的设计模式可以作为架构模式来用,有的把架构模式也作为设计模式来用。 在通常情况下,我们可以说架构模式、设计模式和惯用手法,三者的重要性依次递减,毕竟整体决策比局部决策的影响面更大。 但是任何整体都是局部组成的,局部的决策也会影响全局。 惯用手法的影响虽然是局部的,其作用仍然很重要。 它不但在提高软件的质量方面,而且在加快软件开发进度方面都有很大贡献。 本文介绍几种关于内存的惯用手法,这些手法对于老手来说已经习以为常,对于新手来说则是必修秘技。 1.预分配 假想我们实现了一个动态数组(vector)时,当向其中增加元素时,它会自动扩展(缩减)缓冲区的大小,无需要调用者关心。 扩展缓冲区的大小的原理都是一样的: l先分配一块更大的缓冲区。 l把数据从老的缓冲区拷贝到新的缓冲区。 l释放老的缓冲区。 如果你使用realloc来实现,内存管理器可能会做些优化: 如果老的缓冲区后面有连续的空闲空间,它只需要简单的扩展老的缓冲区,而跳过后面两个步骤。 但在大多数情况下,它都要通过上述三个步骤来完成扩展。 以此可见,扩展缓冲区对调用者来说虽然是透明的,但决不是免费的。 它得付出相当大的时间代价,以及由此产生的产生内存碎片问题。 如果每次向vector中增加一个元素,都要扩展缓冲区,显然是不太合适的。 此时我们可以采用预分配机制,每次扩展时,不是需要多大就扩展多大,而是预先分配一大块内存。 这一大块可以供后面较长一段时间使用,直到把这块内存全用完了,再继续用同样的方式扩展。 预分配机制比较常见,多见于一些带buffer的容器实现中,比如像vector和string等。 2.对象引用计数 在面向对象的系统中,对象之间的协作关系非常复杂。 所谓协作其实就调用对象的函数或者向对象发送消息,但不管调用函数还是发送消息,总是要通过某种方式知道目标对象才行。 而最常见的做法就是保存目标对象的引用(指针),直接引用对象而不是拷贝对象,提高了时间和空间上的效率,也避免了拷贝对象的麻烦,而且有的地方就是要对象共享才行。 对象被别人引用了,但自己可能并不知道。 此时麻烦就来了,如果对象被释放了,对该对象的引用就变成了野针,系统随时可能因此而崩溃。 不释放也不行,因为那样会出现内存泄露。 怎么办呢? 此时我们可以采用对象引用计数,对象有一个引用计数器,不管谁要引用这个对象,就要把对象的引用计数器加1,如果不再该引用了,就把对象的引用计数器减1。 当对象的引用计数器被减为0时,说明没有其它对象引用它,该对象就可以安全的释放了。 这样,对象的生命周期就得到了有效的管理。 对象引用计数运用相当广泛。 像在COM和glib里,都是作为对象系统的基本设施之一。 即使在像JAVA和C#等现代语言中,对象引用计数也是非常重要的,它是实现垃圾回收(GC)的基本手段之一。 代码示例: (atlcom.h: CcomObject) STDMETHOD_(ULONG,AddRef)(){returnInternalAddRef();} STDMETHOD_(ULONG,Release)() { ULONGl=InternalRelease(); if(l==0) deletethis; returnl; } 3.写时拷贝(COW) OS内核创建子进程的过程是最常见而且最有效的COW例子: 创建子进程时,子进程要继承父进程内存空间中的数据。 但继承之后,两者各自有独立的内存空间,修改各自的数据不会互相影响。 要做到这一点,最简单的办法就是直接把父进程的内存空间拷贝一份。 这样做可行,但问题在于拷贝内容太多,无论是时间还是空间上的开销都让人无法接受。 况且,在大多数情况下,子进程只会使用少数继承过来的数据,而且多数是读取,只有少量是修改,也就说大部分拷贝的动作白做了。 怎么办呢? 此时可以采用写时拷贝(COW),COW代表CopyonWrite。 最初的拷贝只是个假象,并不是真正的拷贝,只是把引用计数加1,并设置适当的标志。 如果双方都只是读取这些数据,那好办,直接读就行了。 而任何一方要修改时,为了不影响另外一方,它要把数据拷贝一份,然后修改拷贝的这一份。 也就是说在修改数据时,拷贝动作才真正发生。 当然,在真正拷贝的时候,你可以选择只拷贝修改的那一部分,或者拷贝全部数据。 在上面的例子中,由于内存通常是按页面来管理的,拷贝时只拷贝相关的页面,而不是拷贝整个内存空间。 写时拷贝(COW)对性能上的贡献很大,差不多任何带MMU的OS都会采用。 当然它不限于内核空间,在用户空间也可以使用,比如像一些String类的实现也采用了这种方法。 代码示例(MFC: strcore.cpp): 拷贝时只是增加引用计数: CString: : CString(constCString&stringSrc) { ASSERT(stringSrc.GetData()->nRefs! =0); if(stringSrc.GetData()->nRefs>=0) { ASSERT(stringSrc.GetData()! =_afxDataNil); m_pchData=stringSrc.m_pchData; InterlockedIncrement(&GetData()->nRefs); } else { Init(); *this=stringSrc.m_pchData; } } 修改前才拷贝: voidCString: : MakeUpper() { CopyBeforeWrite(); _tcsupr(m_pchData); } voidCString: : MakeLower() { CopyBeforeWrite(); _tcslwr(m_pchData); } 拷贝动作: voidCString: : CopyBeforeWrite() { if(GetData()->nRefs>1) { CStringData*pData=GetData(); Release(); AllocBuffer(pData->nDataLength); memcpy(m_pchData,pData->data(),(pData->nDataLength+1)*sizeof(TCHAR)); } ASSERT(GetData()->nRefs<=1); } 4.固定大小分配 频繁的分配大量小块内存是内存管理器的挑战之一。 首先是空间利用率上的问题: 由于内存管理本身的需要一些辅助内存,假设每块内存需要8字节用作辅助内存,那么即使只要分配4个字节这样的小块内存,仍然要浪费8字节内存。 一块小内存不要紧,若存在大量小块内存,所浪费的空间就可观了。 其次是内存碎片问题: 频繁分配大量小块内存,很容易造成内存碎片问题。 这不但降低内存管理器的效率,同时由于这些内存不连续,虽然空闲却无法使用。 此时可以采用固定大小分配,这种方式通常也叫做缓冲池(pool)分配。 缓冲池(pool)先分配一块或者多块连续的大块内存,把它们分成N块大小相等的小块内存,然后进行二次分配。 由于这些小块内存大小是固定的,管理大开销非常小,往往只要一个标识位用于标识该单元是否空闲,或者甚至不需要任何标识位。 另外,缓冲池(pool)中所有这些小块内存分布在一块或者几块连接内存上,所以不会有内存碎片问题。 固定大小分配运用比较广泛,差不多所有的内存管理器都用这种方法来对付小块内存,比如glibc、STLPort和linux的slab等。 5.会话缓冲池分配(SessionPool) 服务器要长时间运行,内存泄露是它的威胁之一,任何小概率的内存泄露,都可能会累积到具有破坏性的程度。 从它们的运行模式来看,它们总是不断的重复某个过程,而在这个过程中,又要分配大量(次数)内存。 比如像WEB服务器,它不断的处理HTTP请求,我们把一次HTTP请求,称为一次会话。 一次会话要经过很多阶段,在这个过程要做各种处理,要多次分配内存。 由于处理比较复杂,分配内存的地方又比较多,内存泄露可以说防不甚防。 针对这种情况,我们可以采用会话缓冲池分配。 它基于多次分配一次释放的策略,在过程开始时创建会话缓冲池(SessionPool),这个过程中所有内存分配都通过会话缓冲池(SessionPool)来分配,当这个过程完成时,销毁掉会话缓冲池(SessionPool),即释放这个过程中所分配的全部内存。 因为只需要释放一次,内
- 配套讲稿:
如PPT文件的首页显示word图标,表示该PPT已包含配套word讲稿。双击word图标可打开word文档。
- 特殊限制:
部分文档作品中含有的国旗、国徽等图片,仅作为作品整体效果示例展示,禁止商用。设计者仅对作品中独创性部分享有著作权。
- 关 键 词:
- 大内 高手 调试 手段 原理
![提示](https://static.bdocx.com/images/bang_tan.gif)