ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

嵌入式以太网驱动开发实战:从MAC/PHY到DMA描述符与调试

嵌入式以太网驱动开发实战:从MAC/PHY到DMA描述符与调试 写这一期之前我刚从一堆网线、示波器探头和反复翻寄存器手册的状态里爬出来——连续三天在调一块板子的Ethernet驱动link灯能亮可就是ping不通网关最后定位到一个谁都没注意的DMA描述符对齐问题。嵌入式驱动开发里Ethernet算是比较有代表性的综合活既要懂芯片手册里的寄存器布局又要理解DMA描述符、中断处理、PHY的MDIO管理还得能配合协议栈把活干完。这一期我把以太网驱动从硬件分工、数据结构、初始化流程到收发路径和问题排查按实际开发顺序完整梳理一遍供正在做嵌入式Linux或者裸机网络设备的工程师参考也适合准备嵌入式相关面试的同学查漏补缺。1. 以太网驱动到底在“驱动”什么先看硬件分工很多刚接触网络驱动的朋友第一反应是打开TCP/IP协议栈的源码从套接字一路读到网卡。方向没错但驱动开发的视角恰恰相反——我们不需要从零实现协议栈而是要把硬件控制器“伺候”好让上层协议能正常收发数据。这一步的前提是把MAC、PHY、MII/RMII这些概念彻底分清。1.1 一条网线背后的三层协作以太网硬件通常分成两块MAC媒体访问控制层和PHY物理层收发器。以嵌入式平台最常见的情况来说MAC集成在SoC或MCU内部比如STM32的Ethernet MAC、NXP i.MX系列的FEC或者全志、瑞芯微平台上的GigE MACPHY则是一颗独立芯片比如LAN8720A、KSZ8081、RTL8211等就放在板子网口附近。分工上MAC负责数据链路层的核心工作组帧、地址过滤、CRC校验以及和DMA控制器配合完成的buffer搬运。PHY负责物理层的脏活累活把并行数据变成差分信号发到网线上接收时做信号恢复、解码还要处理协商速率/双工模式这类链路管理。PHY和MAC之间的数据通道最常见的是MII、RMII、GMII、RGMII这几种。这些接口的差异直接影响你的驱动代码怎么配置引脚和时钟。MII4位数据线125MHz下跑100Mbps总共16根信号线占引脚多老一点的设计里常见。RMII把数据线砍到2位时钟固定50MHz不论10M还是100M都复用节省引脚大量低成本方案在用。GMII/RGMII千兆场合RGMII用DDR方式在上升沿和下降沿各采一次数据引脚少但是时序讲究看到板子上PHY和MAC之间有串阻、时钟要求严格多半是这一类。驱动工程师真正需要关心的是MAC和PHY之间还有一条MII管理接口通常叫MDIO。它只有两根线MDC时钟和MDIO数据用来读写PHY的寄存器。没有这条通道你就没法知道PHY当前协商成什么速率、link有没有起来更没法做软复位和配置。可以这样理解数据通道是高速公路MDIO就是路政管理电话平时不运货但车能不能跑、跑多快全靠它。1.2 两种路线自带MAC还是外置协议栈芯片嵌入式项目里有两种常见的以太网实现路线搞清楚自己属于哪一种后面所有步骤的取舍都会不一样。第一种是SoC自带MAC控制器 外置PHY芯片。这是最主流、也是这一期重点讲的方案。驱动要做的就是初始化MAC寄存器、配置DMA、管理PHY。灵活度高吞吐上限取决于MAC和DMA设计适合跑Linux、需要大流量的场景。第二种是外置MACPHY集成芯片比如W5500、DM9051、LAN9303这类的用SPI或并行总线跟MCU连接。厂家通常已经把MAC层、甚至TCP/IP协议栈的一部分封装好了你只需要按厂家提供的驱动把SPI初始化好然后按协议栈接口调用。开发快但性能上限和灵活性都受限制工业控制里也不少。还有一种更轻的方案比如ENC28J60这类带MAC/PHY的SPI网卡芯片在老项目里很常见。但在Linux下这类芯片的驱动性能和稳定性都不如自带MAC的方案新项目我通常不推荐。选型时要明确一点真正决定驱动开发工作量的是MAC、DMA、中断这套东西而它们恰好在SoC内部。2. 驱动骨架搭建的关键数据结构与设计思路以太网驱动的代码骨架不像GPIO驱动那样“配置完寄存器就完事”。它至少包含三块内核里硬件描述符的管理、中断/轮询的取舍、以及和上层协议栈的接口。想要跑得稳设计阶段就得把这些数据结构定清楚。2.1 裸机方案与Linux驱动框架的取舍如果你在裸机环境做以太网比如用STM32H7自带的MAC做一个小设备驱动其实就是一个状态机主循环里检查DMA中断标志、把收到的帧扔给应用层回调、把应用层要发的帧塞进发送描述符并触发发送。结构简单但所有收包、断包、内存分配都要自己处理。如果跑Linux事情就模板化很多也复杂很多。Linux内核里网卡驱动要注册一个net_device结构体里面填上net_device_opsstatic const struct net_device_ops eth_netdev_ops { .ndo_open eth_open, .ndo_stop eth_stop, .ndo_start_xmit eth_start_xmit, .ndo_set_mac_address eth_set_mac_address, .ndo_do_ioctl eth_ioctl, };ndo_open对应ifconfig eth0 upndo_stop对应downndo_start_xmit是协议栈把skb交给驱动的入口。这些回调函数相当于硬件和内核之间的“翻译官”。新手最容易犯的错是一上来就急着写ndo_start_xmit却忽略了ndo_open里必须完成的时钟、GPIO复用、MAC软复位、PHY初始化这些基础工作。我建议的做法是不管用不用Linux都先把“硬件操作”和“业务逻辑”分成两层。硬件层只干四件事写寄存器、搬数据DMA、管中断、读PHY状态业务层管链路状态上报、收包分发、发包入口。这样以后从裸机迁到Linux或者换一颗PHY改动量都能控制在局部。2.2 DMA收发描述符环形队列以太网驱动的核心数据结构是DMA描述符。这是很多面试“八股文”爱问的点也是实际调试中最容易出诡异bug的地方。所谓描述符本质上是一块内存里面存了数据缓冲区的地址、长度、控制标志和状态标志。MAC的DMA引擎拿到描述符才知道把收到的数据写到哪个内存地址或者要从哪个内存地址搬数据发出去。大多数控制器的收发描述符都组织成环形队列初始化时分配固定数量的描述符DMA逐个取用用完回到头部。以发送方向为例初始化一个发送环形队列的伪代码如下for (i 0; i TX_RING_SIZE; i) { tx_buf[i] dma_alloc_coherent(dev, TX_BUF_SIZE); tx_desc[i].addr tx_buf[i]; tx_desc[i].len 0; tx_desc[i].ctrl DESC_OWN | DESC_FIRST | DESC_LAST; } MAC_TX_RING_BASE (uint32_t)tx_desc;注意几个关键点。第一个是所有权标志通常叫OWN位描述符初始化时归CPU所有软件填好数据后清掉OWNDMA才敢动这块缓冲区DMA搬完数据后会把OWN重新置回给CPU表示“你的包我发完了”。OWN位没搞对表现就是发了一包之后队列卡死或者数据被莫名改写。第二个是缓冲区长度如果协议栈交给驱动的skb只有一个缓冲区但数据超过了你定义的单缓冲区长度就需要启用SG散聚模式或者干脆把数据拷贝到自己的连续缓冲区里。稳妥做法是先按MTU 1500加帧头帧尾对齐避免首包就踩坑。第三个是地址对齐很多控制器的描述符要求按4字节或8字节对齐乱放结构体数组会踩到之前我说的连续调三天的坑。接收方向的描述符逻辑类似区别是CPU把缓冲区“借”给DMADMA收到帧后把数据写进去并置OWN给CPU驱动在中断里轮询到OWN为CPU所有就知道有一包新数据到了。2.3 中断收包与NAPI机制收包路径的中断设计决定了你会不会被疯狂的中断淹没。最简单的方案是每个接收帧触发一次中断但100Mbps线速下小包可以到每秒几千甚至上万包CPU会花大量时间在打断和恢复上。Linux下成熟的方案是NAPI。核心思路是第一次收到接收中断后先把该网卡的中断关掉然后转去轮询DMA描述符连续取走一批包直到本轮没有更多包了再重新开中断。这能有效避免中断风暴同时保证低延迟。在驱动代码里NAPI对应netif_napi_add注册的回调poll()收包处理后返回剩余预算内核决定是否继续调度。裸机环境下没有NAPI但思路完全可以借鉴。我的习惯是接收中断里只置一个标志位、清理中断源然后在主循环或RTOS任务里去批量处理描述符队列。不要尝试在中断里做memset、memcpy这种耗时操作更不要在中断里直接调用可能阻塞的应用回调。3. 从复位到联网MAC初始化和PHY配置的实操细节驱动跑起来的第一步不是收发数据而是让MAC和PHY都进入正常状态建立链路。这一步涉及的时序和寄存器配置琐碎却是很多“link time out”问题的重灾区。3.1 MAC初始化一定要有“复位→配置→使能”顺序MAC控制器内部状态机复杂直接上手配寄存器容易出随机问题。稳妥的顺序是使能外设时钟和引脚复用。这里最容易错以太网专用引脚和调试串口、SDIO共用引脚时不查原理图就配置轻则功能错乱重则烧IO。将MAC置于软复位状态。一般有SWR位置1后等待自清零。复位后MAC内部状态回到已知初始状态。配置MAC工作模式。比如帧过滤接收单播、广播、多播、CRC处理、全双工、流控使能、速率相关设置。写MAC地址到MAC地址寄存器。我看到不少板子出厂MAC都烧成一样的多块板同时联网会出事实际项目中建议用UID生成或从EEPROM读取。开启接收和发送使能DMA开始跑。最后才做PHY的访问和链路探测。PHY启动需要时间驱动里加个小延时或者轮询等待能少很多麻烦。有个细节值得专门说MAC的自协商信息和速率双工并非MAC自己决定而是PHY协商完成后MAC需要按PHY的结果配置。有些芯片是MAC自动读取PHY状态有些需要驱动显式读取PHY寄存器0基本控制寄存器和寄存器1基本状态寄存器把速率和双工值写进MAC配置寄存器。如果MAC和PHY的速率双工不一致会出现“link up但收发全丢”的情况我在调试中遇到过不止一次。3.2 PHY芯片的复位与读寄存器关键时序PHY芯片虽然只是一个“物理层收发器”它的配置复杂度一点不比MAC小。最常见的PHY寄存器是寄存器0和寄存器1但实际项目还会用到控制LED的寄存器、支持环回测试的寄存器、识别厂商的ID寄存器。我强烈建议拿到PHY数据手册后先单独写一个小函数验证MDIO读取是否有问题再继续往下做。MDIO读操作的代码模式基本是这样static int phy_read(struct eth_priv *priv, uint8_t phy_addr, uint8_t reg) { uint32_t val; /* 等待MDIO总线空闲 */ while (readl(priv-base MAC_MDIO_CTRL) MDIO_BUSY) ; val (phy_addr MDIO_PHYADDR_SHIFT) | (reg MDIO_REGADDR_SHIFT) | MDIO_READ | MDIO_BUSY; writel(val, priv-base MAC_MDIO_CTRL); /* 等待读完成 */ while (readl(priv-base MAC_MDIO_CTRL) MDIO_BUSY) ; return readl(priv-base MAC_MDIO_DATA) 0xffff; }注意PHY地址不是MAC地址它是MDIO总线上给每颗PHY编的地址常见是0x00~0x1F由PHY芯片的ADDR引脚决定。板子上PHY地址取反、悬空控制不对会导致所有读操作返回0xFFFF或者假数据。然后是PHY上电复位。硬复位一般拉低PHY的RESET引脚至少10~20ms再释放软复位则是向PHY寄存器0写复位位但PHY启动自协商需要一点时间很多PHY启动要50ms甚至更久驱动里最好加一个循环等待“链路up”的超时逻辑而不是只等一次固定的延时。3.3 读取PHY状态判断链路是否正常判断链路是否正常的标准动作是读PHY寄存器1基本状态寄存器的bit 2 Link Status。但要注意这个位在链路断开时可能不会自动清零真的判断需要用“读两次延时”的方式先读一次清除旧状态等200ms再读一次这时得到的值才可靠。如果PHY还在自协商中寄存器0的bit 5 Autonegotiation Complete没置位链路状态也会不稳定。调试时可以做一个带调试串口的循环每隔500ms打印PHY ID、link状态、协商速率在整个驱动还没跑起来的时候这是最直观的探测手段。等到PHY链路up之后MAC主机端也要做一次同步。Linux下通过netif_carrier_on(dev)通知协议栈“载波已检测到”否则即使寄存器已经把PHY配置好了内核也会认为网线没插丢包率100%。我见过不少板卡ifconfig eth0显示的UP状态正常但ip link里state一直显示DOWN问题就出在这里。3.4 开发阶段用NFS挂载根文件系统铺路板子上的以太网驱动在Linux下跑通后开发效率会有质的提升。最典型的做法是用NFS做根文件系统挂载省去反复烧写Flash和SD卡的麻烦。具体流程大致是开发机上先配置好NFS服务导出你的根文件系统目录板子启动时由bootloader设置内核启动参数比如root/dev/nfs nfsroot192.168.1.10:/home/user/rootfs,v3 ip192.168.1.20:192.168.1.10::255.255.255.0::eth0:off但这里有几个坑。第一NFSv4之后默认端口和认证方式有变化很多老项目的根文件系统脚本和内核配置还停留在v2/v3。如果你的开发机新装的NFS服务默认只开v4而板端内核里配置了v3挂载就会一直卡在“VFS: Unable to mount root fs”。解决办法是在NFS服务端配置里显式启用v3版本同时确认内核开启了CONFIG_ROOT_NFS和CONFIG_NFS_V3。第二板端挂NFS根文件系统时网络必须一开始就可用而网络驱动又要依赖根文件系统里的固件、配置这就容易陷入循环。所以这个阶段务必把以太网驱动编进内核而不是模块否则会出现挂载失败再加载驱动的鸡生蛋问题。第三NFS调试模式下板子断电前先正常umount或者干脆别省这一步否则开发机上会残留大量未写回的数据和半残连接影响后续文件系统的一致性。4. 收发路径如何落地发送、接收与调试验证初始化完成后驱动就进入日常的收发工作循环。这一阶段的核心任务就是把协议栈想发的包送出去把网线上的帧收进来交上去同时保证不丢、不乱、不卡。4.1 发送路径从协议栈到网线Linux下协议栈准备好一个sk_buff后会调用ndo_start_xmit。驱动要做的第一件事不是直接把skb的虚拟地址填进描述符因为DMA看到的是物理地址。在需要一致性DMA映射的平台上必须用dma_map_single拿到skb数据区的物理地址和方向再把地址写进描述符。发送完成后还要dma_unmap_single回收否则一次次泄漏最终会导致DMA无法分配缓冲区表现为系统跑一会儿后网络彻底不可用。发送描述符填好之后写MAC的发送触发寄存器DMA就会自动搬数据。这里常犯的错是在上一个描述符还没有被DMA搬完时就直接改写同一个描述符。环形队列就是靠“头指针追赶尾指针”工作的驱动必须维护一个“下次可用描述符”的索引并且在发送完成中断里回收已经发送完的描述符。发送完成中断的回收逻辑做成批处理更好一次中断处理完把所有状态为“已完成”的描述符一次性回收并统计比一个一个处理高效得多。同时注意在发送队列满时ndo_start_xmit要返回NETDEV_TX_BUSY让协议栈稍后重试而不是直接把包丢掉——丢包在低速调试时看不出来高负载下一测吞吐就露馅。4.2 接收路径中断里如何交包接收路径的代码驱动工程师必须很谨慎。因为收包中断里做的事情越少系统越稳。裸机环境下我的建议是中断服务函数里只做三件事——读中断状态寄存器确认是接收事件、清掉中断标志、置一个“收到新包”的软件标志然后回到主循环里调用eth_rx_poll()去扫描接收描述符。接收描述符的OWN位一旦表明CPU拥有数据就表示DMA已经写完一包完整的帧。在Linux下NAPI的poll回调里会做这样几件事int eth_poll(struct napi_struct *napi, int budget) { while (budget-- 0) { desc rx_ring_next(priv); if (!(desc-status DESC_OWN)) break; skb build_skb(desc-buf, RX_BUF_SIZE); skb_put(skb, desc-actual_len); netif_receive_skb(skb); desc-status DESC_OWN; /* 还给DMA */ } /* 仍然有包没处理完则继续 */ if (work_done budget) napi_complete_done(napi, work_done); return work_done; }这里有一个很容易被忽略的细节接收缓冲区在DMA写入数据的时候CPU不能乱动所以不能用skb_copy_data直接去读必须先让DMA“交还”所有权。实际驱动中通常用napi_alloc_skb配合dma_sync_single_for_cpu来保证DMA和CPU缓存一致。很多平台光看描述符状态没问题但收包内容是旧数据或者乱码多半就是缓存一致性问题。遇到这种问题如果平台支持cache bypass的DMA配置优先用它否则在每次读接收缓冲区之前做一次dma_sync_single_for_cpu在把缓冲区还给DMA之前做一次dma_sync_single_for_device虽然有点性能损耗但稳定第一。4.3 用吞吐测试和抓包验证驱动如果你的驱动能ping通网关恭喜基础跑通了。但ping通不代表驱动没问题。我会用下面三个维度来验证第一用iperf3测吞吐。TCP测试填满发送窗口UDP测试则以固定速率打流。观察吞吐曲线是否平稳有没有周期性的掉0。如果TCP吞吐远远低于预期比如千兆卡只能跑到300Mbps优先怀疑中断调度、描述符数量不够、或者缓冲对齐导致DMA效率低。第二用tcpdump抓包看行为。抓包时注意混杂模式下收包是否正确比如有没有大量重复帧、CRC错误帧、长度异常帧这些都是MAC/DMA配置错误的线索。只抓不发或者只发不收也能快速定位是发送路径还是接收路径的问题。第三做长时间稳定性测试。我习惯让设备跑24小时iperf3双向混合打流同时定期检查ifconfig里的dropped、errors、overruns计数。如果计数乱涨接下来就进入问题排查环节。5. 典型问题排查实录与工具速查做网络驱动最终绕不开的就是排障。很多东西看起来玄乎归根到底还是并发、时序和配置三类问题。我把自己这几年在Ethernet驱动调试中遇到过最多的高频问题整理成下面的表格和要点直接在项目里照着查效率会高很多。现象优先检查点常用手段link灯不亮/链路状态一直DOWNPHY供电、时钟、复位时序RMII参考时钟是否到位万用表测PHY供电示波器看时钟读PHY ID寄存器link能亮但ping不通网关MAC与PHY的速率双工不一致MAC地址错乱DMA没使能读PHY寄存器0/1核对MAC速率配置抓包看是否有ARP请求丢包严重、吞吐不稳描述符个数太少中断风暴内存碎片增加描述符深度使用NAPI开大接收队列偶发死机/卡死描述符所有权标志错乱DMA和CPU缓存不一致打印描述符状态检查desc-status是否volatile检查DMA映射方向短包能通、大包不通MTU协商/分片问题接收缓冲区长度不够检查MTU加大接收缓冲区至1522字节以上多设备同MAC导致丢包出厂MAC地址相同从UID/EEPROM生成MAC并配置5.1 链路始终起不来怎么办链路起不来也就是link灯不亮或者ethtool eth0显示no carrier是最常见的初调问题。按顺序排查量PHY的供电和RESET引脚电平确保不在复位状态确认MAC和PHY之间的接口类型和引脚配置一致RMII常见问题就是REF_CLK没有供上这个时钟可以来自MAC的输出也可以来自外部有源晶振必须先确定原理图设计的是哪种然后用MDIO读PHY寄存器能读到非全F的ID值说明管理通道OK。如果读到全0xFFFF基本是PHY地址不对或者MDIO管脚没复用对。我遇到过最刁钻的一个案例是PHY芯片的时钟引脚悬空导致内置时钟电路没工作。示波器量不到MDC有信号但MDIO管脚电平异常。最后是换了一颗PHY芯片才定位到时钟问题这种硬件层面的坑只能靠接地气地翻原理图和量信号。5.2 开发板忘记密码或网络配置失败的处理思路调试网络驱动时也会遇到系统起不来、串口又没接的情况不少人会卡在“忘了进入系统的密码”这种问题上。这个阶段其实有个非常实用的思路依赖NFS挂载根文件系统启动时内核启动参数可以直接指定init/bin/sh绕过正常登录流程。但这只是救急。真正建议的做法是在调试阶段确保串口控制台可用网络驱动没有跑通之前不要依赖SSH或Telnet来做核心调试否则你会像盲人摸象一样痛苦。网络驱动的所有调试串口日志是第一可靠来源。5.3 能ping通但TCP吞吐差这类问题的排查顺序我基本固定。先用ethtool eth0看协商速率和双工是否正常确保没掉到10M半双工。然后看ifconfig eth0的RX/TX errors和dropped计数。如果errors在涨多半是DMA或者PHY在物理层收到坏帧如果dropped在涨多半是接收队列处理不过来内存不够或者描述符太少。最后看中断次数cat /proc/interrupts确认中断没有被疯狂触发。如果中断数每秒几十万次而吞吐还是很低先停用网卡的TSO/GRO特性再测有些内核版本和网卡驱动的offload组合会出问题。5.4 网络偶发卡死典型的描述符问题偶发卡死是所有网络驱动开发最头疼的问题因为“偶发”意味着不一定能复现。最常见的根因有两个一个是描述符的所有权位失配比如DMA已经用完了描述符但中断处理里没有及时更新下一个可用索引另一个是DMA映射的缓冲区在发送完成后没有及时回收导致可用缓冲区池耗尽。我自己踩过一个大坑发送路径用了dma_map_single但不做dma_unmap_single系统跑2小时之后发送停滞因为DMA地址映射占满了IOMMU的有限条目。后来在发送完成中断里补上回收和映射销毁问题彻底消失。这类问题建议在驱动里加描述符状态打印函数卡死时能从串口看到当前环形队列的read/write指针和每个描述符的OWN位基本上现场打印一次就能定位。5.5 调试工具与信息速查整理一个驱动工程师日常用得最多的工具列表每个都可以救急dmesg内核日志网卡驱动的probe和错误输出全在这里调试第一步。ethtool eth0查看协商速率、link状态、驱动信息ethtool -S eth0能看详细统计。ip link show/ifconfig -a确认网卡UP/DOWN状态查看MAC、IP和丢包计数。tcpdump -i eth0抓包分析确认ARP、ping、TCP报文到底有没有到驱动层。iperf3压测吞吐定位性能瓶颈。cat /proc/interrupts确认中断分布排查中断风暴。示波器逻辑分析仪RMII数据线、MDIO时序的终极验证手段。关于调试节奏我个人的习惯是先把一个最小功能跑通能ping通再做性能打流稳定最后做可靠性长时间重负载。开发过程中给驱动加上“debugfs 串口导出描述符状态”这类调试手段而不是依赖猜和撞运气。Ethernet驱动写多了之后你会觉得认真读手册、仔细画时序、一步一步验证比任何技巧都管用——这一期的所有内容概括起来也就是这句话。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进