干嵌入式这行的,谁没被网口折磨过。你画好了板子,焊上PHY,满怀期待地把程序烧进去,结果ping不通、寄存器读出来全是0xFF、Link状态死活起不来,一通排查下来发现手册里早就写了,只是你当时没细看。RTL8201F这颗10/100M以太网PHY,在国产工业板、通信模块上用得特别多,价格便宜、供货稳,但它的坑也相当典型:参考电路怎么抄、RMII时钟怎么给、PHY地址到底是几、寄存器该怎么读,每一个环节都能卡住新手好几天。
这篇内容我按自己实际调板子的流程来写:从芯片手册怎么抓重点,到MDIO读写验证,再到手写PHY初始化,最后完整接进LWIP协议栈。不管你是刚拿到一块带RTL8201F的开发板,还是自己画的板子死活不通,照着这套方法走一遍,大概率能把问题定位到具体环节。
1. 认识RTL8201F:芯片手册应该怎么看
很多人拿到几百页的芯片手册习惯从头翻,我建议反过来:先看引脚定义、参考电路和寄存器表,再回过头看电气参数。PHY芯片本质上是MAC和网线之间的“翻译官”,它不关心IP和TCP,只负责把数字信号调制成差分信号发到网线上,同时把收到的模拟信号解调成数字信号。所以你的第一直觉应该是“这芯片跟MCU之间怎么通信”,而不是“它的DSP怎么实现的”。
1.1 芯片定位与关键参数
RTL8201F是瑞昱出品的一款单端口10/100M以太网PHY,支持MII和RMII两种MAC接口,内核电压通常工作在3.3V,I/O可以直接兼容3.3V逻辑。对我做嵌入式ARM板来说,最关心的是RMII模式,因为只需要TXD[1:0]、RXD[1:0]、TX_EN、CRS_DV、REF_CLK这几根线,比MII的16根数据线省太多IO资源,而且STM32F4/H7系列千兆MAC里面的RMII控制器正好匹配。
这颗芯片内部集成了均衡器、基带处理、编解码等模块,但软件上你完全不用关心。真正要看的是三块内容:第一是MDC/MDIO管理接口,这是CPU和PHY通信的唯一通道;第二是状态指示引脚,比如Link/Act LED,调试的时候看灯比看代码更直观;第三是控制寄存器,尤其是寄存器0(控制)、寄存器1(状态)、寄存器2/3(芯片ID),这几个是任何PHY芯片的通用基础。
1.2 引脚定义与参考电路解读
手册里会给你一张很详细的应用电路图,不要直接抄,你得先确认有没有“隐藏条件”。RTL8201F的RMII参考电路里,25MHz晶振接XI/XO,然后PHY内部生成50MHz的REF_CLK输出给MAC。如果你的MCU要求外部输入50MHz参考时钟,一定要选支持“REF_CLK Out”模式的接法;反过来,如果MCU自己提供50MHz时钟,PHY要配置成“REF_CLK In”模式,否则两边时钟对不上,数据眼图全乱。
别小看这个时钟方向的差别。我见过好几个板子把PHY的CLK_REF引脚空着,结果STM32侧RMII参考时钟没有输入,程序怎么跑都是错。正确做法是:在芯片手册里找到CLK_REF或REF_CLK相关引脚,根据你的MCU规格书里ETH_RMII_REF_CLK的输入输出要求来决定。绝大多数STM32系列用的是PHY输出50MHz给MAC,所以RTL8201F的接法就是让它自己出时钟,MCU那边只配置RMII即可。
1.3 寄存器基础与MDIO接口
MDIO是IEEE 802.3定义的管理接口,一共两根线:MDC是管理时钟,MDIO是双向数据线。每次操作发送一个帧,里面包含读/写标志、PHY地址、寄存器地址和16位数据。RTL8201F的PHY地址由PHYAD引脚的电平决定,手册会给出默认值,有些板子把PHYAD全部接地,地址就是0x00,有些通过上拉电阻设成0x01。
所以你拿到一块板子,第一件事不是查代码,而是看原理图上PHYAD引脚的接法。我建议你在初始化驱动里做一个“PHY地址扫描”:从0扫到31,读到芯片ID合法的那个就是PHY地址。这样即使原理图看漏了,程序也能自动找到。
寄存器方面,寄存器0叫BMCR,负责软复位、速度、双工、自动协商开关;寄存器1叫BMSR,里面最核心的位是bit 2,表示Link状态;bit 5表示自动协商完成。调试Link问题,先读这两个寄存器就够了。寄存器2和3是厂家ID,RTL8201F读出来有特定值,可以用来确认MDIO通信是真通还是假通。
2. 硬件电路设计:把参考电路变成能跑通的板子
你说自己做板子用官方参考电路改的,为什么还是出问题?因为参考电路只是一个“最小可用方案”,在真实环境里,电源纹波、时钟走线、地板回流都会影响PHY的工作。这个环节我不是教你怎么画PCB,而是告诉你哪些点是后续软件排查的“重灾区”,硬件上忽略它们,后面有你折腾的。
2.1 时钟:25MHz晶体与50MHz RMII参考时钟
RTL8201F的时钟有两种来源方式,一是外部25MHz晶体,二是外部直接注入25MHz或50MHz时钟。在RMII模式下,MAC侧需要50MHz参考时钟,这个时钟从哪里来必须要在硬件设计时定死,不能留到软件里翻。通常做法是:给PHY接25MHz无源晶体,PHY内部锁相环输出50MHz REF_CLK给MCU。
这里有个常见误区:有人用STM32的MCO输出25MHz给PHY,然后在RMII模式下让MAC直接使用PHY的50MHz,这种做法并不是所有板子都能稳定工作,因为MCO输出的信号质量和专用晶体比还是有差距的。如果PCB布线空间允许,尽量用无源晶体方案,少用一个MCU引脚,信号更稳定。
千万别忘了RMII接口有一个“收发同步”要求:REF_CLK必须同源,或者保证两边的频率完全一致。只要时钟源不同或者相位偏差过大,调试时会看到能读到PHY寄存器,但收发数据全错,这属于最难排查的一类问题。
2.2 复位、PHY地址与RMII控制引脚
RTL8201F的复位脚是低有效。很多MCU的ETH复位并没有严格时序要求,但踩过坑的人都知道:如果复位脉宽不够,或者复位释放后立刻去读MDIO,PHY可能还没准备好,就会读到全0xFF。所以驱动里上电后至少要延时10ms再开始MDIO操作,如果用的是MCU的GPIO控制复位,程序中先拉低、延时再拉高,之后再等一下。
RMII模式下还有个引脚叫PHYAD,它的电平不仅决定PHY地址,有些PHY还会复用做其他功能。RTL8201F设计上要仔细看手册里PHYAD[4:0]的默认值和内部上拉/下拉。如果你板子上PHYAD引脚悬空,很容易得到非预期地址,这也就是为什么要做地址扫描。
RMII的控制引脚里,TXD0/TXD1、TX_EN、RXD0/RXD1、CRS_DV、REF_CLK这7根线在MCU和PHY之间必须等长走线,尤其是REF_CLK,它作为同步时钟,如果比数据线长太多,会直接导致采样时序不正确。我甚至遇到过只在低温下丢包的板子,最后查出来就是REF_CLK走线绕了一圈,温度一低信号边缘变差,TIMING就不够了。
2.3 电源滤波与PCB走线
PHY芯片的电源纹波直接影响信号质量,尤其是网络变压器到PHY的这一段。RTL8201F的VDD各个引脚旁边必须放0.1uF陶瓷电容,靠近引脚放置;如果有1.8V或2.5V内部稳压输出脚,也别忘了对应电容。我在实际调试中多次遇到“Link时好时坏”的板子,示波器抓电源确实没大纹波,但用近场探头一放,发现PHY芯片上方有很强的时钟辐射,最后是在CLK_REF输出串了一个22欧姆电阻解决。
网络变压器到RJ45之间是差分走线,阻抗控制在100欧姆左右;PHY侧到变压器之间也是差分对,但距离越短越好。如果风枪焊盘间距太近,相邻引脚粘连或者沾锡,板上可能没短路但MDIO信号被干扰,这种现象可以通过读寄存器恢复出正确ID来判断,不过能读到ID不代表硬件没问题。
3. 驱动移植前的准备:先把PHY点亮
在碰LWIP之前,我强烈建议你先做一个极小的测试工程:串口打印+MDIO读写+延时,把PHY当作一个普通I2C类的芯片来调试。这一步能排除八成的问题,而且逻辑极简,出错也好定位。很多人直接把ST官方的LWIP例程改一改烧进去,出了问题不知道是PHY没起来还是MAC没配置对,最后谁也救不了你。
3.1 用串口打印日志搭一个最小调试框架
最简单的方式就是用STM32CubeMX生成一个带串口和GPIO的基础工程,然后把ETH的引脚在代码里手动初始化,或者干脆用CubeMX把ETH外设也一起开起来,但不要勾选LWIP中间件。这样你得到的是一个可以点灯、打串口的裸机工程,先把RCC时钟、GPIO复用配置好。
串口日志建议做一个带时间戳或者步进序号的小宏,例如在每一步操作前后打印“step 1: reset PHY”和结果。别觉得土,实际调试时你靠的就是这些日志判断卡在哪一步。如果你要显示结构体变量,在Keil调试模式下把变量拖进Watch窗口,但要确认当前编译优化级别和变量作用域,否则看到的值会是“optimized away”,容易被误导。
这个最小工程,我会编译下载后先串口打印“PHY test start”,然后一颗LED每隔500ms翻转。如果你连LED都不闪,那就先把基础工程调通,再谈PHY。好的,假设你的基础工程没问题,下一步就是MDIO读写。
3.2 实现MDIO读写并扫描PHY地址
在STM32的HAL库中,ETH外设提供了两个现成的函数:HAL_ETH_ReadPHYRegister和HAL_ETH_WritePHYRegister。它们的本质是通过ETH_MAC_MDIO寄存器配置时钟分频,然后发起MDIO读写时序。注意HAL_ETH_ReadPHYRegister的第一个参数是句柄,第二个参数是PHY地址,第三个是寄存器地址。
你可以这样扫描PHYAD:
uint16_t id1, id2; uint8_t addr; for (addr = 0; addr < 32; addr++) { if (HAL_ETH_ReadPHYRegister(&heth, addr, 2, &id1) == HAL_OK && HAL_ETH_ReadPHYRegister(&heth, addr, 3, &id2) == HAL_OK) { if (id1 != 0xFFFF && id1 != 0x0000) { printf("found phy at addr %d, ID1=0x%04X, ID2=0x%04X\r\n", addr, id1, id2); } } }如果所有地址读出来都是0xFFFF,先查硬件:MDC/MDIO有没有接反,复位脚有没有被拉低,PHY供电是不是正常。如果只有特定地址读出非FF,那就记下这个地址,后续LWIP配置就填它。读出来的ID1和ID2和手册值对比一下,完全一致说明PHY访问链路是通的,这是你后续所有调试的基础。
3.3 验证基本寄存器:芯片ID与模式寄存器
RTL8201F的ID通常可以在寄存器2和3里读到,但不是所有版本的手册都会写得很直白。你可以这样判断:读到的ID值既不是0xFFFF也不是0x0000,并且重复多次读结果稳定,基本可以认定PHY回应你了。如果ID值偶尔变化,有可能是MDIO信号质量差,或者电源不稳定。
接下来读寄存器0和1。寄存器0的默认值一般是0x1000,也就是开启了自动协商但还没完成。我们手动设成0x3000或者0x3100,开启自动协商并允许100M和10M全双工/半双工。然后反复读寄存器1,看bit 5有没有从0变1,bit 2有没有从0变1。Bit 5是自动协商完成,bit 2是Link状态。
这一步花不了多少时间,但它决定了你后续LWIP能不能直接跑通。如果Link状态一直起不来,别急着写网络协议栈,先把自动协商搞定。
4. 手写PHY初始化:从软复位到Link Up
当你已经能用MDIO读到ID并且稳定时,就可以把PHY初始化写成一个模块了。这一节我给出一个“极简但完整”的PHY初始化序列,它不依赖具体MAC库,只通过MDIO操作寄存器。看懂这个序列,你再去套ST的Ethernet_Link_Init或者LAN8720A的驱动就很容易了。
4.1 软复位与基本配置寄存器
第一步是软复位。往寄存器0写0x8000,然后再读寄存器0,直到bit 15清0。注意,有些芯片的软复位需要一点时间,循环里加个延时,比如每次循环delay 10毫秒,最多等2秒,超时就打印错误。
uint16_t bmcr; HAL_ETH_WritePHYRegister(&heth, phy_addr, 0, 0x8000); for (int i = 0; i < 200; i++) { HAL_ETH_ReadPHYRegister(&heth, phy_addr, 0, &bmcr); if ((bmcr & 0x8000) == 0) break; HAL_Delay(10); }复位以后,不要急着配置速度。先设置自动协商使能,同时设置默认速度为100M全双工作为回退保障:
HAL_ETH_WritePHYRegister(&heth, phy_addr, 0, 0x1000); /* 自动协商使能 */如果希望强制100M全双工,直接把寄存器0写成0x2100。但作为首次调试,我建议先用自动协商模式,因为网线另一头可能是交换机可能是电脑,强制模式很容易不通。
4.2 状态轮询与Link Status判断
PHY初始化不只是“写几个寄存器”,你要等它自己完成协商。最朴素的做法是周期读寄存器1,观察bit 2。我一般会写一个函数:
int phy_wait_link(uint8_t addr, uint32_t timeout_ms) { uint16_t bmsr; uint32_t start = HAL_GetTick(); do { HAL_ETH_ReadPHYRegister(&heth, addr, 1, &bmsr); if (bmsr & 0x0004) return 0; /* Link Up */ HAL_Delay(10); } while (HAL_GetTick() - start < timeout_ms); return -1; }Link Up之后,再读寄存器4和5,可以看到本端和协商对方的能力。如果bit 5是1,说明自动协商完成,链路的速率和双工模式已经确定了。此时再读寄存器0,bit 13表示速度,bit 8表示双工状态,你就能确认它协商到了100M全双工。
我在实际项目里会把“由于网线没插”、“对方不是千兆口”、“线序错误”这类原因都归结到这个阶段。你只要打印出BMSR和BMCR的值,基本就能推断出物理链路是否健康。
4.3 RTL8201F的特有寄存器与百兆模式确认
除了标准0到6号寄存器,RTL8201F还有一些扩展寄存器,用于省电、环回、中断状态等。第一次调试不建议碰它们,重点确认默认上电状态就是正常工作模式。有些PHY上电后会进入isolated模式,这时RX信号与MAC断开,Link状态依然可以起来,但网卡MAC收不到包。这种情况比较诡异,建议直接在寄存器0里把bit 10清0,并且确认bit 6为0(不是power down)。
如果你想测试PHY内部是否正常,可以开启环回:寄存器0 bit 14写1,这样PHY会把发出去的数据直接收回来。你可以在不插网线的情况下做MAC环回测试。但注意,这跟实际通信还不同,别拿环回测试的结果当成“网口已经通了”的依据。
5. 把PHY接进LWIP驱动:ethernetif移植细节
PHY已经Link Up了,接下来才是真正的“驱动移植”:把LWIP跑起来。LWIP要正常工作,底层网卡驱动必须提供几个能力:初始化硬件、发送一个数据包、接收一个数据包、处理中断和轮询状态。STM32的HAL库已经把MAC和DMA这部分做了封装,你要做的是补上PHY地址、复位时序、资源申请和中断入口。
5.1 lwIP需要网卡提供什么
LWIP有一套netif接口,每个网络接口对应一个struct netif。你要实现的最底层函数是:
static err_t low_level_init(struct netif *netif); static err_t low_level_output(struct netif *netif, struct pbuf *p); static void low_level_input(struct netif *netif);low_level_init里要做的事情包括:设置MAC地址、配置DMA描述符、初始化PHY、标记netif为UP。low_level_output负责把LWIP的pbuf链表里的数据拷贝到DMA发送缓冲区,或者利用零拷贝把描述符指到pbuf数据区,最后触发发送。low_level_input则是在收到数据包后,把DMA收到的数据组成pbuf,上报到LWIP。
如果你用STM32CubeMX生成LWIP中间件,它会自动生成ethernetif.c,大部分逻辑已经写好了。你真正要改的只有三个地方:PHY地址、PHY初始化时需要的延时、以及是否需要处理额外中断。CubeMX生成的代码默认使用LAN8720A的寄存器定义,把那些宏改成RTL8201F对应的地址即可,或者干脆删掉,用我们自己确认过的寄存器值。
5.2 基于HAL库的ETH初始化和中断
在裸机HAL工程里,ETH初始化由HAL_ETH_Init完成,它会读MAC地址、设置RMII/MII模式、设置DMA描述符。这个函数调用前,你需要先填充ETH_InitTypeDef结构体,包括PhyAddress、MediaInterface、AutoNegotiation、DuplexMode和Speed。
CubeMX生成的代码会在SystemInit阶段调用HAL_ETH_Init,但PHY靠的是外部引脚复位,所以你在low_level_init里要先把PHY复位拉起来,然后调用HAL_ETH_Init,再调用前面写的phy_init和phy_wait_link。顺序错了很常见:先初始化PHY和先初始化MAC没有绝对先后,但一定要保证PHY已经能正常响应MDIO了再让MAC进入Link状态。
中断处理上,STM32的ETH有两个中断号:ETH和ETH_WKUP。普通项目中你只需要接收DMA中断。在中断服务函数里清除标志位,然后调用HAL_ETH_IRQHandler,HAL库内部会帮你清理挂起的DMA中断。如果是裸机轮询,可以在主循环里周期调用HAL_ETH_GetReceivedFrame_IT然后处理low_level_input,但轮询效率低,建议还是开中断。
5.3 low_level_output与low_level_input实现要点
low_level_output的核心是遍历pbuf链表,把数据按32位对齐拷贝到发送缓冲区。很多初学者直接memcpy一次性拷贝整个pbuf,没问题,但要注意LWIP的pbuf可能是非连续的,不能用pbuf->payload直接作为起始地址拷整个长度,得手动遍历。
简单示例:
size_t framelen = 0; for (struct pbuf *q = p; q != NULL; q = q->next) { memcpy((uint8_t *)&buff[framelen], q->payload, q->len); framelen += q->len; }拷贝完成后,把帧长写到DMA描述符的controlBufferSize里,更新描述符状态为ETH_DMATXDESC_FIRST_SEGMENT和ETH_DMATXDESC_LAST_SEGMENT,然后触发发送。
接收方向,low_level_input用DMA描述符里的status判断是否收到完整帧,取出长度后,分配一个pbuf,用pbuf_alloc和pbuf_take把数据从DMA缓冲区复制到pbuf,然后netif->input(pbuf, netif)把数据交给上层协议栈。
这里最容易出错的是DMA描述符数量不够,导致接收流程卡死。我习惯把接收描述符数量设成8个起,发送描述符4个起。描述符不足时,接收中断会反复触发但取不出数据,看起来就像是网络卡死,实际是描述符没及时回填。
6. 常见问题与排查技巧
最后把我在实际项目里踩过、帮别人排查过的典型问题整理成一张速查表。这些问题非常有共性,哪怕你用的是别的PHY芯片,排查思路也一样,只是寄存器地址和位定义可能略有不同。
6.1 MDIO读写异常速查表
| 现象 | 可能原因 | 排查步骤 |
|---|---|---|
| 所有PHY地址读出来都是0xFFFF | MDIO线接反/虚焊,PHY没上电,复位拉住 | 用万用表测PHY引脚电压,示波器抓MDC波形 |
| 只有某个地址能读到非FF | PHYAD引脚电平导致地址偏移 | 对照原理图确认PHY地址,驱动里扫描0~31 |
| 读出的ID值偶尔变化 | MDIO信号质量差,电源纹波大 | 降低MDC时钟频率,检查MDIO上拉电阻,补滤波电容 |
| 寄存器能读但不能写 | MAC侧没有正确配置MDC时钟分频 | 计算AHB时钟和MDC时钟比例,HAL内设置正确分频 |
我调试时会在主循环里周期性打印寄存器1的值,观察Link状态是否来回跳变。如果Link状态一直跳,多半是变压器中心抽头偏置不对、网口隔离没做好,或者差分对走线太长导致眼图质量差。
6.2 Link Up但网络不通
Link Up只代表物理层通了,不代表协议栈通。看到Link灯亮但ping不通,先按顺序检查:先看电脑网卡IP和设备IP是否在同一网段,再查MAC地址是否冲突,最后抓包看ARP有没有响应。如果ARP响应有但Ping一直超时,多半是LWIP层的接收路径有问题。
一个典型的坑是netif->flags里没设置NETIF_FLAG_ETHARP和NETIF_FLAG_UP,或者没调用netif_set_link_up。很多从旧版LWIP移植的代码会因为宏定义变化在编译时直接通过,但运行时逻辑完全错误。你可以在日志里加入网卡状态打印,确认netif_is_up返回真。
如果确认网卡状态正常但收不到包,用Wireshark在电脑上抓包,看是否发出来但PC没收到,还是PC发了但板子没回应。这样可以快速把问题隔离到发送路径还是接收路径。
6.3 低吞吐率与丢包
吞吐率低往往不是PHY的问题,而是DMA描述符和内存碎片。LWIP的PBUF_POOL_SIZE配置太小,或者PBUF_POOL_BUFSIZE填的太大,都会导致接收时无法申请pbuf,然后DMA缓冲区被覆盖,整包数据丢得莫名其妙。
如果你发现UDP测试经常丢包,先把LWIP的内存池调大,尤其是PBUF_POOL。再检查ETH DMA的中断优先级,接收中断优先级低于其他中断时,频繁打断会导致DMA接收描述符耗尽。STM32的ETH中断优先级我习惯配成比SysTick低、但比普通外设高,不能设成最低,否则高负载下会丢帧。
还有一个被忽略的点是发送描述符的TxBufferSize字段必须按描述符格式写,高位表示描述符链的segment标志,低位才是数据长度。一字节写错,轻则发送不出去,重则DMA总线错误触发hardfault。调试时可以在发送描述符回调里打印状态寄存器的值,对照参考手册的位定义检查。
最后再分享一个小技巧
真正调通之后,别忘了把PHY地址和寄存器配置整理成头文件,而不是散落在主程序里。因为大多数项目不是只有一块板,当你从评估板换到自己量产的板卡时,PHY地址、复位脚、参考时钟方向这些都会变,有一个集中管理的地方,移植工作量会小很多。
我自己的习惯是会把整个过程拆成三段:第一段验证MDIO可读ID,第二段验证Link Up,第三段才跑LWIP。每一段都有一个独立的小工程,互不依赖。这样如果哪一天项目又出问题,我能快速定位是硬件还是软件、是物理层还是协议栈,而不用把整套代码重新在线调试一遍。调试RTL8201F这件事,最忌讳的就是“一把梭”,把PHY和协议栈一起调,到最后出了问题谁也救不了你。按这个流程来,网口调通只是时间问题。