为未知 USB 设备编写 Linux 内核驱动程序

大家好!我是大聪明-PLUS

本文将解释如何为一个未公开的 USB 设备生成一个可用的 Linux 内核驱动程序。通过逆向 USB 通信协议,我将演示 USB 内核驱动程序的架构。除了内核驱动程序之外,本文还将介绍一个简单的用户空间工具,用于控制此类设备。虽然我将对这个特定的设备进行一些详细介绍,但请放心,所描述的过程同样适用于其他 USB 设备。

❯ 介绍

我曾经在 eBay 上发现一个奇怪的设备:DreamCheeky USB 信号枪。制造商没有为它提供 Linux 驱动程序,也没有发布 USB 协议规范。只有一个适用于 Windows 的二进制驱动程序可用,有了它,信号枪就成了 Linux 用户名副其实的黑匣子。真是个挑战!让我们让这个可怕的机器在 Linux 下工作。

为了使 USB 编程更容易,有一个可通过libusb从用户空间访问的 USB 接口,libusb 是一个软件 API,它隐藏了与内核的低级交互。要为这个信号枪编写合适的设备驱动程序,我必须依赖这个 API 并忽略所有内核细节。但是,我想专注于内核编程,所以我决定编写一个内核模块,尽管这是一个更加复杂和耗时的过程。

本文的其余部分结构如下。首先,我提供一些相关工作来提供背景信息,然后我对 USB 进行快速概述。之后,我将引导您完成逆向工程过程:我将向您展示如何组装控制信号枪的未知 USB 命令。我将描述包含我推导的控制命令的内核模块的架构。最后,我将向您展示如何使用一个简单的用户空间工具;我们将使用此工具来操作驱动程序。

❯ 相关作品

显然,我不是唯一一个摆弄过这个小玩意儿的人。但我发现的所有方法都不是专门为内核创建自定义 Linux 设备驱动程序。具体来说,有一个 ncurses 应用程序可以控制这把信号枪。另外,请查看这个包含 C++ 实现的存储库。苹果用户可能想尝试这个新西兰项目来控制 USB 信号枪。此外,pymissile库仍然存在,它是一个 Python 实现,支持与其他制造商的信号枪配合使用。作者将信号枪与网络摄像头配对,创建了一个自动的、运动激活的哨兵装置。我将在下文中回顾这些有趣的想法。

❯ ABC USB

通用串行总线 (USB) 允许将各种外围设备连接到主机。该总线旨在将各种较旧、较慢的总线(并行、串行和键盘)合并为一种总线类型,从而实现统一。从拓扑结构上看,它并非总线,而是由多个点对点连接组成的树状结构。USB 主控制器会定期轮询每个设备,查看其是否有数据需要发送。在这种设计下,任何设备都无法发送数据,除非收到明确的请求。由此产生的架构非常适合即插即用 ( PnP ) 应用。

Linux 支持两种主要类型的驱动程序:主机驱动程序和设备驱动程序。让我们抽象出主机驱动程序,仔细看看 USB 设备。如图所示,USB设备由一个或多个 配置 组成,而这些配置又具有一个或多个接口 。这些接口具有零个或多个端点,这些端点是 USB 通信最简单形式的基础。端点始终是单向的,可以从主机连接到设备(OUT 端点),也可以从设备连接到主机(IN 端点)。端点有四种类型,每种类型都以自己的方式传输数据:

  • 控制
  • 对于中断
  • 对于批量传输
  • 等时

控制端点 通常用于异步控制 USB 设备,例如向设备发送命令或检索设备状态信息。每个设备都有一个"零"控制端点,USB 核心使用它来初始化设备。中断端点会定期发送,并在主机向 USB 请求数据时传输小块、固定大小的信息。在这个信号枪示例中,我们不关注连续和同步端点。《Linux 设备驱动程序》一书从程序员的角度提供了关于如何使用这些单元的精彩介绍。以下是来自[源代码]的简短输出,lsusb -v其中包含有关信号枪的详细信息。 此输出的一般结构及其缩进是典型 USB 设备的特征。首先,是制造商和产品 ID,它们唯一地标识特定的 USB 设备。USB 核心使用这些 ID 来确定应将哪个驱动程序连接到给定设备。此外,热插拔脚本允许您决定在特定设备连接到计算机时加载哪个驱动程序。此外,最大功耗(100 mA)可以在配置部分读取。从属接口显然包含一个中断端点(除了控制端点 0),可以通过 访问。由于这是一个中断端点,它会返回设备的状态信息。要处理传入数据,首先需要了解启动器上运行的控制协议。

