从零写OS内核 | 网卡——从 PHY 到 socket

你有没有想过这个过程:你输入一个网址,数据包是怎么从你的电脑发送到服务器的? CPU 把数据交给网卡,网卡再把电信号送到路由器——这条链路里,PHY、MAC、 DMA 环形缓冲区、驱动层层相扣。

本文拆解网卡硬件架构、驱动模型(netdev)、收发缓冲区、多队列 RSS,最后给出 Linux 调试命令。wandos 目前无网络栈,文中会单独说明。


一、网卡硬件架构:PHY + MAC + DMA

一张网卡(NIC)内部有三个核心组件:

Bash
  ┌─────────────────────────────────────────────────────────┐
  │                        网卡芯片                          │
  │  ┌─────────┐   ┌─────────┐   ┌─────────────────────┐  │
  │  │   PHY   │◄──│   MAC   │◄──│       DMA           │  │
  │  │ (物理层)│   │(链路层)  │   │ (TX/RX Ring Buffer) │  │
  │  └────┬────┘   └────┬────┘   └──────────┬──────────┘  │
  │       │             │                    │               │
  │   RJ45 接口      MII/RGMII           PCIe BAR          │
  │   (电信号)     (芯片内部连接)        (内存映射)          │
  └───────────────┴───────────────────────┴───────────────┘
                        │
                    PCIe 总线
                        │
                    CPU / RAM

三个组件的职责:

  • PHY(Physical Layer):负责电信号的编码/解码(RJ45 → 差分信号)、Auto-Negotiation(自动协商速率/双工)
  • MAC(Media Access Control):负责 Ethernet 帧的封装/解封装(源/目标 MAC 地址、类型字段)
  • DMA 控制器:负责在 RAM 和网卡之间直接传输数据,不经过 CPU(”Direct Memory Access”)

二、MII/RGMII 接口:MAC 和 PHY 之间怎么连

MAC 和 PHY 之间有一套标准的并行/串行接口:

接口 全称 数据线 典型速率
MII Media Independent Interface 4 tx + 4 rx 100Mbps
RMII Reduced MII 2 tx + 2 rx 100Mbps
GMII Gigabit MII 8 tx + 8 rx 1Gbps
RGMII Reduced GMII 4 tx + 4 rx + 边沿采样 1Gbps
XGMII 10 Gigabit MII 32 tx + 32 rx 10Gbps

RGMII 是目前最常见的千兆网口接口(Intel I219-V 等板载网卡都用 RGMII):

Bash
  MAC ────────────────────────────────────────────────── PHY
        ─── 4 bits TX ───→
                    ─── 4 bits RX ←───
        ─── TX clk ───→              ←── RX clk ────
        ─── TX ctrl ─→              ←── RX ctrl ───

三、收发缓冲区:TX/RX Ring Buffer

DMA 控制器通过两个环形缓冲区(Ring Buffer)和 CPU 交互:

Bash
  TX Ring Buffer(发送)                              RX Ring Buffer(接收)
  ┌───────────────────────────────────────┐  ┌───────────────────────────────────────┐
  │ [DESC0] [DESC1] [DESC2] ... [DESC15]   │  │ [DESC0] [DESC1] [DESC2] ... [DESC15]   │
  │   ↓       ↓       ↓        ↓           │  │   ↓       ↓       ↓        ↓           │
  │ [buf0]  [buf1]  [buf2]  [buf15]        │  │ [buf0]  [buf1]  [buf2]  [buf15]        │
  │ (网卡的 DMA 内存区域)                 │  │ (CPU 预先分配的 skb 缓冲区)            │
  └───────────────────────────────────────┘  └───────────────────────────────────────┘
          ↑                                        ↓
     CPU 写描述符                               网卡 DMA 写数据
     网卡 DMA 读数据                             CPU 读 skb

TX 流程(发送一个以太网帧):

  1. CPU 分配一个 sk_buff(skb),填充数据
  2. CPU 写描述符(buf 地址 + 长度)到 TX Ring
  3. 网卡 DMA 读取内存中的数据,发送到网线
  4. 网卡发送完成,触发中断或写回描述符
  5. CPU 回收 skb,释放内存

RX 流程(接收一个以太网帧):

  1. 驱动预先在 RX Ring 中填充 sk_buff 缓冲区
  2. 网卡收到帧,DMA 把数据写入 skb
  3. 网卡触发中断,通知 driver
  4. Driver 把 skb 递给上层协议栈(IP 层)
  5. Driver 补充新的 skb 到 RX Ring

