返回

Linux驱动PCIe iomem读写崩溃?优雅处理告别段错误

Linux

Linux 驱动开发:优雅处理 PCI iomem 读写错误,告别段错误

搞嵌入式 Linux 开发,特别是跟 FPGA 打交道的时候,PCIe 总线是个绕不开的话题。数据传输快、接口标准,用起来确实方便。但有时候,方便背后也藏着坑。

问题来了:PCI 读写直接崩?

想象这么个场景:你手头有一块基于 NXP LS1043 CPU 的嵌入式板子,跑着定制的 Linux 4.14 内核。CPU 通过 PCIe 3.0 x1 连接了一块 FPGA。这块 FPGA 很“实在”,只用了 BAR 0 空间,把所有内部寄存器都映射到了这里。

问题就出在这个 BAR 0 上——并非所有地址都是有效的!如果你尝试通过 Linux 驱动去 ioread 一个无效的 BAR 0 地址,FPGA 那边会因为收不到响应而产生 PCIe 事务超时 (Transaction Timeout)。更糟的是,CPU 这边的 ioread 操作并不会返回一个错误码,而是直接引发一个段错误 (Segmentation Fault),把你的驱动甚至整个系统搞挂掉。

尝试启用 PCIe 高级错误报告 (AER, Advanced Error Reporting) 机制?好像也没用,这些错误并没有被 AER 捕获到,或者说 AER 的设置不太对劲。

那有没有办法,在驱动程序里捕获这种访问无效 iomem 地址导致的错误,既不触发段错误,又能避免对每个地址都做显式的范围检查呢?毕竟,每次读写前都加个 if 判断地址范围,代码啰嗦不说,效率也可能受影响。

为什么会这样?刨根问底

要解决问题,得先搞清楚为啥会这样。

  1. PCIe 事务超时 (Transaction Timeout): 当 CPU 通过 PCIe 总线向 FPGA 的 BAR 0 空间发起读请求时,FPGA 作为 PCIe Endpoint 设备,需要在规定的时间内响应。如果访问的地址在 FPGA 内部是无效的、没有逻辑对应,FPGA 就无法完成这个请求,最终导致 PCIe 总线层面的事务超时。

  2. 同步错误 (Synchronous Error):ioread 这类内存映射 I/O (MMIO) 的访问,在很多 CPU 架构上(包括 LS1043 使用的 ARM 架构),它会被直接翻译成 CPU 的 load/store 指令。当这个指令执行时,如果总线层面(这里是 PCIe)发生了错误,比如超时,这个错误会被 CPU 看作是一个同步的总线错误 (Bus Error)。同步错误意味着错误发生在指令执行的当时当刻,CPU 往往会立即产生一个异常 (Exception)。

  3. 内核处理与段错误 (Segfault): Linux 内核捕捉到 CPU 抛出的这个同步总线错误异常后,会进行处理。对于发生在内核态(驱动程序就在内核态运行)的这类访问错误,内核通常没有特别好的办法去“修复”它或者优雅地通知驱动程序。很多时候,默认的处理方式就是认为发生了严重的内存访问违例,最终转化为一个内核 oops 或者直接发送 SIGSEGV 信号给当前进程——但由于发生在内核态,往往表现为系统崩溃或特定模块的致命错误日志,看起来就像驱动代码本身的段错误。

  4. AER 为何“失灵”? AER 主要是用来报告异步的 PCIe 错误事件,比如链路错误、CRC 校验失败等。事务超时虽然也是一种错误,但它导致的是 CPU 指令执行失败进而产生同步异常。AER 机制可能可以配置为报告某些类型的超时,但这取决于 PCIe 控制器硬件、RC (Root Complex) 的实现以及内核驱动 (pcieport) 的配置。在你的 4.14 内核和特定硬件组合下,很可能:

    • AER 默认就没有配置为捕获和报告这种类型的超时。
    • 或者即使报告了,它也是异步发生的,CPU 的同步异常已经抢先一步让系统挂掉了。
    • 某些硬件的 AER 实现可能并不完善。

简单说,ioread 一个无效的 PCIe BAR 地址,触发了底层的同步总线错误,CPU 扔了个异常,内核没接住或者不知道怎么优雅处理,最后就“段错误”了。

怎么办?几条路子

既然知道了原因,就可以对症下药了。下面提供几种可能的思路和解决方案,复杂度各不相同。

方案一:死马当活马医?尝试调优 AER

