Skip to content

DRAFT: transport: rpmsg (OpenAMP) transport for MPU+MCU systems - #304

Draft
beriberikix wants to merge 13 commits into
mainfrom
transport/rpmsg
Draft

DRAFT: transport: rpmsg (OpenAMP) transport for MPU+MCU systems#304
beriberikix wants to merge 13 commits into
mainfrom
transport/rpmsg

Conversation

@beriberikix

@beriberikix beriberikix commented Jul 23, 2026

Copy link
Copy Markdown

Warning

Draft / do not merge. Stacked on #232 (serial transport core) — base is transport/serial, not main. Rebase onto main once #232 lands. Tracks the RFC in #274.

Also depends on #317 (two serial-core fixes against #232's branch). Without those the device crashes on the first ACK and the uplink stalls every session, so this branch does not run on hardware until they land.

What this is

An rpmsg (OpenAMP) transport for Pouch on heterogeneous MPU+MCU SoCs: the MCU runs Zephyr + the Pouch device stack and syncs to a Linux gateway over on-die OpenAMP/rpmsg, keeping its own cloud identity and end-to-end encryption while the Linux side forwards opaque pouches.

Per #274, this is an adapter on the serial transport core (#232), not a new transport — each rpmsg message carries one Pouch Serial frame, reusing the serial channel model, no SAR.

Status

  • rpmsg device adapter, ipc_service (port/zephyr/transport/serial/rpmsg_device.c): ipc_service endpoint ↔ serial device core + POUCH_RPMSG_DEVICE Kconfig.
  • rpmsg device adapter, resource table (port/zephyr/transport/serial/rpmsg_rsc_device.c): the raw-OpenAMP twin, required to talk to Linux. See "Why two adapters" below.
  • UART device adapter (port/zephyr/transport/serial/uart_device.c): interrupt-driven native-tty adapter for native_sim, sharing the framing module.
  • Framing module (uart_framing.[ch]): pure, testable length-delimited framing (encoder + streaming decoder).
  • SAEAD example (examples/zephyr/rpmsg_device): builds in nsim CI; compiles the UART adapter + serial device path with SAEAD. Now also builds for imx95_evk/mimx9596/m7.
  • MCU-mediated OTA (src/transport/endpoints/device/fw.[ch], two new serial channels): a core with no flash of its own downloads its own image through Pouch OTA and relays the plaintext to the host, which verifies the SHA-256 and applies it via remoteproc.
  • Tests (tests/pouch/rpmsg/exchange, native_sim, passing in twister CI): framing round-trip / resync / rejection, plus an end-to-end broker↔device serial exchange routed through the framing.
  • Renode Tier-2 (mechanism validated) — see doc/renode-tier2/. Real Linux remoteproc + virtio-rpmsg in Renode brings up the RFC's exact interface (/dev/rpmsg_ctrl0), loads/starts the Zephyr R5 firmware, and the full R5 firmware bring-up is worked out and documented. The final channel announcement is blocked by an emulator-model IPI stall after remoteproc start.
  • Hardware validated — the round-trip Renode deferred is done. FRDM-IMX95 (Cortex-M7 under Linux remoteproc) against production Golioth, with connect-agent as the broker: device online, telemetry flowing, and the gateway seeing only ciphertext. Three complete OTA runs (install, self-hosted upgrade, downgrade), each installed image byte-identical to the cloud artifact by SHA-256, plus a negative test in which a corrupted image was rejected on hash with nothing installed and the running firmware untouched.

Why two adapters

Zephyr's DT-instantiable ipc_service backends use a static vring layout that the Linux kernel's virtio_rpmsg_bus does not understand. Against Linux the vdev has to come from the resource table, exactly as in the openamp_rsc_table sample — which is what rpmsg_rsc_device.c does. The ipc_service adapter remains the right choice for Zephyr-to-Zephyr and for RTOS peers that speak the same static layout.

Naming the endpoint rpmsg-raw also gets a /dev/rpmsgN for free on the Linux side, with no RPMSG_CREATE_EPT_IOCTL dance.

