解析 ‘Hotplug’ 机制:内核如何在不关机的情况下处理 CPU、内存和 PCI 设备的在线拔插?

各位技术同仁,下午好!

今天,我们将深入探讨 Linux 内核中一个既神秘又至关重要的机制——Hotplug。想象一下,在不关闭服务器的情况下,动态地增加或减少 CPU 核心、扩展内存容量,甚至更换故障的 PCI 设备。这不仅仅是科幻场景,而是现代数据中心和云计算环境中司空见惯的操作。Hotplug 机制正是这一切的幕后英雄。

作为一名编程专家,我将带领大家从内核的视角,层层剖析 Hotplug 如何在不中断系统运行的前提下,优雅地处理 CPU、内存和 PCI 设备的在线插拔。我们将触及内核底层的数据结构、事件通知机制、驱动程序交互,并辅以代码片段和 sysfs 路径,力求做到逻辑严谨、深入浅出。

1. Hotplug 机制概览:动态系统的基石

Hotplug,顾名思义,即“热插拔”。它允许系统在运行时检测并响应硬件设备的插入或移除。对于服务器、大型机、虚拟化平台乃至一些嵌入式系统而言,Hotplug 的重要性不言而喻:

  • 高可用性与弹性: 允许在线更换故障硬件,减少停机时间。
  • 资源动态调整: 根据负载需求动态增减 CPU 和内存,优化资源利用率。
  • 系统维护: 在不影响服务的情况下进行硬件升级或降级。

Linux 内核为了支持 Hotplug,构建了一套复杂而精巧的框架,它不仅仅是简单的设备检测,更涉及到资源管理、中断处理、调度器调整、内存管理单元(MMU)更新等多个子系统的协同工作。其核心思想是将硬件设备的生命周期管理与系统的运行状态解耦。

我们首先从 Hotplug 机制的通用基础设施开始,了解内核是如何抽象和管理设备的。

1.1 kobjectksetsysfs:内核对象的统一表示

在 Linux 内核中,几乎所有的设备和系统组件都被抽象为 kobjectkobject 是一个非常轻量级的结构体,它提供了引用计数、名字管理和父子关系等基本功能,是 sysfs 文件系统的基础。

// include/linux/kobject.h
struct kobject {
    const char          *name;
    struct list_head    entry;
    struct kobject      *parent;
    struct kset         *kset;
    struct kobj_type    *ktype;
    struct kernfs_node  *sd; /* sysfs directory entry */
    struct kref         kref;
    unsigned int        state_initialized:1;
    unsigned int        state_in_sysfs:1;
    unsigned int        state_add_uevent_sent:1;
    unsigned int        state_remove_uevent_sent:1;
    unsigned int        uevent_notifier_xmit:1;
};
  • name:对象的名称,通常对应 sysfs 中的目录名。
  • parent:指向父 kobject,构建层次结构。
  • kset:指向所属的 kset
  • ktype:指向 kobj_type,定义了对象的属性和操作。
  • kref:引用计数,当计数为零时,对象被销毁。

ksetkobject 的集合,它为一组相关的 kobject 提供了一个统一的父目录,并可以定义这些 kobject 的通用行为。例如,所有的 CPU 设备都可能属于同一个 kset

sysfs 是一个虚拟文件系统,它将内核中的 kobject 层次结构以文件和目录的形式暴露给用户空间。用户空间可以通过 sysfs 查看设备信息、配置设备参数,甚至触发 Hotplug 操作。例如,/sys/devices/system/cpu 目录下就是所有的 CPU kobject

1.2 ueventudev:内核与用户空间的桥梁

当内核中的 kobject 状态发生变化(例如,设备插入、移除)时,内核会生成一个 uevent(用户空间事件)并发送给用户空间。uevent 包含了事件类型、设备路径等信息。

// 简化示意
struct kobj_uevent_env {
    char *argv[UEVENT_NUM_ENVP]; // 环境变量
    int envp_idx;
    char buf[UEVENT_BUFFER_SIZE]; // 事件数据
    int buflen;
};

// 内核发送 uevent 的核心函数
int kobject_uevent(struct kobject *kobj, enum kobject_action action);

用户空间中,udev 是一个守护进程,它监听内核发送的 ueventudev 根据预定义的规则(udev rules),解析 uevent 的内容,并执行相应的操作,比如加载驱动、创建设备节点、运行脚本等。

例如,当一个新的 PCI 设备被插入时,内核会发送一个 ueventudev 收到后可能会:

  1. 根据设备 ID 查找并加载相应的驱动模块。
  2. /dev 目录下创建设备文件。
  3. 执行自定义脚本,配置新设备。

