mirror of https://lore.kernel.org/lkml/
 help / color / mirror / Atom feed
From: Douglas Anderson <dianders@chromium.org>
To: Devicetree-compiler@vger.kernel.org,
	Rob Herring <robh@kernel.org>,
	Krzysztof Kozlowski <krzk+dt@kernel.org>,
	Conor Dooley <conor+dt@kernel.org>,
	Konrad Dybcio <konradybcio@kernel.org>
Cc: "Brian Norris" <briannorris@chromium.org>,
	"Chen-Yu Tsai" <wenst@chromium.org>,
	"André Draszik" <andre.draszik@linaro.org>,
	Devicetree@vger.kernel.org, "Simon Glass" <sjg@chromium.org>,
	"David Gibson" <david@gibson.dropbear.id.au>,
	"Tudor Ambarus" <tudor.ambarus@linaro.org>,
	linux-kernel@vger.kernel.org,
	"Julius Werner" <jwerner@chromium.org>,
	"Saravana Kannan" <saravanak@kernel.org>,
	"Peter Griffin" <peter.griffin@linaro.org>,
	"Douglas Anderson" <dianders@chromium.org>
Subject: [PATCH v2 07/10] fdtfactor: Add tool to split DTBs into a base DTB and overlays
Date: Wed, 28 Oct 2026 14:46:56 -0700	[thread overview]
Message-ID: <20261028215600.1713475-8-dianders@chromium.org> (raw)
In-Reply-To: <20261028215600.1713475-1-dianders@chromium.org>

When shipping a single firmware or kernel image that supports many
closely related board variants or SKUs, bundling a full compiled .dtb
for every variant results in substantial duplication.

Add `fdtfactor`, a Python tool built on `pylibfdt` that takes a set of
compiled device tree blobs (.dtb) compiled with `-@ -L` (`/__symbols__`
and `/__local_fixups__`) and splits them into a single common
`base.dtb` and per-board `.dtbo` overlays:

- Common nodes present across all input DTBs are placed in `base.dtb`,
  with each common property assigned the value shared by at least two
  boards that maximizes byte savings across overlays.
- Using `/__local_fixups__`, phandle references inside property values
  are normalized to target node paths so common properties referencing
  common nodes match even when numeric phandles differ across input
  DTBs.
- Any common node that carries a phandle in any input DTB receives its
  phandle in `base.dtb` so overlays only emit `phandle` properties on
  newly added subnodes, matching the requirements of `libfdt`, Linux's
  `drivers/of/overlay.c`, and coreboot's `dt_apply_overlay()`.

Add a `fdtfactor` test suite in `tests/run_tests.sh` verifying that
applying each generated `.dtbo` to `base.dtb` with `fdtoverlay`
reconstructs the original DTB (verified via `dtbs_equal_unordered`,
including across input DTBs with shifted phandle numbers).

Assisted-by: Gemini:gemini-pro-prerelease
Signed-off-by: Douglas Anderson <dianders@chromium.org>
---

Changes in v2:
- Preserve /__local_fixups__ on base.dtb by default (David Gibson)
- Store parsed trees directly in NodeTree with node.phandle and
  node.fixups attributes instead of parallel dicts (David Gibson)
- Extract phandles in a separate pass after unflattening and reject
  legacy linux,phandle properties (David Gibson)
- Exclude non-root common phandle nodes lacking a common symbol from
  base.dtb instead of synthesizing __fdtfactor_ph_* symbols (David
  Gibson)
- Drop -q/--quiet, rename -v/--verbose to -s/--stats, and move tests to
  a dedicated fdtfactor test suite (David Gibson)

 Makefile                           |   2 +-
 README.md                          |  43 ++
 fdtfactor                          | 814 +++++++++++++++++++++++++++++
 meson.build                        |   1 +
 tests/Makefile.tests               |   4 +-
 tests/fdtfactor_board1.dts         |  38 ++
 tests/fdtfactor_board2.dts         |  36 ++
 tests/fdtfactor_board3.dts         |  34 ++
 tests/fdtfactor_board3_shifted.dts |  34 ++
 tests/meson.build                  |   2 +-
 tests/run_tests.sh                 |  65 ++-
 tests/testutils.sh                 |   1 +
 12 files changed, 1067 insertions(+), 7 deletions(-)
 create mode 100755 fdtfactor
 create mode 100644 tests/fdtfactor_board1.dts
 create mode 100644 tests/fdtfactor_board2.dts
 create mode 100644 tests/fdtfactor_board3.dts
 create mode 100644 tests/fdtfactor_board3_shifted.dts

diff --git a/Makefile b/Makefile
index 9110d17796b0..4df5bbda9ce2 100644
--- a/Makefile
+++ b/Makefile
@@ -165,7 +165,7 @@ BIN += fdtget
 BIN += fdtput
 BIN += fdtoverlay
 
-SCRIPTS = dtdiff
+SCRIPTS = dtdiff fdtfactor
 
 all: $(BIN) libfdt
 
diff --git a/README.md b/README.md
index 8f1a5d2a4d67..446135e7c02c 100644
--- a/README.md
+++ b/README.md
@@ -91,6 +91,49 @@ $ make NO_PYTHON=1
 More work remains to support all of libfdt, including access to numeric
 values.
 
+## fdtfactor
+
+`fdtfactor` is a Python tool built on `pylibfdt` that takes a set of
+compiled device tree blobs (`.dtb`) and splits them into a single common
+`base.dtb` and per-board `.dtbo` overlays so that applying a board's
+`.dtbo` to `base.dtb` recreates the original board device tree while
+reducing total storage size.
+
+Input `.dtb` files must be compiled with `dtc -@ -L` so that both
+`/__symbols__` (used to resolve overlay `/__fixups__` against `base.dtb`)
+and `/__local_fixups__` (used to identify phandle references inside
+property values) are present.
+
+Basic usage:
+
+```
+$ fdtfactor -o out_dir board1.dtb board2.dtb board3.dtb
+```
+
+Options:
+
+* `-b`, `--base-dtb`: Output filename for the common base `.dtb`
+  (default: `base.dtb`; placed in `--outdir` if specified, or the current
+  directory otherwise).
+* `-o`, `--outdir`: Output directory for generated `.dtbo` files and
+  `--base-dtb` (default: alongside each input `.dtb` for `.dtbo` files,
+  current directory for `--base-dtb`).
+* `-s`, `--stats`: Print size savings summary.
+
+### TODO
+
+* **Targeted overlay fragments**: Add an option to emit fine-grained
+  `/fragment@N` nodes targeting individual parent subnodes rather than
+  placing all overlay changes under a single `/fragment@0` targeting `/`.
+* **Multi-level overlays**: Support hierarchical overlay layers (for
+  example, SoC base -> board family intermediate overlay -> SKU leaf
+  overlay) when multiple boards share large sub-trees not present across
+  all boards. As an interim step, `fdtfactor` could accept an
+  already-generated `base.dtb` plus a subset of `.dtbo` overlays emitted
+  by an initial pass, leaving `base.dtb` untouched while factoring out
+  an intermediate `.dtbo` for the commonalities shared within that
+  subset.
+
 ## Mailing lists
 
 * The [devicetree-compiler](mailto:devicetree-compiler@vger.kernel.org)
