首页
归档
友情链接
关于
Search
1
在wsl2中安装archlinux
80 阅读
2
nvim番外之将配置的插件管理器更新为lazy
58 阅读
3
2018总结与2019规划
54 阅读
4
PDF标准详解(五)——图形状态
33 阅读
5
为 MariaDB 配置远程访问权限
30 阅读
心灵鸡汤
软件与环境配置
博客搭建
从0开始配置vim
Vim 从嫌弃到依赖
archlinux
Emacs
MySQL
Git与Github
AndroidStudio
cmake
读书笔记
菜谱
编程
PDF 标准
从0自制解释器
qt
C/C++语言
Windows 编程
Python
Java
算法与数据结构
PE结构
登录
Search
标签搜索
c++
c
学习笔记
windows
文本操作术
编辑器
NeoVim
Vim
win32
VimScript
Java
emacs
linux
文本编辑器
elisp
反汇编
OLEDB
数据库编程
数据结构
内核编程
Masimaro
累计撰写
308
篇文章
累计收到
27
条评论
首页
栏目
心灵鸡汤
软件与环境配置
博客搭建
从0开始配置vim
Vim 从嫌弃到依赖
archlinux
Emacs
MySQL
Git与Github
AndroidStudio
cmake
读书笔记
菜谱
编程
PDF 标准
从0自制解释器
qt
C/C++语言
Windows 编程
Python
Java
算法与数据结构
PE结构
页面
归档
友情链接
关于
搜索到
2
篇与
的结果
2021-01-10
记一次内存泄露调试
首先介绍一下相关背景。最近在测试一个程序时发现,在任务执行完成之后,从任务管理器上来看,内存并没有下降到理论值上。程序在启动完成之后会占用一定的内存,在执行任务的时候,会动态创建一些内存,用于存储任务的执行状态,比如扫描了哪些页面,在扫描过程中一些收发包的记录等等信息。这些中间信息在任务结束之后会被清理掉。任务结束之后,程序只会保存执行过的任务列表,从理论上讲,任务结束之后,程序此时所占内存应该与程序刚启动时占用内存接近,但是实际观察的结果就是任务结束之后,与刚启动之时内存占用差距在100M以上,这很明显不正常,当时我的第一反应是有内存泄露内存泄露排查既然有内存泄露,那么下一步就是开始排查,由于程序是采用MFC编写的,那么自然就得找MFC的内存泄露排查手段。根据网上找到的资料,MFC在DEBUG模式中可以很方便的集成内存泄露检查机制的。首先在 stdafx.h 的头文件中加入#define _CRTDBG_MAP_ALLO #include <crtdbg.h>再在程序退出的地方加入代码_CrtDumpMemoryLeaks();如果发生内存泄露的话,在调试运行结束之后,观察VS的输出情况可以看到如下内容Detected memory leaks! Dumping objects -> .\MatriXayTest.cpp(38) : {1301} normal block at 0x0000000005584D30, 40 bytes long. Data: < > CD CD CD CD CD CD CD CD CD CD CD CD CD CD CD CD Object dump complete.在输出中会提示有内存泄露,下面则是泄露的具体内容,MatriXayTest.cpp 是发生泄露的代码文件,括号中的38代表代码所在行数,大括号中1301代表这是程序的第1301次分配内存,或者说第1301次执行malloc操作,再往后就是内存泄露的地址,以及泄露的大小,这个地址是进程启动之后随机分配的地址,参考意义不大。下面一行表示,当前内存中的具体值,从值上来看应该是分配了内存但是没有初始化。根据这个线索,我们来排查,找到第38行所在位置int *p = new int[10]; _CrtDumpMemoryLeaks(); return nRetCode;内存泄露正是出现在new了10个int类型的数据,但是后面没有进行delete操作,正是这个操作导致了内存泄露。到此为止,检测工具也找到了,下面就是加上这段代码,运行发生泄露的程序,查看结果再漫长的等待任务执行完成并自动停止之后,我发现居然没有发现内存泄露!!!我又重复运行任务多次,发现结果是一样的,这个时候我开始怀疑是不是这个库不准,于是我在数据节点的类中添加构造函数,统计任务执行过程中创建了多少个节点,再在析构中统计析构了多少个节点,最终发现这两个数据完全相同。也就是说真的没有发生内存泄露。在这里我也疑惑了,难道是任务管理器有问题?带着这个疑问,我自己写了一段代码,在程序中不定时打印内存占用情况,结果发现虽然与任务管理器有差异,但是结果是类似的,释放之后内存并没有大幅度的下降。我带着疑问查找资料的过程的漫长过程中,发现任务管理器的显示内存占用居然降下去了,我统计了一下时间,应该是在任务结束之后的30分钟到40分钟之间。带着这个疑问,我又重新发起任务,在任务结束,并等待一定时间之后,内存占用果然降下去了。这里我得出结论 程序中执行delete操作之后,系统并不会真的立即回收操作,而是保留这个内存一定时间,以便后续该进程在需要分配时直接使用结论验证科学一般来说需要大胆假设,小心求证,既然上面根据现象做了一些猜想,下面就需要对这个猜想进行验证。首先来验证操作系统在程序调用delete之后不会真的执行delete操作。我使用下面的代码进行验证//定义一个占1M内存的结构 struct data{ char c[1024 * 1024]; } data* pa[1024] = {0}; for (int i = 0; i < 1024; i++) { pa[i] = new data; //这里执行一下数据清零操作,以便操作系统真正为程序分配内存 //有时候调用new或者malloc操作,操作系统只是保留对应地址,但是并未真正分配物理内存 //操作会等到进程真正开始操作这块内存或者进程需要分配的内存总量达到一个标准时才真正进行分配 memset(pa[i], 0x00, sizeof(data)); } printf("内存分配完毕,按任意键开始释放内存...\n"); getchar(); for (int i = 0; i < 1024; i++) { delete pa[i]; } printf("内存释放完毕,按任意键退出\n"); _CrtDumpMemoryLeaks(); char c = getchar();通过调试这段代码,在刚开始运行,没有执行到new操作的时候,进程占用内存在2M左右,运行到第一个循环结束,分配内存后,占用内存大概为1G,在执行完delete之后,内存并没有立马下降到初始的2M,而是稳定在150M左右,过一段时间之后,程序所占用内存会将到2M左右。接着对上面的代码做进一步修改,来测试内存使用时间长度与回收所需时间的长短的关系。这里仍然使用上面定义的结构体来做尝试data* pa[1024] = {0}; for (int i = 0; i < 1024; i++) { pa[i] = new data; memset(pa[i], 0x00, sizeof(data)); } printf("内存分配完毕,按任意键开始写数据到内存\n"); getchar(); //写入随机字符串 srand((unsigned) time(NULL)); DWORD dwStart = GetTickCount(); DWORD dwEnd = dwStart; printf("开始往目标内存中写入数据\n"); while ((dwEnd - dwStart) < 1 * 60 * 1000) //执行时间为1分钟 { for (int i = 0; i < 1024; i++) { for (int j = 0; j < 1024; j++) { int flag = rand() % 3; switch (flag) { case 1: { //生成大写字母 pa[i]->c[j] = (char)(rand() % 26) + 'A'; } break; case 2: { //生成小写字母 pa[i]->c[j] = (char)(rand() % 26) + 'a'; } break; case 3: { //生成数字 pa[i]->c[j] = (char)(rand() % 10) + '0'; } break; default: break; } } } dwEnd = GetTickCount(); } printf("数据写入完毕,按任意键开始释放内存...\n"); getchar(); for (int i = 0; i < 1024; i++) { delete pa[i]; } printf("内存释放完毕,按任意键退出\n"); _CrtDumpMemoryLeaks(); char c = getchar();后面就不放测试的结果了,我直接说结论,同一块内存使用时间越长,操作系统真正保留它的时间也会越长。短时间内差别可能不太明显,长时间运行,这个差别可以达到秒级甚至分。我记得当初上操作系统这门课程的时候,老师说过一句话:一个在过去使用时间越长的资源,在未来的时间内会再次使用到的概率也会越高,基于这一原理,操作会保留这块内存一段时间,如果程序在后面再次申请对应结构时,操作系统会直接将之前释放的内存拿来使用。为了验证这一现象,我来一个小的测试int *p1 = new int[1024]; memset(p, 0x00, sizeof(int) * 1024); delete[] p; int* p2= new int[1024];通过调试发现两次返回的地址相同,也就验证了之前说的内容总结最后来总结一下结论,有时候遇到delete内存后任务管理器或者其他工具显示内存占用未减少或者减少的量不对时,不一定是发生了内存泄露,也可能是操作系统的内存管理策略:程序调用delete后操作系统并没有真的立即回收对应内存,它只是暂时做一个标记,后续真的需要使用相应大小的内存时会直接将对应内存拿出来以供使用。而具体什么时候真正释放,应该是由操作系统进行宏观调控。我觉得这次暴露出来的问题还是自己基础知识掌握不扎实,如果当时我能早点回想起来当初上课时所讲的内容,可能也就不会有这次针对一个错误结论,花费这么大的精力来测试。当然这个世界上没有如果,我希望看到这篇博文的朋友,能少跟风学习新框架或者新语言,少被营销号带节奏,沉下心了,补充计算机基础知识,必将受益匪浅。
2021年01月10日
6 阅读
0 评论
0 点赞
2015-06-15
Windows程序设计学习笔记(一)Windows内存管理初步
学习Windows程序设计也有一些时间了,为了记录自己的学习成果,以便以后查看,我希望自己能够坚持写下一系列的学习心得,对自己学习的内容进行总结,同时与大家交流。因为刚学习所以可能有的地方写不不正确,希望大家能够指出。在学习了一定的Windows API后我决定进入到一些基础的学习,希望能够学习一些原理性的知识,能够做到知其然的同时知其所以然。为了达到这个目的,这段时间我学习了一些计算机的基础知识,在这写下这篇博客,总结一下。在早期的16位8086CPU中我们使用段与段内的偏移偏移的方式寻址,得到的是真实的物理地址,当时的寄存器是16位而地址总线是20位,为了充分利用这些地址总线,Intel的工程师采用的是分段的方式,将段寄存器与通用寄存器中的数值通过地址加法器合成20位的地址,具体的合成方式为段地址 * 16 + 偏移地址,利用这种方式得到的都是真实的物理地址。8086系列的CPU之所以成为一个划时代的产物就是因为这种分段的思想,而这种思想一直沿用至今。但是8因为086CPU得到的都是真实的物理地址,所以在早期的程序设计中不得不详细考虑内存段的划分,有可能出现后一个程序将前一个程序的内存占用,这种方式非常不安全。到32位CPU的诞生产生了一种新的寻址方式,80386CPU有32位地址总线,寻址区间为2的32次方为4GB。所以用32位的通用寄存器都可以访问4GB的内存空间,这个时候段寄存器不在存储段的首地址段,寄存器DS、CS、ES等寄存器中存储的是段选择子的索引。段选择子的概念出现在32位CPU中的保护模式中,在保护模式下,每个内存段都有一个64位的结构体用来描述这段内存的基本信息叫做段描述符,段描述符包括安全级别,段的基地址等信息,系统将所有内存段的64位描述符存储在一个段描述符表中,将段寄存器中的16位数据作为段描述符表的索引,称为选择子。为了管理段描述符表,80386引入两个寄存器一个是48位的GDTR(全局段描述符表寄存器)另一个是16位的LDTR(局部段描述符表寄存器)分别指向GDT(全局段描述符表)和LDT(局部段描述符表)。GDT(Global Descriptor Table)包含系统中所有任务都可用的段描述符,通常包含描述操作系统所使用的代码段、数据段和堆栈段的描述符及各任务的LDT段等;全局描述符表只有一个。而LDTR(Local Descriptor Table)80386处理器设计成每个任务都有一个独立的LDT。它包含有每个任务私有的代码段、数据段和堆栈段的描述符,也包含该任务所使用的一些门描述符,如任务门和调用门描述符等。系统根据不同的任务切换不同的LDT。这样便于实现不同进程间内存的隔离。为了确定所在段描述符的位置,段寄存器中的16位数据中只有13位表示段描述中的索引。第0位、第1位合起来表示程序的等级,第2位TI位用来表示在段描述符的位置;TI=0表示在GDT中,TI=1表示在LDT中。对于一个虚拟地址XXXX:YYYYYYYY首先判断XXXX中TI位的值,当TI = 0时表示的是全局段描述符表,这个时候首先利用GDTR中的值确定GDT的位置,然后直接取段寄存器中高13位的值作为索引在GDTR中查找得到相应的段描述符,由于段描述符包含段的基址、限长、优先级等各种属性,这就得到了段的起始地址;当TI等于1时表示的是局部的段描述符表,这个时候寻址变得相对比较复杂,第一步还是从GDTR获得GDT的位置,然后根据LDTR中的16位数值作为索引在GDT中查找LDT所在位置,然后才是根据XXXX中的高13位作为索引在LDT中查找得到相应的段描述符,由于段描述符包含段的基址、限长、优先级等各种属性,这就得到了段的起始地址。这两种方式的步骤如下图:12在这里我们只是说得到线性地址并没有说得到内存地址,那么线性地址是否就是内存地址呢?可以是也可以不是,这取决于80386的分页机制是否被使用。在早期的分段模式下,系统回收程序使用的内存可能会残留很小的内存碎片,导致任何程序都不能使用,为了解决这一问题,80386CPU提供了一种分页机制,系统将固定大小的内存块分为一页,在一页中在使用段分配的方式,每页的大小有系统决定,Windows系统的页大小为4KB。每页物理内存可以根据“页目录”和“页表”,随意映射到不同的线性地址上。这样,就可以将物理地址不连续的内存的映射连到一起,在线性地址上视为连续。而是否启用内存分页机制是由80386处理器新增的CR0寄存器中的位31(PG位)决定的。如果PG=0,则分页机制不启用,这时所有指令寻址的地址(线性地址)就是系统中实际的物理地址;当PG=1的时候,80386处理器进入内存分页管理模式,所有的线性地址要经过页表的映射才得到最后的物理地址。当每页大小为4KB时,我们得到的32位线性地址中高20位表示页号,低12位表示页中的偏移地址,这样通过页映射的方式我们可以将线性地址转化成对应的物理地址。到这里我们可引出几个重要的概念:1)32位机器中共有2中段描述符表,每个表中有2^13个表项,每个表项代表一个段首地址,而每一段的段内偏移最大为2^32B所以32位机器的理论寻址范围为2^13 2^1 2^32 = 2^(13 + 1 + 32) = 64TB;2)系统为每个应用程序分配4GB的虚拟内存,并且由于保护模式的相关机制,这4GB的内存为每个应用程序独享3)由于保护模式不同应用程序中相同的逻辑地址最终映射到不同的内存地址,所以在应用程序中我们不必过多操心程序所使用的内存被其他应用程序占用。在Windows的保护模式中,将应用程序分级分为RING0到RING3,其中RING0的级别最高、GING3的级别最低,虽说分为4个级别但是实际上只使用了两个,Windows为了与其他CPU兼容,只使用2个,RING0主要是内核代码、RING3主要是用户代码,那是不是说RING3级别的代码就不能访问RING0级别的内存呢?这个自然也不是,Windows我们都知道Windows提供了一系列的API ,其中我们可以调用相应的API访问内核所在的内存,只是不能直接访问内核代码,也就是说不能直接用jmp指令访问内核代码,但是可以使用call指令调用,API对于程序员来说是不透明的。Windows保护模式下主要机制有:1)Windows提供不同安全级别,不同安全级别的代码访问内存的权限也不一样2)不同进程的内存都是独立的,每个进程独享自己的4GB内存,不同进程即使在代码中使用相同的虚拟地址,系统也会映射到不同 的地址空间
2015年06月15日
6 阅读
0 评论
0 点赞