设备栈
主线位置:中断主线的 USB 版本。主机发来一个 SETUP 包,OTG_FS 的中断处理函数把 8 个字节存进驱动的
State、叫醒 EP0 的 waker;跑着UsbDevice::run的任务被 poll,ControlPipe::setup()返回这 8 个字节,栈解析请求、分包回复。和 GPIO 与 EXTI 是同一个形状:处理函数只搬数据、叫醒,所有协议逻辑都在任务里。
sequenceDiagram participant H as 主机 participant I as OTG 中断 participant D as 驱动 participant R as UsbDevice run participant A as 类的任务 H->>I: SETUP 包 I->>I: 存进 State I->>R: wake EP0 waker R->>D: setup 返回 8 字节 R->>R: Request parse R->>D: data_in 分包 D->>H: IN 数据 H->>I: SET_CONFIGURATION R->>D: endpoint_set_enabled D->>A: wait_enabled 就绪
embassy-usb 0.6.0(tag embassy-usb-v0.6.0)的设备栈在 src/lib.rs(787 行)、builder.rs(653 行)、descriptor.rs(434 行)、control.rs(146 行)、descriptor_reader.rs(111 行)、msos.rs(724 行),类在 class/ 下,见 类。它和硬件之间隔着 embassy-usb-driver 的 trait。新项目解析到的是 embassy-usb-driver 0.2.2,发布于 2026-05-28,比本卷锁定的这一批晚两个月;它在 0.2.1 加的 read_transfer / write_transfer 两个默认方法,embassy-usb 0.6.0 一处也没调用。
本页的实验有三种。std:exp-embassy/usb-std,自己写的假驱动(435 行)加一个同进程里的「主机」,debug 构建,M2 Max、macOS 27、rustc 1.99.0。交叉编译:exp-embassy/usb-xc,thumbv7em-none-eabihf,release,opt-level = "s",未打补丁的 embassy-stm32 0.6.0。QEMU:exp-embassy/usb-qemu,QEMU 11.1.2 b-l475e-iot01a,embassy-stm32 打了模板那一行补丁(注释掉 rcc/bd.rs 的 while !cr.read().dbp() {})。QEMU 没有给 OTG_FS 建模,读寄存器得 0、写入被忽略,所以 USB 在 QEMU 上枚举不了;能跑的只有不碰 USB 寄存器的那一段。
两层:栈和驱动
embassy-usb-driver 设备侧的 trait 都在 src/lib.rs(485 行;0.2.1 另加了主机侧的 host.rs,411 行),一共五个,Driver 是入口:
pub trait Driver<'a> {
type EndpointOut: EndpointOut + 'a;
type EndpointIn: EndpointIn + 'a;
type ControlPipe: ControlPipe + 'a;
type Bus: Bus + 'a;
fn alloc_endpoint_out(
&mut self,
ep_type: EndpointType,
ep_addr: Option<EndpointAddress>,
max_packet_size: u16,
interval_ms: u8,
) -> Result<Self::EndpointOut, EndpointAllocError>;
// ... alloc_endpoint_in
fn start(self, control_max_packet_size: u16) -> (Self::Bus, Self::ControlPipe);
}
两个阶段在类型上分开:alloc_endpoint_* 借 &mut self,只能在建设备时调;start(self) 吃掉驱动,换回 Bus(poll 等总线事件、endpoint_set_enabled、endpoint_set_stalled)和 ControlPipe(EP0 的 setup、data_in、data_out、accept、reject、accept_set_address)。start 之后再也拿不到新端点,「运行中分配端点」这种状态写不出来。端点对象 EndpointIn / EndpointOut 交给类,类拿着它们读写,和 Bus 没有引用关系。
ControlPipe 单独成一个 trait,不复用 EndpointIn / EndpointOut,文档给的理由是很多控制器对 EP0 有专门的寄存器和流程。它的注释写明了栈会按什么顺序调用:数据阶段为空的请求是 setup() → accept() 或 reject();IN 请求是 setup() → 若干次 data_in(first, last),不再调 accept(),状态阶段由驱动在 last = true 时自己完成;SET_ADDRESS 走 accept_set_address(addr),因为有的控制器(Synopsys OTG)要求先改地址再回状态包,有的反过来。
Builder:描述符写进调用方给的缓冲区
Builder::new 要四块缓冲区,全由调用方提供:
pub fn new(
driver: D,
config: Config<'d>,
config_descriptor_buf: &'d mut [u8],
bos_descriptor_buf: &'d mut [u8],
msos_descriptor_buf: &'d mut [u8],
control_buf: &'d mut [u8],
) -> Self {
没有堆,所以描述符的总长在运行前不知道、也不能扩容。DescriptorWriter::write 是唯一的写入口,每写一个描述符先检查剩余空间:
assert!(
(self.position + 2 + total_length) <= self.buf.len() && (total_length + 2) <= 255,
"Descriptor buffer full"
);
配置描述符里有几个字段要等后面的内容写完才知道:bNumInterfaces、每个接口的 bNumEndpoints、IAD 的 bInterfaceCount、整段的 wTotalLength。写的时候先填 0,记下位置(num_interfaces_mark、num_endpoints_mark、FunctionBuilder 的 iface_count_index),之后每加一个接口或端点就回去把那个字节加 1;build 最后把 wTotalLength 补上。设备描述符固定 18 字节,device_descriptor 直接从 Config 拼一个 [u8; 18],不占调用方的缓冲区。
接口的个数和 Handler 的个数是编译期常量:build.rs 生成 MAX_INTERFACE_COUNT、MAX_HANDLER_COUNT(默认都是 4),由 max-interface-count-N / max-handler-count-N feature(N 为 1 到 8)或环境变量 EMBASSY_USB_MAX_INTERFACE_COUNT 改,存在 heapless::Vec 里。
每块缓冲区该多大、不够时发生什么,用 usb-std/src/bin/limits.rs 逐个试了(CDC-ACM 设备,配置描述符正好 70 字节,产品字符串 "USB-serial example";输出在 out-limits.txt,panic 位置都以 panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/embassy-usb-0.6.0/src/ 开头,表里只留文件名):
| 缓冲区 / 限制 | 试的值 | 结果 |
|---|---|---|
config_descriptor_buf | 70 | 正常 |
| 69 | descriptor.rs:109:9: Descriptor buffer full | |
bos_descriptor_buf | 0 到 4 | 正常,不写 BOS,主机要 BOS 时回 0 字节 |
| 5、11 | descriptor.rs:412:9: Descriptor buffer full | |
| 12 | 正常(BOS 头 5 字节 + USB 2.0 扩展能力 7 字节) | |
control_buf | 6 | cdc_acm.rs:244:9: assertion failed: builder.control_buf_len() >= 7 |
| 7 | 能建,SET_ADDRESS、BOS 正常,主机一要产品字符串就 lib.rs:758:29: control buffer too small(运行期) | |
| 接口数 | 3 个 CDC-ACM(6 个接口) | builder.rs:342:9: embassy-usb: interface list full. Increase the `max_interface_count` compile-time setting. Current value: 4 |
Handler 数 | 第 5 个 | builder.rs:276:9: embassy-usb: handler list full. Increase the `max_handler_count` compile-time setting. Current value: 4 |
| 端点 | 驱动分配失败 | builder.rs:482:14: alloc_endpoint_in failed: EndpointAllocError |
max_packet_size_0 | 63 | builder.rs:191:18: invalid max_packet_size_0, the allowed values are 8, 16, 32 or 64 |
max_power | 501 | builder.rs:184:9: The maximum allowed value for `max_power` is 500mA |
除了 control_buf 那一行,全部在 Builder 阶段、上电后第一次运行就暴露。UsbDevice::buffer_usage() 返回实际用了多少(config_descriptor_used: 70, bos_descriptor_used: 12),可以先给大缓冲区跑一次再收紧。BOS 只有 0 到 4 字节时被跳过,是 PR #3296(embassy-usb 0.4.0)加的 bos 开头那个 < 5 判断;5 到 11 字节则写了头、写不下能力描述符,照样 panic。
Builder::new 开头还有一条检查:composite_with_iads 为真时,device_class / device_sub_class / device_protocol 必须是 0xEF / 0x02 / 0x01。
PR #3682(2024-12,首次进入 embassy-usb 0.4.0,CHANGELOG 标为 breaking change)之前,Config::new 给的是 device_class: 0x00 和 composite_with_iads: false,没有 IAD。提交「Default USB to use composite_with_iads, which "just works" in most cases」把默认值改成 0xEF / 0x02 / 0x01 加 composite_with_iads: true,CHANGELOG 的说法是这样在「多接口的类或多个类」时更容易直接能用。于是现在每个 builder.function(..) 都会先写一个 8 字节的 IAD。改默认值的代价落在老代码上:cdc_acm::USB_CLASS_CDC 的文档注释仍写着「This should be used as device_class」,照做(config.device_class = USB_CLASS_CDC,其余不动)在 0.6.0 上直接 panic 在 builder.rs:179:13:if composite_with_iads is set, you must set device_class = 0xEF, device_sub_class = 0x02, device_protocol = 0x01。要用 0x02 必须同时关掉 composite_with_iads、把子类和协议设成 0,这时配置描述符少了 IAD,是 62 字节。
一次枚举:假驱动上的实测
QEMU 没有 USB,本机没有开发板,于是在 std 上写了一个假驱动:FakeDriver 实现 Driver,FakeBus、FakeControl、FakeEp 分别实现 Bus、ControlPipe、EndpointIn / EndpointOut。它们和「主机」之间用 embassy-sync 的 Channel 连接:主机往 setup 通道里放 8 个字节,FakeControl::setup() 就返回;data_in 把一包数据送进 ep0_in 通道,主机收。
impl ControlPipe for FakeControl {
async fn setup(&mut self) -> [u8; 8] {
let s = self.w.setup.receive().await;
drv!(self.w, "setup() -> [{}]", hex(&s));
s
}
async fn data_in(&mut self, data: &[u8], first: bool, last: bool) -> Result<(), EndpointError> {
drv!(self.w, "data_in({} B, first {}, last {})", data.len(), first, last);
let bytes = Pkt::from_slice(data).map_err(|_| EndpointError::BufferOverflow)?;
self.w.ep0_in.send(Ep0Reply::Data { bytes, first, last }).await;
Ok(())
}
// ... accept / reject / accept_set_address 各送一个 Ep0Reply
}
假驱动没有模拟的东西:没有中断(唤醒来自 Channel 自己的 waker),没有 NAK、数据翻转位和时序,状态阶段是隐含的(data_in(last = true) 之后主机直接认为状态包已发)。bus.poll() 第一次返回 PowerDetected,这一点照抄 synopsys-otg 在不开 VBUS 检测时的行为;之后的 Reset 由主机发。设备一侧和 embassy 的 stm32l4 例子 usb_serial.rs 一样:Config::new(0xc0de, 0xcafe)、厂商 / 产品 / 序列号三个字符串、256 + 256 + 64 字节缓冲区、一个 CdcAcmClass,和主机脚本用 join3 跑在同一个任务里(usb-std/src/bin/enum.rs,输出全文在 out-enum.txt):
drv: alloc_endpoint_In(Interrupt, mps 8, interval 255) -> 0x81
drv: alloc_endpoint_Out(Bulk, mps 64, interval 0) -> 0x01
drv: alloc_endpoint_In(Bulk, mps 64, interval 0) -> 0x82
drv: start(control_max_packet_size = 64)
buffer_usage: UsbBufferReport { config_descriptor_used: 70, bos_descriptor_used: 12, msos_descriptor_used: 0, control_buffer_size: 64 }
drv: bus.poll() -> PowerDetected
drv: bus.enable()
host: bus reset
host: SETUP [80 06 00 01 00 00 40 00] GET_DESCRIPTOR(DEVICE) len 64
drv: bus.poll() -> Reset
drv: setup() -> [80 06 00 01 00 00 40 00]
drv: data_in(18 B, first true, last true)
host: IN 18 B 12 01 10 02 ef 02 01 40 de c0 fe ca 10 00 01 02 03 01
host: status OUT (ZLP), total 18 B
host: bus reset
host: SETUP [00 05 05 00 00 00 00 00] SET_ADDRESS(5)
drv: bus.poll() -> Reset
drv: setup() -> [00 05 05 00 00 00 00 00]
drv: accept_set_address(5)
host: status IN (ZLP) ok, device now at address 5
...
host: SETUP [80 06 00 02 00 00 ff 00] GET_DESCRIPTOR(CONFIGURATION) len 70
drv: setup() -> [80 06 00 02 00 00 ff 00]
drv: data_in(64 B, first true, last false)
drv: data_in(6 B, first false, last true)
...
host: SETUP [80 06 09 03 09 04 ff 00] GET_DESCRIPTOR(STRING 9) len 255
drv: setup() -> [80 06 09 03 09 04 ff 00]
drv: reject()
host: STALL
host: SETUP [00 09 01 00 00 00 00 00] SET_CONFIGURATION(1)
drv: setup() -> [00 09 01 00 00 00 00 00]
drv: endpoint_set_enabled(0x81, true)
drv: endpoint_set_enabled(0x01, true)
drv: endpoint_set_enabled(0x82, true)
drv: accept()
drv: wait_enabled(0x01) -> ready
逐段看:
- 端点在
CdcAcmClass::new里分配,顺序是中断 IN、批量 OUT、批量 IN。假驱动和 synopsys-otg 一样,每个方向从 1 号开始找空位,所以地址是0x81、0x01、0x82。start在builder.build()里调,传入max_packet_size_0。 - 主机要 64 字节的设备描述符,栈回 18 字节:一个 IN 包,
first和last都为真。 SET_ADDRESS不走accept(),走accept_set_address(5)。- 配置描述符 70 字节,EP0 每包 64 字节,于是两次
data_in:64 + 6。 - 9 号字符串不存在,栈回
reject(),驱动该做的是让 EP0 STALL。 SET_CONFIGURATION(1)之后栈按配置描述符里列出的端点逐个endpoint_set_enabled,类那边等在wait_enabled上的任务随即就绪。
主机收到的 70 字节配置描述符,按描述符拆开:
[ 0] 9 B 09 02 46 00 02 01 00 80 32 CONFIGURATION wTotalLength 70 bNumInterfaces 2 bConfigurationValue 1 bmAttributes 80 bMaxPower 50 (100 mA)
[ 9] 8 B 08 0b 00 02 02 02 00 00 IAD bFirstInterface 0 bInterfaceCount 2 function 02/02/00
[ 17] 9 B 09 04 00 00 01 02 02 00 00 INTERFACE #0 alt 0 bNumEndpoints 1 class 02/02/00 iInterface 0
[ 26] 5 B 05 24 00 10 01 CS_INTERFACE Header bcdCDC 0110
[ 31] 4 B 04 24 02 02 CS_INTERFACE ACM bmCapabilities 02
[ 35] 5 B 05 24 06 00 01 CS_INTERFACE Union control #0 subordinate #1
[ 40] 7 B 07 05 81 03 08 00 ff ENDPOINT 0x81 interrupt wMaxPacketSize 8 bInterval 255
[ 47] 9 B 09 04 01 00 02 0a 00 00 00 INTERFACE #1 alt 0 bNumEndpoints 2 class 0a/00/00 iInterface 0
[ 56] 7 B 07 05 01 02 40 00 00 ENDPOINT 0x01 bulk wMaxPacketSize 64 bInterval 0
[ 63] 7 B 07 05 82 02 40 00 00 ENDPOINT 0x82 bulk wMaxPacketSize 64 bInterval 0
wTotalLength 70、bNumInterfaces 2、IAD 的 bInterfaceCount 2、两个接口的 bNumEndpoints 1 和 2,都是写完之后回填的那几个字节。bmAttributes 80 是总线供电、不支持远程唤醒,bMaxPower 50 是 max_power / 2(单位 2 mA)。设备描述符 12 01 10 02 ef 02 01 40 de c0 fe ca 10 00 01 02 03 01:bcdUSB 0x0210、类 ef/02/01、bMaxPacketSize0 64、VID 0xc0de、PID 0xcafe、bcdDevice 0x0010、三个字符串索引 1 / 2 / 3(固定编号,类自己的字符串从 4 开始)、一个配置。bcdUSB 默认是 2.1。栈还写了 12 字节的 BOS(05 0f 0c 00 01 07 10 02 00 00 00 00:BOS 头加一个全零的 USB 2.0 扩展能力);写不写只看 BOS 缓冲区有没有 5 字节,和 bcdUSB 无关。
run:一个 select 循环
run 永不返回,里面是 run_until_suspend 和 wait_resume 交替:
pub async fn run_until_suspend(&mut self) {
while !self.inner.suspended {
let control_fut = self.control.setup();
let bus_fut = self.inner.bus.poll();
match select(bus_fut, control_fut).await {
Either::First(evt) => self.inner.handle_bus_event(evt).await,
Either::Second(req) => self.handle_control(req).await,
}
}
}
整个设备栈就是这一个任务:等「总线事件」或「SETUP 包」,谁先来处理谁,处理完再进下一轮。select 来自 embassy-futures(见 embassy-futures),总是先 poll 第一个参数,所以同时就绪时总线事件优先。处理一个控制请求的过程中(比如 data_in 在等主机取走数据),栈不 poll 总线事件;驱动 trait 的文档因此要求:控制传输进行中来了新的 SETUP,data_in / data_out 要立刻返回 EndpointError::Disabled,让栈回到循环顶上重新 setup()。
问: 每来一个总线事件,select 就把 setup() 的 future 丢掉一次。SETUP 包恰好在这时到了,会不会丢?
答: 不会,因为 future 本身什么也不存。synopsys-otg 的中断处理函数从 RX FIFO 读到 SETUP 数据时把两个字存进 State 里的 setup_data: [AtomicU32; 2],SETUP 阶段完成的端点中断里再置 setup_ready;setup() 只是一个 poll_fn,每次 poll 看一眼这个标志。被 drop、下一轮重新建,看到的是同一个标志。假驱动用 Channel::receive,同样可以随时丢弃。反过来,一个把「已收到的包」存在 future 自己状态里的驱动,放在这里就会丢请求。trait 文档没有写这条要求,是 select 的用法隐含的。
handle_control_in 负责把回复切成包:
let mut resp_length = req.length as usize;
let max_packet_size = self.control.max_packet_size();
// If we don't have an address yet, respond with max 1 packet.
// The host doesn't know our EP0 max packet size yet, and might assume
// a full-length packet is a short packet, thinking we're done sending data.
// See https://github.com/hathach/tinyusb/issues/184
if self.inner.address == 0 && max_packet_size < DEVICE_DESCRIPTOR_LEN && max_packet_size < resp_length {
trace!("received control req while not addressed: capping response to 1 packet.");
resp_length = max_packet_size;
}
match self.inner.handle_control_in(req, self.control_buf) {
InResponse::Accepted(data) => {
let len = data.len().min(resp_length);
let need_zlp = len != resp_length && (len % max_packet_size) == 0;
let chunks = data[0..len]
.chunks(max_packet_size)
.chain(need_zlp.then(|| -> &[u8] { &[] }));
上面那段「没地址就只回一包」来自提交「usb: fix slow enumeration with EP0 max_packet_size of 8 or 16.」(2022-04,随 #657 合入,首次发布在 embassy-usb 0.1.0)。主机枚举的第一步是在地址 0 上要设备描述符,这时它还不知道设备的 EP0 包长,按注释的说法,主机可能把一个满包当成短包、以为数据已经传完,提交标题里的症状是 EP0 包长为 8 或 16 时枚举变慢。注释引用的是 TinyUSB 的 issue 184,修法是:地址为 0、EP0 包长小于 18 时只回一个包,设备描述符的前 8 个字节里已经有 bMaxPacketSize0。当时这段代码判断的是 self.pending_address == 0、包长取自 self.config.max_packet_size_0;0.6.0 改成看 self.inner.address 和 ControlPipe::max_packet_size(),逻辑不变。
两条规则。一是回复不超过主机要的长度(wLength)。二是零长度包(ZLP):回复比主机要的短、又恰好是整包的倍数时,主机分不清「结束了」还是「还有」,要补一个空包表示结束。first_last 给每一块标上是不是第一块、最后一块,驱动据此决定何时完成状态阶段。
把 max_packet_size_0 设成 8,产品字符串设成 "abc"(字符串描述符 2 + 3 × 2 = 8 字节)。主机依次发:地址 0 时要 64 字节设备描述符;SET_ADDRESS(5);再要 64 字节设备描述符;要 255 字节的 2 号字符串;要 8 字节的 2 号字符串。每次栈调了几次 data_in、各多少字节?答案在本页末尾。
标准请求与设备状态
Inner::handle_control_out 和 Inner::handle_control_in 是两个大 match,先按 (request_type, recipient)、再按请求码分支。标准请求栈自己处理,其余(类请求、厂商请求,以及发给接口的非标准部分)按登记顺序问每个 Handler,第一个返回 Some 的说了算,全都返回 None 就 Rejected。Handler 的方法全有默认实现:reset、addressed、configured、suspended 这些通知,加上 control_in、control_out、get_string。
设备状态 UsbDeviceState 的转换:
stateDiagram-v2 [*] --> Unpowered Unpowered --> DefaultState: PowerDetected DefaultState --> DefaultState: Reset DefaultState --> Addressed: SET_ADDRESS Addressed --> Configured: SET_CONFIGURATION 1 Configured --> Addressed: SET_CONFIGURATION 0 Addressed --> DefaultState: Reset Configured --> DefaultState: Reset DefaultState --> Unpowered: PowerRemoved
图里的 DefaultState 就是 UsbDeviceState::Default。挂起不在这个枚举里,是单独的 suspended 标志,run_until_suspend 看到它就退出、换 wait_resume。SET_CONFIGURATION(1) 那一支:
(Request::SET_CONFIGURATION, CONFIGURATION_VALUE_U16) => {
debug!("SET_CONFIGURATION: configured");
self.device_state = UsbDeviceState::Configured;
// Enable all endpoints of selected alt settings.
foreach_endpoint(self.config_descriptor, |ep| {
let iface = &self.interfaces[ep.interface.0 as usize];
self.bus
.endpoint_set_enabled(ep.ep_address, iface.current_alt_setting == ep.interface_alt);
})
.unwrap();
栈不另存一张端点表,foreach_endpoint 把自己写出去的配置描述符重新解析一遍,每遇到一个端点描述符就按「它所在接口的当前备用设置是不是这一个」决定开关。SET_INTERFACE 也是同样的遍历,只处理那一个接口。只支持一个配置(CONFIGURATION_VALUE = 1)。
字符串描述符在 handle_get_descriptor 里现场编码:0 号固定回 04 03 09 04(只支持英语(美国));1 到 3 号取 Config 的三个字符串;其他编号问各个 Handler::get_string。编码写进 control_buf:
if let Some(s) = s {
assert!(buf.len() >= 2, "control buffer too small");
buf[1] = descriptor_type::STRING;
let mut pos = 2;
for c in s.encode_utf16() {
assert!(pos + 2 < buf.len(), "control buffer too small");
buf[pos..pos + 2].copy_from_slice(&c.to_le_bytes());
pos += 2;
}
buf[0] = pos as u8;
这段有两处和边界有关,都用 limits 跑出来了。第一,断言是 pos + 2 < buf.len(),写最后一个字符时恰好填满缓冲区也会失败:control_buf 64 字节,30 个字符(62 字节)正常,31 个字符(正好 64 字节)panic 在 lib.rs:758:29: control buffer too small;同一个字符串在 128 字节的 control_buf 下正常返回 64 字节。第二,字符串描述符的 bLength 只有一个字节,最长 255,这里不检查,pos as u8 直接截断:200 个字符、512 字节的 control_buf,栈回 402 字节,开头却是 92 03(bLength 146 = 402 − 256),主机按 146 字节解析,剩下 256 字节的第一个字节 0x78(字符 x)被当成下一个描述符的长度 120。两处在 main 上都没改。
还有一处和 USB 2.0 规范不一致:只支持全速的设备应当拒绝 DEVICE_QUALIFIER 请求,0.6.0 照样回 10 字节(实验输出里 0a 06 10 02 ef 02 01 40 01 00)。main 上的 PR #5981 改成只有最高速度高于全速时才回,还没有发版。
驱动那一头:synopsys-otg
STM32L475 的 USB 是 OTG_FS,一个 Synopsys 的 OTG 控制器。embassy-stm32 0.6.0 的 usb/ 只有三个文件:mod.rs(135 行,时钟和电源的公共初始化)、otg.rs(610 行)和 usb.rs(1289 行,F1、L0、G4 这类 ST 自己的 USB 外设)。otg.rs 是一层薄包装,真正的驱动在单独的 crate embassy-usb-synopsys-otg(新项目解析到 0.3.3,2026-05-04 发布,src/lib.rs 1573 行)。
中断主线的那一跳在 on_interrupt。总线类中断(复位、挂起、唤醒、枚举完成、VBUS)它不处理,只屏蔽掉、叫醒 bus_waker;收到的包从共享的 RX FIFO 搬进每个 OUT 端点自己的缓冲区,置 out_size,叫醒那个端点的 out_waker;SETUP 包存进 setup_data:
let ints = r.gintsts().read();
if ints.wkupint() || ints.usbsusp() || ints.usbrst() || ints.enumdne() || ints.otgint() || ints.srqint() {
// Mask interrupts and notify `Bus` to process them
r.gintmsk().write(|w| {
w.set_iepint(true);
w.set_oepint(true);
w.set_rxflvlm(true);
});
state.bus_waker.wake();
}
Bus::poll 在任务里读 GINTSTS,逐个清标志、重新打开中断,翻译成 Event:USBRST 时重新分配 FIFO、地址清 0,等 ENUMDNE(速度协商完)才返回 Event::Reset。不开 VBUS 检测时,第一次 poll 直接返回 PowerDetected,这正是假驱动照抄的那一条。处理函数、State 里的 AtomicWaker、任务里的 poll_fn,和 GPIO 与 EXTI 的 EXTI 驱动是同一套做法。
OTG 的 RX FIFO 所有 OUT 端点共用,处理函数要立刻把包搬走,所以 Driver::new_fs 要一块 ep_out_buffer,alloc_endpoint 每分配一个 OUT 端点切走 max_packet_size 字节。分配只是记账,不碰寄存器,可以在 QEMU 上验证(QEMU 11.1.2 b-l475e-iot01a,一行补丁,usb-qemu/out-probe-*.txt)。CDC-ACM 有一个 64 字节的批量 OUT,start 里 EP0 的 OUT 方向还要 64 字节。ep_out_buffer 给 63 字节(out-probe-small.txt):
before init
init done, usb clock 48000000 Hz
panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/embassy-usb-0.6.0/src/builder.rs:517:14:
alloc_endpoint_out failed: EndpointAllocError
给 127 字节(out-probe-mid.txt):
before init
init done, usb clock 48000000 Hz
panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/embassy-usb-synopsys-otg-0.3.3/src/lib.rs:537:14:
called `Result::unwrap()` on an `Err` value: EndpointAllocError
所以这个设备的 ep_out_buffer 至少 128 字节,例子给了 256。第二种失败发生在 builder.build() 调 start 时,栈层面看不到原因,驱动里那句 error!("Not enough endpoint out buffer capacity") 只在开了 defmt 或 log 时才打印。
硬件初始化推迟到第一次 Bus::poll(embassy-stm32 的 Bus::init),Driver::new_fs 和 Builder 都不碰 USB 寄存器。QEMU 上这条分界看得很清楚,usb-qemu/src/bin/probe.rs 的输出:
before init
init done, usb clock 48000000 Hz
built: UsbBufferReport { config_descriptor_used: 70, bos_descriptor_used: 12, msos_descriptor_used: 0, control_buffer_size: 64 }
size_of UsbDevice 348 CdcAcmClass 56 State 32
calling usb.run()
[runq: killed after 3s]
usb.run() 之后再无输出。-d unimp 的日志里先是一次 OTG_FS: unimplemented device read (size 4, offset 0x0003c)(核心 ID 寄存器 CID),接着 5 秒内 2020763 次 offset 0x00010(GRSTCTL):卡在 Bus::init 的 while !r.grstctl().read().ahbidl() {},等一个 QEMU 永远不会置的「AHB 空闲」位。这又是一处「HAL 假设硬件会回应」,和 init 与时钟 里的三处同类。另外两种在第一次 poll 才暴露的错误:
- USB 时钟没配。
common_init先查 USB 时钟是不是 48 MHz(容差 0.25%)。L475 的clk48sel复位值指向 HSI48,而这颗芯片没有 HSI48。不改config.rcc.mux.clk48sel时,QEMU 上calling usb.run()之后 panic 在生成代码_generated.rs:2567:9:unwrap of `crate::rcc::get_freqs().hsi48.to_hertz()` failed: peripheral 'USB_OTG_FS' is configured to use the 'hsi48' clock, which is not running. Either enable it in 'config.rcc' or change 'config.rcc.mux' to use another clock: NoneError。embassy 的 stm32l4 例子编译的芯片是stm32l4r5zi,它有 HSI48,照抄到 L475 上编译不过:E0609,no field `hsi48` on type `embassy_stm32::rcc::Config`。本页的 L475 程序用 PLL1_Q 出 48 MHz。 - 远程唤醒要时间驱动。synopsys-otg 0.3.3 的
remote_wakeup是 0.3.3 新实现的(之前直接返回Unsupported),里面有一句embassy_time::Timer::after_millis(10).await。不配时间驱动,CDC-ACM 程序照样能链接;一旦调了usb.remote_wakeup(),rust-lld报undefined symbol: _embassy_time_now和_embassy_time_schedule_wake。
体积
usb-xc 两个程序:base 只有 init(同样的 PLL 配置)加一个永远 pending 的 main;serial 是 embassy 的 stm32l4 usb_serial.rs 去掉 defmt、把时钟改成 PLL1_Q 之后的 CDC-ACM 回显。llvm-size -A:
| 程序 | .text | .rodata | .data | .bss |
|---|---|---|---|---|
base | 7308 | 2008 | 0 | 140 |
serial | 27380 | 9160 | 236 | 2124 |
一个 CDC-ACM 设备多出 20 KB 代码、7 KB 只读数据。.rodata 的大头是 panic 消息和源文件路径(上面那些断言都留着)。最大的函数是 Join<UsbDevice::run, echo>::poll,3310 字节,run 的状态机连同控制请求处理被内联进去;其次是 on_interrupt 936 字节、Inner::handle_control_in 904 字节。.bss 多出的 1984 字节几乎都是 __embassy_main::POOL(2032 字节):例子把四块缓冲区(256 + 256 + 256 + 64)、UsbDevice(348 字节)和类都放在 main 的局部变量里,它们成了 main 任务 future 的一部分,静态分配在任务池里(见 任务存储)。也是因为它们是局部变量、不是 'static,例子只能用 join 把 usb.run() 和回显放在同一个任务里;要拆成两个任务,就得把这些缓冲区放进 StaticCell。
动脑筋答案
usb-std/src/bin/mps8.rs 的输出(out-mps8.txt,省略了分配端点的几行):
host: SETUP [80 06 00 01 00 00 40 00] GET_DESCRIPTOR(DEVICE) len 64, address 0
drv: bus.poll() -> Reset
drv: setup() -> [80 06 00 01 00 00 40 00]
drv: data_in(8 B, first true, last true)
host: IN 8 B 12 01 10 02 ef 02 01 08
...
host: SETUP [80 06 00 01 00 00 40 00] GET_DESCRIPTOR(DEVICE) len 64, address 5
drv: setup() -> [80 06 00 01 00 00 40 00]
drv: data_in(8 B, first true, last false)
drv: data_in(8 B, first false, last false)
host: IN 8 B 12 01 10 02 ef 02 01 08
drv: data_in(2 B, first false, last true)
host: IN 8 B de c0 fe ca 10 00 00 02
host: IN 2 B 00 01
host: status OUT (ZLP), total 18 B
host: SETUP [80 06 02 03 09 04 ff 00] GET_DESCRIPTOR(STRING 2) len 255
drv: setup() -> [80 06 02 03 09 04 ff 00]
drv: data_in(8 B, first true, last false)
drv: data_in(0 B, first false, last true)
host: IN 8 B 08 03 61 00 62 00 63 00
host: IN 0 B
host: status OUT (ZLP), total 8 B
host: SETUP [80 06 02 03 09 04 08 00] GET_DESCRIPTOR(STRING 2) len 8
drv: setup() -> [80 06 02 03 09 04 08 00]
drv: data_in(8 B, first true, last true)
- 地址 0:一次,8 字节。
address == 0、包长 8 小于 18、也小于请求的 64,resp_length被压到 8,主机只拿到设备描述符的前 8 字节,第 8 个字节08就是bMaxPacketSize0。 - 有地址之后:三次,8 + 8 + 2,最后一包短,不需要空包。
- 255 字节的字符串请求:两次,8 + 0。回复 8 字节比请求的 255 短,又正好是一整包,按规则补一个空包。
- 8 字节的字符串请求:一次。回复长度等于请求长度,主机自己知道结束了,不补空包。
(host: 行和 drv: 行的先后取决于两个 async 块谁先被 poll,不是总线上的顺序。)
小结
embassy-usb-driver把驱动分成分配阶段(&mut self)和运行阶段(start(self)换来Bus+ControlPipe),运行中不能再分配端点。Builder把描述符写进调用方给的四块缓冲区,计数字段先写 0、事后回填;缓冲区或接口数、Handler数不够都是 panic,除了control_buf都在建设备时暴露,buffer_usage()报告实际用量。UsbDevice::run是一个select(bus.poll(), control.setup())循环;驱动的setup()必须可以随时丢弃,状态存在中断处理函数写的State里。- 控制 IN 回复按
wLength截断、按 EP0 包长分块、需要时补空包;地址为 0 且 EP0 包长小于 18 时只回一个包。 - 字符串描述符的编码有一个差一字节的断言,并且超过 255 字节时
bLength被静默截断;全速设备也回DEVICE_QUALIFIER(main 已改)。 - synopsys-otg 的中断处理函数只搬数据和叫醒;硬件初始化推迟到第一次
Bus::poll,QEMU 上卡在GRSTCTL.AHBIDL;ep_out_buffer要装下所有 OUT 端点(含 EP0)。 - L475 上一个 CDC-ACM 设备比只有
init的程序多 20 KB.text、7 KB.rodata。