USB:從零入門到放棄

2020-10-25 08:00:42

前言

從一無所知到開發USB裝置,需要經歷怎樣的過程?
    我剛接觸USB模組時,有無從下手的感覺。經過「摸石頭過河」式的學習後,才算有了大致概念。雖說USB檔案齊全、原理詳實,但入門還是有一定的門檻。因此,我把自己從零開始的學習USB的過程記錄分享,希望能給USB這條大河搭個橋,以供參考。本文提供一種自上而下的學習過程,無意深刻剖析直達底層原理,只盼所述能使人對完整的USB知識體系有清晰的架構認知。

理論學習

本章將由淺入深介紹USB原理,逐步解釋以下問題:
    第一節:USB從接入到使用,講述USB裝置接入主機後經歷了哪些過程;
    第二節:USB通訊過程,解釋USB裝置和主機之間如何通訊;
    第三節:從機的屬性,介紹如何區分不同型別的USB裝置;
    第四節:列舉的詳細過程,概括主機認識USB裝置的具體過程;
另外,下文將用主機/從機統一描述USB主機和USB裝置:
    主機:USB主機(Win/Android/Mac等)
    從機:USB裝置(滑鼠/鍵盤/U盤等)

USB從接入到使用

    主機發現從機接入後,開始識別從機,成功識別後就可以使用從機的功能了。其中,發現從機接入/拔出的過程稱為USB拔插,識別從機的過程稱為列舉

USB拔插:主機發現從機的接入/拔出

【摘要】主機通過檢測USB D+/D-的電平變化感知從機接入/拔出。
一般USB介面包含4根線(OTG為5根),分別是:Vcc, D+, D-, GND。如圖所示: USB Hardware Interface
    主機端D+/D-下拉15KΩ電阻到GND(0V),從機端D+/D-上拉1.5KΩ電阻到3.3V。當從機接入主機時,D+/D-上的電壓變為3V,雙方通過電平變化就可以發現USB的拔插事件。

USB列舉:主機認識從機的方式

【摘要】主機通過獲取裝置的描述符集合來識別USB裝置,這個過程稱為「列舉」。
    USB裝置(從機)的型別非常多,常見的有滑鼠、鍵盤、遊戲手柄等USB HID(Human Interface Device)裝置,串列埠偵錯的CDC(Communication Device Class)裝置,User自定義傳輸內容的WINUSB裝置等。那麼主機如何區分這些USB裝置呢?
    因此,每個USB裝置都必須有一個描述符集合。這個集合詳細描述了從機的所有功能和用途。USB連線後,主機通過存取描述符集合來識別從機並設定從機(列舉過程),就可以根據從機提供的資訊使用從機的功能。

USB使用:主機使用從機的功能

【摘要】從機以等待主機輪詢的方式發資料,以中斷的方式收資料,從而實現相應的功能。
    列舉成功後,從機開始履行自己的職責。以滑鼠為例,列舉後它向主機傳送報告(Report)來控制遊標移動、點選,但從機並非任意時刻都能傳送資料,它必須等主機已經準備好通訊了才開始傳送。因此,從機準備好傳送的資料後必須進入等待,直到主機輪詢到此功能時,才開始傳送。假設從機可以任意觸發資料的傳送過程,且主機連線多個從機,那麼當多個從機同時傳送資料到主機的USB匯流排上時就會引發衝突。
    反之,當主機需要傳送資料時,從機必須儘快接收,所以從機一般會用中斷處理主機傳送資料的請求。這是因為主機需要輪詢很多從機,每次輪詢都有固定的時間,超時後就通訊失敗了。
【Q】從機傳送/接收資料,主機傳送/接收資料是否容易概念混淆?
【A】 是的。因此USB的資料傳輸過程描述以主機端為主。「從機–>主機」(Device-to-host) 方向的資料傳輸稱為輸入(Data In)「從機<–主機」(Host-to-device) 方向的資料傳輸為輸出(Data Out)
【Q】主機輪詢到從機的輸入功能時,沒有資料要傳送怎麼辦?
【A】 當然是PASS,從機直接回復NAK(即沒有資料)或STALL(裝置掛起)。

USB通訊過程

主機如何存取指定USB裝置?

【摘要】主機為所有從機分配唯一的裝置地址,通過該地址來存取從機。
    以PC為例,一般PC的USB裝置可能包括滑鼠、鍵盤、HUB擴充套件塢、藍芽/WiFi介面卡等。那麼假如PC想存取滑鼠裝置時,該如何實現呢?
Deivce Address
    答案是裝置地址。主機給所有已連線的從機分配裝置地址,並確保不會重複。對剛接入還沒來得及分配地址的從機,主機使用預設地址<Addr0>與之通訊。交換少量的資訊後,主機分配新地址,然後雙方用新地址(Addr1~AddrN)通訊。
【Q】列舉成功後,從機再次拔插還可以用之前分配的地址通訊嗎?
【A】 主機會重新分配裝置地址,但可能分配的碰巧就是之前的地址。
【Q】主機為分配地址前,如何與從機通訊?
【A】 USB規定,對於剛接入的從機,主機用預設地址(Addr0)通訊。

主機如何存取指定USB裝置的指定功能?

【摘要】主機通過<裝置地址(Address),裝置端點(Endpoint)>存取指定從機的指定介面(功能)。
    假設裝置A是USB複合裝置,同時支援滑鼠、鍵盤、CDC功能,那麼主機給裝置A分配裝置地址後,如何存取從機A的其中一個功能(比如鍵盤功能)?且當這個鍵盤功能同時支援傳送和接收資料時,如何避免收發衝突呢?
USB Addr + EP
    答案是用端點(Endpoint,EP)加以區分。主機通過裝置地址找到從機後,再通過端點存取從機的指定功能的指定用途。端點具有唯一性,它們和從機的功能及用途一一對應,按照端點的屬性構建專用的端點通道(Pipe)來通訊。另外,端點還標識了特定用途的資料傳輸方向。因此,對於USB複合裝置A,通過端點號可區分鍵盤功能的傳送或接收。
【Q】有多少個功能/用途就分配多少個裝置地址不就可以了嗎?
【A】 如果這麼做,當主機接入多個USB裝置,而每個USB裝置又支援多種功能、每個功能又包含多個用途時,主機需要分配的地址數量非常之多,且每次拔插裝置需要多次分配地址,最終通訊效率變低了。
【Q】主機未識別從機的功能之前用什麼端點通訊?
【A】 與預設地址0一樣,從機也會有預設端點0(Default Endpoint, EP0)。準確來講,對初次接入的從機,雙方通過<Addr0,EP0>進行通訊

主機、從機如何讀/寫資料

【摘要】主機用預設端點0(EP0)建立通道列舉從機,根據描述符集中的其他端點建立對應通道存取其他功能。
    首先,從機必須支援預設端點EP0。對剛接入的從機,主機使用<Addr0, EP0>存取從機,建立EP0的端點通道,開始列舉並分配地址,然後使用<new Addr, EP0>重新列舉。列舉成功後,主機根據從機提供的資訊建立相應的資源和通道,存取從機的功能。
    當然,從機的功能多種多樣,可能要持續傳輸大量資料,也可能要求實時性高,或是偶爾傳輸資料等。那麼存取的需求不一樣,主機怎麼區分呢?
    當然是給端點加上屬性(Attribute)。在端點描述符中宣告屬性,可以告訴主機構建什麼樣的資料通道,以何種方式讀/寫資料。

一次完整的通訊過程

【摘要】一次完整的通訊分為三個過程:請求過程(令牌包)、資料過程(封包)和狀態過程(握手包),沒有資料要傳輸時,跳過資料過程。
    通訊過程包含以下三種情況:
USB Communication
    主機傳送令牌包(Token)開始請求過程,如果請求中宣告有資料要傳輸則有資料過程,最後由資料接收方(有資料過程)或從機(無資料過程)發起狀態過程,結束本次通訊。
    與USB全速裝置通訊時,主機將每秒等分為1000個幀(Frame)。主機在每幀開始時,向所有從機廣播一個幀起始令牌包(Start Of Frame,SOF包)。它的作用有兩個:一是通知所有從機,主機的USB匯流排正常工作;二是從機以此同步主機的時序。
    與USB高速裝置通訊時,主機將幀進一步等分為8個微幀(Microframe),每個微幀佔125 μ \mu μs。在同一幀內,8個微幀的幀號都等於當前SOF包的幀號。
注意: 下文所有USB包結構均不包括前導碼(同步碼)

#pragma data_alignment=1    //對齊方式為Byte
typedef struct _USB_Token_SOF_t{
    uint8_t  bPID;          // 0xA5, SOF(0101B)
    uint16_t b11FrameID:11; // 幀號
    uint16_t b5CRC:5;       // wFrameID欄位(11bit)的CRC校驗碼
}USB_Token_SOF_t;

【Q】為什麼PID是4bit的,欄位長度卻有8bit?
【A】 因為PID欄位高4bit是低4bit的校驗位:pid(i+4) = ~pid(i)。
【Q】為什麼CRC不校驗PID欄位?
【A】 因為PID欄位本身帶有校驗位。

請求過程(請求包)

    主機廣播SOF包之後,會傳送帶有地址和端點資訊的令牌包(Token) 來指定要存取的從機,分別有:建立令牌包(SETUP)、輸出令牌包(OUT)、輸入令牌包(IN)
    這三種令牌包統稱為請求包,結構如下:

#pragma data_alignment=1    //對齊方式為Byte
typedef struct _USB_Token_t{
    uint8_t bPID;           // 0xE1, OUT    (0001B);
                               0x69, IN     (1001B);
                               0x2D, SETUP  (1101B);
    uint16_t b7Addr:7;      // 要存取的裝置地址
    uint16_t b4Endpoint:4;  // 要存取的端點號
    uint16_t b5CRC:5;       // wFrameID欄位(11bit)的CRC校驗碼
}USB_Token_t;

    主機可以通過請求包指定要存取的從機,發起請求過程,設定從機或指示從機準備傳送/接收資料。在列舉過程中,主機使用SETUP包請求從機的資訊。列舉成功後,主機使用IN包請求輸入資料,OUT包請求輸出資料。
    列舉時,在SETUP包的後面會緊跟一個8B長度的請求(Request),用於描述主機的具體意圖,結構如下:

#pragma data_alignment=1    //對齊方式為Byte
typedef struct _USB_Request_t{
    uint8_t  bmRequestType; // 請求型別
    uint8_t  bRequest;      // 具體請求,參考USB 2.0 Spec Chapter 9.4
    uint16_t wValue;        // 內容和Request有關
    uint16_t wIndex;        // 內容和Request有關
    uint16_t wLength;       // 資料過程可傳輸的最大位元組數
}USB_Request_t;

typedef struct _bmRequestType_t{
    uint8_t b5Recipient:5;  // 0 = Device, 1 = Interface
                               2 = Endpoint, 3 = Other
                               4..31 = Reserved
    uint8_t b2Type:2;       // 0 = Standard, 1 = Class
                               2 = Vendor, 3 = Reserved
    uint8_t b1Direction:1;  // 0 = Host-to-device
                               1 = Device-to-host
}bmRequestType_t;

    在「請求過程」階段,被存取的從機會接收並解析請求,若wLength欄位不為0,則進入資料過程,否則進入狀態過程。
【Q】從機收到不支援的請求怎麼辦?
【A】 可以直接進入狀態過程,從機傳送STALL包。
【Q】有了IN/OUT包,為什麼還要在請求中宣告傳輸方向(Direction)?
【A】 IN/OUT包後面不會帶有請求。從機在收到IN/OUT包後直接進入資料過程,傳送資料或回覆NAK(沒有資料要傳送)。

資料過程(封包)

    請求的bmRequestType欄位中,Direction標誌位宣告了資料要傳輸的方向。
    當請求為輸出(Data OUT,Direction = 1)時,從機接收不超過wLength欄位中宣告長度的資料,並根據請求的內容解析接收到的資料;當請求為輸入時(Data IN,Direction = 0)時,從機根據請求的內容傳送對應的資料(不超過wLength中宣告的長度)。
    封包(Data Packets)的結構如下:

#pragma data_alignment=1    //對齊方式為Byte
typedef struct _USB_Data_Packet_t{
    uint8_t bPID;           // 0xC3, DATA0 (0011B); even
                               0x4B, DATA1 (1011B); odd
                               0x87, DATA2 (0111B); for usb high speed
                               0x0F, MDATA (1111B); for usb high speed
    uint8_t bData[];        // 0 ~ 8192B
    uint16_t wCRC16;        // bData欄位的CRC校驗碼
}USB_Data_Packet_t;

【Q】為什麼要分DATA0和DATA1?
【A】 在USB全速裝置中,封包以DATA0、DATA1的PID交替傳送。當接收方連續收到兩個PID相同的DATA包時,就知道丟包了。而DATA2與MDATA則是USB高速裝置所使用的PID,參考《USB 2.0 Spec》Chapter 5.9.2。

狀態過程(握手包)

    進入狀態過程後,傳送的包是握手包(Handshake Packets),結構如下:

#pragma data_alignment=1    //對齊方式為Byte
typedef struct _USB_Handshake_t{
    uint8_t bPID;           // 0xD2, ACK    (0010B); 確認接收
                               0x5A, NAK    (1010B); 沒有資料要返回
                               0x1E, STALL  (1110B); 無法執行的請求
                               0x96, NYET   (0110B); 接收成功但無法
                            接收下一次資料,僅在usb高速裝置中使用。下
                            次主機傳送資料需要先傳送PING包試探裝置。
}USB_Handshake_t;

    沒有資料過程時,握手包的傳送方是從機;
    資料過程為Data Out時,握手包的傳送方是從機;
    資料過程為Data In時,握手包的傳送方是主機;
    當然,除了上述USB包,還有特殊包(Special Packets):PING(0100B) / SPLIT(1000B) / PRE(1100B) / ERR(1100B)。這些特殊包的作用參考《USB 2.0 Spec》Chapter 8。

通訊異常

    當從機還沒準備好時主機請求資料;從機收到未知請求;端點通訊資料量溢位;主機不應傳送的請求;或沒有資料要傳送等情況時,本輪通訊會直接進入狀態過程,從機傳送NYET/ERR/STALL/NAK包。
    當資料傳輸出錯時,資料的傳送方停止傳送資料,直到本次通訊超時。

從機的屬性

【摘要】描述符集描述了從機的所有功能細節,它包含唯一的裝置描述符,至少一個設定描述符和介面描述符,每個介面描述符至少包含一個端點描述符,此外還有其他可選的特殊描述符進行補充。
    前文提到,主機通過請求從機的描述符集來認識從機,那麼描述符集包含了哪些資訊呢?
    描述符集主要包含裝置描述符(Device Descriptor)設定描述符(Configuration Descriptor)介面描述符(Interface Descriptor)端點描述符(Endpoint Descriptor)字串描述符(String Descriptor)其他描述符

描述符集的層次結構

Descriptor Collection
    一個USB裝置有且僅有一個裝置描述符
    一個裝置描述符指向一個(或多個)設定描述符
    一個設定描述符指向一個(或多個)介面描述符
    一個介面描述符指向一個(或多個)端點描述符,還可能帶有介面補充描述符

    上述描述符如果帶有字串索引號(String Index),主機會根據索引號向從機請求對應的字串描述符,進一步提供可供使用者閱讀的資訊。
    對於一些介面(HID/CDC等),設定集合就包含一種介面補充描述符——特殊類描述符。不同的介面補充描述符作用不同,結構也可能不一樣。如HID描述符會宣告報告描述符的存在,由報告描述符進一步補充介面資訊。如果補充描述符中又宣告了其他描述符,主機會按介面索引號單獨向從機請求其他描述符。
    需要注意的是,同一時間從機只能有一個生效的設定集合,生效的設定通過主機選擇(Set Configuration)來指定,因為設定集合「複用」了從機的硬體資源。
    上述描述符中,除其他特殊描述符外主機能夠單獨獲取的只有裝置描述符、字串和設定描述符,因為這些描述符是全域性有效的。但介面描述符、端點描述符和特殊類描述符是某個設定集合內(區域性)生效的,需要補充設定描述符一起傳送。事實上,列舉過程中主機會一次性獲取整個設定集合

裝置描述符(Device Descriptor)

#pragma data_alignment=1    //對齊方式為Byte
typedef struct _USB_Desc_Device_t {
    uint8_t   bLength;                  // 固定值18B
    uint8_t   bDescriptorType;          // 固定值Device(0x01)
    uint16_t  wBcdUSB;                  // USB Spec版本
    uint8_t   bDeviceClass;             // 裝置型別
    uint8_t   bDeviceSubClass;          // 裝置子型別
    uint8_t   bDeviceProtocol;          // 協定型別
    uint8_t   bMaxPacketSize0;           // EP0的最大包長度
    uint16_t  wIdVendor;                // 廠商ID
    uint16_t  wIdProduct;               // 產品ID
    uint16_t  wBcdDevice;               // 裝置軟體版本
    uint8_t   bStringIndexManufacturer; // 廠商名稱字串索引號
    uint8_t   bStringIndexProduct;      // 產品名稱字串索引號
    uint8_t   bStringIndexSerialNumber; // 序列號索字串引號
    uint8_t   bNumConfigurations        // 設定數量>=1
}USB_Desc_Device_t;

    其中,裝置型別、裝置子型別、協定型別參考USB IF的定義。EP0最大包長度則為從機預設端點EP0一次可傳輸的最大包的大小。其典型值為64B,早期的USB裝置為8B。字串索引號分別對應一個字串,主機用它向從機請求對應的文字資訊。
【Q】Vendor ID和Product ID有什麼作用?
【A】 Vendor ID(VID)的商用需要向USB組織申請,開發者可直接使用開發平臺的廠商ID。Product ID(PID)由廠商自行管理。VID和PID的作用是讓主機快速識別某些著名的裝置(Windows可以在完成列舉之前依此直接派發驅動),它們也常常作為搜尋從機的條件(如libusb)。

