STM32F767与lwIP网络移植全解析:从HAL库ETH到TCP调优
2026/9/16 13:23:04 网站建设 项目流程

简介:面向STM32F7系列单片机开发者,这套资料以STM32F767为例,演示基于HAL库的TCP网络通信实现。内容覆盖以太网MAC/PHY硬件初始化、lwIP协议栈移植、TCP套接字创建与收发数据等关键环节,并配有Lan8720等常见PHY芯片的配置思路,适合需要为嵌入式设备添加网络通信能力的中高级开发者参考。资源包共384个文件,以187个h头文件和166个c源文件为主,分别对应驱动声明与功能实现,辅以少量说明文档、脚本与图片,压缩包大小2.86MB,目录结构清晰便于检索。目前已有190人学习,可用于快速搭建TCP通信工程,理解从寄存器配置到协议栈调用的完整流程。通过阅读源码,还能掌握HAL_ETH_TransmitFrame、tcp_write、tcp_recv等API的实际用法,为远程监控、数据采集等应用开发提供直接参考。

1. STM32F767 跑不好 TCP,问题多半卡在 HAL 库的 ETH 交接层,而不是 TCP/IP 协议栈

STM32F767 是 F7 系列里网络能力靠前的一颗片子,内部集成了 10/100M 以太网 MAC,配合 LAN8720 这类 RMII 接口的 PHY 芯片,就能在系统里拉出一条独立的网络通路。接触过不少 F767 网络工程,拿到源码包后第一反应往往是去翻 lwIP 的tcp_newtcp_connect,但真正让板子跑不起来的,经常是 HAL 库 ETH 驱动边界上的那一层:引脚复用配错、PHY 时钟不对、接收缓冲区没有及时释放、lwIP 的netif没有把收包入口挂进中断。TCP 三次握手对使用者来说是几个回调函数,对协议栈内部状态机来说却是一连串超时、重传和 ACK 确认。这份资源把 STM32F7 系列单片机和 HAL 库驱动绑在一起,正好可以把HAL_ETH_Init到 lwIP 回调链完整拆开看,适合正在从裸机网络转发往协议栈移植阶段的工程师。

2. STM32F767 的 ETH 外设绑定:从 RMII/MII 选择到 HAL_ETH_Init 的参数链

2.1 先定 PHY 模式和时钟,别让 MDIO 对着不存在的寄存器操作

STM32F767 内部只集成 MAC,物理层 PHY 在外面。这个结构决定了移植这件事有两个边界:第一个是 MAC 与 PHY 之间的管理口 MDIO/MDC,第二个是数据口 MII/RMII。数据口接错,PHY 的 link 状态能读出来,但收发包会对不上;时钟接错,可能连 MDIO 都读不到稳定值。F767 的 RMII 模式在硬件上最省引脚,只需要七根信号线;MII 模式引脚翻倍,但可以接老式 100M PHY。目前在入门级网络板上,LAN8720A 配 RMII 是常见组合,因为 REF_CLK 可以由 PHY 侧提供 50MHz 时钟,也可以由外部无源晶振产生,设计上灵活。

模式时钟要求数据引脚数量推荐场景
MIITX_CLK/RX_CLK 各 25MHz16工业板、宽温 PHY,兼容老设计
RMIIREF_CLK 统一 50MHz7LAN8720 等低功耗 PHY,管脚紧张场景

选 RMII 不只是少了四根数据线,还意味着 PHY 和 MAC 共享同一个 50MHz 参考时钟。F767 的 ETH_RMII_REF_CLK 引脚一般走 PA1,这个脚的方向在 CubeMX 里必须配置成输入,因为它接收来自 PHY 的时钟。如果方向配反,ETH 模块能初始化,但收包永远超时。

2.2 MspInit 里的 GPIO、时钟、中断和 RMII 引脚分配