1.3 notifier_block:通用的事件通知机制

在内核内部,notifier_block 提供了一种通用的、多播(one-to-many)的事件通知机制。当某个特定事件发生时,所有注册到该事件链上的 notifier_block 都会被调用。Hotplug 机制大量使用了 notifier_block 来通知各个子系统设备状态的变化,以便它们能及时做出响应。

// include/linux/notifier.h
struct notifier_block {
    int (*notifier_call)(struct notifier_block *, unsigned long, void *);
    struct notifier_block *next;
    int priority;
};

// 注册一个 notifier
int raw_notifier_chain_register(struct raw_notifier_head *nh, struct notifier_block *nb);
// 调用 notifier 链
int raw_notifier_call_chain(struct raw_notifier_head *nh, unsigned long val, void *v);

例如,当 CPU 状态改变(上线/下线)时,cpu_hotplug_notifier 链上的所有 notifier_block 都会被触发,通知文件系统、调度器、中断控制器等进行相应的调整。

1.4 Workqueues:异步处理 Hotplug 事件

Hotplug 操作通常涉及到耗时的任务,例如扫描总线、分配资源、初始化设备等。为了避免阻塞关键路径,内核通常会使用 workqueues 来异步处理这些任务。workqueues 允许将一个函数(work)提交到一个内核线程中执行,从而提高系统的响应性。

// include/linux/workqueue.h
struct work_struct {
    atomic_long_t data;
    struct list_head entry;
    work_func_t func; // 要执行的函数
};

// 定义一个工作
DECLARE_WORK(my_work, my_work_function);
// 调度工作
schedule_work(&my_work);

有了这些基础设施,我们现在可以深入到具体的 Hotplug 实现中。

2. CPU Hotplug:核心计算资源的动态伸缩

CPU Hotplug 允许系统在运行时动态地添加或移除 CPU 核心。这对于需要根据负载弹性伸缩的云环境、以及需要隔离故障 CPU 的容错系统至关重要。

2.1 CPU 在内核中的表示

每个逻辑 CPU(可以是物理核心、超线程)在内核中都有一个唯一的 ID。内核通过 struct cpu 结构体来表示一个 CPU 设备,并将其注册到 sysfs 中。

// drivers/base/cpu.c
struct cpu {
    struct device dev; // 继承自 struct device
    unsigned int id;   // CPU ID
    struct cpu_operations *ops; // CPU 操作函数指针
};

// include/linux/cpu.h
extern struct device *get_cpu_device(unsigned int cpu); // 获取 CPU 对应的 device

sysfs 中,CPU 设备通常位于 /sys/devices/system/cpu/cpuX,其中 X 是 CPU ID。

# 查看所有在线 CPU
ls /sys/devices/system/cpu/cpu[0-9]*
# 查看 CPU0 的在线状态
cat /sys/devices/system/cpu/cpu0/online

2.2 添加 CPU 的过程

添加 CPU 通常是一个由硬件(例如 ACPI)或用户空间触发的过程。

  1. 硬件发现 / 用户空间请求:

    • 在支持 Hotplug 的架构上,BIOS/ACPI 可能会在系统启动时发现所有可能的 CPU 插槽,并预留资源。当 CPU 实际插入后,ACPI 会发出通知。
    • 用户空间可以通过 echo 1 > /sys/devices/system/cpu/cpuX/online 来请求上线一个已发现但离线的 CPU。
  2. cpu_hotplug_begin():准备阶段

    • 在开始任何实际的 CPU 上线操作之前,内核会调用 cpu_hotplug_begin()。这个函数会确保 Hotplug 操作的互斥性,防止并发操作。
  3. __cpu_up():核心上线逻辑

    • 这是 CPU 上线的主要函数,它位于 arch/*/kernel/smp.carch/*/kernel/cpu/hotplug.c
    • 设置 CPU 状态: 将目标 CPU 标记为“正在上线”。
    • 分配 per-CPU 数据: 每个 CPU 都有自己独立的私有数据区域(per-cpu data),例如堆栈、current 指针等。内核需要为新上线的 CPU 分配这些数据。
    • 启动 CPU: 架构相关的代码会向新 CPU 发送启动指令(例如,x86 架构上的 INITSIPI 序列),使其从某个预定义的入口点开始执行。
    • 初始化中断控制器: 配置新 CPU 上的本地 APIC 或其他中断控制器。
    • 集成到调度器: 新 CPU 会被添加到调度器的就绪队列中,开始参与任务调度。
    • 通知其他子系统: 通过 cpu_hotplug_notifier 链,通知文件系统、网络栈、设备驱动等子系统新 CPU 已经上线。这些子系统可能需要更新内部数据结构,以支持新的 CPU。
    // 简化后的 __cpu_up 流程 (arch/x86/kernel/cpu/hotplug.c 或类似文件)
    static int __cpu_up(unsigned int cpu, int hotplug_cpu)
    {
        int err;
        // 1. 设置 CPU 状态
        set_cpu_online(cpu, false); // 暂时标记为离线,防止调度器在未完全初始化前使用
    
        // 2. 分配 per-CPU 数据
        err = setup_per_cpu_areas(cpu);
        if (err) return err;
    
        // 3. 启动 CPU (架构相关)
        err = smp_ops.cpu_up(cpu); // 例如 x86 的 startup_cpu
    
        // 4. 初始化中断,集成调度器
        if (!err) {
            // ... 各种初始化,例如 setup_apic_and_irq_cpu(cpu)
            // ... 调度器相关初始化
            set_cpu_online(cpu, true); // 标记为在线
            pr_info("CPU%d is upn", cpu);
        }
    
        // 5. 通知其他子系统
        // hotplug_cpu_online(cpu); // 通过 notifier_block 通知
        return err;
    }
  4. cpu_hotplug_done():完成阶段

    • 清理 Hotplug 互斥锁,允许后续操作。
    • 发送 uevent 到用户空间,通知 CPU 上线成功。

