cmake: scripts: support board extension

Fixes: #69548

Support extending an existing board with new board variants.

This commit introduces the following changes to allow a board to be
extended out-of-tree.

The board yaml schema is extended to support an extend field which
will be used to identify the board to be extended.

A board 'plank' can be extended like this:
> board:
>   extend: plank
>   variants:
>     - name: ext
>       qualifier: soc1

For the rest of the build system this means that there is no longer a
single board directory.
The existing CMake variable BOARD_DIR is kept and reference the
directory which defines the board.
A new CMake variable BOARD_DIRECTORIES provides a list of all
directories which defines board targets for the board.
This means the directory which defines the board as well as all
directories that extends the board.

Signed-off-by: Torsten Rasmussen <Torsten.Rasmussen@nordicsemi.no>
diff --git a/Kconfig.zephyr b/Kconfig.zephyr
index 425d79f..f978198 100644
--- a/Kconfig.zephyr
+++ b/Kconfig.zephyr
@@ -17,13 +17,13 @@
 # Shield defaults should have precedence over board defaults, which should have
 # precedence over SoC defaults, so include them in that order.
 #
-# $ARCH and $BOARD_DIR will be glob patterns when building documentation.
+# $ARCH and $KCONFIG_BOARD_DIR will be glob patterns when building documentation.
 # This loads custom shields defconfigs (from BOARD_ROOT)
 osource "$(KCONFIG_BINARY_DIR)/Kconfig.shield.defconfig"
 # This loads Zephyr base shield defconfigs
 source "boards/shields/*/Kconfig.defconfig"
 
-osource "$(BOARD_DIR)/Kconfig.defconfig"
+osource "$(KCONFIG_BOARD_DIR)/Kconfig.defconfig"
 
 # This loads Zephyr specific SoC root defconfigs
 source "$(KCONFIG_BINARY_DIR)/soc/Kconfig.defconfig"
diff --git a/boards/Kconfig b/boards/Kconfig
index 6eb9ca5..8f186b3 100644
--- a/boards/Kconfig
+++ b/boards/Kconfig
@@ -129,7 +129,7 @@
 	  GDBstub over serial with `-serial tcp:127.0.0.1:5678,server`
 
 # There might not be any board options, hence the optional source
-osource "$(BOARD_DIR)/Kconfig"
+osource "$(KCONFIG_BOARD_DIR)/Kconfig"
 endmenu
 
 config BOARD_HAS_TIMING_FUNCTIONS
diff --git a/boards/Kconfig.v1 b/boards/Kconfig.v1
index 670e2f2..c98bd27 100644
--- a/boards/Kconfig.v1
+++ b/boards/Kconfig.v1
@@ -2,9 +2,13 @@
 
 # SPDX-License-Identifier: Apache-2.0
 
+# In HWMv1 the KCONFIG_BOARD_DIR points directly to the BOARD_DIR.
+# Set the BOARD_DIR variable for backwards compatibility to legacy hardware model.
+BOARD_DIR := $(KCONFIG_BOARD_DIR)
+
 choice
 	prompt "Board Selection"
 
-source "$(BOARD_DIR)/Kconfig.board"
+source "$(KCONFIG_BOARD_DIR)/Kconfig.board"
 
 endchoice
diff --git a/boards/Kconfig.v2 b/boards/Kconfig.v2
index 47bb3ae..6fce9cc 100644
--- a/boards/Kconfig.v2
+++ b/boards/Kconfig.v2
@@ -25,4 +25,4 @@
 	  For example, if building for ``nrf5340dk/nrf5340/cpuapp`` then this will contain the
 	  value ``nrf5340/cpuapp``.
 
-osource "$(BOARD_DIR)/Kconfig.$(BOARD)"
+osource "$(KCONFIG_BOARD_DIR)/Kconfig.$(BOARD)"
diff --git a/cmake/modules/boards.cmake b/cmake/modules/boards.cmake
index a1b05b0..2b78845 100644
--- a/cmake/modules/boards.cmake
+++ b/cmake/modules/boards.cmake
@@ -185,9 +185,7 @@
 set(format_str "${format_str}{REVISION_FORMAT}\;{REVISION_DEFAULT}\;{REVISION_EXACT}\;")
 set(format_str "${format_str}{REVISIONS}\;{SOCS}\;{QUALIFIERS}")
 
