USB 实战:串口与 HID 键盘

本篇是《Ariel OS 深度实战:Rust 库式 IoT 操作系统全解析》系列第 24 篇。上一篇:应用协议:HTTP 与 CoAP,下一篇:BLE 实战:广播与扫描,目录见文末。

USB 实战:串口与 HID 键盘

导读

USB device 是 MCU 与人类世界最短的桥梁:一根线,设备立刻变成虚拟串口、键盘、网卡。本篇拆解 Ariel OS 的 USB 子系统,用三个官方示例覆盖三种典型形态:usb-serial(CDC-ACM 虚拟串口回显)、usb-serial-logger(把日志通道搬到 USB 串口上的自定义传输)与 usb-keyboard(HID 键盘,按键模拟打字)。第三个示例虽只限 nRF52840-DK/nRF5340-DK(ESP32-C6 没有通用 USB device 外设,只有 USB-Serial-JTAG),但它演示的 usb_builder_hook 机制是所有 USB 类设备共用的核心模式,本篇会用 ESP32-C6 能跑的前两个示例打底、nRF 示例讲机制。

Ariel OS 的 USB 栈完全建立在 embassy-usb 之上(re-export 为 ariel_os::reexports::embassy_usb),自己不发明类驱动 API。它真正贡献的是三件事:系统化的 USB bring-up(驱动、缓冲、UsbBuilder 全部由 init_task 组装)、USB_BUILDER_HOOK 类注入机制(应用类与系统类共存于同一设备)、以及 #[ariel_os::config(usb)] 配置宏(VID/PID 等设备级描述符)。理解这三个机制后,embassy-usb 的全部文档和类 crate(CDC-ACM、CDC-NCM、HID、MIDI、UVC……)都可以直接拿来用。

先划清一个边界(Book usb.md 的开篇约束):Ariel OS 只支持 device 角色(无 host);OTG 外设目前也只当 device 用;只支持"generic" USB 外设——ESP32 的 USB CDC-ACM/JTAG 专用外设不能用于通用类,它只服务于日志与烧录。这直接决定了本篇示例在 ESP32-C6 与 nRF 板卡上的可用性差异。

为什么需要系统级 USB 框架

裸写 embassy-usb 应用大约要做这些事:构造 UsbDriver(每种 MCU 的初始化不同)、准备 CONFIG_DESC/BOS_DESC/MSOS_DESC/CONTROL_BUF 四块静态缓冲、组装 UsbBuilder、注册类、spawner.spawn(usb_task)、再写业务循环。每一步都不难,但加起来是上百行与 MCU 无关性/可移植性的流失——这正是 Ariel OS 的靶心。

系统级 USB 框架的三个组件值得在写代码前理解:

1. laze 模块 usb。启用条件是板卡描述里有通用 USB device 口(SBD 的 has_usb_device_port 标志)。模块被选中后,init_task 会构造 UsbBuilder:UsbDriver 由芯片适配 crate 提供(ariel_os::usb::UsbDriver 类型别名指向对应 HAL 的实现),四块静态缓冲由系统预置。注意 ESP32-C6 的 SBD 标志是 has_usb_cdc_acm_port(USB-Serial-JTAG 口),它满足日志/烧录需要,但不满足 usb 模块的通用外设要求——这就是 C6 跑不了 usb-serial 示例的根因。

2. USB_BUILDER_HOOK。系统 build USB 设备的时刻早于应用任务体执行,应用怎么在"已经组装一半的 builder"上追加自己的类?Ariel OS 的答案是钩子:声明 #[ariel_os::task(autostart, usb_builder_hook)] 后,宏生成静态的 USB_BUILDER_HOOK,系统在 build 前调用所有注册的钩子,应用通过 USB_BUILDER_HOOK.with(|builder| ...) 拿到 builder 引用、注入类构造,.await 后拿到类实例继续用。这个机制让"系统的 CDC-NCM(usb-ethernet 模块)+ 应用的 CDC-ACM + 应用的 HID"复合设备成为可能——composite_with_iads = true 的配置就是为多接口复合准备的。

