
嵌入式驱动开发这个系列写到了第十期这一期聊 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 初始化顺序与内存屏障初始化描述符环时顺序错了硬件一开始就跑飞。我的习惯是分配 DMA 可访问的内存区域并用dma_alloc_coherent或等价接口保证缓存一致性。把所有描述符清空所有权位置 1表示“交给 DMA 控制可以往这个缓冲区写数据”。把每个收包描述符的缓冲区指针指向实际数据缓冲区。配置 DMA 基地址寄存器指向描述符环的首地址。设置收发控制寄存器使能 DMA。等一个固定的时间让硬件完成内部初始化再去读状态寄存器确认为 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 绝大部分时间都耗在中断上下文切换上系统直接跑死。所以现代以太网驱动几乎都采用 NAPINew API机制。它的核心思想是中断只是“敲门”真正收包靠轮询。第一次来包时中断处理器把网卡的中断屏蔽掉然后调度 NAPI 的 poll 函数poll 函数在一个循环里尽可能多地收包直到收完或达到预算收完后再打开中断。我在第一次做千兆驱动时曾天真地把中断阈值调到最低结果 eth0 一跑 iperfCPU 中断占用直接奔着 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;初始化流程按这样的顺序来使能 MAC 和 DMA 的时钟、电源域。拉低 PHY 复位脚再释放延时等待 PHY 就绪。通过 MDIO 读 PHY ID确认 PHY 地址正确。初始化 DMA 描述符环和收包缓冲区。配置 MAC 的接口模式、MAC 地址、帧过滤规则。配置 DMA 控制寄存器使能收发。注册中断处理函数和 NAPI 实例。启动 PHY 自动协商。注册并打开 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 需要 125MHzSGMII 需要 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 复位脚换成独立控制并在驱动里加了上电后的固定延时问题彻底消失。做以太网驱动很多时候要跟硬件工程师一起从原理图层就把坑填平。