-if(BOARD_DIR)
-  set(board_dir_arg "--board-dir=${BOARD_DIR}")
-endif()
+list(TRANSFORM BOARD_DIRECTORIES PREPEND "--board-dir=" OUTPUT_VARIABLE board_dir_arg)
 execute_process(${list_boards_commands} --board=${BOARD} ${board_dir_arg}
   --cmakeformat=${format_str}
                 OUTPUT_VARIABLE ret_board
@@ -200,29 +198,15 @@
 
 if(NOT "${ret_board}" STREQUAL "")
   string(STRIP "${ret_board}" ret_board)
-  string(FIND "${ret_board}" "\n" idx REVERSE)
-  if(idx GREATER -1)
-    while(TRUE)
-      math(EXPR start "${idx} + 1")
-      string(SUBSTRING "${ret_board}" ${start} -1 line)
-      string(SUBSTRING "${ret_board}" 0 ${idx} ret_board)
-
-      cmake_parse_arguments(LIST_BOARD "" "DIR" "" ${line})
-      set(board_dirs "${board_dirs}\n${LIST_BOARD_DIR}")
-
-      if(idx EQUAL -1)
-        break()
-      endif()
-      string(FIND "${ret_board}" "\n" idx REVERSE)
-    endwhile()
-    message(FATAL_ERROR "Multiple boards named '${BOARD}' found in:${board_dirs}")
-  endif()
-
-  set(single_val "NAME;DIR;HWM;REVISION_FORMAT;REVISION_DEFAULT;REVISION_EXACT")
-  set(multi_val  "REVISIONS;SOCS;QUALIFIERS")
+  set(single_val "NAME;HWM;REVISION_FORMAT;REVISION_DEFAULT;REVISION_EXACT")
+  set(multi_val  "DIR;REVISIONS;SOCS;QUALIFIERS")
   cmake_parse_arguments(LIST_BOARD "" "${single_val}" "${multi_val}" ${ret_board})
-  set(BOARD_DIR ${LIST_BOARD_DIR} CACHE PATH "Board directory for board (${BOARD})" FORCE)
-  set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS ${BOARD_DIR}/board.yml)
+  list(GET LIST_BOARD_DIR 0 BOARD_DIR)
+  set(BOARD_DIR ${BOARD_DIR} CACHE PATH "Main board directory for board (${BOARD})" FORCE)
+  set(BOARD_DIRECTORIES ${LIST_BOARD_DIR} CACHE INTERNAL "List of board directories for board (${BOARD})" FORCE)
+  foreach(dir ${BOARD_DIRECTORIES})
+    set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS ${dir}/board.yml)
+  endforeach()
 
   # Create two CMake variables identifying the hw model.
   # CMake variable: HWM=[v1,v2]
diff --git a/cmake/modules/dts.cmake b/cmake/modules/dts.cmake
index 16b497d..a2c5657 100644
--- a/cmake/modules/dts.cmake
+++ b/cmake/modules/dts.cmake
@@ -76,9 +76,9 @@
 #
 # Optional variables:
 # - BOARD: board name to use when looking for DTS_SOURCE
-# - BOARD_DIR: board directory to use when looking for DTS_SOURCE
+# - BOARD_DIRECTORIES: list of board directories to use when looking for DTS_SOURCE
 # - BOARD_REVISION_STRING: used when looking for a board revision's
-#   devicetree overlay file in BOARD_DIR
+#   devicetree overlay file in one of the BOARD_DIRECTORIES
 # - CMAKE_DTS_PREPROCESSOR: the path to the preprocessor to use
 #   for devicetree files
 # - DTC_OVERLAY_FILE: list of devicetree overlay files which will be
@@ -94,7 +94,7 @@
 #   C preprocessor when generating the devicetree from DTS_SOURCE
 # - DTS_SOURCE: the devicetree source file to use may be pre-set
 #   with this variable; otherwise, it defaults to
-#   ${BOARD_DIR}/${BOARD}.dts
+#   ${BOARD_DIRECTORIES}/<normalized_board_target>.dts
 #
 # Variables set by this module and not mentioned above are for internal
 # use only, and may be removed, renamed, or re-purposed without prior notice.
@@ -137,28 +137,30 @@
   zephyr_build_string(board_string SHORT shortened_board_string
                       BOARD ${BOARD} BOARD_QUALIFIERS ${BOARD_QUALIFIERS}
   )
