Hint: this post is also available in English.

这个就是之前提过要写的有关 USB 的文章了。目标是编写对应的 RTL 和软件协议栈,这样就可以在 Pano G1 上使用 USB HID 摇杆、游戏手柄,或者是 USB 大容量存储设备。不过这里只讨论 Pano Logic G1 瘦客户机上由 ISP1760 控制器提供的 USB2.0 接口,所以可能不能适用于其他的平台的了。

硬件连接

在 Pano G1 上, USB2.0 主机控制器(Phillips ISP1760)通过16位并行存储器总线连接到 FPGA;为了解决ISP1760的一个bug,ISP1760后面又额外接了一个Hub(SMSC USB2513),提供给用户的接口则全从外接的Hub走。

整体架构

虽然说,写一个 FSM 来实现基本的 USB 协议栈应该是可行的,不过其实这样做不是很现实(也许从设备FSM还靠谱些),我在这里的解决方案是在 FPGA 上使用 PicoRV32 软核。主控制器将作为 MMIO 映射到 PicoRV32 的存储空间,然后软件驱动程序和协议栈可以在 PicoRV32 上运行。必要时,可以使用 MMIO GPIO 实现某些输出(例如,输出当前按下的按键)。但愿调试软件比调试硬件容易一些的。

一般来说,会有以下几层。最底层称为主机控制器驱动程序Host Controller Driver(HCD),它特定于特定的硬件,如正在使用的控制器芯片。然后是主机驱动程序Host Driver,这部分处理设备枚举和 USB Hub 通信,但这一层不再是特定于硬件平台的了。比这更高一层的是类驱动程序,顾名思义,这特定于设备类,例如,HID 类或音频类。驱动程序可能是整个类通用的,也有可能只支持少数特定 USB 设备之一。比这一层再高的就是操作系统了,或者在我们的情况下,直接就是用户的应用程序了。

总之,我有下面五件事需要处理:

  • 编写 RTL 把 ISP1760 和 PicoRV32 连起来
  • 移植或者给 ISP1760 现写 HCD
  • 移植一些适当的主机驱动程序
  • 为 HID 游戏手柄和大容量存储设备移植或编写类驱动程序
  • 编写一些使用这个驱动程序的应用

让我们来讨论所有这些部分。

MMIO

这是最简单的部分了。它是一个非常标准的并行异步 MMIO 接口。虽然有 DMA 模式,但我还是不打算支持 DMA。地址空间为 64KB,所以应该有 16 个地址线。但由于数据总线宽度为 16 位,并且访问始终是半字对齐的,因此省略最低位,只留下 15 条地址线。但是,这里有 A[17:1] 一共 17 条地址线。根据数据手册上面写的,高2位用于表示当前正访问的页,设备允许同时打开多个页......听起来很SDRAM。不过正如我说这里并不关心速度,所以我并没有在代码中这样做。同时为了RTL写起来更简单,我也没有使用位宽转换逻辑,而是把数据总线的低 16 位连接到CPU的低 16 位,高 16 位没有接。因此,它占用的地址空间是它应该占用的两倍,并且所有地址都应该被移位一位来补偿浪费掉的位宽。

HUB

在我谈到 USB 控制协议栈之前,我想更多地说说 USB Hub。Pano G1 的 USB 架构中基本上充满了 Hub。在硬件方面,有一个 USB Hub 芯片,但这并不是全部。

根据 EHCI 规范,USB 2.0 高速主机控制器只能与高速设备通信。这里没有全速或低速设备。要实现兼容两种类型设备的USB主机,需要在系统中加入额外的 OHCI 或 UHCI 兼容控制器,然后根据现在连接的设备是低速或者全速设备,使用模拟开关切换。

嗯,这实在是太蠢了,是否可以设计一个能够处理这两种设备的控制器呢?

答案是肯定的,这样的控制器也真的存在,它在Linux内核中称为 TDI 控制器,TDI 指的是 TransDimension 公司,这家公司开发了这种控制器。该公司后来更名为Oxford semiconductor,由 PLX 技术收购,然后由 Broadcom 收购。一般的思路是使用 USB 2.0 规范中的事务转换器(TT)(但这不是 EHCI 规范的一部分)。用户可以在同一集线器上以不同的速度插入 USB 设备,但为了最大化吞吐量,USB 集线器应始终使用高速模式与上游通信。这意味着集线器必须具有某种与 Low Speed 或 High Speed 设备接口的方式,并通过 High Speed 端口传输 Low Speed / High Speed 数据。这正是我们所需要的:ECHI 控制器只能提供一个 High Speed 端口。TT 可以将 Low Speed 或 Full Speed 设备适配到 High Speed 端口。