3. #[ariel_os::config(usb)]。设备级描述符(VID/PID/厂商/产品名/序列号/功耗/包大小)由应用以常量形式提供。配套 feature 是 override-usb-config——不启用它时系统使用默认 VID/PID;启用后应用必须提供完整配置。USB 描述符里的 VID/PID 是设备的"身份证"(第 26 篇会讲硬件身份的另一层——设备 ID 与 MAC 派生),示例用 0xc0de:0xcafe 的私有占位值,量产前要换成 USB-IF 分配的合法 VID。

代码案例:CDC-ACM 虚拟串口回显

Cargo.toml(usb-serial):

[dependencies]
ariel-os = { path = "../../src/ariel-os", features = ["override-usb-config"] }
ariel-os-boards = { path = "../../src/ariel-os-boards" }

laze.yml:selects: [usb]。

src/main.rs:

#![no_std]
#![no_main]

use ariel_os::{
    cell::StaticCell,
    log::{Hex, info},
    reexports::embassy_usb,
    usb::UsbDriver,
};
use embassy_usb::{
    class::cdc_acm::{CdcAcmClass, State},
    driver::EndpointError,
};

const MAX_FULL_SPEED_PACKET_SIZE: u8 = 64;

#[ariel_os::config(usb)]
const USB_CONFIG: embassy_usb::Config<'_> = {
    let mut config = embassy_usb::Config::new(0xc0de, 0xcafe);
    config.manufacturer = Some(ariel_os::buildinfo::OS_NAME);
    config.product = Some("USB serial example");
    config.serial_number = Some("12345678");
    config.max_power = 100;
    config.max_packet_size_0 = MAX_FULL_SPEED_PACKET_SIZE;

    // Required for Windows support.
    config.composite_with_iads = true;
    config.device_class = 0xEF;
    config.device_sub_class = 0x02;
    config.device_protocol = 0x01;
    config
};

#[ariel_os::task(autostart, usb_builder_hook)]
async fn main() {
    info!("Hello World!");

    static STATE: StaticCell<State<'_>> = StaticCell::new();

    // Create and inject the USB class on the system USB builder.
    let mut class = USB_BUILDER_HOOK
        .with(|builder| {
            CdcAcmClass::new(
                builder,
                STATE.init_with(State::new),
                MAX_FULL_SPEED_PACKET_SIZE.into(),
            )
        })
        .await;

    // Do stuff with the class!
    loop {
        class.wait_connection().await;
        info!("Connected");
        let _ = echo(&mut class).await;
        info!("Disconnected");
    }
}

struct Disconnected {}

impl From<EndpointError> for Disconnected {
    fn from(val: EndpointError) -> Self {
        match val {
            EndpointError::BufferOverflow => panic!("Buffer overflow"),
            EndpointError::Disabled => Disconnected {},
        }
    }
}

async fn echo(class: &mut CdcAcmClass<'static, UsbDriver>) -> Result<(), Disconnected> {
    let mut buf = [0; MAX_FULL_SPEED_PACKET_SIZE as usize];
    loop {
        let n = class.read_packet(&mut buf).await?;
        let data = &buf[..n];
        info!("data: {}", Hex(data));
        class.write_packet(data).await?;
    }
}

在 nRF52840-DK 上运行(示例 context 限定了通用 USB 板卡,ESP32-C6 不在其中):

laze -C examples/usb-serial build -b nrf52840dk run

插上板的 USB device 口,主机枚举出串口(Linux 下 /dev/ttyACM0),打开任意串口终端即可看到回显。

案例 2:USB HID 键盘

usb-keyboard(context 限 nrf52840dk、nrf5340dk-app)按 4 个物理按键向主机打字 acgt。核心部分:

use ariel_os::{
    cell::StaticCell,
    log::*,
    reexports::{embassy_usb, usbd_hid},
    time::Timer,
    usb::UsbDriver,
};
use embassy_usb::class::hid::{self, HidBootProtocol, HidReaderWriter, HidSubclass};
use usbd_hid::descriptor::{KeyboardReport, SerializedDescriptor};

// Assuming a QWERTY US layout, see https://docs.qmk.fm/#/how_keyboards_work
// and https://www.usb.org/sites/default/files/documents/hut1_12v2.pdf
const KC_A: u8 = 0x04;
const KC_C: u8 = 0x06;
const KC_G: u8 = 0x0a;
const KC_T: u8 = 0x17;

const KEY_RELEASED: u8 = 0x00;
// Maps physical buttons to keycodes/characters
const KEYCODE_MAPPING: [u8; buttons::KEY_COUNT] = [KC_A, KC_C, KC_G, KC_T];

const HID_READER_BUFFER_SIZE: usize = 1;
const HID_WRITER_BUFFER_SIZE: usize = 8;

#[ariel_os::config(usb)]
const USB_CONFIG: embassy_usb::Config<'_> = {
    let mut config = embassy_usb::Config::new(0xc0de, 0xcafe);
    config.manufacturer = Some(ariel_os::buildinfo::OS_NAME);
    config.product = Some("HID keyboard example");
    config.serial_number = Some("12345678");
    config.max_power = 100;
    config.max_packet_size_0 = 64;

    // Required for Windows support.
    config.composite_with_iads = true;
    config.device_class = 0xEF;
    config.device_sub_class = 0x02;
    config.device_protocol = 0x01;
    config
};

static HID_STATE: StaticCell<hid::State<'_>> = StaticCell::new();

#[ariel_os::task(autostart, peripherals, usb_builder_hook)]
async fn usb_keyboard(button_peripherals: peripherals::Buttons) {
    let mut buttons = buttons::Buttons::new(button_peripherals);

    let config = hid::Config {
        report_descriptor: <KeyboardReport as SerializedDescriptor>::desc(),
        request_handler: None,
        poll_ms: 60,
        max_packet_size: 64,
        hid_subclass: HidSubclass::Boot,
        hid_boot_protocol: HidBootProtocol::Keyboard,
    };

    let hid_state = HID_STATE.init_with(hid::State::new);
    let hid_rw: HidReaderWriter<'static, UsbDriver, HID_READER_BUFFER_SIZE, HID_WRITER_BUFFER_SIZE> =
        USB_BUILDER_HOOK
            .with(|usb_builder| hid::HidReaderWriter::new(usb_builder, hid_state, config))
            .await;

    let (_hid_reader, mut hid_writer) = hid_rw.split();

    loop {
        for (i, button) in buttons.iter_mut().enumerate() {
            if button.is_low() {
                info!("Button #{} pressed", i + 1);

                let report = get_keyboard_report(KEYCODE_MAPPING[i]);
                if let Err(e) = hid_writer.write_serialize(&report).await {
                    info!("Failed to send report: {:?}", e);
                }
                let report = get_keyboard_report(KEY_RELEASED);
                if let Err(e) = hid_writer.write_serialize(&report).await {
                    info!("Failed to send report: {:?}", e);
                }
            }
        }

        // Debounce events
        Timer::after_millis(50).await;
    }
}

fn get_keyboard_report(keycode: u8) -> KeyboardReport {
    KeyboardReport {
        keycodes: [keycode, 0, 0, 0, 0, 0],
        leds: 0,
        modifier: 0,
        reserved: 0,
    }
}

Cargo features:["override-usb-config", "time", "usb-hid"];laze selects:usb。

案例 3:usb-serial-logger(自定义日志传输)

const LOG_BUFFER_SIZE: usize = 1024;
static LOG_BUFFER: Pipe<CriticalSectionRawMutex, LOG_BUFFER_SIZE> = Pipe::new();