-  if(EXISTS ${BOARD_DIR}/${shortened_board_string}.dts AND NOT BOARD_${BOARD}_SINGLE_SOC)
-    message(FATAL_ERROR "Board ${ZFILE_BOARD} defines multiple SoCs.\nShortened file name "
-            "(${shortened_board_string}.dts) not allowed, use '<board>_<soc>.dts' naming"
-    )
-  elseif(EXISTS ${BOARD_DIR}/${board_string}.dts AND EXISTS ${BOARD_DIR}/${shortened_board_string}.dts)
-    message(FATAL_ERROR "Conflicting file names discovered. Cannot use both "
-            "${board_string}.dts and ${shortened_board_string}.dts. "
-            "Please choose one naming style, ${board_string}.dts is recommended."
-    )
-  elseif(EXISTS ${BOARD_DIR}/${board_string}.dts)
-    set(DTS_SOURCE ${BOARD_DIR}/${board_string}.dts)
-  elseif(EXISTS ${BOARD_DIR}/${shortened_board_string}.dts)
-    set(DTS_SOURCE ${BOARD_DIR}/${shortened_board_string}.dts)
-  endif()
+  foreach(dir ${BOARD_DIRECTORIES})
+    if(EXISTS ${dir}/${shortened_board_string}.dts AND NOT BOARD_${BOARD}_SINGLE_SOC)
+      message(FATAL_ERROR "Board ${ZFILE_BOARD} defines multiple SoCs.\nShortened file name "
+              "(${shortened_board_string}.dts) not allowed, use '<board>_<soc>.dts' naming"
+      )
+    elseif(EXISTS ${dir}/${board_string}.dts AND EXISTS ${dir}/${shortened_board_string}.dts)
+      message(FATAL_ERROR "Conflicting file names discovered. Cannot use both "
+              "${board_string}.dts and ${shortened_board_string}.dts. "
+              "Please choose one naming style, ${board_string}.dts is recommended."
+      )
+    elseif(EXISTS ${dir}/${board_string}.dts)
+      set(DTS_SOURCE ${dir}/${board_string}.dts)
+    elseif(EXISTS ${dir}/${shortened_board_string}.dts)
+      set(DTS_SOURCE ${dir}/${shortened_board_string}.dts)
+    endif()
+  endforeach()
 endif()
 
 if(EXISTS ${DTS_SOURCE})
   # We found a devicetree. Append all relevant dts overlays we can find...
