PCIe 与 USB 驱动模型

本页目录

PCIe 和 USB 都能枚举设备,但给驱动的控制方式不同。PCI 驱动通常直接操作 BAR 寄存器并建立 DMA 队列;USB 接口驱动把请求提交给 USB 核心,由主机控制器执行总线事务。两者共同的难点是:设备和请求可以异步消失,资源必须在最后一个使用者退出后释放。

1. PCI:配置空间、BAR 与 DMA 是三件事

配置空间提供设备标识、能力和资源窗口信息。系统扫描拓扑并保留或分配可用资源;固件仍可描述根桥、配置空间窗口等平台信息。完整地址例如 0000:03:00.0,跨多个 domain 时只写 bus:device.function 不足以唯一定位。

接口用途不应混淆的对象
配置空间身份、能力、BAR 配置普通设备业务寄存器
BAR 资源寄存器或设备内存的 MMIO / I/O 窗口主存 DMA 缓冲区
DMA 地址设备读取或写入主存时使用的地址CPU 虚拟指针

普通 endpoint 配置头有六个 BAR 槽位,64 位 BAR 占相邻两个槽,桥的布局又不同。因此不能把内核 pci_dev.resource 伪写成固定六项并据此遍历所有资源。通过 pci_resource_start/len/flags() 等 API 解释资源,并使用适当的映射和 I/O 访问器。

pci_iomap() 一类映射返回的 __iomem 地址不能当普通 RAM 随意解引用或 memcpy。使用 ioread32() / iowrite32() 等与设备协议匹配的访问方式;MMIO 顺序、posted write 的完成要求还要查硬件及平台规则。

2. probe 的资源依赖

典型初始化包括使能设备、申请所需 BAR、设置硬件实际支持的 DMA mask、映射寄存器、分配队列和中断,然后注册对外接口。具体顺序受硬件复位、固件加载及中断屏蔽要求影响,不能把一份通用调用列表直接当可运行驱动。

启用 bus mastering 允许设备发起事务,应在缓冲区和设备状态准备妥当的阶段处理。对外发布接口之前,错误返回路径必须能逆序撤销已取得的资源。新驱动通常用 dev_pm_ops 表达电源管理,而不是照抄旧版本 pci_driver 的 suspend/resume 字段。

PCI 驱动文档解释了初始化与资源接口;编写时应对照目标内核头文件,保持 managed/unmanaged 资源的释放责任一致。

3. MSI/MSI-X:申请上限不是返回数量

向量预算:按实际返回数量建立后续资源

MSI/MSI-X 用设备发出的消息触发中断。MSI-X 表最多支持 2048 项,但设备实现、平台及可用资源会限制实际数量。请求 1 到 16 个向量可能只得到 1、4 或 8 个。

下面是初始化阶段的局部示例,并非完整驱动:

int nvec = pci_alloc_irq_vectors(pdev, 1, 16, PCI_IRQ_MSIX);
if (nvec < 0)
    return nvec;

/* 仅遍历 [0, nvec),随后逐个安装处理函数并检查错误。 */
for (int i = 0; i < nvec; ++i) {
    int irq = pci_irq_vector(pdev, i);
    dev_dbg(&pdev->dev, "vector %d uses IRQ %d\n", i, irq);
}

i 是设备的向量索引,irq 是 Linux IRQ 号;两者不能互换。向量数量也不等于队列或 CPU 数量:多个队列可以共享中断,亲和性决定路由而不是永久绑定某个工作线程。

拆除时先阻止设备继续产生相关中断,正确同步处理函数,再按选用 API 的约定释放处理函数和向量。部分 managed PCI 接口具有自动清理行为,不能同时无条件手工释放。见 MSI 驱动指南。

八个队列只拿到三个向量,怎样分配

假定设备允许多个完成队列路由到一个 MSI-X 向量。我们选用一项明确的驱动策略:有两个以上向量时,把索引 0 留给管理事件,其余向量轮流承接数据队列;只有一个向量时,管理事件与所有数据队列共用索引 0。是否支持这种路由必须由硬件确认,不能由操作系统返回数量推断。

实际向量数管理事件数据队列 0~7 对应的向量索引
100、0、0、0、0、0、0、0
301、2、1、2、1、2、1、2
501、2、3、4、1、2、3、4
def route_queues(queue_count, linux_irqs):
    if queue_count < 0 or not linux_irqs or len(set(linux_irqs)) != len(linux_irqs):
        raise ValueError("需要有效队列数与不重复的向量 IRQ 映射")
    nvec = len(linux_irqs)
    indices = [0 if nvec == 1 else 1 + q % (nvec - 1)
               for q in range(queue_count)]
    groups = {i: [] for i in range(nvec)}
    for q, index in enumerate(indices):
        groups[index].append(q)
    return indices, groups

