一个由c/C++编译的程序占用的内存分为以下几个部分
1、栈区(stack)— 由编译器自动分配释放 ,存放函数的参数值,局部变量的值等。其操作方式类似于数据结构中的栈。
2、堆区(heap) — 一般由程序员分配释放, 若程序员不释放,程序结束时可能由OS回收 。注意它与数据结构中的堆是两回事,分配方式倒是类似于链表,呵呵。
3、全局区(静态区)(static)—,全局变量和静态变量的存储是放在一块的,初始化的全局变量和静态变量在一块区域, 未初始化的全局变量和未初始化的静态变量在相邻的另一块区域。- 程序结束后有系统释放
4、文字常量区—常量字符串就是放在这里的。程序结束后由系统释放
5、程序代码区—存放函数体的二进制代码。
例子程序
这是一个前辈写的,非常详细
-
//main.cpp
-
int a = 0; //全局初始化区
-
int a = 0; //全局初始化区
-
char *p1; //全局未初始化区
-
main() {
-
int b; //栈
-
char s[] = "abc"; //栈
-
char *p2; //栈
-
char *p3 = "123456"; //123456\0在常量区,p3在栈上。
-
static int c = 0; //全局(静态)初始化区
-
p1 = (char *)malloc(10);
-
p2 = (char *)malloc(20);
-
//分配得来得10和20字节的区域就在堆区。
-
strcpy(p1, "123456"); //123456\0放在常量区,编译器可能会将它与p3所指向的"123456"优化成一个地方。
-
}
二、堆和栈的理论知识
2.1申请方式
stack:
由系统自动分配。例如,声明在函数中一个局部变量 int b; 系统自动在栈中为b开辟空间
heap:
需要程序员自己申请,并指明大小,在c中malloc函数
如p1 = (char *)malloc(10);
在C++中用new运算符
如p2 = (char *)malloc(10);
但是注意p1、p2本身是在栈中的。
2.2 申请后系统的响应
栈:只要栈的剩余空间大于所申请空间,系统将为程序提供内存,否则将报异常提示栈溢出。
堆:首先应该知道操作系统有一个记录空闲内存地址的链表,当系统收到程序的申请时,
会遍历该链表,寻找第一个空间大于所申请空间的堆结点,然后将该结点从空闲结点链表中删除,并将该结点的空间分配给程序,另外,对于大多数系统,会在这块内存空间中的首地址处记录本次分配的大小,这样,代码中的delete语句才能正确的释放本内存空间。另外,由于找到的堆结点的大小不一定正好等于申请的大小,系统会自动的将多余的那部分重新放入空闲链表中。
2.3 申请大小的限制
栈:在Windows下,栈是向低地址扩展的数据结构,是一块连续的内存的区域。这句话的意思是栈顶的地址和栈的最大容量是系统预先规定好的,在WINDOWS下,栈的大小是2M(也有的说是1M,总之是一个编译时就确定的常数),如果申请的空间超过栈的剩余空间时,将提示overflow。因此,能从栈获得的空间较小。
堆:堆是向高地址扩展的数据结构,是不连续的内存区域。这是由于系统是用链表来存储的空闲内存地址的,自然是不连续的,而链表的遍历方向是由低地址向高地址。堆的大小受限于计算机系统中有效的虚拟内存。由此可见,堆获得的空间比较灵活,也比较大。
2.4 申请效率的比较:
栈由系统自动分配,速度较快。但程序员是无法控制的。
堆是由new分配的内存,一般速度比较慢,而且容易产生内存碎片,不过用起来最方便.
另外,在WINDOWS下,最好的方式是用VirtualAlloc分配内存,他不是在堆,也不是在栈是直接在进程的地址空间中保留一快内存,虽然用起来最不方便。但是速度快,也最灵活。
2.5 堆和栈中的存储内容
栈:在函数调用时,第一个进栈的是主函数中后的下一条指令(函数调用语句的下一条可执行语句)的地址,然后是函数的各个参数,在大多数的C编译器中,参数是由右往左入栈的,然后是函数中的局部变量。注意静态变量是不入栈的。
当本次函数调用结束后,局部变量先出栈,然后是参数,最后栈顶指针指向最开始存的地址,也就是主函数中的下一条指令,程序由该点继续运行。
堆:一般是在堆的头部用一个字节存放堆的大小。堆中的具体内容有程序员安排。
2.6 存取效率的比较
char s1[] = "aaaaaaaaaaaaaaa";
char *s2 = "bbbbbbbbbbbbbbbbb";
aaaaaaaaaaa是在运行时刻赋值的;
而bbbbbbbbbbb是在编译时就确定的;
但是,在以后的存取中,在栈上的数组比指针所指向的字符串(例如堆)快。
比如:
-
#include
-
void main() {
-
char a = 1;
-
char c[] = "1234567890";
-
char *p ="1234567890";
-
a = c[1];
-
a = p[1];
-
return;
-
}
对应的汇编代码
-
10: a = c[1];
-
00401067 8A 4D F1 mov cl,byte ptr [ebp-0Fh]
-
0040106A 88 4D FC mov byte ptr [ebp-4],cl
-
11: a = p[1];
-
0040106D 8B 55 EC mov edx,dword ptr [ebp-14h]
-
00401070 8A 42 01 mov al,byte ptr [edx+1]
-
00401073 88 45 FC mov byte ptr [ebp-4],al
第一种在读取时直接就把字符串中的元素读到寄存器cl中,而第二种则要先把指针值读到edx中,在根据edx读取字符,显然慢了。
2.7小结:
堆和栈的区别可以用如下的比喻来看出:
使用栈就象我们去饭馆里吃饭,只管点菜(发出申请)、付钱、和吃(使用),吃饱了就走,不必理会切菜、洗菜等准备工作和洗碗、刷锅等扫尾工作,他的好处是快捷,但是自由度小。
使用堆就象是自己动手做喜欢吃的菜肴,比较麻烦,但是比较符合自己的口味,而且自由度大。
三 、windows进程中的内存结构
在阅读本文之前,如果你连堆栈是什么多不知道的话,请先阅读文章后面的基础知识。
接触过编程的人都知道,高级语言都能通过变量名来访问内存中的数据。那么这些变量在内存中是如何存放的呢?程序又是如何使用这些变量的呢?下面就会对此进行深入的讨论。下文中的C语言代码如没有特别声明,默认都使用VC编译的release版。
首先,来了解一下 C 语言的变量是如何在内存分部的。C 语言有全局变量(Global)、本地变量(Local),静态变量(Static)、寄存器变量(Regeister)。每种变量都有不同的分配方式。先来看下面这段代码:
-
#include <stdio.h>
-
int g1=0, g2=0, g3=0;
-
int main()
-
{
-
static int s1=0, s2=0, s3=0;
-
int v1=0, v2=0, v3=0;
-
//打印出各个变量的内存地址
-
printf("0x%08x\n",&v1); //打印各本地变量的内存地址
-
printf("0x%08x\n",&v2);
-
printf("0x%08x\n\n",&v3);
-
printf("0x%08x\n",&g1); //打印各全局变量的内存地址
-
printf("0x%08x\n",&g2);
-
printf("0x%08x\n\n",&g3);
-
printf("0x%08x\n",&s1); //打印各静态变量的内存地址
-
printf("0x%08x\n",&s2);
-
printf("0x%08x\n\n",&s3);
-
return 0;
-
}
编译后的执行结果是:
-
0x0012ff78
-
0x0012ff7c
-
0x0012ff80
-
0x004068d0
-
0x004068d4
-
0x004068d8
-
0x004068dc
-
0x004068e0
-
0x004068e4
输出的结果就是变量的内存地址。其中v1,v2,v3是本地变量,g1,g2,g3是全局变量,s1,s2,s3是静态变量。你可以看到这些变量在内存是连续分布的,但是本地变量和全局变量分配的内存地址差了十万八千里,而全局变量和静态变量分配的内存是连续的。这是因为本地变量和全局/静态变量是分配在不同类型的内存区域中的结果。对于一个进程的内存空间而言,可以在逻辑上分成3个部份:代码区,静态数据区和动态数据区。动态数据区一般就是“堆栈”。“栈(stack)”和“堆(heap)”是两种不同的动态数据区,栈是一种线性结构,堆是一种链式结构。进程的每个线程都有私有的“栈”,所以每个线程虽然代码一样,但本地变量的数据都是互不干扰。一个堆栈可以通过“基地址”和“栈顶”地址来描述。全局变量和静态变量分配在静态数据区,本地变量分配在动态数据区,即堆栈中。程序通过堆栈的基地址和偏移量来访问本地变量。
-
├———————┤低端内存区域
-
│ …… │
-
├———————┤
-
│ 动态数据区 │
-
├———————┤
-
│ …… │
-
├———————┤
-
│ 代码区 │
-
├———————┤
-
│ 静态数据区 │
-
├———————┤
-
│ …… │
-
├———————┤高端内存区域
堆栈是一个先进后出的数据结构,栈顶地址总是小于等于栈的基地址。我们可以先了解一下函数调用的过程,以便对堆栈在程序中的作用有更深入的了解。不同的语言有不同的函数调用规定,这些因素有参数的压入规则和堆栈的平衡。windows API的调用规则和ANSI C的函数调用规则是不一样的,前者由被调函数调整堆栈,后者由调用者调整堆栈。两者通过“__stdcall”和“__cdecl”前缀区分。先看下面这段代码:
-
#include <stdio.h>
-
void __stdcall func(int param1,int param2,int param3)
-
{
-
int var1=param1;
-
int var2=param2;
-
int var3=param3;
-
printf("0x%08x\n",param1); //打印出各个变量的内存地址
-
printf("0x%08x\n",param2);
-
printf("0x%08x\n\n",param3);
-
printf("0x%08x\n",&var1);
-
printf("0x%08x\n",&var2);
-
printf("0x%08x\n\n",&var3);
-
return;
-
}
-
int main() {
-
func(1,2,3);
-
return 0;
-
}
编译后的执行结果是:
-
0x0012ff78
-
0x0012ff7c
-
0x0012ff80
-
0x0012ff68
-
0x0012ff6c
-
0x0012ff70
-
├———————┤<—函数执行时的栈顶(ESP)、低端内存区域
-
│ …… │
-
├———————┤
-
│ var 1 │
-
├———————┤
-
│ var 2 │
-
├———————┤
-
│ var 3 │
-
├———————┤
-
│ RET │
-
├———————┤<—“__cdecl”函数返回后的栈顶(ESP)
-
│ parameter 1 │
-
├———————┤
-
│ parameter 2 │
-
├———————┤
-
│ parameter 3 │
-
├———————┤<—“__stdcall”函数返回后的栈顶(ESP)
-
│ …… │
-
├———————┤<—栈底(基地址 EBP)、高端内存区域
上图就是函数调用过程中堆栈的样子了。首先,三个参数以从右到左的次序压入堆栈,先压“param3”,再压“param2”,最后压入“param1”;然后压入函数的返回地址(RET),接着跳转到函数地址接着执行(这里要补充一点,介绍UNIX下的缓冲溢出原理的文章中都提到在压入RET后,继续压入当前EBP,然后用当前ESP代替EBP。然而,有一篇介绍windows下函数调用的文章中说,在windows下的函数调用也有这一步骤,但根据我的实际调试,并未发现这一步,这还可以从param3和var1之间只有4字节的间隙这点看出来);第三步,将栈顶(ESP)减去一个数,为本地变量分配内存空间,上例中是减去12字节(ESP=ESP-3*4,每个int变量占用4个字节);接着就初始化本地变量的内存空间。由于“__stdcall”调用由被调函数调整堆栈,所以在函数返回前要恢复堆栈,先回收本地变量占用的内存(ESP=ESP+3*4),然后取出返回地址,填入EIP寄存器,回收先前压入参数占用的内存(ESP=ESP+3*4),继续执行调用者的代码。参见下列汇编代码:
-
;--------------func 函数的汇编代码-------------------
-
:00401000 83EC0C sub esp, 0000000C //创建本地变量的内存空间
-
:00401003 8B442410 mov eax, dword ptr [esp+10]
-
:00401007 8B4C2414 mov ecx, dword ptr [esp+14]
-
:0040100B 8B542418 mov edx, dword ptr [esp+18]
-
:0040100F 89442400 mov dword ptr [esp], eax
-
:00401013 8D442410 lea eax, dword ptr [esp+10]
-
:00401017 894C2404 mov dword ptr [esp+04], ecx
-
……………………(省略若干代码)
-
:00401075 83C43C add esp, 0000003C ;恢复堆栈,回收本地变量的内存空间
-
:00401078 C3 ret 000C ;函数返回,恢复参数占用的内存空间
-
;如果是“__cdecl”的话,这里是“ret”,堆栈将由调用者恢复
-
;-------------------函数结束-------------------------
-
;--------------主程序调用func函数的代码--------------
-
:00401080 6A03 push 00000003 //压入参数param3
-
:00401082 6A02 push 00000002 //压入参数param2
-
:00401084 6A01 push 00000001 //压入参数param1
-
:00401086 E875FFFFFF call 00401000 //调用func函数
-
;如果是“__cdecl”的话,将在这里恢复堆栈,“add esp, 0000000C”
聪明的读者看到这里,差不多就明白缓冲溢出的原理了。先来看下面的代码:
-
#include <stdio.h>
-
#include <string.h>
-
void __stdcall func() {
-
char lpBuff[8]="\0";
-
strcat(lpBuff,"AAAAAAAAAAA");
-
return;
-
}
-
int main() {
-
func();
-
return 0;
-
}
编译后执行一下回怎么样?哈,“”0x00414141”指令引用的”0x00000000”内存。该内存不能为”read”。”,“非法操作”喽!”41”就是”A”的16进制的ASCII码了,那明显就是strcat这句出的问题了。”lpBuff”的大小只有8字节,算进结尾的\0,那strcat最多只能写入7个”A”,但程序实际写入了11个”A”外加1个\0。再来看看上面那幅图,多出来的4个字节正好覆盖了RET的所在的内存空间,导致函数返回到一个错误的内存地址,执行了错误的指令。如果能精心构造这个字符串,使它分成三部分,前一部份仅仅是填充的无意义数据以达到溢出的目的,接着是一个覆盖RET的数据,紧接着是一段shellcode,那只要这个RET地址能指向这段shellcode的第一个指令,那函数返回时就能执行shellcode了。但是软件的不同版本和不同的运行环境都可能影响这段shellcode在内存中的位置,那么要构造这个RET是十分困难的。一般都在RET和shellcode之间填充大量的NOP指令,使得exploit有更强的通用性。
-
├———————┤<—低端内存区域
-
│ …… │
-
├———————┤<—由exploit填入数据的开始
-
│ │
-
│ buffer │<—填入无用的数据
-
│ │
-
├———————┤
-
│ RET │<—指向shellcode,或NOP指令的范围
-
├———————┤
-
│ NOP │
-
│ …… │<—填入的NOP指令,是RET可指向的范围
-
│ NOP │
-
├———————┤
-
│ │
-
│ shellcode │
-
│ │
-
├———————┤<—由exploit填入数据的结束
-
│ …… │
-
├———————┤<—高端内存区域
windows下的动态数据除了可存放在栈中,还可以存放在堆中。了解C++的朋友都知道,C++可以使用new关键字来动态分配内存。来看下面的C++代码:
-
#include <stdio.h>
-
#include <iostream.h>
-
#include <windows.h>
-
void func()
-
{
-
char *buffer=new char[128];
-
char bufflocal[128];
-
static char buffstatic[128];
-
printf("0x%08x\n",buffer); //打印堆中变量的内存地址
-
printf("0x%08x\n",bufflocal); //打印本地变量的内存地址
-
printf("0x%08x\n",buffstatic); //打印静态变量的内存地址
-
}
-
void main() {
-
func();
-
return;
-
}
程序执行结果为:
-
0x004107d0
-
0x0012ff04
-
0x004068c0
可以发现用new关键字分配的内存即不在栈中,也不在静态数据区。VC编译器是通过windows下的“堆(heap)”来实现new关键字的内存动态分配。在讲“堆”之前,先来了解一下和“堆”有关的几个API函数:
-
- HeapAlloc 在堆中申请内存空间
-
- HeapCreate 创建一个新的堆对象
-
- HeapDestroy 销毁一个堆对象
-
- HeapFree 释放申请的内存
-
- HeapWalk 枚举堆对象的所有内存块
-
- GetProcessHeap 取得进程的默认堆对象
-
- GetProcessHeaps 取得进程所有的堆对象
-
- LocalAlloc
-
- GlobalAlloc
当进程初始化时,系统会自动为进程创建一个默认堆,这个堆默认所占内存的大小为1M。堆对象由系统进行管理,它在内存中以链式结构存在。通过下面的代码可以通过堆动态申请内存空间:
-
HANDLE hHeap=GetProcessHeap();
-
char *buff=HeapAlloc(hHeap,0,8);
其中hHeap是堆对象的句柄,buff是指向申请的内存空间的地址。那这个hHeap究竟是什么呢?它的值有什么意义吗?看看下面这段代码吧:
-
#pragma comment(linker,"/entry:main") //定义程序的入口
-
#include <windows.h>
-
_CRTIMP int (__cdecl *printf)(const char *, ...); //定义STL函数printf
-
/*---------------------------------------------------------------------------
-
写到这里,我们顺便来复习一下前面所讲的知识:
-
(*注)printf函数是C语言的标准函数库中函数,VC的标准函数库由msvcrt.dll模块实现。
-
由函数定义可见,printf的参数个数是可变的,函数内部无法预先知道调用者压入的参数个数,函数只能通过分析第一个参数字符串的格式来获得压入参数的信息,由于这里参数的个数是动态的,所以必须由调用者来平衡堆栈,这里便使用了__cdecl调用规则。BTW,Windows系统的API函数基本上是__stdcall调用形式,只有一个API例外,那就是wsprintf,它使用__cdecl调用规则,同printf函数一样,这是由于它的参数个数是可变的缘故。
-
---------------------------------------------------------------------------*/
-
void main()
-
{
-
HANDLE hHeap=GetProcessHeap();
-
char *buff=HeapAlloc(hHeap,0,0x10);
-
char *buff2=HeapAlloc(hHeap,0,0x10);
-
HMODULE hMsvcrt=LoadLibrary("msvcrt.dll");
-
printf=(void *)GetProcAddress(hMsvcrt,"printf");
-
printf("0x%08x\n",hHeap);
-
printf("0x%08x\n",buff);
-
printf("0x%08x\n\n",buff2);
-
}
执行结果为:
-
0x00130000
-
0x00133100
-
0x00133118
hHeap的值怎么和那个buff的值那么接近呢?其实hHeap这个句柄就是指向HEAP首部的地址。在进程的用户区存着一个叫PEB(进程环境块)的结构,这个结构中存放着一些有关进程的重要信息,其中在PEB首地址偏移0x18处存放的ProcessHeap就是进程默认堆的地址,而偏移0x90处存放了指向进程所有堆的地址列表的指针。windows有很多API都使用进程的默认堆来存放动态数据,如windows 2000下的所有ANSI版本的函数都是在默认堆中申请内存来转换ANSI字符串到Unicode字符串的。对一个堆的访问是顺序进行的,同一时刻只能有一个线程访问堆中的数据,当多个线程同时有访问要求时,只能排队等待,这样便造成程序执行效率下降。
最后来说说内存中的数据对齐
。所位数据对齐,是指数据所在的内存地址必须是该数据长度的整数倍,DWORD数据的内存起始地址能被4除尽,WORD数据的内存起始地址能被2除尽,x86 CPU能直接访问对齐的数据,当他试图访问一个未对齐的数据时,会在内部进行一系列的调整,这些调整对于程序来说是透明的,但是会降低运行速度,所以编译器在编译程序时会尽量保证数据对齐。同样一段代码,我们来看看用VC、Dev-C++和lcc三个不同编译器编译出来的程序的执行结果:
-
#include <stdio.h>
-
int main()
-
{
-
int a;
-
char b;
-
int c;
-
printf("0x%08x\n",&a);
-
printf("0x%08x\n",&b);
-
printf("0x%08x\n",&c);
-
return 0;
-
}
这是用VC编译后的执行结果:
-
0x0012ff7c
-
0x0012ff7b
-
0x0012ff80
变量在内存中的顺序:b(1字节)-a(4字节)-c(4字节)。
这是用Dev-C++编译后的执行结果:
-
0x0022ff7c
-
0x0022ff7b
-
0x0022ff74
变量在内存中的顺序:c(4字节)-中间相隔3字节-b(占1字节)-a(4字节)。
这是用lcc编译后的执行结果:
-
0x0012ff6c
-
0x0012ff6b
-
0x0012ff64
变量在内存中的顺序:同上。
三个编译器都做到了数据对齐,但是后两个编译器显然没VC“聪明”,让一个char占了4字节,浪费内存。
/***************************************************************************
MFC与Qt的内存管理
最近在做MFC向Qt的移植,在内存管理方面遇到了很头疼的问题,虽然不知道问题到底出在哪,先了解下这两个库的内存管理方式。于是转载两篇关于内存管理的文章。
一. Qt内存管理:
在Qt的程序中经常会看到只有new而不delete的情况,其实是因为Qt有一套回收内存的机制,主要的规则如下:
1.所有继承自QObject类的类,如果在new的时候指定了父亲,那么它的清理时在父亲被delete的时候delete的,所以如果一个程序中,所有的QObject类都指定了父亲,那么他们是会一级级的在最上面的父亲清理时被清理,而不用自己清理;
2. 程序通常最上层会有一个根的QObject,就是放在setCentralWidget()中的那个QObject,这个QObject在 new的时候不必指定它的父亲,因为这个语句将设定它的父亲为总的QApplication,当整个QApplication没有时它就自动清理,所以也 无需清理。这里Qt4和Qt3有不同,Qt3中用的是setmainwidget函数,但是这个函数不作为里面QObject的父亲,所以Qt3中这个顶 层的QObject要自行销毁)。
3.这是有人可能会问那如果我自行delete掉这些Qt接管负责销毁的指针了会出现什么情况呢,如果 这么做的话,正常情况下被delete的对象的父亲会知道这件事情,它会知道它的儿子被你直接delete了,这样它会将这个儿子移出它的列表,并且重新 构建显示内容,但是直接这样做是有风险的!也就是要说的下一条。
4.当一个QObject正在接受事件队列时如果中途被你DELETE掉 了,就是出现问题了,所以Qt中建议大家不要直接DELETE掉一个QObject,如果一定要这样做,要使用QObject的 deleteLater()函数,它会让所有事件都发送完一切处理好后马上清除这片内存,而且就算调用多次的deletelater也不会有问题。
5.Qt 不建议在一个QObject对象的父亲的范围之外持有对这个对象的指针,因为如果这样外面的指针很可能不会察觉这个QObject被释放,会出现错误。如 果一定要这样,就要记住你在哪这样做了,然后抓住那个被你违规使用的QObject的destroyed()信号,当它没有时赶快置零你的外部指针。当然 我认为这样做是及其麻烦也不符合高效率编程规范的,所以如果要这样在外部持有QObject的指针,建议使用引用或者用智能指针,如Qt就提供了智能指针 针对这些情况,见最后一条。
6.Qt中的智能指针封装为QPointer类,所有QObject的子类都可以用这个智能指针来包装,很多用法与普通指针一样,可以详见Qt assistant
通过调查这个Qt的内存管理功能,发现了很多东西,现在觉得虽然这个Qt弄的有点小复杂,但是使用起来还是很方便的,最后要说的是某些内存泄露的检测工具会认为Qt的程序因为这种方式存在内存泄露,发现时大可不必理会~
原帖地址:http://blog.csdn.net/leonwei/archive/2009/01/04/3703598.aspx
二. MFC内存分配方式与调试机制
1 内存分配
1.1 内存分配函数
MFCWin32或者C语言的内存分配API,有四种内存分配API可供使用。
Win32的堆分配函数
每一个进程都可以使用堆分配函数创建一个私有的堆──调用进程地址空间的一个或者多个页面。DLL创建的私有堆必定在调用DLL的进程的地址空间内,只能被调用进程访问。
HeapCreate用来创建堆;HeapAlloc用来从堆中分配一定数量的空间,HeapAlloc分配的内存是不能移动的;HeapSize可以确定从堆中分配的空间的大小;HeapFree用来释放从堆中分配的空间;HeapDestroy销毁创建的堆。
Windows传统的全局或者局部内存分配函数
由于Win32采用平面内存结构模式,Win32下的全局和局部内存函数除了名字不同外,其他完全相同。任一函数都可以用来分配任意大小的内存(仅仅受可用物理内存的限制)。用法可以和Win16下基本一样。
Win32下保留这类函数保证了和Win16的兼容。
C语言的标准内存分配函数
C语言的标准内存分配函数包括以下函数:
malloc,calloc,realloc,free,等。
这些函数最后都映射成堆API函数,所以,malloc分配的内存是不能移动的。这些函数的调式版本为
malloc_dbg,calloc_dbg,realloc_dbg,free_dbg,等。
Win32的虚拟内存分配函数
虚拟内存API是其他API的基础。虚拟内存API以页为最小分配单位,X86上页长度为4KB,可以用GetSystemInfo函数提取页长度。虚拟内存分配函数包括以下函数:
该函数用来分配一定范围的虚拟页。参数1指定起始地址;参数2指定分配内存的长度;参数3指定分配方式,取值 MEM_COMMINT或者MEM_RESERVE;参数4指定控制访问本次分配的内存的标识,取值为PAGE_READONLY、 PAGE_READWRITE或者PAGE_NOACCESS。
该函数功能类似于VirtualAlloc,但是允许指定进程process。VirtaulFree、VirtualProtect、VirtualQuery都有对应的扩展函数。
该函数用来回收或者释放分配的虚拟内存。参数1指定希望回收或者释放内存的基地址;如果是回收,参数2可以指向虚 拟地址范围内的任何地方,如果是释放,参数2必须是VirtualAlloc返回的地址;参数3指定是否释放或者回收内存,取值为 MEM_DECOMMINT或者MEM_RELEASE。
该函数用来把已经分配的页改变成保护页。参数1指定分配页的基地址;参数2指定保护页的长度;参数3指定页的保护属性,取值PAGE_READ、PAGE_WRITE、PAGE_READWRITE等等;参数4用来返回原来的保护属性。
该函数用来查询内存中指定页的特性。参数1指向希望查询的虚拟地址;参数2是指向内存基本信息结构的指针;参数3指定查询的长度。
该函数用来锁定内存,锁定的内存页不能交换到页文件。参数1指定要锁定内存的起始地址;参数2指定锁定的长度。
参数1指定要解锁的内存的起始地址;参数2指定要解锁的内存的长度。
1.2 C++的new 和 delete操作符
MFC定义了两种作用范围的new和delete操作符。对于new,不论哪种,参数1类型必须是size_t,且返回void类型指针。
全局范围内的new和delete操作符
原型如下:
void _cdecl ::operator new(size_t nSize);
void __cdecl operator delete(void* p);
调试版本:
void* __cdecl operator new(size_t nSize, int nType,
LPCSTR lpszFileName, int nLine)
类定义的new和delete操作符
原型如下:
类的operator new操作符是类的静态成员函数,对该类的对象来说将覆盖全局的operator new。全局的operator new用来给内部类型对象(如int)、没有定义operator new操作符的类的对象分配内存。
new操作符被映射成malloc或者malloc_dbg,delete被映射成free或者free_dbg。
2 调试手段
MFC应用程序可以使用C运行库的调试手段,也可以使用MFC提供的调试手段。两种调试手段分别论述如下。
2.1 C运行库提供和支持的调试功能
C运行库提供和支持的调试功能如下:
调试信息报告函数
用来报告应用程序的调试版本运行时的警告和出错信息。包括:
_CrtDbgReport 用来报告调试信息;
_CrtSetReportMode 设置是否警告、出错或者断言信息;
_CrtSetReportFile 设置是否把调试信息写入到一个文件。
条件验证或者断言宏:
断言宏主要有:
assert 检验某个条件是否满足,不满足终止程序执行。
验证函数主要有:
_CrtIsValidHeapPointer 验证某个指针是否在本地堆中;
_CrtIsValidPointer 验证指定范围的内存是否可以读写;
_CrtIsMemoryBlock 验证某个内存块是否在本地堆中。
内存(堆)调试:
malloc_dbg 分配内存时保存有关内存分配的信息,如在什么文件、哪一行分配的内存等。有一系列用来提供内存诊断的函数:
_CrtMemCheckpoint 保存内存快照在一个_CrtMemState结构中;
_CrtMemDifference 比较两个_CrtMemState;
_CrtMemDumpStatistics 转储输出一_CrtMemState结构的内容;
_CrtMemDumpAllObjectsSince 输出上次快照或程序开始执行以来在堆中分配的所有对象的信息;
_CrtDumpMemoryLeaks 检测程序执行以来的内存漏洞,如果有漏洞则输出所有分配的对象。
2.2 MFC提供的调试手段
MFC在C运行库提供和支持的调试功能基础上,设计了一些类、函数等来协助调试。
MFC的TRACE、ASSERT
ASSERT
使用ASSERT断言判定程序是否可以继续执行。
TRACE
使用TRACE宏显示或者打印调试信息。TRACE是通过函数AfxTrace实现的。由于AfxTrace函数使用了cdecl调用约定,故可以接受个数不定的参数,如同printf函数一样。它的定义和实现如下:
在程序源码中,可以控制是否显示跟踪信息,显示什么跟踪信息。如果全局变量afxTraceEnabled为 TRUE,则TRACE宏可以输出;否则,没有TRACE信息被输出。如果通过afxTraceFlags指定了跟踪什么消息,则输出有关跟踪信息,例如 为了指定“Multilple Application Debug”,令AfxTraceFlags|=traceMultiApp。可以跟踪的信息有:
这样,应用程序可以在需要的地方指定afxTraceEnabled的值打开或者关闭TRACE开关,指定AfxTraceFlags的值过滤跟踪信息。
Visual C++提供了一个TRACE工具,也可以用来完成上述功能。
为了显示消息信息,MFC内部定义了一个AFX_MAP_MESSAG类型的数组allMessages,储存了Windows消息和消息名映射对。例如:
MFC内部还使用函数_AfxTraceMsg显示跟踪消息,它可以接收一个字符串和一个MSG指针,然后,把该字符串和MSG的各个域的信息组合成一个大的字符串并使用AfxTrace显示出来。
allMessages和函数_AfxTraceMsg的详细实现可以参见AfxTrace.cpp。
MFC对象内容转储
对象内容转储是CObject类提供的功能,所有从它派生的类都可以通过覆盖虚拟函数DUMP来支持该功能。在讲述CObject类时曾提到过。
虚拟函数Dump的定义:
在使用Dump时,必须给它提供一个CDumpContext类型的参数,该参数指定的对象将负责输出调试信 息。为此,MFC提供了一个预定义的全局CDumpContext对象afxDump,它把调试信息输送给调试器的调试窗口。从前面AfxTrace的实 现可以知道,MFC使用了afxDump输出跟踪信息到调试窗口。
CDumpContext类没有基类,它提供了以文本形式输出诊断信息的功能。
例如:
MFC对象有效性检测
对象有效性检测是CObject类提供的功能,所有从它派生的类都可以通过覆盖虚拟函数AssertValid来支持该功能。在讲述CObject类时曾提到过。
虚拟函数AssertValid的定义:
使用ASSERT_VALID宏判断一个对象是否有效,该对象的类必须覆盖了AssertValid函数。形式为:ASSERT_VALID(pObject)。
另外,MFC提供了一些函数来判断地址是否有效,如:
AfxIsMemoryBlock,AfxIsString,AfxIsValidAddress。
3 内存诊断
MFC使用DEBUG_NEW来跟踪内存分配时的执行的源码文件和行数。
把#define new DEBUG_NEW插入到每一个源文件中,这样,调试版本就使用_malloc_dbg来分配内存。MFC Appwizard在创建框架文件时已经作了这样的处理。
AfxDoForAllObjects
MFC提供了函数AfxDoForAllObjects来追踪动态分配的内存对象,函数原型如下:
void AfxDoForAllObjects( void (*pfn)(CObject* pObject,
void* pContext), void* pContext );
其中:
参数1是一个函数指针,AfxDoForAllObjects对每个对象调用该指针表示的函数。
参数2将传递给参数1指定的函数。
AfxDoForAllObjects可以检测到所有使用new分配的CObject对象或者CObject类派生的对象,但全局对象、嵌入对象和栈中分配的对象除外。
内存漏洞检测
仅仅用于new的DEBUG版本分配的内存。
完成内存漏洞检测,需要如下系列步骤:
调用AfxEnableMemoryTracking(TRUE/FALSE)打开/关闭内存诊断。在调试版本下,缺省是打开的;关闭内存诊断可以加快程序执行速度,减少诊断输出。
使用MFC全局变量afxMemDF更精确地指定诊断输出的特征,缺省值是allocMemDF,可以取如下值或者这些值相或:
afxMemDF,delayFreeMemDF,checkAlwaysMemDF
其中:allocMemDF表示可以进行内存诊断输出;delayFreeMemDF表示是否是在应用程序结束时 才调用free或者delete,这样导致程序最大可能的分配内存;checkAlwaysMemDF表示每一次分配或者释放内存之后都调用函数 AfxCheckMemory进行内存检测(AfxCheckMemory检查堆中所有通过new分配的内存(不含malloc))。
这一步是可选步骤,非必须。
创建一个CMemState类型的变量oldMemState,调用CMemState的成员函数CheckPoint获得初次内存快照。
执行了系列内存分配或者释放之后,创建另一个CMemState类型变量newMemState,调用CMemState的成员函数CheckPoint获得新的内存快照。
创建第三个CMemState类型变量difMemState,调用CMemState的成员函数Difference比较oldMemState和newMemState,结果保存在变量difMemState中。如果没有不同,则返回FALSE,否则返回TRUE。
如果不同,则调用成员函数DumpStatistics输出比较结果。
/**********************************************************
Qt内存管理机制
前言
内存管理,是对软件中内存资源的分配与释放进行有效管理的方法和理论。
众所周知,内存管理是软件开发的一个重要的内容。软件规模越大,内存管理可能出现的问题越多。如果像C语言一样手动地管理内存,一会给开发人员带来巨大的负担,二是手动管理内存的可靠性较差。
Qt为软件开发人员提供了一套内存管理机制,用以替代手动内存管理。
下面开始逐条讲述Qt中的内存管理机制。
一脉相承的栈与堆的内存管理
了解C语言的同学都知道,C语言中的内存分配有两种形式:栈内存、堆内存。
栈内存
栈内存的管理是由编译器来做的,栈上申请的内存变量,生存期由所在作用域决定,超出作用域的栈内存变量会被编译器自动释放。
值得一提的是,作用域的显著标志是一对大括号,大括号内部即为作用域内部,大括号外部即为作用域外部。
参考下列代码:
int main() | |
{ | |
int a = 0; | |
return 1; | |
} |
变量a在栈内存上,main函数返回时,作用域结束,a的内存自动被释放。
从以上描述也可以看出,栈内存的使用是在编译器严密监管之下进行的,遵循严格的作用域规则,所以栈内存的大小、申请时机、释放时机都能在编译的时候确定。
堆内存
堆内存是另外一种管理方式。堆内存最大的特点是可以动态分配,即在运行时可以根据需要进行申请。当然随之而来的弊端也显而易见:需要开发人员对堆内存的释放进行严格管理,稍有疏漏会导致内存泄漏,甚至软件崩溃等问题。
参考下列代码:
int main() | |
{ | |
// 申请堆内存 | |
int *intArray = (int *)malloc(100); | |
// 使用堆内存... | |
// 释放堆内存 | |
free(intArray); | |
return 1; | |
} |
如上述代码,堆内存分配的写法区别于栈内存。C语言中,堆内存使用malloc分配,使用free释放。C++中可以使用new分配,使用delete释放。
至此,我们介绍了C语言中的内存管理方式。我们知道Qt是C++的框架,C++是对C语言的扩展,所以C语言中的内存管理方式(堆、栈)和动态内存管理(堆内存释放问题)存在的问题,在C++中仍然存在。所以Qt中自然而然也有相同的问题。说起来可能有点乱,下面用一张图来说明它们的关系:
那么,Qt是如何为我们解决动态内存管理问题的呢?下面开始正式讲解。
使用对象父子关系进行内存管理
使用对象父子关系进行内存管理的原理,简述为:
在创建类的对象时,为对象指定父对象指针。当父对象在某一时刻被销毁释放时,父对象会先遍历其所有的子对象,并逐个将子对象销毁释放。
为了直观理解上述过程,以如下代码为例进行说明:
#include <QApplication> | |
#include <QLabel> | |
int main(int argc, char *argv[]) | |
{ | |
QApplication a(argc, argv); | |
// 创建主窗口 | |
QWidget mainWidget; | |
mainWidget.resize(400, 300); | |
// 创建文字标签 | |
QLabel *label = new QLabel("Hello World!", &mainWidget); | |
// 显示主窗口 | |
mainWidget.show(); | |
return a.exec(); | |
} |
运行结果如下:
上述代码中,mainWidget为主窗口对象,类型为QWidget;label为子窗口对象,类型为QLabel *。
注意代码第13行,在创建label文本标签窗口对象时,new QLabel的第二个参数即为父对象地址(参考Qt Assistant中QLabel的说明文档),这里给的值是主窗口的地址。
在main函数退出时,mainWidget超出main函数作用域会析构,析构时会自动删除label窗口对象,所以这里,我们不需要再写一行:delete label; 来释放label的内存,很方便而且又能节省时间精力。
使用引用计数对内存进行管理
引用计数
引用计数可以说是软件开发人员必知必会的知识点,它在内存管理领域的地位是数一数二的。
引用计数的原理,还是力所能及地用最简单的话来描述:
引用计数需要从三个方面来全面理解:
使用场景:一个资源,多处使用(使用即引用)。
问题:到底谁来释放资源。
原理:使用一个整形变量来统计,此资源在多少个地方被使用,此变量称为引用计数。当某处使用完资源以后,将引用计数减1。当引用计数为0时,即没有任何地方再使用此资源时,真正释放此资源。这里的资源,在动态内存管理中就是指堆内存。
用一句话描述就是:谁最后使用资源,谁负责释放资源。
我们很容易联想到现实中的例子,就是日常生活中的刷碗问题的解决方案,即谁最后吃完谁刷碗。
需要说明的是,引用计数不仅仅是在内存管理中使用,它是一个通用的机制,凡是涉及到资源管理的问题,都可以考虑使用引用计数。
下面将要介绍基于引用计数原理的两种衍生的机制:显式共享和隐式共享。
显式共享
显式共享,是仅仅使用引用计数控制资源的生命周期的一种共享管理机制。这种机制下,无论资源在何处被引用,自始至终所有引用指向资源都是同一个。
之所以叫显式共享,是因为这种共享方式很直接,没有隐含的操作,如:Copy on Write写时拷贝(见隐式共享的相关说明)。如果想要拷贝并建立新的引用计数,必须手动调用detach()函数。
从使用者的角度看,从头到尾资源只有一份,一个地方修改了,另一个地方就能读取到修改后的资源。
**相关Qt类:**QExplicitlySharedDataPointer,更加深入的用法和编码,需要参考Qt文档中的相关说明及Demo。
隐式共享
隐式共享,也是一种基于引用计数的控制资源的生命周期的共享管理机制。
隐式共享,对不同的操作有不同的处理:
-
读取时,在所有引用的地方使用同一个资源;
-
在写入、修改时自动复制一份资源出来做修改,自动脱离原始的引用计数,因为是新的资源,所以要建立新的引用计数。这种操作叫Copy on Write写时复制技术,是自动隐含进行的。
从使用者的角度看,每个使用者都像是拥有独立的一份资源。在一个地方修改,修改的只是原始资源的拷贝,不会影响原始资源的内容,自然就不会影响到其他使用者。所以这种共享方式称为隐式共享。
相关Qt类有QString、QByteArray、QImage、QList、QMap、QHash等。
推荐阅读:Qt文档中的Implicit Sharing专题。
智能指针
智能指针是对C/C++指针的扩展,同样基于引用计数。
智能指针和显示共享和隐式共享有何区别?它们区别是:智能指针是轻量级的引用计数,它将显式共享、隐式共享中的引用计数实现部分单独提取了出来,制作成模板类,形成了多种特性各异的指针。
例如,QString除了实现引用计数,还实现了字符串相关的丰富的操作接口。QList也实现了引用计数,还实现了列表这种数据结构的各种操作。可以说,显式共享和隐式共享一般是封装在功能类中的,不需要开发者来管理。
智能指针将引用计数功能剥离出来,为Qt开发者提供了便捷的引用计数基础设施。
强(智能)指针
Qt中的强指针实现类是:QSharedPointer,此类是模板类,可以指向多种类型的数据,主要用来管理堆内存。关于QSharedPointer在Qt Assistant中有详细描述。
它的原理和显式共享一样:最后使用的地方负责释放删除资源,如类对象、内存块。
强指针中的“强”,是指每多一个使用者,引用计数都会老老实实地**+1**。而弱指针就不同,下面就接着讲解弱指针。
弱(智能)指针
Qt中的弱指针实现类是QWeakPointer,此类亦为模板类,可以指向多种类型的数据,同样主要用来管理堆内存。关于QWeakPointer在Qt Assistant中有详细描述。
弱指针只能从强指针QSharedPointer转化而来,获取弱指针,不增加引用计数,它只是一个强指针的观察者,观察而不干预。只要强指针存在,弱指针也可以转换成强指针。可见弱指针和强指针是一对形影不离的组合,通常结合起来使用。
局部指针
局部指针,是一种超出作用域自动删除、释放堆内存、对象的工具。它结合了栈内存管理和堆内存管理的优点。
Qt中的实现类有:QScopedPointer,QScopedArrayPointer,具体可以参考Qt Assistant。
观察者指针
上面说弱指针的时候,讲到过观察者。观察者是指仅仅做查询作用的指针,不会影响到引用计数。
Qt中的观察者指针是QPointer,它必须指向QObject的子类对象,才能对对象生命周期进行观察。因为只有QObject子类才会在析构的时候通知QPointer已失效。
QPointer是防止悬挂指针(即野指针)的有效手段,因为所指对象一旦被删除,QPointer会自动置空,在使用时,判断指针是否为空即可,不为空说明对象可以使用,不会产生内存访问错误的问题。
总结
本篇文章讲解了Qt中的各种内存管理机制,算是做了一个比较全面的描述。
之所以说是必读,是因为笔者在工作中发现,内存管理确实非常重要。Qt内存管理机制是贯穿整个Qt中所有类的核心线索之一,搞懂了内存管理
- 能在脑海中形成内存中对象的布局图,写代码的时候才能下笔如有神,管理起项目中众多的对象才能游刃有余,提高开发效率;
- 能够减少bug的产生。有经验的开发者应该知道,内存问题很难调试定位到具体的位置,往往导致奇怪的bug出现。
- 能够帮助理解Qt众多类的底层不变的逻辑,学起来更容易。
本文只是对Qt中内存管理进行了梳理,无法涵盖很多细节问题,读者需要花一些时间去详细阅读Qt助手文档,最好是写几个demo测试验证。花时间是值得的,因为技术是日新月异的,但是核心的原理变化是不大的。Qt中的内存管理思想和方法,在很多语言、框架中(Python、Objective C、JavaScript等等)都有类似的应用。
值得一提的是,之所以Qt中具有各种各样的内存管理方式,是因为它能够减轻开发者的负担,更加专注于业务代码的实现,而不是被内存问题折腾的焦头烂额。不使用Qt中的内存管理,只用C的手动内存管理仍然可以写可以运行的代码!前提是不考虑成本问题,并假设开发者在内存问题上不会犯错。总之一句话,不要对立各种技术,每种技术都有适用的场景,抛开场景谈方法都是不理智的。
/*********************************************************************
内存为程序分配空间的四种分配方式
存储器是个宝贵但却有限的资源。一流的操作系统,需要能够有效地管理及利用存储器。
内存为程序分配空间有四种分配方式:
- 1、连续分配方式
- 2、基本分页存储管理方式
- 3、基本分段存储管理方式
- 4、段页式存储管理方式
连续分配方式
首先讲连续分配方式。连续分配方式出现的时间比较早,曾广泛应用于20世纪60~70年代的OS中,但是它至今仍然在内存管理方式中占有一席之地,原因在于它实现起来比较方便,所需的硬件支持最少。连续分配方式又可细分为四种:单一连续分配、固定分区分配、动态分区分配和动态重定位分区分配。
其中固定分区的分配方式,因为分区固定,所以缺乏灵活性,即当程序太小时,会造成内存空间的浪费(内部碎片);程序太大时,一个分区又不足以容纳,致使程序无法运行(外部碎片)。但尽管如此,当一台计算机去控制多个相同对象的时候,由于这些对象内存大小相同,所以完全可以采用这种内存管理方式,而且是最高效的。这里我们可以看出存储器管理机制的多面性:没有那种存储器管理机制是完全没有用的,在适合的场合下,一种被认为最不合理的分配方案却可能称为最高效的分配方案。一切都要从实际问题出发,进行设计。
为了解决固定分区分配方式的缺乏灵活性,出现了动态分配方式。动态分配方式采用一些寻表(Eg:空闲链表)的方式,查找能符合程序需要的空闲内存分区。但代价是增加了系统运行的开销,而且内存空闲表本身是一个文件,必然会占用一部分宝贵的内存资源,而且有些算法还会增加内存碎片。
可重定位分区分配通过对程序实现成定位,从而可以将内存块进行搬移,将小块拼成大块,将小空闲“紧凑”成大空闲,腾出较大的内存以容纳新的程序进程。
基本分页存储管理方式
连续分配方式会形成许多“碎片”,虽然可以通过“紧凑”方式将许多碎片拼接成可用的大块空间,但须为之付出很大开销。所以提出了“离散分配方式”的想法。如果离散分配的基本单位是页,则称为分页管理方式;如果离散分配的基本单位是段,则称为分段管理方式。
分页存储管理是将一个进程的逻辑地址空间分成若干个大小相等的片,称为页面或页,并为各页加以编号,从0开始,如第0页、第1页等。相应地,也把内存空间分成与页面相同大小的若干个存储块,称为(物理)块或页框(frame),也同样为它们加以编号,如0#块、1#块等等。在为进程分配内存时,以块为单位将进程中的若干个页分别装入到多个可以不相邻接的物理块中。由于进程的最后一页经常装不满一块而形成了不可利用的碎片,称之为“页内碎片”。
在分页系统中,允许将进程的各个页离散地存储在内存不同的物理块中(所以能实现离散分配方式),但系统应能保证进程的正确运行,即能在内存中找到每个页面所对应的物理块。为此,系统又为每个进程建立了一张页面映像表,简称页表。在进程地址空间内的所有页,依次在页表中有一页表项,其中记录了相应页在内存中对应的物理块号。在配置了页表后,进程执行时,通过查找该表,即可找到每页在内存中的物理块号。可见,页表的作用是实现从页号到物理块号的地址映射。
为了能够将用户地址空间中的逻辑地址,变换为内存空间中的物理地址,在系统中必须设置地址变换机构。地址变换任务是借助于页表来完成的。
页表的功能可由一组专门的寄存器来实现。由于寄存器成本较高,且大多数现代计算机的页表又很大,使页表项总数可达几千甚至几十万个,显然这些页表项不可能都用寄存器来实现,因此,页表大多驻留在内存中。因为一个进程可以通过它的PCB来时时保存自己的状态,等到CPU要处理它的时候才将PCB交给寄存器,所以,系统中虽然可以运行多个进程,但也只需要一个页表寄存器就可以了。
由于页表是存放在内存中的,这使得CPU在每存取一个数据时,都要两次访问内存。为了提高地址变换速度,在地址变化机构中增设了一个具有并行查询能力的高速缓冲寄存器,又称为“联想寄存器”(Associative Lookaside Buffer)。
在单级页表的基础上,为了适应非常大的逻辑空间,出现了两级和多级页表,但是,他们的原理和单级页表是一样的,只不过为了适应地址变换层次的增加,需要在地址变换机构中增设外层的页表寄存器。
基本分段存储管理方式
分段存储管理方式的目的,主要是为了满足用户(程序员)在编程和使用上多方面的要求,其中有些要求是其他几种存储管理方式所难以满足的。因此,这种存储管理方式已成为当今所有存储管理方式的基础。
- (1)方便编程;
- (2)信息共享:分页系统中的“页”只是存放信息的物理单位(块),并无完整的意义,不便于实现共享;然而段位却是信息的逻辑单位。由此可知,为了实现段的共享,希望存储器管理能与用户程序分段的组织方式相适应。
- (3)信息保护;
- (4)动态增长;
- (5)动态链接。
分段管理方式和分页管理方式在实现思路上是很相似的,只不过他们的基本单位不同。分段有段表,也有地址变换机构,为了提高检索速度,同样增设联想寄存器(具有并行查询能力的高速缓冲寄存器)。所以有些具体细节在这个不再赘述。
分页和分段的主要区别:
1、两者相似之处:两者都采用离散分配方式,且都要通过地址映射机构来实现地址变换。
2、两者的不同之处:
(1)页是信息的物理单位,分页是为实现离散分配方式,以消减内存的外零头,提高内存的利用率。或者说,分页仅仅是由于系统管理的需要而不是用户的需要。段则是信息的逻辑单位,它含有一组其意义相对完整的信息。分段的目的是为了能更好地满足用户的需要。
(2)页的大小固定且由系统决定,而段的长度却不固定。
(3)分页的作业地址空间是一维的,即单一的线性地址空间;而分段的作业地址空间则是二维的。
段页式存储管理方式
前面所介绍的分页和分段存储管理方式都各有优缺点。分页系统能有效地提高内存利用率,而分段系统则能很好地满足用户需求。我们希望能够把两者的优点结合,于是出现了段页式存储管理方式。
段页式系统的基本原理,是分段和分页原理的结合,即先将用户程序分成若干个段,再把每个段分成若干个页,并为每一个段赋予一个段名。在段页式系统中,地址结构由段号、段内页号和页内地址三部分组成。
和前两种存储管理方式相同,段页式存储管理方式同样需要增设联想寄存器。
离散分配方式基于将一个进程直接分散地分配到许多不相邻的分区中的思想,分为分页式存储管理,分段式存储管理和段页式存储管理. 分页式存储管理旨在提高内存利用率,满足系统管理的需要,分段式存储管理则旨在满足用户(程序员)的需要,在实现共享和保护方面优于分页式存储管理,而段页式存储管理则是将两者结合起来,取长补短,即具有分段系统便于实现,可共享,易于保护,可动态链接等优点,又能像分页系统那样很好的解决外部碎片的问题,以及为各个分段可离散分配内存等问题,显然是一种比较有效的存储管理方式。
/**************************************************************************
当我们在使用Qt时不可避免得需要接触到内存的分配和使用,即使是在使用Python,Golang这种带有自动垃圾回收器(GC)的语言时我们仍然需要对Qt的内存管理机制有所了解,以更加清楚的认识Qt对象的生命周期并在适当的时机加以控制或者避免进入陷阱。
这篇文章里我们将学习QObject & parent对象管理机制,以及QWidget与内存管理这两点Qt的基础知识。
QObject和内存管理
在Qt中,我们可以大致把对象分为两类,一类是QObject
和它的派生类;另一类则是普通的C++类。
对于第二种对象,它的生命周期与管理和普通的C++类基本没有区别,而QObject
和它的派生类则有以下的显著区别:
QObject
和其派生类可以使用SIGNAL/SLOT机制- 它们一般会有一个
parent
父对象的指针,用于内存管理(后面重点说明) - 对于
QWidget
和其派生类来说,内存管理要稍微复杂一些,因为QWidget
需要和eventloop高度配合才能工作(后面也会重点说明)
signal和slot一般来说并不会对内存管理产生影响,但是对close()
槽的处理会对QWidget
产生一些影响,所以我们放在后面讲解。
那么先来看一下QObject和parent机制。
QObject的parent
我们时常能看到QWidget或者其他的控件的构造函数中有一项参数parent
,默认值都为NULL,例如:
QLineEdit(const QString &contents, QWidget *parent = nullptr); | |
QWidget(QWidget *parent = nullptr, Qt::WindowFlags f = ...); |
这个parent
的作用就在于使当前的对象实例加入parent指定的QObject及其派生类的children中,当一个QObject被delete或者调用了它的析构函数时,所有加入的children也会全部被析构。
如果parent
设置为NULL,会有如下的情况:
- 如果是构造时直接指定了NULL,那么当前实例不会有父对象存在,Qt也不能自动析构该实例除非实例超出作用域导致析构函数被调用,或者用户在恰当的实际使用
delete
操作符或者使用deleteLater
方法; - 如果已经指定了非NULL的
parent
,这时将它设置成了NULL,那么当前实例会从父对象的children中删除,不再受到QObject & parent机制的影响; - 对于
QWidget
,parent
为NULL时代表其为一个顶层窗口,也可以就是独立于其他widget在系统任务栏单独出现的widget,对于永远都是顶层窗口的widget,例如QDialog
,当parent
不为NULL时他会显示在父widget中心区域的上层; - 如果
QWidget
的parent
为NULL或是其他值,在其加入布局管理器或者QMainWindow
设置widget时,会自动将parent
设置为相应的父widget,在父控件销毁时这些子控件以及布局管理器对象会一并销毁。
所以我们可以看出,QObject对象实际上拥有一颗类实例关系树,在树中保存了所有通过指定parent
注册的子对象,而子对象里又保存有其子对象的关系树,所以当一个父对象被销毁时,所有依赖或间接依赖于它的对象都会被正确的释放,使用者无需手动管理这些资源的释放操作。
基于此原理,我们可以放心的让Qt管理资源,这里有几个建议:
- 对于QObject及其派生类,如果彼此之间存在一定联系,则应该尽量指定parent,对于
QWidget
应该指定parent或者加入布局管理器由管理器自动设置parent。 - 对象只需要在局部作用域存在时可以选择不进行内存分配,利用局部作用域变量的生命周期自动清理资源。
- 对于非
QWidget
的对象来说,如果不指定非NULLparent
,则需要自己管理对象资源。QWidget
比较特殊,我们在下一节讲解。 - 对于在局部作用域上创建的父对象及其子对象,要注意对象销毁的顺序,因为父对象销毁时也会销毁子对象,当子对象会在父对象之后被销毁时会引发double free。
QWidget和内存的释放
QWidget
也是QObject
的子类,所以在parent机制上是没有区别的,然而实际使用时我们更多的是使用“关闭”(close)而不是delete去删除控件,所以差异就出现了。
先提一下widget关闭的流程,首先用户触发close()
槽,然后Qt向widget发送QCloseEvent
,默认的QCloseEvent
会做如下处理:
- 将widget隐藏,也就是
hide()
; - 如果有设置
Qt::WA_DeleteOnClose
,那么会接着调用widget的析构函数
我们可以看到,widget的关闭实际是将其隐藏,而没有释放内存,虽然我们有时会重写closeEvent
但也不会手动释放widget。
看一个因为close机制导致的内存泄漏的例子,我们在button被单击后弹出某个自定义对话框:
button.ConnectClicked(func (_ bool) { | |
dialog := NewMyDialog() | |
dialog.Exec() | |
}) |
因为dialog在close时会被隐藏,而且没有设置DeleteOnClose
,所以Qt不会去释放dialog,而用户也无法回收dialog的资源,也行你会说golang的GC不是能处理这种情况吗,然而遗憾的是GC并不能处理cgo分配的资源,所以如果你期望GC做善后的话恐怕要失望了,每次点击按钮后内存用量都会增加一点,没错,内存泄露了。
那么给dialog设置一个parent,像这样,会如何呢?
dialog.SetParent(self) |
遗憾的是,并没有什么区别,因为这样只是把dialog加入父控件的children,并没有删除dialog,只有父对象被销毁时内存才会真正释放。
解决办法也有三个。
第一种是使用deleteLater
,例如:
dialog.DeleteLater() |
这会通知Qt的eventloop在下次进入主循环的时候析构dialog,这样一来确实解决了内存泄露,不过缺点是会有不可预测的延迟存在,有时候延迟是难以接受的。
第二种是手动删除widget,适用于parent为NULL的场合:
C++:
delete dialog; |
golang:
dialog.DestroyMyDialog() |
说明一下,DestroyType
也是qtmoc生产的帮助函数,因为golang没有析构函数的概念,所以goqt使用生成的该帮助函数显示调用底层C++对象的析构函数。
第三种比较简单,对于单纯显示而不需要和父控件做交互的widget,直接设置DeleteOnClose
即可,close时widget会被自动析构。
当然对于PyQt5来说并不会存在如上的问题,sip库能很好的与python的GC一起工作。唯一需要注意的是有时底层C++对象已经被释放,但是上层python对象依然存在,这时使用该对象将导致抛错。
/********************************************************************************
qt 如何 指针 自动 释放内存_C++|程序中的内存操作、管理
程序加载到内存后代码存储到代码区,并将全局变量、静态变量初始化到全局/静态内存区,然后会分配2M左右的栈内存区用于存储局部变量,并在运行时根据需要可以在堆内存区(空闲内存区及硬盘的虚拟内存区)申请空间。
程序可使用的内存分区↓
各基本类型所需的字节长度↓
程序中的输入、输出与内存↓
内存本质上是一个线性结构↓
1 内存分配方式
内存分配方式有三种:
(1)从静态存储区域分配。内存在程序编译的时候就已经分配好,这块内存在程序的整个运行期间都存在。例如全局变量,static 变量。
(2)在栈上创建。在执行函数时,函数内局部变量的存储单元都可以在栈上创建, 函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。
(3)从堆上分配,亦称动态内存分配。程序在运行的时候用 malloc 或 new 申请任意多少的内存,程序员自己负责在何时用 free 或 delete 释放内存。动态内存的生存期由我们决定,使用非常灵活,但问题也最多。
2 常见的内存错误及其对策
发生内存错误是件非常麻烦的事情。编译器不能自动发现这些错误,通常是在程序运行时才能捕捉到。而这些错误大多没有明显的症状,时隐时现,增加了改错的难度。有时用户怒气冲冲地把你找来,程序却没有发生任何问题,你一走,错误又发作了。
常见的内存错误及其对策如下:
2.1 内存分配未成功,却使用了它
编程新手常犯这种错误,因为他们没有意识到内存分配会不成功。常用解决办法是,在使用内存之前检查指针是否为 NULL。如果指针 p 是函数的参数,那么在函数的入口处用 assert(p!=NULL)进行检查。如果是用 malloc 或 new 来申请内存,应该用 if(p==NULL) 或 if(p!=NULL)进行防错处理。
2.2 内存分配虽然成功,但是尚未初始化就引用它
犯这种错误主要有两个起因:一是没有初始化的观念;二是误以为内存的缺省初值全为零,导致引用初值错误(例如数组)。
内存的缺省初值究竟是什么并没有统一的标准,尽管有些时候为零值,我们宁可信其无不可信其有。所以无论用何种方式创建数组,都别忘了赋初值,即便是赋零值也不可省略,不要嫌麻烦。
2.3 内存分配成功并且已经初始化,但操作越过了内存的边界
例如在使用数组时经常发生下标“多 1”或者“少 1”的操作。特别是在 for循环语句中,循环次数很容易搞错,导致数组操作越界。
2.4 忘记了释放内存,造成内存泄露
含有这种错误的函数每被调用一次就丢失一块内存。刚开始时系统的内存充足,你看不到错误。终有一次程序突然死掉,系统出现提示:内存耗尽。
动态内存的申请与释放必须配对,程序中 malloc 与 free 的使用次数一定要相同,否则肯定有错误(new/delete 同理)。
2.5 释放了内存却继续使用它
有三种情况:
(1)程序中的对象调用关系过于复杂,实在难以搞清楚某个对象究竟是否已经释放了内存,此时应该重新设计数据结构,从根本上解决对象管理的混乱局面。
(2)函数的 return 语句写错了,注意不要返回指向“栈内存”的“指针”或者“引用”,因为该内存在函数体结束时被自动销毁。
(3)使用 free 或 delete 释放了内存后,没有将指针设置为 NULL。导致产生“ 野指针”。
对策:
【1】用 malloc 或 new 申请内存之后,应该立即检查指针值是否为 NULL。防止使用指针值为 NULL 的内存。【2】不要忘记为数组和动态内存赋初值。防止将未被初始化的内存作为右值使用。【3】避免数组或指针的下标越界,特别要当心发生“多 1”或者“少 1”操作。【4】动态内存的申请与释放必须配对,防止内存泄漏。【5】用 free 或 delete 释放了内存之后,立即将指针设置为 NULL,防止产生“野指针”。
3 字符串的三种存储空间
字符串可以存储在栈区、堆区、或常量空间:
char str1[] = "abc";// 字符串存储在栈中char str2[] = "abc";char* str3 = "abc";// 字符串存储在常量区char* str4 = "abc";// 严格的写法应该是 const char* str4 = "abc";char* str5 = (char*)malloc(4);// 字符串存储在堆中char* str6 = (char*)malloc(4);str4[0]='x'; // 编译错误
4 内存操作函数
在头文件中主要有C风格字符串的操作函数以外,还有一类mem系列函数,主要是用来操作内存(不止字符串的操作):
①void *memset(void*s ,int ch,size_t n);将内存地址s处的n个字节的每个字节都替换为ch,并返回s。②void *memcmp(const void*buf1,const void *buf2,unsigned int count);比较内存区域buf1和buf2的前count个字节③void *memcpy(void* d,const void*s,size_t n)内存拷贝,将地址s位置的连续n个字节的内容复制到从地址d开始的内存空间上来。④void *memmove(void* dest,const void* src,size_t count);由src所指的内存区域复制count个字节到dest所指的内存区域。⑤void *memchr(const void *buf, int ch, size_t count)从buf所指内存区域的前count个字节查找字符ch,返回指向ch的指针⑥void* memccpy(void* dest, void* src, unsigned char ch, unsigned int count)由src所指内存区域复制不大于count个字节到dest所指内存区域,如果遇到字符ch则停止复制,返回值为NULL,如果ch没有被复制,返回值为一个指向紧接着dest区域后的字符指针。⑦int memicmp(void* buf1, void* buf2, unsigned int count)比较内存中字符的大小(不区分大小写)
5 计算内存容量
用运算符 sizeof 可以计算出数组的容量(字节数)。如有数组a,sizeof(a)的值就是其数组元素加上’0’的字节总和。指针 p 指向 a,但是 sizeof(p) 的值却是 4。这是因为 sizeof(p)得到的是一个指针变量的字节数,相当于sizeof(char*),而不是 p 所指的内存容量。C++/C 语言没有办法知道指针所指的内存容量,除非在申请内存时记住它。
char a[] = "hello world";char *p = a;cout<< sizeof(a) << endl; // 12 字节cout<< sizeof(p) << endl; // 4 字节
当数组作为函数的参数进行传递时,该数组自动退化为同类型的指针。不论数组 的容量是多少,sizeof(a)始终等于 sizeof(char*)。
void Func(char a[100]){cout<< sizeof(a) << endl; // 4 字节而不是 100 字节}
6 二级指针参数可以传递内存
如果函数的参数是一个指针,不要指望用该指针去申请动态内存。void GetMemory(char *p, int num){p = (char *)malloc(sizeof(char) * num);//指针要解引用操作才可以改变}void Test(void){char *str = NULL;GetMemory(str, 100);// str 仍然为 NULLstrcpy(str, "hello");// 运行错误}
如果非得要用指针参数去申请内存,那么应该改用“指向指针的指针”:
void GetMemory2(char **p, int num){*p = (char *)malloc(sizeof(char) * num);//指针p的解引用*p}void Test2(void){char *str = NULL;GetMemory2(&str, 100);// 注意参数是 &str,而不是 strstrcpy(str, "hello");cout<< str << endl;free(str);}
7 free 和delete 后的指针其地址值并未改变(也未置NULL),只是释放了指针指向的内存
别看 free 和 delete 的名字恶狠狠的(尤其是 delete),它们只是把指针所指的内存给释放掉,但并没有把指针本身干掉。
如下例,指针 p 被 free 以后其地址仍然不变(非 NULL), 只是该地址对应的内存是垃圾,p 成了“野指针”。如果此时不把 p 设置为 NULL, 会让人误以为 p 是个合法的指针。
char *p = (char *) malloc(100);strcpy(p, “hello”);free(p); // p 所指的内存被释放,但是 p 所指的地址仍然不变…if(p != NULL) // 没有起到防错作用{strcpy(p, “world”); // 出错}
如果程序比较长,我们有时记不住 p 所指的内存是否已经被释放,在继续使用 p 之前,通常会用语句 if (p != NULL)进行防错处理。很遗憾,此时 if 语句起不到防错作用,因为即便 p 不是 NULL 指针,它也不指向合法的内存块。
8 动态内存在运行出作用域时并不会被自动释放
函数体内的局部变量在函数结束时自动消亡。很多人误以为以下代码是正确的。理由是 p 是局部的指针变量,它消亡的时候会让它所指的动态内存一起完蛋。这是错觉!
void Func(void){char *p = (char *) malloc(100); // 动态内存会自动释放吗?}
我们发现指针有一些“似是而非”的特征:
(1)指针消亡了,并不表示它所指的内存会被自动释放。(2)内存被释放了,并不表示指针会消亡或者成了NULL 指针。
9 杜绝“野指针”
“野指针”不是 NULL 指针,是指向“垃圾”内存的指针。人们一般不会错用 NULL 指针,因为用 if 语句很容易判断。但是“野指针”是很危险的,if 语句对它不起作用。
“野指针”的成因主要有两种:
(1)指针变量没有被初始化。任何指针变量刚被创建时不会自动成为 NULL 指针,它的缺省值是随机的,它会乱指一气。所以,指针变量在创建的同时应当被初始化,要么将指针设置为 NULL,要么让它指向合法的内存。例如
char *p = NULL;char *str = (char *) malloc(100);
(2)指针 p 被 free 或者 delete 之后,没有置为 NULL,让人误以为 p 是个合法的指针。参见 5 节。
(3)指针操作超越了变量的作用范围。这种情况让人防不胜防,示例程序如下:
class A{public:void Func(void){ cout << “Func of class A” << endl; }};void Test(void){A *p;{A a;p = &a; // 注意 a 的生命期}p->Func(); // p 是“野指针”}
函数 Test 在执行语句 p->Func()时,对象 a 已经消失,而 p 是指向 a 的,所以 p 就成了“野指针”。但奇怪的是我运行这个程序时居然没有出错,这可能与编译器有关。
10 有了 malloc/free 为什么还要new/delete ?
malloc 与 free 是 C++/C 语言的标准库函数,new/delete 是 C++ 的运算符。它们都可用于申请动态内存和释放内存。
对于非内部数据类型的对象而言,光用 maloc/free 无法满足动态对象的要求。对象在创建的同时要自动执行构造函数,对象在消亡之前要自动执行析构函数。由于 malloc/free 是库函数而不是运算符,不在编译器控制权限之内,不能够把执行构造函数和析构函数的任务强加于 malloc/free。
因此 C++语言需要一个能完成动态内存分配和初始化工作的运算符 new,以及一个能完成清理与释放内存工作的运算符 delete。注意 new/delete 不是库函数。
所以我们不要企图用 malloc/free 来完成动态对象的内存管理,应该用new/delete。由于内部数据类型的“对象”没有构造与析构的过程,对它们而言malloc/free 和 new/delete 是等价的。
既然 new/delete 的功能完全覆盖了 malloc/free,为什么 C++不把malloc/free 淘汰出局呢?这是因为 C++程序经常要调用 C 函数,而 C 程序只能用 malloc/free 管理动态内存。
如果用 free 释放“new 创建的动态对象”,那么该对象因无法执行析构函数而可能导致程序出错。如果用 delete 释放“malloc 申请的动态内存”,理论上讲程序不会出错,但是该程序的可读性很差。所以 new/delete 必须配对使用, malloc/free 也一样。
11 各类别内存空间耗尽了怎么办?
如果在申请动态内存时找不到足够大的内存块,malloc 和 new 将返回 NULL 指针,宣告内存申请失败。通常有三种方式处理“内存耗尽”问题。
11.1判断指针是否为 NULL,如果是则马上用 return 语句终止本函数。例如:
void Func(void){A *a = new A;if(a == NULL){return;}…}
11.2判断指针是否为 NULL,如果是则马上用 exit(1)终止整个程序的运行。例如:
void Func(void){A *a = new A;if(a == NULL){cout << “Memory Exhausted” << endl;exit(1);}…}
11.3 为 new 和 malloc 设置异常处理函数。
例如 Visual C++可以用_set_new_hander 函数为 new 设置用户自己定义的异常处理函数,也可以让malloc 享用与 new 相同的异常处理函数。
上述11.1、11.2方式使用最普遍。如果一个函数内有多处需要申请动态内存,那么方式11.1就显得力不从心(释放内存很麻烦),应该用方式11.2来处理。
很多人不忍心用 exit(1),问:“不编写出错处理程序,让操作系统自己解决行不行?”
不行。如果发生“内存耗尽”这样的事情,一般说来应用程序已经无药可救。如果不用 exit(1) 把坏程序杀死,它可能会害死操作系统。
有一个很重要的现象要告诉大家。对于 32 位以上的应用程序而言,无论怎样使用 malloc 与 new,几乎不可能导致“内存耗尽”。如以下程序会无休止地运行下去,根本不会终止。因为 32 位操作系统支持“虚拟内存”,内存用完了,自动用硬盘空间顶替。我只听到硬盘嘎吱嘎吱地响,OS已经累得对键盘、鼠标毫无反应。
// “内存耗尽”测试程序void main(void){float *p = NULL;while(TRUE){p = new float[1000000];cout << “eat memory” << endl;if(p==NULL)exit(1);}}
12 malloc/free 的使用要点
函数 malloc 的原型如下:
void * malloc(size_t size);
用 malloc 申请一块长度为 length 的整数类型的内存,程序如下:
int *p = (int *) malloc(sizeof(int) * length);
我们应当把注意力集中在两个要素上:“类型转换”和“sizeof”。
(1)malloc 返回值的类型是 void *,所以在调用 malloc 时要显式地进行类型转换,将 void * 转换成所需要的指针类型。
(2)malloc 函数本身并不识别要申请的内存是什么类型,它只关心内存的总字节数。我们通常记不住 int、float 等数据类型的变量的确切字节数使用sizeof()即可。
在 malloc 的“()”中使用 sizeof 运算符是良好的风格,但要当心有时我们会昏了头,写出 p = malloc(sizeof(p))这样的程序来。
(3)函数 free 的原型如下:
void free( void * memblock );
为什么 free 函数不象 malloc 函数那样复杂呢?这是因为指针 p 的类型以及它所指的内存的容量事先都是知道的,语句 free(p)能正确地释放内存。如果 p 是 NULL 指针,那么 free 对 p 无论操作多少次都不会出问题。如果 p 不是NULL 指针,那么 free 对 p 连续操作两次就会导致程序运行错误。
13 new/delete 的使用要点
运算符 new 使用起来要比函数 malloc 简单得多,例如:
int *p1 = (int *)malloc(sizeof(int) * length);
int *p2 = new int[length];
这是因为 new 内置了 sizeof、类型转换和类型安全检查功能。对于非内部数据类型的对象而言,new 在创建动态对象的同时完成了初始化工作。如果对象有多个构造函数,那么 new 的语句也可以有多种形式。例如
class Obj{public :Obj(void); // 无参数的构造函数Obj(int x); // 带一个参数的构造函数…};void Test(void){Obj*a = new Obj;Obj*b = new Obj(1);// 初值为 1…delete a;delete b;}
如果用 new 创建对象数组,那么只能使用对象的无参数构造函数。例如
Obj *objects = new Obj[100]; // 创建 100 个动态对象
不能写成
Obj *objects = new Obj[100](1);// 创建 100 个动态对象的同时赋初值 1
在用 delete 释放对象数组时,留意不要丢了符号‘[]’。例如
delete []objects; // 正确的用法
delete objects; // 错误的用法
后者相当于 delete objects[0],漏掉了另外 99 个对象。