-  zephyr_file(CONF_FILES ${BOARD_DIR} DTS DTS_SOURCE)
+  zephyr_file(CONF_FILES ${BOARD_DIRECTORIES} DTS DTS_SOURCE)
 
   zephyr_file(
-    CONF_FILES ${BOARD_DIR}
+    CONF_FILES ${BOARD_DIRECTORIES}
     DTS no_rev_suffix_dts_board_overlays
     BOARD ${BOARD}
     BOARD_QUALIFIERS ${BOARD_QUALIFIERS}
diff --git a/cmake/modules/hwm_v2.cmake b/cmake/modules/hwm_v2.cmake
index b514a33..c4feb03 100644
--- a/cmake/modules/hwm_v2.cmake
+++ b/cmake/modules/hwm_v2.cmake
@@ -95,11 +95,15 @@
 list(REMOVE_DUPLICATES kconfig_soc_source_dir)
 
 # Support multiple ARCH_ROOT, SOC_ROOT and BOARD_ROOT
-kconfig_gen("arch" "Kconfig"           "${kconfig_arch_source_dir}" "Zephyr Arch Kconfig")
-kconfig_gen("soc"  "Kconfig.defconfig" "${kconfig_soc_source_dir}"  "Zephyr SoC defconfig")
-kconfig_gen("soc"  "Kconfig"           "${kconfig_soc_source_dir}"  "Zephyr SoC Kconfig")
-kconfig_gen("soc"  "Kconfig.soc"       "${kconfig_soc_source_dir}"  "SoC Kconfig")
-kconfig_gen("soc"  "Kconfig.sysbuild"  "${kconfig_soc_source_dir}"  "Sysbuild SoC Kconfig")
+kconfig_gen("arch" "Kconfig"             "${kconfig_arch_source_dir}" "Zephyr Arch Kconfig")
+kconfig_gen("soc"  "Kconfig.defconfig"   "${kconfig_soc_source_dir}"  "Zephyr SoC defconfig")
+kconfig_gen("soc"  "Kconfig"             "${kconfig_soc_source_dir}"  "Zephyr SoC Kconfig")
+kconfig_gen("soc"  "Kconfig.soc"         "${kconfig_soc_source_dir}"  "SoC Kconfig")
+kconfig_gen("soc"  "Kconfig.sysbuild"    "${kconfig_soc_source_dir}"  "Sysbuild SoC Kconfig")
+kconfig_gen("boards" "Kconfig.defconfig" "${BOARD_DIRECTORIES}"       "Zephyr board defconfig")
+kconfig_gen("boards" "Kconfig.${BOARD}"  "${BOARD_DIRECTORIES}"       "board Kconfig")
+kconfig_gen("boards" "Kconfig"           "${BOARD_DIRECTORIES}"       "Zephyr board Kconfig")
+kconfig_gen("boards" "Kconfig.sysbuild"  "${BOARD_DIRECTORIES}"       "Sysbuild board Kconfig")
 
 # Clear variables created by cmake_parse_arguments
 unset(SOC_V2_NAME)
diff --git a/cmake/modules/kconfig.cmake b/cmake/modules/kconfig.cmake
index 0273d39..02bebbe 100644
--- a/cmake/modules/kconfig.cmake
+++ b/cmake/modules/kconfig.cmake
@@ -21,9 +21,12 @@
 set_ifndef(KCONFIG_NAMESPACE "CONFIG")
 
 set_ifndef(KCONFIG_BINARY_DIR ${CMAKE_CURRENT_BINARY_DIR}/Kconfig)
+set(KCONFIG_BOARD_DIR ${KCONFIG_BINARY_DIR}/boards)
 file(MAKE_DIRECTORY ${KCONFIG_BINARY_DIR})
 
 if(HWMv1)
+  # HWMv1 only supoorts a single board dir which points directly to the board dir.
+  set(KCONFIG_BOARD_DIR ${BOARD_DIR})
   # Support multiple SOC_ROOT
   file(MAKE_DIRECTORY ${KCONFIG_BINARY_DIR}/soc)
   set(kconfig_soc_root ${SOC_ROOT})
@@ -73,7 +76,7 @@
 endif()
 
 if(NOT DEFINED BOARD_DEFCONFIG)
-  zephyr_file(CONF_FILES ${BOARD_DIR} DEFCONFIG BOARD_DEFCONFIG)
+  zephyr_file(CONF_FILES ${BOARD_DIRECTORIES} DEFCONFIG BOARD_DEFCONFIG)
 endif()
 
 if(DEFINED BOARD_REVISION)
@@ -157,7 +160,7 @@
   APP_VERSION_TWEAK_STRING=${APP_VERSION_TWEAK_STRING}
   CONFIG_=${KCONFIG_NAMESPACE}_
   KCONFIG_CONFIG=${DOTCONFIG}
-  BOARD_DIR=${BOARD_DIR}
+  KCONFIG_BOARD_DIR=${KCONFIG_BOARD_DIR}
   BOARD=${BOARD}
   BOARD_REVISION=${BOARD_REVISION}
   BOARD_QUALIFIERS=${BOARD_QUALIFIERS}
diff --git a/cmake/modules/kernel.cmake b/cmake/modules/kernel.cmake
index 1946e23..6a1a48b 100644
--- a/cmake/modules/kernel.cmake
+++ b/cmake/modules/kernel.cmake
@@ -173,7 +173,9 @@
   set_property(GLOBAL PROPERTY TARGET_SUPPORTS_SHARED_LIBS TRUE)
 endif()
 
-include(${BOARD_DIR}/board.cmake OPTIONAL)
+foreach(dir ${BOARD_DIRECTORIES})
+  include(${dir}/board.cmake OPTIONAL)
+endforeach()
 
 # If we are using a suitable ethernet driver inside qemu, then these options
 # must be set, otherwise a zephyr instance cannot receive any network packets.
diff --git a/doc/_extensions/zephyr/kconfig/__init__.py b/doc/_extensions/zephyr/kconfig/__init__.py
index 6052db6..abbdcc1 100644
--- a/doc/_extensions/zephyr/kconfig/__init__.py
+++ b/doc/_extensions/zephyr/kconfig/__init__.py
@@ -91,7 +91,7 @@
         root_args = argparse.Namespace(**{'soc_roots': [Path(ZEPHYR_BASE)]})
         v2_systems = list_hardware.find_v2_systems(root_args)
 
-        soc_folders = {soc.folder for soc in v2_systems.get_socs()}
+        soc_folders = {soc.folder[0] for soc in v2_systems.get_socs()}
         with open(Path(td) / "soc" / "Kconfig.defconfig", "w") as f:
             f.write('')
 
@@ -114,8 +114,9 @@
 
         (Path(td) / 'boards').mkdir(exist_ok=True)
         root_args = argparse.Namespace(**{'board_roots': [Path(ZEPHYR_BASE)],
-                                          'soc_roots': [Path(ZEPHYR_BASE)], 'board': None})
-        v2_boards = list_boards.find_v2_boards(root_args)
+                                          'soc_roots': [Path(ZEPHYR_BASE)], 'board': None,
+                                          'board_dir': []})
+        v2_boards = list_boards.find_v2_boards(root_args).values()
 
         with open(Path(td) / "boards" / "Kconfig.boards", "w") as f:
             for board in v2_boards:
@@ -126,7 +127,8 @@
                     board_str = 'BOARD_' + re.sub(r"[^a-zA-Z0-9_]", "_", qualifier).upper()
                     f.write('config  ' + board_str + '\n')
                     f.write('\t bool\n')
-                f.write('source "' + (board.dir / ('Kconfig.' + board.name)).as_posix() + '"\n\n')
+                f.write('source "' +
+                        (board.directories[0] / ('Kconfig.' + board.name)).as_posix() + '"\n\n')
 
         # base environment
         os.environ["ZEPHYR_BASE"] = str(ZEPHYR_BASE)
@@ -140,7 +142,7 @@
         os.environ["HWM_SCHEME"] = "v2"
 
         os.environ["BOARD"] = "boards"
-        os.environ["BOARD_DIR"] = str(Path(td) / "boards")
+        os.environ["KCONFIG_BOARD_DIR"] = str(Path(td) / "boards")
 
         # insert external Kconfigs to the environment
         module_paths = dict()
diff --git a/doc/_scripts/gen_boards_catalog.py b/doc/_scripts/gen_boards_catalog.py
index 859f37c..52be751 100644
--- a/doc/_scripts/gen_boards_catalog.py
+++ b/doc/_scripts/gen_boards_catalog.py
@@ -70,7 +70,7 @@
         arch_roots=module_settings["arch_root"],
         board_roots=module_settings["board_root"],
         soc_roots=module_settings["soc_root"],
-        board_dir=ZEPHYR_BASE / "boards",
+        board_dir=[],
         board=None,
     )
 
@@ -78,7 +78,7 @@
     systems = list_hardware.find_v2_systems(args_find_boards)
     board_catalog = {}
 
-    for board in boards:
+    for board in boards.values():
         # We could use board.vendor but it is often incorrect. Instead, deduce vendor from
         # containing folder. There are a few exceptions, like the "native" and "others" folders
         # which we know are not actual vendors so treat them as such.
diff --git a/scripts/ci/check_compliance.py b/scripts/ci/check_compliance.py
index f586b53..63e7fb9 100755
--- a/scripts/ci/check_compliance.py
+++ b/scripts/ci/check_compliance.py
@@ -511,8 +511,9 @@
         soc_roots = self.get_module_setting_root('soc', settings_file)
         soc_roots.insert(0, Path(ZEPHYR_BASE))
         root_args = argparse.Namespace(**{'board_roots': board_roots,
-                                          'soc_roots': soc_roots, 'board': None})
-        v2_boards = list_boards.find_v2_boards(root_args)
+                                          'soc_roots': soc_roots, 'board': None,
+                                          'board_dir': []})
+        v2_boards = list_boards.find_v2_boards(root_args).values()
 
         with open(kconfig_defconfig_file, 'w') as fp:
             for board in v2_boards:
@@ -546,7 +547,7 @@
         root_args = argparse.Namespace(**{'soc_roots': soc_roots})
         v2_systems = list_hardware.find_v2_systems(root_args)
 
-        soc_folders = {soc.folder for soc in v2_systems.get_socs()}
+        soc_folders = {soc.folder[0] for soc in v2_systems.get_socs()}
         with open(kconfig_defconfig_file, 'w') as fp:
             for folder in soc_folders:
                 fp.write('osource "' + (Path(folder) / 'Kconfig.defconfig').as_posix() + '"\n')
@@ -616,7 +617,7 @@
         os.makedirs(os.path.join(kconfiglib_dir, 'soc'), exist_ok=True)
         os.makedirs(os.path.join(kconfiglib_dir, 'arch'), exist_ok=True)
 
-        os.environ["BOARD_DIR"] = kconfiglib_boards_dir
+        os.environ["KCONFIG_BOARD_DIR"] = kconfiglib_boards_dir
         self.get_v2_model(kconfiglib_dir, os.path.join(kconfiglib_dir, "settings_file.txt"))
 
         # Tells Kconfiglib to generate warnings for all references to undefined
@@ -920,6 +921,9 @@
                               # Zephyr toolchain variant and therefore not
                               # visible to compliance.
         "BOARD_", # Used as regex in scripts/utils/board_v1_to_v2.py
+        "BOARD_MPS2_AN521_CPUTEST", # Used for board and SoC extension feature tests
+        "BOARD_NATIVE_SIM_NATIVE_64_TWO", # Used for board and SoC extension feature tests
+        "BOARD_NATIVE_SIM_NATIVE_ONE", # Used for board and SoC extension feature tests
         "BOOT_DIRECT_XIP", # Used in sysbuild for MCUboot configuration
         "BOOT_DIRECT_XIP_REVERT", # Used in sysbuild for MCUboot configuration
         "BOOT_FIRMWARE_LOADER", # Used in sysbuild for MCUboot configuration