虽然前面说 AER 可能无效,但它是 PCIe 标准的错误处理机制,还是值得再争取一下。

  • 原理与作用: AER (Advanced Error Reporting) 是 PCIe 规范定义的一套扩展能力,允许设备和系统报告详细的错误信息,包括可纠正错误 (Correctable Errors) 和不可纠正错误 (Uncorrectable Errors)。理论上,配置得当的 AER 应该能捕获到事务超时这类不可纠正的错误,并通过中断或其他方式通知系统。驱动可以注册 AER 回调函数来处理这些错误事件。

  • 操作步骤与配置:

    1. 检查内核配置: 确保你的内核编译时开启了 AER 支持。检查 .config 文件中是否有 CONFIG_PCIEAER=y。如果没有,需要重新配置编译内核。
      grep CONFIG_PCIEAER /path/to/your/kernel/.config
      
    2. 检查设备 AER 能力: 使用 lspci 查看你的 FPGA 设备以及它连接的根端口 (Root Port) 或交换机端口 (Switch Port) 是否支持 AER。
      # 找到你的 FPGA 设备的总线号,例如 0000:01:00.0
      lspci
      # 查看详细信息,重点关注 Capabilities -> Advanced Error Reporting
      sudo lspci -vvv -s 0000:01:00.0
      # 同时也要检查它上游的端口
      sudo lspci -vvv -s 0000:00:00.0 # 假设这是根端口
      
    3. 检查/配置 AER 服务: 内核提供了 sysfs 接口来查看和控制 AER。路径通常在 /sys/bus/pci/devices/<device_id>/ 下。
      # 查看 AER 相关文件
      ls /sys/bus/pci/devices/0000:01:00.0/aer_*
      # 可能需要配置错误掩码 (uncor_mask, cor_mask) 和严重性 (uncor_severity)
      # 例如,尝试取消屏蔽 "Completion Timeout" 错误
      # 注意:具体哪些位对应什么错误,需要查阅 PCIe 规范和你的硬件文档
      # echo <new_mask> > /sys/bus/pci/devices/0000:01:00.0/aer_uncor_mask
      
    4. 加载 AER 驱动: 确保 pcieaer 模块(或者内核内置的 AER 服务)被加载。
    5. BIOS/固件设置: 有些系统的 BIOS/UEFI 固件里有关于 PCIe AER 的全局开关,也需要检查是否已启用。
  • 额外建议:

    • 查阅 LS1043 的技术手册和你的 FPGA PCIe IP Core 的文档,了解它们对 AER 和事务超时的具体支持情况。
    • 即使配置好了,AER 是异步通知。它可能能在后台记录错误,但不一定能阻止同步异常导致的 ioread 崩溃。你需要一个 AER 错误处理函数 (error_detected, mmio_enabled, slot_reset, resume) 来响应该事件,但这个回调函数很可能在 ioread 已经崩溃之后才被调用。
  • 进阶使用:

    • 你可以编写一个内核模块,实现 struct pci_error_handlers 并通过 pci_set_error_handler() 注册,来定制化 AER 事件的处理逻辑。但这通常用于更复杂的错误恢复场景,比如热插拔或设备复位。

方案二:试试带错误返回的 PCI 配置空间读写(可能不适用)

Linux 内核提供了一些用于读写 PCI 配置空间的函数,它们在出错时会返回错误码,而不是崩溃。

  • 原理与作用: pci_read_config_byte/word/dwordpci_write_config_byte/word/dword 这一族函数是专门设计用来访问 PCI 设备配置空间的(前 256 或 4096 字节)。内核内部实现会处理访问失败的情况,比如设备不存在或者访问超时,并返回一个错误码 (如 -EIO)。

  • 代码示例:

    #include <linux/pci.h>
    #include <linux/errno.h>
    
    int my_read_config(struct pci_dev *pdev) {
        u32 value;
        int ret;
    
        // 读取配置空间偏移量为 0x04 的寄存器 (Command Register)
        ret = pci_read_config_dword(pdev, PCI_COMMAND, &value);
        if (ret) {
            // 读取失败,ret 包含错误码
            dev_err(&pdev->dev, "Failed to read config space at offset %x, error %d\n", PCI_COMMAND, ret);
            return ret;
        }
    
        // 读取成功,value 包含寄存器的值
        dev_info(&pdev->dev, "Successfully read config space: PCI_COMMAND = 0x%x\n", value);
        return 0;
    }
    
  • 重要提示: 这个方案 不适用于 你遇到的问题!因为 ioread 访问的是通过 BAR 映射的 内存空间 (iomem),而 pci_read_config_* 系列函数访问的是独立的 配置空间。FPGA 的内部寄存器既然映射在 BAR 0,就必须通过 ioread/iowrite (或类似的 readl/writel 等) 来访问,而不是配置空间函数。提及这个方案主要是为了区分概念,避免混淆。

方案三:曲线救国?read_poll_timeout 系列宏