用户空间操作示例:

# 假设有一个 CPU7 处于离线状态 (可以通过 lscpu 或 sysfs 查看)
# 激活 CPU7
echo 1 > /sys/devices/system/cpu/cpu7/online

# 验证
cat /sys/devices/system/cpu/cpu7/online # 应该输出 1
lscpu # 查看 CPU 列表,CPU7 应该显示为 online

2.3 移除 CPU 的过程

移除 CPU 的过程比添加更为复杂,因为它涉及到将正在运行的任务安全地迁移走,并确保系统的稳定性。

  1. 用户空间请求:

    • 用户通过 echo 0 > /sys/devices/system/cpu/cpuX/online 请求下线一个 CPU。
  2. cpu_hotplug_begin():准备阶段

    • 同添加 CPU,确保互斥。
  3. __cpu_disable():核心下线逻辑

    • 设置 CPU 状态: 将目标 CPU 标记为“正在下线”。
    • 阻止新任务: 调度器将不再向此 CPU 分配新任务。
    • 迁移任务: 将目标 CPU 上所有正在运行的任务迁移到其他在线 CPU 上。这包括进程、内核线程、软中断等。
    • 停止中断: 禁用目标 CPU 上的中断,防止接收新的中断请求。
    • 停止计时器: 将目标 CPU 上的计时器迁移或停止。
    • 同步内存: 确保所有对内存的写操作都已完成并同步。
    • 通知其他子系统: 通过 cpu_hotplug_notifier 链,通知文件系统、网络栈、设备驱动等子系统 CPU 即将下线。这些子系统可能需要释放与该 CPU 相关的资源。
    • 进入休眠或停机: 目标 CPU 最终会进入一个停机状态(例如,hlt 指令),不再执行任何指令。
    // 简化后的 __cpu_disable 流程 (arch/x86/kernel/cpu/hotplug.c 或类似文件)
    static int __cpu_disable(unsigned int cpu)
    {
        int err = 0;
        // 1. 设置 CPU 状态
        set_cpu_online(cpu, false); // 标记为离线
    
        // 2. 通知其他子系统 CPU 即将下线
        // hotplug_cpu_offline(cpu); // 通过 notifier_block 通知
    
        // 3. 停止调度,迁移任务
        cpu_notifier_call_chain(CPU_DEAD, cpu); // 进一步通知
        // 确保所有任务都已迁移
        while (cpu_rq(cpu)->nr_running != 0) {
            // 等待任务迁移完成
            msleep(1);
        }
    
        // 4. 停止中断、计时器等
        // arch_teardown_cpu(cpu); // 架构相关的清理
        // ...
    
        pr_info("CPU%d is offlinen", cpu);
        return err;
    }
  4. cpu_hotplug_done():完成阶段

    • 清理 Hotplug 互斥锁。
    • 发送 uevent 到用户空间,通知 CPU 下线成功。

用户空间操作示例:

# 假设 CPU7 处于在线状态
# 禁用 CPU7
echo 0 > /sys/devices/system/cpu/cpu7/online

# 验证
cat /sys/devices/system/cpu/cpu7/online # 应该输出 0
lscpu # 查看 CPU 列表,CPU7 应该显示为 offline

