☰
STM32F407+lwIP+FreeRTOS网口调试与socket实战
2026/10/26 3:34:10 网站建设 项目流程

简介:面向STM32F407、LWIP与FreeRTOS的嵌入式网络控制学习资料,适合具备一定单片机基础、希望把实时操作系统与TCP/IP通信落到实际项目中的开发者。内容围绕点钻机下位机控制场景展开:以STM32F407为主控,借助FreeRTOS完成任务调度与资源管理,通过LWIP实现TCP、UDP、ICMP、IPv4等网络服务,使上位机可远程下发钻孔位置、速度等参数并接收状态反馈,形成可参考的软硬件协同方案。压缩包约69.89MB,文件总数与类型明细上游暂未提供,目录与具体文件需下载后查看。已有558人学习,可作为工业自动化、物联网远程控制及嵌入式网络通信的实践案例,帮助梳理协议栈移植、多任务划分、中断处理与上下位机数据交换等关键思路。

1. 网口灯不亮的那个下午:stm32f407+lwip+freertos 这套组合卡在哪

焊好板子、插上网线,两个灯一个都不亮——不少人第一次碰 stm32f407 + lwip + freertos,起点都是这个画面。控制器侧的 ETH 外设、PHY 的 RMII 接口、协议栈的初始化顺序、RTOS 的调度上下文,这四件事只要有一件没对齐,现象都是“网口像死了一样”,而且没有任何报错。这个工程压缩包做的事,就是把已经咬合好的三层骨架交到你手上:ETH 驱动 + lwIP 2.x + FreeRTOS 的移植粘合层、可直接编译的 TCP/IP 通信框架,以及一套能跑通的 socket 示例。它适合已经能把 F407 点灯、串口跑通、想直接上以太网通信的人;也适合做过裸机 lwIP、现在准备把协议栈搬进 RTOS 的人。

2. 三层咬合处:一帧数据从 PHY 进到 socket 要经过几次交接

2.1 从 RMII 到 pbuf:F407 上的收包路径拆开看

先建立一条完整的路径认知,后面所有调试才有坐标系。网线上的差分信号进了 PHY,PHY 解码成 RMII 的 RXD0/RXD1 加 RX_DV,参考时钟 REF_CLK 是 50MHz,每个时钟周期传 2 bit,也就是 100Mbps 满速时的数据节奏。MAC 侧把半字节拼成帧,DMA 按描述符环把帧体直接写进 SRAM 里的接收缓冲区。注意这里说的是“描述符环”,不是“缓冲区队列”——F4 的 ETH DMA 用的是自己的一套描述符数组,每个描述符里存状态位、缓冲区地址和下一个描述符的指针,OWN 位标记这一格现在归 DMA 还是归 CPU。

描述符字段含义常见配置
Status(OWN 位)0 表示归 CPU,1 表示归 DMA由硬件翻转,软件不能随便写
Control buffer size该缓冲区能装多少字节1524 或 1536
Buffer1 address实际数据缓冲区地址必须是 SRAM,4 字节对齐
Next descriptor环上下一个描述符初始化时串成闭环

收包中断触发后,HAL 层的回调一般只做一件事:释放一个信号量,唤醒 ethernetif_input 任务。真正的取帧动作放在任务里做,原因很实在——中断里不能长时间占用,而且 low_level_input 后续要申请 pbuf,pbuf 的申请会走 lwIP 的内存池,那里面有锁。ethernetif_input 拿到帧之后走 low_level_input,申请一块 PBUF_POOL 类型的 pbuf,把 DMA 缓冲区的内容 memcpy 进去,然后调用 netif->input,也就是 tcpip_input,把 pbuf 指针塞进 tcpip_mbox 邮箱。从这一刻起,这帧数据就交给 tcpip_thread 了,跟 ETH 中断再无关系。

这里有个选型分叉值得说清楚:零拷贝。理论上可以把 pbuf 的 payload 地址直接配成 DMA 描述符的缓冲区地址,省掉那次 memcpy。F407 没有 D-Cache,不存在缓存一致性问题,看起来非常适合零拷贝。但实际做下来,接收侧零拷贝要求 pbuf 池的块必须固定大小且地址稳定,而 lwIP 的 pbuf 在释放后地址会变,重新挂回描述符时要重新写寄存器,一不小心就出现“描述符指向已经还给内存池的块”这种极难查的问题。常见做法是接收保留一次拷贝、发送侧做零拷贝或者半零拷贝,这个工程包沿用的也是这条路。别一上来就追求极限吞吐,先让链路稳定下来。

