casaroli commented on code in PR #21344:
URL: https://github.com/apache/nuttx/pull/21344#discussion_r4470501191
##########
tools/gcov.py:
##########
@@ -18,33 +18,352 @@
# License for the specific language governing permissions and limitations
# under the License.
+"""Code coverage report generator for NuttX.
+
+Supports both GCC gcov and Clang source-based coverage. The toolchain is
+inferred from the ELF binary's `.comment` section, and the cross-compile
+prefix (if any) is inferred from the ELF machine type, so users no longer
+have to specify `-t`/`--toolchain`.
+
+Both toolchains converge on the lcov tracefile format: gcc captures it
+with `lcov -c`, clang exports it with `llvm-cov export -format=lcov`, and
+`genhtml` renders both, so reports look the same regardless of the
+toolchain and tracefiles can be merged with `lcov --add-tracefile`.
+
+Usage example:
+
+ gcov.py -e build/nuttx -s build -a coverage_data.bin -o report_dir
+
+The required tools (gcov/lcov/genhtml for GCC; llvm-profdata/llvm-cov/
+llvm-readelf/ld.lld for Clang) are resolved from PATH; the cross-compile
+gcov is matched by arch (e.g. aarch64-none-elf-gcov for EM_AARCH64).
+
+Use `-b OLD=NEW` (repeatable) to rewrite source paths recorded by the
+compiler at build time to local paths, mirroring `-fdebug-prefix-map`:
+
+ gcov.py -b /old/build/path=/new/local/path ...
+
+When only the local root is known, pass just NEW and the build-time root
+is inferred from the paths recorded in the coverage data:
+
+ gcov.py -b /new/local/path ...
+"""
+
import argparse
+import concurrent.futures
+import json
import os
import re
import shutil
+import struct
import subprocess
import sys
+from dataclasses import dataclass, field
+from typing import List, Optional, Tuple
+
+# =============================================================================
+# 1. CONSTANTS
+# =============================================================================
+
+# LLVM raw profile magic bytes (first 8 bytes of .profraw).
+#
+# The magic is a uint64_t written by the runtime in native byte order
+# (high byte 0xff, low byte 0x81; "lprofr"/"lprofR" in the middle), so
+# big-endian platforms produce \xff..\x81 and little-endian platforms
+# (ARM aarch64, x86_64, etc.) produce \x81..\xff. Both forms must be
+# accepted, mirroring LLVM's RawInstrProfReader::hasFormat which compares
+# the on-disk magic against both the native and byte-swapped values.
+#
+# 64-bit BE: \xff lprofr \x81 / 64-bit LE: \x81 rforpl \xff
+# 32-bit BE: \xff lprofR \x81 / 32-bit LE: \x81 Rforpl \xff
+PROFRAW_MAGIC_64 = b"\xff\x6c\x70\x72\x6f\x66\x72\x81"
+PROFRAW_MAGIC_32 = b"\xff\x6c\x70\x72\x6f\x66\x52\x81"
+PROFRAW_MAGICS = (
+ PROFRAW_MAGIC_64,
+ PROFRAW_MAGIC_64[::-1],
+ PROFRAW_MAGIC_32,
+ PROFRAW_MAGIC_32[::-1],
+)
+
+# gcov binary pack heuristic:
+# First 4 bytes are path_len (LE uint32), typically 30~200 for gcda paths.
+# Followed by a path string starting with a printable char (not \x00 or \xff).
+GCOV_BINPACK_PATH_LEN_MIN = 10
+GCOV_BINPACK_PATH_LEN_MAX = 4096
+
+# Suffix appended to a -a input file to name the directory holding the
+# coverage data extracted from it (hex dump / binpack -> individual files).
+CONVERT_SUFFIX = "_convert"
+
+# ELF e_machine values we care about for cross-prefix inference.
+EM_386 = 3
+EM_X86_64 = 62
+EM_ARM = 40
+EM_AARCH64 = 183
+EM_RISCV = 243
+EM_XTENSA = 94
+EM_TRICORE = 44
+
+# Candidate cross-compile gcov binary names per architecture, in priority
+# order. The first one found on PATH is used.
+GCC_CROSS_PREFIXES = {
+ EM_AARCH64: ["aarch64-none-elf-gcov", "aarch64-linux-gnu-gcov"],
+ EM_ARM: ["arm-none-eabi-gcov", "arm-linux-gnueabi-gcov"],
+ EM_RISCV: ["riscv64-unknown-elf-gcov", "riscv32-unknown-elf-gcov"],
Review Comment:
gcov.py now fails on RISC-V with the toolchain NuttX prefers: `error: could
not find gcov for riscv on PATH. Tried: ['riscv64-unknown-elf-gcov',
'riscv32-unknown-elf-gcov']`, and `-t riscv-none-elf-gcov`, which worked before
this PR, is removed. The Xtensa list at line 114 misses `xtensa-esp-elf-gcov`
the same way.
Fix: add `riscv-none-elf-gcov` and `xtensa-esp-elf-gcov` to
`GCC_CROSS_PREFIXES`, and keep `-t` as an override for a gcov that the list
does not know.
##########
tools/gcov.py:
##########
@@ -53,230 +372,1079 @@ def parse_gcda_data(path):
try:
if line.startswith("gcov end"):
started = False
- if size != len(output) // 2:
+ if size != len(payload) // 2:
raise ValueError(
- f"Size mismatch for {filename}: expected {size} bytes,
got {len(output) // 2} bytes"
+ f"size mismatch for {filename}: "
+ f"expected {size}, got {len(payload) // 2}"
)
- match = re.search(r"checksum:\s*(0x[0-9a-fA-F]+)", line)
- if match:
- checksum = int(match.group(1), 16)
- output = bytearray.fromhex(output)
- expected = sum(output) % 65536
+ blob = bytearray.fromhex(payload)
+
+ m = re.search(r"checksum:\s*(0x[0-9a-fA-F]+)", line)
+ if m:
+ checksum = int(m.group(1), 16)
+ expected = sum(blob) % 65536
if checksum != expected:
raise ValueError(
- f"Checksum mismatch for {filename}: expected
{checksum}, got {expected}"
+ f"checksum mismatch for {filename}: "
+ f"expected {checksum}, got {expected}"
)
+ else:
+ # No checksum token: still write the data (it passed the
+ # size check) but flag that it could not be verified.
+ warn(f"no checksum for {filename}; writing unverified
data")
- outfile = os.path.join(gcda_path, "./" + filename)
- os.makedirs(os.path.dirname(outfile), exist_ok=True)
-
- with open(outfile, "wb") as fp:
- fp.write(output)
- print(f"write {outfile} success")
- output = ""
+ out_path = _safe_join(out_dir, filename)
+ if out_path is None:
+ warn(f"skipping unsafe path in dump: {filename}")
+ else:
+ os.makedirs(os.path.dirname(out_path), exist_ok=True)
+ with open(out_path, "wb") as out_fp:
+ out_fp.write(blob)
+ payload = ""
else:
- output += line.strip()
+ payload += line.strip()
except Exception as e:
- print(f"Error processing {path}: {e}")
- print(f"gcov start filename:{filename} size:{size}")
- print(output)
- print(f"gcov end filename:{filename} checksum:{checksum}")
+ warn(f"error processing {path}: {e}")
+ log(f" gcov start filename:{filename} size:{size}")
+ log(f" payload[:200]={payload[:200]}")
+ payload = ""
+ started = False
+
+ return out_dir
+
+
+def parse_gcov_binpack(path: str) -> str:
+ """Parse gcov binary pack file into individual gcda files.
+
+ Binary format: [path_len(4B LE)][path+NUL][data_size(4B LE)][data]...
+ """
+ with open(path, "rb") as f:
+ data = f.read()
+
+ out_dir = path + CONVERT_SUFFIX
+ os.makedirs(out_dir, exist_ok=True)
+
+ offset = 0
+ count = 0
+ errors = 0
+
+ while offset < len(data):
+ if offset + 4 > len(data):
+ break
+
+ path_len = struct.unpack_from("<I", data, offset)[0]
+ offset += 4
+
+ if (
+ path_len == 0
+ or path_len > GCOV_BINPACK_PATH_LEN_MAX
+ or offset + path_len > len(data)
+ ):
+ warn(f"invalid path_len {path_len} at offset {offset - 4}")
+ errors += 1
+ break
+
+ filepath = data[offset : offset +
path_len].rstrip(b"\x00").decode("utf-8")
+ offset += path_len
+
+ if offset + 4 > len(data):
+ warn(f"truncated at data_size for {filepath}")
+ errors += 1
+ break
+
+ data_size = struct.unpack_from("<I", data, offset)[0]
+ offset += 4
+
+ if offset + data_size > len(data):
+ warn(f"truncated data for {filepath}")
+ gcda_data = data[offset:]
+ offset = len(data)
+ errors += 1
+ else:
+ gcda_data = data[offset : offset + data_size]
+ offset += data_size
+
+ out_path = _safe_join(out_dir, filepath)
+ if out_path is None:
+ warn(f"skipping unsafe path in binpack: {filepath}")
+ errors += 1
+ continue
+ os.makedirs(os.path.dirname(out_path), exist_ok=True)
+ with open(out_path, "wb") as out_fp:
+ out_fp.write(gcda_data)
+ count += 1
+
+ log(f"parsed binary pack: {count} files ({errors} errors)")
+ return out_dir
+
+
+def find_profraw_files(directory: str) -> List[str]:
+ """Recursively find all .profraw files in a directory."""
+ found = []
+ for root, _, files in os.walk(directory):
+ for f in files:
+ fpath = os.path.join(root, f)
+ if f.endswith(".profraw") or is_profraw_file(fpath):
+ found.append(fpath)
+ return found
+
+
+def resolve_data_inputs(inputs: List[str], delete_after: bool) -> List[str]:
+ """Normalize -a inputs to a list of directories or profraw files.
+
+ Files are auto-classified:
+ * .profraw (binary) -> kept as-is
+ * gcov binpack (binary) -> parsed to a directory
+ * hex-dump text log -> parsed to a directory
+ Directories are passed through.
+ """
+ out: List[str] = []
+ for entry in inputs:
+ if not os.path.exists(entry):
+ die(f"input not found: {entry}")
+ if os.path.isfile(entry):
+ abs_path = os.path.abspath(entry)
+ if is_profraw_file(abs_path):
+ out.append(abs_path)
+ elif is_gcov_binpack_file(abs_path):
+ out.append(parse_gcov_binpack(abs_path))
+ if delete_after:
+ os.remove(entry)
+ else:
+ out.append(parse_gcda_hex_dump(abs_path))
+ if delete_after:
+ os.remove(entry)
+ else:
+ out.append(os.path.abspath(entry))
+ return out
+
+
+# =============================================================================
+# 6. TOOL RESOLUTION
+# =============================================================================
+
+
+@dataclass
+class GccTools:
+ gcov: str
+ lcov: str
+ genhtml: str
+
+
+@dataclass
+class LlvmTools:
+ profdata: str
+ cov: str
+ readelf: Optional[str] = None
+ lld: Optional[str] = None
+
+
+def _which_first(candidates: List[str]) -> Optional[str]:
+ """Return the first candidate tool found on PATH, or None."""
+ for cand in candidates:
+ found = shutil.which(cand)
+ if found:
+ return found
+ return None
- return gcda_path
+
+def resolve_gcc_tools(elf: ElfInfo) -> GccTools:
+ """Resolve gcov/lcov/genhtml from PATH based on the ELF arch."""
+ candidates = GCC_CROSS_PREFIXES.get(elf.e_machine, ["gcov"])
+ gcov = _which_first(candidates)
+ if gcov is None:
+ die(
+ f"could not find gcov for {elf.arch_name} on PATH. " f"Tried:
{candidates}."
+ )
+
+ lcov = _which_first(["lcov"])
+ if lcov is None:
+ die("lcov not found on PATH. Install lcov.")
+
+ genhtml = _which_first(["genhtml"])
+ if genhtml is None:
+ die("genhtml not found on PATH. Install lcov (provides genhtml).")
+
+ log(f"gcov: {gcov}")
+ log(f"lcov: {lcov}")
+ log(f"genhtml: {genhtml}")
+ return GccTools(gcov=gcov, lcov=lcov, genhtml=genhtml)
-def correct_content_path(file, shield: list, newpath):
- with open(file, "r", encoding="utf-8") as f:
- content = f.read()
+def resolve_llvm_tools() -> LlvmTools:
+ """Resolve llvm-profdata/llvm-cov/llvm-readelf/ld.lld from PATH."""
+ profdata = _which_first(["llvm-profdata"])
+ if profdata is None:
+ die("llvm-profdata not found on PATH. Install LLVM.")
+ cov = _which_first(["llvm-cov"])
+ if cov is None:
+ die("llvm-cov not found on PATH. Install LLVM.")
+ readelf = _which_first(["llvm-readelf"])
+ lld = _which_first(["ld.lld"])
- for i in shield:
- content = content.replace(i, "")
+ log(f"llvm-profdata: {profdata}")
+ log(f"llvm-cov: {cov}")
+ log(f"llvm-readelf: {readelf or '(not found, covmap fix disabled)'}")
+ log(f"ld.lld: {lld or '(not found, covmap fix disabled)'}")
+ return LlvmTools(profdata=profdata, cov=cov, readelf=readelf, lld=lld)
- new_content = content
- if newpath is not None:
- pattern = r"SF:([^\s]*?)/nuttx/include/nuttx"
- matches = re.findall(pattern, content)
- if matches:
- new_content = content.replace(matches[0], newpath)
+# =============================================================================
+# 7. PATH-MAPPING (-b OLD=NEW)
+# =============================================================================
+
+
+@dataclass
+class PathMap:
+ pairs: List[Tuple[str, str]] = field(default_factory=list)
+ # NEW-only entries; OLD is inferred from the coverage data later.
+ pending: List[str] = field(default_factory=list)
+
+ @classmethod
+ def parse(cls, raw_args: Optional[List[str]]) -> "PathMap":
+ pairs: List[Tuple[str, str]] = []
+ pending: List[str] = []
+ for raw in raw_args or []:
+ if "=" in raw:
+ old, new = raw.split("=", 1)
+ if not old:
+ die(f"-b OLD part must be non-empty: {raw!r}")
+ pairs.append((old, new))
+ else:
+ pending.append(raw)
+ return cls(pairs=pairs, pending=pending)
+
+ def resolve_pending(self, recorded_paths: List[str]) -> None:
+ """Fill in OLD for NEW-only entries via build-root inference."""
+ for new in self.pending:
+ old = infer_build_root(recorded_paths, new)
+ if old is None:
+ die(
+ f"-b {new}: could not infer the build-time source root "
+ "from the coverage data. Pass the explicit form: "
+ f"-b <build-time-root>={new}"
+ )
+ log(f"-b {new}: inferred build-time root {old}")
+ self.pairs.append((old, new))
+ self.pending = []
- with open(file, "w", encoding="utf-8") as f:
- f.write(new_content)
+ def _check_resolved(self) -> None:
+ if self.pending:
+ die(
+ f"-b path inference was not performed for {self.pending}; "
+ "pass the explicit OLD=NEW form"
+ )
+ def apply(self, path: str) -> str:
+ """Rewrite a single recorded path through the first matching pair."""
+ for old, new in self.pairs:
+ if path.startswith(old):
+ return new + path[len(old) :]
+ return path
+
+ def llvm_path_equivalence_args(self) -> List[str]:
+ """Render -path-equivalence args for llvm-cov subcommands."""
+ self._check_resolved()
+ out: List[str] = []
+ for old, new in self.pairs:
+ out.append(f"-path-equivalence={old},{new}")
+ return out
+
+
+def lcov_info_source_paths(info_file: str) -> List[str]:
+ """Read the SF: (source file) paths out of an lcov tracefile."""
+ paths: List[str] = []
+ try:
+ with open(info_file, "r", encoding="utf-8", errors="replace") as fp:
+ for line in fp:
+ if line.startswith("SF:"):
+ paths.append(line[3:].strip())
+ except OSError as e:
+ warn(f"cannot read {info_file}: {e}")
+ return paths
+
+
+def lcov_info_remap_sources(info_file: str, path_map: PathMap) -> int:
+ """Rewrite SF: paths in an lcov tracefile in place.
+
+ Replaces lcov's ``--substitute`` (lcov 2.x only), so ``-b`` works on
+ lcov 1.x and both toolchains. Returns the number of records changed.
+ """
+ if not path_map.pairs:
+ return 0
+
+ try:
+ with open(info_file, "r", encoding="utf-8", errors="replace") as fp:
+ lines = fp.readlines()
+ except OSError as e:
+ warn(f"cannot read {info_file}: {e}")
+ return 0
-def copy_file_endswith(endswith, source, target):
- for root, dirs, files in os.walk(source, topdown=True):
- if target in root:
+ changed = 0
+ for i, line in enumerate(lines):
+ if not line.startswith("SF:"):
continue
+ old_path = line[3:].strip()
+ new_path = path_map.apply(old_path)
+ if new_path != old_path:
+ lines[i] = f"SF:{new_path}\n"
+ changed += 1
+
+ if changed:
+ try:
+ with open(info_file, "w", encoding="utf-8") as fp:
+ fp.writelines(lines)
+ except OSError as e:
+ warn(f"cannot rewrite {info_file}: {e}")
+ return 0
+ log(f"remapped {changed} SF: path(s) in {os.path.basename(info_file)}")
+ return changed
+
+
+def infer_build_root(
+ recorded_paths: List[str],
+ new_root: str,
+ min_ratio: float = 0.5,
+ max_samples: int = 64,
+) -> Optional[str]:
+ """Infer the build-time source root from recorded (absolute) paths.
+
+ Walk up from the longest common directory of the recorded paths and
+ return the first candidate OLD under which a majority of samples map
+ to existing local paths (local = new_root + relpath(recorded, OLD)).
+ A sample also counts as mapped when only its directory exists locally,
+ because gcc dumps record *.gcda paths whose files do not exist locally.
+ """
+ abs_paths = [p for p in recorded_paths if p.startswith("/")]
+ if not abs_paths:
+ return None
+
+ new_root = os.path.abspath(new_root)
+ samples = abs_paths[:max_samples]
+ candidate = os.path.commonpath(abs_paths)
+
+ while candidate and candidate != os.sep:
+ hits = 0
+ for f in samples:
+ mapped = os.path.join(new_root, os.path.relpath(f, candidate))
+ if os.path.exists(mapped) or
os.path.isdir(os.path.dirname(mapped)):
+ hits += 1
+ if hits >= min_ratio * len(samples):
+ return candidate
+ candidate = os.path.dirname(candidate)
+ return None
+
+
+# =============================================================================
+# 8. UTILITIES
+# =============================================================================
- for file in files:
- if file.endswith(endswith):
- src_file = os.path.join(root, file)
- dst_file = os.path.join(target, os.path.relpath(src_file,
source))
- os.makedirs(os.path.dirname(dst_file), exist_ok=True)
- shutil.copy2(src_file, dst_file)
+def copy_files_with_suffix(suffix: str, source: str, target: str) -> None:
+ """Mirror files matching `*<suffix>` from source into target.
+
+ Skips paths inside `target` to avoid recursive copies when target is a
+ descendant of source.
+ """
+ target_abs = os.path.abspath(target)
+ for root, _, files in os.walk(source):
+ if os.path.abspath(root).startswith(target_abs):
+ continue
+ for f in files:
+ if f.endswith(suffix):
+ src = os.path.join(root, f)
+ rel = os.path.relpath(src, source)
+ dst = os.path.join(target, rel)
+ os.makedirs(os.path.dirname(dst), exist_ok=True)
+ shutil.copy2(src, dst)
+
+
+def get_lcov_major_version(lcov_path: str) -> int:
+ """Return major version of installed lcov, or 1 if undetermined."""
+ try:
+ result = subprocess.run(
+ [lcov_path, "--version"],
+ capture_output=True,
+ text=True,
+ timeout=5,
+ )
+ m = re.search(r"LCOV version (\d+)", result.stdout)
+ if m:
+ return int(m.group(1))
+ except (subprocess.TimeoutExpired, OSError, FileNotFoundError):
+ pass
+ return 1
+
+
+def lcov_info_has_records(info_file: str) -> bool:
+ """True if an lcov tracefile has at least one SF: record."""
+ try:
+ with open(info_file, "r", encoding="utf-8", errors="replace") as fp:
+ for line in fp:
+ if line.startswith("SF:"):
+ return True
+ except (IOError, OSError):
+ pass
+ return False
-def run_lcov(data_dir, gcov_tool):
- output = data_dir + ".info"
- # lcov collect coverage data to coverage.info
- command = [
- "lcov",
+
+# =============================================================================
+# 9. GCC PIPELINE: gcda -> .info -> HTML
+# =============================================================================
+
+
+def _resolve_gcov_tool(gcov_path: str) -> Optional[str]:
+ """Find the gcov-tool binary that matches a given gcov binary.
+
+ Strategy (first hit wins):
+ 1. Sibling next to the gcov binary, e.g. /opt/.../bin/gcov-13 →
+ /opt/.../bin/gcov-tool-13, or aarch64-none-elf-gcov →
+ aarch64-none-elf-gcov-tool. This avoids the Ubuntu pitfall where
+ /usr/bin/gcov is gcov-13 but /usr/bin/gcov-tool is the old
gcov-tool-11.
+ 2. Plain `gcov-tool` on PATH (last resort).
+ """
+ real = os.path.realpath(gcov_path)
+ base = os.path.basename(real) # e.g. x86_64-linux-gnu-gcov-13
+ directory = os.path.dirname(real)
+
+ candidates: List[str] = []
+ # Mirror "gcov[-suffix]" → "gcov-tool[-suffix]"
+ if "gcov" in base:
+ head, _, tail = base.partition("gcov")
+ candidates.append(os.path.join(directory, head + "gcov-tool" + tail))
+
+ # Also try the literal sibling next to the user-supplied path
+ candidates.append(gcov_path + "-tool")
+
+ for c in candidates:
+ if os.path.isfile(c):
+ return c
+
+ return shutil.which("gcov-tool")
+
+
+def _list_files_with_suffix(root: str, suffix: str) -> List[str]:
+ """Paths (relative to root) of all files under root ending with suffix."""
+ found: List[str] = []
+ for cur, _, files in os.walk(root):
+ for f in files:
+ if f.endswith(suffix):
+ found.append(os.path.relpath(os.path.join(cur, f), root))
+ return found
+
+
+def gcc_merge_gcda(tools: GccTools, gcda_dirs: List[str], out_dir: str) ->
None:
+ """Merge multiple gcda directories into out_dir using `<gcov>-tool merge`.
+
+ The first directory is copied to ``out_dir``; each subsequent directory
+ is merged in via ``gcov-tool merge``. ``out_dir`` must not exist (we make
+ a fresh copy every time so reruns are repeatable).
+ """
+ if os.path.exists(out_dir):
+ shutil.rmtree(out_dir)
+
+ merge_tool: Optional[str] = None
+ if len(gcda_dirs) > 1:
+ merge_tool = _resolve_gcov_tool(tools.gcov)
+ if merge_tool is None:
+ die(
+ f"need gcov-tool to merge {len(gcda_dirs)} -a inputs but "
+ "no matching gcov-tool was found on PATH. Install the "
+ "matching package (e.g. apt install gcc-13)."
+ )
+ log(f"gcov-tool: {merge_tool}")
+
+ shutil.copytree(gcda_dirs[0], out_dir)
+ for extra in gcda_dirs[1:]:
+ # `gcov-tool merge` only handles files present in BOTH inputs; a .gcda
+ # that exists only in `extra` (e.g. a second image of a multi-image
+ # board dumped from the same core, whose objects live under another
+ # build directory) is skipped and would be silently lost. Remember
+ # those and copy them over after the merge. Doing it after, not
+ # before, keeps them from being merged with themselves (doubled).
+ only_in_extra = [
+ rel
+ for rel in _list_files_with_suffix(extra, ".gcda")
+ if not os.path.exists(os.path.join(out_dir, rel))
+ ]
+ try:
+ run_cmd([merge_tool, "merge", out_dir, extra, "-o", out_dir])
+ except subprocess.CalledProcessError:
+ die(
+ f"gcov-tool merge failed for {extra}. Common causes: "
+ "(1) version mismatch between gcov-tool and the .gcda files "
+ "('incorrect gcov version' messages above) — install the "
+ "matching gcov-tool so it is found on PATH, "
+ "(2) the input was already merged in. "
+ f"merge_tool={merge_tool}"
+ )
+ for rel in only_in_extra:
+ dst = os.path.join(out_dir, rel)
+ os.makedirs(os.path.dirname(dst), exist_ok=True)
+ shutil.copy2(os.path.join(extra, rel), dst)
+ if only_in_extra:
+ log(f"{len(only_in_extra)} .gcda only in {extra}: copied (not
merged)")
+
+
+def gcc_run_lcov(tools: GccTools, data_dir: str, path_map: PathMap) -> str:
+ """Run lcov capture, returning path to the produced .info file."""
+ info_file = data_dir + ".info"
+ cmd = [
+ tools.lcov,
"-c",
"-o",
- output,
+ info_file,
"--rc",
"lcov_branch_coverage=1",
"--gcov-tool",
- gcov_tool,
+ tools.gcov,
"--ignore-errors",
"gcov",
"--directory",
data_dir,
]
- print(command)
- subprocess.run(
- command,
- check=True,
- stdout=sys.stdout,
- stderr=sys.stdout,
+ run_cmd(cmd)
+ return info_file
+
+
+def run_genhtml(
+ genhtml: str, info_file: str, report_dir: str, lcov: Optional[str] = None
+) -> None:
+ """Render an lcov tracefile to HTML (shared by both toolchains)."""
+ ignore_errors = "source"
+ if lcov is not None and get_lcov_major_version(lcov) >= 2:
+ ignore_errors = "source,category"
+
+ run_cmd(
+ [
+ genhtml,
+ "--branch-coverage",
+ "-o",
+ report_dir,
+ "--ignore-errors",
+ ignore_errors,
+ info_file,
+ ]
)
- return output
+def gcc_run_genhtml(tools: GccTools, info_file: str, report_dir: str) -> None:
+ run_genhtml(tools.genhtml, info_file, report_dir, tools.lcov)
+
+
+def run_gcc_pipeline(
+ gcda_inputs: List[str],
+ gcno_dir: str,
+ result_dir: str,
+ elf: ElfInfo,
+ path_map: PathMap,
+) -> None:
+ tools = resolve_gcc_tools(elf)
+
+ data_dir = os.path.join(result_dir, "data")
+ report_dir = os.path.join(result_dir, "report")
+
+ gcc_merge_gcda(tools, gcda_inputs, data_dir)
+ copy_files_with_suffix(".gcno", gcno_dir, data_dir)
+
+ info_file = gcc_run_lcov(tools, data_dir, path_map)
+
+ if not lcov_info_has_records(info_file):
+ warn(
+ f"lcov produced no coverage records in {info_file}. This usually "
+ "means the local gcov tool cannot decode the collected .gcda "
+ "files (toolchain version mismatch — see 'version ... prefer ...' "
+ "warnings above). Skipping HTML report generation."
+ )
+ return
+
+ # Remap SF: paths (and resolve NEW-only -b entries) on the tracefile.
+ if path_map.pending:
+ path_map.resolve_pending(lcov_info_source_paths(info_file))
+ lcov_info_remap_sources(info_file, path_map)
+
+ try:
+ gcc_run_genhtml(tools, info_file, report_dir)
+ except subprocess.CalledProcessError:
+ die("failed to generate coverage report.")
+
+ print_report_link(report_dir)
+
+
+# =============================================================================
+# 10. CLANG PIPELINE: profraw -> profdata -> HTML
+# =============================================================================
+
+
+def clang_merge_profraw(
+ tools: LlvmTools, profraw_files: List[str], out_profdata: str
+) -> str:
+ cmd = [tools.profdata, "merge", "-sparse", "-o", out_profdata]
+ cmd.extend(profraw_files)
+ run_cmd(cmd)
+ return out_profdata
+
+
+def _check_covmap_one(readelf: str, path: str) -> Optional[str]:
+ """Return ``path`` if its ELF has a ``__llvm_covmap`` section, else
None."""
+ try:
+ result = subprocess.run(
+ [readelf, "-S", path],
+ capture_output=True,
+ text=True,
+ timeout=10,
+ )
+ except (subprocess.TimeoutExpired, OSError):
+ return None
+ return path if "__llvm_covmap" in result.stdout else None
+
+
+def clang_find_covmap_objects(build_dir: str, readelf: str) -> List[str]:
+ """Find all .o files containing ``__llvm_covmap`` sections.
+
+ Each check spawns ``llvm-readelf -S``, so the work is I/O / subprocess
+ bound — a thread pool parallelises it without the fork/pickle overhead of
+ processes. Tiny trees run inline to skip pool spin-up. Output order is
+ not preserved — safe because ``ld.lld -r`` is order-insensitive.
+ """
+ paths: List[str] = []
+ for root, _, files in os.walk(build_dir):
+ for f in files:
+ if f.endswith(".o"):
+ paths.append(os.path.join(root, f))
+
+ if not paths:
+ return []
+
+ if len(paths) < 32:
+ objects: List[str] = []
+ for p in paths:
+ hit = _check_covmap_one(readelf, p)
+ if hit is not None:
+ objects.append(hit)
+ return objects
+
+ workers = min(32, (os.cpu_count() or 4) * 4)
+ objects = []
+ with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
+ for hit in ex.map(lambda p: _check_covmap_one(readelf, p), paths):
+ if hit is not None:
+ objects.append(hit)
+ return objects
+
+
+def clang_link_relocatable(objects: List[str], out_path: str, lld: str) -> str:
+ log(f"linking {len(objects)} objects into {out_path} ...")
+ run_cmd([lld, "-r", "-o", out_path] + objects)
+ return out_path
+
+
+def _fingerprint_paths(paths: List[str]) -> List[List]:
+ """(path, mtime_ns, size) records keying the relocatable cache."""
+ records = []
+ for p in paths:
+ try:
+ st = os.stat(p)
+ except OSError:
+ continue
+ records.append([p, st.st_mtime_ns, st.st_size])
+ records.sort()
+ return records
+
+
+def _load_cache_fingerprint(cache_path: str) -> Optional[dict]:
+ try:
+ with open(cache_path) as fp:
+ return json.load(fp)
+ except (OSError, ValueError):
+ return None
+
+
+def _save_cache_fingerprint(cache_path: str, payload: dict) -> None:
+ try:
+ with open(cache_path, "w") as fp:
+ json.dump(payload, fp)
+ except OSError as e:
+ warn(f"failed to write cache fingerprint {cache_path}: {e}")
+
+
+def _find_prebuilt_reloc(build_dir: str) -> Optional[str]:
+ """Return a pre-built relocatable covmap object if -s points at one.
+
+ Two accepted forms, both produced once and persisted so reports can
+ be generated long after the build tree (the per-source ``.o`` files)
+ is gone:
+
+ * ``-s path/to/<obj>.o`` -> the file itself
+ * ``-s path/to/dir`` -> ``dir/nuttx_llvm_covmap.o`` or
+ ``dir/coverage_all.o`` if present
+
+ ``nuttx_llvm_covmap.o`` is the slim LLVM coverage object emitted by the
+ build (see tools/gen_llvm_covmap.py); ``coverage_all.o`` is the full
+ relocatable that tools/gcov.py itself caches. Returning a path here lets
+ ``clang_resolve_cov_binary`` skip the readelf scan + ld.lld link.
+ """
+ if build_dir.endswith(".o") and os.path.isfile(build_dir):
+ return build_dir
+ if os.path.isdir(build_dir):
+ for name in ("nuttx_llvm_covmap.o", "coverage_all.o"):
+ candidate = os.path.join(build_dir, name)
+ if os.path.isfile(candidate):
+ return candidate
+ return None
+
+
+def clang_resolve_cov_binary(
+ tools: LlvmTools, elf_path: str, build_dir: str, result_dir: str
+) -> str:
+ """Pick the binary llvm-cov should consult.
+
+ On lld-linked NuttX binaries, __llvm_covfun/__llvm_covmap may sit at
+ VMA=0, so llvm-cov refuses to read them from the final ELF; we link
+ a relocatable .o from all source objects carrying covmap sections.
+
+ Resolution order:
+ 0. No -s (build_dir is None): use the ELF directly. llvm-cov reads its
+ __llvm_covmap/__llvm_covfun even at VMA 0; it then only reports the
+ functions actually linked into the image. Linker-GC'd dead code is
+ correctly excluded, so the coverage denominator reflects real code.
+ 1. A pre-built ``coverage_all.o`` reachable via ``-s`` (file or dir).
+ Used as-is -- no build tree required. This includes dead code GC'd
+ from the final image, inflating the denominator; use only when you
+ specifically want every compiled (even unlinked) function counted.
+ 2. Scan ``build_dir`` for ``.o`` files with covmap sections and link
+ them on the fly (result cached via a ``.fingerprint`` sidecar).
+ """
+ if build_dir is None:
+ log("using the ELF directly for the report (no -s given)")
+ return elf_path
+
+ prebuilt = _find_prebuilt_reloc(build_dir)
+ if prebuilt is not None:
+ log(f"using pre-built relocatable object for report: {prebuilt}")
+ return prebuilt
-def run_genhtml(info, report):
+ if not (tools.readelf and tools.lld):
+ if not tools.readelf:
+ log("note: llvm-readelf not found, skipping relocatable object
build.")
+ if not tools.lld:
+ log("note: ld.lld not found, skipping relocatable object build.")
+ return elf_path
+
+ reloc = os.path.join(result_dir, "coverage_all.o")
+ cache_path = reloc + ".fingerprint"
+
+ # Cache hit: skip both readelf scan and ld.lld link.
+ if os.path.isfile(reloc) and os.path.isfile(cache_path):
+ cached = _load_cache_fingerprint(cache_path)
+ if cached and cached.get("build_dir") == os.path.abspath(build_dir):
+ cached_records = cached.get("objects") or []
+ cached_paths = [r[0] for r in cached_records]
+ current_records = _fingerprint_paths(cached_paths)
+ if current_records == cached_records:
+ log(
+ f"cache hit: reusing {reloc} "
+ f"({len(cached_records)} objects unchanged)"
+ )
+ return reloc
+ log("cache miss: covmap object set changed, rebuilding.")
+
+ log("scanning for .o files with __llvm_covmap sections ...")
+ objects = clang_find_covmap_objects(build_dir, tools.readelf)
+ if not objects:
+ log("no .o files with __llvm_covmap found, using ELF binary directly.")
+ return elf_path
+
+ try:
+ clang_link_relocatable(objects, reloc, tools.lld)
+ log(f"using relocatable object for report: {reloc}")
+ except subprocess.CalledProcessError as e:
+ warn(f"failed to build relocatable object: {e}; falling back to ELF.")
+ return elf_path
+
+ _save_cache_fingerprint(
+ cache_path,
+ {
+ "build_dir": os.path.abspath(build_dir),
+ "objects": _fingerprint_paths(objects),
+ },
+ )
+
+ return reloc
+
+
+def clang_run_report(
+ tools: LlvmTools, profdata: str, binary: str, path_map: PathMap
+) -> None:
+ cmd = [tools.cov, "report", binary, f"-instr-profile={profdata}"]
+ cmd.extend(path_map.llvm_path_equivalence_args())
+ run_cmd(cmd)
+
+
+def clang_run_show_html(
+ tools: LlvmTools, profdata: str, binary: str, report_dir: str, path_map:
PathMap
+) -> None:
cmd = [
- "genhtml",
- "--branch-coverage",
- "-o",
- report,
- "--ignore-errors",
- "source",
- info,
+ tools.cov,
+ "show",
+ binary,
+ f"-instr-profile={profdata}",
+ "-format=html",
+ f"-output-dir={report_dir}",
+ "-show-line-counts-or-regions",
+ "-show-branches=count",
+ "-show-directory-coverage",
]
- print(cmd)
- subprocess.run(
- cmd,
- check=True,
- stdout=sys.stdout,
- stderr=sys.stdout,
- )
+ cmd.extend(path_map.llvm_path_equivalence_args())
+ run_cmd(cmd)
-def run_merge(gcda_dir1, gcda_dir2, output, merge_tool):
- command = [
- merge_tool,
- "merge",
- gcda_dir1,
- gcda_dir2,
- "-o",
- output,
+def clang_run_export_lcov(
+ tools: LlvmTools, profdata: str, binary: str, out_info: str
+) -> str:
+ """Export an lcov tracefile with the recorded (build-time) paths."""
+ cmd = [
+ tools.cov,
+ "export",
+ binary,
+ f"-instr-profile={profdata}",
+ "-format=lcov",
]
- print(command)
- subprocess.run(
- command,
- check=True,
- stdout=sys.stdout,
- stderr=sys.stdout,
- )
+ log(" ".join(cmd))
+ with open(out_info, "w") as fp:
+ subprocess.run(cmd, check=True, stdout=fp, stderr=sys.stdout)
+ return out_info
+
+
+def run_clang_pipeline(
+ data_inputs: List[str],
+ gcno_dir: str,
+ result_dir: str,
+ elf_path: str,
+ path_map: PathMap,
+ llvm_html: bool = False,
+) -> None:
+ tools = resolve_llvm_tools()
+
+ data_dir = os.path.join(result_dir, "data")
+ report_dir = os.path.join(result_dir, "report")
+ os.makedirs(data_dir, exist_ok=True)
+ # Collect all profraw files
+ profraw_files: List[str] = []
+ for entry in data_inputs:
+ if os.path.isfile(entry):
+ profraw_files.append(entry)
+ elif os.path.isdir(entry):
+ profraw_files.extend(find_profraw_files(entry))
+ if not profraw_files:
+ die("no .profraw files found in the specified -a paths.")
+ log(f"found {len(profraw_files)} profraw file(s)")
-def arg_parser():
+ # profraw -> profdata
+ profdata = os.path.join(data_dir, "merged.profdata")
+ clang_merge_profraw(tools, profraw_files, profdata)
+
+ cov_binary = clang_resolve_cov_binary(tools, elf_path, gcno_dir,
result_dir)
+
+ # Export first: the SF: records drive path remap and build-root inference.
+ cov_info = os.path.join(result_dir, "gcov.info")
+ try:
+ clang_run_export_lcov(tools, profdata, cov_binary, cov_info)
+ except subprocess.CalledProcessError as e:
+ die(f"llvm-cov export (lcov format) failed: {e}")
+
+ if path_map.pending:
+ path_map.resolve_pending(lcov_info_source_paths(cov_info))
+ lcov_info_remap_sources(cov_info, path_map)
+
+ log("\n=== Coverage Summary ===")
+ clang_run_report(tools, profdata, cov_binary, path_map)
+
+ ok = False
+ if llvm_html:
+ # Renders from the coverage map, not the remapped tracefile, so it
+ # still needs -path-equivalence.
+ try:
+ clang_run_show_html(tools, profdata, cov_binary, report_dir,
path_map)
+ ok = True
+ except subprocess.CalledProcessError as e:
+ warn(f"llvm-cov show HTML failed: {e}; falling back to genhtml.")
+
+ if not ok:
+ genhtml = shutil.which("genhtml")
+ if genhtml is None:
+ die(
+ "genhtml not found on PATH; install lcov, or pass "
+ "--llvm-html to render with llvm-cov instead."
+ )
+ try:
+ run_genhtml(genhtml, cov_info, report_dir, shutil.which("lcov"))
+ ok = True
+ except subprocess.CalledProcessError as e:
+ die(f"genhtml failed: {e}")
+
+ # Only advertise the report if it actually produced an index page.
+ if ok and os.path.isfile(os.path.join(report_dir, "index.html")):
+ print_report_link(report_dir)
+ else:
+ warn("HTML report was not generated; no report link to show.")
+
+
+# =============================================================================
+# 11. CLI + MAIN
+# =============================================================================
+
+
+def build_arg_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
- description="Code coverage generation tool.", add_help=False
+ description=(
+ "Code coverage report generator (auto-detects GCC vs Clang from "
+ "the ELF binary)."
+ ),
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ epilog="""\
+Examples:
+ # Clang (LLVM source-based coverage)
+ gcov.py -e build/nuttx -s build -a default.profraw -o report
+
+ # GCC (lcov + genhtml)
+ gcov.py -e build/nuttx -s build -a coverage_dir -o report
+
+ # With path remap (works for both backends)
+ gcov.py -b /old/build/path=/new/local/path \\
+ -e build/nuttx -s build -a default.profraw -o report
+
+ # Path remap with inferred build-time root
+ gcov.py -b /new/local/path \\
+ -e build/nuttx -s build -a default.profraw -o report
+
+Tools are resolved from PATH (gcov/lcov/genhtml for GCC; llvm-profdata/
+llvm-cov/llvm-readelf/ld.lld for Clang); make sure the toolchain is on PATH.
+""",
+ )
+ parser.add_argument(
+ "-e",
+ "--elf",
+ dest="elf_binary",
+ required=True,
+ help="Path to the ELF binary that produced the coverage data. "
+ "The toolchain (gcc/clang) and target architecture are inferred "
+ "from this file.",
)
- parser.add_argument("-t", dest="gcov_tool", help="Path to gcov tool")
- parser.add_argument("-b", dest="base_dir", help="Compile base directory")
- parser.add_argument("--debug", action="store_true", help="Enable debug
mode")
- parser.add_argument("--delete", action="store_true", help="Delete gcda
files")
parser.add_argument(
"-s",
dest="gcno_dir",
- default=".",
- help="Directory containing gcno files",
+ required=False,
+ default=None,
+ help="Directory with .gcno files (gcc) or build output containing "
+ ".o files for the covmap relocatable workaround (clang). For "
+ "clang it may also point directly at a pre-built LLVM coverage object "
+ "(nuttx_llvm_covmap.o or coverage_all.o, as produced by the build), "
+ "or a directory containing one, to report from persisted coverage "
+ "data without the build tree. Required for gcc. Optional for clang: "
+ "when omitted, coverage is read straight from the ELF (-e), which "
+ "only reports functions actually linked into the image -- linker "
+ "GC'd dead code is correctly excluded.",
)
parser.add_argument(
"-a",
- dest="gcda_dir",
- default=".",
+ dest="data_inputs",
+ default=["."],
nargs="+",
- help="Directory containing gcda files",
- )
- parser.add_argument(
- "-x",
- dest="only_copy",
- action="store_true",
- help="Only copy *.gcno and *.gcda files",
+ help="Directories or files holding .gcda (gcc) or .profraw (clang) "
+ "data. Hex-dump text logs and gcov binary packs are supported.",
)
parser.add_argument(
"-o",
dest="result_dir",
default="gcov",
- help="Directory to store gcov data and report",
+ help="Output directory for intermediate data and the HTML report.",
)
-
- return parser.parse_args()
+ parser.add_argument(
+ "-b",
+ dest="path_map",
+ action="append",
+ metavar="OLD=NEW",
+ help="Rewrite paths recorded in coverage data: replace OLD with NEW. "
+ "Maps to lcov --substitute / llvm-cov -path-equivalence. "
Review Comment:
The `-b` help says "Maps to lcov --substitute", but b24a9767107 moved the
remap to the SF: records and `gcc_run_lcov()` no longer passes that option.
Fix: change the help text to say that the tracefile SF: records are
rewritten.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]