CPU Hotplug 的核心挑战在于确保所有与 CPU 相关的状态和资源都被正确地迁移或清理,以避免系统崩溃或数据损坏。notifier_block 机制在这里发挥了关键作用,它允许各个内核子系统在 CPU 状态变化时执行其特定的清理或初始化逻辑。

3. 内存 Hotplug:灵活的内存池管理

内存 Hotplug 允许系统在不重启的情况下增加或减少物理内存。这对于需要动态调整内存资源的虚拟化平台、NUMA 架构的服务器以及处理内存故障的场景至关重要。

3.1 内存在内核中的表示

Linux 内核将物理内存划分为多个粒度:

  • 页(Page): 最基本的内存分配单位,通常为 4KB。
  • 内存区(Zone): 内存的逻辑分区,例如 ZONE_DMAZONE_NORMALZONE_HIGHMEM
  • 内存段(Memory Section): 较大粒度的物理内存块,通常是 128MB 或 256MB。每个内存段都有一个 struct mem_section 结构体来描述。

内存 Hotplug 操作通常以内存段为单位进行。sysfs 中,内存设备通常位于 /sys/devices/system/memory/memoryX

# 查看所有内存块
ls /sys/devices/system/memory/memory[0-9]*
# 查看 memory0 的在线状态
cat /sys/devices/system/memory/memory0/online

3.2 添加内存的过程

添加内存通常由 ACPI 或用户空间触发。

  1. 硬件发现 / 用户空间请求:

    • ACPI 可能会在系统启动时发现所有预留的内存插槽。当新的内存模块插入时,ACPI 会通知内核。
    • 用户空间可以通过 echo 1 > /sys/devices/system/memory/memoryX/online 来请求上线一个已发现但离线的内存块。
  2. add_memory():核心添加逻辑

    • 这个函数位于 mm/memory_hotplug.c
    • 查找空闲内存区域: 内核需要确定新内存的物理地址范围。这可能涉及到解析 ACPI 表或设备树。
    • 内存区域验证: 验证新内存区域是否合法、不与其他已用内存重叠。
    • 分配 mem_section 为新内存区域分配和初始化 struct mem_section 结构体。
    • 填充 struct page 数组: 内核需要为新内存区域的每个物理页分配一个 struct page 结构体。这些结构体存储了页的各种元数据(例如,引用计数、标志位等)。
    • 初始化页描述符: 对新内存区域的每个 struct page 进行初始化,包括设置页的类型、将其添加到 Buddy 系统中。
    • 添加到 Buddy 分配器: 将新内存添加到 Buddy 系统中,使其可用于后续的页分配。
    • 更新 NUMA 节点信息: 如果系统是 NUMA 架构,需要更新相关 NUMA 节点的内存统计信息。
    • 通知其他子系统: 通过 memory_hotplug_notifier 链,通知文件系统、块设备层等子系统内存已增加。
    • 发送 uevent 通知用户空间内存上线成功。
    // 简化后的 add_memory 流程 (mm/memory_hotplug.c)
    int add_memory(int nid, u64 start, u64 size)
    {
        unsigned long pfn = start >> PAGE_SHIFT; // 物理页帧号
        unsigned long end_pfn = (start + size) >> PAGE_SHIFT;
        int err;
    
        // 1. 检查并注册内存区域
        err = __add_memory_resource(nid, pfn, end_pfn);
        if (err) return err;
    
        // 2. 将内存区域添加到管理
        err = __add_pages(nid, pfn, end_pfn);
        if (err) {
            // ... 清理资源
            return err;
        }
    
        // 3. 设置内存块在线状态并发送 uevent
        set_memory_block_online(pfn);
        return 0;
    }
    
    // __add_pages 的简化示意
    static int __add_pages(int nid, unsigned long start_pfn, unsigned long end_pfn)
    {
        unsigned long pfn;
        for (pfn = start_pfn; pfn < end_pfn; pfn++) {
            // 为每个页帧分配并初始化 struct page
            struct page *page = pfn_to_page(pfn); // 获取页描述符
            __init_single_page(page, nid, pfn);   // 初始化页
            // 将页添加到 Buddy 系统
            __free_page(page);
        }
        return 0;
    }

用户空间操作示例:

# 假设有一个 memoryX 处于离线状态
# 激活 memoryX
echo 1 > /sys/devices/system/memory/memoryX/online

# 验证
cat /sys/devices/system/memory/memoryX/online # 应该输出 1
lsmem # 查看内存列表,memoryX 应该显示为 online

3.3 移除内存的过程

移除内存比添加内存更加复杂和危险,因为内存中的数据是活跃的,可能被各种进程和内核数据结构占用。安全地移除内存需要确保所有页都是空闲的。

  1. 用户空间请求:

    • 用户通过 echo 0 > /sys/devices/system/memory/memoryX/online 请求下线一个内存块。
  2. remove_memory():核心移除逻辑

    • 这个函数也位于 mm/memory_hotplug.c
    • 检查内存块状态: 确保目标内存块处于在线状态。
    • 尝试迁移页: 这是最困难的部分。内核会遍历目标内存块中的所有页,并尝试将它们迁移到其他在线内存块。
      • MIGRATE_MOVABLE 那些可以移动的页(例如匿名页、文件页)会被迁移。
      • MIGRATE_RECLAIMABLE 那些可以被回收的页(例如缓存页)会被尝试回收。
      • 不可移动页: 内核数据结构、设备映射内存、固定映射等是不可移动的。如果目标内存块中存在任何不可移动的页,内存移除操作将失败。
    • 释放页描述符: 如果所有页都被成功迁移或回收,内核会从 Buddy 系统中移除这些页,并释放对应的 struct page 结构体。
    • 更新 NUMA 节点信息: 更新 NUMA 节点的内存统计信息。
    • 发送 uevent 通知用户空间内存下线成功。
    // 简化后的 remove_memory 流程 (mm/memory_hotplug.c)
    int remove_memory(int nid, u64 start, u64 size)
    {
        unsigned long pfn = start >> PAGE_SHIFT;
        unsigned long end_pfn = (start + size) >> PAGE_SHIFT;
        int err;
    
        // 1. 设置内存块离线状态
        set_memory_block_offline(pfn);
    
        // 2. 尝试将内存页从管理中移除
        err = __remove_pages(nid, pfn, end_pfn);
        if (err) {
            // 如果移除失败,可能需要重新上线内存块
            set_memory_block_online(pfn);
            return err;
        }
    
        // 3. 移除内存资源 (例如 ACPI 资源)
        __remove_memory_resource(nid, pfn, end_pfn);
    
        // 4. 发送 uevent
        kobject_uevent(&mem_dev->dev.kobj, KOBJ_OFFLINE);
        return 0;
    }
    
    // __remove_pages 的简化示意
    static int __remove_pages(int nid, unsigned long start_pfn, unsigned long end_pfn)
    {
        unsigned long pfn;
        for (pfn = start_pfn; pfn < end_pfn; pfn++) {
            struct page *page = pfn_to_page(pfn);
            if (page_count(page) > 0) {
                // 页正在被使用,无法移除
                return -EBUSY;
            }
            // 从 Buddy 系统中移除
            __remove_single_page(page);
            // 释放页描述符 (如果不是静态分配的)
        }
        return 0;
    }

用户空间操作示例:

# 假设 memoryX 处于在线状态
# 禁用 memoryX
echo 0 > /sys/devices/system/memory/memoryX/online

# 验证
cat /sys/devices/system/memory/memoryX/online # 应该输出 0
lsmem # 查看内存列表,memoryX 应该消失或标记为 offline

内存移除的成功率很大程度上取决于系统当前的内存使用模式。在实际生产环境中,移除大量内存往往需要谨慎规划,并可能要求系统负载较低,甚至需要强制释放某些缓存。

4. PCI 设备 Hotplug:I/O 设备的灵活扩展

PCI (Peripheral Component Interconnect) 或更现代的 PCIe (PCI Express) 是连接高性能外围设备的总线标准。PCI Hotplug 允许在系统运行时添加或移除 PCI 设备,这在服务器、存储系统和虚拟化环境中非常常见。

4.1 PCI 设备在内核中的表示

PCI 设备在内核中以 struct pci_dev 结构体表示,它包含设备的供应商 ID、设备 ID、总线、槽位、功能号等信息,以及其配置空间和分配的资源。

// include/linux/pci.h
struct pci_dev {
    struct list_head      bus_list;    /* Node in PCI bus's list */
    struct pci_bus        *bus;        /* PCI bus this device is on */
    struct pci_bus        *subordinate; /* For bridge devices */
    void                  *sysdata;    /* Device-specific system data */
    struct device         dev;         /* Generic device interface */
    u16                   vendor;
    u16                   device;
    u16                   subsystem_vendor;
    u16                   subsystem_device;
    unsigned int          devfn;       /* bus:device.function */
    // ... 其他配置空间寄存器和 BARs 信息
    struct pci_driver     *driver;     /* Driver to which this device is bound */
    struct list_head      dma_pools;
    struct pci_slot       *slot;       /* physical slot this device is in */
    // ...
};
  • bus:指向所属的 PCI 总线。
  • dev:继承自 struct device,是 sysfs 的基础。
  • vendor / device:设备的厂商和产品 ID。
  • devfn:设备的(总线、设备、功能)编号。
  • driver:绑定到该设备的驱动程序。
  • slot:指向 PCI 物理插槽。

sysfs 中,PCI 设备通常位于 /sys/bus/pci/devices/XXXX:XX:XX.X,其中 XXXX:XX:XX.X 是 PCI 总线地址(Domain:Bus:Device.Function)。

# 查看所有 PCI 设备
lspci
# 或通过 sysfs
ls /sys/bus/pci/devices/

4.2 PCI Hotplug 的触发机制

PCI Hotplug 可以通过两种主要方式触发:

  1. ACPI / Native PCIe Hotplug: 现代系统通常支持原生的 PCIe Hotplug。当设备插入或移除时,硬件(例如 PCIe 控制器)会生成中断或事件通知 ACPI 固件,ACPI 固件再通知操作系统。这是最常见和推荐的方式。
  2. 软件扫描 / 用户空间请求: 用户空间可以通过写入 sysfs 来触发 PCI 总线的重新扫描,或者直接请求删除一个设备。这通常用于模拟 Hotplug 或在不支持原生 Hotplug 的系统上。

4.3 添加 PCI 设备的过程

  1. 硬件事件 / 用户空间触发:

    • ACPI/PCIe Hotplug: 当新的 PCIe 卡插入到支持 Hotplug 的插槽时,PCIe 控制器会检测到物理连接变化,并向根联合体(Root Complex)发送中断。根联合体通常通过 ACPI 事件(例如 _EJJ)通知内核。
    • 用户空间扫描: 用户可以向 sysfs 写入 1 来触发某个 PCI 总线的扫描:
      echo 1 > /sys/bus/pci/rescanecho 1 > /sys/bus/pci/slots/slotX/power (如果插槽支持电源控制)。
  2. pcie_port_service_probe() / pci_rescan_bus():检测新设备

    • 当内核收到 Hotplug 事件(无论是来自 ACPI 还是用户空间请求)时,它会调用 pci_rescan_bus() 或其变体。
    • pci_rescan_bus() 会遍历指定 PCI 总线上的所有可能的设备 ID 和功能号,尝试读取它们的配置空间。
    • 如果发现了一个新的、有效的 PCI 设备,内核会为其分配一个 struct pci_dev 结构体。
  3. 资源分配:

    • 新发现的 PCI 设备需要分配资源,包括 I/O 端口、内存地址范围(BARs)和中断请求线(IRQs)。内核会根据设备的 BAR 寄存器信息,为其分配不冲突的资源。
  4. pci_device_add():注册设备

    • 将新发现的 pci_dev 结构体添加到 PCI 设备列表中。
    • 将其 dev 成员注册到设备模型中,使其在 sysfs 中可见。
    • 发送 uevent 到用户空间,通知新设备已添加。
  5. pci_probe_device():驱动绑定

    • 内核的设备模型会尝试为新设备查找并绑定合适的 PCI 驱动程序。
    • 如果找到了匹配的 pci_driver,其 probe 函数会被调用,驱动程序会初始化设备、设置中断、注册字符/块设备等。
    // 简化后的 PCI 设备添加流程 (drivers/pci/bus.c, drivers/pci/probe.c)
    // 假设 pci_scan_slot 发现了一个新设备
    struct pci_dev *pdev = pci_scan_single_device(bus, devfn);
    if (pdev) {
        pci_bus_assign_resources(pdev->bus); // 分配 BARs, IRQs
        pci_device_add(pdev);                // 添加到设备模型,发送 uevent
        pci_bus_attach_device(pdev);         // 尝试绑定驱动
        pci_enable_device(pdev);             // 启用设备
        pr_info("PCI device %s added.n", pci_name(pdev));
    }
    
    // pci_bus_attach_device 内部会调用 driver_probe_device
    // driver_probe_device 会在所有注册的 pci_driver 中查找匹配的
    // 并调用匹配驱动的 probe 函数:
    // drv->probe(pdev, &id_table[i]);

用户空间操作示例:

# 假设有一个空闲的 PCI Express 插槽 0000:01:00.0
# 插入一张新的网卡
# 触发 PCI 总线重新扫描
echo 1 > /sys/bus/pci/rescan

# 验证
lspci # 应该能看到新设备
ls /sys/bus/pci/devices/0000:01:00.0 # 确认 sysfs 路径存在

4.4 移除 PCI 设备的过程

