1. 前言
嵌入式是軟件設計領域的一個分支,它自身的諸多特點決定了系統架構師的選擇,同時它的一些問題又具有相當的通用性,可以推廣到其他的領域。
提起嵌入式軟件設計,傳統的印象是單片機,匯編,高度依賴硬件。傳統的嵌入式軟件開發者往往只關注實現功能本身,而忽視諸如代碼復用,數據和界面分離,可測試性等因素。從而導致嵌入式軟件的質量高度依賴開發者的水平,成敗系之一身。隨著嵌入式軟硬件的飛速發展,今天的嵌入式系統在功能,規模和復雜度各方面都有了極大的提升。比如,Marvell公司的PXA3xx系列的最高主頻已經達到800Mhz,內建USB,WIFI,2D圖形加速,32位DDR內存。在硬件上,今天的嵌入式系統已經達到甚至超過了數年前的PC平臺。在軟件方面,完善的操作系統已經成熟,比如Symbian, linux, WinCE。基于完善的操作系統,諸如字處理,圖像,視頻,音頻,游戲,網頁瀏覽等各種應用程序層出不窮,其功能性和復雜度比諸PC軟件不遑多讓。原來多選用專用硬件和專用系統的一些商業設備公司也開始轉換思路,以出色而廉價的硬件和完善的操作系統為基礎,用軟件的方式代替以前使用專有硬件實現的功能,從而實現更低的成本和更高的可變更,可維護性。
2.決定架構的因素和架構的影響
架構不是一個孤立的技術的產物,它受多方面因素的影響。同時,一個架構又對軟件開發的諸多方面造成影響。
下面舉一個具體的例子。
摩托車的發動機在出廠前必須通過一系列的測試。在流水線上,發動機被送到每個工位上,由工人進行諸如轉速,噪音,振動等方面的測試。要求實現一個嵌入式設備,具備以下基本功能:
- 安裝在工位上,工人上班前開啟并登錄。
- 通過傳感器自動采集測試數據,并顯示在屏幕上。
- 記錄所有的測試結果,并提供統計功能。比如次品率。
如果你是這個設備的架構師,哪些問題是在設計架構的時候應該關注的呢?
2.1. 常見的誤解
2.1.1. 小型的系統不需要架構
有相當多的嵌入式系統規模都較小,一般是為了某些特定的目的而設計的。受工程師認識,客戶規模和項目進度的影響,經常不做任何架構設計,直接以實現功能為目標進行編碼。這種行為表面上看滿足了進度,成本,功能各方面的需求,但是從長遠來看,在擴展和維護上付出的成本,要遠遠高于最初節約的成本。如果系統的最初開發者繼續留在組織內并負責這個項目,那么可能一切都會正常,一旦他離開,后續者因為對系統細節的理解不足,就可能引入更多的錯誤。要注意,嵌入式系統的變更成本要遠遠高于一般的軟件系統。好的軟件架構,可以從宏觀和微觀的不同層次上描述系統,并將各個部分隔離,從而使新特性的添加和后續維護變得相對簡單。
舉一個城鐵刷卡機的例子,這個例子在前面的課程中出現過。簡單的城鐵刷卡機只需要實現如下功能:
一個While循環足以實現這個系統,直接就可以開始編碼調試。但是從一個架構師的角度,這里有沒有值得抽象和剝離的部分呢?
- 計費系統。計費系統是必須抽象的,比如從單次計費到按里程計費。
- 傳感器系統。傳感器包括磁卡感應器,投幣器等。設備可能更換。
- 故障處理和恢復。考慮到較高的可靠性和較短的故障恢復時間,這部分有必要單獨設計。
未來很可能出現的需求變更:
- 操作界面。是否需要抽象出專門的Model來?以備將來實現View。
- 數據統計。是否需要引入關系型數據庫?
如果直接以上面的流程圖編碼,當出現變更后,有多少代碼可以復用?
不過,也不要因此產生過度的設計。架構應當立足滿足當前需求,并適當的考慮重用和變更。
2.1.2. 敏捷開發不需要架構
極限編程,敏捷開發的出現使一些人誤以為軟件開發無需再做架構了。這是一個很大的誤解。敏捷開發是在傳統瀑布式開發流程出現明顯弊端后提出的解決方案,所以它必然有一個更高的起點和對開發更嚴格的要求。而不是倒退到石器時代。事實上,架構是敏捷開發的一部分,只不過在形式上,敏捷開發推薦使用更高效,簡單的方式來做設計。比如畫在白板上然后用數碼相機拍下的UML圖;用用戶故事代替用戶用例等。測試驅動的敏捷開發更是強迫工程師在寫實際代碼前設計好組件的功能和接口,而不是直接開始寫代碼。敏捷開發的一些特征:
- 針對比傳統開發流程更大的系統
- 承認變化,迭代架構
- 簡潔而不混亂
- 強調測試和重構
2. 嵌入式環境下軟件設計的特點
要談嵌入式的軟件架構,首先必須了解嵌入式軟件設計的特點。
2.1. 和硬件密切相關
嵌入式軟件普遍對硬件有著相當的依賴性。這體現在幾個方面:
- 一些功能只能通過硬件實現,軟件操作硬件,驅動硬件。
- 硬件的差異/變更會對軟件產生重大影響。
- 沒有硬件或者硬件不完善時,軟件無法運行或無法完整運行。
這些特點導致幾方面的后果:
- 軟件工程師對硬件的理解和熟練程度會很大程度的決定軟件的性能/穩定性等非功能性指標,而這部分一向是相對復雜的,需要資深的工程師才能保證質量。
- 軟件對硬件設計高度依賴,不能保持相對穩定,可維護性和可重用性差
- 軟件不能離開硬件單獨測試和驗證,往往需要和硬件驗證同步進行,造成進度前松后緊,錯誤定位范圍擴大。
針對這些問題,有幾方面的解決思路:
- 用軟件實現硬件功能。選用更強大的處理器,用軟件來實現部分硬件功能,不僅可以降低對硬件的依賴,在響應變化,避免對特定型號和廠商的依賴方面都很有好處。這在一些行業里已經成為了趨勢。在PC平臺也經歷了這樣的過程,比如早期的漢卡。
- 將對硬件的依賴獨立成硬件抽象層,盡可能使軟件的其他部分硬件無關,并可以脫離硬件運行。一方面將硬件變更甚至換件的風險控制在有限的范圍內,另一方面提高軟件部分的可測試性。
2.2. 穩定性要求高
大部分嵌入式軟件都對程序的長期穩定運行有較高的要求。比如手機經常幾個月開機,通訊設備則要求24*7正常運行,即使是通訊上的測試設備也要求至少正常運行8小時。為了穩定性的目標,有一些比較常用的設計手段:
- 將不同的任務分布在獨立的進程中。良好的模塊化設計是關鍵
- Watch Dog, Heart beat,重新啟動失效的進程。
- 完善而統一的日志系統以快速定位問題。嵌入式設備一般缺乏有力的調試器,日志系統尤其重要。
- 將錯誤孤立在最小的范圍內,避免錯誤的擴散和連鎖反應。核心代碼要經過充分的驗證,對非核心代碼,可以在監控或者沙盒中運行,避免其破壞整個系統。
舉例,Symbian上的GPRS訪問受不同硬件和操作系統版本影響,功能不是非常穩定。其中有一個版本上當關閉GPRS連接時一定會崩潰,而且屬于known issue。將GPRS連接,HTTP協議處理,文件下載等操作獨立到一個進程中,雖然每次操作完畢該進程都會崩潰,對用戶卻沒有影響。
- 雙備份這樣的手段較少采用
2.3. 內存不足
雖然當今的嵌入式系統的內存比之以K計數的時代已經有了很大的提高,但是隨著軟件規模的增長,內存不足的問題依然時時困擾著系統架構師。有一些原則,架構師在進行設計決策的時候可以參考:
2.3.1. 虛擬內存技術
有一些嵌入式設備需要處理巨大的數據量,而這些數據不可能全部裝入內存中。一些嵌入式操作系統不提供虛擬內存技術,比如WinCE4.2每個程序最多只能使用32M內存。對這樣的應用,架構師應該特別設計自己的虛擬內存技術。所謂的虛擬內存技術的核心是,將暫時不太可能使用的數據移出內存。這涉及到一些技術點:
- 引用計數,正在使用的數據不能移出。
- 使用預測,預測下一個階段某個數據的使用可能性。基于預測移出數據或者提前裝入數據。
- 占位數據/對象。
- 高速緩存。在復雜數據結果下緩存高頻率使用的數據,直接訪問。
- 快速的持久化和裝載。
下圖是一個全國電信機房管理系統的界面示意圖:
每個節點下都有大量的數據需要裝載,可以使用上述技術將內存占用降到最低。
2.3.2. 兩段式構造
在內存有限的系統里,對象構造失敗是必須要處理的問題,失敗的原因中最常見的則是內存不足(實際上這也是對PC平臺的要求,但是在實際中往往忽略,因為內存實在便宜)。兩段式構造就是一種常用而有效的設計。舉例來說:
CMySimpleClass:
class CMySimpleClass
{
public:
CMySimpleClass();
~CMySimpleClass();
...
private:
int SomeData;
};
CMyCompoundClass:
class CMyCompoundClass
{
public:
CMyCompoundClass();
~CMyCompoundClass();
...
private:
CMySimpleClass* iSimpleClass;
};
在CMyCompoundClass的構造函數里初始化iSimpleClass對象。
CMyCompoundClass::CMyCompoundClass()
{
iSimpleClass = new CMySimpleClass;
}
當創建CMyCompoundClass的時候會發生什么呢?
CMyCompoundClass* myCompoundClass = new CMyCompoundClass;
- 為CMyCompoundClass的對象分配內存
- 調用CMyCompoundClass對象的構造函數
- 在構造函數中創建一個CMySimpleClass的實例
- 構造函數結束返回
一切看起來都很簡單,但是如果第三步創建CMySimpleClass對象的時候發生內存不足的錯誤怎么辦呢?構造函數無法返回任何錯誤信息以提示調用者構造沒有成功。調用者于是獲得了一個指向CMyCompoundClass的指針,但是這個對象并沒有構造完整。
如果在構造函數中拋出異常會怎么樣呢?這是個著名的噩夢,因為析構函數不會被調用,在創建CMySimpleClass對象之前如果分配了資源就會泄露。關于在構造函數中拋出異常可以單講一個小時,但是有一個建議是:盡量避免在構造函數中拋出異常。
所以,使用兩段式構造法是一個更好的選擇。簡單的說,就是在構造函數避免任何可能產生錯誤的動作,比如分配內存,而把這些動作放在構造完成之后,調用另一個函數。比如:
AddressBook* book = new AddressBook()
If(!book->Construct())
{
delete book;
book = NULL;
}
這樣可以保證當Construct不成功的時候釋放已經分配的資源。
在最重要的手機操作系統Symbian上,二段式構造法普遍使用。
2.3.3. 內存分配器
不同的系統有著不同的內存分配的特點。有些要求分配很多小內存,有的則需要經常增長已經分配的內存。一個好的內存分配器對嵌入式的軟件的性能有時具有重大的意義。應該在系統設計時保證整個系統使用統一的內存分配器,并且可以隨時更換。
2.3.4. 內存泄漏
內存泄漏對嵌入式系統有限的內存是非常嚴重的。通過使用自己的內存分配器,可以很容易的跟蹤內存的分配釋放情況,從而檢測出內存泄漏的情況。
2.4. 處理器能力有限,性能要求高
這里不討論實時系統,那是一塊很大的專業話題。對一般的嵌入式系統而言,由于處理器能力有限,要特別注意性能的問題。一些很好的架構設計由于不能滿足性能要求,最終導致整個項目的失敗。
2.4.1. 抵御新技術的誘惑
架構師必須明白,新技術常常意味著復雜和更低的性能。即使這不是絕對的,由于嵌入式系統硬件性能所限,彈性較低。一旦發現新技術有和當初設想不同之處,就更難通過修改來適應。比如GWT技術。這是google推出的Ajax開發工具,它可以讓程序員像開發一個桌面應用程序一樣開發Web的Ajax程序。這使得在嵌入式系統上用一套代碼實現遠程和本地操作界面成為了很容易的一件事。但是在嵌入式設備上運行B-S結構的應用,性能上是一個很大的挑戰。同時,瀏覽器兼容方面的問題也很嚴重,GWT目前的版本還不夠完善。
事實證明,嵌入式的遠程控制方案還是要采用Activex,VNC或者其他的方案。
2.4.2. 不要有太多的層次
分層結構有利于清晰的劃分系統職責,實現系統的解耦,但是每多一個層次,就意味著性能的一次損失。尤其是當層和層之間需要傳遞大量數據的時候。對嵌入式系統而言,在采用分層結構時要控制層次數量,并且盡量不要傳遞大量數據,尤其是在不同進程的層次之間。如果一定要傳遞數據,要避免大量的數據格式轉換,如XML到二進制,C++結構到Python結構。
嵌入式系統能力有限,一定要將有限的能力用在系統的核心功能上。
2.5. 存儲設備易損壞,速度較慢
受體積和成本的限制,大部分的嵌入式設備使用諸如Compact Flash, SD, mini SD, MMC等作為存儲設備。這些設備雖然有著不擔心機械運動損壞的優點,但是其本身的使用壽命都比較短暫。比如,CF卡一般只能寫100萬次。而SD更短,只有10萬次。對于像數碼相機這樣的應用,也許是足夠的。但是對于需要頻繁擦寫磁盤的應用,比如歷史數據庫,磁盤的損壞問題會很快顯現。比如有一個應用式每天向CF卡上寫一個16M的文件,文件系統是FAT16, 每簇大小是2K,那么寫完這個16M的文件,分區表需要寫8192次,于是一個100萬次壽命的CF實際能夠工作的時間是1000000/8192 = 122天。而損壞的時候,CF卡的其他絕大部分地方的使用次數不過萬分之一。
除了因為靜態的文件分區表等區塊被頻繁的讀寫而提前損壞,一些嵌入式設備還要面對直接斷電的挑戰,這會在存儲設備上產生不完整的數據。
2.5.1. 損耗均衡
損耗均衡的基本思路是平均地使用存儲器上的各個區塊。需要維護一張存儲器區塊使用情況的表,這個表包括區塊的偏移位置,當前是否可用,以及已經擦寫地次數。當有新的擦寫請求的時候,根據以下原則選擇區塊:
- 盡量連續
- 擦寫次數最少
即使是更新已經存在的數據,也會使用以上原則分配新的區塊。同樣,這張表的存放位置也不能是固定不變的,否則這張表所占據的區塊就會最先損壞。當要更新這張表的時候,同樣要使用以上算法分配區塊。
如果存儲器上有大量的靜態數據,那么上述算法就只能針對剩下的空間生效,這種情況下還要實現對這些靜態數據的搬運的算法。但是這種算法會降低寫操作的性能,也增加了算法的復雜度。一般都只使用動態均衡算法。
目前比較成熟的損耗均衡的文件系統有JFFS2, 和 YAFFS。也有另一種思路就是在FAT16等傳統文件系統上實現損耗均衡,只要事先分配一塊足夠大的文件,在文件內部實現損耗均衡算法。不過必須修改FAT16的代碼,關閉對最后修改時間的更新。
現在的CF卡和SD卡有的已經在內部實現了損耗均衡,這種情況下就不需要軟件實現了。
2.5.2. 錯誤恢復
如果在向存儲器寫數據的時候發生斷電或者被拔出,那么所寫的區域的數據就處于未知的狀態。在一些應用中,這會導致不完整的文件,而在另一些應用中,則會導致系統失敗。所以對這類錯誤的恢復也是嵌入式軟件設計必須考慮的。常用的思路有兩種:
- 日志型的文件系統
這種文件系統并不是直接存儲數據,而是一條條的日志,所以當發生斷電的時候,總可以恢復到之前的狀態。這類文件系統的代表如ext3。
- 雙備份
雙備份的思路更簡單,所有的數據都寫兩份。每次交替使用。文件分區表也必須是雙備份的。假設有數據塊A,A1是他的備份塊,在初始時刻和A的內容是一致的。在分區表中,F指向數據塊A,F1是他的備份塊。當修改文件時,首先修改數據塊A1的內容,如果此時斷電,A1的內容錯誤,但因為F指向的是完好的A,所以數據沒有損壞。如果A1修改成功,則修改F1的內容,如果此時斷電,因為F是完好的,所以依然沒有問題。
現在的Flash設備,有的已經內置錯誤檢測和錯誤校正技術,可以保證在斷電時數據的完整。還有的包括自動的動態/靜態損耗均衡算法和壞塊處理,完全無須上層軟件額外對待,可以當作硬盤使用。所以,硬件越發達,軟件就會越可靠,技術不斷的進步,將讓我們可以把更多的精力投入到軟件功能的本身,這是發展的趨勢。
2.6. 故障成本高昂
嵌入式產品都是軟硬件一起銷售的給用戶的,所以這帶來了一個純軟件所不具備的問題,那就是當產品發生故障時,如果需要返廠才能修復,則成本就很高。嵌入式設備常見有以下的幾類故障:
a) 數據故障。由于某些原因導致數據不能讀出或者不一致。比如斷電引起的數據庫錯誤。
b) 軟件故障。軟件本身的缺陷,需要通過發布補丁程序或者新版本的軟件修正。
c) 系統故障。比如用戶下載了錯誤的系統內核,導致系統無法啟動。
d) 硬件故障。這種故障只有返廠,不屬于我們的討論范圍。
針對前三類故障,要盡可能保證客戶自己,或者現場技術人員就可以解決。從架構的角度考慮,如下原則可以參考:
a) 使用具備錯誤恢復能力的數據管理設計。當數據發生錯誤時,用戶可以接受的處理依次是:
i. 錯誤被糾正,所有數據有效
ii. 錯誤發生時的數據(可能不完整)丟失,之前的數據有效。
iii. 所有數據丟失
iv. 數據引擎崩潰無法繼續工作
一般而言,滿足第二個條件即可。(日志,事務,備份,錯誤識別)
b) 將應用程序和系統分離。應用程序應該放置在可插拔的Flash卡上,可以通過讀卡器進行文件復制升級。非必要的情況不要使用專用應用軟件來升級應用程序。
c) 要有“安全模式”。即當主系統被損壞后,設備依然可以啟動,重新升級系統。常見的uboot可以保證這一點,在系統損壞后,可以進入uboot通過tftp重新升級。
3. 軟件框架
在桌面系統和網絡系統上,框架是普遍應用的,比如著名的ACE, MFC, Ruby On Rails等。而在嵌入式系統中,框架則是很少使用的。究其原因,大概是認為嵌入式系統簡單,沒有重復性,過于注重功能的實現和性能的優化。在前言中我們已經提到,現在的嵌入式發展趨勢是向著復雜化,大型化,系列化發展的。所以,在嵌入式下設計軟件框架也是很有必要,也很有價值的。
3.1. 嵌入式軟件架構面臨的問題
前面我們講到,嵌入式系統軟件架構所面臨的一些問題,其中很重要的一點是,對硬件的依賴和硬件相關軟件的復雜性。還包括嵌入式軟件在穩定性和內存占用等方面的苛刻要求。如果團隊中的每個人都是這些方面高手的話,也許有可能開發出高質量的軟件,但事實是一個團隊中可能只有一兩個資深人員,其他大部分都是初級工程師。人人都去和硬件打交道,都負責穩定性,性能等等指標的話,是很難保證最終產品質量的。如果組件團隊時都是精通硬件等底層技術的人才,又很難設計出在可用性,擴展性等方面出色的軟件。術業有專攻,架構師的選擇決定著團隊的組成方式。
同時,嵌入式軟件開發雖然復雜,但是也存在大量的重用的可能性。如何重用,又如何應對將來的變更?
所以,如何將復雜性對大多數人屏蔽,如何將關注點分離,如何保證系統的關鍵非功能指標,是嵌入式軟件架構設計師應該解決的問題。一種可能的解決方案就是軟件框架。
3.2. 什么是框架
框架是在一個給定的問題領域內,為了重用和應對未來需求變化而設計的軟件半成品。框架強調對特定領域的抽象,包含大量的專業領域知識,以縮短軟件的開發周期,提高軟件質量為目的。使用框架的二次開發者通過重寫子類或組裝對象的方式來實現特殊的功能。
3.2.1. 軟件復用的層次
復用是在我們經常談到的話題,“不要重復發明輪子”也是耳熟能詳的戒條。不過對于復用的理解實際上是有很多個層次的。
最基礎的復用是復制粘貼。某個功能以前曾經實現過,再次需要的時候就復制過來,修改一下就可以使用。經驗豐富的程序員一般都會有自己的程序庫,這樣他們實現的時候就會比新的程序員快。復制粘貼的缺點是代碼沒有經過抽象,往往并不完全的適用,所以需要進行修改,經過多次復用后,代碼將會變得混亂,難以理解。很多公司的產品都有這個問題,一個產品的代碼從另一個產品復制而來,修改一下就用,有時候甚至類名變量名都不改。按照“只有為復用設計的代碼才能真正復用”的標準,這稱不上是復用,或者說是低水平的復用。
更高級的復用是則是庫。這種功能需要對經常使用的功能進行抽象,提取出其中恒定不變的部分,以庫的形式提供給二次開發程序員使用。因為設計庫的時候不知道二次開發者會如何使用,所以對設計者有著很高的要求。這是使用最廣泛的一種復用,比如標準C庫,STL庫。現在非常流行的Python語言的重要優勢之一就是其庫支持非常廣泛,相反C++一直缺少一個強大統一的庫支持,成為短板。在公司內部的開發中總結常用功能并開發成庫是很有價值的,缺點是對庫的升級會影響到很多的產品,必須慎之又慎。
框架是另一種復用。和庫一樣,框架也是對系統中不變的部分進行抽象并加以實現,由二次開發者實現其他變化的部分。典型的框架和庫的最大的區別是,庫是靜態的,由二次開發者調用的;框架是活著的,它是主控者,二次開發者的代碼必須符合框架的設計,由框架決定在何時調用。
舉個例子,一個網絡應用總是要涉及到連接的建立,數據收發和連接的關閉。以庫的形式提供是這樣的:
conn = connect(host,port);
if(conn.isvalid())
{
data = conn.recv();
printf(data);
conn.close();
}
框架則是這樣的:
class mycomm:class connect
{
public:
host();
port();
onconnected();
ondataarrived(unsigned char* data, int len);
onclose();
};
框架會在“適當”的時機創建mycomm對象,并查詢host和port,然后建立連接。在連接建立后,調用onconnected()接口,給二次開發者提供進行處理的機會。當數據到達的時候調用ondataarrived接口讓二次開發者處理。這是好萊塢原則,“不要來找我們,我們會去找你”。
當然,一個完整的框架通常也要提供各種庫供二次開發者使用。比如MFC提供了很多的庫,如CString, 但本質上它是一個框架。比如實現一個對話框的OnInitDialog接口,就是由框架規定的。
3.2.2. 針對高度特定領域的抽象
和庫比較起來,框架是更針對特定領域的抽象。庫,比如C庫,是面向所有的應用的。而框架相對來說則要狹窄的多。比如MFC提供的框架只適合于windows平臺的桌面應用程序開發,ACE則是針對網絡應用開發的框架,Ruby On Rails是為快速開發web站點設計的。
越是針對特定的領域,抽象就可以做的越強,二次開發就可以越簡單,因為共性的東西越多。比如我們上面談到嵌入式系統軟件開發的諸多特點,這就是特定領域的共性,就屬于可以抽象的部分。具體到實際的嵌入式應用,又會有更多的共性可以抽象。
框架的設計目的是總結特定領域的共性,以框架的方式實現,并規定二次開發者的實現方式,從而簡化開發。相應的,針對一個領域開發的框架就不能服務于另一個領域。對企業而言,框架是一種極好的積累知識,降低成本的技術手段。
3.2.3. 解除耦合和應對變化
框架設計的一個重要目的就是應對變化。應對變化的本質就是解耦。從架構師的角度看,解耦可以分為三種:
- 邏輯解耦。邏輯解耦是將邏輯上不同的模塊抽象并分離處理。如數據和界面的解耦。這也是我們最常做的解耦。
- 知識解耦。知識解耦是通過設計讓掌握不同知識的人僅僅通過接口工作。典型的如測試工程師所掌握的專業知識和開發工程師所掌握的程序設計和實現的知識。傳統的測試腳本通常是將這二者合二為一的。所以要求測試工程師同時具備編程的能力。通過適當的方式,可以讓測試工程師以最簡單的方式實現他的測試用例,而開發人員編寫傳統的程序代碼來執行這些用例。
- 變與不變的解耦。這是框架的重要特征。框架通過對領域知識的分析,將共性,也就是不變的內容固定下來,而將可能發生變化的部分交給二次開發者實現。
3.2.4. 框架可以實現和規定非功能性需求
非功能性需求是指如性能,可靠性,可測試性,可移植性等。這些特性可以通過框架來實現。以下我們一一舉例。
性能。對性能的優化最忌諱的就是普遍優化。系統的性能往往取決于一些特定的點。比如在嵌入式系統中,對存儲設備的訪問是比較慢的。如果開發者不注意這方面的問題,頻繁的讀寫存儲設備,就會造成性能下降。如果對存儲設備的讀寫由框架設計,二次開發者只作為數據的提供和處理者,那么就可以在框架中對讀寫的頻率進行調節,從而達到優化性能的目的。由于框架都是單獨開發的,完成后供廣泛使用,所以就有條件對關鍵的性能點進行充分的優化。
可靠性。以上面的網絡通訊程序為例,由于框架負責了連接的創建和管理,也處理了各種可能的網絡錯誤,具體的實現者無須了解這方面的知識,也無須實現這方面錯誤處理的代碼,就可以保證整個系統在網絡通訊方面的可靠性。以框架的方式設計在可靠性方面的最大優勢就是:二次開發的代碼是在框架的掌控之內運行的。一方面框架可以將容易出錯的部分實現,另一方面對二次開發的代碼產生的錯誤也可以捕獲和處理。而庫則不能代替使用者處理錯誤。
可測試性。可測試性是軟件架構需要考慮的一個重要方面。下面的章節會講到,軟件的可測試性是由優良的設計來保證的。一方面,由于框架規定了二次開發的接口,所以可以迫使二次開發者開發出便于進行單元測試的代碼。另一方面,框架也可以在系統測試的層面上提供易于實現自動化測試和回歸測試的設計,例如統一提供的TL1接口。
可移植性。如果軟件的可移植性是軟件設計的目標,框架設計者可以在設計階段來保證這一點。一種方式是通過跨平臺的庫來屏蔽系統差異,另一種可能的方式更加極端,基于框架的二次開發可以是腳本化的。組態軟件是這方面的一個例子,在PC上組態的工程,也可以在嵌入式設備上運行。
3.3. 一個框架設計的實例
3.3.1. 基本架構
3.3.2. 功能特點
上面是一個產品系列的架構圖,其特點是硬件部分是模塊化的,可以隨時插拔。不同的硬件應用于不同的通訊測試場合。比如光通訊測試,xDSL測試,Cable Modem測試等等。針對不同的硬件,需要開發不同的固件和軟件。固件層的功能主要是通過USB接口接收來自軟件的指令,并讀寫相應的硬件接口,再進行一些計算后,將結果返回給軟件。軟件運行在WinCE平臺,除了提供一個觸摸式的圖形化界面外,還對外提供基于XML(SOAP)接口和TL1接口。為了實現自動化測試,還提供了基于Lua的腳本語言接口。整個產品系列有幾十個不同的硬件模塊,相應的需要開發幾十套軟件。這些軟件雖然服務于不同的硬件,但是彼此之間有著高度的相似性。所以,選擇先開發一個框架,再基于框架開發具體的模塊軟件成了最優的選擇。
### 3.3.3. 分析
軟件部分的結構分析如下:
系統分為軟件,固件和硬件三大塊。軟件和固件運行在兩塊獨立的板子上,有各自的處理器和操作系統。硬件則插在固件所在的板子上,是可以替換的。
軟件和固件其實都是軟件,下面我們分別分析。
軟件
軟件的主要工作是提供各種用戶界面。包括本地圖形化界面,SOAP訪問界面,TL1訪問界面。
整個軟件部分分為五大部分:
- 通訊層
- 協議層
- 圖形界面
- SOAP服務器
- TL1服務器
通訊層要屏蔽用戶對具體通信介質和協議的了解,無論是USB還是socket,對上層都不產生影響。通訊層負責提供可靠的通訊服務和適當的錯誤處理。通過配置文件,用戶可以改變所使用的通訊層。
協議層的目的是將數據進行編碼和解碼。編碼的產生物是可以在通訊層發送的流,按照嵌入式軟件的特點,我們選擇二進制作為流的格式。解碼的產生物是多種的,既有供界面使用的C Struct,也可以是XML數據,還可以是Lua的數據結構(tablegt)。如果需要,還可以產生JSON,TL1,Python數據,TCL數據等等。這一層在框架中是通過機器自動生成的,我們后面會講到。
內存數據庫,SOAP Server和TL1 Server都是協議層的用戶。圖形界面通過讀寫內存數據庫和底層通訊。
圖形界面是框架設計的重點之一,原因是這里工作量最大,重復而無聊的工作最多。
讓我們分析一下在圖形界面開發工作中最主要的事情是什么。
- 收集用戶輸入的數據和命令
- 將數據和命令發給底層
- 接收底層反饋
- 將數據顯示在界面上
同時有一些庫用來進一步簡化開發:
這是一個簡化的例子,但是很好的說明了框架的特點:
- 客戶代碼必須按照規定的接口實現
- 框架在適當的時候調用客戶實現的接口
- 每個接口都被設計為只完成特定的單一功能
- 將各個步驟有機的串起來是框架的事,二次開發者不知道,也無須知道。
- 通常都要有附帶的庫。
固件
固件的主要工作是接受來自軟件的命令,驅動硬件工作;獲取硬件的狀態,進行一定的計算后返回給軟件。早期的固件是很薄的一層,因為絕大部分工作是由硬件完成的,固件只起到一個中轉通訊的作用。隨著時代發展,現在的固件開始承擔越來越多原來由硬件完成的工作。
整個固件部分分為五大部分:
硬件抽象層,提供對硬件的訪問接口
互相獨立的任務群
任務/消息派發器
協議層
通訊層
針對不同的設備,工作量集中在硬件抽象層和任務群上。硬件抽象層是以庫的形式提供的,由對硬件最熟悉,經驗最豐富的工程師來實現。任務群則由一系列的任務組成,他們分別代表不同的業務應用。比如測量誤碼率。這部分由相對經驗較少的工程師來實現,他們的主要工作是實現規定的接口,按照標準化文檔定義的方式實現算法。
任務定義了如下接口,由具體開發者來實現:
OnInit();
OnRegisterMessage();
OnMessageArrive();
Run();
OnResultReport();
框架的代碼流程如下:(偽代碼)
CTask* task = new CBertTask();
task->OnInit();
task->OnRegisterMessage();
while(TRUE)
{
task->OnMessageArrive();
task->Run();
task->OnResultReport();
}
delete task;
task = NULL;
這樣,具體任務的實現者所關注的最重要的事情就是實現這幾個接口。其他如硬件的初始化,消息的收發,編碼解碼,結果的上報等等事情都由框架進行了處理。避免了每個工程師都必須處理從上到下的所有方面。并且這樣的任務代碼還有很高的重用性,比如是在以太網上還是在Cable Modem上實現PING的算法都是一樣的。
3.3.4. 實際效果
在實際項目中,框架大大降低了開發難度。對軟件部分尤其明顯,由實習生即可完成高質量的界面開發,開發周期縮短50%以上。產品質量大大提升。對固件部分的貢獻在于降低了對精通底層硬件的工程師的需要,一般的工程師熟知測量算法即可。同時,框架的存在保證了性能,穩定和可測試性等要素。
3.4. 框架設計中的常用模式
3.4.1. 模板方法模式
模板方法模式是框架中最常用的設計模式。其根本的思路是將算法由框架固定,而將算法中具體的操作交給二次開發者實現。例如一個設備初始化的邏輯,框架代碼如下:
TBool CBaseDevice::Init()
{
if ( DownloadFPGA() != KErrNone )
{
LOG(LOG_ERROR,_L(“Download FPGA fail”));
return EFalse;
}
if ( InitKeyPad() != KerrNone )
{
LOG(LOG_ERROR,_L(“Initialize keypad fail”));
return EFalse;
}
return ETrue;
}
DownloadFPGA和InitKeyPad都是CBaseDevice定義的虛函數,二次開發者創建一個繼承于CBaseDevice的子類,具體來實現這兩個接口。框架定義了調用的次序和錯誤的處理方式,二次開發者無須關心,也無權決定。
3.4.2. 創建型模式
由于框架通常都涉及到各種不同子類對象的創建,創建型模式是經常使用的。例如一個繪圖軟件的框架,有一個基類定義了圖形對象的接口,基于它可以派生出橢圓,矩形,直線各種子類。當用戶繪制一個圖形時,框架就要實例化該子類。這時候可以用工廠方法,原型方法等等。
class CDrawObj
{
public:
virtual int DrawObjTypeID()=0;
virtual Icon GetToolBarIcon()=0;
virtual void Draw(Rect rect)=0;
virtual CDrawObj* Clone()=0;
};
3.4.3. 消息訂閱模式
消息訂閱模式是最常用的分離數據和界面的方式。界面開發者只需要注冊需要的數據,當數據變化時框架就會將數據“推”到界面。界面開發者可以無須關注數據的來源和內部組織形式。
消息訂閱模式最常見的問題是同步模式下如何處理重入和超時。作為框架設計者,一定要考慮好這個問題。所謂重入,是二次開發者在消息的回調函數中執行訂閱/取消訂閱的操作,這會破壞消息訂閱的機制。所謂超時是指二次開發者的消息回調函數處理時間過長,導致其他消息無法響應。最簡單的辦法是使用異步模式,讓訂閱者和數據發布者在獨立進程/線程中運行。如果不具備此條件,則必須作為框架的重要約定,禁止二次開發者產生此類問題。
3.4.4. 裝飾器模式
裝飾器模式賦予了框架在后期增加功能的能力。框架定義裝飾器的抽象基類,而由具體的實現者實現,動態地添加到框架中。
舉一個游戲中的例子,圖形繪制引擎是一個獨立的模塊,比如可以繪制人物的靜止,跑動等圖像。如果策劃決定在游戲中增加一種叫“隱身衣”的道具,要求穿著此道具的玩家在屏幕上顯示的是若有若無的半透明圖像。應該如何設計圖像引擎來適應后期的游戲升級呢?
當隱身衣被裝備后,就向圖像引擎添加一個過濾器。這是個極度簡化的例子,實際的游戲引擎要比這個復雜。裝飾器模式還常見用于數據的前置和后置處理上。
3.5. 框架的缺點
一個好的框架可以大大提高產品的開發效率和質量,但也有它的缺點。
- 框架一般都比較復雜,設計和實現一個好的框架需要相當的時間。所以,一般只有在框架可以被多次反復應用的時候適合,這時候,前提投入的成本會得到豐厚的回報。
- 框架規定了一系列的接口和規則,這雖然簡化了二次開發工作,但同時也要求二次開發者必須記住很多規定,如果違反了這些規定,就不能正常工作。但是由于框架屏蔽了大量的領域細節,相對而言,其學習成本還是大大降低了的。
- 框架的升級對已有產品可能會造成嚴重的影響,導致需要完整的回歸測試。對這個問題有兩個辦法。第一是對框架本身進行嚴格的測試,有必要建立完善的單元測試庫,同時開發示例項目,用來測試框架的所有功能。第二則是使用靜態鏈接,讓已有產品不輕易跟隨升級。當然,如果已有產品有較好的回歸測試手段,就更好。
- 性能損失。由于框架對系統進行了抽象,增加了系統的復雜性。諸如多態這樣的手段使用也會普遍的降低系統的性能。但是從整體上來看,框架可以保證系統的性能處于一個較高的水平。
4. 自動代碼生成
4.1. 機器能做的事就不要讓人來做
懶惰是程序員的美德,更是架構師的美德。軟件開發的過程就是人告訴機器如何做事的過程。如果一件事情機器自己就可以做,那就不要讓人來做。因為機器不僅不知疲倦,而且絕不會犯錯。我們的工作是讓客戶的工作自動化,多想一點,就能讓我們自己的工作也部分自動化。極有耐心的程序員是好的,也是不好的。
經過良好設計的系統,往往會出現很多高度類似而且具有很強規律的代碼。未經良好設計的系統則可能對同一類功能產生很多不同的實現。前面關于框架設計的部分已經證明了這一點。有時候,我們更進一步,分析出這些相似代碼之中的規律,用格式化的數據來描述這些功能,而由機器來產生代碼。
4.2. 舉例
4.2.1. 消息的編碼和解碼
上面關于框架的實例中,可以看到消息編解碼的部分已經被獨立出來,和其他部分沒有耦合。加上他本身的特點,非常適合進一步將其“規則化”,用機器產生代碼。
編碼,就是把數據結構流化;解碼反之。以編碼為例,代碼無非是這樣的:(二進制協議)
stream << a.i;
stream << a.j;
stream << a.object;
(為了簡化,這里假設已經設計了一個流對象,可以流化各種數據類型,并且已經處理了諸如字節序轉換等問題。)
最后我們得到一個stream。大家是否已經習慣了寫這種代碼?但是這樣的代碼不能體現工程師任何的創造性,因為我們早已經知道有i, 有j, 還有一個object,為什么還要自己敲入這些代碼呢?如果我們分析一下a的定義,是不是就可以自動產生這樣的代碼呢?
struct dataA
{
int i;
int j;
struct dataB object;
};
只需要一個簡單的語義分析器解析這段代碼,得到一棵關于數據類型的樹,就可以輕易的產生流化的代碼。這樣的分析器用Python等字符串處理能力強的語言不過兩百行左右。關于數據類型的樹類似下圖:
只要遍歷這棵樹,就可以生成所有數據結構的流化代碼。
在上一個框架所舉例的項目中,為一個硬件模塊自動產生的消息編碼解碼器代碼量高達三萬行,幾乎相當于一個小軟件。由于是自動產生,沒有任何錯誤,為上層提供了高可靠性。
還可以用XML或者其他的格式定義數據結構,從而產生自動代碼。根據需要,C++/JAVA/Python,任何類型的都可以。如果希望提供強檢查,可以使用XSD來定義數據結構。有一個商業化的產品,xBinder,很貴,很難用,還不如自己開發。(為什么難用?因為它太通用)。除了編碼為二進制格式,還可以編碼為任何你需要的格式。我們知道二進制格式雖然效率很高,但是太難調試(當然有些人看內存里的十六進制還是很快的),所以我們可以在編碼成二進制的同時,還生成編碼為其他可閱讀的格式的代碼,比如XML。這樣,通訊使用二進制,而調試使用XML,兩全其美。產生二進制的代碼大概是這樣的:
Xmlbuilder.addelement(“i”,a.i);
Xmlbuilder.addelement(“j”,a.j);
Xmlbuilder.addelement(“object”,a.object);
同樣也很適合機器產生。同樣的思路可以用來讓軟件內嵌腳本支持。這里不多說了。(內嵌腳本支持最大的問題是在C/C++和腳本之間交換數據,也是針對數據類型的大量相似代碼。)
最近Google 發布了它的protocol buffer,就是這樣的思路。目前支持C++/Python,估計很快會支持更多的語言,大家可以關注。以后就不要再手寫編碼解碼器了。
4.2.2. GUI代碼
上面的框架設計部分,我們說到框架對界面數據收集和界面更新無能為力,只能抽象出接口,由程序員具體實現。但是讓我們看看這些界面程序員做的事情吧。(代碼經過簡化,可以看作偽代碼)。
void onDataArrive(CDataBinder& data)
{
m_biterror.setText(“%d”,data.biterror);
m_signallevel.setText(“%d”,data.signallevel”);
m_latency.setText(“%d”,data.latency”);
}
Void onCollectData(CDataBinder& data)
{
data.biterror = atoi(m_biterror.getText());
data. signallevel = atoi(m_ signallevel.getText());
data. latency = atoi(m_ latency.getText());
}
這樣的代碼很有趣嗎?想想我們可以怎么做?(XML描述界面,問題是對于復雜邏輯很難)
4.2.3. 小結
由此可見,在軟件架構的過程中,首先要遵循一般性的原則,盡量將系統各個功能部分獨立出來,實現高內聚低耦合,進而發現系統存在的高度重復,規律性很強的代碼,進一步將他們規則化,形式化,最后用機器來產生這些代碼。目前這方面最成功的應用就是消息的編解碼。對界面代碼的自動化生成有一定局限,但也可以應用。大家在自己的工作中要擅于發現這樣的可能,減少工作量,提高工作效率。
4.2.4. Google Protocol Buffer
Google剛剛發布的Protocol Buffer是使用代碼自動生成的一個典范。
Protocol buffers are a flexible, efficient, automated mechanism for serializing structured data – think XML, but smaller, faster, and simpler. You define how you want your data to be structured once, then you can use special generated source code to easily write and read your structured data to and from a variety of data streams and using a variety of languages. You can even update your data structure without breaking deployed programs that are compiled against the "old" format.
你要做的首先是定義消息的格式,Google指定了它的格式:
message Person {
required string name = 1;
required int32 id = 2;
optional string email = 3;
enum PhoneType {
MOBILE = 0;
HOME = 1;
WORK = 2;
}
message PhoneNumber {
required string number = 1;
optional PhoneType type = 2 [default = HOME];
}
repeated PhoneNumber phone = 4;
}
Once you've defined your messages, you run the protocol buffer compiler for your Application's language on your .proto file to generate data access classes. These provide simple accessors for each field (like query() and set_query()) as well as methods to serialize/parse the whole structure to/from raw bytes – so, for instance, if your chosen language is C++, running the compiler on the above example will generate a class called Person. You can then use this class in your application to populate, serialize, and retrieve Person protocol buffer messages. You might then write some code like this:
Person person;
person.set_name("John Doe");
person.set_id(1234);
person.set_email("jdoe@example.com");
fstream output("myfile", IOS::out | ios::binary);
person.SerializeToOstream(&output);
Then, later on, you could read your message back in:
fstream input("myfile", ios::in | ios::binary);
Person person;
person.ParseFromIstream(&input);
cout << "Name: " << person.name() << endl;
cout << "E-mail: " << person.email() << endl;
Protocol Buffer的編碼格式是二進制的,同時也提供可讀的文本格式。效率高,體積小,上下兼容。目前支持Java,Python和C++,很快會支持更多的語言。
5. 面向語言編程(LOP)
5.1. 從自動化代碼生成更進一步
面向語言編程的通俗定義是:將特定領域的知識融合到一種專用的計算機語言當中,從而提高人與計算機交流的效率。
自動化代碼生成其實就是面向語言編程。語言不等于是編程語言,可以是圖,也可以是表,任何可以建立人和機器之間交流渠道的都是計算機語言。軟件開發歷史上的一次生產率的飛躍是高級語言的發明。它讓我們以更簡潔的方式實現更復雜的功能。但是高級語言也有它的缺點,那就是從問題領域到程序指令的過程很復雜。因為高級語言是為通用目的而設計的,所以離問題領域很遠。舉例來說,要做一個圖形界面,我可以跟另一個工程師說:這里放一個按鈕,那邊放一個輸入框,當按下按鈕的時候,就在輸入框里顯示Hello World。我甚至可以隨手給他畫出來。
對于我和他直接的交流而言,這已經足夠了,5分鐘。但是要讓轉變為計算機能夠理解的語言,需要多久?
如果是匯編語言?(告訴計算機如何操作寄存器和內存)
如果是C++? (告訴計算機如何在屏幕上繪圖,如果響應鼠標鍵盤消息)
如果有一個不錯的圖形界面庫?(告訴計算機創建Button,Label對象,管理這些對象,放置這些對象,處理消息)
如果有一個不錯的開發框架+IDE? (用WYSIWYG工具繪制,設計類,類的成員變量,編寫消息響應函數)
如果有一門專門做圖形界面開發的語言?
可以是這樣的:
Label l {Text=””}
Button b{Text=”ok”,action=l.Text=”hello world”}
通用的計算機語言是基于變量,類,分支,循環,鏈表,消息這些概念的。這些概念離問題本身有著遙遠的距離,而且表達能力非常有限。自然語言表達能力很強,但是歧義和冗余太多,無法格式化標準化。傳統的思想告訴我們:計算機語言就是一條條的指令,編程就是寫下這些指令。而面向語言編程的思想是,用盡量貼近問題,貼近人的思維的辦法來描述問題,從而降低從人的思想到計算機軟件轉換的難度。
舉一個游戲開發的例子。現在的網絡游戲普遍的采用了C++或者C開發游戲引擎。而具體的游戲內容,則是由一系列二次開發工具和語言完成的。地圖編輯器就是一種面向游戲的語言。Lua或者類似的腳本則被嵌入到游戲內部,用來編寫武器,技能,任務等等。Lua本身不具備獨立開發應用程序的能力,然而游戲引擎的設計者通過給Lua提供一系列的,各種層次上的接口,將領域知識密集的賦予了腳本,從而大大提高了游戲二次開發的效率。網絡游戲的鼻祖MUD則是設計了LPC來作為游戲的開發語言。MUD的引擎MudOS和LPC之間的關系如圖:
用LPC創建一個NPC的代碼類似如下:
inherit NPC;
void create()
{
set_name("菜花蛇", ({ "caihua she", "she" }) );
set("race", "野獸");
set("age", 1);
set("long", "一只青幽幽的菜花蛇,頭部呈橢圓形。n");
set("attitude", "peaceful");
set("str", 15);
set("cor", 16);
set("limbs", ({ "頭部", "身體", "七寸", "尾巴" }) );
set("verbs", ({ "bite" }) );
set("combat_exp", 100+random(50));
set_temp("apply/attack", 7);
set_temp("apply/damage", 4);
set_temp("apply/defence",6);
set_temp("apply/armor",5);
setup();
}
void die()
{
object ob;
message_vision("$N抽搐兩下,$N死了。n", this_object());
ob = new(__DIR__"obj/sherou");
ob->move(environment(this_object()));
destruct(this_object());
}
LPC培養了一大批業余游戲開發者,甚至成為很多人進入IT行業的起點。原因就是它簡單,易理解,100%為游戲開發設計。這就是LOP的魅力。
5.2. 優勢和劣勢
LOP最重要的優點是將領域知識固化到語言中,從而:
- 提高開發效率。
- 優化團隊結構,降低交流成本,領域專家和程序員可以更好的合作。
- 降低耦合,易于維護。
其次,由于LOP不是通用語言,所涉及的范圍就狹窄很多,所以:
- 更容易得到穩定的系統
- 更容易移植
相應的,LOP也有它的劣勢:
- LOP對領域知識抽象的要求比框架更高。
- 開發一門新的語言本身的成本。幸好現在設計一門新的語言不算太難,還有Lua這樣的“專用二次開發”語言的支持。
- 性能損失。不過相比開發成本的節約,在非性能核心部分使用LOP還是很值得的。
5.3. 在嵌入式系統中的應用
舉例,嵌入式設備的Web服務器。很多設備都提供Web服務用于配置,比如路由器,ADSL貓等等。這種設備所提供的web服務的典型用例是用戶填寫一些參數,提交給Web服務器,Web 服務器將這些參數寫入硬件,并將操作結果或者其他信息生成頁面返回給瀏覽器。由于典型的Apache,MySQL,php組合體積太大且不容易移植,通常嵌入式系統的Web服務都是用C/C++直接寫就的。從socket管理,http協議到具體操作硬件,生成頁面,都一體負責。然而對于功能復雜,Web界面要求較高的情況,用C來寫頁面效率就太低了。
shttpd是一個小巧的web服務器,小巧到只有一個.c文件,4000余行代碼。雖然體積很小,卻具備了最基本的功能,比如CGI。它既可以獨立運行,也可以嵌入到其他的應用程序當中。shttpd在大多數平臺上都可以順利編譯、運行。lua是一個小巧的腳本語言,專用于嵌入和擴展。它和C/C++代碼有著良好的交互能力。
將Lua引擎嵌入到shttpd中,再使用C編寫一個(一些)驅動硬件的擴展,注冊成為Lua的函數,形成的系統結構如下圖:
這樣的應用在嵌入式系統中是有一定代表性的,即,以C實現底層核心功能,而把系統的易變部分以腳本實現。大家可以思考在自己的開發過程中是否可以使用這種技術。這是LOP的一種具體應用模式。(沒有創造一種全新的語言,而是使用Lua)
6. 測試
6.1. 可測試性是軟件質量的一個度量指標
好的軟件是設計出來的,好的軟件也一定是便于測試的。一個難于測試的軟件的質量是難以得到保障的。在今天軟件規模越來越大的趨勢下,以下問題是普遍存在的:
- 測試只能手工進行,回歸測試代價極大,實際只能執行點測,質量無法保證
- 各個模塊只有集成到一起后才能測試
- 代碼不經過任何單元測試就集成
這些問題的根源都在于缺乏一個良好的軟件設計。一個好的軟件設計應該使得單元測試,模塊測試和回歸測試都變得容易,從而保證測試的廣度和深度,最終產生高質量的軟件。除了功能,非功能性需求也必須是可測試的。所以,可測試性是軟件設計中一個重要的指標,是系統架構師需要認真考慮的問題。
6.2. 測試驅動的軟件架構
這里談的是測試驅動的軟件架構,而不是測試驅動的開發。TDD(Test Driven Development) 是一種開發方式,是一種編碼實踐。而測試驅動的架構強調的是,從提高可測試性的角度進行架構設計。軟件的測試分為多個層次:
6.3. 系統測試
系統測試是指由測試人員執行的,驗證軟件是否完整正確的實現了需求的測試。這種測試中,測試人員作為用戶的角色,通過程序界面進行測試。在大部分情況下這些工作是手工完成的。在規范的流程中,這個過程通常要占到整個軟件開發時間的1/3以上。而當有新版本發布的時候,盡管只涉及了軟件的一部分,測試部門依然需要完整的測試整個軟件。這是由代碼“副作用”特點決定的。有時候修改一個bug可以引發更多的bug,破壞原來工作正常的代碼。這在測試中叫回歸測試(Regression test)。對于規模較大的軟件,回歸測試需要很長的時間,在版本新增功能和錯誤修正不多的情況下,回歸測試可以占到整個軟件開發過程了一半以上,嚴重影響了軟件的交付,也使軟件測試部門成為軟件開發流程中的瓶頸。測試過程自動化,是部分解決這個問題的辦法。
作為架構師,有必要考慮如何實現軟件的可自動化測試性。
6.3.1. 界面自動化測試
在沒有圖形化界面以前,字符方式的界面是比較容易進行自動化測試的。一個編寫良好的腳本就可以實現輸入和對輸出的檢查。但是對于圖形化的界面,人的參與似乎變得不可缺少。有一些界面自動化的測試工具,如WinRunner, 這些工具可以記錄下測試人員的操作成為腳本,然后通過回放這些腳本,就可以實現操作的自動化。針對嵌入式設備,有Test Quest可以使用,通過在設備中運行一個類似遠程桌面的Agent,PC端的測試工具可以用圖像識別的方法識別出不同的組件,并發送相應用戶的輸入。此類工具的基本工作原理如圖:
但是這個過程在實際中存在三個問題:
- 可靠性差,經常中斷運行。要寫一個可靠的腳本甚至比開發軟件還要困難。比如,按下一個按鈕,有時候一個對話框立刻就出現,有時候可能要好幾秒,有時候甚至不出現,操作錄制工具不能自動實現這些判斷,而需要手動修改。
- 對操作結果的判斷很困難,尤其是非標準的控件。
- 當界面修改后,原有代碼很容易失效
要應用基于圖形界面的自動化測試工具,架構師在架構的時候應該考慮:
- 界面風格如何保持一致。應當由架構,而非程序員決定架構的風格。包括布局,控件大小,相對位置,文字,對操作的響應方式,超時時長,等等。
- 如何在最合適測試工具的界面和用戶喜歡的界面之中折中。比如,Test Quest是基于圖像識別的,那么黑白兩色的界面是最有利的,而用戶喜歡的漸進色就非常不利。也許讓界面具備自動的切換能力最好。
對于已經完成的產品,如果架構沒有為自動化測試做過考慮,所能應用的范圍就非常有限,不過還是有一些思路可以供參考:
- 實現小規模的自動化腳本。針對一個具體的操作流程進行測試,而不是試圖用一個腳本測試整個軟件。一系列的小測試腳本組成了一個集合,覆蓋系統的一部分功能。這些測試腳本可以都以軟件啟動時的狀態作為基準,所以在狀態處理上會比較簡單
- ”猴子測試”有一定的價值。所謂猴子測試,就是隨機操作鼠標和鍵盤。這種測試完全不理解軟件的功能,可以發現一些正常測試無法發現的錯誤。據微軟內部的資料,微軟的一些產品15%的錯誤是由“猴子測試”發現的。
總的來講,基于界面的自動化測試是不成熟的。對架構師而言一定要避免功能只能通過界面才能訪問。要讓界面僅僅是界面,而軟件大部分的功能是獨立于界面并可以通過其他方式訪問的。上面框架的例子中的設計就體現了這一點。
思考:如何讓界面具有自我測試功能?
6.3.2. 基于消息的自動化測試
如果軟件對外提供基于消息的接口,自動化測試就會變得簡單的多。上面已經提到了固件的TL1接口。對于界面部分,則應該在設計的時候,將純粹的“界面”獨立出來,讓它盡可能的薄,而其他部分依然可以基于消息提供服務。
在消息的基礎上,用腳本語言包裝成函數的形式,可以很容易的調用,并覆蓋消息的各種參數組合,從而提高測試的覆蓋率。關于如何將消息包裝為腳本,可以參考SOAP的實現。如果使用的不是XML,也可以自己實現類似的自動代碼生成。
這些測試腳本應該由開發人員撰寫,每當實現了一個新的接口(也就是一條新的消息),就應該撰寫相應的測試腳本,并作為項目的一部分保存在代碼庫中。當需要執行回歸測試的時候,只要運行一遍測試腳本即可,大大提高了回歸測試的效率。
所以,為了實現軟件的自動化測試,提供基于消息的接口是一個很好的辦法,這讓我們可以在軟件之外獨立的編寫測試腳本。在設計的時候可以考慮這個因素,適當的增加軟件消息的支持。當然,TL1只是一個例子,根據項目的需要,可以選擇任何適合的協議,如SOAP。
6.3.3. 自動化測試框架
在編寫自動化測試腳本的時候,有很多的工作是重復的,比如建立socket連接,日志,錯誤處理,報表生成等。同時,對于測試人員來說,這些工作可能是比較困難的。因此,設計一個框架,實現并隱藏這些重復和復雜的技術,讓測試腳本的編寫者將注意力集中在具體的測試邏輯上。
這樣一個框架應該實現以下功能:
- 完成連接的初始化等基礎工作。
- 捕獲所有的錯誤,保證Test Case中的錯誤不會打斷后續的Test Case執行。
- 自動檢測和執行Test Case。新增的Test Case是獨立的腳本文件,無須修改框架的代碼或者配置。
- 消息編解碼,并以函數的方式提供給Test Case編寫者調用。
- 方便的工具,如報表,日志等。
- 自動統計Test Case的運行結果并生成報告。
自動化測試框架的思路和一般的軟件框架是一致的,就是避免重復勞動,降低開發難度。
下圖是一個自動化測試框架的結構圖:
每個Test Case都必須定義一個規定的Run函數,框架將依次調用,并提供相應的庫函數供Test Case用來發送命令和獲得結果。這樣,測試用例的編寫者就只需要將注意力集中在測試本身。舉例:
def run():
open_laser()
assert(get_laser_state() == ON)
insert_error(BIT_ERROR)
assert(get_error_bit() == BIT_ERROR)
測試用例的編寫者擁有的知識是“必須先打開激光器然后才能向線路上插入錯誤”。而架構師能提供的是消息收發,編解碼,錯誤處理,報表生成等,并將這些為測試用例編寫者隔離。
問題: open_laser, get_laser_state這些函數是誰寫的?
問題:如何進一步實現知識的解耦?能否有更方便的語言來編寫TestCase?
6.3.4. 回歸測試
有了自動化的測試腳本和框架,回歸測試就變得很簡單了。每當有新版本發布時,只需運行一遍現有的Test Case,分析測試報告,如果有測試失敗的Case則回歸測試失敗,需要重新修改,直到所有的Case完全通過。完整的回歸測試是軟件質量的重要保證。
6.4. 集成測試
集成測試要驗證的是系統各個組成模塊的接口是否工作正常。這是比系統測試更低層的測試,通常由開發人員和測試人員共同完成。
例如在一個典型的嵌入式系統中,FPGA,固件和界面是常見的三個模塊。模塊本身還可以劃分為更小的模塊,從而降低復雜度。嵌入式軟件模塊測試的常見問題是硬件沒有固件則無法工作,固件沒有界面就無法驅動;反過來,界面沒有固件不能完整運行,固件沒有硬件甚至無法運行。于是沒有經過測試的模塊直到集成的時候才能完整運行,發現問題后需要考慮所有模塊的問題,定位和解決的代價都很大。假設有模塊A和B,各有十個bug。如果都沒有經過模塊測試直接集成,可以認為排錯的工作量相當于10*10等于100。
所以,在設計一個模塊的時候,首先要考慮,這個模塊如何單獨測試?比如,如果界面和固件之間是通過SOCKET通信的,那么就可以開發一個模擬固件,在同樣的端口上提供服務。這個模擬固件不執行實際的操作,但是會響應界面的請求并返回模擬的結果。并且返回的結果可以覆蓋到各種典型的情況,包括錯誤的情況。使用這樣的技術,界面部分幾乎可以得到100%的驗證,在集成階段遇到錯誤的大大減少。
對固件而言,因為處于系統的中間,所以問題復雜一些。一方面,要讓固件可以通過GUI以外的途徑被調用;另一方面則要模擬硬件的功能。對于第一點,在設計的時候,要讓接口和實現分離。接口可以隨意的更換,比如和GUI的接口也許是JSON,同時還可以提供telnet的TL1接口,但是實現是完全一樣的。這樣,在和GUI集成之前,就可以通過TL1進行完全的測試固件。對于第二點,則應該在設計的時候提取出硬件抽象層,讓固件的主要實現和寄存器,內存地址等因素隔離開來。在沒有硬件或者硬件設計未定的時候實現一個硬件模擬層,來保證固件可以完整運行并測試。
6.5. 單元測試
單元測試是軟件測試的最基本單位,是由開發人員執行以保證其所開發代碼正確的過程。開發人員應該提交經過測試的代碼。未經單元測試的代碼在進入軟件后,不僅發現問題后很難定位,而且通過系統測試是很難做到對代碼分支的完全覆蓋的。TDD就是基于這個層次的開發模式。
單元測試的粒度一般是函數或者類,例如下面這個常用函數:
int atoi(const char *nptr);
這是一個功能非常單一的函數,所以單元測試對它非常有效。可以通過單元測試驗證下列情況:
- 一般正常調用,如”9”,”1000”,”-1”等
- 空的nptr指針
- 非數字字符串,”abc”,”@#!123”,”123abc”
- 帶小數點的字符串, “1.1”,”0.111”,”.123”
- 超長字符串
- 超大數字,”999999999999999999999999999”
- 超過一個的-號和位置錯誤的-號,”—1”,”-1-“,”-1-2”
如果atoi通過了以上測試,我們就可以放心的將它集成到軟件中去了。由它再引發問題的概率就很小了(不是完全沒有,因為我們不能遍歷所有可能,只是挑選有代表性的異常情況進行測試)。
以上的例子可以說是單元測試的典范,但實際中卻常常不是這么回事。我們常常發現寫好的函數很難做單元測試,不僅工作量很大,效果也不見得好。其根本的原因是,函數沒有遵循好一些原則:
- 單一功能
- 低耦合
反觀atoi的例子,功能單一明確,和其他函數幾乎沒有任何耦合(我上面并沒有寫atoi的代碼實現,大家可以自己實現,希望是0耦合)。
下面我舉一個實際中的例子。
這是一個簡單的TL1命令發送和解析軟件,功能需求描述如下:
ü 通過telnet與TL1服務器通訊
ü 發送TL1命令給TL1服務器
ü 解析TL1服務器的響應
TL1是通訊行業廣泛使用的一種協議,為了給不熟悉TL1的朋友簡化問題,我定義了一個簡化的格式:
CMD:CTAG:PAYLOAD;
CMD - 命令的名字,可以是任意字母開頭,由字母和下劃線組成的字符串
CTAG - 一個數字,用于標志命令的序號
PAYLOAD - 可以是任意格式的內容
; - 結束符
相應的,TL1服務器的回應也有格式:
DATE
CTAG COMPLD
PAYLOAD
;
DATE – 日期和時間
CTAG – 一個數字,和TL1 命令所攜帶的CTAG一樣
COMPLD – 表明命令執行成功
PAYLOAD - 返回的結果,可以是任何格式的內容
; - 結束符
舉例:
命令:GET-IP-CONFIG:1:;
結果:
2008-7-19 11:00:00
1 COMPLD
ip address: 192.168.1.200
gate way: 192.168.1.1
DNS: 192.168.1.3
;
命令:
SET-IP-CONFIG:2:172.31.2.100,172.31.2.1,172.31.2.3;
結果:
2008-7-19 11:00:05
2 COMPLD
;
軟件的最上層可能是這樣的:
Dict* ipconf = GET_IP_CONFIG();
ipconf->set(“ipaddr”,”172.31.2.100)
ipconf->set(“gateway”,”172.3.2.1”)
ipconf->set(“dns”,”172.31.2.1”)
SET_IP_CONFIG(ipconf);
以GET_IP_CONFIG為例,這個函數應該完成的功能包括:
ü 建立telnet連接,如果連接尚未建立
ü 構造TL1命令字符串
ü 發送
ü 接收反饋
ü 解析反饋,并給IP_CONF結構復制
ü 返回
我們當然不希望每個這樣的函數都重復實現這些功能,所以我們定義了幾個模塊:
- Telnet 連接管理
- TL1命令構造
- TL1 結果解析
這里我們來分析TL1結果解析,假設設計為一個函數,函數的原型如下:
Dict* TL1Parse(const char* tl1response)
這個函數的功能是接受一個字符串,如果它是一個合法且已知的TL1回應,則將其中的結果提取出來,放入一個字典對象中。
這本來會是一個很便于進行單元測試的例子:輸入各種字符串,檢查返回結果是否正確即可。但是在這個軟件中,有一個很特殊的問題:
TL1Parse在解析一個字符串時,它必須要知道當前要處理的是哪條命令的回應。但是請注意,在TL1的回應中,是不包括命令的名字的。唯一的辦法是使用CTAG,這個命令和回應一一對應的數字。Tl1Parse首先提取出CTAG來,然后查找使用這個CTAG的是什么命令。這里產生了一個對外調用,也就是耦合。
有一個對象維護了一個CTAG和命令名字對應關系的表,通過CTAG,可以查詢到對應的命令名,從而知道如何解析這個TL1 response.
如此一來,TL1Parse就無法進行單元測試了,至少不能輕易的進行。通常的樁函數的辦法都不好用了。
怎么辦?
重新設計,消除耦合。
將TL1Parse拆分為兩個函數:
Tl1_header TL1_get_header(const char* tl1response)
Dict* TL1_parse_payload(const char* tl1name ,const char* tl1payload)
這兩個函數都可以單獨進行完整的單元測試。而這兩個函數的代碼基本就是TL1Parse切分了一下,但是其可測試性得到了很大的提高,得到一個可靠的解析器的可能性自然也大大提升了。
這個例子演示了如何通過設計來提高代碼的可測試性—這里是單元測試。一個隨意設計,隨意實現的軟件要進行單元測試將會是一場噩夢,只有在設計的時候就考慮到單元測試的需要,才能真正的進行單元測試。
6.5.1. 圈復雜度測量
模塊的復雜度直接影響了單元測試的覆蓋率。最著名的度量代碼復雜度的方法是圈復雜度測量。
計算公式:V(F)=e-n+2。其中e是流程圖中的邊的數量,n是節點數量。簡單的算法是統計如 if、while、do和switch 中的 case 語句數加1。適合于單元測試的代碼的復雜度一般認為不應該超過10。
6.5.2. 扇入扇出測量
扇入是指一個模塊被其他模塊所引用。扇出是指一個模塊引用其他模塊。我們都知道好的設計應該是高內聚低耦合的,也就是高扇入低扇出。一個扇出超過7的模塊一般認為是設計欠佳的。扇出過大的模塊進行單元測試不論從樁設置還是覆蓋率上都是困難的。將系統的傳出耦合和傳入耦合的數量結合起來,形成另一個度量:不穩定性。
不穩定性 = 扇出 / (扇入 + 扇出)
這個值的范圍從0到1。值越接近1,它就越不穩定。在設計和實現架構時,應當盡量依賴穩定的包,因為這些包不太可能更改。相反的,依賴一個不穩定的包,發生更改時間接受到傷害的可能性就更大。
6.5.3. 框架對單元測試的意義
框架的應用在很大程度上可以幫助進行單元測試。因為二次開發者被限定實現特定的接口,而這些接口勢必都是功能明確,簡單,低耦合的。之前的框架示例代碼也演示了這一點。這再次說明了,由高水平的工程師設計出的框架,可以強制初級工程師產生高質量的代碼。
7. 維護架構的一致性
在實際的開發中,代碼偏離精心設計的架構是很常見的事情,比如下圖示例了一個嵌入式設備中設計的MVC模式:
View依賴于Controller和Model, Controller依賴于Model,Model作為底層服務提供者,不依賴View或者Controller. 這是一個適用的架構,可以在相當程度上分離業務,數據和界面。但是,某個程序員在實現時,使用了一個從Model到View的調用,破壞了架構。
這種現象通常發生在產品的維護階段,有時也發生在架構的實現階段。為了增加一個功能或者修正一個錯誤,程序員由于不理解原有架構的思路,或者只是單純的偷懶,走了“捷徑”。如果這樣的實現不能及時發現并糾正,設計良好的架構就會被漸漸破壞,也就是我們常說的“架構”腐爛了。通常一個有一定年齡的軟件產品的架構都有這個問題。如何監視并防止這種問題,有技術上的和管理上的手段。
技術上,借助工具,可以對系統組件的依賴進行分析,架構的外在表現最重要的就是各個部分的耦合關系。有一些工具可以統計軟件組件的扇入和扇出。可以用這種工具編寫測試代碼,對組件的扇出進行檢測,一旦發現測試失敗,就說明架構遭到了破壞。這種檢查可以集成在一些IDE中, 在編譯時同步進行,或者在check in的時候進行。更高級的工具可以對代碼進行反向工程生成UML,可以提供更進一步的信息。但通常對扇入扇出做檢查就可以了。
通過設置代碼檢視的開發流程,對程序員check in的代碼進行評審,也可以防止此類問題。代碼檢視是開發中非常重要的一環,它屬于開發后期階段用來防止壞的代碼進入系統的重要手段。代碼檢視通常要關注以下問題:
- 是否正確完整的完成了需求
- 是否遵循了系統的架構
- 代碼的可測試性
- 錯誤處理是否完備
- 代碼規范
代碼檢視通常以會議的形式進行,時間點設置在項目階段性完成,需要check in代碼時。對于迭代式開發,則可以在一個迭代周期結束前組織。參與人員包括架構師,項目經理,項目成員,其他項目的資深工程師等。一般時間不要太長,以不超過2個小時為宜。會議前2天左右發出會議通知和相關文檔代碼,與會者必須先了解會議內容,進行準備。會議中,由代碼的作者首先講解代碼需要實現的功能,自己的實現思路。然后展示代碼。與會者根據自己的經驗提出各種問題和改進意見。這種會議最忌諱的是讓作者感到被指責或者輕視,所以,會議組織者要首先定義會議的基調:會議成功與否的標準不是作者的代碼質量如何,而是與會者是否提供了有益的建議。會后由作者給與會者打分,而不是反之。
8. 一個實際嵌入式系統架構的演化
上世紀九十年代,互聯網的極速發展讓通訊測試設備也得到了極大的發展。那個年代,能夠實現某種測量的硬件是競爭的核心,軟件的目的僅僅是驅動硬件運行起來,再提供一個簡單的界面。所以,最初的產品的軟件結構非常簡單,類似前面的城鐵門禁系統。
優點:程序簡單明了的實現了用戶的需求,一個程序員就可以全部搞定。
缺點:完全沒有劃分模塊,底層上層耦合嚴重。
8.1. 數據處理
用戶要求能將測量結果保存下來,并可以重新打開。數據存儲模塊和界面被獨立出來。
依然保持上面的主邏輯,但是界面部分不僅可以顯示實時的數據,也可以從ResultManager中讀取數據來顯示。
優點:數據和界面分離的雛形初步顯現
缺點:ResultManager只是作為一個工具存在,負責保存和裝載歷史數據。界面和數據的來源依然耦合的很緊。不同的界面需要的不同數據都是通過硬編碼判斷的。
8.2. 窗口管理
隨著功能不斷復雜,界面窗口越來越多,原來靠一個類來繪制各種界面的方式已經不能承受。于是窗口的概念被引入。每個界面都被視為一個窗口,窗口中的元素為控件。窗口的打開,關閉,隱藏則由窗口管理器負責。
優點:界面功能以窗口的單位分離,不再是一個超大的集合。
缺點:雖然有了窗口管理器,但是界面依然是直接和底層耦合的,依然是大循環結構。
8.3. MVC模式
隨著規模進一步擴大,最初的大循環結構終于無法滿足日益復雜的需求了。標準的MVC模式被引入,經歷了一次大的重構。
數據中心作為Model被獨立出來,保存著當前最新的數據。View被放在了獨立的任務中執行,定期從DataCenter輪詢數據。用戶的操作通過View發送給Controller,進一步調用硬件驅動執行。硬件執行的結果從驅動到Controller更新到DataCenter中。界面,數據,命令三者基本解耦。ResultManager成為DataCenter的一個組件,View不再直接與其通訊。
MVC模式的引入,第一次讓這個產品了有真正意義上職責明晰,功能獨立的架構。
8.4. 大量類似模塊,低效的復用
到上一步,作為一個單獨的嵌入式設備,其架構基本可以滿足需求。但是隨著市場的擴展,越來越多的設備被設計出來。這些設備雖然執行的具體測量任務不同,但是他們都有著同樣的操作方式,類似的界面,更主要的是,它們面臨的問題領域是相同的。長期以來,復制和粘貼是唯一的復用方式,甚至類名變量名都來不及改。一個錯誤在一個設備上被修正,同樣一段代碼的錯誤在其他設備上卻來不及修改。而隨著團隊規模的擴大,甚至MVC的基本架構在一些新設備上都沒能遵守。
最終框架被引入了這個系列的產品。框架確定了如下內容:
- MVC模式的基本架構
- 窗口管理器和組件布局算法
- 多國語言方案(字符串管理器)
- 日志系統
- 內存分配器和內存泄露檢測
8.5. 遠程控制
客戶希望將設備固定安放在網絡的某個位置,作為“探針”使用,在辦公室通過遠程控制來訪問這個設備。這對于原本是作為純手持設備設計的系統又是一個挑戰。幸運的是,MVC架構具有相當的彈性,早期的投入獲得了回報。
TL1 Server 對外提供基于Telnet的遠程控制接口。在系統內部,它的位置相當于View,只和原有的Controller和DataCenter通訊。
8.6. 自動化的TL1解釋器
由于TL1命令相當多,而TL1又往往不是客戶的第一需求,很多設備的TL1命令開始不完整。究其原因,還是手寫TL1命令的解釋器太累。后來通過引入Bison和Flex,這個問題有所改善,但還是不足。自動化代碼生成在這個階段被引入。通過以如下的格式定義TL1,工具可以自動生成TL1的編碼和解碼器代碼。
CMD_NAME
{
cmd = “SET-TIME-CONFIG::<ctag>::<year>,<month>,<day>,<hour>,<minute>,[<second>]”
year = 1970..2100
month = 1..12
day = 1..31
hour = 0..23
minute = 0..59
second = 0..59
}
8.7. 測試的難題
經過數十年的積累,產品已經成為一個系列,幾十種設備。大部分設備進入了維護期,經常有客戶提一些小的改進,或者要求修正一下缺陷。繁重的手工回歸測試成為了噩夢。
基于TL1的自動化測試極大的解放了測試人員。通過在PC上運行的測試腳本,回歸測試變得簡單而可靠。唯一不足的是界面部分無法驗證。
基于Test Quest的自動化工具需要在設備運行的pSOS系統上開發一個類似遠程桌面的軟件,而這在pSOS上并非易事。不過好消息是,由于框架固定了界面的風格和布局算法,基于Test Quest的自動化工具會有很高的識別效率。
8.8. 小結
從這個實際的嵌入式產品重構的歷程可以看出,第三步引入MVC模式和第四步的框架化是非常關鍵的。成熟的MVC模式保證了后續一系列的可擴充性,而框架則保證了這個架構的在所有產品中的準確重用。
9. 總結
本文是針對嵌入式軟件開發的特點,討論架構設計的思路和方法。試圖給大家提供一種思想,啟發大家的思維。框架,自動化代碼生成和測試驅動的架構是核心內容,其中框架又是貫穿始終的要素。有人問我,什么是架構師,怎么樣才能成為架構師?我回答說:編碼,編碼,再編碼;改錯,改錯,再改錯。當你覺得厭煩的時候,停下來想想,怎么才能更快更好的完成這些工作?架構師就是在實踐中產生的,架構師來自于那些勤于思考,懶于重復的人。