四、驱动模型:netdev

Linux 网卡驱动通过 netdev 框架注册到内核:

C
// 典型的网卡驱动注册
static int my网卡_probe(struct pci_dev *pci_dev) {
    struct net_device *dev = alloc_etherdev(sizeof(struct my网卡_priv));
    // ... 初始化 ...
    if (register_netdev(dev))
        return -ENODEV;
    netdev_info(dev, "My NIC driver loadedn");
}

netdev 的核心 ops:

C
static const struct net_device_ops my网卡_ops = {
    .ndo_start_xmit    = my网卡_xmit,      // 发包
    .ndo_set_rx_mode   = my网卡_set_rx_mode, // 设置混杂/多播模式
    .ndo_get_stats64   = my网卡_stats64,    // 读统计
    .ndo_open          = my网卡_open,        // 启用网卡
    .ndo_stop          = my网卡_stop,       // 禁用网卡
};

Linux 查看网卡状态:

Bash
$ ip link show
2: enp0s3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP
    link/ether 08:00:27:6a:1f:2f brd ff:ff:ff:ff:ff:ff

$ ethtool -i enp0s3
driver: e1000
version: 5.15.0--generic
firmware-version: 0.13.04
expansion-rom-version: 
bus-info: 0000:00:03.0
supports-statistics: yes
supports-test: yes
supports-eeprom-access: yes
supports-priv-flags: no

$ cat /proc/net/dev
Inter-|   Receive                                                | Transmit
 face |bytes    packets errs drop fifo frame compressed multicast|bytes    packets
    lo: 12345     123    0   0   0 0         0          0      12345     123
enp0s3: 123456789   12345    0   0   0 0         0          0  987654321   9876

五、多队列网卡和 RSS

现代网卡有多个 TX/RX 队列,每个队列绑定不同的 CPU 核心,实现负载均衡:

Bash
  网卡(8 队列)
  ┌───┬───┬───┬───┬───┬───┬───┬───┐
  │ Q0│ Q1│ Q2│ Q3│ Q4│ Q5│ Q6│ Q7│
  └───┴───┴───┴───┴───┴───┴───┴───┘
    ↑   ↑   ↑   ↑   ↑   ↑   ↑   ↑
  CPU0 CPU1 CPU2 CPU3 CPU4 CPU5 CPU6 CPU7
  (通过 MSI-X 中断亲和性绑定)

  RSS(Receive Side Scaling):
  5-tuple (srcIP, dstIP, srcPort, dstPort, proto)
        ↓ hash
    indirection table → CPU 队列

查看网卡队列数和 RSS 配置:

Bash
$ ethtool -l enp0s3
  Current hardware settings:
  RX: 8
  TX: 8
  Other: 0
  Combined: 8

$ ethtool -x enp0s3
RX flow hash indirection table for enp0s3:
  0:    0   1   2   3   4   5   6   7
  1:    0   1   2   3   4   5   6   7
  # 每个 CPU 核心负责 8 个队列中的某一个

六、wandos 的网络栈

wandos 目前没有网络协议栈实现。 wandos 是一个从零构建的 x86 操作系统,目前的 I/O 能力集中在 VFS/ext2 文件系统、简单的文本输出(VGA/串口)和 PS/2 键盘输入。

如果你想在 wandos 上支持网络,需要:

  1. arch/x86/ 下实现 Intel e1000 / RTL8139 网卡驱动
  2. 实现以太网卡驱动(netdev 模型)
  3. 实现 TCP/IP 协议栈(或移植 lwIP)
  4. 对接 socket API(connect/listen/accept)

这是一个复杂的子系统,建议从简单的 UDP echo server 开始,逐步实现 IP/UDP 层。


公众号 footer

相关阅读:

  • 《从零写OS内核 | netfilter——Linux 防火墙框架》(231)
  • 《从零写OS内核 | conntrack——连接跟踪》(233)
  • 《从零写OS内核 | 总线与地址空间——CPU 是怎么找到每个设备的》(343)

动手环节:
运行 ethtool -i $(ip route get 8.8.8.8 | awk '{print $5}' | head -1) 看你的网卡驱动是什么型号,然后用 ethtool -S 查看 TX/RX 队列的统计。

仓库地址:
github.com/golang12306/os-kernel-from-scratch


关注公众号「AI不着急」,回复”资料”获取内核学习路线图。

最后修改: 2024年5月9日

作者

评论

发表评论

您的邮箱地址不会被公开。