移除 PCI 设备同样需要谨慎,以确保相关驱动程序正确卸载,并释放所有已分配的资源。

  1. 硬件事件 / 用户空间请求:

    • ACPI/PCIe Hotplug: 当 PCIe 卡从插槽中移除时,硬件会通知内核。
    • 用户空间请求: 用户可以通过写入 sysfs 来请求移除设备:
      echo 1 > /sys/bus/pci/devices/0000:01:00.0/remove
  2. pci_remove_device():核心移除逻辑

    • 这个函数位于 drivers/pci/remove.c
    • 解除驱动绑定: 首先,内核会解除设备与当前绑定驱动程序的关联,调用驱动程序的 remove 函数。驱动程序在 remove 函数中需要释放所有与设备相关的资源,例如注销中断处理程序、释放 DMA 缓冲区、注销字符/块设备。
    • 禁用设备: 调用 pci_disable_device() 禁用设备,停止其 I/O 操作和 DMA 传输。
    • 释放资源: 释放设备之前分配的 I/O 端口、内存地址范围和中断请求线。
    • 从设备模型中移除: 从 PCI 设备列表中移除 pci_dev 结构体,并将其 dev 成员从 sysfs 中移除。
    • 发送 uevent 通知用户空间设备已移除。
    // 简化后的 pci_remove_device 流程 (drivers/pci/remove.c)
    void pci_remove_device(struct pci_dev *dev)
    {
        // 1. 调用驱动的 remove 函数
        if (dev->driver) {
            device_release_driver(&dev->dev); // 内部会调用 driver->remove(dev)
        }
    
        // 2. 禁用设备
        pci_disable_device(dev);
    
        // 3. 释放设备资源
        pci_release_regions(dev); // 释放 BARs 对应的 I/O 和内存区域
        // ... 释放 IRQ
    
        // 4. 从设备模型中移除
        device_del(&dev->dev);
        // ... 其他清理,例如移除从属总线
    
        // 5. 释放 pci_dev 结构体 (通过 kobject 的引用计数)
        pci_put_dev(dev);
        pr_info("PCI device %s removed.n", pci_name(dev));
    }

用户空间操作示例:

# 假设有一个 PCI 设备 0000:01:00.0
# 移除设备
echo 1 > /sys/bus/pci/devices/0000:01:00.0/remove

# 验证
lspci # 应该看不到该设备
ls /sys/bus/pci/devices/ # 确认 sysfs 路径已消失

PCI Hotplug 的成功关键在于驱动程序的正确实现。一个 Hotplug-aware 的驱动程序必须能够优雅地处理设备的拔出,包括停止所有操作、释放所有资源,并确保不会留下悬空指针或导致内核崩溃。

5. 用户空间与 Hotplug 管理

Hotplug 机制的最终目标是为用户提供方便的硬件管理能力。用户空间与内核的 Hotplug 交互主要通过 sysfsudev 实现。

5.1 sysfs 路径速览

下表总结了 Hotplug 相关的常用 sysfs 路径:

设备类型 Hotplug 相关 sysfs 路径 描述
CPU /sys/devices/system/cpu/cpuX/online 读:CPU 在线状态 (1=在线, 0=离线)。写:控制 CPU 上线/下线。
/sys/devices/system/cpu/cpuX/present 读:CPU 是否物理存在 (1=存在, 0=不存在)。
/sys/devices/system/cpu/cpuX/possible 读:CPU 是否可能被在线。
Memory /sys/devices/system/memory/memoryX/online 读:内存块在线状态。写:控制内存块上线/下线。
/sys/devices/system/memory/memoryX/state 读:内存块状态 (online, offline)。
/sys/devices/system/memory/probe 写:触发内存扫描 (在某些架构上)。
PCI /sys/bus/pci/rescan 1 触发 PCI 总线重新扫描,发现新设备。
/sys/bus/pci/devices/XXXX:XX:XX.X/remove 1 移除指定的 PCI 设备。
/sys/bus/pci/slots/slotX/power 读/写:控制 PCI Hotplug 插槽的电源(如果支持)。写 1 打开电源,0 关闭电源。
/sys/bus/pci/slots/slotX/attention 读/写:控制 PCI Hotplug 插槽的“注意”指示灯。
/sys/bus/pci/slots/slotX/hotplug 读/写:控制 PCI Hotplug 插槽的热插拔功能启用/禁用。

5.2 udev 规则和 Hotplug 自动化

当内核发送 uevent 时,udev 守护进程会捕获这些事件。udev 规则存储在 /etc/udev/rules.d/ 目录下,它们定义了当匹配到特定 uevent 时应该执行的操作。

udev 规则示例 (伪代码):