diff --git a/fdtfactor b/fdtfactor
new file mode 100755
index 000000000000..f1a5b35d5ee9
--- /dev/null
+++ b/fdtfactor
@@ -0,0 +1,814 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: GPL-2.0-or-later
+"""fdtfactor - Split multiple compiled .dtb files into a base .dtb and .dtbo overlays.
+
+Given two or more input .dtb files compiled with `dtc -@ -L` (so they include
+`/__symbols__` and `/__local_fixups__`), `fdtfactor` extracts all nodes and
+properties shared across the inputs into a single common `base.dtb` and emits a
+small per-board `.dtbo` overlay for each input. Applying a board's `.dtbo` to
+`base.dtb` (e.g., via `fdtoverlay` or `fdt_overlay_apply()`) reconstructs the
+original board DTB.
+"""
+
+import argparse
+from collections import Counter
+import os
+import struct
+import sys
+
+import libfdt
+
+
+def _is_dtbo_overlay(fdt):
+    """Return True if `fdt` is a `.dtbo` overlay rather than a base DTB.
+
+    Args:
+        fdt: A `libfdt.FdtRo` instance to inspect.
+
+    Returns:
+        True if `fdt` contains `/__fixups__` or a top-level subnode with an
+        `__overlay__` child, False otherwise.
+    """
+    if fdt.subnode_offset(0, '__fixups__', quiet=libfdt.QUIET_NOTFOUND) >= 0:
+        return True
+    return any(
+        fdt.subnode_offset(soff, '__overlay__', quiet=libfdt.QUIET_NOTFOUND) >= 0
+        for soff in fdt.subnodes(0)
+    )
+
+
+class ParsedDtb:
+    """Complete input .dtb blob with phandle-normalized tree and FDT metadata.
+
+    While `libfdt.NodeTree` is a generic live-tree representation of a node
+    hierarchy (with raw byte properties and unprocessed `/__symbols__`,
+    `/__local_fixups__`, and `phandle` properties), `ParsedDtb` wraps an entire
+    input `.dtb` and normalizes it for cross-board comparison:
+
+    - Stores FDT header, memory reservation, and file-level metadata that live
+      outside the root node tree (`boot_cpuid_phys`, `mem_rsv`, `filename`,
+      `raw_bytes`).
+    - Pops `/__symbols__` (parsed into `self.symbols`) and `/__local_fixups__`
+      out of `self.root`.
+    - Moves each node's `phandle` property out of `node.props` into
+      `node.phandle`, and uses `/__local_fixups__` to zero out numeric phandle
+      cells in `node.props` while recording canonical `(offset, target_path)`
+      tuples in `node.fixups` so properties can be compared across DTBs
+      independent of numeric phandle numbering.
+    """
+
+    def __init__(self, filename):
+        """Parse an uncompressed FDT file into a normalized NodeTree.
+
+        Args:
+            filename: Path to the input .dtb file.
+        """
+        self.filename = filename
+        with open(filename, 'rb') as f:
+            raw_bytes = f.read()
+        self.raw_bytes = raw_bytes
+
+        try:
+            fdt = libfdt.FdtRo(raw_bytes)
+            libfdt.check_err(libfdt.fdt_check_full(fdt._fdt, len(raw_bytes)))
+        except libfdt.FdtException as exc:
+            raise ValueError(f'{filename}: Invalid FDT blob ({exc})') from exc
+
+        if _is_dtbo_overlay(fdt):
+            raise ValueError(
+                f'{filename}: Input is already a DTB overlay (.dtbo)'
+            )
+
+        self.boot_cpuid_phys = fdt.boot_cpuid_phys()
+        self.mem_rsv = tuple(fdt.get_mem_rsv_list())
+        self.root = libfdt.NodeTree.unflatten(fdt)
+
+        sym_node = self.root.children.pop('__symbols__', None)
+        lf_root = self.root.children.pop('__local_fixups__', None)
+        if sym_node is None or lf_root is None:
+            raise ValueError(
+                f'input DTB(s) lack /__symbols__ or /__local_fixups__: '
+                f'{filename} (compile input DTBs with dtc -@ -L)'
+            )
+
+        phandle_to_path = self._extract_phandles()
+        self.symbols = self._parse_symbols(sym_node)
+        self._apply_local_fixups(lf_root, phandle_to_path)
+
+    def _extract_phandles(self):
+        """Move `phandle` properties out of `node.props`.
+
+        Walks `self.root` in pre-order and, for each node:
+        - Initializes `node.phandle = None` and `node.fixups = {}`.
+        - Pops `phandle` from `node.props` (so phandle properties are not
+          compared as ordinary byte properties across DTBs, since `dtc` may
+          assign different integer phandles to the same node in different
+          boards) and stores the decoded integer in `node.phandle`.
+        - Validates that `node.phandle` is not `0` or `0xffffffff`, and that no
+          two nodes in the DTB share the same phandle.
+
+        Returns:
+            Dict mapping each integer phandle in this DTB to its node path
+            string (`""` for root, `"/soc/..."` for subnodes), used by
+            `_apply_local_fixups()` to resolve numeric phandle cells to
+            canonical target node paths.
+        """
+        phandle_to_path = {}
+        for path, node in self.root.walk():
+            node.phandle = None
+            node.fixups = {}
+            if 'linux,phandle' in node.props:
+                raise ValueError(
+                    f'{self.filename}: {path or "/"}: legacy linux,phandle '
+                    f'property is not supported (compile with dtc -H epapr)'
+                )
+            if 'phandle' in node.props:
+                node.phandle = node.get_u32('phandle')
+                del node.props['phandle']
+
+            if node.phandle is not None:
+                if node.phandle in (0, 0xffffffff):
+                    raise ValueError(
+                        f'{self.filename}: invalid phandle {node.phandle:#x} '
+                        f'on {path or "/"}'
+                    )
+                if node.phandle in phandle_to_path:
+                    raise ValueError(
+                        f'{self.filename}: duplicate phandle {node.phandle:#x} '
+                        f'on {phandle_to_path[node.phandle] or "/"} and '
+                        f'{path or "/"}'
+                    )
+                phandle_to_path[node.phandle] = path
+        return phandle_to_path
+
+    def _parse_symbols(self, sym_node):
+        """Parse the popped `/__symbols__` node into a `label -> path` dict.
+
+        Each property on `/__symbols__` (ignoring any `phandle` property on the
+        `/__symbols__` node itself) maps a Devicetree label name to the full
+        path of the target node in the DTB. Root paths (`"/"`) are normalized to
+        `""` to match `NodeTree` path conventions, and each target path is
+        verified to exist in `self.root`.
+
+        Args:
+            sym_node: The `NodeTree` for `/__symbols__` popped from `self.root`.
+
+        Returns:
+            Dict mapping symbol label name (str) to normalized target node path
+            (str, `""` for root or `"/..."` for subnodes).
+        """
+        symbols = {}
+        for label in sym_node.props:
+            if label == 'phandle':
+                continue
+            raw_path = sym_node.get_str(label)
+            if not raw_path.startswith('/') or '//' in raw_path:
+                raise ValueError(
+                    f'{self.filename}: symbol {label!r} has malformed '
+                    f'path {raw_path!r}'
+                )
+            target_path = raw_path.rstrip('/')
+            if self.root.get_node(target_path) is None:
+                raise ValueError(
+                    f'{self.filename}: symbol {label!r} references '
+                    f'missing node {target_path or "/"!r}'
+                )
+            symbols[label] = target_path
+        return symbols
+
+    def _apply_local_fixups(self, lf_root, phandle_to_path):
+        """Normalize phandle references in `self.root` using `/__local_fixups__`.
+
+        The `/__local_fixups__` tree mirrors the structure of `self.root`: each
+        property in `/__local_fixups__` contains a list of 32-bit byte offsets
+        where the corresponding property in `self.root` holds a phandle cell.
+        For every such offset, this method:
+        - Reads the 32-bit integer phandle from `target_node.props[prop_name]`
+          and resolves it to a canonical target node path via `phandle_to_path`.
+        - Zeros out the 4-byte phandle cell in `target_node.props[prop_name]` so
+          property values can be compared across DTBs independent of numeric
+          phandle numbering.
+        - Records `((offset, target_path), ...)` in
+          `target_node.fixups[prop_name]`.
+
+        Args:
+            lf_root: The `NodeTree` for `/__local_fixups__` popped from
+                `self.root`.
+            phandle_to_path: Dict mapping integer phandle values to normalized
+                node paths in `self.root`, as returned by `_extract_phandles()`.
+        """
+        for path, lf_node in lf_root.walk():
+            if not lf_node.props:
+                continue
+            target_node = self.root.get_node(path)
+            if target_node is None:
+                raise ValueError(
+                    f'{self.filename}: local fixup references missing node '
+                    f'{path or "/"}'
+                )
+            for prop_name in lf_node.props:
+                if prop_name not in target_node.props:
+                    raise ValueError(
+                        f'{self.filename}: local fixup references missing '
+                        f'property {path or "/"}:{prop_name}'
+                    )
+                offsets = sorted(set(lf_node.get_u32_list(prop_name)))
+                raw_prop = target_node.props[prop_name]
+                masked = bytearray(raw_prop)
+                targets = []
+                for off in offsets:
+                    if off + 4 > len(raw_prop):
+                        raise ValueError(
+                            f'{self.filename}: fixup offset {off} out of range '
+                            f'on {path or "/"}:{prop_name} (len {len(raw_prop)})'
+                        )
+                    if targets and off < targets[-1][0] + 4:
+                        raise ValueError(
+                            f'{self.filename}: overlapping fixup offset {off} '
+                            f'on {path or "/"}:{prop_name}'
+                        )
+                    ph = struct.unpack_from('>I', raw_prop, off)[0]
+                    target_path = phandle_to_path.get(ph)
+                    if target_path is None:
+                        raise ValueError(
+                            f'{self.filename}: unknown phandle {ph:#x} at '
+                            f'{path or "/"}:{prop_name}+{off}'
+                        )
+                    masked[off:off + 4] = b'\x00\x00\x00\x00'
+                    targets.append((off, target_path))
+                target_node.props[prop_name] = bytes(masked)
+                target_node.fixups[prop_name] = tuple(targets)
+
+
+def select_best_candidate(candidates, path, prop_name):
+    """Choose the `(masked_bytes, fixups)` value that maximizes overlay savings.
+
+    Only candidates shared by at least two input DTBs (`count > 1`) are
+    eligible for the base DTB; a value that appears in only one DTB would add
+    one copy to `base.dtb` to save one overlay copy (zero net savings).
+
+    Byte cost model per overlay occurrence:
+    - Each FDT property carries a 12-byte structure header (`FDT_PROP` 4-byte
+      tag + 4-byte length + 4-byte `nameoff`) plus `len(masked_bytes)` value
+      bytes.
+    - Each phandle fixup in an overlay adds a string entry of the form
+      `"/fragment@0/__overlay__<path>:<prop_name>:<off>\\0"` to `/__fixups__`:
+      22 bytes (`"/fragment@0/__overlay__"`) + `len(path)` + 1 (`":"`) +
+      `len(prop_name)` + 1 (`":"`) + ~1-2 digits for `<off>` + 1 (`"\\0"`) +
+      ~3 bytes amortized `/__fixups__` property overhead =
+      `len(path) + len(prop_name) + 28` bytes.
+
+    Args:
+        candidates: Sequence of `(masked_bytes, fixups)` tuples observed across
+            input DTBs for `prop_name` on `path`.
+        path: Normalized node path (`""` for root or `"/..."` for subnodes).
+        prop_name: Property name string.
+
+    Returns:
+        The `(masked_bytes, fixups)` tuple that yields the largest estimated
+        byte savings when placed in `base.dtb`, or `None` if no candidate
+        appears in at least two input DTBs.
+    """
+    fixup_entry_cost = len(path) + len(prop_name) + 28
+    counts = Counter(candidates)
+    best_val = None
+    best_key = None
+    for val, count in counts.items():
+        if count <= 1:
+            continue
+        masked_bytes, fixups = val
+        length = len(masked_bytes) + len(fixups) * fixup_entry_cost
+        # Placing `val` in base.dtb adds 1 copy and removes `count` overlay
+        # copies, saving `(count - 1) * (length + 12)` bytes across the set.
+        key = ((count - 1) * (length + 12), count, length)
+        if best_key is None or key > best_key:
+            best_key = key
+            best_val = val
+    return best_val
+
+
+def _validate_dtb_headers(dtbs):
+    """Verify that FDT header fields and `/memreserve/` entries match across DTBs.
+
+    `fdt_overlay_apply()` only merges the `.dtbo` structure block into the base
+    DTB and ignores the `.dtbo` header and `/memreserve/` block, so all DTBs
+    sharing a base DTB must have identical `boot_cpuid_phys` and `mem_rsv`.
+
+    Args:
+        dtbs: Sequence of `ParsedDtb` instances to validate.
+    """
+    for d in dtbs[1:]:
+        if d.boot_cpuid_phys != dtbs[0].boot_cpuid_phys:
+            raise ValueError(
+                f'boot_cpuid_phys mismatch between {dtbs[0].filename} '
+                f'({dtbs[0].boot_cpuid_phys}) and {d.filename} '
+                f'({d.boot_cpuid_phys})'
+            )
+        if d.mem_rsv != dtbs[0].mem_rsv:
+            raise ValueError(
+                f'/memreserve/ mismatch between {dtbs[0].filename} '
+                f'({dtbs[0].mem_rsv}) and {d.filename} ({d.mem_rsv})'
+            )
+
+
+def _find_common_nodes_and_phandles(dtbs):
+    """Build the common `NodeTree` skeleton and assign base DTB phandles.
+
+    Any common node that carries a phandle in any input DTB is assigned a
+    phandle in the base DTB so overlays never attempt to add a `phandle`
+    property to an already-existing base DTB node. Non-root nodes that carry a
+    phandle in some input DTB but lack a common symbol in `/__symbols__` across
+    all input DTBs (for example, when two boards define the same node path with
+    different label names) are excluded from `base.dtb` so overlays can create
+    them with board-local phandles and symbols.
+
+    Args:
+        dtbs: Sequence of `ParsedDtb` instances.
+
+    Returns:
+        A `NodeTree` containing all common nodes with `node.phandle` set to
+        their assigned sequential integer phandle (or `None` if the node has no
+        phandle in any input DTB) and `node.fixups` initialized to `{}`.
+    """
+    common_paths = {path for path, _ in dtbs[0].root.walk()}
+    for d in dtbs[1:]:
+        common_paths &= {path for path, _ in d.root.walk()}
+
+    common_sym_targets = {
+        path for label, path in dtbs[0].symbols.items()
+        if all(d.symbols.get(label) == path for d in dtbs[1:])
+    }
+
+    base_root = libfdt.NodeTree()
+    next_ph = 1
+    for path, _ in dtbs[0].root.walk():
+        if path not in common_paths:
+            continue
+        if path and base_root.get_node(path.rpartition('/')[0]) is None:
+            continue
+        has_phandle = any(
+            d.root.get_node(path).phandle is not None for d in dtbs
+        )
+        if has_phandle and path and path not in common_sym_targets:
+            continue
+        base_node = base_root.create_path(path)
+        base_node.fixups = {}
+        if has_phandle:
+            base_node.phandle = next_ph
+            next_ph += 1
+        else:
+            base_node.phandle = None
+    return base_root
+
+
+def _find_common_symbols(dtbs, base_root):
+    """Find symbols common to all input DTBs that point to the same base node.
+
+    Args:
+        dtbs: Sequence of `ParsedDtb` instances.
+        base_root: `NodeTree` containing the common nodes for `base.dtb`.
+
+    Returns:
+        Tuple of `(base_dtb_symbols, path_to_base_dtb_symbol)` where:
+        * `base_dtb_symbols`: Dict mapping symbol label to target node path.
+        * `path_to_base_dtb_symbol`: Dict mapping target node path to its
+          primary symbol label in `base_dtb_symbols`.
+    """
+    base_dtb_symbols = {}
+    path_to_base_dtb_symbol = {}
+
+    for label, chosen_path in dtbs[0].symbols.items():
+        if not all(d.symbols.get(label) == chosen_path for d in dtbs[1:]):
+            continue
+        base_node = base_root.get_node(chosen_path)
+        if base_node is None:
+            continue
+        base_dtb_symbols[label] = chosen_path
+        if (base_node.phandle is not None and
+                chosen_path not in path_to_base_dtb_symbol):
+            path_to_base_dtb_symbol[chosen_path] = label
+
+    return base_dtb_symbols, path_to_base_dtb_symbol
+
+
+def _build_base_tree(dtbs):
+    """Construct the normalized `NodeTree` for the common base DTB.
+
+    Args:
+        dtbs: Sequence of `ParsedDtb` instances.
+
+    Returns:
+        A `NodeTree` containing all common nodes, their assigned `phandle`
+        attributes, and their best shared `(masked_bytes, fixups)` properties.
+    """
+    base_root = _find_common_nodes_and_phandles(dtbs)
+    for path, base_node in base_root.walk():
+        dtb_nodes = [d.root.get_node(path) for d in dtbs]
+        common_prop_names = [
+            k for k in dtb_nodes[0].props
+            if all(k in n.props for n in dtb_nodes[1:])
+        ]
+        for prop_name in common_prop_names:
+            eligible = []
+            for n in dtb_nodes:
+                fixups = n.fixups.get(prop_name, ())
+                if not all(base_root.get_node(t_path) is not None
+                           for _, t_path in fixups):
+                    continue
+                eligible.append((n.props[prop_name], fixups))
+
+            chosen = select_best_candidate(eligible, path, prop_name)
+            if chosen is not None:
+                base_node.props[prop_name], base_node.fixups[prop_name] = chosen
+
+    return base_root
+
+
+def _serialize_base_dtb(base_root, base_dtb_symbols, rsvmap, boot_cpuid_phys):
+    """Materialize phandles, `/__symbols__`, and `/__local_fixups__` and flatten.
+
+    Args:
+        base_root: Normalized `NodeTree` for the base DTB from
+            `_build_base_tree()`.
+        base_dtb_symbols: Dict mapping common symbol labels to target paths.
+        rsvmap: Sequence of `(address, size)` memory reservation tuples.
+        boot_cpuid_phys: Physical boot CPU ID for the FDT header.
+
+    Returns:
+        Serialized `.dtb` `bytes` for the common base DTB.
+    """
+    out_root = libfdt.NodeTree()
+    lf_entries = []
+
+    for path, base_node in base_root.walk():
+        out_node = out_root.create_path(path)
+        for prop_name, masked_bytes in base_node.props.items():
+            fixups = base_node.fixups.get(prop_name, ())
+            if not fixups:
+                out_node.props[prop_name] = masked_bytes
+            else:
+                val = bytearray(masked_bytes)
+                for off, t_path in fixups:
+                    target_ph = base_root.get_node(t_path).phandle
+                    struct.pack_into('>I', val, off, target_ph)
+                out_node.props[prop_name] = bytes(val)
+                lf_entries.append((path, prop_name, [off for off, _ in fixups]))
+
+        if base_node.phandle is not None:
+            out_node.set_u32('phandle', base_node.phandle)
+
+    if base_dtb_symbols:
+        sym_node = out_root.create_path('/__symbols__')
+        for label, target_path in base_dtb_symbols.items():
+            sym_node.set_str(label, target_path or '/')
+
+    # Always create `/__local_fixups__` on the base DTB so `fdt_overlay_apply()`
+    # updates it with the overlay's phandle fixups when merging.
+    lf_root = out_root.create_path('/__local_fixups__')
+    for path, prop_name, offs in lf_entries:
+        lf_root.create_path(path).set_u32_list(prop_name, offs)
+
+    return out_root.flatten(mem_rsv=rsvmap, boot_cpuid_phys=boot_cpuid_phys)
+
+
+def _assign_local_phandles(dtb, base_root):
+    """Assign sequential overlay-local phandles (`1, 2, ...`) to board-only nodes.
+
+    Args:
+        dtb: `ParsedDtb` instance for the board.
+        base_root: `NodeTree` containing the common nodes in `base.dtb`.
+
+    Returns:
+        Dict mapping each board-only node path that has a phandle in `dtb` to
+        its sequential overlay-local integer phandle (`1, 2, ...`).
+    """
+    local_phandles = {}
+    next_local_ph = 1
+    for path, node in dtb.root.walk():
+        if node.phandle is not None and base_root.get_node(path) is None:
+            local_phandles[path] = next_local_ph
+            next_local_ph += 1
+    return local_phandles
+
+
+def _encode_dtbo_property(dtb_filename, path, prop_name, masked_bytes, fixups,
+                          base_root, path_to_base_dtb_symbol, local_phandles,
+                          ext_fixups, ov_local_fixups):
+    """Encode a property value for `/fragment@0/__overlay__` and record fixups.
+
+    Args:
+        dtb_filename: Input DTB filename (for error reporting).
+        path: Normalized node path (`""` for root or `"/..."` for subnodes).
+        prop_name: Property name string.
+        masked_bytes: Normalized property bytes (with zeroed phandle cells).
+        fixups: Tuple of `(offset, target_path)` phandle fixups for this
+            property.
+        base_root: `NodeTree` containing the common nodes in `base.dtb`.
+        path_to_base_dtb_symbol: Dict mapping common node paths to their symbol
+            labels in `base.dtb`.
+        local_phandles: Dict mapping board-only node paths to overlay-local
+            phandles.
+        ext_fixups: Dict mapping target base symbol labels to lists of
+            `/__fixups__` entry strings (mutated in place).
+        ov_local_fixups: Dict mapping `(path, prop_name)` to lists of byte
+            offsets for `/__local_fixups__` (mutated in place).
+
+    Returns:
+        Encoded property value `bytes` with `0xffffffff` placeholders for
+        external base references and local phandle integers for overlay-local
+        references.
+    """
+    if not fixups:
+        return masked_bytes
+
+    val = bytearray(masked_bytes)
+    for off, t_path in fixups:
+        if base_root.get_node(t_path) is not None:
+            sym_label = path_to_base_dtb_symbol.get(t_path)
+            if sym_label is None:
+                raise ValueError(
+                    f'{dtb_filename}: property {path or "/"}:{prop_name} '
+                    f'references common base node {t_path or "/"} which lacks '
+                    f'a common symbol in /__symbols__'
+                )
+            struct.pack_into('>I', val, off, 0xffffffff)
+            entry = f'/fragment@0/__overlay__{path}:{prop_name}:{off}'
+            ext_fixups.setdefault(sym_label, []).append(entry)
+        else:
+            struct.pack_into('>I', val, off, local_phandles[t_path])
+            ov_local_fixups.setdefault((path, prop_name), []).append(off)
+    return bytes(val)
+
+
+def _build_board_dtbo(dtb, base_root, base_dtb_symbols,
+                      path_to_base_dtb_symbol):
+    """Construct and flatten the `.dtbo` overlay blob for a single board.
+
+    Args:
+        dtb: `ParsedDtb` instance for the board.
+        base_root: Normalized `NodeTree` for `base.dtb`.
+        base_dtb_symbols: Dict mapping common symbol labels to target paths.
+        path_to_base_dtb_symbol: Dict mapping common node paths to their symbol
+            labels in `base.dtb`.
+
+    Returns:
+        Serialized `.dtbo` overlay `bytes` for `dtb`.
+    """
+    dtbo_root = libfdt.NodeTree()
+    frag_node = dtbo_root.create_path('/fragment@0')
+    frag_node.set_str('target-path', '/')
+    ov_root = frag_node.create_path('__overlay__')
+
+    local_phandles = _assign_local_phandles(dtb, base_root)
+    ext_fixups = {}
+    ov_local_fixups = {}
+
+    for path, node in dtb.root.walk():
+        base_node = base_root.get_node(path)
+        diff_props = [
+            prop_name for prop_name, raw_val in node.props.items()
+            if (base_node is None or
+                base_node.props.get(prop_name) != raw_val or
+                base_node.fixups.get(prop_name, ()) !=
+                node.fixups.get(prop_name, ()))
+        ]
+        if base_node is None or diff_props:
+            ov_node = ov_root.create_path(path)
+            for prop_name in diff_props:
+                ov_node.props[prop_name] = _encode_dtbo_property(
+                    dtb.filename, path, prop_name, node.props[prop_name],
+                    node.fixups.get(prop_name, ()), base_root,
+                    path_to_base_dtb_symbol, local_phandles, ext_fixups,
+                    ov_local_fixups,
+                )
+            if path in local_phandles:
+                ov_node.set_u32('phandle', local_phandles[path])
+
+    diff_syms = [
+        (label, t_path)
+        for label, t_path in dtb.symbols.items()
+        if base_dtb_symbols.get(label) != t_path
+    ]
+    if diff_syms:
+        sym_node = dtbo_root.create_path('/__symbols__')
+        for label, t_path in diff_syms:
+            ov_root.create_path(t_path)
+            sym_node.set_str(label, f'/fragment@0/__overlay__{t_path}')
+
+    if ext_fixups:
+        fixups_node = dtbo_root.create_path('/__fixups__')
+        for label, entries in ext_fixups.items():
+            fixups_node.set_stringlist(label, entries)
+
+    if ov_local_fixups:
+        ov_lf_root = dtbo_root.create_path(
+            '/__local_fixups__/fragment@0/__overlay__'
+        )
+        for (path, prop_name), offs in ov_local_fixups.items():
+            ov_lf_root.create_path(path).set_u32_list(prop_name, offs)
+
+    return dtbo_root.flatten()
+
+
+def create_base_dtb_and_overlays(dtbs):
+    """Compute the base DTB and per-board DTBO blobs from parsed DTBs.
+
+    Args:
+        dtbs: Sequence of `ParsedDtb` instances (at least two).
+
+    Returns:
+        Tuple of `(base_dtb_blob, dtbo_blobs)` where `base_dtb_blob` is the
+        serialized `base.dtb` `bytes` and `dtbo_blobs` is a list of serialized
+        `.dtbo` `bytes` corresponding 1:1 with `dtbs`.
+    """
+    _validate_dtb_headers(dtbs)
+    base_root = _build_base_tree(dtbs)
+    base_dtb_symbols, path_to_base_dtb_symbol = _find_common_symbols(
+        dtbs, base_root
+    )
+    base_dtb_blob = _serialize_base_dtb(
+        base_root, base_dtb_symbols,
+        dtbs[0].mem_rsv, dtbs[0].boot_cpuid_phys,
+    )
+    dtbo_blobs = [
+        _build_board_dtbo(
+            d, base_root, base_dtb_symbols, path_to_base_dtb_symbol,
+        )
+        for d in dtbs
+    ]
+    return base_dtb_blob, dtbo_blobs
+
+
+def get_dtbo_output_path(dtb_path, outdir):
+    """Determine the output `.dtbo` file path for an input `.dtb` path.
+
+    Args:
+        dtb_path: Path to the input `.dtb` file.
+        outdir: Output directory path, or `None` to place the `.dtbo` alongside
+            `dtb_path`.
+
+    Returns:
+        Output `.dtbo` file path string.
+    """
+    dtb_dir, filename = os.path.split(dtb_path)
+    stem = filename[:-4] if filename.endswith('.dtb') else filename
+    target_dir = outdir if outdir is not None else dtb_dir
+    return os.path.join(target_dir, f'{stem}.dtbo')
+
+
+def parse_args(argv=None):
+    """Parse command-line arguments.
+
+    Args:
+        argv: Optional list of command-line argument strings (defaults to
+            `sys.argv[1:]`).
+
+    Returns:
+        Parsed `argparse.Namespace` instance.
+    """
+    parser = argparse.ArgumentParser(
+        description=(
+            'Split a set of compiled device tree blobs (.dtb) into a single '
+            'common base .dtb and per-board overlay blobs (.dtbo).'
+        )
+    )
+    parser.add_argument(
+        '-b', '--base-dtb',
+        default='base.dtb',
+        metavar='FILENAME',
+        help=(
+            'Output common base .dtb filename (default: base.dtb; placed in '
+            '--outdir if specified, or current directory otherwise)'
+        ),
+    )
+    parser.add_argument(
+        '-o', '--outdir',
+        default=None,
+        metavar='DIR',
+        help=(
+            'Output directory for generated .dtbo files and --base-dtb '
+            '(default: alongside each input .dtb for .dtbo, current directory '
+            'for --base-dtb)'
+        ),
+    )
+    parser.add_argument(
+        '-s', '--stats',
+        action='store_true',
+        help='Print size statistics summary',
+    )
+    parser.add_argument(
+        'dtbs',
+        nargs='+',
+        metavar='DTB',
+        help='Input .dtb files (at least two required)',
+    )
+    args = parser.parse_args(argv)
+    if os.path.dirname(args.base_dtb):
+        parser.error(
+            '--base-dtb must be a filename without directory components; '
+            'use -o/--outdir to specify the output directory'
+        )
+    if len(args.dtbs) < 2:
+        parser.error('at least two input .dtb files are required')
+    return args
+
+
+def _print_dtb_stats(dtbs, base_dtb_blob, dtbo_blobs):
+    """Print uncompressed size savings summary.
+
+    Args:
+        dtbs: Sequence of input `ParsedDtb` instances.
+        base_dtb_blob: Uncompressed base DTB `bytes`.
+        dtbo_blobs: Sequence of uncompressed `.dtbo` overlay `bytes`.
+    """
+    orig_uncomp = sum(len(d.raw_bytes) for d in dtbs)
+    base_dtb_uncomp = len(base_dtb_blob)
+    dtbo_uncomp = sum(len(b) for b in dtbo_blobs)
+    new_uncomp = base_dtb_uncomp + dtbo_uncomp
+    saved_uncomp = orig_uncomp - new_uncomp
+    pct_uncomp = (saved_uncomp / orig_uncomp * 100.0) if orig_uncomp else 0.0
+    print(
+        f'Uncompressed DTBs: {len(dtbs)} files ({orig_uncomp:,} B) -> '
+        f'base {base_dtb_uncomp:,} B + {len(dtbo_blobs)} overlays '
+        f'{dtbo_uncomp:,} B = {new_uncomp:,} B '
+        f'(saved {saved_uncomp:,} B / {pct_uncomp:.1f}%)'
+    )
+
+
+def factor_dtb_files(args):
+    """Factor a list of standalone .dtb files into a base .dtb and .dtbo files.
+
+    Args:
+        args: Parsed `argparse.Namespace` from `parse_args()`.
+    """
+    input_dtb_paths = args.dtbs
+    dtbs = [ParsedDtb(p) for p in input_dtb_paths]
+
+    base_dtb_path = (
+        os.path.join(args.outdir, args.base_dtb)
+        if args.outdir is not None
+        else args.base_dtb
+    )
+
+    input_abs_paths = {os.path.abspath(p) for p in input_dtb_paths}
+    base_abs_path = os.path.abspath(base_dtb_path)
+    if base_abs_path in input_abs_paths:
+        raise ValueError(
+            f'base DTB output path {base_dtb_path!r} collides with '
+            f'an input DTB file'
+        )
+
+    dtbo_paths = []
+    seen_paths = {base_abs_path}
+    for dtb_path in input_dtb_paths:
+        out_path = get_dtbo_output_path(dtb_path, args.outdir)
+        abs_out = os.path.abspath(out_path)
+        if abs_out in input_abs_paths:
+            raise ValueError(
+                f'output path {out_path!r} collides with an input DTB file'
+            )
+        if abs_out in seen_paths:
+            raise ValueError(
+                f'output path collision on {out_path!r}; ensure input '
+                f'files have distinct names'
+            )
+        seen_paths.add(abs_out)
+        dtbo_paths.append(out_path)
+
+    base_dtb_blob, dtbo_blobs = create_base_dtb_and_overlays(dtbs)
+
+    if args.outdir:
+        os.makedirs(args.outdir, exist_ok=True)
+    with open(base_dtb_path, 'wb') as f:
+        f.write(base_dtb_blob)
+
+    for out_path, dtbo_blob in zip(dtbo_paths, dtbo_blobs):
+        out_dir = os.path.dirname(out_path)
+        if out_dir:
+            os.makedirs(out_dir, exist_ok=True)
+        with open(out_path, 'wb') as f:
+            f.write(dtbo_blob)
+
+    if args.stats:
+        _print_dtb_stats(dtbs, base_dtb_blob, dtbo_blobs)
+
+
+def main(argv=None):
+    """Run the fdtfactor command-line tool.
+
+    Args:
+        argv: Optional list of command-line argument strings.
+
+    Returns:
+        Integer exit status (`0` on success).
+    """
+    args = parse_args(argv)
+    try:
+        factor_dtb_files(args)
+    except (OSError, ValueError, RuntimeError, libfdt.FdtException) as exc:
+        sys.exit(f'Error: {exc}')
+    return 0
+
+
+if __name__ == '__main__':
+    sys.exit(main())
diff --git a/meson.build b/meson.build
index 520cd35f9075..73b4687a2852 100644
--- a/meson.build
+++ b/meson.build
@@ -114,6 +114,7 @@ if get_option('tools') and not wheel_only
 
   install_data(
     'dtdiff',
+    'fdtfactor',
     install_dir: get_option('bindir'),
     install_mode: 'rwxr-xr-x',
   )
