跳到主要内容

14.11 The PCI Subsystem

上一节我们聊完了内核的「通知链」——那是软件层面的解耦艺术。

现在,让我们把视线从纯粹的软件逻辑拉回到物理世界。看着你手里那张插在服务器上的网卡,或者那块焊在开发板上的 Wi-Fi 模块,你可能会问:内核是怎么发现这些硬件的?又是怎么知道这块板子是 Intel 的而不是 Realtek 的?

对于绝大多数现代网络设备来说,答案都指向同一个底座——PCI 子系统

不是所有网卡都生而平等

先要把话说清楚:并不是所有的网络接口都是 PCI 设备。

在嵌入式领域,很多网络接口是直接焊在 SoC(片上系统)里的,它们挂在 CPU 内部的总线上(比如 AHB、AXI 或者 PLB),而不是 PCI 总线。这些设备的初始化和handled方式完全不同,这一节的讨论对它们并不适用。

但对于 x86 服务器、桌面 PC 以及很多嵌入式板卡来说,网卡(尤其是现在主流的 PCIe 网卡)确实是 PCI 设备。特别是 2004 年 PCI Express (PCIe) 标准推出后,原本的并行 PCI 总线逐渐被串行的 PCIe 取代——这不仅带来了更高的带宽,也改变了我们要与之交互的方式。

配置空间:设备的「身份证」

每个 PCI 设备,无论它多复杂,都有一个只读的配置空间

  • 对于传统 PCI 设备,这个空间至少是 256 字节
  • 对于 PCI-X 2.0 和 PCI Express 设备,这个空间扩展到了 4096 字节(扩展配置空间)。

你可以把这块空间想象成设备的「身份证」和「说明书」。里面记录了厂商 ID、设备 ID、类别码、内存映射地址、中断请求号等一系列关键信息。如果不读这块空间,内核就不知道设备的存在,更别提驱动它了。

你在终端上敲 lspci 命令时,看到的正是这些信息的解码版。如果你喜欢看原始的十六进制数据(就像内核看到的那样),可以加上 -x 参数:

  • lspci -xxx:显示标准 PCI 配置空间的十六进制 dump。
  • lspci -xxxx:显示扩展 PCI 配置空间的十六进制 dump。

在内核代码里,读写字段不能像读内存那样直接解引用,必须走 PCI API。Linux 提供了三组 API,分别对应 8 位、16 位和 32 位的粒度:

读取配置空间

static inline int pci_read_config_byte(const struct pci_dev *dev, int where, u8 *val);
static inline int pci_read_config_word(const struct pci_dev *dev, int where, u16 *val);
static inline int pci_read_config_dword(const struct pci_dev *dev, int where, u32 *val);

写入配置空间

static inline int pci_write_config_byte(const struct pci_dev *dev, int where, u8 val);
static inline int pci_write_config_word(const struct pci_dev *dev, int where, u16 val);
static inline int pci_write_config_dword(const struct pci_dev *dev, int where, u32 val);

寻找你的驱动:pci_device_id

内核怎么知道要把哪个驱动派给哪块网卡?

这就靠匹配了。每个 PCI 厂商都会在配置空间的 vendor(厂商)、device(设备)和 class(类别)字段里填上特定的值。内核的 PCI 子系统通过这些值来识别设备。

在驱动代码里,我们用一个 pci_device_id 结构体来描述「我能支持哪些设备」。这个结构体定义在 include/linux/mod_devicetable.h 里:

struct pci_device_id {
__u32 vendor, device; /* Vendor and device ID or PCI_ANY_ID */
__u32 subvendor, subdevice; /* Subsystem ID's or PCI_ANY_ID */
__u32 class, class_mask; /* (class,subclass,prog-if) triplet */
kernel_ulong_t driver_data; /* Data private to the driver */
};

这里的 vendordevice 是核心,绝大多数驱动只填这两个就足够了。如果你看到 PCI_ANY_ID,那就表示「我不在乎这个值,谁来都行」。

驱动程序的骨架:struct pci_driver

PCI 驱动的核心是一个 pci_driver 对象。它就像是驱动和内核之间的契约,定义了驱动叫什么、支持谁、以及设备插拔时该怎么办。

让我们看看它的骨架(定义在 include/linux/pci.h):

struct pci_driver {
. . .
const char *name;
const struct pci_device_id *id_table; /* must be non-NULL for probe to be called */
int (*probe) (struct pci_dev *dev, const struct pci_device_id *id); /* New device inserted */
void (*remove) (struct pci_dev *dev); /* Device removed (NULL if not a hot-plug capable driver) */
int (*suspend) (struct pci_dev *dev, pm_message_t state); /* Device suspended */
. . .
int (*resume) (struct pci_dev *dev); /* Device woken up */
. . .
};