# /etc/udev/rules.d/99-hotplug-events.rules

# 当一个 CPU 上线时
ACTION=="add", SUBSYSTEM=="cpu", ATTR{online}=="1", RUN+="/usr/local/bin/cpu_online_script.sh %k"

# 当一个 PCI 设备添加时
ACTION=="add", SUBSYSTEM=="pci", ATTR{vendor}=="0x10de", ATTR{device}=="0x13ba", RUN+="/usr/local/bin/nvidia_card_added.sh %k"

# 当一个 PCI 设备移除时
ACTION=="remove", SUBSYSTEM=="pci", RUN+="/usr/local/bin/pci_removed_alert.sh %k"
  • ACTION:事件类型(addremovechange)。
  • SUBSYSTEM:设备子系统(cpumemorypci)。
  • ATTR{...}:匹配设备的 sysfs 属性。
  • RUN+:执行的外部命令或脚本。%k 代表内核设备名称。

通过 udev,系统管理员可以自动化 Hotplug 设备的配置、驱动加载、日志记录、甚至触发通知或警报。

5.3 常用 Hotplug 工具

  • lscpu 查看 CPU 架构和在线/离线状态。
  • lsmem 查看系统内存信息和内存块的在线/离线状态。
  • lspci 列出所有 PCI 设备,包括总线地址、ID 和驱动信息。
  • dmidecode 读取 SMBIOS/DMI 信息,有时可以获取 Hotplug 插槽的硬件信息。
  • udevadm monitor 实时监听内核 uevent,用于调试 Hotplug 事件。
# 实时监听 uevent
sudo udevadm monitor --kernel --property

6. Hotplug 机制的挑战与考量

尽管 Hotplug 机制功能强大,但在其实现和应用中也面临诸多挑战:

  • 驱动程序兼容性: 并非所有设备驱动都天生支持 Hotplug。一个驱动必须能够处理设备在任何时间点被移除的情况,包括释放资源、停止 DMA 操作、清理中断等。如果驱动不“Hotplug-aware”,强制移除设备可能导致系统崩溃。
  • 资源管理复杂性: 动态添加和移除设备需要复杂的资源管理,包括 I/O 端口、内存地址范围、中断请求线。内核必须确保新分配的资源不与现有资源冲突,并在设备移除时正确回收。
  • 内存移除的困难: 移除内存是所有 Hotplug 操作中最具挑战性的。由于内存中的数据可能被各种不可移动的内核数据结构、进程堆栈或设备映射占用,要确保所有页都可迁移或空闲非常困难。通常,只有在系统负载极低或明确知道内存块空闲的情况下才能成功移除。
  • NUMA 架构的复杂性: 在 NUMA 系统中,CPU 和内存与特定的 NUMA 节点关联。Hotplug 操作需要正确更新 NUMA 拓扑,并确保进程能够高效地访问新添加的资源。
  • 性能影响: Hotplug 操作,特别是设备扫描和资源重新分配,可能会在短时间内对系统性能产生一定影响。
  • 安全隐患: 恶意用户可能利用 Hotplug 功能插入或移除未经授权的设备,从而绕过安全机制或造成系统不稳定。因此,对 Hotplug 操作的权限控制至关重要。

7. 未来的演进

Hotplug 机制将继续演进,以适应新的硬件技术和系统需求:

  • CXL (Compute Express Link): 作为一个新的开放标准,CXL 旨在实现 CPU、内存和加速器之间的高速互连。CXL 支持更细粒度的内存 Hotplug 和设备 Hotplug,允许设备共享内存,并提供更灵活的资源池化能力。
  • 更智能的资源管理: 随着系统规模的扩大,内核需要更智能的算法来动态优化资源分配,减少 Hotplug 操作对性能的影响。
  • 高级虚拟化 Hotplug: 虚拟化环境对 Hotplug 的需求尤其强烈。未来的 Hotplug 机制将提供更强大的功能,允许虚拟机更灵活地动态调整其虚拟硬件资源。

Hotplug:现代系统的脉搏

Hotplug 机制是 Linux 内核在应对复杂多变硬件环境时所展现的工程智慧的典范。它通过 kobjectsysfsuevent 等通用基础设施,结合各子系统(CPU、内存、PCI)的特定实现,为系统提供了无与伦比的弹性和高可用性。理解 Hotplug 不仅能帮助我们更好地管理和维护现代系统,也为我们深入探索内核的设备管理和资源调度提供了宝贵的视角。尽管其实现细节复杂,但其核心思想——将硬件的物理插拔与软件的逻辑管理解耦——却是构建健壮、灵活系统的基石。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注