☰
嵌入式以太网驱动开发实战:从MAC/PHY到DMA与NAPI调优
2026/10/8 6:18:25 网站建设 项目流程

嵌入式驱动开发这个系列写到了第十期,这一期聊 Ethernet。做过几年驱动的朋友应该都有个共同感受:以太网这模块,看起来入门简单,真要把它调稳、跑满、不出错,难度比 SPI、I2C 高一个量级。应用层 ping 通一台设备很简单,难的是驱动层那一堆寄存器、DMA 描述符、PHY 状态机和中断回调之间环环相扣的关系。你随便动一个参数,可能 link 都起不来;碰一下中断阈值,吞吐可能直接掉一半。

这一期我打算把嵌入式以太网驱动开发的完整脉络捋一遍,从硬件分层讲到底层 DMA 搬运,再到 NAPI 收包机制、PHY 自动协商,最后给出一套可落地的移植验证流程和实际排障案例。适合三类人看:一是从裸机 MCU 转向 Linux 驱动开发的工程师,二是正在做车载以太网、工业以太网或网关设备的嵌入式开发者,三是被 PHY 芯片和 DMA 描述符折磨得睡不着觉的同行。内容尽量讲人话,该给代码的给代码,该给参数的给参数,保证你合上文章就能动手。

1. 以太网驱动开发到底难在哪:先看清全貌再动手

1.1 一块网卡的硬件组成,其实就三块

很多人第一次看以太网原理图,会被“MAC + PHY + Transformer + RJ45”的架构弄晕。其实拆开看就三大块:MAC 控制器、PHY 芯片、连接器外围电路。

MAC 控制器通常集成在 SoC 内部,负责链路层的东西,包括以太网帧的封装与解封装、CRC 校验、流控帧处理、以及对 DMA 引擎的控制。PHY 是独立的模拟/混合信号芯片,工作在物理层,负责编码解码、时钟恢复、线路驱动、自动协商这些“电气活”。变压器是隔离用的,顺便做共模抑制。

嵌入式设备最常见的设计是“SoC 内置 MAC + 外置 PHY”,两者之间通过 MII、RMII、RGMII 这类接口连接。SoC 内部再挂一个 DMA 控制器,负责把内存里的数据搬到 MAC,或把收进来的帧搬到内存。驱动开发的核心,就是把这三层的寄存器、中断、状态、数据通路全部串起来。

1.2 驱动在整个网络栈里的位置

如果你在 Linux 下做驱动,以太网驱动是挂在网络协议栈底层的。从上层往下看大概是:socket → TCP/IP 协议栈 → 网络设备层(net_device)→ 驱动 → DMA/MAC → PHY → 网线。

驱动并不需要实现 TCP/IP 协议,那是内核协议栈的事。驱动真正要做的就四件事:初始化并注册一个 net_device、实现 open/stop 打开和关闭硬件、收发数据的核心路径(sk_buff 与 DMA 描述符之间的搬运)、处理 PHY 的 link 状态变化。理解 sk_buff 不一定要精通,但要知道它是协议栈和驱动之间的数据载体,发送路径上驱动从 sk_buff 取数据交给 DMA,接收路径上驱动从 DMA 收回缓冲区并组装成 sk_buff 送给协议栈。

1.3 为什么说这是“状态机最密集”的驱动

做 GPIO 驱动,操作就是读电平写电平。做 I2C 驱动,核心是时序加寄存器。做以太网驱动,难度直接跳一个维度,因为整个模块被大量状态机覆盖:PHY 有链路协商状态机、MAC 有收发状态机、DMA 有描述符状态机、NAPI 有调度状态机、硬件还有电源管理和暂停帧流控。

任何一环状态没对上,问题就冒出来了。最常见的就是 PHY 明明 link up 了,但 MAC 侧还不知道,导致收到帧但不上报;或者 DMA 描述符所有权位没处理好,驱动和硬件同时在抢同一个缓冲区,直接数据错乱。这也是为什么我一直强调,写以太网驱动前,先把状态机轮廊画出来,不然就是边写边踩坑。

2. MAC、PHY 与 MDIO:三角关系不捋清,后面全是坑

2.1 这两个芯片到底各管什么

很多新人搞不清 MAC 和 PHY 的边界,总觉得“网卡芯片”就一个。实际上,MAC 偏数字逻辑,PHY 偏模拟信号,两者必须配对工作。

MAC 负责的典型工作包括:组装以太网帧(添加前导码、帧起始符、源/目的 MAC、长度/类型字段,算 FCS)、识别收到的帧、处理 VLAN 标签、统计收发计数器、支持流控帧等。PHY 负责的则是:把 MAC 送来的并行数据编成适合线缆传输的串行码流(百兆以太网用 4B/5B 编码,千兆用 8B/10B 或 64B/66B)、从线缆上恢复时钟和数据、驱动双绞线信号,以及最关键的一一自动协商。

自动协商是 PHY 芯片独有的功能。两端设备上电后,PHY 通过发送快速链路脉冲来探测对端能力,协商出双方都支持的最高速率和工作模式(半双工/全双工)。协商完成之前,MAC 侧是不能正常收发的。驱动开发里,你看到“link up 但是不跑数据”的诡异问题,十有八九都出在自动协商这个环节。

2.2 MDIO/MDC 总线操作与 PHY 寄存器

MAC 管不到 PHY 内部状态,所以硬件上专门设计了一条管理总线:MDIO(数据线)+ MDC(时钟线),用来读写 PHY 寄存器。MDC 频率一般不超过 2.5MHz,也就是讲,读一次 PHY 寄存器要花微秒级的时间,这在高速收包路径上是完全不能接受的,所以驱动只在初始化、状态轮询、链路变化这些低频路径上访问 MDIO。

MDIO 帧格式是标准化的,由前导码(32 个 1)、起始码、操作码、PHY 地址、寄存器地址、转向位和数据组成。写寄存器时,CPU 把数据从 MDIO 引脚串行移出;读寄存器时,PHY 需要两个时钟周期的转向时间把 MDIO 总线让给数据输出。这个转向时间少写了,读回来的数据就是乱码。

PHY 寄存器前 6 个是标准定义的,0x00 BMCR(基本控制)、0x01 BSR(基本状态)、0x02~0x03 PHY ID、0x04 自动协商通告、0x05 链路伙伴能力。我自己的排查习惯是:上电第一步先读 PHY ID 确认访问的 PHY 地址和数据通路没问题,第二步读 BSR 看链接状态位,再决定要不要深入查。

2.3 接口模式:MII/RMII/RGMII/SGMII 怎么选

MAC 和 PHY 之间的数据接口,直接决定了 PCB 布线难度、引脚数量、时钟频率和驱动代码的时序要求。

MII 是经典百兆接口,数据线 16 根(发送 4 位 + 接收 4 位,加上时钟、控制信号),TX_CLK 百兆时 25MHz,十兆时 2.5MHz。它最直观,但引脚太多,现代设计已经很少直接用。

RMII 把数据线压到发送 2 位 + 接收 2 位,统一用外部 50MHz 参考时钟,引脚少一半,是低成本百兆方案的常客,MCU 里用得很多。

RGMII 是千兆时代的标准接口,4 位数据线采用 DDR 双沿采样,千兆时 GTX_CLK 是 125MHz。这里有个最常见的坑:RGMII 的 TX_CTL 信号在千兆模式下需要加约 2ns 延迟,否则时序裕量不足,高速时偶发错包。有的 MAC 内部有延迟配置,有的需要在 PCB 上加走线延迟,驱动里还有对应的 dll 配置寄存器,具体以芯片手册为准。

SGMII 则是串行接口,通过 SerDes 实现,只有一对发送差分线和一对接收差分线,速率 1.25Gbps 承载千兆数据。它的好处是引脚少、速率高、抗干扰强,也是车载以太网里常见的 MAC-PHY 互联方式之一。驱动开发时,SGMII 模式往往要先配置 PCS/PMA 层的状态机,确保 SerDes 链路同步,然后再等 PHY 侧的自动协商完成。做 2.5G 以太网时,带宽要求更高,PCS 层的适配就更关键,选型时得提前确认 MAC 是否支持对应的 PCS 配置。

3. DMA 描述符与环形缓冲区:数据搬运的主干道

3.1 描述符环是怎么工作的

以太网驱动的高性能,靠的不是 CPU 逐字节搬数据,而是 DMA。DMA 控制器本身不会“知道”要搬哪块内存,它靠的是驱动预先在内存里建好的描述符环。

描述符本质是一个结构体数组,每个描述符包含缓冲区物理地址、数据长度、控制标志和一个最关键的所有权位(ownership bit)。所有权位为 1 时代表 DMA 硬件可以操作这个缓冲区,驱动不能碰;所有权位为 0 时代表驱动可以回收缓冲区并重新填充。收发各有一个环,驱动初始化时一次性分配好,运行期间反复循环使用。

从操作系统的视角看,这是个典型的“生产者-消费者”模型。DMA 是生产/消费的一方,驱动是消费/生产的一方,所有同步都靠所有权位完成,不依赖锁。理解这个模型,你就能明白为什么以太网驱动对描述符的读写顺序极其敏感,先写哪个字段、后置哪个位,都有讲究。

3.2 初始化顺序与内存屏障

初始化描述符环时,顺序错了,硬件一开始就跑飞。我的习惯是:

  1. 分配 DMA 可访问的内存区域,并用dma_alloc_coherent或等价接口保证缓存一致性。
  2. 把所有描述符清空,所有权位置 1,表示“交给 DMA 控制,可以往这个缓冲区写数据”。
  3. 把每个收包描述符的缓冲区指针指向实际数据缓冲区。
  4. 配置 DMA 基地址寄存器,指向描述符环的首地址。
  5. 设置收发控制寄存器,使能 DMA。
  6. 等一个固定的时间,让硬件完成内部初始化,再去读状态寄存器确认为 ready。

这里必须强调内存屏障。CPU 访问内存的顺序和 DMA 看到的不一定一致,尤其是在 ARM 这类弱内存模型架构上。写完描述符字段后,一定要调用dma_wmb()确保写操作先完成,再从硬件角度触发操作;从 DMA 收回描述符后,读数据前要调用dma_rmb()。漏掉内存屏障的典型症状是:偶发性收包数据错乱、发出去的第一个包 CRC 错、百兆正常千兆必出问题。

3.3 发包和收包路径上的几个关键细节

发包路径上,驱动从协议栈拿到 sk_buff,把 skb 的数据地址转换成 DMA 地址填入描述符,设置长度和校验配置,然后把所有权位置 1,最后写 DMA 控制寄存器触发硬件发送。这里要注意三个细节:小于 60 字节的短包要补齐到以太网最小帧长,否则对端直接丢弃;如果硬件支持 TCP/IP 校验和卸载,要设置对应的描述符标志,让硬件把校验和算好,可以大幅降低 CPU 开销;发送完成后必须回收 skb,否则内存泄漏。

收包路径上,DMA 先往缓冲区里写数据,写完把所有权位清零并置错误标志/长度字段,然后触发中断或计数。驱动被唤醒后,从环尾取出描述符,读取长度,把缓冲区里的数据封装成 sk_buff 上送协议栈,然后重新分配一个新的缓冲区挂回描述符,置所有权位为 1,继续交给硬件。

这个循环里最容易出的问题是缓冲区不够。收包速率一旦超过驱动回收缓冲区的速度,描述符环就被占满,DMA 没地方写数据,只能把新包丢掉。这就是所谓“高负载丢包”的一个重要根源。所以描述符环的深度(常见的 128 或 256)以及收包缓冲区是否复用,都要按实际吞吐需求来定。

4. 中断与 NAPI:把 CPU 从“收包地狱”里解放出来

4.1 为什么“一来数据就进中断”不行

最简单粗暴的收包方式是:DMA 收完一个包就触发一次中断,驱动在中断里马上把数据搬出来上送协议栈。这种模式在低速场景没问题,但千兆以太网线速收包时,小包每秒可以到百万级,如果每个包都进一次中断,CPU 绝大部分时间都耗在中断上下文切换上,系统直接跑死。

所以现代以太网驱动几乎都采用 NAPI(New API)机制。它的核心思想是:中断只是“敲门”,真正收包靠轮询。第一次来包时,中断处理器把网卡的中断屏蔽掉,然后调度 NAPI 的 poll 函数;poll 函数在一个循环里尽可能多地收包,直到收完或达到预算;收完后再打开中断。

我在第一次做千兆驱动时,曾天真地把中断阈值调到最低,结果 eth0 一跑 iperf,CPU 中断占用直接奔着 80% 去。改成 NAPI 之后,同样流量下 CPU 中断占用降到 5% 以内,这个对比比任何理论都直观。

4.2 NAPI 的核心机制:poll 与预算

NAPI 的代码套路其实很固定。驱动初始化时用netif_napi_add注册一个 poll 回调,收包中断里执行两个动作:napi_schedule将这个 NAPI 实例加入调度队列,同时关闭本中断源。系统在软中断上下文里回调 poll。

poll 函数的骨架大概是:

int eth_poll(struct napi_struct *napi, int budget) { int work_done = 0; while (work_done < budget) { struct sk_buff *skb = eth_receive_packet(priv); if (!skb) break; napi_gro_receive(napi, skb); work_done++; } if (work_done < budget) { napi_complete_done(napi, work_done); eth_enable_rx_interrupt(priv); } return work_done; }

当 poll 返回的 work_done 等于 budget 时,内核会认为还有包没处理完,继续调 poll;只有返回小于 budget 时代表收包队列空了,NAPI 才会结束,驱动在结束前重新打开中断。budget 一般取 64,代表一次 poll 最多处理 64 个包。

这套机制里最容易犯的错是napi_schedule之后忘记关闭中断,结果中断反复触发,NAPI 又被重复调度,整个软中断逻辑全乱。另一个常见问题是 poll 函数里处理包耗时太长,导致中断一直被关闭,其他设备的中断也被饿着,出现“收包延迟暴涨”的情况。

4.3 中断合并、延迟与多队列

除了 NAPI,现代 MAC 控制器还支持中断合并(interrupt coalescing):不是每个包都触发中断,而是攒够 N 个包或者超过 T 微秒才触发一次。这套机制对小包场景极其有效,能显著降低中断频率,代价是增加少量延迟。

我们在实际项目里的调优经验是:对实时性要求高的控制类流量,关闭合并延迟,网络延迟最低;对吞吐优先的存储传输场景,把合并阈值调到最高的几十微秒,百兆线速下 CPU 占用能再降一个档次。如果 SoC 支持多队列 DMA,还可以把收发队列绑定到不同 CPU 核上,配合 irqbalance 或手动设置 smp_affinity,多核跑满时吞吐往往能再涨 20% 以上。

5. 从代码到板子:一套最小 Ethernet 驱动的移植与验证流程

5.1 移植前的硬件确认清单

拿到一块新板子,先别急着写代码。驱动开发最忌讳不看原理图直接抄代码。我会按下面的清单逐项确认:

  • PHY 芯片型号、PHY 地址(由复位时的 strap 引脚或地址引脚决定)
  • PHY 的复位引脚、中断引脚接在哪个 GPIO 或中断控制器上
  • MAC 与 PHY 使用的是哪种接口模式(MII/RMII/RGMII/SGMII)
  • 时钟来源:PHY 晶振频率、RMII 参考时钟由谁提供、MAC 侧时钟配置
  • MAC 是否有独立的 DMA 中断,中断号是多少
  • 供电时序:PHY 复位释放后需要延时多少毫秒才能访问寄存器

