PY32的musb(Mentor USB)的Rust支持
本文最后更新于 2025年2月1日 下午
PY32的musb(Mentor USB)的Rust支持
仓库链接:https://github.com/decaday/musb
py32-hal仓库:https://github.com/py32-rs/py32-hal
最近在给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就是隔壁群主HaoboGu,rmk作者,也是hpm-hal的维护者之一。我当时想的是,等我给py32的主要功能做好了,看大佬有没有空。那时候我基本上不懂USB。
后来,半糖送了我三片py32板子
玩了几周,做了几个外设,感觉简单外设都是一个套路,挺没意思的。
有一天在大巴上,闲着没事,想着学一学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 |
|
说来,4o真不行,有几个寄存器offset错误搞得我浪费了很长时间,想念我的claude-sonnet-3.5了……
stm32-data范式的代码生成之后再讲,大概就是,调用 embassy-rs/chiptool 将yaml转为Rust。
资料
Puya官方提供的资料只有一个CherryUSB的移植层,以及Reference Manual,还有SVD。
SVD里其他外设还好,USB这有好几个错误,而且没有描述。
移植层很明显是用CherryUSB官方的musb port改的,比如,这个:
1 |
|
g_pyusb_udc.fifo_size_offset在整个文件中只有赋值而没有使用。这是因为,py32的musb是定长FIFO,而musb是可以变长的,而py32修改移植层时没有删除这个成员。这个下面再细说。
他们的手册也做的很差,比如:
最新版本的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/out
和start
函数。Bus
。管理总线事务。有enable,disable,poll,endpoint_set_enabled,endpoint_set_stalled,endpoint_is_stalled,还有两个可选的force_reset和remote_wakeup。若不支持则直接返回Unsupported
即可。Endpoint
,又包含EndpointIn
和EndpointOut
,也就是async的读写函数。ControlPipe
。专门管控制传输的EP0的。包含了max_packet_size, data_out, data_in, accept, reject, accept_set_address。
Driver
实现
Embassy似乎用“alloc”这种方式的地方很多,比如time-driver
的alarm
。
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使用的也不是同一个控制寄存器,操作逻辑也不太一致。
首先是Setup:
在Data从总线加载进FIFO后,会产生一个EP0中断。我们需要做的就是右下角的小字:从FIFO读出,清标志位。
然后根据需求设置InPktRdy或DataEnd。因为embassy-usb会根据setup包内容来进一步操作,比如调用data_in等,所以我们不用处理。
这个模型可以很好地做到Async框架上:
1 |
|
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 |
|
Endpoint
实现
这里以写入(In)函数为例。
1 |
|
仍然是使用全局的EP_TX_WAKERS
,通过判断tx_pkt_rdy是否被清掉,来确定上一个传输是否完成。然后,我们使用一个迭代器将buf写入fifo中,并设置tx_pkt_rdy位。
有一点需要注意,我们在这个函数中反复设置了index寄存器。这是因为是await函数,可能并不会一次执行完整个函数。中间如果有别的端点插嘴,index就错了。
此外,当MaxPktSize小于1/2的FIFOSIze的时候,musb有个双包缓存功能可用,但我还没做,未来有需要了做一下。
Bus
实现
endpoint_set_enabled函数如下,功能是开关某个端点。
1 |
|
开启的时候,我们分别设置了INTRTXE、RXMAXP、ClrDataTrg寄存器,根据模式开启ISO寄存器,然后清了两次FIFO(当使用双包缓存时需要清两次)。然后设置EP_RX_ENABLED和唤醒EP_RX_WAKERS。这是为了实现Endpoint::wait_enabled()。
关闭的时候就简单设置了标志位。py32的musb移植层在关闭端点的时候什么都没有做,而我简单看了别的使用musb的芯片的USB驱动。在关闭ep的时候还是能做点事情的,这个等之后实现好了。
poll函数,处理总线事务,比如Reset、Suspend。
1 |
|
测试
经过测试,运行HID例子,CDC例子都表现良好。
测试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难多了,因为具体实现已经在上面讨论过,所以我们这里仅讲述不同的地方。
usb-device并没有单独为控制传输的读写操作设置函数,端点统一使用write和read函数,并没有提供是否是第一个包、最后一个包的信息。(embassy-usb-driver的ControlPipe的data_in和data_out都有first和last的bool参数)而musb IP需要在最后一个包时设置SetupEnd。
usb-device未处理在未设置地址时读取设备描述符的行为。在embassy-usb中,有一个特殊处理,保证只会发一个包:在测试时,我发现当从机发送两个包后,主机会不再接收,然后发送下一个Setup包。(只有当最大包长小于描述符长度的时候,才能复现)
embassy-usb仅会在控制传输之外调用setup函数等待setup包,所以我们直接读取RxPktRdy,看有没有包加载进来就行了。但是usb-device是轮询,无法只读RxPktRdy来判断是不是Setup包,musb也没有特定的判断是否为Setup包的寄存器(虽然musb内部的状态机可以做到,但没有对应的寄存器),usb-device的控制传输状态机的信息,也无法在移植层获取到。
usb-device并未提供accept函数的Trait,而是将accept的行为默认为写入一个空包,然而musb不需要这么做。
综上,我在usb-device中写了一个控制传输的状态机,才解决这些问题。
对于未设置地址时读取设备描述符的问题2,SetupEnd位的作用就体现出来了,在疑似有Setup包进来的时候,可以读取SetupEnd位来重置状态机。
状态机会在移植层直接读取setup包的长度和方向信息,更新状态,捕获空包写入的行为(问题4),在合适的时候,设置DataEnd位。
在花费了更长的时间后,我也终于跑通了usb-device。