diff --git a/tests/Makefile.tests b/tests/Makefile.tests
index af5d309cf8a9..ea1d35cd8e4e 100644
--- a/tests/Makefile.tests
+++ b/tests/Makefile.tests
@@ -53,10 +53,10 @@ TESTS_DEPFILES = $(TESTS:%=%.d) \
 	$(addprefix $(TESTS_PREFIX),testutils.d)
 
 TESTS_CLEANFILES_L = $(STD_CLEANFILES) \
-	*.dtb *.test.dts *.test.dt.yaml *.dtsv1 tmp.* *.bak \
+	*.dtb *.test.dtbo *.test.dts *.test.dt.yaml *.dtsv1 tmp.* *.bak \
 	treegen
 TESTS_CLEANFILES = $(TESTS) $(TESTS_CLEANFILES_L:%=$(TESTS_PREFIX)%)
-TESTS_CLEANDIRS_L = fs
+TESTS_CLEANDIRS_L = fs *.test.dir
 TESTS_CLEANDIRS = $(TESTS_CLEANDIRS_L:%=$(TESTS_PREFIX)%)
 
 .PHONY: tests
diff --git a/tests/fdtfactor_board1.dts b/tests/fdtfactor_board1.dts
new file mode 100644
index 000000000000..c5f4e1672d98
--- /dev/null
+++ b/tests/fdtfactor_board1.dts
@@ -0,0 +1,38 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+/dts-v1/;
+
+/memreserve/ 0xdeadbeef00000000 0x100000;
+
+/ {
+	compatible = "test_fdtfactor";
+	model = "board1";
+	prop-int = <0xdeadbeef>;
+	#address-cells = <1>;
+	#size-cells = <0>;
+
+	sn1: subnode@1 {
+		compatible = "subnode1";
+		reg = <1>;
+		prop-ref = <&sn1 0x10 &ssn1 0x20>;
+
+		ssn1: subsubnode {
+			compatible = "subsubnode1", "subsubnode";
+			prop-phandle = <&sn1>;
+		};
+	};
+
+	sn2: subnode@2 {
+		reg = <2>;
+		prop-maybe-phandle = <&sn2>;
+	};
+
+	b1_node: board1-node {
+		prop-str = "board1";
+		ref-base = <&sn1>;
+		ref-local = <&b1_node &b1_child>;
+
+		b1_child: child {
+			reg = <1>;
+		};
+	};
+};
diff --git a/tests/fdtfactor_board2.dts b/tests/fdtfactor_board2.dts
new file mode 100644
index 000000000000..32172f755321
--- /dev/null
+++ b/tests/fdtfactor_board2.dts
@@ -0,0 +1,36 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+/dts-v1/;
+
+/memreserve/ 0xdeadbeef00000000 0x100000;
+
+/ {
+	compatible = "test_fdtfactor";
+	model = "board2";
+	prop-int = <0xdeadbeef>;
+	#address-cells = <1>;
+	#size-cells = <0>;
+
+	sn1: subnode@1 {
+		compatible = "subnode1";
+		reg = <1>;
+		prop-ref = <&sn1 0x10 &ssn1 0x20>;
+
+		ssn1: subsubnode {
+			compatible = "subsubnode1", "subsubnode";
+			prop-phandle = <&sn1>;
+		};
+	};
+
+	sn2: subnode@2 {
+		reg = <2>;
+		prop-maybe-phandle = <&sn2>;
+		board2-only-prop = "only-on-board2";
+		board2-ref = <&b2_node>;
+	};
+
+	b2_node: board2-node {
+		prop-str = "board2";
+		ref-base = <&ssn1>;
+		ref-self = <&b2_node>;
+	};
+};
diff --git a/tests/fdtfactor_board3.dts b/tests/fdtfactor_board3.dts
new file mode 100644
index 000000000000..b93dd2545a0f
--- /dev/null
+++ b/tests/fdtfactor_board3.dts
@@ -0,0 +1,34 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+/dts-v1/;
+
+/memreserve/ 0xdeadbeef00000000 0x100000;
+
+/ {
+	compatible = "test_fdtfactor";
+	model = "board3";
+	prop-int = <0x12345678>;
+	#address-cells = <1>;
+	#size-cells = <0>;
+
+	sn1: subnode@1 {
+		compatible = "subnode1";
+		reg = <1>;
+		prop-ref = <&sn1 0x10 &ssn1 0x20>;
+
+		ssn1: subsubnode {
+			compatible = "subsubnode1", "subsubnode";
+			prop-phandle = <&sn2>;
+		};
+	};
+
+	sn2: subnode@2 {
+		reg = <2>;
+		prop-maybe-phandle = <0>;
+	};
+
+	b3_node: board3-node {
+		prop-str = "board3";
+		ref-base = <&sn2>;
+		ref-self = <&b3_node>;
+	};
+};
diff --git a/tests/fdtfactor_board3_shifted.dts b/tests/fdtfactor_board3_shifted.dts
new file mode 100644
index 000000000000..3ada7bda4545
--- /dev/null
+++ b/tests/fdtfactor_board3_shifted.dts
@@ -0,0 +1,34 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+/dts-v1/;
+
+/memreserve/ 0xdeadbeef00000000 0x100000;
+
+/ {
+	compatible = "test_fdtfactor";
+	model = "board3";
+	prop-int = <0x12345678>;
+	#address-cells = <1>;
+	#size-cells = <0>;
+
+	b3_node: board3-node {
+		prop-str = "board3";
+		ref-self = <&b3_node>;
+		ref-base = <&sn2>;
+	};
+
+	sn1: subnode@1 {
+		compatible = "subnode1";
+		reg = <1>;
+		prop-ref = <&sn1 0x10 &ssn1 0x20>;
+
+		ssn1: subsubnode {
+			compatible = "subsubnode1", "subsubnode";
+			prop-phandle = <&sn2>;
+		};
+	};
+
+	sn2: subnode@2 {
+		reg = <2>;
+		prop-maybe-phandle = <0>;
+	};
+};
diff --git a/tests/meson.build b/tests/meson.build
index c25500606059..e469ba4cba56 100644
--- a/tests/meson.build
+++ b/tests/meson.build
@@ -143,7 +143,7 @@ run_test_deps = [
   dtc_tools, treegen_dtb, tests_exe,
 ]
 if pylibfdt_enabled