設定描述符(Configuration Descriptor)

#pragma data_alignment=1    //對齊方式為Byte
typedef struct _USB_Desc_Configuration_t {
    uint8_t   bLength;                 // 固定值9B
    uint8_t   bDescriptorType;         // 固定值Configuration(0x02)
    uint16_t  wTotalConfigurationSize; // 設定集合的總大小
    uint8_t   bTotalInterfaces;        // 設定集合的介面數量
    uint8_t   bConfigurationNumber;    // 當前設定的序號(從1開始)
    uint8_t   bConfigurationStrIndex;  // 設定名稱的字串索引號
    uint8_t   bConfigAttributes;       // 設定集合的屬性
    uint8_t   bMaxPowerConsumption;    // 最大供電電流,單位是2mA
}USB_Desc_Configuration_t;

// 設定集合的屬性
typedef struct _bConfigAttributes_t{
    uint8_t   b5reserved:5;            // 保留置0
    uint8_t   b1RemoteWakeup:1;        // 置1表示支援遠端喚醒
    uint8_t   b1Selfpowerd:1;          // 置1表示支援自己供電
    uint8_t   b1reserved:1;            // 保留置1
}bConfigAttributes_t;

    設定集合的總大小是當前設定集合內設定描述符、介面描述符、端點描述符和特殊類描述符的總長度。需注意,如果供電電流為100mA,「bMaxPowerConsumption」欄位的值應當為50。

介面描述符(Interface Descriptor)

#pragma data_alignment=1    //對齊方式為Byte
typedef struct _USB_Desc_Interface_t {
    uint8_t bLength;                // 固定值9B
    uint8_t bDescriptorType;        // 固定值Interface(0x04)
    uint8_t bInterfaceNum;          // 介面索引號
    uint8_t bAlternateSetting;      // 備用介面號
    uint8_t bNumberEndpoints;       // 端點數量
    uint8_t bInterfaceClass;        // 介面型別
    uint8_t bInterfaceSubclass;     // 介面子型別
    uint8_t bInterfaceProtocol;     // 介面協定
    uint8_t bInterfaceStringIndex;  // 介面名稱的字串索引號
}USB_Desc_Interface_t;

    其中,介面型別、子型別、介面協定參考USB IF的定義。備用介面號用於宣告另一個可以替代當前介面的備用介面。

端點描述符(Endpoint Descriptor)

#pragma data_alignment=1    //對齊方式為Byte
//參考USB Spec 2.0 Table 9-13
typedef struct _USB_Desc_Endpoint_t{
    uint8_t  bLength;           // 固定值7B
    uint8_t  bDescriptorType;   // 固定值Endpoint(0x05)
    uint8_t  bEndpointAddress;  // 端點地址
    uint8_t  bmAttributes;      // 端點屬性
    uint16_t wMaxPacketSize;    // 端點支援的最大包大小
    uint8_t  bInterval;         // 輪詢間擱(僅中斷端點有效)
}USB_Desc_Endpoint_t;

// 端點地址
typedef struct _bEndpointAddress_t{
    uint8_t b4EndpointNumber:4; // 端點號
    uint8_t b3Reserved:3;       // 保留置0
    uint8_t b1Direction:1;      // 傳輸方向(IN/OUT)
}bEndpointAddress_t;
// 端點屬性
typedef struct _bmAttributes_t{
    uint8_t b2TransferType:2;   // 傳輸型別
                                ** 00 = Control
                                ** 01 = Isochronous
                                ** 10 = Bulk
                                ** 11 = Interrupt
                               
    uint8_t b2SynchronizationType:2; // 僅iso傳輸有效
    uint8_t b2UsageType:2;      // 僅iso傳輸有效
    uint8_t b2Reserved:2;       // 保留置0
}bmAttributes_t;

    端點支援的最大包大小是端點通道一次可以傳輸的最巨量資料量。在批次傳輸(bulk transfer)中,超過該值的資料會被分包傳輸,一般來說,如果接收方接收到恰好為最大包長度的資料,則會認為還有資料要傳輸。當然,bulk傳輸的方式本身是可以自定義的,具體行為可以由開發者控制。而在中斷傳輸(interrupt transfer)中,不允許超過最大包長度的資料量傳輸。

批次傳輸(Bulk Transfer)

    批次傳輸是最好理解的,它幾乎沒有什麼限制,全看怎麼實現,語法、語意都是私有的。它適合需要傳輸大量資料且對資料實時性要求不高的場景。一般來說,傳輸過程中會以傳輸包是否小於最大包長度作為本輪傳輸結束的標誌。下文的例程Winusb就是使用這種傳輸方式。具體參考USB Spec 2.0 Chapter 5.8。

控制傳輸(Control Transfer)

    控制傳輸適用於資料量少且對時序有嚴格要求的場景。顧名思義,它就是用來傳輸裝置資訊和主機資訊的。所有的從機都必須支援控制傳輸,以便和主機交換資訊,也就是說,從機的預設端點0的型別都是控制傳輸。具體參考USB Spec 2.0 Chapter 5.5。

中斷傳輸(Interrupt Transfer)

    中斷傳輸適用於傳輸資料量少但需要定時詢問的場景,如鍵鼠裝置。端點描述符的輪詢間擱欄位宣告了主機兩次存取之間的最長間擱。具體參考USB Spec 2.0 Chapter 5.7。

同步傳輸(Synchronous Transfer)*

    參考USB Spec 2.0 Chapter 5.6。同步傳輸適合資料量大且實時性要求高的場景,比如音訊傳輸。
【Q】端點EP in 1(0x01)和端點EP out 1(0x81)是同一個端點嗎?
【A】 端點號 ≠ \neq =端點地址。EP in 1和EP out 1的端點號雖然相同,但傳輸方向不同,構建的端點通道(Pipe)也不同。因此不能認為它們是同一個端點。

字串描述符(String Descriptor)

