Rust in Everything
基于 embassy-usb 0.6.0

类

主线位置:跨任务主线。类的 Handler 在跑 UsbDevice::run 的任务里被调用(主机发来 SET_CONTROL_LINE_STATE),它改共享状态、叫醒等在 control_changed() 上的应用任务;数据则走类自己拿着的端点,不经过设备栈。端点就绪的那一跳是中断主线,见 设备栈。

flowchart TB
  new["CdcAcmClass::new"] --> desc["写描述符"]
  new --> eps["分配三个端点"]
  new --> ctl["Control 进 handlers"]
  desc --> builder["Builder 缓冲区"]
  ctl --> run["UsbDevice run 任务"]
  run --> shared["ControlShared"]
  shared --> app["应用任务"]
  eps --> app
  app --> drv["驱动端点读写"]

embassy-usb 0.6.0 的 src/class/ 下有八个类:cdc_acm(701 行)、cdc_ncm(528 + 104 行)、hid(629 行)、midi、uac1(音频)、dfu、web_usb、cmsis_dap_v2。本页以 CDC-ACM 为主,HID 一节。实验沿用 设备栈 的假驱动:exp-embassy/usb-std,std,debug,M2 Max、macOS 27、rustc 1.99.0。

类就是三件事

embassy-usb 没有「类」这个 trait。一个类是一个普通的构造函数,拿 &mut Builder 做三件事:写描述符、分配端点、登记 Handler。CdcAcmClass::new:

    pub fn new(builder: &mut Builder<'d, D>, state: &'d mut State<'d>, max_packet_size: u16) -> Self {
        assert!(builder.control_buf_len() >= 7);

        let mut func = builder.function(USB_CLASS_CDC, CDC_SUBCLASS_ACM, CDC_PROTOCOL_NONE);

        // Control interface
        let mut iface = func.interface();
        let comm_if = iface.interface_number();
        let data_if = u8::from(comm_if) + 1;
        let mut alt = iface.alt_setting(USB_CLASS_CDC, CDC_SUBCLASS_ACM, CDC_PROTOCOL_NONE, None);

        alt.descriptor(
            CS_INTERFACE,
            &[
                CDC_TYPE_HEADER, // bDescriptorSubtype
                0x10,
                0x01, // bcdCDC (1.10)
            ],
        );
        // ... ACM 功能描述符 [CDC_TYPE_ACM, 0x02]、Union 功能描述符 [CDC_TYPE_UNION, comm_if, data_if]

        let comm_ep = alt.endpoint_interrupt_in(None, 8, 255);

        // Data interface
        let mut iface = func.interface();
        let data_if = iface.interface_number();
        let mut alt = iface.alt_setting(USB_CLASS_CDC_DATA, 0x00, CDC_PROTOCOL_NONE, None);
        let read_ep = alt.endpoint_bulk_out(None, max_packet_size);
        let write_ep = alt.endpoint_bulk_in(None, max_packet_size);

        drop(func);

        let control = state.control.write(Control {
            shared: &state.shared,
            comm_if,
        });
        builder.handler(control);
        // ...

builder.function → func.interface() → iface.alt_setting(..) → alt.descriptor(..) / alt.endpoint_*(..),每一层都可变借用上一层,描述符按调用顺序落进缓冲区,正好是 设备栈 那 70 字节的顺序:IAD、通信接口、三个功能描述符(头、ACM、Union)、中断 IN 端点、数据接口、批量 OUT、批量 IN。alt.endpoint_* 同时写端点描述符、调驱动分配端点,返回的端点对象由类自己保管。Union 描述符要写数据接口的编号,那时数据接口还没建,代码先按 comm_if + 1 算出来;func.interface() 保证编号从 0 连续分配,这样算才成立。

Handler 要以 &'d mut dyn Handler 交给 Builder,栈里没有堆可以装它。State 就是调用方提供的那块存储:

pub struct State<'a> {
    control: MaybeUninit<Control<'a>>,
    shared: ControlShared,
}

control 是留给 Handler 的位置,new 里 state.control.write(..) 把它就地构造出来、拿到 &'d mut;shared 是 Handler 和类对象之间共享的状态。State::new 是 const fn,可以放进 static。这个模式在 HID 等类里一样。thumbv7em 上 State 32 字节,CdcAcmClass 56 字节(QEMU 上 size_of 实测,见设备栈一页)。

控制请求:Control 处理三个类请求

impl Handler for Control 先看请求是不是「类请求、发给接口、wIndex 是我的通信接口」,不是就返回 None,让栈去问下一个 Handler。是的话处理三个:SET_LINE_CODING(7 字节:波特率、停止位、校验、数据位)、GET_LINE_CODING、SET_CONTROL_LINE_STATE(wValue 的 bit 0 是 DTR、bit 1 是 RTS);SEND_ENCAPSULATED_COMMAND 假装接受,其余一律 Rejected。收到之后写进 ControlShared,置 changed,叫醒 waker:

struct ControlShared {
    line_coding: CriticalSectionMutex<Cell<LineCoding>>,
    dtr: AtomicBool,
    rts: AtomicBool,

    waker: RefCell<WakerRegistration>,
    changed: AtomicBool,
}

这是跨任务主线的一个小版本:写的一方是 UsbDevice::run 所在的任务(Handler 在栈处理控制请求时被同步调用),读的一方是应用任务里的 ControlChanged::control_changed(),一个 WakerRegistration 连接两边,所以只能有一个等待者。waker 放在 RefCell 里,ControlShared 不是 Sync,持有 &ControlShared 的 Sender / Receiver 也就不是 Send。拿 Sender<'static, D> 去要求 Send(usb-std/errs/send_check.rs):

error[E0277]: `RefCell<embassy_sync::waitqueue::waker_registration::WakerRegistration>` cannot be shared between threads safely
...
note: required because it appears within the type `cdc_acm::ControlShared`
...
note: required because it appears within the type `embassy_usb::class::cdc_acm::Sender<'static, FakeDriver>`

后果是 Sender / Receiver 不能作为参数交给经 SendSpawner spawn 的任务(InterruptExecutor 给的就是 SendSpawner,为什么要求 Send 见 Spawner),它们和跑 UsbDevice::run 的任务得在同一个执行器上。

usb-std/src/bin/cdc.rs 里应用任务先等连接,再等一次 control_changed(),主机隔 50 ms 之后才发 SET_CONTROL_LINE_STATE(DTR):

  drv: accept()
  drv: wait_enabled(0x01) -> ready
app: configured, dtr false
app: control_changed, dtr false rts false
host:   status IN (ZLP) ok
app: read timed out, future dropped
host: SETUP [21 22 01 00 00 00 00 00]  SET_CONTROL_LINE_STATE DTR

control_changed() 没等到 DTR 就返回了,这时 dtr 还是 false。原因在 Handler::reset:总线复位时它把线路编码恢复默认、DTR / RTS 清零,同样置 changed。所以第一次 control_changed() 报告的是那次复位,想等主机打开串口,要在循环里检查 dtr()。默认线路编码是 LineCoding::default 的 8000 波特、8N1,枚举实验里 app: connected 那一行打印的就是 data_rate: 8000。

ACM 功能描述符的 bmCapabilities 是 0x02,注释说的是设备支持 Set_Line_Coding、Set_Control_Line_State、Get_Line_Coding 和 Serial_State 通知。Serial_State 要从那个 8 字节的中断 IN 端点发,而类把它存在 _comm_ep 字段里,0.6.0 从来没有往里写过一个字节。

数据:按包,不是字节流

CdcAcmClass 的 read_packet / write_packet 直接转给端点的 read / write,一次一包,没有中间缓冲。结构体文档写了三条约束:读的缓冲区要装得下一整包;写不能超过一包;写满一整包(64 字节)之后,主机要等到下一个短包才把数据交给上层,没有别的数据要发时要补一个空包(ZLP)。split() 把它拆成 Sender 和 Receiver,两者实现了 embedded-io-async 的 Write / Read。Sender::write:

    async fn write(&mut self, buf: &[u8]) -> Result<usize, Self::Error> {
        let len = core::cmp::min(buf.len(), self.max_packet_size() as usize);
        match self.write_packet(&buf[..len]).await {
            Ok(()) => Ok(len),
            Err(EndpointError::BufferOverflow) => unreachable!(),
            Err(EndpointError::Disabled) => Err(CdcAcmError::NotConnected),
        }
    }

    async fn flush(&mut self) -> Result<(), Self::Error> {
        Ok(())
    }

一次 write 最多发一包、返回实际写了多少;flush 什么也不做。cdc.rs 后半段写 100 字节、再 write_all 64 字节加 flush:

  drv: ep 0x82 write 64 B
app: write(100 B) -> 64
  drv: ep 0x82 write 64 B
host: IN  ep 0x82 64 B "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
app: write_all(64 B) + flush returned
host: IN  ep 0x82 64 B "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"
host: nothing more on 0x82 within 100 ms

write_all + flush 之后总线上只有一个 64 字节的满包,没有空包。假驱动的主机照单全收;按上面那条约束,真实的主机会一直等着这 64 字节后面的短包。0.6.0 上要自己 write_packet(&[])。main 上的提交 50ed5dd11(「embassy_usb, cdc_acm class: make Write::flush send a zero length packet when necessary.」,2026-10)改成 flush 在需要时补空包,还没有发版。

Receiver 的 Read 只能整包读,embedded-io-async 的 Read 却允许调用方给任意小的缓冲区。into_buffered(buf) 换成 BufferedReceiver,多一块一包大小的缓冲区(生命周期和整个设备同为 'd:在 UsbDevice 之后才声明的局部数组会报 E0597,borrow might be used here, when `usb` is dropped and runs the destructor for type `UsbDevice<'_, FakeDriver>` )。read:

    async fn read(&mut self, buf: &mut [u8]) -> Result<usize, Self::Error> {
        // If there is a buffered packet, return data from that first
        if self.start != self.end {
            return Ok(self.read_from_buffer(buf));
        }

        // If the caller's buffer is large enough to contain an entire packet, read directly into
        // that instead of buffering the packet internally.
        if buf.len() > self.receiver.max_packet_size() as usize {
            return match self.receiver.read_packet(buf).await {
                // ...
            };
        }

        // Otherwise read a packet into the internal buffer, and return some of it to the caller.
        //
        // It's important that `start` and `end` be updated in this order so they're left in a
        // consistent state if the `read` future is dropped mid-execution, e.g. from a timeout.
        match self.receiver.read_packet(&mut self.buffer).await {
            Ok(n) => self.end = n,
            // ...
        }
        self.start = 0;
        return Ok(self.read_from_buffer(buf));
    }

缓冲区里有剩的先给剩的;调用方的缓冲区比一包大就直接读进去;否则读一包进内部缓冲、给一部分。注意第二个分支用的是 >:调用方给正好 64 字节时,走的是内部缓冲、多拷一次。

❗IMPORTANT
动脑筋:4 字节的读缓冲

cdc.rs 里,应用任务先做一次 10 ms 超时的 read(这时主机还没发数据,future 被丢掉),然后用一个 4 字节的数组连续调 5 次 BufferedReceiver::read。主机在这期间依次发两包:"abcdef"(6 字节)和 "0123456789"(10 字节)。5 次 read 各返回什么?假驱动的 OUT 端点一共被 read 了几次?答案在本页末尾。

最后一个分支的顺序有讲究:先 await 到新包、写 end,之后才把 start 归零。read_packet 的 future 被丢掉时,这两行一行也没执行,start == end 保持原样,下一次 read 照常去等新包。

📝NOTE
冷知识:读到一半被取消,下次会重复上一包

PR #4963(2025-11,首次进入 embassy-usb 0.6.0,CHANGELOG 记作「Fix a bug where CDC ACM BufferedReceiver repeats data when its future is dropped」)之前,最后一段是先 self.start = 0;,再 self.end = self.receiver.read_packet(&mut self.buffer).await?;。read 的 future 停在 await 上时被丢掉(例如外面套了 with_timeout),start 已经归零,end 还是上一包的长度,下一次 read 看到 start != end,把上一包的数据又交了一遍。修法只是把两行换个顺序,并加上上面代码里那段注释。

wait_connection 就是 read_ep.wait_enabled():等的是主机发 SET_CONFIGURATION、栈打开端点,跟 DTR 无关。所以「连接上了」不等于「主机上有程序打开了这个串口」,后者要看 dtr()。主机发 SET_CONFIGURATION(0)、或驱动报告 PowerRemoved 时端点被关,read_packet / write_packet 返回 EndpointError::Disabled,embedded-io 那一层映射成 CdcAcmError::NotConnected,例子里的回显循环据此回到 wait_connection。

HID

hid::Config 带报告描述符(调用方给的字节串)、可选的 RequestHandler、轮询间隔、包长、子类和启动协议。构造函数只有两个:HidWriter::new(只有中断 IN)和 HidReaderWriter::new(IN + OUT),共用一个内部的 build,三件事和 CDC-ACM 一样:一个接口、一个 HID 类描述符(9 字节,里面记着报告描述符的长度)、一两个中断端点、一个 Control 进 handlers。limits.rs 的 hid 用例,一个 23 字节报告描述符的启动键盘:

    [  0]  9 B  09 02 2a 00 01 01 00 80 32 CONFIGURATION wTotalLength 42 bNumInterfaces 1 bConfigurationValue 1 bmAttributes 80 bMaxPower 50 (100 mA)
    [  9]  8 B  08 0b 00 01 03 01 01 00    IAD bFirstInterface 0 bInterfaceCount 1 function 03/01/01
    [ 17]  9 B  09 04 00 00 01 03 01 01 00 INTERFACE #0 alt 0 bNumEndpoints 1 class 03/01/01 iInterface 0
    [ 26]  9 B  09 21 10 01 00 01 22 17 00 type 0x21
    [ 35]  7 B  07 05 81 03 08 00 0a       ENDPOINT 0x81 interrupt wMaxPacketSize 8 bInterval 10
report descriptor 23 B

只有一个接口也照样有 IAD,因为默认的 composite_with_iads 对每个 function 都写一个。报告描述符本身不在配置描述符里:主机读到类型 0x21 的 HID 描述符(22 17 00:类型 0x22、长 23),再发一个接收者为接口的 GET_DESCRIPTOR(0x22)。这个标准请求栈不认识,转给 Handler,HID 的 control_in 先比 wIndex 和自己的接口号,再回 report_descriptor。所以 Handler 不只接类请求,标准请求里栈不处理的那部分也会落到它头上。

数据面和 CDC-ACM 不同,HID 按「报告」收发,报告可以比一包长。HidWriter::write 把报告切成包,长度小于 N(报告最大长度)又是整包倍数时补空包;HidReader::read 跨包拼报告,已收到的字节数存在 State 的 out_report_offset: AtomicUsize 里,future 中途被丢掉之后,下一次 read 不从头开始,而是返回 ReadError::Sync(range),告诉调用方这一次只填了缓冲区的哪一段。这是对上面 CDC-ACM 那类取消问题的另一种处理:不假装没发生,把半截报告的事实交给调用方。

控制管道上的 GET_REPORT、SET_REPORT、SET_IDLE、SET_PROTOCOL 等转给用户实现的 RequestHandler;没给 RequestHandler 时,SET_PROTOCOL(Boot) 被拒绝、GET_PROTOCOL 回「报告协议」。启动协议模式(Boot Protocol Mode)的支持是 0.6.0 加的(CHANGELOG「Add support for USB HID Boot Protocol Mode」)。

其他类,和 cdc_ncm 接 embassy-net

其余的类同一个套路,各管一种协议:cdc_ncm(USB 网卡)、midi、uac1(USB 音频 1.0 扬声器)、dfu(固件升级,app_mode 用到 embassy-time,这是 embassy-usb 依赖 embassy-time 的唯一原因)、web_usb、cmsis_dap_v2(调试探针)。cdc_ncm 多一层:into_embassy_net_device 用 embassy-net-driver-channel 把类的收发端点接成一个 embassy-net 驱动,返回的 Runner::run 一边把 USB 收到的帧塞进通道、一边把通道里的帧写出去,链路状态跟着 wait_connection 走;通道这一层见 驱动接口。

动脑筋答案

out-cdc.txt 里对应的几行:

app: read timed out, future dropped
...
host: OUT ep 0x01 6 B "abcdef"
host: OUT ep 0x01 10 B "0123456789"
  drv: ep 0x01 read -> 6 B
app: read(4 B buf) -> 4 "abcd"
app: read(4 B buf) -> 2 "ef"
  drv: ep 0x01 read -> 10 B
app: read(4 B buf) -> 4 "0123"
app: read(4 B buf) -> 4 "4567"
app: read(4 B buf) -> 2 "89"
  • 被超时丢掉的那次 read 停在内部缓冲分支的 read_packet().await 上,start、end 都还是 0,什么也没留下。
  • 第 1 次:缓冲区空,4 字节不大于一包,读一整包 "abcdef" 进内部缓冲,给出 4 字节。第 2 次:缓冲区里剩 2 字节,直接给,不跨包拼接,所以返回 2 而不是 4。
  • 第 3 到 5 次:同样的规则作用在 10 字节的第二包上,4 + 4 + 2。
  • 端点只被读了 2 次,每次一整包;5 次 read 里有 3 次没碰驱动。
  • 主机连发两包没有被挡住,是因为假驱动的 OUT 通道容量为 1、第一包已被取走;真实硬件上这一步是端点在 read 之前一直回 NAK。

小结

  • embassy-usb 没有类 trait:一个类就是一个构造函数,往 Builder 写描述符、分配端点、登记一个 Handler;Handler 的存储由调用方的 State 提供(MaybeUninit 就地构造),没有堆。
  • CDC-ACM 的 Control 处理线路编码和 DTR / RTS,通过单个 WakerRegistration 叫醒应用任务;总线复位也会触发一次 control_changed();ControlShared 里有 RefCell,Sender / Receiver 不是 Send。
  • 数据按包收发:Sender::write 一次最多一包,0.6.0 的 flush 不补空包,写满整包后要自己发空包;BufferedReceiver 每次最多交出当前这一包剩下的部分。
  • wait_connection 等的是 SET_CONFIGURATION 打开端点,不是 DTR;bmCapabilities 声称支持 Serial_State 通知,中断端点却从不使用。
  • HID 的报告描述符由 Handler 应答接口上的标准 GET_DESCRIPTOR;读报告被取消时返回 ReadError::Sync,而不是悄悄重来。
  • cdc_ncm 经 embassy-net-driver-channel 变成一个 embassy-net 驱动。

💬 关联讨论

发起讨论
加载中...