如果你的 FPGA 可以在发生内部错误或准备好数据之前,通过某个状态寄存器来指示,那么可以使用轮询的方式来避免直接读取可能导致超时的数据寄存器。

  • 原理与作用: 内核提供了 read_poll_timeout, read_poll_timeout_atomic, readx_poll_timeout, readx_poll_timeout_atomic 等宏(在 linux/iopoll.h 中定义)。它们允许你轮询一个 I/O 内存地址,直到读取到的值满足某个条件,或者超时发生。这可以将一个可能阻塞或崩溃的读操作,变成一个带超时的、可控的轮询操作。

  • 代码示例:

    #include <linux/iopoll.h>
    #include <linux/delay.h> // for usleep_range
    
    // 假设 FPGA BAR 0 映射到 dev->regs
    // 假设 0x10 是状态寄存器,bit 0 为 1 表示数据就绪,bit 1 为 1 表示错误
    #define STATUS_REG_OFFSET 0x10
    #define STATUS_READY_MASK (1 << 0)
    #define STATUS_ERROR_MASK (1 << 1)
    // 假设 0x20 是可能导致超时的数据寄存器
    #define DATA_REG_OFFSET 0x20
    
    int my_read_fpga_data(struct my_device *dev) {
        u32 status;
        u32 data;
        void __iomem *status_addr = dev->regs + STATUS_REG_OFFSET;
        void __iomem *data_addr = dev->regs + DATA_REG_OFFSET;
        int ret;
    
        // 轮询状态寄存器,等待就绪或错误,超时时间 100ms (100000us)
        // 每隔 10-20us 轮询一次
        // condition: (status & STATUS_READY_MASK) || (status & STATUS_ERROR_MASK)
        ret = readl_poll_timeout(status_addr, status,
                                 (status & STATUS_READY_MASK) || (status & STATUS_ERROR_MASK),
                                 10, 100000); // sleep_us=10, timeout_us=100000
    
        if (ret == -ETIMEDOUT) {
            dev_err(dev->pci_dev, "Timeout waiting for FPGA status ready/error\n");
            return -ETIMEDOUT;
        }
        if (ret) { // 其他可能的错误(虽然这里不太可能)
            dev_err(dev->pci_dev, "Error polling FPGA status: %d\n", ret);
            return ret;
        }
    
        // 读取最终的状态值 (宏内部已经读到了 status 变量里)
        if (status & STATUS_ERROR_MASK) {
            dev_err(dev->pci_dev, "FPGA reported an error (status=0x%x)\n", status);
            // 可能需要读取额外的错误信息寄存器等
            // ...
            return -EIO; // 返回 I/O 错误
        }
    
        if (status & STATUS_READY_MASK) {
            // 状态表示数据就绪,现在可以安全地读取数据寄存器了
            // (假设此时读取数据寄存器不会超时)
            data = readl(data_addr);
            dev_info(dev->pci_dev, "FPGA data read successfully: 0x%x\n", data);
            // 处理数据...
            return 0; // 成功
        }
    
        // 逻辑不应该走到这里,但作为防御
        dev_warn(dev->pci_dev, "Unexpected FPGA status after poll: 0x%x\n", status);
        return -EIO;
    }
    
  • 前提条件: 这个方案 严重依赖于 FPGA 的设计 。FPGA 必须提供一个可靠的状态寄存器,能在你访问有风险的数据寄存器之前就告诉你操作是否安全或已失败。如果 FPGA 没有这样的机制,这个方法就不适用。

  • 额外建议:

    • 轮询间隔 (sleep_us) 和超时时间 (timeout_us) 需要根据实际硬件的响应时间来仔细调整。太短的间隔会增加 CPU 负担,太长的超时会影响性能。
    • readl_poll_timeout 内部使用的是 usleep_rangeudelay,这在非原子上下文中是安全的。如果在中断处理程序或其他原子上下文中需要轮询,应使用 readl_poll_timeout_atomic

方案四:探索架构特定的 M M I O 容错机制 (可能需要内核定制)