Bus 005 Device 004: ID 1941:8021
Device Descriptor:
bLength 18
bDescriptorType 1
bcdUSB 1.10
bDeviceClass 0 (Defined at Interface level)
bDeviceSubClass 0
bDeviceProtocol 0
bMaxPacketSize0 8
idVendor 0x1941
idProduct 0x8021
bcdDevice 1.00
iManufacturer 0
iProduct 0
iSerial 0
bNumConfigurations 1
Configuration Descriptor:
bLength 9
bDescriptorType 2
wTotalLength 34
bNumInterfaces 1
bConfigurationValue 1
iConfiguration 0
bmAttributes 0xa0
Remote Wakeup
MaxPower 100mA
Interface Descriptor:
bLength 9
bDescriptorType 4
bInterfaceNumber 0
bAlternateSetting 0
bNumEndpoints 1
bInterfaceClass 3 Human Interface Devices
bInterfaceSubClass 0 No Subclass
bInterfaceProtocol 0 None
iInterface 0
HID Device Descriptor:
bLength 9
bDescriptorType 33
bcdHID 1.00
bCountryCode 0 Not supported
bNumDescriptors 1
bDescriptorType 34 Report
wDescriptorLength 52
Report Descriptors:
** UNAVAILABLE **
Endpoint Descriptor:
bLength 7
bDescriptorType 5
bEndpointAddress 0x81 EP 1 IN
bmAttributes 3
Transfer Type Interrupt
Synch Type None
Usage Type Data
wMaxPacketSize 0x0008 1x 8 bytes
bInterval 10

0x81

❯ 逆向工程 USB 协议

首先,我们将逆向工程(或"窥探")Windows 二进制驱动程序使用的 USB 通信协议。一种可行的方法是将设备封装在 VMware 中,并在主机系统上拦截主机与连接设备之间交换的数据。但是,由于已经存在一些用于分析 USB 流量的工具,因此更简单的解决方案是依赖其中一种。显然,这类工具中最流行的免费应用程序是SnoopyPro。奇怪的是,我手边没有 Windows 电脑,所以我不得不在 VMware 中安装二进制驱动程序和 SnoopyPro。

为了收集所有重要的 USB 数据并拦截设备的所有控制命令,信号枪必须在被观察的情况下执行所有可能的操作:沿其两个轴中的每一个移动以及同时沿两个轴移动、发射以及在其限制轴(轴是触发通知的线,指示该轴无法在该方向上进一步移动)内移动。在分析 SnoopyPro 转储时,很容易检测到发送给信号枪的控制命令。例如,下图显示了一个 8 字节的传输缓冲区。当信号枪向右移动时,缓冲区包含0x00000008。当信号枪向上旋转时,该缓冲区的内容变为0x00000001。显然,从逻辑上很容易推断出信号枪所依赖的控制字节。除非0x00000000向设备发送"停止"命令( ),否则缓冲区包含上一个命令的状态。这意味着,在收到"向下"命令后,信号枪将在其三脚架上降低,直到收到新的命令。如果无法继续移动,电机将继续运转,齿轮将难以忍受地磨损,直至断裂。仔细检查后发现,朝向 IN 的中断端点缓冲区将根据设备的当前位置而变化。每当运动轴到达无法跨越的边界(并且开始发出难以忍受的磨损噪音)时,设备就会检测到这种情况并相应地修改中断缓冲区的内容。因此,内核开发人员可以依靠这些通知来实现边界检查机制;该机制应在启动器"撞墙"时立即发出"停止"命令。

以下是驱动程序源代码的摘录。它提供了可发送到设备的完整控制命令列表。