indices, groups = route_queues(8, [48, 113, 207])
assert indices == [1, 2, 1, 2, 1, 2, 1, 2]
assert groups == {0: [], 1: [0, 2, 4, 6], 2: [1, 3, 5, 7]}
for nvec in (1, 3, 5):
    for count in (0, 1, 8, 17):
        indices, groups = route_queues(count, list(range(100, 100 + nvec)))
        assert sorted(q for group in groups.values() for q in group) == list(range(count))
        assert all(0 <= i < nvec for i in indices)
assert route_queues(8, [48])[0] == [0] * 8

这里 IRQ 113 的处理函数要检查队列 0、2、4、6 的完成状态,不能用“收到一次中断”代替“恰好完成一个请求”。中断合并、多个完成条目以及队列继续产生工作,都要求按设备队列协议推进消费位置。共享一个设备向量也不等于使用传统 INTx 的共享线路模式,两种“共享”应分开理解。

初始化失败也要按实际建立的资源回滚。若已分配三个向量,但第三个 request_irq() 失败,就只释放前两个已安装的处理函数,随后释放向量;队列和 DMA 缓冲仍按自己的取得顺序处理。进入正常运行后,除了同步硬中断,还要排空处理函数已经派生的工作,否则释放 IRQ 并不能证明工作项不再访问队列。

4. USB:驱动常绑定 interface

一个 USB device 可以提供多个 configuration,选中的 configuration 包含若干 interface;interface 还有 alternate settings,每个设置描述一组 endpoint。摄像头的音频与视频功能因此可以由不同接口驱动处理。

常见 usb_driver.probe() 接收的是 usb_interface *。驱动应检查所绑定接口当前设置的 endpoint,而不是假设“第一个 endpoint 永远是 bulk IN”。端点零是设备的默认控制端点,不应套用成每个 interface 独有的端点。

传输类型适合的任务服务特征
Control标准请求和配置命令包含 setup 等阶段
Bulk存储及大块数据可靠传输,无固定带宽/延迟保证
Interrupt键盘等小量周期数据主机按服务间隔调度
Isochronous音视频流按时服务,出错通常不重传

这里的 interrupt 不是外设任意时刻直接打断 CPU。USB 总线传输由主机调度,HCD 的 CPU 中断是另一层机制。端口连接检测随 USB 速率与协议不同,也不能统一解释为 D+ 电平变化。

5. URB 是异步请求,不是数据已经发送的证明

USB 请求:接受请求与函数返回不是同一时刻 驱动准备 URB 与缓冲区 设置 endpoint、方向、长度及完成回调 1 核心接受请求,进入在途阶段 核心负责在途请求,驱动保留资源寿命 2 HCD 执行并报告完成 成功、错误或取消都会产生完成状态 3 完成回调检查 status / actual_length 回收、唤醒等待者,或在允许条件下重新提交 4 提交失败由调用方处理;提交成功不等于传输已完成。
usb_submit_urb 结果所有权与后续责任
返回错误请求未成功交给核心,调用方处理错误和资源
返回 0请求已接受,缓冲区与上下文必须活到完成

提交前先准备好端点、缓冲区和回调上下文。提交成功不意味着设备已经结束使用数据。

提交成功的请求可能很快完成,回调甚至可能在 usb_submit_urb() 返回之前发生;因此回调会使用的上下文必须在调用提交接口前准备好。图中“接受请求”发生在提交调用期间,不能把函数返回当成回调开始的屏障。

先取得回调引用,再提交请求

驱动私有请求对象与 URB 本身是两个对象。USB 核心对 URB 的管理不会自动为任意 urb->context 指针取得引用。下面用一个单次请求说明配对方式:调用者先持有一份私有对象引用,提交前再预留一份给完成路径。成功提交后由回调释放这份引用;提交失败、不产生此次完成回调时,由提交方撤销预留。

回调先完成或后完成,都需要提交前预留引用 提前完成 预留:2 回调:1 调用者退出:0 稍后完成 预留:2 调用者退出:1 回调:0 数字是驱动私有对象的引用数;提交失败时由调用方撤销回调预留。
class Request:
    def __init__(self):
        self.refs = 1
        self.ready = True
        self.callback_owned = False
        self.completions = 0
        self.freed = False

    def put(self):
        assert self.refs > 0 and not self.freed
        self.refs -= 1
        self.freed = self.refs == 0

    def reserve_callback(self):
        assert self.ready and not self.freed and not self.callback_owned
        self.refs += 1
        self.callback_owned = True

    def finish(self):
        assert self.ready and self.callback_owned and not self.freed
        self.completions += 1
        self.callback_owned = False
        self.put()

    def rollback_failed_submit(self):
        assert self.callback_owned and self.completions == 0
        self.callback_owned = False
        self.put()


