2026/10/13 22:28:06

STM32F107 LWIP移植实战:从PHY初始化到TCP回显服务器

STM32F107 LWIP移植实战:从PHY初始化到TCP回显服务器 简介这份资源是面向嵌入式开发者的STM32F107平台LWIP协议栈移植范例适合已掌握C语言与STM32基础、希望打通以太网通信的工程师和学生。内容围绕轻量级TCP/IP协议栈在Cortex-M3微控制器上的整合展开涉及协议栈架构、以太网MAC驱动、RTOS集成、参数配置编译以及启动调试等关键环节并附有评估板手册与移植指南文档。压缩包共294个文件以133个.h头文件与114个.c源文件为主体另有12个汇编文件、多个工程与链接脚本、2份PDF手册及readme、changelog等说明文件整体约6.88MB结构完整便于按模块查阅。目前已有266人学习。通过该范例读者可获取从底层网卡驱动到上层网络应用的参考实现理解数据包缓冲、网络接口层与协议处理层的组织方式并借助现成工程快速验证DHCP、TCP/UDP通信等功能为物联网设备联网开发提供可复用的移植思路与排错参考。1. 从零把 stm32107 LWIP 移植范例跑通为什么你的 ping 永远超时手里有一块 stm32107 的开发板网口灯亮着代码烧进去PC 端ping过去却全是Request timed out——这是绝大多数人第一次做 stm32107 LWIP 移植范例时遇到的第一个下马威。LWIP 本身不复杂复杂的是它和 STM32 的 ETH 外设、DMA 描述符、PHY 芯片、时钟树、内存堆之间那一层“胶水”。移植范例的价值就在于它把这一层胶水固化成了可复用的模板让你不用从ethernetif.c的第一行开始啃。这篇笔记面向两类人一是刚拿到 stm32107 板子、想把裸机网络跑通的嵌入式新手二是做过 STM32 但一直用现成协议栈、想搞清楚 LWIP 到底怎么接进来的老手。我会按“先讲清选型理由再给可抄的配置和代码最后把踩过的坑摊开”的顺序写所有参数都落到具体寄存器、宏定义和回调函数上不写“配置一下就好”这种废话。2. stm32107 上跑 LWIP 的三种接法裸机轮询、裸机中断、RTOS 该怎么选在动手改代码之前先把这个选择做掉否则后面返工的成本远大于现在多花十分钟。stm32107 是 STM32F107 系列带 10/100M 以太网 MAC支持 RMII 和 MII 两种 PHY 接口内部有独立的 ETH DMA。LWIP 作为一套轻量 IP 栈本身不关心你用什么方式驱动它但驱动方式直接决定了你后面调ping和TCP连接时的难度。2.1 三种接法的本质区别与适用场景裸机轮询是最简单的主循环里不断调用ethernetif_input()收到包就交给 LWIP 处理。优点是逻辑直白不需要中断优先级配置不需要 RTOS出问题容易定位。缺点是 CPU 利用率高网络流量大时主循环被拖慢而且ping的响应延迟不稳定。裸机中断是折中方案ETH 收到包触发中断在中断里置标志位主循环检测标志位再调用ethernetif_input()。这样 CPU 不用一直轮询实时性比纯轮询好又不用引入 RTOS。坑在于中断优先级和HAL_ETH_IRQHandler的调用时机配错了会丢包。RTOS 方案是把 LWIP 的tcpip_thread跑起来网络操作在独立线程里完成应用层通过netconn或socketAPI 调用。适合需要同时跑多个任务、网络只是其中一部分的场景。代价是你要处理线程优先级、信号量、内存池的线程安全调试复杂度上一个台阶。我一般建议如果只是验证网络通不通、跑个ping和简单TCP回显先用裸机中断把链路跑通确认 PHY 和 DMA 没问题再决定要不要上 RTOS。直接上 RTOS 会把 PHY 层的问题和线程调度的问题混在一起排查时你分不清是网口没通还是线程没跑起来。2.2 最小验证路径先 ping 通再谈 TCP不管最终选哪种接法验证顺序必须是PHY 链路 up → ETH DMA 能收发 → ARP 能解析 →ping通 →TCP连接。很多人一上来就写TCP服务器结果ping都不通白白浪费一天。PHY 链路 up 的判断依据是PHY_BSR寄存器的 link status 位以及PHY_BCR的自协商完成位。stm32107 常见的 PHY 是 DP83848 或 LAN8720两者寄存器地址有差异读PHY_BSR时地址要按 PHY 手册来。链路没 up 之前后面所有步骤都没有意义。ETH DMA 能收发的标志是ETH_DMASR寄存器的RS接收状态和TS发送状态位以及ETH_DMACSR的NIS位。如果 DMA 描述符地址没对齐或者ETH_DMABMR的PBL和RDP设错DMA 会静默失败你看到的现象就是“链路 up 但收不到任何包”。2.3 选型决策表接法代码量CPU 占用调试难度适合场景裸机轮询最少高低单任务、低速数据、快速验证裸机中断中等中中单任务、中等吞吐、无 RTOSRTOS最多低高多任务、高吞吐、已有 RTOS 工程这张表不是绝对的但能帮你快速排除明显不合适的选项。比如你的工程里已经跑了 FreeRTOS那就直接上 RTOS 方案没必要为了“简单”再写一套裸机轮询后面合并反而更麻烦。3. 把 LWIP 接进 stm32107从 PHY 初始化到 ethernetif 的完整配置这一章是核心所有代码和参数都围绕“让 stm32107 的 ETH 外设和 LWIP 的ethernetif对上话”展开。我按初始化顺序写你照着从上到下配中间不要跳步。3.1 时钟树和 GPIO 配置RMII 模式下最容易配错的三个地方stm32107 的 ETH 时钟来自HCLK通过ETH_MACCR的FES位选择 10M/100M。RMII 模式下REF_CLK是 50MHz由外部 PHY 或 MCU 提供。stm32107 的PA1是REF_CLK输入PA2是MDIOPA7是CRS_DVPC1是MDCPC4是RXD0PC5是RXD1PB11是TX_ENPB12是TXD0PB13是TXD1。// stm32107 ETH GPIO 初始化RMII 模式 // 注意PA1 必须配置为浮空输入或复用功能不能配成推挽输出 GPIO_InitTypeDef GPIO_InitStructure; // 使能 GPIOA、GPIOB、GPIOC 和 ETH 时钟 RCC_AHBPeriphClockCmd(RCC_AHBPeriph_ETH_MAC | RCC_AHBPeriph_ETH_MAC_Tx | RCC_AHBPeriph_ETH_MAC_Rx, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_GPIOB | RCC_APB2Periph_GPIOC, ENABLE); // PA1: REF_CLK, PA2: MDIO, PA7: CRS_DV GPIO_InitStructure.GPIO_Pin GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; // 复用推挽 GPIO_Init(GPIOA, GPIO_InitStructure); // PC1: MDC, PC4: RXD0, PC5: RXD1 GPIO_InitStructure.GPIO_Pin GPIO_Pin_1 | GPIO_Pin_4 | GPIO_Pin_5; GPIO_Init(GPIOC, GPIO_InitStructure); // PB11: TX_EN, PB12: TXD0, PB13: TXD1 GPIO_InitStructure.GPIO_Pin GPIO_Pin_11 | GPIO_Pin_12 | GPIO_Pin_13; GPIO_Init(GPIOB, GPIO_InitStructure);这段代码里最容易翻车的是PA1。很多人把它配成GPIO_Mode_AF_PP后发现链路死活不 up原因是REF_CLK在 RMII 模式下是输入配成推挽输出会和外部 PHY 的时钟输出打架。正确做法是配成GPIO_Mode_IN_FLOATING或GPIO_Mode_AF_IN取决于具体库版本。另外GPIO_Speed建议设50MHz设低了在 100M 模式下会丢包。3.2 PHY 初始化与自协商DP83848 和 LAN8720 的寄存器差异PHY 初始化分两步先通过SMI接口读PHY_ID确认芯片型号再配置PHY_BCR启动自协商最后轮询PHY_BSR等待链路 up。// PHY 初始化以 DP83848 为例LAN8720 的寄存器地址不同 #define PHY_ADDR 0x01 // PHY 地址由 PHYAD[4:0] 引脚决定 #define PHY_BCR 0x00 // 基本控制寄存器 #define PHY_BSR 0x01 // 基本状态寄存器 #define PHY_BCR_ANEN 0x1000 // 自协商使能位 #define PHY_BCR_ANRST 0x0200 // 自协商重启位 #define PHY_BSR_LINK 0x0004 // 链路状态位 #define PHY_BSR_ANEG 0x0020 // 自协商完成位 uint16_t phy_read(uint16_t reg) { // 通过 ETH_MACMIIAR 读 PHY 寄存器 // 等待 MII 总线空闲然后发起读操作 while (ETH-MACMIIAR ETH_MACMIIAR_MB); ETH-MACMIIAR (PHY_ADDR 11) | (reg 6) | ETH_MACMIIAR_CR_Div42 | ETH_MACMIIAR_MB; while (ETH-MACMIIAR ETH_MACMIIAR_MB); return (uint16_t)(ETH-MACMIIDR 0xFFFF); } void phy_write(uint16_t reg, uint16_t val) { while (ETH-MACMIIAR ETH_MACMIIAR_MB); ETH-MACMIIDR val; ETH-MACMIIAR (PHY_ADDR 11) | (reg 6) | ETH_MACMIIAR_CR_Div42 | ETH_MACMIIAR_MW | ETH_MACMIIAR_MB; while (ETH-MACMIIAR ETH_MACMIIAR_MB); } int phy_init(void) { uint16_t bcr, bsr; // 读取 PHY ID确认芯片在位 uint16_t id1 phy_read(0x02); uint16_t id2 phy_read(0x03); if ((id1 0xFFFF) || (id2 0xFFFF)) { return -1; // PHY 无响应检查 MDIO/MDC 接线 } // 启动自协商 bcr phy_read(PHY_BCR); bcr | PHY_BCR_ANEN | PHY_BCR_ANRST; phy_write(PHY_BCR, bcr); // 等待自协商完成超时 5 秒 for (int i 0; i 500; i) { bsr phy_read(PHY_BSR); if ((bsr PHY_BSR_LINK) (bsr PHY_BSR_ANEG)) { return 0; // 链路 up 且自协商完成 } delay_ms(10); } return -2; // 自协商超时 }PHY_ADDR由 PHY 芯片的PHYAD[4:0]引脚决定DP83848 默认是0x01LAN8720 默认是0x00。读PHY_ID时如果返回0xFFFF说明MDIO或MDC没接好或者PHY_ADDR配错了。自协商超时通常是REF_CLK没给到 PHY或者 PHY 的复位引脚没拉高。3.3 ETH DMA 描述符与收发缓冲区地址对齐和数量怎么定stm32107 的 ETH DMA 使用描述符环每个描述符包含状态、控制、缓冲地址等字段。描述符必须 4 字节对齐缓冲区建议 32 字节对齐。接收描述符数量建议 4 到 8 个发送描述符 2 到 4 个。数量太少会丢包太多浪费内存。// ETH DMA 描述符定义必须 4 字节对齐 __attribute__((aligned(4))) ETH_DMADESCTypeDef DMARxDscrTab[ETH_RXBUFNB]; __attribute__((aligned(4))) ETH_DMADESCTypeDef DMATxDscrTab[ETH_TXBUFNB]; // 接收缓冲区建议 32 字节对齐 __attribute__((aligned(32))) uint8_t Rx_Buff[ETH_RXBUFNB][ETH_MAX_PACKET_SIZE]; __attribute__((aligned(32))) uint8_t Tx_Buff[ETH_TXBUFNB][ETH_MAX_PACKET_SIZE]; // DMA 初始化关键参数 void eth_dma_init(void) { // 配置 DMA 总线模式PBL 设为 32RDP 设为 32 ETH-DMABMR ETH_DMABMR_SR | ETH_DMABMR_PBL_32Beat | ETH_DMABMR_RDP_32Beat | ETH_DMABMR_AAB; // 等待软件复位完成 while (ETH-DMABMR ETH_DMABMR_SR); // 设置接收描述符列表地址 ETH-DMARDLAR (uint32_t)DMARxDscrTab; // 设置发送描述符列表地址 ETH-DMATDLAR (uint32_t)DMATxDscrTab; // 使能接收和发送 ETH-DMAROMR ETH_DMAROMR_RTC_64 | ETH_DMAROMR_OSF; ETH-DMAOMR | ETH_DMAOMR_ST | ETH_DMAOMR_SR; }ETH_DMABMR_PBL_32Beat和ETH_DMABMR_RDP_32Beat是突发长度设成 32 在 stm32107 上比较稳。ETH_DMABMR_AAB是地址对齐突发建议打开。ETH_DMAROMR_RTC_64是接收阈值设 64 字节。如果 DMA 描述符地址没对齐ETH_DMASR的FBE致命总线错误位会置起这时候收发包全挂。3.4 ethernetif.c 的四个关键函数low_level_init、low_level_output、low_level_input、ethernetif_inputLWIP 的ethernetif.c是协议栈和硬件之间的桥梁核心是四个函数。low_level_init负责初始化 MAC 和 DMAlow_level_output把pbuf里的数据通过 ETH 发送出去low_level_input从 ETH 接收数据并封装成pbufethernetif_input把pbuf交给 LWIP 的netif处理。// low_level_output把 pbuf 数据拷贝到发送缓冲区并启动 DMA 发送 static err_t low_level_output(struct netif *netif, struct pbuf *p) { struct pbuf *q; uint8_t *buffer (uint8_t *)Tx_Buff[tx_idx]; uint32_t framelen 0; // 把 pbuf 链里的数据拷贝到连续缓冲区 for (q p; q ! NULL; q q-next) { memcpy(buffer framelen, q-payload, q-len); framelen q-len; } // 长度不足 60 字节要补齐否则 PHY 会丢弃 if (framelen 60) { memset(buffer framelen, 0, 60 - framelen); framelen 60; } // 设置发送描述符并启动 DMA DMATxDscrTab[tx_idx].ControlBufferSize framelen; DMATxDscrTab[tx_idx].Status ETH_DMATXDESC_OWN | ETH_DMATXDESC_LS | ETH_DMATXDESC_FS | ETH_DMATXDESC_IC; ETH-DMATPDR 0; // 触发发送轮询 tx_idx (tx_idx 1) % ETH_TXBUFNB; return ERR_OK; } // low_level_input从接收描述符取数据封装成 pbuf static struct pbuf *low_level_input(struct netif *netif) { struct pbuf *p NULL; uint32_t len 0; uint8_t *buffer; // 检查接收描述符的 OWN 位如果为 0 说明有数据 if (!(DMARxDscrTab[rx_idx].Status ETH_DMARXDESC_OWN)) { len (DMARxDscrTab[rx_idx].Status 16) 0x3FFF; buffer (uint8_t *)DMARxDscrTab[rx_idx].Buffer1Addr; // 分配 pbufPBUF_POOL 类型适合接收路径 p pbuf_alloc(PBUF_RAW, len, PBUF_POOL); if (p ! NULL) { pbuf_take(p, buffer, len); } // 把描述符还给 DMA DMARxDscrTab[rx_idx].Status ETH_DMARXDESC_OWN; ETH-DMARPDR 0; // 触发接收轮询 rx_idx (rx_idx 1) % ETH_RXBUFNB; } return p; }low_level_output里最容易忽略的是 60 字节补齐。以太网最小帧是 64 字节含 4 字节 FCSLWIP 的pbuf可能只有 40 多字节不补齐 PHY 直接丢。low_level_input里pbuf_alloc用PBUF_POOL而不是PBUF_RAM因为接收路径在中断上下文里PBUF_POOL从内存池分配速度更快且不会碎片化。3.5 lwipopts.h 里必须改的五个参数lwipopts.h是 LWIP 的配置头文件默认值在 stm32107 上跑不通。必须改的五个参数参数默认值建议值原因MEM_SIZE16004096默认堆太小TCP 发送大包会失败PBUF_POOL_SIZE168stm32107 RAM 有限16 个池容易耗尽TCP_MSS5361460默认值导致 TCP 分片降低吞吐TCP_SND_BUF2562920发送缓冲区太小TCP 窗口上不去LWIP_DHCP01需要 DHCP 自动获取 IP 时打开MEM_SIZE设 4096 是经验值再大要看你的 RAM 余量。TCP_MSS设 1460 是标准以太网 MTU 1500 减去 IP 头 20 和 TCP 头 20。TCP_SND_BUF建议是TCP_MSS的两倍这样 TCP 窗口能开到 2 个 MSS。4. 避坑与排查stm32107 LWIP 移植中最容易翻车的五个点这一章按“现象 → 原因 → 解决”写每条都是我在实际调试中踩过的。4.1 ping 不通但链路灯亮现象PHY 链路灯亮phy_init返回 0但 PC 端ping全是超时。原因最常见的是low_level_output里没做 60 字节补齐或者ETH_DMATXDESC_OWN位没置对。另一个可能是lwipopts.h里LWIP_ARP被关掉了PC 发 ARP 请求板子不回自然ping不通。解决先用示波器看TX_EN和TXD0有没有波形确认 MAC 在发。如果没有波形检查ETH_MACCR的TE位和ETH_DMAOMR的ST位。如果有波形但 PC 收不到检查网线和水晶头以及 PC 的防火墙。4.2 能 ping 通但 TCP 连接建立后立刻断开现象ping正常PC 用telnet连板子的TCP端口连接建立后马上收到RST。原因TCP_SND_BUF和TCP_WND设得太小或者MEM_SIZE不够tcp_write返回ERR_MEMLWIP 直接发RST断开。解决把MEM_SIZE加到 4096 以上TCP_SND_BUF设 2920TCP_WND设 2920。如果还不行检查pbuf_alloc的返回值在low_level_input里加日志看是不是pbuf分配失败导致丢包。4.3 大数据量传输时丢包严重现象小包ping正常一传大文件就大量丢包ifconfig里RX errors和TX errors飙升。原因接收描述符数量太少DMA 来不及处理就被覆盖。或者ETH_DMABMR的PBL设得太小总线利用率低。解决把ETH_RXBUFNB从 4 加到 8ETH_TXBUFNB从 2 加到 4。ETH_DMABMR_PBL设32BeatETH_DMABMR_RDP设32Beat。如果还丢检查Rx_Buff和Tx_Buff的地址对齐必须是 32 字节对齐。4.4 中断模式下系统跑一段时间后死机现象裸机中断方案跑几分钟后系统卡死看门狗复位。原因ETH_IRQHandler里没有清中断标志或者中断优先级设得太高抢占了SysTick导致HAL_Delay卡死。解决在ETH_IRQHandler里先读ETH_DMASR把NIS、RIS、TIS等标志清掉再调用ethernetif_input。中断优先级设成比SysTick低比如SysTick优先级 0ETH 中断优先级 1。4.5 DHCP 获取不到 IP 地址现象LWIP_DHCP设为 1但板子一直用默认 IPdhcp_fine_tmr超时。原因dhcp.c里的DHCP_DOES_ARP_CHECK默认是 1会先发 ARP 探测 IP 是否被占用如果网络里有人用同一个 IPDHCP 会一直重试。解决把DHCP_DOES_ARP_CHECK设为 0跳过 ARP 探测。或者检查路由器 DHCP 地址池是不是满了。另外LWIP_DHCP打开后netif的ip_addr要设成0.0.0.0否则 DHCP 不会启动。5. 进阶用 netconn API 写一个稳定的 TCP 回显服务器裸机raw API写回调函数容易把逻辑写散netconn API是阻塞式的代码更线性适合在 RTOS 线程里跑。下面是一个TCP回显服务器的核心代码跑在 FreeRTOS 任务里。// TCP 回显服务器使用 netconn API void tcp_echo_server(void *arg) { struct netconn *conn, *newconn; struct netbuf *buf; err_t err; void *data; u16_t len; // 创建 TCP 连接对象 conn netconn_new(NETCONN_TCP); if (conn NULL) { return; // 内存不足 } // 绑定到 8080 端口 netconn_bind(conn, IP_ADDR_ANY, 8080); // 进入监听状态 netconn_listen(conn); while (1) { // 阻塞等待客户端连接 err netconn_accept(conn, newconn); if (err ! ERR_OK) { continue; } // 处理客户端数据 while ((err netconn_recv(newconn, buf)) ERR_OK) { do { // 获取 buf 里的数据指针和长度 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_new(NETCONN_TCP)创建一个 TCP 连接对象netconn_bind绑定端口netconn_listen进入监听。netconn_accept阻塞等待客户端连接返回后newconn就是新的客户端连接。netconn_recv阻塞接收数据netbuf_data取出数据指针和长度netconn_write回发。NETCONN_COPY标志表示数据会被拷贝到内部缓冲区调用返回后data指针可以释放。这个服务器跑在 FreeRTOS 任务里任务栈建议 512 字以上优先级低于tcpip_thread。netconnAPI 内部会调用tcpip_thread的邮箱所以tcpip_thread必须先跑起来。如果netconn_accept一直返回ERR_MEM检查MEMP_NUM_NETCONN和MEMP_NUM_TCP_PCB是不是设得太小。验证方法PC 端用telnet连板子 IP 的 8080 端口随便输入字符板子原样返回。如果连不上先确认ping通再确认netconn_listen的返回值是ERR_OK。如果连上后立刻断开检查netconn_write的返回值ERR_MEM说明发送缓冲区不够。我自己的习惯是每次改完lwipopts.h先跑一遍ping再跑TCP回显最后跑大文件传输。三步都过才算这个移植范例真正跑通。希望帮到你。本文还有配套的精品资源点击获取