-  run_test_types += 'pylibfdt'
+  run_test_types += ['pylibfdt', 'fdtfactor']
   run_test_deps += pylibfdt
 endif
 foreach test_type : run_test_types
diff --git a/tests/run_tests.sh b/tests/run_tests.sh
index e232c86aab37..e012aff2193b 100755
--- a/tests/run_tests.sh
+++ b/tests/run_tests.sh
@@ -1131,6 +1131,61 @@ pylibfdt_tests () {
     tot_tests=$((tot_tests + $total_tests))
 }
 
+fdtfactor_tests () {
+    for i in 1 2 3; do
+	run_dtc_test -b 2 -@ -L -I dts -O dtb -o fdtfactor_board${i}.test.dtb \
+	    "$SRCDIR/fdtfactor_board${i}.dts"
+    done
+
+    # Default mode: factor 3 boards into base.dtb + .dtbo overlays and verify
+    # that majority properties land in base.dtb and fdtoverlay reconstructs
+    # each original DTB (including /__local_fixups__, /memreserve/, and
+    # boot_cpuid_phys).
+    rm -rf fdtfactor_out.test.dir
+    run_wrap_test $PYTHON $FDTFACTOR -s -b base.test.dtb -o fdtfactor_out.test.dir \
+	fdtfactor_board1.test.dtb fdtfactor_board2.test.dtb fdtfactor_board3.test.dtb
+    run_fdtget_test "deadbeef" -tx fdtfactor_out.test.dir/base.test.dtb \
+	/ prop-int
+    run_wrap_error_test $DTGET \
+	fdtfactor_out.test.dir/fdtfactor_board1.test.dtbo \
+	/fragment@0/__overlay__ prop-int
+    for i in 1 2 3; do
+	run_wrap_test $FDTOVERLAY -i fdtfactor_out.test.dir/base.test.dtb \
+	    -o fdtfactor_out.test.dir/merged${i}.test.dtb \
+	    fdtfactor_out.test.dir/fdtfactor_board${i}.test.dtbo
+	run_test dtbs_equal_unordered fdtfactor_board${i}.test.dtb \
+	    fdtfactor_out.test.dir/merged${i}.test.dtb
+    done
+
+    # Shifted phandles: fdtfactor_board3_shifted.dts defines board3-node before
+    # subnode@1 so all phandle numbers differ from board1 and board2; verify
+    # that fdtfactor normalizes them via /__local_fixups__.
+    run_dtc_test -b 2 -@ -L -I dts -O dtb -o fdtfactor_board3_shifted.test.dtb \
+	"$SRCDIR/fdtfactor_board3_shifted.dts"
+    rm -rf fdtfactor_shift.test.dir
+    run_wrap_test $PYTHON $FDTFACTOR -o fdtfactor_shift.test.dir \
+	fdtfactor_board1.test.dtb fdtfactor_board2.test.dtb \
+	fdtfactor_board3_shifted.test.dtb
+    run_wrap_error_test $DTGET \
+	fdtfactor_shift.test.dir/fdtfactor_board3_shifted.test.dtbo \
+	/fragment@0/__overlay__/subnode@1 prop-ref
+    run_wrap_test $FDTOVERLAY -i fdtfactor_shift.test.dir/base.dtb \
+	-o fdtfactor_shift.test.dir/merged3.test.dtb \
+	fdtfactor_shift.test.dir/fdtfactor_board3_shifted.test.dtbo
+    run_test dtbs_equal_unordered fdtfactor_board3.test.dtb \
+	fdtfactor_shift.test.dir/merged3.test.dtb
+
+    # Legacy linux,phandle properties (dtc -H both / -H legacy) and overlay
+    # .dtbo inputs are rejected.
+    run_dtc_test -H both -@ -L -I dts -O dtb \
+	-o fdtfactor_legacy_ph.test.dtb "$SRCDIR/fdtfactor_board1.dts"
+    run_wrap_error_test $PYTHON $FDTFACTOR -o fdtfactor_legacy.test.dir \
+	fdtfactor_legacy_ph.test.dtb fdtfactor_board2.test.dtb
+    run_wrap_error_test $PYTHON $FDTFACTOR -o fdtfactor_legacy.test.dir \
+	fdtfactor_out.test.dir/fdtfactor_board1.test.dtbo \
+	fdtfactor_board2.test.dtb
+}
+
 while getopts "vt:me" ARG ; do
     case $ARG in
 	"v")
