This design enables a seamless Zephyr build experience in Bazel where the complex matrix of Application + Board configurations is handled automatically. Users interact with generic, board-centric platforms, while Bazel transparently manages the app-specific Kconfig and devicetree parsing in the background.
Note on Legacy Support: This system replaces the previous prototype which used a global @zephyr_kconfig repository and manually named {project}_{board} repositories. This new architecture standardizes on @zephyr_kconfig for the global symbol schema and deterministic @zc_<hash> names for contextual repositories to ensure scalability and avoid naming collisions.
bazel build //apps/blinky --platforms=@zephyr//boards/nordic/nrf52840dk:nrf52840dk(app, board) combination.select() statements throughout the codebase.zephyr_setup.env)A Bzlmod module extension that serves as the entry point for configuring the Zephyr environment. It leverages Zephyr's native discovery scripts to find available hardware, applications, and modules.
Unified Board Discovery: The extension executes Zephyr's scripts/list_boards.py via a hermetic Python environment. It passes the internal Zephyr root AND all boards_dirs as --board-root arguments. This ensures that HWM v2 board.yml parsing, revisions, and qualifiers are handled identically for in-tree and OOT hardware.
Stratified Heuristic Discovery: To prevent lockfile Cartesian combinations scaling issues, the extension applies heuristics algorithms to figure out combinations and supported metadata constraints rules setup (see board discovery design).
Module & Dependency Discovery (Native Tooling): The extension identifies Zephyr modules by delegating to Zephyr's internal discovery logic. This ensures that topological sorting, complex module.yml parsing, and Kconfig path resolution exactly match the official Zephyr build system.
modules_dirs and all Bazel dependencies (mctx.modules) that contain a zephyr/module.yml file.scripts/zephyr_module.py using these paths. The script is called with --modules <paths> and --zephyr-base <path>.@zephyr_state file. This ensures that modules are processed in their correct dependency order during Kconfig schema generation.@hal_nordic is a bazel_dep and contains a zephyr/module.yml, it is automatically detected and passed to the discovery script.# module_extension pseudo-code for Module Discovery def _zephyr_setup_impl(mctx): # 1. Collect candidate paths for modules candidate_roots = [mctx.path(p) for p in mctx.tag.env.modules_dirs] for mod in mctx.modules: root = mctx.path(mod.root) if root.get_child("zephyr/module.yml").exists: candidate_roots.append(root) # 2. Run the native Zephyr discovery script res = python.execute([ "zephyr_module.py", "--zephyr-base", str(zephyr_root), "--modules", [str(r) for r in candidate_roots], "--meta-out", "metadata.yml", # Get machine-readable info ]) # 3. Parse result and store in @zephyr_state # The result contains: Name, Path, and resolved Kconfig path for each # module.
To stay within OS path length limits and ensure consistency between the Module Extension and the Platform Transition, the design uses a unified naming strategy implemented in @zephyr-bazel//:naming.bzl.
@, /, and : characters are stripped to handle different label formats (e.g., //apps/blinky, @//apps/blinky, and @@//apps/blinky) identically.examples/hello_bazel:hello_bazel), the target name is stripped to ensure that referencing an app by its package or its explicit target name results in the same hash.repo_name -> app_label) is rebuilt from the results of the apps_dirs scan. If two different application labels result in the same 8-character hash, the extension fails with a clear error identifying the conflicting labels and their corresponding filesystem paths.# @zephyr-bazel//:naming.bzl (Shared Utility) def get_zc_repo_name(app_label, board_id): # 1. Normalize label norm_app = str(app_label).lstrip("@").lstrip("/").lstrip(":") if ":" in norm_app: pkg, target = norm_app.split(":", 1) if pkg.endswith(target) or target == pkg.split("/")[-1]: norm_app = pkg # 2. Hash to 8 characters (32-bit hex) h = hash(norm_app) app_hash = "%x" % (h & 0xFFFFFFFF) app_hash = ("00000000" + app_hash)[-8:] # 3. Sanitize board ID (e.g., nrf52840dk/nrf52840 -> "nrf52840dk_nrf52840") safe_board = board_id.replace("/", "_").replace("-", "_").replace(".", "_") return "zc_%s_%s" % (app_hash, safe_board)
# Generated in @zephyr_hardware//:BUILD.bazel (Hardware Index) # Provides human-readable targets for debugging and discovery. alias( name = "blinky_on_nrf52840dk", actual = "@zc_a7f2_nrf52840dk//:blinky_nrf52840dk", )
To optimize the Loading Phase, the extension caches the results of list_boards.py and the filesystem scan in a state file. It only re-executes the discovery logic if the apps_dirs, boards_dirs, or modules_dirs labels themselves change, or those directories are changed.
To maintain high performance and avoid bloating Bazel's internal metadata, the discovery results are not passed as individual attributes to every contextual repository. Instead, the module extension centralizes this data in a dedicated state repository.
state.json) that maps all discovered assets (boards, modules, OOT roots) to their absolute filesystem paths.@zc_...) read this file at execution time to retrieve the paths they need for Kconfig and DTS parsing.# Inside zephyr_setup.env extension logic def _zephyr_setup_impl(mctx): # 1. Gather all paths state_data = { "zephyr_root": str(mctx.path("@zephyr//").dirname), "modules": {m.name: m.path for m in discovered_modules}, "board_index": {b.id: b.path for b in discovered_boards}, # Mapping of apps to surviving boards "combinations": {"apps/blinky": ["nrf52840dk_nrf52840", "same70q21b"]}, } # 2. Write to the state repository mctx.file("@zephyr_state//:state.json", json.encode(state_data))
# Inside gen_zephyr_config repository rule def _gen_zephyr_config_impl(rctx): # 1. Read the global state file state_path = rctx.path(Label("@zephyr_state//:state.json")) state = json.decode(rctx.read(state_path)) # 2. Use the paths to run Zephyr scripts zephyr_base = state["zephyr_root"] module_list = state["modules"].keys() # ... execute python scripts using these paths ...
To ensure @zc_<hash> repositories are correctly invalidated when global paths or application configurations change, the repository rule must declare its dependencies explicitly:
rctx.path(Label("@zephyr_state//:state.json")). This ensures that if the module extension updates the paths (e.g., during a Zephyr SDK upgrade), all contextual repositories are automatically re-generated.rctx.watch_tree() on the application's boards/ directory. Since Zephyr discovery logic implicitly pulls in .conf and .overlay files based on the board name, watching the entire directory is required to capture changes to these secondary configuration files.rctx.watch() on individual files if the number of configuration files is large; watch_tree() is more efficient for monitoring directory-level changes in Zephyr's complex structure.A single repository that defines the “sockets” for all Kconfig symbols. This allows libraries to be written once and configured by the active platform.
select() against these symbols.bool_flag, int_flag, etc., for every symbol. In the global schema, these flags are set to their Kconfig default values. They are only “bound” to specific values by the contextual platform overlay.config_setting targets for use in select().# Example of a generated flag in @zephyr_kconfig: bool_flag( name = "CONFIG_GPIO", build_setting_default = False, ) config_setting( name = "CONFIG_GPIO=true", flag_values = {":CONFIG_GPIO": "true"}, )
The zephyr_app macro uses a Starlark Incoming Transition to resolve the contextual configuration based on the requested platform's filesystem path.
@zephyr_index//:index.bzl).zephyr_app.(app_label, board_id), it must always produce the same target platform.To prevent accidental builds for incorrect hardware variants, the transition enforces explicit intent by matching the platform's target name to the registered hardware identity.
To correctly link a Zephyr board ID to its Bazel platform target, the following rules apply for all discovered hardware (both in-tree and OOT):
boards_dirs (e.g., //boards) and identifies Zephyr board IDs by searching for board.yml or board-specific Kconfig files.BUILD or BUILD.bazel file. This ensures that OOT boards can be referenced using their natural Bazel labels.@zephyr_index//:index.bzl.platform() target in that package. The name of the platform() target MUST match one of the Zephyr board IDs or be named :default (following Rule B).BUILD file. The build system automatically links the platform to the Zephyr hardware identity based on their shared package directory.:default and the directory contains exactly one board, that board is selected.:nrf52840 matching nrf52840dk/nrf52840), that variant is selected.# @zephyr_index//:index.bzl (Generated) # Used by the transition to map user-facing platforms to internal repositories. PACKAGE_TO_BOARDS = { "boards/nordic/nrf52840dk": ["nrf52840dk_nrf52840", "nrf52840dk_nrf52811"], "boards/tdk/robokit1": ["robokit1"], } # Mapping of deterministic names to Bzlmod canonical names (@@+zephyr_setup+...) REPO_NAME_TO_CANONICAL = { "zc_a7f2_nrf52840dk": "@@+zephyr_setup+zc_a7f2_nrf52840dk", }
def _zephyr_transition_impl(settings, attr): label = settings["//command_line_option:platforms"][0] pkg_boards = PACKAGE_TO_BOARDS.get(label.package, []) # 1. Match Rule A if label.name in pkg_boards: board_id = label.name # 2. Match Rule B elif label.name == "default" and len(pkg_boards) == 1: board_id = pkg_boards[0] # 3. Fail (Strictness) else: fail("Could not resolve board for %s. " + "Found boards: %s" % (label, pkg_boards)) # Construct repo name using naming.bzl repo_name = get_zc_repo_name(attr.app_label, board_id) return {"//command_line_option:platforms": ["@%s//:platform" % repo_name]}
Because transitions create new “branches” in the build graph, they can lead to increased memory usage if many different applications are built in a single invocation. The design minimizes this by ensuring the transition only changes the platforms flag, keeping other configuration bits (like host-side flags) identical across the graph.
WARNING: Transition Purity: To prevent catastrophic configuration fan-out, the transition MUST NOT modify any flags other than //command_line_option:platforms. Adding or modifying other flags (even seemingly harmless ones like debug levels or defines) will cause Bazel to create unique configurations for every unique combination of flags, potentially exhausting available memory and slowing down analysis significantly. ...
gen_zephyr_config)This repository rule performs the heavy lifting. It is only executed by Bazel if the specific (app, board) combination is part of the build graph. Since the combinations of all boards and apps in the zephyr tree creates an extremely large matrix of repositories we attempt to filter the discovered results for in tree boards and apps. See the Board Discovery Design for the details on that feature.
mctx.watch_tree() on the application’s boards/ directory. This ensures that any change to board- specific Kconfig (.conf) or Devicetree (.overlay) files—including revisions and SoC-specific overrides—triggers a re-run of the parsing logic.kconfig_gen_values.py and DTS scripts, passing the app_dir as a base path. The Zephyr Python scripts handle the internal discovery of all relevant secondary configuration files.platform target named after the specific combination (e.g., blinky_nrf52840dk_nrf52840) that uses the user's requested platform as its parent. This ensures that standard hardware constraints (CPU, Arch, SoC) are preserved while Kconfig flags are applied as overlays.# Generated inside @zc_a0_b5//:BUILD.bazel platform( name = "blinky_nrf52840dk_nrf52840", # Inherit user-facing constraints parents = ["//boards/nordic/nrf52840dk:nrf52840dk_nrf52840"], flags = [ # Values derived from contextual parsing of prj.conf + app/boards/*.conf "--@zephyr_kconfig//:CONFIG_GPIO=true", "--@zephyr_kconfig//:CONFIG_SERIAL=false", # Bind redirection flags to this specific configuration's generated # headers "--@zephyr//:autoconf_header=@zc_a0_b5//:autoconf.h", "--@zephyr//:devicetree_header=@zc_a0_b5//:devicetree_generated.h", ], )
To allow static libraries (kernel, drivers) to access context-specific parsing results without hardcoded paths, the design uses label_flag redirection points defined in the @zephyr module.
label_flag targets (e.g., @zephyr//:autoconf_header).@zc_... repository uses the flags attribute to point these global flags to its own local files.# Usage in a driver (@zephyr//drivers/gpio/BUILD.bazel) zephyr_cc_library( name = "gpio", srcs = ["gpio_dw.c"], )
To prevent the Zephyr platform transition from “leaking” into host-side tools (e.g., code generators, Python scripts, or local test utilities), the design follows standard Bazel best practices for configuration management.
zephyr_app macro). This ensures that the entire target dependency graph (kernel, drivers, libraries) is built using the contextual Zephyr platform.cfg = "exec"): Any dependency that must run on the host machine during the build (e.g., a custom script in tools or genrule) must be declared using the `exec" configuration. Bazel automatically “transitions back” to the host platform for these targets, ensuring they are built with the host compiler and not the Zephyr ARM/RISC-V toolchain.bazel build invocation without interference.To ensure that all Zephyr-specific code (kernel, drivers, and application sources) consistently uses the correct generated configuration, the build system automatically injects context-specific headers into every library definition.
zephyr_cc_library macro instead of the native cc_library.-include) to force the inclusion of autoconf.h and other generated headers.label_flag) are managed by the macro, so users do not need to list them manually in their BUILD files.zephyr_cc_library:# @zephyr//:cc.bzl (Managed by the Zephyr-Bazel overlay) def zephyr_cc_library(name, **kwargs): # 1. Force-include the contextual autoconf.h for every source file copts = kwargs.get("copts", []) copts.append("-include $(execpath @zephyr//:autoconf_header)") kwargs["copts"] = copts # 2. Ensure the library depends on the header target. # @zephyr//:autoconf_header is a label_flag redirected by the platform. deps = kwargs.get("deps", []) deps.append("@zephyr//:autoconf_header") kwargs["deps"] = deps native.cc_library(name = name, **kwargs)
To ensure consistent behavior across different developer machines and CI environments, all Zephyr discovery and parsing scripts are executed using a hermetic Python environment managed by rules_python.
//bazel/private:repo_rule_python.bzl) to locate a hermetic Python interpreter.kconfiglib, PyYAML, ply) are declared in scripts/requirements.in and locked in scripts/requirements- lock.txt.PYTHONPATH that includes:@zephyr_bazel_pip_deps.scripts/kconfig).//scripts/build.python extension from rules_python.Zephyr requires a multi-stage linking process to generate hardware-specific interrupt tables based on the final memory layout of the binary.
_zephyr_cc_binary macro first builds a “pre-final” ELF (e.g., app._pre0.elf) that contains the compiled code and a special .intList section with all registered interrupts.gen_isr_tables.py script is executed as a genrule to parse the pre-final ELF and generate isr_table.c and linker scripts (.ld).select() statements against CONFIG_GEN_SW_ISR_TABLE and CONFIG_GEN_IRQ_VECTOR_TABLE to link the correct generated source into the final binary.cc_binary depends on the generated ISR source and linker scripts, ensuring the interrupt vectors are correctly placed in memory.The user defines search roots and can rely on automatic module detection for Bzlmod dependencies:
# MODULE.bazel bazel_dep(name = "hal_nordic", version = "...") zephyr_setup = use_extension("//:setup.bzl", "zephyr_setup") zephyr_setup.env( apps_dirs = ["//apps", "@my_oot_apps//"], boards_dirs = ["//boards", "@my_custom_boards//"], # hal_nordic is detected automatically because it's a bazel_dep! # modules_dirs is used for non-Bzlmod OOT modules. modules_dirs = ["//modules"], manual_boards = ["nrf52840dk/nrf52840", "robokit1"], # fallback boards )
And defines platforms in their board directories (Out-of-Tree example):
# //boards/my_custom_vendor/my_board/BUILD.bazel platform( name = "my_board", constraint_values = [ "@platforms//cpu:armv7m", "@pigweed//pw_build/constraints/arm:cortex-m4", ], )
The build command points to the board platform package. The system uses the package path to identify the board.
# Build for an IN-TREE board (provided by the @zephyr overlay) bazel build //apps/blinky --platforms=@zephyr//boards/tdk/robokit1:same70q21b # Build for an OUT-OF-TREE board bazel build //apps/blinky --platforms=//boards/my_custom_vendor/my_board:my_soc
N combinations actually being built consume CPU and disk for Kconfig/DTS parsing.