#[ariel_os::task(autostart)]
async fn logging() {
    loop {
        Timer::after_secs(1).await;
        info!("Testing USB CDC ACM logging");
        println!("-- this is printed via `println!()`");
        // trace!/debug!/info!/warn!/error! 同 log 示例
    }
}

#[ariel_os::task(autostart, usb_builder_hook)]
async fn main() {
    register_custom_transport(write_bytes, flush);

    info!("Hello World!");

    static STATE: StaticCell<State<'_>> = StaticCell::new();
    let class = USB_BUILDER_HOOK
        .with(|builder| {
            CdcAcmClass::new(
                builder,
                STATE.init_with(State::new),
                MAX_FULL_SPEED_PACKET_SIZE.into(),
            )
        })
        .await;

    let (mut sender, mut receiver) = class.split();
    loop {
        let sender_fut = async {
            sender.wait_connection().await;
            let mut buffer = [0; LOG_BUFFER_SIZE as usize];
            loop {
                let len = LOG_BUFFER.read(&mut buffer).await;
                if matches!(sender.write_all(&buffer[..len]).await, Err(CdcAcmError::NotConnected)) {
                    // If the USB connection is disconnected, wait until it's reconnected.
                    sender.wait_connection().await;
                }
            }
        };

        // The serial has to be read otherwise the USB device hangs on some MCUs.
        let receiver_fut = async {
            receiver.wait_connection().await;
            let mut buffer = [0; MAX_FULL_SPEED_PACKET_SIZE as usize];
            loop {
                if matches!(receiver.read_packet(&mut buffer).await, Err(EndpointError::Disabled)) {
                    receiver.wait_connection().await;
                }
            }
        };

        embassy_futures::join::join(sender_fut, receiver_fut).await;
    }
}

pub fn write_bytes(bytes: &[u8]) {
    let end = bytes.len();
    let mut total = 0;
    while total < end {
        let n = match LOG_BUFFER.try_write(&bytes[total..end]) {
            Ok(n) => n,
            // Pipe full, drop the data.
            Err(_) => return,
        };
        total += n;
    }
}

// No-op, flushing isn't possible with this setup.
pub fn flush() {}

laze.yml 的模块顺序藏着本篇最重要的易错点:

selects:
  - log
  - logging-over-custom-transport # Has to be selected before `usb` is selected
  - usb

逐段解析

USB_CONFIG 常量块:为什么是 const 代码块

#[ariel_os::config(usb)]
const USB_CONFIG: embassy_usb::Config<'_> = {
    let mut config = embassy_usb::Config::new(0xc0de, 0xcafe);
    ...
    config
};

配置不是函数调用而是一个 const 代码块:Config::new(vid, pid) 后逐字段修改,块表达式的值即配置。const 求值保证这一切发生在编译期——描述符数据直接进 rodata,运行时零开销。字段逐个看:

  • max_packet_size_0:端点 0(控制传输)的包大小,全速设备最大 64。
  • max_power = 100:设备从总线取电的毫安数(100 mA),主机据此决定供电预算。
  • composite_with_iads = true + device_class = 0xEF / 0x02 / 0x01:这是"复合设备 + 接口关联描述符(IAD)"的标准组合(0xEF/0x02/0x01 即 Misc/IAD 类)。Book 注释明确"Required for Windows support"——没有 IAD 的复合 CDC 设备在 Windows 上会为每个接口装一遍驱动并失败。即使示例只挂一个类,也为将来复合扩展留好了路。
  • serial_number:字符串描述符,可作主机侧区分同型设备的标识。

USB_BUILDER_HOOK:钩子的时序