还有一个容易被忽略的细节:low_level_input 里申请 pbuf 失败时,帧已经被 DMA 收走了,如果不把这个描述符归还,接收环就会越来越短,最后表现为“跑一段时间后彻底收不到包”。正确写法是在申请失败的分支里把描述符的 OWN 位置回 1,把缓冲区还给 DMA,同时把 lwIP 的 stats 里 memerr 计数加一。这个计数器是你后面判断内存够不够的第一手证据。

2.2 lwipopts.h:先确定这 8 个参数再谈调优

lwipopts.h 是整套配置的中枢,改动它比改代码更能影响行为。新手最常见的错误是把裸机工程的 lwipopts.h 直接搬过来,NO_SYS 还是 1,结果 socket API 一调就崩,或者 tcpip_thread 根本没起来。下表是我一般会先确认的一组值,具体数字按板子的 RAM 和业务再调。

参数典型值作用配错的后果
NO_SYS0声明使用 RTOS 模式配成 1 则没有 tcpip_thread,socket 调用直接断言
MEM_SIZE16×1024 起堆内存总量,pbuf、PCB 都从这来太小则 tcp_write 返回 ERR_MEM,大包发不出去
MEMP_NUM_PBUF16memp 里的 pbuf 数量太小则高并发下 pbuf 申请失败丢包
PBUF_POOL_SIZE16池化 pbuf 个数,接收主力太小则突发流量整片丢
TCP_WND4×TCP_MSS 起接收窗口太小则吞吐上不去,大延迟链路上尤其明显
TCP_SND_BUF4×TCP_MSS 起发送缓冲太小则发送侧频繁阻塞
TCPIP_THREAD_STACKSIZE1024 字协议栈线程栈太小则在处理 ARP、重组时栈溢出
TCPIP_MBOX_SIZE16 起投递给协议栈线程的消息队列深度太小则高负载下 tcpip_input 丢消息

MEM_SIZE 和 PBUF_POOL_SIZE 的关系要理清:PBUF_POOL 的每一块是从 memp 的静态数组里出的,不占 MEM_SIZE;而 PBUF_RAM 类型的 pbuf 以及各种 PCB、控制块是从 MEM_SIZE 那块堆里分配的。所以“调大 MEM_SIZE 就能解决丢包”这句话只对了一半,如果是接收池耗尽,你该加的是 PBUF_POOL_SIZE。判断依据还是 stats 里的 memerr 和 memp 溢出计数,后面第 6 章会给打印方法。

MEM_ALIGNMENT 在 Cortex-M4 上填 4 就行,F407 虽然支持非对齐访问,但 lwIP 内部的指针运算依赖对齐假设,填错会导致某些结构体字段读出垃圾值。这个坑很隐蔽,现象是“TCP 选项解析异常但 ping 正常”,查起来能耗掉一天。

2.3 sys_arch.c 与 tcpip_thread:多出来的那层邮箱不能省

裸机 lwIP 是主循环轮询,进了 FreeRTOS 之后,协议栈必须有一个独立的执行上下文,这就是 tcpip_thread。它由 tcpip_init 创建,本质上是一个死循环,从 tcpip_mbox 里取消息,按消息类型分发到对应的处理函数。你在应用任务里调用 socket 的 send,实际动作是把一个 TCPIP_MSG_API 类型的消息投进这个邮箱,然后阻塞等结果。这层间接是必需的,因为 lwIP 的内部数据结构不是线程安全的。

sys_arch.c 的职责就是把 lwIP 要求的几个抽象映射到 FreeRTOS 原语上,映射关系大致如下。

lwIP 抽象FreeRTOS 实现关键点
sys_sem_tSemaphoreHandle_t(二值或计数)创建时用 xSemaphoreCreateBinary 要注意初始不可用
sys_mutex_tSemaphoreHandle_t(互斥)必须用 xSemaphoreCreateMutex,不能用二值信号量顶替
sys_mbox_tQueueHandle_t队列长度就是邮箱深度,元素类型是 void*
sys_thread_newxTaskCreate栈深度单位是字不是字节
sys_nowxTaskGetTickCount 换算必须返回毫秒

