1. 为什么2024年还在折腾STM32以太网
先说背景。今年手头一个设备项目要加联网功能,工业现场既没有Wi-Fi也不方便上4G模块,唯独每个机柜都预留了RJ45网口。一圈调研下来,方案基本锁定在MCU内置MAC加外部PHY芯片,配合LwIP协议栈这条路。市面上主流的MCU里,STM32的ETH外设资料最多、CubeMX还有图形化配置加持,踩坑门槛相对低,于是就有了这个“24-ETH”项目。
名字里的“反思”不是矫情。这一年里从打板回来点不亮PHY,到LwIP ping通了但TCP收发几小时就崩,再到排查出是内存池配置失误,前前后后折腾了快两个月。回头再看,80%的时间其实都耗在了几个固定的坑上。这篇博文就把整个过程中我认为最值得记录的思路、配置、代码和排错经历整理出来,希望对准备做STM32以太网开发的朋友有点帮助。
适合谁看?用STM32F4/F7/H7系列做以太网通信的工程师,尤其是第一次碰MAC+PHY+LwIP这套组合的人。如果你已经有了点基础,只是想看一些冷门坑,可以直接跳到第5节。
2. 方案选型:为什么是内部MAC加外部PHY,而不是SPI网口
2.1 先分清“以太网”这件事里的三个角色
很多人第一次搞以太网,容易被一堆术语绕晕。其实从MCU角度看,要跑通以太网,硬件上就三块:
- MAC层:负责数据帧的封装、解封装、地址过滤、流量控制。在STM32里,这个模块叫ETH,是芯片内部自带的。
- PHY层:负责把数字信号变成差分模拟信号往网线上送,同时处理链路协商、状态检测。这就是外部挂的那颗PHY芯片。
- 变压器和RJ45:负责电气隔离和物理接口。
MCU内部只有MAC,没有PHY,因为PHY涉及的模拟电路部分很难跟数字逻辑集成在同一颗芯片上,成本和工艺都不划算。所以必须外挂一颗PHY芯片。
2.2 为什么不用SPI接口的以太网模块
有人会问:直接用W5500这种SPI接口的以太网控制芯片不是更省事吗?确实,W5500把MAC和PHY全集成在内部,MCU通过SPI读写寄存器,不用碰LwIP,硬件上也算简单。但我这次没选它,原因有两条:
- 吞吐量瓶颈。SPI时钟即便跑到几十MHz,实际TCP带宽也就在几MB/s量级。工业现场虽然不追求极限速度,但我的项目里需要把一段时间内的波形数据打包上传,单次传输量接近1MB,SPI方案传输耗时明显偏长。
- 协议栈可定制性太差。W5500内部硬件协议栈虽然方便,但遇到特殊需求(比如自定义应用层协议、非标准的UDP组播行为)就非常受限。LwIP是源码级的,想怎么改都行。
所以最终确定用STM32F407内部的ETH外设,加一颗外置PHY,跑LwIP。F407的ETH自带DMA,支持RMII和MII两种接口,性能足够,资料也多。
2.3 PHY芯片选型:LAN8720A还是DP83848
PHY芯片我用过LAN8720A和DP83848,各有优缺点,简单做个对比:
| 项目 | LAN8720A | DP83848 |
|---|---|---|
| 接口 | RMII | MII / RMII |
| 工作电压 | 3.3V(内部1.2V LDO) | 3.3V(内部1.8V LDO) |
| 功耗 | 较低 | 较高 |
| 50MHz时钟源 | 必须外部提供 | 可外部提供也可自振 |
| 价格 | 便宜 | 稍贵 |
| 常见产地 | 国产芯片兼容型号多 | TI原厂 |
我最终选了LAN8720A,核心原因就是RMII接口下PHY需要的50MHz参考时钟可以直接由STM32的MCO引脚输出,省一个有源晶振。DP83848也能这么干,但它对时钟质量更敏感,而且整体功耗偏高。不过要注意,LAN8720A的寄存器布局比较特殊,有些寄存器地址和标准PHY不完全一致,后面调试部分会专门讲。
3. 硬件设计里最容易被忽略的几个点
3.1 RMII接口只有7根线,但时序要求并不低
RMII全称是Reduced Media Independent Interface,相比MII把数据线从8根减到2根,时钟频率从25MHz提高到50MHz。好处是MCU引脚占用少,坏处是50MHz时钟对PCB走线长度匹配有要求。
STM32F407的ETH RMII接口信号一共7根:
- ETH_RMII_REF_CLK:50MHz参考时钟
- ETH_RMII_CRS_DV:载波侦听/数据有效
- ETH_RMII_TX_EN:发送使能
- ETH_RMII_TXD[1:0]:发送数据
- ETH_RMII_RXD[1:0]:接收数据
- ETH_RMII_MDIO:管理接口数据线
- ETH_RMII_MDC:管理接口时钟
这里有个坑:REF_CLK到底谁提供?如果让STM32的MCO输出50MHz给PHY,那么PHY的时钟输入和MAC侧接收逻辑都用这一路时钟,相位关系是固定的,相对简单。但有的PHY要求REF_CLK由外部晶振提供,PHY内部再把它转发给MAC,这种情况下如果电路设计没留好跳线,调试时就会非常痛苦。
3.2 复位电路和PHY地址别想当然
LAN8720A的PHY地址由RXER/PHYAD0引脚的上拉下拉决定,默认地址是0x00,但很多开发板把它配置成0x01。如果你按着默认地址去写代码,读不到PHY ID,链路状态永远是Down,而且这种问题用示波器量信号往往量不出来,因为物理波形都正常,纯粹是寄存器读写地址对不上。
复位引脚同样不能随意接。LAN8720A的NRST要求低电平复位,复位脉冲宽度最少1ms。MCU上电瞬间如果GPIO默认输出高电平,而PHY的复位引脚恰好通过一个电容做上电延迟,有可能导致复位不彻底。我后来干脆用一个普通GPIO控制PHY复位,上电后先拉低50ms,再拉高,然后延时200ms等PHY内部初始化完成,问题就消失了。
3.3 变压器和RJ45的选择
以太网变压器不是随便选个型号都能用的。带PoE供电需求的要选带中心抽头供电的型号,普通数据通信选常见的HR911105A这类集成RJ45加变压器的连接器即可。集成式连接器最大的好处是BOM少、Layout方便,但要注意不同厂家的引脚定义存在差异,打板前一定要对着数据手册核对封装。
另外,PHY芯片的TX/RX差分对走线要做阻抗控制,单端50欧姆、差分100欧姆。两层板做不了严格的阻抗匹配,但至少要做到差分对等长、远离晶振和电源走线,这个经验很重要。
4. 基于STM32CubeMX的ETH与LwIP配置
4.1 时钟配置:别把50MHz从MCO引出的坑留给后续
打开CubeMX,选好芯片型号后第一件事是配置时钟树。STM32F407最高主频168MHz,我习惯把HCLK拉到168MHz,APB2时钟84MHz。ETH外设挂载在AHB1总线上,时钟使能后它的时钟是HCLK。
RMII模式下PHY需要的50MHz REF_CLK由MCO1引脚输出,时钟源选PLL的PLLQ,分频得到50MHz。CubeMX里RCC配置中MCOx settings选MCO1,时钟源选择PLLCLK,分频系数填5,因为PLLQ默认是336MHz(168MHz x 2),除以5以后是67.2MHz,不对。这里有一个很隐蔽的地方:PLLQ不是336MHz,需要打开Clock Configuration页面仔细看。我实际用的事PLLCLK的168MHz经MCO1二分频得到84MHz,再配PHY内部PLL?不对,LAN8720A不支持这么干。
所以务必在CubeMX的Clock Configuration里确认PLLQ的实际数值,确保MCO1输出50MHz。以F407配置168MHz系统时钟为例,PLLM=8、PLLN=336、PLLP=2、PLLQ=7,那么PLLQ输出是336/7=48MHz。48MHz对LAN8720A虽然能出链路,但RMII时序已经不严格满足100Mbps的要求。要么把PLLQ调成能整除50的数值,要么干脆外接50MHz有源晶振,不折腾MCO。
我当时第一版板子为了省一颗晶振,在MCO这条路上反复调整时钟树,最后还是换了50MHz有源晶振方案,一劳永逸。如果你不是特别缺引脚和成本,我建议直接用有源晶振给PHY提供50MHz,省心很多。
4.2 ETH外设配置:RMII模式和DMA参数
CubeMX里将ETH使能后,选择RMII接口,MAC地址填入自己规划的地址,比如2C:F7:F1:08:1A:2B。PHY Address按硬件设计填,LAN8720A默认是0,如果硬件上改了地址,这里必须对应改。Speed/duplex mode如果不会配,选AutoNegotiation自动协商,让PHY自己跟交换机谈速率。
DMA参数默认即可,但有几个地方值得说明一下:
| 参数 | 值 | 说明 |
|---|---|---|
| Receive DMA Mode | Descriptor Ring | 环形描述符,接收连续性强 |
| Transmit DMA Mode | Descriptor Ring | 同上 |
| Number of DMA RX Descriptors | 4~6 | 太少丢包,太多费内存 |
| Number of DMA TX Descriptors | 4~6 | 同上 |
| Ethernet Control Field | 默认 | 一般不用动 |
| TCP/IP Checksum Offload | Enable | 由硬件计算校验和,CPU负担小很多 |
DMA描述符是ETH外设在内存里维护的一张表,告诉DMA控制器数据要搬到哪、搬到多长。STM32F407默认描述符大小是32字节,如果配置了校验和卸载,描述符里还会包含校验和状态信息。描述符数量直接决定DMA能缓存几个数据包,太小了接收溢出丢包,太大了每个描述符占用内存,8个描述符需要32字节×2个字段×8个描述符,也就是不足1KB,根本不影响,所以至少配4个。
4.3 LwIP参数配置:这里最花心思
CubeMX生成代码后,LwIP参数在lwipopts.h里体现。配置界面里几个关键的:
- Memory Heap Size:默认是某个值,我改成 100KB 左右。这个值代表可动态分配的PBUF内存总量,TCP收发窗口都从这里出。改太大RAM吃紧,改太小吞吐上不去。
- Memory Pool Size:PBUF池的大小,默认大概几十个PBUF。我用默认即可。
- TCP Window Size:TCP接收窗口,我设置为 20KB,配合LwIP的滑动窗口机制足够应付工业场景。
- TCP_SND_BUF:发送缓冲区大小,我设置为 20KB。这个值太小,应用层往TCP写数据时容易阻塞;太大占用RAM。
- LWIP_NETIF_API:如果不用多线程,关掉可以省资源。
- NO_SYS:如果不用RTOS,设为1,直接在裸机while循环里跑LwIP。
再说LwIP的核心文件处理。CubeMX生成LwIP初始化后,在ethernetif.c里有两个关键函数:low_level_input和low_level_output,这是LwIP跟ETH驱动之间的桥梁。CubeMX生成的代码默认是不带校验和卸载处理的,如果你在ETH配置里开了硬件校验和,就得在low_level_input里检查描述符的校验和状态,否则不会出错,但也没享受到硬件加速。
4.4 初始化顺序:一个看似简单却容易翻车的点
CubeMX生成的MX_LWIP_Init函数内部调用lwip_init,然后执行netif_add、netif_set_default和netif_set_up。但有一个细节很容易忽略:netif_add之后到link真正up之间,LwIP不知道底层链路状态,需要我们在ethernetif.c的low_level_init里调用PHY读取寄存器,判断link状态。
我建议初始化顺序这样安排:
- 调用HAL_ETH_Init完成ETH外设配置
- 读取PHY芯片的Basic Mode Status Register(地址1),检查bit2(Link Status)
- 如果链路正常,设置PHY的AutoNegotiation完成标志
- 调用netif_set_link_up,让LwIP感知链路状态
- 启动ARP定时器等相关任务
一个问题:如果在while循环里检测到link down,要不要调用netif_set_link_down?要。而且最好延时几秒再重新读状态,因为PHY在协商过程中会产生抖动,polling太快会把状态反复切换,导致ARP表频繁失效。
5. 代码实现:从裸机ping通到数据流畅传输
5.1 初始化代码的框架结构
CubeMX生成代码放在main.c里,结构大概是:
int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_ETH_Init(); MX_LWIP_Init(); while (1) { MX_LWIP_Process(); } }MX_LWIP_Process在LwIP里就是轮询方式处理定时器和接收。注意CubeMX生成的MX_LWIP_Process内部调用的是ethernetif_input,它会把底层收到的数据帧转换成pbuf投递到LwIP协议栈。如果你的应用里还需要周期性处理TCP重传、ARP超时等,这些也都在这个循环里通过sys_check_timeouts完成。
如果用了RTOS,可以把MX_LWIP_Process放到一个专用任务里,优先级设置成比普通应用任务更高,防止网络任务饿死。
5.2 一个裸机环境下的最小TCP服务器实现
TCP服务器是嵌入式设备最常用的网络功能。下面是一段最简实现,用raw API,不依赖操作系统:
static struct tcp_pcb *server_pcb; static err_t server_recv_cb(void *arg, struct tcp_pcb *tpcb, struct pbuf *p, err_t err) { if (p == NULL) { // 对端关闭连接 tcp_close(tpcb); return ERR_OK; } // 这里p是收到的数据,处理完后必须释放 tcp_recved(tpcb, p->len); pbuf_free(p); return ERR_OK; } static err_t server_accept_cb(void *arg, struct tcp_pcb *newpcb, err_t err) { tcp_recv(newpcb, server_recv_cb); return ERR_OK; } void server_init(void) { server_pcb = tcp_new(); tcp_bind(server_pcb, IP_ADDR_ANY, 8080); server_pcb = tcp_listen(server_pcb); tcp_accept(server_pcb, server_accept_cb); }调用tcp_recved函数很关键。LwIP的TCP接收窗口是有限的,你从回调里取走数据后,一定要调用tcp_recved告诉协议栈“我已经处理了n个字节”,它才会更新窗口,否则发送端很快就会因窗口耗尽而停止发送。
5.3 发送数据:小心指针生命周期
发送方向的坑也不少。tcp_write需要把数据拷贝到协议栈内部缓冲区,但如果数据量大于发送缓冲,需要等对方ACK后再发下一批。最可靠的方式是配合tcp_sent回调:
static err_t server_sent_cb(void *arg, struct tcp_pcb *tpcb, u16_t len) { // 可以在这里发送下一段数据 return ERR_OK; }有一点要特别注意:传给tcp_write的指针必须指向在调用期间保持有效的数据。如果你用一个局部数组,tcp_write会立刻拷贝还好;但如果数据量大到需要排队等ACK,数据先被拷贝到pbuf里,此时数据生命周期已经被LwIP接管,不会出问题。容易出问题的是你直接把某块DMA缓冲区的地址传进去,而这块缓冲区又被其他模块复用,数据就被改了。
5.4 UDP和TCP如何选
很多嵌入式项目纠结用TCP还是UDP。我的判断标准很简单:
- 远程配置、指令下发,选UDP。因为UDP无连接,状态简单,丢包重传由应用层自己做。
- 大数据量、要求顺序可靠,选TCP。TCP的乱序重组、丢失重传、流量控制是现成的。
但是UDP也有一个容易被忽视的病:接收缓冲区溢出。LwIP的UDP接收是每个PCB有自己的接收队列,如果应用层处理慢,队列会一直被塞满,后续数据包被协议栈直接丢弃,没有通知。排查时表现为“偶发性丢包”,其实不是网络丢,是处理不过来。
6. 调试实录:我从“ping不通”到稳定运行的全过程
6.1 PHY读不到ID:地址错了
第一次上电,代码烧进去后用调试器看HAL_ETH_Init返回值,发现返回HAL_ERROR。进一步读PHY寄存器,发现读出来全是0xFFFF。第一反应以为芯片虚焊,补焊之后还是不行。
后来看原理图才发现RXER/PHYAD0引脚被拉高了,PHY地址是0x01,而我在代码里用的是0x00。CubeMX里把PHY Address改成1,重新生成代码,再读PHY ID,0x0007C0F1(LAN8720A的ID)就跳出来了。这个坑很多人遇到过,提醒大家画板子时就在原理图上把PHY地址标清楚,代码和硬件对齐。
6.2 有链接但ping不通:问题在MAC地址和ARP缓存
PHY能读到ID,link状态也是up,但ping就是不通。PC端抓包发现ARP请求发出去了,但设备没有回复。
排查方法:在lwip的etharp.c里打断点,看是否有ARP请求进来。结果发现根本没有进入etharp_input。检查ethernetif.c的low_level_input,发现接收描述符里的事件标志被错误处理,导致收包后数据没传给LwIP。
修复后能回复ARP了,但ping还是通不了。继续查,发现MAC地址在CubeMX里填的和实际发送的不一致。因为有些PHY的驱动会修改MAC?不会,MAC地址完全是由HAL_ETH_SetMACAddr设置的,问题在于我初始化顺序里MAC地址设置之后有个地方又把eth->Init.MACAddr覆盖了。改成在MX_ETH_Init之后立刻设置MAC地址,并确认netif的hwaddr跟它一致,才解决。
6.3 TCP连接成功但传输一会儿就卡死:内存池耗尽了
TCP能连上,PC端能发几十KB数据,然后设备端就卡死不响应。用调试器挂上,查看内存池状态,发现PBUF池已经用光。
根因是接收回调里没有及时释放pbuf。LwIP文档里有一句话:在TCP接收回调里,当你不需要这个pbuf时,必须调用pbuf_free。我在早期代码里用了pbuf_copy把数据拷贝到应用缓冲区后忘了释放,再加上tcp_recved没调用,窗口越来越小,最后协议栈卡死。
这个问题在调试器下看内存池数值非常明显。在lwipopts.h里定义PBUF_POOL_SIZE,然后用一个定时器周期性打印memp_pools状态,就能看到数值一路下降。
6.4 偶发丢包:描述符数量战胜利于玄学
稳定运行几小时后,偶尔出现UDP丢包,PC端发的1000个UDP包,设备收到998个。开始怀疑PHY或变压器,各种硬件排查都做了,问题依旧。
最后怀疑DMA描述符太少。接收描述符默认4个,在突发流量下DMA来不及搬运,新到的包只能丢弃。把接收描述符改成8个,丢包现象明显减少。再把CXMX配置里ETH的DMA接收描述符数量调大,同时把PBUF池开到足够大,就基本不再丢了。
6.5 一个软硬件交叉的坑:交换机和直连网线
调试时还会遇到一个特别迷惑的现象:设备通过交换机跟PC通信正常,但网线直连PC就link down。原因要么是PHY没有开启Auto-MDIX(自动翻转线序),要么PC网卡没开翻转。LAN8720A默认是支持Auto-MDIX的,但如果直连时没生效,检查PHY的BCR寄存器bit12(Auto-Negotiation Enable)是否置1,以及PHY的Auto-MDIX模式是否开启。网线交叉/直通我测试时反而没遇到问题,这里提一句是建议大家在硬件验证时准备一根交叉线备用。
7. 排查工具和方法:别只靠printf
7.1 Wireshark 抓包是最有用的调试手段
无论PC端还是设备端,只要网卡支持,用Wireshark抓包能最直观看到数据流。比如PCping设备不通,抓包发现设备根本没回ARP,问题就锁定在设备端的接收路径。设备回了ARP但ICMP不回,问题可能在协议栈路由或校验和。很多情况下抓包比看代码更高效。
7.2 用CubeMX重新生成代码后要注意手动改动
CubeMX有个Bug:修改配置重新生成代码时,ethernetif.c里你手动添加的代码可能会被保留也可能被覆盖,取决于代码是放在用户代码区还是普通位置。我建议把对ethernetif.c、lwip.c的手动改动都用用户代码区包裹,或者干脆把自定义代码放到单独文件中,别放在CubeMX生成的文件里,否则某天误点生成把你一上午的修改全冲了。
7.3 关于PHY寄存器调试的一个可选工具
如果有逻辑分析仪,可以拉MDIO和MDC信号,确认MCU是否在正确地址上周期性轮询PHY状态。数据手册上MDC最高频率是2.5MHz,有的PHY可以到12.5MHz,但读数时宁慢勿快,速率不对读出来的寄存器会出错。
8. 性能优化和注意事项总结
8.1 接收路径优化:用硬件校验和卸载
配置ETH时开启了TCP/IP Checksum Offload之后,以太网帧的校验和计算由DMA硬件完成,CPU不用介入。好处是CPU占用率下降,尤其在高吞吐场景下差距非常明显。但如果同时LwIP也要计算校验和,就会做重复劳动。在CubeMX的lwip配置里,把LWIP_CHECKSUM_CTRL_PER_NETIF设为0或者按网卡区分,可以避免重复计算。
8.2 防止协议栈任务饿死
如果用了RTOS,LwIP协议栈处理任务的优先级不要低于其他业务任务,否则高优先级中断或任务持续占用CPU,网络任务就一直得不到调度,底层DMA接收队列很快填满然后丢包。我一般把网络任务放在中等优先级,周期5ms轮询一次。
8.3 关于TCP_ACK和Nagle算法
嵌入式TCP还经常遇到一个问题:小数据包发送时,Nagle算法会把小包合并成大包发送,导致对端收到数据有延迟。比如你每100ms发一个设备的温度值,只有几字节,默认Nagle会等ACK再发,延迟可能到500ms。解决方法是调用tcp_nagle_disable(pcb),让每个小包都立即发送。
8.4 功耗和射频考虑
PHY芯片在空闲时也会消耗电流,LAN8720A大概几十mA。对于电池供电的设备,要用PHY的Power Down模式,在不需要网口时把PHY拉入低功耗,需要时再唤醒。但要注意唤醒后链路重新协商需要几百毫秒到几秒,网络层要考虑这个阶段的超时重传。
9. 写在最后的几点实操体会
做这个24-ETH项目最大的收获是:以太网调试,80%的问题不是出在写代码,而是出在对硬件行为和协议机制的理解上。比如PHY地址、复位时序、时钟源、描述符数量、内存池管理,这些交互点才是真正决定系统稳不稳定的地方。
我后来在公司内部做了一次分享,主题就是用STM32CubeMX做ETH加LwIP的坑,大家反馈最有帮助的内容有两个:一是把所有关键配置项整理成一张对照表,二是把调试过程中遇到的现象、原因、解法记录下来形成排查手册。这个思路分享给大家,也可以在自己项目的README里维护一份,踩过的坑记录下来比什么都有价值。
最后再分享一个小技巧:如果你第一次做以太网项目,打板时把PHY复位引脚、PHY地址配置引脚都引到排针上,方便调试时改跳线。甚至把50MHz有源晶振的封装做成可焊可不焊的样式,一旦MCO方案行不通,还能直接改成有源晶振方案。这些小设计改动成本极低,但关键时刻能省下至少一周的折腾时间。