基於 ioctl 的介面

ioctl() 是應用程式與裝置驅動程式互動最常用的方式。它靈活且易於透過新增新命令進行擴充套件,並可透過字元裝置、塊裝置以及套接字和其他特殊檔案描述符傳遞。

然而,ioctl 命令定義也極易出錯,並且在不破壞現有應用程式的情況下很難修復,因此本文件旨在幫助開發者正確地定義。

命令編號定義

命令編號(或請求編號)是傳遞給 ioctl 系統呼叫的第二個引數。雖然它可以是唯一標識特定驅動程式操作的任何 32 位數字,但有一些圍繞定義它們的約定。

include/uapi/asm-generic/ioctl.h 提供了四個宏用於定義遵循現代約定的 ioctl 命令:_IO_IOR_IOW_IOWR。所有新命令都應使用這些宏,並提供正確的引數。

_IO/_IOR/_IOW/_IOWR

宏名稱指定了引數將如何使用。它可能是一個指向要傳入核心(_IOW)、傳出核心(_IOR)或雙向(_IOWR)的資料的指標。_IO 可以表示沒有引數的命令,或者傳遞整數值而非指標的命令。建議僅將 _IO 用於無引數命令,並使用指標來傳遞資料。

型別(type)

一個 8 位數字,通常是字元字面量,特定於某個子系統或驅動程式,並列在Ioctl 編號中。

編號(nr)

一個 8 位數字,用於標識特定命令,對於給定的“型別”值是唯一的。

資料型別(data_type)

引數所指向的資料型別的名稱,命令編號以 13 位或 14 位整數編碼 sizeof(data_type) 值,導致引數的最大大小限制為 8191 位元組。注意:不要將 sizeof(data_type) 型別傳遞給 _IOR/_IOW/IOWR,因為那將導致編碼 sizeof(sizeof(data_type)),即 sizeof(size_t)。_IO 沒有 data_type 引數。

介面版本

一些子系統在資料結構中使用版本號來透過不同的引數解釋來過載命令。

這通常不是一個好主意,因為對現有命令的更改往往會破壞現有應用程式。

一個更好的方法是新增一個帶有新編號的新 ioctl 命令。舊命令仍需要在核心中實現以保持相容性,但這可以是新實現的一個包裝器。

返回碼

ioctl 命令可以返回 errno(3) 中記錄的負錯誤碼;這些錯誤碼在使用者空間中轉換為 errno 值。成功時,返回碼應為零。返回正的“long”值也是可能的,但不推薦。

當 ioctl 回撥以未知命令編號呼叫時,處理程式返回 -ENOTTY 或 -ENOIOCTLCMD,這也導致系統呼叫返回 -ENOTTY。某些子系統出於歷史原因在此處返回 -ENOSYS 或 -EINVAL,但這是錯誤的。

在 Linux 5.5 之前,compat_ioctl 處理程式必須返回 -ENOIOCTLCMD 才能使用回退轉換為原生命令。由於所有子系統現在都負責自行處理相容模式,因此不再需要這樣做,但在將錯誤修復反向移植到舊核心時,這可能需要考慮。

時間戳

傳統上,時間戳和超時值以 struct timespecstruct timeval 傳遞,但由於轉換為 64 位 time_t 後用戶空間中這些結構的不相容定義,它們存在問題。

當需要單獨的秒/納秒值時,可以使用 struct __kernel_timespec 型別嵌入到其他資料結構中,或者直接傳遞給使用者空間。但這仍然不理想,因為該結構既不完全匹配核心的 timespec64,也不完全匹配使用者空間的 timespec。可以使用 get_timespec64()put_timespec64() 輔助函式來確保佈局與使用者空間保持相容,並且填充得到正確處理。

由於將秒轉換為納秒很廉價,但反向轉換需要昂貴的 64 位除法,因此一個簡單的 __u64 納秒值可能更簡單、更高效。

超時值和時間戳理想情況下應使用 CLOCK_MONOTONIC 時間,如 ktime_get_ns()ktime_get_ts64() 所返回。與 CLOCK_REALTIME 不同,這使得時間戳不受閏秒調整和 clock_settime() 呼叫導致時間前後跳動的影響。

ktime_get_real_ns() 可用於需要跨重啟或在多臺機器之間保持持久的 CLOCK_REALTIME 時間戳。

32 位相容模式

為了支援在 64 位機器上執行的 32 位使用者空間,每個實現 ioctl 回撥處理程式的子系統或驅動程式也必須實現相應的 compat_ioctl 處理程式。

只要遵循資料結構的所有規則,這就像將 .compat_ioctl 指標設定為 compat_ptr_ioctl() 或 blkdev_compat_ptr_ioctl() 等輔助函式一樣簡單。

