1. 项目缘起为什么需要双网卡大家好我是老李一个在嵌入式网络这块摸爬滚打了十来年的工程师。最近在做一个基于STM32H743的智能网关项目客户要求设备既要能通过网线稳定接入工厂内网又要能连接现场的WiFi热点进行数据回传。说白了就是需要以太网和WiFi双网卡同时工作。拿到这个需求我第一反应就是去找现成的例程。手头有野火STM32H743的开发板他们提供的资料很全有独立的以太网例程也有独立的WiFi用的是常见的ESP8266/ESP32模块例程。但问题来了这两个例程是分开的各自为政。项目要求把它们整合到一个工程里让FreeRTOS和Lwip能同时管理两个网络接口。这可不是简单的“复制粘贴”。以太网驱动比如LAN8720A这类PHY芯片和WiFi驱动通过SPI或SDIO连接底层硬件操作天差地别它们的初始化流程、数据收发机制、中断处理方式都不同。更关键的是在FreeRTOSLwip这个框架下如何让两个网卡“和平共处”不打架、不丢包还能高效协作这才是真正的挑战。我花了些时间把野火的以太网例程和WiFi例程的驱动代码都啃了一遍终于理清了Lwip网卡驱动的通用框架以及两种驱动在实现上的异同。今天我就把这些实战经验分享出来带你一步步理解FreeRTOSLwipSTM32双网卡驱动整合的核心要点避开我踩过的那些坑。2. 基石Lwip的netif结构与五大驱动函数要整合驱动首先得明白Lwip是怎么看待一个网卡的。在Lwip眼里每个网络接口都是一个struct netif结构体。你可以把它想象成一个网卡的“身份证”加“能力手册”。里面记录了IP地址、网关、子网掩码这些身份信息更重要的是它包含了三个关键的函数指针input、output、linkoutput。这三个指针就是网卡与Lwip协议栈通信的“接口协议”。Lwip源码里提供了一个模板文件通常是ethernetif.c。这个文件定义了五个核心的驱动函数框架我们的工作就是根据实际硬件往这个框架里“填肉”。2.1 五大函数角色解析这五个函数我习惯称它们为“驱动五虎将”各自职责分明err_t ethernetif_init(struct netif *netif)军师负责挂帅。这是网卡的初始化总入口会被Lwip的netif_add()函数调用。它的核心任务是对传入的netif结构体进行“武装”设置MAC地址、MTU最大传输单元最关键的是把output和linkoutput这两个函数指针指向我们实际编写的发送函数。对于以太网output通常指向Lwip内置的etharp_output而linkoutput则必须指向我们自己的low_level_output。static void low_level_init(struct netif *netif)先锋负责硬件排头兵。这个函数由ethernetif_init调用专门负责底层硬件的初始化。比如对于STM32的以太网外设ETH这里会调用HAL库的HAL_ETH_Init()来配置MAC和DMA对于WiFi模块这里则是通过SPI/SDIO发送AT指令或初始化驱动芯片让WiFi模块就绪。它不参与数据流转只管把硬件通道打通。static err_t low_level_output(struct netif *netif, struct pbuf *p)信使负责对外送信。这是最底层的发送函数。当Lwip协议栈决定发送一个数据包时最终会调用到这里。它的参数pbuf *p是Lwip内部的数据包结构。这个函数的工作就是把pbuf这个“通用包裹”拆解成你的网卡硬件能认识的“本地包裹格式”然后启动发送。对于ETH可能是填充DMA描述符并启动传输对于WiFi可能是通过SPI将数据写入模块的发送缓冲区。static struct pbuf* low_level_input(struct netif *netif)驿丞负责接收并翻译。这个函数是底层接收函数。当硬件收到一帧数据时通常在中斷服务程序中这个函数被调用来读取原始数据。它的核心职责是进行“格式转换”把从硬件寄存器或缓冲区里读出来的一串原始字节精心打包成Lwip喜欢的pbuf结构然后返回。pbuf是Lwip管理数据包的生命线这一步转换至关重要。static void ethernetif_input(struct netif *netif)城门官负责通报与递交。这个函数通常被实现为一个独立的FreeRTOS任务一个永不停歇的循环。它不直接操作硬件而是作为一个“等待者”和“传递者”。它等待一个信号量Semaphore这个信号量由网卡接收中断服务程序ISR释放。一旦等到信号它就调用上面的low_level_input去取回打包好的pbuf然后调用netif-input(p)将这个数据包正式提交给Lwip的TCP/IP协议栈去处理。为什么前四个函数都加了static这是C语言的关键字意味着这些函数只在当前这个.c文件内可见。想想看low_level_output、low_level_input这些函数高度依赖具体硬件以太网的和WiFi的实现完全不同它们本来就不应该被其他无关的文件调用。用static封装起来避免了命名冲突也让代码模块更清晰。只有ethernetif_init是例外它需要被netif_add这个“上级领导”调用所以必须公开。它们之间的关系我画个简单的流程图帮你理解网卡收到数据 - 产生接收中断 - ISR释放信号量 - ethernetif_input任务从信号量等待中唤醒 - 调用low_level_input读取数据并打包成pbuf - 调用netif-input()上交协议栈协议栈决定发送数据 - 调用netif-output() - (内部可能经过ARP处理) - 调用netif-linkoutput() - 实际指向low_level_output - 操作硬件发送数据2.2 netif_add将网卡“注册”到系统理解了五虎将再看netif_add这个函数就豁然开朗了。它就像是系统的“设备注册中心”。我们在主函数或初始化任务中会这样调用它netif_add(g_netif_eth, ipaddr, netmask, gw, NULL, ðernetif_init, tcpip_input); netif_add(g_netif_wifi, ipaddr_wifi, netmask_wifi, gw_wifi, NULL, wifi_netif_init, tcpip_input);这里做了几件关键事传入一个空的netif结构体如g_netif_eth。传入IP、掩码、网关等网络配置。第五个参数这里是NULL是状态参数一般不用。第六个参数是初始化函数指针这里指向我们的ethernetif_init。netif_add会在内部调用这个函数完成对netif结构体的“武装”。第七个参数是输入函数指针这里固定填tcpip_input。这个函数是Lwip内核提供的非常强大它能自动判断收到的数据包是ARP包、IP包、TCP包还是UDP包并分发给对应的处理模块。netif_add会把这个函数指针赋值给netif-input。这就是为什么我们的ethernetif_input任务最后要调用netif-input(p)其实就是把数据包交给了tcpip_input去处理。3. 分兵解析以太网与WiFi驱动的实现差异现在我们分别看看以太网和WiFi驱动在具体实现这“五虎将”时有什么不同。这是整合前必须搞清楚的重点。3.1 以太网驱动以LAN8720ASTM32 ETH为例以太网驱动通常更“底层”因为它直接操作STM32内部的ETH外设和PHY芯片。low_level_init这里会通过HAL库初始化STM32的ETH外设MAC DMA配置引脚、时钟、中断。然后通过SMI站管理接口读写PHY芯片如LAN8720A的寄存器设置工作模式速度、双工、开启自动协商等。low_level_input以太网接收数据靠DMA。ETH外设收到数据后会通过DMA自动存放到预先定义好的缓冲区通常是描述符链表指向的数组。这个函数的工作就是遍历接收描述符链表找到有数据的那一个然后把数据拷贝出来封装到pbuf里。它需要处理DMA描述符的维护比如将当前描述符标记为已处理交给硬件下次使用。low_level_output发送也是通过DMA。函数将pbuf中的数据拷贝到发送描述符链表对应的缓冲区中配置好描述符然后启动DMA发送。发送完成后中断或轮询方式检查发送状态回收描述符。ethernetif_input任务与信号量这是关键同步机制。在ETH的接收中断服务函数HAL_ETH_RxCpltCallback中不能进行复杂的操作如申请内存、调用协议栈。标准做法是中断里只做最少的事——释放一个二值信号量Binary Semaphore。而ethernetif_input任务在一个while(1)循环中调用xSemaphoreTake(s_xSemaphore, portMAX_DELAY)等待这个信号量。信号量一到任务切换出就绪态然后才安全地调用low_level_input去取数据、调用netif-input上交协议栈。这样就把中断的快速响应和协议栈的复杂处理解耦开了。3.2 WiFi驱动以ESP8266 SPI为例WiFi驱动通常更“上层”因为WiFi模块本身如ESP8266内部已经运行了完整的TCP/IP协议栈和WiFi协议STM32通过串口、SPI或SDIO与它进行“数据包级别”的通信。low_level_init这里不涉及STM32的ETH外设。主要是初始化与WiFi模块通信的硬件接口如SPI的GPIO、时钟、中断。然后通过发送一系列AT命令或驱动芯片的初始化序列让WiFi模块连接到指定的热点SSID/密码。low_level_input与ethernetif_input的合并这是与以太网驱动一个显著的区别。在很多WiFi驱动实现中比如野火的例程并没有一个独立的low_level_input函数。因为从WiFi模块读取数据往往不是通过硬件中断触发而是通过查询Polling或者WiFi模块通过中断通知STM32“有数据”然后在中断服务程序或一个专门的任务中直接读取数据并处理。 因此常见的做法是将low_level_input的功能合并到一个类似ethernetif_input的函数里我称之为host_network_process_ethernet_data名字各异。这个函数同样是一个任务它可能等待一个来自WiFi模块中断的信号量也可能主动轮询SPI接口。当得知有数据后它直接通过SPI读取数据流解析出网络数据包当场就封装成pbuf然后调用netif-input(p)上交。它身兼了“城门官”和“驿丞”两职。low_level_output发送数据时函数将pbuf的数据按照WiFi模块要求的通信协议比如在数据前加个长度头、校验和等通过SPI接口写入WiFi模块的发送缓冲区然后触发模块发送。核心差异总结表特性以太网驱动 (如LAN8720A)WiFi驱动 (如ESP8266 SPI)硬件接口RMII/MII SMI直接操作PHYUART/SPI/SDIO与智能模块通信数据处理层级链路层MAC帧网络层IP包或链路层依赖模块数据接收触发硬件中断(ETH RX) 信号量同步模块中断或主动轮询input函数结构ethernetif_input(任务) low_level_input(函数)分离常合并为一个任务函数协议处理Lwip处理全部TCP/IPWiFi模块可能处理部分底层协议驱动复杂度较低但需熟悉ETH外设较高需处理与模块的通信协议4. 合兵一处双网卡驱动的整合关键点了解了差异整合的思路就清晰了我们需要在一个工程里创建并初始化两个netif并确保它们各自的驱动任务能独立、稳定地运行。以下是几个最关键的整合点。4.1 创建两个独立的netif结构体这是基础。在全局域定义两个struct netif变量。struct netif g_netif_eth; // 以太网接口 struct netif g_netif_wifi; // WiFi接口它们拥有各自独立的IP地址、网关、函数指针和驱动状态。在ethernetif_init和wifi_netif_init函数内部操作的分别是g_netif_eth和g_netif_wifi。4.2 信号量与任务管理避免资源竞争这是整合的核心挑战。两个网卡驱动都会创建自己的任务ethernetif_input和host_network_process_ethernet_data并且都可能使用信号量与中断同步。独立的信号量必须为两个网卡定义不同的信号量。比如s_xSemaphore_eth和s_xSemaphore_wifi。在各自的接收中断里释放对应的信号量。在自己的任务里等待自己的信号量。绝对不能用同一个信号量否则会乱套。合理的任务优先级这两个驱动任务都是数据接收任务优先级应该设为相同且较高比如高于你的应用任务但不建议一个高于另一个除非有特殊的实时性要求。让FreeRTOS的时间片轮转调度它们即可。优先级设置过高且相同可能导致它们频繁切换消耗CPU设置过低可能导致数据接收不及时。通常设置为configMAX_PRIORITIES - 2或configMAX_PRIORITIES - 3是比较合适的。堆栈空间分配确保每个任务有足够的堆栈空间。处理网络数据包尤其是pbuf的申请和释放需要一定的栈空间。可以在FreeRTOS的FreeRTOSConfig.h中定义或者在创建任务时指定。建议至少1KB以上并通过uxTaskGetStackHighWaterMark()函数监控实际使用情况进行调整。4.3 内存池Pool与pbuf管理Lwip的数据包pbuf是从内存池Memory Pool中分配的。当两个网卡高速收发数据时可能会瞬间申请大量pbuf。增大内存池检查lwipopts.h中的MEM_SIZE、PBUF_POOL_SIZE、PBUF_POOL_BUFSIZE等配置。双网卡环境下尤其是可能同时传输大量数据时如文件传输需要适当增大PBUF_POOL_SIZE池中pbuf的数量避免因pbuf耗尽而丢包。统一管理无论数据来自以太网还是WiFi最终都使用同一个pbuf内存池。这简化了管理但也要求池的大小必须满足两个网卡的峰值需求。4.4 中断冲突与优先级配置STM32的ETH中断和连接WiFi模块的SPI中断或EXTI中断可能同时发生。中断优先级NVIC根据你的系统需求合理配置。如果以太网对实时性要求极高可以将其接收中断优先级设得比WiFi中断高。但要注意在中断服务程序ISR中只能调用带FromISR结尾的FreeRTOS API如xSemaphoreGiveFromISR并且要尽快退出。中断服务程序精简再次强调中断里只做最必要的事设置标志、释放信号量、清除中断标志。所有耗时操作数据拷贝、协议处理务必放到任务中去做。5. 实战代码适配方案理论说再多不如看代码。下面我给出一些关键代码片段展示如何适配双网卡。5.1 以太网驱动适配片段ethernetif.c(以太网部分):// 定义以太网专用的信号量 SemaphoreHandle_t s_xSemaphore_eth; // ethernetif_init 函数片段 err_t ethernetif_init(struct netif *netif) { // ... 设置netif的name, MAC地址等 ... netif-output etharp_output; netif-linkoutput low_level_output; // ... 其他设置 ... low_level_init(netif); // 初始化硬件 // 创建以太网专用的信号量 s_xSemaphore_eth xSemaphoreCreateBinary(); // 创建以太网数据接收任务 sys_thread_new(eth_input, ethernetif_input, netif, DEFAULT_THREAD_STACKSIZE, ETH_INPUT_TASK_PRIO); return ERR_OK; } // ethernetif_input 任务函数 static void ethernetif_input(void *arg) { struct netif *netif (struct netif *)arg; struct pbuf *p; for(;;) { // 等待以太网专用的信号量 if (xSemaphoreTake(s_xSemaphore_eth, portMAX_DELAY) pdTRUE) { // 读取数据 p low_level_input(netif); if (p ! NULL) { // 上交数据给Lwip协议栈 if (netif-input(p, netif) ! ERR_OK) { pbuf_free(p); // 上交失败释放pbuf } } } } } // 在ETH接收完成中断回调中 void HAL_ETH_RxCpltCallback(ETH_HandleTypeDef *heth) { BaseType_t xHigherPriorityTaskWoken pdFALSE; // 释放以太网专用的信号量通知任务 xSemaphoreGiveFromISR(s_xSemaphore_eth, xHigherPriorityTaskWoken); portYIELD_FROM_ISR(xHigherPriorityTaskWoken); }5.2 WiFi驱动适配片段wwd_network.c(WiFi部分假设使用SPI接口):// 定义WiFi专用的信号量 SemaphoreHandle_t s_xSemaphore_wifi; // wifi_netif_init 函数 (类似ethernetif_init) err_t wifi_netif_init(struct netif *netif) { // ... 设置netif的name, MAC地址 (从WiFi模块获取) ... netif-output etharp_output; netif-linkoutput wifi_low_level_output; // 指向WiFi的发送函数 // ... 其他设置 ... wifi_low_level_init(netif); // 初始化SPI连接WiFi热点 // 创建WiFi专用的信号量 s_xSemaphore_wifi xSemaphoreCreateBinary(); // 创建WiFi数据接收任务 (这里将接收和处理合并了) sys_thread_new(wifi_input, host_network_process_ethernet_data, netif, DEFAULT_THREAD_STACKSIZE, WIFI_INPUT_TASK_PRIO); return ERR_OK; } // WiFi数据接收处理任务 static void host_network_process_ethernet_data(void *arg) { struct netif *netif (struct netif *)arg; struct pbuf *p; for(;;) { // 等待WiFi模块的数据通知信号量 (可能由SPI RX中断释放) if (xSemaphoreTake(s_xSemaphore_wifi, portMAX_DELAY) pdTRUE) { // 直接从SPI读取数据流并解析封装成pbuf // 这里省略了具体的SPI通信和协议解析代码它替代了low_level_input的功能 p wifi_read_packet_to_pbuf(); // 假设这个函数完成了读取和pbuf封装 if (p ! NULL) { // 直接上交协议栈 if (netif-input(p, netif) ! ERR_OK) { pbuf_free(p); } } } } } // WiFi模块数据接收中断 (例如模块通过GPIO通知STM32) void EXTIx_IRQHandler(void) { BaseType_t xHigherPriorityTaskWoken pdFALSE; if(EXTI_GetITStatus(EXTI_Linex) ! RESET) { // 释放WiFi专用的信号量 xSemaphoreGiveFromISR(s_xSemaphore_wifi, xHigherPriorityTaskWoken); EXTI_ClearITPendingBit(EXTI_Linex); portYIELD_FROM_ISR(xHigherPriorityTaskWoken); } }5.3 主函数中的初始化流程最后在main函数或系统初始化任务中按顺序启动int main(void) { // HAL初始化时钟配置等... // FreeRTOS初始化... // 初始化Lwip协议栈 tcpip_init(NULL, NULL); // 添加以太网网络接口 IP4_ADDR(ipaddr, 192, 168, 1, 100); IP4_ADDR(netmask, 255, 255, 255, 0); IP4_ADDR(gw, 192, 168, 1, 1); netif_add(g_netif_eth, ipaddr, netmask, gw, NULL, ðernetif_init, tcpip_input); netif_set_default(g_netif_eth); // 可选设置一个默认网卡 netif_set_up(g_netif_eth); // 添加WiFi网络接口 IP4_ADDR(ipaddr_wifi, 192, 168, 2, 100); IP4_ADDR(netmask_wifi, 255, 255, 255, 0); IP4_ADDR(gw_wifi, 192, 168, 2, 1); netif_add(g_netif_wifi, ipaddr_wifi, netmask_wifi, gw_wifi, NULL, wifi_netif_init, tcpip_input); netif_set_up(g_netif_wifi); // 创建其他应用任务... // 启动调度器 vTaskStartScheduler(); while(1){} }6. 调试与排坑经验分享整合过程不可能一帆风顺我踩过几个典型的坑这里分享给你希望能帮你节省时间。坑1网卡能Ping通但TCP连接不稳定。现象单独测试每个网卡都正常双网卡同时工作时TCP连接偶尔会断连或者数据传输速度很慢。排查首先检查两个驱动任务的优先级是否设置过高导致系统频繁进行任务切换协议栈处理不过来。其次用printf或调试器查看pbuf内存池的可用数量可以通过Lwip的统计功能或自定义代码看是否在大量收发数据时pbuf被耗尽。解决适当降低驱动任务的优先级确保它们不会饿死其他任务如Lwip的tcpip线程。显著增大lwipopts.h中的PBUF_POOL_SIZE我曾在双网卡视频传输项目中将其从16增加到64。确保在low_level_input或类似函数中如果申请pbuf失败要有合理的错误处理如丢弃该帧并记录日志而不是死等。坑2一个网卡工作正常另一个完全没反应。现象以太网正常WiFi死活连不上或者反之。排查信号量首先确认是不是信号量弄混了。检查中断服务程序中释放的信号量和任务中等待的信号量是不是同一个变量名。双网卡一定要用两个不同的信号量句柄。硬件冲突检查两个网卡使用的硬件资源是否有冲突。例如WiFi的SPI和以太网的RMII引脚是否共用了一个GPIO时钟配置是否正确用逻辑分析仪或示波器抓一下SPI或RMII的波形是最直接的。初始化顺序确保Lwip的tcpip_init在netif_add之前调用。有些WiFi模块需要在low_level_init中等待较长时间来连接热点如果这里用了阻塞式延时可能会影响整个系统的启动考虑将其移到独立的任务中。坑3系统运行一段时间后死机。现象系统刚开始正常运行几分钟或几小时后随机性死机。排查这很可能是堆栈溢出或内存泄漏。堆栈在FreeRTOSConfig.h中增加configCHECK_FOR_STACK_OVERFLOW的定义设为1或2并在钩子函数中打印任务名和溢出信息。同时增大可疑任务特别是两个网卡驱动任务和Lwip的tcpip线程的堆栈大小。内存泄漏重点检查pbuf的释放。确保每一个通过low_level_input或类似函数成功申请 (pbuf_alloc) 并上交 (netif-input) 的pbuf在协议栈处理完毕后都会被Lwip自动释放。如果在上交前出错比如数据校验失败必须手动调用pbuf_free释放。可以在申请和释放的地方加计数器长期运行观察是否平衡。整合双网卡驱动就像让两个性格迥异的搭档一起工作。理解它们各自的工作原理Lwip的驱动框架尊重它们的差异ETH与WiFi的实现方式并建立好沟通和协调机制信号量、任务优先级、内存管理就能让它们协同发力为你的STM32设备插上有线无线的双翼。这个过程需要耐心调试但一旦跑通那种成就感是非常实在的。希望我的这些经验能成为你项目路上的一块垫脚石。