def one_request(mode):
    r = Request()
    r.reserve_callback()                 # 必须在进入 submit 之前
    if mode == "failure":
        result = -1                      # 失败提交没有此次完成回调
    else:
        result = 0
        if mode == "early":
            r.finish()                   # 回调先于提交函数返回
    if result:
        r.rollback_failed_submit()
    assert not r.freed                    # 调用者自己的引用仍有效
    r.put()                              # 调用者完成本次访问
    if mode == "late":
        assert not r.freed                # 回调引用继续保护对象
        r.finish()
    assert r.freed and r.refs == 0
    assert r.completions == (mode != "failure")

for mode in ("failure", "early", "late"):
    one_request(mode)

这段模型只核算单次提交的私有对象寿命;真实并发代码还需要合适的原子引用、状态同步,以及独立的 URB/缓冲区管理。如果回调重新提交,必须为新一次请求建立完整的交接,而不能把旧请求完成误认为整个会话结束。单独保存 submitted = true 并在提交返回后才增加引用,在提前完成路径上已经太晚。

状态成功、字节完整、业务完整要分别判断

下面讨论 bulk IN 等非等时传输。请求读 4096 字节,成功返回 512 字节可以是合法短读;如果上层协议要求一个固定 4096 字节记录,这次 USB 成功仍不代表记录完整。反过来,一次 URB 也可能承载多个业务记录,记录边界需要协议解析器维护。

完成结果可以确定的事实上层还要决定什么
status=0,actual_length=4096本次实际收到 4096 字节内容、校验及记录边界是否有效
status=0,actual_length=512未把短读作为错误,收到 512 字节一条短响应,还是需要继续拼接
status 为错误,actual_length=512出错前可能已有部分数据丢弃、保留前缀或重新同步协议
actual_length=0本次没有数据字节协议是否允许空响应,不能直接等同文件 EOF

非等时读取可用 URB_SHORT_NOT_OK 要求把短读报告为错误,但它不会补齐剩余字节,也不会验证业务记录。等时传输则要逐项查看 iso_frame_desc 的状态和长度,不能只用 URB 总体状态推断所有帧成功。字段含义见 USB Host Side API。

完成时按顺序检查:

  1. 用 urb->status 判断结果。
  2. 用 actual_length 判断实际长度,成功不保证一定等于请求长度。
  3. 需要重提时,先确认没有进入断开、关闭或暂停状态。

回调处于不可睡眠上下文,复杂阻塞工作应交给合适工作机制;否则退出过程中可能重新启动传输。

  • usb_unlink_urb() 请求异步取消,不能据此立即释放仍可能被回调访问的对象。
  • usb_kill_urb() 等待请求退出及完成回调结束,调用位置必须允许睡眠,且不能持有回调需要的锁。
  • USB anchor 可跟踪一组 URB,便于停止成组在途请求,但仍需管理驱动自己的打开文件、工作项和引用。

接口规则和取消语义详见 URB 文档。

在请求飞行或回调执行期间尝试释放 buffer。取消请求、回调退出与释放资格分别显示;再加入一个回调需要的锁,观察同步等待为何可能卡住。

正在呈现知识画面
请求取消以后,谁还可能使用缓冲区?

usb_unlink_urb 是取消请求,不能替代完成回调结束。同步 kill 等待请求和回调退出,等待时不能持有回调所需锁。

6. 热拔插与电源管理都要求先停止请求

disconnect/remove 应先标记离线并阻止新请求,再撤销异步请求、等待回调、注销功能接口并释放资源。用户可能仍持有设备文件;后续操作应看到合适的错误,而不是访问已释放私有数据。物理设备已经拔掉,也意味着不能依赖“再写一次寄存器一定成功”完成清理。

提交引用:空闲挂起与系统 suspend 分开处理

runtime PM 处理空闲期间的挂起和恢复,系统 suspend 则协调整机状态。提交 I/O 前取得正确的 PM 使用引用,失败时处理返回值;传输完成后按约定释放引用。USB 的自动挂起需要接口驱动与核心协作,PCI 也可能在恢复后重建设备状态。参见电源管理。

继续阅读

  • 设备模型:发现、匹配与对象引用。
  • DMA API:PCI 队列背后的地址和缓冲区交接。
  • 内核源码入口:drivers/pci/、drivers/usb/core/、include/linux/usb.h;实际驱动的错误回滚和 remove 路径应与 probe 一起阅读。