原文标题: Writing a basic Linux device driver when you know nothing about Linux drivers or USB 原文链接: https://crescentro.se/posts/writing-drivers/ 作者: Ivan (crescentro.se) 说明: 本文记录了作者从零开始逆向工程 Nanoleaf Pegboard Desk Dock(带 RGB 灯效果的 USB 扩展坞)并使用 Rust + libusb(rusb 库)编写 Linux 用户态驱动程序的完整过程与心得。


背景与前言

作者买了一个 Nanoleaf Pegboard Desk Dock(具有 RGB LED 灯效和挂钩功能的 USB Hub)。该设备官方仅支持 Windows 和 macOS,未提供 Linux 官方驱动。

作者通过 Windows 虚拟机抓包逆向了通信协议,同时也联系了 Nanoleaf 技术支持,并在 4 小时内收到了官方提供的详细协议文档(确认了灯效控制、电源与亮度管理协议)。由于作者过去从未写过 Linux 驱动或直接操作过 USB 协议,本文记录了他从零起步编写 Linux 驱动的全过程。


1. 从零开始:硬件识别与 USB 基础

在 Linux 中插入该 USB 设备后,系统能够自动识别并为其供电。通过 lsusb 命令可以查看到设备节点:

$ lsusb
Bus 001 Device 062: ID 37fa:8201 JW25021301515 Nanoleaf Pegboard Desk Dock

使用详细模式 lsusb -d 37fa:8201 -v 可以展开设备的描述符信息:

USB 核心概念极简科普

根据 USB in a NutShell 的规范提炼:

  1. Configuration(配置):声明设备的电源需求与工作模式,绝大多数设备仅有 1 个配置。
  2. Interface(接口):代表设备的功能抽象(例如摄像头可能包含“文件存储”和“Webcam 视频”两个接口)。
  3. Endpoint(端点):数据传输的管道通道(支持 Control, Bulk, Interrupt, Isochronous 4 种传输类型)。
    • IN 端点:数据从设备发送给主机(Host)。
    • OUT 端点:数据从主机(Host)发送给设备。

该设备暴露了一个 HID(Human Interface Device) 接口。内核默认带有通用 HID 驱动,因此能自动识别并供电,但因为缺少专门的 RGB 控制子类逻辑,内核只能让其处于默认待机动画状态。


2. 驱动实现路径选择:内核驱动 vs 用户态驱动

针对这种 RGB 设备,有两种实现思路:

  1. 内核空间驱动(Kernel Space Driver):
    • 遵循内核标准,把每个 LED 在 /sys/class/leds 目录下暴露为 R/G/B 三个属性文件。
    • 缺点:开发门槛高、合入主线内核流程繁琐,且 /sys/class/leds 主要用于状态指示灯而非复杂 RGB 灯效。
  2. 用户空间驱动(Userspace Driver via libusb):
    • 在用户态使用 libusb(Rust rusb 绑定)直接向 USB 端点读写字节。
    • 优点:无需编译内核模块、无需 root 权限(配置 udev 规则后)、开发调试极其灵活。

作者选择了方案 2(用户态驱动)。


3. udev 规则配置(免 root 权限访问)

在 Linux 中直接操作底层 USB 设备默认需要 root 权限。为了允许普通用户免提权运行驱动,需在 /etc/udev/rules.d/70-pegboard.rules 中添加 udev 规则:

ACTION=="add", SUBSYSTEM=="usb", DRIVERS=="usb", ATTRS{idVendor}=="37fa", ATTRS{idProduct}=="8201", MODE="0770", TAG+="uaccess"

注意:文件名需保证按字典序排在 73 之前(如 70-pegboard.rules),以便 uaccess 标签生效。重新插拔设备后即可无需 sudo 操作该 USB 设备。


4. 使用 Rust + rusb 编写驱动

步骤一:创建项目与获取设备句柄

添加依赖 rusb = "0.9":

use rusb::{Context, UsbContext};

const VENDOR: u16 = 0x37fa;
const DEVICE: u16 = 0x8201;