sys_arch_mbox_fetch 是最容易写出 bug 的函数。它的语义是:阻塞等待最多 timeout 毫秒,取到消息返回等待耗时,超时返回 SYS_ARCH_TIMEOUT。坑在于 timeout 传 0 表示“永久阻塞”,而不是“立即返回”。如果直接拿 0 去调 xQueueReceive 的 ticks 参数,行为就反了,协议栈会疯狂轮询把 CPU 吃满。正确写法是先判断 timeout 是否为 0,是则用 portMAX_DELAY,否则把毫秒换算成 tick 并用 pdMS_TO_TICKS。

/* sys_arch.c 里 mbox 取消息的实现,重点在 0 值的语义 */ u32_t sys_arch_mbox_fetch(sys_mbox_t *mbox, void **msg, u32_t timeout) { TickType_t ticks; TickType_t start = xTaskGetTickCount(); if (timeout == 0U) { ticks = portMAX_DELAY; /* 0 代表永久等待,不能直接传给队列 */ } else { ticks = pdMS_TO_TICKS(timeout); /* 毫秒换 tick,注意向上取整 */ if (ticks == 0U) { ticks = 1U; /* 小于一个 tick 也要等一次,避免空转 */ } } if (xQueueReceive(*mbox, msg, ticks) != pdTRUE) { return SYS_ARCH_TIMEOUT; /* 协议栈靠这个返回值判断超时 */ } return (xTaskGetTickCount() - start) * portTICK_PERIOD_MS; }

这段代码有三个参数要说清楚:timeout 的单位是毫秒,由调用方传入,通常是 socket 的阻塞时长;返回值必须是“实际等待的毫秒数”,协议栈用它在 select、connect 这类带超时的调用里做剩余时间计算,返回 0 或者乱返回会导致超时逻辑错乱;portTICK_PERIOD_MS 决定了 tick 到毫秒的换算精度,如果系统 tick 配成 1ms,换算就是 1:1,配成 10ms 则小于 10ms 的超时会退化成一次 tick,对实时性有要求的场景要把 tick 调细。

sys_now 同理,返回的是系统启动以来的毫秒数。很多模板里写成直接返回 xTaskGetTickCount(),如果 tick 不是 1ms,TCP 的重传定时、ARP 老化全都会算错,表现为“重传特别频繁”或者“对端断开半天没反应”。这个错误不改也能 ping 通,所以极难被发现,属于典型的血泪经验。

3. 让它 ping 通:时钟、PHY 地址与第一包

3.1 硬件侧:RMII 的 50MHz 到底谁给谁

RMII 相比 MII 省了一半引脚,代价是时钟方案必须提前定死。F407 的 ETH_RMII_REF_CLK 在 PA1 上,它需要一路稳定的 50MHz。工程上常见的三种接法,选错了就是网口灯不亮的头号原因。

第一种,PHY 外挂 25MHz 晶振,PHY 内部 PLL 倍频出 50MHz,从这个引脚输出给 MCU 的 PA1。这是最普遍的设计,调试时用示波器量 PA1 应该能看到干净的 50MHz 方波,峰峰值 3.3V 左右。第二种,MCU 用 MCO1 从 PA8 输出 25MHz 给 PHY 的晶振输入脚,PHY 再倍频。这种方案省一个晶振,但 PA8 被占用,而且 MCO 的时钟源配置错了会连带 PHY 一起不工作。第三种,板上放 50MHz 有源晶振,同时喂给 PHY 和 MCU。

需要留意的是,PA1 一旦配成 ETH 的参考时钟输入,就不能再复用成别的东西;如果硬件已经接了 PHY 输出 50MHz,而软件里又把 PA1 配成了 MCO 输出,两边对着推,时钟信号会变成一团毛刺,这时候示波器上看频率对、波形不对,非常误导人。

3.2 时钟树与 GPIO 复用

F407 的 ETH 挂在 AHB1 上,使能 ETH MAC 时钟和 ETH TX/RX 时钟之后才能访问寄存器。GPIO 这一块,RMII 模式下要配的引脚不算多,但每个都得配成复用推挽、高速、正确的 AF11。