初始化代码的第一步不是HAL_ETH_Init,而是HAL_ETH_MspInit。HAL 库把资源独立的底层层放到 MspInit 里,包括外设时钟、GPIO、中断优先级以及 DMA。F767 的 CubeMX 生成工程里,如果 PHY 选项选错,生成出来的 MspInit 会缺失某几个 ETH 引脚。常见做法是手动核对一遍 RMII 引脚映射,下面是一组 LAN8720A 的典型 F767 配置:

void HAL_ETH_MspInit(ETH_HandleTypeDef *heth) { GPIO_InitTypeDef gpio = {0}; __HAL_RCC_ETH1MAC_CLK_ENABLE(); __HAL_RCC_ETH1TX_CLK_ENABLE(); __HAL_RCC_ETH1RX_CLK_ENABLE(); __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_GPIOB_CLK_ENABLE(); __HAL_RCC_GPIOC_CLK_ENABLE(); if (heth->Init.MediaInterface == HAL_ETH_RMII_MODE) { /* RMII 数据口 */ gpio.Pin = GPIO_PIN_11 | GPIO_PIN_12 | GPIO_PIN_13; /* PB11 TX_EN,PB12/13 TXD0/1 */ gpio.Mode = GPIO_MODE_AF_PP; gpio.Speed = GPIO_SPEED_FREQ_HIGH; gpio.Alternate = GPIO_AF11_ETH; HAL_GPIO_Init(GPIOB, &gpio); /* PA1 REF_CLK,PA7 CRS_DV */ gpio.Pin = GPIO_PIN_1 | GPIO_PIN_7; gpio.Mode = GPIO_MODE_AF_PP; gpio.Alternate = GPIO_AF11_ETH; HAL_GPIO_Init(GPIOA, &gpio); /* PC4 RXD0,PC5 RXD1,PC1 MDC */ gpio.Pin = GPIO_PIN_4 | GPIO_PIN_5 | GPIO_PIN_1; gpio.Mode = GPIO_MODE_AF_PP; gpio.Alternate = GPIO_AF11_ETH; HAL_GPIO_Init(GPIOC, &gpio); /* PA2 MDIO */ gpio.Pin = GPIO_PIN_2; gpio.Mode = GPIO_MODE_AF_PP; gpio.Alternate = GPIO_AF11_ETH; HAL_GPIO_Init(GPIOA, &gpio); } }

代码里MediaInterfaceHAL_ETH_Init传入的配置项,MspInit 会拿它判断该初始化 RMII 还是 MII。GPIO_AF11_ETH是 F7 系列把 ETH 信号复用到这些引脚的具体复用功能。GPIO_SPEED_FREQ_HIGH在 50MHz REF_CLK 下比较稳妥,速度等级偏低会拉高边沿抖动,重负载时出现 CRC 错误。MDIO/MDC 并不是严格的高速信号,但仍建议和 RX/TX 一起放在 AF11 组里配置,避免后续单独遗漏。

2.3 PHY 寄存器读取决定网线插没插:HAL_ETH_ReadPHYRegister 的用法

ETH 外设初始化完成还只是 MAC 这侧就绪。PHY 是否工作,要通过 MDIO 管理口读取 PHY 寄存器。HAL 库提供了HAL_ETH_ReadPHYRegisterHAL_ETH_WritePHYRegister,入参分别是 ETH 句柄、PHY 寄存器地址和数据指针。一般先读 PHY 标识寄存器确认 PHY 地址正确,再读基本状态寄存器判断链路:

uint32_t phy_id = 0; uint32_t bmsr = 0; if (HAL_ETH_ReadPHYRegister(&heth, PHY_BSR, &bmsr) == HAL_OK) { if (bmsr & 0x0004) /* BMSR bit2: link status */ { set_network_link_state(1); } } else { set_network_link_state(0); }

这里PHY_BSR是 0x01,bit2 为链路状态,1 表示网线已连接。HAL_ETH_ReadPHYRegister返回HAL_OK只代表 MDIO 事务完成,不保证 PHY 一定有有效链路。常见坑是 LAN8720 的 PHY 地址并非固定 0,某些板子把 PHYAD0 引脚拉高后地址变成 1,读不到寄存器时先查板子原理图再换地址。