或者,在不修改控制器的情况下,也可以将标准的 USB 2.0 集线器连接到 EHCI 控制器,集线器可以很好地处理这种情况。这就是飞利浦的做法:在 ISP1760 中,有一个集成的飞利浦 ISP1520 USB 2.0 集线器,直接连接到芯片内部的主控制器。我不确定这种连接是如何在内部完成的,但从驱动程序的角度来看,这只是一个连接到其中一个 DFP(downstream facing port、下游端口)的集线器。现在设备中总共有 2 个集线器。

但也有第三种方案:根集线器。在 Windows PC 的设备管理器中,可以看到这些根集线器。例如,这是我的 PC 上设备管理器的屏幕截图:
image

这些是什么?在控制器中有一个内嵌集成的集线器吗?并不是的。有时候,主机驱动程序需要控制端口。例如,打开或关闭特定端口的电源,或复位特定端口。对于USB集线器来说,它们其实被实现为发送到USB集线器的控制数据包,集线器将接收这些数据包并执行相应的操作。但是要控制 EHCI 兼容控制器上的端口就需要使用 MMIO 来修改某些寄存器以完成相同的操作了。从驱动程序接口的角度来看,这样的方法并不方便:会有两个不同的接口来做同样的事情。因此,通常在 HCD(host controller driver、主机控制器驱动程序)中,会有一些代码来模拟集线器:它会拦截发送到端点 0 的控制数据包,将其自身报告为USB集线器,当接收到某些控制数据包时,将值写入 MMIO。所以系统中会有第三个集线器。这实在是太蠢了,想象一下我们的数据包到达另一端的时候至少要经过三个集线器...而且我们的协议栈也必须能够处理多层集线器才行。

虽然幸运的是,在 ISP1760 上,第三层是可选的。ISP1760 HC(host controller、主机控制器)只有一个 DFP,它始终连接到集成的 ISP1520 集线器。软件在运行后通常没有理由对这个 DFP 做出控制,所以我们需要硬编码这样的配置。然后集成的 ISP1520 将成为根集线器。但是那时系统仍然有两个 Hub 要经过。

主机驱动程序

虽然下一步就是 HCD 了。但因为 HCD 高度依赖主机驱动程序的原因,我需要先决定使用哪个主机驱动程序。也就是选择要用的 USB 主机协议栈。我不想浪费时间在这里重新发明另一个轮子,主要是同时调试很多东西的时候事情会变得很棘手。

我找到的第一个方案叫做 TinyUSB。不幸的是,正如其 README 指出的那样,它不支持级联集线器。我觉得这应该是受它的体系结构所限制的,而且如果不重构大量代码的情况下应该也不可以轻松支持。不过他们的代码非常干净,也相对更容易被理解和修改的了。然后是第二个方案,这是为 Arduino USB host shield 扩展板编写的协议栈。(Circuits @ Home 的那个)。它支持集线器级联,也包含了许多有用的类驱动(特别是对于USB游戏手柄,除了有 dualshock 3 的以外,也有 xbox 360 控制器的驱动程序等。虽然后来发现我其实用不到这些)。在我看来,这里的主要问题是与 TinyUSB 相比,代码并不是很干净。这不是说我可以写出更好更干净的代码,只是想节省我阅读其他代码的时间而已,主要是我不那么擅长阅读代码而已。

第三个是 Linux。(和 uClinux)事实证明,ADI 公司曾为其(我们的)Blackfin DSP 制造了一个带有 ISP1760 的 USB 主机扩展板。它有两个驱动,一个是飞利浦的,另一个是社区版的。后者合并到 Linux 主线。起初我认为它和 Blackfin 一起玩体验会很棒:在 JTAG 调试器的帮助下,我就可以在 Blackfin 上编写和调试我的裸机驱动了。不过在办公室找了一圈发现,这里已经没有 USB 扩展板了(虽然有发现其他两种类型的 USB 扩展板,但它们对我没什么用)。Linux 驱动程序将来可能会作为参考,但我不打算尝试将 USB 协议栈就这样移植到裸机上。这可能比重新写一个还费时间吧。

那么,我可以在 Pano G1 上运行 Linux 并使用 ISP1760 的驱动吗?答案是做不到的。在 Pano G1 的 256KB 的板载非易失性存储器,我根本不能在其中存放得下 USB 的 Linux 内核。另外我也不能从以太网加载它,因为 Pano 没有以太网 IP 核。唯一可行的方法是将一些引导加载程序(如 u-boot )放在那里,它可以从 USB 存储设备加载内核然后启动 Linux 内核。但是,如果我有一个可以访问 USB 的引导加载程序的话,我就用不到 Linux 了。只要把我的应用程序代码添加到 BootLoader 中就可以了。