/* eth_gpio.c —— RMII 模式的引脚与时钟初始化(HAL 风格,按实际封装删减) */ static void eth_gpio_init(void) { GPIO_InitTypeDef gpio = {0}; __HAL_RCC_ETHMAC_CLK_ENABLE(); __HAL_RCC_ETHMACTX_CLK_ENABLE(); __HAL_RCC_ETHMACRX_CLK_ENABLE(); __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_GPIOB_CLK_ENABLE(); __HAL_RCC_GPIOC_CLK_ENABLE(); gpio.Mode = GPIO_MODE_AF_PP; gpio.Pull = GPIO_NOPULL; gpio.Speed = GPIO_SPEED_FREQ_VERY_HIGH; gpio.Alternate = GPIO_AF11_ETH; /* PA1: REF_CLK 输入, PA2: MDIO, PA7: CRS_DV */ gpio.Pin = GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_7; HAL_GPIO_Init(GPIOA, &gpio); /* PC1: MDC, PC4: RXD0, PC5: RXD1 */ gpio.Pin = GPIO_PIN_1 | GPIO_PIN_4 | GPIO_PIN_5; HAL_GPIO_Init(GPIOC, &gpio); /* PB11: TX_EN, PB12: TXD0, PB13: TXD1 */ gpio.Pin = GPIO_PIN_11 | GPIO_PIN_12 | GPIO_PIN_13; HAL_GPIO_Init(GPIOB, &gpio); }

参数说明:GPIO_SPEED_FREQ_VERY_HIGH 对应 100MHz,RMII 的 50MHz 时钟沿比较陡,速度档位配低了波形会塌;Alternate 必须是 AF11,配成 AF0 的话引脚就是普通 GPIO,看起来编译通过但网口毫无反应;MDIO 引脚按规范需要外部上拉,如果硬件漏了,软件侧可以临时配成 GPIO_PULLUP 验证一下,但正式版还是补电阻。另外这段代码里没写 PHY 复位脚的 GPIO,实际工程里一定要有,因为没有硬件复位的情况下,PHY 上电状态依赖于之前那次运行的残留,热启动后不认链路是常事。

ETH 外设本身的初始化,HAL 提供 HAL_ETH_Init,重点是 MAC 配置结构体里的 ETH_MACConfigTypeDef:速率填 100M、双工填全双工、CRC 剥离打开、自动填充前导码打开。DMA 配置里 RX/TX 描述符个数一般给 4 到 5,缓冲大小 1524。这里不要贪多,描述符数量翻倍带来的收益远小于它吃掉的 SRAM,而 F407 那 192KB 里还要塞协议栈和数据区。

3.3 静态 IP 起步,先 ping 通再谈别的

上电即用 DHCP 是产品形态,但调试阶段一律建议静态 IP。理由很简单:DHCP 失败的时候你分不清是链路问题、PHY 问题还是服务器问题,而静态 IP 把变量减到一个。

/* net_init.c —— tcpip 初始化 + netif 挂载 + 静态地址 */ #include "lwip/tcpip.h" #include "lwip/netif.h" #include "netif/ethernetif.h" static struct netif g_netif; void net_bringup(void) { ip4_addr_t ip, mask, gw; tcpip_init(NULL, NULL); /* 内部创建 tcpip_thread 和邮箱 */ IP4_ADDR(&ip, 192, 168, 1, 50); IP4_ADDR(&mask, 255, 255, 255, 0); IP4_ADDR(&gw, 192, 168, 1, 1); /* 第五个参数是传给 ethernetif_init 的状态指针, 不用就填 NULL */ netif_add(&g_netif, &ip, &mask, &gw, NULL, ethernetif_init, tcpip_input); netif_set_default(&g_netif); if (ethernetif_wait_linkup(&g_netif, 3000) != ERR_OK) { /* 链路没起来, 先把错误打出来再继续, 不要静默失败 */ printf("phy link down, check ref_clk and phy addr\r\n"); } netif_set_up(&g_netif); }