3. lwIP 在 STM32F767 上的最小移植:netif 注册与 pbuf 内存池

3.1 内存池不是越大越好:TCP_WND 和 PBUF_POOL_SIZE 必须一起调

lwIP 是一个典型的 memory-budget 敏感型协议栈。F767 的 SRAM 不小,但 ETH DMA 描述符、接收 pbuf、TCP 窗口三者之间是联动关系。很多工程只调大TCP_WND,却不给PBUF_POOL_SIZE留空间,结果 TCP 连接建立后接收数据一多就丢包。实际设计时,应先把PBUF_POOL_SIZE设置到能容纳至少两个 TCP 窗口的数据量,再去调TCP_WNDlwipopts.h里需要反复验证的几个关键参数:

#define TCP_MSS 1460 #define TCP_WND (8 * TCP_MSS) #define PBUF_POOL_SIZE 32 #define PBUF_POOL_BUFSIZE 1512 #define MEM_ALIGNMENT 4 #define LWIP_NETCONN 1 #define LWIP_SOCKET 0

TCP_MSS是单条 TCP 报文能承载的最大应用数据字节数。以太网 MTU 是 1500,去掉 IP/TCP 头后常用 1460。TCP_WND表示接收端通告窗口,如果设成 8 倍 MSS,协议栈就需要能缓冲约 12KB 的接收链。PBUF_POOL_SIZEPBUF_POOL_BUFSIZE是 pbuf 池的总容量,这里约 48KB。如果使用 STM32F767 的 DTCM 当主 RAM,这部分内存不会全部被 DMA 访问到,要注意 linker 脚本里 ETH 描述符和 pbuf 池是否放在了 AXI SRAM 或普通 SRAM 区域。

3.2 netif_add 与 tcpip_input:把网口挂进协议栈的完整动作

lwIP 的移植核心是netif结构体。常见做法是使用tcpip_thread的 NO_SYS=0 模式,让协议栈跑在自己的线程里,应用线程和网络线程通过 mailbox 通信。初始化时先tcpip_init,再netif_add

struct netif g_netif; ip4_addr_t ip, mask, gw; uint8_t eth_mac[6] = {0x02, 0x00, 0x12, 0x34, 0x56, 0x78}; void network_stack_init(void) { ip4addr_aton("192.168.1.66", &ip); ip4addr_aton("255.255.255.0", &mask); ip4addr_aton("192.168.1.1", &gw); tcpip_init(NULL, NULL); netif_add(&g_netif, &ip, &mask, &gw, eth_mac, ethernetif_init, tcpip_input); netif_set_default(&g_netif); netif_set_up(&g_netif); }

netif_add的第 6 个参数ethernetif_init是底层的初始化函数,inside 里会完成 MAC 寄存器设置和描述符建立。第 7 个参数tcpip_input决定了收包后是直接进协议栈还是通过 tcpip_thread 的 mailbox。这里选用tcpip_input而不是ethernetif_input,是因为 tcpip_thread 模式把协议栈和链路层解耦,不需要在 ETH 中断回调里处理 TCP 状态,避免中断上下文过长。

解压源码包时如果看到fsdata.c这类文件,不要误判为主链路。它通常是 lwIP httpd 通过makefsdata工具生成的网页资源静态数组,在这个 TCP 通信工程里可以裁掉,也可以在调试期配合 http 服务做简单的状态页。

3.3 接收路径:DMA 缓冲必须尽快搬走,否则覆盖

ETH 中断或轮询入口拿到的是 DMA 描述符指向的帧。HAL 库的HAL_ETH_GetRxDataBuffer拿到的rx_buf.buffer指向 DMA 所有权区域,必须把内容拷贝到 pbuf 后立即释放描述符,否则后续帧会覆盖旧帧。推荐在专用线程或 while 循环里做轮询:

static void ethernetif_input_loop(void) { struct pbuf *p; ETH_BufferTypeDef rx_buf; uint32_t framelen = 0; while (1) { if (HAL_ETH_GetRxDataBuffer(&heth, &rx_buf) == HAL_OK) { HAL_ETH_GetRxDataLength(&heth, &framelen); p = pbuf_alloc(PBUF_RAW, framelen, PBUF_POOL); if (p != NULL) { pbuf_take(p, (const void *)rx_buf.buffer, framelen); g_netif.input(p, &g_netif); } HAL_ETH_ReleaseRxBuffer(&heth); } } }

pbuf_alloc的第三个参数选PBUF_POOL,表示从固定大小的池子分配内存,时间确定且不会产生堆碎片。pbuf_take把 DMA buffer 里的数据复制进 pbuf;g_netif.input内部实际调用的是tcpip_input,会把 pbuf 挂进协议栈的接收队列。如果pbuf_alloc返回空,说明 pbuf 池耗尽,此时应直接释放 RX 描述符并统计丢包,而不是阻塞等待,因为 ETH DMA 不能长时间不释放。

4. 用 lwIP 原始回调接口建 TCP 连接:三次握手之后的时序与控制

4.1 tcp_new 和 tcp_bind:端口冲突在嵌入式里的表现

在 F767 上用 lwIP 建 TCP 服务端,核心 API 是tcp_newtcp_bindtcp_listentcp_accepttcp_new在内存池里分配一个 TCP PCB,分配失败返回 NULL。tcp_bind把 PCB 绑定到本地 IP 和端口,如果端口被占用,lwIP 会返回ERR_USE。上位机调试时常见bind: only one usage of each socket address这类报错,在嵌入式里的对应表现就是反复执行tcp_bind却返回失败,原因通常是连接没有正常关闭、PCB 没有释放或者端口号写成了 0。lwIP 中tcp_listen调用后会返回一个新的 listening PCB,原始 PCB 在内部被释放,后续所有操作都要使用返回的新指针。

lwIP 返回码含义排错方向
ERR_OK操作成功继续下一阶段
ERR_MEM内存不足加大PBUF_POOL_SIZEMEM_SIZE
ERR_VAL参数非法检查 PCB 指针和回调函数
ERR_USE端口已被占用关闭旧连接或等待 TIME_WAIT 超时

4.2 tcp_accept 与 tcp_recv:收数据后别忘了 tcp_recved

TCP 服务端最简单的启动流程如下。注意tcp_accept注册的连接回调只表示三次握手已经完成,此时对端进入 ESTABLISHED 状态,而应用代码只负责挂后续回调:

static struct tcp_pcb *tcp_server; static err_t server_accept(void *arg, struct tcp_pcb *newpcb, err_t err) { if (err != ERR_OK || newpcb == NULL) { return ERR_VAL; } tcp_recv(newpcb, server_recv); tcp_err(newpcb, server_err); tcp_nagle_disable(newpcb); return ERR_OK; } static err_t server_recv(void *arg, struct tcp_pcb *pcb, struct pbuf *p, err_t err) { if (p == NULL) { /* p 为 NULL 表示对端发出 FIN,进入四次挥手 */ tcp_close(pcb); return ERR_OK; } app_ring_write(p->payload, p->len); /* 告诉协议栈这些字节已经被应用接收 */ tcp_recved(pcb, p->len); pbuf_free(p); return ERR_OK; } void tcp_server_start(void) { tcp_server = tcp_new(); if (tcp_server == NULL) { return; } if (tcp_bind(tcp_server, IP_ADDR_ANY, 6000) == ERR_OK) { tcp_server = tcp_listen(tcp_server); tcp_accept(tcp_server, server_accept); } }

tcp_recv注册的是一个同名回调,当新的数据段到达或者发生 FIN 时被调用。当p为 NULL 时,意味着对端关闭连接,lwIP 主动把回调触发出来,应用必须调用tcp_close并释放相关资源。tcp_recved是关键动作,它给 lwIP 一个信号:接收窗口可以恢复。如果数据已经从 pbuf 拷贝到应用缓冲区,却一直不调用tcp_recved,协议栈的接收窗口会慢慢变成 0,对端发送数据会被抑制,最终表现为连接不死但数据停住。