@@ -1151,14 +1206,15 @@ done
 if [ -z "$TESTSETS" ]; then
     TESTSETS="libfdt utilfdt dtc dtbs_equal fdtget fdtput fdtdump fdtoverlay"
 
-    # Test pylibfdt if the libfdt Python module is available.
+    # Test pylibfdt and fdtfactor if the libfdt Python module is available.
     if ! $no_python; then
-        TESTSETS="$TESTSETS pylibfdt"
+        TESTSETS="$TESTSETS pylibfdt fdtfactor"
     fi
 fi
 
 # Make sure we don't have stale blobs lying around
-rm -f *.test.dtb *.test.dts
+rm -f *.test.dtb *.test.dtbo *.test.dts
+rm -rf *.test.dir
 
 for set in $TESTSETS; do
     case $set in
@@ -1189,6 +1245,9 @@ for set in $TESTSETS; do
         "fdtoverlay")
 	    fdtoverlay_tests
 	    ;;
+	"fdtfactor")
+	    fdtfactor_tests
+	    ;;
     esac
 done
 
diff --git a/tests/testutils.sh b/tests/testutils.sh
index 6b2f0d14e3e5..7f35ba8e4158 100644
--- a/tests/testutils.sh
+++ b/tests/testutils.sh
@@ -27,6 +27,7 @@ DTGET=${TEST_BINDIR}/fdtget
 DTPUT=${TEST_BINDIR}/fdtput
 FDTDUMP=${TEST_BINDIR}/fdtdump
 FDTOVERLAY=${TEST_BINDIR}/fdtoverlay
