Implement support for Zephyr‘s Sysbuild (System build) feature in zephyr-bazel. This will allow developers to define multi-image, multi-core, and multi-SoC targets in Bazel, coordinating their configuration, compilation, and flashing while maintaining Kconfig/DTS correctness and Bazel’s lazy evaluation.
Currently, zephyr-bazel excels at building single-image Zephyr applications (e.g., bazel build //apps/blinky). However, modern embedded systems often require building and packaging multiple related images for a single target board. Examples include:
Currently, zephyr-bazel lacks a native way to define and coordinate these multi-image builds. Developers are forced to build each image separately and manually stitch them together, breaking the unified build graph and caching guarantees of Bazel.
In standard Zephyr, multi-image builds are handled by Sysbuild. Sysbuild is a meta-build system that:
SB_CONFIG_BOOTLOADER_MCUBOOT=y) or CMake (ExternalZephyrProject_Add).sysbuild/mcuboot.conf in the main app's directory as an overlay to the MCUboot helper).merged.hex) for production.To maintain compatibility with standard Zephyr projects and support realistic production hardware workloads, zephyr-bazel must implement a comparable meta-build capability.
We evaluated several approaches to support multi-image builds in Bazel, balancing Starlark complexity, lockfile performance, and developer usability.
In this approach, the Bzlmod module extension would eagerly parse Zephyr's sysbuild.conf or sysbuild.cmake files to automatically discover what helper images are enabled and generate the Bazel build graph dynamically.
sysbuild.cmake in Python/Starlark is extremely fragile and error-prone.Here, the user explicitly registers the union of all possible helper images in MODULE.bazel, and then uses standard Bazel select() statements in BUILD.bazel to conditionally wire the build graph based on target board constraints.
select() statements.select() statements in every BUILD.bazel for multi-core/multi-image targets, creating a high barrier to entry and risking dual-source-of-truth discrepancies.Similar to the proposed solution, but using JSON files (sysbuild.json) next to the source code to define the hardware graph.
module.yml, board.yml, testcase.yaml). Using JSON would introduce a different syntax style into the codebase.We propose adopting a Declarative YAML-based Hierarchical design.
Instead of writing complex CMake or verbose Bazel select() code, developers describe the hardware graph declaratively in simple YAML files (sysbuild.yml) next to their source code. Bazel's module extension recursively parses these files during the loading phase to generate the complete multi-image build graph automatically.
graph TD Platform["User Platform<br>(//boards/my_board)"] Sysbuild["zephyr_sysbuild Target<br>(my_firmware)"] Main["Main App<br>(my_app on cpuapp)"] Mcuboot["Mcuboot Helper<br>(on cpuapp)"] NetApp["Net App Helper<br>(on cpunet)"] Netboot["Netboot Nested Helper<br>(on cpunet)"] Platform --> Sysbuild Sysbuild -->|Transition| Main Sysbuild -->|Transition| Mcuboot Sysbuild -->|Transition| NetApp NetApp -->|Recursive Transition| Netboot
sysbuild.yml and boards/<board>.sysbuild.yml.zephyr_sysbuild target in BUILD.bazel that automatically resolves the entire multi-core, multi-SoC graph at build time using transitions.This section details the specific implementation details.
sysbuild.yml)Instead of writing complex Bazel code, the hardware graph is described declaratively in YAML files next to the source code, matching Zephyr's configuration style.
sysbuild.yml)Defined in the app's root directory. These helpers are built for all boards, inheriting the active platform.
# //apps/my_app/sysbuild.yml helpers: mcuboot: target: "@mcuboot//boot/zephyr:zephyr_project"
Defined in the app's boards/<board>.sysbuild.yml file. This allows adding companion images running on different cores/SoCs, or board-specific external (non-Zephyr) helper targets.
# //apps/my_app/boards/nrf5340dk_nrf5340_cpuapp.sysbuild.yml helpers: net_app: target: "//apps/net_companion" # Explicitly transition this helper to the companion network core SoC/board: platform: "//boards/nordic/nrf5340dk:nrf5340_cpunet" custom_coprocessor_firmware: target: "//third_party/custom_firmware" # Mark as external to bypass Zephyr contextual repository parsing: type: "external"
platform attribute, which explicitly redirects the helper to compile for a companion core or SoC using its specific platform target.type: "external". This allows including custom bootloaders or companion firmware built with other rules, which will only be compiled when building for boards that list them in their <board>.sysbuild.yml.During the loading phase, the zephyr_setup module extension recursively scans these YAML files starting from the main application:
nrf5340_cpuapp: Resolves mcuboot (inherits nrf5340_cpuapp), net_app (targets nrf5340_cpunet), and custom_coprocessor_firmware (external target).net_companion is transitioned to nrf5340_cpunet, the extension scans //apps/net_companion/sysbuild.yml in the context of nrf5340_cpunet, resolving netboot (inherits nrf5340_cpunet).type: "external" bypass the generation of namespaced contextual repositories (@zc_...). Their dependency wiring is managed directly in the transition.In standard Zephyr, a top-level application can override configurations for any helper in the tree (e.g., my_app providing sysbuild/netboot.conf). This means a helper's configuration is technically unique to its full dependency path (e.g., my_app -> net_companion -> netboot).
To prevent lockfile Cartesian product explosion, the module extension applies a Smart Sharing optimization during the loading phase:
netboot at my_app -> net_companion -> netboot, it checks for my_app/sysbuild/netboot.conf and net_companion/sysbuild/netboot.conf.@zephyr_index//:sysbuild_index.bzl) is configured to point this helper to the shared default repository: @zc_<helper_hash>_<board>.@zc_<path_hash>_<board>.This ensures 100% compatibility with Zephyr's hierarchical configuration while keeping the lockfile size minimal for the common case where nested helpers are not customized.
The complete resolved graph mapping is stored in @zephyr_index//:sysbuild_index.bzl.
sysbuild.conf)While sysbuild.yml defines the hardware graph (which images and platforms are built), standard Zephyr projects use sysbuild.conf (a Kconfig file) to define sysbuild-specific configurations and pass namespaced variables to specific helper images.
To support this, the module extension executes a Python translation helper during the discovery phase to parse sysbuild.conf and propagate settings to the respective images, replicating Zephyr's CMake namespacing and translation logic.
Zephyr Sysbuild allows directing Kconfig settings to specific helper images using the domain name as a prefix: -D<domain_name>_CONFIG_<OPTION>=<value> (on command line) or <domain_name>_CONFIG_<OPTION>=<value> in sysbuild.conf.
For example, to configure the MCUboot helper specifically, a user writes in sysbuild.conf:
mcuboot_CONFIG_BOOT_SIGNATURE_TYPE_RSA=y
The Python translation helper script parses sysbuild.conf (and any board-specific sysbuild_<board>.conf if present) during the loading phase:
^([a-zA-Z0-9_]+)_(CONFIG_[a-zA-Z0-9_]+)=(.*)$mcuboot_CONFIG_BOOT_SIGNATURE_TYPE_RSA=y), it extracts the target domain (mcuboot) and the Kconfig setting (CONFIG_BOOT_SIGNATURE_TYPE_RSA=y).SB_CONFIG_ Symbols: It parses standard SB_CONFIG_ symbols and translates them to target CONFIG_ values based on Zephyr‘s standard mapping rules (defined in Zephyr’s share/sysbuild/image_configurations/).SB_CONFIG_BOOTLOADER_MCUBOOT to CONFIG_BOOTLOADER_MCUBOOT, SB_CONFIG_MCUBOOT_MODE_* to CONFIG_MCUBOOT_BOOTLOADER_MODE_*, etc.SB_CONFIG_MCUBOOT_MODE_* to CONFIG_BOOT_UPGRADE_ONLY or similar, SB_CONFIG_BOOT_SIGNATURE_* to CONFIG_BOOT_SIGNATURE_*, etc.sysbuild_generated.conf) for each affected image.The generated Kconfig fragments are passed to the respective contextual repositories (@zc_...) via the zephyr_state or as explicit attributes in gen_zephyr_config.
During repository rule execution, gen_zephyr_config includes these generated fragments in the Kconfig merge list (as an extra overlay), ensuring that the final compiled binaries are correctly configured according to the sysbuild.conf settings.
This maintains 100% compatibility with standard Zephyr Kconfig propagation and namespacing without requiring the execution of CMake.
gen_zephyr_config)For custom helper configurations (Case B above), the gen_zephyr_config repository rule is instantiated with the full list of resolved configuration overlays propagated by the module extension.
The repository rule uses these explicit paths to resolve Kconfig and Devicetree overlays during execution, ensuring that:
owner_app/sysbuild/helper.conf) are correctly applied.top_app/sysbuild/helper.conf) take precedence.This replicates Zephyr's standard hierarchical configuration overlay behavior hermetically inside the Bazel repository rule execution phase.
zephyr_sysbuild Starlark Target (Macro + Rule)To provide a zero-boilerplate user experience, the public API zephyr_sysbuild is implemented as a Starlark Macro that wraps an underlying private rule _zephyr_sysbuild_rule.
# In //apps/my_app/BUILD.bazel load("@zephyr-bazel//bazel:sysbuild.bzl", "zephyr_sysbuild") zephyr_sysbuild( name = "my_firmware", main = "//apps/my_app", )
Because Bazel rules cannot dynamically discover dependencies during the Analysis Phase, the macro resolves them during the Loading Phase by loading the index generated by the module extension:
# //bazel:sysbuild.bzl (Starlark Macro API) load("@zephyr_index//:sysbuild_index.bzl", "SYSBUILD_GRAPHS") def zephyr_sysbuild(name, main, **kwargs): # 1. Resolve helpers at loading time using the generated index graph = SYSBUILD_GRAPHS.get(main, {}) helpers = graph.get("helpers", []) # 2. Instantiate the underlying rule with resolved dependencies _zephyr_sysbuild_rule( name = name, main = main, helpers = helpers, **kwargs )
To support heterogeneous multi-core and multi-SoC setups where different helpers require different target platforms, we use intermediate Platform Transition Targets generated dynamically by the zephyr_sysbuild macro.
This avoids the Bazel limitation where a single transition on a label_list attribute must transition all targets to the same platform.
A generic Starlark rule transitioned_dep is defined to transition a single dependency to a dynamically specified platform:
# //bazel:private/transition_rule.bzl def _transition_impl(settings, attr): return {"//command_line_option:platforms": [attr.platform]} platform_transition = transition( implementation = _transition_impl, inputs = [], outputs = ["//command_line_option:platforms"], ) def _transitioned_dep_impl(ctx): # Forward providers from the actual target return [ctx.attr.dep[DefaultInfo]] transitioned_dep = rule( implementation = _transitioned_dep_impl, attrs = { "dep": attr.label(cfg = platform_transition), "platform": attr.string(mandatory = True), }, )
The zephyr_sysbuild macro loads the resolved graph from the index and generates transitioned_dep targets for the main application and each helper. It resolves the correct platform (shared default or custom path-specific) at loading time:
# //bazel:sysbuild.bzl (Starlark Macro API) load("@zephyr_index//:sysbuild_index.bzl", "SYSBUILD_GRAPHS") load("//bazel:private/transition_rule.bzl", "transitioned_dep") def zephyr_sysbuild(name, main, **kwargs): graph = SYSBUILD_GRAPHS.get(main, {}) helpers = graph.get("helpers", []) main_platform = graph.get("main_platform") transitioned_helpers = [] for i, helper in enumerate(helpers): helper_target = helper["target"] helper_platform = helper["resolved_platform"] helper_name = "%s_helper_%d" % (name, i) transitioned_dep( name = helper_name, dep = helper_target, platform = helper_platform, ) transitioned_helpers.append(":" + helper_name) # Transition the main application main_name = "%s_main" % name transitioned_dep( name = main_name, dep = main, platform = main_platform, ) # Instantiate the underlying sysbuild rule with transitioned targets _zephyr_sysbuild_rule( name = name, main = ":" + main_name, helpers = transitioned_helpers, **kwargs )
This keeps the transition “pure” (only changing --platforms), preventing configuration fan-out, while supporting heterogeneous multi-SoC and multi-core hardware.
To ensure build correctness while maintaining loading-phase performance, the system must implement a precise invalidation strategy.
The zephyr_setup module extension must watch all configuration files that define the sysbuild graph. To avoid redundant watches and performance degradation:
apps_dirs or boards_dirs discovery (which uses mctx.watch_tree), the extension should not add duplicate watches for files inside these directories.@mcuboot//boot/zephyr), the extension must explicitly watch its sysbuild.yml and any candidate overlay files using mctx.watch(). This ensures that changes to external bootloaders or companion firmware configurations correctly invalidate the Bazel lockfile and re-generate the graph.sysbuild/<helper>.conf). If the file does not exist, watching the path ensures that creating the file triggers invalidation to transition the helper to a custom configuration.@zc_... repository rules must watch the specific resolved overlay files passed to them by the extension using rctx.watch().sysbuild/ directory to detect configuration changes.The user documentation must be updated to add a user facing description of the supported sysbuild features and how they are used. It should enumerate the differences between the bazel and cmake sysbuild. It must also include this warning about toolchains:
[!IMPORTANT] Heterogeneous Toolchains: Building for multi-SoC setups (e.g., ARM main core + RISC-V companion) requires that the workspace contains registered toolchains for both architectures. Bazel will automatically select the correct toolchain for each core based on the platform constraints, but both toolchains must be configured in the workspace.
We will verify this implementation by adding integration tests in the examples/ folder:
Example App: Add a new example app named sysbuild_app in the examples/ directory.
Multi-Board Configuration: Configure sysbuild_app with two different boards (e.g., board_single_core and board_multi_core) that define different child helper configurations in their <board>.sysbuild.yml files.
board_single_core will only enable mcuboot.board_multi_core will enable mcuboot + a companion net_app (which recursively enables netboot on the companion core).CI/CD Integration: Add the build commands for both board configurations to examples/workflows.json under the "builds" block. This ensures that running ./pw default will build both multi-image graphs automatically during local testing and CI.
Output Verification: Verify that:
sysbuild_app, mcuboot, net_app, netboot) are compiled successfully for their respective platforms.merged.hex) is generated for both board configurations.Invalidation & Watching Tests: We will add automated unit tests to verify the invalidation logic:
sysbuild.yml or adding a sysbuild/<helper>.conf file triggers a re-run of the module extension and updates the build graph.sysbuild.yml in an external repository (mocked in tests) correctly invalidates the extension.This plan breaks down the implementation into progressive phases, each with clear verification steps to ensure correctness before proceeding.
sysbuild.yml recursive scanning in Bzlmod extension.setup.bzl (zephyr_setup extension) to scan sysbuild.yml and boards/<board>.sysbuild.yml using PyYAML.SYSBUILD_GRAPHS index in sysbuild_index.bzl.bazel query on a test app with nested and companion helpers generates the expected graph in sysbuild_index.bzl.sysbuild.conf Parsing & Kconfig Translationsysbuild.conf and translate SB_CONFIG_ to CONFIG_.sysbuild.conf parsing using kconfiglib in the extension.SB_CONFIG_ to CONFIG_ for the main app and mcuboot (replicating Zephyr's CMake logic).sysbuild_generated.conf).gen_zephyr_config repository rule to accept these fragments and append them as Kconfig overlays.SB_CONFIG_BOOTLOADER_MCUBOOT=y translates correctly for both main app and bootloader).autoconf.h in the @zc_ repository contains the translated CONFIG_ symbols.zephyr_sysbuild target.transitioned_dep rule in bazel/private/transition_rule.bzl.zephyr_sysbuild macro in bazel/sysbuild.bzl to generate transitioned_dep targets and instantiate _zephyr_sysbuild_rule._zephyr_sysbuild_rule to depend on transitioned targets and coordinate packaging (generating merged binaries if needed).zephyr_sysbuild target and verify that building it triggers compilation of both main app and helpers for their respective platforms. Use bazel query to inspect the analyzed configuration.mctx.watch_tree / mctx.watch) on the sysbuild/ directory in the Bzlmod extension.sysbuild.yml (e.g., add a helper) -> Verify Bazel detects the change and compiles the new helper.sysbuild.conf (e.g., change MCUboot mode) -> Verify Bazel recompiles the helper with the new configuration.sysbuild.yml and sysbuild.conf usage.