#pragma data_alignment=1    //對齊方式為Byte
typedef struct _USB_Desc_String_t{
    uint8_t  bLength;           // 字串描述符的長度
    uint8_t  bDescriptorType;   // 固定值String(0x03)
    wchar_t  wUnicodeString[];
}USB_Desc_String_t;

    UnicodeString是wchar_t型字串。如果希望定義裝置為"DevName",則需定義L"DevName"(長度16B,包含了停止位L"\0"),bLength欄位的值則為14。

其他描述符

    除了上述基本的描述符,USB裝置還會帶有其他特殊的描述符,對裝置功能、資訊作進一步補充。以下列舉一些常見的特殊描述符:

裝置限定符描述符(Qualifier Descriptor)

#pragma data_alignment=1    //對齊方式為Byte
typedef struct _USB_Desc_Device_Qualifier_t{
    uint8_t   bLength;                  // 固定值18B
    uint8_t   bDescriptorType;          // 固定值Device(0x01)
    uint16_t  wBcdUSB;                  // USB Spec版本
    uint8_t   bDeviceClass;             // 裝置型別
    uint8_t   bDeviceSubClass;          // 裝置子型別
    uint8_t   bDeviceProtocol;          // 協定型別
    uint8_t   bMaxPacketSize0;          // EP0的最大包長度
    uint8_t   bNumConfigurations        // 設定數量>=1
    uint8_t   bReserved;                // 保留置0
}USB_Desc_Device_Qualifier_t;

    可以看到,裝置限定描述符的結構就是裝置描述符的一部分。假如主機和從機正在全速USB的速率通訊,結果主機發現從機帶有裝置限定描述符,且在描述符中宣告從機支援高速USB通訊,那麼主機就會復位從機,重新以高速USB的通訊速率進行通訊。
    

特殊類描述符(Class-specific Descriptor)

    特殊類描述符的結構取決於介面的實際型別。比如HID描述符:

#pragma data_alignment=1    //對齊方式為Byte
//Human Interface Device Descriptor,參考 Device Class Definition for HID 1.11 Chapter 6.2.1
typedef struct _USB_Desc_HID_t{
    uint8_t  bLength;
    uint8_t  bDescriptorType;
    uint16_t wBcdHID;           // 遵循的Hid協定版本
    uint8_t  bCountryCode;      // 國區程式碼
    uint8_t  bNumDescriptors;   // 其他特殊描述符的個數
    uint8_t  bDescriptorType;   // 其他特殊描述符的型別,一般為Report(0x22)
    uint16_t wDescriptorLength; // 其他特殊描述符的長度
    (optional)uint8_t bDescriptorType;
    (optional)uint16_t wDescriptorLength;
    ...
}USB_Desc_HID_t;
//Hub Descriptor,參考 USB Spec 2.0 Chapter 11.23.2
typedef struct _USB_Desc_Hub_t{
    uint8_t  bDescLength;
    uint8_t  bDescriptorType;
    uint8_t  bNbrPorts;
    uint16_t wHubCharacteristics;
    uint8_t  bPwrOn2PwrGood;
    uint8_t  bHubContrCurrent;
    uint8_t  abDeivceRemovable[];
    uint8_t  abPortPwrCtrlMask[];
}USB_Desc_Hub_t;

功能描述符(Functional Descriptor)

    以下功能描述符的通用結構:

#pragma data_alignment=1    //對齊方式為Byte
//參考 CDC120-20101103-track Chapter 5.2.3
typedef struct _USB_Desc_Functional_t{
    uint8_t bFunctionLength;
    uint8_t bDescriptorType;
    uint8_t bDescriptorSubType;
    uint8_t abFunctionSpecificData[];    // data[0] ~ data[N - 1]
}USB_Desc_Functional_t;

物理描述符(Physical Descriptor)

    參考Device Class Definition for HID 1.11 Chapter 6.2.3。

微軟系統描述符(Microsoft OS Descriptor)

    微軟系統描述符是由微軟定義的,參考Microsoft docs

列舉的詳細過程

    ①USB裝置接入後,主機復位從機,使用<addr0, EP0>構建端點通道(Pipe)請求裝置描述符,從機傳送完整的裝置描述符或只傳送前8B內容(當EP0最大包長度只有8B);
    ②主機分配唯一的裝置地址並行送Set Address請求,收到應答後再次復位從機;
    ③主機再次請求完整的裝置描述符,當一次請求不足以獲取完整的描述符,主機會請求多次;
    ④主機請求完整的設定描述符
    ⑤根據裝置描述符和設定描述符中宣告的字串描述符索引號,請求所有字串描述符
    ⑥(可選)主機請求限定符描述符,當描述符中宣告了支援更高速的USB協定時,主機復位從機,用新的USB協定重新列舉從機,當獲取描述符失敗時,認為從機不支援此功能,按原協定重新列舉並跳過此步驟;
    ⑦根據設定描述符中宣告的集合長度,請求設定集合。其中設定集合包括設定描述符、介面描述符、端點描述符以及特殊類描述符。當從機包含多個設定描述符集合時,會多次請求。
    ⑧主機請求選擇設定(Set Configuration);
    ⑨主機選擇介面,請求介面空閒狀態(Set Idle),此時介面生效。根據介面描述符,可能會請求其他的特殊描述符(一般這些描述符是對介面描述符的補充描述)。如果從機包含多個介面,此步驟會重複多次;
    ⑩主機知道USB裝置的型別、通訊方式和工作方式後,採用恰當的對策輪詢USB裝置。在Windows平臺,主機完成列舉後會給從機派發相應的驅動(符合官方支援的裝置標準)或者不派發驅動(找不到對應驅動,需要手動安裝)。

USB常用的偵錯工具和SDK

為了印證上述理論和偵錯開發,以下是我們常用到的USB工具/SDK:
【USB View】
    用於檢視從機描述符集,Windows SDK Debugger工具之一。
【Bus Hound】
    記錄主機與從機之間傳輸的資料(包括列舉)的工具,但它並不統計所有資料,比如部分被NAK回覆的主機請求不會被記錄。