提示:tcp_recved的调用时机决定 TCP 背压。放在“数据已经搬走”之后,窗口回补才安全;放在 osa 写进环形缓冲之后也没问题,但要求环形缓冲有足够空间。

4.3 发送一条 TCP 报文:tcp_write、tcp_output 与 Nagle 的合作

发送侧最容易被忽略的是tcp_write的返回值和 Nagle 算法。tcp_write只是把数据复制进 PCB 的发送队列,真正驱动发送的是tcp_output。在默认配置下,lwIP 会启用 Nagle 算法,小包会被攒在队列里等待上一个 ACK,这就导致那种“每次发一个字节”的业务延迟明显。tcp_nagle_disable可以关掉这个行为,代价是网络上可能出现更多小包。

err_t send_tcp_payload(struct tcp_pcb *pcb, const uint8_t *data, uint16_t len) { err_t err = tcp_write(pcb, data, len, TCP_WRITE_FLAG_COPY); if (err == ERR_OK) { tcp_output(pcb); } else if (err == ERR_MEM) { /* 发送队列满,等待 tcp_sent 回调后再重试 */ } return err; }

TCP_WRITE_FLAG_COPY告诉协议栈把用户数据拷贝进 pbuf,应用层可以立刻释放 data 缓冲区。tcp_write返回ERR_MEM时不要反复重试,正确做法是等tcp_sent回调出现后,再补发剩余数据。tcp_output在无系统线程的 lwIP 配置里是显式发送入口;如果使用 tcpip_thread 模式,也可以不调用,交给协议栈线程在合适时机发送,但实时性会略微下降。

5. 给 TCP 透传收尾:环形缓冲、窗口回补和关掉 Nagle

5.1 环形缓冲让tcp_recved的时机更安全

很多透传工程把 pbuf 里的数据直接处理掉,逻辑简单,但只要网络一次来的是分片包,就必须攒到完整数据帧再处理,这时靠tcp_recv回调直接做业务逻辑很容易被 4KB pbuf 池限制住。更稳妥的方式是把接收数据全部落到一个应用层环形缓冲里,然后让业务线程按消息解析。环形缓冲写入完成后才调用tcp_recved,这样 lwIP 认为数据已经消费掉,可以继续扩大窗口:

static uint8_t rx_ring[8192]; static uint32_t rx_head, rx_tail; static err_t server_recv(void *arg, struct tcp_pcb *pcb, struct pbuf *p, err_t err) { uint32_t written; if (p == NULL) { tcp_close(pcb); return ERR_OK; } written = ring_write(rx_ring, &rx_head, &rx_tail, sizeof(rx_ring), p->payload, p->len); tcp_recved(pcb, written); pbuf_free(p); if (written < p->len) { /* 环形缓冲满,暂时不回补窗口,让对端减少发送 */ tcp_slowtmr(); } return ERR_OK; }

ring_write的返回值是实际写入字节数。当环形缓冲剩余空间不足时,这里只回补实际写入的部分,剩余 pbuf 数据不会被立即确认,lwIP 会保持接收窗口为 0,对端就会从 TCP 层感受到背压。这比直接丢弃数据再靠应用层重传要干净。tcp_slowtmr在 NO_SYS 模式下手动驱动协议栈定时器,能更快触发窗口更新和超时处理。

5.2 调试期验证 Nagle 是否生效

关掉 Nagle 后,一次tcp_writetcp_output就应该立刻在网络上看到 PSH 置位的 TCP 报文。使用 Wireshark 抓包时,如果发现对端发出的数据段只有 ACK 没有 PSH,说明仍在积攒小包;如果在server_accept里已经执行tcp_nagle_disable,则每个小包都会独立带 PSH,便于应用层及时响应。发送侧的tcp_sent回调里不能做重入发送操作,否则可能递归过深,通常做法是设一个tx_complete标志,让业务线程自己判断是否可以把下一块数据投进tcp_write

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

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

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

立即咨询