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));
}