串口是最常见的接口,因为它足够简单,几乎在所有的调试场合都能看到它的身影。从编程的角度来说,这种接口的代码已经非常成熟,从 C 到 Python 都能够支持这种接口。实际工作生产中最常见的就是USB转串口。美中不足的是,这USB转串口在编程的时候存在着如下缺陷:
- 某些USB转串口设备需要额外安装驱动才能使用;
- 当同一台电脑存在多个COM Port时,查找特定的设备会比较麻烦;
- 打开串口后很难得知串口另外一端的设备是否已经准备好;
- 传输速度有限制,最常见的波特率只有115200,意味着一秒只能传输11KB左右的数据。
针对上述问题,这次使用南京沁恒微电子公司出品的 CH32V208 制作一个双USB串口数据交换器(Dual USB SERIAL DATA EXCHANGER, 简称DUSDE)。CH32V208是一款基于32位RISC-V设计的无线型微控制器,配备了硬件堆栈区、快速中断入口,在标准RISC-V基础上大大提高了中断响应速度。搭载V4C内核,加入内存保护单元,同时降低硬件除法周期。除了片上集成2Mbps低功耗蓝牙BLE 通讯模块、10M以太网MAC+PHY模块、CAN控制器等接口之外还带有2个USB2.0全速设备+主机/设备接口。这次的双USB串口数据交换器就是将自身模拟为2个 USB 串口设备分别连接到两台电脑上,从而实现数据传输的功能。
从上面的系统结构图可以看到,CH32V208WBU6支持两个USB2.0 Full Speed设备,其中一个可以作为 HOST或者Device,另外一个只能作为 Device 使用。我们通过编程的方式,让他们都工作在Device 模式下,下图就是我们设备的框图。
针对前面的需要额外驱动的问题,通过编程在DUSDC上实现USB CDC协议,这样在Windows 8 及其以上的系统无需额外安装驱动。Windows 识别所属的 Class 之后,会自动加载内置驱动。
针对多个串口和需要检测对面设备是否准备好的问题,我们预定义了一些命令,当用户使用2022波特率打开串口后,能够响应如下命令。
方向 | 命令 | 返回值 | 用途 |
PC->Device | ?ACV | USB1 或者 USB2 | 设备测试。返回当前的USB接口名称 |
PC->Device | ?VER | 例如:0003 | 查询当前固件版本。返回当前固件版本信息,例如:0003 这种版本号 |
PC->Device | ?SER | 例如:1234 | 查询设备序列号。返回当前设备的序列号,对于同一个设备,从USB1和USB2读取的序列号相同,例如:1234 |
PC->Device | ?CFG | NRDY或者REDY | 查询另外一个USB端口是否已经Active。当一个USB Port 收到“?ACV” 命令后就处于 Active 状态了。之后另外一个USB Port 使用这个命令会反馈前一个USB Port的状态 |
例如,系统中存在 COM1 到 COM4 多个串口,程序枚举每一个串口,打开串口后设定波特率为2022,之后从该串口发送“?ACV”命令,如果能够得到“USB1”或者“USB2”这样的回复,会表明当前为1号或者2号USB端口;
再比如,我们用设备连接两台电脑,USB Port1有程序打开过端口,并且用 “?ACV”查询过当前设备,那么USB Port 2这端程序以2022波特率打开端口后,再发送“?CFG”就能得到“REDY”的回复,表示对面的端口(USB1)已经准备好,反之如果收到“NRDY”则表示另外端口没有收到过“?ACV”命令;
最后,在程序看来数据使用串口传输,但是因为所有的传输都是在USB中进行,USB端口之间的数据也是在内存中进行的交换,相比传统串口速度会有很大的提升。实际测试表明在在使用超级终端程序的zmodem 协议传输文件时,速度可达300KB/S以上。同时因为没有串口的发送和采样过程,传输过程发生错误的概率极低。
上面就是为什么要设计DUSDC,以及它的优点。下面介绍DUSDC的具体实现。
首先是硬件部分。整体设计非常简单,并没有太多的元件:
核心部分就是基于CH32V208的最小系统,其中的S4 是Download 按钮,需要下载Firmware时,先按住按钮然后插入USB接口,之后再抬起按钮,使用 WCHISPTool即可下载。
USB 使用的是Type-B 母头,选择原因是这种结构非常稳固,最大限度保证连接的可靠。需要注意的是J1是预留的取电接口,在连接两台PC时,为了避免5V电压不同可以将J1断开,这样设备就只能通过USB2进行取电。
通过一颗TLV1117 来实现5V到3.3V的转换,5V来自USB 端口。预留了UART1作为调试接口,基本上所有的问题都能通过输出 Log 来解决。
此外,板子上预留了一个SPI1 出来的SPI NOR 接口,如果有记录数据的需求,可以考虑在该位置焊接SPI NOR芯片或者PSRAM。
因为板子上没有多余的元件,所以布线也比较简单:
3D渲染结果:
成品PCB:
硬件确定之后就可以着手设计软件了。CH32V208的示例程序有两个USB 转UART的例子,一个是USBDB (全速设备控制器)的例子,另外一个是USBFS(全速主机/设备控制器,这里用作全速设备控制器)。因为功能上有差别,这两个控制器名称也有差别,编程比较麻烦。类似CH567的话,一个是USB1另外一个是USB2感觉就会好很多。第一个工作是将两个代码融合在一起通过编译。
例如,当前是USBDB,工作基本流程是:
- 报告HOST 当前是 USB CDC 设备;
- 在 USBDB OUT的Endpoint收取来自HOST的数据,收下之后该OUT Endpoint设置为 NAK 回复状态,这样HOST不会继续对该OUT Endpoint 发送数据;
- 在主循环中轮询,如果有收到数据,那么查询USBFS IN Endpoint 是否 Busy,如果Busy继续等待,否则通过USBFS的IN Endpoint 将数据发送出去。
注意:USB 中描述的 OUT 和IN 是以HOST的CPU 为准的,对于运行着 Windows的x86 来说,OUT 是指从CPU到单片机方向(Write),反之数据是从单片机到CPU(Read)。
上述过程中, 代码位置简述:
- 在 usb_desc.c 有添加iSerialNumber 定义,这样当使用同样的设备时,每次插入会维持相同的串口号。例如,第一次使用Windows 分配为为 COM10,如果iSerialNumber为空,那么再次插入可能会被分配为COM11,但是iSerialNumber 不为空,再次插入仍然会是 COM10;
- usb_endp.c 处理USBDB 收到的主机OUT的数据,接收到的数据会放在 USB1_Tx_Buf[] 中。这部分代码可以看作是两部分:一部分是处理特别 COMMAND(以2022波特率打开串口之后进入特别 COMMAND 模式);另外一部分是处理转发数据的代码,就是在USB1_Tx_Counter=USB1BufLen 记录收到的数据长度
/*********************************************************************
* @fn EP2_OUT_Callback
*
* @brief Endpoint 2 OUT.
*
* @return none
*/
void EP2_OUT_Callback(void) {
//ZivDebug_Start
uint32_t Status;
Status = _GetEPRxStatus(EP2_OUT);
uint16_t USB1BufLen = GetEPRxCount( EP2_OUT & 0x7F);
PMAToUserBufferCopy(&USB1_Tx_Buf[USB1_Tx_Counter], GetEPRxAddr( EP2_OUT & 0x7F),
USB1BufLen);
if (USB1Replay!=0xFF) {
if ((USB1_Tx_Buf[0] == '?') && (USB1_Tx_Buf[1] == 'A')
&& (USB1_Tx_Buf[2] == 'C') && (USB1_Tx_Buf[3] == 'V')) {
printf("RCV ACV CMD\r\n");
USB1Replay = 0x01;
}
if ((USB1_Tx_Buf[0] == '?') && (USB1_Tx_Buf[1] == 'V')
&& (USB1_Tx_Buf[2] == 'E') && (USB1_Tx_Buf[3] == 'R')) {
printf("RCV VER CMD\r\n");
USB1Replay = 0x02;
}
if ((USB1_Tx_Buf[0] == '?') && (USB1_Tx_Buf[1] == 'S')
&& (USB1_Tx_Buf[2] == 'E') && (USB1_Tx_Buf[3] == 'R')) {
printf("RCV SER CMD\r\n");
USB1Replay = 0x03;
}
if ((USB1_Tx_Buf[0] == '?') && (USB1_Tx_Buf[1] == 'C')
&& (USB1_Tx_Buf[2] == 'F') && (USB1_Tx_Buf[3] == 'G')) {
printf("RCV CFG CMD\r\n");
USB1Replay = 0x04;
}
} else {
//printf("EPS1[%d]\r\n",GetEPRxStatus(ENDP2));
USB1_Tx_Counter=USB1BufLen;
printf("EP2O[%d]\r\n",USB1_Tx_Counter);
}
}
- 主机的轮询发送,在main.c中,主要动作是将数据通过 USBFS_Endp_DataUp() 函数转发给USBFS 的 Port 上。
// If USB2 is connected, we will send data to USB2
if (USBFS_DevEnumStatus == 1) {
if ((USBFS_Endp_Busy[DEF_UEP3]==0)&&(USB1_Tx_Counter!=0)&&(USB1Replay==0xFF)) {
printf("A[%d]\r\n",USB1_Tx_Counter);
// Send data to USB2
USBFS_Endp_DataUp( ENDP3, &USB1_Tx_Buf[0], USB1_Tx_Counter, DEF_UEP_CPY_LOAD);
// If data length is 64, we should send a null package
if (USB1_Tx_Counter==DEF_USBD_UEP0_SIZE) {
// Wait until USB2 is free
while (USBFS_Endp_Busy[DEF_UEP3]!=0) {
}
// Send a NULL package
USBFS_Endp_DataUp( ENDP3, &USB1_Tx_Buf[0], 0, DEF_UEP_CPY_LOAD);
}
USB1_Tx_Counter=0;
// Enable USB1 Endpoint2
SetEPRxValid( ENDP2);
}
} else {
//If USB2 is NOT connected, data from USB1 would be dropped
USB1_Tx_Counter=0;
SetEPRxValid( ENDP2);
}
此外,特别命令模式处理代码如下,通过USBD_ENDPx_DataUp() 将数据返回到USBDB对的USB Port上。
if ((USB1Replay>0)&&(USB1Replay<5)) {
// Reply USB1 Command
if (USB1Replay==1) {
RPYMSG[0]='U';
RPYMSG[1]='S';
RPYMSG[2]='B';
RPYMSG[3]='1';
}
if (USB1Replay==2) {
RPYMSG[0]='0';
RPYMSG[1]='0';
RPYMSG[2]='0';
RPYMSG[3]='3';
}
if (USB1Replay==3) {
RPYMSG[0]='1';
RPYMSG[1]='2';
RPYMSG[2]='3';
RPYMSG[3]='4';
}
if (USB1Replay==4) {
if (USB2Replay!=0xFF) {
RPYMSG[0]='R';
RPYMSG[1]='E';
RPYMSG[2]='D';
RPYMSG[3]='Y';
} else {
RPYMSG[0]='N';
RPYMSG[1]='R';
RPYMSG[2]='D';
RPYMSG[3]='Y';
}
}
USBD_ENDPx_DataUp( ENDP3, &RPYMSG, sizeof(RPYMSG)); // Send to USB1
USB1Replay=0;
SetEPRxValid( ENDP2);
}
上面就是USBDB 的USB Port 处理流程,USBFS的USB Port处理逻辑相同,但是代码表达上差异很大。下面是实物:
介绍的视频
https://www.bilibili.com/video/BV1484y1H7UM/
本文提到的电路图和PCB设计下载:
本文提到的完整代码下载: