ARM平台网络驱动移植实战:从零点亮一块“失联”的网口
你有没有遇到过这样的场景?手里的ARM开发板一切就绪,系统启动正常,串口日志刷得飞快——可偏偏ifconfig eth0 up之后,终端只冷冷地回你一句:
eth0: link down没有IP,ping不通,tcpdump抓不到包。明明硬件上清清楚楚画着RJ45接口和PHY芯片,为什么就是“活不了”?
别急。这背后往往不是玄学,而是你还没真正掌握如何让Linux内核与那块沉默的MAC控制器对话。
今天,我们就来干一票大的:不依赖现成驱动,从零开始,在一个缺乏官方支持的ARM SoC上,亲手实现以太网功能。这不是调用API的教程,而是一场深入寄存器、穿越中断、直面DMA的真实移植之旅。
为什么不能直接用USB网卡?原生MAC才是硬道理
在动手之前,先回答一个灵魂拷问:既然有ASIX这类成熟的USB转以太网方案,为何还要费劲去写原生驱动?
答案藏在性能与控制权里。
想象一下你的设备是工业PLC,需要每毫秒稳定上报传感器数据;或者是边缘AI盒子,持续传输高清视频流。这时候,如果网络层频繁中断CPU、延迟波动剧烈,再强的算法也白搭。
而原生MAC控制器(Media Access Control)正是为此而生。它不是外挂模块,而是集成在SoC内部的高速通路,配合DMA引擎,能做到近乎“零拷贝”的数据搬运。
更重要的是:你能完全掌控它的每一个比特。
相比之下,USB或SPI桥接方案就像租来的车——能开,但油门响应慢,你还看不到发动机舱里发生了什么。一旦出问题,只能靠猜。
所以,如果你追求的是确定性、高性能、低延迟,那么这条路,必须走。
第一步:看懂你的MAC——初始化不只是“打开开关”
所有故事都始于MAC控制器。它是数据链路层的执行者,负责帧封装、CRC校验、流量控制,甚至时间戳同步(PTP)。但刚上电时,它是一块“死铁”,必须由我们唤醒。
以常见的Cadence GEM或Allwinner EMAC为例,初始化流程远比想象中复杂:
static int arm_mac_init(struct arm_eth_priv *priv) { // 1. 开启时钟 clk_prepare_enable(priv->clk_mac); // 2. 软件复位MAC控制器 writel(MAC_CR_SWRST, priv->base + MAC_CR); while (readl(priv->base + MAC_CR) & MAC_CR_SWRST) udelay(10); // 3. 设置MAC地址(从设备树或EEPROM读取) uint8_t mac_addr[6] = {0x00, 0x11, 0x22, 0x33, 0x44, 0x55}; writel((mac_addr[0] << 8) | mac_addr[1], priv->base + MAC_SA1H); writel((mac_addr[2] << 24)| (mac_addr[3] << 16) | (mac_addr[4] << 8) | mac_addr[5], priv->base + MAC_SA1L); // 4. 配置工作模式:RMII + 100Mbps + 全双工 writel(RMII_MODE | SPEED_100M | DUPLEX_FULL, priv->base + MAC_NCR); // 5. 启用DMA引擎,并设置描述符基址 writel(TX_RING_BASE, priv->base + TX_BASE_ADDR); writel(RX_RING_BASE, priv->base + RX_BASE_ADDR); writel(DMA_EN_ALL, priv->base + DMA_CONFIG); return 0; }这段代码看似简单,实则步步惊心。比如:
-复位后必须等待完成标志清除,否则后续配置无效;
-MAC地址若未正确烧录,交换机根本不会转发你的帧;
-RMII模式下必须启用内部48MHz时钟源,否则PHY无法锁定;
-DMA缓冲区地址需对齐且位于物理连续内存,否则会触发总线错误。
任何一个环节疏忽,结果都是“link down”。
第二步:MDIO通信——你是怎么跟PHY“聊天”的?
MAC只是大脑,PHY才是耳朵和嘴巴。它们之间的桥梁,叫做MDIO总线(Management Data Input/Output),一条只有两根线的串行总线:MDC(时钟)和MDIO(数据)。
通过这条“对讲机”,我们可以读写PHY的32个标准寄存器。其中最关键的几个是:
| 寄存器 | 名称 | 关键位说明 |
|---|---|---|
| Reg 0 | 控制寄存器 | Bit 12: 自协商使能;Bit 9: 重启自协商 |
| Reg 1 | 状态寄存器 | Bit 2: Link Status;Bit 5: Auto-Nego Complete |
| Reg 4 | 双工/速率能力 | 广告支持的模式 |
| Reg 5 | 对端能力 | 对方通告的能力 |
典型链路建立流程如下:
void phy_init_and_wait(struct arm_eth_priv *priv) { // 写控制寄存器:启用自协商 + 重启 phy_write(PHY_ADDR, 0, (1 << 12) | (1 << 9)); printk("Waiting for PHY link...\n"); while (1) { uint16_t sr = phy_read(PHY_ADDR, 1); if (sr & (1 << 2)) { // Link Status == 1 uint16_t aneg = phy_read(PHY_ADDR, 5); if (aneg & (1 << 7)) // 对端支持100M Full printk("Link UP: 100Mbps Full-Duplex\n"); else printk("Link UP: 10Mbps\n"); break; } msleep(100); } }⚠️ 常见坑点:某些PHY(如LAN8720)默认关闭自协商!必须手动写Reg0开启;另外,MDIO线上拉电阻缺失会导致通信失败——别小看这两个1kΩ电阻,它们可能就是你三天调试的罪魁祸首。
第三步:构建DMA双缓冲环——让数据自己跑起来
如果说中断是“通知”,那么DMA就是“搬运工”。没有它,每个数据包都要CPU亲自搬进搬出,效率极低。
我们的目标是建立两个环形队列:发送描述符环(TX Ring)和接收描述符环(RX Ring),每个描述符指向一块预分配的内存缓冲区。
接收环设计示例
#define RX_DESC_COUNT 64 struct rx_desc { uint32_t addr; // 数据缓冲区物理地址 uint32_t status; // OWN bit表示是否被DMA占用 } __attribute__((aligned(16))); // 预分配接收缓冲区(非缓存内存) static char rx_buffer[RX_DESC_COUNT][1536]; static struct rx_desc rx_ring[RX_DESC_COUNT]; void init_rx_dma(struct arm_eth_priv *priv) { for (int i = 0; i < RX_DESC_COUNT; i++) { phys_addr_t buf_phys = virt_to_phys(rx_buffer[i]); rx_ring[i].addr = buf_phys | DESC_OWNER_DMA; // 初始归DMA所有 rx_ring[i].status = 0; } // 最后一个描述符设为“环尾” rx_ring[RX_DESC_COUNT - 1].addr |= DESC_WRAP; // 通知MAC控制器起始地址 writel(virt_to_phys(rx_ring), priv->base + RX_DESC_BASE); }当PHY收到数据帧后,MAC自动将其写入当前OWN的缓冲区,并更新状态寄存器触发中断。此时CPU只需检查哪些描述符已被释放,即可批量收取多个包。
这就是NAPI机制的基础:一次中断处理多个包,避免“中断风暴”。
第四步:接入Linux网络栈——让内核认识你
现在硬件通了,接下来要让它成为系统中的一个合法网络接口:eth0。
这就需要用到Linux的net_device框架。你需要做三件事:
- 分配并填充
struct net_device - 实现核心操作函数
- 注册设备到内核
static const struct net_device_ops arm_eth_netdev_ops = { .ndo_open = arm_eth_open, // ifconfig eth0 up 时调用 .ndo_stop = arm_eth_close, // 关闭接口 .ndo_start_xmit = arm_eth_xmit, // 发送sk_buff .ndo_set_mac_address = eth_mac_addr, }; static int arm_eth_probe(struct platform_device *pdev) { struct net_device *ndev = alloc_etherdev(sizeof(struct arm_eth_priv)); if (!ndev) return -ENOMEM; struct arm_eth_priv *priv = netdev_priv(ndev); SET_NETDEV_DEV(ndev, &pdev->dev); // 映射寄存器空间 priv->base = devm_ioremap_resource(&pdev->dev, mem_res); if (IS_ERR(priv->base)) goto free_netdev; ndev->netdev_ops = &arm_eth_netdev_ops; ndev->ethtool_ops = &arm_eth_ethtool_ops; // 注册设备 if (register_netdev(ndev)) { dev_err(&pdev->dev, "Failed to register net device\n"); goto unmap_io; } platform_set_drvdata(pdev, ndev); return 0; }一旦注册成功,你就可以用标准工具操作它了:
# 查看链路状态 ethtool eth0 # 抓包测试 tcpdump -i eth0 icmp # 手动设置IP ip addr add 192.168.1.100/24 dev eth0调试秘籍:那些年我们一起踩过的坑
驱动开发最痛苦的从来不是写代码,而是“为什么没反应”。
以下是我在多个项目中总结的高频故障排查清单:
🔹 现象:eth0: link down
- ✅ 检查PHY供电是否正常(1.8V / 3.3V)
- ✅ 测量MDIO/MDC波形,确认有通信
- ✅ 查看设备树中
phy-mode = "rmii"是否匹配实际布线 - ✅ 复位PHY芯片(GPIO控制RST引脚低电平10ms)
🔹 现象:能发不能收,或严重丢包
- ✅ 检查RX描述符是否正确标记
OWN位 - ✅ 确保DMA缓冲区位于非缓存区域(使用
dma_alloc_coherent()) - ✅ 增大RX ring size(建议≥64),防止溢出
- ✅ 使用逻辑分析仪抓MII信号,确认帧已送达MAC
🔹 现象:发送超时(tx timeout)
- ✅ 检查TDNR(Transmit Descriptor Number Register)是否递增
- ✅ 清除DMA状态寄存器(如GEM的NSR、TSR)
- ✅ 确认TBSA(Tx Buffer Start Address)指向有效描述符
🔹 现象:偶尔工作,重启失效
- ✅ 检查时钟使能顺序:必须先开MAC时钟再访问寄存器
- ✅ 添加延时等待PHY稳定(一般≥100ms)
- ✅ 使用
printk(KERN_DEBUG ...)输出关键路径日志,结合dmesg定位卡点
设备树配置:硬件描述的“说明书”
现代ARM Linux普遍采用Device Tree解耦硬件信息。以下是一个典型配置片段:
&mac0 { compatible = "cdns,at91sam9g45-emac"; pinctrl-names = "default"; pinctrl-0 = <&pinctrl_mac0>; phy-mode = "rmii"; status = "okay"; phy-handle = <&phy0>; mdio { #address-cells = <1>; #size-cells = <0>; phy0: ethernet-phy@1 { reg = <1>; }; }; };关键字段解释:
-compatible:决定加载哪个驱动
-phy-mode:必须与原理图一致(MII/RMII/GMII)
-reg:PHY的MDIO地址(可通过硬件ADDR引脚配置)
-mdio子节点:声明管理总线下的所有PHY
错一个,整个驱动就会“找不到人”。
写在最后:当你掌握了底层,你就拥有了自由
当你第一次看到ping 192.168.1.1返回“64 bytes from…”的时候,那种成就感,远超任何高级框架的快速搭建。
因为你知道,这一字节的数据,是从你的代码出发,穿过DMA通道,经由MDIO协商速率,最终通过变压器传上网线——全程由你主宰。
这种能力意味着:
- 你可以为定制化SoC赋予联网能力;
- 你可以优化中断合并策略提升吞吐;
- 你可以加入PTP支持实现微秒级同步;
- 你不再惧怕“无驱动支持”的新芯片。
在物联网、工业控制、车载电子等领域,这正是区分普通开发者与系统级工程师的关键分水岭。
所以,下次再遇到“link down”,别慌。拿起逻辑分析仪,翻开数据手册,走进那个由寄存器、时序和状态机构成的世界——那里,藏着真正的力量。
如果你在移植过程中遇到了具体问题,欢迎留言交流。我们可以一起看波形、读日志、拆寄存器。毕竟,每一个成功的驱动背后,都有无数次失败的日志输出。