USB for Software Developers: An introduction to writing userspace USB drivers | 海外技术热榜
原文链接
🔗 USB for Software Developers: An introduction to writing userspace USB drivers
翻译说明
本文翻译自Hacker News最新热门技术文章,内容仅供学习参考,版权归原作者所有。
完整翻译内容
[标志]
帖子项目
搜索
准确
电子稳定控制系统
概述
- 01简介
- 02USB设备
- 03手动枚举设备
- 04基本信息
- 05类别和驾驶员信息
- 06使用libusb枚举设备
- 07与设备对话
- 08请求我们的第一个数据
- 09请求描述符
- 10个端点
- 11种控制传输类型
- 12批量传输类型
- 13种中断传输类型
- 14等时传输类型
- 15个输入/输出端点
- 16Fastboot,终于
- 17最后的话
2026 年 4 月 7 日
18分钟
3556字
加载视图
面向软件开发人员的 USB
为不需要了解线路上发生的情况的人们提供的 USB 基本介绍
USBC++
[]
简介h1
假设您收到一个 USB 设备并被要求为其编写一个驱动程序。乍一看似乎是一项艰巨的任务,对吧?编写驱动程序意味着你必须编写内核代码,而编写内核代码是困难的、低水平的、难以调试等等。
但这实际上都不是真的。为 USB 设备编写驱动程序实际上并不比编写使用 Sockets 的应用程序困难多少。
这篇文章旨在为那些可能还没有过多使用硬件而只是想使用该技术的人们提供有关使用 USB 的高级介绍。有一些令人惊叹的资源,例如 USB in a NutShell,其中详细介绍了 USB 的精确工作原理(如果您需要更多信息,请查看它们),但是对于以前从未使用过 USB 并且没有一定硬件背景的人来说,它们并不是真正容易理解的。您无需成为嵌入式系统工程师即可使用 USB,就像您无需成为网络专家即可使用套接字和互联网一样。
USB 设备h1
我们将在引导加载程序模式下使用 Android 手机。这样做的原因是
- 这是一个您可以轻松掌握的设备
- 它使用的协议有详细记录并且非常简单
- 它的驱动程序通常不会预安装在您的系统上,因此操作系统不会干扰我们的实验
对于每个设备,将手机进入引导加载程序模式的方式都不同,但通常需要在手机启动时按住按钮组合。就我而言,它是在打开手机电源时按住音量调低按钮
通过handh1枚举设备
枚举是指主机向设备询问有关自身信息的过程。当您插入设备时,这种情况会自动发生,并且操作系统通常决定为设备加载哪个驱动程序。对于大多数标准设备,操作系统将查看 USB 设备类并加载支持该类的驱动程序。对于特定于供应商的设备,您通常会安装制造商制作的驱动程序,该驱动程序将查看 VID(供应商 ID)和 PID(产品 ID)来检测是否应该处理该设备。
基本信息h2
即使没有驱动程序,将手机插入计算机仍会使其被识别为 USB 设备。这是因为 USB 规范定义了设备向主机识别自身身份的标准方式,稍后会详细介绍其具体工作原理。
在 Linux 上,我们可以使用方便的 lsusb 工具来查看设备将自身标识为:
LSSB
$ lsusb...总线 008 设备 014:ID 18d1:4ee0 Google Inc. Nexus/Pixel 设备(快速启动)...
总线和设备只是设备所插入的物理 USB 端口的标识符。它们很可能在您的系统上有所不同,因为它们取决于您将设备插入的端口。 ID 是这里最有趣的部分。第一部分 18d1 是供应商 ID (VID),第二部分 4ee0 是产品 ID (PID)。这些是设备发送到主机以识别自身的标识符。 VID 由 USB-IF 分配给支付给他们很多钱的公司(在本例中为 Google),而 PID 由该公司分配给特定产品(在本例中为 Nexus/Pixel Bootloader)。
类别和驾驶员信息化h2
使用 lsusb -t 命令我们还可以查看设备的 USB 类以及当前正在处理它的驱动程序:
LSSB
$ lsusb -t.../: 总线 008.端口 001: Dev 001, Class=root_hub, Driver=xhci_hcd/1p, 480M |__ 端口 001: Dev 002, 如果 0, Class=Hub, Driver=hub/4p, 480M |__ 端口 003: Dev 003, 如果 0, Class=Hub, Driver=hub/4p, 480M |__ 端口 002:Dev 014,如果 0,类=供应商特定类,驱动程序=[无],480M...
这显示了连接到系统的 USB 设备的整个树。树的这一部分中最底部的一个是我们的设备(如上一个命令中报告的总线 008、设备 014)。 Class=Vendor Specific Class 部分指定设备不使用任何标准 USB 类(例如 HID、大容量存储或音频),而是使用制造商定义的自定义协议。 Driver=[none] 部分只是告诉我们操作系统没有加载设备的驱动程序,这对我们有好处,因为我们想编写自己的驱动程序。
Windows 注意事项
如果您使用的是 Windows,则不会有 lsusb,但您仍然可以使用设备管理器或 USB 设备树查看器等工具找到大部分信息
我们还将追踪 VID 和 PID,因为它们是我们拥有的唯一真实的识别信息。设备类在这里并不是很有用,因为它只是供应商特定类,任何制造商都可以将其用于任何设备。不过,我们可以编写一个执行相同操作的用户空间应用程序,而不是在内核中执行所有这些操作。这更容易编写和调试(并且可以说是驱动程序居住的正确位置,但这是另一个主题)。为此,我们可以使用 libusb 库,它提供了一个简单的 API,用于从用户空间与 USB 设备进行通信。它通过提供一个可以为任何设备加载的通用驱动程序来实现这一点,然后为用户空间应用程序提供一种声明该设备并直接与其通信的方法。
使用 libusbh1 枚举设备
我们手动完成的同样的事情也可以通过软件完成。以下程序初始化 libusb,为与 18d1:4ee0 VendorId / ProductId 组合匹配的设备注册热插拔事件处理程序,然后等待该设备插入主机。
主程序
#include <打印>#include <libusb-1.0/libusb.h>
auto hotplug_callback( libusb_context *ctx, libusb_device *device, libusb_hotplug_event event, void *user_data) -> int { std::println("设备已插入!\n");
返回0;}
auto main() -> int { // 创建一个上下文,以便我们可以与 libusb 驱动程序交互 libusb_context *context = nullptr; libusb_init(&上下文);
// 注册一个热插拔事件处理程序来等待我们的设备插入 libusb_hotplug_callback_handle hotplug_callback_handle; libusb_hotplug_register_callback( context, LIBUSB_HOTPLUG_EVENT_DEVICE_ARRIVED, // 设备插入事件 LIBUSB_HOTPLUG_ENUMERATE, // 已插入设备的触发事件 0x18d1, 0x4ee0, // 我们之前找到的 VID 和 PID LIBUSB_HOTPLUG_MATCH_ANY, // 匹配任何 USB 类 hotplug_callback, nullptr, // 调用的回调 &hotplug_callback_handle );
// 处理 libusb 事件 while (true) { if (libusb_handle_events(context) < 0) break; }
// 清理 libusb_hotplug_deregister_callback(context, hotplug_callback_handle); libusb_exit(上下文);}
如果编译并运行它,插入设备应该会产生以下输出:
libusb_枚举
$ ./libusb_enumerate
设备已插入!
恭喜!您现在拥有一个可以检测您的设备的程序,而无需触及任何内核代码。
Windows 注意事项
在 Linux 上,所有这些通常都可以正常工作。如果出于任何原因加载了驱动程序,您可以使用 libusb_detach_kernel_driver() 强制分离它。
在 Windows 上,情况可能看起来有所不同。如果幸运的话,该设备装有 Microsoft 操作系统告诉 Windows 为您的设备加载 Winusb.sys 驱动程序的描述符。在这种情况下,libusb 可以直接与它对话。但是,如果没有加载驱动程序(设备在设备管理器中显示,并带有一个小 ⚠️ 图标),您可能需要使用 Zadig 将设备的驱动程序强制替换为 Winusb.sys 或其他支持的驱动程序。更多信息可以在这里找到:libusb Wiki
与设备交谈h1
下一步,从设备获取任何答案。目前最简单的方法是使用标准化的控制端点。该端点始终位于 ID 0x00 上并具有标准化协议。该端点也是操作系统之前用来识别设备并获取其 VID:PID 的端点。
我们有点超前了,因为我们甚至不知道终点是什么,但我保证,过一会儿一切都会有意义的。现在,只需将端点视为网络上设备的端口,该端口具有我们向其发送数据的特定编号。
请求我们的第一个 datah2
我们使用此端点的方式是使用另一个 libusb 函数,该函数专门用于向该端点发送请求。因此,我们可以使用以下代码扩展我们的热插拔事件处理程序:
主程序
// 打开设备,以便我们可以与其通信libusb_device_handle *handle = nullptr;libusb_open(device, &handle);
std::vector<std::uint8_t> 数据(0xFF);
// 执行控制传输 const auto result = libusb_control_transfer( handle, uint8_t(LIBUSB_ENDPOINT_IN) | // 向设备请求数据... LIBUSB_RECIPIENT_DEVICE | // 关于整个设备... LIBUSB_REQUEST_TYPE_STANDARD, // 使用标准请求。 LIBUSB_REQUEST_GET_STATUS, // 发送 GET_STATUS 请求 0x00, // wValue value of 0x00 0x00, // wIndex value of 0x00 data.data(), data.size(), // 将数据读入的缓冲区 1000 // 1000ms timeout);
// 如果没有错误,则打印设备返回的数据if (result >= 0) print_bytes(std::span(data).subspan(0, result));
// 再次关闭设备libusb_close(handle);
现在,此代码将在设备插入后立即向设备发送 GET_STATUS 请求,并打印出发送回控制台的数据。
libusb_枚举
$ ./libusb_enumerateAddr 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F
0000:01 00
这些字节来自设备本身!使用规范解码它们告诉我们,第一个字节告诉我们设备是否是自供电的(1 表示它是有意义的,设备有电池),第二个字节表示它不支持远程唤醒(意味着它无法唤醒主机)。
还有一些更标准化的请求类型(有些设备甚至为了简单的事情而添加自己的请求类型!),但我们(以及操作系统)感兴趣的主要请求类型是 GET_DESCRIPTOR 请求。
请求描述符h2
描述符是二进制结构,通常硬编码到 USB 设备的固件中。它们准确地告诉主机该设备是什么、它有什么功能以及它希望操作系统加载什么驱动程序。因此,当您插入设备时,主机只需向 ID 0x00 处的标准化控制端点发送多个 GET_DESCRIPTOR 请求即可获取一个结构体,该结构体为它提供了枚举所需的所有信息。最酷的是,我们也能做到!
我们现在发送 GET_DESCRIPTOR 请求,而不是 GET_STATUS 请求:
主程序
const auto result = libusb_control_transfer(handle, uint8_t(LIBUSB_ENDPOINT_IN) | // 向设备请求数据... LIBUSB_RECIPIENT_DEVICE | // 关于整个设备... LIBUSB_REQUEST_TYPE_STANDARD, // 使用标准请求。 LIBUSB_REQUEST_GET_DESCRIPTOR, // 发送 GET_DESCRIPTOR 请求 (LIBUSB_DT_DEVICE << 8) | 0, // 请求第0个设备描述符 0x00, // 语言ID,可以忽略这里 data.data(), data.size(), // 将数据读入的缓冲区 1000 // 1000ms timeout);
现在返回以下数据:
libusb_枚举
$ ./libusb_enumerateAddr 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F
0000:12 01 00 02 00 00 00 40 D1 18 E0 4E 99 99 01 020010:00 01
现在要解码此数据,我们需要查看第 9.6.1 章“设备”中的 USB 规范。在那里我们可以发现格式如下:
usb.hexpat
struct DeviceDescriptor { u8 bLength; u8 b描述符类型; u16 bcdUSB; u8 bDeviceClass; u8 bDeviceSubClass; u8 bDeviceProtocol; u8 bMaxPacketSize0; u16 idVendor; u16 id产品; u8 i制造商; u8 i产品; u8 i序列号; u8 bNumConfigurations;};
将数据放入 ImHex 并为其模式语言提供此结构定义会产生以下结果:
[USB设备描述符,使用ImHex解码]
我们就有了! idVendor 和 idProduct 对应于我们之前使用 lsusb 找到的值。
不过,不仅仅是设备描述符。还有配置、接口、端点、字符串和其他一些描述符。这些都可以在控制端点上使用相同的 GET_DESCRIPTOR 请求来读取。我们仍然可以手动完成这一切,但幸运的是,lsusb 已经有一个选项可以为我们做到这一点!
LSSB
$ lsusb -d 18d1:4ee0 -v
总线 001 设备 012:ID 18d1:4ee0 Google Inc. Nexus/Pixel 设备 (fastboot)协商速度:高速 (480Mbps)设备描述符:bLength 18 bDescriptorType 1 bcdUSB 2.00 bDeviceClass 0 [未知] bDeviceSubClass 0 [未知] bDeviceProtocol 0 bMaxPacketSize0 64 idVendor 0x18d1 Google Inc. idProduct 0x4ee0 Nexus/Pixel 设备(快速启动) bcdDevice 99.99 iManufacturer 1 Synaptics iProduct 2 USB 下载小工具 iSerial 0 bNumConfigurations 151 折叠行 配置描述符:bLength 9 bDescriptorType 2 wTotalLength 0x0020 bNumInterfaces 1 bConfigurationValue 1 iConfiguration 2 USB 下载小工具 bmAttributes 0xc0 自供电 MaxPower 2mA 接口描述符:bLength 9 bDescriptorType 4 bInterfaceNumber 0 bAlternateSetting 0 bNumEndpoints 2 bInterfaceClass 255 供应商特定类 bInterfaceSubClass 66 [未知] bInterfaceProtocol 3 iInterface 3 Android Fastboot 端点描述符:bLength 7 bDescriptorType 5 bEndpointAddress 0x81 EP 1 IN bmAttributes 2 传输类型批量同步类型 无 使用类型数据 wMaxPacketSize 0x0200 1x 512 字节 bInterval 0 端点描述符:bLength 7 bDescriptorType 5 bEndpointAddress 0x02 EP 2 OUT bmAttributes 2 传输类型批量同步类型 无 使用类型数据wMaxPacketSize 0x0200 1x 512 字节 bInterval 0 设备限定符(对于其他设备速度):bLength 10 bDescriptorType 6 bcdUSB 2.00 bDeviceClass 0 [未知] bDeviceSubClass 0 [未知] bDeviceProtocol 0 bMaxPacketSize0 64 bNumConfigurations 1设备状态:0x0001 自供电
此输出向我们展示了设备拥有的更多描述符。具体来说,它有一个配置描述符,其中包含 Android Fastboot 接口的接口描述符。该界面现在包含 twØ 端点。这是设备向主机告知除控制端点之外的所有其他端点的地方,这些端点将是我们在下一步中实际最终将数据发送到设备的 Fastboot 接口时使用的端点!
端点sh1
不过,让我们先多谈谈端点。我们已经了解了地址 0x00 上的控制端点。查看上面的描述符,该控制描述符并不存在。相反,还有另外两个不同类型的。
控制传输类型h2
每个设备只有一个,并且始终固定在端点地址 0x00 上。它用于初始配置和请求有关设备的信息。
控制端点的主要目的是解决先有鸡还是先有蛋的问题,即在不知道设备端点的情况下无法与设备通信,但要知道设备的端点,您需要与其通信。这也是它甚至没有出现在描述符中的原因。它不是任何接口的一部分,而是设备本身的一部分。我们通过规范就知道它的存在,而无需做广告。
它用于设置简单的配置值或请求少量数据。 libusb 中的函数甚至不允许您设置端点地址来发出控制请求,因为只有一个控制端点,并且它始终位于地址 0x00
批量传输类型h2
当您想要传输大量数据时,会使用批量端点。当您有大量非时间敏感数据只想通过网络发送时,可以使用它们。 这用于大容量存储类、CDC-ACM(USB 串行端口)和 RNDIS(USB 以太网)等。
一个细节:通过批量端点发送的数据带宽高,但优先级低。这意味着,批量数据将始终填满剩余带宽。任何中断和同步传输(下面有更多详细信息)都具有更高的优先级,因此如果您通过同一连接同时发送批量和同步数据,则批量传输的带宽将降低,直到同步传输可以在请求的时间范围内传输其数据。
中断传输类型h2
中断端点与批量端点相反。它们允许您以非常低的延迟发送少量数据。例如,键盘和鼠标在 HID 类下使用此传输类型来轮询每秒超过 1000 次的按钮按下操作。如果没有按下任何按钮,传输会立即失败,而不会发回完整的失败消息(仅 NAK),只有当某些内容实际发生变化时,您才会收到所发生情况的描述。
这里重要的事实是,即使这些被称为中断端点,也不会发生中断。未经询问,设备仍然不会与主机通信。主机轮询如此频繁,以至于它的行为就好像中断一样。 libusb 中处理中断传输的函数也进一步抽象了这种行为。您可以启动中断传输,该函数将阻塞,直到设备发回完整响应。
等时传输类型h2
等时端点有些特殊。它们用于处理对时间要求非常严格的大量数据。它们主要用于音频或视频等流媒体接口,其中任何延迟或延迟都会通过口吃或不同步立即被注意到。在 libusb 中,这些是异步工作的。您可以一次设置多个传输,它们将排队,一旦数据到达,您将收到一个事件,以便您可以处理它并排队进一步的请求。 在音频和视频类之外,这种类型通常不经常使用。
输入/输出端点sh2
除了传输类型之外,端点还有方向。请记住,USB 是一个完全面向主从的接口。主机是唯一发出任何请求的人,除非主机发出请求,否则设备永远不会应答。这意味着,设备实际上无法直接发送任何数据给主机。相反,主机需要请求设备发送数据。
这就是方向的目的。
- IN 端点用于主机想要接收一些数据的情况。它在 IN 端点上发出请求并等待设备响应数据。
- OUT 端点用于主机想要传输一些数据时。它在 OUT 端点上发出请求,然后立即传输它想要发送的数据。在这种情况下,设备仅确认 (ACK) 已收到数据,但不会发回任何其他数据。
我记忆指示的方式是使用主从类比。大师非常以自我为中心,总是从自己的角度来看待一切事情。
- IN:我想获取数据
- OUT:我要发送数据
与传输类型相反,方向是在端点地址中编码的。如果最高位 (MSB) 设置为 1,则为 IN 端点,如果设置为 0,则为 OUT 端点。 (如果您热衷于硬件,您可能会从 I2C 接口中认识到相同的概念。)
这意味着两件事:
- 您一次最多可以有 2⁷ − 1 = 12727−1=127 个可用的自定义端点
- 2⁷27 因为我们有 7 位可用于地址
- − 1−1 因为我们总是有位于固定地址 0x00 上的控制端点。
- 端点完全是单向的。您使用端点来请求数据或传输数据,它不能同时执行这两项操作
- 这也是我们的 Fastboot 接口有两个批量端点的原因:一个专门用于监听主机发送的请求,另一个用于响应这些相同的请求
快速启动,终于h1
现在我们已经了解了有关 USB 的所有信息,接下来让我们研究一下 Fastboot 协议。最好的文档是 u-boot 源代码及其文档。
根据文档,该协议确实非常简单。主机发送字符串命令,设备响应 4 个字符的状态代码,后跟一些数据。
摘自文档中的示例
Host: "getvar:version" 请求版本变量
客户端:“OKAY0.4”返回版本“0.4”
主机:“getvar:不存在”请求一些未定义的变量
客户端:“OKAY”返回值“”
让我们更新我们的代码来做到这一点:
主程序
// 打开设备,以便我们可以与其通信libusb_device_handle *handle = nullptr;libusb_open(device, &handle);
// 声明接口让 libusb 知道是哪个接口 // 我们正在向libusb_claim_interface(handle, 0);发送数据
// 为我们的请求和响应设置一个 64 字节缓冲区 // 文档指定 64 字节用于全速和// 512 字节用于高速。由于这是一个全速设备,//我们使用 64 个字节。std::vector<uint8_t> bytes(64);
// 将命令“getvar:version”复制到缓冲区的开头std::ranges::copy(“getvar:version”, bytes.begin());
// 在 OUT 端点上批量传输该数据 0x02int num_bytes_transferred = 0;libusb_bulk_transfer( handle, // 设备句柄 LIBUSB_ENDPOINT_OUT | 0x02, // 端点 OUT 0x02 bytes.data(), bytes.size(), // 要发送的数据 &num_bytes_transferred, // 发送的字节数 1000 // 1000ms超时);
// 打印传输的数据 datastd::println("Response: {}", std::string_view( reinterpret_cast<const char *>(bytes.data()), num_bytes_transferred ));
// 清除 bufferstd::ranges::fill(bytes, 0x00);num_bytes_transferred = 0;
// 在 IN 端点上进行批量传输 0x01libusb_bulk_transfer( handle, // 设备句柄 LIBUSB_ENDPOINT_IN | 0x01, // 端点 IN 0x81 bytes.data(), bytes.size(), // 接收到的缓冲区 &num_bytes_transferred, // 接收到的字节数 1000 // 1000ms 超时);
// 打印返回的字符d::println("响应:{}",std::string_view(reinterpret_cast<const char *>(bytes.data()),num_bytes_transferred ));
// 再次释放接口libusb_release_interface(handle, 0);
// 关闭设备句柄libusb_close(handle);
现在插入设备,将以下消息打印到终端:
libusb_枚举
$ ./libusb_enumerateRequest: getvar:versionResponse: OKAY0.4
这似乎与文档相符! 前 4 个字节是 OKAY,表明请求已成功执行。 之后的其余数据是 0.4,对应于文档中实现的 Fastboot 版本:v0.4
最后一句话sh1
就是这样!您从头开始成功制作了第一个 USB 驱动程序,而无需接触内核。
所有这些相同的原则适用于所有 USB 驱动程序。底层协议可能比 fastboot 协议复杂得多(之前我对 MTP 协议的残暴感到抓狂),但它周围的一切都保持不变。并不比基于套接字的 TCP 复杂多少,不是吗? :)
添加一名作者
帖子:软件开发人员的 USB
许可证:CC BY-NC-SA 4.0
下一篇文章
热敏打印机BLE协议逆向工程
目录
- 01 简介
- 02 USB 设备
- 03 手动枚举设备
- 04 基本信息
- 05 类别和驾驶员信息
- 06 使用libusb枚举设备
- 07 与设备对话
- 08 请求我们的第一个数据
- 09 请求描述符
- 10 个端点
- 11种控制传输类型
- 12种批量传输类型
- 13种中断传输类型
- 14 种同步传输类型
- 15 个输入/输出端点
- 16 Fastboot,终于
- 17 最后的话
返回帖子
评论
$ ~/ 帖子项目标签
由 WeWolv 使用 ♥️ 制作
翻译声明:本文由AI自动翻译,如有不准确之处欢迎指正
🙏 如果本文对你有帮助,欢迎打赏支持,你的鼓励是我持续输出优质内容的最大动力! 💴 打赏通道:点击文章末尾「赞赏」按钮即可,每一分支持都是我前进的动力~