这里有几个关键字段:

  • name:驱动名称,人类可读。
  • id_table:这就是刚才说的 pci_device_id 数组。驱动通过它告诉内核:「我是管这些设备的」。如果不填这个表,probe 函数永远不会被调用。
  • probe:这是重头戏。当内核发现有一个设备匹配上了你的 id_table,它就会调用这个函数。你需要在这里做所有初始化工作:申请资源、映射内存、注册网络设备等。
  • remove:当设备被拔出(或者驱动卸载)时调用。它的职责通常是清理 probe 里申请的所有资源。如果你不清理,就会内存泄漏。
  • suspend / resume:电源管理回调。当设备进入低功耗状态或被唤醒时触发。

设备的化身:struct pci_dev

内核里怎么表示一个具体的 PCI 设备?靠 struct pci_dev

这个结构体非常庞大,包含了设备的所有动态信息。我们挑几个核心字段看看(定义在 include/linux/pci.h):

struct pci_dev {
. . .
unsigned short vendor;
unsigned short device;
unsigned short subsystem_vendor;
unsigned short subsystem_device;
. . .
struct pci_driver *driver; /* which driver has allocated this device */
. . .
pci_power_t current_state; /* Current operating state. In ACPI-speak,
this is D0-D3, D0 being fully functional,
and D3 being off. */
struct device dev; /* Generic device interface */
int cfg_size; /* Size of configuration space */
unsigned int irq; /* IRQ assigned to the device */
};

你可以看到,这里面既有从配置空间读出来的静态信息(vendor, device),也有内核运行时分配的状态(driver, current_state, irq)。那个嵌入的 struct device dev 是 Linux 设备模型(Device Model)的标准用法,通过它,PCI 设备才能挂到那个统一的 /sys/devices 树下。

注册与初始化流程

把一个 PCI 驱动跑起来,标准流程是这样的:

  1. 定义 pci_driver 对象:填好名字、ID 表和回调函数。
  2. 注册驱动:调用 pci_register_driver()
    • 通常在驱动的 module_init() 里干这件事。
    • 一旦调用,PCI 核心层就会立刻去遍历总线上的设备,看有没有谁能匹配上你的 id_table。如果有,probe 马上就被调用了。
  3. probe 里初始化设备
    • 调用 pci_enable_device():这是关键一步。它会唤醒设备(如果它处于休眠),并激活设备的 I/O 和内存资源。如果不调这个,设备是死寂的。
    • 调用 request_irq():注册中断处理函数,这样才能处理网卡发来的数据包到达信号。
    • DMA 设置:申请 DMA 缓冲区。
  4. 注销驱动:调用 pci_unregister_driver()
    • 通常在 module_exit() 里调用。

DMA 的一点小事:Coherent Memory

PCI 网卡的高性能全靠 DMA(直接内存访问)。设备直接读写内存,不需要 CPU 搬运数据。

但这里有个坑:Cache Coherency(缓存一致性)。CPU 写了一块数据到内存,数据还在 L1 Cache 里,没进主存。这时候如果网卡来读这块内存,读到的就是旧数据(Garbage),然后包就发错了。

解决这个问题的标准办法是使用 dma_alloc_coherent() / dma_free_coherent()

void *dma_alloc_coherent(struct device *dev, size_t size,
dma_addr_t *dma_handle, gfp_t flag);

这个 API 分配的内存是「缓存一致性」的。也就是说,CPU 和设备看到的这块内存永远是一致的。你不需要手动去调用 cache flush 之类的操作,内核帮你搞定了。

这在很多高性能网卡驱动里都能看到,比如 Intel 的 e1000 驱动(drivers/net/ethernet/intel/e1000e/netdev.c)里的 e1000_alloc_ring_dma()

⚠️ 注意 别自己随便用 kmalloc 搞 DMA buffer,除非你非常清楚自己在干什么,并且知道什么时候该调用 dma_map_single / dma_unmap_single。用 dma_alloc_coherent 虽然开销稍大,但安全省心。

Note Single Root I/O Virtualization (SR-IOV) 是一个很酷的 PCI 特性,它能让一个物理设备伪装成多个虚拟设备(VF)。这在虚拟化环境里非常有用,可以直接把虚拟网卡透传给虚拟机。具体可以看内核文档 Documentation/PCI/pci-iov-howto.txt


Wake-On-LAN (WOL):远程唤醒

有时候你希望关机了还能远程把机器叫醒——这就是 Wake-On-LAN (WOL) 干的事。

