April 26, 2026 · updated April 27, 2026 · Alex Potanin

seL4, Microkit, and Alpine Linux on the Jetson AGX Orin Devkit

What this post covers — and what's behind the curtain. This walkthrough gets seL4, Microkit, and libvmm running on the Jetson AGX Orin Devkit with an sDDF Ethernet driver mediating the network — enough to boot Alpine under libvmm with SSH end-to-end. It is a preview of what we can stand up quickly. If you need a more performant Ethernet driver, GPU passthrough, or support for additional architectures (other AArch64 SoCs, RISC-V, etc.), please get in touch — that is the kind of work we do to secure your systems.
Active work in progress. sel4test, Microkit parts 1–4, the libvmm + Alpine guest with SSH, and now the seL4-mediated sDDF network stack (Path B) all work end-to-end — same Alpine, same IP, but every packet now flows through a trusted PD instead of bare hardware passthrough. GPU passthrough beyond host1x + VIC and L4T-as-guest are still mid-stream. Repo layout, branch names, and the device-tree shape may shift as those land. Built with Claude Opus 4.7 (1M context); the 1M context kept multi-hour debug sessions coherent, but the model still over-engineers when it can't see the simpler path and tends to keep adding instead of stepping back. Chronological logs in AUTONOMOUS-*.md in the working tree. Please contact me for the unfiltered story. Thanks, Alex.

Picking up from the Orin Nano port: the same Tegra T234 SoC sits in the AGX Orin Developer Kit too. With small additions we run sel4test (141/141 pass), Microkit (tutorial parts 1–4 including a tiny bare-metal libvmm guest), and a full Alpine Linux 3.19 distro under libvmm with on-board 10 GbE working and keyless SSH from the host PC. Mainline tegra-host1x + tegra-vic bind to passed-through hardware so /dev/dri/card0 exists.

sel4test 141 / 141 · Microkit pt 1–4 · Alpine + SSH + DRM card · Path B sDDF network

Contents

  1. Hardware
  2. Boot setup — PXE proxyDHCP
  3. sel4test
  4. Microkit (parts 1–4)
  5. libvmm + Alpine Linux + SSH
  6. Path B — sDDF-mediated network
  7. Repository structure
  8. Coming soon: GPU passthrough & L4T as guest

All AGX-specific changes live on the orin-agx branches of the github.com/potanin forks (kernel, seL4_tools, util_libs, microkit, libvmm). The Nano port established the hard-won fixes — SDRAM-only kernel window, MT_NORMAL identity mapping, FEAT_CCIDX cache parsing, etc.; AGX inherits those wholesale.

1. Hardware

Compared to the Nano, the AGX Orin Devkit is friendlier for embedded development: the on-board USB-C debug port exposes the Tegra Combined UART (TCU) through the SPE coprocessor. No external USB-UART adapter, no level-shifting, no soldering — one cable handles both flashing and serial.

ItemNotes
Jetson AGX Orin Developer Kit (P3737 carrier + P3701 module)Tegra T234, 12× Cortex-A78AE, 32 GB RAM, on-board 10 GbE.
USB-C cableHost ↔ the small USB-C port labelled Front Type-C. Enumerates four /dev/ttyACMn devices on the host; interface 01 is TCU.
Ethernet cableTo your LAN, for PXE boot and (later) Alpine guest networking.
DC barrel jack5V supply included with the Devkit. We toggle power via a smart plug + Home Assistant for non-interactive iteration.
Linux x86_64 hostFor cross-compiling and running dnsmasq + tftpd-hpa.

Bidirectional console: host keystrokes reach the guest via the SPE → HSP (Hardware Synchronization Primitives) mailbox → tegra-tcu driver in the guest, exposed as /dev/ttyTCU0. The reverse path takes guest output back out the TCU via the AON HSP. We use a tiny host-side tcu-term.py that pads each Enter to 8 bytes so the SPE's USB-CDC RX batch flushes immediately.

2. Boot setup — PXE proxyDHCP

The Nano blog used UEFI HTTP boot. The AGX UEFI also supports HTTP boot, but we found PXE over IPv4 faster to iterate against because the AGX picks up new payloads simply by power-cycling — no UEFI menu, no SD card.