这些信息在原理图和芯片手册里都有,但一定要亲自对着 datasheet 查。特别是 PHY 地址,我在一个项目里遇到过板子上两个 PHY 因为 strap 电阻焊错,地址都变成 0x01 的情况,排查了很久才发现。

5.2 初始化、打开、收发包的代码骨架

用一个通用的描述符结构来演示:

typedef struct { uint32_t buf_addr; /* 数据缓冲区物理地址 */ uint32_t buf_len; /* 数据长度 */ uint32_t flags; /* 错误标志、校验状态 */ uint32_t owner; /* 所有权位 */ } eth_dma_desc_t;

初始化流程按这样的顺序来:

  1. 使能 MAC 和 DMA 的时钟、电源域。
  2. 拉低 PHY 复位脚再释放,延时等待 PHY 就绪。
  3. 通过 MDIO 读 PHY ID,确认 PHY 地址正确。
  4. 初始化 DMA 描述符环和收包缓冲区。
  5. 配置 MAC 的接口模式、MAC 地址、帧过滤规则。
  6. 配置 DMA 控制寄存器,使能收发。
  7. 注册中断处理函数和 NAPI 实例。
  8. 启动 PHY 自动协商。
  9. 注册并打开 net_device。

open 函数里做的事,本质就是“把初始化好的硬件真正跑起来”:

static int eth_open(struct net_device *dev) { struct eth_priv *priv = netdev_priv(dev); eth_phy_reset(priv); eth_dma_init_rx_ring(priv); eth_dma_init_tx_ring(priv); eth_mac_set_address(dev->dev_addr); eth_dma_enable(priv); napi_enable(&priv->napi); eth_enable_rx_interrupt(priv); phy_start(priv->phydev); netif_start_queue(dev); return 0; }

收包中断处理函数,逻辑非常固定:关本中断源、调度 NAPI、然后回中断。真正的收包逻辑放到 poll 里。发送完成则通过 TX 中断或轮询 TX 描述符所有权位来实现,回收 skb 并调用netif_wake_queue唤醒协议栈继续发包。

5.3 验证三板斧:link、ping、吞吐

驱动移植完成后,建议按下面的顺序验证:

先用 ethtool 看 link 状态,确认 PHY 协商出来的速率和双工模式符合预期。能看到Link detected: yes、Speed: 1000Mb/s就说明硬件通路基本通了。然后 ping 网关或对端设备,验证双向收发基本通路。ping 都不过且 link 正常时,优先查 MAC 地址是否配置正确、DMA 描述符环是否配置成功。

ping 通了再上 iperf 测吞吐。这阶段最能暴露问题:吞吐上不去,多半是 DMA 描述符深度不够或者中断过多;大包正常、小包吞吐极低,基本是中断合并参数没调好;CPU 占用率过高,就需要上 NAPI 或者调整预算。实测中,我习惯先用单线程 iperf 测试,再用四线程并发,对比数据能更快定位瓶颈。

5.4 设备树与平台配置要点

在 Linux 环境下移植,设备树里最关键的几个节点是 phy-mode、phy-handle、mac-address 和中断。phy-mode 必须和硬件实际接口严格一致,写成 rgmii-id、rgmii-rxid、rgmii-txid 会影响 MAC 内部延迟补偿逻辑,配错会导致千兆偶发错包。phy-handle 指向具体的 PHY 设备节点,reg 属性就是 PHY 地址,这俩必须和原理图对得上。

时钟这一项也容易被忽略,RMII 模式需要外部 50MHz 参考时钟,RGMII 需要 125MHz,SGMII 需要 SerDes 参考时钟。设备树里 clock-frequency 配错,PHY 根本起不来或者 link 后数据全错。我踩过最深的坑是:MAC 的时钟频率配成 25MHz(百兆),但实际接口跑千兆,结果启动后 link 是 down 的,查了整整半天才发现是设备树时钟频率的问题。

