(如何避免) 搞砸 ioctl¶
來源: https://blog.ffwll.ch/2013/11/botching-up-ioctls.html
作者: Daniel Vetter, 版權所有 © 2013 Intel Corporation
過去幾年,核心圖形駭客獲得的一個清晰的洞察是,嘗試為完全不同的 GPU 上執行單元和記憶體管理提供統一介面是徒勞的。因此,現在每個驅動程式都有自己的一套 ioctl 來分配記憶體和向 GPU 提交工作。這很好,因為不再有那種虛假通用、實際上只使用一次的介面的瘋狂。但明顯的缺點是,搞砸事情的可能性大大增加。
為了避免再次重複所有相同的錯誤,我寫下了一些在處理 drm/i915 驅動程式時學到的教訓。其中大部分只涉及技術細節,而不涉及諸如命令提交 ioctl 到底應該是什麼樣子這樣的大局問題。學習這些教訓可能每個 GPU 驅動程式都必須自己完成。
先決條件¶
首先是先決條件。如果沒有這些,你已經失敗了,因為你需要新增一個 32 位相容層。
只使用固定大小的整數。為避免與使用者空間中的 typedef 衝突,核心有特殊的型別,如 __u32、__s64。請使用它們。
將所有內容對齊到自然大小並使用顯式填充。32 位平臺不一定會將 64 位值對齊到 64 位邊界,但 64 位平臺會。因此,我們總是需要填充到自然大小才能做到這一點。
如果結構包含 64 位型別,則將整個結構填充為 64 位的倍數——否則結構大小在 32 位和 64 位之間會有所不同。當將結構陣列傳遞給核心時,或者當核心檢查結構大小時(例如 drm 核心會這樣做),不同的結構大小會造成麻煩。
指標是 __u64,在使用者空間側從/到 uintptr_t 強制轉換,在核心中從/到 void __user * 強制轉換。請務必努力不要延遲這種轉換,更糟的是,讓原始的 __u64 在你的程式碼中隨意傳遞,因為這會削弱像 sparse 這樣的檢查工具所能提供的能力。在核心中可以使用宏 u64_to_user_ptr 來避免關於不同大小的整數和指標的警告。
基本原理¶
避免了編寫相容層的麻煩之後,我們可以看看基本的失誤。忽略這些將使向後和向前相容性變得非常痛苦。而且由於第一次嘗試出錯是必然的,所以對於任何給定的介面,你都會有第二次迭代或者至少是擴充套件。
讓使用者空間有一個清晰的方式來判斷你的新 ioctl 或 ioctl 擴充套件在給定核心上是否受支援。如果你不能依賴舊核心拒絕新的標誌/模式或 ioctl(因為過去這樣做被搞砸了),那麼你需要一個驅動程式特性標誌或版本號。
制定一個計劃,在結構末尾用新標誌或新欄位擴充套件 ioctl。drm 核心會檢查每個 ioctl 呼叫傳入的大小,並對核心與使用者空間之間的任何不匹配進行零擴充套件。這有幫助,但不是一個完整的解決方案,因為較新的使用者空間在較舊的核心上不會注意到新新增的欄位被忽略了。所以這仍然需要新的驅動程式特性標誌。
檢查所有未使用的欄位、標誌和所有填充是否為 0,如果不是則拒絕 ioctl。否則,你未來擴充套件的好計劃將付諸東流,因為有人會提交一個 ioctl 結構,其中未使用的部分包含隨機的棧垃圾。這會使得 ABI 被固定下來,這些欄位永遠不能用於垃圾之外的任何其他用途。這也是為什麼你必須顯式填充所有結構的原因,即使你從未在陣列中使用它們——編譯器可能插入的填充可能包含垃圾。
為以上所有情況提供簡單的測試用例。
錯誤路徑的樂趣¶
如今,drm 驅動程式不再有任何藉口成為巧妙的小型 root 漏洞。這意味著我們既需要完整的輸入驗證,也需要可靠的錯誤處理路徑——無論如何,GPU 最終會在最奇怪的邊緣情況下崩潰。
ioctl 必須檢查陣列溢位。它還需要檢查整數值的一般溢位/下溢和截斷問題。常見的例子是將精靈定位值直接饋送到硬體,而硬體只有 12 位左右。這在某些奇怪的顯示伺服器不理會自身截斷並且游標在螢幕上環繞時執行良好。
為你的 ioctl 中每個輸入驗證失敗的情況提供簡單的測試用例。檢查錯誤程式碼是否符合你的預期。最後,確保在每個子測試中只測試一個錯誤路徑,透過提交其他方面完全有效的資料。如果沒有這個,早期的檢查可能會拒絕 ioctl,並遮蓋你實際想要測試的程式碼路徑,從而隱藏錯誤和迴歸。
使所有 ioctl 都能重新啟動。首先,X 非常喜歡訊號;其次,這會允許你透過不斷地用訊號中斷你的主測試套件,來測試 90% 的錯誤處理路徑。多虧了 X 對訊號的偏愛,你幾乎可以免費為圖形驅動程式獲得所有錯誤路徑的優秀基礎覆蓋。另外,在處理 ioctl 重新啟動時要保持一致——例如,drm 在其使用者空間庫中有一個微小的 drmIoctl 輔助函式。i915 驅動程式在 set_tiling ioctl 上搞砸了這一點,現在我們永遠被困在核心和使用者空間中一些神秘的語義上。
如果你不能使給定的程式碼路徑可重啟,至少要使卡住的任務可終止。GPU 只是會宕機,如果你讓他們的整個盒子掛起(透過一個不可終止的 X 程序),使用者也不會更喜歡你。如果狀態恢復仍然過於棘手,作為最後的努力,在硬體失控時,設定一個超時或掛起檢查安全網。
為錯誤恢復程式碼中真正棘手的邊緣情況提供測試用例——在你的掛起檢查程式碼和等待者之間建立死鎖太容易了。
時間、等待與錯過¶
GPU 大部分操作都是非同步的,因此我們需要對操作計時並等待未完成的操作。這是一項非常棘手的工作;目前 drm/i915 支援的 ioctl 沒有一個能完全正確處理,這意味著這裡還有大量的教訓需要學習。
始終使用 CLOCK_MONOTONIC 作為參考時間。這是 alsa、drm 和 v4l 如今預設使用的。但是要讓使用者空間知道哪些時間戳是從不同的時鐘域(例如由核心提供的主系統時鐘或某個獨立硬體計數器)派生出來的。如果你仔細觀察,時鐘會不匹配,但是如果效能測量工具擁有這些資訊,它們至少可以進行補償。如果你的使用者空間可以獲取某些時鐘的原始值(例如透過命令流中的效能計數器取樣指令),也考慮暴露這些值。
使用 __s64 秒加上 __u64 納秒來指定時間。它不是最方便的時間規格,但它基本上是標準。
檢查輸入時間值是否已規範化,如果不是則拒絕。請注意,核心原生的 struct ktime 在秒和納秒上都有一個有符號整數,所以這裡要小心。
對於超時,使用絕對時間。如果你是一個好人,並且使你的 ioctl 可重啟,那麼相對超時往往過於粗略,並且可能由於每次重啟的舍入而無限期延長你的等待時間。特別是如果你的參考時鐘是非常慢的,例如顯示幀計數器。戴上規範律師的帽子,這不算一個bug,因為超時總是可以延長的——但如果你的漂亮動畫因此開始卡頓,使用者肯定會恨你。
考慮放棄任何帶超時的同步等待 ioctl,而是在可輪詢檔案描述符上傳遞非同步事件。它更適合事件驅動應用程式的主迴圈。
為邊緣情況提供測試用例,特別是檢查已完成事件、成功等待和超時等待的返回值是否都正常且符合您的需求。
資源洩露,絕不¶
一個功能完備的 drm 驅動程式本質上實現了一個小型作業系統,但專用於給定的 GPU 平臺。這意味著驅動程式需要向用戶空間公開大量用於不同物件和其他資源的控制代碼。正確地做到這一點本身就有一系列陷阱。
始終將動態建立資源的生命週期附加到檔案描述符的生命週期。如果您的資源需要在程序間共享,請考慮使用 1:1 對映——透過 Unix 域套接字進行 fd 傳遞也簡化了使用者空間的生命週期管理。
始終支援 O_CLOEXEC。
確保不同客戶端之間有足夠的隔離。預設情況下選擇私有的每 fd 名稱空間,這會強制任何共享都必須顯式完成。僅當物件真正是裝置唯一時,才使用更全域性的每裝置名稱空間。drm 模式設定介面中的一個反例是,每裝置模式設定物件(如聯結器)與幀緩衝區物件共享名稱空間,而幀緩衝區物件大多根本不共享。為幀緩衝區提供一個獨立的、預設私有的名稱空間會更合適。
思考使用者空間控制代碼的唯一性要求。例如,對於大多數 drm 驅動程式來說,在同一命令提交 ioctl 中兩次提交同一物件是使用者空間的一個 bug。但是,如果物件可共享,使用者空間就需要知道它是否已經看到過從另一個程序匯入的物件。我還沒有親自嘗試過,因為缺少一類新的物件,但可以考慮使用共享檔案描述符上的 inode 號作為唯一識別符號——這也是區分真實檔案的方式。不幸的是,這需要在核心中實現一個完整的虛擬檔案系統。
最後,但並非最不重要¶
並非所有問題都需要一個新的 ioctl。
仔細思考你是否真的需要一個驅動程式私有介面。當然,推動一個驅動程式私有介面要比為更通用的解決方案進行冗長的討論快得多。偶爾,為了開創一個新概念,私有介面是必需的。但最終,一旦通用接口出現,你將不得不維護兩個介面。無限期地。
考慮 ioctl 之外的其他介面。對於每個裝置的設定,或者對於生命週期相當靜態的子物件(例如 drm 中帶有所有檢測覆蓋屬性的輸出聯結器),sysfs 屬性要好得多。或者也許只有你的測試套件需要這個介面,那麼 debugfs 及其關於沒有穩定 ABI 的免責宣告會更好。
最後,遊戲的關鍵是在第一次嘗試時就做對,因為如果你的驅動程式很受歡迎,並且你的硬體平臺壽命很長,那麼你將基本上永遠被某個 ioctl 困住。你可以在硬體的較新迭代中嘗試廢棄糟糕的 ioctl,但通常需要數年才能完成。然後又是數年,直到最後一個能夠抱怨迴歸的使用者也消失。