USB cho Lập trình viên: Hướng dẫn viết Driver ở Không gian Người dùng

08 tháng 4, 2026·11 phút đọc

Viết driver USB thường bị coi là nhiệm vụ khó khăn đòi hỏi kiến thức sâu về Kernel, nhưng thực tế không hẳn vậy. Bài viết này sẽ giới thiệu cách viết driver USB ở không gian người dùng (userspace) sử dụng thư viện libusb, giúp quá trình phát triển trở nên đơn giản và dễ dàng hơn bao giờ hết.

USB cho Lập trình viên: Hướng dẫn viết Driver ở Không gian Người dùng

USB cho Lập trình viên: Hướng dẫn viết Driver ở Không gian Người dùng

Hãy tưởng tượng bạn được giao một thiết bị USB và được yêu cầu viết driver cho nó. Nghe có vẻ là một nhiệm vụ đáng sợ đúng không? Viết driver thường đồng nghĩa với việc phải viết mã Kernel, vốn khó, ở mức thấp, khó gỡ lỗi và phức tạp.

Tuy nhiên, tất cả những điều đó thực ra không đúng. Viết driver cho một thiết bị USB thực tế không khó hơn nhiều so với việc viết một ứng dụng sử dụng Sockets. Bài viết này nhằm cung cấp một cái nhìn tổng quan ở mức độ cao về việc sử dụng USB cho những người có thể chưa từng làm việc nhiều với Phần cứng nhưng chỉ muốn sử dụng công nghệ này.

Thiết bị USB mục tiêu

Thiết bị chúng ta sẽ sử dụng trong bài viết này là một chiếc điện thoại Android ở chế độ Bootloader. Lý do chúng ta chọn thiết bị này là vì:

  • Đây là thiết bị bạn có thể dễ dàng tiếp cận.
  • Giao thức mà nó sử dụng được tài liệu hóa rất rõ ràng và cực kỳ đơn giản.
  • Driver cho thiết bị này thường không được cài đặt sẵn trên hệ thống, nên hệ điều hành sẽ không can thiệp vào các thí nghiệm của chúng ta.

Việc đưa điện thoại vào chế độ Bootloader khác nhau tùy từng thiết bị, nhưng thường liên quan đến việc giữ kết hợp các nút vật lý trong khi điện thoại đang khởi động. Trong trường hợp của tôi, đó là giữ nút giảm âm lượng trong khi bật nguồn.

Liệt kê thiết bị thủ công (Enumeration)

Enumeration (Liệt kê) đề cập đến quá trình máy chủ (host) yêu cầu thiết bị cung cấp thông tin về chính nó. Quá trình này diễn ra tự động khi bạn cắm thiết bị và là nơi hệ điều hành thường quyết định driver nào sẽ được tải cho thiết bị. Đối với hầu hết các thiết bị tiêu chuẩn, hệ điều hành sẽ xem xét Lớp thiết bị USB (USB Device Class) và tải driver hỗ trợ lớp đó. Đối với các thiết bị dành riêng cho nhà cung cấp (vendor-specific), bạn thường cài đặt driver do nhà sản xuất tạo ra, driver này sẽ xem xét VID (Vendor ID) và PID (Product ID) để phát hiện xem nó có nên xử lý thiết bị hay không.

Thông tin cơ bản

Ngay cả khi không có driver, việc cắm điện thoại vào máy tính vẫn sẽ khiến nó được nhận dạng là một thiết bị USB. Đó là vì đặc tả USB định nghĩa một cách tiêu chuẩn để các thiết bị tự nhận dạng với máy chủ.

Trên Linux, chúng ta có thể sử dụng công cụ lsusb tiện dụng để xem thiết bị đã tự nhận dạng là gì:

$ lsusb
...
Bus 008 Device 014: ID 18d1:4ee0 Google Inc. Nexus/Pixel Device (fastboot)
...

Bus và Device chỉ là định danh cho cổng USB vật lý mà thiết bị đang cắm vào. Chúng có thể khác nhau trên hệ thống của bạn vì phụ thuộc vào cổng bạn đã cắm thiết bị vào.

ID là phần thú vị nhất ở đây. Phần đầu 18d1 là Vendor ID (VID) và phần thứ hai 4ee0 là Product ID (PID). Đây là các định danh mà thiết bị gửi đến máy chủ để tự nhận dạng. VID được USB-IF gán cho các công ty trả tiền cho họ (trong trường hợp này là Google), và PID được công ty gán cho một sản phẩm cụ thể (trong trường hợp này là Nexus/Pixel Bootloader).

