写作前言
市面上基本上只有上位机如何控制仪器设备的介绍,基本都是透过 ni-visa 中间件或者某种已知库,这只能帮助上位机编写。
本文是将板子枚举为仪器设备并使得驱动识别的方法介绍,在我长久的检索下发现应该是全网首发。这是一个相对封闭而小众的行业,技术资料都在各个厂商自己手里捂着。
基本介绍
标准仪器设备接口是上位机和仪器通信使用的接口,如示波器、电源、信号发生器等。常用的标准仪器接口及对应的通信协议如下。
| 通信接口 | 通信协议 |
|---|---|
| LAN | VXI-11 |
| USB | USB-TMC |
| GPIB | IEEE488 |
LAN 的通信协议和 TCP/IP 的 TCP Client/Server 、RAW Socket 没什么本质区别,只要支持其中之一都可以正常识别。
GPIB 的通信协议比较复杂,而且物理层是并口传输,几乎只能使用专用的收发器芯片进行通信,专用收发器的控制方式类似 SRAM ,也并没有什么很难解决的问题。
USB 的通信方式就必须品鉴一下了。
而我司作为一个测试设备和仪器公司,居然在这方面没有任何技术积累,那就让我来细品一下什么是 USB-TMC 枚举吧。
整体思路
首先非常重要的是翻阅标准文档,有以下两份我自己机翻的双语对照版,需要双页视图对照查看。
USBTMC_1_00_EnCh.pdf | yono的文件
USBTMC_usb488_subclass_1_00_EnCh.pdf | yono的文件
其次非常不重要的是找一个友商的正规仪器抓数据包看看是啥情况。
ps:实际上我并没有看过第二份文档哪怕一眼,完全依靠抓数据和第一个文档互相映照就完成了驱动开发。
值得关注的是 USB-TMC 类枚举实际上只有 USB488 这一个子类,而且仍有许多的预留数据位置和通信过程,所以我认为 TMC 这个枚举仍然可以使用很多年。
枚举过程
众所周知,驱动程序在 USB 设备的枚举阶段需要询问描述符,这个描述符的编写是怎么枚举成 TMC 设备的关键。

枚举过程数据包
经过抓包,一个示例的设备描述符就出来了,和他一模一样就好了,有如下。
其中最为关键的是 Interface descriptor 部分中的 bInterfaceClass 和 bInterfaceSubClass 这两个部分直接决定了我们的枚举类型。当然整个描述符中的其他标有 (static) 的部分都是必须与示例一模一样的,是标准的规定。有些是 TMC 标准,有些是 USB 标准,反正标有 (static) 的部分都一模一样就好了。
次为关键的是 VID/PID 相关,这与使用 ni-visa 生成的 usb 驱动需要对应,生成 usb 驱动并在电脑端安装的方法官方有指南不赘述,指示了厂商和设备型号,像我这个完全一模一样是非法的,将显示友商的厂商名和设备型号。
最为特殊的是 ,USB-TMC 的协议没有高速全速之分,无论工作在什么 USB 线路上,都需要设备限定符,这是和其他现代化的 USB 协议栈所不同的地方。
次为特殊的是,USB-TMC 设备不能使用组合枚举,会破坏 NI-VISA 对 USB-TMC 的识别和资源调用。例如我尝试组合枚举成 TMC + 串口 设备,设备管理器中都显示出来了(枚举成功),但是使用 VISA 调用时只能调用串口,TMC 设备不能调用了。
向仪器发送指令的过程
通信中,会通过此前描述符中的 Bulk Out 端点向仪器发送报文。所以设备应该反复开启这个 Bulk Out 端点以接收任何可能的报文。如果设备没有开启 Bulk Out 端点,将会使得设备产生大量的 NAK 包,触发向设备发送重启端点的 setup 指令。
例如一个经典的报文如下
0x01, 0x02, 0xFD, 0x00, 0x06, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x2A, 0x49, 0x44, 0x4E, 0x3F, 0x0A, 0x00, 0x00
这个报文可以依据 USBTMC_1_00 标准文档中的描述进行解析,分为如下几个段。
| 报文段 | 含义 |
|---|---|
| 0x01 | 标识这个报文是 OUT 请求,也就是仪器需要解析这个报文的内容进行动作。 |
| 0x02, 0xFD | 报文 tag 及其反码,这个 tag 每个报文都会不同。 |
| 0x00 | 固定 0。 |
| 0x06, 0x00, 0x00, 0x00 | 指内容长度是 6 ,这个长度以小端 32 位传输。 |
| 0x01, 0x00, 0x00, 0x00 | 仅首字节的第一个 bit 有意义,是 1 表示内容语句已经完成传输,是 0 表示还有后续的内容报文。 |
| 0x2A, 0x49, 0x44, 0x4E, 0x3F, 0x0A | 6 个字节的正式内容,是 *IDN?\r ,\r 是回车的转义字符。 |
| 0x00, 0x00 | 保证整个报文长度是 4 字节对齐的补 0。 |
简单来说也就是解析 Bulk Out 端点接收到的报文数据的首个字节,如果是 0x01 那么进行后续解析,提取到 "*IDN?" 的正式 SCPI 指令并进行处理。
向仪器接收回复的过程
通信中,仪器并不能随意通过 USB 向上位机发送回复,这是和串口通信不同的。当上位机期望接收回复数据时,会在 Bulk Out 端点上发送一个报文,表示仪器现在可以发送回复了,一个经典开启回复的报文示例如下。
0x02, 0x03, 0xFC, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
这个报文可以依据 USBTMC_1_00 标准文档中的描述进行解析,分为如下几个段。
| 报文段 | 含义 |
|---|---|
| 0x02 | 标识这个报文是 IN 请求,也就是仪器应当发送任何可能的回复了。 |
| 0x03, 0xFC | 报文 tag 及其反码,这个 tag 每个报文都会不同。 |
| 0x00 | 固定 0。 |
| 0x00, 0x04, 0x00, 0x00 | 表示上位机开通了 0x0400 共 1024 个字节的接收 buffer,这个值完全不用理会,上位机资源那么丰富,如果通信失败让他自己开大点就好了。 |
| 0x00 | 其 D1 位也就是 &0x2 的那一位有意义,如果是 1 意味着下面一个字节是上位机支持且设备必须支持的终止符,如果是 0 则不用理会。其他位全是预留。 |
| 0x00 | 如果上一个字节的 D1 位是 1,那么这个字节表示支持的终止符,例如 \r。 |
| 0x00, 0x00 | 填充的保留字符,实际作用也是保证整个报文长度是 4 字节对齐的补 0。 |
简单来说也就是解析 Bulk Out 端点接收到的报文数据的首个字节,如果是 0x02 那么开启设备的发送通道,例如上位机此前传输了 "*IDN?" 的正式 SCPI 指令,那么设备此时需要传输原本产生的回复。
而所谓指定终止符功能,当然不会支持,在后面必须的 setup 包介绍中,会介绍如何让上位机知道我们不支持这个指定。
一个经典的回复传输报文如下。
0x02, 0x03, 0xFC, 0x00, 0x06, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x2A, 0x49, 0x44, 0x4E, 0x3F, 0x0A, 0x00, 0x00,
| 报文段 | 含义 |
|---|---|
| 0x02 | 标识这个报文是 IN 请求的回复。 |
| 0x03, 0xFC | 报文 tag 及其反码,这个 tag 应该复制请求报文的。 |
| 0x00 | 固定 0。 |
| 0x06, 0x00, 0x00, 0x00 | 表示回复的有效数据共 6 个字节,这个长度以小端 32 位传输。 |
| 0x01, 0x00, 0x00, 0x00 | 仅首字节的第一个 bit 有意义,是 1 表示内容语句已经完成传输,是 0 表示还有后续的内容报文。 |
| 0x2A, 0x49, 0x44, 0x4E, 0x3F, 0x0A | *IDN?\r,这是在做回环测试,我的仪器会复制最后一条接收到的指令响应任何回复。 |
| 0x00, 0x00 | 保证整个报文长度是 4 字节对齐的补 0。 |
一个优秀的 4 字节对齐方法
使用位运算非常方便高效地进行 4 字节对齐
setup 包的处理
0x80 0x06 开头的这些标准 setup 包,有如下类别,恕我也爱莫能助,我也是完全依赖 usbx 协议栈进行自动处理。
而 0xA1 or 0xA2 开头的 COMMAND_REQUEST 包,有如下内容是我们必须处理的。
以 0xA1 0x07 开头,表示在询问设备的特性,此时有一个示例的回复包如下。其中的内容比较多,可以自行翻阅文档,推荐是直接按这个回复就好了。此时会告诉上位机,我们不支持设定结尾符。
0x01, 0x00, 0x10, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
以 0xA2 0x01 开头,表示清除 bulk-out 传输,我们啥也不管,回复成功就好了,回复包如下。
0x01,tag(复制setup包以0计的第2个字节)
以 0xA2 0x02 开头,表示问询此前清除 bulk-out 传输的请求的状态,我们啥也不管,回复成功就好了,回复包如下。
0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00
至于其他的 COMMAND_REQUEST 包,不常用不用管。
Note
首先你应该稍微熟悉 usbx 的实践,起码使用过其中已有的例子例如 CDC_ACM,否则以下内容毫无意义。以下均是在 usbx 这个 usb 协议栈框架下,如何将 TMC 枚举增加进去的示例。
枚举描述符在 usbx 中的实践
关注到 _ux_device_stack_initialize 函数,其中有这样的部分,也就是只会以 Interface descriptor 以及 Configuration descriptor 部分在描述符整体中的位置进行寄存。Configuration descriptor 部分之前的统统作为设备描述符, Interface descriptor 部分以后的作为端点描述符。
那么需要稍微修改原本的 ux_device_descriptors.c 文件中 USBD_Device_Framework_Builder 函数的内容,原本函数中应该有类似如下的内容。其中指定只有高速 USB 才需要设备限定符,但是我们的 USB-TMC 现在通常都是工作在全速 USB 上的,又因为 USB-TMC 的协议没有高速全速之分,所以这里需要删掉这个 if 判断,无论高速还是全速,都把这个设备限定符编进描述符中。
其他部分不需要大的调整,只要按照示例描述符一段一段修改参数就可以了,在原文件基础上修改好的 ux_device_descriptors.c 和 ux_device_descriptors.h 文件如下。
发送和接收在 usbx 中的实践
管你这那的,看代码。开了一共 3 个 buffer,这是必须的,因为 USB 传输线路会占用一个 buffer,接收到的完整语句的寄存需要一个,发送寄存需要一个。值得注意的是,_ux_device_class_dpump_tmc_read 函数哪怕经过我的改造,也依然是一个阻塞的函数,所以 TMC_Engine() 的 TMC 收发支持需要独立占用一个线程。
TMC_Dpump 指针将会在 TMC_Device 的 activate 和 deactivate 中进行绑定和解绑。
setup 特殊支持在 usbx 中的实践
我们知道,usbx 中想要开启设备类,必须调用 ux_device_stack_class_register() API,这个 API 需要传入一个 _entry 函数作为类的驱动,依据 usbx 的官方示例和文档,自定义的类枚举最好以 dpump 类为基础,所以这里以 _ux_device_class_dpump_entry 为基础改造一个 TMC 类的驱动。
其中主要需要改写的是 UX_SLAVE_CLASS_COMMAND_QUERY 和 UX_SLAVE_CLASS_COMMAND_REQUEST 分支,UX_SLAVE_CLASS_COMMAND_QUERY 分支需要改写成对于 0xFE InterfaceClass: TMC 的支持,而 UX_SLAVE_CLASS_COMMAND_REQUEST 分支需要增加此前提到的 0xA1 0xA2 TMC 特殊命令的支持。