【Libusb】
    使用者在主機端直接存取從機的開源庫。但Windows下使用libusb存取從機時,需確保從機不是複合裝置(windows下libusb沒有存取複合裝置的許可權,且libusb會直接使用複合裝置的第一個功能),還要確保windows也給從機分發了驅動「winusb.sys」,可以使用Zadig工具手動給裝置安裝驅動(當然,從機必須是winusb裝置)。
【MichaelTien8901/STM32WINUSB】
    WINUSB裝置開發,參考這位仁兄把STM32例程中的USB CDC改成WINUSB的做法。
【STM32CubeMx】
    用STM32平臺開發,可以用官方工具直接生成USB HID/CDC的例程。

例程1:WINUSB裝置

    Winusb裝置的實現相對簡單,也很好理解,初學者可以嘗試開發winusb裝置,對usb列舉和bulk傳輸也會有一個比較清晰的印象。
    需要注意的是,本文給出的所有USB包結構均按char型對齊,使用這些結構開發時需注意。
以下是winusb裝置常見的描述符集:

#pragma data_alignment=1    //對齊方式為Byte
const USB_Desc_Device_t stDevWinusb = {
    0x12,       // sizeof(USB_Desc_Device_t)
    0x01,       // descriptor type: device
    0x0200,     // USB Spec 2.0
    0x00,       // no device class
    0x00,       // no device subclass
    0x00,       // no device protocol
    0x40,       // max ep0 packet size: 64B
    0x1234,     // vendor id
    0x5678,     // product id
    0x0001, 	// product release number
    0x01,       // manufacturer string index
    0x02,       // product string index
    0x03, 	    // serial number string index
    1			// configuration numbers
};
typedef struct _USB_Winusb_Configuration_t{
    USB_Desc_Configuration_t stDescConfiguration;
    USB_Desc_Interface_t     stDescInterface;
    USB_Desc_Endpoint_t      stDescEndpointIn;
    USB_Desc_Endpoint_t      stDescEndpointOut;
}USB_Winusb_Configuration_t;
const USB_Winusb_Configuration_t stConfWinusb = {
    // configuration descriptor
    {
        0x09,   // sizeof(USB_Desc_Configuration_t)
        0x02,   // descriptor type: configuration
        0x0020, // sizeof(USB_Winusb_Configuration_t)
        0x01,   // interface numbers
        0x01,   // configuration index
        0x00,   // no configuation string
        0x80,   // no attributes
        0x32    // max power: 50*2 = 100 mA
    },
    // interface descriptor
    {
        0x09,   // sizeof(USB_Desc_Interface_t)
        0x04,   // descriptor type: interface
        0x00,   // index of interface
        0x00,   // no alternate setting
        0x02,   // endpoint numbers: 2
        0xFF,   //Interface Class: Vendor defined
		0x00,	//Interface Subclass: none
		0x00, 	//Interface Protocol: none
    },
    // endpoint descriptor
    {
        0x07,   // sizeof(USB_Desc_Endpoint_t)
        0x05,   // descriptor type: endpoint
        0x81,   // endpoint in 1
        0x02,   // transfer type: bulk
        0x40,   // max packet size: 64B
        0x00,   // useless in bulk
    },
    // endpoint descriptor
    {
        0x07,   // sizeof(USB_Desc_Endpoint_t)
        0x05,   // descriptor type: endpoint
        0x01,   // endpoint out 1
        0x02,   // transfer type: bulk
        0x40,   // max packet size: 64B
        0x00,   // useless in bulk
    }
}
//當主機請求index為 manufacturer string index(0x01)的字串時
USB_Desc_String_t stVendorStr = {
    0x1A,       // 1 + 1 + sizeof(L"SampleVendor") - 2
    0x03,       // descriptor type: string
    L"SampleVendor"
}
//當主機請求index為 product string index(0x02)的字串時
USB_Desc_String_t stProductStr = {
    0x1C,       // 1 + 1 + sizeof(L"SampleProduct") - 2
    0x03,       // descriptor type: string
    L"SampleProduct"
}
//當主機請求index為 serial number string index(0x03)的字串時
USB_Desc_String_t stSerialStr = {
    0x14,       // 1 + 1 + sizeof(L"W20201022") - 2
    0x03,       // descriptor type: string
    L"W20201022"
}

    上述描述符集合,就是一個winusb裝置的簡單實現。但有了這些資料,還要將它們按主機列舉的規則來傳送,所以我們還要實現它們的通訊部分。
    一般在各個MCU平臺都會實現USB最底層的部分:SOF包同步、接收並處理令牌包、接收並解析請求、設定裝置地址、讀寫IO中斷等。要實現winusb裝置,我們只需要在這些平臺上完成以下事情:
    1、在USB中斷架構中對不同的描述符請求返回正確的資料;
    2、根據端點描述符構建所有端點對應的端點通道;
    3、實現端點bulk傳輸的讀寫IO;
    最後,把它接入到主機,就可以看到主機能夠識別到這個裝置並顯示出對應的文字資訊(字串描述)。如果你想拋棄MCU的USB架構從零開始實現,可以參考圈圈所著的 《圈圈教你玩USB》
    當然,作為一個標準的winusb裝置,上述功能還不能算是完整的。在Windows平臺,所有的USB裝置都需要安裝驅動(又一個龐大的知識體系)後才能使用,只不過一些知名的USB裝置是支援免驅的(實際上是Windows為USB裝置安裝了預設的驅動)。Microsoft規定:想要Windows為winusb裝置自動派發winusb.sys(即免驅功能),裝置應當提供OS描述符

例程2:HID鍵盤裝置

    本例程的目標是實現一個鍵盤裝置,它屬於HID(Human Interface Device)類別,即可以與人互動的裝置。常見的鍵盤裝置主要包含三個功能:
    - 輸入按鍵資訊(ESC/Win/Ctrl/A/B等);
    - (可選)主機輸出按鍵狀態(Numlock/Capslock/Scroll等);
    - (可選)輸入多媒體控制(快進/快退/暫停等);
    那麼,我們就分別需要3個端點來對應上述功能:輸入端點1對應輸入按鍵、輸出端點1對應按鍵狀態、輸入端點2對應多媒體控制。然而,在USB HID裝置中,多媒體控制和輸入按鍵是可以通過唯一的報告標識號(Report ID)來區分的,所以輸入端點只要一個就可以了(只要資料前面使用Report ID)。當然,第二、三個功能即使不支援也是可以的,那麼這樣一個鍵盤裝置就只需要一個端點。