The pattern: leave the home router's regular DHCP alone, run a proxyDHCP on the dev host that injects PXE options (next-server, boot-file). TFTP is served by stock tftpd-hpa out of /srv/tftp.

$ sudo apt install dnsmasq tftpd-hpa
$ sudo systemctl start tftpd-hpa
$ ./dhcp-sel4test.sh &       # serves agx/sel4test.efi
$ # or ./dhcp-microkit.sh &   # serves agx/microkit.efi
$ # or ./dhcp-libvmm-alpine.sh & # serves agx/libvmm-alpine.efi

Each wrapper just sets BOOTFILE and exec's a shared dhcp.sh that runs dnsmasq --dhcp-range=...,proxy --pxe-service=ARM64_EFI,.... Drop the matching .efi into /srv/tftp/agx/ and power-cycle.

One-time UEFI tweak: connect a DisplayPort monitor + USB keyboard, reboot, hit Esc on the splash, set UEFI Boot Order so the on-board NIC's UEFI PXEv4 entry is first. After that the AGX boots the EFI we serve every time.

3. sel4test

The seL4 kernel for AGX is a near-clone of the Nano's orin-nano platform. Same SoC, same UARTC at 0x0c280000, same GICv3, same A78AE errata behaviour. The new orin-agx platform entry exists because:

The kernel and elfloader fixes (T234 SLC cache discipline, init_downpages full-PA identity map, SDRAM-only kernel window, 2 MiB low-address reservation, UART driver init, plat_console_putchar override) carry over from the orin-nano post unchanged — that post documents them with rationale and per-file diffs. The AGX-only delta on top is the new src/plat/orin-agx/ directory + DTS, the libsel4 and util_libs platform headers, and (for libvmm guests later in this post) the HCR_TID3 trap in vcpu.h. Build flags are identical: -DARM_HYP=ON -DDEBUG=ON (the latter unlocks 9 BREAKPOINT / SINGLESTEP tests — 132/141 without it, 141/141 with).

Build

cd ~/jetson-agx-orin/sel4test-src
mkdir build-orin-agx && cd build-orin-agx

../init-build.sh \
    -DPLATFORM=orin-agx \
    -DAARCH64=TRUE \
    -DARM_HYP=ON \
    -DDEBUG=ON

ninja
cp images/sel4test-driver-image-arm-orin-agx /srv/tftp/agx/sel4test.efi

Boot

$ ./dhcp-sel4test.sh &
$ ./run-sel4test.sh             # power-cycle + open picocom on /dev/ttyACM0
...
Test suite passed. 141 tests passed. 42 tests disabled.
All is well in the universe

4. Microkit (parts 1–4)

The au-ts/microkit_tutorial walks four parts: a hello PD on UARTC; two PDs sharing memory rings; a third PD with a separate input UART; finally replacing the bare-metal client with a libvmm guest VM. Adding the AGX as a Microkit board is three small changes:

  1. build_sdk.py — new BoardInfo(name="orin_agx", gcc_cpu="cortex-a78ae", loader_link_address=0x88000000, kernel_options={"KernelPlatform": "orin-agx", ...}). The link address is bumped from the default 0x80500000 to 0x88000000 (128 MiB into DDR) so the Microkit "initial task" has room for a libvmm payload that embeds a Linux kernel + initrd (~36 MiB).
  2. loader/src/uart.c — UARTC at 0x0c280000 for early loader prints. UEFI has already enabled the clock and divisor, so uart_init is a no-op.
  3. loader/src/aarch64/util64.S — in disable_mmu, preserve SCTLR_EL2's C bit (data cache enable). Clearing it on T234 triggers writeback of dirty firmware cache lines into protected memory, which the SLC hands to the IOB and which then RAS-faults the core. Same root cause as the cache-maintenance fixes on the Nano, just surfacing in a different code path.

Part 4 — the tiny libvmm guest

Part 4 was the only one that needed a kernel change beyond the Nano's work: enable HCR_TID3 in HCR_VCPU and emulate guest reads of ID-group-3 system registers in the kernel's hypervisor stub. Without this, the guest kernel's CPU-feature probe trips on ID_AA64MMFR0_EL1.PARange (the A78AE reports 44-bit; our stage-2 page tables are configured for 40-bit, the same override we use on Nano). The trap lets the kernel return a sanitised value with PARange = 40.