MCU-mediated OTA

On these parts the MCU is loaded from the host filesystem at every boot, so it has no flash to write an update into — but it is still the authenticated Pouch endpoint. It downloads its own image through Pouch OTA and streams the plaintext out over a firmware channel; the host verifies the SHA-256 from the manifest, installs alongside the previous image (<name>.prev for rollback) and restarts the core.

The image is never resident on the MCU. Golioth OTA reports only a state upstream, never a received offset, so the cloud streams a whole component within one session and expects the device to consume it. A flashless MCU with a small relay buffer cannot do that if the firmware channel is only collected after the downlink verb finishes — so the broker collects the firmware channel concurrently with the downlink that feeds it, and the channel holds its transfer open when the relay is momentarily empty rather than reporting "nothing to collect".

Notes for reviewers

  • This branch now does touch the serial core: it adds two channels to include/pouch/transport/serial/common.h and registers them in src/transport/serial/device.c. That is the OTA feature, not the transport. The two actual serial-core fixes are split out into fix(serial): guard optional endpoint->end and re-arm a stalled channel #317 against Add serial transport #232's branch.
  • The west-ncs twister job fails because this branch's base is behind main; resolves on rebase after Add serial transport #232. The nsim example job is red only from pre-existing backend-dependent coap/http pytests; sample.pouch.rpmsg_device builds clean.
  • Renode Tier-2 is local-only (no OpenAMP/ZynqMP in pouch CI).
  • The example's device credentials are placeholders; hardware validation used real ones kept out of the tree.
  • Known gap: a rejected image leaves the OTA component marked "updating", so the device needs a restart before it will retry — the manifest is delivered once per connection and never comes round again. A fix is drafted but is the one piece not yet hardware-verified, so it is deliberately not in this branch.

🤖 Generated with Claude Code

trond-snekvik and others added 12 commits July 17, 2026 11:53
Adds the serial transport layer, as outlined in golioth/architecture#85.
This does not include a real transport adapter, but includes three test
suites that test the serial device, serial broker and a test that covers
both together, each using mock serial adapters and endpoints.

Signed-off-by: Trond Snekvik <trond.snekvik@canonical.com>
Add an rpmsg (OpenAMP) physical adapter for the Pouch Serial device
transport. rpmsg is reliable, ordered and message-oriented, so each rpmsg
message carries exactly one Pouch Serial frame (1-byte header + payload)
with no extra framing or segmentation; the adapter feeds received messages
straight into the serial device core and pulls frames to send when the core
signals data is available.

The adapter is a device-side driver only (the broker runs on the Linux
application processor). It initializes from the ipc_service instance
referenced by the "golioth,pouch-rpmsg-ipc" chosen node at APPLICATION init
priority.

This establishes the port/zephyr/transport/serial adapter directory that
future bus adapters can share.