以下是鍵盤裝置的描述符集合:

#pragma data_alignment=1    //對齊方式為Byte
const USB_Desc_Device_t stDevKeyboard = {
    0x12,       // sizeof(USB_Desc_Device_t)
    0x01,       // descriptor type: device
    0x0200,     // USB Spec 2.0
    0x00,       // no device class
    0x00,       // no device subclass
    0x00,       // no device protocol
    0x40,       // max ep0 packet size: 64B
    0x1234,     // vendor id
    0x5679,     // product id
    0xABCD, 	// product release number
    0x01,       // manufacturer string index
    0x02,       // product string index
    0x03,       // serial number string index
    1           // configuration numbers
};
typedef struct _USB_Keyboard_Configuration_t{
    USB_Desc_Configuration_t stDescConfiguration;
    USB_Desc_Interface_t     stDescInterface;
    USB_Desc_HID_t           stDescHid;
    USB_Desc_Endpoint_t      stDescEndpointIn;
    USB_Desc_Endpoint_t      stDescEndpointOut;
}USB_Keyboard_Configuration_t;
USB_Keyboard_Configuration_t stConfKeyboard = {
    // configuration descriptor
    {
        0x09,   // sizeof(USB_Desc_Configuration_t)
        0x02,   // descriptor type: configuration
        0x003B, // sizeof(USB_Winusb_Configuration_t)
        0x01,   // interface numbers
        0x01,   // configuration index
        0x00,   // no configuation string
        0x80,   // no attributes
        0x32    // max power: 50*2 = 100 mA
    },
    // interface descriptor
    {
        0x09,   // sizeof(USB_Desc_Interface_t)
        0x04,   // descriptor type: interface
        0x00,   // index of interface
        0x00,   // no alternate setting
        0x02,   // endpoint numbers: 2
        0x03,   // Interface Class: HID
        0x01,	// Interface Subclass: Boot Supported
        0x00, 	// Interface Protocol: none
    },
    // hid descriptor
    {
        0x09,   // sizeof(USB_Desc_Interface_t)
        0x21,   // descriptor type: HID
        0x111,  // Hid Spec Version 1.1.1
        0x21,   // Country Code: US
        0x01,   // Descriptor Numbers
        0x22,	// Descriptor Type: Report
        0xXXXX, // Descriptor Length: sizeof(bReportKeyboard)
    },
    // endpoint descriptor
    {
        0x07,   // sizeof(USB_Desc_Endpoint_t)
        0x05,   // descriptor type: endpoint
        0x81,   // endpoint in 1
        0x03,   // transfer type: interrupt
        0x10,   // max packet size: 16B
        0x0A,   // polling interval: 10ms
    },
    // endpoint descriptor
    {
        0x07,   // sizeof(USB_Desc_Endpoint_t)
        0x05,   // descriptor type: endpoint
        0x01,   // endpoint out 1
        0x03,   // transfer type: interrupt
        0x08,   // max packet size: 8B
        0x0A,   // polling interval: 10ms
    }
};
//當主機請求index為 manufacturer string index(0x01)的字串時
USB_Desc_String_t stVendorStr = {
    0x14,       // 1 + 1 + sizeof(L"SampleHid") - 2
    0x03,       // descriptor type: string
    L"SampleHid"
}
//當主機請求index為 product string index(0x02)的字串時
USB_Desc_String_t stProductStr = {
    0x1E,       // 1 + 1 + sizeof(L"SampleKeyboard") - 2
    0x03,       // descriptor type: string
    L"SampleKeyboard"
}
//當主機請求index為 serial number string index(0x03)的字串時
USB_Desc_String_t stSerialStr = {
    0x14,       // 1 + 1 + sizeof(L"K20201022") - 2
    0x03,       // descriptor type: string
    L"K20201022"
}

    而HID描述符中宣告的報告描述符(Report Descriptor)是什麼呢?
    上文提到,報告標識號(Report ID)可以將鍵盤輸入的按鍵資訊、多媒體控制區分開來。這個Report ID就是在報告描述符中定義的。而Report ID本身,以及按鍵、多媒體控制、按鍵狀態等資料的輸入/輸出,統稱為報告(Report)。可以說,HID裝置所有功能的具體內容、格式、作用,都由報告描述符給出詳細、徹底的定義,描述成一個個實際的報告
    這裡先給出鍵盤的報告描述符:

#define SUPPORT_KEYBOARD_SWITCH //支援獲取主機按鍵狀態
#define SUPPORT_MEDIA_CONTROL   //支援多媒體控制

