简介:面向嵌入式网络开发初学者与中级工程师的STM32F407+LWIP+DP83848移植例程,聚焦在无操作系统环境下搭建TCP/IP服务器并完成数据收发。工程不依赖外部存储器,硬件只需一块STM32F407开发板配合DP83848以太网PHY即可复现,降低了移植与验证门槛,适合学习LWIP协议栈裁剪、网卡驱动适配及裸机网络通信流程。压缩包共3458个文件,约22.92MB,以C源文件与头文件为主体,辅以汇编启动代码、Keil工程配置、链接映射及说明文档,便于按工程结构查阅和二次开发。已有4769人学习下载,说明该方案在同类移植实践中具有一定参考价值。读者可从中获得较完整的裸机TCP/IP服务器实现框架、DP83848驱动适配思路、工程编译配置与问题排查线索,并借助随包说明文件快速理解移植要点,对初始化流程、收发缓冲区管理和协议栈对接形成直观参照。
1. 一块能下载、能串口打印、就是 ping 不通的板子
手头这块 STM32F407 的板子,PHY 焊的是 DP83848,程序能下载、串口能打印、灯也亮,可网线插上去之后对面交换机的灯一闪一闪,ping 永远是“请求超时”,串口里连个 ARP 包的影子都看不到。这份 STM32F407 + LwIP + DP83848 移植例程,解决的正是这一段最容易卡住人的链路上:从 MAC 的 DMA 描述符、RMII 的九根线、50MHz 参考时钟,一直到 LwIP 的 netif 注册、pbuf 收发和 socket 接口。它适合两类人:一类是刚拿到带网口的 F407 板子、想先把 ping 打通的新手;另一类是手上有产品要加网口、需要一个干净起点的熟手。例程本身不大,但网络这一块有个特点——任何一处配置错,现象都是“ping 不通”,所以真正值钱的是知道每一层该看什么。
2. 硬件与工程骨架:RMII 九根线、50MHz 时钟和目录职责
拿到这类工程,我的习惯是先不打开 main.c,而是先确认硬件这一层对不对。因为 LwIP 再怎么配,PHY 没有链路、参考时钟没进来,代码写得再漂亮也白搭。这一步花十分钟,后面能省掉几个小时。
2.1 DP83848 的 RMII 接线与 PHY 地址
STM32F407 内部只有以太网 MAC,没有 PHY,DP83848 就是挂在 MAC 外面的那颗收发器。它支持 MII 和 RMII 两种接法,例程基本都用 RMII——线少,只要 9 根信号线,PCB 好走。MII 要 16 根左右,还得单独给 TX_CLK、RX_CLK,板子上很少这么干。
| 信号 | STM32F407 引脚 | 复用 | 方向 | 说明 |
|---|---|---|---|---|
| RMII_REF_CLK | PA1 | AF11 | 输入 | 50MHz,MAC 和 PHY 共用 |
| RMII_MDIO | PA2 | AF11 | 双向 | 管理接口数据,需要 1.5k~2.2k 上拉 |
| RMII_CRS_DV | PA7 | AF11 | 输入 | 载波侦听/数据有效 |
| RMII_MDC | PC1 | AF11 | 输出 | 管理接口时钟,通常 1~2.5MHz |
| RMII_RXD0 | PC4 | AF11 | 输入 | 接收数据位 0 |
| RMII_RXD1 | PC5 | AF11 | 输入 | 接收数据位 1 |
| RMII_TX_EN | PB11 | AF11 | 输出 | 发送使能 |
| RMII_TXD0 | PB12 | AF11 | 输出 | 发送数据位 0 |
| RMII_TXD1 | PB13 | AF11 | 输出 | 发送数据位 1 |
这张表建议直接抄进自己的笔记。我踩过一次坑:把 PB11 配成了普通推挽输出,结果 TX_EN 一直是高电平,PHY 那边收到的是一串垃圾,链路灯亮着但一个包都发不出去,查了半天以为是 LwIP 配置问题。
引脚初始化用 HAL 写出来是这样,关键是Alternate必须是GPIO_AF11_ETH,速度和上下拉按表来:
/* RMII 引脚复用:全部 AF11,注意 PB11/PC4/PC5 默认是别的复用功能 */ static void ETH_GPIO_Config(void) { GPIO_InitTypeDef gpio = {0}; __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; /* 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); /* MDIO 需要外部上拉,若板上没焊,可以在这里打开内部上拉兜底 */ /* HAL_GPIO_WritePin / 单独配置 PA2 的 Pull 为 GPIO_PULLUP */ }参数上只有两处值得动手改:Speed建议VERY_HIGH,RMII 是 50MHz 同步信号,速度等级低会导致时序余量不足;Pull一般是NOPULL,因为外部已经有上下拉,但如果你是自己画的板子、MDIO 忘了加上拉,读 PHY 寄存器会一直返回全 1,这一条后面避坑章还会提。
PHY 地址这一项,例程里通常写0x01。DP83848 的 PHYAD[4:0] 由芯片上电时几个 strap 引脚的电平决定,具体是哪几根脚、默认值多少,得对着数据手册的 strap 表看板子的实际焊接。地址填错的表现很典型:MDIO 能通,读出来全是 0xFFFF,因为总线上根本没有器件应答。
2.2 时钟树与 50MHz REF_CLK 的两种来源
RMII 必须有一颗 50MHz 的参考时钟同时送给 MAC 和 PHY,这是整块板子最容易翻车的地方。常见做法有两种。
第一种,板上放一颗 50MHz 有源晶振,输出一分为二,一路进 DP83848 的 X1,一路进 MCU 的 PA1。这种方式最省心,CubeMX 里把 PA1 配成 AF11 就完事,不用管时钟树。市面上大部分带网口的 F407 开发板都是这么设计的。
第二种,板上只有 PHY 的 25MHz 晶振或者干脆没有独立晶振,得让 MCU 用 MCO1(PA8)输出 50MHz 去喂 PHY,PHY 再把时钟回送给 PA1,或者反过来。这条路的麻烦在于:F407 主频跑到 168MHz 时,MCO1 的分频只能取 1~5,168/4 = 42MHz,凑不出 50MHz。要做就得单独算一组 PLL,把 VCO 定到 200MHz、PLLP 分出 100MHz 给 MCO1、再二分频得到 50MHz,代价是系统主频掉到 100MHz。很多自制板在这里反复改代码,最后发现是时钟根本没出对。
我的建议是:先拿示波器或者逻辑分析仪量 PA1,看到干净的 50MHz 方波再往下走。量不到就先解决时钟,别去动 LwIP。这一步没做,后面所有调试都是在黑匣子里猜。
2.3 工程目录与文件职责
例程的目录结构大同小异,剥掉 CMSIS 和 HAL 驱动之后,真正和网络相关的就那几个文件:
Project/ ├── Drivers/ │ ├── CMSIS/ # 内核头文件、启动文件 │ └── STM32F4xx_HAL_Driver/ # HAL,含 stm32f4xx_hal_eth.c ├── Middlewares/ │ └── Third_Party/LwIP/ │ ├── src/ # 协议栈本体:core / ipv4 / api │ └── system/ # 与 OS 或裸机对接的 sys_arch ├── BSP/ │ ├── stm32f4xx_eth_bsp.c # GPIO、时钟、PHY 复位 │ └── stm32f4xx_eth_bsp.h ├── LwIP/ │ ├── lwipopts.h # 全部裁剪开关都在这 │ ├── ethernetif.c # 网卡驱动:low_level_init/output/input │ └── ethernetif.h └── Src/ ├── main.c # netif 注册 + 主循环 └── app/ # tcp_echo / udp_echo 之类的应用ethernetif.c是整个移植的核心,LwIP 不管你底层是 DM9000 还是片内 MAC,它只认netif结构里挂的那几个函数指针,而ethernetif_init负责把这些指针填上。lwipopts.h决定协议栈编多大、开哪些功能。main.c负责把网卡挂到 netif 链表上、设 IP、把网卡使能起来。剩下的应用层,写成什么样都行。
有一件事要提前确认:工程里用的是 HAL 库还是老的标准外设库。这两套的 ETH 驱动 API 完全不一样,HAL 是HAL_ETH_Init/HAL_ETH_TransmitFrame/HAL_ETH_GetReceivedFrame,老库是ETH_Init/ETH_TransmitFrame/ETH_GetReceivedFrame,网上抄代码时如果混着抄,编译能过但行为不对。看到不认识的函数名,先去Drivers目录里搜一下它属于哪一套。
3. LwIP 裁剪与 ethernetif 驱动:一个 ping 包从网线到协议栈的路径
协议栈这一层,我一般按“进来”和“出去”两条路径读。进来是ETH DMA 收到帧 → 中断 → ethernetif_input → low_level_input → pbuf → netif->input → ip4_input → icmp_input;出去反过来走。把这两条链在脑子里跑通一遍,出问题的时候就知道该在哪一段下断点。
3.1 lwipopts.h 里真正影响能不能通的几个开关
lwipopts.h动辄一两百行,但绝大多数是性能和功能开关,真正会让“ping 不通”的只有那么几个。下面这段是我一般会先确认的最小集合:
/* ---------- 运行模式 ---------- */ #define NO_SYS 1 /* 裸机跑,主循环里轮询;上 FreeRTOS 就改 0 */ #define SYS_LIGHTWEIGHT_PROT 0 #define LWIP_NETCONN 0 /* 裸机下不开 netconn,只用 raw API */ #define LWIP_SOCKET 0 #define MEM_ALIGNMENT 4 /* F407 是 32 位,对齐到 4 */ /* ---------- 内存 ---------- */ #define MEM_SIZE (16 * 1024) #define MEMP_NUM_PBUF 16 #define PBUF_POOL_SIZE 16 /* 收包池,太小会丢包 */ #define PBUF_POOL_BUFSIZE 1524 /* 必须 >= 一个最大以太帧 */ /* ---------- 协议开关 ---------- */ #define LWIP_ARP 1 #define LWIP_ICMP 1 /* 关了就 ping 不通,别手贱 */ #define LWIP_DHCP 0 /* 先用静态 IP 打通,跑通再换 DHCP */ #define LWIP_UDP 1 #define LWIP_TCP 1 /* ---------- TCP 参数 ---------- */ #define TCP_MSS 1460 #define TCP_WND (4 * TCP_MSS) #define TCP_SND_BUF (4 * TCP_MSS) /* ---------- 与 STM32 ETH 相关的两个关键项 ---------- */ #define CHECKSUM_BY_HARDWARE 1 /* 用 MAC 的校验和卸载 */ #define ETH_PAD_SIZE 0 /* 与底层拷贝起始偏移必须一致 */PBUF_POOL_BUFSIZE是最容易设错的一项。有人为了省 RAM 写成 512,结果 MTU 1500 的包进来时一个 pbuf 装不下,LwIP 会拆成链,而low_level_input里如果按单个 pbuf 处理,就会出现“小包能 ping,大包丢”的怪现象。老老实实写 1524(1500 + 14 以太头 + 4 对齐余量)最省事。
CHECKSUM_BY_HARDWARE这一项和ethernetif.c是绑定的:开了它,MAC 会在发送时自动填 IP/TCP/UDP 的校验和,接收时也会校验并打标记;但如果你在low_level_output里没把描述符的校验和插入位打开,或者收发缓冲区没按 DMA 要求对齐,包发出去对方会直接丢弃。校验和错的表现是“抓包能看到包,但对方不回应”。调试阶段我一般先把它关掉,让软件算校验和,确认整条链路通了,再开硬件卸载提性能——这个顺序能少走很多弯路。
3.2 low_level_init 与 DMA 描述符初始化
ethernetif_init里做的事很少,填netif字段、调low_level_init。真正干活的是low_level_init:
static void low_level_init(struct netif *netif) { /* 1. 网卡硬件属性,LwIP 靠这几个字段决定怎么组包 */ netif->hwaddr_len = ETHARP_HWADDR_LEN; /* 6 字节 MAC */ netif->mtu = 1500; netif->flags = NETIF_FLAG_BROADCAST | NETIF_FLAG_ETHARP | NETIF_FLAG_LINK_UP; /* 2. MAC 地址:从芯片 UID 拼一个,避免所有板子出厂一个地址 */ uint32_t uid0 = HAL_GetUIDw0(); uint32_t uid1 = HAL_GetUIDw1(); netif->hwaddr[0] = 0x02; /* 本地管理地址,第一字节 bit1 置 1 */ netif->hwaddr[1] = (uid0 >> 16) & 0xFF; netif->hwaddr[2] = (uid0 >> 8) & 0xFF; netif->hwaddr[3] = (uid0 ) & 0xFF; netif->hwaddr[4] = (uid1 >> 24) & 0xFF; netif->hwaddr[5] = (uid1 >> 16) & 0xFF; /* 3. PHY 复位:拉低至少 100us,给它足够时间起来 */ HAL_GPIO_WritePin(ETH_RST_GPIO_Port, ETH_RST_Pin, GPIO_PIN_RESET); HAL_Delay(2); HAL_GPIO_WritePin(ETH_RST_GPIO_Port, ETH_RST_Pin, GPIO_PIN_SET); HAL_Delay(50); /* 4. MAC + DMA 初始化,描述符和缓冲区在下面这段之后才有效 */ heth.Instance = ETH; heth.Init.AutoNegotiation = ETH_AUTONEGOTIATION_ENABLE; heth.Init.Speed = ETH_SPEED_100M; heth.Init.DuplexMode = ETH_MODE_FULLDUPLEX; heth.Init.PhyAddress = DP83848_PHY_ADDRESS; /* 与硬件 strap 一致 */ heth.Init.MACAddr = netif->hwaddr; heth.Init.RxMode = ETH_RXMODE_DMA; /* 收包走 DMA,别用轮询 MAC 寄存器 */ heth.Init.ChecksumMode = ETH_CHECKSUM_BY_HARDWARE; /* 和 lwipopts 里保持一致 */ if (HAL_ETH_Init(&heth) != HAL_OK) { Error_Handler(); /* 这里失败基本就是 PHY 没应答 */ } /* 5. 网卡使能,MAC 开始收包 */ HAL_ETH_Start(&heth); }几个参数值得单独说。PhyAddress必须和 2.1 节里硬件 strap 定的地址一致;AutoNegotiation开自动协商,但Speed和DuplexMode也要填,因为当对端不支持协商或者协商失败时,HAL 会退回这两个默认值——如果它们和实际链路不匹配,就会出现“灯亮着但丢包严重”的现象,经验上模式错配的丢包率会到百分之几十,很难查。RxMode填ETH_RXMODE_DMA而不是轮询,是因为 F407 的 MAC 收包速率很高,CPU 轮询会丢包。
描述符和缓冲区是分开定义的全局数组,HAL 在HAL_ETH_Init里会把地址写进寄存器:
/* DMA 描述符与收发缓冲区:必须放在 SRAM,不能放 CCM */ ETH_DMADescTypeDef DMARxDscrTab[ETH_RXBUFNB] __attribute__((aligned(4))); ETH_DMADescTypeDef DMATxDscrTab[ETH_TXBUFNB] __attribute__((aligned(4))); uint8_t Rx_Buff[ETH_RXBUFNB][ETH_RX_BUF_SIZE] __attribute__((aligned(4))); uint8_t Tx_Buff[ETH_TXBUFNB][ETH_TX_BUF_SIZE] __attribute__((aligned(4)));提示:F407 有 64KB 的 CCM RAM,地址在 0x10000000。这块内存 ETH 的 DMA 访问不到,描述符和收发缓冲区绝对不能放进去。默认链接脚本不会往那放,但如果你自己改过
*.ld或者用了__attribute__((section(".ccmram"))),一定要绕开。
3.3 收包 low_level_input 与 pbuf 的零拷贝边界
low_level_input做的事情就三件:从 DMA 描述符里拿到刚收到的帧、申请一个 pbuf、把数据拷进去。例程基本都是“拷贝式”,不是零拷贝,因为 F407 没有 D-Cache,不需要做 cache 维护,拷贝一次的开销在百兆网下完全可以接受(实测拷贝一个 1500 字节包大概几微秒)。
static struct pbuf *low_level_input(struct netif *netif) { struct pbuf *p = NULL; ETH_BufferTypeDef rxbuf; FrameTypeDef frame; /* 1. 从 DMA 取一帧,没有就返回 NULL,调用者继续下一次轮询 */ if (HAL_ETH_GetReceivedFrame_IT(&heth) != HAL_OK) { return NULL; } uint16_t len = heth.RxFrameInfos.length; uint8_t *buf = (uint8_t *)heth.RxFrameInfos.buffer; if (len == 0 || len > ETH_RX_BUF_SIZE) { return NULL; } /* 2. 从内存池申请 pbuf,PBUF_POOL 走的是固定大小池,比 PBUF_RAM 快 */ p = pbuf_alloc(PBUF_RAW, len, PBUF_POOL); if (p == NULL) { return NULL; /* 池子空了,这一帧只能丢,调大 PBUF_POOL_SIZE */ } /* 3. 单 pbuf 直接拷;链式 pbuf 要顺着 next 一段段拷 */ if (p->next == NULL) { MEMCPY(p->payload, buf, len); } else { struct pbuf *q = p; uint16_t copied = 0; while (q != NULL) { MEMCPY(q->payload, buf + copied, q->len); copied += q->len; q = q->next; } } /* 4. 告诉 DMA 这块描述符可以重新用了,否则收满一圈就停 */ HAL_ETH_ReleaseRxFrame(&heth); return p; }HAL_ETH_GetReceivedFrame_IT和HAL_ETH_ReleaseRxFrame这对函数是配对使用的,后者不调用,DMA 的描述符计数就不会往前推,收满ETH_RXBUFNB帧之后网卡就哑了。这个 bug 的表现特别有迷惑性:刚上电 ping 通,跑几十秒或者发个大文件之后突然不通,复位又好了。我在避坑章里还会再说一次,因为它太好踩。
配套的ethernetif_input是个死循环,一次把 DMA 里积压的帧全部抽干:
err_t ethernetif_input(struct netif *netif) { struct pbuf *p; err_t err = ERR_OK; do { p = low_level_input(netif); if (p != NULL) { /* 交给上层,返回值决定谁负责释放 */ if (netif->input(p, netif) != ERR_OK) { pbuf_free(p); /* 上层没收,自己释放 */ err = ERR_IF; } /* 注意:上层返回 ERR_OK 时所有权已经转移,这里不能再 free */ } } while (p != NULL); return err; }所有权这一行注释是 LwIP 新手最容易写错的地方。netif->input返回ERR_OK就代表 pbuf 被上层接管了(比如 IP 层收下继续处理,或者已经被释放),再 free 一次就是双重释放,内存池的链表会被写坏,跑着跑着就硬故障。反过来,返回错误时你不 free,就是内存泄漏,同样跑一段时间就卡死。两种症状都很像,所以这段代码我一般会加个计数器统计 free 次数和申请次数,跑一小时看看是不是相等。
裸机模式下主循环长这样,ethernetif_input和sys_check_timeouts都不能少:
while (1) { ethernetif_input(&gnetif); /* 收包 */ sys_check_timeouts(); /* 驱动 LwIP 的定时器:ARP 老化、TCP 重传都要它 */ app_poll(); /* 应用自己的轮询逻辑 */ }sys_check_timeouts忘了调用,现象是 TCP 连接建立得起来但过一会儿就僵住,因为重传定时器根本没跑。sys_now()的实现也要记得挂到HAL_GetTick()上,不然 LwIP 的时间基准是错的。
4. 从 ping 到 socket:TCP 回环与 UDP 收发的实测闭环
底层通了之后,往上就是三步:注册 netif、ping 通、跑通 TCP/UDP。这三步每一步都有明确的验收标准,别跳步。
4.1 静态 IP 与 ARP/ICMP 打通的最小闭环
先用静态 IP,别急着上 DHCP。DHCP 牵扯到广播、超时重试、服务器地址,多一层就多一类故障。main.c里的注册序列大概是这样:
struct netif gnetif; ip4_addr_t ipaddr, netmask, gw; /* 1. 协议栈初始化,内部会建内存池、开定时器 */ lwip_init(); /* 2. 静态地址:和 PC 配到同一网段,网关先随便填或不填 */ IP4_ADDR(&ipaddr, 192, 168, 1, 50); IP4_ADDR(&netmask, 255, 255, 255, 0); IP4_ADDR(&gw, 192, 168, 1, 1); /* 3. 挂网卡:ethernetif_init 是驱动入口,ethernet_input 是以太网帧解析入口 */ if (netif_add(&gnetif, &ipaddr, &netmask, &gw, NULL, ðernetif_init, ðernet_input) == NULL) { Error_Handler(); } /* 4. 设默认网卡并使能,这两步漏一个都 ping 不通 */ netif_set_default(&gnetif); netif_set_up(&gnetif);验收标准很明确:PC 和板子网线直连或者接同一个交换机,PC 配 192.168.1.100/24,ping 192.168.1.50能通,并且延迟在 1ms 以内。如果 ping 不通但 ARP 表里能看到板子的 MAC,说明以太网收发通了、IP 层有问题;如果 PC 的 ARP 表里连条目都没有,说明板子根本没回 ARP,问题还在底层。用arp -a和 Wireshark 抓包这两招,比在代码里瞎加打印快得多。
4.2 一个 TCP echo server 的骨架
裸机下用 raw API,不用 netconn 也行,但 netconn 写起来直观得多,我这里给 netconn 的版本(需要把LWIP_NETCONN打开;裸机想省 RAM 就用tcp_new/tcp_accept那套回调):
static void tcp_echo_thread(void *arg) { struct netconn *conn, *newconn; err_t err; struct netbuf *buf; void *data; u16_t len; /* 1. 建 PCB 并绑定 5000 端口 */ conn = netconn_new(NETCONN_TCP); netconn_bind(conn, IP_ADDR_ANY, 5000); netconn_listen(conn); for (;;) { /* 2. 阻塞等连接;裸机下要把这个循环放到主循环里非阻塞地调 */ err = netconn_accept(conn, &newconn); if (err != ERR_OK) { continue; } /* 3. 收多少回多少,注意 netbuf 可能是链式的 */ while (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); } }NETCONN_COPY这个标志值得说一句:它表示netconn_write内部把数据拷一份再返回,调用返回后你的 buffer 可以复用;如果传 0(NETCONN_NOCOPY),数据不会被拷贝,但你必须保证在数据真正发出去之前 buffer 一直有效——在裸机轮询模型下这几乎做不到,所以老老实实用 COPY。测试用nc 192.168.1.50 5000或者一个 Python 脚本发几十 KB 数据,能原样回来就算通了。
4.3 吞吐与延迟调优的几个旋钮
ping 通、echo 通之后,下一步通常是想知道能跑多快。百兆全双工理论 12.5MB/s,实际用 F407 跑到 8~10MB/s 是正常水平。影响最大的几个参数:
PBUF_POOL_SIZE和ETH_RXBUFNB:前者是 LwIP 的收包池,后者是 DMA 描述符个数。两个都调大一倍,丢包率会明显下降,代价是 RAM。F407 有 192KB SRAM,一般给 LwIP 留 40~60KB 是合理的。TCP_SND_BUF和TCP_WND:窗口越大,单次往返能发越多数据。局域网内 RTT 很小,窗口开到 4~8 个 MSS 就够了,再大会吃 RAM 且收益递减。CHECKSUM_BY_HARDWARE:打开后 CPU 不用算校验和,大包吞吐能提 10%~20%。LWIP_NETIF_TX_SINGLE_PBUF:设成 1 时,LwIP 会保证交给驱动的 pbuf 是连续的,省掉一次链式拷贝。
调优的正确姿势是先测基线再改一个参数,改完再测。一次改五个参数,出了问题根本不知道是哪个引起的,这是我早年最常见的翻车方式。测的时候在 PC 端用大文件传输,同时看板子串口打出来的丢包计数,比看 CPU 占用有用。
5. 避坑与排查:五个让网口起不来的高频故障
这一章是我自己踩过、也帮别人看过的故障里出现频率最高的五个。每条都按“看到什么现象 → 为什么 → 怎么办”写,遇到问题可以直接对号入座。
5.1 PHY 链路灯不亮,MDIO 也读不到
现象:插上网线,板子侧和交换机侧的灯都不亮或者只亮电源灯,串口里打印 PHY ID 是 0xFFFF 或 0x0000,HAL_ETH_Init直接返回错误。
原因:常见有三种。一是 50MHz 参考时钟没到 PA1,PHY 内部没有工作时钟;二是 DP83848 的复位脚一直拉低或者复位时序不够,芯片停在复位态;三是 MDIO 没有上拉电阻,总线处于浮空,读回来全是 1。
解决:先用示波器量 PA1 和 PHY 的 X1,确认 50MHz 存在且幅度够;再量复位脚,上电后应该是高电平,低电平时间至少 100us;最后量 MDIO 空闲电平,应该是高。三样都对还是 0xFFFF,就检查 PHY 地址——把PhyAddress从 0x01 改成 0x00 试一遍,很多时候 strap 电阻焊的位置和原理图不一致。
5.2 灯亮着、链路也协商上了,但 ping 不通
现象:PHY 灯正常闪烁,HAL_ETH_Init返回成功,但 PC 上ping一直超时,arp -a里也看不到板子的条目。
原因:这一层的坑最多。按概率排:IP 和掩码配错网段(最常见)、MAC 地址是全 0 或者和别的设备冲突、netif_set_up忘了调、PC 端防火墙拦了 ICMP、LWIP_ICMP被裁掉了。
解决:先在板子上加打印,把netif_is_up(&gnetif)、netif->ip_addr、netif->hwaddr都打出来确认;再在 PC 上用 Wireshark 抓包,看板子有没有发出 ARP 请求。如果板子一个包都不发,问题在发送路径(low_level_output);如果发了 ARP 但 PC 不回,检查 PC 的 IP 是否同网段、防火墙是否放行。MAC 地址这块建议按 3.2 节用芯片 UID 拼,别所有板子烧同一个。
提示:有些例程里
netif_set_up放在了ethernetif_init内部,有些放在main里,改代码时容易两个地方都删掉或者都留着。全局搜一下这个函数名,确认它至少被调用一次。
5.3 刚上电正常,跑几十秒到几分钟后彻底不通
现象:上电后 ping 通、TCP 也能连,跑一段时间(几十秒到几分钟不等)突然所有通信中断,复位后恢复,如此循环。
原因:九成是 pbuf 泄漏或者 DMA 描述符没释放。ethernetif_input里netif->input返回ERR_OK之后又pbuf_free了一次,内存池链表被写坏;或者low_level_input里忘了调HAL_ETH_ReleaseRxFrame,DMA 收满一圈就停住。第三种可能是sys_check_timeouts没在主循环里调,LwIP 的定时器不跑。
解决:在pbuf_alloc和pbuf_free两边各加一个计数器,跑十分钟看两者差值是否稳定在池子大小以内。差值是单调增长的,就是泄漏;差值为 0 但通信还是断,就在low_level_input开头打印一次,看是不是根本收不到包了——收不到就是描述符卡住,检查ReleaseRxFrame是否成对调用。
5.4 小包正常,一发大包就 HardFault 或者重启
现象:ping -l 32没问题,ping -l 1472直接丢包;或者用 TCP 传个几十 KB 的文件,板子直接进 HardFault。
原因:三个方向。PBUF_POOL_BUFSIZE小于 1524,大包被拆成链式 pbuf,而low_level_input只拷了第一个;ETH_RX_BUF_SIZE定义得比 1524 小,DMA 写溢出;栈溢出——大包处理路径上局部变量多,默认栈 1KB 不够。
解决:先把PBUF_POOL_BUFSIZE改成 1524、ETH_RX_BUF_SIZE改成 1524 以上,并且在low_level_input里用链式循环拷贝(3.3 节的代码已经处理了)。如果还崩,把主栈从 0x400 加到 0x1000 再试。另外确认描述符和缓冲区没被链接脚本扔进 CCM,DMA 访问不到那块内存,表现也是大包一写就异常。
5.5 硬件校验和打开后,抓包能看到请求但对方不回应
现象:Wireshark 抓包能看到板子发出的 TCP SYN 或者 ICMP Echo,但 PC 不回应;把CHECKSUM_BY_HARDWARE关掉就正常了。
原因:MAC 的校验和卸载需要在发送描述符里置位,并且要告诉 MAC 这个包的校验和起始偏移。如果low_level_output里只把数据拷进缓冲区、没设置描述符的TDES0校验和插入位,包头的校验和字段就是垃圾值,对端直接丢弃。
解决:要么按 HAL 的规范,在HAL_ETH_TransmitFrame之前把heth.TxFrameInfos的校验和参数填对;要么调试阶段干脆关掉硬件卸载,让 LwIP 软件算。我的习惯是永远先用软件校验和跑通全链路,把它当成已知正确的基线,再开硬件卸载做 A/B 对比,出问题能立刻定位到是不是卸载配置的锅。
6. 二次开发改造:把例程的网络层拆成可复用的三件套
例程拿来跑通是一回事,塞进自己的产品里是另一回事。我做过几次这种迁移,最后都会把网络部分拆成三层:初始化层、链路管理层、应用层,中间用两个明确的接口隔开。
第一步是把初始化从main.c里抽出来,做成net_init(void)和net_poll(void)一对函数。main里只留这两行的调用,所有 netif 相关的全局变量收到一个结构体里。这样换板子、换 PHY 的时候只需要改net_init内部的实现,应用代码一行不用动。抽的时候注意lwip_init()必须在netif_add之前调用,这个顺序错了会直接硬故障,而且报错位置和真正的原因隔得很远。
第二步是加链路状态检测。DP83848 的 BMSR 寄存器 bit2 是 Link Status,掉线重插的时候这个位会变。做法是在net_poll里每 500ms 读一次,检测到从有到无就netif_set_link_down,从无到有就netif_set_link_up。如果是插拔网线频繁的场景,还要在 link down 之后把 PHY 重新软复位一次(BMCR 的 bit15),因为有些批次的 PHY 在异常拔插后会卡在错误状态,不复位就再也协商不上。验证方法很简单:反复插拔二十次,看每次恢复通信要多久,正常情况下应该在一秒内。
第三步是应用层的超时与重连。TCP 用 netconn 的时候,netconn_recv默认是无限阻塞的,产品里绝对不能这么写,要配合netconn_set_recvtimeout设一个几秒的超时,超时后关连接重连。UDP 那边则是要注意udp_recv的回调里不能做耗时操作,回一个包就赶紧返回。
| 改造点 | 改哪里 | 验证方式 |
|---|---|---|
| 初始化抽离 | main.c→net_init/net_poll | 换一块板子只改一个文件 |
| 链路检测 | net_poll里读 BMSR | 反复插拔 20 次,恢复时间 < 1s |
| 收发超时 | netconn_set_recvtimeout | 拔掉网线后不卡死,能自动重连 |
| MAC 唯一性 | low_level_init里用 UID 拼 | 多块板子同网段不冲突 |
还有一个经常被忽略的点:这套例程的ethernetif.c里low_level_output通常只处理单帧发送,如果应用层是高并发写,需要检查发送描述符用完时的处理——HAL 在描述符满时会返回HAL_BUSY,此时要等一个发送完成中断再重试,直接丢弃会导致 TCP 大量重传。我一般会在发送路径上加一个简单的等待计数,连续HAL_BUSY超过十次就记一笔日志,跑压力测试的时候看这个计数是不是在涨。
从那以后我每次拿到一份新的网络例程,不管作者写得多好,都强制走一遍“先量 PA1 的 50MHz、再抓 ARP 包、最后看 pbuf 计数差”这三步,确认基线干净了才往里加业务代码。这三步花不了半小时,但能挡掉后面八成莫名其妙的故障。希望帮到你。
本文还有配套的精品资源,点击获取