Part of the rpmsg transport work for #274. Depends on the
serial transport core (#232); do not merge until that lands.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add an interrupt-driven UART device adapter on the serial transport core for
development and native_sim end-to-end testing. rpmsg is not available on
native_sim, so the same serial device core is exercised over a host pty (the
native-tty UART driver) with the broker running as a host process. A minimal
length-delimited framing (SOF + 2-byte length) recovers frame boundaries on
the byte stream; the serial frame's own 1-byte header carries the channel.

Also add the examples/zephyr/rpmsg_device sample: a Pouch device that reports
telemetry and handles a setting over the serial transport. It builds for
native_sim (build_only) so CI compiles the serial-core + adapter path. The
same device code runs over the rpmsg adapter on real MPU+MCU hardware.

Part of #274. Do not merge until #232 lands.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The serial device wired the server-cert and device-cert channels
unconditionally, but those endpoints are only compiled when SAEAD encryption
is enabled, so a POUCH_ENCRYPTION_NONE build failed to link with undefined
references to pouch_device_endpoint_server_cert/device_cert. Guard the two
cert channels on CONFIG_POUCH_ENCRYPTION_SAEAD (mirroring the BLE GATT
transport, which only exposes its cert characteristics under SAEAD), and
defensively ignore frames that arrive for a channel with no endpoint.

This lets the serial transport build without encryption, e.g. for the
native_sim rpmsg_device example.

Candidate to fold into the serial transport core (#232).

Part of #274. Do not merge until #232 lands.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extract the UART adapter's length-delimited framing (SOF + 2-byte length) into
a pure, dependency-free module (uart_framing.[ch]) with an encoder and a
byte-at-a-time streaming decoder, and use it from uart_device.c. This makes the
framing unit-testable and lets a host-side broker mirror the exact wire format.

Add tests/pouch/rpmsg/exchange (native_sim ztest):
- direct framing tests: encode/decode round-trip, resync after garbage,
  rejection of oversized and malformed frames;
- an end-to-end test that runs a full broker <-> device serial exchange (reusing
  the serial exchange stub endpoints, so no gateway/cloud is needed) with every
  frame passed through encode + streaming decode, asserting all channel payloads
  survive the framing round-trip.

Unlike the coap/http example pytests, this runs in the twister CI without any
backend.

Part of #274. Do not merge until #232 lands.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ample

The earlier cert-channel SAEAD guard regressed the serial transport unit tests
(pouch.serial.exchange, pouch.serial.device): those tests build the serial
device core without CONFIG_POUCH_ENCRYPTION_SAEAD but still exercise the
certificate channels through stub endpoints, so guarding the channels on SAEAD
removed endpoints the tests rely on.

Revert src/transport/serial/{device,channel}.c to the #232 base so this PR no
longer modifies the serial core, and drop the native_sim rpmsg_device example
that depended on POUCH_ENCRYPTION_NONE. The serial device requires SAEAD in a
real build (its certificate endpoints are only compiled under SAEAD); a proper
SAEAD-based example (which also compiles the UART/rpmsg adapters) is a
follow-up.

The rpmsg framing + end-to-end exchange test (tests/pouch/rpmsg/exchange) is
unaffected: it drives the real broker/device serial cores with the shared stub
endpoints, so it needs no encryption and exercises the framing round-trip.

Part of #274. Do not merge until #232 lands.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a native_sim example that exercises the serial transport's UART device
adapter with SAEAD end-to-end encryption (Pouch's default), so CI compiles the
UART adapter and the serial device path. The device credentials are embedded
self-signed placeholders (P-256), keeping the example self-contained with no
provisioned filesystem; a real deployment provisions a CA-signed device
certificate (see coap_client/ble_gatt for the filesystem pattern).

The example reports telemetry over the serial transport; on native_sim the link
is a host pty (native-tty UART) and on a real MPU+MCU part the same device code
runs over the rpmsg adapter. Built build_only on native_sim in CI.

Part of #274. Do not merge until #232 lands.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Capture the work-in-progress harness for validating the Pouch rpmsg (OpenAMP)
transport against a real Linux remoteproc + virtio-rpmsg stack in Renode, with
no hardware. This complements the native_sim tests, which cannot exercise the
ipc_service/OpenAMP adapter.

Includes:
- README documenting the validated approach: platform (AMD Kria KV260 / ZynqMP
  Cortex-R5, Zephyr board kv260_r5), the OpenAMP DDR carve-out addresses taken
  from the demo's Linux DTB (vrings @0x3ed40000/0x3ed44000, buffers
  @0x3ed48000), the IPI mailbox wiring, and how to build/run.
- kv260_r5 openamp overlay + conf (zephyr,ipc_shm @0x3ed40000, zephyr,ipc =
  &rpu0_ipi) that lets zephyr/samples/subsys/ipc/openamp_rsc_table build for
  kv260_r5.
- A robot smoke test that boots a kv260_r5 Zephyr image on the emulated R5.

Validated so far: Renode's shipped ZynqMP OpenAMP echo test passes (real
remoteproc + virtio-rpmsg round-trip); a kv260_r5 hello_world boots on the
emulated R5; the openamp_rsc_table firmware builds for kv260_r5. Remaining:
load the firmware via Linux remoteproc, swap in the Pouch device + mock broker,
Ubuntu rootfs, and an end-to-end Robot test.

Part of #274. Do not merge until #232 lands.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Zephyr OpenAMP firmware built for kv260_r5 now loads and starts on the R5
via real Linux remoteproc in the Renode ZynqMP demo (remoteproc state ->
running). Add the robot test that boots the demo Linux, injects the firmware
into a copy of the rootfs (debugfs, no root), and drives the remoteproc
load/start; document the debugfs injection flow.

Fix firmware placement: device-address 0x0 maps to the 64K R5 TCM, which the
~155K openamp image overflows (remoteproc "bad phdr da 0x0"). Relocate &sram0
to the DDR rproc carve-out at 0x3ed00000 (256K), which the Xilinx R5
remoteproc driver maps.

Remaining: confirm the rpmsg channel end to end, swap in the Pouch device +
adapter firmware, an Ubuntu rootfs, and a mock broker.

Part of #274. Do not merge until #232 lands.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Root-cause the R5-side bring-up: the ZynqMP R5 boots/executes from TCM (reset
at 0x0), not DDR. A DDR-relinked firmware loads via remoteproc (state=running)
but never runs. Switch the overlay back to the default TCM link address and
keep only the vrings/buffers in DDR (zephyr,ipc_shm @ 0x3ed40000); route the
R5 console to uart0 (Linux owns uart1). Trim the OpenAMP sample (no shell/log)
to ~45 KiB so it fits ATCM.

