摘 要:为了获得最佳性能,C/C++语言把操纵内存的权限以指针的方式暴露给开发人员。但是C/C++语言编译器GCC和Clang都不提供内存安全检测,导致开发人员使用C/C++语言编写的项目可能存在内存泄漏的风险。文章介绍了改进指针算法和shared_ptr源码,分析了它们检测内存泄漏的方式,最后指出该类算法的缺陷,提出改进思路,并建议用户避免写出该类算法无法检测的代码结构。
关键词:C/C++程序;内存泄漏;改进指针算法;shared_ptr
中图分类号:TP311 文献标识码:A文章编号:2096-4706(2021)22-0098-03
Abstract: In order to obtain the best performance, C/C++ language exposes the permission to manipulate memory to developers in the form of pointers. However, the C/C++ language compiler GCC and Clang do not provide memory security detection, which leads to the risk of memory leakage for projects written by developers in C/C++ language. This paper introduces the improved pointer algorithm and shared_ptr source code, analyzes their ways to detect memory leakage, finally points out the defects of this kind of algorithm and puts forward improvement ideas, and advises users to avoid writing code structures that cannot be detected by such algorithms.
Keywords: C/C++ program; memory leak; improved pointer algorithm; shared_ ptr
0 引 言
随着使用C/C++语言所构建项目规模的不断增大,内存安全也越来越受重视。内存泄漏往往在程序运行中就发生,不易被发现和修改。相较于其他运行于虚拟机上的语言(如Python和Java),C/C++为了实现对内存的细粒度的操作,没有设计垃圾收集器。因此,使用C/C++编写项目时,开发人员需要格外注意内存的申请和释放。本文介绍了改进指针算法[1-3]和shared_ptr[4,5]源码,分析了它们对内存泄漏的检测方式,并指出它们所存在的不足。希望读者可以通过改进指针算法或share_ptr来规避内存泄漏,尽量不要写出连检测算法也无法处理的代码结构。
1 内存泄漏检测原理分析
1.1 改进指针算法
改进指針算法是一种新的内存安全性动态分析方法,采用源码插桩技术实现,支持复杂的动态内存检查。改进指针方法的最大创新点是分别为每一个内存对象和指针创建和维护一个状态节点数据(status node data, snd)和指针元数据(pointer matedata, pmd)。该方法不仅可以在程序运行时记录每个指针指向内存对象的边界信息,还可以记录内存对象所对应snd的状态和计数信息。snd的状态(stat)是指对应内存对象的内存类型,如无效(invalid)、栈(heap)、全局(global)、静态(static)和函数(function)等,snd的定义为
1: typedef enum{
2: invalid, heap,global,...
3: } status;
4: typedef struct{
5: status stat;size_t count; 7: } SND;
由于多个指针引用同一块内存,这些指针所对应的指针元数据将共享同一个snd。通过snd的count变量判断出该内存对象无用后,其所对应的状态节点也会足够“智能”地自我销毁,不会常驻内存。
指针pmd是改进指针算法的重要数据结构,用来存储运行时指针相关信息。改进指针算法在程序运行时为每个指针变量维护一个pmd。pmd结构存储对应指针所引用内存对象的基地址(base)、边界地址(bound)以及内存对象的snd地址,pmd结构定义为:
1: typedef struct{
2: void *base;
3: void *bound;
4: SND *snda;
5: }PMD;
改进指针算法在检查内存泄漏上可以做到非常细粒度,这是其他工具所不具备的优势。第2行执行赋值语句后,指针p2和p1同时指向同一块8个字节的堆内存,它们的pmd共享同一个snd:
1:p1 = (int*)malloc(8);2:p2 = p1;3:int i; p1 = &i;4:p2 = &i; /*mem leak*/
第3行赋值语句执行后,状态如图1(a)所示,p1指针指向变量i的首地址,pmd引用了变量i的snd,因此指向堆内存的指针减1,相应snd的count也减1。第4行赋值语句执行后,状态如图1(b)所示。p2指针不再指向堆内存,p2指针pmd引用变量i的snd,此时8个字节的堆内存没有指针指向它,因此其snd的count值为0。在检查到堆内存的count值为0后,内存泄漏的错误将被报出,因为没有指针指向这块内存。
1.2 shared_ptr原理分析
阅读C++STL源码可知,shared_ptr的_M_use_count变量值为0是判断内存泄漏的必要条件,shared_ptr部分重要源码:
1: __shared_count<_Lp> _M_refcount; // Reference counter
2: _Sp_counted_base<_Lp>* _M_pi; //__shared_count类私有变量
3: typedef int _Atomic_word; //C++标准库的GNU扩展文件atomic_word.h
4: _Atomic_word _M_use_count; // _Sp_counted_base类私有变量
5: inline void _Sp_counted_base<_S_single>::_M_add_ref_copy()
6: { ++_M_use_count; } //_Sp_counted_base类的内联函数
7: _Sp_counted_base<_S_single>::_M_release() noexcept
8: if (--_M_use_count == 0){
9: _M_dispose(); //当_M_use_count自减到0,释放资源 ...}
代码均来自C++11标准模板库源文件。
_M_refcount是shared_ptr模板类的成员变量,它是用于处理引用计数最核心的变量。_M_refcount的类型__shared_count也是一个模板类,这个类有一个私有指针变量_M_pi,所有指向同一动态对象的shared_ptr都共享同一个_M_pi变量,如第2行所示。_M_pi变量指向的_Sp_counted_base类型有一个int类型的_M_use_count变量,如第3、第4行所示。_M_use_count变量表示引用数,每当有新的shared_ptr通过函数调用或拷贝等操作指向同一个动态对象时,_M_pi变量都会调用如第5、第6行所示的内联函数_M_add_ref_copy,将_M_use_count值加1。当指向某动态对象的shared_ptr不再指向该动态对象时,其析构函数会使_M_pi变量调用_M_release(),将_M_use_count值减1,_M_release()也会在此时判断无用动态对象。如第7~9行所示,_M_release()函数调用将M_use_count值减1后示,若_M_use_count值为0,则表示最后一个指向该动态对象的shared_ptr被销毁或最后一个指向该动态对象的shared_ptr通过操作符“=”或reset函数调用被赋值为其他值,检测产生内存泄漏,因此调用_M_dispose()来释放无用动态对象。
2 无法检测的内存泄漏介绍
通过改进指针算法和shared_ptr判断的堆内存对象泄漏都是依据引用计数值“PREFIXcount”为0来判断的。然而,我们在调试专业测试集时发现当“PREFIXcount”值大于0时,也存在内存泄漏和无用动态对象。在程序中的表现为:存在数量大于等于1的指针指向该内存对象,但是这些指针无法获取,从而导致内存泄漏。“指针自指”就是其中一种:
/*point-self*/
1:#include <malloc.h>
2:int main()
3:{
4: int **m, i = 5;
5: m = malloc(sizeof(int*)*6);
6: m[i] = (int*)m;
7: m = 0; /* mem leak*/
8: return 0;}
第5行賦值语句执行后,指针m指向一块容纳6个“int*”类型变量的内存对象首地址。第6行赋值语句执行后,m[i]相当于*(m+5)向该内存尾部空间写进了m变量的值(即该内存首地址)。当第7行中的m变量通过赋值语句指向它时,会发生内存泄漏,状态如图2所示。虽然仍然有指针指向该堆内存对象,但是指向它的指针来自于自身空间的内部指针,由于任何方式都无法获取内部指针,因此导致内存泄漏。
另一种“循环引用”也会导致这种内存泄漏:
/*Memory leaks on memory circles.*/
1:#include <malloc.h>
2:typedef struct st {
3: int i;
4: struct st *next;
5:} st;
6:int main(){
7: st *m, *n;
8: m = malloc(sizeof(st));
9: n = malloc(sizeof(st));
10: n->next = m;
11: m->next = n; //构成循环状态
12: m=0;
13: n=0; /*mem leak*/ return 0;}
循环引用代码
第8、第9行赋值语句执行后,指针m和指针n分别指向一个容纳st类型变量的内存对象首地址。第10、第11行使这两个内存对象的内部指针“struct st *next”形成互指彼此内存对象的状态,因此在第12、第13行执行后,引用这两个内存对象的指针只来自彼此内部,形成了一个没有起点的环,此时的状态如图3所示。因此在程序运行到图2和图3这两种状态时,改进指针算法和shared_ptr的“计数值”都大于0,根据它们“计数值”大于0的描述,这个内存对象是有用的,但是事实上已经存在内存泄漏。
3 改进方案
虽然C++1x提供了一个解决方案,但是该方案过于依赖用户,需要开发人员在使程序构成循环时,利用weak_ptr的弱引用替换部分shared_ptr的强引用,程序编写者通过手动破坏循环结构来解决shared_ptr的设计缺陷。我们需要实现自动化和智能化的工具,目的是解决人为原因带来的易错性、低效性和不可靠性问题,靠开发人员自己发现程序特定缺陷并做出相应修改是非常低效的。
shared_ptr的设计丢失了动态对象信息,所有指向同一个动态对象的指针都共享同一个_Sp_counted_base类型_M_pi,但是_Sp_counted_base类私有变量只有“计数值”和一系列用来更新“计数值”的接口。判断程序是否存在“指针自指”,需要获取内存对象的信息,由于shared_ptr的功能单一和对高性能的追求,并没有记录动态对象的具体信息。相较于shared_ptr的设计,改进指针算法记录的内存对象信息更加具体和完善。用pmd和snd的联接,模拟了指针指向内存对象的状态,指针pmd包含了所指内存对象的基地址和边界。因此改进指针算法记录的状态信息更用于实现对该类型错误的检测。
根据改进指针算法的源码,我们提出完善改进指针算法的检测接口,在指针pmd与snd将要解绑时,执行“指针自指”算法:扫描存储pmd的hash表,查找当前“解绑”pmd记录的边界内自指指针数和对应snd的count是否相同,若相同snd则可判断出发生“指针自指”,导致内存泄漏。然后采用深度遍历的方式,逐个解绑该内存泄漏范围内的指针pmd。
4 结 论
利用C/C++语言编写项目后,可以使用改进指针算法和shared_ptr来检测普通的内存泄漏。完善后的改进指针算法也可以检测“指针自指”发生的内存泄漏,但是尽量避免编写“循环引用”代码结构,因为本文论证了该结构导致的内存泄漏,目前C/C++尚未找到一个较为完备的检测算法来对内存泄漏进行检测和预防。甚至是Google公司开发的内存安全性动态分析工具AddressSanitizer和国内基于改进指针算法实现的Movec都无法完全检测出C/C++程序中“循環引用”导致的内存泄漏。
参考文献:
[1] 朱云龙.C程序运行时监控和验证的插桩方法研究与应用 [D].南京:南京航空航天大学,2016.
[2] CHEN Z,TAO C Q,ZHANG Z Y,et al. Poster: Beyond Spatial and Temporal Memory Safety [C]//2018 IEEE/ACM 40th International Conference on Software Engineering: Companion (ICSE-Companion). Gothenburg:IEEE,2018:189-190.
[3] 严峻琦.C程序内存安全错误的运行时检测技术研究与实现 [D].南京:南京航空航天大学,2017.
[4] 叶蓉,陈榕.运用CAR智能指针实现Callback机制 [J].计算机技术与发展,2008(2):9-12+16.
[5] 张彤,何源.一种自适应的引用计数智能指针的实现 [J].成都大学学报(自然科学版),2007(1):55-57.
作者简介:仵俊(1997—),男,汉族,江苏南京人,硕士研究生在读,研究方向:软件验证。