mirror of https://lore.kernel.org/lkml/
 help / color / mirror / Atom feed
* [PATCH v2 00/10] fdtfactor: Introduce fdtfactor to auto-factor overlays
@ 2026-10-28 21:46 Douglas Anderson
  2026-10-28 21:46 ` [PATCH v2 01/10] pylibfdt: Fix FdtSw.as_fdt() when buffer is nearly full Douglas Anderson
                   ` (9 more replies)
  0 siblings, 10 replies; 12+ messages in thread
From: Douglas Anderson @ 2026-10-28 21:46 UTC (permalink / raw)
  To: Devicetree-compiler, Rob Herring, Krzysztof Kozlowski,
	Conor Dooley, Konrad Dybcio
  Cc: Brian Norris, Chen-Yu Tsai, André Draszik, Devicetree,
	Simon Glass, David Gibson, Tudor Ambarus, linux-kernel,
	Julius Werner, Saravana Kannan, Peter Griffin, Douglas Anderson


Devicetree overlays exist at both the source level (via `/include/` and
`.dtsi` files) and the binary level (where a compiled `.dtbo` overlay is
applied to a "base" `.dtb` at runtime to add or modify nodes).

Binary `.dtbo` overlays were created by transcribing source-level
overlay semantics to `.dtb` blobs to serve two main use cases:

1. Supporting add-on boards (like "hats" on a dev board), where a base
   board's `.dtb` is combined with an add-on board's `.dtbo` at boot or
   runtime (a use case that Hervé Codina's upcoming "addons" work aims
   to model more cleanly).

2. Allowing constrained bootloaders or firmware images to support a
   "family" of similar boards in a space-efficient way without shipping
   a full `.dtb` for every variant. In upstream Linux, for example,
   there are 47 distinct sc7180-trogdor boards supported. At the source
   level these boards share common `.dtsi` files, but we still compile
   (and ship) 47 separate full `.dtb` blobs. Binary devicetree overlays
   can make this much more efficient by factoring the common bits into a
   single base `.dtb` and representing each of the 47 boards as a small
   `.dtbo` overlay.

Even though `.dtbo` overlay semantics are a natural fit for case #2
(since they derive from source-level `.dts` overlays, which were
designed for board/SoC families), upstream Linux has mostly reserved
compiled `.dtso` -> `.dtbo` overlays for case #1. Many Android phones
attempt to use `.dtso` overlays for case #2 by describing the SoC as the
base devicetree and describing boards in `.dtso` overlays, but forcing
the binary base/overlay split into the source files has drawbacks: there
is still lots of duplication between board overlays, and upstream
reception towards structuring board source files this way has been
lukewarm [1].

This patch series introduces a tool to decouple binary `.dtbo`
generation from `.dts` source structure. The idea (suggested by Konrad
Dybcio at LPC) is to continue to represent all the boards in a family as
normal standalone device trees in source code (using `.dtsi` includes as
makes sense for human readability). Then, after building the full `.dtb`
blobs, `fdtfactor` automatically factors them into a common base `.dtb`
and a set of per-board `.dtbo` overlays.

This tool has quite a few advantages over other solutions:
1. No change to the way device trees are stored / arranged upstream.
2. No need to deal with some of the awkwardness / limitations of
   overlays.
3. Better savings than just a simple partitioning, like between SoC and
   boards.

I've used this tool to reduce sc7180-trogdor device tree files (which
are stored lz4 compressed) from 1.71 MB to 213 KB, an 87.5% savings.

To compare against the manual SoC-base + board-overlay split used by
downstream Android kernels, I also tested `fdtfactor -S` on the
downstream Pixel 10 family device trees (29 physical hardware board
variants: 27 phone variants across Pixel 10, Pixel 10 Pro, and Pixel 10
Pro XL plus 2 socketed dev board variants) by combining the downstream
base + overlays into full board DTBs and re-factoring them with
`fdtfactor -S`:

* Just B0 silicon (29 boards):
  - Full unfactored DTBs (29 `.dtb`s): 26.30 MB uncompressed (5.28 MB
    lz4)
  - Downstream manual SoC base (`lga-b0.dtb`) + 29 board `.dtbo`s:
    8.06 MB uncompressed (1.66 MB lz4)
  - Auto-factored with `fdtfactor -S` (1 `base.dtb` + 29 `.dtbo`s):
    3.27 MB uncompressed (668 KB lz4) -- 2.48x smaller than the manual
    SoC split (and 3.68x smaller across the 27 phone boards alone, where
    average overlay size drops from 252 KB to 44 KB uncompressed, or
    52 KB to 9.1 KB lz4).

* Both A0 and B0 SoC steppings:
  In practice, phone Proto boards (8 variants) only ever use A0 silicon,
  phone EVT1.1+ boards (15 variants) only ever use B0 silicon, and only
  phone EVT1 boards (4 variants) and the socketed dev boards (2
  variants) can have either A0 or B0 silicon -- giving 35 realistic
  (board, stepping) combinations (14 A0 + 21 B0), compared to 58 for a
  naive 29 x 2 cross-product:
  - Full unfactored DTBs (35 `.dtb`s): 31.71 MB uncompressed (6.37 MB
    lz4)
  - Downstream manual split (2 SoC bases `lga-a0.dtb` + `lga-b0.dtb` +
    29 board `.dtbo`s): 8.78 MB uncompressed (1.79 MB lz4)
  - Auto-factored with `fdtfactor -S` (1 shared `base.dtb` + 35
    `.dtbo`s): 4.00 MB uncompressed (855 KB lz4). This could likely be
    improved more with multiple levels of overlays, but we're already
    2x smaller than what downstream is doing today.

NOTE: on Chromebooks like sc7180-trogdor, using overlays like this is
an optimization. On Android phones like Pixel 10, the bootloader logic
bakes in the need for overlays. Specifically, if we want to be able to
build a single image that supports multiple boards and have the
bootloader auto-construct the right devicetree, we need the boards to be
represented by overlays. This fdtfactor tool can make that a reality.

The fdtfactor tool can either work directly on a set of compiled
devicetree files or it can work on a FIT (flattened image tree) file.
The idea is that you'd create the FIT file containing all of the
duplicated devicetree files and then fdtfactor would "optimize" them.

Before introducing `fdtfactor` itself, the series starts with a few
preparatory patches: adding Python iterators and a `NodeTree` live-tree
abstraction to `pylibfdt`, and teaching `libfdt`'s `fdt_overlay_apply()`
to update `/__local_fixups__` on the base tree so overlays can be
applied in multiple stages.

I'll freely admit that the patches in this series were pretty heavily
vibe coded. I'll not pretend that I have a deep understanding of the
intricacies of how overlays are implemented. That being said:
* I've spent quite a bit of time reviewing the code from a high-level
  perspective: confirming that functions are well documented, well
  structured, and make sense.
* I believe that what the tool is doing and how the tool is used make
  sense.
* I have had quite a number of internal "the AI reviews the AI" rounds
  to the point of diminishing returns.
At this point, I'm not embarrassed to put my name behind this. While I
won't promise that there are zero cases of the AI doing something really
stupid, I'd be willing to bet that the end result is better (and cleaner
/ better structured) than what I would have coded up if I had spent a
few weeks doing this on my own.

[1] https://lore.kernel.org/all/20251114-elite-refined-yak-bf9e64@kuoka/

v1: https://lore.kernel.org/r/20261015182221.2931742-1-dianders@chromium.org

Changes in v2:
- New patch fixing FdtSw.as_fdt() buffer resize when < 4 bytes remain
- New patch adding FdtRo.subnodes(), FdtRo.properties(), and
  FdtRo.get_mem_rsv_list()
- Split NodeTree live tree representation out of fdtfactor into
  pylibfdt (David Gibson)
- New patch factoring out overlay_parse_fixup_entry() and
  overlay_split_fragment_path() helpers in libfdt/fdt_overlay.c
- New patch tightening fixup offset parsing and generalizing
  overlay_split_fragment_path() for non-NUL-terminated paths in
  libfdt/fdt_overlay.c
- New patch updating base /__local_fixups__ in libfdt
  fdt_overlay_apply() instead of stripping /__local_fixups__ by default
  (David Gibson)
- 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)
- Split -S/--strip-local-fixups into a separate opt-in patch (David
  Gibson)
- Add --base-compression to explicitly control base.dtb compression
  while documenting auto-detection from --base-dtb's filename suffix
  (David Gibson)
- Support legacy LZ4 frames (lz4 -l) and use CRC32 check for XZ outputs
  (Simon Glass)
- Drop "tests: Fix Python test environment setup in meson.build"
  (applied upstream by David Gibson)
- Clarify source-level (.dts/.dtsi) vs. binary-level (.dtb/.dtbo)
  overlay history in cover letter (David Gibson)
- Add Pixel 10 (Tensor G5) base + overlay comparison numbers to cover
  letter (Simon Glass)
- Rename -f/--fit and -F/--fit-out to -F/--fit and --outfile and clarify
  FIT mode in the --help description (David Gibson, Simon Glass)
- Reject signed FIT images with signature* subnodes (Simon Glass)
- Use '<base-node-name>.dtb' for FIT base FDT descriptions instead of
  common-prefix heuristics (Simon Glass)
- Compare FIT group node overlap against the group's common node set
  instead of only the first entry (Simon Glass)
- Document in README.md that U-Boot's boot_get_fdt_fit() currently
  requires FIT overlays to be uncompressed (Simon Glass)

Douglas Anderson (10):
  pylibfdt: Fix FdtSw.as_fdt() when buffer is nearly full
  pylibfdt: Add subnode/property iterators and get_mem_rsv_list()
  pylibfdt: Add NodeTree live tree representation
  libfdt: overlay: Factor out fixup and fragment path parsing helpers
  libfdt: overlay: Prepare fixup and fragment path helpers for reuse
  libfdt: overlay: Update base /__local_fixups__ on apply
  fdtfactor: Add tool to split DTBs into a base DTB and overlays
  fdtfactor: Add -S/--strip-local-fixups option
  fdtfactor: Support compressed DTB inputs and outputs
  fdtfactor: Add compiled FIT image factoring support

 Makefile                           |    2 +-
 README.md                          |   74 ++
 fdtfactor                          | 1626 ++++++++++++++++++++++++++++
 libfdt/fdt_overlay.c               |  847 +++++++++++++--
 meson.build                        |    1 +
 pylibfdt/libfdt.i                  |  310 +++++-
 tests/Makefile.tests               |    7 +-
 tests/fdtfactor_board1.dts         |   38 +
 tests/fdtfactor_board2.dts         |   36 +
 tests/fdtfactor_board3.dts         |   34 +
 tests/fdtfactor_board3_shifted.dts |   34 +
 tests/fdtfactor_lz4.its            |  109 ++
 tests/fdtfactor_other1.dts         |   33 +
 tests/fdtfactor_other2.dts         |   33 +
 tests/fdtfactor_test.its           |  109 ++
 tests/meson.build                  |    2 +-
 tests/pylibfdt_tests.py            |   80 ++
 tests/run_tests.sh                 |  337 +++++-
 tests/testutils.sh                 |    1 +
 19 files changed, 3629 insertions(+), 84 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
 create mode 100644 tests/fdtfactor_lz4.its
 create mode 100644 tests/fdtfactor_other1.dts
 create mode 100644 tests/fdtfactor_other2.dts
 create mode 100644 tests/fdtfactor_test.its

-- 
2.56.0.313.g2479322e68-goog


^ permalink raw reply	[flat|nested] 12+ messages in thread

* [PATCH v2 01/10] pylibfdt: Fix FdtSw.as_fdt() when buffer is nearly full
  2026-10-28 21:46 [PATCH v2 00/10] fdtfactor: Introduce fdtfactor to auto-factor overlays Douglas Anderson
@ 2026-10-28 21:46 ` Douglas Anderson
  2026-10-28 21:46 ` [PATCH v2 02/10] pylibfdt: Add subnode/property iterators and get_mem_rsv_list() Douglas Anderson
                   ` (8 subsequent siblings)
  9 siblings, 0 replies; 12+ messages in thread
From: Douglas Anderson @ 2026-10-28 21:46 UTC (permalink / raw)
  To: Devicetree-compiler, Rob Herring, Krzysztof Kozlowski,
	Conor Dooley, Konrad Dybcio
  Cc: Brian Norris, Chen-Yu Tsai, André Draszik, Devicetree,
	Simon Glass, David Gibson, Tudor Ambarus, linux-kernel,
	Julius Werner, Saravana Kannan, Peter Griffin, Douglas Anderson

`FdtSw.as_fdt()` calls `fdt_finish()` on a copy of `self._fdt` without
using `self.check_space()`. Because `fdt_finish()` appends the 4-byte
`FDT_END` token, `as_fdt()` raises `FdtException(FDT_ERR_NOSPACE)` if
fewer than 4 bytes of free space remain in `self._fdt` after the final
`end_node()`.

Wrap `fdt_finish()` in a `while self.check_space(fdt_finish(fdtsw)):`
loop so `FdtSw.as_fdt()` automatically resizes the buffer when needed,
matching the rest of `FdtSw`'s methods.

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

Changes in v2:
- New patch fixing FdtSw.as_fdt() buffer resize when < 4 bytes remain

 pylibfdt/libfdt.i       | 3 ++-
 tests/pylibfdt_tests.py | 8 ++++++++
 2 files changed, 10 insertions(+), 1 deletion(-)

diff --git a/pylibfdt/libfdt.i b/pylibfdt/libfdt.i
index b41c03f4932a..46f28f67290d 100644
--- a/pylibfdt/libfdt.i
+++ b/pylibfdt/libfdt.i
@@ -959,7 +959,8 @@ class FdtSw(FdtRo):
             Fdt object allowing access to the newly created device tree
         """
         fdtsw = bytearray(self._fdt)
-        check_err(fdt_finish(fdtsw))
+        while self.check_space(fdt_finish(fdtsw)):
+            fdtsw = bytearray(self._fdt)
         return Fdt(fdtsw)
 
     def check_space(self, val):
diff --git a/tests/pylibfdt_tests.py b/tests/pylibfdt_tests.py
index 0e1660753477..1b65961491bf 100644
--- a/tests/pylibfdt_tests.py
+++ b/tests/pylibfdt_tests.py
@@ -646,6 +646,14 @@ class PyLibfdtSwTests(unittest.TestCase):
         # Make sure we did at least two resizes
         self.assertTrue(len(fdt.as_bytearray()) > FdtSw.INC_SIZE * 2)
 
+        # Check that as_fdt() resizes when fewer than 4 bytes remain for FDT_END
+        sw_exact = FdtSw(76)
+        sw_exact.finish_reservemap()
+        sw_exact.begin_node('')
+        sw_exact.end_node()
+        fdt_exact = sw_exact.as_fdt()
+        self.assertEqual(80, fdt_exact.totalsize())
+
 
 class PyLibfdtRoTests(unittest.TestCase):
     """Test class for read-only pylibfdt access functions
-- 
2.56.0.313.g2479322e68-goog


^ permalink raw reply	[flat|nested] 12+ messages in thread

* [PATCH v2 02/10] pylibfdt: Add subnode/property iterators and get_mem_rsv_list()
  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 ` 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
                   ` (7 subsequent siblings)
  9 siblings, 1 reply; 12+ messages in thread
From: Douglas Anderson @ 2026-10-28 21:46 UTC (permalink / raw)
  To: Devicetree-compiler, Rob Herring, Krzysztof Kozlowski,
	Conor Dooley, Konrad Dybcio
  Cc: Brian Norris, Chen-Yu Tsai, André Draszik, Devicetree,
	Simon Glass, David Gibson, Tudor Ambarus, linux-kernel,
	Julius Werner, Saravana Kannan, Peter Griffin, Douglas Anderson

Add Pythonic iterator helpers on `FdtRo` to simplify walking nodes,
properties, and memory reservation entries without manual offset loops
or `-FDT_ERR_NOTFOUND` sentinel handling:

- `subnodes(nodeoffset)`: yields the offset of each direct child subnode
  of `nodeoffset`.
- `properties(nodeoffset)`: yields a `Property` object for each property
  of `nodeoffset`.
- `get_mem_rsv_list()`: returns a list of `(address, size)` tuples for
  all entries in the memory reservation block.

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

Changes in v2:
- New patch adding FdtRo.subnodes(), FdtRo.properties(), and
  FdtRo.get_mem_rsv_list()

 pylibfdt/libfdt.i       | 45 +++++++++++++++++++++++++++++++++++++++++
 tests/pylibfdt_tests.py | 18 +++++++++++++++++
 2 files changed, 63 insertions(+)

diff --git a/pylibfdt/libfdt.i b/pylibfdt/libfdt.i
index 46f28f67290d..0db7110b4a38 100644
--- a/pylibfdt/libfdt.i
+++ b/pylibfdt/libfdt.i
@@ -201,6 +201,23 @@ class FdtRo(object):
         """
         return check_err(fdt_next_subnode(self._fdt, nodeoffset), quiet)
 
+    def subnodes(self, nodeoffset):
+        """Iterate over the subnodes of a node
+
+        Args:
+            nodeoffset: Offset of parent node to check
+
+        Yields:
+            Node offset of each direct subnode in order
+
+        Raises:
+            FdtException on error (e.g. invalid nodeoffset)
+        """
+        soff = self.first_subnode(nodeoffset, quiet=QUIET_NOTFOUND)
+        while soff >= 0:
+            yield soff
+            soff = self.next_subnode(soff, quiet=QUIET_NOTFOUND)
+
     def magic(self):
         """Return the magic word from the header
 
@@ -302,6 +319,17 @@ class FdtRo(object):
         check_err(val[0], quiet)
         return val[1:]
 
+    def get_mem_rsv_list(self):
+        """Return a list of all memory reserve-map records
+
+        Returns:
+            List of (address, size) tuples for each memory reserve-map record
+        """
+        return [
+            tuple(self.get_mem_rsv(i))
+            for i in range(self.num_mem_rsv())
+        ]
+
     def address_cells(self, nodeoffset, quiet=()):
         """Return the number of address cells used by a node's children
 
@@ -434,6 +462,23 @@ class FdtRo(object):
             return pdata[1]
         return Property(pdata[0], pdata[1])
 
+    def properties(self, nodeoffset):
+        """Iterate over the properties of a node
+
+        Args:
+            nodeoffset: Offset of node to check
+
+        Yields:
+            Property object for each property on @nodeoffset in order
+
+        Raises:
+            FdtException on error (e.g. invalid nodeoffset)
+        """
+        poff = self.first_property_offset(nodeoffset, quiet=QUIET_NOTFOUND)
+        while poff >= 0:
+            yield self.get_property_by_offset(poff)
+            poff = self.next_property_offset(poff, quiet=QUIET_NOTFOUND)
+
     def getprop(self, nodeoffset, prop_name, quiet=()):
         """Get a property from a node
 
diff --git a/tests/pylibfdt_tests.py b/tests/pylibfdt_tests.py
index 1b65961491bf..2a0509c7802e 100644
--- a/tests/pylibfdt_tests.py
+++ b/tests/pylibfdt_tests.py
@@ -243,6 +243,22 @@ class PyLibfdtBasicTests(unittest.TestCase):
             node_list.append(self.fdt.get_name(node))
             node = self.fdt.next_subnode(node, QUIET_NOTFOUND)
         self.assertEqual(node_list, ['subnode@1', 'subnode@2'])
+        self.assertEqual(
+            [self.fdt.get_name(n) for n in self.fdt.subnodes(0)],
+            ['subnode@1', 'subnode@2'],
+        )
+        leaf = self.fdt.path_offset('/subnode@1/ss1')
+        self.assertEqual(list(self.fdt.subnodes(leaf)), [])
+
+    def testPropertiesIterator(self):
+        """Check that we can iterate through properties of a node"""
+        node = self.fdt.path_offset('/subnode@1')
+        props = list(self.fdt.properties(node))
+        self.assertEqual([p.name for p in props],
+                         ['compatible', 'reg', 'prop-int'])
+        self.assertEqual(props[0], b'subnode1\0')
+        leaf = self.fdt.path_offset('/subnode@1/ss1')
+        self.assertEqual(list(self.fdt.properties(leaf)), [])
 
     def testFirstNextSubnodeOffsetExceptions(self):
         """Check except handling for first/next subnode functions"""
@@ -410,6 +426,8 @@ class PyLibfdtBasicTests(unittest.TestCase):
         self.assertEqual([ 0xdeadbeef00000000, 0x100000],
                           self.fdt.get_mem_rsv(0))
         self.assertEqual([123456789, 0o10000], self.fdt.get_mem_rsv(1))
+        self.assertEqual([(0xdeadbeef00000000, 0x100000), (123456789, 0o10000)],
+                         self.fdt.get_mem_rsv_list())
 
     def testAddDelReserveMap(self):
         """Test that we can add to and remove from the memory reserve map"""
-- 
2.56.0.313.g2479322e68-goog


^ permalink raw reply	[flat|nested] 12+ messages in thread

* [PATCH v2 03/10] pylibfdt: Add NodeTree live tree representation
  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-28 21:46 ` Douglas Anderson
  2026-10-28 21:46 ` [PATCH v2 04/10] libfdt: overlay: Factor out fixup and fragment path parsing helpers Douglas Anderson
                   ` (6 subsequent siblings)
  9 siblings, 0 replies; 12+ messages in thread
From: Douglas Anderson @ 2026-10-28 21:46 UTC (permalink / raw)
  To: Devicetree-compiler, Rob Herring, Krzysztof Kozlowski,
	Conor Dooley, Konrad Dybcio
  Cc: Brian Norris, Chen-Yu Tsai, André Draszik, Devicetree,
	Simon Glass, David Gibson, Tudor Ambarus, linux-kernel,
	Julius Werner, Saravana Kannan, Peter Griffin, Douglas Anderson

Add `NodeTree`, an in-memory live tree representation of a devicetree
node and its subtree, to `pylibfdt`:

- `NodeTree.unflatten(fdt, offset=0)` builds a `NodeTree` hierarchy from
  an `FdtRo` instance.
- `NodeTree.flatten(mem_rsv=(), boot_cpuid_phys=0)` serializes a root
  `NodeTree` back into a packed FDT blob via `FdtSw`.
- `get_node()`, `create_path()`, and `walk()` provide path lookup,
  intermediate node creation, and pre-order traversal.
- `get_str()`, `set_str()`, `get_stringlist()`, `set_stringlist()`,
  `get_u32()`, `set_u32()`, `get_u32_list()`, and `set_u32_list()`
  provide typed property access and mutation (updating
  `Property.as_str()` and `Property.as_stringlist()` so empty `b''`
  properties raise `ValueError` or return `[]` rather than
  `IndexError`).

Add unit tests in `tests/pylibfdt_tests.py` covering `NodeTree`
unflatten/flatten round-tripping and helper methods.

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

Changes in v2:
- Split NodeTree live tree representation out of fdtfactor into
  pylibfdt (David Gibson)

 pylibfdt/libfdt.i       | 262 +++++++++++++++++++++++++++++++++++++++-
 tests/pylibfdt_tests.py |  54 +++++++++
 2 files changed, 315 insertions(+), 1 deletion(-)

diff --git a/pylibfdt/libfdt.i b/pylibfdt/libfdt.i
index 0db7110b4a38..f511c61746e3 100644
--- a/pylibfdt/libfdt.i
+++ b/pylibfdt/libfdt.i
@@ -929,7 +929,7 @@ class Property(bytearray):
 
     def as_str(self):
         """Unicode is supported by decoding from UTF-8"""
-        if self[-1] != 0:
+        if not self or self[-1] != 0:
             raise ValueError('Property lacks nul termination')
         if 0 in self[:-1]:
             raise ValueError('Property contains embedded nul characters')
@@ -937,6 +937,8 @@ class Property(bytearray):
 
     def as_stringlist(self):
         """Unicode is supported by decoding from UTF-8"""
+        if not self:
+            return []
         if self[-1] != 0:
             raise ValueError('Property lacks nul termination')
         parts = self[:-1].split(b'\x00')
@@ -1210,6 +1212,264 @@ class NodeAdder():
 
     def __exit__(self, type, value, traceback):
         self._fdt.end_node()
+
+
+class NodeTree:
+    """In-memory live tree representation of a devicetree node and its subtree.
+
+    Attributes:
+        props: Dict mapping property name (str) to raw property value (bytes).
+        children: Dict mapping child node name (str) to child NodeTree.
+    """
+
+    def __init__(self):
+        self.props = {}
+        self.children = {}
+
+    @classmethod
+    def unflatten(cls, fdt, offset=0):
+        """Unflatten an FdtRo subtree starting at `offset` into a NodeTree.
+
+        Args:
+            fdt: An `FdtRo` (or `Fdt`) instance.
+            offset: Starting node offset (default 0 for the root node).
+
+        Returns:
+            A `NodeTree` representing the node at `offset` and its descendants.
+        """
+        node = cls()
+        for prop in fdt.properties(offset):
+            node.props[prop.name] = bytes(prop)
+        for suboff in fdt.subnodes(offset):
+            node.children[fdt.get_name(suboff)] = cls.unflatten(fdt, suboff)
+        return node
+
+    def _flatten_subtree(self, sw, name):
+        """Recursively write this NodeTree and its children into an FdtSw.
+
+        Args:
+            sw: An `FdtSw` sequential-write instance.
+            name: Node name for this subtree (empty string `''` for root).
+        """
+        with sw.add_node(name):
+            for prop_name, val in self.props.items():
+                sw.property(prop_name, bytes(val))
+            for child_name, child_node in self.children.items():
+                child_node._flatten_subtree(sw, child_name)
+
+    def flatten(self, mem_rsv=(), boot_cpuid_phys=0):
+        """Serialize this root NodeTree into a packed FDT byte string.
+
+        Args:
+            mem_rsv: Iterable of (address, size) tuples for the FDT memory
+                reservation block.
+            boot_cpuid_phys: Physical boot CPU ID for the FDT header.
+
+        Returns:
+            Serialized FDT blob as `bytes`.
+        """
+        sw = FdtSw()
+        for addr, size in mem_rsv:
+            sw.add_reservemap_entry(addr, size)
+        sw.finish_reservemap()
+        self._flatten_subtree(sw, '')
+        fdt = sw.as_fdt()
+        fdt_set_boot_cpuid_phys(fdt._fdt, boot_cpuid_phys)
+        fdt.pack()
+        return bytes(fdt.as_bytearray())
+
+    def get_node(self, path):
+        """Look up a descendant NodeTree by path without modifying the tree.
+
+        Follows the slash-separated node names in `path` starting from `self`
+        (leading, trailing, and repeated `'/'` characters are ignored, so
+        `'/soc/i2c@4000'` and `'soc/i2c@4000'` behave identically, and `''` or
+        `'/'` returns `self`). If any component along `path` is not present in
+        `children`, returns `None`.
+
+        Args:
+            path: Slash-separated node path starting from `self` (e.g.
+                `'/soc/i2c@4000'` or `'soc/i2c@4000'`).
+
+        Returns:
+            The `NodeTree` at `path`, or `None` if not found.
+        """
+        node = self
+        for part in path.split('/'):
+            if not part:
+                continue
+            node = node.children.get(part)
+            if node is None:
+                return None
+        return node
+
+    def create_path(self, path):
+        """Return the descendant NodeTree at `path`, creating missing nodes.
+
+        Looks up a child or deeper descendant `NodeTree` by following the
+        slash-separated node names in `path` starting from `self` (leading,
+        trailing, and repeated `'/'` characters are ignored, so
+        `'/soc/i2c@4000'` and `'soc/i2c@4000'` behave identically). If any
+        node along `path` does not exist yet in `children`, a new empty
+        `NodeTree` is created for it automatically (similar to `mkdir -p`).
+        Passing `''` or `'/'` returns `self`.
+
+        Example:
+            root = NodeTree()
+            # Automatically creates root.children['soc'] and
+            # root.children['soc'].children['i2c@4000'], and returns the
+            # 'i2c@4000' NodeTree.
+            i2c = root.create_path('/soc/i2c@4000')
+            i2c.set_str('status', 'okay')
+
+        Args:
+            path: Slash-separated node path starting from `self` (e.g.
+                `'/soc/i2c@4000'` or `'soc/i2c@4000'`).
+
+        Returns:
+            The `NodeTree` instance at `path`.
+        """
+        node = self
+        for part in path.split('/'):
+            if not part:
+                continue
+            if part not in node.children:
+                node.children[part] = type(self)()
+            node = node.children[part]
+        return node
+
+    def walk(self, path=''):
+        """Yield `(path, node)` pairs for this subtree in pre-order.
+
+        Args:
+            path: Path prefix for `self` (defaults to `''` for the root node,
+                producing paths like `''`, `'/soc'`, `'/soc/i2c@4000'`).
+
+        Yields:
+            Tuple of `(path, node)` for `self` followed recursively by each
+            descendant `NodeTree`.
+        """
+        prefix = path.rstrip('/')
+        yield path, self
+        for name, child in self.children.items():
+            yield from child.walk(f'{prefix}/{name}')
+
+    def get_str(self, name):
+        """Return property `name` decoded as a NUL-terminated UTF-8 string.
+
+        Args:
+            name: Property name to look up in `self.props`.
+
+        Returns:
+            Decoded string value (without the trailing NUL byte).
+
+        Raises:
+            KeyError: If `name` is not in `self.props`.
+            ValueError: If the property value is not a single NUL-terminated
+                UTF-8 string.
+        """
+        return Property(name, self.props[name]).as_str()
+
+    def set_str(self, name, val):
+        """Set property `name` to a NUL-terminated UTF-8 string.
+
+        Args:
+            name: Property name to set in `self.props`.
+            val: String value to encode as UTF-8 with a trailing NUL byte.
+
+        Raises:
+            ValueError: If `val` contains embedded NUL characters.
+        """
+        if '\x00' in val:
+            raise ValueError('String value contains embedded nul characters')
+        self.props[name] = val.encode('utf-8') + b'\x00'
+
+    def get_stringlist(self, name):
+        """Return property `name` decoded as a list of NUL-separated strings.
+
+        Args:
+            name: Property name to look up in `self.props`.
+
+        Returns:
+            List of decoded UTF-8 strings (`[]` if the property is empty).
+
+        Raises:
+            KeyError: If `name` is not in `self.props`.
+            ValueError: If a non-empty property lacks NUL termination.
+        """
+        return Property(name, self.props[name]).as_stringlist()
+
+    def set_stringlist(self, name, vals):
+        """Set property `name` to a NUL-separated list of UTF-8 strings.
+
+        Args:
+            name: Property name to set in `self.props`.
+            vals: Iterable of strings to encode as NUL-terminated UTF-8.
+
+        Raises:
+            ValueError: If any string in `vals` contains embedded NUL
+                characters.
+        """
+        vals = list(vals)
+        if any('\x00' in v for v in vals):
+            raise ValueError('String value contains embedded nul characters')
+        self.props[name] = b''.join(v.encode('utf-8') + b'\x00' for v in vals)
+
+    def get_u32(self, name):
+        """Return property `name` decoded as a single big-endian uint32 cell.
+
+        Args:
+            name: Property name to look up in `self.props`.
+
+        Returns:
+            Integer value of the 32-bit cell.
+
+        Raises:
+            KeyError: If `name` is not in `self.props`.
+            ValueError: If the property length is not 4 bytes.
+        """
+        raw = self.props[name]
+        if len(raw) != 4:
+            raise ValueError(f'Property {name!r} length {len(raw)} != 4')
+        return Property(name, raw).as_uint32()
+
+    def set_u32(self, name, val):
+        """Set property `name` to a single big-endian uint32 cell.
+
+        Args:
+            name: Property name to set in `self.props`.
+            val: Unsigned 32-bit integer value.
+        """
+        self.props[name] = struct.pack('>I', val)
+
+    def get_u32_list(self, name):
+        """Return property `name` decoded as a list of big-endian uint32 cells.
+
+        Args:
+            name: Property name to look up in `self.props`.
+
+        Returns:
+            List of 32-bit unsigned integers.
+
+        Raises:
+            KeyError: If `name` is not in `self.props`.
+            ValueError: If the property length is not a multiple of 4 bytes.
+        """
+        raw = self.props[name]
+        if len(raw) % 4 != 0:
+            raise ValueError(
+                f'Property {name!r} length {len(raw)} is not a multiple of 4'
+            )
+        return Property(name, raw).as_uint32_list()
+
+    def set_u32_list(self, name, vals):
+        """Set property `name` to a sequence of big-endian uint32 cells.
+
+        Args:
+            name: Property name to set in `self.props`.
+            vals: Sequence of unsigned 32-bit integer values.
+        """
+        self.props[name] = struct.pack(f'>{len(vals)}I', *vals)
 %}
 
 %rename(fdt_property) fdt_property_func;
diff --git a/tests/pylibfdt_tests.py b/tests/pylibfdt_tests.py
index 2a0509c7802e..569eb698a86f 100644
--- a/tests/pylibfdt_tests.py
+++ b/tests/pylibfdt_tests.py
@@ -697,6 +697,60 @@ class PyLibfdtRoTests(unittest.TestCase):
         self.assertEqual(b'this is a placeholder string\0string2\0',
                          self.fdt.getprop(node, 'placeholder'))
 
+    def testNodeTreeRoundTrip(self):
+        """Test unflattening and flattening a tree via NodeTree"""
+        tree = libfdt.NodeTree.unflatten(self.fdt)
+        rsvmap = self.fdt.get_mem_rsv_list()
+        cpuid = self.fdt.boot_cpuid_phys()
+        blob = tree.flatten(rsvmap, cpuid)
+
+        fdt2 = libfdt.FdtRo(blob)
+        self.assertEqual(rsvmap, fdt2.get_mem_rsv_list())
+        self.assertEqual(cpuid, fdt2.boot_cpuid_phys())
+
+        tree2 = libfdt.NodeTree.unflatten(fdt2)
+        nodes1 = [(path, n.props, list(n.children.keys()))
+                  for path, n in tree.walk()]
+        nodes2 = [(path, n.props, list(n.children.keys()))
+                  for path, n in tree2.walk()]
+        self.assertEqual(nodes1, nodes2)
+
+    def testNodeTreeHelpers(self):
+        """Test NodeTree navigation and typed property helpers"""
+        root = libfdt.NodeTree()
+        self.assertIs(root, root.get_node(''))
+        self.assertIs(root, root.get_node('/'))
+        self.assertIsNone(root.get_node('/soc/i2c@4000'))
+
+        i2c = root.create_path('/soc/i2c@4000')
+        self.assertIs(i2c, root.get_node('/soc/i2c@4000'))
+        self.assertIs(i2c, root.get_node('soc/i2c@4000'))
+        self.assertEqual(['', '/soc', '/soc/i2c@4000'],
+                         [path for path, _ in root.walk()])
+        self.assertEqual(['/', '/soc', '/soc/i2c@4000'],
+                         [path for path, _ in root.walk('/')])
+
+        i2c.set_str('status', 'okay')
+        self.assertEqual('okay', i2c.get_str('status'))
+        with self.assertRaises(ValueError):
+            i2c.set_str('status', 'ok\x00ay')
+
+        i2c.set_stringlist('compatible', ['vendor,i2c', 'generic,i2c'])
+        self.assertEqual(['vendor,i2c', 'generic,i2c'],
+                         i2c.get_stringlist('compatible'))
+        with self.assertRaises(ValueError):
+            i2c.set_stringlist('compatible', ['vendor\x00,i2c'])
+        i2c.set_stringlist('empty-list', [])
+        self.assertEqual([], i2c.get_stringlist('empty-list'))
+        with self.assertRaises(ValueError):
+            i2c.get_str('empty-list')
+
+        i2c.set_u32('#address-cells', 1)
+        self.assertEqual(1, i2c.get_u32('#address-cells'))
+
+        i2c.set_u32_list('reg', [0x4000, 0x100])
+        self.assertEqual([0x4000, 0x100], i2c.get_u32_list('reg'))
+
 
 if __name__ == "__main__":
     unittest.main()
-- 
2.56.0.313.g2479322e68-goog


^ permalink raw reply	[flat|nested] 12+ messages in thread

* [PATCH v2 04/10] libfdt: overlay: Factor out fixup and fragment path parsing helpers
  2026-10-28 21:46 [PATCH v2 00/10] fdtfactor: Introduce fdtfactor to auto-factor overlays Douglas Anderson
                   ` (2 preceding siblings ...)
  2026-10-28 21:46 ` [PATCH v2 03/10] pylibfdt: Add NodeTree live tree representation Douglas Anderson
@ 2026-10-28 21:46 ` Douglas Anderson
  2026-10-28 21:46 ` [PATCH v2 05/10] libfdt: overlay: Prepare fixup and fragment path helpers for reuse Douglas Anderson
                   ` (5 subsequent siblings)
  9 siblings, 0 replies; 12+ messages in thread
From: Douglas Anderson @ 2026-10-28 21:46 UTC (permalink / raw)
  To: Devicetree-compiler, Rob Herring, Krzysztof Kozlowski,
	Conor Dooley, Konrad Dybcio
  Cc: Brian Norris, Chen-Yu Tsai, André Draszik, Devicetree,
	Simon Glass, David Gibson, Tudor Ambarus, linux-kernel,
	Julius Werner, Saravana Kannan, Peter Griffin, Douglas Anderson

In preparation for updating the base device tree's `/__local_fixups__`
node during `fdt_overlay_apply()`, factor out two helper functions in
`libfdt/fdt_overlay.c`:

- Factor out `overlay_parse_fixup_entry()` from
  `overlay_fixup_phandle()` to parse `"path:name:offset"` entries in
  `/__fixups__` properties.
- Factor out `overlay_split_fragment_path()` from
  `overlay_symbol_update()` to validate and split
  `"/<fragment>/__overlay__[/<rel>]"` paths into the fragment name and
  relative subnode path.

This commit is pure code movement with no functional change.

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

Changes in v2:
- New patch factoring out overlay_parse_fixup_entry() and
  overlay_split_fragment_path() helpers in libfdt/fdt_overlay.c

 libfdt/fdt_overlay.c | 226 +++++++++++++++++++++++++++++--------------
 1 file changed, 154 insertions(+), 72 deletions(-)

diff --git a/libfdt/fdt_overlay.c b/libfdt/fdt_overlay.c
index 51a3859620a4..f9d1a555ed5d 100644
--- a/libfdt/fdt_overlay.c
+++ b/libfdt/fdt_overlay.c
@@ -351,6 +351,72 @@ static int overlay_fixup_one_phandle(void *fdto, int symbols_off,
 						   sizeof(phandle_prop));
 }
 
+/**
+ * overlay_parse_fixup_entry - Parse one "path:name:offset" entry from /__fixups__
+ * @valuep: In/out pointer to the current NUL-terminated fixup string
+ * @lenp: In/out remaining length of the /__fixups__ property value
+ * @pathp: Output pointer to the target node path
+ * @path_lenp: Output length of @pathp in bytes
+ * @namep: Output pointer to the target property name
+ * @name_lenp: Output length of @namep in bytes
+ * @poffsetp: Output byte offset within the target property
+ *
+ * returns:
+ *      0 on success
+ *      -FDT_ERR_BADOVERLAY if the fixup entry is malformed
+ */
+static int overlay_parse_fixup_entry(const char **valuep, int *lenp,
+				     const char **pathp, uint32_t *path_lenp,
+				     const char **namep, uint32_t *name_lenp,
+				     int *poffsetp)
+{
+	const char *path, *name, *fixup_end;
+	const char *fixup_str = *valuep;
+	uint32_t path_len, name_len;
+	uint32_t fixup_len;
+	const char *sep;
+	char *endptr;
+	int poffset;
+
+	fixup_end = memchr(*valuep, '\0', *lenp);
+	if (!fixup_end)
+		return -FDT_ERR_BADOVERLAY;
+	fixup_len = fixup_end - fixup_str;
+
+	*lenp -= fixup_len + 1;
+	*valuep += fixup_len + 1;
+
+	path = fixup_str;
+	sep = memchr(fixup_str, ':', fixup_len);
+	if (!sep || *sep != ':')
+		return -FDT_ERR_BADOVERLAY;
+
+	path_len = sep - path;
+	if (path_len == (fixup_len - 1))
+		return -FDT_ERR_BADOVERLAY;
+
+	fixup_len -= path_len + 1;
+	name = sep + 1;
+	sep = memchr(name, ':', fixup_len);
+	if (!sep || *sep != ':')
+		return -FDT_ERR_BADOVERLAY;
+
+	name_len = sep - name;
+	if (!name_len)
+		return -FDT_ERR_BADOVERLAY;
+
+	poffset = strtoul(sep + 1, &endptr, 10);
+	if ((*endptr != '\0') || (endptr <= (sep + 1)))
+		return -FDT_ERR_BADOVERLAY;
+
+	*pathp = path;
+	*path_lenp = path_len;
+	*namep = name;
+	*name_lenp = name_len;
+	*poffsetp = poffset;
+	return 0;
+}
+
 /**
  * overlay_fixup_phandle - Set an overlay phandle to the base one
  * @fdt: Base Device Tree blob
@@ -403,44 +469,14 @@ static int overlay_fixup_phandle(void *fdt, void *fdto, int symbols_off,
 		return -FDT_ERR_NOTFOUND;
 
 	do {
-		const char *path, *name, *fixup_end;
-		const char *fixup_str = value;
+		const char *path, *name;
 		uint32_t path_len, name_len;
-		uint32_t fixup_len;
-		const char *sep;
-		char *endptr;
 		int poffset, ret;
 
-		fixup_end = memchr(value, '\0', len);
-		if (!fixup_end)
-			return -FDT_ERR_BADOVERLAY;
-		fixup_len = fixup_end - fixup_str;
-
-		len -= fixup_len + 1;
-		value += fixup_len + 1;
-
-		path = fixup_str;
-		sep = memchr(fixup_str, ':', fixup_len);
-		if (!sep || *sep != ':')
-			return -FDT_ERR_BADOVERLAY;
-
-		path_len = sep - path;
-		if (path_len == (fixup_len - 1))
-			return -FDT_ERR_BADOVERLAY;
-
-		fixup_len -= path_len + 1;
-		name = sep + 1;
-		sep = memchr(name, ':', fixup_len);
-		if (!sep || *sep != ':')
-			return -FDT_ERR_BADOVERLAY;
-
-		name_len = sep - name;
-		if (!name_len)
-			return -FDT_ERR_BADOVERLAY;
-
-		poffset = strtoul(sep + 1, &endptr, 10);
-		if ((*endptr != '\0') || (endptr <= (sep + 1)))
-			return -FDT_ERR_BADOVERLAY;
+		ret = overlay_parse_fixup_entry(&value, &len, &path, &path_len,
+						&name, &name_len, &poffset);
+		if (ret)
+			return ret;
 
 		ret = overlay_fixup_one_phandle(fdto, symbols_off,
 						path, path_len, name, name_len,
@@ -887,6 +923,80 @@ static int get_path_len(const void *fdt, int nodeoffset)
 	return len;
 }
 
+/**
+ * overlay_split_fragment_path - Split "/<frag>/__overlay__[/<rel>]" into parts
+ * @path: Input NUL-terminated path property string
+ * @path_len: Length of @path in bytes (including trailing NUL)
+ * @frag_namep: Output pointer to the fragment name
+ * @frag_name_lenp: Output length of @frag_namep in bytes
+ * @rel_pathp: Output pointer to the relative subnode path after __overlay__
+ * @rel_path_lenp: Output length of @rel_pathp in bytes
+ * @is_overlayp: Set to true if @path is under an __overlay__ subnode
+ *
+ * returns:
+ *      0 on success
+ *      -FDT_ERR_BADVALUE if @path is not a valid NUL-terminated '/' path
+ */
+static int overlay_split_fragment_path(const char *path, int path_len,
+				       const char **frag_namep,
+				       int *frag_name_lenp,
+				       const char **rel_pathp,
+				       int *rel_path_lenp,
+				       bool *is_overlayp)
+{
+	int len, frag_name_len, rel_path_len;
+	const char *s, *e;
+	const char *frag_name;
+	const char *rel_path;
+
+	*is_overlayp = false;
+
+	/* verify it's a string property (terminated by a single \0) */
+	if (path_len < 1 || memchr(path, '\0', path_len) != &path[path_len - 1])
+		return -FDT_ERR_BADVALUE;
+
+	/* keep end marker to avoid strlen() */
+	e = path + path_len;
+
+	if (*path != '/')
+		return -FDT_ERR_BADVALUE;
+
+	/* get fragment name first */
+	s = strchr(path + 1, '/');
+	if (!s) {
+		/* Symbol refers to something that won't end
+		 * up in the target tree */
+		return 0;
+	}
+
+	frag_name = path + 1;
+	frag_name_len = s - path - 1;
+
+	/* verify format; safe since "s" lies in \0 terminated prop */
+	len = sizeof("/__overlay__/") - 1;
+	if ((e - s) > len && (memcmp(s, "/__overlay__/", len) == 0)) {
+		/* /<fragment-name>/__overlay__/<relative-subnode-path> */
+		rel_path = s + len;
+		rel_path_len = e - rel_path - 1;
+	} else if ((e - s) == len
+		   && (memcmp(s, "/__overlay__", len - 1) == 0)) {
+		/* /<fragment-name>/__overlay__ */
+		rel_path = "";
+		rel_path_len = 0;
+	} else {
+		/* Symbol refers to something that won't end
+		 * up in the target tree */
+		return 0;
+	}
+
+	*frag_namep = frag_name;
+	*frag_name_lenp = frag_name_len;
+	*rel_pathp = rel_path;
+	*rel_path_lenp = rel_path_len;
+	*is_overlayp = true;
+	return 0;
+}
+
 /**
  * overlay_symbol_update - Update the symbols of base tree after a merge
  * @fdt: Base Device Tree blob
@@ -907,7 +1017,6 @@ static int overlay_symbol_update(void *fdt, void *fdto)
 {
 	int root_sym, ov_sym, prop, path_len, fragment, target;
 	int len, frag_name_len, ret, rel_path_len;
-	const char *s, *e;
 	const char *path;
 	const char *name;
 	const char *frag_name;
@@ -934,47 +1043,20 @@ static int overlay_symbol_update(void *fdt, void *fdto)
 
 	/* iterate over each overlay symbol */
 	fdt_for_each_property_offset(prop, fdto, ov_sym) {
+		bool is_overlay;
+
 		path = fdt_getprop_by_offset(fdto, prop, &name, &path_len);
 		if (!path)
 			return path_len;
 
-		/* verify it's a string property (terminated by a single \0) */
-		if (path_len < 1 || memchr(path, '\0', path_len) != &path[path_len - 1])
-			return -FDT_ERR_BADVALUE;
-
-		/* keep end marker to avoid strlen() */
-		e = path + path_len;
-
-		if (*path != '/')
-			return -FDT_ERR_BADVALUE;
-
-		/* get fragment name first */
-		s = strchr(path + 1, '/');
-		if (!s) {
-			/* Symbol refers to something that won't end
-			 * up in the target tree */
-			continue;
-		}
-
-		frag_name = path + 1;
-		frag_name_len = s - path - 1;
-
-		/* verify format; safe since "s" lies in \0 terminated prop */
-		len = sizeof("/__overlay__/") - 1;
-		if ((e - s) > len && (memcmp(s, "/__overlay__/", len) == 0)) {
-			/* /<fragment-name>/__overlay__/<relative-subnode-path> */
-			rel_path = s + len;
-			rel_path_len = e - rel_path - 1;
-		} else if ((e - s) == len
-			   && (memcmp(s, "/__overlay__", len - 1) == 0)) {
-			/* /<fragment-name>/__overlay__ */
-			rel_path = "";
-			rel_path_len = 0;
-		} else {
-			/* Symbol refers to something that won't end
-			 * up in the target tree */
+		ret = overlay_split_fragment_path(path, path_len,
+						  &frag_name, &frag_name_len,
+						  &rel_path, &rel_path_len,
+						  &is_overlay);
+		if (ret)
+			return ret;
+		if (!is_overlay)
 			continue;
-		}
 
 		/* find the fragment index in which the symbol lies */
 		ret = fdt_subnode_offset_namelen(fdto, 0, frag_name,
-- 
2.56.0.313.g2479322e68-goog


^ permalink raw reply	[flat|nested] 12+ messages in thread

* [PATCH v2 05/10] libfdt: overlay: Prepare fixup and fragment path helpers for reuse
  2026-10-28 21:46 [PATCH v2 00/10] fdtfactor: Introduce fdtfactor to auto-factor overlays Douglas Anderson
                   ` (3 preceding siblings ...)
  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 ` Douglas Anderson
  2026-10-28 21:46 ` [PATCH v2 06/10] libfdt: overlay: Update base /__local_fixups__ on apply Douglas Anderson
                   ` (4 subsequent siblings)
  9 siblings, 0 replies; 12+ messages in thread
From: Douglas Anderson @ 2026-10-28 21:46 UTC (permalink / raw)
  To: Devicetree-compiler, Rob Herring, Krzysztof Kozlowski,
	Conor Dooley, Konrad Dybcio
  Cc: Brian Norris, Chen-Yu Tsai, André Draszik, Devicetree,
	Simon Glass, David Gibson, Tudor Ambarus, linux-kernel,
	Julius Werner, Saravana Kannan, Peter Griffin, Douglas Anderson

Clean up `overlay_parse_fixup_entry()` and generalize
`overlay_split_fragment_path()` in `libfdt/fdt_overlay.c` so both can be
reused when updating `/__local_fixups__`:

- Change `poffset` from `int` to `uint32_t` in
  `overlay_fixup_one_phandle()`, `overlay_parse_fixup_entry()`, and
  `overlay_fixup_phandle()`, and parse into `unsigned long` before
  validating the range.
- Require `*(sep + 1)` to be an ASCII digit (`'0'..'9'`) and bound
  `poffset` to `INT32_MAX - sizeof(uint32_t)` so negative strings (such
  as `"-4"`), leading whitespace, or large values cannot silently
  truncate or wrap around in `fdt_setprop_inplace_namelen_partial()`.
- Drop the redundant `*sep != ':'` checks after `memchr(..., ':', ...)`.
- Generalize `overlay_split_fragment_path()` to accept a length-bounded
  `path` slice that is not necessarily NUL-terminated (using `memchr()`
  instead of `strchr()`, and keeping the NUL-terminated property check
  in `overlay_symbol_update()`) so it can also split `:`-delimited paths
  from `/__fixups__`.

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

Changes in v2:
- New patch tightening fixup offset parsing and generalizing
  overlay_split_fragment_path() for non-NUL-terminated paths in
  libfdt/fdt_overlay.c

 libfdt/fdt_overlay.c | 46 +++++++++++++++++++++++---------------------
 1 file changed, 24 insertions(+), 22 deletions(-)

diff --git a/libfdt/fdt_overlay.c b/libfdt/fdt_overlay.c
index f9d1a555ed5d..eb87b356145f 100644
--- a/libfdt/fdt_overlay.c
+++ b/libfdt/fdt_overlay.c
@@ -330,7 +330,7 @@ static int overlay_update_local_references(void *fdto, uint32_t delta)
 static int overlay_fixup_one_phandle(void *fdto, int symbols_off,
 				     const char *path, uint32_t path_len,
 				     const char *name, uint32_t name_len,
-				     int poffset, uint32_t phandle)
+				     uint32_t poffset, uint32_t phandle)
 {
 	fdt32_t phandle_prop;
 	int fixup_off;
@@ -368,7 +368,7 @@ static int overlay_fixup_one_phandle(void *fdto, int symbols_off,
 static int overlay_parse_fixup_entry(const char **valuep, int *lenp,
 				     const char **pathp, uint32_t *path_lenp,
 				     const char **namep, uint32_t *name_lenp,
-				     int *poffsetp)
+				     uint32_t *poffsetp)
 {
 	const char *path, *name, *fixup_end;
 	const char *fixup_str = *valuep;
@@ -376,7 +376,7 @@ static int overlay_parse_fixup_entry(const char **valuep, int *lenp,
 	uint32_t fixup_len;
 	const char *sep;
 	char *endptr;
-	int poffset;
+	unsigned long poffset;
 
 	fixup_end = memchr(*valuep, '\0', *lenp);
 	if (!fixup_end)
@@ -388,7 +388,7 @@ static int overlay_parse_fixup_entry(const char **valuep, int *lenp,
 
 	path = fixup_str;
 	sep = memchr(fixup_str, ':', fixup_len);
-	if (!sep || *sep != ':')
+	if (!sep)
 		return -FDT_ERR_BADOVERLAY;
 
 	path_len = sep - path;
@@ -398,15 +398,17 @@ static int overlay_parse_fixup_entry(const char **valuep, int *lenp,
 	fixup_len -= path_len + 1;
 	name = sep + 1;
 	sep = memchr(name, ':', fixup_len);
-	if (!sep || *sep != ':')
+	if (!sep)
 		return -FDT_ERR_BADOVERLAY;
 
 	name_len = sep - name;
 	if (!name_len)
 		return -FDT_ERR_BADOVERLAY;
 
+	if ((*(sep + 1) < '0') || (*(sep + 1) > '9'))
+		return -FDT_ERR_BADOVERLAY;
 	poffset = strtoul(sep + 1, &endptr, 10);
-	if ((*endptr != '\0') || (endptr <= (sep + 1)))
+	if ((*endptr != '\0') || (poffset > (INT32_MAX - sizeof(uint32_t))))
 		return -FDT_ERR_BADOVERLAY;
 
 	*pathp = path;
@@ -470,8 +472,8 @@ static int overlay_fixup_phandle(void *fdt, void *fdto, int symbols_off,
 
 	do {
 		const char *path, *name;
-		uint32_t path_len, name_len;
-		int poffset, ret;
+		uint32_t path_len, name_len, poffset;
+		int ret;
 
 		ret = overlay_parse_fixup_entry(&value, &len, &path, &path_len,
 						&name, &name_len, &poffset);
@@ -925,8 +927,8 @@ static int get_path_len(const void *fdt, int nodeoffset)
 
 /**
  * overlay_split_fragment_path - Split "/<frag>/__overlay__[/<rel>]" into parts
- * @path: Input NUL-terminated path property string
- * @path_len: Length of @path in bytes (including trailing NUL)
+ * @path: Input path string (need not be NUL-terminated)
+ * @path_len: Length of @path in bytes (excluding any terminator)
  * @frag_namep: Output pointer to the fragment name
  * @frag_name_lenp: Output length of @frag_namep in bytes
  * @rel_pathp: Output pointer to the relative subnode path after __overlay__
@@ -935,7 +937,7 @@ static int get_path_len(const void *fdt, int nodeoffset)
  *
  * returns:
  *      0 on success
- *      -FDT_ERR_BADVALUE if @path is not a valid NUL-terminated '/' path
+ *      -FDT_ERR_BADVALUE if @path does not start with '/'
  */
 static int overlay_split_fragment_path(const char *path, int path_len,
 				       const char **frag_namep,
@@ -951,18 +953,14 @@ static int overlay_split_fragment_path(const char *path, int path_len,
 
 	*is_overlayp = false;
 
-	/* verify it's a string property (terminated by a single \0) */
-	if (path_len < 1 || memchr(path, '\0', path_len) != &path[path_len - 1])
-		return -FDT_ERR_BADVALUE;
-
 	/* keep end marker to avoid strlen() */
 	e = path + path_len;
 
-	if (*path != '/')
+	if (path_len < 1 || *path != '/')
 		return -FDT_ERR_BADVALUE;
 
 	/* get fragment name first */
-	s = strchr(path + 1, '/');
+	s = memchr(path + 1, '/', path_len - 1);
 	if (!s) {
 		/* Symbol refers to something that won't end
 		 * up in the target tree */
@@ -972,13 +970,13 @@ static int overlay_split_fragment_path(const char *path, int path_len,
 	frag_name = path + 1;
 	frag_name_len = s - path - 1;
 
-	/* verify format; safe since "s" lies in \0 terminated prop */
+	/* verify format */
 	len = sizeof("/__overlay__/") - 1;
-	if ((e - s) > len && (memcmp(s, "/__overlay__/", len) == 0)) {
+	if ((e - s) >= len && (memcmp(s, "/__overlay__/", len) == 0)) {
 		/* /<fragment-name>/__overlay__/<relative-subnode-path> */
 		rel_path = s + len;
-		rel_path_len = e - rel_path - 1;
-	} else if ((e - s) == len
+		rel_path_len = e - rel_path;
+	} else if ((e - s) == (len - 1)
 		   && (memcmp(s, "/__overlay__", len - 1) == 0)) {
 		/* /<fragment-name>/__overlay__ */
 		rel_path = "";
@@ -1049,7 +1047,11 @@ static int overlay_symbol_update(void *fdt, void *fdto)
 		if (!path)
 			return path_len;
 
-		ret = overlay_split_fragment_path(path, path_len,
+		/* verify it's a string property (terminated by a single \0) */
+		if (path_len < 1 || memchr(path, '\0', path_len) != &path[path_len - 1])
+			return -FDT_ERR_BADVALUE;
+
+		ret = overlay_split_fragment_path(path, path_len - 1,
 						  &frag_name, &frag_name_len,
 						  &rel_path, &rel_path_len,
 						  &is_overlay);
-- 
2.56.0.313.g2479322e68-goog


^ permalink raw reply	[flat|nested] 12+ messages in thread

* [PATCH v2 06/10] libfdt: overlay: Update base /__local_fixups__ on apply
  2026-10-28 21:46 [PATCH v2 00/10] fdtfactor: Introduce fdtfactor to auto-factor overlays Douglas Anderson
                   ` (4 preceding siblings ...)
  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 ` Douglas Anderson
  2026-10-28 21:46 ` [PATCH v2 07/10] fdtfactor: Add tool to split DTBs into a base DTB and overlays Douglas Anderson
                   ` (3 subsequent siblings)
  9 siblings, 0 replies; 12+ messages in thread
From: Douglas Anderson @ 2026-10-28 21:46 UTC (permalink / raw)
  To: Devicetree-compiler, Rob Herring, Krzysztof Kozlowski,
	Conor Dooley, Konrad Dybcio
  Cc: Brian Norris, Chen-Yu Tsai, André Draszik, Devicetree,
	Simon Glass, David Gibson, Tudor Ambarus, linux-kernel,
	Julius Werner, Saravana Kannan, Peter Griffin, Douglas Anderson

When a base device tree is compiled with `dtc -L` (`--local-fixups`), it
contains a root `/__local_fixups__` node recording the byte offsets of
all phandle references in its properties.

Previously, `fdt_overlay_apply()` updated the base tree's `/__symbols__`
via `overlay_symbol_update()`, but left the base tree's
`/__local_fixups__` untouched. As a result, applying an overlay to a
base tree with `/__local_fixups__` left `/__local_fixups__` missing the
phandle references introduced by the overlay (both from the overlay's
`/__local_fixups__` and from its resolved `/__fixups__`), and left stale
offsets if the overlay overwrote a base property that previously had
phandle references.

Add `overlay_local_fixups_update()` to update the base tree's
`/__local_fixups__` whenever `/__local_fixups__` is present in the base
tree:

- Remove pre-existing `/__local_fixups__` properties for any base
  properties overwritten by overlay fragments.
- Merge the overlay's `/__local_fixups__` and `/__fixups__` offsets into
  the base tree's `/__local_fixups__` in sorted ascending order.
- Prune any subnodes under `/__local_fixups__` that become empty after
  overwriting properties.

When the base tree does not have a `/__local_fixups__` node,
`overlay_local_fixups_update()` is a no-op.

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

Changes in v2:
- New patch updating base /__local_fixups__ in libfdt
  fdt_overlay_apply() instead of stripping /__local_fixups__ by default
  (David Gibson)

 libfdt/fdt_overlay.c | 621 ++++++++++++++++++++++++++++++++++++++++++-
 tests/run_tests.sh   |  19 ++
 2 files changed, 636 insertions(+), 4 deletions(-)

diff --git a/libfdt/fdt_overlay.c b/libfdt/fdt_overlay.c
index eb87b356145f..47acab15041d 100644
--- a/libfdt/fdt_overlay.c
+++ b/libfdt/fdt_overlay.c
@@ -804,7 +804,7 @@ static int overlay_prevent_phandle_overwrite(void *fdt, void *fdto)
  *      Negative error code on failure
  */
 static int overlay_apply_node(void *fdt, int target,
-			      void *fdto, int node)
+			      const void *fdto, int node)
 {
 	int property;
 	int subnode;
@@ -1003,9 +1003,8 @@ static int overlay_split_fragment_path(const char *path, int path_len,
  * overlay_symbol_update() updates the symbols of the base tree with the
  * symbols of the applied overlay
  *
- * This is the last step in the device tree overlay application
- * process, allowing the reference of overlay symbols by subsequent
- * overlay operations.
+ * This step in the device tree overlay application process allows
+ * the reference of overlay symbols by subsequent overlay operations.
  *
  * returns:
  *      0 on success
@@ -1122,6 +1121,616 @@ static int overlay_symbol_update(void *fdt, void *fdto)
 	return 0;
 }
 
+/**
+ * overlay_create_path - Create a relative path under a parent node
+ * @fdt: Base Device Tree blob
+ * @parent: Node offset of the starting parent node
+ * @path: Relative or slash-prefixed path to traverse
+ * @path_len: Length of @path in bytes
+ *
+ * overlay_create_path() walks the slash-separated path components in @path
+ * starting at @parent, creating any missing intermediate subnodes along the
+ * way.
+ *
+ * returns:
+ *      Node offset of the resolved or created subnode on success
+ *      Negative error code on failure
+ */
+static int overlay_create_path(void *fdt, int parent, const char *path,
+			       int path_len)
+{
+	const char *sep;
+	int node = parent;
+
+	while (path_len > 0 && *path == '/') {
+		path++;
+		path_len--;
+	}
+
+	while (path_len > 0) {
+		int comp_len;
+		int next;
+
+		sep = memchr(path, '/', path_len);
+		comp_len = sep ? (sep - path) : path_len;
+
+		if (comp_len > 0) {
+			next = fdt_subnode_offset_namelen(fdt, node, path,
+							  comp_len);
+			if (next == -FDT_ERR_NOTFOUND)
+				next = fdt_add_subnode_namelen(fdt, node, path,
+							       comp_len);
+			if (next < 0)
+				return next;
+			node = next;
+		}
+
+		path += comp_len;
+		path_len -= comp_len;
+		while (path_len > 0 && *path == '/') {
+			path++;
+			path_len--;
+		}
+	}
+
+	return node;
+}
+
+/**
+ * overlay_fragment_local_fixups - Find or create a fragment's target under /__local_fixups__
+ * @fdt: Base Device Tree blob
+ * @fdto: Device tree overlay blob
+ * @fragment: Node offset of the fragment in @fdto
+ * @create: Whether to create missing subnodes under /__local_fixups__
+ *
+ * overlay_fragment_local_fixups() resolves the target of @fragment in @fdt and
+ * locates (or creates, when @create is true) the corresponding node at the
+ * same path under @fdt's /__local_fixups__ subtree.
+ *
+ * returns:
+ *      Node offset of the corresponding /__local_fixups__ node on success
+ *      Negative error code on failure
+ */
+static int overlay_fragment_local_fixups(void *fdt, const void *fdto,
+					 int fragment, bool create)
+{
+	int root_lf, target, cur_fdt, node;
+
+	root_lf = fdt_subnode_offset(fdt, 0, "__local_fixups__");
+	if (root_lf < 0)
+		return root_lf;
+
+	target = fdt_overlay_target_offset(fdt, fdto, fragment, NULL);
+	if (target < 0)
+		return target;
+
+	cur_fdt = 0;
+	node = root_lf;
+	while (cur_fdt != target) {
+		int subnode, ancestor = -FDT_ERR_INTERNAL;
+		const char *name;
+		int namelen, next_lf;
+
+		fdt_for_each_subnode(subnode, fdt, cur_fdt) {
+			if (subnode <= target)
+				ancestor = subnode;
+			else
+				break;
+		}
+		if (ancestor < 0)
+			return ancestor;
+
+		name = fdt_get_name(fdt, ancestor, &namelen);
+		if (!name)
+			return namelen;
+
+		next_lf = fdt_subnode_offset_namelen(fdt, node, name, namelen);
+		if (next_lf == -FDT_ERR_NOTFOUND && create) {
+			char namebuf[256];
+			int old_struct_size = fdt_size_dt_struct(fdt);
+
+			if ((size_t)namelen >= sizeof(namebuf))
+				return -FDT_ERR_INTERNAL;
+			memcpy(namebuf, name, namelen);
+			next_lf = fdt_add_subnode_namelen(fdt, node, namebuf,
+							  namelen);
+			if (next_lf < 0)
+				return next_lf;
+			if (next_lf <= ancestor) {
+				int delta = fdt_size_dt_struct(fdt) -
+					    old_struct_size;
+				target += delta;
+				ancestor += delta;
+			}
+		} else if (next_lf < 0) {
+			return next_lf;
+		}
+
+		cur_fdt = ancestor;
+		node = next_lf;
+	}
+
+	return node;
+}
+
+/**
+ * overlay_clear_overwritten_local_fixups - Remove local fixups for overwritten properties
+ * @fdt: Base Device Tree blob
+ * @target_lf: Node offset in @fdt's /__local_fixups__ subtree
+ * @fdto: Device tree overlay blob
+ * @overlay: Node offset in @fdto's __overlay__ subtree
+ *
+ * overlay_clear_overwritten_local_fixups() recursively deletes properties under
+ * @target_lf that correspond to properties overwritten by @overlay in @fdto.
+ *
+ * returns:
+ *      0 on success
+ *      Negative error code on failure
+ */
+static int overlay_clear_overwritten_local_fixups(void *fdt, int target_lf,
+						  const void *fdto, int overlay)
+{
+	int prop, subnode;
+
+	fdt_for_each_property_offset(prop, fdto, overlay) {
+		const char *name;
+		const void *val;
+		int len, ret;
+
+		val = fdt_getprop_by_offset(fdto, prop, &name, &len);
+		if (!val)
+			return len;
+
+		ret = fdt_delprop(fdt, target_lf, name);
+		if (ret && ret != -FDT_ERR_NOTFOUND)
+			return ret;
+	}
+
+	fdt_for_each_subnode(subnode, fdto, overlay) {
+		const char *name;
+		int namelen, sub_lf, ret;
+
+		name = fdt_get_name(fdto, subnode, &namelen);
+		if (!name)
+			return namelen;
+
+		sub_lf = fdt_subnode_offset_namelen(fdt, target_lf, name,
+						    namelen);
+		if (sub_lf == -FDT_ERR_NOTFOUND)
+			continue;
+		if (sub_lf < 0)
+			return sub_lf;
+
+		ret = overlay_clear_overwritten_local_fixups(fdt, sub_lf,
+							     fdto, subnode);
+		if (ret)
+			return ret;
+	}
+
+	return 0;
+}
+
+/**
+ * overlay_add_local_fixup_offset - Insert a sorted u32 offset into a local fixup property
+ * @fdt: Base Device Tree blob
+ * @lf_node: Node offset in @fdt's /__local_fixups__ subtree
+ * @name: Property name to update
+ * @name_len: Length of @name in bytes
+ * @poffset: Byte offset of the phandle reference within the target property
+ *
+ * overlay_add_local_fixup_offset() inserts @poffset in ascending sorted order
+ * (ignoring duplicates) into the u32 array property @name on @lf_node.
+ *
+ * returns:
+ *      0 on success
+ *      Negative error code on failure
+ */
+static int overlay_add_local_fixup_offset(void *fdt, int lf_node,
+					  const char *name, int name_len,
+					  uint32_t poffset)
+{
+	const fdt32_t *old_val;
+	fdt32_t *vals;
+	void *prop_data;
+	int old_len, count, i, idx, ret;
+
+	old_val = fdt_getprop_namelen(fdt, lf_node, name, name_len, &old_len);
+	if (!old_val && old_len != -FDT_ERR_NOTFOUND)
+		return old_len;
+
+	if (!old_val) {
+		old_len = 0;
+		count = 0;
+		idx = 0;
+	} else {
+		if (old_len % sizeof(uint32_t))
+			return -FDT_ERR_BADSTRUCTURE;
+		count = old_len / sizeof(uint32_t);
+		idx = count;
+		for (i = 0; i < count; i++) {
+			uint32_t cur = fdt32_ld(old_val + i);
+
+			if (cur == poffset)
+				return 0;
+			if (cur > poffset) {
+				idx = i;
+				break;
+			}
+		}
+	}
+
+	ret = fdt_setprop_placeholder_namelen(fdt, lf_node, name, name_len,
+					      old_len + sizeof(uint32_t),
+					      &prop_data);
+	if (ret)
+		return ret;
+
+	vals = prop_data;
+	if (idx < count)
+		memmove(vals + idx + 1, vals + idx,
+			(count - idx) * sizeof(uint32_t));
+	fdt32_st(vals + idx, poffset);
+
+	return 0;
+}
+
+/**
+ * overlay_merge_fixup_into_local_fixups - Record one /__fixups__ property for a fragment
+ * @fdt: Base Device Tree blob
+ * @fdto: Device tree overlay blob
+ * @property: Property offset in @fdto's /__fixups__ node
+ * @fragment: Node offset of the current fragment in @fdto
+ * @target_frag_name: Name of @fragment
+ * @target_frag_len: Length of @target_frag_name in bytes
+ * @target_lfp: In/out cached node offset of @fragment under @fdt's /__local_fixups__
+ *
+ * overlay_merge_fixup_into_local_fixups() parses all "path:prop:offset" entries
+ * in a single /__fixups__ property of @fdto and adds the resolved property
+ * byte offsets belonging to @fragment into @fdt's /__local_fixups__ subtree.
+ *
+ * returns:
+ *      0 on success
+ *      Negative error code on failure
+ */
+static int overlay_merge_fixup_into_local_fixups(void *fdt, const void *fdto,
+						 int property, int fragment,
+						 const char *target_frag_name,
+						 int target_frag_len,
+						 int *target_lfp)
+{
+	const char *value, *label;
+	int len;
+
+	value = fdt_getprop_by_offset(fdto, property, &label, &len);
+	if (!value)
+		return len;
+
+	do {
+		const char *path, *name, *frag_name, *rel_path;
+		uint32_t path_len, name_len, poffset;
+		int frag_name_len, rel_path_len, lf_node, ret;
+		bool is_overlay;
+
+		ret = overlay_parse_fixup_entry(&value, &len, &path, &path_len,
+						&name, &name_len, &poffset);
+		if (ret)
+			return ret;
+
+		ret = overlay_split_fragment_path(path, path_len,
+						  &frag_name, &frag_name_len,
+						  &rel_path, &rel_path_len,
+						  &is_overlay);
+		if (ret)
+			return ret;
+		if (!is_overlay)
+			continue;
+
+		if (frag_name_len != target_frag_len ||
+		    memcmp(frag_name, target_frag_name, frag_name_len) != 0)
+			continue;
+
+		if (*target_lfp < 0) {
+			*target_lfp = overlay_fragment_local_fixups(
+				fdt, fdto, fragment, true);
+			if (*target_lfp < 0)
+				return *target_lfp;
+		}
+
+		lf_node = overlay_create_path(fdt, *target_lfp, rel_path,
+					      rel_path_len);
+		if (lf_node < 0)
+			return lf_node;
+
+		ret = overlay_add_local_fixup_offset(fdt, lf_node, name,
+						     name_len, poffset);
+		if (ret)
+			return ret;
+	} while (len > 0);
+
+	return 0;
+}
+
+/**
+ * overlay_merge_fixups_into_local_fixups - Record resolved /__fixups__ for a fragment
+ * @fdt: Base Device Tree blob
+ * @fdto: Device tree overlay blob
+ * @fragment: Node offset of the current fragment in @fdto
+ * @frag_name: Name of @fragment
+ * @frag_name_len: Length of @frag_name in bytes
+ * @target_lfp: In/out cached node offset of @fragment under @fdt's /__local_fixups__
+ *
+ * overlay_merge_fixups_into_local_fixups() parses external phandle references
+ * belonging to @fragment in @fdto's /__fixups__ node and records their resolved
+ * property byte offsets in @fdt's /__local_fixups__ subtree.
+ *
+ * returns:
+ *      0 on success
+ *      Negative error code on failure
+ */
+static int overlay_merge_fixups_into_local_fixups(void *fdt, const void *fdto,
+						  int fragment,
+						  const char *frag_name,
+						  int frag_name_len,
+						  int *target_lfp)
+{
+	int fixups_off, prop;
+
+	fixups_off = fdt_subnode_offset(fdto, 0, "__fixups__");
+	if (fixups_off == -FDT_ERR_NOTFOUND)
+		return 0;
+	if (fixups_off < 0)
+		return fixups_off;
+
+	fdt_for_each_property_offset(prop, fdto, fixups_off) {
+		int ret;
+
+		ret = overlay_merge_fixup_into_local_fixups(
+			fdt, fdto, prop, fragment, frag_name, frag_name_len,
+			target_lfp);
+		if (ret)
+			return ret;
+	}
+
+	return 0;
+}
+
+/**
+ * overlay_prune_empty_local_fixups - Remove empty subnodes from /__local_fixups__
+ * @fdt: Base Device Tree blob
+ * @lf_node: Node offset in @fdt's /__local_fixups__ subtree
+ *
+ * overlay_prune_empty_local_fixups() recursively deletes any child subnodes
+ * under @lf_node that have neither properties nor subnodes remaining.
+ *
+ * returns:
+ *      0 on success
+ *      Negative error code on failure
+ */
+static int overlay_prune_empty_local_fixups(void *fdt, int lf_node)
+{
+	int subnode = fdt_first_subnode(fdt, lf_node);
+
+	while (subnode >= 0) {
+		int next, ret;
+
+		ret = overlay_prune_empty_local_fixups(fdt, subnode);
+		if (ret < 0)
+			return ret;
+
+		next = fdt_next_subnode(fdt, subnode);
+		if (fdt_first_property_offset(fdt, subnode) == -FDT_ERR_NOTFOUND &&
+		    fdt_first_subnode(fdt, subnode) == -FDT_ERR_NOTFOUND) {
+			int old_struct_size = fdt_size_dt_struct(fdt);
+
+			ret = fdt_del_node(fdt, subnode);
+			if (ret < 0)
+				return ret;
+			if (next >= 0)
+				next += fdt_size_dt_struct(fdt) -
+					old_struct_size;
+		}
+		subnode = next;
+	}
+
+	return 0;
+}
+
+/**
+ * overlay_validate_local_fixups - Validate /__local_fixups__ fragment nodes in overlay
+ * @fdto: Device tree overlay blob
+ * @ov_lf_root: Node offset of /__local_fixups__ in @fdto
+ *
+ * overlay_validate_local_fixups() verifies that every fragment subnode under
+ * @ov_lf_root that contains an __overlay__ subnode corresponds to a valid
+ * fragment node at the root of @fdto.
+ *
+ * returns:
+ *      0 on success
+ *      Negative error code on failure
+ */
+static int overlay_validate_local_fixups(const void *fdto, int ov_lf_root)
+{
+	int lf_frag;
+
+	fdt_for_each_subnode(lf_frag, fdto, ov_lf_root) {
+		const char *frag_name;
+		int frag_name_len, lf_overlay, fragment;
+
+		frag_name = fdt_get_name(fdto, lf_frag, &frag_name_len);
+		if (!frag_name)
+			return frag_name_len;
+
+		lf_overlay = fdt_subnode_offset(fdto, lf_frag, "__overlay__");
+		if (lf_overlay == -FDT_ERR_NOTFOUND)
+			continue;
+		if (lf_overlay < 0)
+			return lf_overlay;
+
+		fragment = fdt_subnode_offset_namelen(fdto, 0, frag_name,
+						      frag_name_len);
+		if (fragment < 0)
+			return -FDT_ERR_BADOVERLAY;
+
+		if (fdt_subnode_offset(fdto, fragment, "__overlay__") < 0)
+			return -FDT_ERR_BADOVERLAY;
+	}
+
+	return 0;
+}
+
+/**
+ * overlay_fragment_local_fixups_src - Find a fragment's __overlay__ in overlay /__local_fixups__
+ * @fdto: Device tree overlay blob
+ * @ov_lf_root: Node offset of /__local_fixups__ in @fdto (or -FDT_ERR_NOTFOUND)
+ * @frag_name: Name of the fragment node
+ * @frag_name_len: Length of @frag_name in bytes
+ *
+ * overlay_fragment_local_fixups_src() locates the __overlay__ subnode for
+ * @frag_name under @ov_lf_root in @fdto.
+ *
+ * returns:
+ *      Node offset of /__local_fixups__/<frag_name>/__overlay__ on success
+ *      -FDT_ERR_NOTFOUND if @ov_lf_root, <frag_name>, or __overlay__ is absent
+ *      Negative error code on other failures
+ */
+static int overlay_fragment_local_fixups_src(const void *fdto, int ov_lf_root,
+					     const char *frag_name,
+					     int frag_name_len)
+{
+	int lf_frag;
+
+	if (ov_lf_root < 0)
+		return -FDT_ERR_NOTFOUND;
+
+	lf_frag = fdt_subnode_offset_namelen(fdto, ov_lf_root, frag_name,
+					     frag_name_len);
+	if (lf_frag < 0)
+		return lf_frag;
+
+	return fdt_subnode_offset(fdto, lf_frag, "__overlay__");
+}
+
+/**
+ * overlay_fragment_update_local_fixups - Update base /__local_fixups__ for one fragment
+ * @fdt: Base Device Tree blob
+ * @fdto: Device tree overlay blob
+ * @fragment: Node offset of the fragment in @fdto
+ * @ov_lf_root: Node offset of /__local_fixups__ in @fdto (or -FDT_ERR_NOTFOUND)
+ *
+ * overlay_fragment_update_local_fixups() clears any base /__local_fixups__
+ * entries overwritten by @fragment, then merges phandle offsets for @fragment
+ * from @fdto's /__local_fixups__ and /__fixups__ subtrees.
+ *
+ * returns:
+ *      0 on success
+ *      Negative error code on failure
+ */
+static int overlay_fragment_update_local_fixups(void *fdt, const void *fdto,
+						int fragment, int ov_lf_root)
+{
+	const char *frag_name;
+	int frag_name_len, overlay, target_lf, lf_overlay, ret;
+
+	overlay = fdt_subnode_offset(fdto, fragment, "__overlay__");
+	if (overlay == -FDT_ERR_NOTFOUND)
+		return 0;
+	if (overlay < 0)
+		return overlay;
+
+	frag_name = fdt_get_name(fdto, fragment, &frag_name_len);
+	if (!frag_name)
+		return frag_name_len;
+
+	target_lf = overlay_fragment_local_fixups(fdt, fdto, fragment, false);
+	if (target_lf < 0 && target_lf != -FDT_ERR_NOTFOUND)
+		return target_lf;
+
+	if (target_lf >= 0) {
+		ret = overlay_clear_overwritten_local_fixups(fdt, target_lf,
+							     fdto, overlay);
+		if (ret)
+			return ret;
+	}
+
+	lf_overlay = overlay_fragment_local_fixups_src(fdto, ov_lf_root,
+						       frag_name,
+						       frag_name_len);
+	if (lf_overlay < 0 && lf_overlay != -FDT_ERR_NOTFOUND)
+		return lf_overlay;
+
+	if (lf_overlay >= 0) {
+		if (target_lf < 0)
+			target_lf = overlay_fragment_local_fixups(fdt, fdto,
+								  fragment,
+								  true);
+		if (target_lf < 0)
+			return target_lf;
+
+		ret = overlay_apply_node(fdt, target_lf, fdto, lf_overlay);
+		if (ret)
+			return ret;
+	}
+
+	return overlay_merge_fixups_into_local_fixups(
+		fdt, fdto, fragment, frag_name, frag_name_len, &target_lf);
+}
+
+/**
+ * overlay_local_fixups_update - Update base /__local_fixups__ with overlay fixups
+ * @fdt: Base Device Tree blob
+ * @fdto: Device tree overlay blob
+ *
+ * overlay_local_fixups_update() updates the base device tree's
+ * /__local_fixups__ subtree (when present) by walking each overlay fragment in
+ * application order, clearing entries for properties overwritten by that
+ * fragment, and merging phandle reference offsets from @fdto's
+ * /__local_fixups__ and /__fixups__ subtrees.
+ *
+ * This is part of the device tree overlay application process, when you want
+ * the merged device tree to retain complete local phandle fixup metadata.
+ *
+ * returns:
+ *      0 on success
+ *      Negative error code on failure
+ */
+static int overlay_local_fixups_update(void *fdt, const void *fdto)
+{
+	int root_lf, ov_lf_root, fragment, ret;
+
+	root_lf = fdt_subnode_offset(fdt, 0, "__local_fixups__");
+	if (root_lf == -FDT_ERR_NOTFOUND)
+		return 0;
+	if (root_lf < 0)
+		return root_lf;
+
+	ov_lf_root = fdt_subnode_offset(fdto, 0, "__local_fixups__");
+	if (ov_lf_root >= 0) {
+		ret = overlay_validate_local_fixups(fdto, ov_lf_root);
+		if (ret)
+			return ret;
+	} else if (ov_lf_root != -FDT_ERR_NOTFOUND) {
+		return ov_lf_root;
+	}
+
+	/*
+	 * Process fragments in application order so that if a later fragment in
+	 * fdto overwrites a property modified by an earlier fragment, the
+	 * earlier fragment's local fixups for that property are cleared first.
+	 */
+	fdt_for_each_subnode(fragment, fdto, 0) {
+		ret = overlay_fragment_update_local_fixups(fdt, fdto, fragment,
+							   ov_lf_root);
+		if (ret)
+			return ret;
+	}
+
+	root_lf = fdt_subnode_offset(fdt, 0, "__local_fixups__");
+	if (root_lf < 0)
+		return root_lf;
+
+	return overlay_prune_empty_local_fixups(fdt, root_lf);
+}
+
 int fdt_overlay_apply(void *fdt, void *fdto)
 {
 	uint32_t delta;
@@ -1162,6 +1771,10 @@ int fdt_overlay_apply(void *fdt, void *fdto)
 	if (ret)
 		goto err;
 
+	ret = overlay_local_fixups_update(fdt, fdto);
+	if (ret)
+		goto err;
+
 	/*
 	 * The overlay has been damaged, erase its magic.
 	 */
diff --git a/tests/run_tests.sh b/tests/run_tests.sh
index 842b543059bb..e232c86aab37 100755
--- a/tests/run_tests.sh
+++ b/tests/run_tests.sh
@@ -1089,6 +1089,25 @@ fdtoverlay_tests() {
 
     shorten_echo "check phandle to noda d wasn't overwritten:	"
     run_wrap_test test "$bd" = "$pd"
+
+    # verify that /__local_fixups__ in a -L base blob is updated when applying an overlay
+    run_dtc_test -@ -L -I dts -O dtb -o overlay_base_phandle_lf.test.dtb "$SRCDIR/overlay_base_phandle.dts"
+    run_wrap_test $FDTOVERLAY -i overlay_base_phandle_lf.test.dtb -o overlay_base_phandle_lfO.test.dtb overlay_overlay_phandle.test.dtb
+    run_fdtget_test 0 overlay_base_phandle_lfO.test.dtb /__local_fixups__/b a
+    run_fdtget_test 0 overlay_base_phandle_lfO.test.dtb /__local_fixups__/b d
+    run_fdtget_test 0 overlay_base_phandle_lfO.test.dtb /__local_fixups__/c b
+    run_fdtget_test 0 overlay_base_phandle_lfO.test.dtb /__local_fixups__/d a
+    run_fdtget_test 0 overlay_base_phandle_lfO.test.dtb /__local_fixups__/d b
+    run_fdtget_test 0 overlay_base_phandle_lfO.test.dtb /__local_fixups__/d c
+
+    run_dtc_test -@ -L -I dts -O dtb -o overlay_base_lf.test.dtb "$SRCDIR/overlay_base.dts"
+    run_wrap_test $DTPUT overlay_base_lf.test.dtb -cp /__local_fixups__/test-node/sub-test-node
+    run_wrap_test $DTPUT overlay_base_lf.test.dtb /__local_fixups__/test-node/sub-test-node new-sub-test-property -tu 0
+    run_dtc_test -@ -I dts -O dtb -o overlay_overlay.fdtoverlay.test.dtb "$SRCDIR/overlay_overlay.dts"
+    run_wrap_test $FDTOVERLAY -i overlay_base_lf.test.dtb -o overlay_base_lfO.test.dtb overlay_overlay.fdtoverlay.test.dtb
+    run_fdtget_test "0 4" overlay_base_lfO.test.dtb /__local_fixups__/test-node test-phandle
+    run_fdtget_test "0 4" overlay_base_lfO.test.dtb /__local_fixups__/test-node test-several-phandle
+    run_test check_path overlay_base_lfO.test.dtb not-exists "/__local_fixups__/test-node/sub-test-node"
 }
 
 pylibfdt_tests () {
-- 
2.56.0.313.g2479322e68-goog


^ permalink raw reply	[flat|nested] 12+ messages in thread

* [PATCH v2 07/10] fdtfactor: Add tool to split DTBs into a base DTB and overlays
  2026-10-28 21:46 [PATCH v2 00/10] fdtfactor: Introduce fdtfactor to auto-factor overlays Douglas Anderson
                   ` (5 preceding siblings ...)
  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
  2026-10-28 21:46 ` [PATCH v2 08/10] fdtfactor: Add -S/--strip-local-fixups option Douglas Anderson
                   ` (2 subsequent siblings)
  9 siblings, 0 replies; 12+ messages in thread
From: Douglas Anderson @ 2026-10-28 21:46 UTC (permalink / raw)
  To: Devicetree-compiler, Rob Herring, Krzysztof Kozlowski,
	Conor Dooley, Konrad Dybcio
  Cc: Brian Norris, Chen-Yu Tsai, André Draszik, Devicetree,
	Simon Glass, David Gibson, Tudor Ambarus, linux-kernel,
	Julius Werner, Saravana Kannan, Peter Griffin, Douglas Anderson

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


^ permalink raw reply	[flat|nested] 12+ messages in thread

* [PATCH v2 08/10] fdtfactor: Add -S/--strip-local-fixups option
  2026-10-28 21:46 [PATCH v2 00/10] fdtfactor: Introduce fdtfactor to auto-factor overlays Douglas Anderson
                   ` (6 preceding siblings ...)
  2026-10-28 21:46 ` [PATCH v2 07/10] fdtfactor: Add tool to split DTBs into a base DTB and overlays Douglas Anderson
@ 2026-10-28 21:46 ` 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
  9 siblings, 0 replies; 12+ messages in thread
From: Douglas Anderson @ 2026-10-28 21:46 UTC (permalink / raw)
  To: Devicetree-compiler, Rob Herring, Krzysztof Kozlowski,
	Conor Dooley, Konrad Dybcio
  Cc: Brian Norris, Chen-Yu Tsai, André Draszik, Devicetree,
	Simon Glass, David Gibson, Tudor Ambarus, linux-kernel,
	Julius Werner, Saravana Kannan, Peter Griffin, Douglas Anderson

By default, `fdtfactor` preserves `/__local_fixups__` on `base.dtb` so
that when `fdt_overlay_apply()` merges a `.dtbo` overlay, the
reconstructed device tree retains full `/__local_fixups__` information
(for instance, if an OS kernel wants to know which property cells
contain phandles at runtime).

When `-L` was passed to `dtc` solely to help `fdtfactor` locate phandle
cells at build time and `/__local_fixups__` is not needed at runtime,
retaining `/__local_fixups__` in `base.dtb` wastes space.

Add `-S` / `--strip-local-fixups` to omit `/__local_fixups__` from
`base.dtb` (and therefore from the merged device trees produced by
`fdt_overlay_apply()`). On a set of 47 Qualcomm SC7180 Trogdor
Chromebook board DTBs, `fdtfactor -S` reduces total uncompressed size
from 7.69 MB to 634 KB (91.8% savings).

Add a test case in `tests/run_tests.sh` verifying that `fdtfactor -S`
followed by `fdtoverlay` reproduces DTBs compiled with `-@` (without
`-L`) via `dtbs_equal_unordered`.

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

Changes in v2:
- Split -S/--strip-local-fixups into a separate opt-in patch (David
  Gibson)

 README.md          |  9 +++++++++
 fdtfactor          | 34 ++++++++++++++++++++++++++--------
 tests/run_tests.sh | 22 ++++++++++++++++++++++
 3 files changed, 57 insertions(+), 8 deletions(-)

diff --git a/README.md b/README.md
index 446135e7c02c..9b2115ebbc67 100644
--- a/README.md
+++ b/README.md
@@ -118,6 +118,15 @@ Options:
 * `-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`, `--strip-local-fixups`: Omit `/__local_fixups__` from the generated
+  `base.dtb` and final reconstructed device trees. By default, `fdtfactor`
+  preserves `/__local_fixups__` on `base.dtb` so that when `fdt_overlay_apply()`
+  merges a `.dtbo` overlay, the reconstructed device tree retains full
+  `/__local_fixups__` information (e.g., for an OS kernel that wants to
+  know which property cells contain phandles at runtime). When `-L` was
+  passed to `dtc` solely to help `fdtfactor` locate phandles at build time
+  and `/__local_fixups__` is not needed at runtime, passing `-S` strips it
+  from `base.dtb` to save space.
 * `-s`, `--stats`: Print size savings summary.
 
 ### TODO
diff --git a/fdtfactor b/fdtfactor
index f1a5b35d5ee9..ebe1e3d44382 100755
--- a/fdtfactor
+++ b/fdtfactor
@@ -424,7 +424,8 @@ def _build_base_tree(dtbs):
     return base_root
 
 
-def _serialize_base_dtb(base_root, base_dtb_symbols, rsvmap, boot_cpuid_phys):
+def _serialize_base_dtb(base_root, base_dtb_symbols, rsvmap, boot_cpuid_phys,
+                        strip_local_fixups=False):
     """Materialize phandles, `/__symbols__`, and `/__local_fixups__` and flatten.
 
     Args:
@@ -433,6 +434,8 @@ def _serialize_base_dtb(base_root, base_dtb_symbols, rsvmap, boot_cpuid_phys):
         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.
+        strip_local_fixups: If True, omit `/__local_fixups__` from the
+            generated `base.dtb`.
 
     Returns:
         Serialized `.dtb` `bytes` for the common base DTB.
@@ -462,11 +465,12 @@ def _serialize_base_dtb(base_root, base_dtb_symbols, rsvmap, boot_cpuid_phys):
         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)
+    if not strip_local_fixups:
+        # 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)
 
@@ -610,11 +614,13 @@ def _build_board_dtbo(dtb, base_root, base_dtb_symbols,
     return dtbo_root.flatten()
 
 
-def create_base_dtb_and_overlays(dtbs):
+def create_base_dtb_and_overlays(dtbs, strip_local_fixups=False):
     """Compute the base DTB and per-board DTBO blobs from parsed DTBs.
 
     Args:
         dtbs: Sequence of `ParsedDtb` instances (at least two).
+        strip_local_fixups: If True, omit `/__local_fixups__` from the
+            generated `base.dtb`.
 
     Returns:
         Tuple of `(base_dtb_blob, dtbo_blobs)` where `base_dtb_blob` is the
@@ -629,6 +635,7 @@ def create_base_dtb_and_overlays(dtbs):
     base_dtb_blob = _serialize_base_dtb(
         base_root, base_dtb_symbols,
         dtbs[0].mem_rsv, dtbs[0].boot_cpuid_phys,
+        strip_local_fixups=strip_local_fixups,
     )
     dtbo_blobs = [
         _build_board_dtbo(
@@ -691,6 +698,14 @@ def parse_args(argv=None):
             'for --base-dtb)'
         ),
     )
+    parser.add_argument(
+        '-S', '--strip-local-fixups',
+        action='store_true',
+        help=(
+            'Omit /__local_fixups__ from the generated base .dtb and '
+            'reconstructed device trees'
+        ),
+    )
     parser.add_argument(
         '-s', '--stats',
         action='store_true',
@@ -775,7 +790,10 @@ def factor_dtb_files(args):
         seen_paths.add(abs_out)
         dtbo_paths.append(out_path)
 
-    base_dtb_blob, dtbo_blobs = create_base_dtb_and_overlays(dtbs)
+    base_dtb_blob, dtbo_blobs = create_base_dtb_and_overlays(
+        dtbs,
+        strip_local_fixups=args.strip_local_fixups,
+    )
 
     if args.outdir:
         os.makedirs(args.outdir, exist_ok=True)
diff --git a/tests/run_tests.sh b/tests/run_tests.sh
index e012aff2193b..b0c621e5d1c3 100755
--- a/tests/run_tests.sh
+++ b/tests/run_tests.sh
@@ -1184,6 +1184,28 @@ fdtfactor_tests () {
     run_wrap_error_test $PYTHON $FDTFACTOR -o fdtfactor_legacy.test.dir \
 	fdtfactor_out.test.dir/fdtfactor_board1.test.dtbo \
 	fdtfactor_board2.test.dtb
+
+    # Strip local fixups (-S): verify that merged DTBs match DTBs compiled
+    # with -@ (without -L), and that inputs lacking /__local_fixups__ are
+    # rejected.
+    for i in 1 2 3; do
+	run_dtc_test -b 2 -@ -I dts -O dtb -o fdtfactor_board${i}.nolf.test.dtb \
+	    "$SRCDIR/fdtfactor_board${i}.dts"
+    done
+    run_wrap_error_test $PYTHON $FDTFACTOR -o fdtfactor_strip.test.dir \
+	fdtfactor_board1.nolf.test.dtb fdtfactor_board2.test.dtb
+    rm -rf fdtfactor_strip.test.dir
+    run_wrap_test $PYTHON $FDTFACTOR -S -b base_strip.test.dtb \
+	-o fdtfactor_strip.test.dir \
+	fdtfactor_board1.test.dtb fdtfactor_board2.test.dtb \
+	fdtfactor_board3.test.dtb
+    for i in 1 2 3; do
+	run_wrap_test $FDTOVERLAY -i fdtfactor_strip.test.dir/base_strip.test.dtb \
+	    -o fdtfactor_strip.test.dir/merged${i}.test.dtb \
+	    fdtfactor_strip.test.dir/fdtfactor_board${i}.test.dtbo
+	run_test dtbs_equal_unordered fdtfactor_board${i}.nolf.test.dtb \
+	    fdtfactor_strip.test.dir/merged${i}.test.dtb
+    done
 }
 
 while getopts "vt:me" ARG ; do
-- 
2.56.0.313.g2479322e68-goog


^ permalink raw reply	[flat|nested] 12+ messages in thread

* [PATCH v2 09/10] fdtfactor: Support compressed DTB inputs and outputs
  2026-10-28 21:46 [PATCH v2 00/10] fdtfactor: Introduce fdtfactor to auto-factor overlays Douglas Anderson
                   ` (7 preceding siblings ...)
  2026-10-28 21:46 ` [PATCH v2 08/10] fdtfactor: Add -S/--strip-local-fixups option Douglas Anderson
@ 2026-10-28 21:46 ` Douglas Anderson
  2026-10-28 21:46 ` [PATCH v2 10/10] fdtfactor: Add compiled FIT image factoring support Douglas Anderson
  9 siblings, 0 replies; 12+ messages in thread
From: Douglas Anderson @ 2026-10-28 21:46 UTC (permalink / raw)
  To: Devicetree-compiler, Rob Herring, Krzysztof Kozlowski,
	Conor Dooley, Konrad Dybcio
  Cc: Brian Norris, Chen-Yu Tsai, André Draszik, Devicetree,
	Simon Glass, David Gibson, Tudor Ambarus, linux-kernel,
	Julius Werner, Saravana Kannan, Peter Griffin, Douglas Anderson

Firmware and FIT image build pipelines frequently store device tree
blobs compressed with LZ4, LZMA/XZ, or gzip (e.g., `.dtb.lz4`).

Extend `fdtfactor` to detect compressed input blobs by magic bytes or
file extension, transparently decompress them before factoring, and
compress the resulting `.dtbo` and `base.dtb` outputs when appropriate:

- Support `.lz4` (including legacy `-l` frames via the `lz4` CLI),
  `.lzma`/`.xz` (via Python's `lzma` module, using `CHECK_CRC32` for XZ
  compatibility with kernel/bootloader decompressors), and `.gz` (via
  Python's `gzip` module).
- Preserve the input compression format on generated `.dtbo` files and
  compress `base.dtb` according to `--base-compression` (which defaults
  to `auto`, auto-detecting from `--base-dtb`'s filename extension such
  as `-b base.dtb.lz4`).
- Report compressed size savings alongside uncompressed savings when
  `-s`/`--stats` is specified.

On the 47 Qualcomm SC7180 Trogdor Chromebook `.dtb.lz4` files,
`fdtfactor -S` reduces total LZ4-compressed size from 1.71 MB to 213 KB
(87.5% savings).

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

Changes in v2:
- Add --base-compression to explicitly control base.dtb compression
  while documenting auto-detection from --base-dtb's filename suffix
  (David Gibson)
- Support legacy LZ4 frames (lz4 -l) and use CRC32 check for XZ outputs
  (Simon Glass)

 README.md            |  17 ++-
 fdtfactor            | 312 +++++++++++++++++++++++++++++++++++++++----
 tests/Makefile.tests |   5 +-
 tests/run_tests.sh   |  77 ++++++++++-
 4 files changed, 379 insertions(+), 32 deletions(-)

diff --git a/README.md b/README.md
index 9b2115ebbc67..6b5ad32f474b 100644
--- a/README.md
+++ b/README.md
@@ -94,10 +94,10 @@ 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.
+compiled device tree blobs (`.dtb`, or compressed `.dtb.lz4`, `.dtb.xz`,
+`.dtb.lzma`, `.dtb.gz`) 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`)
@@ -114,7 +114,12 @@ Options:
 
 * `-b`, `--base-dtb`: Output filename for the common base `.dtb`
   (default: `base.dtb`; placed in `--outdir` if specified, or the current
-  directory otherwise).
+  directory otherwise). By default (`--base-compression=auto`), `base.dtb`
+  is compressed if its filename carries a `.lz4`, `.xz`, `.lzma`, or `.gz`
+  suffix, whereas each `.dtbo` preserves its input `.dtb`'s compression.
+* `--base-compression`: Compression format for `--base-dtb`: `auto`,
+  `none`, `lz4`, `lz4_legacy`, `lzma`, `xz`, or `gzip` (default: `auto`,
+  which auto-detects from the `--base-dtb` filename suffix).
 * `-o`, `--outdir`: Output directory for generated `.dtbo` files and
   `--base-dtb` (default: alongside each input `.dtb` for `.dtbo` files,
   current directory for `--base-dtb`).
@@ -127,7 +132,7 @@ Options:
   passed to `dtc` solely to help `fdtfactor` locate phandles at build time
   and `/__local_fixups__` is not needed at runtime, passing `-S` strips it
   from `base.dtb` to save space.
-* `-s`, `--stats`: Print size savings summary.
+* `-s`, `--stats`: Print uncompressed and compressed size savings.
 
 ### TODO
 
diff --git a/fdtfactor b/fdtfactor
index ebe1e3d44382..ab261765bb29 100755
--- a/fdtfactor
+++ b/fdtfactor
@@ -12,13 +12,214 @@ original board DTB.
 
 import argparse
 from collections import Counter
+import gzip
+import lzma
 import os
 import struct
+import subprocess
 import sys
 
 import libfdt
 
 
+FDT_MAGIC = b'\xd0\x0d\xfe\xed'
+LZ4_MAGIC = b'\x04\x22\x4d\x18'
+LZ4_LEGACY_MAGIC = b'\x02\x21\x4c\x18'
+GZIP_MAGIC = b'\x1f\x8b'
+XZ_MAGIC = b'\xfd7zXZ\x00'
+# Matches raw LZMA_ALONE headers with default lc=3, lp=0, pb=2 (0x5d) and
+# power-of-two dictionary sizes >= 64 KiB; .lzma extension handles any
+# non-default LZMA headers.
+LZMA_ALONE_MAGIC = b'\x5d\x00\x00'
+
+COMP_TO_EXT = {
+    'none': '',
+    'lz4': '.lz4',
+    'lz4_legacy': '.lz4',
+    'lzma': '.lzma',
+    'xz': '.xz',
+    'gzip': '.gz',
+}
+
+EXT_TO_COMP = {
+    '.lz4': 'lz4',
+    '.lzma': 'lzma',
+    '.xz': 'xz',
+    '.gz': 'gzip',
+}
+
+
+def detect_compression(raw_bytes, filename=''):
+    """Detect compression algorithm from magic bytes or filename extension.
+
+    Args:
+        raw_bytes: Raw file bytes to inspect for magic headers.
+        filename: Optional filename string used as a fallback if `raw_bytes`
+            does not match a known magic header.
+
+    Returns:
+        Compression format string (`'none'`, `'lz4'`, `'lz4_legacy'`, `'lzma'`,
+        `'xz'`, or `'gzip'`).
+    """
+    if raw_bytes.startswith(FDT_MAGIC):
+        return 'none'
+    if raw_bytes.startswith(LZ4_MAGIC):
+        return 'lz4'
+    if raw_bytes.startswith(LZ4_LEGACY_MAGIC):
+        return 'lz4_legacy'
+    if raw_bytes.startswith(GZIP_MAGIC):
+        return 'gzip'
+    if raw_bytes.startswith(XZ_MAGIC):
+        return 'xz'
+    if raw_bytes.startswith(LZMA_ALONE_MAGIC):
+        return 'lzma'
+    _, ext = os.path.splitext(filename)
+    if ext in EXT_TO_COMP:
+        return EXT_TO_COMP[ext]
+    return 'none'
+
+
+def _run_lz4(args, input_bytes, action):
+    """Run the `lz4` CLI with `args` on `input_bytes` and return stdout.
+
+    Args:
+        args: List of command-line arguments to pass to `lz4`.
+        input_bytes: Bytes to feed to `lz4` standard input.
+        action: Human-readable action description for error messages.
+
+    Returns:
+        Output `bytes` written to `lz4` standard output.
+    """
+    try:
+        res = subprocess.run(
+            ['lz4', *args],
+            input=input_bytes,
+            capture_output=True,
+            check=False,
+        )
+    except FileNotFoundError as exc:
+        raise RuntimeError(
+            'lz4 command not found; install lz4 to process .lz4 files'
+        ) from exc
+    if res.returncode != 0:
+        err = res.stderr.decode('utf-8', errors='replace').strip()
+        raise RuntimeError(f'Failed to {action} with lz4: {err}')
+    return res.stdout
+
+
+def decompress_blob(raw_bytes, filename=''):
+    """Decompress a DTB blob if compressed, returning `(raw_bytes, comp)`.
+
+    Args:
+        raw_bytes: Input FDT `bytes` (compressed or uncompressed).
+        filename: Optional filename for error reporting and extension fallback.
+
+    Returns:
+        Tuple of `(uncompressed_bytes, resolved_compression_type)`.
+    """
+    comp = detect_compression(raw_bytes, filename)
+    if comp == 'none':
+        return raw_bytes, 'none'
+    if comp in ('lz4', 'lz4_legacy'):
+        return _run_lz4(['-d', '-q', '-c'], raw_bytes, f'decompress {filename}'), comp
+    if comp == 'gzip':
+        try:
+            return gzip.decompress(raw_bytes), 'gzip'
+        except Exception as exc:
+            raise RuntimeError(
+                f'Failed to decompress {filename} with gzip: {exc}'
+            ) from exc
+    if comp in ('lzma', 'xz'):
+        try:
+            return lzma.decompress(raw_bytes), comp
+        except Exception as exc:
+            raise RuntimeError(
+                f'Failed to decompress {filename} with {comp}: {exc}'
+            ) from exc
+    prefix = f'{filename}: ' if filename else ''
+    raise ValueError(f'{prefix}Unsupported compression type: {comp}')
+
+
+def compress_blob(raw_bytes, comp, filename=''):
+    """Compress an FDT blob using the specified compression algorithm.
+
+    Args:
+        raw_bytes: Uncompressed FDT `bytes`.
+        comp: Compression format (`'none'`, `'lz4'`, `'lz4_legacy'`, `'lzma'`,
+            `'xz'`, or `'gzip'`).
+        filename: Optional filename for error messages.
+
+    Returns:
+        Compressed (or uncompressed if `comp` is `'none'`) `bytes`.
+    """
+    if comp in ('none', '', None):
+        return raw_bytes
+    target = filename or 'blob'
+    if comp == 'lz4':
+        return _run_lz4(
+            ['-12', '-z', '-q', '-c'], raw_bytes, f'compress {target}'
+        )
+    if comp == 'lz4_legacy':
+        return _run_lz4(
+            ['-l', '-12', '-q', '-c'], raw_bytes, f'compress {target}'
+        )
+    if comp == 'gzip':
+        return gzip.compress(raw_bytes, mtime=0)
+    if comp == 'lzma':
+        return lzma.compress(raw_bytes, format=lzma.FORMAT_ALONE)
+    if comp == 'xz':
+        return lzma.compress(
+            raw_bytes, format=lzma.FORMAT_XZ, check=lzma.CHECK_CRC32
+        )
+    prefix = f'{filename}: ' if filename else ''
+    raise ValueError(f'{prefix}Unsupported compression type: {comp}')
+
+
+def split_dtb_filename(path):
+    """Split a DTB path into `(directory, stem, compression_type)`.
+
+    Strips a recognized compression suffix (`.lz4`, `.lzma`, `.xz`, `.gz`), if
+    present, followed by a `.dtb` or `.dtbo` suffix, if present. Note that for
+    input files, `ParsedDtb` detects the actual compression format from the
+    file's magic bytes via `decompress_blob()` rather than relying on the
+    filename extension; `split_dtb_filename()` is used to derive the output
+    `.dtbo` filename stem and (when `--base-compression=auto`) to infer the
+    default compression format for `--base-dtb` from its output filename.
+
+    Examples:
+        >>> split_dtb_filename('/path/to/board.dtb')
+        ('/path/to', 'board', 'none')
+        >>> split_dtb_filename('board.dtbo')
+        ('', 'board', 'none')
+        >>> split_dtb_filename('/path/to/board.dtb.lz4')
+        ('/path/to', 'board', 'lz4')
+        >>> split_dtb_filename('base.dtb.xz')
+        ('', 'base', 'xz')
+        >>> split_dtb_filename('/path/to/board.rev2.dtb.gz')
+        ('/path/to', 'board.rev2', 'gzip')
+        >>> split_dtb_filename('/path/to/board.custom.lzma')
+        ('/path/to', 'board.custom', 'lzma')
+        >>> split_dtb_filename('my_blob')
+        ('', 'my_blob', 'none')
+
+    Args:
+        path: Input or output DTB file path string.
+
+    Returns:
+        Tuple of `(directory, stem, compression_type)`.
+    """
+    directory, filename = os.path.split(path)
+    comp = 'none'
+    prefix, sep, ext = filename.rpartition('.')
+    if prefix and sep and f'.{ext}' in EXT_TO_COMP:
+        comp = EXT_TO_COMP[f'.{ext}']
+        filename = prefix
+    stem, sep, ext = filename.rpartition('.')
+    if not stem or not sep or ext not in ('dtb', 'dtbo'):
+        stem = filename
+    return directory, stem, comp
+
+
 def _is_dtbo_overlay(fdt):
     """Return True if `fdt` is a `.dtbo` overlay rather than a base DTB.
 
@@ -58,14 +259,16 @@ class ParsedDtb:
     """
 
     def __init__(self, filename):
-        """Parse an uncompressed FDT file into a normalized NodeTree.
+        """Parse an input FDT file into a normalized NodeTree.
 
         Args:
-            filename: Path to the input .dtb file.
+            filename: Path to the input .dtb (or compressed .dtb.*) file.
         """
         self.filename = filename
         with open(filename, 'rb') as f:
-            raw_bytes = f.read()
+            file_bytes = f.read()
+        self.compressed_size = len(file_bytes)
+        raw_bytes, self.compression = decompress_blob(file_bytes, filename)
         self.raw_bytes = raw_bytes
 
         try:
@@ -646,21 +849,23 @@ def create_base_dtb_and_overlays(dtbs, strip_local_fixups=False):
     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.
+def get_dtbo_output_path(dtb_path, outdir, comp='none'):
+    """Determine the output `.dtbo[.comp]` file path for an input `.dtb` path.
 
     Args:
-        dtb_path: Path to the input `.dtb` file.
+        dtb_path: Path to the input `.dtb` (or `.dtb.*`) file.
         outdir: Output directory path, or `None` to place the `.dtbo` alongside
             `dtb_path`.
+        comp: Compression format string for the output `.dtbo` (default:
+            `'none'`).
 
     Returns:
-        Output `.dtbo` file path string.
+        Output `.dtbo` (or compressed `.dtbo.*`) file path string.
     """
-    dtb_dir, filename = os.path.split(dtb_path)
-    stem = filename[:-4] if filename.endswith('.dtb') else filename
+    dtb_dir, stem, _ = split_dtb_filename(dtb_path)
+    dtbo_name = f'{stem}.dtbo{COMP_TO_EXT[comp]}'
     target_dir = outdir if outdir is not None else dtb_dir
-    return os.path.join(target_dir, f'{stem}.dtbo')
+    return os.path.join(target_dir, dtbo_name)
 
 
 def parse_args(argv=None):
@@ -675,8 +880,9 @@ def parse_args(argv=None):
     """
     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).'
+            'Split a set of compiled device tree blobs (.dtb, or compressed '
+            '.dtb.lz4/.xz/.lzma/.gz) into a single common base .dtb and '
+            'per-board overlay blobs (.dtbo).'
         )
     )
     parser.add_argument(
@@ -684,8 +890,18 @@ def parse_args(argv=None):
         default='base.dtb',
         metavar='FILENAME',
         help=(
-            'Output common base .dtb filename (default: base.dtb; placed in '
-            '--outdir if specified, or current directory otherwise)'
+            'Output common base .dtb (or .dtb.lz4/.xz/.lzma/.gz) filename '
+            '(default: base.dtb; placed in --outdir if specified, or current '
+            'directory otherwise)'
+        ),
+    )
+    parser.add_argument(
+        '--base-compression',
+        choices=('auto', 'none', 'lz4', 'lz4_legacy', 'lzma', 'xz', 'gzip'),
+        default='auto',
+        help=(
+            'Compression format for --base-dtb (default: auto, which '
+            'auto-detects from the --base-dtb filename extension)'
         ),
     )
     parser.add_argument(
@@ -709,13 +925,16 @@ def parse_args(argv=None):
     parser.add_argument(
         '-s', '--stats',
         action='store_true',
-        help='Print size statistics summary',
+        help='Print size statistics and compression summary',
     )
     parser.add_argument(
         'dtbs',
         nargs='+',
         metavar='DTB',
-        help='Input .dtb files (at least two required)',
+        help=(
+            'Input .dtb (or compressed .dtb.lz4/.xz/.lzma/.gz) files (at '
+            'least two required)'
+        ),
     )
     args = parser.parse_args(argv)
     if os.path.dirname(args.base_dtb):
@@ -728,13 +947,17 @@ def parse_args(argv=None):
     return args
 
 
-def _print_dtb_stats(dtbs, base_dtb_blob, dtbo_blobs):
-    """Print uncompressed size savings summary.
+def _print_dtb_stats(dtbs, base_dtb_blob, dtbo_blobs, base_dtb_comp='none',
+                     base_dtb_csize=None, dtbo_csizes=None):
+    """Print uncompressed and compressed 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`.
+        base_dtb_comp: Compression format of the base DTB output.
+        base_dtb_csize: Byte size of the compressed base DTB output.
+        dtbo_csizes: Sequence of byte sizes of the compressed `.dtbo` outputs.
     """
     orig_uncomp = sum(len(d.raw_bytes) for d in dtbs)
     base_dtb_uncomp = len(base_dtb_blob)
@@ -748,6 +971,25 @@ def _print_dtb_stats(dtbs, base_dtb_blob, dtbo_blobs):
         f'{dtbo_uncomp:,} B = {new_uncomp:,} B '
         f'(saved {saved_uncomp:,} B / {pct_uncomp:.1f}%)'
     )
+    all_comps = {base_dtb_comp, *(d.compression for d in dtbs)}
+    if any(c != 'none' for c in all_comps):
+        comp_label = '/'.join(sorted(all_comps))
+        hdr = (
+            f'Compressed ({comp_label}):'
+            if len(all_comps) == 1
+            else f'On-disk ({comp_label}):   '
+        )
+        orig_comp = sum(d.compressed_size for d in dtbs)
+        dtbo_csize = sum(dtbo_csizes)
+        new_comp = base_dtb_csize + dtbo_csize
+        saved_comp = orig_comp - new_comp
+        pct_comp = (saved_comp / orig_comp * 100.0) if orig_comp else 0.0
+        print(
+            f'{hdr} {len(dtbs)} files ({orig_comp:,} B) -> '
+            f'base {base_dtb_csize:,} B + {len(dtbo_blobs)} overlays '
+            f'{dtbo_csize:,} B (avg {dtbo_csize // len(dtbo_blobs):,} B) = '
+            f'{new_comp:,} B (saved {saved_comp:,} B / {pct_comp:.1f}%)'
+        )
 
 
 def factor_dtb_files(args):
@@ -764,6 +1006,13 @@ def factor_dtb_files(args):
         if args.outdir is not None
         else args.base_dtb
     )
+    if args.base_compression == 'auto':
+        _, _, base_dtb_comp = split_dtb_filename(base_dtb_path)
+        if (base_dtb_comp == 'lz4' and
+                all(d.compression == 'lz4_legacy' for d in dtbs)):
+            base_dtb_comp = 'lz4_legacy'
+    else:
+        base_dtb_comp = args.base_compression
 
     input_abs_paths = {os.path.abspath(p) for p in input_dtb_paths}
     base_abs_path = os.path.abspath(base_dtb_path)
@@ -775,8 +1024,10 @@ def factor_dtb_files(args):
 
     dtbo_paths = []
     seen_paths = {base_abs_path}
-    for dtb_path in input_dtb_paths:
-        out_path = get_dtbo_output_path(dtb_path, args.outdir)
+    for dtb_path, d in zip(input_dtb_paths, dtbs):
+        out_path = get_dtbo_output_path(
+            dtb_path, args.outdir, comp=d.compression
+        )
         abs_out = os.path.abspath(out_path)
         if abs_out in input_abs_paths:
             raise ValueError(
@@ -795,20 +1046,33 @@ def factor_dtb_files(args):
         strip_local_fixups=args.strip_local_fixups,
     )
 
+    base_dtb_file_bytes = compress_blob(
+        base_dtb_blob, base_dtb_comp, filename=base_dtb_path
+    )
     if args.outdir:
         os.makedirs(args.outdir, exist_ok=True)
     with open(base_dtb_path, 'wb') as f:
-        f.write(base_dtb_blob)
+        f.write(base_dtb_file_bytes)
 
-    for out_path, dtbo_blob in zip(dtbo_paths, dtbo_blobs):
+    dtbo_file_sizes = []
+    for out_path, dtbo_blob, d in zip(dtbo_paths, dtbo_blobs, dtbs):
         out_dir = os.path.dirname(out_path)
         if out_dir:
             os.makedirs(out_dir, exist_ok=True)
+        out_bytes = compress_blob(
+            dtbo_blob, d.compression, filename=out_path
+        )
+        dtbo_file_sizes.append(len(out_bytes))
         with open(out_path, 'wb') as f:
-            f.write(dtbo_blob)
+            f.write(out_bytes)
 
     if args.stats:
-        _print_dtb_stats(dtbs, base_dtb_blob, dtbo_blobs)
+        _print_dtb_stats(
+            dtbs, base_dtb_blob, dtbo_blobs,
+            base_dtb_comp=base_dtb_comp,
+            base_dtb_csize=len(base_dtb_file_bytes),
+            dtbo_csizes=dtbo_file_sizes,
+        )
 
 
 def main(argv=None):
diff --git a/tests/Makefile.tests b/tests/Makefile.tests
index ea1d35cd8e4e..300f244f41c5 100644
--- a/tests/Makefile.tests
+++ b/tests/Makefile.tests
@@ -53,7 +53,10 @@ TESTS_DEPFILES = $(TESTS:%=%.d) \
 	$(addprefix $(TESTS_PREFIX),testutils.d)
 
 TESTS_CLEANFILES_L = $(STD_CLEANFILES) \
-	*.dtb *.test.dtbo *.test.dts *.test.dt.yaml *.dtsv1 tmp.* *.bak \
+	*.dtb *.test.dtbo *.test.dts *.test.dt.yaml *.dtsv1 \
+	*.test.dtb.gz *.test.dtbo.gz *.test.dtb.xz *.test.dtbo.xz \
+	*.test.dtb.lzma *.test.dtbo.lzma *.test.dtb.lz4 *.test.dtbo.lz4 \
+	tmp.* *.bak \
 	treegen
 TESTS_CLEANFILES = $(TESTS) $(TESTS_CLEANFILES_L:%=$(TESTS_PREFIX)%)
 TESTS_CLEANDIRS_L = fs *.test.dir
diff --git a/tests/run_tests.sh b/tests/run_tests.sh
index b0c621e5d1c3..b1a8188a9264 100755
--- a/tests/run_tests.sh
+++ b/tests/run_tests.sh
@@ -1206,6 +1206,79 @@ fdtfactor_tests () {
 	run_test dtbs_equal_unordered fdtfactor_board${i}.nolf.test.dtb \
 	    fdtfactor_strip.test.dir/merged${i}.test.dtb
     done
+
+    # Compressed (.dtb.gz / .dtb.xz / .dtb.lzma / .dtb.lz4) modes
+    for comp_spec in "gz:gzip -9 -k -f:gzip -d -f" \
+		     "xz:xz -k -f:xz -d -f" \
+		     "lzma:xz --format=lzma -k -f:xz -d -f" \
+		     "lz4:lz4 -12 -q -f -m:lz4 -d -q -f --rm" \
+		     "lz4:lz4 -l -12 -q -f -m:lz4 -d -q -f --rm"; do
+	ext="${comp_spec%%:*}"
+	rest="${comp_spec#*:}"
+	comp_cmd="${rest%%:*}"
+	decomp_cmd="${rest#*:}"
+	if ! command -v "${comp_cmd%% *}" >/dev/null 2>&1; then
+	    continue
+	fi
+	run_wrap_test $comp_cmd \
+	    fdtfactor_board1.test.dtb fdtfactor_board2.test.dtb \
+	    fdtfactor_board3.test.dtb
+	rm -rf fdtfactor_comp.test.dir
+	run_wrap_test $PYTHON $FDTFACTOR -s -b "base.test.dtb.$ext" \
+	    -o fdtfactor_comp.test.dir \
+	    "fdtfactor_board1.test.dtb.$ext" \
+	    "fdtfactor_board2.test.dtb.$ext" \
+	    "fdtfactor_board3.test.dtb.$ext"
+	run_wrap_test $decomp_cmd "fdtfactor_comp.test.dir/base.test.dtb.$ext"
+	run_wrap_test $decomp_cmd \
+	    "fdtfactor_comp.test.dir/fdtfactor_board1.test.dtbo.$ext"
+	run_wrap_test $FDTOVERLAY \
+	    -i fdtfactor_comp.test.dir/base.test.dtb \
+	    -o fdtfactor_comp.test.dir/merged1.test.dtb \
+	    fdtfactor_comp.test.dir/fdtfactor_board1.test.dtbo
+	run_test dtbs_equal_unordered fdtfactor_board1.test.dtb \
+	    fdtfactor_comp.test.dir/merged1.test.dtb
+    done
+
+    # Test magic-byte detection on a compressed blob with a plain .dtb
+    # extension and an uncompressed -b alongside compressed inputs.
+    if command -v gzip >/dev/null 2>&1; then
+	run_wrap_test gzip -9 -k -f \
+	    fdtfactor_board1.test.dtb fdtfactor_board2.test.dtb \
+	    fdtfactor_board3.test.dtb
+	cp fdtfactor_board1.test.dtb.gz fdtfactor_board1_magic.test.dtb
+	rm -rf fdtfactor_comp_mixed.test.dir
+	run_wrap_test $PYTHON $FDTFACTOR -s -b base_uncomp.test.dtb \
+	    -o fdtfactor_comp_mixed.test.dir \
+	    fdtfactor_board1_magic.test.dtb \
+	    fdtfactor_board2.test.dtb.gz \
+	    fdtfactor_board3.test.dtb.gz
+	run_wrap_test gzip -d -f \
+	    fdtfactor_comp_mixed.test.dir/fdtfactor_board1_magic.test.dtbo.gz
+	run_wrap_test $FDTOVERLAY \
+	    -i fdtfactor_comp_mixed.test.dir/base_uncomp.test.dtb \
+	    -o fdtfactor_comp_mixed.test.dir/merged1.test.dtb \
+	    fdtfactor_comp_mixed.test.dir/fdtfactor_board1_magic.test.dtbo
+	run_test dtbs_equal_unordered fdtfactor_board1.test.dtb \
+	    fdtfactor_comp_mixed.test.dir/merged1.test.dtb
+
+	# Explicit --base-compression overriding the -b filename extension
+	rm -rf fdtfactor_base_comp.test.dir
+	run_wrap_test $PYTHON $FDTFACTOR --base-compression=gzip \
+	    -b base_custom.test.dtb -o fdtfactor_base_comp.test.dir \
+	    fdtfactor_board1.test.dtb fdtfactor_board2.test.dtb \
+	    fdtfactor_board3.test.dtb
+	mv fdtfactor_base_comp.test.dir/base_custom.test.dtb \
+	    fdtfactor_base_comp.test.dir/base_custom.test.dtb.gz
+	run_wrap_test gzip -d -f \
+	    fdtfactor_base_comp.test.dir/base_custom.test.dtb.gz
+	run_wrap_test $FDTOVERLAY \
+	    -i fdtfactor_base_comp.test.dir/base_custom.test.dtb \
+	    -o fdtfactor_base_comp.test.dir/merged1.test.dtb \
+	    fdtfactor_base_comp.test.dir/fdtfactor_board1.test.dtbo
+	run_test dtbs_equal_unordered fdtfactor_board1.test.dtb \
+	    fdtfactor_base_comp.test.dir/merged1.test.dtb
+    fi
 }
 
 while getopts "vt:me" ARG ; do
@@ -1235,7 +1308,9 @@ if [ -z "$TESTSETS" ]; then
 fi
 
 # Make sure we don't have stale blobs lying around
-rm -f *.test.dtb *.test.dtbo *.test.dts
+rm -f *.test.dtb *.test.dtbo *.test.dts *.test.dtb.gz *.test.dtbo.gz \
+    *.test.dtb.xz *.test.dtbo.xz *.test.dtb.lzma *.test.dtbo.lzma \
+    *.test.dtb.lz4 *.test.dtbo.lz4
 rm -rf *.test.dir
 
 for set in $TESTSETS; do
-- 
2.56.0.313.g2479322e68-goog


^ permalink raw reply	[flat|nested] 12+ messages in thread

* [PATCH v2 10/10] fdtfactor: Add compiled FIT image factoring support
  2026-10-28 21:46 [PATCH v2 00/10] fdtfactor: Introduce fdtfactor to auto-factor overlays Douglas Anderson
                   ` (8 preceding siblings ...)
  2026-10-28 21:46 ` [PATCH v2 09/10] fdtfactor: Support compressed DTB inputs and outputs Douglas Anderson
@ 2026-10-28 21:46 ` Douglas Anderson
  9 siblings, 0 replies; 12+ messages in thread
From: Douglas Anderson @ 2026-10-28 21:46 UTC (permalink / raw)
  To: Devicetree-compiler, Rob Herring, Krzysztof Kozlowski,
	Conor Dooley, Konrad Dybcio
  Cc: Brian Norris, Chen-Yu Tsai, André Draszik, Devicetree,
	Simon Glass, David Gibson, Tudor Ambarus, linux-kernel,
	Julius Werner, Saravana Kannan, Peter Griffin, Douglas Anderson

Bootloaders such as depthcharge on Chromebooks and U-Boot use Flattened
Image Tree (FIT) images to bundle a kernel image with multiple board FDT
blobs (`fdt-1`, `fdt-2`, ...) and per-board configuration nodes.

Add `-F`/`--fit`, `--outfile`, and `--single-base-dtb` options to
`fdtfactor` to factor a compiled FIT image directly:

- Parse non-overlay `type = "flat_dt"` nodes under `/images`,
  decompressing their `data` blobs according to their `compression`
  property (`none`, `lz4`, `lzma`, `xz`, `gzip`).
- Reject FIT images containing `signature*` subnodes under `/images/*`
  or `/configurations/*`, as modifying FDT blobs and configurations
  would invalidate cryptographic signatures.
- Partition the `flat_dt` blobs into compatible factor groups (by
  compression, architecture, FDT header fields, and node-path overlap)
  so a single FIT image containing multiple board/SoC families produces
  a separate `fdt-base-1`, `fdt-base-2`, etc. for each family.
- Support `--single-base-dtb` to force all `flat_dt` blobs into a single
  base DTB regardless of node-path overlap, failing only if their FDT
  headers, architecture, or compression differ.
- Insert each `fdt-base-N` node into `/images`, replace each factored
  `fdt-*` node's `data` with its compressed `.dtbo` overlay, recompute
  any `hash*` subnode digests, and prepend `fdt-base-N` to each matching
  configuration's `fdt` string list while preserving all other
  configuration properties (`description`, `compatible`, etc.,
  populating `compatible` from the board DTB's root node when omitted)
  and any pre-existing overlay entries.

Add uncompressed, LZ4-compressed multi-family, signed-FIT rejection, and
`--single-base-dtb` FIT image test cases to the `fdtfactor` test suite.

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

Changes in v2:
- Rename -f/--fit and -F/--fit-out to -F/--fit and --outfile and clarify
  FIT mode in the --help description (David Gibson, Simon Glass)
- Reject signed FIT images with signature* subnodes (Simon Glass)
- Use '<base-node-name>.dtb' for FIT base FDT descriptions instead of
  common-prefix heuristics (Simon Glass)
- Compare FIT group node overlap against the group's common node set
  instead of only the first entry (Simon Glass)
- Document in README.md that U-Boot's boot_get_fdt_fit() currently
  requires FIT overlays to be uncompressed (Simon Glass)

 README.md                  |  17 ++
 fdtfactor                  | 588 +++++++++++++++++++++++++++++++++++--
 tests/Makefile.tests       |   2 +-
 tests/fdtfactor_lz4.its    | 109 +++++++
 tests/fdtfactor_other1.dts |  33 +++
 tests/fdtfactor_other2.dts |  33 +++
 tests/fdtfactor_test.its   | 109 +++++++
 tests/run_tests.sh         | 158 +++++++++-
 8 files changed, 1018 insertions(+), 31 deletions(-)
 create mode 100644 tests/fdtfactor_lz4.its
 create mode 100644 tests/fdtfactor_other1.dts
 create mode 100644 tests/fdtfactor_other2.dts
 create mode 100644 tests/fdtfactor_test.its

diff --git a/README.md b/README.md
index 6b5ad32f474b..17f7563cffa5 100644
--- a/README.md
+++ b/README.md
@@ -108,6 +108,7 @@ Basic usage:
 
 ```
 $ fdtfactor -o out_dir board1.dtb board2.dtb board3.dtb
+$ fdtfactor -F image.fit --outfile image.factored.fit
 ```
 
 Options:
@@ -123,6 +124,22 @@ Options:
 * `-o`, `--outdir`: Output directory for generated `.dtbo` files and
   `--base-dtb` (default: alongside each input `.dtb` for `.dtbo` files,
   current directory for `--base-dtb`).
+* `-F`, `--fit`: Factor `/images/fdt-*` blobs inside a compiled FIT image
+  (creating `/images/fdt-base-1`, `/images/fdt-base-2`, ... per compatible
+  board/SoC family grouped by FDT headers and `>= 50%` node-path overlap,
+  converting each `/images/fdt-N` node into a `.dtbo` overlay, updating
+  `/configurations/conf-*` `fdt` and `compatible` properties, and
+  recalculating `/images/*/hash-*` digests). Each factored base DTB and
+  `.dtbo` overlay preserves the `compression` setting of its `/images/fdt-*`
+  node (note: while bootloaders such as depthcharge decompress compressed
+  FIT overlays, U-Boot's `boot_get_fdt_fit()` currently only decompresses
+  the base DTB and requires FIT overlays to be uncompressed). Signed FIT
+  images with `signature*` subnodes are rejected because modifying FDT blobs
+  and configurations would invalidate cryptographic signatures.
+* `--outfile`: Output FIT image path when `--fit` is used (default:
+  overwrite `--fit` in place).
+* `--single-base-dtb`: Force a single `/images/fdt-base-1` across all FDTs
+  in `--fit` mode rather than grouping by node-path overlap.
 * `-S`, `--strip-local-fixups`: Omit `/__local_fixups__` from the generated
   `base.dtb` and final reconstructed device trees. By default, `fdtfactor`
   preserves `/__local_fixups__` on `base.dtb` so that when `fdt_overlay_apply()`
diff --git a/fdtfactor b/fdtfactor
index ab261765bb29..f07389c07d02 100755
--- a/fdtfactor
+++ b/fdtfactor
@@ -8,16 +8,25 @@ 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.
+
+Alternatively, `fdtfactor -F <image.fit>` can rewrite a compiled U-Boot /
+ChromeOS Flattened Image Tree (FIT) in place (or to `--outfile`), factoring its
+embedded `/images/fdt-*` blobs into base DTBs (`fdt-base-1`, `fdt-base-2`, ...)
+and overlays while updating `/configurations/conf-*` `fdt` and `compatible`
+properties and recalculating `/images/*/hash-*` digests.
 """
 
 import argparse
 from collections import Counter
+import copy
 import gzip
+import hashlib
 import lzma
 import os
 import struct
 import subprocess
 import sys
+import zlib
 
 import libfdt
 
@@ -107,17 +116,24 @@ def _run_lz4(args, input_bytes, action):
     return res.stdout
 
 
-def decompress_blob(raw_bytes, filename=''):
+def decompress_blob(raw_bytes, filename='', comp=None):
     """Decompress a DTB blob if compressed, returning `(raw_bytes, comp)`.
 
     Args:
-        raw_bytes: Input FDT `bytes` (compressed or uncompressed).
-        filename: Optional filename for error reporting and extension fallback.
+        raw_bytes: Input file or property `bytes` (compressed or uncompressed).
+        filename: Optional filename or node description for error reporting and
+            extension fallback.
+        comp: Optional explicit compression type (`'none'`, `'lz4'`,
+            `'lz4_legacy'`, `'lzma'`, `'xz'`, `'gzip'`), or `None` to
+            auto-detect via `detect_compression()`.
 
     Returns:
         Tuple of `(uncompressed_bytes, resolved_compression_type)`.
     """
-    comp = detect_compression(raw_bytes, filename)
+    if not comp:
+        comp = detect_compression(raw_bytes, filename)
+    elif comp == 'lz4' and raw_bytes.startswith(LZ4_LEGACY_MAGIC):
+        comp = 'lz4_legacy'
     if comp == 'none':
         return raw_bytes, 'none'
     if comp in ('lz4', 'lz4_legacy'):
@@ -238,6 +254,10 @@ def _is_dtbo_overlay(fdt):
     )
 
 
+class OverlayBlobError(ValueError):
+    """Raised when an input FDT blob is already a `.dtbo` overlay."""
+
+
 class ParsedDtb:
     """Complete input .dtb blob with phandle-normalized tree and FDT metadata.
 
@@ -258,17 +278,24 @@ class ParsedDtb:
       independent of numeric phandle numbering.
     """
 
-    def __init__(self, filename):
-        """Parse an input FDT file into a normalized NodeTree.
+    def __init__(self, filename, file_bytes=None, comp=None):
+        """Parse an input FDT file or blob into a normalized NodeTree.
 
         Args:
-            filename: Path to the input .dtb (or compressed .dtb.*) file.
+            filename: File path (or display name when `file_bytes` is given).
+            file_bytes: Optional raw (compressed or uncompressed) FDT bytes; if
+                None, read from `filename`.
+            comp: Optional explicit compression algorithm name, or None to
+                auto-detect via `decompress_blob()`.
         """
         self.filename = filename
-        with open(filename, 'rb') as f:
-            file_bytes = f.read()
+        if file_bytes is None:
+            with open(filename, 'rb') as f:
+                file_bytes = f.read()
         self.compressed_size = len(file_bytes)
-        raw_bytes, self.compression = decompress_blob(file_bytes, filename)
+        raw_bytes, self.compression = decompress_blob(
+            file_bytes, filename, comp=comp
+        )
         self.raw_bytes = raw_bytes
 
         try:
@@ -278,7 +305,7 @@ class ParsedDtb:
             raise ValueError(f'{filename}: Invalid FDT blob ({exc})') from exc
 
         if _is_dtbo_overlay(fdt):
-            raise ValueError(
+            raise OverlayBlobError(
                 f'{filename}: Input is already a DTB overlay (.dtbo)'
             )
 
@@ -849,6 +876,456 @@ def create_base_dtb_and_overlays(dtbs, strip_local_fixups=False):
     return base_dtb_blob, dtbo_blobs
 
 
+# ---------------------------------------------------------------------------
+# Compiled FIT image factoring (targeting depthcharge / coreboot / U-Boot)
+# ---------------------------------------------------------------------------
+
+def _compute_fit_hash(algo_name, data_bytes):
+    """Compute a binary hash digest for a FIT image `hash*` subnode.
+
+    Args:
+        algo_name: Hash algorithm name (e.g., `'crc32'`, `'sha1'`, `'sha256'`).
+        data_bytes: Input `bytes` to hash.
+
+    Returns:
+        Binary hash digest `bytes`.
+    """
+    if algo_name == 'crc32':
+        return struct.pack('>I', zlib.crc32(data_bytes) & 0xffffffff)
+    h = hashlib.new(algo_name)
+    h.update(data_bytes)
+    return h.digest()
+
+
+def _update_fit_hashes(img_node):
+    """Recompute `value` properties on any FIT `hash*` subnodes of `img_node`.
+
+    Args:
+        img_node: `NodeTree` for a `/images/*` subnode whose `hash*` child
+            nodes should be updated in place from `img_node.props['data']`.
+    """
+    data_bytes = img_node.props.get('data')
+    if data_bytes is None:
+        return
+    for sub_name, sub_node in img_node.children.items():
+        if not sub_name.startswith('hash'):
+            continue
+        if 'algo' not in sub_node.props:
+            raise ValueError(
+                f'FIT hash subnode {sub_name!r} lacks an algo property'
+            )
+        algo = sub_node.get_str('algo')
+        sub_node.props['value'] = _compute_fit_hash(algo, data_bytes)
+
+
+def _is_dtb_filename_desc(desc):
+    """Return True if `desc` looks like a `.dtb` filename.
+
+    Args:
+        desc: Description string from a FIT `/images/fdt-*` node.
+
+    Returns:
+        True if `desc` has no spaces and ends with `.dtb` (optionally followed
+        by a recognized compression suffix), False otherwise.
+    """
+    if not desc or ' ' in desc:
+        return False
+    _, stem, desc_comp = split_dtb_filename(desc)
+    return os.path.basename(desc) == f'{stem}.dtb{COMP_TO_EXT[desc_comp]}'
+
+
+def group_fit_dtbs(fdt_entries):
+    """Partition FIT `flat_dt` entries into compatible factor groups.
+
+    DTBs are grouped by `(compression, arch, load, entry, boot_cpuid_phys,
+    mem_rsv)` and node-path overlap (>= 50%) against the group's common node
+    set so that a single FIT image containing multiple board/SoC families
+    produces one base DTB per family.
+
+    Args:
+        fdt_entries: Sequence of FIT `flat_dt` entry dicts from
+            `_extract_fit_fdt_entries()`.
+
+    Returns:
+        List of entry-dict lists, one per compatible group.
+    """
+    groups = []
+    for entry in fdt_entries:
+        dtb = entry['dtb']
+        header_key = (
+            dtb.compression,
+            *(entry['node'].props.get(p) for p in ('arch', 'load', 'entry')),
+            dtb.boot_cpuid_phys,
+            dtb.mem_rsv,
+        )
+        entry_nodes = {path for path, _ in dtb.root.walk()}
+
+        placed = False
+        for grp in groups:
+            if grp['key'] != header_key:
+                continue
+            common_nodes = grp['common_nodes']
+            union_len = len(entry_nodes | common_nodes)
+            overlap = (
+                len(entry_nodes & common_nodes) / union_len
+                if union_len
+                else 1.0
+            )
+            if overlap >= 0.5:
+                grp['common_nodes'] &= entry_nodes
+                grp['entries'].append(entry)
+                placed = True
+                break
+
+        if not placed:
+            groups.append({
+                'key': header_key,
+                'common_nodes': set(entry_nodes),
+                'entries': [entry],
+            })
+
+    return [grp['entries'] for grp in groups]
+
+
+def _extract_fit_fdt_entries(fit_in_path, images_node, configs_node):
+    """Extract and parse non-overlay `flat_dt` entries from `/images`.
+
+    Args:
+        fit_in_path: Input FIT image path (for error reporting).
+        images_node: `NodeTree` for the FIT `/images` node.
+        configs_node: `NodeTree` for the FIT `/configurations` node.
+
+    Returns:
+        List of dicts (`{'name', 'node', 'dtb'}`) for all non-overlay
+        `flat_dt` image subnodes.
+    """
+    if any(name.startswith('fdt-base-') for name in images_node.children):
+        raise ValueError(
+            f'{fit_in_path}: FIT image already contains fdt-base-* node(s)'
+        )
+
+    for conf_name, conf_node in configs_node.children.items():
+        for sub_name in conf_node.children:
+            if sub_name.startswith(('signature', 'hash')):
+                raise ValueError(
+                    f'{fit_in_path}: /configurations/{conf_name}/{sub_name} '
+                    f'is not supported; sign the FIT image after factoring'
+                )
+
+    fdt_entries = []
+    for img_name, img_node in images_node.children.items():
+        if ('data-offset' in img_node.props or
+                'data-position' in img_node.props):
+            raise ValueError(
+                f'{fit_in_path}: /images/{img_name} uses external data '
+                f'(data-offset/data-position), which is not supported'
+            )
+        for sub_name in img_node.children:
+            if sub_name.startswith('signature'):
+                raise ValueError(
+                    f'{fit_in_path}: /images/{img_name}/{sub_name} is not '
+                    f'supported; sign the FIT image after factoring'
+                )
+        img_type = img_node.get_str('type') if 'type' in img_node.props else ''
+        if img_type != 'flat_dt':
+            continue
+        raw_data = img_node.props.get('data')
+        if raw_data is None:
+            raise ValueError(
+                f'{fit_in_path}: /images/{img_name} lacks a data property'
+            )
+        node_label = f'{fit_in_path}:/images/{img_name}'
+        comp_prop = (
+            img_node.get_str('compression')
+            if 'compression' in img_node.props
+            else 'none'
+        ) or 'none'
+        try:
+            parsed_dtb = ParsedDtb(
+                node_label, file_bytes=raw_data, comp=comp_prop
+            )
+        except OverlayBlobError:
+            continue
+
+        fdt_entries.append({
+            'name': img_name,
+            'node': img_node,
+            'dtb': parsed_dtb,
+        })
+
+    if len(fdt_entries) < 2:
+        raise ValueError(
+            'FIT image must contain at least two non-overlay flat_dt '
+            'nodes to factor'
+        )
+
+    return fdt_entries
+
+
+def _partition_fit_fdt_entries(fdt_entries, single_base_dtb):
+    """Partition `fdt_entries` into factorable groups and validate constraints.
+
+    Args:
+        fdt_entries: List of FIT `flat_dt` entry dicts from
+            `_extract_fit_fdt_entries()`.
+        single_base_dtb: If True, require all `fdt_entries` to share a single
+            base DTB group rather than grouping by node-path overlap.
+
+    Returns:
+        List of factorable entry-dict groups (each containing at least 2
+        entries).
+    """
+    if single_base_dtb:
+        ref_entry = fdt_entries[0]
+        for entry in fdt_entries[1:]:
+            if entry['dtb'].compression != ref_entry['dtb'].compression:
+                raise ValueError(
+                    f'--single-base-dtb requires all FIT flat_dt nodes to use '
+                    f'the same compression: {ref_entry["name"]} has '
+                    f'{ref_entry["dtb"].compression!r} vs {entry["name"]} '
+                    f'{entry["dtb"].compression!r}'
+                )
+            for prop in ('arch', 'load', 'entry'):
+                if (entry['node'].props.get(prop) !=
+                        ref_entry['node'].props.get(prop)):
+                    raise ValueError(
+                        f'--single-base-dtb requires all FIT flat_dt nodes to '
+                        f'use the same {prop} property: {ref_entry["name"]} vs '
+                        f'{entry["name"]}'
+                    )
+        return [fdt_entries]
+
+    groups = group_fit_dtbs(fdt_entries)
+    factorable_groups = [g for g in groups if len(g) >= 2]
+    if not factorable_groups:
+        raise ValueError(
+            'FIT image must contain at least two compatible '
+            'flat_dt nodes to factor'
+        )
+    for g in groups:
+        if len(g) == 1:
+            print(
+                f'Note: FIT flat_dt node {g[0]["name"]} has no '
+                f'compatible peers and will be left unfactored',
+                file=sys.stderr,
+            )
+    return factorable_groups
+
+
+def _factor_fit_group(entries, base_dtb_node_name, strip_local_fixups):
+    """Factor one compatible group of FIT `flat_dt` entries.
+
+    Args:
+        entries: List of FIT `flat_dt` entry dicts in this compatible group.
+        base_dtb_node_name: Name for the new `/images/<base_dtb_node_name>`
+            node (e.g., `'fdt-base-1'`).
+        strip_local_fixups: If True, omit `/__local_fixups__` from the
+            generated base DTB.
+
+    Returns:
+        Tuple of `(base_dtb_node, stat_args)` where `base_dtb_node` is the
+        constructed `NodeTree` for `/images/<base_dtb_node_name>` and
+        `stat_args` is an argument tuple for `_print_dtb_stats()`.
+    """
+    group_dtbs = [e['dtb'] for e in entries]
+    comp = group_dtbs[0].compression
+
+    base_dtb_blob, dtbo_blobs = create_base_dtb_and_overlays(
+        group_dtbs,
+        strip_local_fixups=strip_local_fixups,
+    )
+
+    base_dtb_comp_bytes = compress_blob(
+        base_dtb_blob, comp, filename=f'/images/{base_dtb_node_name}'
+    )
+    base_dtb_node = copy.deepcopy(entries[0]['node'])
+    for prop in ('phandle', 'compatible'):
+        base_dtb_node.props.pop(prop, None)
+    base_dtb_node.children = {
+        k: v
+        for k, v in base_dtb_node.children.items()
+        if k.startswith('hash')
+    }
+    for sub_node in base_dtb_node.children.values():
+        sub_node.props.pop('phandle', None)
+    if 'description' in base_dtb_node.props:
+        base_dtb_node.set_str(
+            'description',
+            f'{base_dtb_node_name}.dtb{COMP_TO_EXT[comp]}',
+        )
+    base_dtb_node.props['data'] = base_dtb_comp_bytes
+    if 'compression' in base_dtb_node.props or comp != 'none':
+        comp_prop = 'lz4' if comp == 'lz4_legacy' else comp
+        base_dtb_node.set_str('compression', comp_prop)
+    _update_fit_hashes(base_dtb_node)
+
+    dtbo_comp_sizes = []
+    for entry, dtbo_blob in zip(entries, dtbo_blobs):
+        img_node = entry['node']
+        dtbo_comp_bytes = compress_blob(
+            dtbo_blob, comp, filename=f'/images/{entry["name"]}'
+        )
+        dtbo_comp_sizes.append(len(dtbo_comp_bytes))
+
+        if 'description' in img_node.props:
+            old_desc = img_node.get_str('description')
+            if _is_dtb_filename_desc(old_desc):
+                img_node.set_str(
+                    'description',
+                    get_dtbo_output_path(old_desc, None, comp=comp),
+                )
+
+        # Strip 'load' and 'entry' from overlay image nodes so bootloaders do
+        # not load overlays at the base FDT address and overwrite the base FDT.
+        for prop in ('load', 'entry'):
+            img_node.props.pop(prop, None)
+
+        img_node.props['data'] = dtbo_comp_bytes
+        _update_fit_hashes(img_node)
+
+    stat_args = (
+        group_dtbs,
+        base_dtb_blob,
+        dtbo_blobs,
+        comp,
+        len(base_dtb_comp_bytes),
+        dtbo_comp_sizes,
+        f'[{base_dtb_node_name}] ',
+    )
+    return base_dtb_node, stat_args
+
+
+def _update_fit_images_and_configs(images_node, configs_node,
+                                   insert_before_first, fdt_to_base_dtb):
+    """Insert `fdt-base-*` nodes into `/images` and update `/configurations`.
+
+    Args:
+        images_node: `NodeTree` for `/images` (mutated in place).
+        configs_node: `NodeTree` for `/configurations` (mutated in place).
+        insert_before_first: Dict mapping the first `fdt-*` subnode name of
+            each group to `(base_dtb_node_name, base_dtb_node)`.
+        fdt_to_base_dtb: Dict mapping each factored `fdt-*` subnode name to
+            `(base_dtb_node_name, orig_parsed_dtb)`.
+    """
+    new_images_children = {}
+    for img_name, img_node in images_node.children.items():
+        if img_name in insert_before_first:
+            b_name, b_node = insert_before_first[img_name]
+            new_images_children[b_name] = b_node
+        new_images_children[img_name] = img_node
+    images_node.children = new_images_children
+
+    for conf_name, conf_node in configs_node.children.items():
+        if 'fdt' not in conf_node.props:
+            continue
+        fdt_list = conf_node.get_stringlist('fdt')
+        if not fdt_list:
+            continue
+        primary_fdt = fdt_list[0]
+        for extra_fdt in fdt_list[1:]:
+            if extra_fdt in fdt_to_base_dtb:
+                raise ValueError(
+                    f'/configurations/{conf_name} lists factored flat_dt '
+                    f'{extra_fdt!r} after the primary FDT'
+                )
+        if primary_fdt in fdt_to_base_dtb:
+            base_dtb_name, orig_dtb = fdt_to_base_dtb[primary_fdt]
+            conf_node.set_stringlist('fdt', [base_dtb_name] + fdt_list)
+            # If the configuration omitted 'compatible' (relying on the
+            # bootloader reading root 'compatible' from fdt_list[0]), copy the
+            # board's root 'compatible' property onto the configuration node
+            # now that fdt_list[0] is the shared base DTB.
+            if 'compatible' not in conf_node.props:
+                root_compat = orig_dtb.root.props.get('compatible')
+                if root_compat is not None:
+                    conf_node.props['compatible'] = root_compat
+
+
+def factor_fit_image(fit_in_path, fit_out_path, strip_local_fixups=False,
+                     single_base_dtb=False, stats=False):
+    """Factor all `flat_dt` images in a compiled FIT image into base + overlays.
+
+    Args:
+        fit_in_path: Path to the input compiled FIT image.
+        fit_out_path: Path where the factored FIT image should be written.
+        strip_local_fixups: If True, omit `/__local_fixups__` from generated
+            base DTBs.
+        single_base_dtb: If True, force all `flat_dt` nodes into a single
+            `fdt-base-1` base DTB.
+        stats: If True, print per-group and overall FIT size savings.
+    """
+    with open(fit_in_path, 'rb') as f:
+        orig_fit_bytes = f.read()
+
+    try:
+        orig_fit_fdt = libfdt.FdtRo(orig_fit_bytes)
+        libfdt.check_err(
+            libfdt.fdt_check_full(orig_fit_fdt._fdt, len(orig_fit_bytes))
+        )
+    except libfdt.FdtException as exc:
+        raise ValueError(f'{fit_in_path}: Invalid FDT blob ({exc})') from exc
+
+    fit_root = libfdt.NodeTree.unflatten(orig_fit_fdt)
+    images_node = fit_root.children.get('images')
+    configs_node = fit_root.children.get('configurations')
+    if images_node is None or configs_node is None:
+        raise ValueError(
+            f'{fit_in_path}: FIT image must contain /images and '
+            f'/configurations nodes'
+        )
+
+    fdt_entries = _extract_fit_fdt_entries(
+        fit_in_path, images_node, configs_node
+    )
+    factorable_groups = _partition_fit_fdt_entries(
+        fdt_entries, single_base_dtb
+    )
+
+    fdt_to_base_dtb = {}
+    insert_before_first = {}
+    group_stats = []
+
+    for idx, entries in enumerate(factorable_groups, 1):
+        base_dtb_node_name = f'fdt-base-{idx}'
+        base_dtb_node, stat_args = _factor_fit_group(
+            entries, base_dtb_node_name, strip_local_fixups
+        )
+        insert_before_first[entries[0]['name']] = (
+            base_dtb_node_name, base_dtb_node
+        )
+        for entry in entries:
+            fdt_to_base_dtb[entry['name']] = (base_dtb_node_name, entry['dtb'])
+        group_stats.append(stat_args)
+
+    _update_fit_images_and_configs(
+        images_node, configs_node, insert_before_first, fdt_to_base_dtb
+    )
+
+    new_fit_bytes = fit_root.flatten(
+        mem_rsv=orig_fit_fdt.get_mem_rsv_list(),
+        boot_cpuid_phys=orig_fit_fdt.boot_cpuid_phys(),
+    )
+
+    out_dir = os.path.dirname(fit_out_path)
+    if out_dir:
+        os.makedirs(out_dir, exist_ok=True)
+    with open(fit_out_path, 'wb') as f:
+        f.write(new_fit_bytes)
+
+    if stats:
+        for stat_args in group_stats:
+            _print_dtb_stats(*stat_args)
+        fit_saved = len(orig_fit_bytes) - len(new_fit_bytes)
+        fit_pct = (
+            (fit_saved / len(orig_fit_bytes) * 100.0) if orig_fit_bytes else 0.0
+        )
+        print(
+            f'FIT image total: {len(orig_fit_bytes):,} B -> '
+            f'{len(new_fit_bytes):,} B '
+            f'(saved {fit_saved:,} B / {fit_pct:.1f}%)'
+        )
+
+
 def get_dtbo_output_path(dtb_path, outdir, comp='none'):
     """Determine the output `.dtbo[.comp]` file path for an input `.dtb` path.
 
@@ -881,13 +1358,15 @@ def parse_args(argv=None):
     parser = argparse.ArgumentParser(
         description=(
             'Split a set of compiled device tree blobs (.dtb, or compressed '
-            '.dtb.lz4/.xz/.lzma/.gz) into a single common base .dtb and '
-            'per-board overlay blobs (.dtbo).'
+            '.dtb.lz4/.xz/.lzma/.gz) into a common base .dtb and per-board '
+            'overlay blobs (.dtbo), or update a compiled FIT image '
+            '(-F/--fit) in-place (or to --outfile) by factoring its '
+            '/images/fdt-* blobs into base DTBs and overlays.'
         )
     )
     parser.add_argument(
         '-b', '--base-dtb',
-        default='base.dtb',
+        default=None,
         metavar='FILENAME',
         help=(
             'Output common base .dtb (or .dtb.lz4/.xz/.lzma/.gz) filename '
@@ -914,6 +1393,23 @@ def parse_args(argv=None):
             'for --base-dtb)'
         ),
     )
+    parser.add_argument(
+        '-F', '--fit',
+        default=None,
+        metavar='FIT_IMAGE',
+        help=(
+            'Input compiled FIT image (.fit / .itb) to factor (updated '
+            'in-place unless --outfile is specified)'
+        ),
+    )
+    parser.add_argument(
+        '--outfile',
+        default=None,
+        metavar='FIT_OUT',
+        help=(
+            'Output path for the factored FIT image (default: overwrite --fit)'
+        ),
+    )
     parser.add_argument(
         '-S', '--strip-local-fixups',
         action='store_true',
@@ -922,6 +1418,15 @@ def parse_args(argv=None):
             'reconstructed device trees'
         ),
     )
+    parser.add_argument(
+        '--single-base-dtb',
+        action='store_true',
+        help=(
+            'Force all flat_dt nodes in a FIT image (-F/--fit) to be '
+            'factored into a single fdt-base-1 base DTB, failing only if '
+            'headers, arch, or compression differ'
+        ),
+    )
     parser.add_argument(
         '-s', '--stats',
         action='store_true',
@@ -929,26 +1434,40 @@ def parse_args(argv=None):
     )
     parser.add_argument(
         'dtbs',
-        nargs='+',
+        nargs='*',
         metavar='DTB',
         help=(
-            'Input .dtb (or compressed .dtb.lz4/.xz/.lzma/.gz) files (at '
-            'least two required)'
+            'Input .dtb (or compressed .dtb.lz4/.xz/.lzma/.gz) files (when '
+            '--fit is not used)'
         ),
     )
     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')
+    if args.outfile and not args.fit:
+        parser.error('--outfile requires -F/--fit')
+    if args.single_base_dtb and not args.fit:
+        parser.error('--single-base-dtb requires -F/--fit')
+    if args.fit:
+        if (args.dtbs or args.base_dtb or args.outdir or
+                args.base_compression != 'auto'):
+            parser.error(
+                '-F/--fit cannot be combined with -b/--base-dtb, '
+                '--base-compression, -o/--outdir, or positional DTB arguments'
+            )
+    else:
+        if args.base_dtb is None:
+            args.base_dtb = 'base.dtb'
+        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, base_dtb_comp='none',
-                     base_dtb_csize=None, dtbo_csizes=None):
+                     base_dtb_csize=None, dtbo_csizes=None, prefix=''):
     """Print uncompressed and compressed size savings summary.
 
     Args:
@@ -958,6 +1477,7 @@ def _print_dtb_stats(dtbs, base_dtb_blob, dtbo_blobs, base_dtb_comp='none',
         base_dtb_comp: Compression format of the base DTB output.
         base_dtb_csize: Byte size of the compressed base DTB output.
         dtbo_csizes: Sequence of byte sizes of the compressed `.dtbo` outputs.
+        prefix: Optional line prefix (e.g., `'[fdt-base-1] '`).
     """
     orig_uncomp = sum(len(d.raw_bytes) for d in dtbs)
     base_dtb_uncomp = len(base_dtb_blob)
@@ -966,7 +1486,7 @@ def _print_dtb_stats(dtbs, base_dtb_blob, dtbo_blobs, base_dtb_comp='none',
     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'{prefix}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}%)'
@@ -975,9 +1495,9 @@ def _print_dtb_stats(dtbs, base_dtb_blob, dtbo_blobs, base_dtb_comp='none',
     if any(c != 'none' for c in all_comps):
         comp_label = '/'.join(sorted(all_comps))
         hdr = (
-            f'Compressed ({comp_label}):'
+            f'{prefix}Compressed ({comp_label}):'
             if len(all_comps) == 1
-            else f'On-disk ({comp_label}):   '
+            else f'{prefix}On-disk ({comp_label}):   '
         )
         orig_comp = sum(d.compressed_size for d in dtbs)
         dtbo_csize = sum(dtbo_csizes)
@@ -1086,7 +1606,17 @@ def main(argv=None):
     """
     args = parse_args(argv)
     try:
-        factor_dtb_files(args)
+        if args.fit:
+            fit_out = args.outfile if args.outfile else args.fit
+            factor_fit_image(
+                args.fit,
+                fit_out,
+                strip_local_fixups=args.strip_local_fixups,
+                single_base_dtb=args.single_base_dtb,
+                stats=args.stats,
+            )
+        else:
+            factor_dtb_files(args)
     except (OSError, ValueError, RuntimeError, libfdt.FdtException) as exc:
         sys.exit(f'Error: {exc}')
     return 0
diff --git a/tests/Makefile.tests b/tests/Makefile.tests
index 300f244f41c5..a89bcde1f038 100644
--- a/tests/Makefile.tests
+++ b/tests/Makefile.tests
@@ -53,7 +53,7 @@ TESTS_DEPFILES = $(TESTS:%=%.d) \
 	$(addprefix $(TESTS_PREFIX),testutils.d)
 
 TESTS_CLEANFILES_L = $(STD_CLEANFILES) \
-	*.dtb *.test.dtbo *.test.dts *.test.dt.yaml *.dtsv1 \
+	*.dtb *.test.dtbo *.test.dts *.test.dt.yaml *.dtsv1 *.test.fit \
 	*.test.dtb.gz *.test.dtbo.gz *.test.dtb.xz *.test.dtbo.xz \
 	*.test.dtb.lzma *.test.dtbo.lzma *.test.dtb.lz4 *.test.dtbo.lz4 \
 	tmp.* *.bak \
diff --git a/tests/fdtfactor_lz4.its b/tests/fdtfactor_lz4.its
new file mode 100644
index 000000000000..dc2b345d90e5
--- /dev/null
+++ b/tests/fdtfactor_lz4.its
@@ -0,0 +1,109 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+/dts-v1/;
+
+/ {
+	description = "Test LZ4 FIT image for fdtfactor";
+	#address-cells = <1>;
+
+	images {
+		kernel-1 {
+			description = "Unavailable";
+			data = /incbin/("fdtfactor_board1.test.dtb.lz4");
+			type = "kernel_noload";
+			arch = "arm64";
+			os = "linux";
+			compression = "lz4";
+			load = <0>;
+			entry = <0>;
+		};
+		fdt-1 {
+			description = "fdtfactor_board1.test.dtb.lz4";
+			data = /incbin/("fdtfactor_board1.test.dtb.lz4");
+			type = "flat_dt";
+			arch = "arm64";
+			compression = "lz4";
+			hash-1 {
+				algo = "sha1";
+			};
+		};
+		fdt-2 {
+			description = "fdtfactor_board2.test.dtb.lz4";
+			data = /incbin/("fdtfactor_board2.test.dtb.lz4");
+			type = "flat_dt";
+			arch = "arm64";
+			compression = "lz4";
+			hash-1 {
+				algo = "sha1";
+			};
+		};
+		fdt-3 {
+			description = "fdtfactor_board3.test.dtb.lz4";
+			data = /incbin/("fdtfactor_board3.test.dtb.lz4");
+			type = "flat_dt";
+			arch = "arm64";
+			compression = "lz4";
+			hash-1 {
+				algo = "sha1";
+			};
+		};
+		fdt-4 {
+			description = "fdtfactor_other1.test.dtb.lz4";
+			data = /incbin/("fdtfactor_other1.test.dtb.lz4");
+			type = "flat_dt";
+			arch = "arm64";
+			compression = "lz4";
+			hash-1 {
+				algo = "sha1";
+			};
+		};
+		fdt-5 {
+			description = "fdtfactor_other2.test.dtb.lz4";
+			data = /incbin/("fdtfactor_other2.test.dtb.lz4");
+			type = "flat_dt";
+			arch = "arm64";
+			compression = "lz4";
+			hash-1 {
+				algo = "sha1";
+			};
+		};
+		fdt-ovl-1 {
+			description = "overlay_overlay_phandle.test.dtb.lz4";
+			data = /incbin/("overlay_overlay_phandle.test.dtb.lz4");
+			type = "flat_dt";
+			arch = "arm64";
+			compression = "lz4";
+		};
+	};
+	configurations {
+		default = "conf-1";
+		conf-1 {
+			description = "board-meta-1";
+			kernel = "kernel-1";
+			fdt = "fdt-1";
+			compatible = "vendor,board1", "vendor,common";
+		};
+		conf-2 {
+			description = "board-meta-2";
+			kernel = "kernel-1";
+			fdt = "fdt-2";
+		};
+		conf-3 {
+			description = "board-meta-3";
+			kernel = "kernel-1";
+			fdt = "fdt-3", "fdt-ovl-1";
+			compatible = "vendor,board3", "vendor,common";
+		};
+		conf-4 {
+			description = "board-meta-4";
+			kernel = "kernel-1";
+			fdt = "fdt-4";
+			compatible = "vendor,other1", "vendor,other";
+		};
+		conf-5 {
+			description = "board-meta-5";
+			kernel = "kernel-1";
+			fdt = "fdt-5";
+			compatible = "vendor,other2", "vendor,other";
+		};
+	};
+};
diff --git a/tests/fdtfactor_other1.dts b/tests/fdtfactor_other1.dts
new file mode 100644
index 000000000000..d6257d1fd29d
--- /dev/null
+++ b/tests/fdtfactor_other1.dts
@@ -0,0 +1,33 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+/dts-v1/;
+
+/memreserve/ 0xdeadbeef00000000 0x100000;
+
+/ {
+	compatible = "vendor,other1", "vendor,other";
+	model = "other1";
+	#address-cells = <1>;
+	#size-cells = <0>;
+
+	osn1: other-subnode@1 {
+		compatible = "other-subnode1";
+		reg = <1>;
+		prop-ref = <&osn1 0x10 &ossn1 0x20>;
+
+		ossn1: other-subsubnode {
+			compatible = "other-subsubnode";
+			prop-int = <0x1111>;
+		};
+	};
+
+	osn2: other-subnode@2 {
+		reg = <2>;
+		prop-phandle = <&osn2>;
+	};
+
+	o1_node: other1-node {
+		prop-str = "other1";
+		ref-base = <&osn1>;
+		ref-self = <&o1_node>;
+	};
+};
diff --git a/tests/fdtfactor_other2.dts b/tests/fdtfactor_other2.dts
new file mode 100644
index 000000000000..2d97f3595e3c
--- /dev/null
+++ b/tests/fdtfactor_other2.dts
@@ -0,0 +1,33 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+/dts-v1/;
+
+/memreserve/ 0xdeadbeef00000000 0x100000;
+
+/ {
+	compatible = "vendor,other2", "vendor,other";
+	model = "other2";
+	#address-cells = <1>;
+	#size-cells = <0>;
+
+	osn1: other-subnode@1 {
+		compatible = "other-subnode1";
+		reg = <1>;
+		prop-ref = <&osn1 0x10 &ossn1 0x20>;
+
+		ossn1: other-subsubnode {
+			compatible = "other-subsubnode";
+			prop-int = <0x2222>;
+		};
+	};
+
+	osn2: other-subnode@2 {
+		reg = <2>;
+		prop-phandle = <&osn2>;
+	};
+
+	o2_node: other2-node {
+		prop-str = "other2";
+		ref-base = <&ossn1>;
+		ref-self = <&o2_node>;
+	};
+};
diff --git a/tests/fdtfactor_test.its b/tests/fdtfactor_test.its
new file mode 100644
index 000000000000..bf3b9ef8c439
--- /dev/null
+++ b/tests/fdtfactor_test.its
@@ -0,0 +1,109 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+/dts-v1/;
+
+/ {
+	description = "Test FIT image for fdtfactor";
+	#address-cells = <1>;
+
+	images {
+		kernel-1 {
+			description = "Unavailable";
+			data = /incbin/("fdtfactor_board1.test.dtb");
+			type = "kernel_noload";
+			arch = "arm64";
+			os = "linux";
+			compression = "none";
+			load = <0>;
+			entry = <0>;
+		};
+		fdt-1 {
+			description = "fdtfactor_board1.test.dtb";
+			data = /incbin/("fdtfactor_board1.test.dtb");
+			type = "flat_dt";
+			arch = "arm64";
+			compression = "none";
+			hash-1 {
+				algo = "sha1";
+			};
+		};
+		fdt-2 {
+			description = "fdtfactor_board2.test.dtb";
+			data = /incbin/("fdtfactor_board2.test.dtb");
+			type = "flat_dt";
+			arch = "arm64";
+			compression = "none";
+			hash-1 {
+				algo = "sha1";
+			};
+		};
+		fdt-3 {
+			description = "fdtfactor_board3.test.dtb";
+			data = /incbin/("fdtfactor_board3.test.dtb");
+			type = "flat_dt";
+			arch = "arm64";
+			compression = "none";
+			hash-1 {
+				algo = "sha1";
+			};
+		};
+		fdt-4 {
+			description = "fdtfactor_other1.test.dtb";
+			data = /incbin/("fdtfactor_other1.test.dtb");
+			type = "flat_dt";
+			arch = "arm64";
+			compression = "none";
+			hash-1 {
+				algo = "sha1";
+			};
+		};
+		fdt-5 {
+			description = "fdtfactor_other2.test.dtb";
+			data = /incbin/("fdtfactor_other2.test.dtb");
+			type = "flat_dt";
+			arch = "arm64";
+			compression = "none";
+			hash-1 {
+				algo = "sha1";
+			};
+		};
+		fdt-ovl-1 {
+			description = "overlay_overlay_phandle.test.dtb";
+			data = /incbin/("overlay_overlay_phandle.test.dtb");
+			type = "flat_dt";
+			arch = "arm64";
+			compression = "none";
+		};
+	};
+	configurations {
+		default = "conf-1";
+		conf-1 {
+			description = "board-meta-1";
+			kernel = "kernel-1";
+			fdt = "fdt-1";
+			compatible = "vendor,board1", "vendor,common";
+		};
+		conf-2 {
+			description = "board-meta-2";
+			kernel = "kernel-1";
+			fdt = "fdt-2";
+		};
+		conf-3 {
+			description = "board-meta-3";
+			kernel = "kernel-1";
+			fdt = "fdt-3", "fdt-ovl-1";
+			compatible = "vendor,board3", "vendor,common";
+		};
+		conf-4 {
+			description = "board-meta-4";
+			kernel = "kernel-1";
+			fdt = "fdt-4";
+			compatible = "vendor,other1", "vendor,other";
+		};
+		conf-5 {
+			description = "board-meta-5";
+			kernel = "kernel-1";
+			fdt = "fdt-5";
+			compatible = "vendor,other2", "vendor,other";
+		};
+	};
+};
diff --git a/tests/run_tests.sh b/tests/run_tests.sh
index b1a8188a9264..fb95ff6fbd25 100755
--- a/tests/run_tests.sh
+++ b/tests/run_tests.sh
@@ -1279,6 +1279,162 @@ fdtfactor_tests () {
 	run_test dtbs_equal_unordered fdtfactor_board1.test.dtb \
 	    fdtfactor_base_comp.test.dir/merged1.test.dtb
     fi
+
+    # FIT image factoring (uncompressed and compressed, multi-group by node
+    # overlap)
+    # fdtget -t r appends a trailing newline; strip that last byte.
+    fdtget_raw () {
+	$DTGET -t r "$1" "$2" "$3" > "$4.tmp"
+	head -c $(($($STATSZ "$4.tmp") - 1)) "$4.tmp" > "$4"
+	rm -f "$4.tmp"
+    }
+    run_dtc_test -b 2 -@ -L -I dts -O dtb -o fdtfactor_other1.test.dtb \
+	"$SRCDIR/fdtfactor_other1.dts"
+    run_dtc_test -b 2 -@ -L -I dts -O dtb -o fdtfactor_other2.test.dtb \
+	"$SRCDIR/fdtfactor_other2.dts"
+    run_dtc_test -@ -I dts -O dtb -o overlay_overlay_phandle.test.dtb \
+	"$SRCDIR/overlay_overlay_phandle.dts"
+
+    run_dtc_test -i . -I dts -O dtb -o fdtfactor.test.fit \
+	"$SRCDIR/fdtfactor_test.its"
+    cp fdtfactor.test.fit fdtfactor_orig.test.fit
+    run_wrap_test $PYTHON $FDTFACTOR -s -F fdtfactor.test.fit
+    run_fdtget_test "fdt-base-1.dtb" fdtfactor.test.fit \
+	/images/fdt-base-1 description
+    run_fdtget_test "fdtfactor_board1.test.dtbo" fdtfactor.test.fit \
+	/images/fdt-1 description
+    run_fdtget_test "fdt-base-2.dtb" fdtfactor.test.fit \
+	/images/fdt-base-2 description
+    run_fdtget_test "fdtfactor_other1.test.dtbo" fdtfactor.test.fit \
+	/images/fdt-4 description
+    run_fdtget_test "fdt-base-1 fdt-1" fdtfactor.test.fit \
+	/configurations/conf-1 fdt
+    run_fdtget_test "fdt-base-1 fdt-2" fdtfactor.test.fit \
+	/configurations/conf-2 fdt
+    run_fdtget_test "fdt-base-1 fdt-3 fdt-ovl-1" fdtfactor.test.fit \
+	/configurations/conf-3 fdt
+    run_fdtget_test "fdt-base-2 fdt-4" fdtfactor.test.fit \
+	/configurations/conf-4 fdt
+    run_fdtget_test "fdt-base-2 fdt-5" fdtfactor.test.fit \
+	/configurations/conf-5 fdt
+    # conf-1 already had an explicit compatible property; conf-2 did not, so
+    # fdtfactor should populate it from fdt-2's root compatible property.
+    run_fdtget_test "vendor,board1 vendor,common" fdtfactor.test.fit \
+	/configurations/conf-1 compatible
+    run_fdtget_test "test_fdtfactor" fdtfactor.test.fit \
+	/configurations/conf-2 compatible
+    run_fdtget_test "vendor,other1 vendor,other" fdtfactor.test.fit \
+	/configurations/conf-4 compatible
+    # Verify that hash-1/value was populated for fdt-base-1, fdt-base-2, and
+    # updated for fdt-1.
+    base1_hash=$($DTGET -t bx fdtfactor.test.fit \
+	/images/fdt-base-1/hash-1 value)
+    base2_hash=$($DTGET -t bx fdtfactor.test.fit \
+	/images/fdt-base-2/hash-1 value)
+    fdt1_hash=$($DTGET -t bx fdtfactor.test.fit \
+	/images/fdt-1/hash-1 value)
+    if [ -z "$base1_hash" ] || [ -z "$base2_hash" ] || \
+       [ "$fdt1_hash" = "0 0 0 0 0 0 0 0" ]; then
+	FAIL "FIT hash-1 values were not updated"
+    fi
+    # Extract factored base DTBs and overlays from the FIT and verify all 5
+    # boards with fdtoverlay + dtbs_equal_unordered.
+    fdtget_raw fdtfactor.test.fit /images/fdt-base-1 data fit_base1.test.dtb
+    for i in 1 2 3; do
+	fdtget_raw fdtfactor.test.fit /images/fdt-${i} data \
+	    fit_fdt${i}.test.dtbo
+	run_wrap_test $FDTOVERLAY -i fit_base1.test.dtb \
+	    -o fit_merged${i}.test.dtb fit_fdt${i}.test.dtbo
+	run_test dtbs_equal_unordered fdtfactor_board${i}.test.dtb \
+	    fit_merged${i}.test.dtb
+    done
+    fdtget_raw fdtfactor.test.fit /images/fdt-base-2 data fit_base2.test.dtb
+    for i in 1 2; do
+	idx=$((i + 3))
+	fdtget_raw fdtfactor.test.fit /images/fdt-${idx} data \
+	    fit_fdt${idx}.test.dtbo
+	run_wrap_test $FDTOVERLAY -i fit_base2.test.dtb \
+	    -o fit_merged${idx}.test.dtb fit_fdt${idx}.test.dtbo
+	run_test dtbs_equal_unordered fdtfactor_other${i}.test.dtb \
+	    fit_merged${idx}.test.dtb
+    done
+
+    # --single-base-dtb forces a single fdt-base-1 across all FDTs
+    run_wrap_test $PYTHON $FDTFACTOR --single-base-dtb \
+	-F fdtfactor_orig.test.fit --outfile fdtfactor_single.test.fit
+    run_fdtget_test "fdt-base-1.dtb" fdtfactor_single.test.fit \
+	/images/fdt-base-1 description
+    run_fdtget_test "fdt-base-1 fdt-1" fdtfactor_single.test.fit \
+	/configurations/conf-1 fdt
+    run_fdtget_test "fdt-base-1 fdt-4" fdtfactor_single.test.fit \
+	/configurations/conf-4 fdt
+    fdtget_raw fdtfactor_single.test.fit /images/fdt-base-1 data \
+	fit_single_base1.test.dtb
+    for i in 1 2 3; do
+	fdtget_raw fdtfactor_single.test.fit /images/fdt-${i} data \
+	    fit_single_fdt${i}.test.dtbo
+	run_wrap_test $FDTOVERLAY -i fit_single_base1.test.dtb \
+	    -o fit_single_merged${i}.test.dtb fit_single_fdt${i}.test.dtbo
+	run_test dtbs_equal_unordered fdtfactor_board${i}.test.dtb \
+	    fit_single_merged${i}.test.dtb
+    done
+    for i in 1 2; do
+	idx=$((i + 3))
+	fdtget_raw fdtfactor_single.test.fit /images/fdt-${idx} data \
+	    fit_single_fdt${idx}.test.dtbo
+	run_wrap_test $FDTOVERLAY -i fit_single_base1.test.dtb \
+	    -o fit_single_merged${idx}.test.dtb fit_single_fdt${idx}.test.dtbo
+	run_test dtbs_equal_unordered fdtfactor_other${i}.test.dtb \
+	    fit_single_merged${idx}.test.dtb
+    done
+
+    # Verify that conflicting image properties (e.g. arch) in a group fail
+    cp fdtfactor_orig.test.fit fdtfactor_bad_arch.test.fit
+    run_wrap_test $DTPUT fdtfactor_bad_arch.test.fit /images/fdt-4 \
+	arch -ts "arm"
+    run_wrap_error_test $PYTHON $FDTFACTOR --single-base-dtb \
+	-F fdtfactor_bad_arch.test.fit
+
+    # Verify that signed FIT images (with signature* subnodes) and
+    # already-factored FIT images (containing fdt-base-*) are rejected
+    cp fdtfactor_orig.test.fit fdtfactor_signed.test.fit
+    run_wrap_test $DTPUT fdtfactor_signed.test.fit -c \
+	/configurations/conf-1/signature-1
+    run_wrap_error_test $PYTHON $FDTFACTOR -F fdtfactor_signed.test.fit
+    run_wrap_error_test $PYTHON $FDTFACTOR -F fdtfactor.test.fit
+
+    if command -v lz4 >/dev/null 2>&1; then
+	run_wrap_test lz4 -12 -q -f -m \
+	    fdtfactor_board1.test.dtb fdtfactor_board2.test.dtb \
+	    fdtfactor_board3.test.dtb fdtfactor_other1.test.dtb \
+	    fdtfactor_other2.test.dtb overlay_overlay_phandle.test.dtb
+	run_dtc_test -i . -I dts -O dtb -o fdtfactor_lz4.test.fit \
+	    "$SRCDIR/fdtfactor_lz4.its"
+	run_wrap_test $PYTHON $FDTFACTOR -s \
+	    -F fdtfactor_lz4.test.fit --outfile fdtfactor_lz4_out.test.fit
+	run_fdtget_test "fdt-base-1.dtb.lz4" fdtfactor_lz4_out.test.fit \
+	    /images/fdt-base-1 description
+	run_fdtget_test "fdtfactor_board1.test.dtbo.lz4" \
+	    fdtfactor_lz4_out.test.fit /images/fdt-1 description
+	run_fdtget_test "fdt-base-2.dtb.lz4" fdtfactor_lz4_out.test.fit \
+	    /images/fdt-base-2 description
+	run_fdtget_test "fdtfactor_other1.test.dtbo.lz4" \
+	    fdtfactor_lz4_out.test.fit /images/fdt-4 description
+	run_fdtget_test "fdt-base-1 fdt-1" fdtfactor_lz4_out.test.fit \
+	    /configurations/conf-1 fdt
+	run_fdtget_test "fdt-base-2 fdt-4" fdtfactor_lz4_out.test.fit \
+	    /configurations/conf-4 fdt
+	fdtget_raw fdtfactor_lz4_out.test.fit /images/fdt-base-1 data \
+	    fit_lz4_base1.test.dtb.lz4
+	fdtget_raw fdtfactor_lz4_out.test.fit /images/fdt-1 data \
+	    fit_lz4_fdt1.test.dtbo.lz4
+	run_wrap_test lz4 -d -q -f --rm fit_lz4_base1.test.dtb.lz4
+	run_wrap_test lz4 -d -q -f --rm fit_lz4_fdt1.test.dtbo.lz4
+	run_wrap_test $FDTOVERLAY -i fit_lz4_base1.test.dtb \
+	    -o fit_lz4_merged1.test.dtb fit_lz4_fdt1.test.dtbo
+	run_test dtbs_equal_unordered fdtfactor_board1.test.dtb \
+	    fit_lz4_merged1.test.dtb
+    fi
 }
 
 while getopts "vt:me" ARG ; do
@@ -1308,7 +1464,7 @@ if [ -z "$TESTSETS" ]; then
 fi
 
 # Make sure we don't have stale blobs lying around
-rm -f *.test.dtb *.test.dtbo *.test.dts *.test.dtb.gz *.test.dtbo.gz \
+rm -f *.test.dtb *.test.dtbo *.test.dts *.test.fit *.test.dtb.gz *.test.dtbo.gz \
     *.test.dtb.xz *.test.dtbo.xz *.test.dtb.lzma *.test.dtbo.lzma \
     *.test.dtb.lz4 *.test.dtbo.lz4
 rm -rf *.test.dir
-- 
2.56.0.313.g2479322e68-goog


^ permalink raw reply	[flat|nested] 12+ messages in thread

* Re: [PATCH v2 02/10] pylibfdt: Add subnode/property iterators and get_mem_rsv_list()
  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
  0 siblings, 0 replies; 12+ messages in thread
From: David Gibson @ 2026-10-29  8:38 UTC (permalink / raw)
  To: Douglas Anderson
  Cc: Devicetree-compiler, Rob Herring, Krzysztof Kozlowski,
	Conor Dooley, Konrad Dybcio, Brian Norris, Chen-Yu Tsai,
	André Draszik, Devicetree, Simon Glass, Tudor Ambarus,
	linux-kernel, Julius Werner, Saravana Kannan, Peter Griffin

[-- Attachment #1: Type: text/plain, Size: 5625 bytes --]

On Wed, Oct 28, 2026 at 02:46:51PM -0700, Douglas Anderson wrote:
> Add Pythonic iterator helpers on `FdtRo` to simplify walking nodes,
> properties, and memory reservation entries without manual offset loops
> or `-FDT_ERR_NOTFOUND` sentinel handling:
> 
> - `subnodes(nodeoffset)`: yields the offset of each direct child subnode
>   of `nodeoffset`.
> - `properties(nodeoffset)`: yields a `Property` object for each property
>   of `nodeoffset`.
> - `get_mem_rsv_list()`: returns a list of `(address, size)` tuples for
>   all entries in the memory reservation block.
> 
> Assisted-by: Gemini:gemini-pro-prerelease
> Signed-off-by: Douglas Anderson <dianders@chromium.org>

Patches 1 & 2 are useful independent of the rest of the series.
Applied, thanks.

> ---
> 
> Changes in v2:
> - New patch adding FdtRo.subnodes(), FdtRo.properties(), and
>   FdtRo.get_mem_rsv_list()
> 
>  pylibfdt/libfdt.i       | 45 +++++++++++++++++++++++++++++++++++++++++
>  tests/pylibfdt_tests.py | 18 +++++++++++++++++
>  2 files changed, 63 insertions(+)
> 
> diff --git a/pylibfdt/libfdt.i b/pylibfdt/libfdt.i
> index 46f28f67290d..0db7110b4a38 100644
> --- a/pylibfdt/libfdt.i
> +++ b/pylibfdt/libfdt.i
> @@ -201,6 +201,23 @@ class FdtRo(object):
>          """
>          return check_err(fdt_next_subnode(self._fdt, nodeoffset), quiet)
>  
> +    def subnodes(self, nodeoffset):
> +        """Iterate over the subnodes of a node
> +
> +        Args:
> +            nodeoffset: Offset of parent node to check
> +
> +        Yields:
> +            Node offset of each direct subnode in order
> +
> +        Raises:
> +            FdtException on error (e.g. invalid nodeoffset)
> +        """
> +        soff = self.first_subnode(nodeoffset, quiet=QUIET_NOTFOUND)
> +        while soff >= 0:
> +            yield soff
> +            soff = self.next_subnode(soff, quiet=QUIET_NOTFOUND)
> +
>      def magic(self):
>          """Return the magic word from the header
>  
> @@ -302,6 +319,17 @@ class FdtRo(object):
>          check_err(val[0], quiet)
>          return val[1:]
>  
> +    def get_mem_rsv_list(self):
> +        """Return a list of all memory reserve-map records
> +
> +        Returns:
> +            List of (address, size) tuples for each memory reserve-map record
> +        """
> +        return [
> +            tuple(self.get_mem_rsv(i))
> +            for i in range(self.num_mem_rsv())
> +        ]
> +
>      def address_cells(self, nodeoffset, quiet=()):
>          """Return the number of address cells used by a node's children
>  
> @@ -434,6 +462,23 @@ class FdtRo(object):
>              return pdata[1]
>          return Property(pdata[0], pdata[1])
>  
> +    def properties(self, nodeoffset):
> +        """Iterate over the properties of a node
> +
> +        Args:
> +            nodeoffset: Offset of node to check
> +
> +        Yields:
> +            Property object for each property on @nodeoffset in order
> +
> +        Raises:
> +            FdtException on error (e.g. invalid nodeoffset)
> +        """
> +        poff = self.first_property_offset(nodeoffset, quiet=QUIET_NOTFOUND)
> +        while poff >= 0:
> +            yield self.get_property_by_offset(poff)
> +            poff = self.next_property_offset(poff, quiet=QUIET_NOTFOUND)
> +
>      def getprop(self, nodeoffset, prop_name, quiet=()):
>          """Get a property from a node
>  
> diff --git a/tests/pylibfdt_tests.py b/tests/pylibfdt_tests.py
> index 1b65961491bf..2a0509c7802e 100644
> --- a/tests/pylibfdt_tests.py
> +++ b/tests/pylibfdt_tests.py
> @@ -243,6 +243,22 @@ class PyLibfdtBasicTests(unittest.TestCase):
>              node_list.append(self.fdt.get_name(node))
>              node = self.fdt.next_subnode(node, QUIET_NOTFOUND)
>          self.assertEqual(node_list, ['subnode@1', 'subnode@2'])
> +        self.assertEqual(
> +            [self.fdt.get_name(n) for n in self.fdt.subnodes(0)],
> +            ['subnode@1', 'subnode@2'],
> +        )
> +        leaf = self.fdt.path_offset('/subnode@1/ss1')
> +        self.assertEqual(list(self.fdt.subnodes(leaf)), [])
> +
> +    def testPropertiesIterator(self):
> +        """Check that we can iterate through properties of a node"""
> +        node = self.fdt.path_offset('/subnode@1')
> +        props = list(self.fdt.properties(node))
> +        self.assertEqual([p.name for p in props],
> +                         ['compatible', 'reg', 'prop-int'])
> +        self.assertEqual(props[0], b'subnode1\0')
> +        leaf = self.fdt.path_offset('/subnode@1/ss1')
> +        self.assertEqual(list(self.fdt.properties(leaf)), [])
>  
>      def testFirstNextSubnodeOffsetExceptions(self):
>          """Check except handling for first/next subnode functions"""
> @@ -410,6 +426,8 @@ class PyLibfdtBasicTests(unittest.TestCase):
>          self.assertEqual([ 0xdeadbeef00000000, 0x100000],
>                            self.fdt.get_mem_rsv(0))
>          self.assertEqual([123456789, 0o10000], self.fdt.get_mem_rsv(1))
> +        self.assertEqual([(0xdeadbeef00000000, 0x100000), (123456789, 0o10000)],
> +                         self.fdt.get_mem_rsv_list())
>  
>      def testAddDelReserveMap(self):
>          """Test that we can add to and remove from the memory reserve map"""
> -- 
> 2.56.0.313.g2479322e68-goog
> 
> 

-- 
David Gibson (he or they)	| I'll have my music baroque, and my code
david AT gibson.dropbear.id.au	| minimalist, thank you, not the other way
				| around.
http://www.ozlabs.org/~dgibson

[-- Attachment #2: signature.asc --]
[-- Type: application/pgp-signature, Size: 833 bytes --]

^ permalink raw reply	[flat|nested] 12+ messages in thread

end of thread, other threads:[~2026-10-29  9:11 UTC | newest]

Thread overview: 12+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
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 ` [PATCH v2 07/10] fdtfactor: Add tool to split DTBs into a base DTB and overlays Douglas Anderson
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

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®