1. 为什么要折腾这套“FreeRTOS LwIP”组合STM32F407 移植 FreeRTOS 和 LwIP说难不难说简单也真不是配几个文件就能完事的。我前前后后给好几个基于 F407 的产品做过这套组合一个联网温控器、一个带以太网接口的数据采集盒子、还有一个跑 MQTT 上云的小网关。每次都会遇到不一样的问题但整体流程已经非常成熟。这篇文章就是把 F407 FreeRTOS LwIP 的迁移过程、配置原理和我实际踩过的坑从头讲一遍适合想给设备加网络功能、又不想用现成模组去买独立协议栈授权的工程师也适合刚学完 F407 裸机编程、想上 RTOS 的新手。1.1 这套组合到底解决了什么问题F407 这颗芯片自带 10/100M 以太网 MAC外部再接一颗 PHY 芯片就能实现物理层收发。硬件上其实只差一个软件协议栈网络数据来了之后谁来处理、怎么处理这就是 LwIP 的活。但光有协议栈还不够因为 LwIP 处理 TCP/IP 需要持续占用 CPU而你的产品通常还要同时干别的事比如读传感器、控制继电器、响应按键、刷新屏幕。如果全塞在一个大的 while(1) 循环里网络接收和业务处理互相卡脖子等网口数据的时候按键没反应处理业务的时候 TCP 又超时重传。FreeRTOS 的作用就是把这些任务拆开让 LwIP 跑在自己的任务里业务逻辑跑在另外的任务里任务之间用队列、信号量、互斥锁通信。说白了就是用调度换耦合度代码结构会清爽很多后期加功能也不用推翻重写。1.2 方案选型为什么偏偏是这两兄弟FreeRTOS 是开源免费的实时内核源码结构简单Cortex-M4 的移植包官方早就做好了网上资料多到看不完。LwIP 同样免费专门为嵌入式场景设计内存占用可控支持 raw、netconn、socket 三种编程接口和 FreeRTOS 的组合在 STM32 生态里属于“标准答案”。我见过有人用 uC/OS-III 配 LwIP也有人用 RT-Thread 自带网络框架都可行但论资料成熟度和调试工具丰富程度FreeRTOS LwIP 依然是最省心的默认选择。我对比过裸机跑 LwIP 和 FreeRTOS LwIP 的差别。裸机方案里以太网中断收包后只能在中断里简单处理或者设一堆标志位让主循环轮询一旦 TCP 重传、超时定时器多起来裸机循环就撑不住了。LwIP 本身有很多定时回调比如 ARP 表老化、TCP 超时重传需要周期性调用tcpip_thread来处理。有了 FreeRTOS这些机制才能自然运转起来。所以我的结论很直接凡是产品里网络和业务同时存在直接上 RTOS省得以后返工。对比项裸机 LwIPFreeRTOS LwIP网络收发的并发处理主循环轮询互相阻塞协议栈独立线程互不干扰业务扩展加一个功能就要改大循环新建任务即可逻辑隔离调试难度状态标志满天飞难定位任务栈、信号量可视化代码可维护性阶段性可以长期维护痛苦结构性好适合持续迭代2. 移植前准备源码、工程结构、硬件排查很多人一上来就急着往 Keil 里拖文件结果编译报错几百条。移植第一步其实是把环境理清楚硬件先确认能不能跑网络再开始动软件。2.1 源码获取和版本选择FreeRTOS 官方内核在 GitHub 上有独立仓库叫 FreeRTOS/FreeRTOS-Kernel下载 V10.4.x 或者更新的版本都行。我不建议直接拉最新开发分支选稳定的发布版就好比如 V10.4.6教程多、踩坑记录全。LwIP 当前稳定版是 2.1.x 系列2.1.2、2.1.3 我都用过后面讲的配置以 2.1.x 为准。注意 LwIP 官方新版本发布节奏不快别去下载那种带一堆 contrib 示例的完整包我们只需要 src 目录里的东西。源码放哪里也有讲究。我是这么组织的Project/ User/ main.c ethernetif.c sys_arch.c tcp_server.c Middlewares/ FreeRTOS/ include/ portable/ heap_4.c LwIP/ core/ api/ netif/ port/这样 FreeRTOS 和 LwIP 互不干扰Keil 工程里按这个目录建分组一目了然。另外建议把版本号写进工程说明或者注释里我吃过亏项目放半年再回来维护根本想不起来用的是哪个版本的 LwIP查 API 文档都对不上号。2.2 硬件排查RMII 接口和 PHY 选型F407 的以太网 MAC 通过 RMII 或 MII 接口连接外部 PHY。绝大多数开发板用 RMII因为引脚少只需要 7 根信号线。RMII 和 MII 的区别一句话解释MII 是 4 位数据线加独立收发时钟RMII 把数据线收窄到 2 位时钟频率从 25MHz 提到 50MHz引脚省一半。F407 上常用的 RMII 引脚映射如下PA1RMII_REF_CLK50MHz 参考时钟PA7RMII_CRS_DV载波检测PC4RMII_RXD0PC5RMII_RXD1PG11RMII_TX_ENPG13RMII_TXD0PG14RMII_TXD1PA2MDIO管理接口数据PC1MDC管理接口时钟PHY 芯片决定了很多细节。LAN8720 是最常见的低成本选择默认地址是 0自带 50MHz 晶振的话就不需要 STM32 提供参考时钟。DP83848 则是另一种常见方案有些板子喜欢让 STM32 的 MCO1PA8输出 50MHz 给 PHY。动手前必须打开原理图确认三件事PHY 型号、PHY 地址线接法、50MHz 参考时钟由谁提供。我见过有人拿着 LAN8720 的板子照抄 DP83848 的初始化代码PHY 地址都不对结果查了整整一天。这一步千万别省。2.3 CubeMX 能不能用怎么用F407 的 HAL 库和 CubeMX 其实已经能一键生成 FreeRTOS LwIP 的框架这也是很多热词里出现“cubemx stm32h723 配置 lwip”这类搜索的原因。CubeMX 生成确实快适合赶工期。但我的建议是至少手过一遍移植流程因为生成代码里藏着太多隐式配置等到出问题要排查的时候你连文件在哪都找不到。我的习惯是 CubeMX 只用来看引脚冲突和时钟树实际工程仍然手动移植。两种方式没有对错只是手动移植能让你真正理解 LwIP 怎么和 FreeRTOS 对接出了问题能顺着代码一路查下去。如果你时间紧用 CubeMX 生成后再按本文的配置项逐个检查也是不错的选择。3. FreeRTOS 移植实操FreeRTOS 的移植是所有后续工作的地基。这一节我把每个文件、每个配置项讲清楚因为后面 LwIP 跑出问题很多根源其实在 FreeRTOS 配置不正确。3.1 到底要往工程里加哪些文件FreeRTOS 内核源码集中在几个核心文件中千万别把整个目录一股脑拖进工程。面向 Keil F407 的场景你需要的是内核文件tasks.c、queue.c、list.c、timers.c用到事件组就加 event_groups.c用消息缓冲区就加 stream_buffer.c移植层portable/RVDS/ARM_CM4F 目录下的 port.c 和 portmacro.h这是 Keil ARMCC 编译器对应 Cortex-M4F 的移植内存管理portable/MemMang/heap_4.c只选一个 heap 方案放进工程为什么选 heap_4它支持内存块合并和释放是实际项目中用得最多的方案。heap_1 只分配不释放适合任务全部静态创建的场景heap_2 有释放但会碎块heap_3 是简单包装 C 库 malloc。F407 这种要长期跑网络协议栈的设备任务会反复创建删除内存得能回收复用heap_4 是最稳的选择。我在 Keil 里建了 FreeRTOS 分组把上面这些文件全部放进去然后在 Options for Target 的 Include Paths 里加上 FreeRTOS 的 include 目录和 portable 目录。注意Keil 的 C99 模式建议开起来否则部分源码编译会告警。3.2 FreeRTOSConfig.h 关键配置逐项解读FreeRTOSConfig.h 是整个移植的灵魂每个宏都有实际意义。下面这份配置是我在 F407 上常用的基线#define configCPU_CLOCK_HZ ((unsigned long)168000000) #define configTICK_RATE_HZ ((TickType_t)1000) #define configMAX_PRIORITIES (10) #define configMINIMAL_STACK_SIZE ((unsigned short)128) #define configTOTAL_HEAP_SIZE ((size_t)(20 * 1024)) #define configUSE_PREEMPTION 1 #define configUSE_MUTEXES 1 #define configUSE_RECURSIVE_MUTEXES 1 #define configUSE_TIMERS 1 #define configUSE_CO_ROUTINES 0 #define configCHECK_FOR_STACK_OVERFLOW 2 #define configPRIO_BITS 4 #define configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY 5 #define configMAX_SYSCALL_INTERRUPT_PRIORITY \ (configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY (8 - configPRIO_BITS))逐个说重点configCPU_CLOCK_HZ是 168MHzF407 主频这个必须准确所有时间换算都依赖它。configTICK_RATE_HZ我用 1000也就是 1ms 一个 tick网络处理需要相对精细的时间片1000Hz 比较合适别用 100Hz否则延迟会很明显。configPRIO_BITS填 4因为 Cortex-M4 的 NVIC 优先级寄存器只用高 4 位这句话翻译一下优先级数值范围是 0 到 15数值越小优先级越高。configMAX_SYSCALL_INTERRUPT_PRIORITY是 FreeRTOS 判定“哪些中断能调用 API”的边界5 意味着优先级数值大于等于 5 的中断可以调用xSemaphoreGiveFromISR这类函数。0 到 4 的高优先级中断里不允许调用任何 FreeRTOS API。这是后面 HardFault 重灾区先记住这个规则。configCHECK_FOR_STACK_OVERFLOW打开到 2功能更全面配合vApplicationStackOverflowHook使用任务栈溢出时能第一时间抓到。另外 F407 是有 FPU 的如果开了 Keil 的 Floating Point Hardware 选项port.c 会自动处理 FPU 上下文保存。你需要在 FreeRTOSConfig.h 里确认configENABLE_FPU相关宏F407 免费内核版本默认支持不需要额外操作但要记得 Keil 编译选项不要选错否则硬件浮点任务切换时寄存器保存不完整会出诡异问题。3.3 中断接管和启动文件修改Cortex-M4 上 FreeRTOS 依赖三个异常SVC 用于启动第一个任务PendSV 用于任务切换SysTick 用于系统时钟节拍。Keil 的启动文件startup_stm32f407xx.s里面默认这些中断处理函数名叫SVC_Handler、PendSV_Handler、SysTick_Handler而 FreeRTOS 的 port 层期望的是vPortSVCHandler、xPortPendSVHandler、xPortSysTickHandler。处理方法有两条路。第一是直接改启动文件里的向量表把中断向量指向 FreeRTOS 的 handler一劳永逸但改汇编容易手滑。第二更优雅在 FreeRTOSConfig.h 里用宏把名字映射回来#define vPortSVCHandler SVC_Handler #define xPortPendSVHandler PendSV_Handler #define xPortSysTickHandler SysTick_Handler这样启动文件不用动。但有个前提编译链接的时候别让 port.c 里原本的xPortPendSVHandler函数体重复定义宏替换后它实际生成的是PendSV_Handler符号和启动文件里的声明对齐。我用第二种方式在多个项目里验证过稳定。这里还要提醒一个 HAL 库用户最容易踩的坑如果你用 CubeMX 生成的 HAL 工程HAL 库默认用 SysTick 做HAL_GetTick的时间基准。FreeRTOS 接管 SysTick 之后HAL_GetTick会变得不可靠HAL_Delay也会失灵。解决办法是在 CubeMX 里把 Timebase Source 改成其他定时器比如 TIM6让 HAL 和 FreeRTOS 各用各的时钟源。这个坑我在第一次移植时踩得头破血流界面卡死在那里查了半天才发现是 tick 冲突。3.4 两个任务跑起来才算移植完成FreeRTOS 文件加好、配置写好、启动文件处理好之后先别急着碰 LwIP写两个简单的 LED 闪烁任务验证调度器能不能正常工作void vTaskLed1(void *arg) { for (;;) { LED1_TOGGLE(); vTaskDelay(pdMS_TO_TICKS(500)); } } void vTaskLed2(void *arg) { for (;;) { LED2_TOGGLE(); vTaskDelay(pdMS_TO_TICKS(1000)); } } int main(void) { SystemInit(); /* 初始化 GPIO、时钟等外设 */ xTaskCreate(vTaskLed1, led1, 128, NULL, 2, NULL); xTaskCreate(vTaskLed2, led2, 128, NULL, 2, NULL); vTaskStartScheduler(); while (1) { } }注意xTaskCreate的第三个参数是栈深度单位是“字”而不是“字节”。128 就是 512 字节。4 字节对齐的 M4 上我习惯最小任务栈给 128 字。两个 LED 以不同频率闪烁说明任务切换正常。再人为造一个栈溢出测试configCHECK_FOR_STACK_OVERFLOW能不能触发 hook确认保护机制生效FreeRTOS 移植才算真正完成。4. LwIP 移植实操LwIP 移植的工作量比 FreeRTOS 大因为它多了一层操作系统抽象。核心思路是LwIP 本体运行在tcpip_thread任务里网卡驱动收到数据后通过tcpip_input丢给这个线程处理而我们的业务任务再用各种 API 和协议栈交互。4.1 LwIP 源文件怎么选LwIP 的 src 目录下核心代码分几块我按最小可运行集合列出来core 基础init.c、mem.c、memp.c、netif.c、timeouts.c、def.c、inet_chksum.c、ip.cIPv4 协议core/ipv4 下的 ip4.c、ip4_addr.c、etharp.c、icmp.c、dhcp.c网络接口netif/ethernet.c应用接口层api/api_lib.c、api/api_msg.c、api/err.c、api/netbuf.c、api/netifapi.c、api/tcpip.c移植层你自己写的 sys_arch.c、ethernetif.c如果只用 IPv4IPv6 那些文件就不加省内存也省编译时间。DNS、SNTP 这些应用协议按需再加。我见过有人图省事把整个 src 目录全拖进工程结果编译出一堆重复定义和未定义符号排查起来比手动挑选更痛苦。LwIP 的裁剪性本来就是它的优点别浪费。4.2 lwipopts.h 配置思路lwipopts.h 是 LwIP 的配置文件作用类似 FreeRTOSConfig.h。以下是我的基线配置项每个都解释一句为什么这么设#define NO_SYS 0 #define LWIP_SOCKET 0 #define LWIP_NETCONN 1 #define LWIP_TCP 1 #define LWIP_UDP 1 #define LWIP_ICMP 1 #define LWIP_DHCP 1 #define LWIP_DNS 1 #define MEM_ALIGNMENT 4 #define MEM_SIZE 8192 #define PBUF_POOL_SIZE 20 #define PBUF_POOL_BUFSIZE 1520 #define TCP_WND 4096 #define TCP_SND_BUF 4096 #define TCP_MSS 1460 #define LWIP_NETIF_STATUS_CALLBACK 1 #define LWIP_NETIF_LINK_CALLBACK 1 #define LWIP_STATS 0 #define LWIP_DEBUG 0NO_SYS必须设 0表示 LwIP 运行在操作系统之上这个宏错了整个移植方向就错了。LWIP_SOCKET我关掉用 netconn API省一层封装也少踩一些坑如果你要用 MQTT 库之类的第三方组件它们多半依赖 socket API到时候再打开。MEM_SIZE是 LwIP 内部堆的大小TCP 报文段、各种控制块都从这里分配。F407 有 128KB 主 SRAM我给 8KB实测能稳定维持几个 TCP 连接。PBUF_POOL_SIZE是 pbuf 池的数量每个缓冲区是PBUF_POOL_BUFSIZE大小1520 刚好能装下最大以太网帧加头部开销。收包时 pbuf 从池里拿速度远快于从 MEM_SIZE 堆里分配所以池的数量要够用否则高负载下丢包。TCP_WND和TCP_SND_BUF我习惯都设 4096再大一点吞吐更高但对内存压力也大。LWIP_STATS和LWIP_DEBUG平时都关掉省内存省 flash出了问题再打开对应模块的调试宏定位。4.3 sys_arch.c连接 FreeRTOS 和 LwIP 的桥梁LwIP 预期操作系统提供信号量、互斥锁、邮箱、线程创建、系统时间等能力这部分就是 sys_arch.c。这是移植中技术含量最高、也最容易出错的文件。关键实现思路信号量用 FreeRTOS 的二值信号量封装。sys_sem_new对应xSemaphoreCreateBinarysys_sem_signal对应xSemaphoreGivesys_sem_wait对应带超时的xSemaphoreTake注意 LwIP 的超时参数单位是毫秒0 表示永远等待。测试中我发现常见错误是忘记处理超时返回值LwIP 要求sys_sem_wait返回SYS_ARCH_TIMEOUT表示超时如果直接返回错误码协议栈会误判信号量状态导致 TCP 连接异常。邮箱用 FreeRTOS 队列实现队列元素是void *指针存的是 pbuf 或 netconn 消息指针。我在 sys_arch.c 里创建一个预先分配好存储的队列#define SYS_MBOX_SIZE 16 static QueueHandle_t mbox_queue[SYS_MBOX_SIZE]; static void *mbox_storage[SYS_MBOX_SIZE] {0};实际上更标准的是每次sys_mbox_new时调用xQueueCreate(size, sizeof(void *))注意队列长度至少能缓冲网卡中断进来的数据包太短会丢包。线程创建封装xTaskCreatesys_thread_t sys_thread_new(const char *name, lwip_thread_fn thread, void *arg, int stacksize, int prio) { TaskHandle_t handle; xTaskCreate(thread, name, stacksize, arg, prio, handle); return (sys_thread_t)handle; }这里有个细节stacksize 的单位要和你xTaskCreate的预期对上。LwIP 配置里的TCPIP_THREAD_STACKSIZE默认是字节还是字不同移植包处理不同。我习惯在 sys_arch.c 里直接把传入值除以 4 转成字或者干脆在 lwipopts.h 里把TCPIP_THREAD_STACKSIZE写成足够大的值比如 2048配合uxTaskGetStackHighWaterMark检测实际使用率再回调。sys_now也很关键LwIP 所有定时器依赖它u32_t sys_now(void) { return (u32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS); }如果 FreeRTOS tick 是 1000HzxTaskGetTickCount()返回的就是毫秒数直接返回即可如果改过 tick 频率必须乘上周期换算。4.4 以太网驱动和 PHY 初始化网卡驱动是整个移植里硬件相关最重的部分。F407 的以太网外设自带 DMA有独立的发送接收描述符。我的做法是RX 描述符数组和对应的 pbuf 缓冲区放在普通 SRAM 里注意不要放在 CCM RAM 里因为 CCM 不接总线矩阵DMA 根本访问不到。很多人在这一步栽跟头数据老是不对查来查去发现缓冲区在 CCM 里。初始化流程大致是使能 ETH、GPIO、DMA 相关时钟配置 RMII 引脚为复用功能F407 上以太网引脚复用号为 AF11PHY 复位等待复位完成初始化以太网 MAC设置 MAC 地址、RMII 模式、自动协商或强制 100M 全双工初始化 DMA 描述符RX 描述符指定缓冲区地址TX 描述符清零配置以太网中断打开 DMA 收发通过 MDIO 读取 PHY 寄存器和 PHY 交互中断处理我用的是 ETH 全局中断ETH_IRQHandler在中断里判断 DMA 接收完成事件然后给接收信号量void ETH_IRQHandler(void) { BaseType_t xHigherPriorityTaskWoken pdFALSE; if (ETH_GetRxPktSize(ETH) ! 0) { xSemaphoreGiveFromISR(rx_semaphore, xHigherPriorityTaskWoken); } ETH_DMAClearITPendingBit(ETH_DMA_IT_R); portYIELD_FROM_ISR(xHigherPriorityTaskWoken); }这种方式把收包处理和协议栈处理解耦收包任务等信号量拿到后从 DMA 描述符中取出数据封成 pbuf调用tcpip_input送进协议栈。注意中断优先级必须设置成 5 或更高数值因为它在中断里调用了xSemaphoreGiveFromISR优先级 0 到 4 的中断不允许触碰 FreeRTOS API这个问题放在后面的排查部分详细讲。PHY 初始化时要写寄存器比如配置自动协商、使能交叉检测。LAN8720 和 DP83848 的寄存器地址不一样千万不要混用。我最常用的排查手段是直接回读 PHY ID 寄存器LAN8720 的 PHY ID 地址是 2 和 3读出约 0x0007C0F1DP83848 的 PHY ID 是 1 和 2读出约 0x20005C90如果读出全 0xFFFF八成是时序或者地址不对得回头查 PHY 地址引脚和 MDC/MDIO 接线。4.5 netif 注册与 IP 获取驱动就绪后把网络接口注册进 LwIP。我在一个初始化函数里完成static struct netif g_netif; static ip4_addr_t ipaddr, netmask, gw; void ethernet_if_init(void) { ip4_addr_set_zero(gw); ip4_addr_set_zero(ipaddr); IP4_ADDR(netmask, 255, 255, 255, 0); netif_add(g_netif, ipaddr, netmask, gw, NULL, ethernetif_init, tcpip_input); netif_set_default(g_netif); netif_set_up(g_netif); }netif_add的第六个参数ethernetif_init是网卡初始化回调它负责把 netif 的 output 函数指向 etharp_outputlinkoutput 指向底层发送函数。第七个参数传tcpip_input表示收到数据后投递给 tcpip_thread 处理。IP 获取用 DHCP 还是静态 IP取决于产品场景。开发调试阶段我推荐 DHCP省得来回改电脑 IP。上线产品如果对启动速度有要求静态 IP 更稳DHCP 协商失败会耽误几十秒。DHCP 模式下这样启动dhcp_start(g_netif);同时实现 link 状态回调检测到网线插入后把 netif 置为 up拔掉后置为 down。链路状态读取通过 PHY 的状态寄存器LAN8720 的状态寄存器是 0x1Fbit0 表示链路建立。轮询还是中断都可以我习惯在一个低优先级任务里每 500ms 读一次简单可靠。5. TCP 服务器示例网线插上就能连驱动通了、协议栈跑起来了接下来用一个最简单的 TCP 回声服务器验证整条链路电脑往设备发什么设备就原样回什么。这个实验做完你的移植已经可以打 80 分了。5.1 netconn API 和 raw API 怎么选LwIP 提供三种编程接口raw API、netconn API、socket API。raw API 性能最好但全部基于回调代码写起来像倒悬的控制流新手上手容易糊涂。netconn API 是阻塞式封装一个线程里就能完成 accept、recv、send 的逻辑和写普通 PC 网络程序很像特别适合跑在 FreeRTOS 任务里。socket API 是 POSIX 风格方便移植第三方库但多了一层封装内存开销略大。我自己的原则自研业务用 netconn速度够用、代码好维护要接 MQTT、HTTP 这类现成开源库时打开 LWIP_SOCKET 走 socket。别一上来就追求 raw API 的极致性能先把功能跑通比什么都重要。5.2 回声服务器任务完整实现static void tcp_echo_task(void *arg) { struct netconn *conn; struct netconn *newconn; err_t err; conn netconn_new(NETCONN_TCP); netconn_bind(conn, IP_ADDR_ANY, 8080); netconn_listen(conn); for (;;) { err netconn_accept(conn, newconn); if (err ! ERR_OK) { vTaskDelay(pdMS_TO_TICKS(10)); continue; } while (1) { struct netbuf *buf; void *data; u16_t len; err netconn_recv(newconn, buf); if (err ! ERR_OK) { break; } netbuf_data(buf, data, len); netconn_write(newconn, data, len, NETCONN_COPY); netbuf_delete(buf); } netconn_close(newconn); netconn_delete(newconn); } }这段代码的流程是创建监听连接绑定 8080 端口进入 accept 等待客户端连接收到连接后在新连接上循环 recv 数据并把数据原样写回去客户端断开后退出循环清理连接资源。有几个细节值得注意。netconn_write的最后一个参数我传NETCONN_COPY表示 LwIP 会把数据拷贝一份再发送。如果传NETCONN_NOCOPY数据发送完毕前不能释放源缓冲区使用不当会踩内存错误。回声服务器场景数据量小COPY 模式最安全。这个任务在 main 里的启动顺序也很关键tcpip_init(NULL, NULL); xTaskCreate(tcp_echo_task, echo, 512, NULL, configMAX_PRIORITIES - 2, NULL); vTaskStartScheduler();tcpip_init必须在创建业务任务之前完成因为它要创建 tcpip_thread 和初始化协议栈内部结构。任务栈 512 字在 netconn 阻塞收发场景下够用但如果你的逻辑复杂建议留到 1024 并用高水位检测一次再定。5.3 测试闭环怎么打硬件连上网线电脑配好同一网段的 IP先用 ping 测链路ping 192.168.1.10ping 通说明 ARP、ICMP、链路层都正常。然后用nc测 TCP 服务nc 192.168.1.10 8080输入一行字回车如果原样返回整条 TCP 收发链路就通了。我习惯同时开 Wireshark 抓包观察 DHCP 四步交互、ARP 请求、TCP 三次握手这些在网络异常时是最有力的排查证据。测完 TCP建议顺手把 UDP 也测一遍比如设备周期性向电脑某端口发一个心跳包确认 UDP 通路没有问题因为后面做 MQTT、SNTP、设备发现都依赖 UDP。6. 常见问题排查与避坑实录这一节整理我实际项目中遇到的高频问题。每一个都真实花掉过不少时间写成速查表能帮你少走很多弯路。6.1 HardFault 和中断优先级的关系最经典的 HardFault 场景网口一有数据就进异常。绝大多数原因是中断里调用了 FreeRTOS API但中断优先级设置得太高。前面讲过优先级数值 0 到 4 的中断不允许调用xSemaphoreGiveFromISR因为 FreeRTOS 认为这些中断用于最紧急的任务不在它的保护范围内。排查方法很简单查ETH_IRQHandler对应的 NVIC 配置。正确配置是 5NVIC_InitStructure.NVIC_IRQChannel ETH_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 5; NVIC_InitStructure.NVIC_IRQChannelSubPriority 0; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure);另一个 HardFault 高发点是任务栈溢出。configCHECK_FOR_STACK_OVERFLOW打开后溢出时会进入vApplicationStackOverflowHook调试时在这里打断点立刻就能定位到是哪个任务爆栈。另外别忘了 FreeRTOS 调用netconn_write时数据流要在不同任务间传递局部变量或栈上数组作为缓冲区传给异步接口是常见错误。6.2 内存不足和分配失败现象是 TCP 连接能建立但传输几包数据后卡死或者netconn_accept返回ERR_MEM。问题几乎都出在内存配置上。我排查的顺序是确认 FreeRTOS 的configTOTAL_HEAP_SIZE和 LwIP 的MEM_SIZE、PBUF_POOL_SIZE是否匹配实际业务。FreeRTOS 堆和 LwIP 堆是两块独立内存互不借用。打开LWIP_STATS通过lwip_stats结构体看mem_used、mem_max、pbuf分配失败次数。用uxTaskGetStackHighWaterMark检查每个任务的真实栈用量比瞎猜准得多。F407 的 SRAM 分两块128KB 主 SRAM 和 64KB CCM。CCM 只能被内核访问DMA 和外设够不到所以以太网描述符、pbuf 缓冲区必须放在主 SRAM。如果开了内存优化编译器选项要小心别让变量悄悄被安排到 CCM 里。我常用的搭配是 FreeRTOS 堆 20KB、LwIP MEM_SIZE 8KB、PBUF_POOL_SIZE 203 到 4 个并发 TCP 连接稳稳的。如果你的产品要同时跑 HTTP 服务器和 MQTT 客户端把 MEM_SIZE 提到 16KB、PBUF_POOL_SIZE 提到 30 比较稳妥。6.3 PHY 识别失败、网口不通ping 不通是移植期最让人抓狂的问题。我建议按这个顺序查读 PHY ID。值不对说明 MDIO 通信有问题或者 PHY 地址选错。LAN8720 地址 0DP83848 地址可能被拉成 1 或别的值看原理图。量 RMII_REF_CLK 有没有 50MHz 时钟。没有时钟PHY 根本没法工作。确认netif_set_link_up有没有被调用。协议栈认为链路 down 时数据包处理会临时走特殊路径表现为 ping 不通但寄存器看起来正常。检查 ETH_Handle 或 SPL 的初始化参数速度模式、双工模式、RMII 选择都要和 PHY 特性匹配。遇到过最恶心的一种情况是PHY 的上电时序不对ETH_SoftwareReset后立刻读寄存器PHY 还没从复位中恢复导致初始化失败。在复位后加一个几十毫秒延时再操作 PHY 寄存器问题立刻消失。这个经验写在代码注释里后来同事抄初始化代码时直接少踩一个坑。6.4 TCP 断连和不稳定“lwip tcp断连”这类搜索背后通常隐藏着几个原因。我逐个列出来接收窗口太小。TCP_WND设太小对端发稍快一点就触发零窗口表现就是连接没断但卡住不动。适当调大TCP_WND和TCP_SND_BUF。没有开启 TCP keepalive。网络中间设备老化连接后设备自己不知道。在 lwipopts.h 里打开LWIP_TCP_KEEPALIVE按业务需要配置空闲探测周期。单任务串行处理多连接。一个连接netconn_recv阻塞时其他连接全部排队。要么每连接建一个新任务处理要么换 raw API 的事件驱动方式。ARP 表项老化后重传失败。抓包能看出来设备在收到大量 ARP 请求时表现尤其明显。多数情况下调大ARP_TABLE_SIZE和缩短 ARP 超时能改善。还有一个容易被忽略的点tcpip_thread的栈不够。LwIP 内部处理 TCP 分段重组、收包时耗栈大户都在这个线程里。我把TCPIP_THREAD_STACKSIZE设为 2048 并配合高水位检查稳定运行几天没有问题。6.5 问题速查表现象可能原因排查思路解决办法启动即 HardFault中断优先级 0-4 内调用 API检查 ETH/NVIC 配置优先级改为 5 或更大数值任务莫名卡死任务栈溢出打开栈溢出检测 hook加大任务栈或检查局部数组ping 不通PHY 地址错误 / 无 50MHz 时钟回读 PHY ID、示波器测时钟修正地址 / 检查时钟来源DHCP 一直拿不到 IPpbuf 池太小 / ARP 异常打开 LWIP_STATS增大 PBUF_POOL_SIZETCP 传输一卡一卡TCP_WND 太小抓包看窗口调大 TCP_WND、TCP_SND_BUF多连接互相阻塞单任务串行 acceptrecv观察连接响应时间每连接一任务或换 raw APIHAL_Delay 失灵SysTick 被 FreeRTOS 占用检查 HAL timebase 配置CubeMX 改 TIM6 做时基7. 调优和扩展建议移植跑通只是开始真正让这套系统稳定工作在产品里还需要做一些优化和规划。这一节算是我个人经验层面的总结。先说内存优化。读懂 LwIP 的内存分配路径是调优的前提普通数据包走 pbuf 池快速分配协议控制块和某些大块数据走 MEM_SIZE 堆。通过lwip_stats观察两种内存的使用峰值然后按 1.5 倍余量配置既省内存又留余地。FreeRTOS 侧同样用高水位函数定期上报任务栈余量把这些监控信息通过串口打印出来压力测试跑一晚上第二天看报告比凭感觉调参靠谱得多。再说性能。F407 跑 LwIP 的瓶颈通常不在 CPU 主频而在内存拷贝。默认netconn_write的 COPY 模式会拷贝数据如果吞吐要求高可以换 raw API 实现零拷贝把待发送数据直接封装成 pbuf 队列交给协议栈。另一个性能点是 DMA 描述符数量RX 描述符多几个可以吸收突发流量我一般配 4 到 6 个 TX 描述符、6 到 8 个 RX 描述符。协议栈跑稳定后很多人会继续加 MQTT、HTTP 服务器、SNTP 校时、Modbus TCP 等功能。我的建议是每加一个协议都单独建一个任务用消息队列和外层业务解耦别把所有协议塞进同一个任务里。比如 MQTT 任务断线重连时不能影响 HTTP 服务正常响应独立任务天然隔离。再聊一个很多人问的问题有人想用 Proteus 仿真 F407 跑这套组合。我的看法是别浪费时间Proteus 对 F407 的以太网外设和 LwIP 完整协议栈模拟能力非常有限真实运行时的中断时序、PHY 交互、内存布局差距太大仿真能跑不代表真机没问题。直接花几十块买块带 LAN8720 的开发板真机调试比仿真省几个星期都不止。最后再分享一个小技巧移植过程中每完成一个里程碑比如 FreeRTOS 跑通、LwIP 初始化成功、TCP 回声测试通过就做一次完整工程的备份。方法很土但非常有效后面调崩了随时可以回退到最近一个稳定点比在代码里打补丁再删补丁效率高得多。这套 F407 FreeRTOS LwIP 的组合我已经在多个量产项目里验证过只要配置合理、内存规划清楚连续运行几个月不重启没有任何问题。