Thông tin về Lớp và Driver

Sử dụng lệnh lsusb -t, chúng ta cũng có thể xem lớp USB của thiết bị và driver nào hiện đang xử lý nó:

$ lsusb -t
...
/:  Bus 008.Port 001: Dev 001, Class=root_hub, Driver=xhci_hcd/1p, 480M
    |__ Port 001: Dev 002, If 0, Class=Hub, Driver=hub/4p, 480M
        |__ Port 003: Dev 003, If 0, Class=Hub, Driver=hub/4p, 480M
            |__ Port 002: Dev 014, If 0, Class=Vendor Specific Class, Driver=[none], 480M
...

Phần Class=Vendor Specific Class chỉ định rằng thiết bị không sử dụng bất kỳ lớp USB tiêu chuẩn nào (ví dụ: HID, Mass Storage hay Audio) mà thay vào đó sử dụng giao thức tùy chỉnh do nhà sản xuất định nghĩa.

Phần Driver=[none] đơn giản cho chúng ta biết rằng hệ điều hành chưa tải driver cho thiết bị, điều này tốt cho chúng ta vì chúng ta muốn viết driver của riêng mình.

Lưu ý cho Windows: Nếu bạn dùng Windows, bạn sẽ không có lsusb nhưng vẫn có thể tìm thấy hầu hết thông tin này bằng Trình quản lý thiết bị (Device Manager) hoặc các công cụ như USB Device Tree Viewer.

Chúng ta sẽ dựa vào VID và PID vì đó là thông tin định danh thực sự duy nhất chúng ta có. Lớp thiết bị không hữu ích lắm ở đây vì nó chỉ là Vendor Specific Class mà bất kỳ nhà sản xuất nào cũng có thể sử dụng cho bất kỳ thiết bị nào.

Thay vì làm tất cả những điều này trong Kernel, chúng ta có thể viết một ứng dụng Userspace (không gian người dùng) thực hiện cùng một việc. Việc này dễ viết và gỡ lỗi hơn nhiều (và có lẽ là nơi đúng đắn để driver tồn tại, nhưng đó là một câu chuyện khác). Để làm điều này, chúng ta có thể sử dụng thư viện libusb, thư viện này cung cấp một API đơn giản để giao tiếp với các thiết bị USB từ Userspace. Nó đạt được điều này bằng cách cung cấp một driver chung có thể được tải cho bất kỳ thiết bị nào và sau đó cung cấp cách cho các ứng dụng Userspace yêu cầu thiết bị và giao tiếp trực tiếp với nó.

Liệt kê thiết bị với libusb

Cùng một việc chúng ta vừa làm thủ công cũng có thể thực hiện được bằng phần mềm. Chương trình sau khởi tạo libusb, đăng ký một trình xử lý sự kiện hotplug cho các thiết bị khớp với tổ hợp VendorId / ProductId 18d1:4ee0 và sau đó chờ thiết bị đó được cắm vào máy chủ.

#include <libusb.h>
#include <print>

auto hotplug_callback(
    libusb_context *ctx,
    libusb_device *device,
    libusb_hotplug_event event,
    void *user_data) -> int {
    std::println("Device plugged in!\n");
    return 0;
}

auto main() -> int {
    // Tạo context để tương tác với driver libusb
    libusb_context *context = nullptr;
    libusb_init(&context);

    // Đăng ký callback hotplug để chờ thiết bị của chúng ta được cắm vào
    libusb_hotplug_callback_handle hotplug_callback_handle;
    libusb_hotplug_register_callback(
        context,
        LIBUSB_HOTPLUG_EVENT_DEVICE_ARRIVED, // Sự kiện thiết bị được cắm vào
        LIBUSB_HOTPLUG_ENUMERATE,  // Kích hoạt sự kiện cho các thiết bị đã cắm
        0x18d1, 0x4ee0,            // VID và PID chúng ta tìm thấy trước đó
        LIBUSB_HOTPLUG_MATCH_ANY,  // Khớp bất kỳ Lớp USB nào
        hotplug_callback, nullptr, // Callback để gọi
        &hotplug_callback_handle
    );

    // Xử lý các sự kiện libusb
    while (true) {
        if (libusb_handle_events(context) < 0)
            break;
    }

    libusb_exit(context);
}