USB_BUILDER_HOOK.with(|builder| CdcAcmClass::new(...)).await 这一行的执行时序是本篇的技术核心。拆开看:

  1. 宏 #[ariel_os::task(autostart, usb_builder_hook)] 除了把任务注册进 EMBASSY_TASKS,还生成一个静态 USB_BUILDER_HOOK(基于系统同步原语)。
  2. init_task 构建 UsbBuilder 后、调用 usb_builder.build() 前,检查钩子是否已被应用任务"预约"。应用任务在 autostart 任务阶段就执行到 .with(...):它把闭包挂上钩子并挂起等待。
  3. 系统对每个钩子执行闭包(闭包拿到 &mut UsbBuilder,CdcAcmClass::new 在 builder 上注册接口与端点、返回类实例),.with(...) 的 Future 完成并归还类实例给应用。
  4. 系统 usb_builder.build() 生成完整描述符集,启动 USB 任务轮询。

StaticCell<State<'_>> 是 embassy-usb 类的固定搭配:类需要 'static 的内部状态(端点缓冲管理、控制传输状态),STATE.init_with(State::new) 把栈上的 State 搬进静态内存再交给类。hid 示例的 HID_STATE、logger 示例没有显式 state(CdcAcmClass::new 的 State 参数都在)同理。

HidReaderWriter 的泛型参数 'static, UsbDriver, 1, 8 中两个数字是 reader/writer 各自的缓冲槽数——键盘场景 writer 槽 8(报告小、频率高)、reader 槽 1(键盘基本不收数据,只收 LED 状态)。split() 拆成独立的读写句柄,读写可在不同任务并发。hid::Config 里的 report_descriptor 来自 usbd_hid 的 KeyboardReport 描述符生成(编译期计算的标准 HID 报告描述符),HidSubclass::Boot + HidBootProtocol::Keyboard 让键盘在 BIOS/引导菜单这种"没有完整驱动"的环境也能工作。

KeyboardReport 的构造也有讲究:按下时发送 [KC_A,0,0,0,0,0],紧接着发送全零的"释放"报告——HID 键盘协议是状态报告制,按下/释放都显式上报,主机才认为是一次完整敲键。示例没有处理修饰键(Ctrl/Shift 的 modifier 位),打出来是小写 acgt。

自定义日志传输的管线

usb-serial-logger 把两件系统设施焊在了一起:logging-over-custom-transport 提供的 ariel_os::log::transport::register_custom_transport(write_bytes, flush)(第 7 篇讲过:回调不得等待、不得再调日志宏),以及 embassy-sync 的 Pipe(单缓冲异步管道)。

管线走向:任何任务的 info! → defmt/log facade → 自定义 transport 的 write_bytes → LOG_BUFFER.try_write(Pipe 满直接丢弃,不阻塞日志方——这是回调"不得等待"约束下的唯一选择)→ USB 任务从 Pipe read → CdcAcmClass 写给主机。

两个容易忽视的细节都写在注释里:

  • flush 是 no-op:"flushing isn't possible with this setup"——Pipe 语义上没有 flush 概念,数据随时被消费方取走。
  • receiver 任务必须存在并持续 read_packet:"The serial has to be read otherwise the USB device hangs on some MCUs."——CDC 设备若不消费 OUT 端点,某些 MCU 的 USB 驱动会因 OUT 端点 NAK 积压而卡死。这是 USB 设备开发的通用暗坑,Ariel OS 用一个"读完丢弃"的 receiver_fut 显式规避。

split() 把 CDC 类拆成 sender/receiver 两个独立端,join 并发跑收发,断连(NotConnected/EndpointError::Disabled)后 wait_connection 重挂——与 usb-serial 的外层循环同一套恢复模式,只是粒度到了半边通道。

运行现象

usb-serial(nRF52840-DK):烧录后插 USB(device 口),Linux dmesg 出现 cdc_acm 1-1:1.0: ttyACM0: USB ACM device;picocom /dev/ttyACM0 打开,敲键即回显,设备日志同步打印 INFO data: 68 65 6c 6c 6f(Hex 格式的字节流)。拔线后设备打印 Disconnected,重插重新 Connected——wait_connection 循环的现场演示。

usb-keyboard:设备枚举为 HID keyboard example,打开文本编辑器,依次按板上 USER 按钮 1-4,主机上出现 acgt。按住不放只打一个字符(按下+释放报告对),快速连按可能出现丢键——60 ms 的轮询 + 50 ms 去抖决定了事件分辨率。

usb-serial-logger:插 USB 打开串口终端(注意这是设备唯一日志通道了,没有它看不到任何日志),每秒收到一组:

INFO Testing USB CDC ACM logging
-- this is printed via `println!()`
-- info log level enabled
-- warn log level enabled
...

先开终端再烧录 vs 烧录后插线的差异值得观察:后者会丢掉启动初期(USB 尚未枚举)的日志——Pipe 只能缓存 1024 字节且丢弃策略无情,这是自定义传输换来的灵活性的代价。

常见问题

Q1:ESP32-C6-DevKitC-1 上能跑 usb-serial 吗?报错说 USB 外设不可用?

成因:C6 只有一个 USB-Serial-JTAG 外设(SBD 标志 has_usb_cdc_acm_port),它是"带 CDC-ACM 界面的调试通道",不是通用 USB device 外设——Book 明确它"不能用于通用类(仅用于日志)"。usb 模块要求 has_usb_device_port,esp32c6 上下文只 provides has_usb_cdc_acm,示例 laze 在能力校验阶段就拒绝构建。解决方案:换有通用 USB 的板(nRF52840-DK、nRF5340-DK、RP2040/RP235x、ESP32-S2/S3——S2/S3 有 OTG 且 0.4.0 起支持 CDC-NCM);C6 上的"串口类 USB 体验"由 USB-Serial-JTAG 天然提供(espflash monitor 就是走它)。

Q2:构建报错说 USB_CONFIG 与系统默认配置冲突,或设备用了默认 VID/PID?

成因:#[ariel_os::config(usb)] 提供配置的同时需要 override-usb-config feature 授权(防呆设计:无 feature 时宏不生效,系统静默用默认 VID/PID;有 config 但没 feature 则被忽略)。解决方案:Cargo features 加 "override-usb-config";反之若报"重复配置",检查是否既有 config 宏又开了其它配置路径。另外提醒:0xc0de:0xcafe 是示例占位 VID/PID,量产固件必须换 USB-IF 分配的 VID 或使用测试用途声明,主机驱动按 VID/PID 匹配时占位值会引发设备识别异常。

Q3:USB 类注入时序:应用任务里 .with() 之前系统就 build 了 USB,类没注册成功?

成因:usb_builder_hook 的握手依赖任务在系统 build 前执行到 .with(...)。autostart 任务在 init_task 中先于 USB build 启动,正常时序没问题;但如果应用任务在 .with() 之前先 await 了别的长操作(比如 network_stack() 等待 DHCP),系统钩子检查点到来时闭包还没挂上,类注册被跳过且 .with() 永不返回——双向死等。解决方案:把 USB_BUILDER_HOOK.with(...) 放在任务体第一条await 之前(usb-serial 示例正是这个顺序,info! 同步调用不算 await);确实需要先做其它异步初始化的场景,把那些工作移到 .with() 之后的任务里,或拆出专门的 USB 注入任务。

Q4:自定义日志传输丢日志严重,启动期的输出几乎全丢?

成因:register_custom_transport 的回调约束(不得等待)决定了 write_bytes 只能 try_write;Pipe 1024 字节在启动风暴(defmt 帧密集)时立刻写满,溢出即丢。且 USB 枚举完成前没有消费者,Pipe 里最早的日志也随 USB 断连窗口丢失。解决方案:加大 LOG_BUFFER_SIZE(静态内存换缓冲深度);降低启动期日志量(-DLOG=warn 提高阈值);接受"连接后日志"的现实——这也是为什么系统默认日志走 RTT(非阻塞 trimming,且 probe-rs attach 切阻塞模式不丢),USB 传输适合"设备已部署、没有调试探针"的场景而非开发期主力。

Q5:HID 键盘在部分主机上不打字,或 BIOS 里不响应?

成因:主机侧按 VID/PID 或驱动匹配问题(Windows 首次枚举要装驱动、Linux 的 udev 权限);BIOS 场景要求 Boot protocol 支持且报告描述符标准——示例用 HidSubclass::Boot + HidBootProtocol::Keyboard + 标准 KeyboardReport 描述符正是为此。另外 poll_ms = 60 的轮询间隔过大会让主机觉得设备响应迟缓。解决方案:保持 Boot 子类与标准描述符不变;检查 composite_with_iads 等配置与 usb-serial 示例一致(Windows 支持的前置);按一次键打不出字时用设备日志确认报告是否真的 write_serialize 成功——错误分支的 Failed to send report 说明 USB 层未就绪或断连。

面试考点

考点 1:Ariel OS 的 USB_BUILDER_HOOK 解决什么问题?它的握手时序中哪一步失败会导致应用永远挂起?

参考答案:问题:系统在 init_task 中集中构造并 build USB 设备,而应用需要向同一个 builder 注入自己的类——注入必须发生在 build 之前,但应用任务与系统 init 的执行交错。HOOK 提供了 rendezvous:应用任务执行 .with(closure) 挂载闭包并挂起;系统在 build 前执行所有已挂载钩子,闭包在 builder 上注册类并返回实例;应用 .with() 完成拿到实例继续业务。失败模式:若应用任务在 .with() 之前先 await 了其它未完成的 Future(网络就绪、传感器就绪等),系统钩子检查点到来时闭包未挂载,系统 build 无此类;应用侧 .with() 永不完成——双向挂起。预防:.with() 必须是任务体内第一个 await。

考点 2:CDC-ACM 复合设备为什么需要 composite_with_iads 与 0xEF/0x02/0x01 类码?删掉会怎样?

参考答案:IAD(Interface Association Descriptor)把多个接口捆绑为一个功能单元(如 CDC 需要 communication + data 两个接口)。复合设备(多类共存)在 Windows 上的驱动安装依赖 IAD 描述的分组边界;0xEF/0x02/0x01(Miscellaneous/IAD)类码组合向主机声明"我按 IAD 分组"。删掉的后果:单接口设备可能侥幸工作,但复合设备在 Windows 上每个接口被独立枚举、驱动匹配失败,表现为"设备有黄色感叹号"或只认出部分功能;macOS/Linux 相对宽容但也不保证。这是 USB IF 规范层面的兼容性要求,embassy-usb 只是把它暴露为配置项。

考点 3:usb-serial-logger 的 write_bytes 为什么用 try_write 丢弃而不是阻塞写?这个设计体现了什么嵌入式日志哲学?

参考答案:register_custom_transport 的回调运行在日志调用方上下文,契约禁止阻塞——否则任何任务的 info! 都可能被 USB 慢速传输卡住,日志从观测工具变成系统瓶颈甚至死锁源(日志回调里再调日志宏 = 自递归死锁)。try_write + 满则丢弃是唯一合规选择。哲学:日志是"尽力而为的观测",宁可丢帧也不能反压业务;与之对照,RTT 默认的 trimming 模式(缓冲满丢新数据)与 USB-Pipe(满则丢)是同一原则的两种实现。开发期要完整日志可用阻塞 RTT(probe-rs attach)换;部署期要系统活性就用丢弃式传输。设计日志通道先问"丢日志和卡系统哪个更不可接受"。

考点 4:HID 键盘报告中"按下"与"释放"都要发送的原因是什么?如果只发按下会怎样?

参考答案:HID 键盘是状态报告协议:每次报告描述"当前按下键的完整集合"(6 键无冲的 6 个 keycode 槽)。主机把"上一份报告与当前报告的差集"翻译为按下/释放事件。只发按下不发释放:主机认为键永远按着,编辑器里 a 变成 aaaaaa... 自动重复。示例先发 [KC_A,...] 再发全零报告正是完整的按下-释放对。理解这一点还能解释多键同按(一个报告含多个 keycode)、6 键限制(槽数固定)、以及"幽灵键"问题的处理(标准键盘描述符故意声明全键滚动溢出位)。

考点 5:ESP32 的 USB-Serial-JTAG 与通用 USB device 外设(S2/S3 OTG)的区别是什么?为什么 Ariel OS 不允许前者跑通用类?

参考答案:USB-Serial-JTAG 是乐鑫在 C3/C6/H2 等芯片内置的固定功能外设:它把 USB 端点硬连线到 JTAG 调试器与一个 CDC-ACM 串口,端点行为、描述符、类处理全部固化在硬件里,软件只能收发串口数据与控制复位——无法注册自定义接口/类/描述符。S2/S3 的 OTG 外设是标准 USB device 控制器,软件完全控制描述符与端点。Ariel OS 的 usb 模块构建在 embassy-usb 的驱动模型上(需要软件侧的 UsbDriver 与端点管理),Serial-JTAG 没有这个可编程层,所以只能服务于日志(logging-over-usb)与烧录(espflash 经它下载),而 S2/S3 可跑完整 USB 类栈(0.4.0 起含 CDC-NCM 网卡)。分界线是"描述符可编程性"。

小结

本篇完成了 USB device 三部曲:CDC-ACM 回显展示了系统 USB 框架的最小应用面(config 宏 + HOOK 注入 + 类实例循环);HID 键盘展示了第二个类、Boot 协议与报告语义,同时确认了 usb_builder_hook + peripherals 的组合任务形态;usb-serial-logger 则把 USB 从"应用功能"升级为"系统日志通道",暴露了自定义传输的丢弃哲学与 OUT 端点消费的暗坑。ESP32-C6 的 USB-Serial-JTAG 边界也划清了:调试通道很好用,通用 USB 要换板。

下一篇进入 BLE:Ariel OS 的 TrouBLE 集成、广播与扫描的两种角色实现。

系列目录

  1. 系列总览:Ariel OS 设计理念与生态全景
  2. 环境搭建:Rust 工具链与 laze 构建系统
  3. laze 元构建系统:contexts、modules 与 builders
  4. 第一个应用:从 minimal 到 hello-world
  5. 板卡描述体系:boards yaml 与结构化配置
  6. 烧录与调试:probe-rs、RTT 与日志查看
  7. 日志系统:defmt 与 ariel-os-log
  8. 调试控制台:shell 与交互调试
  9. 异步执行器:Embassy 集成与任务宏
  10. 内存布局与全局分配器
  11. 时钟体系与启动流程
  12. 引脚与 GPIO:静态配置与运行时切换
  13. 多线程(上):抢占式线程与线程宏
  14. 多线程(下):Channel、Event 与互斥
  15. 线程与异步互操作:multicore 与定时器
  16. 低功耗与电源管理
  17. I2C 实战:总线扫描与设备通信
  18. SPI 与 UART 实战
  19. 传感器框架:sensor API 与注册表
  20. 随机数与熵源
  21. 网络栈(上):初始化、Wi-Fi 与 UDP
  22. 网络栈(下):TCP 客户端与回显服务
  23. 应用协议:HTTP 与 CoAP
  24. USB 实战:串口与 HID 键盘
  25. BLE 实战:广播与扫描
  26. 以太网、设备元数据与身份
  27. 持久化存储
  28. 测试:embedded-test 与硬件在环
  29. 原生目标、基准测试与 Ferrocene
  30. 收官:新增板卡支持、横向对比与路线图
posted @ 2026-10-28 00:45  navysummer_hardware  阅读(12)  评论(0)    收藏  举报