Skip to main content

Heterogeneous Builds — Zephyr + Yocto on the same SoM

Walkthrough for a dual-app project on E1M-V2N101: Yocto Linux on the four Cortex-A55 cores plus Zephyr on the Cortex-M33 system-manager, the two halves talking over RPMsg. You'll declare both halves in a single board.yaml, let tan build fan out into per-core slices (planned by alp-sdk's alp_orchestrate), and end up with a flashable bundle that covers Linux + Zephyr + the on-module GD32 helper MCU.

The same pattern generalises to E1M-AEN E5..E8 (A32 + M55-HP + M55-HE), E1M-N93 (A55 + M33), and any future heterogeneous SoM.

Single-OS SoM (e.g. AEN E3/E4 with M55 cores only)? Follow Quick start instead. The orchestrator handles single-slice fan-outs too, but you don't need the cross-core machinery this guide focuses on.

1. What you'll have at the end

  • A V2N project that boots Yocto Linux on the A55 cluster.
  • A Zephyr image running on the M33-SM that the kernel brings up via remoteproc on first boot.
  • A two-way RPMsg channel between the two halves, accessed through <alp/rpc.h>.
  • A system-manifest.yaml that feeds tan image, tan flash, and OTA.

Out of scope: writing Yocto recipes from scratch (Yocto docs); writing Zephyr drivers from scratch (Zephyr docs); the wire-level RPMsg protocol details (OpenAMP docs).

2. Prerequisites

  1. The tan CLI (0.5.1) installed — and installed first, since bootstrapping is a tan verb. It ships from its own repo (alplabai/tan-cli) on its own version line, and it is a Python program released as a self-contained PyInstaller freeze: no Rust toolchain, no rustup, no host Python interpreter.

    curl -fsSL https://raw.githubusercontent.com/alplabai/tan-cli/main/install.sh | sh
    # Windows PowerShell: irm https://raw.githubusercontent.com/alplabai/tan-cli/main/install.ps1 | iex
  2. West workspace bootstrapped — tan bootstrap --sdk-root "$PWD" from the SDK root (the SDK's scripts/bootstrap.sh does the same setup directly). That installs alp_cli as an editable package into the workspace venv — an SDK-side package, not a user command, and not something tan calls.

  3. Zephyr SDK 1.0.1 installed (ZEPHYR_SDK_INSTALL_DIR exported) — only for the Zephyr slice's real-silicon target. Not required for native_sim/native/64 smoke builds, which use host gcc with ZEPHYR_TOOLCHAIN_VARIANT=host. CI's pr-twister runs container-less on ubuntu-latest with the same host-gcc setting.

  4. Yocto build host set up (50+ GB free, Poky host packages).

  5. Plan for ~30 GB of build/<core>-yocto/tmp/ on the first cold build. Subsequent builds reuse sstate-cache and stay small.

3. Project layout

A dual-app project keeps each half in its own sub-directory. Sub-directory names match the cores: keys in board.yaml exactly — the orchestrator uses them to route generated config and find source trees.

examples/rpmsg-v2n/
├── board.yaml (declares a55_cluster + m33_sm)
├── README.md
├── linux/ (a55_cluster's app)
│ ├── CMakeLists.txt
│ └── src/main.c (consumer using <alp/rpc.h>)
└── m33_sm/ (m33_sm's app)
├── CMakeLists.txt
├── prj.conf
└── src/main.c (producer using <alp/rpc.h>)

Folder names are conventions, not magic — the cores.<id>.app: path in board.yaml binds them. The canonical convention is folder = core ID (m33_sm/ for the m33_sm core); linux/ for the A-cluster slice is the one blessed alias, bound explicitly via app: ./linux. The SDK's multi-image examples all follow this layout.

Single-OS examples don't change shape: they keep their flat src/ layout and declare a single core in board.yaml. The sub-directory split is opt-in per project.

4. The cores: block, walked through

som:
sku: E1M-V2N101
hw_rev: r1

preset: e1m-x-evk

cores:
a55_cluster:
# os: omitted → topology default (a55_cluster → yocto).
app: ./linux
image: alp-image-edge
peripherals: [ethernet, usb, emmc]
iot: { wifi: true, mqtt: true }
m33_sm:
# os: omitted → topology default (m33_sm → zephyr).
app: ./m33_sm
peripherals: [adc, pwm, i2c, gpio]

libraries: # top-level, optionally core-scoped
- { name: mbedtls, cores: [a55_cluster] }
- { name: nlohmann-json, cores: [a55_cluster] }
- { name: cmsis-dsp, cores: [m33_sm] }

ipc:
- kind: rpmsg
endpoints: [a55_cluster, m33_sm]
carve_out_kb: 512
name: alp_default_rpmsg

diagnostics:
log_level: info

os is optional — and class-derived, not a free choice. Omit it and the orchestrator derives the runtime from the core's silicon class (Cortex-M → zephyr, Cortex-A → yocto). The only values you may write explicitly:

ValueWhen to write it
baremetalRare hand-written firmware in place of the core's natural runtime.
offCore present in silicon but intentionally not used.

Writing the other class's OS (zephyr on a Cortex-A, yocto on a Cortex-M) is rejected by the orchestrator's cross-file core-class check. Writing the natural value (yocto on an A-core, zephyr on an M-core) is allowed but redundant — just omit it. To inspect what each core resolved to, emit the per-core OS facts:

tan generate --target os-topology
# → JSON per core: core_type, runtime_class, default_os, effective_os, allowed_os

# straight from the SDK script, no tan:
python3 scripts/alp_project.py --emit os-topology

os-topology is one of the nine targets in tan generate's default (--all) set. tan renders it in-process from tan.planner; the scripts/alp_project.py spawn is an opt-in escape hatch behind TAN_GENERATE_EXECUTOR=subprocess. There is no tan emit verb.

off is a first-class state — no implicit "did we forget a core?" failure mode. The recommended pattern on AEN E5..E8 is to declare every on-die core explicitly so the project's intent is self-documenting:

cores:
a32_cluster: { app: ./linux, image: alp-image-edge } # os: omitted → yocto
m55_hp: { app: ./m55_hp, peripherals: [i2c] } # os: omitted → zephyr
m55_he: { os: off } # peer core present, unused here

The remaining per-core fields (peripherals, iot, inference, extra_libraries) are scoped to that slice. The M33-SM doesn't carry networking on V2N, so iot: only appears under a55_cluster. Curated libraries are the exception — they're declared once in the top-level libraries: block and scoped with cores: on the entry, rather than per-core. inference: no longer carries a backend: knob — the dispatcher set is silicon-determined from the SoM preset's capabilities:; apps pick per-handle at runtime via alp_inference_open(.backend = ...). Only tensor_arena_kb: lives in board.yaml.

5. The ipc: block

Each entry declares one cross-core channel.

ipc:
- kind: rpmsg
endpoints: [a55_cluster, m33_sm]
carve_out_kb: 512
name: alp_default_rpmsg
  • kind: — the schema accepts rpmsg (rides OpenAMP), raw_shmem (plain shared memory + your own synchronisation), and mailbox_only (doorbell + signal, no shared memory). rpmsg is the one this guide walks and the one with OpenAMP wiring plus a generated contract header; the carve-out allocation itself is kind-agnostic.
  • endpoints — the cores sharing this channel. Both must have os: != off. Exactly two; RPMsg is point-to-point.
  • carve_out_kb — shared-memory region size in kibibytes. The orchestrator allocates from the SoM preset's memory_map:, preferring non-cacheable on SoMs with no M-class cache (V2N), cacheable + auto-generated cache-maintenance on SoMs that do (AEN).
  • name — stable identifier. Becomes the resource-table label on OpenAMP, the Linux DT reserved-memory node label, and the #define prefix in the generated header. Stick to [a-z][a-z0-9_]+.

For each ipc: entry, tan build (via alp-sdk's alp_orchestrate) emits a header both halves #include:

/* build/generated/alp/system_ipc.h — auto-generated, do not edit.
The channel `name:` is upper-cased and prepended with ALP_IPC_,
so `name: alp_default_rpmsg` yields the ALP_IPC_ALP_DEFAULT_RPMSG_*
macro stem (note the doubled `ALP_`). */
#define ALP_IPC_ALP_DEFAULT_RPMSG_NAME "alp_default_rpmsg"
#define ALP_IPC_ALP_DEFAULT_RPMSG_ADDR 0x10078000u
#define ALP_IPC_ALP_DEFAULT_RPMSG_SIZE 0x00080000u
#define ALP_IPC_ALP_DEFAULT_RPMSG_SRC_EPT 0x00000401u
#define ALP_IPC_ALP_DEFAULT_RPMSG_DST_EPT 0x00000402u
#define ALP_IPC_ALP_DEFAULT_RPMSG_MBOX_CH 0u

Both linux/src/main.c and m33_sm/src/main.c #include <alp/system_ipc.h> and use the same constants. Endpoint IDs are derived from name deterministically — re-running the build produces byte-identical headers. Drift between the Linux DT and the Zephyr overlay becomes impossible.

6. Building

tan --project examples/rpmsg-v2n build

tan build plans first, then executes — ADR 0020: alp-sdk is plans-only, tan is the sole executor. tan consumes alp_orchestrate --emit build-plan (seeding its own system-manifest.yaml from --emit system-manifest) and runs west / bitbake / cmake per slice itself:

  1. The SDK's planner (alp_orchestrate) loads + validates board.yaml against the JSON Schema + cross-field validator.
  2. The planner resolves the SoM preset → topology defaults → effective per-core mapping.
  3. For each core with os: != off, tan materialises the planner's per-core config to disk (build/m33_sm-zephyr/alp.conf, build/a55_cluster-yocto/conf/local.conf).
  4. tan writes the planner's shared generated artefacts (alp/system_ipc.h, dts-reservations.dtsi).
  5. tan materialises the helper-MCU artefacts (GD32, CC3501E) the plan registers.
  6. tan dispatches slice builds in parallel (west / bitbake / cmake per slice).
  7. tan writes build/system-manifest.yaml, seeded from the planner's --emit system-manifest, joining everything together.

Output layout:

build/
├── a55_cluster-yocto/
│ ├── conf/local.conf
│ └── tmp/deploy/images/e1m-v2n101-a55/{rootfs.wic.gz, Image, *.dtb}
├── m33_sm-zephyr/
│ └── zephyr/zephyr.elf
├── helper-gd32/
│ └── gd32_bridge.bin
├── helper-cc3501e/
│ └── cc3501e_otp.blob
├── generated/
│ ├── alp/system_ipc.h
│ ├── dts-reservations.dtsi
│ └── alp_hw_info_build.h
└── system-manifest.yaml

The build plan — what a tool sees before anything is built

west alp-emit build-plan # JSON to stdout; no build

# straight from the planner, no west:
PYTHONPATH=scripts python3 -m alp_orchestrate --input board.yaml --emit build-plan

build-plan is the machine-readable rendering of everything above, emitted without building — it is tan's sole build input, and the same consumer contract IDEs and CI read instead of scraping build output. It's governed by metadata/schemas/build-plan-v1.schema.json and validated by a gate, so its shape is a real contract rather than a convention.

One entry per non-off core slice, each carrying:

FieldWhat it gives you
commandThe build command: {tool, args, cwd} — or null when the emitter has no valid command (see warnings below)
configArtefactsThe slice's generated alp.conf / local.conf / cmake-args, contents inline, ready to byte-write
appDirResolved absolute path to the slice's app source, independent of command — lets tooling watch the source without reverse-engineering it out of a command line
toolchain{target_triple, compiler, sysroot, id} — grounded in the SoM preset's topology.<core>.toolchain, never invented; fields are null when not derivable
artifactsDeterministic output paths (elf, map, bin, size_report, symbols, compile_commands) — the where, not a promise the files exist yet
debug{console, probe} — the console backend selector (uart / ram / linux) and debug-probe selector a headless consumer needs
envEnvironment the consumer must inject (e.g. ALP_SDK_ROOT)

Paths resolve against the board.yaml directory, not the cwd. A relative app: is resolved from where board.yaml lives, so the plan is now byte-identical regardless of which directory you invoke from — a prerequisite for caching and diffing it.

Non-fatal problems surface as machine-readable warnings[] entries ({code, coreId, message}) rather than as a failed emit. Two codes worth knowing: no-command (the core's OS has no app/board/image to build) and yocto-recipe-missing — an app-only Yocto slice with no recipe:, which yields command: null instead of an invalid bitbake <path>. See recipe:.

Iterating on one slice

The Yocto cold build takes hours; the Zephyr build takes seconds. When iterating on the M-side firmware, just re-run the build — the Zephyr slice rebuilds incrementally in seconds while the already-built Yocto slice is reused (west/bitbake short-circuit an up-to-date tree):

tan --project examples/rpmsg-v2n build

There is no per-slice --core flag on tan build; it runs every buildable slice, and unchanged slices are near-instant. Slice failures don't cascade — system-manifest.yaml carries per-slice status: ok | failed; re-running re-attempts only the failed slices.

7. Flashing

tan image # → build/image-bundle/alp-system.zip + .swu (Mender)
tan flash # programs attached hardware

tan image consumes system-manifest.yaml and assembles a single flashable bundle:

  • The Yocto .wic.gz rootfs.
  • The Zephyr .elf (installed into the rootfs at /lib/firmware/alp/E1M-V2N101/m33_sm.elf so remoteproc picks it up on first boot).
  • Helper-MCU firmware (gd32_bridge.bin, cc3501e_otp.blob).
  • A Mender .swu for OTA.

tan flash walks the manifest's boot_order: and programs each piece with the right backend tool (vendor flasher for the SoC, openocd-via-SWD for the GD32 helper, USB-CDC bootloader for CC3501E). You don't pick the tool.

8. Debugging

Per-slice logs

Each slice gets its own log directory under build/<core>-<os>/:

  • build/m33_sm-zephyr/build.log — Zephyr CMake + ninja output.
  • build/a55_cluster-yocto/log/bitbake.log — bitbake task output.
  • build/helper-gd32/build.log — GD32 firmware build.

system-manifest.yaml carries each slice's log_path: so tooling jumps straight to the right log on a failure.

Attaching a debugger

  • A55 cluster (Linux): alp-image-edge ships with gdbserver. SSH in, attach to your process.
  • M33-SM (Zephyr): SWD via openocd or J-Link. The orchestrator installs build/m33_sm-zephyr/openocd.cfg; west debug --build-dir build/m33_sm-zephyr attaches a GDB session.
  • Cross-core sanity check: print your endpoint IDs on both sides with printk("ept=%u\n", ALP_IPC_ALP_DEFAULT_RPMSG_SRC_EPT) — they must match system-manifest.yaml's ipc[].rpmsg_endpoint_ids field.

Renode smoke test

No board needed to verify the heterogeneous handshake:

tan renode

Renode loads both slice images, simulates RPMsg over its mailbox peripheral, and runs a name-service ping/pong. CI uses the same tan renode invocation in pr-renode-dual-os.yml.

CI status: pr-alp-build, pr-bitbake, and pr-renode-dual-os remain advisory (continue-on-error) pending self-hosted toolchain runners with the Zephyr SDK, bitbake, and Renode. The manifest-shape + determinism gates run on ubuntu-latest and block merges; slice-build failures don't.

9. Cross-core API

<alp/rpc.h> is the customer-facing IPC API. It sits on OpenAMP and uses the generated endpoint constants — apps don't type addresses, endpoint IDs, or mailbox channels by hand.

Producer (M33-SM)

/* m33_sm/src/main.c */
#include <alp/rpc.h>
#include <alp/system_ipc.h> /* generated by tan build */
#include <zephyr/kernel.h>

int main(void) {
alp_rpc_channel_t *ch = alp_rpc_open(&(alp_rpc_config_t){
.name = ALP_IPC_ALP_DEFAULT_RPMSG_NAME,
.src_ept = ALP_IPC_ALP_DEFAULT_RPMSG_SRC_EPT,
.dst_ept = ALP_IPC_ALP_DEFAULT_RPMSG_DST_EPT,
});
if (ch == NULL) {
return -1; /* alp_last_error() reports why */
}

while (1) {
float temperature_c = read_thermistor();
alp_rpc_call(ch, "temperature",
&temperature_c, sizeof(temperature_c));
k_msleep(1000);
}
}

Consumer (A55)

/* linux/src/main.c */
#include <alp/rpc.h>
#include <alp/system_ipc.h>
#include <stdio.h>
#include <unistd.h>

static void on_temperature(const void *buf, size_t len, void *user) {
if (len == sizeof(float)) {
printf("[a55] temperature=%.2f C\n", *(const float *)buf);
}
}

int main(void) {
alp_rpc_channel_t *ch = alp_rpc_open(&(alp_rpc_config_t){
.name = ALP_IPC_ALP_DEFAULT_RPMSG_NAME,
.src_ept = ALP_IPC_ALP_DEFAULT_RPMSG_DST_EPT, /* swap src/dst */
.dst_ept = ALP_IPC_ALP_DEFAULT_RPMSG_SRC_EPT,
});

alp_rpc_subscribe(ch, "temperature", on_temperature, NULL);

for (;;) pause();
}

Both sides #include the same generated header, so endpoint IDs match by construction. The producer's src_ept is the consumer's dst_ept and vice versa — that symmetry is the only piece a developer keeps straight. For multiple channels, declare multiple ipc: entries with distinct name: values.

10. Common pitfalls

Forgetting to declare ipc:. Call alp_rpc_open() for a name that doesn't appear in any ipc: block and you won't compile — <alp/system_ipc.h> doesn't carry the matching constants. Every cross-core touchpoint is declared at build time, not discovered at runtime.

Cache coherency on AEN. V2N's default carve-out is non-cacheable because the M33-SM has no data cache. AEN's M55 cores do have a cache, so the default flips to cacheable with auto-generated cache-maintenance points in alp_rpc_*. Don't write cache ops by hand.

Boot ordering. Linux brings the M33 up via remoteproc; the M33 can't talk to the A55 until userspace pokes /sys/class/remoteproc/.../state = start. App code should re-try alp_rpc_open() with backoff — or use alp_rpc_open_blocking() which loops until the peer answers.

See also

Questions about this page? Discuss in Community Forum