With that, the Linux side is fully up (virtio_rpmsg_bus online, /dev/rpmsg_ctrl0
+ rpmsg_ctrl/rpmsg_ns devices) and the R5 now executes the firmware (prints on
uart0). Remaining open blocker: the firmware faults in its IPM/IPI setup
(ipm_set_enabled -> prefetch abort) before completing the rpmsg handshake;
documented as the next step.

Part of #274. Do not merge until #232 lands.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Document and capture the R5-side bring-up progress. Three real, sequential
ZynqMP-R5-OpenAMP requirements the board lacked out of the box:

1. TCM-linked firmware (R5 boots from TCM, not DDR).
2. zephyr,ipc must point at the child mailbox (&rpu0_apu_mailbox); the
   ipm_xlnx_ipi driver leaves the parent rpu0_ipi device with a NULL api, so
   pointing at the parent faults in ipm_set_enabled.
3. An MPU region for the DDR OpenAMP carve-out: the ZynqMP SoC uses a static
   MPU table that doesn't map it, so the R5 data-aborts in virtqueue init.
   Captured as zynqmp-r5-openamp-mpu.patch.

With all three the R5 runs the OpenAMP firmware without faulting. Remaining
open blocker: after remoteproc start the emulation nearly stalls and no
R5-announced rpmsg channel is observed yet (likely an IPI notify /
interrupt-storm issue); documented for follow-up.

Part of #274. Do not merge until #232 lands.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rdware

