自從40多年前嵌入式系統誕生以來,隨著技術的發展和需求的變化,嵌入式系統軟件就在嵌入式系統中越來越重要。現在,甚至一些嵌入式系統硬件一模一樣,僅僅是軟件不同,就是不一樣的產品(如交換機和路由器)。
嵌入式系統應用領域千差萬別、他們對嵌入式系統的要求和側重點不盡相同,(如工業控制特別強調可靠性), 但基本要求嵌入式系統功能強大、性能穩定、工作可靠。但這3點不是相輔相成的,而是互相之間有矛盾的。
嵌入式系統的功能、穩定性、可靠應與嵌入式系統的硬件、軟件都有關系。本文僅討論版嵌入式系統軟件的可靠性設計問題,因此假設嵌入式系統的硬件是穩定可靠的。盡管一些應用可以在不可靠的硬件上通過軟件設計獲得可靠的產品(如U盤,NAND FLASH是一個不可靠的存儲介質,但通過軟件設計,可以得到可靠的存儲設備,硬盤更是如此),但這不在本文的討論范圍之內。
可靠性與穩定性之間的關系
定律1:越簡單的東西越容易做得可靠
相對錘子來說,機械手表足夠復雜。如果讓一個錘子和一個機械手表都從10層樓高處掉到普通水泥地面,哪個損壞的可能性更大?
當然,如果花費大的代價,如使用最好的材料,并增減減震系統,機械手表甚至可以做到錘子摔壞了而手表不壞。不相信?飛行員從幾萬米高空掉下來不受傷的比比皆是(當然有降落傘啦)。
從上述說明可知,簡單的東西很容易做得高可靠,但復雜的東西要做高可靠花費的代價就高多了。這是普遍原則,對于嵌入式軟件也適用。既然如此,那為什么人們還要做復雜的東西呢?這就涉及第二定律了。
定律2:越復雜的東西越容易做得穩定
記得大學剛入學時有軍訓,最后一項是打靶。本班奉命在打靶的前一天下午擦拭打靶用得半自動步槍,具體型號記不得了,但肯定是中國建國后早期生產的。在擦拭前教官給我們講注意事項,其中有一句是這樣的:“一個人擦一把槍,不要把零件搞混,否則裝不上的。”也就是說,同樣型號的兩把槍,同一個零件不能互換!只是因為建國初期的槍都是使用簡單的工具制造的,零件的尺寸、質量都不穩定,而一把槍上一些零件間的公差要求較小,只好用人工的方法篩選能夠互相配合的零件組裝成成品。這樣,由于產品零件的不穩定,造成了同一個型號的產品的零件互不通用。再看一些現在的槍支,不同型號的槍支60%零件可以互換是很正常的,這有設計的原因,同時也要歸功于制造工具足夠精密復雜,足以制造尺寸質量足夠穩定的零件。
嵌入式系統軟件也是這樣。我們的代碼越寫越大,越寫越復雜,很大程度不就是讓軟件在各種情況下都能夠穩定運行嗎?
定律3:每個系統有一個最小的復雜度
一般普通的錘子必須有一個錘柄和一個錘體,錘柄最簡單估計是圓柱體了,錘體也一樣。似乎最簡單的錘子就是由兩個圓柱體組成了,筆者想象不出更簡單的錘子。而要把錘子做復雜一些很容易,方法很多,例如在錘子上鑄龍雕鳳。
也就是說,在相同的功能與穩定性的前提下,每個系統有一個最小的復雜度。錘子的功能是敲打東西,僅僅是這個功能的話,僅需要一個垂體即可,但那樣容易傷到人的手(穩定性不好),所以需要一個錘柄。嵌入式系統軟件也是如此。
結論
由上面3條定律可知,系統的穩定和可靠之間有一定的矛盾:提高穩定性容易實現的方式是降低系統的復雜度,這又往往降低了系統的穩定性。同樣,提高系統的穩定性又容易降低系統的可靠性,要穩定和可靠都高就需要花費比較大的代價。
功能與可靠性、穩定性之間的關系
由上可知,系統的功能與可靠性、穩定性之間不是孤立的,而是互相聯系互相制約的,下面詳細分析。
定律1:功能的增加是依靠復雜度的增加而增加的
大家知道,普通的錘子只能錘東西,現在需要增加拔釘子的功能,錘體的一端需要改變形狀,很顯然更難制造了(復雜度增加了)。錘子功能增加了,可是也更難使用也更容易損毀了(錘子拿反了,用拔釘子的一面錘東西……)。
復雜度增加了,要保證同樣的可靠性就需要花費更多的代價,顯然功能和可靠性也是一對矛盾。
定律2:功能的增加可能造成單個功能的復雜度的減少
大家可以找一個目前市場上可以買到的最好的拍照手機,和一個普通的數碼相機,比較它們的拍照效果。可以肯定,數碼相機的效果更好。原因是拍照手機由于種種限制,不能把其集成的數碼相機功能做得與普通數碼相機一樣復雜(鏡頭不夠精密、閃光燈只能用LED或低擋的氖燈、感光元件也只能用簡單的),當然穩定性要差一些了。對于嵌入式軟件也是如此,受限于存儲空間的大小、人機接口等,嵌入式軟件的往往只能簡化各個功能代碼才能把它們集成在一起。
復雜度降低了,要保證同樣的穩定性就需要花費更多的代價,所以保證同樣的穩定性甚至是不可能完成的任務,顯然功能和穩定性也是一對矛盾。
結論
由上面2條定律可知,系統的功能和系統的穩定、可靠之間有一定的矛盾。要功能多又要穩定可靠就需要花費比較大的代價。
增加嵌入式系統軟件的可靠性和穩定性的有效方法
優化系統框架設計可以提高系統的穩定性和可靠性
在一定的穩定性和可靠性的基礎上,一個系統有一個理論上最小的最小復雜度,但在實際上要達到這個最小復雜度是不可能的。在實際工作中,往往如在錘子上雕花一樣,增加了復雜度,不但不會提高系統的穩定性,如果做得不好,反而會降低系統的穩定性。
系統的復雜度的增加,要保持原有的可靠性更困難,對提高系統可靠性沒有任何幫助。
想要花費比較小的代價提高系統的穩定性和可靠性,比較好的辦法就是減少系統不必要的復雜度。而對系統復雜度影響最大的就是系統框架,一個好的系統框架能夠抑制系統復雜度的不必要的增加,并且在系統功能變化時對已存在的功能模塊的影響降到最低。
這樣,提高系統的穩定性和可靠性所花費的代價就較低,間接提高了系統的穩定性和可靠性。
穩定可靠來源于嚴格的測試
人永遠不能完全了解世界,因此設計系統時不可能把所有的情況都考慮到。因此,穩定可靠不是嘴說出來的,也不能僅通過分析系統設計而來確定。
提高穩定性的第二步來自嚴格的測試,包括先期的設計人員自己測試和中后期的第三方測試。在測試中發現了問題就必須修改設計并重新測試。如此反復,直到在一定的時間內測試不出問題。
穩定可靠有賴于時間的檢驗
產品經過嚴格的內部測試和小批量試產并提供給友好顧客使用后(外部測試),終于大批量上市了。但即使這樣,世界級的大公司也會出現產品大規模召回的現象,為什么?
前面說過,人永遠不能完全了解世界,因此再嚴格的測試也不可能模擬出實際使用過程中的所有情況。這樣,用戶使用的環境和方法與測試的環境與方法不一致時,產品潛在的不穩定點或不可靠點被暴露出來。如果這些不穩定點或不可靠點是致命的,產品必須被召回。如果不是致命的,也需要改進設計,提高系統的穩定性和可靠性。如此反復。如果系統大量和長時間的使用而不需要改進,說明是穩定可靠的。
因為專業所以穩定可靠
在古代,如果您與專業打鐵匠做鐵錘,誰的產量和質量穩定可靠呢?顯然是打鐵匠。為什么?因為您是業余的而打鐵匠是專業的。
為什么專業會導致穩定可靠?
最重要的原因是他們已經在這個領域花費了很多代價提高系統的穩定和可靠性(否則就不專業了),他們與非專業的已經不在一條起跑線上,非專業的想在短期內超過專業的是不可能的。
其次,是他們對本領域內的情況非常了解,制定的測試方法與實際情況符合度很高,增加了穩定性和可靠性。
第三,是他們可以利用已經經過時間檢驗的系統作為新系統的基礎,甚至直接使用老系統,不可控的復雜度增加有限,只要花費較小的代價就可以保證系統的穩定性和可靠性。
結論:專業分工合作是提高嵌入式系統軟件的最快最省方法
隨著技術的發展和社會的進步,現在用戶要求嵌入式系統功能強大、性能穩定、工作可靠。一個功能強大、穩定的系統有比較高的復雜度,但不是所有的復雜度都對系統的可靠性有大的負面影響。一個經過時間檢驗的可靠模塊對系統可靠性的負面影響很小。
但一個強大的系統往往涉及多方面的知識,很多往往還不是自己的專業范圍內,自己研發要做到可靠需要花費的代價太大,甚至超過收益。此時,尋找專業的合作伙伴提供穩定可靠的模塊集成到自己的系統中,自己只做自己專業內的部分,這樣,復雜度的各個部分對可靠性的負面影響都較少,同時整體復雜度也容易控制,產品可以較快的上市。
嵌入式系統軟件更加適合這種模式。這是因為軟件是一種容易復制的東西,復制品的可靠性、穩定性和復雜度都不會改變。專業公司的軟件模塊一般已經被多個公司在完全不同的環境使用,其功能、穩定性、可靠性都經過嚴格的檢驗,不會對自己的系統帶來大的負面影響。多個公司使用也可以分擔軟件研發的費用,直接使用成本較低。同時,專業公司對自己所屬的領域非常了解,他們可以協助用戶開發,更進一步降低用戶成本。
所以說專業分工合作是提高嵌入式系統軟件的最快最省方法。
需要注意的問題
男人征服世界,女人通過征服男人來征服世界;硬件叱咤江湖,軟件通過控制硬件來統治江湖。當今世界,放眼江湖,有電子的地方就有嵌入式軟件,有電子故障的地方,也就有嵌入式軟件設計缺陷的影子。我們今天就把軟件所容易犯的錯誤和規避的方法一一羅列,并給出應對之法。
嵌入式軟件的最大特點是以控制為主,軟硬結合的較多,功能性的操作較多,模塊相互間調用的較多,外部工作環境復雜容易受到干擾或干擾別的設備,且執行錯誤的后果不僅僅是數據錯誤而是有可能導致不可估量的災難,所以總結起來,嵌入式軟件可靠性設計需注意的問題有四個方面:
1、軟件接口
先說軟件接口中容易出問題的地方和編程人員容易犯的錯誤。
軟件接口調用一般會有數據的賦值,賦值變量的數據類型可能會存在強制的數據轉換;需加以檢查。如果為了防范出問題的話,可以添加對數據范圍和數據類型的檢查。
賦值數據的數量不對路,多了少了的都不好,會出現意外的賦值結果,不過還好,這項錯誤比較好檢查。
軟件編程中,會有對某一功能操作代碼的復用,比如對某個端口的數據檢查和控制,在整個程序中只會發生兩次,為了圖省事,可能就直接把該段代碼直接插入實際程序模塊中去了,這樣,在源程序代碼中,就出現了兩段完全相同,完成相同功能,只是服務于不同模塊的代碼,按道理來說,這樣設計其實也沒啥問題,是的,你沒錯,但你的行為會使別人無意中犯錯。就像青年男女相處,女孩子純粹是想和男孩子充分享受溫馨的氣氛和心情,并不想更深入的發生什么,但女孩子邀請男生去的是她的家,在家里換上了家居的睡衣,窗戶緊閉,放著的還是曖昧的音樂,被男孩子半強迫發生后,無限哀怨地說“我沒想到結果會是這樣的”,那怪得誰來呢?在代碼方面,您的這種做法與貌似引誘男孩上鉤的少女無異。
有人會說了,我這樣寫代碼怎么就算引誘呢?原因是程序可能會升級,您這幾行代碼在實際應用過程中也不能保證是盡善盡美的,發現不完善的地方后,勢必會修改,如果你還能想得起來,可能不會遺漏,如果修改此代碼的是別的人,改了一個地方,別的地方沒改,是不是還留著隱患?那如何做呢?方法不難,把這段功能單獨做成一個模塊即可,對此端口的讀取和控制賦值均由此獨立模塊完成,如果數據的正確性影響大的話,還需要對端口數據的正確性進行檢查和判斷。嵌入式軟件可靠性編程方法的四個目的是防錯、判錯、糾錯、容錯。對端口數據的判斷屬于判錯的內容,如果數據有錯的話,糾錯和容錯的設計方法應該不用我深入講解了吧?
2、軟硬件接口
硬件如男人,對外的執行都靠它來實現,一旦出現問題,執行后的后果就不可控了,周總理說過“外交無小事”。但如何注意呢?
對讀進來的硬件接口的數據要判斷其真偽;
對輸出的數據的執行效果要檢測;
對輸出的數據的可能后果要進行預防性設計,數據輸出的過程,我們從設計上要做一個分析,分析的思路是一般容易局限在穩態過程,忽視了過渡過程。舉例說明,比如我們控制一個支路的供電,從軟件控制來說,直接給繼電器一個啟動信號,讓開狀態的觸點閉合就可以了,非“關”即“開”,是受控繼電器的兩個穩態狀態,但事實上,在從開到閉合的過程中,支路供電的電壓并不是一個簡單0V—24V(24V為示例而已)的跳變狀態,而是一個抖動,有沖擊信號的過程,這種情況在硬件上的防護是必不可少的,但在軟件上也不是可以事不關己、高高掛起的。
另外在邏輯上,宜將容易被干擾和容易產生的干擾控制動作從時序上控制好,予以分開隔離。比如,控制繼電器的過程是容易產生抖動尖峰脈沖而干擾數據總線和控制信號總線的,這時候從控制上,不宜同時實施數據的發送和接收工作,不宜作出其他的控制動作,惹不起咱躲得起,躲過這一陣干擾的時候總可以了吧?
3、軟件代碼
軟件的可靠性是隨著時間的推移,可靠性逐漸增加的,這一點區別于電子可靠性、機械可靠性。電子可靠性服從指數分布,在整個生命周期內,其失效率為一個常數;機械可靠性因為磨損、腐蝕、運動等因素的存在,隨時間推移可靠度會下降。因此也就有了軟件可靠性設計的一個特定規律和注意事項。
既然需要通過時間推移,通過不斷改進,軟件可靠性得到提升。那么軟件的可維護性就是一個大問題了。這也是為什么軟件工程管理方面特別關注軟件文檔、注釋的原因了。但做這些要求的人只是人云亦云,并不理解如此做法的真正動機。至于注釋如何去做、變量如何命名、軟件配置管理如何操作,這里面既有很常規的方法,也有一些我們司空見慣然而是錯誤的做法。信手舉上幾個值得注意的細節供參考。
變量定義時宜將變量類型的變量名程中體現于其中;如AD_result_int、Cal_result_float等。這樣為的好檢查,防止數據類型的強制轉換或強制賦值時出現數據類型的錯誤;
注釋要充分;
代碼的布局風格宜統一,便于閱讀查找;
不可出現非受控的default流程,所有數值和變量,不論是調用函數時賦予的、讀取接口讀進來的、還是中間變量計算出來的,在應用前都宜作數據有效性的判斷,并對判定的所有可能結果均做受控的對應處理。
… …
關于軟件可維護性編程方法方面的文章資料在網上是鋪天蓋地,不予贅述,綜合采用之即可。很多文章把軟件可維護性編程規范推薦做成企業的嵌入式軟件可靠性設計規范,實在是有點以偏概全,有失偏頗的,用一句娛樂圈的話來說,“愛情是生活的重要內容,但它不是生活的全部”,軟件可維護性編程方法亦然。
軟件代碼在執行中容易出現的下一個問題是跑飛,程序指針受到干擾,跳轉到了一個非受控位置,執行了不該執行的代碼。如果執行了不該執行的代碼,如果在程序中加入了足夠的變量判斷、讀值判斷、狀態檢測判斷等,那倒還好了,后果也不會太嚴重,甚至最終還是可能自己跑回來的。但有一種跑飛是比較可怕的,一般我們在ROM中存放的程序目標代碼是1-3字節的指令,就是最多3條字段的目標碼組成了執行動作,如果程序指針跑飛到了某個3字節指令的第2個字節上的時候,執行的后果是什么,可就真的沒人知道了,即使在程序上作了足夠的數據判錯、邏輯跳轉的防范措施,結果也不會好。而且ROM一般是不可能全部都被程序代碼填滿的,總有富余空間,富余空間中的默認內容是啥,這些默認字節是否也會導致一些操作呢?單片機中的默認空間是0FFH,DSP的我沒查過,大家有興趣查一下,跳到這些字段里,也是容易出麻煩的。
好了,不再羅嗦,直接給出解決方法吧,就是每隔一段程序代碼或控制區域,就人為放置上幾個NOP指令,在NOP指令后放置一個長跳轉的ERR處理程序。注意NOP最少放置3個,這樣任何的跑飛最多只能占用2個NOP,第三個NOP一樣還是能把程序代碼揪回來,揪回來后就執行ERR處理程序。
如果碰到安全性、可靠性等級要求比較高的程序,推薦的處理方法可以采用熱備份的處理方法,即用兩段代碼同時執行同一個功能,執行的結果進行對比,如果一致則放行通過,如果結果不一致,咋處理就看您的嘍。但是… …國人有的是辦法,為了圖省事,你領導不是要求我編熱備份程序嗎,那好,我就把原來的代碼復制一遍,重新插入到某個地方,您這和明朝時代馮保太監(還是嚴嵩、張居正阿?拿不準了,大家有興趣的翻看《明朝那些事兒》查閱下)玩的沒啥兩樣,自己寫奏章,自己給自己審批奏章。既然是備份就是為了防止一個人出問題,那最好的辦法自然是不同的人來編這段,如果原理計算方法上也不同,數據采集通道也不同,那就過年帶娶媳婦的,好上加好了。
安全性和可靠性的編程細節注意事項還有很多,窺一斑難見全豹呵,諸位仁兄一起努力鉆研了。
4、數據、變量
變量的定義是為的避免各種混淆,同一程序內數據和數據的混淆、不同人讀程序時對變量理解上出現的二義性、視覺效果上容易出現的錯誤(字母的“o”和數字的“0”,字母的“l”和數字的“1”)。這里要遵循一個“要么相同,要么迥異”的基本規則,這條規則在很多的領域都有應用,用的最絕的是朱元璋,對待貪官,要么不理你,自覺點您貪差不多了就收手吧,您自己不收手的話,做的過了直接就殺,株連幾族,所以在明朝,朱元璋是殺人最多的皇帝;在結構的防呆性設計上,接插件的選型也是如此,如果一個乳白色和一個淺灰色的同類接插件,最好的選擇是有很直觀的視覺差異或結構的差異,或者干脆就是相同的,相同須基于一個前提,互換性要好。
用顯意的符號來命名變量和語句標號。標識符的命名有明確含義,且是完整單詞或易理解的縮寫。短單詞通過去掉“元音”形成縮寫;長單詞取頭幾個字母形成縮寫;一些單詞有公認的縮寫。如:
Temp — tmp;
Flag — f.l.g;(*注:請去年中間的。號)
Statistic — stat;
Increment — inc;
Message — msg。
特殊約定或縮寫,要有注釋說明。在源文件開始處,對使用的縮寫或約定注釋說明。自己特有的命名風格,要自始至終保持一致。對于變量命名,禁止取單個字符(如i、j、k.。。);含義+變量類型、數據類型等,i、j、k作局部循環變量是允許的,但容易混淆的字母慎用。如int Liv_Width,L代表局部變量(Local)(g全局變量Global)、i代表數據類型(Interger)、 v代表 變量(Variable)(c常量Const)、Width代表變量的含義,這種命名方式可防止局部變量與全局變量重名。
禁用易混淆的標識符(R1和Rl,DO和D0等)來表示不同的變量、文件名和語句標號。
除了編譯開關/頭文件等特殊應用,避免使用_EXAMPLE_TEST_之類以下劃線開始和結尾的定義。
全局變量是戰略性資源,它決定了模塊和模塊間的耦合度,需在項目上提升到一個足夠高的高度,慎用全局變量,不得不用的時候,要單獨為每一個全局變量編寫獨立的操作模塊或函數,在修改全局變量的時候,要檢查是否有別的函數在調用它并且需要此數值保持穩定。
對變量代表某個特定含義的時候,盡量不要僅僅用位來代表什么,比如用某變量的第零位代表某個狀態(0000 0001,其中僅用1代表某個內容,這樣01H、03H、05H… 會有很多個組合都能代表這個狀態);位容易受干擾被修改,信息出現錯誤的幾率大很多。
也不要用00H、FFH等數據代表,就像我們面試一群人一樣,第一個被面試人和最后一個被面試人容易被記住,00H和FFH亦然,系統默認狀態是00和FF的時候較多,他們容易被復位或置位成這類數值。推薦以四位的二進制碼的某個中間值為狀態變量,如1001。
變量數據在應用之前宜作數據類型和數值范圍的判斷;
數據在存儲過程中也容易出現問題,EEPROM、RAM等都有過類似的案例。數據出錯時避免不了的,解決的辦法是學花旗銀行等美國金融企業,之所以在9.11后他們能很快恢復業務,基本沒有數據方面的損失,原因何在?因為他們有異地容災數據備份系統,知里面有兩個關鍵詞,異地、備份。我們的信息也同樣,首先選擇存在不同的介質中、或相同的介質但迥異的存放環境和位置下,雙重備份的結局是兩邊不一致的時候,數據被懷疑并拒絕反映執行,但嵌入式軟件很多時候是要靠數據來推動執行機構的,即使發現數據有問題也不允許行政不作為,這種情況下,作為我們也很難辦,2個不同的數據,有明顯問題的還好排除,都在有限范圍內可如何判定哈?這種時候沒辦法只好三備份,少數服從多數是唯一的選擇了。石頭剪刀布的方式不好用,葛優的分歧終端機也不適用,就只好選擇這種最原始最有效的辦法了,唯一需要注意的是數據宜存放于三種不同的備份環境下,不然豈不成了你家哥倆兒,咋表決都占便宜啊。
以上僅就嵌入式軟件可靠性的關注方面分了幾大類,進行了基本的描述,實際應用中,需要關注的點還有很多很多,如果是準備自行制定設計規范的話,以上的思路應該也可以給與一些啟迪了。
如何防錯
設備的可靠性涉及多個方面:穩定的硬件、優秀的軟件架構、嚴格的測試以及市場和時間的檢驗等等。這里著重談一下對嵌入式軟件可靠性設計的一些理解,通過一定的技巧和方法提高軟件可靠性。這里所說的嵌入式設備,是指使用單片機、ARM7、Cortex-M0,M3之類為核心的測控或工控系統。
嵌入式軟件可靠性設計應該從防錯、判錯和容錯三方面進行考慮。 此外,還需理解自己所使用的編譯器特性。
此文屬拋磚引玉。
1.防錯
良好的軟件架構、清晰的代碼結構、掌握硬件、深入理解C語言是防錯的要點,這里只談一下C語言。
“人的思維和經驗積累對軟件可靠性有很大影響“。C語言詭異且有種種陷阱和缺陷,需要程序員多年歷練才能達到較為完善的地步。“軟件的質量是由程序員的質量以及他們相互之間的協作決定的”。因此,作者認為防錯的重點是要考慮人的因素。
“深入一門語言編程,不要浮于表面”。軟件的可靠性,與你理解的語言深度密切相關,嵌入式C更是如此。除了語言,作者認為嵌入式開發還必須深入理解編譯器。
本節將對C語言的陷阱和缺陷做初步探討。
1.1 處處皆陷阱
最初開始編程時,除了英文標點被誤寫成中文標點外,可能被大家普遍遇到的是將比較運算符==誤寫成賦值運算符=,代碼如下所示:
if(x=5) { … }
這里本意是比較變量x是否等于常量5,但是誤將’==’寫成了’=’,if語句恒為真。如果在邏輯判斷表達式中出現賦值運算符,現在的大多數編譯器會給出警告信息。并非所有程序員都會注意到這類警告,因此有經驗的程序員使用下面的代碼來避免此類錯誤:
if(5==x) { … }
將常量放在變量x的左邊,即使程序員誤將’==’寫成了’=’,編譯器會產生一個任誰也不能無視的語法錯誤信息:不可給常量賦值!
+=與=+、-=與=-也是容易寫混的。復合賦值運算符(+=、*=等等)雖然可以使表達式更加簡潔并有可能產生更高效的機器代碼,但某些復合賦值運算符也會給程序帶來隱含Bug,如下所示代碼:
tmp=+1;
該代碼本意是想表達tmp=tmp+1,但是將復合賦值運算符+=誤寫成=+:將正整數常量1賦值給變量tmp。編譯器會欣然接受這類代碼,連警告都不會產生。
如果你能在調試階段就發現這個Bug,你真應該慶祝一下,否則這很可能會成為一個重大隱含Bug,且不易被察覺。
-=與=-也是同樣道理。與之類似的還有邏輯與&&和位與&、邏輯或||和位或|、邏輯非!和位取反~。此外字母l和數字1、字母O和數字0也易混淆,這種情況可借助編譯器來糾正。
很多的軟件BUG自于輸入錯誤。在Google上搜索的時候,有些結果列表項中帶有一條警告,表明Google認為它帶有惡意代碼。如果你在2009年1月31日一大早使用Google搜索的話,你就會看到,在那天早晨55分鐘的時間內,Google的搜索結果標明每個站點對你的PC都是有害的。這涉及到整個Internet上的所有站點,包括Google自己的所有站點和服務。Google的惡意軟件檢測功能通過在一個已知攻擊者的列表上查找站點,從而識別出危險站點。在1月31日早晨,對這個列表的更新意外地包含了一條斜杠(“/”)。所有的URL都包含一條斜杠,并且,反惡意軟件功能把這條斜杠理解為所有的URL都是可疑的,因此,它愉快地對搜索結果中的每個站點都添加一條警告。很少見到如此簡單的一個輸入錯誤帶來的結果如此奇怪且影響如此廣泛,但程序就是這樣,容不得一絲疏忽。
數組常常也是引起程序不穩定的重要因素,C語言數組的迷惑性與數組下標從0開始密不可分,你可以定義int a[30],但是你絕不可以使用數組元素a[30],除非你自己明確知道在做什么。
switch…case語句可以很方便的實現多分支結構,但要注意在合適的位置添加break關鍵字。程序員往往容易漏加break從而引起順序執行多個case語句,這也許是C的一個缺陷之處。對于switch…case語句,從概率論上說,絕大多數程序一次只需執行一個匹配的case語句,而每一個這樣的case語句后都必須跟一個break。去復雜化大概率事件,這多少有些不合常情。
break關鍵字用于跳出最近的那層循環語句或者switch語句,但程序員往往不夠重視這一點。
1990年1月15日,AT&T電話網絡位于紐約的一臺交換機當機并且重啟,引起它鄰近交換機癱瘓,由此及彼,一個連著一個,很快,114臺交換機每六秒當機重啟一次,六萬人九小時內不能打長途電話。當時的解決方式:工程師重裝了以前的軟件版本。事后的事故調查發現,這是break關鍵字誤用造成的。《C專家編程》提供了一個簡化版的問題源碼:
network code(){ switch(line) { case THING1: doit1(); break; case THING2: if(x==STUFF) { do_first_stuff(); if(y==OTHER_STUFF) break; do_later_stuff(); } /*代碼的意圖是跳轉到這里… …*/ initialize_modes_pointer(); break; default: processing(); }/*… …但事實上跳到了這里。*/ use_modes_pointer();/*致使modes_pointer未初始化*/}
那個程序員希望從if語句跳出,但他卻忘記了break關鍵字實際上跳出最近的那層循環語句或者switch語句。現在它跳出了switch語句,執行了use_modes_pointer()函數。但必要的初始化工作并未完成,為將來程序的失敗埋下了伏筆。
將一個整形常量賦值給變量,代碼如下所示:
int a=34, b=034;
變量a和b相等嗎?答案是不相等的。我們知道,16進制常量以’0x’為前綴,10進制常量不需要前綴,那么8進制呢?它與10進制和16進制表示方法都不相通,它以數字’0’為前綴,這多少有點奇葩:三種進制的表示方法完全不相通。如果8進制也像16進制那樣以數字和字母表示前綴的話,或許更有利于減少軟件Bug,畢竟你使用8進制的次數可能都不會有誤使用的次數多!下面展示一個誤用8進制的例子,最后一個數組元素賦值錯誤:
a[0]=106; /*十進制數106*/a[1]=112; /*十進制數112*/a[2]=052; /*實際為十進制數42,本意為十進制52*/
指針的加減運算是特殊的。下面的代碼運行在32位ARM架構上,執行之后,a和p的值分別是多少?
int a=1; int *p=(int*)0x00001000; a=a+1; p=p+1;
對于a的值很容判斷出結果為2,但是p的結果卻是0x00001004。指針p加1后,p的值增加了4,這是為什么呢?原因是指針做加減運算時是以指針的數據類型為單位。p+1實際上是p+1*sizeof(int)。不理解這一點,在使用指針直接操作數據時極易犯錯。比如下面對連續RAM初始化零操作代碼:
unsigned int *pRAMaddr; //定義地址指針變量for(pRAMaddr=StartAddr;pRAMaddr《EndAddr;pRAMaddr+=4){ *pRAMaddr=0x00000000; //指定RAM地址清零}
由于pRAMaddr是一個指針變量,所以pRAMaddr+=4代碼其實使pRAMaddr偏移了4*sizeof(int)=16個字節,所以每執行一次for循環,會使變量pRAMaddr偏移16個字節空間,但只有4字節空間被初始化為零。其它的12字節數據的內容,在大多數架構處理器中都會是隨機數。
對于sizeof(),這里強調兩點,第一它是一個關鍵字,而不是函數,并且它默認返回無符號整形數據(要記住是無符號);第二,使用sizeof獲取數組長度時,不要對指針應用sizeof操作符,比如下面的例子:
void ClearRAM(char array[]){ int i ; for(i=0;i《sizeof(array)/sizeof(array[0]);i++) //這里用法錯誤,array實際上是指針 { array[i]=0x00; }} int main(void){ char Fle[20]; ClearRAM(Fle); //只能清除數組Fle中的前四個元素}
我們知道,對于一個數組array[20],我們使用代碼sizeof(array)/sizeof(array[0])可以獲得數組的元素(這里為20),但數組名和指針往往是容易混淆的,而且有且只有一種情況下是可以當做指針的,那就是數組名作為函數形參時,數組名被認為是指針。同時,它不能再兼任數組名。注意只有這種情況下,數組名才可以當做指針,但不幸的是這種情況下容易引發風險。在ClearRAM函數內,作為形參的array[]不再是數組名了,而成了指針。sizeof(array)相當于求指針變量占用的字節數,在32位系統下,該值為4,sizeof(array)/sizeof(array[0])的運算結果也為4。所以在main函數中調用ClearRAM(Fle),也只能清除數組Fle中的前四個元素了。
增量運算符++和減量運算符--既可以做前綴也可以做后綴。前綴和后綴的區別在于值的增加或減少這一動作發生的時間是不同的。作為前綴是先自加或自減然后做別的運算,作為后綴時,是先做運算,之后再自加或自減。許多程序員對此認識不夠,就容易埋下隱患。下面的例子可以很好的解釋前綴和后綴的區別。
int a=8,b=2,y;y=a+++--b;
代碼執行后,y的值是多少?
這個例子并非是挖空心思設計出來專門讓你絞盡腦汁的C難題(如果你覺得自己對C細節掌握很有信心,做一些C難題檢驗一下是個不錯的選擇。那么,《The C Puzzle Book》這本書一定不要錯過。),你甚至可以將這個難懂的語句作為不友好代碼的反面例子。但是它也可以讓你更好的理解C語言。根據運算符優先級以及編譯器識別字符的貪心法原則,代碼y=a+++--b;可以寫成更明確的形式:
y=(a++)+(--b);
當賦值給變量y時,a的值為8,b的值為1,所以變量y的值為9;賦值完成后,變量a自加,a的值變為9,千萬不要以為y的值為10。這條賦值語句相當于下面的兩條語句:
y=a+(--b);a=a+1;
1.2 玩具般的編譯器語義檢查
為了更簡單的設計編譯器,目前幾乎所有編譯器的語義檢查都比較弱小,加之為了獲得更快的執行效率,C語言被設計的足夠靈活且幾乎不進行任何運行時檢查,比如數組越界、指針是否合法、運算結果是否溢出等等。
C語言足夠靈活,對于一個數組a[30],它允許使用像a[-1]這樣的形式來快速獲取數組首元素所在地址前面的數據;允許將一個常數強制轉換為函數指針,使用代碼(*((void(*)())0))()來調用位于0地址的函數。C語言給了程序員足夠的自由,但也由程序員承擔濫用自由帶來的責任。下面的兩個例子都是死循環,如果在不常用分支中出現類似代碼,將會造成看似莫名其妙的死機或者重啟。
a. unsigned char i; b. unsigned chari;
for(i=0;i《256;i++) {… } for(i=10;i》=0;i--) { … }
對于無符號char類型,表示的范圍為0~255,所以無符號char類型變量i永遠小于256(第一個for循環無限執行),永遠大于等于0(第二個for循環無線執行)。需要說明的是,賦值代碼i=256是被C語言允許的,即使這個初值已經超出了變量i可以表示的范圍。C語言會千方百計的為程序員創造出錯的機會,可見一斑。
假如你在if語句后誤加了一個分號改變了程序邏輯,編譯器也會很配合的幫忙掩蓋,甚至連警告都不提示。代碼如下:
if(a》b); //這里誤加了一個分號 a=b; //這句代碼一直被執行
不但如此,編譯器還會忽略掉多余的空格符和換行符,就像下面的代碼也不會給出足夠提示:
if(n《3) return //這里少加了一個分號 logrec.data=x[0]; logrec.time=x[1]; logrec.code=x[2];
這段代碼的本意是n《3時程序直接返回,由于程序員的失誤,return少了一個結束分號。編譯器將它翻譯成返回表達式logrec.data=x[0]的結果,return后面即使是一個表達式也是C語言允許的。這樣當n》=3時,表達式logrec.data=x[0];就不會被執行,給程序埋下了隱患。
可以毫不客氣的說,弱小的編譯器語義檢查在很大程度上縱容了不可靠代碼可以肆無忌憚的存在。
上文曾提到數組常常是引起程序不穩定的重要因素,程序員往往不經意間就會寫數組越界。一位同事的代碼在硬件上運行,一段時間后就會發現LCD顯示屏上的一個數字不正常的被改變。經過一段時間的調試,問題被定位到下面的一段代碼中:
int SensorData[30]; …for(i=30;i》0;i--) { SensorData[i]=…; … }
這里聲明了擁有30個元素的數組,不幸的是for循環代碼中誤用了本不存在的數組元素SensorData[30],但C語言卻默許這么使用,并欣然的按照代碼改變了數組元素SensorData[30]所在位置的值, SensorData[30]所在的位置原本是一個LCD顯示變量,這正是顯示屏上的那個值不正常被改變的原因。真慶幸這么輕而易舉的發現了這個Bug。
其實很多編譯器會對上述代碼產生一個警告:賦值超出數組界限。但并非所有程序員都對編譯器警告保持足夠敏感,況且,編譯器也并不能檢查出數組越界的所有情況。舉一個例子,你在模塊A中定義數組:
int SensorData[30];
在模塊B中引用該數組,但由于你引用代碼并不規范,這里沒有顯示聲明數組大小,但編譯器也允許這么做:
extern int SensorData[];
如果在模塊B中存在和上面一樣的代碼:
for(i=30;i》0;i--) { SensorData[i]=…; … }
這次,編譯器不會給出警告信息,因為編譯器壓根就不知道數組的元素個數。所以,當一個數組聲明為具有外部鏈接,它的大小應該顯式聲明。
再舉一個編譯器檢查不出數組越界的例子。函數func()的形參是一個數組形式,函數代碼簡化如下所示:
char * func(char SensorData[30]){ unsignedint i; for(i=30;i》0;i--) { SensorData[i]=…; … }}
這個給SensorData[30]賦初值的語句,編譯器也是不給任何警告的。實際上,編譯器是將數組名Sensor隱含的轉化為指向數組第一個元素的指針,函數體是使用指針的形式來訪問數組的,它當然也不會知道數組元素的個數了。造成這種局面的原因之一是C編譯器的作者們認為指針代替數組可以提高程序效率,而且,還可以簡化編譯器的復雜度。
指針和數組是容易給程序造成混亂的,我們有必要仔細的區分它們的不同。其實換一個角度想想,它們也是容易區分的:可以將數組名等同于指針的情況有且只有一處,就是上面例子提到的數組作為函數形參時。其它時候,數組名是數組名,指針是指針。
下面的例子編譯器同樣檢查不出數組越界。
我們常常用數組來緩存通訊中的一幀數據。在通訊中斷中將接收的數據保存到數組中,直到一幀數據完全接收后再進行處理。即使定義的數組長度足夠長,接收數據的過程中也可能發生數組越界,特別是干擾嚴重時。這是由于外界的干擾破壞了數據幀的某些位,對一幀的數據長度判斷錯誤,接收的數據超出數組范圍,多余的數據改寫與數組相鄰的變量,造成系統崩潰。由于中斷事件的異步性,這類數組越界編譯器無法檢查到。
如果局部數組越界,可能引發ARM架構硬件異常。同事的一個設備用于接收無線傳感器的數據,一次軟件升級后,發現接收設備工作一段時間后會死機。調試表明ARM7處理器發生了硬件異常,異常處理代碼是一段死循環(死機的直接原因)。接收設備有一個硬件模塊用于接收無線傳感器的整包數據并存在自己的硬件緩沖區中,當一幀數據接收完成后,使用外部中斷通知設備取數據,外部中斷服務程序精簡后如下所示:
__irq ExintHandler(void) { unsignedchar DataBuf[50]; GetData(DataBug); //從硬件緩沖區取一幀數據 … }
由于存在多個無線傳感器近乎同時發送數據的可能加之GetData()函數保護力度不夠,數組DataBuf在取數據過程中發生越界。由于數組DataBuf為局部變量,被分配在堆棧中,同在此堆棧中的還有中斷發生時的運行環境以及中斷返回地址。溢出的數據將這些數據破壞掉,中斷返回時PC指針可能變成一個不合法值,硬件異常由此產生。
如果我們精心設計溢出部分的數據,化數據為指令,就可以利用數組越界來修改PC指針的值,使之指向我們希望執行的代碼。1988年,第一個網絡蠕蟲在一天之內感染了2000到6000臺計算機,這個蠕蟲程序利用的正是一個標準輸入庫函數的數組越界Bug。起因是一個標準輸入輸出庫函數gets(),原來設計為從數據流中獲取一段文本,遺憾的是,gets()函數沒有規定輸入文本的長度。gets()函數內部定義了一個500字節的數組,攻擊者發送了大于500字節的數據,利用溢出的數據修改了堆棧中的PC指針,從而獲取了系統權限。
一個程序模塊通常由兩個文件組成,源文件和頭文件。如果你在源文件定義變量:
unsigned int a;
并在頭文件中聲明該變量:extern unsigned long a;
編譯器會提示一個語法錯誤:變量’a’聲明類型不一致。但如果你在源文件定義變量:
volatile unsigned int a,
在頭文件中聲明變量:extern unsigned int a; /*缺少volatile限定符*/
編譯器卻不會給出錯誤信息(有些編譯器僅給出一條警告)。這里volatile屬于類型限定符,另一個常見的類型限定符是const關鍵字。限定符volatile在嵌入式軟件中至關重要,用來告訴編譯器不要優化它修飾的變量。這里舉一個刻意構造出的例子,因為現實中的volatile使用Bug大都隱含且難以理解。
在模塊A的源文件中,定義變量:
volatile unsigned int TimerCount=0;
該變量用來在一個定時器服務程序中進行軟件計時:
TimerCount++; //讀取IO端口1的值
在模塊A的頭文件中,聲明變量:
extern unsigned int TimerCount; //這里漏掉了類型限定符volatile
在模塊B中,要使用TimerCount變量進行精確的軟件延時:
#include “。。.A.h” //首先包含模塊A的頭文件 … TimerCount=0; while(TimerCount》=TIMER_VALUE); //延時一段時間 …
實際上,這是一個死循環。由于模塊A頭文件中聲明變量TimerCount時漏掉了volatile限定符,在模塊B中,變量TimerCount是被當作unsigned int類型變量。由于寄存器速度遠快于RAM,編譯器在使用非volatile限定變量時是先將變量從RAM中拷貝到寄存器中,如果同一個代碼塊再次用到該變量,就不再從RAM中拷貝數據而是直接使用之前寄存器備份值。代碼while(TimerCount》=TIMER_VALUE)中,變量TimerCount僅第一次執行時被使用,之后都是使用的寄存器備份值,而這個寄存器值一直為0,所以程序無限循環。下面的流程圖說明了程序使用限定符volatile和不使用volatile的執行過程。
ARM架構下的編譯器會頻繁的使用堆棧,堆棧用于存儲函數的返回值、AAPCS規定的必須保護的寄存器以及局部變量,包括局部數組、結構體、聯合體和C++的類。從堆棧中分配的局部變量的初值是不確定的,因此需要運行時顯式初始化該變量。一旦離開局部變量的作用域,這個變量立即被釋放,其它代碼也就可以使用它,因此堆棧中的一個內存位置可能對應整個程序的多個變量。
局部變量必須顯式初始化,除非你確定知道你要做什么。下面的代碼得到的溫度值跟預期會有很大差別,因為在使用局部變量sum時,并不能保證它的初值為0。編譯器會在第一次運行時清零堆棧區域,這加重了此類Bug的隱蔽性。
unsigned intGetTempValue(void) { unsigned int sum; //定義局部變量,保存總值 for(i=0;i《10;i++) { sum+=CollectTemp(); //函數CollectTemp可以得到當前的溫度值 } return (sum/10); }
由于一旦程序離開局部變量的作用域即被釋放,所以下面代碼返回指向局部變量的指針是沒有實際意義的,該指針指向的區域可能會被其它程序使用,其值會被改變。
char * GetData(void) { char buffer[100]; //局部數組 … return buffer; }
讓人欣慰的是,現在越來越多的編譯器意識到了語義檢查的重要性,編譯器的語義檢查也越來越強大,比如著名的Keil MDK編譯器在其 V4.47或以上版本中增加了動態語法檢查并加強了語義檢查,可以友好的提示更多警告信息。
1.3 不合理的優先級
C語言有32個關鍵字,卻有34個運算符。要記住所有運算符的優先級是困難的。不合理的#define會加重優先級問題,讓問題變得更加隱蔽。
#define READSDA IO0PIN&(1《《11) //定義宏,讀IO口p0.11的端口狀態 //判斷端口p0.11是否為高電平 if(READSDA==(1《《11)) { … }
編譯器在編譯后將宏帶入,原if語句變為:
if(IO0PIN&(1《《11) ==(1《《11)) { … }
運算符‘==’的優先級是大于‘&’的,代碼IO0PIN&(1《《11) ==(1《《11))等效為IO0PIN&0x00000001:判斷端口P0.0是否為高電平,這與原意相差甚遠。
為了制造更多的軟件Bug,C語言的運算符當然不會只止步于數目繁多。在此基礎上,按照常規方式使用時,可能引起誤會的運算符更是比比皆是!如下表所示:
常被誤會的
優先級表達式常被誤認為:其實是:
取值運算符*與自增運算符++優先級相同,但它們是自右向左結合*p++(*p)++*(p++)
成員選擇運算符。高于取值運算符**p.f(*p).f*(p.f)
數組下標運算符[]優先級高于取值運算符*int *ap[]int (*ap)[]
ap為數組指針int *(ap[])
ap為指針數組
函數()優先級高于取值運算符*int * fp()int (*fp)()
fp為函數指針int * (fp())
fp為函數,返回指針
等于==和不等于!=運算符優先級高于位操作運算符&、^ 和 |val & mask != 0(val & mask)!= 0val &(mask != 0)
等于==和不等于!=運算符高于賦值運算符=c=getchar()!=EOF(c=getchar())!=EOFc=(getchar()!=EOF)
算數運算符+和-優先級高于移位運算符《《和》》msb《《4+lsb(msb《《4)+lsbmsb《《(4+lsb)
1.4 隱式轉換和強制轉換
這又是C語言的一大詭異之處,它造成的危害程度與數組和指針有的一拼。語句或表達式通常應該只使用一種類型的變量和常量。然而,如果你混合使用類型,C使用一個規則集合來自動完成類型轉換。這可能很方便,但也很危險。
a.當出現在表達式里時,有符號和無符號的char和short類型都將自動被轉換為int類型,在需要的情況下,將自動被轉換為unsigned int(在short和int具有相同大小時)。這稱為類型提升。提升在算數運算中通常不會有什么大的壞處,但如果位運算符 ~ 和 《《 應用在基本類型為unsigned char或unsigned short 的操作數,結果應該立即強制轉換為unsigned char或者unsigned short類型(取決于操作時使用的類型)。
uint8_t port =0x5aU; uint8_t result_8; result_8= (~port) 》》 4;
假如我們不了解表達式里的類型提升,認為在運算過程中變量port一直是unsigned char類型的。我們來看一下運算過程:~port結果為0xa5,0xa5》》4結果為0x0a,這是我們期望的值。但實際上,result_8的結果卻是0xfa!在ARM結構下,int類型為32位。變量port在運算前被提升為int類型:~port結果為0xffffffa5,0xa5》》4結果為0x0ffffffa,賦值給變量result_8,發生類型截斷(這也是隱式的!),result_8=0xfa。經過這么詭異的隱式轉換,結果跟我們期望的值,已經大相徑庭!正確的表達式語句應該為:
result_8=(unsigned char) (~port) 》》 4; /*強制轉換*/
b.在包含兩種數據類型的任何運算里,兩個值都會被轉換成兩種類型里較高的級別。類型級別從高到低的順序是long double、double、float、unsigned long long、long long、unsigned long、long、unsigned int、int。這種類型提升通常都是件好事,但往往有很多程序員不能真正理解這句話,從而做一些想當然的事情,比如下面的例子,int類型表示16位。
uint16_t u16a = 40000; /* 16位無符號變量*/ uint16_t u16b= 30000; /*16位無符號變量*/ uint32_t u32x; /*32位無符號變量 */ uint32_t u32y; u32x = u16a +u16b; /* u32x = 70000還是4464 ? */ u32y =(uint32_t)(u16a + u16b); /* u32y = 70000 還是4464 ? */
u32x和u32y的結果都是4464(70000%65536)!不要認為表達式中有一個高類別uint32_t類型變量,編譯器都會幫你把所有其他低類別都提升到uint32_t類型。正確的書寫方式:
u32x = (uint32_t)u16a +(uint32_t)u16b;或者: u32x = (uint32_t)u16a + u16b;
后一種寫法在本表達式中是正確的,但是在其它表達式中不一定正確,比如:
uint16_t u16a,u16b,u16c; uint32_t u32x; u32x= u16a + u16b + (uint32_t)u16c;/*錯誤寫法,u16a+ u16b仍可能溢出*/
c.在賦值語句里,計算的最后結果被轉換成將要被賦予值得那個變量的類型。這一過程可能導致類型提升也可能導致類型降級。降級可能會導致問題。比如將運算結果為321的值賦值給8位char類型變量。程序必須對運算時的數據溢出做合理的處理。
很多其他語言,像Pascal語言(好笑的是C語言設計者之一曾撰文狠狠批評過Pascal語言),都不允許混合使用類型,但C語言不會限制你的自由,即便這經常引起Bug。
d.當作為函數的參數被傳遞時,char和short會被轉換為int,float會被轉換為double。
e.C語言支持強制類型轉換,如果你必須要進行強制類型轉換時,要確保你對類型轉換有足夠了解:
并非所有強制類型轉換都是由風險的,把一個整數值轉換為一種具有相同符號的更寬類型時,是絕對安全的。
精度高的類型強制轉換為精度低的類型時,通過丟棄適當數量的最高有效位來獲取結果,也就是說會發生數據截斷,并且可能改變數據的符號位。
精度低的類型強制轉換為精度高的類型時,如果兩種類型具有相同的符號,那么沒什么問題;需要注意的是負的有符號精度低類型強制轉換為無符號精度高類型時,會不直觀的執行符號擴展,例如:
unsigned int bob;signed char fred = -1; bob=(unsigned int )fred; /*發生符號擴展,此時bob為0xFFFFFFFF*/
一些編程建議:
深入理解嵌入式C語言以及編譯器
細致、謹慎的編程
使用好的風格和合理的設計
不要倉促編寫代碼,寫每一行的代碼時都要三思而后行:可能會出現什么樣的錯誤?是否考慮了所有的邏輯分支?
打開編譯器所有警告開關
使用靜態分析工具分析代碼
安全的讀寫數據(檢查所有數組邊界…)
檢查指針的合法性
檢查函數入口參數合法性
檢查所有返回值
在聲明變量位置初始化所有變量
合理的使用括號
謹慎的進行強制轉換
使用好的診斷信息日志和工具
責任編輯:haq
-
嵌入式
+關注
關注
5068文章
19019瀏覽量
303265 -
嵌入式系統
+關注
關注
41文章
3567瀏覽量
129227 -
軟件
+關注
關注
69文章
4774瀏覽量
87160 -
路由器
+關注
關注
22文章
3707瀏覽量
113541 -
交換器
+關注
關注
2文章
90瀏覽量
16527
發布評論請先 登錄
相關推薦
評論