這是一篇針對不需了解傳輸線路細節的開發者所撰寫的 USB 入門介紹。
假設你拿到一個 USB 裝置並被告知要為它撰寫驅動程式。一開始聽起來是不是很嚇人?撰寫驅動程式意味著你必須編寫核心(Kernel)程式碼,而核心程式碼很難、低階、難以除錯等等。
但實際上,這些都不是真的。為 USB 裝置撰寫驅動程式,實際上並不比編寫使用 Socket 的應用程式困難多少。
這篇文章旨在為那些可能沒有太多硬體經驗,只想使用這項技術的開發者,提供一個高層次的 USB 使用介紹。市面上已經有許多很棒的資源,例如《USB in a NutShell》,深入探討 USB 的精確運作方式(如果你想了解更多資訊,可以去看看),但對於從未接觸過 USB、也沒有特定硬體背景的人來說,這些資源可能不太容易入門。你不需要成為嵌入式系統工程師才能使用 USB,就像你不需要成為網路專家才能使用 Socket 和網際網路一樣。
我們將使用的裝置是一個處於 Bootloader 模式的 Android 手機。這樣做的原因是:
將手機進入 Bootloader 模式的方法因裝置而異,但通常涉及在手機啟動時按住某個按鈕組合。以我的情況來說,就是開機時按住音量鍵下。
枚舉(Enumeration)是指主機向裝置詢問自身資訊的過程。當你插入裝置時,這個過程會自動發生,也是作業系統通常決定載入哪個驅動程式來處理該裝置的時機。對於大多數標準裝置,作業系統會查看 USB 裝置類別(USB Device Class),並載入支援該類別的驅動程式。對於廠商自訂的裝置,你通常會安裝製造商提供的驅動程式,該驅動程式會查看廠商 ID(VID)和產品 ID(PID)來判斷是否應處理該裝置。
即使沒有驅動程式,將手機連接到電腦後,它仍然會被辨識為一個 USB 裝置。這是因為 USB 規格定義了一種標準方式讓裝置向主機識別自己,稍後會更詳細地說明這是如何運作的。
在 Linux 上,我們可以使用方便的 `lsusb` 工具來查看裝置如何識別自己:
Bus 和 Device 只是裝置連接的實體 USB 連接埠的識別符號。在你的系統上,它們很可能不同,因為它們取決於你將裝置插入哪個連接埠。ID 是這裡最有趣的部分。第一個部分 `18d1` 是廠商 ID(VID),第二個部分 `4ee0` 是產品 ID(PID)。這些是裝置發送給主機以識別自己的識別符號。VID 由 USB-IF 分配給支付高額費用的公司,在這裡是 Google,而 PID 則由公司分配給特定產品,在這裡是 Nexus/Pixel Bootloader。
使用 `lsusb -t` 命令,我們還可以查看裝置的 USB 類別以及目前正在處理它的驅動程式:
這顯示了系統連接的所有 USB 裝置的完整樹狀結構。在這個樹狀結構的最底部是我們的裝置(根據前一個命令報告的 Bus 008,Device 014)。`Class=Vendor Specific Class` 部分指定該裝置不使用任何標準 USB 類別(例如 HID、Mass Storage 或 Audio),而是使用製造商定義的自訂協定。`Driver=[none]` 部分只是告訴我們作業系統沒有為該裝置載入驅動程式,這對我們來說很好,因為我們想編寫自己的驅動程式。
如果你使用的是 Windows,你可能沒有 `lsusb`,但你仍然可以使用裝置管理員(Device Manager)或像 USB Device Tree Viewer 這樣的工具找到大部分資訊。
我們也將關注 VID 和 PID,因為它們是我們擁有的唯一真實識別資訊。裝置類別在這裡沒有太大用處,因為它只是「廠商自訂類別」,任何製造商都可以用於任何裝置。然而,與其在核心中完成所有這些工作,我們可以編寫一個使用者空間應用程式來做同樣的事情。這樣編寫和除錯都容易得多(而且可以說,這才是驅動程式應有的位置,但那是另一個話題)。為此,我們可以使用 `libusb` 函式庫,它提供了一個簡單的 API,用於從使用者空間與 USB 裝置通訊。它透過提供一個可以為任何裝置載入的通用驅動程式來實現這一點,然後提供一種讓使用者空間應用程式能夠取得裝置並直接與之通訊的方式。
我們剛才手動完成的相同操作,也可以透過軟體來完成。以下程式碼會初始化 `libusb`,為符合 `18d1:4ee0` 廠商 ID/產品 ID 組合的裝置註冊一個熱插拔事件處理器,然後等待該裝置被插入主機。
如果你編譯並執行此程式碼,插入裝置後應該會看到以下輸出:
恭喜!你現在有一個程式,可以在完全不接觸任何核心程式碼的情況下偵測到你的裝置。
在 Linux 上,這一切通常都能正常運作。如果因為任何原因仍然載入了驅動程式,你可以使用 `libusb_detach_kernel_driver()` 強行分離它。
在 Windows 上,情況可能有所不同。如果你幸運的話,該裝置會有一個 Microsoft OS 描述符,指示 Windows 為你的裝置載入 `Winusb.sys` 驅動程式。在這種情況下,`libusb` 可以直接與之通訊。但是,如果沒有載入驅動程式(裝置在裝置管理員中顯示為帶有 ⚠️ 圖示),你可能需要使用 Zadig 來強制將裝置的驅動程式替換為 `Winusb.sys` 或其他支援的驅動程式。更多資訊可以在這裡找到:libusb Wiki
下一步,從裝置取得任何回應。目前最簡單的方法是使用標準化的控制端點(Control Endpoint)。這個端點的 ID 始終是 `0x00`,並具有標準化的協定。作業系統先前也使用這個端點來識別裝置並取得其 VID:PID。
我們在這裡有點超前了,因為我們甚至還不知道端點是什麼,但稍後一切都會變得有意義,我保證。目前,你只需將端點想像成裝置上的網路連接埠,我們將資料發送到特定的編號。
我們使用另一個 `libusb` 函式來使用這個端點,該函式專門用於向該端點發送請求。因此,我們可以擴充我們的熱插拔事件處理器,使用以下程式碼:
這段程式碼現在會在裝置插入後立即向裝置發送一個 `GET_STATUS` 請求,並將回傳的資料列印到主控台。
這些位元組來自裝置本身!使用 USB 規格解碼它們,我們可以得知第一個位元組告訴我們裝置是否為自供電(Self-Powered)(1 表示是,這是有道理的,裝置有電池),第二個位元組表示它不支援遠端喚醒(Remote Wakeup)(意味著它無法喚醒主機)。
還有一些標準化的請求類型(有些裝置甚至會為簡單的事情添加自己的請求!),但我們(以及作業系統)最感興趣的主要請求是 `GET_DESCRIPTOR`。
描述符(Descriptors)是二進位結構,通常硬編碼在 USB 裝置的韌體中。它們告訴主機裝置是什麼、它能做什麼以及它希望作業系統載入哪個驅動程式。因此,當你插入裝置時,主機會發送多個 `GET_DESCRIPTOR` 請求到標準化的控制端點 ID `0x00`,以取得一個結構,其中包含枚舉所需的所有資訊。而酷的是,我們也可以做到這一點!
我們現在發送一個 `GET_DESCRIPTOR` 請求,而不是 `GET_STATUS` 請求:
這現在回傳了以下資料:
現在要解碼這些資料,我們需要查閱 USB 規格的第 9.6.1 節「Device」。在那裡我們可以找到格式如下:
將資料放入 ImHex 並給予其 Pattern Language 這個結構定義,會得到以下結果:
瞧!`idVendor` 和 `idProduct` 對應我們之前使用 `lsusb` 找到的值。
然而,裝置描述符(Device Descriptor)並非全部。還有組態描述符(Configuration)、介面描述符(Interface)、端點描述符(Endpoint)、字串描述符(String)以及其他一些描述符。這些都可以使用相同的 `GET_DESCRIPTOR` 請求在控制端點上讀取。我們仍然可以手動完成這一切,但幸運的是,`lsusb` 有一個選項可以為我們做到這一點!
此輸出顯示了裝置的更多描述符。具體來說,它有一個單一的組態描述符,其中包含一個用於 Android Fastboot 介面的介面描述符。該介面現在包含兩個端點。這就是裝置告訴主機關於除控制端點之外的所有其他端點的地方,這些將是我們在下一步中實際向裝置的 Fastboot 介面發送資料的端點!
不過,我們先來談談端點。我們已經了解了位址為 `0x00` 的控制端點。端點基本上相當於裝置在網路上開啟的連接埠,供我們來回傳送資料。裝置在其描述符中指定了它有哪些類型的端點,然後在其韌體中處理這些端點。因此,我們甚至不需要進行連接埠掃描,也不需要知道 SSH 通常運行在連接埠 22,我們有很好的方法可以找出裝置有哪些介面、它們使用哪種語言以及我們如何與它們溝通。然而,從上面的描述符來看,控制描述符並不存在。相反,還有另外兩個不同類型的端點。
每個裝置只有一個,並且始終固定在端點位址 `0x00`。它用於初始組態和請求裝置資訊。
控制端點的主要目的是解決雞生蛋、蛋生雞的問題,即在不知道裝置端點的情況下無法與之通訊,但要知道其端點又需要與之通訊。這也是為什麼它甚至沒有出現在描述符中。它不屬於任何介面,而是裝置本身。我們透過規格知道它的存在,而無需它被廣告。
它用於設定簡單的組態值或請求少量資料。libusb 中的函式甚至不允許你設定端點位址來發送控制請求,因為永遠只有一個控制端點,而且它始終位於位址 `0x00`。
批量端點(Bulk Endpoints)用於傳輸大量資料。當你需要傳輸大量非時間敏感的資料時,就會使用它們。這用於諸如 Mass Storage Class、CDC-ACM(USB 序列埠)和 RNDIS(USB 乙太網路)之類的用途。
一個細節:透過批量端點傳送的資料是高頻寬但低優先級的。這意味著,批量資料總是會填滿剩餘的頻寬。任何中斷(Interrupt)和同步(Isochronous)傳輸(進一步細節如下)的優先級較高,因此如果你在同一連接上同時傳輸批量和同步資料,批量傳輸的頻寬將會降低,直到同步傳輸能夠在要求的時間範圍內傳輸其資料。
中斷端點(Interrupt Endpoints)與批量端點相反。它們允許你以非常低的延遲傳送少量資料。例如,鍵盤和滑鼠在 HID Class 下使用這種傳輸類型,每秒輪詢按鍵 1000 次以上。如果沒有按下按鈕,傳輸會立即失敗,而不會回傳完整的失敗訊息(只有 NAK),只有當實際發生變化時,你才會收到關於發生了什麼的描述。
一個重要的事實是,即使這些被稱為中斷端點,實際上並沒有發生中斷。裝置仍然不會在未被詢問的情況下與主機通訊。主機只是非常頻繁地輪詢,以至於看起來像是中斷。libusb 中處理中斷傳輸的函式也進一步抽象化了這種行為。你可以啟動一個中斷傳輸,函式會阻塞直到裝置回傳完整的響應。
同步端點(Isochronous Endpoints)有點特別。它們用於傳輸大量對時間要求非常嚴格的資料。它們主要用於串流介面,如音訊或視訊,其中任何延遲或延遲都會透過卡頓或不同步立即顯現出來。在 libusb 中,它們以非同步方式工作。你可以一次設定多個傳輸,它們會被排隊,一旦資料到達,你就會收到一個事件,以便你可以處理它並排隊進一步的請求。這種端點類型通常在音訊和視訊類別之外不常使用。
除了傳輸類型外,端點還有方向。請記住,USB 是一個完全主從導向的介面。主機是唯一發出請求的一方,裝置除非被主機尋址,否則永遠不會回應。這意味著,裝置實際上無法直接將資料發送給主機。相反,主機需要請求裝置發送資料。
我記住方向的方法是使用主從類比。主機非常以自我為中心,總是從自己的角度來描述一切。
與傳輸類型相反,方向編碼在端點位址中。如果最高位元(MSB)設定為 1,則是 IN 端點;如果設定為 0,則是 OUT 端點。(如果你對硬體感興趣,你可能會從 I2C 介面中辨識出這個概念。)
現在我們有了關於 USB 的所有這些資訊,讓我們來看看 Fastboot 協定。這方面的最佳文件是 u-boot 的原始碼及其文件。
根據文件,該協定實際上非常簡單。主機發送一個字串命令,裝置則以一個 4 個字元的狀態碼加上一些資料進行回應。
讓我們來更新我們的程式碼以執行此操作:
插入裝置後,現在會在終端機列印以下訊息:
這似乎與文件相符!前 4 個位元組是 `OKAY`,表示請求已成功執行。之後的資料是 `0.4`,對應於文件中實現的 Fastboot 版本:v0.4。
就是這樣!你成功地從頭開始編寫了你的第一個 USB 驅動程式,而無需接觸核心程式碼。
所有這些相同的原則都適用於所有現有的 USB 驅動程式。底層協定可能比 Fastboot 協定複雜得多(我曾為 MTP 協定的可怕之處而抓狂),但圍繞它的一切都保持不變。不像 TCP over sockets 那麼複雜,對吧? :)
如何在新平台上啟動 Linux 核心
熱感應印表機 BLE 協定逆向工程