尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

STM32 USB虚拟网卡实现:从CDC到ECM的协议栈整合指南

STM32 USB虚拟网卡实现:从CDC到ECM的协议栈整合指南 简介一个基于HAL库的STM32 USB虚拟网卡通信工程包面向嵌入式开发者、电子、自动化等专业学生及工程师解决单片机与PC间通过网络协议传输数据的开发需求。压缩包共1085个文件大小约31.23MB其中611个C源文件与305个H头文件构成核心固件78个汇编启动文件与46个ICF链接脚本适配不同工程配置另含STM32CubeMX的.ioc工程、Keil/IAR工程文件以及若干说明文档和静态库代码结构完整可直接编译调试并在此基础上二次开发。目前已有312人学习下载。整体代码经过测试运行通过覆盖USB底层配置、HAL库接口调用及虚拟网卡通信逻辑适合作为毕业设计、课程设计或项目初期的立项演示也可帮助入门者快速搭建STM32网络通信实验环境。1. USB虚拟网卡不是串口改个名是换了一套类协议很多人拿到“基于HAL库的STM32 USB虚拟网卡”例程第一反应是看看能不能直接当高速串口用。实际完全不是一回事。这套工程把STM32的USB Device接口模拟成一块标准以太网卡主机侧看到的是eth接口不需要额外的串口终端直接跑TCP/IP协议栈。相比UART通信它的吞吐量能跑到几百KB/s以上而且天然解决了组帧、校验、重传的问题。对于固件升级、测试仪器的数据回传、产线自动化这类场景非常实用。适合有一定HAL库基础、想把USB和网络协议栈串起来的工程师也适合毕设里做“嵌入式上位机通信”的同学。2. 虚拟网卡背后的USB类协议从CDC串口到ECM/RNDIS2.1 USB CDC不是一个“驱动”而是一整套设备类规范USB委员会把通信设备归为CDC类所有CDC类设备共享同一个类代码0x02。类下面又分很多模型我们耳熟能详的虚拟串口用的是Abstract Control ModelACM子类0x02而网卡用的是Ethernet Network Control ModelECM子类0x06Windows平台还常用Microsoft的RNDIS类代码0x02子类0x02但会额外带厂商扩展标识。一个设备想被主机识别成网卡关键不在端点而在接口描述符、功能描述符里的子类字义。如果直接在HAL库生成的CDC工程上改最核心的是把接口描述符里的bInterfaceSubClass从0x02改成0x06bInterfaceProtocol从0x00保持0x00。但仅仅这样还不够因为主机还会发送ECM特有的控制请求比如SET_ETHERNET_PACKET_FILTER、SET_ETHERNET_MAC_ADDRESS。这些请求没有被默认的usbd_cdc.c处理必须自己实现。下表是ECM接口描述符和数据端点描述符的关键字段对照括号里是十六进制值描述符字段ACM虚拟串口ECM虚拟网卡作用bInterfaceClass0x020x02通信设备类bInterfaceSubClass0x02 (ACM)0x06 (ECM)区分串口/网卡bInterfaceProtocol0x010x00配合子类CDC功能描述符无有指定MAC、最大包长、过滤能力数据端点数量2个Bulk(双向)2个Bulk(双向)真正走数据从表里可以看出串口和网卡在描述符层面只差几个字节但在协议栈层面差了整整一个网络层。所以替换描述符只是第一步更关键的是在数据端点上收发完整以太网帧。2.2 为什么HAL库不直接支持ECMHAL库的USB中间件STM32Cube_FW包里的STM32_USB_Device_Library已经封装好了PCD驱动把底层操作USB控制器做得很透明但类驱动部分只给了CDC ACM、HID、MSC这些常用类。ECM属于网络通信类ST官方没有在所有系列里提供现成Class所以工程里的实现方式基本是用HAL的PCD回调自己解析Setup请求挂在LwIP网络接口上。还有一个原因是ECM的控制模型是“主机告诉设备如何过滤包”设备要做很多控制请求的响应比如设置组播过滤器、设置电源管理过滤器。这部分逻辑对普通串口程序来说完全陌生。好在控制请求只要按规范回一个空包或状态包数据面就能跑。真正复杂的是把IP包放进USB的 Bulk传输这需要我们理解USB包和以太网帧不是一一对应的。2.3 让LwIP接住USB数据netif是桥梁虚拟网卡不管底层传输介质是什么对外都要表现为一个netif。LwIP的netif结构体就是协议栈和硬件的分界。我们需要在初始化时调用netif_add把netif-linkoutput指向我们的USB发送函数把netif-output交给LwIP内部的etharp_output。这样LwIP要发ARP或IP包时都会调用linkoutput最终把帧塞进USB端点。这里给出在STM32F4上的注册代码片段struct netif g_stm32_netif; err_t usb_eth_if_init(struct netif *netif) { netif-name[0] u; netif-name[1] 0; netif-output etharp_output; /* IP发包先查ARP再走linkoutput */ netif-linkoutput usb_eth_linkoutput; /* 真正把帧送出 */ netif-mtu 1500; netif-hwaddr_len 6; netif-flags NETIF_FLAG_BROADCAST | NETIF_FLAG_ETHARP | NETIF_FLAG_LINK_UP; return ERR_OK; } err_t usb_eth_linkoutput(struct netif *netif, struct pbuf *p) { /* 把pbuf链表拷贝到USB发送缓冲区然后调用HAL_PCD_EP_Transmit */ return usb_ecm_send_pbuf(p); }代码里的usb_eth_linkoutput是自定义函数参数p是LwIP的pbuf链表里面可能不是连续内存所以发送之前要拼成连续buffer。如果USB端点的发送缓冲区需要4字节对齐pbuf本身不保证所以建议在DMA内存区开个静态数组做中转。这个过程看起来多一次拷贝但能显著避免USB DMA到非对齐地址导致HardFault。注册netif之后还需要调用netif_set_default和netif_set_up并把IP地址、网关、掩码填好。一般应用我们会固定STM32的IP为192.168.7.1类似BBB主机侧虚拟网卡会被DHCP或静态IP配成192.168.7.2。如果不想跑DHCP可以在usb_eth_if_init里直接用netif-flags不带DHCP简化联调。到这里理论面讲完了。接下来是实际操作如何用CubeMX生成一个带有HAL底座的骨架工程然后逐步替换成ECM类。3. 用CubeMX生成HAL底座再手工扩展ECM类3.1 CubeMX配置项逐项拆解打开STM32CubeMX选择MCU比如STM32F407VET6或STM32F103C8T6但F103的USB全速只能FS且没有DMA需要调节点建议用F407/F429这类带USB OTG FS的型号。配置顺序如下RCC选择HSE外部晶振USB_OTG_FS模式选择Device_Only需要注意不是Host_Only中间件USB DeviceClass for FS IP选Communications Device ClassCDC这样先得到一个能枚举成CDC的工程时钟树里把USB时钟配置成48MHz否则枚举不稳定项目管理器中Toolchain选MDK-ARM生成代码。因为我用的例子最终要做ECMCubeMX生成的CDC代码只有参考价值但保留它非常有好处Keil工程里已经包含HAL库的USB驱动、中断向量、时钟初始化、DMA内存分配。在CDC基础上改而不是从空工程写能省掉至少半天配置时间。下表是我常用的参数值配置项取值说明USB Clock48MHzUSB设备必须PCD FIFO RX/OTG0512字节单位实际按32位字配置CDC_DATA_MAX_PACKET_SIZE64FS批量端点最大包长主堆栈Heap / Stack0x1000 / 0x2000给LwIP和USB描述符留空间这些值在CubeMX生成的usbd_conf.h和usbd_cdc.h里都能找到。新手如果后面出现枚举失败先检查时钟是不是48MHz再检查FIFO是否太小。3.2 修改描述符把ACM改成ECM生成完CDC工程后打开usbd_cdc.c定位到USBD_CDC_InterfaceDescriptor数组。这个数组就是当前工程里接口层的原始描述符。我的做法是新增一个独立文件usbd_ecm.c把CDC的接口描述符整体搬进来再按ECM规范修改和扩充。ECM的配置描述符结构比ACM复杂需要包含三个部分标准设备描述符在前配置描述符中间接口描述符和端点描述符在后。最核心的一段是数据接口描述符后面的“Ethernet Networking Functional Descriptor”/* CDC Functional Descriptor: Ethernet Networking */ 0x0D, /* bLength: 13 */ 0x24, /* bDescriptorType: CS_INTERFACE */ 0x0F, /* bDescriptorSubtype: Ethernet Networking */ 0x00, /* iMACAddress */ (0x00 0xFF), (0x00 8), /* bmEthernetStatistics: 高位统计置0即可 */ (0x10 0xFF), (0x10 8), /* wMaxSegmentSize: 1514 */ 0x06, /* wNumberMCFilters */ 0x04, /* bNumberPowerFilters */ 0x00, /* iInterface */注意wMaxSegmentSize我写成1514意思是最大以太网段不包含FCS恰好容纳1500字节IP包加14字节以太网头。如果这里写错主机可能拒绝把MTU配成1500后续ping包超过某个长度直接失败。接口描述符部分也把原来的两个接口调整。ECM通常有一个控制接口和一个数据接口。控制接口使用中断端点数据接口使用一对Bulk端点。数据接口的端点描述符如下/* Bulk OUT endpoint */ 0x07, 0x05, 0x01, 0x02, 0x40, 0x00, 0x00, /* Bulk IN endpoint */ 0x07, 0x05, 0x81, 0x02, 0x40, 0x00, 0x00,第一个字节0x07表示长度第二个0x05是端点描述符类型第三个是端点地址0x01是OUT端点10x81是IN端点1。0x02表示Bulk0x40是包长64字节后跟0x00是间隔。FS模式下最大包长就是64不能超过HS才能跑到512。修改描述符文件后需要在usbd_conf.c里把分配FIFO的代码调整到匹配端点。对OTG FS控制器我一般用HAL_PCDEx_SetRxFiFo和HAL_PCDEx_SetTxFiFo配置。如果工程跑了但设备不能枚举多半是FIFO大小配得不对。3.3 在Class驱动里加入ECM控制请求响应描述符定好之后电脑插上USB会尝试和STM32握手中间会发送多个标准请求和类请求。HAL库的PCD驱动会把Setup包转到HAL_PCD_SetupStageCallback再由usbd_cdc.c的s_cb.Setup处理。ECM的类请求文档在USB CDC spec里定义我贴一个简化版处理static uint8_t usbd_ecm_Setup(USBD_HandleTypeDef *pdev, USBD_SetupReqTypedef *req) { switch (req-bRequest) { case SET_ETHERNET_MULTICAST_FILTERS: case SET_ETHERNET_PACKET_FILTER: /* 这里不需要真正执行过滤直接返回状态包 */ USBD_CtlSendStatus(pdev); break; case GET_ETHERNET_STATISTIC: /* 返回一个统计结构 */ USBD_CtlSendData(pdev, (uint8_t *)stats, 4); break; default: break; } return USBD_OK; }逻辑说明请求的bRequest字段存放在req-bRequest里SET_ETHERNET_PACKET_FILTER是0x43MULTICAST_FILTERS是0x40。主机发的这些请求不是真要过滤数据而是问设备“接不接受过滤规则”。我们只要回一个状态包表示收到不影响后面的网络数据收发。如果这些请求不及时响应主机会一直卡在枚举阶段表现为拔插后设备识别成“未知USB设备”。把Setup处理接到Class回调后再实现Init、DeInit、Receive、Transmit四个回调就能让USB端点真正开关。Init里调用HAL_PCD_EP_Receive来预置一个接收缓冲区等数据到了以后再调用HAL_PCD_DataOutStageCallback。到这一步HAL库工程已经能枚举成一块标准ECM网卡了主机侧会识别为网卡设备具体名字取决于操作系统。这章内容是基础接下来我们要看数据通路在代码里如何被组织。4. 数据通路从USB中断到LwIP的每一个环节4.1 OUT路径Bulk数据如何重组成以太网帧USB Bulk传输一次最多64字节一个标准以太网帧1518字节所以要拆成多个USB事务发上来。主机驱动的虚拟网卡接口会替我们做分割但STM32侧必须自己拼帧。我在工程里用一个静态缓冲区偏移变量的方式#define ECM_RX_BUF_SIZE 2048 ALIGN_32BYTES static uint8_t ecm_rx_buf[ECM_RX_BUF_SIZE]; static volatile uint16_t ecm_rx_len 0; void HAL_PCD_DataOutStageCallback(PCD_HandleTypeDef *hpcd, uint8_t epnum) { if (epnum ECM_DATA_OUT_EP) { uint16_t len HAL_PCD_EP_GetRxCount(hpcd, epnum); memcpy(ecm_rx_buf[ecm_rx_len], ecm_rx_payload, len); ecm_rx_len len; if (ecm_rx_len 14) { /* 根据以太网长度字段判断完整帧length 1500 时可做判定 */ } if (ecm_rx_len ETH_ZLEN ecm_rx_len eth_frame_len(ecm_rx_buf[0])) { struct pbuf *p pbuf_alloc(PBUF_RAW, ecm_rx_len, PBUF_POOL); if (p ! NULL) { pbuf_take(p, ecm_rx_buf, ecm_rx_len); g_netif.input(p, g_netif); ecm_rx_len 0; } } HAL_PCD_EP_Receive(hpcd, epnum, ecm_rx_payload, USB_FS_MAX_PACKET_SIZE); } }逻辑说明函数先拿到本次数据字节数拷贝到静态缓冲区再用以太网帧头里的长度字段判断是否收完整一帧。这里eth_frame_len返回帧的总长度例如IP包总长度14字节头。只有收完整才交给LwIP的netif.input否则继续等待下一个Bulk包。最后一定要再次调用HAL_PCD_EP_Receive重新接收否则主机发来的下一个包会没有接收缓冲区而直接被USB控制器丢弃。这个重新接收的动作最容易漏漏了以后ping第一次能通第二次就永久超时。我选择用memcpy把数据从端点DMA缓冲区搬到自己的静态数组而不是直接用端点缓冲区原因是USB端点缓冲区往往由双缓冲管理器维护做多帧处理时内容会被覆盖。虽然多了一次拷贝但大大降低数据竞争风险。等后续性能优化阶段可以改成PBUF_REF方式让LwIP直接引用端点缓冲区但那样要处理释放时机。4.2 IN路径LwIP要发帧时怎么把pbuf发到USBLwIP在调用linkoutput时会把待发送的以太网帧放在struct pbuf里。由于pbuf可能是链式的必须先整理到连续内存。我这里用一个发送互斥信号量保证同一时间只有一个线程占用USB发送端点。static err_t usb_ecm_send_pbuf(struct pbuf *p) { uint16_t total_len 0; struct pbuf *q; osMutexAcquire(ecm_tx_mutex, osWaitForever); for (q p; q ! NULL; q q-next) { memcpy(ecm_tx_buf[total_len], q-payload, q-len); total_len q-len; } while (HAL_PCD_EP_Transmit(hpcd, ECM_DATA_IN_EP, ecm_tx_buf, total_len) ! HAL_OK); osSemaphoreAcquire(ecm_tx_done_sem, osWaitForever); osMutexRelease(ecm_tx_mutex); return ERR_OK; }逻辑说明这个函数先锁定USB发送权限然后把pbuf链表逐一拷贝到发送缓冲区再调用HAL_PCD_EP_Transmit发起一次USB IN传输。发送完成中断里释放信号量这里才退出。如果省略超时或信号量LwIP在高速连续发布包时会因为上一个包还没发完而卡死或覆盖缓冲。实际上当USB主机没有及时读走数据时IN端点会NAKHAL库PCD_EP_Transmit会一直等待所以需要一个超时机制我的经验是500ms超时后直接丢弃避免LwIP发一个包就卡死全局。链路层的实现对于很多做串口的人来说有点绕但只要抓住一条主线USB提供字节流我们负责把它包装成以太网帧然后交给LwIP。反过来LwIP给的包我们拆成Bulk传输发出去。理解了这条主线接下来调性能就顺手了。4.3 与主机的首次连接联调把固件烧进去后用一根USB线连接PC和STM32MCU端用USB_OTG_FS的DP/DM引脚。命令行里执行dmesg或设备管理器应该能看到新增的网络适配器。在Linux下可以直接看到ethX接口并且因为是CDC ECM默认驱动为cdc_ether不需要额外安装。然后给PC侧虚拟网卡配置同网段IP# Linux侧 sudo ip addr add 192.168.7.2/24 dev eth1 sudo ip link set eth1 up # 验证STM32侧是否应答 ping -c 3 192.168.7.1如果ping通说明USB的描述符、控制请求、数据通路都正常。如果没通先用dmesg看内核是否识别为“eth1: register cdc_ether”如果显示“disconnected”或者“device descriptor read/64”说明枚举阶段就有问题回第三章查描述符和FIFO。另一种常见情况是枚举成功但ping不通那通常不是ARP就是MTU问题这类故障排除我放到第五章讲。5. 吞吐量瓶颈与四个最容易踩的坑5.1 先算账USB FS的Bulk传输节奏STM32F4内部USB OTG FS是Full SpeedBulk端点的最大包长64字节。USB协议限定每帧1ms内可传输的事务总数受限于帧时间。所以理论最大吞吐约19641000 1.2MB/s。去掉以太网帧头、IP头、TCP头以及协议开销实际TCP应用层大概能到700~900KB/s。如果你测量出来只有300KB/s先检查是不是每个USB事务都等待了中断延迟。MTU设置对虚拟网卡影响也很大。以太网帧一般1500但USB底层包是64字节一个1500字节的IP包会被拆成24个Bulk事务增加很多ACK和调度开销。我的经验是如果纯传小包低于160字节把MTU降到600可以降低分片竞争但如果是下载固件这类大块数据保持1500更合理。可以两边手工调MTU对比。5.2 坑一端点FIFO分配不对导致枚举不稳定HAL库的底层需要知道为每个端点分配多少FIFO。对STM32 OTG_FSRX_FIFO大小必须覆盖所有OUT端点最大包总和。如果配置太小主机发一个64字节包USB控制器直接返回NAK设备在枚举过程超时。常见设置是HAL_PCDEx_SetRxFiFo(hpcd, 0x200); /* 512 个32位字 */ HAL_PCDEx_SetTxFiFo(hpcd, 0, 0x80); /* IN端点0 128字 */ HAL_PCDEx_SetTxFiFo(hpcd, 1, 0x100); /* IN端点1 256字 */注意单位是32位字不是字节填0x200就是512个字相当于2K字节很容易溢出。这个错误非常隐蔽因为大部分教程都是直接抄例程换一块MCU内存分布不同就出问题。5.3 坑二缓存没有4字节对齐OTG控制器可以用DMA把数据拉进内存而DMA默认要求4字节对齐。如果你定义接收数组时没有指定对齐属性LwIP传入的pbuf payload也可能不对齐。在ARM Compiler下我习惯用__ALIGNED(4)或直接放到DMA专用内存段ALIGN_32BYTES static uint8_t ecm_rx_payload[64];如果不对齐表现很怪异小包能通大包丢偶尔HardFault。调试时用Keil的HardFault_Handler定位到memcpy但根因不在拷贝本身。5.4 坑三主机发来的是大量ARP别忽略协议栈超时虚拟网卡刚插上时主机网卡会立刻发ARP请求。如果你在USB中断里没有及时调用netif-inputARP请求堆积LwIP还没应答主机就认为IP冲突。解决方式是保证LwIP的tcpip_thread优先级不要低于USB中断。我见过有些移植为了抢速度把USB中断设成最高优先级结果tcpip_thread永远饿死整个系统看起来像卡死其实是优先级反转。5.5 坑四用USB抓包定位枚举异常这里提一个比较高效的排查手段用USBlyzer或者Wireshark的USBPcap抓包。Wireshark插上USBPcap可以抓Host侧的USB总线流量能在枚举失败时看到设备回的是STALL还是空数据。如果Setup请求一直STALL重点查usbd_ecm_Setup里有没有全部响应类请求如果是高速设备插入低速端口查硬件上有没有用外部晶振而不是内部HSI。抓包前先把抓包、联调用的是同一个USB口否则会看到一堆无关的Hub设备。抓包可以看到SET_ADDRESS、SET_CONFIGURATION、SET_PACKET_FILTER等请求这比翻代码调描述符高效十倍。6. 一个进阶技巧ECM和CDC ACM组成复合设备USB虚拟网卡调试过程中日志输出往往不够用。很多工程师一边ping一边又在J-Link RTT里看printf但拔了USB网卡就全断了。我的技巧是让一根USB线同时承载ECM网卡和CDC ACM串口形成复合设备。在描述符设计上把ECM的接口放在接口0/1ACM串口放在接口2/3并在配置描述符开头加一个IADInterface Association Descriptor把两组接口分别绑定。这样主机既拿到eth接口又多出/dev/ttyACM0或COM口一举两得。具体做法在usbd_ecm.c的配置描述符中先连接一个IAD描述符bFirstInterface0bInterfaceCount2bFunctionClass0x02然后是ECM的控制和数据接口。之后再连接第二个IADbFirstInterface2bInterfaceCount2bFunctionClass0x02bFunctionSubClass0x02指向ACM。注意ECM和ACM都使用类代码0x02操作系统依赖IAD来区分功能如果没有IADWindows会把它们当成一个混合设备加载驱动会异常。代码片段如下0x08, /* bLength */ 0x0B, /* bDescriptorType: IAD */ 0x00, /* bFirstInterface */ 0x02, /* bInterfaceCount */ 0x02, /* bFunctionClass: CDC */ 0x06, /* bFunctionSubClass: ECM */ 0x00, /* bFunctionProtocol */ 0x00, /* iFunction */ /* 后续紧跟ECM的接口0/1和ACM的接口2/3 */这样配置后Linux下会看到ethX和ttyACM0Windows下如果没有RNDIS驱动ECM部分可能需要安装第三方驱动但ACM部分直接用系统串口驱动就能出COM口。对于日常开发串口打日志、网口传大文件互不阻塞几乎能覆盖所有调试场景。你还可以在这个基础上进一步把USB HS打开配合更大FIFO吞吐量能跳到几十MB/s这时虚拟网卡就真正具备替代以太网MAC的潜力了。本文还有配套的精品资源点击获取
返回列表