做多传感器采集板的时候,我遇到过最尴尬的一件事:板子上一共需要6个串口,GNSS定位、4G模块、激光雷达、姿态传感器、两路RS485总线设备,F407虽然有6个UART,但全部焊完之后想再挂一块MCU做协处理器,一看引脚表,一个口都没剩。当时的第一反应是拿IO口模拟UART,结果波特率一上57600就各种丢字节,查了一下午时序发现根本没法稳定;换WK2124这类SPI转串口芯片,又要重新画板,而且一片WK2124价格不便宜。后来灵机一动——既然CH340在PC上插上就能用,那能不能让STM32当USB主机,直接认CH340当"USB串口"用?顺着这个思路折腾了大概两周,最后在F407上跑通了USB HOST + CH340虚拟串口,再挂一个四口USB Hub,一口气扩出四路串口。这篇文章就把整个方案的选型思路、硬件接线、软件移植、以及我踩过的那些坑完整写出来。
这套方案的核心是用STM32的USB OTG外设做主控,通过USB HOST协议栈枚举并驱动CH340芯片,把USB转串口模块变成"即插即用的串口卡"。适合几类人看:一是项目里串口不够用、又不想改板子加UART芯片的;二是想把手头USB转串口模块直接用进嵌入式系统里的;三是对USB HOST协议栈感兴趣,想找一个具体设备做练手的。文章中所有代码以STM32F407 + HAL库 + USB_HOST中间件为基础,思路同样适用于F429、H7等带OTG外设的芯片。
1. 为什么绕一圈走USB HOST,而不是加UART
1.1 串口不够用时的几种常见方案
串口不够用,嵌入式工程师最常用的招数就那么几招,但每一招都有明显的短板。
第一招是换更多串口的芯片。比如从STM32F103换到STM32F407,再从F407换到F429,UART数量确实多了,但引脚密集、BGA封装、成本上涨,而且到了6个UART以上,芯片内部还有信号交叉、DMA通道冲突的问题,改板换片子的工作量一点也不小。
第二招是IO口模拟串口。这个方案听起来省钱,实际用起来想哭。一个8位定时器模拟9600波特率勉强能跑,但要同时模拟三路、每路还要开RX中断收不定长数据,CPU占用率直接飙到30%以上。而且IO模拟串口对中断延迟极其敏感,系统里一旦跑RTOS或者频繁进临界区,数据就开始错位,永远是调试时正常、联调时抽风。
第三招是用SPI/I2C转UART芯片,比如WK2124、SC16IS752这些。这个方案稳定,一个SPI口能扩出4路串口,但需要重新画板、加芯片、写SPI驱动,而且这类芯片不能热插拔,串口参数要在初始化时一次性配置好,后期想接个临时设备调试很不方便。
第四招就是我最终选的:USB HOST + USB转串口模块。STM32作为USB主机,CH340作为USB设备,中间Type-C线一连,CH340的串口侧直接接目标设备,主控这边用BULK端点收发数据。表面上看多绕了一层USB协议,实际上换来几个实打实的好处:CH340模块满大街都是、几块钱一个,坏了直接换;支持热插拔,调试时想接哪台设备就接哪台;一个USB HOST口挂上Hub就能扩多路,不需要重新改板。
1.2 USB HOST方案的真实优势与代价
不过USB HOST方案也不是白捡的,它的代价非常清晰:协议栈复杂、调试链路长、对主控性能有要求。USB HOST要走完枚举流程,解析设备描述符、配置描述符、端点描述符,然后才能建立通信通道。这一整套动作对单片机来说不是简单活,协议栈得占至少20KB Flash和4KB RAM,而且枚举期间主循环基本不能干别的。
但换一个角度看,这套方案解决的是一个长期问题。以前板子上想加串口,要么硬件就已经焊死,要么得飞线改板;现在我的做法是任何串口不够用的时刻,直接从抽屉里拿一个CH340模块插上,系统自动识别、自动注册一条虚拟串口链路。这种感觉是一劳永逸的。
选型时的核心判断标准我总结成一句:如果只是缺1路串口、项目已经冻结硬件,那不要碰USB HOST,老老实实IO模拟或者飞线;如果是新项目、缺3路以上串口、而且以后大概率会有临时接设备的需求,那USB HOST + CH340值得投入。我这次是4路扩展,平均摊下来每一路成本不到5块钱。
2. 硬件连接与芯片选型:哪些型号能玩,引脚怎么接
2.1 支持USB HOST的STM32型号
很多人拿着STM32F103C8T6想折腾USB HOST,直接卡在第一步,因为F103的USB外设是Device Only,根本没有HOST模式。这里必须先理清硬件前提:USB HOST功能要求芯片有USB OTG外设,且型号手册上明确写了支持HOST模式。
我整理了一张选型表,按我自己的使用经验排了个优先级。主流选择是STM32F405/407/415/417,它们带OTG_FS和OTG_HS两个USB控制器,OTG_FS可以直接当HOST用,外设丰富、参考资料多、CubeMX配置也成熟,是我认为折腾这套方案最舒服的型号。F429/439同样可以,OTG_FS结构一样,最大的好处是主频更高,跑协议栈和大数据量收发时余量更足。H7系列性能最强,OTG_HS还能跑High Speed,也就是480Mbps,但H7的USB驱动底子和F4的HAL库不完全一样,代码迁移时有些细节要留意,新手不建议直接起步。F1全系不支持USB HOST,这一点没有任何争议,F1的USB只是Device,想玩HOST只能换芯片。L4的部分型号,比如L4R5/L4S5,也带OTG_FS可以HOST,低功耗场景才能体现它的价值,但资料量少,除非功耗卡得很死,否则我还是推荐F4。
还有一个容易忽略的坑:同一个芯片封装不同,USB引脚是否全部引出也不一样,比如LQFP64封装的F407,PA9/PA10/PA11/PA12都在,但少数精简封装会砍掉PA9或者VBUS检测脚,画板前一定去翻封装Pinout,别等板子打回来才发现引脚被复用了。
2.2 引脚与最小系统接线
以STM32F407VET6为例,USB OTG_FS真正用到的引脚是这四个:
| 引脚 | 功能 | HOST模式下的接法 |
|---|---|---|
| PA11 (OTG_FS_DM) | USB D- | 直连CH340模块的D-接口 |
| PA12 (OTG_FS_DP) | USB D+ | 直连CH340模块的D+接口 |
| PA10 (OTG_FS_ID) | 主/从识别 | HOST模式下必须接GND |
| PA9 (OTG_FS_VBUS) | VBUS检测 | 接5V检测点,一般通过分压接 |
ID脚接GND这一步千万别省。很多人第一次上电发现USB枚举完全没反应,查了半天发现ID脚悬空,OTG控制器分不清自己是Master还是Slave,整个会话协商直接卡住。PA9是VBUS检测脚,它必须监控5V总线电压,所以一般会在VBUS上做电阻分压,把5V分到3.3V以下再送PA9。同时需要外供5V给整个USB总线,如果你的开发板上有USB电源管理芯片,要确认它的使能信号没被漏配置,很多开发板的VBUS是通过GPIO控制的,默认不上电。
CH340模块这边的接线很常规:VCC接5V,GND共地,D+/D-分别连PA12/PA11。注意,这里是让CH340作为USB设备挂到STM32的HOST控制器上,和控制芯片本身是否有多余UART完全无关。CH340模块另一侧的TXD/RXD再接目标设备,这样目标设备的串口数据经过CH340变成USB BULK包,再被STM32接收,等于把目标设备的串口"虚拟"到了主控的USB总线上。
2.3 VBUS供电:HOST模式最容易忽视的电源问题
USB HOST模式下,5V VBUS要由主机主动提供,而很多STM32最小系统板上压根没有5V输出电路,或者5V能力弱得可怜。CH340模块本身功耗不大,但挂上Hub之后,Hub芯片、多个CH340、指示灯、电平转换电路加一起,电流就可能超过单个开发板LDO的承受能力。
我吃过一次亏:用开发板自带的5V引脚直接给一个4口Hub供电,结果Hub上接了3个CH340模块,其中一个模块的蓝色LED一亮,整条5V瞬间掉到4.2V,USB信号直接畸变,表现为设备反复枚举、偶尔能识别但一收发就断开。后来我在5V输入侧单独加了一个DC-DC降压模块,输出5V/2A,配上470uF电解电容做储能,问题瞬间消失。
经验:USB HOST的5V电源要单独走线,不要和主控电源串在一起。如果5V源本身就是电池供电,正极线上加个500mA的PTC保险丝,防止设备短路烧坏整板。电源质量直接决定USB信号质量,这一点在USB身上比UART严格得多,丢包、误码、枚举失败,有一半以上其实是电源纹波造成的,不要一开始就去怀疑协议栈。
3. CH340不是标准CDC设备,这一步决定成败
3.1 CH340的设备描述符和通信模型
先说一个大部分人没意识到的关键点:CH340在USB层面并不完全符合CDC ACM标准,而是一个厂商自定义类(Vendor-Specific)设备。在PC上Windows装驱动后能直接用,是因为WCH提供了专用串口驱动;在Linux上能直接识别,是因为内核里有一个ch341驱动模块。但在STM32的USB HOST协议栈里,官方给的标准CDC类驱动USBH_CDC,默认处理的是标准CDC描述符,拿它去枚举CH340,大概率失败或者只能枚举、不能通信。
CH340的典型描述符是:VID = 0x1A86,PID = 0x7523,接口类代码为0xFF(vendor specific),这意味着系统没法用通用类驱动去适配它,必须针对VID/PID做匹配,然后自己实现一套厂商命令序列。这也是为什么我用CH340而不是CP2102当首选——CP2102是标准CDC设备,STM32的USBH_CDC可以直接驱动,但CH340便宜、常见、抗干扰性也不错,而且我手头全是CH340模块。
CH340的通信模型是这样的:它内部有一个USB转串口引擎,USB侧提供两个BULK端点——一个IN端点用于把串口收到的数据上传给主机,一个OUT端点用于接收主机下发的串口数据。主机要设置波特率、数据位、停止位、流控,需要发送一组厂商特定的控制请求,这些请求不在标准USB规范里,必须在驱动代码里硬编码。
3.2 在STM32 Host端处理Vendor请求
既然CH340走Vendor类,那就不能指望HAL库自动搞定配置。我的做法是在USBH_HOST中间件的基础上,自己实现一个vendor class驱动,核心工作有三块:
第一块是枚举阶段的设备识别。在类驱动注册列表里,添加上自定义的Class,在Init阶段调用HAL_HCD_GetCurrentSpeed等接口读取设备信息,判断VID/PID是否匹配CH340(0x1A86 / 0x7523),匹配成功才继续初始化,否则跳过。这样挂标准CDC设备时不会误绑定。
第二块是发送CH340初始化序列。CH340上电后必须由主机发送一系列Vendor Write请求,具体字节序列可以参考WCH官方提供的CH341PAR库或者Linux内核drivers/usb/serial/ch341.c源码。初始化序列的核心内容是设置DTR/RTS状态、配置波特率分频因子。以波特率9600为例,需要根据CH340内部基准时钟(一般是12MHz或改进型的内部时钟)计算分频值,再通过Vendor请求写入控制端点。不同批次芯片内部时钟可能略有差异,所以严格来说还要做时钟校准读取,这里不展开细节,具体计算公式和命令码以官方手册为准,代码里预留一个配置结构体就好。
第三块是BULK收发。初始化完成后,CH340的数据通道就是纯粹的BULK端点。发送数据走OUT端点,接收数据走IN端点,和CDC设备的数据收发在代码层面几乎一样,只是端点号不同。我的发送函数直接调用HAL_HCD_SubmitURB往OUT端点写,接收则用HAL_HCD_DataIn回调在中断里把数据放进环形队列。
下面这段是我在项目里实际在用的初始化代码框架,命令码部分我模糊处理了,具体参数请对照你手头芯片的官方手册调整:
typedef struct { uint8_t baud_div_low; uint8_t baud_div_high; uint8_t line_ctrl; uint8_t flow_ctrl; } ch340_config_t; USBH_StatusTypeDef CH340_Init(USBH_HandleTypeDef *phost) { uint8_t init_seq[] = { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }; // 检查 VID/PID if ((phost->device.Data[USBH_DESC_VIDL] != 0x86) || // 0x1A86 的低字节 (phost->device.Data[USBH_DESC_VIDH] != 0x1A)) { return USBH_FAIL; } // 发送Vendor setup请求,设置DTR、RTS USBH_CtlReq(phost, init_seq, sizeof(init_seq), 0x0000); // 计算并写入波特率分频因子 uint16_t div = CH340_ComputeDiv(9600); uint8_t baud_cmd[2]; baud_cmd[0] = div & 0xFF; baud_cmd[1] = (div >> 8) & 0xFF; USBH_CtlReq(phost, baud_cmd, sizeof(baud_cmd), 0x0001); return USBH_OK; }这套初始化流程看起来很绕,但一旦跑通,后续换波特率只需要再次发送分频命令就行。我建议把波特率配置函数做成公共接口,后续所有串口业务逻辑都只面对一个抽象的"虚拟串口句柄",完全不需要关心底层是USB还是硬件UART。
3.3 不折腾CH340的替代路线:用标准CDC芯片
如果你手头预算够、不想啃Vendor驱动,我强烈建议第一版先用标准CDC芯片把功能跑通,再回头决定要不要适配CH340。标准CDC设备在STM32 HAL库的USBH_CDC驱动下几乎开箱即用,典型的芯片有CP2102、FT232RL、CH343(部分型号)。把这些芯片插上,枚举结束就能收发,省掉厂商命令序列那一大堆活儿。
我用过CP2102做对比实验,STM32 HOST侧把USBH_CDC类驱动注册好,初始化阶段会主动读设备描述符,解析出CDC接口的BULK端点,开箱跑通,全程没在厂商特性上花一分钟。而CH340同样一套板子,光是初始化序列就调了四个小时。所以我的建议是:项目工期紧选CDC芯片,追求性价比、手头有存货选CH340,但要做好啃命令序列的心理准备。
还需要提醒一点:无论选哪颗USB转串口芯片,芯片串口侧的波特率由芯片自身分频决定,而不是由USB总线的传输速度决定,USB总线始终以12Mbps全速跑,通过BULK包把数据送过去,真正决定串口线上快慢的是你在厂商命令里配置的分频系数。这个逻辑很多人弄反了,以为USB是高速的串口就一定快,实际上串口波形速度完全取决于配置。
4. 软件移植:从CubeMX生成到数据收发打通
4.1 CubeMX里的一次性配置
我在项目里用的是STM32CubeMX 6.x + STM32CubeF4固件包1.27左右,配置分几步。RCC开启外部高速晶振HSE,调试口SWD保留,串口1用作日志输出(方便打印调试信息),然后关键在于USB_OTG_FS的配置。在CubeMX里把USB_OTG_FS的Mode选为Host_Only,Speed选为Full Speed,USB_HOST中间件使能,并且在中间件配置里注册你需要的类。如果用的是标准CDC设备,勾上CDC类;如果自己实现CH340的Vendor类,可以不勾CDC,但需要在USB_HOST的类驱动注册表里填上自己的Class。
时钟树是一定要认真配的。USB OTG FS在F407上要求48MHz的时钟,一般通过PLLQ输出,如果配置错误,USB模块直接不工作,而且CubeMX默认的时钟树有时会把USB时钟配成不合法状态,编译不报错、上电无反应,检查点务必放在USB时钟这一项上。我踩过一次:时钟树里漏开PLLQ,USB寄存器全部读出来是0xFFFFFFFF,排查了很久还以为是芯片坏了。
工程生成后,主循环里调用MX_USB_HOST_Process(),同时确保中断里USB_OTG_FS_IRQHandler被正确处理。USB_HOST中间件是状态机驱动的,主循环必须在1ms以内轮询一次,否则枚举超时。如果你在主循环里做了大量阻塞操作,比如Flash擦写、长时间延时,务必将USB_HOST_Process放在高优先级任务里,或者放到RTOS的独立线程中。
4.2 主循环里发生的完整通信流程
USB HOST初始化完成后,一次完整的串口数据收发,在软件层面经历的过程大致是:
设备连接时,主机检测到D+上拉,开始复位总线、分配地址、读取设备描述符、配置描述符,然后装载类驱动。我的打印日志里,枚举成功后的标志是CH340的VID/PID被打印出来,此时CH340已经处于可以收发数据的状态。然后自己的协议栈会调用CH340_Init发送初始化命令,完成串口参数配置。
发送数据时,上层业务代码把待发送的字节放入一个环形发送队列,发送任务从队列里取出64字节以下的数据块,调用BULK OUT端点URB,把数据交给USB外设,之后在下一次Process调用中检查URB完成状态。接收方向则完全走中断:HAL_HCD_DataIn回调被触发,把从CH340 IN端点收到的数据搬进接收环形队列,业务代码再异步从队列里读。
我用一个很直观的比喻解释这个流程:USB总线像一条快递通道,BULK端点是通道上的货车,CH340则把串口侧的字节打包成最多64字节一箱的包裹,主机这边只管收货拆包。和普通UART中断的实时性不同,USB的BULK传输有毫秒级延迟,因此不能把USB虚拟串口当成硬实时串口用,如果你的目标设备对时序要求特别严格,比如需要微秒级精确控制,这个方案不适合。
4.3 缓冲设计、丢包和流控
虚拟串口和硬件UART一个显著区别是:硬件UART有硬件FIFO,一般只有几字节,但配置简单;USB虚拟串口的缓冲全靠软件实现,所以缓冲区的设计直接决定丢包率。我的做法是发送和接收各维护一个环形队列,接收队列大小定在4KB,发送队列2KB。实测在115200波特率下,目标设备连续大量上传数据时,如果接收队列小于1KB,系统被其他任务占用200ms以上就会出现队列满丢包。
流控方面,CH340支持RTS/CTS硬件流控。当接收队列快满时,主机需要通过厂商命令拉高CH340的RTS引脚,通知对端设备暂停发送。这个操作在PC驱动里有现成的,但在自研Host驱动里需要自己实现,具体命令要参考CH340的寄存器定义。我项目里的目标设备大多没有硬件流控线,所以我直接禁用了流控,靠加大缓冲区来扛瞬时流量。
丢包问题还有一个隐藏点:CRC错误和STALL状态处理。USB传输本身有CRC校验,如果总线信号质量差或者电源不稳,IN端点可能出现CRC错误,导致主机重试。HAL库默认的重试次数有限,超过次数就会丢URB。我在代码里对接收URB做了异常处理,一旦URB状态异常,就重新提交接收URB,保证链路不中断。
void HAL_HCD_DataInStageCallback(USBH_HandleTypeDef *phost, uint8_t epnum) { if (g_ch340.rx_urb_ready) { uint32_t len = g_ch340.rx_len; ring_buf_write(&g_ch340.rx_queue, g_ch340.rx_buffer, len); // 重新提交接收URB,持续接收 HAL_HCD_SubmitURB(phost, epnum, 0, g_ch340.rx_buffer, MAX_RX_LEN, 0); } }这段代码的关键在于每次接收完成必须重新提交URB,否则只收到一包就再也不进数据了,这是HAL库USB HOST中间件最容易踩的坑,官方例程里通常只演示一次收发,实际项目里忘了重新提交URB的大有人在。我在代码里写了状态保护,用一个标志位防止重复提交导致URB冲突。
5. 多路CH340并联实战:Hub连接与枚举调度
5.1 Hub方案与硬件连接
一个USB HOST口只能接一个设备,这是USB协议的根本约束,所以要扩展多路CH340,必须引入USB Hub。我选用的是一个很常见的4口USB 2.0 Hub模块,主控芯片一般是GL850G或者类似方案,自带一个上行口、四个下行口。每个下行口插一个CH340模块,这样STM32就能管理4路虚拟串口。
硬件连接上有一个细节要特别注意:USB Hub下行口的5V供电要单独考虑。很多Hub模块的5V是直接从上行口输入的,如果上行口接开发板,四个CH340同时工作,电流可能到300-400mA,开发板那条USB 5V线容易顶不住。我的做法是把Hub模块的5V和GND单独接一组电源,和信号线D+/D-一起接入,但电源不再取自主控板。这么干以后,所有模块单独插拔都不会影响主控稳定。
5.2 端口枚举流程
Hub接入后,USB HOST先枚举Hub本身,然后Hub中断端点会报告下行端口的事件,比如"某个端口有设备插入"。主机收到事件后,需要对该端口做复位、分配设备地址、枚举子设备,整个过程和直接连接USB设备类似,只是多了一层端口选择逻辑。老版本STM32 HAL库没有内置Hub驱动,我当时是从Linux驱动里把Hub状态机的逻辑移植过来的,工作量不小;新版本固件包已经带了USBH_HUB类,直接用就行,省了很多事。
枚举时序是这样的:主循环每次调用USBH_HUB_Process,它会读取Hub中断IN端点的状态变化位图,找出端口事件,然后对对应端口执行复位和枚举。枚举期间,主控会依次读取子设备的描述符,如果VID/PID匹配CH340,就初始化对应通道。我代码里维护了一个端口结构体数组,每个端口记录设备地址、通道ID、数据端点号、环形队列指针,业务层拿到的是一个干净的virt_uart_t数组,根本不用关心某路串口到底挂在哪个Hub端口上。
5.3 实测带宽与稳定性
挂4路CH340、全部以115200波特率跑,我做了几轮压力测试。全速USB总线的实际带宽大约1MB/s(12Mbps协议带宽扣除协议开销后),4路115200波特率每路约11.5KB/s,四路加起来不到50KB/s,带宽完全够用,而且BULK传输模式下每路还能抢到还算稳定的时隙,所以大流量下不太会出现互相饿死的情况。
但我确实遇到了一个和Hub相关的问题:某个CH340模块拔插次数多了以后,对应端口偶尔枚举失败,具体表现是枚举到配置描述符阶段卡住。排查之后发现是Hub下行端口上的电容导致信号上升沿变缓,加上劣质模块D+上拉电阻取值不标准,最终USB眼图不合格。后来我把所有CH340模块换成同一批次的正品模块,问题不再复现。USB对信号质量比对UART敏感得多,不要省钱买那种几块钱还包邮的散新模块,稳定联调的效率远大于省下的那两块钱。
多路并发时的吞吐稳定性,和主循环的调度关系也很大。我实测过:如果每路都用独立URB发送、发送回调里打印日志,4路同时跑满115200时CPU占用率在F407@168MHz下大约是20%,还有余量跑协议解析和显示刷新。如果把发送缓冲区提高、合并小数据包,理论上还能再压一些CPU,但也没必要为了省CPU牺牲代码复杂度。
6. 调试中遇到的几个典型问题和排查链路
6.1 枚举失败:三类原因
USB项目调试起来比UART痛苦,因为报错不像串口那样有一堆十六进制数据可看,往往只有一个"hcd fail"状态。我把枚举失败的排查链路理成下面这张表,照着查效率能高不少:
| 现象 | 大概率原因 | 排查手段 |
|---|---|---|
| 枚举完全无反应,USB中断都不进 | VBUS没供上、ID脚没接地、USB时钟配置错 | 测5V、测ID脚电平,优先查电源和时钟树 |
| 能读到设备描述符,但配置描述符失败 | 电源纹波大、信号线过长或阻抗异常 | 换短杜邦线,加10uF电容稳定VBUS,示波器看D+/D-波形 |
| 枚举成功但类驱动不匹配 | VID/PID没匹配上,或设备被识别为标准CDC但实际是Vendor类 | 把枚举阶段打印的设备描述符原始字节打出来,逐字节对照 |
| 插上设备后反复枚举、偶尔成功 | 供电能力不足、USB线接触不良 | 单独供5V,换线,检查Hub下行端口电容 |
我在调第一版时最常犯的错误是VBUS供电不足。用万用表量5V电是有的,但一插上设备就掉到4.5V以下,量出来是静态电压,带载之后暴露问题。所以排查USB电源问题一定要带载测量,插上CH340模块再量5V,而不是空载量。
6.2 收发乱码和丢字节
数据通了之后,乱码是最折磨人的问题。我遇到过乱码的原因有两类:一类是CH340的波特率分频算错,串口波形频率和PC侧不一样,收出来的字节全是错位;另一类是USB BULK包的边界问题,就是数据本身是对的,但被拆包后顺序乱了。
第二类问题比较隐蔽。USB BULK传输每次最多64字节,CH340从串口侧收满64字节或者遇到某个特殊条件才会打包上传。如果目标设备发送一串80字节的帧,USB可能会出现一包64字节、一包16字节的分拆,而业务层如果每一包都当成完整帧处理,必然出错。解决办法是串口协议层必须自己做组帧,按帧头帧尾或者长度字段来切分,不能依赖USB包边界。我在接收队列后面加了一层帧解析器,按超时+长度双重判帧,问题立刻消失。
另外,SPI/I2C转串口芯片和USB虚拟串口在乱码排查上有很大区别:前者是寄存器配置错导致波特率不对,后者除了配置错,还有可能因为厂商命令里的校准时序和芯片批次不符,导致波特率有微小偏差。CH340的波特率误差一般很小,但如果遇到偶尔乱码,优先怀疑分频系数而不是硬件。
6.3 热插拔与异常复位处理
USB相比HS-UART最大的优势就是热插拔,但热插拔也会引入状态管理问题。插拔瞬间,D+电平跳变,HOST状态机可能进入错误状态,如果代码不做复位,下一次插入可能直接无法枚举。我的做法是在Hub状态机里检测到端口断开事件后,主动对对应端口的通道做清理,释放URB、清空环形队列,并把通道标记为"未连接"。
还有一种情况是CH340模块被静电打掉线,表现为枚举成功、收发几秒钟后总线进入挂起状态。遇到这种问题,一是硬件层面在D+/D-上加TVS管,二是软件层面启用看门狗定期检测USB端口状态,超过一定时间没有数据就强制重建连接。我最终在量产板上加了ESD保护,软件也做了超时重连,双保险后整机跑了半年没再出现设备丢失。
另外要提一下系统进入低功耗模式时USB的处理。USB HOST在低功耗模式下必须停掉VBUS和时钟,否则电流会超标,唤醒后再重新初始化USB外设。这个坑在电池供电的项目里尤其重要,我在低功耗唤醒回调里做了完整的外设复位和重新枚举,否则设备从睡眠唤醒后USB链路是死的,只能断电恢复。
6.4 一点个人体会
整套方案从立项到稳定运行,其实花在协议栈调试上的时间只占一小半,更多时间花在电源、信号完整性和软件边界处理上。USB HOST + 多路CH340这套架构,给我最大的收获是让"串口"这个资源不再是硬件稀缺资源,而是像插U盘一样可以随时扩展的通用资源。后面我又在另一个项目里把这个方案推广到H7上,挂了一个7口Hub,同时接CH340、MSC存储和HID键盘,照样稳定,说明这套思路在STM32全系OTG芯片上是可复用的。
如果看完这篇文章,你想动手试但心里没底,我建议从最简单的"单CH340 + STM32F407最小系统板"开始,先把枚举打通,再谈多路扩展。USB协议栈的坑确实多,但只要熬过一次完整的枚举过程,后续再碰USB HOST就轻车熟路了。