USB devices are everywhere in modern computing, from keyboards and mice to game controllers and specialized hardware. While most devices work out of the box thanks to generic drivers, understanding how they communicate with the operating system opens up possibilities for custom implementations, debugging, and learning about low-level hardware interaction.

This article explores the process of reverse engineering a USB device’s communication protocol and implementing a custom Linux kernel driver. We’ll focus on the Sony DualShock 4 controller as our target device, chosen for its widespread availability and relatively straightforward HID protocol implementation.

The goal is to understand how USB HID devices communicate, capture and analyze their data packets, and finally write a minimal Linux kernel driver that can read input from the controller. This knowledge is transferable to other USB devices and provides insight into how operating systems interact with hardware at a low level.

Understanding USB HID Devices

What is HID?

The Human Interface Device (HID) protocol is a USB standard designed for devices that interact directly with humans, such as keyboards, mice, joysticks, and game controllers. HID devices use a standardized way of describing their capabilities and communicating with the host system, making them relatively easy to work with once you understand the fundamentals.

Key USB Concepts

Before diving into reverse engineering, it’s important to understand some basic USB terminology: Usb hierarchy diagram

  • Endpoints are communication channels between the host and device. Each endpoint has a direction (IN for device-to-host, OUT for host-to-device) and a type (control, interrupt, bulk, or isochronous). HID devices typically use interrupt endpoints for real-time data like button presses.

  • Interfaces represent different functions or modes of a device. A single USB device can have multiple interfaces - for example, a gaming headset might have separate interfaces for audio and microphone functionality.

  • Descriptors are data structures that describe the device’s capabilities and configuration. The device descriptor contains basic information like vendor ID (VID) and product ID (PID), while HID descriptors specify the format of input and output reports.

USB HID Communication Model

HID devices communicate using reports - structured data packets that contain input from the device (like button states) or output to the device (like LED control). These reports follow a format defined by the device’s HID descriptor, which acts as a template for interpreting the raw data.

The DualShock 4 controller, like most game controllers, primarily sends input reports containing button states, analog stick positions, and sensor data. Understanding this report structure is crucial for writing a driver that can interpret the controller’s data.

For a deeper understanding of the HID protocol, see this article.

Reverse Engineering the Protocol

Setting Up the Environment

You can list connected USB devices using:

1
2
$ lsusb
Bus 001 Device 005: ID 054c:09cc Sony Corp. DualShock 4 [CUH-ZCT2x]

This shows the DualShock 4’s vendor ID (054c) and product ID (09cc), which we’ll need later for the driver.

Capturing USB Traffic

Enable USB monitoring by loading the usbmon kernel module:

1
$ sudo modprobe usbmon

usbmon is a kernel module that allows monitoring USB traffic in real-time. You can use it directly or with Wireshark to visualize packets.

For HID devices, you can also view raw data directly:

1
$ sudo hexdump -C /dev/hidraw0

Identifying the Correct /dev/hidraw* Device

If you have multiple HID devices, you can identify which /dev/hidrawX corresponds to your controller:

1
2
$ ls -l /sys/class/hidraw/
$ udevadm info -a -n /dev/hidrawX | grep "idVendor\|idProduct"

Compare the idVendor and idProduct values with the ones from lsusb (054c:09cc). The matching device is your DualShock 4.


Device-Specific Case Study: DualShock 4

Although we often refer to them as analog controls, the stick and trigger readings are already digitized — they’re discrete 8-bit integers (0–255) representing positions.

Control Byte Index Center Value Notes
Left Stick X 1 0x80 (128) Horizontal
Left Stick Y 2 0x80 (128) Vertical
Right Stick X 3 0x80 (128) Horizontal
Right Stick Y 4 0x80 (128) Vertical

Writing a Minimal Linux Kernel Driver

The Linux USB API

The Linux kernel provides an API for USB driver developers, including:

  • Device registration: usb_register_driver() and usb_deregister()
  • Device matching: via usb_device_id tables
  • Data transfer: usb_control_msg(), usb_bulk_msg(), and usb_fill_int_urb()
  • Memory management: usb_alloc_urb(), usb_free_urb(), and usb_alloc_coherent()

These functions form the foundation for writing USB drivers in Linux.

Understanding URBs (USB Request Blocks)

URBs (USB Request Blocks) are data structures representing asynchronous USB transactions. Each URB includes a buffer, endpoint, transfer length, and a callback function that is triggered when the transfer completes.


Kernel Module Structure

Below is a simplified example of a USB driver initialization:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
#include <linux/module.h>
#include <linux/usb.h>
#include <linux/hid.h>

static struct usb_device_id ds4_table[] = {
    { USB_DEVICE(0x054c, 0x09cc) },  // DualShock 4
    { }
};
MODULE_DEVICE_TABLE(usb, ds4_table);

static struct usb_driver ds4_driver = {
    .name = "ds4_driver",
    .id_table = ds4_table,
    .probe = ds4_probe,
    .disconnect = ds4_disconnect,
};

Implementing the Probe Function

