PY32的musb(Mentor USB)的Rust支持

本文最后更新于 2025年2月1日 下午

PY32的musb(Mentor USB)的Rust支持

最近在给PY32做Rust HAL支持。这个HAL等我改天写一篇Blog再细讲,先留着位置:传送门(未生效)

py32-rs/py32-hal: Rust HAL implementation for py32 mcu.

不知不觉中,也给py32-hal,py32-data拉了将近40个PR了。

大部分外设跟STM32非常像,就是抄的STM32,但又不完全一致。除了USART,其他的直接copy都跑不了,还得修一番。

但是,这些外设的高级功能、不常用的寄存器差别比较大。不过,embassy-stm32还都没支持这些功能,我也暂时不支持就好了(比如ADC使用其它触发源,USART自动波特率等)。

下面是一大段吐槽,技术分析在:编写Rust驱动

Puya这种厂,基本不可能自己做IP的,所有内核、外设IP都是买的(除了他们厂自己的Flash)。

之前看过CherryUSB,CherryUSB是基于IP来实现的移植层。USB IP就那么几家,如果能知道PY32用的IP,说不定就能抄别人的Rust实现。

于是,半糖佬告诉我,是个阉割版的musb。

其实我当时还不知道musb是啥,问ai,ai说是micro usb……

Jason就是隔壁群主HaoboGurmk作者,也是hpm-hal的维护者之一。我当时想的是,等我给py32的主要功能做好了,看大佬有没有空。那时候我基本上不懂USB。

后来,半糖送了我三片py32板子

玩了几周,做了几个外设,感觉简单外设都是一个套路,挺没意思的。

有一天在大巴上,闲着没事,想着学一学USB,于是找了个教程开始听。

《USB技术应用与开发》

这教程是沁恒出的,不得不说质量相当不错。听了几集,发现基本能看懂USB移植层了,草,自己试试好了

USB移植层在Rust上也就1k行代码左右,复杂的事情都由USB IP或USB Stack管了,移植层本身不麻烦,做移植层也是在Package(包)层去管理的USB,不用涉及更底层的协议内容(比如JK信号,NZI编码等)。

生成寄存器操作到Rust

比较奇特的是,py32的USB寄存器是8位的:

34.5. USB 寄存器
注意:在对USB寄存器进行写操作时只能按照字节(byte)或者字(word)操作,在对USB寄存器进行读操作时只能按照字节(byte)操作。

普冉提供的SVD文件中,USB寄存器是按照32位进行划分的(与手册一致),如果直接生成Rust,可能会碰到无法读出数据的问题?

于是,在AI的帮助下 在AI的捣乱下,我写了个8位的寄存器描述yaml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
block/USB:
description: USB control and status registers for managing USB operations.
items:
- name: ADDR
description: Function address of the USB device.
byte_offset: 0x00
bit_size: 8
fieldset: ADDR
- name: POWER
description: USB power management register.
byte_offset: 0x01
bit_size: 8
fieldset: POWER
- name: INT_USB
description: USB interrupt status register.
byte_offset: 0x04
bit_size: 8
    fieldset: INT_USB
...

完整代码:py32-data

说来,4o真不行,有几个寄存器offset错误搞得我浪费了很长时间,想念我的claude-sonnet-3.5了……

stm32-data范式的代码生成之后再讲,大概就是,调用 embassy-rs/chiptool 将yaml转为Rust。

资料

Puya官方提供的资料只有一个CherryUSB的移植层,以及Reference Manual,还有SVD。

SVD里其他外设还好,USB这有好几个错误,而且没有描述。

移植层很明显是用CherryUSB官方的musb port改的,比如,这个:

1
2
3
fifo_size = pyusb_get_fifo_size(ep_cfg->ep_mps, &used);
USB->MAX_PKT_IN = fifo_size;
g_pyusb_udc.fifo_size_offset += used;

g_pyusb_udc.fifo_size_offset在整个文件中只有赋值而没有使用。这是因为,py32的musb是定长FIFO,而musb是可以变长的,而py32修改移植层时没有删除这个成员。这个下面再细说。

他们的手册也做的很差,比如:

f655fd8657778f72c59bcfd45ffb59bf.png

5884d3002bf938d3933f15541198a951.png

最新版本的Reference Manual 0.7,估计是他们直接copy然后忘记改offset了……

我之前按本地存的是0.4,这个更离谱,

EP1最大使用512字节缓冲区,EP2、EP3、EP4都是使用128字节缓冲区,EP5使用64字节缓冲区

0.7版本修复了,不然就麻烦了……(幸好后来下载了新版Reference Manual)

EP1最大使用64字节缓冲区,EP2、EP3、EP4都是使用128字节缓冲区,EP5使用512字节缓冲区

musb是8位/16位寄存器嘛,我当时想起来STC了,疑惑:STC用的啥USB IP?之前有人猜测是STC自己搓的,但是STC又没啥IP自研能力,更何况是USB,一看,草,还是,musb。

而且阉割程度几乎跟py32一模一样,我称之为mini musb。

AI8051u.pdf

好了,这下有参考资料了。事实上,我后面写USB驱动,大部分就是参考的STC的手册。

其实STC的手册并没有好太多,py好歹还机翻了功能模块描述,stc没有。(我基本写完后才找到musb_programing_guide.pdf)

比如,寄存器描述中对于SetupEnd位的描述:

STC:读不懂,看起来是机翻+人工润色,SETUP翻成安装包可海星。。另外STC给PDF上锁不让复制也太离谱了

SETUP 安装包结束标志。当一次控制传输在软件向DATAEND位写“1”之前结束时,硬件将此只读位置“1”。当软件向SSUEND写’1’后,硬件将该位清“0”

Puya:机翻无疑

该位在控制传输结束,DataEnd置1前置1,会产生中断并清除FIFO

Puya 英文手册:这,,,英译中译英?绷不住了

This bit, at the end of the control transfer, DataEnd set 1 precedes 1, generates an interrupt and clears the FIFO;

musb_programming_guide原文:

This bit will be set when a control transaction ends before the DataEnd bit has been set.
An interrupt will be generated and the FIFO flushed at this time. The bit is cleared by the CPU writing a 1 to the ServicedSetupEnd bit.

另外,不同厂商对寄存器命名也是不同的,比如,OUT_CSR1,在musb_programming_guide中为RXCSRL。后面我都以musb的标准名称为准。

编写Rust驱动

embassy-usb似乎是Embedded Rust唯二的USB协议栈(另一个是usb-device)。embassy多好!啥都给了。我实现 embassy-usb-driver 就OK了。

(更新,tock也有一个usb协议栈)

我参考的是embassy-stm32的实现。stm32好像有一部分用的自研IP,带OTG的用的synopsys的IP(dwc2)。Embassy也给dwc2做了独立crate的实现:embassy/embassy-usb-synopsys-otg

embassy-usb-driver 分为四个Trait。分别是:

  • Driver。真就是一个驱动。在USB开始工作前用的,包含alloc_endpoint_in/outstart函数。

  • Bus。管理总线事务。有enable,disable,poll,endpoint_set_enabled,endpoint_set_stalled,endpoint_is_stalled,还有两个可选的force_reset和remote_wakeup。若不支持则直接返回Unsupported即可。

  • Endpoint,又包含EndpointInEndpointOut,也就是async的读写函数。

  • ControlPipe。专门管控制传输的EP0的。包含了max_packet_size, data_out, data_in, accept, reject, accept_set_address。

Driver 实现

Embassy似乎用“alloc”这种方式的地方很多,比如time-driveralarm

alloc_endpoint由stack来调用,分配可用的endpoint。在使用一块RAM作为端点数据或动态大小FIFO的情况下,还要根据max packet size来计算和安排FIFO大小。

这样做确实是损失了一定的灵活性,比如,用户没法特定地去选择哪个EP使用双缓冲区。

py32f072的musb是固定大小FIFO,且同一个端点的两个方向共享FIFO。经测试,跑各种HID例程,包括RMK,都没问题,但是跑CDC串口不行。不共用ep的话,能跑CDC。CDC有两个Bluk端点嘛。刚好f403是不共享FIFO的,到时候可以试试究竟是Bluk必须单向端点,还是共享FIFO的原因。

