TLDR; 进程 A 在高版本 Linux kernel 上通过 UIO 驱动访问 NVMe 盘,其他进程 B 在不对盘本身进行访问的情况下,可能影响到进程 A 对 NVMe 盘的访问,出现 NVMe 盘无响应的神奇问题。
背景
为实现高性能存储,我们计划使用 SPDK 来管理和读写 NVMe 盘。这需要把 NVMe 盘从内核驱动解绑,而绑定为用户态驱动以供 SPDK 访问、配置和读写——我们使用了 UIO(userspace I/O) 驱动(后面讨论为什么不使用 VFIO)。
在这种背景下,盘处于用户态驱动,标准的 nvme-cli 工具就无法访问和操作 NVMe 盘,也就无法对盘做格式化等操作。为了解决这个问题,SPDK 额外提供了一个 nvme-cli工具,可以在用户态对盘完成标准 nvme-cli 工具能完成的操作。为了区分,我们后文称 SPDK 提供的 nvme-cli 工具为 spdk-nvme-cli 。
问题
在用户态驱动下,我们使用 SPDK 读写 NVMe 盘,用 spdk-nvme-cli 工具做盘的格式化操作。结果我们发现:当 SPDK 正常读写盘 A 时,我们期望对盘 B 进行格式化,却会导致盘 A 无响应。具体来说复现流程为:
- SPDK 将盘 A 纳入管理:
rpc.py create_nvme_bdev 10000:01:00.0
- 使用 spdk-nvme-cli 对盘 B 进行格式化:
spdk-nvme-cli format --lbaf="$lbaf" 10000:02:00.0
- 执行
rpc.py get_bdevs
获取 NVMe 盘的信息,SPDK 打印日志命令超时:nvme_pcie.c:1560:wait_qpair_process_completions: NVME controller(10000:01:00.0) timeout(csts.raw: 1).
问题复现后,再次重启 SPDK,盘又能恢复正常。感觉是某些状态位被改了,再次 attach NVMe 时 reset 了一遍盘就恢复正常了。
此时黑人问号脸.jpg,震惊又迷茫。但事出反常必有妖,开始抓妖。
问题定位
spdk-nvme-cli 工具 bug ?
首先第一反应就是 spdk-nvme-cli 工具有问题,这是一个必现的问题,是不是在访问盘 B 时有 bug ,不小心访问到了盘 A 并修改了盘 A 的状态。
但通过不断验证、测试和追查,我们发现了以下现象:
- 咨询公司的本地盘团队。本地盘团队已经用 spdk-nvme-cli 这个工具很长时间了,但没有出过我们遇到的这种必现问题;
- 我们在同一机型的不同机器上无法复现问题,两种的机器差异只在 NVMe 盘的厂商不同。能复现问题的机器上的盘为 Intel NVMe 盘,未出问题的盘为 Samsung NVMe 盘;
- 把我们的 SPDK 组件和 spdk-nvme-cli 工具部署到本地盘团队使用的机型上也没有复现问题,该机型上的盘也是 Intel 盘,但是盘的型号以及固件版本和我们使用的机型上的 Intel 盘不一样。
通过上面的现象,我们基本可以排除 spdk-nvme-cli 工具存在 bug 而误操作其他盘的可能。我更偏向怀疑是 Intel 盘的固件存在问题:难道是 spdk-nvme-cli 的工具格盘操作可能触发了 Intel 盘固件的 bug 。
固件 bug ?
硬盘的固件不开源,没法看。我们只能先找源头——到底是什么操作触发了固件的 bug。接下来就是传统的 gdb 环节...(此处省略一万字)。gdb 过程中发现一定是在 spdk-nvme-cli 进程退出后,才会复现问题。然后就不断二分法打断点,执行后直接 kill 进程。最后发现在 spdk-nvme-cli 代码的pci_uio_alloc_resource
函数中执行 open(devname, O_RDWR)
,然后 spdk-nvme-cli 工具退出(这里手动 kill)就能复现问题。
根据上面的现象,进一步猜测是进程退出时 close 了这个 fd 的才触发问题。简单编写一个验证程序如下,直接打开 UIO 设备对应的文件,再 close 这个 fd,就能完美复现问题。不同的是,现在只会影响到/dev/uio0
对应的设备,其他设备不会被影响。这是因为 spdk-nvme-cli 在启动时会 probe
所有设备,也就会 open
并且 close
所有 UIO 设备。重大发现!
#include <stdio.h>
#include <errno.h>
#include <fcntl.h>
int main() {
int fd = 0;
char *devname = "/dev/uio0";
fd = open(devname, O_RDWR);
if (fd < 0) {
printf("Cannot open %s: %s\n", devname, strerror(errno));
exit(1);
}
close(fd);
return 0;
}
UIO 驱动问题
至此,方向很明确了——第一步看 UIO 驱动的 open
和 close
分别做了什么,在 Linux Kernel 中对应源文件:
- drivers/uio/uio_pci_generic.c
- drivers/uio/uio.c
UIO 通用驱动注册的文件操作函数定义在struct file_operations uio_fops
实例中,分别对应 uio_open
和 uio_release
,两者对应的操作看不出异常:uio_open
申请一些资源并初始化,uio_release
释放资源。
static int uio_release(struct inode *inode, struct file *filep)
{
int ret = 0;
struct uio_listener *listener = filep->private_data;
struct uio_device *idev = listener->dev;
mutex_lock(&idev->info_lock);
if (idev->info && idev->info->release)
ret = idev->info->release(idev->info, inode);
mutex_unlock(&idev->info_lock);
module_put(idev->owner);
kfree(listener);
put_device(&idev->dev);
return ret;
}
因为在 release 时才触发问题,我们重点看下uio_release
的实现。uio_release
在释放资源后会调用特定驱动注册的 idev->info->release(idev->info, inode)
函数。该信息在 uio_pci_generic.c 的 probe()
函数中注册,通过阅读代码可以看到,最终调用了 uio_pci_generic.c 文件中定义的 release()
函数,具体的定义如下。该函数只做了一件事情,主动清除 PCIe 设备配置空间中 Bus Master 标志位。Bus Master 标志位在 PCIe Spec 中说明了其作用,简单来说就是是否允许其设备发起 DMA 操作。
Bus Master Enable – Controls the ability of a PCI Express agent to issue memory and I/O read/write requests. Disabling this bit prevents a PCI Express agent from issuing any memory or I/O read/write requests. Note that as MSI interrupt messages are in-band memory writes, disabling the bus master enable bit disables MSI interrupt messages as well.
static int release(struct uio_info *info, struct inode *inode)
{
struct uio_pci_generic_dev *gdev = to_uio_pci_generic_dev(info);
/*
* This driver is insecure when used with devices doing DMA, but some
* people (mis)use it with such devices.
* Let's at least make sure DMA isn't left enabled after the userspace
* driver closes the fd.
* Note that there's a non-zero chance doing this will wedge the device
* at least until reset.
*/
pci_clear_master(gdev->pdev);
return 0;
}
上面这段注释很是引入注目,翻译过来大概就是说:UIO 驱动对于支持 DMA 的设备来说不安全,但是有些人就是不听话/不懂,还是把它用来驱动支持 DMA 的设备。所以,为了安全,在你释放设备时,我帮你禁用一下设备的 DMA(你得谢谢我)。最后好心提醒你一下,这样可能会导致设备 hang 住,要记得 reset 设备噢。
但是这个实现存在一个很严重的问题,UIO 驱动层没有做引用计数,任意一个进程 open
并且 close
UIO 的设备文件,都会导致其他进程访问 UIO 驱动 NVMe 盘无响应。对此,我只能说:“听我说谢谢你,因为有你,温暖了四季!”。不过啊,问题终于定位清楚了!
进一步实锤
按照我们的分析,在 NVMe 盘处于内核驱动时,我们通过命令将 PCIe bus master disable 掉,此时该盘应该也无法响应读写请求。
根据 PCIe Spec,bus master bit 位于配置空间 PCI Header 的 04H 偏移的 bit 2 位置。我们首先读出 04H 开头的 4 字节内容,再 disable bus master 后写回。0406
展开为二进制 0100_0000_0110,表示:
- Memory Space Enable
- Bus Master Enable
- Interrupt Disable
我们将其修改为 0402
后写入,再通过 lspci
验证看到写入正确,BusMaster 标记为-
。
# setpci -s 10001:02:00.0 04.w
0406
# setpci -s 10001:02:00.0 04.w=0402
# lspci -s 10001:02:00.0 -vv
Control: I/O- Mem+ BusMaster- SpecCycle- MemWINV- VGASnoop- ParErr- Stepping- SERR- FastB2B- DisINTx+
此时再用 fio 对盘进行访问,就无法响应读写请求了。setpci -s 10001:02:00.0 04.w=0406
后又恢复正常。
定位到这一步,我们实际上还存在以下问题:
- 为什么本地盘机器上的环境没有复现问题?
对比了一下两者的区别,本地盘的机器上使用的 Linux Kernel 3.10 版本,而这个在 release 时 disable PCIe bus master 标志位的 patch 是在 Linux kernel v5.1 版本中提上去的。
- 为什么同机型的 Samsung NVMe 盘没有影响?
首先,按我的理解,Bus master 标志是 PCIe 层面的机制,如果 Bus master 位被 disable 掉,对应的设备发起的 DMA 请求应该会被 PCIe 总线拒绝掉才对。(PCIe 我是现学的,可能说得不太准确,纯个人理解)
其次,在 NVMe Spec[4] 中明确指出了,当 Bus master 位被置为 0 时,NVMe 的controller 要停止 active DMA engines 并且进入 idle 状态。
Enables the controller to act as a master for data transfers. When set to ‘1’, bus master activity is allowed. When cleared to ‘0’, the controller stops any active DMA engines and returns to an idle condition.
从我们验证现象来看,Intel NVMe 才是标准按照 NVMe Spec 实现的。如果 Samsung NVMe 也是按照 NVMe Spec 标准实现的,那难道 NVMe 还可以通过非 DMA 的操作去工作吗?这里需要进一步去验证。
解决方案
- 降低机器上的内核版本。最直接的方式,但是不太合理,为了芝麻丢西瓜。
- 在 SPDK 内实现下发格式化等操作,实现单进程对 UIO 驱动的盘的访问。这种方案修改比较大,并且没有很好的支持压测的方案。
- 在内核 uio_pci_generic 中实现引用计数,并尝试向主线提交 patch。—— 这个相对来说麻烦一些。
- 编译一个老版本——未注册 release 回调的 UIO kernel module,默认加载老版本的 UIO kernel module。
- 替换 UIO 为 VFIO 驱动。这是最好的解决方案,但是我们一开始明知道 UIO DMA 不安全的情况下,依然采用 UIO。这是因为老代次的 CPU 不支持 VFIO 驱动下设备的热插拔,在故障换盘的情况下插拔盘会导致机器宕机。
因此,目前看最好的方案是方案 4,后续尝试从这个方向解决问题。同时在新代次机型上采用更安全的 VFIO 驱动。
总结
从怀疑 spdk-nvme-cli 工具有 bug,到 Intel NVMe 固件有 bug (冤枉 Intel 大佬们了,我道歉),一路追查,最后发现是高版本 Linux Kernel 中 UIO 驱动有“高级特性”。
这个坑有点冤,也有点不冤。不管怎么样,这是一次很好的学习过程。通过这个问题,深入理解了 UIO 驱动的原理,整体上了解了 PCIe 的原理和工作过程,也学习了 SPDK NVMe 的驱动原理以及 NVMe 盘本身的工作过程。
参考资料 & 致谢
- The Userspace I/O HOWTO
- PCI Express Base Specification Revision 1.0
- # PCIe(二) —— 配置空间 : 这个系列文章非常好 !
- NVM Express 1.0e. January 23, 2013
cc
能说明一下异常的具体内核版本嘛
Dingmos
@cc : 文章中提到了,Linux kernel v5.1