这段代码里几个参数值得单独点出来:netif_add 的第六个参数是输入函数,RTOS 模式下必须用 tcpip_input 而不是 netif_input,用错了包会直接在中断上下文里被处理,运气好只是慢,运气差就是随机死机;第五个参数传 NULL 是因为这个工程没做多网口,如果要做双网口,这里得传各自的私有结构;ethernetif_wait_linkup 不是标准 API,是模板里加的一个轮询 PHY 状态寄存器的辅助函数,超时给 3 秒,够 PHY 完成自协商了,如果 3 秒还没起来,基本可以判定时钟或者 PHY 地址有问题。

PHY 地址这一项单独强调。常见的 10/100M PHY 用 PHYAD0 引脚在上电复位那一刻的电平决定地址是 0 还是 1,这个值被锁存后就不再变。软件里如果写死成 0,而硬件把那个脚上拉成了 1,MDIO 读回来的全是 0xFFFF,表现为“PHY 完全不存在”。稳妥的做法是初始化时把 0 到 31 全扫一遍,读到有效的 ID 寄存器值再定下来,代价只是几十毫秒。

3.4 从 ping 到 TCP:最小 echo 服务

ping 通了只证明 ICMP 通路正常,ARP、IP 收发、校验和都没问题,但 TCP 是另一套东西——三次握手、序列号、窗口、PCB 分配。用一个 echo 服务验证,比直接上业务代码快得多。

/* tcp_echo.c —— 跑在独立 FreeRTOS 任务里的最小 TCP echo */ #include "lwip/sockets.h" #include "lwip/inet.h" #define ECHO_PORT 5000 #define RX_CHUNK 1460 static void echo_task(void *arg) { int listen_fd, conn_fd, n; struct sockaddr_in addr, peer; socklen_t peer_len = sizeof(peer); static char buf[RX_CHUNK]; listen_fd = lwip_socket(AF_INET, SOCK_STREAM, 0); if (listen_fd < 0) { vTaskDelete(NULL); } memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_port = htons(ECHO_PORT); addr.sin_addr.s_addr = htonl(INADDR_ANY); lwip_bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr)); lwip_listen(listen_fd, 2); for (;;) { conn_fd = lwip_accept(listen_fd, (struct sockaddr *)&peer, &peer_len); if (conn_fd < 0) { continue; } /* 连接数满了先跳过, 别退出任务 */ while ((n = lwip_recv(conn_fd, buf, sizeof(buf), 0)) > 0) { lwip_send(conn_fd, buf, n, 0); /* 原样回写, 不做粘包处理 */ } lwip_close(conn_fd); } }

逻辑说明:listen 的 backlog 给 2 就够了,这个值只影响已完成握手但还没被 accept 的连接排队数,给太大除了占内存没别的用;accept 返回负值时必须 continue 而不是 break,因为 MEMP_NUM_TCP_PCB 耗尽时 accept 会失败,但任务退出就再也没人服务了;recv 返回 0 表示对端正常关闭,返回负值表示出错,两种都走 close。recv 的缓冲区用了 static,因为它有 1460 字节,放在任务栈里会让栈需求直接飙升到 2KB 以上,这一点后面第 4 章还会展开。

注意一个细节:这里用的是 lwip_socket 而不是 socket。如果工程里同时链了 newlib 或者别的库,socket 这个名字可能被抢占,编译能过但运行时会走到别的实现上。加上前缀是最省事的隔离手段。

编译下载,PC 端先 ping 192.168.1.50,通了再用 telnet 或者 nc 连 5000 端口,敲几个字符看回显。这一步过了,说明 ETH 驱动、lwIP 配置、FreeRTOS 移植层三者都对上了,后面再谈性能和业务。

4. 在 FreeRTOS 里写 socket:任务切分、超时与内存边界

4.1 任务怎么切:协议栈、应用、看门狗三条线

FreeRTOS 下的网络应用,任务划分直接决定了系统稳不稳。我一般的分法是三条主线:协议栈线程(tcpip_thread,由 tcpip_init 自动创建)、以太网输入线程(ethernetif_input,负责从 DMA 取帧投递)、应用线程(业务逻辑)。外加一条低优先级的看门狗或心跳线程。优先级从高到低按这个顺序排,理由是以太网输入线程延迟大了会丢包,而应用线程慢一点只是响应迟钝。

任务优先级(数值越大越高)栈(字)职责
ethernetif_input5512取帧、申请 pbuf、投递邮箱
tcpip_thread41024协议栈全部处理
app_task31024+业务 socket 收发
heartbeat1256喂狗、打印水位

表格里的栈数值是下限,不是推荐值。lwIP 内部有递归调用(比如 TCP 选项解析),栈不够时的现象是“跑几个小时后 random hardfault”,而且每次都崩在不同地方,非常难定位。稳妥做法是在调试阶段用 uxTaskGetStackHighWaterMark 打印每个任务的历史最小剩余,留 30% 余量再定稿。

优先级还有一个硬约束容易被忽略:ethernetif_input 任务是从 ETH 中断里被唤醒的,而 ETH 中断的 NVIC 优先级数值必须大于等于 configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY。F407 是 4 位优先级,这个宏一般配 5,那么 ETH 中断就要配成 5 或者更大。配成 0 的时候,中断里调 xSemaphoreGiveFromISR 会直接触发 configASSERT,如果你的工程把断言关了,那就是随机死机,且没有任何提示。这是新手最常踩的一个坑,属于“不查手册绝对想不到”的类型。

4.2 socket 的阻塞与超时:别让一个 recv 卡死整个任务

lwIP 的 socket 默认是阻塞的,recv 在没有数据时会一直等。如果这个任务的职责不止一件事(比如还要处理串口命令、还要刷新状态灯),那就必须加超时。两种做法:设 SO_RCVTIMEO,或者用 select 做多路复用。

/* 给 socket 加接收超时,避免任务被单条连接钉死 */ struct timeval tv; tv.tv_sec = 0; tv.tv_usec = 200 * 1000; /* 200ms, 太短会频繁唤醒, 太长会影响其他事务 */ lwip_setsockopt(conn_fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); /* select 版本, 同时盯多条连接和唤醒管道 */ fd_set rfds; struct timeval wait = {0, 100 * 1000}; FD_ZERO(&rfds); FD_SET(conn_fd, &rfds); FD_SET(wake_pipe[0], &rfds); int maxfd = (conn_fd > wake_pipe[0] ? conn_fd : wake_pipe[0]) + 1; int ret = lwip_select(maxfd, &rfds, NULL, NULL, &wait); if (ret > 0 && FD_ISSET(conn_fd, &rfds)) { /* 有数据可读, 再调 recv 就不会长时间阻塞 */ }

参数说明:tv_usec 的最小有效粒度受 lwipopts.h 里的 LWIP_SO_RCVTIMEO 控制,这个宏没打开的话 setsockopt 会静默失败,返回 0 但什么也没设,这是排查“为什么超时没生效”时的第一检查项;select 的 maxfd 必须比最大的 fd 大 1,写错会导致某些 fd 永远不被监视,表现是“偶尔漏事件”,非常难查;select 在 lwIP 里受 MEMP_NUM_NETCONN 限制,同时监视的 fd 数量超过这个值会返回错误。

还有一点:lwIP 默认没有实现 SO_REUSEADDR 的完整语义,但在某些版本里这个选项是被接受的,打开它能让服务器重启时更快绑定端口。工程实践中我一般会打开,代价是几乎没有。

4.3 压测与调参:从 echo 到持续吞吐

echo 通了不等于能用,得压。PC 端写个小脚本往板子灌数据,同时观察板子的内存水位和丢包计数,这是最有效的调参依据。

# stress.py —— 持续灌包并统计往返延迟 import socket, time HOST, PORT = "192.168.1.50", 5000 CHUNK = 1024 s = socket.create_connection((HOST, PORT), timeout=3) s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) # 关掉 Nagle, 减小往返延迟 payload = b"x" * CHUNK total, start = 0, time.time() try: for _ in range(2000): s.sendall(payload) got = 0 while got < CHUNK: # echo 服务会原样回写, 必须读完 data = s.recv(CHUNK - got) if not data: raise RuntimeError("peer closed") got += len(data) total += CHUNK finally: elapsed = time.time() - start print(f"{total/1024:.0f} KB in {elapsed:.2f}s, {total*8/elapsed/1e6:.2f} Mbps") s.close()

脚本里 TCP_NODELAY 的意义是把小包立即推出去,避免 Nagle 算法把多个小包攒在一起,压测时能更真实地反映往返延迟;循环里必须把 CHUNK 全部读完再进入下一轮,否则 TCP 接收窗口会被未读数据占满,测出来的数字偏低,而且会误判成板子的问题。