fn main() {
    let context = Context::new().expect("cannot open libusb context");
    let device = context
        .open_device_with_vid_pid(VENDOR, DEVICE)
        .expect("cannot get device");

    let descriptor = device
        .device()
        .device_descriptor()
        .expect("cannot describe device");

    println!("{descriptor:#?}");
}

步骤二:解绑内核默认驱动与 Claim Interface

尝试 device.claim_interface(0) 时会报错 Busy,因为内核通用的 usbhid 驱动已占用了该 Interface 0。需要在 Claim 前主动调用 detach_kernel_driver:

const INTERFACE: u8 = 0x0;

if device.kernel_driver_active(INTERFACE).unwrap_or(false) {
    device.detach_kernel_driver(INTERFACE).expect("cannot detach kernel driver");
}
device.claim_interface(INTERFACE).expect("unable to claim interface");

步骤三:向 Interrupt OUT 端点发送控制数据

根据协议,向端点 0x02 发送 02 00 c0 指令加上 64 组 RGB 数据(如 0f ff 0f 表示纯红)即可设置全板灯效:

use std::time::Duration;

const ENDPOINT_OUT: u8 = 0x02;
const TIMEOUT: Duration = Duration::from_secs(1);

let command: [u8; 3] = [0x02, 0x00, 0xc0];
let color: [u8; 3] = [0x0f, 0xff, 0x0f]; // RGB 控制
let body: Vec<u8> = command
    .into_iter()
    .chain(color.into_iter().cycle().take(192))
    .collect();

device
    .write_interrupt(ENDPOINT_OUT, &body, TIMEOUT)
    .expect("unable to write to device");

步骤四:处理 Interrupt IN 响应(防止固件崩溃)

在 USB 规范中,主机(Host)必须主动 Polling 轮询读取中断。如果在向设备写入指令后不去读取设备的 Response 中断,设备固件会因缓冲区溢出而挂起并重置为离线默认状态。

读取端点 0x82 的响应数据:

const ENDPOINT_IN: u8 = 0x82;
let mut buf = [0u8; 64];

device
    .read_interrupt(ENDPOINT_IN, &mut buf, TIMEOUT)
    .expect("unable to read from device");

dbg!(buf); // 收到 [130, 0, 1, 0...] 代表 ACK 成功

5. 多线程并发架构:定时刷新与事件响应

在完整驱动中,需要一边维持灯效帧的流式刷新(避免设备因为超时未收到帧而退回离线模式),一边监听物理按键触发的 Interrupt 事件(单击/双击/长按):

use std::thread;
use std::time::Duration;

const WRITE_TIMEOUT: Duration = Duration::from_secs(1);
const READ_TIMEOUT: Duration = Duration::from_millis(1); // 符合 bInterval 声明

thread::scope(|s| {
    // 线程 1:定时发送灯效控制帧
    s.spawn(|| {
        device
            .write_interrupt(ENDPOINT_OUT, &body, WRITE_TIMEOUT)
            .expect("unable to write to device");
    });

    // 线程 2:死循环轮询读取中断事件(如按键)
    s.spawn(|| loop {
        let mut buf = [0u8; 64];
        match device.read_interrupt(ENDPOINT_IN, &mut buf, READ_TIMEOUT) {
            Ok(_) => println!("Interrupt event: {}", buf[0]),
            Err(rusb::Error::Timeout) => continue, // 正常无事件超时
            Err(e) => panic!("{e:?}"),
        }
    });
});

6. 逆向过程中的避坑总结

  1. 颜色通道顺序坑:官方协议文档标注为标准 RGB 顺序,但实际硬件抓包与显示发现颜色顺序为 GRB。
  2. 亮度过高复位:如果将 RGB 设得过亮,硬件功耗超出 70mA 限制,设备会触发过流保护硬重化复位。
  3. 中断缓冲区清理:每发一条指令必须及时读掉 IN 端点返回的 ACK 帧,否则固件崩溃。

总结

作者指出,编写简单硬件设备的 Linux 用户态驱动其实并不复杂,使用不到 50 行 Rust 代码配合 libusb 与 udev 规则,就能建立起稳定双向通信的驱动程序。这为探索 Linux 硬件驱动与逆向工程提供了一个非常好的实战入门视角。