Khi bạn chạy chương trình này và cắm điện thoại vào, nó sẽ in ra "Device plugged in!" ngay khi thiết bị được nhận diện.

Mở thiết bị và Giao tiếp Control Transfer

Bây giờ chúng ta đã có thể phát hiện thiết bị, hãy thử giao tiếp với nó. Đầu tiên, chúng ta cần mở thiết bị và yêu cầu một giao diện (interface) trên đó. Sau đó, chúng ta có thể gửi một yêu cầu Control Transfer.

Control Transfer là loại truyền tải mặc định được sử dụng để gửi các lệnh cấu hình đến thiết bị. Nó luôn sử dụng Endpoint 0.

// Mở thiết bị bằng VID và PID
libusb_device_handle *handle = nullptr;
libusb_open(context, &handle);

// Yêu cầu giao diện 0 của thiết bị
libusb_claim_interface(handle, 0);

// Chuẩn bị bộ đệm dữ liệu để nhận phản hồi
std::vector<uint8_t> data(0xFF);

// Thực hiện Control transfer
const auto result = libusb_control_transfer(
    handle,
    uint8_t(LIBUSB_ENDPOINT_IN)      | // Yêu cầu dữ liệu từ thiết bị...
        LIBUSB_RECIPIENT_DEVICE      | //   về toàn bộ thiết bị...
        LIBUSB_REQUEST_TYPE_STANDARD,  //   sử dụng yêu cầu tiêu chuẩn.
    LIBUSB_REQUEST_GET_STATUS,         // Gửi yêu cầu GET_STATUS
    0x00,                              // Giá trị wValue là 0x00
    0x00,                              // Giá trị wIndex là 0x00
    data.data(), data.size(),          // Bộ đệm để đọc dữ liệu vào
    1000                               // Timeout 1000ms
);

// In dữ liệu trả về bởi thiết bị nếu không có lỗi
if (result >= 0)
    print_bytes(std::span(data).subspan(0, result));

// Đóng thiết bị lại
libusb_close(handle);

Đoạn mã này sẽ gửi yêu cầu GET_STATUS đến thiết bị ngay khi nó được cắm vào và in ra dữ liệu mà nó gửi lại cho bảng điều khiển.

$ ./libusb_enumerate
Addr  00 01 02 03 04 05 06 07  08 09 0A 0B 0C 0D 0E 0F
0000: 01 00

Những byte này đến từ chính thiết bị! Giải mã chúng bằng đặc tả cho chúng ta biết rằng byte đầu tiên cho biết thiết bị có tự cấp nguồn (Self-Powered) hay không (1 nghĩa là có, điều này hợp lý vì thiết bị có pin) và byte thứ hai có nghĩa là nó không hỗ trợ Remote Wakeup (tức là nó không thể đánh thức máy chủ).

Có một vài loại yêu cầu tiêu chuẩn hóa khác (và một số thiết bị thậm chí thêm các loại của riêng chúng cho những việc đơn giản!), nhưng loại chính mà chúng ta (và cả hệ điều hành) quan tâm là yêu cầu GET_DESCRIPTOR.

Mô tả Descriptor trong bộ nhớ hexMô tả Descriptor trong bộ nhớ hex

Yêu cầu Descriptor

Descriptors là các cấu trúc nhị phân thường được mã hóa cứng vào phần mềm điều khiển (firmware) của thiết bị USB. Chúng là thứ cho máy chủ biết chính xác thiết bị là gì, nó có khả năng gì và driver nào nó muốn hệ điều hành tải. Vì vậy, khi bạn cắm một thiết bị, máy chủ chỉ cần gửi nhiều yêu cầu GET_DESCRIPTOR đến Control Endpoint tiêu chuẩn tại ID 0x00 để nhận lại một struct cung cấp tất cả thông tin cần thiết cho việc liệt kê. Và điều tuyệt vời là chúng ta cũng có thể làm điều đó!

Thay vì yêu cầu GET_STATUS, bây giờ chúng ta gửi yêu cầu GET_DESCRIPTOR:

const auto result = libusb_control_transfer(
    handle,
    uint8_t(LIBUSB_ENDPOINT_IN)      | // Yêu cầu dữ liệu từ thiết bị...
        LIBUSB_RECIPIENT_DEVICE      | //   về toàn bộ thiết bị...
        LIBUSB_REQUEST_TYPE_STANDARD,  //   sử dụng yêu cầu tiêu chuẩn.
    LIBUSB_REQUEST_GET_DESCRIPTOR,     // Gửi yêu cầu GET_DESCRIPTOR
    (LIBUSB_DT_DEVICE << 8) | 0x00,    // Loại Descriptor và Index
    0x00,                              // wValue
    data.data(), data.size(),          // Bộ đệm
    1000                               // Timeout
);

Điều này sẽ trả về cho chúng ta Device Descriptor, chứa các thông tin như VID, PID, số lượng cấu hình, v.v.

Giao tiếp Bulk Transfer (Giao thức Fastboot)

Bây giờ chúng ta đã biết cách gửi các lệnh tiêu chuẩn, hãy thử gửi một lệnh thực tế theo giao thức Fastboot. Giao thức Fastboot sử dụng Bulk Transfers để truyền dữ liệu. Bulk Transfers được sử dụng cho lượng lớn dữ liệu cần truyền nhanh chóng và đáng tin cậy.

Theo tài liệu, giao thức Fastboot hoạt động như sau:

  • Gửi lệnh đến Endpoint OUT 0x02.
  • Nhận phản hồi từ Endpoint IN 0x81.

Chúng ta sẽ gửi lệnh getvar:version để hỏi phiên bản Fastboot của thiết bị.

// Chuẩn bị bộ đệm 64 byte
std::vector<uint8_t> bytes(64);

// Sao chép lệnh "getvar:version"
// vào đầu bộ đệm
std::ranges::copy(
    "getvar:version",
    bytes.begin());

// Thực hiện Bulk transfer dữ liệu đó trên Endpoint OUT 0x02
int num_bytes_transferred = 0;
libusb_bulk_transfer(
    handle,                     // Handle thiết bị
    LIBUSB_ENDPOINT_OUT | 0x02, // Endpoint OUT 0x02
    bytes.data(), bytes.size(), // Dữ liệu để gửi
    &num_bytes_transferred,     // Số byte đã gửi
    1000                        // Timeout 1000ms
);

// In dữ liệu đã truyền
std::println("Request: {}",
    std::string_view(
        reinterpret_cast<char*>(bytes.data()),
        num_bytes_transferred
    ));

// Xóa bộ đệm
std::ranges::fill(bytes, 0x00);
num_bytes_transferred = 0;

// Thực hiện Bulk transfer trên Endpoint IN 0x81
libusb_bulk_transfer(
    handle,                     // Handle thiết bị
    LIBUSB_ENDPOINT_IN | 0x01,  // Endpoint IN 0x81
    bytes.data(), bytes.size(), // Bộ đệm để nhận
    &num_bytes_transferred,     // Số byte đã nhận
    1000                        // Timeout 1000ms
);

// In các ký tự trả về
std::println("Response: {}",
    std::string_view(
        reinterpret_cast<char*>(bytes.data()),
        num_bytes_transferred
    ));

// Giải phóng giao diện
libusb_release_interface(handle, 0);

// Đóng handle thiết bị
libusb_close(handle);

Cắm thiết bị vào bây giờ sẽ in ra thông báo sau cho thiết bị đầu cuối:

$ ./libusb_enumerate
Request:  getvar:version
Response: OKAY0.4

Có vẻ như nó khớp với tài liệu!

  • 4 byte đầu tiên là OKAY, chỉ định rằng yêu cầu đã được thực hiện thành công.
  • Phần dữ liệu còn lại là 0.4, tương ứng với Phiên bản Fastboot được triển khai trong Tài liệu: v0.4.

Lời kết

Và thế là xong! Bạn đã tạo thành công driver USB đầu tiên của mình từ đầu mà không cần chạm vào Kernel.

Tất cả các nguyên tắc này đều áp dụng cho tất cả các driver USB ngoài kia. Giao thức cơ bản có thể phức tạp hơn đáng kể so với giao thức fastboot (tôi từng phát điên vì sự kinh khủng của giao thức MTP), nhưng mọi thứ xung quanh nó vẫn giữ nguyên. Không phức tạp hơn nhiều so với TCP qua sockets, đúng không? :)

Bài viết được tổng hợp và biên soạn bằng AI từ các nguồn tin tức công nghệ. Nội dung mang tính tham khảo. Xem bài gốc ↗