说到 BootLoader,我想最有名的就是 u-boot吧。u-boot 支持 USB。那么使用它的 USB 协议栈呢?将 USB 协议栈从 u-boot 中取出并独立使用,应该比为 u-boot 添加 RV32IC 支持更容易。u-boot 的 USB 支持代码是从一个非常古老版本的 Linux 中获得的,并在这个基础上进行了开发。作为一个引导程序而言,它倾向于轻量而不是性能。有着非常干净和简单的代码,和非常精简的 HCD 接口和类驱动 。虽然它不支持 ISP1760,但支持 EHCI(ISP1760 声称自己与 EHCI 兼容),并支持许多其他控制器供我参考。它只有两个类驱动,一个用于键盘(HID),另一个用于大容量存储。我只用得到这些了。所以这样的代码对我而言是非常合适的。要实际使用 u-boot 的 USB 代码...将 usb.c,usb.h,相应的 HCD 和所需的类驱动复制到您的项目中就可以了。需要提一下的是,在撰写本文时,他们正在适配所有 USB 驱动程序应符合的新驱动程序模型。我认为我用不到它,我也没有使用大量额外的 xHCI(eXtensible Host Controller Interface、可扩展主机控制器接口)支持代码,因此我只使用了 u-boot 2009 中的代码。我也发现了这份代码中的一些 BUG 但其实主人已经更早地修复了它们。您可以在 github.com/zephray/phUSB 上找到包含我的补丁的代码。

HCD

HCD(Host Controller Driver、主机控制器驱动程序)是与 USB 控制器芯片通信的。通常是特定一个芯片或一个平台的。不幸的是,u-boot 没有对 ISP1760 的内置支持。但至少有 EHCI 的支持。通常而言,如果 USB 控制器与 EHCI 兼容,那么我应该能够直接使用 EHCI 驱动程序,因为这就是 EHCI 的意思:驱动程序和 USB 控制器芯片之间的统一接口。大多数 EHCI 兼容控制器就是这种情况,例如恩智浦 i.MX SoC 上的 USB 控制器。那么,ISP1760 声称是EHCI兼容控制器,我可以使用标准的EHCI驱动程序吗?不,ISP1760 做了很大的改动,ISP1760 是一个从机,意味着它不能从内存请求数据。这是ISP1760 的一个重要特性,同时使其与 EHCI 截然不同。它确实有许多 EHCI 寄存器,但又不完全符合 EHCI 规范。另外,它还有更多自己的寄存器。总之,EHCI 规范和 EHCI 驱动程序将是很好的参考,不过我最好编写自己的驱动程序吧。

在 u-boot 的 USB 主机堆栈模型中,HCD 只需要做两件事:传输数据和模拟根集线器。在我们的例子中,第二个是可选的。让我们看一下 HCD 和主机驱动程序之间的接口吧:

int usb_lowlevel_init(void);

int usb_lowlevel_stop(void);

int submit_bulk_msg(struct usb_device *dev, unsigned long pipe, void *buffer, int transfer_len);

int submit_control_msg(struct usb_device *dev, unsigned long pipe, void *buffer, int transfer_len, struct devrequest *setup);

int submit_int_msg(struct usb_device *dev, unsigned long pipe, void *buffer, int transfer_len, int interval);

void usb_event_poll(void);

如果没有中断,则初始化,重置,提交三种类型的消息和事件轮询。除中断传输外的所有功能都是阻塞的,它们应该在完成后返回。意味着没有像这样的队列或列表,大大简化了 HCD 和主机驱动程序的设计,同时降低了性能成本。另一方面,Linux 驱动程序主要使用非阻塞功能。事情是异步完成的,以获得更高的性能。下一个任务是阅读数据手册,阅读应用程序代码,并根据这些文档编写代码。如有疑问,就把猜测标记为 TODO,或引用 Linux 驱动程序。测试代码主要是看它是否可以不报错地完成设备枚举工作。

类驱动

类驱动程序是与插入的 USB 设备(集线器除外)连接的驱动程序。就我而言,我需要两个类驱动程序,一个用于 HID,另一个用于大容量存储。幸运的是, u-boot 同时具备这两种功能,尽管 HID 只是给键鼠用的。

HID 游戏手柄