某些 CPU 架构(或者特定的 SoC 实现)可能提供了一些机制来更温和地处理总线错误,而不是直接抛出致命异常。

  • 原理与作用: 这通常涉及到架构相关的内存管理单元 (MMU) 特性或特殊的 CPU 指令。例如,一些架构允许将某些内存区域标记为“允许出错”(fault tolerant),访问失败时可能会返回一个固定的错误值或触发一个可恢复的异常。但这在 Linux 通用内核中并不常见,通常需要 BSP (Board Support Package) 或内核开发人员针对特定硬件进行深度定制。

  • 操作步骤/代码:

    • 研究文档: 深入研究 LS1043 处理器和其使用的 ARM 架构(可能是 Cortex-A53 或 A72)的技术参考手册 (TRM),查找关于总线错误处理、外部中止 (External Aborts)、MMU 错误处理的相关章节。看是否有特殊的页表属性或系统控制寄存器可以改变对某些内存区域访问失败时的行为。
    • 内核源码挖掘: 在内核源码树的 arch/arm64/ (或 arch/arm/ 如果是 32 位) 目录下,特别是 mm/fault.c 等文件中,查找处理数据中止 (Data Abort) 的相关代码。看是否有与 PCI/MMIO 相关的特殊处理逻辑或配置选项。
    • 寻求社区/厂商支持: 在 NXP 社区论坛或联系技术支持,询问是否有针对 LS1043 处理 PCIe MMIO 访问超时的推荐方法或内核补丁。
  • 额外建议:

    • 这通常是最复杂、最不可移植的方案,可能需要深入的内核和体系结构知识。
    • 修改内核核心的错误处理路径风险很高,可能引入不稳定或安全问题。
  • 进阶技巧:

    • 某些场景下,可以考虑使用 Linux 的 probe_kernel_read() / probe_kernel_write() 函数,它们被设计用来安全地探测用户空间传入的地址是否有效。虽然主要目标是用户空间地址,但它们内部包含了一些错误处理逻辑。然而,将它们直接用于 iomem 可能并不合适或没有效果,需要仔细研究其实现并测试。不推荐直接使用。

方案五:最后的妥协:有限的地址检查或修改 FPGA

虽然最初的目标是“不进行范围检查”,但在现实面前,如果上述方案都走不通,可能需要重新考虑这个约束。

  • 原理与作用: 当硬件行为(访问无效地址导致同步致命错误)无法通过软件技巧(如 AER、特殊函数)完全规避时,只能在软件层面添加防御。

    1. 有限的范围检查: 如果你知道哪些地址范围是“危险区”,可以在驱动中仅对这些特定范围的访问进行前置检查。这比检查所有地址要高效。
      // 假设你知道 0x1000 - 0x1FFF 是无效区域
      #define INVALID_ADDR_START 0x1000
      #define INVALID_ADDR_END   0x2000 // 不包含
      
      u32 my_safe_ioread32(struct my_device *dev, unsigned long offset) {
          void __iomem *addr = dev->regs + offset;
          if (offset >= INVALID_ADDR_START && offset < INVALID_ADDR_END) {
              dev_warn_ratelimited(dev->pci_dev, "Skipping read from known invalid address range: 0x%lx\n", offset);
              return 0xFFFFFFFF; // 或者返回一个特定的错误指示值
                                // 注意:返回错误值可能需要修改调用者的逻辑
                                // 或者,如果可以,直接 return -EFAULT;
          }
          return readl(addr);
      }
      
    2. 请求 FPGA 修改设计 (治本之策): 最可靠、最干净的解决方案,通常是与 FPGA 设计者沟通,修改 FPGA 逻辑:
      • 确保全地址空间有效: 让 FPGA 对 BAR 0 内所有地址都有响应,即使是无效寄存器也返回一个预定义的值(比如 0xFFFFFFFF),而不是不响应导致超时。
      • 添加状态/错误寄存器: 设计一个专门的寄存器,CPU 可以先读取它来了解目标寄存器的状态(是否有效、是否有错误)。这基本上就是方案三的基础。
  • 额外建议:

    • 即使做了有限的范围检查,也最好配合 dev_warn_ratelimited() 打印警告,以便于追踪潜在的非法访问尝试。
    • 修改 FPGA 设计虽然可能需要更多跨团队协作,但从长远看,是解决这类硬件层面问题的根本方法。

总结一下

处理 ioread 访问无效 PCIe BAR 地址导致的段错误,确实是个棘手的问题,因为它触及了硬件行为、CPU 异常处理和内核机制的交叉点。

  • 理想情况: 修复或正确配置 AER,让标准的 PCIe 错误处理机制工作起来。但这不一定能阻止同步异常。
  • 实用主义: 如果 FPGA 支持,使用 read_poll_timeout 系列宏配合状态寄存器轮询,是比较稳妥且常用的方法。
  • 硬核探索: 研究 CPU 架构和内核底层,寻找特定的容错机制,但风险和复杂度高。
  • 最终妥协: 实现有限的地址范围检查,或者(最好的)推动 FPGA 设计层面的改进。

没有一招鲜的完美方案,具体选择哪条路,得看你的硬件平台特性、内核版本、可投入的开发资源以及对稳定性的要求。希望这些分析和思路能帮你找到适合你的解决方案。