diff --git a/README.md b/README.md index ed4d602..0bd147f 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,24 @@ [![Test](https://github.com/xnvme/devbind/actions/workflows/test.yml/badge.svg)](https://github.com/xnvme/devbind/actions/workflows/test.yml) `devbind` is a small CLI for binding and unbinding PCI devices to a -chosen kernel driver via sysfs. The typical use is moving a device -between its native driver (e.g. `nvme`) and a user space driver -framework (`vfio-pci`, `uio_pci_generic`) for DPDK/SPDK and xNVMe/uPCIe -workloads. `devbind --list` also reports the process `RLIMIT_MEMLOCK` -and warns when the soft limit is below the 64 MiB threshold those -frameworks inherit. +chosen kernel driver. The typical use is moving a device between its +native driver (e.g. `nvme`) and a user space driver framework for +DPDK/SPDK and xNVMe/uPCIe workloads. `devbind --list` also reports the +process `RLIMIT_MEMLOCK` and warns when the soft limit is below the +64 MiB threshold those frameworks inherit. + +Both **Linux** and **FreeBSD** are supported; the platform-specific +operations are handled by a platform implementation selected at runtime: + +| | Linux | FreeBSD | +|---|---|---| +| enumerate / inspect | `lspci`, sysfs | `pciconf -l`, `camcontrol` | +| user space framework | `vfio-pci`, `uio_pci_generic` | `nic_uio` | +| unbind | sysfs `driver/unbind` | `devctl detach` | +| bind (kernel driver) | sysfs `drivers//bind` | `devctl set driver` | +| bind (user space framework) | sysfs `driver_override` + `bind` | `hw.nic_uio.bdfs` + `kldload` | + +(The Linux-only `iommugroup` is reported as `None` on FreeBSD.) ## Install @@ -33,7 +45,7 @@ curl -fsSL https://raw.githubusercontent.com/xnvme/devbind/main/src/devbind/devb devbind --print-completion bash > ~/.local/share/bash-completion/completions/devbind ``` -Open a new shell (or `source` the file) and tab-completion is live: `devbind --bind ` lists `nvme vfio-pci vfio-noiommu uio_pci_generic`. +Open a new shell (or `source` the file) and tab-completion is live: `devbind --bind ` lists `nvme vfio-pci vfio-noiommu uio_pci_generic nic_uio`. ## Usage @@ -43,7 +55,7 @@ usage: devbind [-h] [--version] [--classcode CLASSCODE] [--device DEVICE] [--list] [--unbind] [--bind BIND] [--verbose] [--print-completion SHELL] -Inspect and control PCI device-driver binding in Linux +Inspect and control PCI device-driver binding on Linux and FreeBSD options: -h, --help show this help message and exit @@ -55,8 +67,8 @@ options: association. --unbind Unbind if bound. --bind BIND Unbind if bound; then bind to the given driver-name - [nvme, vfio-pci, uio_pci_generic] or to a .ko driver - file (path) + [nvme, vfio-pci, uio_pci_generic, nic_uio] or to a + driver file (path) --verbose Enable verbose logging --print-completion SHELL Print shell completion script to stdout and exit @@ -71,6 +83,14 @@ sudo devbind --bind nvme --device 0000:01:00.0 # rebind to the native driv sudo devbind --unbind --device 0000:01:00.0 # unbind without rebinding ``` +On FreeBSD, bind to `nic_uio` instead of `vfio-pci`/`uio_pci_generic` +(`--device` always takes the `domain:bus:device.function` form `0000:01:00.0` on both platforms): + +``` +sudo devbind --bind nic_uio --device 0000:01:00.0 # hand device to DPDK/SPDK +sudo devbind --bind nvme --device 0000:01:00.0 # rebind to the native driver +``` + `devbind --list` sample output (stock WSL host, no NVMe devices visible): ``` diff --git a/pyproject.toml b/pyproject.toml index ed1b3fb..b6ba120 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "devbind" dynamic = ["version"] -description = "Inspect and control PCI device-driver binding in Linux" +description = "Inspect and control PCI device-driver binding on Linux and FreeBSD" readme = "README.md" license = "BSD-3-Clause" requires-python = ">=3.10" @@ -18,6 +18,7 @@ classifiers = [ "Intended Audience :: Developers", "Intended Audience :: System Administrators", "Operating System :: POSIX :: Linux", + "Operating System :: POSIX :: BSD :: FreeBSD", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", diff --git a/src/devbind/devbind.py b/src/devbind/devbind.py index c85ded3..f64b25e 100644 --- a/src/devbind/devbind.py +++ b/src/devbind/devbind.py @@ -4,30 +4,19 @@ # # Get info about and control driver associated with NVMe devices # -# This makes use of the following tools: +# Platform-specific operations (device enumeration, driver binding) are handled by +# a Platform selected at runtime via get_platform(): # -# * lspci -Dvmmnk -# * lsof {devhandle1, devhandle2, ... devhandleN} +# * Linux -- sysfs (/sys/bus/pci/...) + lspci / lsof / setpci +# * FreeBSD -- pciconf / devctl / camcontrol / fstat, with nic_uio as the +# userspace stub driver # -# The following sysfs entries for driver bindings: -# -# * /sys/bus/pci/devices/{bdf}/driver -# * /sys/bus/pci/devices/{bdf}/driver_override -# * /sys/bus/pci/devices/{bdf}/driver/unbind -# * /sys/bus/pci/devices/{bdf}/iommu_group -# * /sys/bus/pci/devices/{bdf}/nvme/nvme* -# * /sys/bus/pci/devices/{bdf}/nvme/nvme*/ng* -# * /sys/bus/pci/devices/{bdf}/nvme/nvme*/nvme* -# * /sys/bus/pci/drivers/{driver_name}/bind -# -# The following could, but currently are not, be used for automatic detection based on -# class-code etc. -# -# * /sys/bus/pci/drivers/{driver_name}/new_id -# * /sys/bus/pci/drivers_probe +# Kept as a single, stdlib-only file so it can be installed by copying this script. # import sys import os +import abc +import shlex import subprocess import argparse import errno @@ -35,7 +24,7 @@ import time import logging as log from itertools import chain -from typing import Optional +from typing import Iterable, Optional from pathlib import Path from dataclasses import dataclass, asdict, field @@ -43,11 +32,15 @@ PCIE_DEFAULT_CLASSCODE = 0x0108 # Mass Storage - NVM +# Driver-names recognized across platforms; used for argument parsing and +# completion. The active platform reports which are actually available. +KNOWN_DRIVERS = {"nvme", "vfio-pci", "vfio-noiommu", "uio_pci_generic", "nic_uio"} + BASH_COMPLETION = r"""# bash completion for devbind _devbind() { local cur="${COMP_WORDS[COMP_CWORD]}" local prev="${COMP_WORDS[COMP_CWORD-1]}" - local drivers="nvme vfio-pci vfio-noiommu uio_pci_generic" + local drivers="nvme vfio-pci vfio-noiommu uio_pci_generic nic_uio" local opts="--classcode --device --list --unbind --bind --verbose --help --print-completion" case "${prev}" in --bind) @@ -70,22 +63,102 @@ def run(cmd: str): return subprocess.run(cmd, capture_output=True, shell=True, text=True) +@dataclass +class Device: + """Encapsulation of a PCIe device (platform-neutral)""" + + bdf: str # PCI address as domain:bus:device.function, e.g. "0000:02:00.0" + vendor: str # Vendor ID (hex), e.g. "144d" for Samsung + device: str # Device ID (hex), identifies the specific device model + classcode: str # PCI class code (hex), e.g. "0108" for NVMe controller + + driver: Optional[str] = None # Name of the driver bound to the device, e.g. "nvme" + iommugroup: Optional[int] = None # IOMMU group number (Linux-only; None elsewhere) + + is_used: bool = True # Whether or not the device is in use; assume it is + handles: list = field(default_factory=list) + + +class Platform(abc.ABC): + """Platform-specific PCI device-driver binding operations""" + + #: Drivers this platform knows how to bind devices to + DRIVERS: set = set() + + def driver_names(self) -> set: + """Return the set of driver-names this platform can bind to""" + return set(self.DRIVERS) + + @abc.abstractmethod + def probe_drivers(self) -> dict: + """Return ``{driver_name: {"available": bool}}`` for the known drivers""" + + @abc.abstractmethod + def scan_devices(self, classcode: int, bdf: Optional[str] = None) -> Iterable[Device]: + """Yield a fully-probed Device for each matching PCIe device + + A bdf (domain:bus:device.function, e.g. "0000:01:00.0") bypasses the class filter: + only that device is yielded, whatever its class. Otherwise devices + whose class matches classcode are yielded. + """ + + @abc.abstractmethod + def unbind(self, device: Device): + """Detach device from its current driver""" + + @abc.abstractmethod + def bind(self, device: Device, driver_name: str): + """Unbind if bound, then bind device to driver_name""" + + @abc.abstractmethod + def memlock_remediation_hint(self) -> str: + """Platform-specific guidance for raising RLIMIT_MEMLOCK""" + + +# --- Linux platform ----------------------------------------------------------- + +# Mapping from ``lspci -Dvmmnk`` record keys to Device fields +_LSPCI_KEYS = { + "slot": "bdf", + "vendor": "vendor", + "device": "device", + "classcode": "classcode", +} + + def sysfs_write(path: Path, text): log.info(f'{path} "{text}"') with os.fdopen(os.open(path, os.O_WRONLY), "w") as f: f.write(f"{text}\n") -class System: - DRIVERS = {"nvme", "vfio-pci", "vfio-noiommu", "uio_pci_generic"} +class Linux(Platform): + """Linux implementation, via sysfs and CLI tools - # DPDK/SPDK and xNVMe/uPCIe convention: VFIO_IOMMU_MAP_DMA pins user - # space pages against RLIMIT_MEMLOCK. Below 64 MiB the buffer-pool - # allocation fails outright. - MEMLOCK_MIN_BYTES = 64 * 1024 * 1024 + This makes use of the following tools: + + * lspci -Dvmmnk + * lsof {devhandle1, devhandle2, ... devhandleN} - drivers: dict = {} - limits: dict = {} + And the following sysfs entries for driver bindings: + + * /sys/bus/pci/devices/{bdf}/driver + * /sys/bus/pci/devices/{bdf}/driver_override + * /sys/bus/pci/devices/{bdf}/driver/unbind + * /sys/bus/pci/devices/{bdf}/iommu_group + * /sys/bus/pci/devices/{bdf}/nvme/nvme* + * /sys/bus/pci/devices/{bdf}/nvme/nvme*/ng* + * /sys/bus/pci/devices/{bdf}/nvme/nvme*/nvme* + * /sys/bus/pci/drivers/{driver_name}/bind + + The following could, but currently are not, be used for automatic + detection based on class-code etc. + + * /sys/bus/pci/drivers/{driver_name}/new_id + * /sys/bus/pci/drivers_probe + """ + + DRIVERS = {"nvme", "vfio-pci", "vfio-noiommu", "uio_pci_generic"} def probe_drivers(self): loaded = set( @@ -94,213 +167,443 @@ def probe_drivers(self): missing = self.DRIVERS - loaded + drivers = {} for driver_name in self.DRIVERS: - self.drivers[driver_name] = { + drivers[driver_name] = { "available": driver_name not in missing, } + return drivers - def probe_limits(self): - """Read process resource limits relevant to vfio-pci consumers""" - - soft, hard = resource.getrlimit(resource.RLIMIT_MEMLOCK) - self.limits["memlock_soft"] = soft - self.limits["memlock_hard"] = hard - - if soft != resource.RLIM_INFINITY and soft < self.MEMLOCK_MIN_BYTES: - log.warning( - f"memlock soft limit ({self._fmt_bytes(soft)}) is below " - f"{self._fmt_bytes(self.MEMLOCK_MIN_BYTES)}; " - "VFIO_IOMMU_MAP_DMA will fail for DPDK/SPDK and xNVMe/uPCIe. " - "Raise via /etc/security/limits.d/, prlimit, or systemd LimitMEMLOCK=" - ) + def memlock_remediation_hint(self): + return "Raise via /etc/security/limits.d/, prlimit, or systemd LimitMEMLOCK=" @staticmethod - def _fmt_bytes(n): - if n == resource.RLIM_INFINITY: - return "unlimited" - for unit in ("B", "kB", "MB", "GB", "TB"): - if n < 1024: - return f"{n} {unit}" - n //= 1024 - return f"{n} PB" - - def pp(self): - print("system:") - print(" drivers:") - for driver_name, props in self.drivers.items(): - print(f" - {driver_name}: {props}") - print(" limits:") - for name, val in self.limits.items(): - print(f" {name}: {self._fmt_bytes(val)}") - - -@dataclass -class Device: - """Encapsulation of a PCIe device""" - - MANDATORY_KEYS = { - "slot": "bdf", - "vendor": "vendor", - "device": "device", - "classcode": "classcode", - } - - bdf: str # PCI address of the device, e.g. "0000:02:00.0" - vendor: str # Vendor ID (hex), e.g. "144d" for Samsung - device: str # Device ID (hex), identifies the specific device model - classcode: str # PCI class code (hex), e.g. "0108" for NVMe controller - - driver: Optional[str] = None # Name of the driver bound to the device, e.g. "nvme" - iommugroup: Optional[int] = None # IOMMU group number the device belongs to - - is_used: bool = True # Whether or not the device is in use; assume it is - handles: list = field(default_factory=list) - - @classmethod - def from_dict(cls, data: dict) -> "Device": + def _device_from_dict(data: dict) -> Device: cdata = {} - for src, tgt in Device.MANDATORY_KEYS.items(): + for src, tgt in _LSPCI_KEYS.items(): cdata[tgt] = data.copy().get(src) - return cls(**cdata) + return Device(**cdata) - def probe_driver(self): + @staticmethod + def _probe_driver(device: Device): """Populate driver via sysfs; returns False if no driver is found""" - try: - self.driver = Path(f"/sys/bus/pci/devices/{self.bdf}/driver").resolve(strict=True).name + device.driver = ( + Path(f"/sys/bus/pci/devices/{device.bdf}/driver").resolve(strict=True).name + ) except FileNotFoundError: pass - return self.driver is not None + return device.driver is not None - def probe_iommugroup(self): + @staticmethod + def _probe_iommugroup(device: Device): """Populate iommugroup via sysfs; returns False if no iommugroup is found""" - try: - self.iommugroup = int( - Path(f"/sys/bus/pci/devices/{self.bdf}/iommu_group").resolve(strict=True).name + device.iommugroup = int( + Path(f"/sys/bus/pci/devices/{device.bdf}/iommu_group").resolve(strict=True).name ) except FileNotFoundError: - self.iommugroup = None - return self.iommugroup is not None + device.iommugroup = None + return device.iommugroup is not None - def probe_handles(self): + @staticmethod + def _probe_handles(device: Device): """Determine possible handles to the NVMe device""" - - for top in Path(f"/sys/bus/pci/devices/{self.bdf}/nvme").glob("nvme*"): + for top in Path(f"/sys/bus/pci/devices/{device.bdf}/nvme").glob("nvme*"): for bottom in chain(top.glob("ng*"), top.glob("nvme*")): for path in Path("/dev").glob(f"{bottom.name}*"): - self.handles.append(str(path)) + device.handles.append(str(path)) - def probe_usage(self): + @staticmethod + def _probe_usage(device: Device): """Attempt to determine whether the device is in use""" + if not device.handles: + device.is_used = False + return + handles = " ".join(device.handles) + proc = run(f"lsof {handles}") + device.is_used = bool(proc.stdout) + + def scan_devices(self, classcode: int, bdf: Optional[str] = None): + proc = run("lspci -Dvmmnk") + + props = {} + for line in proc.stdout.splitlines(): + if not line: + if bdf: + matches = bdf == props.get("slot", "") + else: + matches = int(props.get("classcode", "0"), 16) == classcode + if matches: + device = self._device_from_dict(props) + self._probe_handles(device) + self._probe_usage(device) + self._probe_driver(device) + self._probe_iommugroup(device) + yield device + + props = {} + continue + + key, val = [txt.strip().lower() for txt in str(line).split(":", 1)] + if key == "class": + key = "classcode" + + props[key] = val + + def unbind(self, device: Device): + log.info(f"Unbinding({device.bdf}) from '{device.driver}'") - if not self.handles: - self.is_used = False + driver_path = Path("/sys") / "bus" / "pci" / "devices" / device.bdf / "driver" + + unbind = driver_path / "unbind" + if not unbind.exists(): + log.info("Not bound; skipping unbind()") return - handles = " ".join(self.handles) - proc = run(f"lsof {handles}") + sysfs_write(unbind, device.bdf) - self.is_used = bool(proc.stdout) + def bind(self, device: Device, driver_name: str): + """Bind the driver named 'driver_name' with 'device'""" + self.unbind(device) -def device_scan(args): - """Yields matching PCIe devices. + log.info(f"Binding({device.bdf}) to '{driver_name}'") + + sysfs = Path("/sys") / "bus" / "pci" + + sysfs_write(sysfs / "devices" / device.bdf / "driver_override", driver_name) + + max_attempts = 10 + for attempt in range(1, max_attempts + 1): + try: + sysfs_write(sysfs / "drivers" / driver_name / "bind", device.bdf) + break + except OSError as exc: + if attempt == max_attempts or exc.errno != errno.EBUSY: + log.error(f"Could not bind despite {max_attempts} retries.") + raise + delay = attempt * 1 + log.info(f"Retrying in in {delay} second(s)") + time.sleep(delay) + + # Enable BUS-mastering (tell it that it can initiate DMA) + if driver_name == "uio_pci_generic": + log.info(f"Running setpci to enable bus-mastering; driver_name({driver_name})") + run(f"setpci -s {device.bdf} COMMAND=0x06") + else: + log.info(f"Not running setpci; driver_name({driver_name})") + + +# --- FreeBSD platform --------------------------------------------------------- + +# Userspace stub driver used by DPDK/SPDK on FreeBSD (analogous to uio_pci_generic) +FREEBSD_USERSPACE_DRIVER = "nic_uio" + + +def selector_to_bdf(selector: str) -> str: + """Convert a FreeBSD selector 'pci0:1:0:0' to the bdf form '0000:01:00.0'""" + domain, bus, slot, func = (int(part) for part in selector[len("pci") :].split(":")) + return f"{domain:04x}:{bus:02x}:{slot:02x}.{func:x}" - When args.device is set, the class filter is bypassed and the named device - is yielded regardless of its class. Otherwise devices whose class matches - args.classcode are yielded. - """ - proc = run("lspci -Dvmmnk") +def bdf_to_selector(bdf: str) -> str: + """Convert a bdf '0000:01:00.0' to a FreeBSD selector 'pci0:1:0:0'""" + dom_bus_slot, func = bdf.split(".") + domain, bus, slot = dom_bus_slot.split(":") + return f"pci{int(domain, 16)}:{int(bus, 16)}:{int(slot, 16)}:{int(func, 16)}" + + +class FreeBSD(Platform): + DRIVERS = {"nvme", FREEBSD_USERSPACE_DRIVER} + + @staticmethod + def _module_loaded(name: str) -> bool: + return run(f"kldstat -q -n {name}").returncode == 0 + + def probe_drivers(self): + # nvme ships in GENERIC; nic_uio is an out-of-tree module that must be loaded + return { + "nvme": {"available": True}, + FREEBSD_USERSPACE_DRIVER: {"available": self._module_loaded(FREEBSD_USERSPACE_DRIVER)}, + } + + def memlock_remediation_hint(self): + return "Raise via the 'memorylocked' capability in /etc/login.conf or /boot/loader.conf" + + @staticmethod + def _cam_disks(instance: str) -> list: + """Map an nvmeX controller instance to its CAM disk names (e.g. ['nda0']) + + Parses ``camcontrol devlist -v``, whose output groups peripherals under + bus headers like ``scbus1 on nvme0 bus 0:``. Legacy nvd(4) disks do not + attach via CAM and are not mapped. Openers of /dev/nvd* are therefore + invisible to the in-use check. + """ + disks = [] + on_our_bus = False + for line in run("camcontrol devlist -v").stdout.splitlines(): + stripped = line.strip() + parts = stripped.split() + if stripped.startswith("scbus") and len(parts) >= 3 and parts[1] == "on": + on_our_bus = parts[2] == instance + continue + if not on_our_bus or "(" not in stripped: + continue + names = stripped.rsplit("(", 1)[1].rstrip(")").split(",") + disks.extend(name for name in names if name.startswith("nda")) + return disks + + @staticmethod + def _probe_handles(device: Device, instance: str): + """Map the pciconf instance (e.g. 'nvme0') to its /dev nodes""" + if not instance.startswith("nvme"): + return + for stem in [instance] + FreeBSD._cam_disks(instance): + for path in Path("/dev").glob(f"{stem}*"): + device.handles.append(str(path)) - props = {} - for line in proc.stdout.splitlines(): - if not line: - classcode = int(props.get("classcode", "0"), 16) - bdf = props.get("bdf", "") - if args.device: - matches = args.device == bdf + @staticmethod + def _probe_usage(device: Device): + """Attempt to determine whether the device is in use via fstat""" + if not device.handles: + device.is_used = False + return + handles = " ".join(device.handles) + proc = run(f"fstat {handles}") + # fstat always prints a header line; any further rows mean an open handle + rows = [line for line in proc.stdout.splitlines() if line.strip()] + device.is_used = len(rows) > 1 + + def scan_devices(self, classcode: int, bdf: Optional[str] = None): + # Each `pciconf -l` line, FreeBSD 14.3 and earlier: + # nvme0@pci0:1:0:0:\tclass=0x010802 card=0x... chip=0xDDDDVVVV rev=0x.. hdr=0x.. + # 15.x and 14.4 replaced the packed chip=/card= fields: + # nvme0@pci0:1:0:0:\tclass=0x010802 rev=0x.. hdr=0x.. vendor=0xVVVV device=0xDDDD ... + proc = run("pciconf -l") + + for line in proc.stdout.splitlines(): + if "@pci" not in line: + continue + + parts = line.split() + instance, _, selector = parts[0].partition("@") + selector = selector.rstrip(":") + + fields = dict(tok.split("=", 1) for tok in parts[1:] if "=" in tok) + if "class" not in fields: + continue + + if "chip" in fields: + chip = int(fields["chip"], 16) + vendor = chip & 0xFFFF + device_id = (chip >> 16) & 0xFFFF + elif "vendor" in fields and "device" in fields: + vendor = int(fields["vendor"], 16) + device_id = int(fields["device"], 16) else: - matches = classcode == args.classcode - if matches: - device = Device.from_dict(props) - device.probe_handles() - device.probe_usage() - device.probe_driver() - device.probe_iommugroup() - yield device + continue + + cls = int(fields["class"], 16) + if bdf: + if selector_to_bdf(selector) != bdf: + continue + elif (cls >> 8) != classcode: + continue + # 'none0' (or 'none1', ...) denotes a device without an attached driver + stem = instance.rstrip("0123456789") + driver = None if stem == "none" else stem + + device = Device( + bdf=selector_to_bdf(selector), + vendor=f"{vendor:04x}", + device=f"{device_id:04x}", + classcode=f"{cls >> 8:04x}", + driver=driver, + ) + self._probe_handles(device, instance) + self._probe_usage(device) - props = {} - continue + yield device - key, val = [txt.strip().lower() for txt in str(line).split(":", 1)] - if key == "class": - key = "classcode" + def unbind(self, device: Device): + if not device.driver: + log.info("Not bound; skipping unbind()") + return - props[key] = val + selector = bdf_to_selector(device.bdf) + log.info(f"Unbinding({device.bdf}) from '{device.driver}'") + proc = run(f"devctl detach {selector}") + if proc.returncode != 0: + raise OSError(proc.stderr.strip() or f"devctl detach {selector} failed") -def print_props(args, device: Device): - """Pretty-print the properties of a device""" + @staticmethod + def _bdfs_entry(bdf: str) -> str: + """hw.nic_uio.bdfs entry (decimal bus:device:function) for a bdf""" + domain, bus, slot_func = bdf.split(":") + slot, func = slot_func.split(".") + if int(domain, 16) != 0: + raise OSError(f"nic_uio supports PCI domain 0 only, not {bdf}") + return f"{int(bus, 16)}:{int(slot, 16)}:{int(func, 16)}" - print("props:") - for key, val in asdict(device).items(): - if isinstance(val, int) or isinstance(val, list): - print(f" {key}: {val}") + @staticmethod + def _nic_uio_bdfs() -> list: + """Current hw.nic_uio.bdfs entries""" + proc = run("kenv hw.nic_uio.bdfs") + if proc.returncode != 0: + return [] + return [entry for entry in proc.stdout.strip().split(",") if entry] + + @staticmethod + def _set_nic_uio_bdfs(entries: list): + if entries: + proc = run(f"kenv hw.nic_uio.bdfs={shlex.quote(','.join(entries))}") else: - print(f" {key}: '{val}'") + proc = run("kenv -u hw.nic_uio.bdfs") + if proc.returncode != 0: + raise OSError(proc.stderr.strip() or "updating hw.nic_uio.bdfs failed") + + def _bind_nic_uio(self, device: Device): + """Register the device in hw.nic_uio.bdfs and (re)load the module + + nic_uio only probes devices listed in hw.nic_uio.bdfs at module + load. Loading detaches the previous driver and claims the device by + itself; devctl set driver cannot attach it. + """ + entry = self._bdfs_entry(device.bdf) + entries = self._nic_uio_bdfs() + if entry not in entries: + entries.append(entry) + + if self._module_loaded(FREEBSD_USERSPACE_DRIVER): + proc = run(f"kldunload {FREEBSD_USERSPACE_DRIVER}") + if proc.returncode != 0: + raise OSError(proc.stderr.strip() or "kldunload nic_uio failed") + # A driver forced earlier with devctl set driver pins the device and + # keeps nic_uio from claiming it at module load; drop any such pin + run(f"devctl clear driver -f {bdf_to_selector(device.bdf)}") + self._set_nic_uio_bdfs(entries) + proc = run(f"kldload {FREEBSD_USERSPACE_DRIVER}") + if proc.returncode != 0: + raise OSError(proc.stderr.strip() or "kldload nic_uio failed") + + selector = bdf_to_selector(device.bdf) + proc = run("pciconf -l") + for line in proc.stdout.splitlines(): + if line.startswith(FREEBSD_USERSPACE_DRIVER) and f"@{selector}:" in line: + break + else: + raise OSError(f"nic_uio did not attach {device.bdf}; check hw.nic_uio.bdfs") + + log.info(f"Running pciconf to enable bus-mastering on {device.bdf}") + proc = run(f"pciconf -w -h {selector} 0x4 0x6") + if proc.returncode != 0: + log.error( + proc.stderr.strip() + or f"pciconf write failed; bus-mastering not enabled for {device.bdf}" + ) + def bind(self, device: Device, driver_name: str): + """Bind the driver named 'driver_name' with 'device'""" -def unbind(args, device: Device): - log.info(f"Unbinding({device.bdf}) from '{device.driver}'") + log.info(f"Binding({device.bdf}) to '{driver_name}'") - driver_path = Path("/sys") / "bus" / "pci" / "devices" / device.bdf / "driver" + if driver_name == FREEBSD_USERSPACE_DRIVER: + self._bind_nic_uio(device) + return - unbind = driver_path / "unbind" - if not unbind.exists(): - log.info("Not bound; skipping unbind()") - return + self.unbind(device) - sysfs_write(unbind, device.bdf) + selector = bdf_to_selector(device.bdf) + driver_arg = shlex.quote(str(driver_name)) + proc = run(f"devctl set driver {selector} {driver_arg}") + if proc.returncode != 0: + raise OSError( + proc.stderr.strip() or f"devctl set driver {selector} {driver_arg} failed" + ) + # A later nic_uio load must not reclaim the device + try: + entry = self._bdfs_entry(device.bdf) + except OSError: + return + entries = self._nic_uio_bdfs() + if entry in entries: + entries.remove(entry) + self._set_nic_uio_bdfs(entries) -def bind(args, device: Device, driver_name: str): - """Bind the driver named 'driver_name' with 'device'""" - unbind(args, device) +def get_platform() -> Platform: + """Return the Platform implementation for the running platform""" + if sys.platform.startswith("linux"): + return Linux() + if sys.platform.startswith("freebsd"): + return FreeBSD() - log.info(f"Binding({device.bdf}) to '{driver_name}'") + raise NotImplementedError(f"devbind does not support platform '{sys.platform}'") - sysfs = Path("/sys") / "bus" / "pci" - sysfs_write(sysfs / "devices" / device.bdf / "driver_override", driver_name) +class System: + # DPDK/SPDK and xNVMe/uPCIe convention: pinning user space pages for DMA + # (VFIO_IOMMU_MAP_DMA on Linux, nic_uio/contigmem on FreeBSD) counts against + # RLIMIT_MEMLOCK. Below 64 MiB the buffer-pool allocation fails outright. + MEMLOCK_MIN_BYTES = 64 * 1024 * 1024 - max_attempts = 10 - for attempt in range(1, max_attempts + 1): - try: - sysfs_write(sysfs / "drivers" / driver_name / "bind", device.bdf) - break - except OSError as exc: - if attempt == max_attempts or exc.errno != errno.EBUSY: - log.error(f"Could not bind despite {max_attempts} retries.") - raise - delay = attempt * 1 - log.info(f"Retrying in in {delay} second(s)") - time.sleep(delay) - - # Enable BUS-mastering (tell it that it can initiate DMA) - if driver_name == "uio_pci_generic": - log.info(f"Running setpci to enable bus-mastering; driver_name({driver_name})") - run(f"setpci -s {device.bdf} COMMAND=0x06") - else: - log.info(f"Not running setpci; driver_name({driver_name})") + def __init__(self): + self.drivers: dict = {} + self.limits: dict = {} + + def probe_limits(self, remediation_hint: str = ""): + """Read process resource limits relevant to userspace-driver consumers""" + + soft, hard = resource.getrlimit(resource.RLIMIT_MEMLOCK) + self.limits["memlock_soft"] = soft + self.limits["memlock_hard"] = hard + + if soft != resource.RLIM_INFINITY and soft < self.MEMLOCK_MIN_BYTES: + log.warning( + f"memlock soft limit ({self._fmt_bytes(soft)}) is below " + f"{self._fmt_bytes(self.MEMLOCK_MIN_BYTES)}; " + "DMA mapping will fail for DPDK/SPDK and xNVMe/uPCIe. " + f"{remediation_hint}" + ) + + @staticmethod + def _fmt_bytes(n): + if n == resource.RLIM_INFINITY: + return "unlimited" + for unit in ("B", "kB", "MB", "GB", "TB"): + if n < 1024: + return f"{n} {unit}" + n //= 1024 + return f"{n} PB" + + def pp(self): + print("system:") + print(" drivers:") + for driver_name, props in self.drivers.items(): + print(f" - {driver_name}: {props}") + print(" limits:") + for name, val in self.limits.items(): + print(f" {name}: {self._fmt_bytes(val)}") + + +def print_props(args, device: Device): + """Pretty-print the properties of a device""" + + print("props:") + for key, val in asdict(device).items(): + if isinstance(val, int) or isinstance(val, list): + print(f" {key}: {val}") + else: + print(f" {key}: '{val}'") def parse_args(): parser = argparse.ArgumentParser( - description="Inspect and control PCI device-driver binding in Linux" + description="Inspect and control PCI device-driver binding on Linux and FreeBSD" ) parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") @@ -327,14 +630,15 @@ def parse_args(): parser.add_argument("--unbind", action="store_true", help="Unbind if bound.") def parse_bind(value): - if value in System.DRIVERS: + if value in KNOWN_DRIVERS: return value return Path(value) parser.add_argument( "--bind", type=parse_bind, - help="Unbind if bound; then bind to the given driver-name [nvme, vfio-pci, uio_pci_generic] or to a .ko driver file (path)", + help="Unbind if bound; then bind to the given driver-name " + "[nvme, vfio-pci, uio_pci_generic, nic_uio] or to a driver file (path)", ) parser.add_argument("--verbose", action="store_true", help="Enable verbose logging") @@ -365,16 +669,30 @@ def main(): log.error("Binding/unbinding PCIe devices requires root. Re-run with sudo.") sys.exit(errno.EPERM) - system = System() - system.probe_drivers() - system.probe_limits() + try: + platform = get_platform() + except NotImplementedError as exc: + log.error(str(exc)) + sys.exit(errno.ENOSYS) - if args.list: - system.pp() + if isinstance(args.bind, str) and args.bind not in platform.driver_names(): + log.error( + f"driver '{args.bind}' is not supported on this platform; " + f"expected one of: {', '.join(sorted(platform.driver_names()))}" + ) + sys.exit(errno.EINVAL) - devices = list(device_scan(args)) + system = System() try: + system.drivers = platform.probe_drivers() + system.probe_limits(platform.memlock_remediation_hint()) + + if args.list: + system.pp() + + devices = list(platform.scan_devices(args.classcode, args.device)) + for cur, device in enumerate(devices, 1): log.info(f"Device({device.bdf}) -- {cur}/{len(devices)}") @@ -385,17 +703,20 @@ def main(): if device.is_used: log.info(f"Skipping unbind({device.driver}); device is in use.") else: - unbind(args, device) + platform.unbind(device) if args.bind: if device.is_used: log.info(f"Skipping bind({args.bind}); device is in use.") else: - bind(args, device, args.bind) + platform.bind(device, args.bind) except PermissionError as exc: log.error(str(exc)) log.error("Binding/unbinding PCIe devices requires root. Re-run with sudo.") sys.exit(errno.EPERM) + except OSError as exc: + log.error(str(exc)) + sys.exit(exc.errno or 1) if __name__ == "__main__": diff --git a/tests/test_freebsd.py b/tests/test_freebsd.py new file mode 100644 index 0000000..fb76a2d --- /dev/null +++ b/tests/test_freebsd.py @@ -0,0 +1,226 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) Jaeyoon Choi +"""Unit tests for the FreeBSD platform (runnable on any platform).""" + +import sys +from types import SimpleNamespace + +import pytest + +from devbind import devbind +from devbind.devbind import FreeBSD, Device, bdf_to_selector, selector_to_bdf + + +# Representative `pciconf -l` output: an NVMe controller (bound), an NVMe +# controller without a driver, and a non-matching ethernet device. The first +# two lines use the packed chip=/card= format of FreeBSD <= 14; the last NVMe +# line uses the split vendor=/device= format that FreeBSD 15 switched to. +PCICONF_L = ( + "nvme0@pci0:1:0:0:\tclass=0x010802 card=0xa801144d chip=0xa808144d rev=0x00 hdr=0x00\n" + "none0@pci0:2:0:0:\tclass=0x010802 card=0x0000 chip=0x540a1b96 rev=0x00 hdr=0x00\n" + "em0@pci0:3:0:0:\tclass=0x020000 card=0x00008086 chip=0x10d38086 rev=0x00 hdr=0x00\n" + "nvme1@pci0:4:0:0:\tclass=0x010802 rev=0x02 hdr=0x00 vendor=0x1b36 device=0x0010 " + "subvendor=0x1af4 subdevice=0x1100\n" +) + + +def _fake_run(outputs): + """Build a run() replacement dispatching by command prefix + + Commands matching no prefix fail (returncode 1), so a test cannot pass by + accident when the code under test never issues the expected command. + """ + + def runner(cmd): + for prefix, result in outputs.items(): + if cmd.startswith(prefix): + return SimpleNamespace(stdout=result, stderr="", returncode=0) + return SimpleNamespace(stdout="", stderr="", returncode=1) + + return runner + + +def test_selector_bdf_roundtrip(): + assert selector_to_bdf("pci0:1:0:0") == "0000:01:00.0" + assert bdf_to_selector("0000:01:00.0") == "pci0:1:0:0" + assert selector_to_bdf("pci1:255:31:7") == "0001:ff:1f.7" + assert bdf_to_selector(selector_to_bdf("pci0:130:5:3")) == "pci0:130:5:3" + + +def test_scan_devices_parses_and_filters(monkeypatch): + monkeypatch.setattr( + devbind, "run", _fake_run({"pciconf -l": PCICONF_L, "fstat": "USER CMD ...\n"}) + ) + # Avoid touching the real /dev during handle probing + monkeypatch.setattr(FreeBSD, "_probe_handles", staticmethod(lambda device, inst: None)) + + devices = list(FreeBSD().scan_devices(0x0108)) + + # The ethernet device (class 0x0200) is filtered out + assert [d.bdf for d in devices] == ["0000:01:00.0", "0000:02:00.0", "0000:04:00.0"] + + nvme = devices[0] + assert nvme.vendor == "144d" # low 16 bits of chip + assert nvme.device == "a808" # high 16 bits of chip + assert nvme.classcode == "0108" + assert nvme.driver == "nvme" + + assert devices[1].driver is None # 'none0' -> no driver attached + + fb15 = devices[2] # FreeBSD 15 vendor=/device= format + assert fb15.vendor == "1b36" + assert fb15.device == "0010" + assert fb15.driver == "nvme" + + +def test_probe_drivers_nic_uio_loaded(monkeypatch): + monkeypatch.setattr( + devbind, "run", _fake_run({"kldstat -q -n nic_uio": ""}) + ) # returncode 0 -> loaded + drivers = FreeBSD().probe_drivers() + assert drivers["nvme"]["available"] is True + assert drivers["nic_uio"]["available"] is True + + +def test_probe_drivers_nic_uio_not_loaded(monkeypatch): + monkeypatch.setattr(devbind, "run", _fake_run({})) # kldstat fails -> not loaded + drivers = FreeBSD().probe_drivers() + assert drivers["nvme"]["available"] is True + assert drivers["nic_uio"]["available"] is False + + +def test_probe_usage_via_fstat(monkeypatch): + device = Device(bdf="0000:01:00.0", vendor="144d", device="a808", classcode="0108") + device.handles = ["/dev/nvme0", "/dev/nda0"] + + header = "USER CMD PID FD PATH\n" + + monkeypatch.setattr(devbind, "run", _fake_run({"fstat": header})) + FreeBSD._probe_usage(device) + assert device.is_used is False # header only -> no open handles + + monkeypatch.setattr(devbind, "run", _fake_run({"fstat": header + "root dd 71 3 /dev/nda0\n"})) + FreeBSD._probe_usage(device) + assert device.is_used is True + + +CAMCONTROL_V = ( + "scbus0 on ahcich0 bus 0:\n" + " at scbus0 target 0 lun 0 (pass0,ada0)\n" + "<> at scbus0 target -1 lun ffffffff ()\n" + "scbus1 on nvme0 bus 0:\n" + " at scbus1 target 0 lun 1 (pass1,nda0)\n" + "<> at scbus1 target -1 lun ffffffff ()\n" + "scbus-1 on xpt0 bus 0:\n" + "<> at scbus-1 target -1 lun ffffffff (xpt0)\n" +) + + +def test_cam_disks_maps_controller_to_disks(monkeypatch): + monkeypatch.setattr(devbind, "run", _fake_run({"camcontrol devlist -v": CAMCONTROL_V})) + assert FreeBSD._cam_disks("nvme0") == ["nda0"] + assert FreeBSD._cam_disks("ahcich0") == [] # ada is not an NVMe disk + assert FreeBSD._cam_disks("nvme1") == [] # no such bus + + +def _nic_uio_run(calls, loaded=False, bdfs="", attach_ok=True): + """run() fake for the nic_uio bind flow, dispatching by exact command""" + + def runner(cmd): + calls.append(cmd) + if cmd == "kenv hw.nic_uio.bdfs": + return SimpleNamespace(stdout=bdfs, stderr="", returncode=0 if bdfs else 1) + if cmd.startswith("kldstat"): + return SimpleNamespace(stdout="", stderr="", returncode=0 if loaded else 1) + if cmd == "pciconf -l": + inst = "nic_uio0" if attach_ok else "none0" + line = f"{inst}@pci0:1:0:0:\tclass=0x010802 vendor=0x1b36 device=0x0010\n" + return SimpleNamespace(stdout=line, stderr="", returncode=0) + return SimpleNamespace(stdout="", stderr="", returncode=0) + + return runner + + +def _nvme_device(driver="nvme"): + return Device( + bdf="0000:01:00.0", vendor="144d", device="a808", classcode="0108", driver=driver + ) + + +def test_bind_nic_uio_registers_bdfs_and_loads_the_module(monkeypatch): + calls = [] + monkeypatch.setattr(devbind, "run", _nic_uio_run(calls)) + + FreeBSD().bind(_nvme_device(), "nic_uio") + + assert "kenv hw.nic_uio.bdfs=1:0:0" in calls + assert "kldload nic_uio" in calls + # pciconf -w takes the selector as a positional argument (no -s flag) + assert "pciconf -w -h pci0:1:0:0 0x4 0x6" in calls + # the module load detaches and claims the device by itself; devctl is + # only used to drop a previously forced driver + assert "devctl clear driver -f pci0:1:0:0" in calls + assert not any(cmd.startswith(("devctl detach", "devctl set driver")) for cmd in calls) + assert "kldunload nic_uio" not in calls + + +def test_bind_nic_uio_reloads_a_loaded_module_and_merges_bdfs(monkeypatch): + calls = [] + monkeypatch.setattr(devbind, "run", _nic_uio_run(calls, loaded=True, bdfs="3:0:0")) + + FreeBSD().bind(_nvme_device(), "nic_uio") + + assert "kldunload nic_uio" in calls + assert "kenv hw.nic_uio.bdfs=3:0:0,1:0:0" in calls + assert calls.index("kldunload nic_uio") < calls.index("kldload nic_uio") + + +def test_bind_nic_uio_fails_loudly_when_the_module_does_not_attach(monkeypatch): + calls = [] + monkeypatch.setattr(devbind, "run", _nic_uio_run(calls, attach_ok=False)) + + with pytest.raises(OSError): + FreeBSD().bind(_nvme_device(), "nic_uio") + + assert not any(cmd.startswith("pciconf -w") for cmd in calls) + + +def test_bind_nvme_uses_devctl_and_clears_the_bdfs_entry(monkeypatch): + calls = [] + monkeypatch.setattr(devbind, "run", _nic_uio_run(calls, bdfs="1:0:0")) + + FreeBSD().bind(_nvme_device(driver="nic_uio"), "nvme") + + assert "devctl detach pci0:1:0:0" in calls + assert "devctl set driver pci0:1:0:0 nvme" in calls + assert "kenv -u hw.nic_uio.bdfs" in calls + + +def test_unbind_skips_when_unbound(monkeypatch): + calls = [] + monkeypatch.setattr(devbind, "run", lambda cmd: calls.append(cmd) or SimpleNamespace()) + device = Device( + bdf="0000:01:00.0", vendor="144d", device="a808", classcode="0108", driver=None + ) + FreeBSD().unbind(device) + assert calls == [] + + +def test_get_platform_selection(monkeypatch): + monkeypatch.setattr(sys, "platform", "freebsd14") + assert isinstance(devbind.get_platform(), FreeBSD) + + monkeypatch.setattr(sys, "platform", "win32") + with pytest.raises(NotImplementedError): + devbind.get_platform() + + +def test_scan_devices_named_device_bypasses_class_filter(monkeypatch): + monkeypatch.setattr(devbind, "run", _fake_run({"pciconf -l": PCICONF_L})) + monkeypatch.setattr(FreeBSD, "_probe_handles", staticmethod(lambda device, inst: None)) + + devices = list(FreeBSD().scan_devices(0x0108, "0000:03:00.0")) + + # The ethernet device is found by BDF even though its class is 0x0200 + assert [d.bdf for d in devices] == ["0000:03:00.0"] + assert devices[0].driver == "em" diff --git a/tests/test_linux.py b/tests/test_linux.py new file mode 100644 index 0000000..a487f8c --- /dev/null +++ b/tests/test_linux.py @@ -0,0 +1,95 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) Jaeyoon Choi +"""Unit tests for the Linux platform (runnable on any platform).""" + +import sys +from types import SimpleNamespace + +from devbind import devbind +from devbind.devbind import Linux, Device + + +# Representative `lspci -Dvmmnk` output: an NVMe controller and a +# non-matching ethernet device. +LSPCI_DVMMNK = ( + "Slot:\t0000:01:00.0\n" + "Class:\t0108\n" + "Vendor:\t144d\n" + "Device:\ta808\n" + "SVendor:\t144d\n" + "SDevice:\ta801\n" + "Driver:\tnvme\n" + "Module:\tnvme\n" + "\n" + "Slot:\t0000:03:00.0\n" + "Class:\t0200\n" + "Vendor:\t8086\n" + "Device:\t10d3\n" + "\n" +) + + +def test_scan_devices_parses_and_filters(monkeypatch): + monkeypatch.setattr( + devbind, + "run", + lambda cmd: SimpleNamespace(stdout=LSPCI_DVMMNK, stderr="", returncode=0), + ) + # Avoid touching the real /sys and /dev during probing + for name in ("_probe_handles", "_probe_usage", "_probe_driver", "_probe_iommugroup"): + monkeypatch.setattr(Linux, name, staticmethod(lambda device: None)) + + devices = list(Linux().scan_devices(0x0108)) + + # The ethernet device (class 0x0200) is filtered out + assert [d.bdf for d in devices] == ["0000:01:00.0"] + + nvme = devices[0] + assert nvme.vendor == "144d" + assert nvme.device == "a808" + assert nvme.classcode == "0108" + + +def test_bind_writes_override_then_bind_then_setpci(monkeypatch): + writes = [] + calls = [] + monkeypatch.setattr( + devbind, "sysfs_write", lambda path, text: writes.append((str(path), text)) + ) + monkeypatch.setattr( + devbind, + "run", + lambda cmd: calls.append(cmd) or SimpleNamespace(stdout="", stderr="", returncode=0), + ) + monkeypatch.setattr(Linux, "unbind", lambda self, device: None) + + device = Device( + bdf="0000:01:00.0", vendor="144d", device="a808", classcode="0108", driver="nvme" + ) + Linux().bind(device, "uio_pci_generic") + + assert writes == [ + ("/sys/bus/pci/devices/0000:01:00.0/driver_override", "uio_pci_generic"), + ("/sys/bus/pci/drivers/uio_pci_generic/bind", "0000:01:00.0"), + ] + assert "setpci -s 0000:01:00.0 COMMAND=0x06" in calls + + +def test_get_platform_selects_linux(monkeypatch): + monkeypatch.setattr(sys, "platform", "linux") + assert isinstance(devbind.get_platform(), Linux) + + +def test_scan_devices_named_device_bypasses_class_filter(monkeypatch): + monkeypatch.setattr( + devbind, + "run", + lambda cmd: SimpleNamespace(stdout=LSPCI_DVMMNK, stderr="", returncode=0), + ) + for name in ("_probe_handles", "_probe_usage", "_probe_driver", "_probe_iommugroup"): + monkeypatch.setattr(Linux, name, staticmethod(lambda device: None)) + + devices = list(Linux().scan_devices(0x0108, "0000:03:00.0")) + + # The ethernet device is found by BDF even though its class is 0x0200 + assert [d.bdf for d in devices] == ["0000:03:00.0"]