+FDTFACTOR=${SRCDIR}/../fdtfactor
 
 verbose_run () {
     if [ -z "$QUIET_TEST" ]; then
-- 
2.56.0.313.g2479322e68-goog


  parent reply	other threads:[~2026-10-28 21:56 UTC|newest]

Thread overview: 12+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-10-28 21:46 [PATCH v2 00/10] fdtfactor: Introduce fdtfactor to auto-factor overlays Douglas Anderson
2026-10-28 21:46 ` [PATCH v2 01/10] pylibfdt: Fix FdtSw.as_fdt() when buffer is nearly full Douglas Anderson
2026-10-28 21:46 ` [PATCH v2 02/10] pylibfdt: Add subnode/property iterators and get_mem_rsv_list() Douglas Anderson
2026-10-29  8:38   ` David Gibson
2026-10-28 21:46 ` [PATCH v2 03/10] pylibfdt: Add NodeTree live tree representation Douglas Anderson
2026-10-28 21:46 ` [PATCH v2 04/10] libfdt: overlay: Factor out fixup and fragment path parsing helpers Douglas Anderson
2026-10-28 21:46 ` [PATCH v2 05/10] libfdt: overlay: Prepare fixup and fragment path helpers for reuse Douglas Anderson
2026-10-28 21:46 ` [PATCH v2 06/10] libfdt: overlay: Update base /__local_fixups__ on apply Douglas Anderson
2026-10-28 21:46 ` Douglas Anderson [this message]
2026-10-28 21:46 ` [PATCH v2 08/10] fdtfactor: Add -S/--strip-local-fixups option Douglas Anderson
2026-10-28 21:46 ` [PATCH v2 09/10] fdtfactor: Support compressed DTB inputs and outputs Douglas Anderson
2026-10-28 21:46 ` [PATCH v2 10/10] fdtfactor: Add compiled FIT image factoring support Douglas Anderson

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20261028215600.1713475-8-dianders@chromium.org \
    --to=dianders@chromium.org \
    --cc=Devicetree-compiler@vger.kernel.org \
    --cc=Devicetree@vger.kernel.org \
    --cc=andre.draszik@linaro.org \
    --cc=briannorris@chromium.org \
    --cc=conor+dt@kernel.org \
    --cc=david@gibson.dropbear.id.au \
    --cc=jwerner@chromium.org \
    --cc=konradybcio@kernel.org \
    --cc=krzk+dt@kernel.org \
    --cc=linux-kernel@vger.kernel.org \
    --cc=peter.griffin@linaro.org \
    --cc=robh@kernel.org \
    --cc=saravanak@kernel.org \
    --cc=sjg@chromium.org \
    --cc=tudor.ambarus@linaro.org \
    --cc=wenst@chromium.org \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox

all inboxes | Powered by JetHome®