Design: Zephyr-Bazel Automatic Discovery & Contextual Configuration

Overview

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.

Core Requirements

  1. Automatic Discovery: Apps and boards (both in-tree and out-of-tree) are discovered by scanning user-provided root directories.
  2. Generic User Interface: Users build using standard platforms defined in board packages: bazel build //apps/blinky --platforms=@zephyr//boards/nordic/nrf52840dk:nrf52840dk
  3. Contextual Parsing: Kconfig and Devicetree are parsed uniquely for every (app, board) combination.
  4. Lazy Evaluation: Only the specific combinations requested in a build are actually parsed. The module extension applies dynamic heuristics validation rules to prune combinations (see the board discovery design) preventing lockfile Cartesian product explosions. Bazel only executes the underlying repository rules for the surviving combinations present in the build graph.
  5. Build-Time Selection: Parsed Kconfig symbols must be available for use in select() statements throughout the codebase.

Architectural Components

1. The Discovery Extension (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.

    1. Path Collection: The extension collects absolute paths from both explicit modules_dirs and all Bazel dependencies (mctx.modules) that contain a zephyr/module.yml file.
    2. Native Execution: It executes scripts/zephyr_module.py using these paths. The script is called with --modules <paths> and --zephyr-base <path>.
    3. Topological Sorting: The extension uses the order returned by the script to populate the @zephyr_state file. This ensures that modules are processed in their correct dependency order during Kconfig schema generation.
    4. Automatic Detection Example: If @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.

Deterministic Repository Naming (Name Compression)

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.

  • Label Normalization: To ensure the same app results in the same hash regardless of how it is referenced, labels are normalized before hashing.
    1. Prefix Stripping: All leading @, /, and : characters are stripped to handle different label formats (e.g., //apps/blinky, @//apps/blinky, and @@//apps/blinky) identically.
    2. Redundant Target Removal: If the label includes a target name that matches the last part of the package path (e.g., 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.
  • Collision-Resistant Hashing: The logic uses an 8-character hex hash of the normalized application label to generate unique repository names.
  • Collision Validation Registry: During every evaluation of the module extension, a registry of generated repository names (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.
  • Shared Logic: Both the loading-phase extension and the analysis-phase transition import the same utility to stay in sync.
# @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",
)

Discovery Caching

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.

2. Global Discovery State (@zephyr_state)

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 Repository: A small, internal repository created once by the module extension.
  • State File: It contains a single JSON file (state.json) that maps all discovered assets (boards, modules, OOT roots) to their absolute filesystem paths.
  • Lazy Consumption: Contextual repositories (@zc_...) read this file at execution time to retrieve the paths they need for Kconfig and DTS parsing.

Implementation for the Extension:

# 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))

Implementation for the Repository Rule:

# 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 ...

Repository Dependency and Invalidation

To ensure @zc_<hash> repositories are correctly invalidated when global paths or application configurations change, the repository rule must declare its dependencies explicitly:

  • Global State Dependency: The rule MUST access the state file using 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.
  • Application-Specific Watches: The rule MUST use 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.
  • Performance Note: Avoid using 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.

3. Global Kconfig Schema (@zephyr_kconfig)

A single repository that defines the “sockets” for all Kconfig symbols. This allows libraries to be written once and configured by the active platform.

  • Universal Symbol Tree (Super-Schema): The schema repository is generated by parsing the combined Kconfig tree of the Zephyr Core, all discovered Modules, and all discovered Boards.
  • Hardware-Agnostic Selection: Every board-specific Kconfig symbol becomes a valid Bazel target, allowing any driver or library to use select() against these symbols.
  • Flags & Defaults: Provides 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.
  • Settings: Provides 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"},
)

4. The Platform Redirection (Transition)

The zephyr_app macro uses a Starlark Incoming Transition to resolve the contextual configuration based on the requested platform's filesystem path.

Technical Constraints

  • No Filesystem Access: Transitions execute in the Analysis Phase. They cannot perform I/O or read files. They must rely entirely on data passed via attributes or pre-generated Starlark files (@zephyr_index//:index.bzl).
  • The “Transition Wall”: To prevent Zephyr-specific flags from leaking into host tools (like Python scripts or compilers used for code generation), the transition is strictly applied at the edge of the zephyr_app.
  • Deterministic Output: The transition must be a pure function. For a given (app_label, board_id), it must always produce the same target platform.

Board Identity Resolution (Naming Strictness)

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.

OOT Board Discovery & Linkage

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):

  • Discovery: The module extension scans boards_dirs (e.g., //boards) and identifies Zephyr board IDs by searching for board.yml or board-specific Kconfig files.
  • Bazel Package Resolution: For each discovered board, the extension resolves its Bazel Package Path by searching upwards from the board's directory for the nearest BUILD or BUILD.bazel file. This ensures that OOT boards can be referenced using their natural Bazel labels.
  • Indexing: Every discovered Zephyr Board ID is mapped to its resolved Bazel package path in @zephyr_index//:index.bzl.
  • Platform Implementation: Users must define a standard Bazel 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).
  • Implicit Linkage: No explicit mapping is required in the BUILD file. The build system automatically links the platform to the Zephyr hardware identity based on their shared package directory.

Resolution Rules

  1. Rule A (Default Shortcut): If the target name is exactly :default and the directory contains exactly one board, that board is selected.
  2. Rule B (SoC/Variant Match):
    • If the target name matches a specific SoC variant of a board (e.g., :nrf52840 matching nrf52840dk/nrf52840), that variant is selected.
    • If there is exactly one base board in the package and the target name matches a known SoC for that board, the combination is automatically resolved.
  3. Rule C (Strict Error): In all other cases (name mismatch or ambiguity), the build fails with a clear error message listing the valid board IDs for that directory.
# @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",
}

Implementation Logic:

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]}

Avoiding Configuration Fan-out

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. ...

5. The Per-Combination Repository (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.

  • App-Specific Configuration (Overlays & Boards): To maintain 100% compatibility with Zephyr‘s discovery logic, the repository rule calls 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.
  • Runs Zephyr Scripts: Executes 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 Inheritance: Defines an internal 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",
    ],
)

Generated Header Redirection

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.

  • Redirection Flags: Standard Zephyr headers are defined as label_flag targets (e.g., @zephyr//:autoconf_header).
  • Contextual Binding: The generated platform in the @zc_... repository uses the flags attribute to point these global flags to its own local files.
  • Transparent Usage: Static libraries include the headers via compiler options, remaining agnostic of the actual board or application being built.
# Usage in a driver (@zephyr//drivers/gpio/BUILD.bazel)
zephyr_cc_library(
    name = "gpio",
    srcs = ["gpio_dw.c"],
)

6. Host/Target Configuration Separation (The “Transition Wall”)

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.

  • Incoming Transition: The transition is applied only at the “edge” of the Zephyr application (the zephyr_app macro). This ensures that the entire target dependency graph (kernel, drivers, libraries) is built using the contextual Zephyr platform.
  • Execution Configuration (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.
  • Contextual Scoping: By using an incoming transition rather than a global one, the build remains “hermetic” for non-Zephyr targets. A host-side unit test and a Zephyr firmware binary can coexist in the same bazel build invocation without interference.

7. Automatic Build Context Injection

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.

  • Encapsulated Macro: Developers use the zephyr_cc_library macro instead of the native cc_library.
  • Automatic Flags: The macro automatically adds the necessary compiler options (-include) to force the inclusion of autoconf.h and other generated headers.
  • Zero-Boilerplate: Dependencies on context-redirection flags (label_flag) are managed by the macro, so users do not need to list them manually in their BUILD files.

Implementation for 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)

8. Hermetic Python Environment

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.

  • Bootstrap Strategy: The module extension and repository rules use a shared utility (//bazel/private:repo_rule_python.bzl) to locate a hermetic Python interpreter.
  • Dependency Management: All required Python packages (e.g., kconfiglib, PyYAML, ply) are declared in scripts/requirements.in and locked in scripts/requirements- lock.txt.
  • Environment Injection: When executing Zephyr scripts, the environment is configured with a PYTHONPATH that includes:
    1. The hermetic site-packages from @zephyr_bazel_pip_deps.
    2. The Zephyr Core's own script directories (e.g., scripts/kconfig).
    3. Internal utility scripts located in //scripts/build.
  • Loading Phase Access: To allow module extensions to use the hermetic environment before the full toolchain is resolved, the extension identifies the interpreter path by referencing the python extension from rules_python.

9. Interrupt Service Table (ISR) Generation

Zephyr requires a multi-stage linking process to generate hardware-specific interrupt tables based on the final memory layout of the binary.

  • Pre-Final Link: The _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.
  • Table Generation: The 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).
  • Kconfig-Driven Selection: Since the hardware supports different interrupt models (e.g., software-dispatched vs. direct-vector), the build system generates all four possible combinations of SW-ISR and Vector Tables in parallel. It uses Bazel select() statements against CONFIG_GEN_SW_ISR_TABLE and CONFIG_GEN_IRQ_VECTOR_TABLE to link the correct generated source into the final binary.
  • Final Link: The final cc_binary depends on the generated ISR source and linker scripts, ensuring the interrupt vectors are correctly placed in memory.

User Workflow

Setup

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",
    ],
)

Building

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

Advantages

  • Zephyr Compatible: This methodology matches the Zephyr model where an app can be built for any board and the configuration is specific to that board and app combination.
  • No Manual Matrix: New apps or boards are picked up automatically by the discovery scan.
  • Path-Based Robustness: Renaming platform targets is safe, as identification is based on the registered board directory.
  • Context Isolation: App A and App B can have different values for the same Kconfig symbol while building for the same board in a single invocation.
  • Resource Efficient: Instead of declaring repositories for the massive Cartesian product of all apps and boards, the system uses Stratified Heuristics to only declare configuration repositories for combinations that are supported (refer to the board discovery design). Bazel's lazy repository execution ensures that only the N combinations actually being built consume CPU and disk for Kconfig/DTS parsing.