复制代码
#define ML_STOP     	0x00
#define ML_UP       	0x01
#define ML_DOWN     	0x02
#define ML_LEFT     	0x04
#define ML_RIGHT    	0x08
#define ML_UP_LEFT  	(ML_UP | ML_LEFT)
#define ML_DOWN_LEFT	(ML_DOWN | ML_LEFT)
#define ML_UP_RIGHT 	(ML_UP | ML_RIGHT)
#define ML_DOWN_RIGHT   (ML_DOWN | ML_RIGHT)
#define ML_FIRE         0x10

以下字节出现在中断端点缓冲区(IN)中 - 它们包含在注释中 - 并表明已达到边界。

复制代码
#define ML_MAX_UP   	0x80    	/* 80 00 00 00 00 00 00 00 */
#define ML_MAX_DOWN 	0x40        /* 40 00 00 00 00 00 00 00 */
#define ML_MAX_LEFT 	0x04        /* 00 04 00 00 00 00 00 00 */
#define ML_MAX_RIGHT	0x08        /* 00 08 00 00 00 00 00 00 */

现在我们已经掌握了有关控制的所有必要信息,让我们将其用于开发并深入研究内核编程的领域。

❯ 设备驱动程序

编写内核代码是一门艺术,本文只能触及皮毛。

与许多其他学科一样,机制策略 的分离是每个程序员都必须遵守的基本范例。机制提供功能,而策略规则表达如何使用这些功能。硬件访问通常因环境而异。因此,必须编写与策略无关的 代码:驱动程序应该简单地公开硬件而不施加任何限制。Linux的一个很好的特性是能够将目标代码动态链接到正在运行的内核中。这段目标代码称为内核模块 。Linux 区分了模块可以实现的三种主要设备类型:

  • 象征性装置
  • 块设备
  • 网络设备

字符设备 (char) 与用户进程之间传输字节流。因此,模块会实现系统调用,特别是openclosereadwriteioctl 。字符设备类似于文件,不同之处在于它可以被查找,并且大多数设备按顺序操作。字符设备的示例包括文本控制台 ( /dev/console) 和串行端口 ( /dev/ttyS0)。最简单的硬件设备由字符驱动程序控制。关于块设备和网络接口的讨论超出了这些设备的范围;您应该阅读专门的文献来了解它们。

除了这种分类之外,还有其他根本不同的方法。例如,USB 设备可以实现为 USB 模块,但它们也可以表现为字符设备(例如信号枪)、块设备(例如 USB 闪存驱动器)或网络接口(例如 USB 的以太网接口)。接下来,让我们大致了解一下设计用于与 USB 配合使用的内核模块的结构,然后再讨论有关火箭发射器的具体细节。

复制代码
struct usb_ml {
	
};
 
static struct usb_device_id ml_table [] = {
	{ USB_DEVICE(ML_VENDOR_ID, ML_PRODUCT_ID) },
	{ }
};
 
static int ml_open(struct inode *inode, struct file *file)
{
	
}
static int ml_release(struct inode *inode, struct file *file)
{
	
}
 
static ssize_t ml_write(struct file *file, const char __user *user_buf, size_t
    	count, loff_t *ppos);
{
	
}
 
static struct file_operations ml_fops = {
	.owner =	THIS_MODULE,
	.write =	ml_write,
	.open = 	ml_open,
	.release =  ml_release,
};
 
static int ml_probe(struct usb_interface *interface, const struct usb_device_id
    	*id)
{
	
}
 
static void ml_disconnect(struct usb_interface *interface)
{
	
}
 
static struct usb_driver ml_driver = {
	.name = "missile_launcher",
	.id_table = ml_table,
	.probe = ml_probe,
	.disconnect = ml_disconnect,
};
 
static int __init usb_ml_init(void)
{
	
}
 
static void __exit usb_ml_exit(void)
{
	
}
 
module_init(usb_ml_init);
module_exit(usb_ml_exit);

不算一些全局变量、辅助函数和中断处理程序,这实际上就是整个内核模块!但让我们逐步分解它。内核 USB 驱动程序由一个结构体表示,该结构体包含一些函数回调和标识 USB 驱动程序的变量。当使用insmod struct usb_driver程序加载模块时,将执行该函数,将驱动程序注册到 USB 子系统。当卸载模块时,将调用该函数,从 USB 子系统中注销模块。和 标记表示这些函数仅在初始化期间和退出时调用。模块加载后,将设置探测断开 连接回调。当调用探测函数(在设备插入连接器时调用)时,驱动程序将初始化用于管理 USB 设备的所有本地数据结构。例如,它为结构体分配内存,该结构体包含有关连接设备运行时状态的信息。以下是该函数初始部分的摘录:__init usb_ml_init(void)``__exit usb_ml_exit(void)
__init``__exit``struct usb_ml

复制代码
static int ml_probe(struct usb_interface *interface,
                	const struct usb_device_id *id)
{
	struct usb_device *udev = interface_to_usbdev(interface);
	struct usb_ml *dev = NULL;
	struct usb_host_interface *iface_desc;
	struct usb_endpoint_descriptor *endpoint;
	int i, int_end_size;
	int retval = -ENODEV;
 
	if (! udev)
	{
    	DBG_ERR("udev is NULL");
    	goto exit;
	}
 
	dev = kzalloc(sizeof(struct usb_ml), GFP_KERNEL);
	if (! dev)
	{
    	DBG_ERR("cannot allocate memory for struct usb_ml");
    	retval = -ENOMEM;
    	goto exit;
	}
 
	dev->command = ML_STOP;
 
	init_MUTEX(&dev->sem);
	spin_lock_init(&dev->cmd_spinlock);
 
	dev->udev = udev;
	dev->interface = interface;
	iface_desc = interface->cur_altsetting;
 
	
	for (i = 0; i < iface_desc->desc.bNumEndpoints; ++i)
	{
    	endpoint = &iface_desc->endpoint[i].desc;
 
    	if (((endpoint->bEndpointAddress & USB_ENDPOINT_DIR_MASK) == USB_DIR_IN)
            	&& ((endpoint->bmAttributes & USB_ENDPOINT_XFERTYPE_MASK) ==
                	USB_ENDPOINT_XFER_INT))
        	dev->int_in_endpoint = endpoint;
 
	}
	if (! dev->int_in_endpoint)
	{
    	DBG_ERR("could not find interrupt in endpoint");
    	goto error;
	}
 
	/* ... */
 
	
	retval = usb_register_dev(interface, &ml_class);
 
	/* ... */
}

您可能已经注意到此代码片段中使用了 goto 语句goto。虽然 goto 语句通常不受欢迎,但内核开发人员使用它们goto来集中错误处理,从而消除复杂的逻辑和大量缩进的代码。探测函数分配内存来存储设备的内部结构,初始化信号量和自旋锁,并设置端点信息。稍后,在同一函数中,设备被注册。现在设备已准备就绪,可以使用系统调用从用户空间访问。下面,我们将讨论一个用于访问信号枪的简单用户空间工具。但首先,我将向您展示用于向设备发送数据的通信原语。Linux USB 实现使用 USB请求块

(URB) 作为其"数据载体",并用于与 USB 设备通信。URB 类似于异步发送到端点和从端点发送的数据消息。URB 有四种不同的类型,与 USB 标准定义的端点类型相同:控制、中断、批量和等时。一旦驱动程序分配并初始化了 URB,它就会被传递给 USB 核心,然后 USB 核心会将其转发给设备。如果 URB 成功传递到 USB 核心,则会执行完成处理程序 。然后,USB 核心将控制权交还给设备驱动程序。 由于我们的信号枪使用两个端点(端点 0 和一个中断端点),因此我们必须处理控制 URB 和中断 URB。我们逆向工程的命令本质上是被打包到控制 URB 中,并从那里发送到设备。我们还会持续接收来自周期性触发的中断 URB 的状态信息。例如,要向信号枪发送简单数据,我们使用以下函数:

usb_control_msg

复制代码
memset(&buf, 0, sizeof(buf));
buf[0] = cmd;
 

spin_lock(&dev->cmd_spinlock);
dev->command = cmd;
spin_unlock(&dev->cmd_spinlock);
 
