1 简介
1.1 读者对象和范围
本文的读者对象是:所有使用C++语言为Symbian OS 6.x/7.0s 开发应用的开发伙伴们。
有一个不成文的80/20 法则,说的是:需要用80%的时间去纠正开发中产生的20%的问题。本文的目的就是要解决这20%的问题。
2 内存
本节所述内容包括:对Symbian OS 所提供的预防内存泄漏问题的一些技术作了回顾。所有开发者应该对此都有深刻理解:这是Symbian OS 在编程方面的精髓!
2.1 有关清除堆栈 (CleanupStack )
2.1.1 所有程序都应检查“资源用尽”出错
任何应用都可能在运行中发生因资源缺乏而导致的出错,例如,机器用尽了内存,或某个通讯端口不可用。这种类型的出错被称为一个异常。
必需区分异常与编程错误:编程错误用修改程序来解决,但一个程序是不可能完全消除出现异常的可能性。
因此,发生异常时,程序本身应该有能力从各种异常中恢复。在Symbian OS 中,这一点特别重要,这是基于下列理由:
* 各种Symbian OS 应用都被设计成能长时间运行(几个月,甚至几年)而不发生中断或系统重启。
* 各种Symbian OS 应用都被设计成能在仅具备有限资源,特别是内存有限的设备上运行。因而,比起台式机上的应用,在有限资源设备上更容易发生“资源用尽”出错。
并非所有的Symbian OS 设备都具有相同的资源,即,为某类Symbian OS 设备设计并通过验证的应用可能在其他制造商的Symbian OS 设备上发生资源性异常。
2.1.2 传统的侦错方法
在传统的C 或C++程序中,往往用一个if语句来检查是否发生了资源用尽出错。如:
if ((myObject = new CSomeObject()) == NULL)
PerformSomeErrorCode();
2.1.3 使用传统方法的问题
使用这种传统解决方法会产生两方面的问题:
它需要在每个可能导致资源用尽错误的独立函数周围放置许多额外的代码行。这样就会增加代码量,并降低可读性。
如果某个构造函数无法分配资源,就无法返回一个出错代码,因为构造函数没有返回值。结果就可能是一个不完整的被分配对象,这可导致程序崩溃。
* C++异常处理(try,catch及throw)机制为这些问题提供了一些解决方案,但并没有在Symbian OS 中使用,这是因为其代码开销比较大。相反,Symbian OS 提供其本身的异常处理系统。
2.1.4 Symbian OS 中的解决方案
各种Symbian OS 应用能使用下列规则获得有效的异常处理:
* 规则1:所有可以异常退出的函数其名字都以字母 ‘L’结尾。各种异常都顺着调用栈通过一些“异常函数”向后传递,直到被一个 “trap harness (捕获模块)”捕获为止。通常在针对各种控制台应用的E32Main()主函数中实现这一功能,或作为图形用户界面程序的应用框架的一部分提供。
* 规则2:当在堆中分配内存时,如果指向该内存的指针是一个自动变量(即,不是成员变量), 必须将其推入清除堆栈中,以便当发生异常退出时能被释放掉。所有被推入该清除堆栈的对象都必须在销毁前弹出。
* 规则3:C++构造函数或解构函数是不允许异常退出或失败的。因而,如果某个对象的构造函数出现资源不足错误而失败,所有可能导致失败的指令都必须移出该C++构造函数,并将它们放入到ConstructL()函数中,在C++构造函数完成之后才调用该函数。这一过程被称为两阶段构造。
2.2 规则1:异常退出函数和捕获模块
2.2.1 异常退出函数
Symbian OS 中的函数并不返回出错代码,而是一出现资源不足错误时就异常退出。一个异常退出就是对User::Leave()的调用,它导致程序的执行被立即返回到捕获模块中,该函数就在其中执行。所有可以异常退出的函数都以字母‘L’结尾。这使得程序员们明了:该函数是可以异常退出的。例如:
void MyFunctionL()
{
iMember = new (ELeave) CMember;
iValue = AnotherFunctionL();
User::LeaveIfError(iSession.Connect());
}
MyFunctionL 中的每一行都可能导致异常退出。其中的任何一行都使MyFunctionL成为一个异常退出函数。 然而需要注意的是:应用程序代码中很少有必要使用TRAP,因为应用框架已经在适当的地方提供了这些捕捉错误的代码(TRAP),也提供了相应的处理代码。在正常编码过程中并不需要使用错误捕捉代码。一般说来,处理各种异常退出的方法很简单,就是在函数名字后面加上一个字母‘L’,从而让其能顺着函数传递。
2.2.2 new (ELeave)运算符
在Symbian OS 中,New 运算符失败的可能性很高,以至该运算符已经被重置而带上了一个参数,即Eleave。当用这个参数调用New 时,如果没能分配到所需的内存空间,被重置的new运算符就会异常退出。这一功能已经得到了全局性实现,所以,任何类都可以使用该运算符的new
(ELeave)版本,如:
CSomeObject* myObject = new CSomeObject;
if (!myObject) User::Leave(KErrNoMemory);
Can be replaced in by:
CSomeObject* myObject = new (ELeave) CSomeObject;
2.2.3 NewL()和NewLC()惯例
习惯上,Symbian OS 的一些类经常实现NewL()和NewLC()方法。这两个方法在类定义中被声明为static方法,这就使得它们可以在该类的一个实例存在之前就被调用。可以使用类范围来调用它们。如:
CSomeObject* myObject = CSomeObject::NewL();
NewL()在堆上创建了该类的一个新实例,当出现内存不足错误时,它就会异常退出。对简单对象来说,这仅仅涉及到对new (ELeave)的调用。然而,对复合对象来说,它要用到两阶段构造(请见下面对“规则3”的讲述)。
NewLC()在堆上创建了该类的一个新实例,并将其推入到清除堆栈 (见下面对“规则2”的讲述),如果出现了内存不足错误,就发生异常退出。(总的说来,某一个方法尾部的‘C’后缀是指:它在返回前将一个已创建的对象推入到堆中。)
当创建C-类(C-class )对象时,如果某个成员函数会指向该对象,就应该在程序中使用NewL();而如果某个自动变量会指向该对象,就应该使用NewLC()。但是,并不建议对每个类都实现NewL()和NewLC()。实际上,如果仅仅从应用中的一个地方调用NewL()和NewLC(),实现它们的代码行比起所保存的要多许多。较好的做法是:对每个单一类都作一下评估,看看其是否需要用到NewL()和NewLC()。
2.2.4 TRAP and TRAPD 使用捕获模块:TRAP和TRAPD
在出现异常的情形中,开发者可以用一个捕获模块来处理一个异常。然而,TRAP和TRAPD 的使用仅限于特殊情况,而对所有的一般性编码来说,则应避免使用。通常,最佳反应过程是:允许该异常退出传递回Active Scheduler (活动调度器),以便进行默认处理。如果不能确认是否真正需要一个捕获模块,应该存在一个经济的或明晰的方法,以实现相同的功能。
Symbian OS 提供了两种非常相似的捕获模块宏,即TRAP和TRAPD。当捕获模块中的代码执行发生异常退出时,程序控制立即返回给这个陷阱宏。然后该宏返回一个可以由调用函数使用的出错代码。
要在某个捕获模块中执行一个函数,可以使用TRAPD,如下所示:
TRAPD(error, doExampleL());
if (error != KErrNone)
{
// Do some error code
}
TRAP与TRAPD 的不同之处仅仅在于:前者的程序代码必须声明异常代码变量。TRAPD用起来更方便,因为在宏的内部声明了error。如果用TRAP,上述代码就变成:
TInt error;
TRAP(error, doExampleL());
if (error != KErrNone)
{
// Do some error code
}
所有被doExampleL()调用的函数也在捕获模块内部执行,就像所有被其调用的函数一样。在doExampleL()内部嵌套的任何函数如果发生了异常退出,也将返回到这个捕获模块中。其他的TRAP模块也可以被嵌套(nested)在第一个内部,这样就可以在该应用内部的不同级别上对所有的出错进行检查。
2.3 规则2:使用清除堆栈
2.3.1 为何需要清除堆栈(Cleanup Stack )
如果某个函数出现了异常,就立即将控制返回给在其中调用它的TRAP模块。一般说来,默认的TRAP模块处于该线程的活动调度器内。这意味着:TRAP模块中这些被调用函数内部的任何自动变量都被销毁了。然而,如果这些自动变量中的任何一个是指向堆中已分配对象的指针,就会产生问题。当发生异常退出并销毁了这个指针时,被指向对象就悬空了,从而产生内存泄漏。
例如:
void doExampleL()
{
CSomeObject* myObject1 = new (ELeave) CSomeObject;
CSomeObject* myObject2 = new (ELeave) CSomeObject;// WRONG
}
在这个范例中,如果成功创建了myObject1,但却没有足够的内存空间可分配给myObject2,
myObject1 就会在堆中悬空。
这样,我们就需要某些机制来保留这类指针,以便让其所指向的内存在异常退出后得到释放。Symbian OS 在清除堆栈中为此目的提供了一种机制。
2.3.2 使用清除堆栈
清除堆栈中含有一些指针,它们指向所有当发生异常退出时需要释放的对象。这意味着:所有C-类(C-class )对象都由自由变量而不是实例数据所指向。 当发生异常退出时,会弹出TRAP或TRAPD宏,并销毁从TRAP起始时推入到该清除堆栈中的一切东西。
所有的应用程序都有自己创建的清除堆栈。(应用程序框架在图形用户界面应用中自动创建了一 个。)典型的情况是:所有的应用程序将至少有一个对象被推入到清除堆栈中。我们用CleanupStack::PushL()将对象推入到清除堆栈中,而用CleanupStack::Pop()将其弹出。如果位于清除堆栈中的那些对象不再有机会因异常退出而悬空,就必须将这些对象弹出。通常在释放该对象之前会发生异常退出。我们一般使用PopAndDestroy(),而不是Pop(),因为前者将确保该对象在弹出的同时被释放掉,从而避免释放前发生异常退出及内存泄漏。
拥有指向其他C-类(C-class)对象指针的复合对象必须在其解构器中被释放掉。因此,并不需要将任何由另一个对象的成员数据(而不是一个自动变量)所指向的对象推入到清除堆栈中。事实上,一定不需要将其推入到清除堆栈中,否则当发生异常退出时它就会被销毁两次:一次由解构器,另一次由这个TRAP宏。
2.4 规则3:两阶段构造
有时候,某个构造函数需要分配资源,如内存。最普遍的情况就是某个复合C-类(C-class ):如果某个复合类含有一个指向另一个C-类(C-class)的指针,它就需要在自己的构造过程中为那个类分配内存(注意:Symbian OS 中的C-类(C-class)总是被分配在堆中,而且总是将Cbase作为其最根本的基类。)
在下列范例程序中,CmyCompoundClass 具有一个数据成员,这是一个指向CmySimpleClass
的指针。
这里是CmySimpleClass 的定义:
class CMySimpleClass : public CBase
{
public:
CMySimpleClass();
~CMySimpleClass();
…
private:
TInt iSomeData;
};
这里是CmyCompoundClass 的定义:
class CMyCompoundClass : public CBase
{
public:
CMyCompoundClass();
~CMyCompoundClass();
…
private:
CMySimpleClass* iSimpleClass; // owns another C-class
};
开发者可能会为CmyCompoundClass撰写构造函数:
CMyCompoundClass::CMyCompoundClass()
{
iSimpleClass = new CMySimpleClass; // WRONG
}
现在来考虑当创建了一个新的CmyCompoundClass 时发生了什么:
CMyCompoundClass* myCompoundClass = new (ELeave) CMyCompoundClass;
用上面这个构造函数将产生下列依次发生的事件:
为CmyCompoundClass 的实例分配了内存。
调用了CmyCompoundClass 的构造函数。
该构造函数创建了CmySimpleClass 的一个新实例,并将一个指向它的指针存储到
iSimpleClass 中。
构造函数完成工作。
但是,如果由于内存不足而导致第三步失败,将发生什么?不可能从构造函数返回一个出错代码以指出该构造过程并没有完成。New 运算符将返回一个指向分配给CmyCompoundClass 的内存的指针,但它指向的是一个部分构造的对象。
如果我们让该构造函数异常退出,那么当该对象没有完全构造时就能被探测到,如下所示:
CMyCompoundClass::CMyCompoundClass() // WRONG
{
iSimpleClass = new (ELeave) CMySimpleClass;
}
然而,这并不是发现出错的可行方法,因为我们已经为CmyCompoundClass 的实例分配了内存。某次异常退出将销毁指向所分配内存的指针(this),而且无法释放它,从而导致内存泄漏。解决方案是:在C++构造函数对该复合函数进行初始化之后,为该对象的组件分配所有的内存。按惯例,在Symbian OS 中这是在ConstructL()中实现的,如:
void CMyCompoundClass::ConstructL() // RIGHT
{
iSimpleClass = new (ELeave) CMySimpleClass;
}
The C++ constructor should contain only initialization code that cannot leave (if any):
该C++构造函数应该仅含有不可能异常退出(如果有的话)的初始化代码:
CMyCompoundClass::CMyCompoundClass() // RIGHT
{
// Initialization that cannot leave.
}
现在,构造对象如下:
CMyCompoundClass* myCompoundClass = new (ELeave) CMyCompoundClass;
CleanupStack::PushL(myCompoundClass);
myCompoundClass->ConstructL(); // RIGHT
为方便起见,可以将其封装在一个NewL()或NewLC()方法中。
2.4.1 用NewL()和NewLC()实现两阶段构建
如果某个复合对象有一个NewL()方法(或NewLC()方法),那么就应该同时包含构造过程的两个阶段。分配阶段之后,如果ConstructL()发生了异常,应该在调用ConstructL()之前将该对象推入到清除堆栈中。例如:
CMyCompoundClass* CMyCompoundClass::NewLC()
{
CMyCompoundClass* self = new (ELeave) CMyCompoundClass;
CleanupStack::PushL(self);
self->ConstructL();
return self;
}
CMyCompoundClass* CMyCompoundClass::NewL()
{
CMyCompoundClass* self = new (ELeave) CMyCompoundClass;
CleanupStack::PushL(self);
self->ConstructL();
CleanupStack::Pop(); // self
return self;
}
2.5 公共错误
2.5.1 误用TRAP和TRAPD
一些类会重复使用下列形式的代码:
void NonLeavingFunction()
{
TRAPD(error, LeavingFunctionL());
}
这是一段合法的代码,但却不应该广泛使用。考虑到可执行二进制代码的大小和执行速度,错误捕捉模块的代价高昂,除非很小心使用,否则将导致代码丢失错误。经常情形是:在该方法名的尾部加上字母 ‘L’,使异常退出能够向上传递。然而需要注意的是:为维持库兼容性,有时候这成为不可能。库设计应该充分考虑到未来异常退出的需要。
下列代码非常不好,因为整个TRAP都是无意义的!
void NonLeavingFunction()
{
TRAPD(error, LeavingFunctionL());
if (error != KErrNone)
User::Leave(error);
}
2.5.2 错误使用了new 运算符
下面的代码是非法的,也是危险的:
void NonLeavingFunction()
{
bar* foo = NULL;
TRAPD(error, foo = new bar());
foo->DoSomething();
}
在这种情形中,基本地,我们应该使用new 运算符(本身不会退出)的new (ELeave)版本,否则就会导致内存泄漏,也会导致对某个未初始化指针的使用。
2.5.3 错误使用了后缀‘L ’
void NonLeavingFunction()
{
LeavingFunctionL();
bar* foo = new (ELeave) bar();
bar* foo1 = bar::NewL();
}
该函数的所有三行代码都违反了后缀 ‘L’的使用规则。这里有两种选择:
1. 退出行必须在一个错误捕捉代码(TRAP)中被捕获(也许不是最佳方案)。
2. 函数NonLeavingFunction 必须变成一个‘L’函数(也许较佳)。
请注意:这段代码还违反了规则2 (使用清除堆栈,如上所述),因为当NewL 退出时,foo在堆中就被悬空了。
2.6 内存泄漏
在Symbian OS 代码的开发过程中经常进行内存测试非常重要。如果发现了一个内存泄漏,那么就容易在当前的工作环境内部解决这一问题,而不需要去搜寻整个应用程序。
Symbian OS 提供了可用于辅助Symbian OS 代码内存压力测试的、针对编译连接的各种堆内存失败的调试工具。用这些工具我们将看到应用程序在两方面的表现:
1. 内存用完时应用程序的表现。
2. 应用程序关闭时所报告的内存泄漏。
目标是:至少能“向用户传达完整的数据信息”。特别重要的是:在内存测试时使用‘Back(返回)’功能键。直接使用右上部的关闭按钮来关闭模拟器将使得内存检查代码无法运行。
2.6.1 使用WINS 模拟器中的工具
WINS 模拟器提供了一个能检查内存性能的工具,只要按CTRL-SHIFT-ALT-P 键就可执行这种检查。在SDK 文档及《专业Symbian 编程》(Professional Symbian Programming)一书的第158 页中都有详细介绍。该书所讲述的实用程序可用于大部分基于Symbian OS 的SDK,如Series 60 SDK 。各个SDK 的测试实例其屏幕外观各不相同。
图1. Series 60 终端模拟器内存泄漏压力测试实用程序
在Symbian OS 中调试内存泄漏是一件令人生畏的事情,但有些技术可以使这一过程变得不那么痛苦。然而,寻找内存泄漏从来不是一件小事,预防其发生才是最好的对付办法!下列窍门可以在一开始就防止出现内存泄漏,以免日后搜寻之苦。
1. 理解清除堆栈和Leave/TRAP 的范例。
2. 经常生成并运行代码 – 如果发生了泄漏,这样就更容易了解其出处。
3. 使用Symbian OS 6.x/7.0s 的堆检测宏。
4. 测试时,请退出该应用。不要只是杀掉模拟器。
5. 代码检查非常有用。
有两种类型的内存泄漏。“静态”泄漏是一种可重复泄漏,总是发生在应用运行时,它由new和delete运算符的相互不匹配引起。这些泄漏相对比较容易找到,因为它们总发生在相同的地方,所以是可调试的。“动态”泄漏不太会重复。举例来说,由出错状态,或争抢状态所导致的泄漏就是如此。
2.6.1.1 泄漏了什么?
当关闭某个应用时,如果内存泄漏了,模拟器会出现严重提示(panic,实际上这是运行了一个 _UHEAP_MARKEND 宏)。应用程序需要干净地退出,即使在开发进行过程中也应该如此。当开发过程中出现 ‘程序关闭严重提示’这一情况时,可以非常直接对其进行处理。如果拖而不决,以后处理的难度将十倍于此。
在微软的Visual C++调试程序中,严重提示(panic )以“由位于0xxxxxx 的代码调用的用户断点”对话框形式出现。栈跟踪(用“View>Debug Windows>Call Stack”)显示其位于CcoeEnv解构函数中。
接下去,请按 ‘OK’和 ‘F5 ’。这时会遇到另一个用户断点,这一次位于DebugThreadPanic。
这时输出窗口显示Panic ALLOC及一个地址。选择这个地址,将其复制到剪贴板上(“Edit>Copy ”)。
这里是尚未释放内存单元的16 进制地址。试着将这个地址投射到一些可能的类型,就有可能从这个地址找出泄漏类的类型。使用Visual Studio 中的“Quick watch ”窗口,并努力将badCell 指针投射到下列类型上:
CBase* (in case it is a CBase-derived object).
TDesC16* (in case it is a string).
这些投射无法给出任何有用信息,虽然当没有关闭某个客户端时服务器一般应出现严重提示(panic),但也有可能这是一个R-类(R-Class,资源处理)。另外,它也可以是一个被错误地置于堆内存中的T 类(T-Class )。请注意:当某个大型的复合C-类(C-Class)发生了泄漏,这种技术可能会给出稍稍偏离的信息,因为很有可能会报告该大型类的一个成员函数,而不是父函数本身。
2.6.1.2 它被分配到了何处?
一旦知道了已泄漏内存的地址,可以在堆内存的分配器函数中设定一个条件断点以确定其所分配的点。
所有的堆内存分配都通过函数RHeap::Alloc(int)进行。所以,先在那里放一个断点。Symbian目前并不开放这一函数的源代码,但却可以用微软的Visual C++ 中的“Edit>Breakpoints>Break At ”功能明确无误地设置一个断点。
用“Debug>Go ” (‘F5’) 继续操作,直到系统进行首次分配。源代码是不可见的,但却可以看到反汇编的代码。顺着反汇编代码往下看,经过retryAllocation,直到下一个函数roundToPageSize开始前的一行。在RET行放置一个断点,在这个点上,注册器EAX将包含来自RHeap::Alloc 函数的返回值。当其值等于出现问题的内存单元时,用“Edit>Breakpoints”来设置一个断点。请先去除RHeap::Alloc 处的断点,选择新的断点并使用‘条件’来设置这样的条件:返回值为被跟踪单元。在两个对话框中都点击 ‘OK’,然后用“Debug>Go ”继续。与之前一样运行该应用程序。当程序在断点处停止执行时,请检查堆栈,看看问题单元被分配到了何处。
有时可能分配了相同的单元,又多次释放了这个单元。这种情况下,我们只对最后一次分配感兴趣。如果并没有分配单元,这也许是因为,这次运行与第一次不太一样,而泄漏单元则位于不同的地方。继续工作,直到应用程序退出,并找到新问题单元的地址。然后在同一位置将其设置为另一个断点,但加上一个条件,即捕获新的问题单元。这时用“Debug>Restar ”来重新启动。可能会出现“不能恢复所有断点”这样的出错信息。这是因为:当可执行模拟器 (EPOC.EXE)第一次启动时没有加载EUSER DLL 。解决办法是:重新激活RHeap::Alloc(int)断点,运行程序直到该断点,然后恢复其它的条件和断点。
请注意:这段代码由于使用了断点,执行速度会大大降低,所以请在最后一刻才激活断点!同时,同样的地址可以被分配许多次,哪一个才与泄漏有关呢?这就需要当每次遇到断点时都对调用栈进行调查,以发现当时的场景!
2.7 检查和严重提示(Asserts and Panics)
使用__ASSERT_DEBUG测试宏可以避免许多问题。应该不受限制地使用这些宏,检查是否有比较愚 蠢的参数进入到了这些函数中,是否有空指针,以及其他的出错条件等。许多出错条件并不直接导致应用的失败,但却会在以后导致一些副作用。如果能于错误出现之时就捕获它,以后的调试就变得非常容易。例如:
CMyClass::Function(CThing* aThing)
{
__ASSERT_DEBUG(aThing, Panic(EMyAppNullPointerInFunction));
}