// include/arch/arm/armv/armv8-a/64/armv/vcpu.h
#define HCR_VCPU   ( HCR_COMMON | HCR_TWE | HCR_TWI | HCR_TID3 )

Plus a small handler that decodes the ESR_EL2.ISS, reads the real hardware ID register, masks the offending field, and writes the sanitised value back into the guest register file. ~80 lines.

5. libvmm + Alpine Linux + SSH

The next step is swap the toy bare-metal guest from Microkit part 4 for a real Linux distro. We use au-ts/libvmm's simple example as a starting point and fork it for the AGX. End state: an Alpine 3.19 guest with on-board 10 GbE, keyless SSH from the host PC, and a working DRM card via mainline tegra-host1x + tegra-vic.

What we pass through to the guest

Region / IRQWhy
UARTC @ 0x0c280000 + SPI 114earlycon for the guest before tegra-tcu binds.
HSP top0 + HSP AON + 13 SPIsBidirectional TCU console via SPE mailboxes (host /dev/ttyACM0 ↔ guest /dev/ttyTCU0).
BPMP shmem (2× 4 KiB SYSRAM pages)BPMP-FW IPC for clocks / resets / power domains.
MGBE0 (3 reg ranges) + SPI 38410 GbE NIC. Mainline tegra-mgbe + Aquantia AQR113C PHY bind directly.
host1x (3 reg ranges) + 9 SPIs, VIC + SPI 206Graphics fabric + 2D blit engine. Mainline tegra-host1x + tegra-drm + tegra-vic bind.

DMA worked out of the box without any explicit IOMMU programming: stage-2 identity-maps guest_ram (IPA = PA), and the Tegra SMMU defaults to bypass for our stream IDs. That's the unsafe part — this Section 5 path lets the guest's MGBE driver program DMA with arbitrary physical addresses. Section 6 (Path B) fixes this by moving MGBE ownership into a trusted PD; the guest only sees virtio-net with no DMA capability.

The vGIC fix

libvmm's vgic_v3 emulator was built and tested against odroid-c4 / qemu; on T234 the guest's first GICR_ISENABLER write fell into a branch that printed "Unknown enable register encoding" and no IRQs were ever delivered. Three small additions to src/arch/aarch64/vgic/vgic_v3.c + headers fix it; the changes are board-agnostic and should go upstream.

Initrd: Alpine + bake-in openssh + pinned MAC