start: alloc个EP0装进Self::ControlPipe里,然后返回(Self::Bus, Self::ControlPipe),销毁自身。

ControlPipe 实现

embassy-usb将EP0的控制传输独立出来整了个ControlPipe。我感觉和musb还是挺契合的,musb的EP0和其它的EP使用的也不是同一个控制寄存器,操作逻辑也不太一致。

image-20241217220829835

首先是Setup:

image-20241220113705270

在Data从总线加载进FIFO后,会产生一个EP0中断。我们需要做的就是右下角的小字:从FIFO读出,清标志位。

然后根据需求设置InPktRdy或DataEnd。因为embassy-usb会根据setup包内容来进一步操作,比如调用data_in等,所以我们不用处理。

这个模型可以很好地做到Async框架上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
async fn setup(&mut self) -> [u8; 8] {
let regs = T::regs();
loop {
poll_fn(|cx| {
EP_RX_WAKERS[0].register(cx.waker());
regs.index().write(|w| w.set_index(0));
if regs.csr0l().read().rx_pkt_rdy() {
Poll::Ready(())
} else {
Poll::Pending
}
})
.await;

regs.index().write(|w| w.set_index(0));
if regs.count0().read().count() != 8 {
trace!("SETUP read failed: {:?}", regs.count0().read().count());
continue;
}
let mut buf = [0; 8];
(&mut buf).into_iter().for_each(|b|
*b = regs.fifo(0).read().data()
);
regs.csr0l().modify(|w| w.set_serviced_rx_pkt_rdy(true));
return buf;
}
}

T是这些Usb有关的struct的Instance泛型。

然后我们使用poll_fn函数,创建一个Future。这个函数在第一次执行,注册EP_RX_WAKERS的时候也相当于执行了一次,所以如果包已经读入FIFO了,就可以直接Ready继续执行。如果还没有读入FIFO,则会await,等中断wake EP_RX_WAKERS,再次被Poll并执行。

EP_RX_WAKERS是一个全局AtomicWaker,在中断中检查标志位并wake它们。然后判断csr0l.RxPktRdy来返回结果。

poll_fn闭包中,我们在读取csr0l寄存器之前,设置了index寄存器。这是因为,musb有一块寄存器是Indexed Register,也就是设置了index才能访问到的寄存器。比如,EP1-15共享的RXCSRL,RXCSRH等。

The action of the following registers when the selected endpoint has not been configured is undefined.

EP0虽然有独享的寄存器CSR0H、CSR0L,但也属于Indexed Register,需要设置index。在CSR0H中也写了:Address: 12h (with the Index register set to 0)

然后就是读fifo,清标志位等。

发送数据的data_in/out等到后面说EP了再讲。除了DataEnd外流程都是一致的。

接下来是空的accept()函数,其他USB IP的行为似乎是发送一个空包,但是musb不需要这一行为,当没有stall的时候,直接就自动accept了。

accept_set_address函数也比较简单。因为不同的IP有不同的顺序要求,有的需要在设置地址前accept,有的需要在设置地址后应答。而musb就不用考虑这么多了,向FADDR寄存器填入地址就行。

1
2
3
4
5
6
async fn accept_set_address(&mut self, addr: u8) {
// self.accept().await; // 在设置地址前accept
let regs = T::regs();
regs.faddr().write(|w| w.set_func_addr(addr));
// self.accept().await; // 在设置地址后accept
}

Endpoint 实现

这里以写入(In)函数为例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
async fn write(&mut self, buf: &[u8]) -> Result<(), EndpointError> {
if buf.len() > self.info.max_packet_size as usize {
return Err(EndpointError::BufferOverflow);
}

let index = self.info.addr.index();
let regs = T::regs();

let _ = poll_fn(|cx| {
EP_TX_WAKERS[index].register(cx.waker());
regs.index().write(|w| w.set_index(index as _));

let unready = regs.txcsrl().read().tx_pkt_rdy();
if unready {
Poll::Pending
} else {
Poll::Ready(())
}
})
.await;

regs.index().write(|w| w.set_index(index as _));
buf.into_iter().for_each(|b|
regs.fifo(index).write(|w| w.set_data(*b))
);
regs.txcsrl().modify(|w| w.set_tx_pkt_rdy(true));
Ok(())
}

