xref: /dpdk/usertools/dpdk-pmdinfo.py (revision 0d7dad082ab3d8d74a26cc5feaba4117b0025c58)
1#!/usr/bin/env python3
2# SPDX-License-Identifier: BSD-3-Clause
3# Copyright(c) 2016  Neil Horman <nhorman@tuxdriver.com>
4# Copyright(c) 2022  Robin Jarry
5# pylint: disable=invalid-name
6
7r"""
8Utility to dump PMD_INFO_STRING support from DPDK binaries.
9
10This script prints JSON output to be interpreted by other tools. Here are some
11examples with jq:
12
13Get the complete info for a given driver:
14
15  %(prog)s dpdk-testpmd | \
16  jq '.[] | select(.name == "cnxk_nix_inl")'
17
18Get only the required kernel modules for a given driver:
19
20  %(prog)s dpdk-testpmd | \
21  jq '.[] | select(.name == "net_i40e").kmod'
22
23Get only the required kernel modules for a given device:
24
25  %(prog)s dpdk-testpmd | \
26  jq '.[] | select(.pci_ids[]? | .vendor == "15b3" and .device == "1013").kmod'
27"""
28
29import argparse
30import json
31import logging
32import os
33import re
34import string
35import subprocess
36import sys
37from pathlib import Path
38from typing import Iterable, Iterator, List, Union
39
40import elftools
41from elftools.elf.elffile import ELFError, ELFFile
42
43
44# ----------------------------------------------------------------------------
45def main() -> int:  # pylint: disable=missing-docstring
46    try:
47        args = parse_args()
48        logging.basicConfig(
49            stream=sys.stderr,
50            format="%(levelname)s: %(message)s",
51            level={
52                0: logging.ERROR,
53                1: logging.WARNING,
54            }.get(args.verbose, logging.DEBUG),
55        )
56        info = parse_pmdinfo(args.elf_files, args.search_plugins)
57        print(json.dumps(info, indent=2))
58    except BrokenPipeError:
59        pass
60    except KeyboardInterrupt:
61        return 1
62    except Exception as e:  # pylint: disable=broad-except
63        logging.error("%s", e)
64        return 1
65    return 0
66
67
68# ----------------------------------------------------------------------------
69def parse_args() -> argparse.Namespace:
70    """
71    Parse command line arguments.
72    """
73    parser = argparse.ArgumentParser(
74        description=__doc__,
75        formatter_class=argparse.RawDescriptionHelpFormatter,
76    )
77    parser.add_argument(
78        "-p",
79        "--search-plugins",
80        action="store_true",
81        help="""
82        In addition of ELF_FILEs and their linked dynamic libraries, also scan
83        the DPDK plugins path.
84        """,
85    )
86    parser.add_argument(
87        "-v",
88        "--verbose",
89        action="count",
90        default=0,
91        help="""
92        Display warnings due to linked libraries not found or ELF/JSON parsing
93        errors in these libraries. Use twice to show debug messages.
94        """,
95    )
96    parser.add_argument(
97        "elf_files",
98        metavar="ELF_FILE",
99        nargs="+",
100        type=existing_file,
101        help="""
102        DPDK application binary or dynamic library.
103        """,
104    )
105    return parser.parse_args()
106
107
108# ----------------------------------------------------------------------------
109def parse_pmdinfo(paths: Iterable[Path], search_plugins: bool) -> List[dict]:
110    """
111    Extract DPDK PMD info JSON strings from an ELF file.
112
113    :returns:
114        A list of DPDK drivers info dictionaries.
115    """
116    binaries = set(paths)
117    for p in paths:
118        binaries.update(get_needed_libs(p))
119    if search_plugins:
120        # cast to list to avoid errors with update while iterating
121        binaries.update(list(get_plugin_libs(binaries)))
122
123    drivers = []
124
125    for b in binaries:
126        logging.debug("analyzing %s", b)
127        try:
128            for s in get_elf_strings(b, ".rodata", "PMD_INFO_STRING="):
129                try:
130                    info = json.loads(s)
131                    scrub_pci_ids(info)
132                    drivers.append(info)
133                except ValueError as e:
134                    # invalid JSON, should never happen
135                    logging.warning("%s: %s", b, e)
136        except ELFError as e:
137            # only happens for discovered plugins that are not ELF
138            logging.debug("%s: cannot parse ELF: %s", b, e)
139
140    return drivers
141
142
143# ----------------------------------------------------------------------------
144PCI_FIELDS = ("vendor", "device", "subsystem_vendor", "subsystem_device")
145
146
147def scrub_pci_ids(info: dict):
148    """
149    Convert numerical ids to hex strings.
150    Strip empty pci_ids lists.
151    Strip wildcard 0xFFFF ids.
152    """
153    pci_ids = []
154    for pci_fields in info.pop("pci_ids"):
155        pci = {}
156        for name, value in zip(PCI_FIELDS, pci_fields):
157            if value != 0xFFFF:
158                pci[name] = f"{value:04x}"
159        if pci:
160            pci_ids.append(pci)
161    if pci_ids:
162        info["pci_ids"] = pci_ids
163
164
165# ----------------------------------------------------------------------------
166def get_plugin_libs(binaries: Iterable[Path]) -> Iterator[Path]:
167    """
168    Look into the provided binaries for DPDK_PLUGIN_PATH and scan the path
169    for files.
170    """
171    for b in binaries:
172        for p in get_elf_strings(b, ".rodata", "DPDK_PLUGIN_PATH="):
173            plugin_path = p.strip()
174            logging.debug("discovering plugins in %s", plugin_path)
175            for root, _, files in os.walk(plugin_path):
176                for f in files:
177                    yield Path(root) / f
178            # no need to search in other binaries.
179            return
180
181
182# ----------------------------------------------------------------------------
183def existing_file(value: str) -> Path:
184    """
185    Argparse type= callback to ensure an argument points to a valid file path.
186    """
187    path = Path(value)
188    if not path.is_file():
189        raise argparse.ArgumentTypeError(f"{value}: No such file")
190    return path
191
192
193# ----------------------------------------------------------------------------
194PRINTABLE_BYTES = frozenset(string.printable.encode("ascii"))
195
196
197def find_strings(buf: bytes, prefix: str) -> Iterator[str]:
198    """
199    Extract strings of printable ASCII characters from a bytes buffer.
200    """
201    view = memoryview(buf)
202    start = None
203
204    for i, b in enumerate(view):
205        if start is None and b in PRINTABLE_BYTES:
206            # mark beginning of string
207            start = i
208            continue
209        if start is not None:
210            if b in PRINTABLE_BYTES:
211                # string not finished
212                continue
213            if b == 0:
214                # end of string
215                s = view[start:i].tobytes().decode("ascii")
216                if s.startswith(prefix):
217                    yield s[len(prefix):]
218            # There can be byte sequences where a non-printable byte
219            # follows a printable one. Ignore that.
220            start = None
221
222
223# ----------------------------------------------------------------------------
224def elftools_version():
225    """
226    Extract pyelftools version as a tuple of integers for easy comparison.
227    """
228    version = getattr(elftools, "__version__", "")
229    match = re.match(r"^(\d+)\.(\d+).*$", str(version))
230    if not match:
231        # cannot determine version, hope for the best
232        return (0, 24)
233    return (int(match[1]), int(match[2]))
234
235
236ELFTOOLS_VERSION = elftools_version()
237
238
239def from_elftools(s: Union[bytes, str]) -> str:
240    """
241    Earlier versions of pyelftools (< 0.24) return bytes encoded with "latin-1"
242    instead of python strings.
243    """
244    if isinstance(s, bytes):
245        return s.decode("latin-1")
246    return s
247
248
249def to_elftools(s: str) -> Union[bytes, str]:
250    """
251    Earlier versions of pyelftools (< 0.24) assume that ELF section and tags
252    are bytes encoded with "latin-1" instead of python strings.
253    """
254    if ELFTOOLS_VERSION < (0, 24):
255        return s.encode("latin-1")
256    return s
257
258
259# ----------------------------------------------------------------------------
260def get_elf_strings(path: Path, section: str, prefix: str) -> Iterator[str]:
261    """
262    Extract strings from a named ELF section in a file.
263    """
264    with path.open("rb") as f:
265        elf = ELFFile(f)
266        sec = elf.get_section_by_name(to_elftools(section))
267        if not sec:
268            return
269        yield from find_strings(sec.data(), prefix)
270
271
272# ----------------------------------------------------------------------------
273LDD_LIB_RE = re.compile(
274    r"""
275    ^                  # beginning of line
276    \t                 # tab
277    (\S+)              # lib name
278    \s+=>\s+
279    (/\S+)             # lib path
280    \s+
281    \(0x[0-9A-Fa-f]+\) # address
282    \s*
283    $                  # end of line
284    """,
285    re.MULTILINE | re.VERBOSE,
286)
287
288
289def get_needed_libs(path: Path) -> Iterator[Path]:
290    """
291    Extract the dynamic library dependencies from an ELF executable.
292    """
293    with subprocess.Popen(
294        ["ldd", str(path)], stdout=subprocess.PIPE, stderr=subprocess.PIPE
295    ) as proc:
296        out, err = proc.communicate()
297        if proc.returncode != 0:
298            err = err.decode("utf-8").splitlines()[-1].strip()
299            raise Exception(f"cannot read ELF file: {err}")
300        for match in LDD_LIB_RE.finditer(out.decode("utf-8")):
301            libname, libpath = match.groups()
302            if libname.startswith("librte_"):
303                libpath = Path(libpath)
304                if libpath.is_file():
305                    yield libpath.resolve()
306
307
308# ----------------------------------------------------------------------------
309if __name__ == "__main__":
310    sys.exit(main())
311