它允许一个处于软关机状态的机器被一个特殊的网络数据包唤醒。默认情况下这个功能是关着的,因为没人希望自己的电脑在大半夜因为一个广播包突然亮起来。

在 Linux 里,网络设备驱动要想支持 WOL,需要在 ethtool_ops 对象里定义一个 set_wol() 回调。

你可以用 ethtool 命令来查和设置:

  • ethtool <ethX>:查看网卡是否支持 WOL。
  • ethtool -s eth1 wol g:开启 WOL,并指定只响应 MagicPacket(一种 AMD 定义的特殊帧格式)。

怎么发送这个唤醒包?你可以用 net-tools 包里的 ether-wake 工具。当你发送这个 MagicPacket 时,目标机器的网卡(即使主机关机了,网卡通常还在微供电状态)会检测到这个包,然后触发主板开机。

这在 RealTek 的 8139cp 驱动(drivers/net/ethernet/realtek/8139cp.c)里有经典的实现。


Teaming Driver:链路聚合的新选择

如果你需要更高的带宽或者冗余,通常会把两块网卡绑在一起。这就是 Link Aggregation

过去我们用的是 bonding 驱动(drivers/net/bonding)。它很稳,但代码都在内核里,导致内核模块臃肿,而且改点逻辑还得重新编译内核。

新的方案叫 Teaming Driver

它的核心思路是:逻辑下沉,控制上移

  • 内核驱动部分只负责最核心的数据包转发。
  • 复杂的控制逻辑(比如 LACP 协议计算、端口选择策略)交给用户空间的守护进程 teamd 去做。
  • teamd 和内核驱动通过 libteam 库通信,底层用的是 Generic Netlink(我们在第 2 章聊过这个)。

Teaming 驱动支持四种模式,都在 drivers/net/team 下:

  1. loadbalance (net/team/team_mode_loadbalance.c):用于 LACP(802.3ad 标准),自动根据流量负载均衡。
  2. activebackup (net/team/team_mode_activebackup.c):一主多备。只有一个口在干活,其他口待命。主挂了,备立马顶上。
  3. broadcast (net/team/team_mode_broadcast.c):简单粗暴,所有包从所有口复制一份发出去。
  4. roundrobin (net/team/team_mode_roundrobin.c):轮询分发,不需要用户空间介入。

这个项目主要由 Jiri Pirko 在开发,更多信息可以去 http://libteam.org/ 看。


The PPPoE Protocol:拨号上网的幕后

最后,我们简单提一下 PPPoE (Point-to-Point Protocol over Ethernet)

虽然现在光纤普及了,但如果你用过早期的 ADSL 宽带,你对这个协议肯定不陌生。它的核心作用是:在以太网这种多路访问的网络上,模拟出点对点的连接,以便进行用户认证和计费。

PPPoE 分为两个阶段:

1. Discovery(发现阶段)

这是为了让客户端(你的电脑)找到运营商的接入设备(Access Concentrator,AC)。这就像是你刚进楼里,得先找到那个负责收房租的房东。

这个过程有四步握手:

  • PADI (PPPoE Active Discovery Initiation)
    • 主机广播:「这里有人吗?我是要上网的。」
    • Code: 0x09, Session ID: 0x0000.
  • PADO (PPPoE Active Discovery Offer)
    • AC(房东)听到了,回复单播:「我是房东,我在。」
    • Code: 0x07, Session ID: 0x0000.
  • PADR (PPPoE Active Discovery Request)
    • 主机选定了某个 AC,发请求:「我要跟你建立连接。」
    • Code: 0x19, Session ID: 0x0000.
  • PADS (PPPoE Active Discovery Session-confirmation)
    • AC 回复:「准了,给你分配个 ID。」
    • Code: 0x65, Session ID: <非零的唯一ID>.

之后双方就拿着这个 Session ID 说话。如果想断开,就发一个 PADT(Terminate)包。

这五个包的 Ethernet Type 都是 0x8863

2. Session(会话阶段)

一旦 Discovery 完成,双方就进入了 Session 阶段。

这时候 Ethernet Type 变成了 0x8864。数据包前面不再加 PPPoE 发现头,而是加一个 6 字节的 PPPoE 头,紧接着是标准的 PPP 头(2字节)。

在这个阶段,你就可以跑 PPP 协议了——用 PAP 或 CHAP 验证密码,用 LCP 协商链路参数,用 IPCP 获取 IP 地址。这就是为什么拨号软件最后能拿到 IP 的原因。

理解 PPPoE 的关键就在于那个 6 字节的头。如果你抓包看到 0x88630x8864,你就知道,这段网络正在上演 PPPoE 的故事。