build-initrd.sh takes Alpine's aarch64 minirootfs tarball and packs it through Linux's gen_init_cpio. At build time we also chroot into the rootfs (under sudo chroot; binfmt_misc's qemu-aarch64 with the F flag handles the ABI swap transparently) to:

We also pin the guest MAC in the device tree (local-mac-address = [02 c2 0a 00 01 01]) so DHCP returns the same lease across reboots. mini-init.sh on first boot mounts devpts, brings eth0 up, runs udhcpc, starts sshd, and prints the IP on TCU.

Boot & SSH in

$ ./run-libvmm-alpine.sh
[l4t] building libvmm + wrapping into EFI...
[l4t] deploying to /srv/tftp/agx/libvmm-alpine.efi...
[l4t] starting dhcp-libvmm-alpine.sh in background...
[l4t] power-cycling jetson...
[l4t] opening /dev/ttyACM0 with tcu-term.py...
...
>>> Alpine shell on /dev/ttyTCU0 — welcome <<<
~ # uname -a
Linux (none) 6.6.1 #9 SMP PREEMPT aarch64 Linux
~ # cat /etc/alpine-release
3.19.1
>>> sshd up: ssh root@192.168.100.180 (key or pw: libvmm)

# from the host:
$ ssh root@192.168.100.180   # keyless, your pubkey is baked in
~ # ls /dev/dri/
card0  renderD128
~ # drm_info /dev/dri/card0 | head
Driver: tegra (NVIDIA Tegra graphics) version 1.0.0
Device: host1x nvidia,tegra234-host1x

6. Path B — sDDF-mediated network (NEW Apr 27)

Section 5 above gets Alpine on the wire by directly passing through MGBE0 to the guest: the guest's mainline tegra-mgbe driver writes the MAC controller registers, the AQR113C PHY autonegotiates, the guest's own DMA engine moves packets. That works — you've seen the SSH demo — but it has a fundamental security problem:

Path B moves all of that into a trusted protection domain. The guest now sees a plain virtio-net device. The full PD layout looks like this:

                ┌─────────────────────────────────────┐
                │  CLIENT_VMM (prio 100)              │
                │   client_vmm.elf                    │
                │   ┌───────────────────────────────┐ │
                │   │ client_linux  (VirtualMachine)│ │
                │   │   guest sees:                 │ │
                │   │     virtio-net@160000         │ │
                │   │     MAC 02:c2:0a:00:01:01     │ │
                │   │     192.168.100.180           │ │
                │   └───────────────────────────────┘ │
                └────────────────▲────────────────────┘
                                 │  virtio queues
                                 ▼
                ┌─────────────────────────────────────┐
                │  client0_net_copier (prio 200)      │
                │   network_copy.elf  [stock sDDF]    │
                └────────────────▲────────────────────┘
                                 │ sDDF net queues
                   ┌─────────────┴─────────────┐
                   ▼                           ▼
       ┌────────────────────┐       ┌────────────────────┐
       │ net_virt_rx (200)  │       │ net_virt_tx (200)  │
       │ network_virt_rx.elf│       │ network_virt_tx.elf│
       │   [stock sDDF]     │       │   [stock sDDF]     │
       └─────────▲──────────┘       └──────────┬─────────┘
                 │                             │
                 └──────────────┬──────────────┘
                                ▼
                ┌─────────────────────────────────────┐
                │  eth_driver (prio 200)              │
                │   eth_driver.elf  ← NEW (Path B)    │
                │                                     │
                │   maps into PD vaddr space:         │
                │     0x50000000  bpmp_tx  (shmem)    │
                │     0x50001000  bpmp_rx  (shmem)    │
                │     0x50002000  hsp_dbell           │
                │   plus MGBE MMIO + IRQ              │
                │                                     │
                │   talks to: BPMP (clk/reset MRQs)   │
                │             AQR113C PHY (MDIO C45)  │
                │             XPCS UPHY (init only)   │
                └────────────────▲────────────────────┘
                                 │
                                 ▼
                        ┌────────────────┐
                        │  T234 MGBE0    │
                        │  + AQR113C PHY │
                        │  + RJ45        │
                        └────────────────┘

The eth_driver PD is the only thing in the system that touches MGBE registers, BPMP shmem, or the HSP doorbell. The guest has no DMA capability beyond its own RAM, no view of MAC registers, no BPMP access. net_virt_rx, net_virt_tx and network_copy are stock upstream sDDF binaries — we only had to write eth_driver. A future "firewall PD" or "sniffer PD" slots in cleanly between net_virt_rx and the copier without touching the guest at all.

What had to be written

sDDF doesn't ship a Tegra MGBE driver. We wrote one (~1,700 LOC of BSD-2 across ethernet.c, bpmp.c, aquantia.c, xpcs_pcs.c) using OE4T's nvethernetrm register headers (MIT) for the MGBE/XGMAC core, with a clean-room clause-45 driver for the AQR113C and a clean-room BPMP IVC client. The driver does its own:

The five bugs that took the longest

"Driver compiles, link comes up, packets flow" took five register-level fixes that no amount of reading vendor headers would have surfaced. The unblocking diagnostic was to boot the working Linux kernel (Section 5's passthrough Alpine), ssh in, dump every relevant MGBE register via a small mmap("/dev/mem")-based reader, then diff against our driver's writes. Highlights:

  1. Wrong ring-length register offsets. MGBE_DMA_CHX_TDRL and RDRL are at 0x3130 and 0x3134 in real MGBE/XGMAC2 (matches upstream Linux dwxgmac2.h), not 0x3128/0x3130 as the offsets I was deriving suggested. The result: RX ring length register held 0x20000000 (low 10 bits = 0), MGBE thought the ring had zero descriptors and silently dropped almost all received packets.
  2. Misleading vendor macro names. nvethernetrm's MGBE_DMA_CHX_TX_CNTRL2 and RX_CNTRL2 macros target the same offsets as the ring-length registers above. Writing the ORRQ/OWRQ values they suggest stomped the ring lengths.
  3. SPH (Split Header) bit on by default. Alpine's working CHX_CTRL = 0x01010000 includes bit 24, which makes MGBE write each frame across two descriptors (header in one, payload in the next). Linux's stmmac handles SPH; sDDF's flat net_buff_desc_t doesn't. Disabled it.
  4. Driver MAC didn't match guest virtio-net MAC. sdfgen auto-generates a MAC for each virtio-net device; net_virt_rx routes inbound packets by destination MAC. The driver's pinned 02:c2:0a:00:01:01 and the guest's auto-MAC didn't match, so unicast packets had no client to route to and got dropped. Fix: pin both ends to the same MAC in meta.py.
  5. Half a dozen smaller register-state diffs. DMA_SBUS, CHX_TX_CTRL, CHX_RX_CTRL, CHX_INTREN — all had values the docs would let you choose, but the working Linux kernel had picked specific bit patterns we needed to match (notably TIE for TX completion IRQ, which we had been omitting in favour of just the NIE umbrella).

The "diff against a working kernel via /dev/mem" technique unlocked everything in one session. Vendor docs and even mainline driver source are useful, but neither tells you what your specific board firmware ended up programming — the live state of a working kernel does.

Result

$ ssh root@192.168.100.180
Linux (none) 6.6.1 #9 SMP PREEMPT aarch64
Alpine Linux 3.19.1
~ # ip addr show eth0
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP qlen 1000
    link/ether 02:c2:0a:00:01:01 brd ff:ff:ff:ff:ff:ff
    inet 192.168.100.180/24 brd 192.168.100.255 scope global eth0

Same Alpine, same IP, same MAC as Section 5's passthrough variant — but the route the bytes take is now seL4-mediated. The guest has zero direct hardware capability beyond what its virtio-net device exposes. The same trusted-driver pattern carries over unchanged to the L4T-as-guest plan in Section 7: keep GPU/HDMI passthrough for CUDA & display, but route eth0 through Path B.

7. Repository Structure

All AGX changes live on orin-agx branches in the github.com/potanin forks. Each repo's branch is small — the heavy lifting was done on orin-nano; AGX adds a sibling platform plus the libvmm-only HCR_TID3 trap.

kernel/ — potanin/seL4 (6 files)

FileChange
src/plat/orin-agx/config.cmakeNew platform declaration: A78AE (A72 proxy), GICv3. (40-bit PA override turned out to be unnecessary — see Nano post's Dead Ends.)
src/plat/orin-agx/overlay-orin-agx.dtsDevice tree overlay: UARTC, dual memory bank with 64 MiB carve-out gap at 0xbe000000.
tools/dts/orin-agx.dtsFull device tree dumped from running L4T on the AGX hardware.
tools/hardware.ymlRegister orin-agx in the seL4 hardware database.
libsel4/sel4_plat_include/orin-agx/sel4/plat/api/constants.hPlatform constants header (required by build system).
include/arch/arm/armv/armv8-a/64/armv/vcpu.hAdd HCR_TID3 to HCR_VCPU; emulate ID-group-3 register reads in armv_handle_id_trap. Required by libvmm Linux guests on A78AE.

tools/seL4/ — potanin/seL4_tools (2 files)

FileChange
cmake-tool/helpers/application_settings.cmakeAdd orin-agx to the EFI boot platform list.
cmake-tool/helpers/nanopb.cmakeLink nanopb against muslc so it picks up stdint.h under -nostdinc. Generic fix; surfaces on T234 because we exercise nanopb-using libs.

projects/util_libs/ — potanin/util_libs (5 files)

FileChange
libplatsupport/plat_include/orin-agx/platsupport/plat/clock.hPlatform stub header (required by build system).
libplatsupport/plat_include/orin-agx/platsupport/plat/i2c.hPlatform stub header.
libplatsupport/plat_include/orin-agx/platsupport/plat/serial.hUARTC address and parameters.
libplatsupport/plat_include/orin-agx/platsupport/plat/timer.hNV timer addresses and interrupt routing.
libplatsupport/src/mach/nvidia/timer.cRoute the timer interrupt through the shared register, same code path as TX2 / orin-nano.

microkit — potanin/microkit (3 files)

FileChange
build_sdk.pyAdd BoardInfo(name="orin_agx", ...) with loader_link_address=0x88000000 (and orin_nano).
loader/src/uart.cAdd orin-agx / orin-nano case using UARTC at 0x0c280000.
loader/src/aarch64/util64.SPreserve SCTLR_EL2 C bit in disable_mmu; drop set/way dc cisw in flush_dcache — same RAS root cause as Nano.

libvmm — potanin/libvmm (10 files)

FileChange
examples/simple/board/orin_agx/simple.systemMicrokit system description: guest_ram + UARTC + HSP top0/AON + BPMP shmem + MGBE0 + host1x/VIC memory regions and IRQ channels.
examples/simple/board/orin_agx/linux.dtsMinimal handwritten guest DTS: GICv3, UARTC, hsp_top0/aon, tcu, bpmp, mgbe0+AQR113C PHY, host1x+VIC. Pinned local-mac-address.
examples/simple/board/orin_agx/mini-init.shThe /init for the Alpine guest: mount /dev /proc /sys /dev/pts, spawn shell on /dev/ttyTCU0, bring up eth0 + udhcpc + sshd in background.
examples/simple/board/orin_agx/build-initrd.shBuilds the cpio initrd from Alpine minirootfs, bakes in openssh-server, host keys, root password, and the host's ~/.ssh/id_rsa.pub via sudo chroot.
examples/simple/vmm.cBOARD_orin_agx case: 768 MiB guest_ram at 0xc2000000, HSP / MGBE / host1x / VIC IRQ passthrough table (channels 2–25).
include/libvmm/arch/aarch64/vgic/vgic.hAdd CONFIG_PLAT_ORIN_AGX case with GICv3 dist/redist addresses (0x0f400000 / 0x0f440000).
include/libvmm/arch/aarch64/vgic/vdist.hvgic distributor: handle the GICR_ISENABLER write encoding the guest uses on its first IRQ enable.
src/arch/aarch64/vgic/vgic_v3.cFix for "Unknown enable register encoding" on the guest's first GICR_ISENABLER write. Board-agnostic; should go upstream.
src/arch/aarch64/linux.cMinor: clearer guest-boot diagnostics; log kernel/initrd/DTB placement.
src/arch/aarch64/fault.cMinor: log faulting IPA on stage-2 faults to make passthrough mistakes obvious in dmesg.

8. Coming soon — GPU passthrough & L4T as guest

Section 6 already showed the seL4-mediated network working with Alpine. The remaining piece is the graphics stack — bringing CUDA, NVDISPLAY/HDMI, and the rest of the L4T stack online under libvmm with the same trusted-base story.

Phase 2 (started) — mainline graphics stack

tegra-host1x and tegra-vic already bind under our minimal libvmm; /dev/dri/card0 exists. The actual GA10B Ampere GPU has no upstream driver in mainline today — Nouveau doesn't yet support Tegra Ampere — so a real GPU device is blocked on upstream Nouveau work.

Phase 3 (started) — L4T as the libvmm guest

For CUDA / HDMI / proper GPU we need NVIDIA's downstream stack, which means running L4T's kernel and userspace under libvmm. First milestone reached: L4T's 5.15.148-tegra kernel boots successfully under our libvmm, runs init, drops to a shell, and nvethernet / nvgpu modules load (binding still requires extracting the rich nvidia,* properties from L4T's NV-DTB into our guest device tree).

The end-state target: pass GPU + NVDISPLAY through to the L4T guest directly, but route eth0 through Section 6's sDDF stack — the same trusted-driver pattern that already works for Alpine. Once the L4T guest boots far enough to bring up the network, it'll inherit the seL4-mediated path for free, and a small "firewall PD" can slot in between net_virt_rx and the copier. Future post when GPU passthrough lands.

Discussion

Questions or feedback? Email Alex. See also the Orin Nano post for the underlying T234 fixes that AGX inherits.