1. 为什么要在 STM32F407 上同时跑 FreeRTOS 和 LwIP
把 FreeRTOS 和 LwIP 一起搬到 STM32F407 上,这件事在工业网关、数据采集终端、远程监控模块这类项目里出现频率极高。原因不复杂:F407 自带 10/100M 以太网 MAC(IEEE 1588 也支持),168MHz 主频、192KB 片内 SRAM、带 FPU 的 Cortex-M4 内核,单芯片就能把"实时控制 + 网络通信"两件事一起干完,省掉一颗外挂 MCU 或者一颗网络协处理器的成本。而 FreeRTOS 负责把任务调度、时序抖动控制住,LwIP 负责把 TCP/IP 协议栈跑起来,两者配合,才能让"边采集边上传"这种需求落地。
但真上手写,很多人会在同一个地方卡住:裸机跑的 LwIP 能 ping 通,一挂到 FreeRTOS 下就再也回不来;或者反过来,系统任务调度正常,网口就是没反应。问题的根源几乎都出在"两个系统都要碰中断和内存"这件事上。所以这篇文章我打算按我自己的移植顺序,从骨架搭建、参数配置、协议栈对接,一直到联调排查,完整走一遍,把那些数据手册和官方例程里不会写的坑都摆出来。
内容适合已经会点 F407 裸机开发、想往 RTOS + 网络方向走的朋友,也适合手上有个 F407 项目正在被网口折磨的人对号入座。整条路线不需要额外的开发板改动,标准库或 HAL 库都能用,我下面的配置以 HAL + CubeMX 生成为例,但原理部分对标准库同样成立。
1.1 从裸机到多任务的真实痛点
先说清楚为什么要 RTOS。裸机跑网络,主循环里塞一个ethernetif_input轮询 + 一个sys_check_timeouts,看着能跑,实际上一旦 TCP 连接多了或者对端发数据密集,主循环就会被协议栈吃满,你的 ADC 采样、串口协议解析、继电器控制全部被拖慢。更麻烦的是 LwIP 里大量用了延时等待(比如 DHCP 超时、ARP 重试),裸机下只能靠时间戳轮询,代码会写得像一堆状态机缝合怪,维护成本极高。
挂上 FreeRTOS 之后,思路立刻干净:网络相关的活单独开一个任务,网卡数据接收用中断 + 信号量唤醒,协议栈内部超时交给sys_check_timeouts的定时任务或者 LwIP 自己的线程模式。你的控制任务该怎么写还怎么写,互不干扰。这就是"实时性隔离"的价值——不是让系统变快,而是让关键路径的延迟变得可预测。
我踩过的最典型的一个坑:裸机时代在主循环里用了HAL_Delay做呼吸灯,接了 LwIP 之后发现网口偶尔断流。原因就是HAL_Delay是忙等,把主循环堵住了,网卡 FIFO 里的数据来不及取,DMA 描述符被覆盖。换成 RTOS 之后呼吸灯扔进低优先级任务用vTaskDelay,问题自然消失。所以别把 RTOS 当成"高级功能",它更像是把系统里各条时间线拆开管理的工具。
1.2 F407 这颗片子的资源账:SRAM、FPU、以太网 MAC
动手之前先算账。F407 的 192KB SRAM 分三块:112KB + 16KB 的 SRAM1/SRAM2(连续,可做 DMA 缓冲),64KB 的 CCM RAM。这里有个必须记住的点:CCM RAM 不能被以太网 DMA 访问,因为它不挂在 AHB1 总线上,只有 CPU 内核能直连。所以你的 ETH 收发描述符和 pbuf 缓冲绝对不要往 CCM 里放,否则现象就是"配置全对但一个包都收不到",非常隐蔽。
我的常规分配是:ETH 描述符 + 接收缓冲(4~6 个 1524 字节描述符)约 10KB 放在 SRAM1,LwIP 的MEM_SIZE堆给 16KB 放普通 SRAM,FreeRTOS 的 heap 用heap_4给 20~30KB,再给每个任务分栈(网络任务 1.5KB、控制任务 1KB、UI/日志任务 512B~1KB)。算下来 100KB 出头,还留有余量。如果你外挂了 SRAM 芯片(FSMC/FMC 接 IS62WV51216 之类),可以把 LwIP 的内存堆搬过去,片内留给任务栈,这样能跑更复杂的应用。
FPU 这块要单独提一句。Cortex-M4F 的 FPU 默认在复位后是不开启的,需要写CPACR寄存器使能。CubeMX 里勾选 FPU 之后,启动文件里会调用SystemInit做这件事。但如果你用 FreeRTOS,还要注意configUSE_TASK_FPU_SUPPORT以及上下文切换时是否保存 FPU 寄存器。默认 FreeRTOS 的 Cortex-M4F 端口在有 FPU 任务时会启用懒加载(lazy stacking),一般不用手动干预,但中断服务函数里不要随手做浮点运算,容易在某些编译器优化级别下引入额外的 FPU 上下文,带来难以定位的时序问题。
1.3 LwIP 与 FreeRTOS 的三种结合方式选择
LwIP 提供了三种运行模式,选错模式是新手翻车的重灾区。
第一种是NO_SYS=1 裸机模式,全部靠sys_check_timeouts轮询,最简单,但和 RTOS 无缘。
第二种是NO_SYS=0 + tcpip_thread(线程模式),这是官方最推荐的。协议栈内部所有操作通过tcpip_thread串行化,你的应用任务通过tcpip_callback或者 socket/netconn API 跟它通信。好处是线程安全、结构清晰,代价是多一次消息传递,延迟略高。
第三种是NO_SYS=0 但不用 tcpip_thread(无锁模式/raw API + 自建保护),直接在自己的任务里调用 raw API(tcp_new、tcp_write等),靠你自己保证同一时刻只有一个任务碰协议栈。这种方式延迟最低,适合对性能敏感的场景,但一旦有两个任务同时调 API,就会出现指针错乱,极难调试。
我的建议是:第一次移植老实用线程模式。等你把整条链路跑通、理解了tcpip_thread的消息机制,再去考虑无锁优化。下面所有配置都以线程模式为基准。
2. 工程骨架搭建与 FreeRTOS 移植要点
这一章是移植的地基。地基没打对,后面调网口会调到怀疑人生。我的做法是先用 CubeMX 生成一个"FreeRTOS + LwIP + ETH"的完整骨架,再逐个文件核对,而不是从官方例程里抠代码往自己工程里贴。原因很简单:F407 的时钟树、PHY 地址、引脚复用这些硬件相关的东西,CubeMX 生成的初始化代码已经处理得很干净,自己手动改反而容易漏。
2.1 用 CubeMX 快速生成骨架(附关键配置项)
新建工程选 STM32F407ZGT6(或你板上实际的型号),按下面这几步走。
时钟配置:HSE 一般 8MHz 晶振,PLL 配到 168MHz 系统时钟,AHB 168MHz、APB1 42MHz、APB2 84MHz。这里有个细节:ETH 的 MAC 时钟来自 AHB,PHY 的 25MHz/50MHz 参考时钟必须由外部晶振或 MCO 引脚提供。很多板子用 25MHz 无源晶振直接给 PHY,这时候要确认 PHY 的 REF_CLK 走的是 MCO1(PA8)还是独立晶振。用 PA8 输出 25MHz 的配置下,PA8 就不能再作他用了。
外设勾选:ETH选 RMII 模式(MII 需要 25 根信号线,RMII 只要 9 根,PCB 布线友好得多),USART1留给调试打印,FREERTOS选 CMSIS_V1 或 V2 接口,LWIP勾上并选PHY型号(DP83848 和 LAN8720 是最常见的两种,DP83848 是 MII/RMII 均可,LAN8720 只支持 RMII)。
引脚分配要重点核对:RMII 模式下ETH_RMII_REF_CLK(PA1)、ETH_RMII_CRS_DV(PA7)、ETH_RMII_RXD0/RXD1(PC4/PC5)、ETH_RMII_TX_EN(PB11)、ETH_RMII_TXD0/TXD1(PB12/PB13)、ETH_MDC(PC1)、ETH_MDIO(PA2)。这几根线一根接错,PHY 就识别不了或者收不到包。PB11/PB12/PB13/PC1/PC4/PC5 在部分封装上还有复用冲突,CubeMX 会标红,注意解决。
注意:如果板子上用的是 DP83848,它的 PHY 地址由
PHYAD[4:0]引脚决定,常见是 0x01 或 0x00,不是所有板子都一样。CubeMX 里默认填 0,如果HAL_ETH_Init返回错误,第一个要怀疑的就是地址。
2.2 FreeRTOSConfig.h 里必须逐个核对的参数
CubeMX 生成的FreeRTOSConfig.h大部分能用,但有几项我建议手动确认。
| 配置项 | 建议值 | 原因说明 |
|---|---|---|
configCPU_CLOCK_HZ | 168000000 | 必须和实际系统时钟一致,否则所有延时和超时都是错的 |
configTICK_RATE_HZ | 1000 | 1ms 一个 tick,兼顾精度和中断开销 |
configMAX_PRIORITIES | 7~10 | 够用即可,太多浪费 RAM |
configMINIMAL_STACK_SIZE | 128 | 单位是 word,128 words = 512 字节,空闲任务别再小 |
configTOTAL_HEAP_SIZE | 20480 起 | LwIP + 任务 + 队列都吃 heap |
configCHECK_FOR_STACK_OVERFLOW | 2 | 强烈建议开,出问题时能直接告诉你哪个任务炸了 |
configUSE_MALLOC_FAILED_HOOK | 1 | 内存分配失败时能被捕获而不是静默返回 NULL |
configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY | 5 | 核心参数,见下一节 |
configCHECK_FOR_STACK_OVERFLOW这个选项我特别推荐开。设成 1 是只检查栈顶标记,速度快但只能发现完全溢出;设成 2 是在创建任务时用已知图案填充整个栈、切换时检查最后 16 字节,能提前发现"快溢出了"的任务。代价是上下文切换时多一点点开销,在 168MHz 上完全可以忽略。你只要实现一个vApplicationStackOverflowHook,在里面把任务名打到串口然后死循环,就能立刻定位问题源头。
2.3 FreeRTOS 堆方案选择:heap_4 与外部 SRAM 的取舍
FreeRTOS 官方给了heap_1到heap_5五种内存管理实现,移植时选哪个直接影响后面所有的内存行为。
heap_1只分配不释放,适合任务固定在启动时创建、之后不再动态创建的场景,最简单最安全。heap_2支持释放但不合并相邻空闲块,会碎片化,现在基本不推荐。heap_3直接包一层malloc/free,需要自己保证线程安全,还得改标准库的_sbrk,麻烦。heap_4支持释放并合并相邻空闲块,是绝大多数项目的默认选择。heap_5在 heap_4 基础上支持多块不连续内存区域。
我用heap_4,并且如果板子有外部 SRAM,我会用heap_5把片内 SRAM 和外部 SRAM 拼成两个 region:任务栈、队列、信号量这类访问频繁的放片内,LwIP 的 PBUF 和大缓冲放外部,因为片内 SRAM 访问速度快,任务切换时的栈拷贝不受影响。
这里还有个隐蔽问题:heap_4的块头为了内存对齐会占用额外字节,如果你开configTOTAL_HEAP_SIZE到 20480,实际可用会略少于这个数。所以别把configTOTAL_HEAP_SIZE卡得太紧,留 20% 余量。真要精确知道用了多少,打开configUSE_TRACE_FACILITY和configUSE_STATS_FORMATTING_FUNCTIONS,再用xPortGetFreeHeapSize()和xPortGetMinimumEverFreeHeapSize()打印,后者尤其有价值——它告诉你历史最低水位,能判断系统跑久了会不会因为内存曲线波动而崩。
2.4 中断优先级与 FreeRTOS 的配合
Cortex-M4 的 NVIC 优先级是数值越小优先级越高,一共 4 位有效(F407 用NVIC_PRIORITYGROUP_4,16 级抢占)。FreeRTOS 有个硬性约定:任何调用了xxxFromISR()API 的中断,它的抢占优先级数值必须大于等于configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY,也就是优先级不能比这个门槛高。
CubeMX 里configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY默认是 5,含义是:优先级数值 0~4 的中断不受 FreeRTOS 屏蔽,也不能调用 FromISR 函数;数值 5~15 的中断可以被 FreeRTOS 用BASEPRI屏蔽,允许调 FromISR。
所以 ETH 中断、串口 DMA 中断、定时器中断这些要用 FromISR 的,优先级都要设成 5 或更大。反过来,如果你有个对延迟要求极高、完全不想被 RTOS 屏蔽打断的中断(比如故障保护),设成 0~4,并且里面绝对不能碰 RTOS API。这两个原则混用一次,后果就是随机 HardFault 或者系统假死,而且很难复现。
另外,HAL_NVIC_SetPriorityGrouping(NVIC_PRIORITYGROUP_4)必须在 RTOS 启动之前调用,通常在main里HAL_Init()之后。如果你中途又调了一次改成分组 3,之前设好的优先级会被重新解释,全乱套。
3. LwIP 协议栈接入:从 PHY 到网口的完整链路
骨架搭好,FreeRTOS 跑起来,接下来是把 LwIP 从"能编译"变成"能通网"。这一章是全文的核心,我会把 PHY 硬件、lwipopts.h参数、ethernetif.c三个函数、sys_arch.c对接全部拆开讲,最后给一套实测过的静态 IP + DHCP 配置。
3.1 PHY 芯片硬件连接与时钟确认
DP83848 和 LAN8720 是最常见的两颗 PHY。DP83848 走 RMII 时,REF_CLK需要 50MHz,可以由 MCU 的 MCO 输出,也可以由 PHY 自己的 25MHz 晶振倍频。LAN8720 的REF_CLK是 50MHz 输入,它内部有 PLL,外部给 25MHz 晶振即可,但注意LAN8720 的 nINT/REFCLKO 引脚需要根据PHYAD0配置决定是输出还是输入,如果板子上这个脚被硬件拉成了时钟输出模式,你必须给它提供 50MHz 参考,不能在软件里改。
上电后第一步不是跑代码,而是拿示波器或者用 GPIO 翻转的方式确认:PHY 的 25MHz/50MHz 时钟有没有、幅度对不对、MDC 上有没有 2.5MHz 的时钟翻转。我遇到过一块板子,PHY 晶振焊盘虚焊,代码里HAL_ETH_Init一直返回HAL_ERROR,查了半天寄存器配置,最后是拿万用表量晶振两端才有发现。
MDIO 通信能正常读到 PHY 的 ID 寄存器(寄存器 2 和 3),是判断"硬件通路 OK"的第一道门槛。DP83848 的 ID 应该是0x2000A0xx系列,LAN8720 是0x0007C0xx。在HAL_ETH_Init之后加一段读 ID 的调试代码,打印出来对比手册,这一步花五分钟,能省掉后面几小时。
3.2 lwipopts.h 参数怎么定
lwipopts.h是 LwIP 的"人格设定",默认值偏保守,实际项目必须调。我给出我的常用配置和理由。
内存相关:MEM_SIZE建议 16KB 起,这是 LwIP 自己的堆,给mem_malloc用,太小会导致 TCP 发送缓冲申请失败。MEMP_NUM_PBUF设 16,PBUF_POOL_SIZE设 16,PBUF_POOL_BUFSIZE设 1524(刚好装一个最大以太网帧,避免链式 pbuf 拆分带来额外开销)。如果你要传大文件,PBUF_POOL_SIZE加到 32 甚至 64。
TCP 相关:TCP_MSS设 1460(1500 MTU 减去 20 字节 IP 头减去 20 字节 TCP 头),TCP_WND和TCP_SND_BUF建议都设成 8×MSS 以上,也就是 12000 左右,否则高延迟链路下吞吐上不去。TCP_SND_QUEUELEN设 32 或更大,它决定单连接能排队的 pbuf 数。
功能开关:LWIP_DHCP开,LWIP_DNS开(用域名访问就得开),LWIP_NETCONN和LWIP_SOCKET至少开一个(线程模式下应用层要用),LWIP_NETIF_STATUS_CALLBACK开(用于网线插拔时触发回调),LWIP_SO_RCVTIMEO开(socket 接收超时控制)。
还有一个坑要提:MEM_ALIGNMENT默认是 4,在 Cortex-M4 上够用;但如果你的编译器开了-mfloat-abi=hard并且某些结构体里有double,对齐要求会是 8,这时候如果MEM_ALIGNMENT还是 4,mem_malloc返回的地址可能是 4 字节对齐的,访问double会触发UsageFault。稳妥做法是把它设成 8。
提示:改完
lwipopts.h一定要全局重新编译,LwIP 里有大量#if条件编译,增量编译偶尔会漏掉依赖,出现"改了参数没生效"的假象。
3.3 以太网回调与 ethernetif.c 核心函数
ethernetif.c是硬件和协议栈的桥。标准模板里有几个函数必须自己填对。
low_level_init:在netif_add之前会被调用一次,用来设置netif->hwaddr(MAC 地址)、netif->hwaddr_len = 6、netif->mtu = 1500,以及netif->flags |= NETIF_FLAG_BROADCAST | NETIF_FLAG_ETHARP。MAC 地址可以从芯片唯一 ID 生成,也可以写死,但同一网段内不能重复,否则 ARP 会打架。
low_level_output:把 pbuf 里的数据拷到发送缓冲,然后调HAL_ETH_Transmit。这里如果 pbuf 是链式的(p->next != NULL),必须遍历所有分段拷贝,不能只拷第一段,否则发出去的包长度不对。
low_level_input:从接收描述符取数据,包成 pbuf 返回。这一环最容易出内存泄漏——申请的 pbuf 如果后续没被释放,PBUF_POOL会被耗尽,现象是"ping 突然不通,重启就好"。我的习惯是在ethernetif_input里netif->input(p, netif)之后,如果返回值不是ERR_OK,手动pbuf_free(p)。
接收路径推荐用"中断 + 信号量 + 独立任务"的结构:ETH 中断(HAL_ETH_RxCpltCallback)里释放信号量,一个叫ethernetif_input的任务循环等信号量、取包、交给netif->input。这样收包在任务上下文完成,不存在在中断里调协议栈的问题。
3.4 sys_arch.c 与 FreeRTOS 的信号量/邮箱对接
线程模式下,LwIP 需要sys_arch.c提供一组 OS 抽象接口,本质上就是把 FreeRTOS 的信号量、互斥量、邮箱、线程包装成 LwIP 认识的类型。
需要实现的清单:sys_mutex_new/lock/unlock/free、sys_sem_new/signal/free/arch_sem_wait、sys_mbox_new/free/post/trypost/fetch、sys_thread_new、sys_now、sys_arch_protect/unprotect。
sys_arch_protect的实现有个经典错误做法:直接用taskENTER_CRITICAL()关中断。这在短临界区没问题,但 LwIP 有些地方保护时间较长,关中断太久会影响系统实时性。更好的做法是用vPortEnterCritical系列,或者干脆用一个全局互斥量(但要注意递归调用会死锁)。CubeMX 生成的版本一般用taskENTER_CRITICAL,短临界区可以接受,心里有数就行。
sys_now返回毫秒时间戳,用xTaskGetTickCount()乘portTICK_PERIOD_MS即可。但要注意 tick 溢出:TickType_t在 32 位下大约 49 天回绕,sys_now用它做差值运算时,LwIP 内部用的是(u32_t)(now - last)这种无符号减法,本身能正确处理回绕,所以直接返回xTaskGetTickCount()的毫秒换算值就行,不用做特殊处理。
sys_mbox_fetch里传入的超时时间单位也是毫秒,要转成 tick。别直接拿毫秒当 tick 传,configTICK_RATE_HZ是 1000 时刚好相等,一旦你改成 100 或者 200,就会出现"超时时间比预期长十倍"的诡异现象。
3.5 静态 IP、DHCP、DNS 的实测配置
跑通链路后,IP 分配方式要选。调试阶段强烈建议先用静态 IP,把LWIP_DHCP关掉,netif手动设地址:
IP4_ADDR(&ipaddr, 192, 168, 1, 100); IP4_ADDR(&netmask, 255, 255, 255, 0); IP4_ADDR(&gw, 192, 168, 1, 1); netif_add(&gnetif, &ipaddr, &netmask, &gw, NULL, ðernetif_init, &tcpip_input); netif_set_default(&gnetif); netif_set_up(&gnetif);静态 IP 的好处是排除了 DHCP 交互带来的变量,ping 通了说明底层链路全对。等静态能 ping 通,再开 DHCP:
dhcp_start(&gnetif);DHCP 要走通,注意两点:一是netif_set_up要在dhcp_start之前;二是要给 DHCP 足够的超时时间,路由器响应慢的时候别急着判断失败。我一般挂个状态打印,看到DHCP_ADDRESS_ASSIGNED才算成功。
DNS 配置用dns_setserver(0, &dnsaddr),dnsaddr 填路由器地址或者公共 DNS。要验证域名解析,可以用netconn_gethostbyname或者 socket 的getaddrinfo。注意LWIP_DNS开启后需要额外的内存,MEMP_NUM_...相关池可能要跟着调大。
4. 联调避坑实录:常见问题排查表
这一章是我这些年踩坑攒下来的。移植过程里 90% 的时间不是花在"写代码",而是花在"为什么不通"。下面按现象组织,给出排查顺序。
4.1 网口灯不亮 / ping 不通的排查路径
网口灯不亮,先分清是 PHY 的 LINK 灯还是 ACT 灯。LINK 灯不亮说明物理链路没建立,这时候先换网线、换对端口,再用万用表量 PHY 的供电和复位引脚。很多开发板的 PHY 复位脚接了一个 RC 电路,上电复位时间不够就会导致 PHY 处于未初始化状态,这也是为什么有些代码里会手动拉复位脚延时几百毫秒。
LINK 灯亮了但 ping 不通,按这个顺序查:先确认netif_is_link_up()返回真;再确认 MAC 地址没和同网段设备冲突;然后用抓包工具(比如电脑上装个抓包软件)看有没有 ARP 请求发出去。如果 ARP 都没发,问题在netif_set_up或者发送路径;如果 ARP 发了但对端没回,可能是子网掩码或网关不对。
还有一种很隐蔽的情况:low_level_output里的发送缓冲是静态数组,多个任务同时发数据时会互相覆盖。正确做法是用信号量保护发送,或者保证同一时刻只有一个任务在调发送接口(线程模式下由tcpip_thread串行化,天然满足,这也是线程模式的一大优势)。
4.2 堆栈溢出、HardFault 定位方法
HardFault 是移植期的常客。定位手段有几个层次。
最直接的是开configCHECK_FOR_STACK_OVERFLOW = 2和configUSE_MALLOC_FAILED_HOOK = 1,再实现两个 hook 函数,把任务名和当前状态打印出来。栈溢出会精确告诉你是哪个任务,内存分配失败会告诉你哪次pvPortMalloc返回了 NULL。
如果 hook 没抓到,就用HardFault_Handler里读SCB->CFSR、SCB->HFSR、SCB->BFAR这几个寄存器。CFSR的IMPRECISERR位表示不精确总线错误,通常是访问了非法地址或者对齐错误;PRECISERR配合BFAR能直接指出出错地址。把这几个值打到串口,再对照 map 文件看地址落在哪个函数附近,基本能锁定。
我遇到过一个特别典型的:ethernetif_input任务的栈设了 512 字节,跑单次 ping 没事,一跑 iperf 打流就 HardFault。原因就是接收路径上调用netif->input会有一层较深的函数嵌套,加上局部变量,512 字节不够。把栈加到 1536 字节就稳了。所以网络任务的栈千万别抠,1.5KB 是底线。
4.3 收发大数据量时的内存耗尽与死锁
小数据能通、大数据就崩,几乎都是内存池耗尽。表现是tcp_write一直返回ERR_MEM,或者mem_malloc返回 NULL。解决办法有两条:一是调大MEM_SIZE和PBUF_POOL_SIZE;二是降低单次发送的数据量,用tcp_sndbuf查询当前可发送窗口,能发多少发多少,剩下的等下次。后一条是治本的做法,尤其在做文件传输时,一定不要一次性把整个文件塞给tcp_write。
死锁方面,最常见的是在持有 LwIP 互斥量的时候又去等一个 FreeRTOS 信号量,而这个信号量要靠另一个也在等 LwIP 互斥量的任务来给。避免方法很简单:不要在 LwIP 回调函数里做阻塞等待。回调是在tcpip_thread上下文执行的,你在里面阻塞,整个协议栈就卡住了。
4.4 常见问题速查表
| 现象 | 高概率原因 | 处理方式 |
|---|---|---|
HAL_ETH_Init返回错误 | PHY 地址不对 / 时钟没起来 | 读 PHY ID 确认地址,量 REF_CLK |
| LINK 灯不亮 | 网线、供电、复位时序 | 换线、量电、加复位延时 |
| ping 不通但灯亮 | MAC 冲突、子网掩码错误、发送缓冲竞争 | 改 MAC、核对掩码、加发送保护 |
| 收几个包就不通了 | pbuf 未释放,PBUF_POOL 耗尽 | 检查 input 返回值,补pbuf_free |
| 随机 HardFault | 中断优先级越界、任务栈不足 | 核对优先级门槛,加大网络任务栈 |
tcp_write返回ERR_MEM | 发送缓冲不足 | 调大TCP_SND_BUF,改用分片发送 |
| 跑几十小时后异常 | 内存碎片或时间戳回绕处理错误 | 开 heap 统计,检查sys_now实现 |
| DHCP 一直拿不到地址 | netif_set_up顺序错、路由器未响应 | 先静态 IP 验证,再开 DHCP |
注意:这张表里的每一条我都实际遇到过至少一次,尤其是"收几个包就不通"和"随机 HardFault",前者九成是 pbuf 泄漏,后者九成是中断优先级问题。有这两个现象时先往这两个方向查,效率最高。
5. 实战扩展与经验沉淀
链路通了只是开始,实际项目里还有不少东西要补。这一章说说我做完基础移植之后通常会做的几件事,以及一些调优经验。
5.1 加一个 TCP Echo 服务器做压力测试
验证协议栈稳不稳,最快的方法是写一个 TCP Echo 服务器,用电脑端打流测试。用 netconn API 大概这样:
struct netconn *conn, *newconn; err_t err; conn = netconn_new(NETCONN_TCP); netconn_bind(conn, IP_ADDR_ANY, 8080); netconn_listen(conn); while (1) { err = netconn_accept(conn, &newconn); if (err == ERR_OK) { struct netbuf *buf; void *data; u16_t len; while ((err = netconn_recv(newconn, &buf)) == ERR_OK) { do { netbuf_data(buf, &data, &len); netconn_write(newconn, data, len, NETCONN_COPY); } while (netbuf_next(buf) >= 0); netbuf_delete(buf); } netconn_close(newconn); netconn_delete(newconn); } }这个 Echo 服务器单独放在一个任务里,优先级低于网络输入任务、高于日志任务。测试的时候用电脑端的网络测试工具持续打流十几分钟,观察xPortGetMinimumEverFreeHeapSize()的变化,如果这个值一路往下掉不回升,说明有内存泄漏;如果稳定在某个水位波动,说明内存管理是健康的。
Echo 测试还有一个隐藏价值:它能压出接收路径的问题。我之前有个项目,Echo 跑 5 分钟必断,最后定位到是low_level_input里接收描述符没有及时归还,DMA 缓冲区用光。这种问题只在持续大流量下暴露,平时手动敲几个 ping 根本发现不了。
5.2 多任务优先级划分的经验法则
FreeRTOS 任务的优先级怎么分,我给一套自己常用的模板,按数值从高到低。
网络接收任务放最高(比如 5),因为它直接关系到丢包率,必须及时把 DMA 里的数据取走。定时器服务和超时处理(sys_check_timeouts如果用任务方式)放 4。TCP/IP 线程(tcpip_thread)由 LwIP 自己创建,优先级在 CubeMX 里可配,一般设 4 或 3,要高于你的应用任务,因为应用任务依赖它完成发送。
你的应用任务按实时性要求排,比如控制环放 3、数据处理放 2、日志和显示放 1。空闲任务是 0。关键在于不要让一个长耗时的任务占据高优先级,否则会饿死网络任务。如果一个任务确实需要跑很久(比如擦写 Flash),要么拆成小步,要么在循环里主动taskYIELD(),要么干脆放低优先级。
还有一个容易被忽略的点:tcpip_thread的栈大小。CubeMX 里默认可能只有 512 字节,跑复杂应用时不够。我一般设到 1024 或 1536,具体看你的应用在回调里做了多少事。回调里栈用得越多,tcpip_thread的栈就得越大。
5.3 后续可扩展方向
基础链路跑通之后,能往上搭的东西很多。最直接的是接一个轻量级 Web 服务器,用httpd或者fs模块做设备配置页面,浏览器访问就能改参数,比串口命令行友好得多。这块要注意文件系统的内存占用,LWIP_HTTPD_...相关配置项开多了会明显吃 RAM。
另一个方向是做 OTA 升级。思路是在 LwIP 上开一个 TCP 或者 HTTP 通道,把固件分包传进来写进 Flash,配合双区或者带 bootloader 的方案做校验和切换。这里的难点不在网络,而在 Flash 擦写时机——一定要在系统空闲、网络不忙的时候擦,或者把擦写操作放到低优先级任务里分块做,避免阻塞网络任务。同时升级过程要有看门狗配合和回滚机制,不然升一半掉电就变砖了。
如果项目还需要本地显示,LVGL 和 FreeRTOS 的配合也很常见,但要注意 LVGL 的刷新任务和网络任务的优先级平衡。通常 LVGL 任务放中等优先级,它内部有自己的 tick 处理,别让它去抢网络任务的 CPU。
最后分享一个我个人的习惯:每次移植完,我会把当时用的 CubeMX 配置、lwipopts.h、FreeRTOSConfig.h、ethernetif.c全部打包存一份,并且写一段简短的备注说明这块板子的 PHY 型号和地址。因为半年后再拿起这个项目,最先忘记的就是"这块板子的 PHY 地址是 0 还是 1"。这个习惯帮我省过很多重复劳动,也推荐给你。