retval = usb_control_msg(dev->udev,
    	usb_sndctrlpipe(dev->udev, 0),
    	ML_CTRL_REQUEST,
    	ML_CTRL_REQEUST_TYPE,
    	ML_CTRL_VALUE,
    	ML_CTRL_INDEX,
    	&buf,
    	sizeof(buf),
    	HZ * 5);
 
if (retval < 0)
{
	DBG_ERR("usb_control_msg failed (%d)", retval);
	goto unlock_exit;
}

该命令cmd被插入到缓冲区中,该缓冲区包含要发送到设备的数据。如果 URB 成功完成,则执行相应的处理程序。它不会执行任何特殊操作,只是通知驱动程序我们已经使用write buf系统调用发出了一个(尚未更正的)命令:

复制代码
static void ml_ctrl_callback(struct urb *urb, struct pt_regs *regs)
{
	struct usb_ml *dev = urb->context;
	dev->correction_required = 0;
}

一旦设备到达无法逾越的限制轴,我们不希望通过发送错误命令或任何命令来损坏启动器硬件。理想情况下,一旦到达这样的轴(意味着启动器无法再沿该方向移动),该方向的移动就应该停止。事实证明,URB 中断完成处理程序是实现这一想法的最佳位置:

复制代码
static void ml_int_in_callback(struct urb *urb, struct pt_regs *regs)
{
    	/* ... */
 
    	if (dev->int_in_buffer[0] & ML_MAX_UP && dev->command & ML_UP)
    	{
        	dev->command &= ~ML_UP;
        	dev->correction_required = 1;
    	} else if (dev->int_in_buffer[0] & ML_MAX_DOWN &&
            	dev->command & ML_DOWN)
    	{
        	dev->command &= ~ML_DOWN;
        	dev->correction_required = 1;
    	}
 
    	if (dev->int_in_buffer[1] & ML_MAX_LEFT && dev->command & ML_LEFT)
    	{
        	dev->command &= ~ML_LEFT;
        	dev->correction_required = 1;
    	} else if (dev->int_in_buffer[1] & ML_MAX_RIGHT &&
            	dev->command & ML_RIGHT)
    	{
        	dev->command &= ~ML_RIGHT;
        	dev->correction_required = 1;
    	}
 
    	/* ... */
}

上面的代码设置了 correction_required 变量,该变量决定是否触发"校正"控制 URB:此 URB 仅包含最后一个命令,不包含有害位。需要提醒的是,URB 回调函数在中断上下文 中运行,因此不应执行任何内存分配、持有信号量或触发任何可能导致进程进入睡眠状态的事件。通过使用这种自动校正机制,信号枪可以免受滥用。同样,这里没有施加任何策略限制;我们只是在保护设备本身。

❯ 用户空间控制

对于大多数读者来说,乐趣就从这里开始。目前还没有人因为解引用空指针而崩溃,而且老旧的 libc 仍然可用。我们的内核模块加载后,可以通过 访问信号枪/dev/ml0。在本例中,第二支信号枪将显示为 ,/dev/ml1依此类推。这是一个控制该设备的非常简单的应用程序:

复制代码
#include <fcntl.h
#include <stdio.h
#include <stdlib.h
#include <unistd.h
 
#define DEFAULT_DEVICE  	"/dev/ml0"
#define DEFAULT_DURATION	800
 
#define ML_STOP     	0x00
#define ML_UP       	0x01
#define ML_DOWN     	0x02
#define ML_LEFT     	0x04
#define ML_RIGHT    	0x08
#define ML_FIRE     	0x10
 
#define ML_FIRE_DELAY   5000
 
void send_cmd(int fd, int cmd)
{
	int retval = 0;
 
	retval = write(fd, &cmd, 1);
	if (retval < 0)
    	fprintf(stderr, "an error occured: %d\n", retval);
}
 
static void usage(char *name)
{
	fprintf(stderr,
        	"\nusage: %s [-mslrudfh] [-t msecs]\n\n"
        	"  -m  	missile launcher [/dev/ml0]\n"
        	"  -s  	stop\n"
        	"  -l  	turn left\n"
        	"  -r  	turn right\n"
        	"  -u  	turn up\n"
        	"  -d  	turn down\n"
        	"  -f  	fire\n"
        	"  -t  	specify duration in milli seconds\n"
        	"  -h  	display this help\n\n"
        	"notes:\n"
        	"* it is possible to combine the directions of the two axes, e.g.\n"
        	"  '-lu' send_cmds the missile launcher up and left at the same time.\n"
        	"" , name);
	exit(1);
}
 
int main(int argc, char *argv[])
{
	char c;
	int fd;
	int cmd = ML_STOP;
	int duration = DEFAULT_DURATION;
	char *dev = DEFAULT_DEVICE;
 
	if (argc < 2)
    	usage(argv[0]);
 
	while ((c = getopt(argc, argv, "mslrudfht:")) != -1)
	{
    	switch (c)
    	{
        	case 'm': dev = optarg;
                  	break;
        	case 'l': cmd |= ML_LEFT;
                  	break;
        	case 'r': cmd |= ML_RIGHT;
                  	break;
        	case 'u': cmd |= ML_UP;
                  	break;
        	case 'd': cmd |= ML_DOWN;
                  	break;
        	case 'f': cmd = ML_FIRE;
                  	break;
        	case 's': cmd = ML_STOP;
                  	break;
        	case 't': duration = atoi(optarg);
                  	break;
        	default: usage(argv[0]);
    	}
	}
 
	fd = open(dev, O_RDWR);
	if (fd == -1)
	{
    	perror("open");
    	exit(1);
	}
 
	send_cmd(fd, cmd);
 
	if (cmd & ML_FIRE)
    	duration = ML_FIRE_DELAY;
	else if (cmd == ML_UP || cmd == ML_DOWN)
    	duration /= 2;
	usleep(duration * 1000);
 
	send_cmd(fd, ML_STOP);
 
	close(fd);
 
	return EXIT_SUCCESS;
}

此工具(我们称之为)允许用户使用write ml_control系统调用向设备发送数据。例如,如果您指定,信号枪将向上和向左移动 3 秒。它会根据命令发射,并根据命令-s 停止。这段代码只是一个概念验证;当然,还有更复杂的用例。./ml_control -ul -t 3000``./ml_control --f``./ml_control

为了好玩,我还在信号枪上安装了一个外置 iSight 摄像头。pymissile的作者本人建议,下一个有趣的步骤是实现一个基于运动检测的"哨兵"功能。一旦在当前视野中检测到运动,信号枪就会自动瞄准目标并开火。我没有足够的时间来实现这个项目------也许有一天我无聊的时候会再回来做。

❯ 结果

在本文中,我概述了如何为 Linux 内核创建自定义驱动程序以与 USB 设备配合使用。首先,我们使用 Windows 驱动程序对一个未知的 USB 协议进行逆向工程,以拦截所有传入和传出的 USB 流量。在拦截所有通信原语后,我解释了如何构建一个内核驱动程序来与 USB 配合使用。最后,作为概念验证,我们编写了一个用户空间工具,为更多有趣的想法奠定了基础。然后,我谈到了该项目未来可能的发展方向,例如在信号枪上添加摄像头或将其安装在其他设备上。

相关推荐
2301_767902642 小时前
10. Linux 系统启动原理
linux·运维·服务器
☆璇2 小时前
【Linux】Ext系列文件系统(下)
linux·运维·服务器
_w_z_j_2 小时前
Linux----使用vim编辑器
linux·编辑器·vim
crackpot·2 小时前
Linux 命令
linux·运维·服务器
所念皆为东辞2 小时前
服务器时间同步校准
linux·运维·服务器
JanelSirry4 小时前
VMware+RockyLinux+ikuai+docker+cri-docker+k8s 自用 实践笔记(一)
linux·docker·kubernetes
Misnice4 小时前
k8s 常用命令
linux·docker·kubernetes
goldenocean4 小时前
Ubuntu之旅-06 EMQX
linux·运维·ubuntu
JyHuai424 小时前
安全运维-Nginx
linux·运维·服务器