仍然是使用全局的EP_TX_WAKERS,通过判断tx_pkt_rdy是否被清掉,来确定上一个传输是否完成。然后,我们使用一个迭代器将buf写入fifo中,并设置tx_pkt_rdy位。

有一点需要注意,我们在这个函数中反复设置了index寄存器。这是因为是await函数,可能并不会一次执行完整个函数。中间如果有别的端点插嘴,index就错了。

此外,当MaxPktSize小于1/2的FIFOSIze的时候,musb有个双包缓存功能可用,但我还没做,未来有需要了做一下。

Bus 实现

endpoint_set_enabled函数如下,功能是开关某个端点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
fn endpoint_set_enabled(&mut self, ep_addr: EndpointAddress, enabled: bool) {
let ep_index = ep_addr.index();

if enabled {
T::regs().index().write(|w| w.set_index(ep_index as u8));
match ep_addr.direction() {
Direction::Out => {
if ep_index == 0 {
T::regs().intrtxe().modify(|w|
w.set_ep_txe(0, true))
} else {
T::regs().intrrxe().modify(|w|
w.set_ep_rxe(ep_index, true)
);
}
T::regs().rxmaxp().write(|w|
w.set_maxp(self.ep_confs[ep_index].rx_max_fifo_size_dword)
);
T::regs().rxcsrl().write(|w| {
w.set_clr_data_tog(true);
});
if self.ep_confs[ep_index].ep_type == EndpointType::Isochronous {
T::regs().rxcsrh().write(|w| {
w.set_iso(true);
});
}
if T::regs().rxcsrl().read().rx_pkt_rdy() {
T::regs().rxcsrl().modify(|w|
w.set_flush_fifo(true)
);
T::regs().rxcsrl().modify(|w|
w.set_flush_fifo(true)
);
}
let flags = EP_RX_ENABLED.load(Ordering::Acquire) | ep_index as u16;
EP_RX_ENABLED.store(flags, Ordering::Release);
// Wake `Endpoint::wait_enabled()`
EP_RX_WAKERS[ep_index].wake();
}
Direction::In => {} // 省略
}
}
else {
// py32 official CherryUsb port does nothing when disable an endpoint
match ep_addr.direction() {
Direction::Out => {
let flags = EP_RX_ENABLED.load(Ordering::Acquire) & !(ep_index as u16);
EP_RX_ENABLED.store(flags, Ordering::Release);
}
Direction::In => {} // 省略
}
}
}

开启的时候,我们分别设置了INTRTXE、RXMAXP、ClrDataTrg寄存器,根据模式开启ISO寄存器,然后清了两次FIFO(当使用双包缓存时需要清两次)。然后设置EP_RX_ENABLED和唤醒EP_RX_WAKERS。这是为了实现Endpoint::wait_enabled()。

关闭的时候就简单设置了标志位。py32的musb移植层在关闭端点的时候什么都没有做,而我简单看了别的使用musb的芯片的USB驱动。在关闭ep的时候还是能做点事情的,这个等之后实现好了。

poll函数,处理总线事务,比如Reset、Suspend。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
async fn poll(&mut self) -> Event {
poll_fn(move |cx| {
BUS_WAKER.register(cx.waker());let regs = T::regs();

// TODO: implement VBUS detection.
if !self.inited {
self.inited = true;
return Poll::Ready(Event::PowerDetected);
}

if IRQ_RESUME.load(Ordering::Acquire) {
IRQ_RESUME.store(false, Ordering::Relaxed);
return Poll::Ready(Event::Resume);
}

if IRQ_RESET.load(Ordering::Acquire) {
IRQ_RESET.store(false, Ordering::Relaxed);
regs.power().write(|w| w.set_suspend_mode(true));
for w in &EP_TX_WAKERS { w.wake() }
for w in &EP_RX_WAKERS { w.wake() }
return Poll::Ready(Event::Reset);
}

if IRQ_SUSPEND.load(Ordering::Acquire) {
IRQ_SUSPEND.store(false, Ordering::Relaxed);
return Poll::Ready(Event::Suspend);
}

Poll::Pending
})
.await
}