compat_ptr()

在 s390 架構上,31 位使用者空間的資料指標表示具有歧義,高位被忽略。當在相容模式下執行此類程序時,必須使用 compat_ptr() 輔助函式清除 compat_uptr_t 的高位並將其轉換為有效的 64 位指標。在其他架構上,此宏僅執行向 void __user * 指標的型別轉換。

在 compat_ioctl() 回撥中,最後一個引數是 unsigned long,它可以根據命令被解釋為指標或標量。如果它是標量,則不能使用 compat_ptr(),以確保 64 位核心對於設定了高位的引數,其行為與 32 位核心相同。

對於僅接受指向相容資料結構指標的引數的驅動程式,compat_ptr_ioctl() 輔助函式可以代替自定義的 compat_ioctl 檔案操作。

結構體佈局

相容的資料結構在所有架構上都具有相同的佈局,避免了所有有問題成員。

  • longunsigned long 的大小與暫存器相同,因此它們可以是 32 位或 64 位寬,不能用於可移植的資料結構。固定長度的替代品是 __s32__u32__s64__u64

  • 指標也存在同樣的問題,此外還需要使用 compat_ptr()。最好的解決方案是使用 __u64 代替指標,這需要在使用者空間中進行到 uintptr_t 的型別轉換,並在核心中使用 u64_to_user_ptr() 將其轉換回使用者指標。

  • 在 x86-32 (i386) 架構上,64 位變數的對齊只有 32 位,但它們在包括 x86-64 在內的大多數其他架構上都是自然對齊的。這意味著像下面這樣的結構:

    struct foo {
        __u32 a;
        __u64 b;
        __u32 c;
    };
    

    在 x86-64 上,a 和 b 之間有四個位元組的填充,末尾還有四個位元組的填充,但在 i386 上沒有填充,它需要一個 compat_ioctl 轉換處理程式來在這兩種格式之間進行轉換。

    為避免此問題,所有結構體的成員都應自然對齊,或者在隱式填充的位置新增顯式保留欄位。pahole 工具可用於檢查對齊情況。

  • 在 ARM OABI 使用者空間中,結構體被填充到 32 位倍數,如果它們不以 32 位邊界結束,則某些結構與現代 EABI 核心不相容。

  • 在 m68k 架構上,結構成員不保證具有大於 16 位的對齊,這在依賴隱式填充時是一個問題。

  • 位欄位和列舉通常按預期工作,但它們的一些屬性是實現定義的,因此最好在 ioctl 介面中完全避免使用它們。

  • char 成員可以是 signed 或 unsigned,取決於架構,因此 8 位整數值應使用 __u8 和 __s8 型別,儘管對於固定長度字串,字元陣列更清晰。

資訊洩露

未初始化的資料不得複製回用戶空間,因為這可能導致資訊洩露,可用於擊敗核心地址空間佈局隨機化(KASLR),從而助長攻擊。

因此(也為了相容性支援),最好避免資料結構中任何隱式填充。對於現有結構中存在隱式填充的情況,核心驅動程式必須小心在將其複製到使用者空間之前,完全初始化該結構的一個例項。這通常透過在分配給單個成員之前呼叫 memset() 來完成。

子系統抽象

雖然有些裝置驅動程式實現自己的 ioctl 函式,但大多數子系統為多個驅動程式實現相同的命令。理想情況下,子系統有一個 .ioctl() 處理程式,它將引數從使用者空間複製到使用者空間,並透過普通核心指標將它們傳遞給子系統特定的回撥函式。

這在多方面有所幫助:

  • 如果使用者空間 ABI 沒有細微差別,為某個驅動程式編寫的應用程式更可能在同一子系統中的另一個驅動程式上工作。

  • 使用者空間訪問和資料結構佈局的複雜性集中在一個地方,減少了實現錯誤的潛力。

  • 當 ioctl 在多個驅動程式之間共享時,比僅在單個驅動程式中使用時,更有可能被經驗豐富的開發人員審查,從而發現介面中的問題。

ioctl 的替代方案

在許多情況下,ioctl 並非解決問題的最佳方案。替代方案包括:

  • 系統呼叫是更佳選擇,適用於不依賴於物理裝置或不受字元裝置節點檔案系統許可權限制的系統級功能。

  • netlink 是透過套接字配置任何網路相關物件的首選方式。

  • debugfs 用於除錯功能,這些功能無需作為穩定介面暴露給應用程式。

  • sysfs 是暴露不與檔案描述符關聯的核心物件狀態的好方法。

  • configfs 可用於比 sysfs 更復雜的配置。

  • 自定義檔案系統可以透過簡單的使用者介面提供額外的靈活性,但會大大增加實現的複雜性。