写这一期之前,我刚从一堆网线、示波器探头和反复翻寄存器手册的状态里爬出来——连续三天在调一块板子的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 MAC;PHY则是一颗独立芯片,比如LAN8720A、KSZ8081、RTL8211等,就放在板子网口附近。
分工上,MAC负责数据链路层的核心工作:组帧、地址过滤、CRC校验,以及和DMA控制器配合完成的buffer搬运。PHY负责物理层的脏活累活:把并行数据变成差分信号发到网线上,接收时做信号恢复、解码,还要处理协商速率/双工模式这类链路管理。PHY和MAC之间的数据通道,最常见的是MII、RMII、GMII、RGMII这几种。
这些接口的差异,直接影响你的驱动代码怎么配置引脚和时钟。
- MII:4位数据线,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、需要大流量的场景。
第二种是外置MAC+PHY集成芯片,比如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_ops:
static 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 up,ndo_stop对应down,ndo_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所有,软件填好数据后清掉OWN,DMA才敢动这块缓冲区;DMA搬完数据后会把OWN重新置回给CPU,表示“你的包我发完了”。OWN位没搞对,表现就是发了一包之后队列卡死,或者数据被莫名改写。第二个是缓冲区长度,如果协议栈交给驱动的skb只有一个缓冲区,但数据超过了你定义的单缓冲区长度,就需要启用SG(散聚)模式,或者干脆把数据拷贝到自己的连续缓冲区里。稳妥做法是先按MTU 1500加帧头帧尾对齐,避免首包就踩坑。第三个是地址对齐,很多控制器的描述符要求按4字节或8字节对齐,乱放结构体数组会踩到之前我说的连续调三天的坑。
接收方向的描述符逻辑类似,区别是CPU把缓冲区“借”给DMA,DMA收到帧后把数据写进去并置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 nfsroot=192.168.1.10:/home/user/rootfs,v3 ip=192.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灯不亮/链路状态一直DOWN | PHY供电、时钟、复位时序;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驱动写多了之后你会觉得,认真读手册、仔细画时序、一步一步验证,比任何技巧都管用——这一期的所有内容,概括起来也就是这句话。