HID 实际上指很多种的设备,包括键盘,鼠标,游戏手柄,操纵杆,控制轮,遥控器等。我要编写的类驱动程序仅适用于游戏手柄。尽管存在许多不同类型的 HID,但它们都是一样的输入设备,它们通过 HID 报告,将固定长度信息返回给主机。对于键盘和鼠标,报告格式在所有符合 HID 标准的键盘和鼠标中都是一致的。因此,可以编写一个通用驱动程序来适配所有的键盘鼠标。然而,其他设备并非如此。不同的 HID 可能需要报告完全不同的项目,因此无法设计适用于所有其他 HID 的报告格式。他们所做的是引入了一个 HID 报告描述符。该描述符包含报告的字段定义。顺便一提,键盘和鼠标也有它们自己的 HID 报告描述符,但是不需要太多地解析它们,对于键鼠而言几乎总是兼容的。

所以我们可以选择:编写 HID 游戏手柄的驱动程序,可以对各种不同的 HID 兼容的游戏手柄的报告字段定义进行硬编码; 也可以编写一个解析器来解析 HID 描述符,使得驱动程序对所有 HID 兼容的游戏手柄都是通用的。我之前提到的 Arduino USB host shield 扩展板的协议栈选择了第一种方法; 他们为不同的游戏手柄创建了一大堆驱动程序,因为它们具有不同的报告格式。而 Windows 和 Linux 选择了第二种方式。第二种方式的问题是,驱动程序只会知道报告中有多少个按钮,但不会知道这些按钮是什么(是O形还是X形)。因此,当第一次在 PC 上玩游戏时,通常需要进行一些按钮映射的。

由于市场上有太多不同的 USB 游戏手柄了,我无法支持所有这些游戏手柄。所以我也采用了第二种方法了。

为了让我的生活更轻松,我不打算制作一个完全符合 HID 标准的解析器,只需要处理相关的部件并在通用控制器中使用就可以了。结果证明这是相当简单。事物是在集合中组织的,这意味着描述符中有一些层次结构,但就我而言,它们可能被忽略。一般处理流程如下:

void usb_gp_parse_descriptor(uint8_t *descriptor, uint32_t descriptor_length) {
    uint8_t item_type;
    uint8_t item_length;
    uint32_t item_value;
    uint32_t i = 0;

    while(i < descriptor_length) {
        item_type = descriptor[i] & 0xfc;
        item_length = descriptor[i] & 0x03;
        item_length = (item_length == 3) ? (4) : (item_length); // 3 means 4
        i += 1;
        item_value = 0;
        for (int bc = 0; bc < 4; bc++)
            if (item_length > bc)
                item_value |= (uint32_t)descriptor[i + bc] << (8 * bc);
        
        switch (item_type) {
        
        }
        i += item_length;
    }
    return;
}

HID描述符内的基本单元是 item,以下称之为项。每个项都有其类型和值。多个项可能具有相同的类型。类型长度总是 6 位的。该值长度可以是 1 个字节,2 个字节或 4 个字节。解析器可以被视为状态机,一些项 将改变其状态,其状态将影响它如何解释该项。描述符的作用是描述报告的位或字节字段,因此最重要的项是描述单个字段的项。该项的类型为 0x80(输入)。但是,输入项的值仅指示字段是否有效(用于填充),长度和定义由全局状态定义。在描述许多相同或相似的字段时,这可以节省几个字节。这也可能是保持全局状态的原因。

在我的解析器中,输入分为两类:二进制控件和模拟控件。按钮和 Dpad 通常是二进制值,模拟摇杆,模拟触发器和加速度计值都是模拟值。解析器将保存报告中每个二进制值的位偏移量,以及报告中每个模拟值的字节偏移量。

最后,在报表解析器中,它将使用从 HID 描述符收集的偏移量来从报表中提取值。然后,它将这些值放入一个数组中,用户应用程序稍后就可以使用了。

完整的解析器代码可以在以下网址找到:https://github.com/zephray/phUSB/blob/master/class_driver/usb_gamepad.c

大容量存储设备

没有太多可说的,因为 u-boot 的驱动程序对我来说已经很好用了,我就没有太多关注它了。

结论

总之,USB可以用了。调试经验让我想起我第一次卡血学习 MCU 的时候:没有JTAG,没有跟踪,也没有堆栈转储。只有串行打印输出可用。也不能访问实际的信号:它们位于 Pano 盒子里,也很难将探针连接到 LQFP 引脚。每次我想调试新版本的程序时,我都需要将新固件下载到 Flash 中,而不是只加载到 RAM 中(尽管这比紫外线擦除的 PROM 要好得多)。所有这些只是减慢了过程,不过还是能用的。

心愿单

目前,Pano G1 没有以太网支持。如果我们可以通过以太网使用 TFPT 等协议加载代码会好得多。它将使调试变得更加容易一些。

注:本文由Shellbin翻译