6. 实战问题排查:丢包、速度掉半、link 反复

6.1 最典型的几个现象和原因

我整理了一张排查表,基本覆盖了嵌入式以太网驱动最常见的故障:

故障现象最可能的原因排查手段
link 一直 downPHY 供电/复位时序不对、PHY 地址错、晶振没起振示波器测晶振、MDIO 读 PHY ID、查复位时序
link up 但不通MAC 地址错、DMA 环没跑起来、接口模式不匹配抓 RGMII 信号、查 DMA 状态寄存器
小包通、大包不通MTU 配置不一致、DMA 长包拆分没使能、CRC 错误抓包分析、关掉 checksum offload 测试
吞吐只有一半半双工协商、中断过多、描述符深度不足ethtool 查双工模式、调整中断合并、加深环
高负载随机丢包描述符环耗尽、CPU 软中断占用过高、NAPI 预算太低看网卡统计计数、perf 分析、调 budget
偶发 CRC errorRGMII 时序裕量不足、时钟抖动、线缆质量差逻辑分析仪采样、检查 TX_CTL 延迟、降速测试

6.2 排查工具和手段

驱动排查,我最常用的组合是 ethtool、tcpdump、iperf 和 /proc/interrupts。ethtool -S 能看硬件统计计数,rx_crc_errors、rx_dropped、tx_timeout 这些计数器直接给出方向。tcpdump 负责确认协议层的数据流是否正常。iperf 用来量化吞吐瓶颈。/proc/interrupts 能直观看到中断是不是被打爆了,如果某个中断号计数涨得飞快,合并中断和 NAPI 就是要动手的地方。

硬件层面,示波器测 PHY 晶振和 RGMII 时钟是绕不开的。RGMII 的时序问题我通常会做两件事:先用示波器看 GTX_CLK 的占空比和上升时间,再用逻辑分析仪抓 TXD 与 TX_CTL 的相对延迟。很多时候看着“应该没问题”的波形,实际量出来 delay 只有 1ns,那就是千兆错包的根因。

6.3 我的几个独家经验

排查以太网问题,我有一条屡试不爽的路线:先隔离到单层。PHY 和 MAC 之间数据不通,先看 PHY 侧有没有正常协商,再看 MAC 侧有没有正确收发。最简单粗暴的方法是让 MAC 进入 loopback 模式,把发出的数据直接环回接收路径,绕开 PHY 和网线。如果 loopback 下数据正常,问题 100% 在 PHY 或物理链路上,不用到 MAC 寄存器里瞎找。

第二个经验是保留一个“轮询模式”调试开关。正常驱动用中断收包,但调试初期,我会临时写一个只靠查询描述符所有权位的收包逻辑。中断那套逻辑一旦有问题,你分不清是中断没触发、napi_schedule 没生效还是 poll 里收包失败。轮询模式绕开所有中断相关的变量,先把收包通路调通,再回来加中断。

第三个经验是建议把所有 PHY 寄存器访问打点记录。每读写一次 MDIO,就把寄存器地址、值和结果序号存到环形日志里。PHY 状态异常时,翻日志往往比翻示波器更直接,尤其是我见过几次 PHY 在自动协商过程中被驱动反复复位,整个状态机根本走不完的情况,靠日志一眼就看出问题。

最后再分享一个小技巧:链路反复 up/down 的时候,先不要急着改驱动。八成是 PHY 的自动协商不稳定,或者供电纹波大导致 PHY 偶尔复位。我处理过一个工控设备的案例,就是 PHY 复位引脚接在了一个和电源时序相关的 GPIO 上,导致每次系统唤醒时 PHY 随机掉 link。后来把 PHY 复位脚换成独立控制,并在驱动里加了上电后的固定延时,问题彻底消失。做以太网驱动,很多时候要跟硬件工程师一起,从原理图层就把坑填平。

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

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

立即咨询