Frame the Renode Tier-2 outcome: the Linux remoteproc + virtio-rpmsg mechanism
(the RFC's /dev/rpmsg_ctrl0 interface) and the full Zephyr R5 firmware bring-up
(TCM link, child-mailbox IPM, MPU shared-memory region) are validated and
documented. The final fault-free R5 rpmsg channel announcement is blocked by an
emulator-model IPI interrupt-storm/stall after remoteproc start, so the
end-to-end Pouch-over-rpmsg round-trip is deferred to real MPU+MCU hardware.

Part of #274. Do not merge until #232 lands.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@beriberikix beriberikix mentioned this pull request Aug 1, 2026
… on hardware

Completes this PR's deferred item: the end-to-end Pouch-over-rpmsg round-trip
now runs on real MPU+MCU hardware. Validated on an FRDM-IMX95 (Cortex-M7 under
Linux remoteproc) against production Golioth, with connect-agent as the broker
on the Linux side.

Resource-table transport
------------------------

port/zephyr/transport/serial/rpmsg_rsc_device.c is a second device adapter
alongside the ipc_service one. Zephyr's DT-instantiable ipc_service backends
use a static vring layout that the Linux kernel's virtio_rpmsg_bus does not
understand, so against Linux the vdev has to come from the resource table, as
in the openamp_rsc_table sample. Naming the endpoint "rpmsg-raw" gets a
/dev/rpmsgN for free, with no RPMSG_CREATE_EPT_IOCTL.

Two bugs that only a fast host exposes:

- rpmsg_send() blocks until a TX buffer frees, and it is the thread servicing
  the vrings that would free one. Use rpmsg_trysend() with a bounded retry.
- The management thread ran at the same priority as the Pouch work queue, so
  it drained the entire RX vring without yielding while the decrypt queue
  never ran. A host pushed 137 KB in 6 ms and pouch_downlink_push() kept
  heap-allocating encrypted blocks until malloc failed, which left the
  downlink channel permanently erroring. The management thread now runs below
  the work queue, and the RX path stalls while the firmware relay is backed
  up so the backpressure reaches the broker instead of piling up on-device.

MCU-mediated OTA
----------------

A core loaded by remoteproc has no flash to write to, but it is still the
authenticated Pouch endpoint. It downloads its own image through Pouch OTA and
relays the plaintext to the host over two new serial channels, and the host
verifies the SHA-256 from the manifest, installs alongside the previous image
and restarts the core.

The relay never holds the image. Golioth OTA reports only a state upstream,
never a received offset, so the cloud streams a whole component within one
session and expects the device to consume it - which a flashless MCU with
32 KB of buffer cannot do if the firmware channel is only collected after the
downlink verb completes. The broker therefore collects the firmware channel
concurrently with the downlink that feeds it, and the channel holds its
transfer open when the relay is momentarily empty rather than reporting
"nothing to collect".

Results
-------

Three complete OTA runs (install, self-hosted upgrade, downgrade), each
installed image byte-identical to the cloud artifact by SHA-256, plus a
negative test: a corrupted image was rejected on hash, nothing was installed,
the core was not restarted and the running firmware was untouched.

Notes
-----

- This branch adds two channels to the serial common header and registers
  them in device.c, so unlike the rest of the PR it does touch the serial
  core. It also depends at runtime on two serial-core fixes sent separately
  against #232's branch; without them the first ACK crashes the device and
  the uplink stalls every session.
- The example's device credentials remain placeholders.
- Known gap: a rejected image leaves the component marked "updating", and the
  device needs a restart to retry, because the OTA manifest is delivered once
  per connection. A fix is drafted but not yet hardware-verified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@beriberikix

Copy link
Copy Markdown
Author

Hardware validation: findings

Pushed 4a14a0e, which closes out the item this PR deferred — the end-to-end round-trip now runs on real MPU+MCU hardware. Setup: FRDM-IMX95, Cortex-M7 under Linux remoteproc, connect-agent as the broker, production Golioth. Three complete OTA runs plus a negative test.

Most of the work turned out to be in things the diff does not make obvious, so they are worth writing down.

1. ipc_service cannot talk to Linux

This is the biggest one, and it is why there are now two adapters. Zephyr's DT-instantiable ipc_service backends implement their own static-vring layout. The Linux kernel's virtio_rpmsg_bus does not speak it. Against Linux the vdev must come from the resource table.

The adapter this PR originally shipped could never have worked against a Linux host as written. rpmsg_rsc_device.c is the resource-table twin; the ipc_service one is still correct for Zephyr-to-Zephyr.

2. A host/device deadlock that is entirely on the Linux side

The link would freeze mid-transfer with the gateway blocked in write() on /dev/rpmsg0 — for minutes. Goroutine stacks showed it parked in Go's netpoller waiting for EPOLLOUT.

virtio_rpmsg_poll() reports the endpoint writable only when a TX buffer is already free. It never enables the tx-complete callback. Only the blocking write(2) path calls rpmsg_upref_sleepers(), which is what enables it. So a non-blocking writer that drains the ring — routine when streaming a firmware image — waits for a wakeup that structurally cannot arrive.

Worth flagging for anyone writing a gateway in a runtime that makes fds non-blocking by default (Go, Node, async Rust). The fix was to keep the fd out of the poller and use blocking writes.

3. Thread priority is load-bearing, not a tuning knob

The rpmsg management thread ran at the same priority as the Pouch work queue. Same priority + preemptive means the management thread drains the entire RX vring without ever yielding — we measured 137 KB arriving in 6 ms — while the decrypt queue never runs. pouch_downlink_push() heap-allocates one buffer per encrypted block, so it allocated until malloc failed.

The failure mode is nasty: the allocation failure puts the downlink channel into a permanent error state, and every subsequent session fails with "peer rejected transfer". The device never recovers. A transient allocation failure arguably should not be terminal for the channel.

The management thread now runs below the work queue, and the RX path applies real backpressure by stalling while the relay is backed up, so the pressure reaches the broker instead of pooling on the device.

4. OTA has no resume, which constrains the design

golioth_ota_receive_component() reports only a state upstream (idle / downloading / updating), never a received byte offset. The cloud therefore streams an entire component within one session and expects the device to consume all of it.

A flash-backed device writes as it goes. A flashless MCU cannot: there is no way to buffer a 253 KB image in 256 KB of RAM. And it cannot drain to the host either, because the broker only collected the firmware channel after the downlink verb completed — the buffer fills partway through the very verb that has to finish before it can empty.

So the broker has to service the firmware channel concurrently with the downlink. The Pouch Serial frame format already supports this (every frame carries its channel); it was only the broker's sequential verb loop that did not. If OTA ever grows an offset in its upstream status, this gets much simpler.

5. Known gap: a rejected image needs a reboot to retry

Deliberately not in this branch, because it is the one piece I could not verify on hardware before losing the board.

When the host rejects an image, the device has already called golioth_ota_mark_updating(), which stops the cloud offering the component again. It never retries. The obvious place to undo that — the OTA manifest handler — does not work: the manifest is delivered once per connection, so a device that has already marked itself updating never sees another one. I confirmed this: 18 sessions after a rejection, zero retries.

The fix is to hang it off the apply verdict itself rather than the manifest. That is drafted, but the M7 took a bus fault on boot with it in and I could not isolate whether the cause was the change or this board's known load-order-dependent TCM ECC behaviour. Left out rather than shipped unverified.

Two serial-core bugs → #317

Both block any device talking to a real broker, and both are unreachable from the current tests: every mock in stub_endpoints.c defines .end, and they are all synchronous so none ever returns MORE_DATA with zero bytes. Split out into #317 against #232's branch.

Platform notes for anyone reproducing this

  • The i.MX95 System Manager config name must be mx95evkrpmsg — U-Boot's power_on_m7() compares it and silently skips otherwise, and that function is what zero-initialises both TCMs. The M7's ITCM is ECC-protected with a 64-bit granule, and arm64's memcpy_toio() writes a segment's unaligned tail as single-byte stores, so a partial write into a never-written granule leaves invalid ECC and the first read of it is a precise data bus fault. Skipping the zeroing makes booting depend on what ran before.
  • Relatedly, soc/nxp/imx/imx9/imx95/m7/linker.ld gives .resource_table only 4-byte alignment with no explicit address, so the tail alignment of the last PT_LOAD depends on the parity of every preceding ROMABLE section. That is a latent trap for any OpenAMP app on this SoC.
  • Zephyr's drivers/ipm/ipm_mbox.c dispatches its callback with no NULL check. The host can kick the MU before the application registers one — the MU's interrupt-enable bits survive a remoteproc restart — and the core faults during early boot. Both Zephyr fixes to be sent upstream separately.

🤖 Generated with Claude Code

@trond-snekvik
trond-snekvik force-pushed the transport/serial branch 3 times, most recently from b39bbd2 to a431caf Compare August 12, 2026 09:15
Base automatically changed from transport/serial to main September 2, 2026 09:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants