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.yamlthat feedstan 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
-
The
tanCLI (0.5.1) installed — and installed first, since bootstrapping is atanverb. 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 -
West workspace bootstrapped —
tan bootstrap --sdk-root "$PWD"from the SDK root (the SDK'sscripts/bootstrap.shdoes the same setup directly). That installsalp_clias an editable package into the workspace venv — an SDK-side package, not a user command, and not somethingtancalls. -
Zephyr SDK 1.0.1 installed (
ZEPHYR_SDK_INSTALL_DIRexported) — only for the Zephyr slice's real-silicon target. Not required fornative_sim/native/64smoke builds, which use host gcc withZEPHYR_TOOLCHAIN_VARIANT=host. CI'spr-twisterruns container-less onubuntu-latestwith the same host-gcc setting. -
Yocto build host set up (50+ GB free, Poky host packages).
-
Plan for ~30 GB of
build/<core>-yocto/tmp/on the first cold build. Subsequent builds reusesstate-cacheand 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:
| Value | When to write it |
|---|---|
baremetal | Rare hand-written firmware in place of the core's natural runtime. |
off | Core 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 acceptsrpmsg(rides OpenAMP),raw_shmem(plain shared memory + your own synchronisation), andmailbox_only(doorbell + signal, no shared memory).rpmsgis 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 haveos: != off. Exactly two; RPMsg is point-to-point.carve_out_kb— shared-memory region size in kibibytes. The orchestrator allocates from the SoM preset'smemory_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 DTreserved-memorynode label, and the#defineprefix 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:
- The SDK's planner (
alp_orchestrate) loads + validatesboard.yamlagainst the JSON Schema + cross-field validator. - The planner resolves the SoM preset → topology defaults → effective per-core mapping.
- For each core with
os: != off,tanmaterialises the planner's per-core config to disk (build/m33_sm-zephyr/alp.conf,build/a55_cluster-yocto/conf/local.conf). tanwrites the planner's shared generated artefacts (alp/system_ipc.h,dts-reservations.dtsi).tanmaterialises the helper-MCU artefacts (GD32, CC3501E) the plan registers.tandispatches slice builds in parallel (west/bitbake/cmakeper slice).tanwritesbuild/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:
| Field | What it gives you |
|---|---|
command | The build command: {tool, args, cwd} — or null when the emitter has no valid command (see warnings below) |
configArtefacts | The slice's generated alp.conf / local.conf / cmake-args, contents inline, ready to byte-write |
appDir | Resolved 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 |
artifacts | Deterministic 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 |
env | Environment 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.gzrootfs. - The Zephyr
.elf(installed into the rootfs at/lib/firmware/alp/E1M-V2N101/m33_sm.elfso remoteproc picks it up on first boot). - Helper-MCU firmware (
gd32_bridge.bin,cc3501e_otp.blob). - A Mender
.swufor 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-edgeships withgdbserver. 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-zephyrattaches 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 matchsystem-manifest.yaml'sipc[].rpmsg_endpoint_idsfield.
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, andpr-renode-dual-osremain advisory (continue-on-error) pending self-hosted toolchain runners with the Zephyr SDK, bitbake, and Renode. The manifest-shape + determinism gates run onubuntu-latestand 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
<alp/rpc.h>— full RPC API referenceboard.yamlreference — flat schema + the declarative blocksrpmsg-v2n·rpmsg-aen·rpmsg-imx93·heterogeneous-offload— flagship examples- ADR 0010 — design rationale