4. 編寫正確的程式碼¶
儘管關於紮實且面向社群的設計流程有很多可說之處,但任何核心開發專案的成果都體現在其最終程式碼中。這段程式碼將被其他開發者審查,並(可能)合入主線程式碼樹。因此,程式碼的質量將決定專案的最終成功。
本節將探討編碼過程。我們將首先審視核心開發者可能犯錯的幾種方式。接著,重點將轉向如何正確地做事以及在此過程中能提供幫助的工具。
4.1. 陷阱¶
4.1.1. 編碼風格¶
核心長期以來都有一套標準編碼風格,在 Documentation/process/coding-style.rst 中有所描述。在很長一段時間裡,該檔案中描述的策略最多被視為建議性的。結果是,核心中有大量程式碼不符合編碼風格指南。這些程式碼的存在給核心開發者帶來了兩個獨立的風險。
其中第一個風險是認為核心編碼標準不重要且未被強制執行。事實是,如果新程式碼不符合標準,將其新增到核心中將非常困難;許多開發者甚至會在審查程式碼之前要求對其進行重新格式化。像核心這樣龐大的程式碼庫需要一定程度的程式碼統一性,以便開發者能夠快速理解其中任何部分。因此,不再容許格式怪異的程式碼。
有時,核心的編碼風格會與僱主強制的風格發生衝突。在這種情況下,程式碼合入之前,必須遵循核心的風格。將程式碼納入核心意味著在許多方面放棄一定程度的控制——包括對程式碼格式的控制。
另一個陷阱是,假設核心中已有的程式碼急需修復編碼風格。開發者可能會開始生成重新格式化的補丁,以此來熟悉流程,或者將自己的名字寫入核心變更日誌——或者兩者兼而有之。但純粹的編碼風格修復被開發社群視為“噪音”;它們往往受到冷遇。因此,最好避免此類補丁。在因其他原因處理某段程式碼時,順便修復其風格是自然而然的,但編碼風格的更改不應是其自身的目的。
編碼風格文件也不應被視為一條絕不能逾越的絕對法則。如果有充分的理由違反風格(例如,一行程式碼如果為了符合80列限制而拆分會變得難以閱讀),那就直接去做。
請注意,您也可以使用 clang-format 工具來幫助您遵循這些規則,自動快速地重新格式化部分程式碼,並檢查整個檔案以發現編碼風格錯誤、拼寫錯誤和可能的改進。它也方便用於排序 #includes、對齊變數/宏、重新排布文字以及其他類似任務。更多詳情請參閱檔案 Documentation/dev-tools/clang-format.rst。
如果您使用的編輯器與 EditorConfig 相容,那麼一些基本的編輯器設定(例如縮排和行尾)將自動設定。更多資訊請參閱 EditorConfig 官方網站:https://editorconfig.org/
4.1.2. 抽象層¶
計算機科學教授教導學生為了靈活性和資訊隱藏而廣泛使用抽象層。核心當然也大量使用了抽象;任何涉及數百萬行程式碼的專案若不如此便無法生存。但經驗表明,過度或過早的抽象與過早最佳化一樣有害。抽象應僅限於所需的程度,不再進一步。
簡單來說,考慮一個函式,其某個引數總是被所有呼叫者傳遞為零。人們可能會保留該引數,以防將來有人需要利用它提供的額外靈活性。然而,到那時,實現這個額外引數的程式碼很可能已經以某種微妙的方式損壞了,卻從未被注意到——因為它從未被使用過。或者,當需要額外靈活性時,它並非以程式設計師早期預期的那種方式出現。核心開發者通常會提交補丁來移除未使用的引數;這些引數一般不應在一開始就被新增。
隱藏硬體訪問的抽象層——通常是為了讓驅動程式的大部分程式碼能在多個作業系統中使用——尤其不受歡迎。這樣的層會使程式碼模糊不清,並可能帶來效能損失;它們不屬於 Linux 核心。
另一方面,如果您發現自己正在從另一個核心子系統中複製程式碼,那麼是時候考慮是否應該將其中一部分程式碼提取到單獨的庫中,或者在更高層實現該功能。在整個核心中重複相同的程式碼是沒有意義的。
4.1.3. #ifdef 和預處理器的一般使用¶
C 預處理器似乎對一些 C 程式設計師構成了強大的誘惑,他們將其視為一種將大量靈活性高效編碼到原始檔中的方式。但預處理器不是 C 語言,大量使用它會導致程式碼變得難以閱讀,也難以讓編譯器檢查正確性。大量使用預處理器幾乎總是程式碼需要清理的標誌。
使用 #ifdef 進行條件編譯確實是一個強大的功能,並在核心中得到使用。但我們不希望看到程式碼中大量散佈 #ifdef 塊。一般來說,#ifdef 的使用應儘可能限制在標頭檔案中。條件編譯的程式碼可以限制在函式中,如果程式碼不需要存在,這些函式就簡單地變成空函式。編譯器隨後會悄悄地最佳化掉對空函式的呼叫。結果是程式碼更加清晰,更易於理解。
C 預處理器宏存在一些危險,包括可能對帶有副作用的表示式進行多次求值以及缺乏型別安全。如果您想定義一個宏,請考慮改為建立一個行內函數。產生的程式碼是相同的,但行內函數更易於閱讀,不會多次評估其引數,並允許編譯器對引數和返回值進行型別檢查。
4.1.4. 行內函數¶
然而,行內函數本身也存在危險。程式設計師可能迷戀於避免函式呼叫所帶來的“效率”,從而在原始檔中大量使用行內函數。然而,這些函式實際上可能降低效能。由於它們的程式碼在每個呼叫點都會被複制,它們最終會使編譯後的核心大小膨脹。這反過來又對處理器的記憶體快取造成壓力,從而顯著減慢執行速度。通常,行內函數應該非常小且相對少見。畢竟,函式呼叫的開銷並不高;大量建立行內函數是過早最佳化的典型例子。
通常,核心程式設計師忽視快取效應將自擔風險。在初級資料結構課程中教授的經典時間/空間權衡通常不適用於當代硬體。空間就是時間,因為一個更大的程式會比一個更緊湊的程式執行得更慢。
更現代的編譯器在決定給定函式是否應該實際內聯方面發揮著越來越積極的作用。因此,大量放置“inline”關鍵字可能不僅僅是過度行為;它也可能變得無關緊要。
4.1.5. 鎖機制¶
2006 年 5 月,“Devicescape”網路協議棧在盛大的宣傳下,以 GPL 許可釋出,並可納入主線核心。這次捐贈是個好訊息;Linux 中對無線網路的支援充其量只能算是差強人意,而 Devicescape 協議棧有望改善這種狀況。然而,這段程式碼直到 2007 年 6 月(2.6.22 版)才真正進入主線。發生了什麼?
這段程式碼顯示出一些在公司內部開發的跡象。但一個特別大的問題是,它並非為多處理器系統而設計。在這個網路協議棧(現在稱為 mac80211)被合入之前,需要為其增加一個鎖機制。
曾幾何時,Linux 核心程式碼的開發可以不考慮多處理器系統帶來的併發問題。然而,現在,這份文件正是在一臺雙核筆記型電腦上編寫的。即使在單處理器系統上,為提高響應能力而進行的工作也會提高核心內部的併發級別。核心程式碼可以不考慮鎖機制而編寫的日子早已一去不復返。
任何可能被多個執行緒併發訪問的資源(資料結構、硬體暫存器等)都必須由鎖保護。新程式碼應在編寫時考慮到這一要求;事後新增鎖機制是一項相當困難的任務。核心開發者應花時間充分理解可用的鎖原語,以便為任務選擇正確的工具。對併發缺乏關注的程式碼將難以進入主線。
4.1.6. 迴歸¶
最後一個值得一提的危險是:進行一項(可能帶來巨大改進的)更改,但卻導致現有使用者的功能損壞,這很誘人。這種更改稱為“迴歸”,而回歸在主線核心中是最不受歡迎的。除少數例外,如果迴歸問題無法及時修復,導致迴歸的更改將被回滾。最好從一開始就避免迴歸。
人們常爭辯說,如果迴歸能讓更多人受益而非帶來問題,那麼它就是合理的。如果一項更改能為十個系統帶來新功能而只破壞一個系統,為什麼不做呢?
So we don't fix bugs by introducing new problems. That way lies
madness, and nobody ever knows if you actually make any real
progress at all. Is it two steps forwards, one step back, or one
step forward and two steps back?
對此問題的最佳回答由 Linus 於 2007 年 7 月給出:(https://lwn.net/Articles/243460/)。
一種特別不受歡迎的迴歸是使用者空間 ABI 的任何更改。一旦介面被匯出到使用者空間,它就必須被無限期地支援。這一事實使得使用者空間介面的建立尤其具有挑戰性:因為它們不能以不相容的方式更改,所以必須一次性做對。因此,使用者空間介面總是需要大量的思考、清晰的文件和廣泛的審查。
4.2. 程式碼檢查工具¶
至少目前,編寫無錯誤程式碼仍是我們少數人才能達到的理想。然而,我們希望能做的是,在我們的程式碼進入主線核心之前,儘可能多地發現並修復這些錯誤。為此,核心開發者彙集了一系列令人印象深刻的工具,能夠以自動化方式捕獲各種難以發現的問題。任何由計算機發現的問題,都不會在以後困擾使用者,因此理所當然地,應儘可能使用自動化工具。
第一步是簡單地注意編譯器產生的警告。現代版本的 gcc 可以檢測(並警告)大量潛在錯誤。這些警告常常指向真正的問題。提交審查的程式碼,原則上不應產生任何編譯器警告。在消除警告時,請務必理解其真正原因,並儘量避免那些未能解決根本原因而只是讓警告消失的“修復”。
請注意,並非所有編譯器警告都預設啟用。使用“make KCFLAGS=-W”構建核心以啟用所有警告。
核心提供了幾個用於啟用除錯功能的配置選項;大部分可以在“kernel hacking”子選單中找到。任何用於開發或測試目的的核心都應啟用其中幾個選項。特別地,您應該啟用:
`FRAME_WARN` 以獲取大於給定大小的堆疊幀警告。生成的輸出可能會很詳細,但無需擔心來自核心其他部分的警告。
`DEBUG_OBJECTS` 將新增程式碼來追蹤核心建立的各種物件的生命週期,並在操作順序不正確時發出警告。如果您正在新增一個建立(並匯出)自身複雜物件的子系統,請考慮為物件除錯基礎設施新增支援。
`DEBUG_SLAB` 可以發現各種記憶體分配和使用錯誤;它應該在大多數開發核心上使用。
`DEBUG_SPINLOCK`、`DEBUG_ATOMIC_SLEEP` 和 `DEBUG_MUTEXES` 將發現許多常見的鎖錯誤。
還有許多其他除錯選項,其中一些將在下面討論。其中一些對效能有顯著影響,不應一直使用。但花時間學習可用的選項很可能會在短時間內獲得多倍的回報。
最重要的除錯工具之一是鎖檢查器,或稱“lockdep”。該工具將跟蹤系統中每個鎖(自旋鎖或互斥鎖)的獲取和釋放,鎖之間獲取的相對順序,當前的中斷環境等等。然後,它可以確保鎖總是以相同的順序獲取,相同的中斷假設適用於所有情況等等。換句話說,lockdep 可以發現一些系統在極少數情況下可能發生死鎖的場景。這種問題在已部署的系統中可能會很痛苦(對開發者和使用者都是);lockdep 允許提前以自動化方式發現它們。包含任何非平凡鎖的程式碼在提交合入之前都應在啟用 lockdep 的情況下執行。
作為一名勤奮的核心程式設計師,您無疑會檢查任何可能失敗的操作(例如記憶體分配)的返回狀態。然而,事實是,由此產生的故障恢復路徑很可能完全未經測試。未經測試的程式碼往往是損壞的程式碼;如果所有這些錯誤處理路徑都曾被執行過幾次,您就能對自己的程式碼更加自信。
核心提供了一個故障注入框架,能夠做到這一點,尤其是在涉及記憶體分配的地方。啟用故障注入後,可配置百分比的記憶體分配將失敗;這些失敗可以限制在特定的程式碼範圍內。啟用故障注入執行允許程式設計師檢視程式碼在出現問題時的響應方式。有關如何使用此功能的更多資訊,請參閱 故障注入能力基礎設施。
其他型別的可移植性錯誤最好透過為其他架構編譯程式碼來發現。使用 sparse,可以警告程式設計師關於使用者空間和核心空間地址之間的混淆、大端和小端量混合、在期望位標誌集的地方傳遞整數值等問題。Sparse 必須單獨安裝(如果您的發行版未提供,可以在 https://sparse.wiki.kernel.org/index.php/Main_Page 找到);然後可以透過在 make 命令中新增“C=1”來對程式碼執行它。
“Coccinelle”工具(http://coccinelle.lip6.fr/)能夠發現各種潛在的編碼問題;它還可以提出針對這些問題的修復方案。相當多的核心“語義補丁”已打包在 scripts/coccinelle 目錄下;執行“make coccicheck”將執行這些語義補丁並報告發現的任何問題。更多資訊請參閱 Documentation/dev-tools/coccinelle.rst。
其他型別的可移植性錯誤最好透過為其他架構編譯程式碼來發現。如果您手頭沒有 S/390 系統或 Blackfin 開發板,您仍然可以執行編譯步驟。針對 x86 系統的交叉編譯器集可以在此處找到:
花時間安裝和使用這些編譯器將有助於避免日後尷尬。
4.3. 文件¶
在核心開發中,文件常常是例外而非慣例。即便如此,充分的文件仍將有助於簡化新程式碼合入核心的過程,讓其他開發者更容易使用,並對您的使用者有所幫助。在許多情況下,新增文件已基本成為強制性要求。
任何補丁的第一份文件是其相關的變更日誌。日誌條目應描述所解決的問題、解決方案的形式、參與補丁工作的人員、任何相關的效能影響以及理解補丁可能需要的任何其他資訊。務必確保變更日誌說明了補丁值得應用的原因;令人驚訝的是,許多開發者未能提供這些資訊。
任何新增新使用者空間介面的程式碼(包括新的 sysfs 或 /proc 檔案)都應包含該介面的文件,以便使用者空間開發者瞭解其使用方式。有關此文件的格式和所需資訊的描述,請參閱 Documentation/ABI/README。
檔案 Documentation/admin-guide/kernel-parameters.rst 描述了所有核心啟動引數。任何新增新引數的補丁都應在此檔案中新增相應的條目。
任何新的配置選項都必須附帶幫助文字,清楚地解釋這些選項以及使用者何時可能需要選擇它們。
許多子系統的內部 API 資訊透過特殊格式的註釋進行文件化;這些註釋可以透過“kernel-doc”指令碼以多種方式提取和格式化。如果您正在處理一個包含 kerneldoc 註釋的子系統,您應該為外部可用的函式維護並酌情新增這些註釋。即使在尚未如此文件化的區域,為將來新增 kerneldoc 註釋也沒有壞處;事實上,這對於初級核心開發者來說是一項有益的活動。這些註釋的格式以及如何建立 kerneldoc 模板的一些資訊可以在 Documentation/doc-guide/ 找到。
任何閱讀大量現有核心程式碼的人都會注意到,通常,註釋的顯著特點是其缺失。再次強調,對新程式碼的期望比過去更高;合併未註釋的程式碼將更加困難。儘管如此,我們也不希望看到過度註釋的程式碼。程式碼本身應該可讀,註釋則用於解釋更微妙的方面。
某些內容應始終添加註釋。記憶體屏障的使用應附帶一行註釋,解釋為何需要該屏障。資料結構的鎖規則通常需要某處進行解釋。主要資料結構通常需要全面的文件。應指出獨立程式碼片段之間不明顯的依賴關係。任何可能誘使程式碼清理者進行不正確“清理”的地方都需要註釋說明其原因。諸如此類。
4.4. 內部 API 變更¶
核心提供給使用者空間的二進位制介面,除了在最嚴峻的情況下,都不能被破壞。相反,核心的內部程式設計介面具有高度的流動性,可以在需要時進行更改。如果您發現自己不得不繞過某個核心 API,或者僅僅因為某個特定功能不滿足您的需求而沒有使用它,這可能表明該 API 需要更改。作為一名核心開發者,您有權進行此類更改。
當然,也有一些注意事項。API 可以更改,但需要充分的理由。因此,任何進行內部 API 更改的補丁都應附帶更改內容和必要性的描述。這種更改也應該拆分成單獨的補丁,而不是埋藏在一個更大的補丁中。
另一個注意事項是,更改內部 API 的開發者通常負責修復核心樹中因該更改而損壞的任何程式碼。對於一個廣泛使用的函式,這項任務可能導致字面上的數百甚至數千處更改——其中許多可能與正在由其他開發者進行的工作發生衝突。不言而喻,這可能是一項巨大的工作,因此最好確保理由充分。請注意,Coccinelle 工具可以幫助處理範圍廣泛的 API 更改。
當進行不相容的 API 更改時,應儘可能確保未更新的程式碼能被編譯器捕獲。這將幫助您確定已找到該介面的所有核心樹內使用。它還將提醒核心樹外程式碼的開發者,存在需要他們響應的更改。支援核心樹外程式碼並非核心開發者需要擔心的事情,但我們也沒有必要讓核心樹外開發者面臨不必要的困難。