测试

经过测试,运行HID例子,CDC例子都表现良好。

py32-hal/examples/py32f072

测试RMK也运行良好,我还给RMK写了个example: #173

做embassy-usb-driver其实挺顺利,在后期的测试中,发现并修复了很多问题,但是这些问题都没有在前期太影响USB的行为。

纠结时间比较长的是SetupEnd位,尤其是在找到musb_programming_guide之前,STC和PY的手册的离谱翻译都无法体现出该位的意思(在资料这引用了原文)。

该位的作用其实是检测突然而来的Setup包,而DataEnd还未被置位,musb认为上一次控制传输还没有结束,下一次控制传输就开始了。这个寄存器目前在embassy-usb-driver impl中没有用到,到后面做usb-device driver时才用到(见下)。

musb crate

后面,我发现SiFli(思澈)的USB也是musb,于是,我注册了个musb的crate名,并把驱动代码转进去,改用musb的标准寄存器名,并借助AI重新生成了寄存器信息,弄了个寄存器生成构建程序,可以适配不同的魔改阉割musb,只需要编写profile和特定的寄存器描述yaml。然后,使用yaml2pac (基于chiptool) 生成寄存器代码。同时,也提供prebuild,详细信息请参考musb的README。

截止目前,SiFli他们的开发板还没做出来,虽然可以申请芯片和模块,但是我懒得画板子……

usb-device 的实现

embassy-usb并不是rust嵌入式唯一的USB Stack,另一个是usb-device,我也是最近才发现这点。Trait咋一看和embassy-usb-driver区别不大,我想着顺便做了吧?

usb-device不是Async模式的,而是nb (no-block)模式,通过轮询来推动任务进行。

usb-device/src/bus.rs 里则是我们需要实现的Trait:Bus。并没有像embassy-usb-driver这样分为四个Trait。

实现usb-device比embassy-usb-driver难多了,因为具体实现已经在上面讨论过,所以我们这里仅讲述不同的地方。

  1. usb-device并没有单独为控制传输的读写操作设置函数,端点统一使用write和read函数,并没有提供是否是第一个包、最后一个包的信息。(embassy-usb-driver的ControlPipe的data_in和data_out都有first和last的bool参数)而musb IP需要在最后一个包时设置SetupEnd。

  2. usb-device未处理在未设置地址时读取设备描述符的行为。在embassy-usb中,有一个特殊处理,保证只会发一个包:在测试时,我发现当从机发送两个包后,主机会不再接收,然后发送下一个Setup包。(只有当最大包长小于描述符长度的时候,才能复现)

  3. embassy-usb仅会在控制传输之外调用setup函数等待setup包,所以我们直接读取RxPktRdy,看有没有包加载进来就行了。但是usb-device是轮询,无法只读RxPktRdy来判断是不是Setup包,musb也没有特定的判断是否为Setup包的寄存器(虽然musb内部的状态机可以做到,但没有对应的寄存器),usb-device的控制传输状态机的信息,也无法在移植层获取到。

  4. usb-device并未提供accept函数的Trait,而是将accept的行为默认为写入一个空包,然而musb不需要这么做。

综上,我在usb-device中写了一个控制传输的状态机,才解决这些问题。

对于未设置地址时读取设备描述符的问题2,SetupEnd位的作用就体现出来了,在疑似有Setup包进来的时候,可以读取SetupEnd位来重置状态机。

状态机会在移植层直接读取setup包的长度和方向信息,更新状态,捕获空包写入的行为(问题4),在合适的时候,设置DataEnd位。

在花费了更长的时间后,我也终于跑通了usb-device。

例子:py32-hal/examples/usbd-f072


PY32的musb(Mentor USB)的Rust支持
https://decaday.github.io/blog/py32-musb/
作者
星期十
发布于
2025年2月1日
更新于
2025年2月1日
许可协议