diff --git a/scripts/ci/test_plan.py b/scripts/ci/test_plan.py
index 326068a..4bb428e 100755
--- a/scripts/ci/test_plan.py
+++ b/scripts/ci/test_plan.py
@@ -239,12 +239,12 @@
         # Look for boards in monitored repositories
         lb_args = argparse.Namespace(**{'arch_roots': roots, 'board_roots': roots, 'board': None, 'soc_roots':roots,
                                         'board_dir': None})
-        known_boards = list_boards.find_v2_boards(lb_args)
+        known_boards = list_boards.find_v2_boards(lb_args).values()
 
         for changed in changed_boards:
             for board in known_boards:
                 c = (zephyr_base / changed).resolve()
-                if c.is_relative_to(board.dir.resolve()):
+                if c.is_relative_to(board.directories[0].resolve()):
                     for file in glob.glob(os.path.join(board.dir, f"{board.name}*.yaml")):
                         with open(file, 'r', encoding='utf-8') as f:
                             b = yaml.load(f.read(), Loader=SafeLoader)
diff --git a/scripts/kconfig/lint.py b/scripts/kconfig/lint.py
index 30064c3..5a123de 100755
--- a/scripts/kconfig/lint.py
+++ b/scripts/kconfig/lint.py
@@ -209,7 +209,7 @@
         ZEPHYR_BASE=TOP_DIR,
         SOC_DIR="soc",
         ARCH_DIR="arch",
-        BOARD_DIR="boards/*/*",
+        KCONFIG_BOARD_DIR="boards/*/*",
         ARCH="*")
 
     kconf = kconfiglib.Kconfig(suppress_traceback=True)
diff --git a/scripts/list_boards.py b/scripts/list_boards.py
index bf71658..634c67d 100755
--- a/scripts/list_boards.py
+++ b/scripts/list_boards.py
@@ -4,13 +4,13 @@
 # SPDX-License-Identifier: Apache-2.0
 
 import argparse
-from collections import defaultdict
+from collections import defaultdict, Counter
 from dataclasses import dataclass, field
 import itertools
 from pathlib import Path
 import pykwalify.core
 import sys
-from typing import List
+from typing import List, Union
 import yaml
 import list_hardware
 from list_hardware import unique_paths
@@ -91,7 +91,8 @@
 @dataclass(frozen=True)
 class Board:
     name: str
-    dir: Path
+    # HWMv1 only supports a single Path, and requires Board dataclass to be hashable.
+    directories: Union[Path, List[Path]]
     hwm: str
     full_name: str = None
     arch: str = None
@@ -103,6 +104,41 @@
     socs: List[Soc] = field(default_factory=list, compare=False)
     variants: List[str] = field(default_factory=list, compare=False)
 
+    def from_qualifier(self, qualifiers):
+        qualifiers_list = qualifiers.split('/')
+
+        node = Soc(None)
+        n = len(qualifiers_list)
+        if n > 0:
+            soc_qualifier = qualifiers_list.pop(0)
+            for s in self.socs:
+                if s.name == soc_qualifier:
+                    node = s
+                    break
+
+        if n > 1:
+            if node.cpuclusters:
+                cpu_qualifier = qualifiers_list.pop(0)
+                for c in node.cpuclusters:
+                    if c.name == cpu_qualifier:
+                        node = c
+                        break
+                else:
+                    node = Variant(None)
+
+        for q in qualifiers_list:
+            for v in node.variants:
+                if v.name == q:
+                    node = v
+                    break
+            else:
+                node = Variant(None)
+
+        if node in (Soc(None), Variant(None)):
+            sys.exit(f'ERROR: qualifiers {qualifiers} not found when extending board {self.name}')
+
+        return node
+
 
 def board_key(board):
     return board.name
@@ -165,11 +201,10 @@
     for arch in arches:
         if not (boards / arch).is_dir():
             continue
-
         for maybe_board in (boards / arch).iterdir():
             if not maybe_board.is_dir():
                 continue
-            if board_dir is not None and board_dir != maybe_board:
+            if board_dir and maybe_board not in board_dir:
                 continue
             for maybe_defconfig in maybe_board.iterdir():
                 file_name = maybe_defconfig.name
@@ -181,7 +216,8 @@
 
 
 def load_v2_boards(board_name, board_yml, systems):
-    boards = []
+    boards = {}
+    board_extensions = []
     if board_yml.is_file():
         with board_yml.open('r', encoding='utf-8') as f:
             b = yaml.load(f.read(), Loader=SafeLoader)