const uint8_t bReportKeyboard[] = {
    0x05, 0x01,   // USAGE_PAGE(Generic Desktop)
    0x09, 0x06,   // USAGE(Keyboard)
    0xA1, 0x01,   // COLLECTION(Application)
    0x05, 0x07,   // USAGE(Keypad)
#ifndef SUPPORT_KEYBOARD_SWITCH //如果只有一個輸入報告可以忽略Report ID欄位
    0x85, 0x01,   // REPORT_ID(0x01)
#endif
    0x19, 0xE0,   // USAGE_MINIMUM(Left Control)
    0x29, 0xE7,   // USAGE_MAXIMUM(Right GUI)
    0x15, 0x00,   // LOGICAL_MINIMUM(0)
    0x25, 0x01,   // LOGICAL_MAXIMUM(1)
    0x95, 0x08,   // REPORT_COUNT(8)
    0x75, 0x01,   // REPORT_SIZE(1)
    0x81, 0x02,   // INPUT(Data, Var, Abs)
    0x95, 0x01,   // REPORT_COUNT(1)
    0x75, 0x08,   // REPORT_SIZE(8)
    0x81, 0x03,   // INPUT(Const, Var, Abs)
    0x05, 0x07,   // USAGE(Keypad)
    0x19, 0x00,   // USAGE_MINIMUM(0)
    0x29, 0x68,   // USAGE_MAXIMUM(104)
    0x15, 0x00,   // LOGICAL_MINIMUM(0)
    0x25, 0x68,   // LOGICAL_MAXIMUM(104)
    0x95, 0x06,   // REPORT_COUNT(6)
    0x75, 0x08,   // REPORT_SIZE(8)
    0x81, 0x00,   // INPUT(Data, Array, Abs)
#ifdef SUPPORT_KEYBOARD_SWITCH
    0x05, 0x08,   // USAGE(LEDs)
    0x19, 0x01,   // USAGEMinimum (NumLock)
    0x29, 0x05,   // USAGEMaximum (Kana) 
    0x95, 0x05,   // Report Count (5)
    0x75, 0x01,   // Report Size Bit(s) (1)
    0x91, 0x02,   // Output (Data, Var, Abs)
    0x95, 0x01,   // Report Count (1)
    0x75, 0x03,   // Report Size Bit(s) (3)
    0x91, 0x01    // Output(Const, Array, Abs)
#endif
    0xC0         // End Collection

#ifdef SUPPORT_MEDIA_CONTROL
    ,
    0x05, 0x0C,   // USAGE_PAGE(Consumer)
    0x09, 0x01,   // USAGE(Consumer Control)
    0xA1, 0x01,   // COLLECTION(Application)
    0x85, 0x02,   // REPORT_ID(Media Control)
    0x09, 0xB5,   // USAGE(Scan Next Track)
    0x09, 0xB6,   // USAGE(Scan Previous Track)
    0x09, 0xB7,   // USAGE(Stop)
    0x09, 0xCD,   // USAGE(Play/Pause)
    0x09, 0xE2,   // USAGE(Mute)
    0x09, 0xE9,   // USAGE(Volume Up)
    0x09, 0xEA,   // USAGE(Volume Down)
    0x15, 0x00,   // LOGICAL_MINIMUM(0)
    0x25, 0x01,   // LOGICAL_MAXIMUM(1)
    0x75, 0x01,   // REPORT_SIZE(1)
    0x95, 0x07,   // REPORT_COUNT(7)
    0x81, 0x02,   // INPUT(Data, Var, Abs)
    0x95, 0x01,   // REPORT_COUNT(1)
    0x81, 0x03    // INPUT(Cnst, Var, Abs)
    0xC0          // END_COLLECTION
#endif
};

    可以看到,報告描述符的長度不是固定的,它隨著功能的變化而變化。換句話說,報告描述符詳細規定了報告的所有細節。而組成報告描述符的單位,就是短條目(Short Item)
長條目(Long Item):既然有短條目,當然有長條目。不過長條目當前只是預留的,為了避免未來短條目不夠用。
短條目(Short Item):參考《HID Spec 1.1.1》 Chapter 5.2。
    短條目是標準的TLV結構,只不過T和L在同一位元組(T佔6bit、L佔2bit)。不過,當Length=11b時,短條目的Data欄位長度是4B而非3B。

Byte01,2,3,4
欄位Tag+LengthData(0B~4B)
bit7,6,5,43,21,0
PartsbTagbTypebSize

    對於上文的鍵盤報告描述符,每行都是一個短條目,它們的意義需要在《HID Usage Tables 1.2》中查表。
    因此報告描述符就像是一本翻譯指南:查字典、造句。
    首先,「0x05, 0x01」查詢短條目(《HID Spec 1.1.1》Chapter 5.2)的Tag定義可知需要查詢用途頁0x01(《HID Usage Tables 1.2》),也就是說,讓我們把「字典」翻到頁面:Generic Desktop(通用桌面);
    接下來,「0x09, 0x06」可知需要在Generic Desktop用途頁下查詢用途0x06:Keyboard(鍵盤);
    同理,「0xA1, 0x01」可知開始構建一個App Collection(應用集合),這個集合的用途就是鍵盤;
    … …(大家可以試著自己解析一下,再往下看)

    通過一個個短條目,就構建了一個完整的報告描述符。而上文的報告描述符,其實就是宣告了三個報告
    第一個報告是Report ID為0x01的輸入報告,長度為9B,作用:從機告訴主機按了XX鍵。比如按了「Ctrl+Alt+W+D」,傳送的報告為:0x01, 0x05, 0x00, 0x07, 0x1A, 0x00, 0x00, 0x00, 0x00。其中0x1A為「W」的鍵值,0x07為「D」的鍵值。

Byte0123~9
欄位0x01Sepecial KeyReservedNormal Key(0~6B)

    Special Key:

bit01234567
欄位LCtrlLShiftLAltLWinRCtrlRShiftRAltRWin

    第二個報告是沒有Report ID的輸出報告(因為輸出型別的報告只有一個,可以省略ID欄位),長度為1B,作用:主機告訴從機當前按鍵狀態:numlock、capslock等。其結構為:低5bit分別對應一個按鍵的狀態,高3bit為常數,為了對5bit資料進行對齊。
    第三個報告是Report ID為0x02的輸入報告,長度為2B,作用:從機告訴主機暫停/繼續播放、快進/快退、音量+/-、靜音。其結構為:第一個位元組為Report ID 0x02,第二個位元組為7bit控制位+1bit位元組對齊。
    最後,上述報告描述符(Report Descriptor)構建完畢後,需要在主機的對應介面請求中返回給主機,和介面、類、端點描述符不同,它是可以單獨獲取的。在開發過程中,也有人習慣稱之為:Report Map,它和Report Descriptor是同一個東西。

參考檔案

USB.org:USB規範的官方組織。
圈圈教你玩USB(第二版):USB原理介紹非常清晰,剛學USB的人都愛它。
USB 2.0 Spec:USB協定的官方標準。
HID Usage Tables 1.2:查詢HID裝置報告描述符的條目(Item)程式碼。
HID Spec 1.11:HID裝置的定義。
CDC120-20101103-track:CDC裝置的定義。