调参的顺序我一般是这样:先看丢包计数,如果 memerr 涨得快,加 PBUF_POOL_SIZE 和 MEMP_NUM_PBUF;如果 ERR_MEM 出现在发送侧,加 MEM_SIZE 和 TCP_SND_BUF;如果吞吐上不去但一个包都不丢,那是窗口的问题,加 TCP_WND。每次只改一个参数,改完重跑脚本,否则你永远不知道是哪个改动起了作用。

F407 这类主频 168MHz、带 ETH MAC 的控制器,实际能跑到什么水平,跟 lwipopts.h 的配置、描述符数量、是否零拷贝强相关。默认配置下常见只有几 Mbps,把窗口和缓冲区调到一个合理的量级后,十几到三十几 Mbps 是能见到的区间。别拿网上别人的截图当基线,板子的 SRAM 大小、PHY 型号、PC 端网卡都能影响结果。

5. 排查清单:五个把调试拖到凌晨的现场

5.1 现象:网口灯一个都不亮

原因往往不在软件。按顺序查三件事:REF_CLK 上有没有 50MHz、PHY 有没有复位、MDIO 上能不能读到 PHY ID。REF_CLK 没有,说明时钟方案选错了或者晶振没起振;PHY 没复位,说明复位脚的电平极性搞反了,有些 PHY 是低电平复位,代码里给了高电平,芯片一直处于复位态;MDIO 读不到 ID,多半是 PHY 地址不对或者 MDIO 上拉缺失。

解决办法:先用示波器量 PA1,再量复位脚在上电后 100ms 内的电平变化,最后写一段只做 MDIO 读的裸机代码,把 0 到 31 号地址全扫一遍打印 ID。三件事里必有一个是坏的。灯亮但只有一个亮(比如只有 link 灯没有 activity 灯),说明链路起来了但没数据,那是下一步的问题。

5.2 现象:灯亮、PHY 也认到了,但 ping 不通

先分清楚是“发不出去”还是“收不到回应”。PC 端用 Wireshark 抓包,如果能看到 ARP 请求但板子没回,问题在接收侧;如果板子回了 ARP 但 PC 说不可达,问题在 IP 配置或者 PC 的防火墙。接收侧的问题里,最常见的是 netif 没 set_up,或者 netif_add 时输入函数写成了 netif_input 而不是 tcpip_input——后者在裸机下是对的,在 RTOS 下会把包投到错误的地方,表现就是完全没反应。

还有一种情况是 IP 冲突。板子和 PC 配了同一个网段但地址撞了,现象是 ping 时通时不通,而且延迟忽大忽小。解决很简单,先把 PC 端 ARP 表清一遍,换个没人用的地址再试。子网掩码配错也会导致 ping 不通,尤其是板子配 255.255.255.0 而 PC 配 255.255.0.0 的时候,单向能通反向不通,非常迷惑。

5.3 现象:跑几分钟就触发断言或者直接 hardfault

第一个怀疑对象是中断优先级。ETH 中断里调用了 FreeRTOS 的 FromISR 接口,而 NVIC 优先级数值小于 configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY,就会在断言打开时立刻停住。把 ETH_IRQn 的抢占优先级改成 5 或更大,问题消失。如果断言是关的,现象就变成随机死机,栈回溯往往停在一些不相干的地方。

第二个怀疑对象是栈溢出。协议栈线程的栈给 512 字看着够,但在处理 TCP 选项或者 IP 分片重组时会深不少。用 uxTaskGetStackHighWaterMark 打印一下,如果返回值小于 100,基本可以确定。把 TCPIP_THREAD_STACKSIZE 加到 1024 或者 1536 再观察。

第三个是 pbuf 使用越界。low_level_input 里 memcpy 的长度如果直接用描述符里的帧长度而没有减去 CRC(取决于 MAC 是否配置了 CRC 剥离),会多写 4 个字节,踩坏相邻内存。这种问题不一定马上崩,可能跑一晚上才发作。

5.4 现象:小包正常,一跑大包就丢或者断连