The probe function is called when a matching device is connected. It’s responsible for setting up the driver’s internal state and configuring communication with the device:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
static int ds4_probe(struct usb_interface *intf, 
                     const struct usb_device_id *id)
{
    struct usb_device *udev = interface_to_usbdev(intf);
    struct usb_endpoint_descriptor *endpoint;
    struct urb *urb;
    unsigned char *data_buffer;
    
    // Allocate memory for data buffer
    data_buffer = kmalloc(64, GFP_KERNEL);
    if (!data_buffer) {
        printk(KERN_ERR "ds4: Failed to allocate memory\n");
        return -ENOMEM;
    }

    // Find the interrupt endpoint
    endpoint = &intf->cur_altsetting->endpoint[0].desc;
    
    // Allocate URB for receiving data
    urb = usb_alloc_urb(0, GFP_KERNEL);
    if (!urb) {
        kfree(data_buffer);
        printk(KERN_ERR "ds4: Failed to allocate URB\n");
        return -ENOMEM;
    }

    // Initialize URB
    usb_fill_int_urb(urb, udev, usb_rcvintpipe(udev, endpoint->bEndpointAddress),
                     data_buffer, 64, ds4_irq_handler, NULL, endpoint->bInterval);

    // Submit URB for receiving data
    int ret = usb_submit_urb(urb, GFP_KERNEL);
    if (ret) {
        usb_free_urb(urb);
        kfree(data_buffer);
        printk(KERN_ERR "ds4: Failed to submit URB\n");
        return ret;
    }

    printk(KERN_INFO "ds4: Device connected\n");
    
    return 0;  // Success
}

Handling Input Data

USB communication in kernel drivers uses URBs (USB Request Blocks). For interrupt endpoints like those used by HID devices, you set up a URB with a callback function that gets called whenever data arrives:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
static void ds4_irq_handler(struct urb *urb)
{
    unsigned char *data = urb->transfer_buffer;
    
    // Process the received report
    if (data[1] & 0x20) {
        printk(KERN_INFO "ds4: X button pressed\n");
    }

    ... // Process other buttons
    
    // Print analog stick values
    printk(KERN_INFO "ds4: Left stick X=%d Y=%d\n", 
           data[4], data[5]);
    
    // Resubmit URB for continuous monitoring
    usb_submit_urb(urb, GFP_ATOMIC);
}

Creating a Device Interface

For user-space applications to access the driver, you can create a character device or use existing interfaces like the input subsystem. A simple approach is creating a device file that applications can read:

1
2
3
// In probe function
major = register_chrdev(0, "ds4raw", &ds4_fops);
device_create(ds4_class, NULL, MKDEV(major, 0), NULL, "ds4raw");

Building and Loading the Module

Compile the driver using a Makefile that specifies the kernel build system:

1
2
3
4
5
6
7
obj-m += ds4_driver.o

all:
	make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules

clean:
	make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean

Load the module with sudo insmod ds4_driver.ko and check dmesg for output when you use the controller.

Info

if when when executing dmesg and you don’t see any output related to your driver, make sure you controller isn’t binded to another driver, you can check this by running lsusb -vt and looking for the correct bus and port numbers which usually look like this bus.port.port:config.interface. lsusb -vt

In our case, it is 1.2.2:1.3, to be really sure you can also run ls /sys/bus/usb/devices/usbhid/ and check if the device is listed there. Then finally, you can unbind the device from the usbhid driver by running:

1
echo "1-2.2:1.3" > /sys/bus/usb/drivers/usbhid/unbind

and rebind it to your driver by running:

1
echo "1-2.2:1.3" > /sys/bus/usb/drivers/ds4_driver/bind

Error Handling and Cleanup

Proper error handling is crucial in kernel code. Always check return values and clean up resources on failure:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
static void ds4_disconnect(struct usb_interface *interface)
{
    struct usb_device *udev = interface_to_usbdev(interface);

    pr_info("ds4: Controller disconnected\n");

    if (ds4_urb) {
        usb_kill_urb(ds4_urb);
        usb_free_urb(ds4_urb);
        ds4_urb = NULL;
    }

    if (ds4_buffer) {
        usb_free_coherent(udev, 64, ds4_buffer, ds4_dma);
        ds4_buffer = NULL;
    }

    pr_info("ds4: Cleaned up controller resources\n");
}

static void __exit ds4_exit(void)
{
    pr_info("ds4: Unloading driver\n");

    // Clean up character device or input interface if applicable
    unregister_chrdev(major, "ds4raw");
    device_destroy(ds4_class, MKDEV(major, 0));
    class_destroy(ds4_class);

    usb_deregister(&ds4_driver);
}

You can find a minimal implementation of the driver on github.

Conclusion

Reverse engineering USB devices like the DualShock 4 controller provides valuable insights into low-level hardware communication. By understanding the HID protocol, capturing USB traffic, and implementing a custom Linux kernel driver, you gain practical experience in systems programming and device interaction.

Key takeaways:

  • Understanding USB HID protocols and descriptors is essential for interpreting device data.
  • Tools like lsusb, usbmon, and Wireshark are invaluable for capturing and analyzing USB traffic.
  • Writing a Linux kernel driver involves setting up device registration, handling URBs, and managing memory and resources carefully.
  • Testing and debugging kernel code requires careful attention to error handling and cleanup.

This process not only enhances your debugging skills but also opens up possibilities for creating custom applications and drivers for various USB devices. The techniques demonstrated here can be applied to other HID devices, making it a versatile skill set for any aspiring systems programmer or hardware enthusiast.

As you continue exploring USB devices, consider experimenting with different controllers or peripherals to deepen your understanding of USB protocols and driver development. The skills learned here are foundational for anyone interested in systems programming, embedded systems, or hardware hacking.


References