@@ -199,6 +235,18 @@
 
         board_array = b.get('boards', [b.get('board', None)])
         for board in board_array:
+            mutual_exclusive = {'name', 'extend'}
+            if len(mutual_exclusive - board.keys()) < 1:
+                sys.exit(f'ERROR: Malformed "board" section in file: {board_yml.as_posix()}\n'
+                         f'{mutual_exclusive} are mutual exclusive at this level.')
+
+            # This is a extending an existing board, place in array to allow later processing.
+            if 'extend' in board:
+                board.update({'dir': board_yml.parent})
+                board_extensions.append(board)
+                continue
+
+            # Create board
             if board_name is not None:
                 if board['name'] != board_name:
                     # Not the board we're looking for, ignore.
@@ -220,9 +268,9 @@
             socs = [Soc.from_soc(systems.get_soc(s['name']), s.get('variants', []))
                     for s in board.get('socs', {})]
 
-            board = Board(
+            boards[board['name']] = Board(
                 name=board['name'],
-                dir=board_yml.parent,
+                directories=[board_yml.parent],
                 vendor=board.get('vendor'),
                 full_name=board.get('full_name'),
                 revision_format=board.get('revision', {}).get('format'),
@@ -234,8 +282,28 @@
                 variants=[Variant.from_dict(v) for v in board.get('variants', [])],
                 hwm='v2',
             )
-            boards.append(board)
-    return boards
+            board_qualifiers = board_v2_qualifiers(boards[board['name']])
+            duplicates = [q for q, n in Counter(board_qualifiers).items() if n > 1]
+            if duplicates:
+                sys.exit(f'ERROR: Duplicated board qualifiers detected {duplicates} for board: '
+                         f'{board["name"]}.\nPlease check content of: {board_yml.as_posix()}\n')
+    return boards, board_extensions
+
+
+def extend_v2_boards(boards, board_extensions):
+    for e in board_extensions:
+        board = boards.get(e['extend'])
+        if board is None:
+            continue
+        board.directories.append(e['dir'])
+
+        for v in e.get('variants', []):
+            node = board.from_qualifier(v['qualifier'])
+            if str(v['qualifier'] + '/' + v['name']) in board_v2_qualifiers(board):
+                board_yml = e['dir'] / BOARD_YML
+                sys.exit(f'ERROR: Variant: {v["name"]}, defined multiple times for board: '
+                         f'{board.name}.\nLast defined in {board_yml}')
+            node.variants.append(Variant.from_dict(v))
 
 
 # Note that this does not share the args.board functionality of find_v2_boards
@@ -253,14 +321,25 @@
     root_args = argparse.Namespace(**{'soc_roots': args.soc_roots})
     systems = list_hardware.find_v2_systems(root_args)
 
-    boards = []
+    boards = {}
+    board_extensions = []
     board_files = []
-    for root in unique_paths(args.board_roots):
-        board_files.extend((root / 'boards').rglob(BOARD_YML))
+    if args.board_dir:
+        board_files = [d / BOARD_YML for d in args.board_dir]
+    else:
+        for root in unique_paths(args.board_roots):
+            board_files.extend((root / 'boards').rglob(BOARD_YML))
 
     for board_yml in board_files:
-        b = load_v2_boards(args.board, board_yml, systems)
-        boards.extend(b)
+        b, e = load_v2_boards(args.board, board_yml, systems)
+        conflict_boards = set(boards.keys()).intersection(b.keys())
+        if conflict_boards:
+            sys.exit(f'ERROR: Board(s): {conflict_boards}, defined multiple times.\n'
+                     f'Last defined in {board_yml}')
+        boards.update(b)
+        board_extensions.extend(e)
+
+    extend_v2_boards(boards, board_extensions)
     return boards
 
 
@@ -285,7 +364,7 @@
                         help='add a soc root, may be given more than once')
     parser.add_argument("--board", dest='board', default=None,
                         help='lookup the specific board, fail if not found')
-    parser.add_argument("--board-dir", default=None, type=Path,
+    parser.add_argument("--board-dir", default=[], type=Path, action='append',
                         help='Only look for boards at the specific location')
 
 
@@ -327,20 +406,16 @@
 
 
 def dump_v2_boards(args):
-    if args.board_dir:
-        root_args = argparse.Namespace(**{'soc_roots': args.soc_roots})
-        systems = list_hardware.find_v2_systems(root_args)
-        boards = load_v2_boards(args.board, args.board_dir / BOARD_YML, systems)
-    else:
-        boards = find_v2_boards(args)
+    boards = find_v2_boards(args)
 
-    for b in boards:
+    for b in boards.values():
         qualifiers_list = board_v2_qualifiers(b)
         if args.cmakeformat is not None:
             notfound = lambda x: x or 'NOTFOUND'
             info = args.cmakeformat.format(
                 NAME='NAME;' + b.name,
-                DIR='DIR;' + str(b.dir.as_posix()),
+                DIR='DIR;' + ';'.join(
+                    [str(x.as_posix()) for x in b.directories]),
                 VENDOR='VENDOR;' + notfound(b.vendor),
                 HWM='HWM;' + b.hwm,
                 REVISION_DEFAULT='REVISION_DEFAULT;' + notfound(b.revision_default),
@@ -365,7 +440,7 @@
             if args.cmakeformat is not None:
                 info = args.cmakeformat.format(
                     NAME='NAME;' + board.name,
-                    DIR='DIR;' + str(board.dir.as_posix()),
+                    DIR='DIR;' + str(board.directories.as_posix()),
                     HWM='HWM;' + board.hwm,
                     VENDOR='VENDOR;NOTFOUND',
                     REVISION_DEFAULT='REVISION_DEFAULT;NOTFOUND',
diff --git a/scripts/pylib/twister/twisterlib/testplan.py b/scripts/pylib/twister/twisterlib/testplan.py
index 25aa5c2..377a0da 100755
--- a/scripts/pylib/twister/twisterlib/testplan.py
+++ b/scripts/pylib/twister/twisterlib/testplan.py
@@ -442,7 +442,7 @@
             logger.debug(f"Adding platform {platform.name} with aliases {platform.aliases}")
             self.platforms.append(platform)
 
-        for board in known_boards:
+        for board in known_boards.values():
             new_config_found = False
             # don't load the same board data twice
             if not bdirs.get(board.dir):
diff --git a/scripts/schemas/board-schema.yml b/scripts/schemas/board-schema.yml
index 7a2afbd..656a62a 100644
--- a/scripts/schemas/board-schema.yml
+++ b/scripts/schemas/board-schema.yml
@@ -23,17 +23,33 @@
           required: false
           include: variant-schema
 
+schema;extend-variant-schema:
+  required: false
+  type: seq
+  sequence:
+    - type: map
+      mapping:
+        name:
+          required: true
+          type: str
+        qualifier:
+          required: true
+          type: str
+
 schema;board-schema:
   type: map
   mapping:
     name:
-      required: true
+      required: false # Note: either name or extend is required, but that is handled in python
       type: str
       desc: Name of the board
     full_name:
       required: false
       type: str
       desc: Full name of the board. Typically set to the commercial name of the board.
+    extend:
+      required: false # Note: either name or extend is required, but that is handled in python
+      type: str
     vendor:
       required: false
       type: str
@@ -63,7 +79,7 @@
                   required: true
                   type: str
     socs:
-      required: true
+      required: false # Required for name:, but not for extend.
       type: seq
       sequence:
         - type: map
@@ -73,6 +89,8 @@
               type: str
             variants:
               include: variant-schema
+    variants:
+      include: extend-variant-schema
 
 type: map
 mapping:
diff --git a/scripts/west_commands/boards.py b/scripts/west_commands/boards.py
index 9777d37..9cb6182 100644
--- a/scripts/west_commands/boards.py
+++ b/scripts/west_commands/boards.py
@@ -97,14 +97,14 @@
             log.inf(args.format.format(name=board.name, arch=board.arch,
                                        dir=board.dir, hwm=board.hwm, qualifiers=''))
 
-        for board in list_boards.find_v2_boards(args):
+        for board in list_boards.find_v2_boards(args).values():
             if name_re is not None and not name_re.search(board.name):
                 continue
             log.inf(
                 args.format.format(
                     name=board.name,
                     full_name=board.full_name,
-                    dir=board.dir,
+                    dir=board.directories[0],
                     hwm=board.hwm,
                     vendor=board.vendor,
                     qualifiers=list_boards.board_v2_qualifiers_csv(board),
diff --git a/share/sysbuild/Kconfig b/share/sysbuild/Kconfig
index 556462a..69f2d4c 100644
--- a/share/sysbuild/Kconfig
+++ b/share/sysbuild/Kconfig
@@ -6,7 +6,7 @@
 
 comment "Sysbuild image configuration"
 
-osource "$(BOARD_DIR)/Kconfig.sysbuild"
+osource "$(KCONFIG_BOARD_DIR)/Kconfig.sysbuild"
 osource "$(KCONFIG_BINARY_DIR)/soc/Kconfig.sysbuild"
 
 menu "Modules"