典型的缓冲区边界问题。先看 MTU,lwIP 默认 1500,如果对端发了 1514 的帧而接收缓冲只给了 1500,多出来的部分会被丢弃或者截断。检查 ETH 的接收缓冲大小是不是 1524 以上,以及 PBUF_POOL_BUFSIZE 是否匹配。PBUF_POOL_BUFSIZE 的默认算法是 MSS 加上各层头部,配小了会导致一个完整帧需要多个 pbuf 链接,链一长,申请失败的概率就高。

再看 MEM_SIZE。大包传输时 TCP 会占用更多发送缓冲,MEM_SIZE 紧张时 tcp_write 返回 ERR_MEM,上层如果没处理这个返回值,数据就静默丢了。正确做法是检查每次 send 的返回值,返回 ERR_MEM 时短暂延时重试,而不是当成成功。

5.5 现象:反复断开重连几十次之后,再也连不上

PCB 泄漏。每次 accept 之后如果没有正确 close,或者 close 之后 PCB 没有及时回收,MEMP_NUM_TCP_PCB 会被慢慢耗尽。lwIP 的 PCB 回收依赖 TIME_WAIT 定时器,而这个定时器由 tcpip_thread 驱动,如果协议栈线程被饿死(比如应用任务优先级配得比它高且长时间不让出 CPU),回收就会停摆。

解决办法分两层:代码层确保每条路径都有 close,包括错误分支;配置层把 MEMP_NUM_TCP_PCB 给足,同时在 lwipopts.h 里确认 LWIP_TIMEVAL_PRIVATE 和 TCP 的定时器相关宏没有配错。测试方法很直接,写个循环脚本连上就断,跑 200 次,看第几次开始失败,失败时打印 memp 的 TCP_PCB 使用数,一眼就能看出来。

6. 进阶自检:用内存水位和抓包把问题按在发生之前

调通之后很多人就把板子扔到一边,等客户反馈“跑一天就断”再回头查,那时候现场什么都没留下。我的习惯是在工程里常驻一个轻量自检,成本很低,收益很大。

第一步,把 lwIP 的统计打开。lwipopts.h 里设 LWIP_STATS=1、MEMP_STATS=1、STATS_DISPLAY=1,然后在一个低优先级任务里每 10 秒打印一次关键计数:memp 的 PBUF_POOL 和 TCP_PCB 当前使用量、memerr 和 memp overflow 的累计值、以及 uxTaskGetStackHighWaterMark 的最小剩余。

/* 每 10 秒一次的水位快照,跑在最低优先级任务里 */ #include "lwip/stats.h" #include "lwip/memp.h" void health_report(void) { static u32_t last_memerr = 0; u32_t now = lw_stats.memerr; printf("[lwip] memerr=%lu(+%lu) pbuf_used=%d pcb_used=%d\n", (unsigned long)now, (unsigned long)(now - last_memerr), (int)(MEMP_NUM_PBUF - memp_used(MEMP_PBUF)), (int)(MEMP_NUM_TCP_PCB - memp_used(MEMP_TCP_PCB))); last_memerr = now; printf("[task ] stack_min=%u heap_free=%u\n", (unsigned)uxTaskGetStackHighWaterMark(NULL), (unsigned)xPortGetFreeHeapSize()); vTaskDelay(pdMS_TO_TICKS(10000)); }

这里的重点是看“增量”而不是绝对值。memerr 累计到几百不算事,但如果每 10 秒涨几十,说明有稳定的泄漏或者缓冲区不足,趁早改还来得及。pbuf_used 长期贴着上限,那就直接加 PBUF_POOL_SIZE。stack_min 一路往下掉,说明有递归或者大局部变量,赶紧找。

第二步,把 PC 端的抓包固定下来。每次改动配置后,用 Wireshark 抓 30 秒验证三件事:有没有重复的 ARP 请求(说明 ARP 表在老化但没更新)、有没有 TCP 重传(说明链路或者窗口有问题)、握手时的 MSS 和窗口大小是否和 lwipopts.h 里配的一致。这三项一过,基本可以确认配置生效了,而不是“编译通过了就以为改对了”。

有个教训我记了很久:早期调试一个间歇性断连的问题,改了七八个参数都没用,最后是抓包发现对端一直在发零窗口通告,根因是接收缓冲被一个没读完的连接长期占着。从那以后我每次改完 lwipopts.h 都强制走一遍“抓包 + 水位快照”的组合,不再靠猜。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询