Experiment First, Compile Later: Python on Both Processors of the Allwinner T527
Part 5 of Bringing Up Heterogeneous RISC-V on Allwinner SoCs (Radxa Cubie A5E)
One Cubie A5E, two kinds of processor, Python on both: check out new hardware and run experiments in seconds, with no compile in the loop.
The Radxa Cubie A5E is a small board with a surprise inside. Its Allwinner T527 runs Linux on eight ARM Cortex-A55 cores, and next to them sits a 200 MHz XuanTie E906 RISC-V core that Linux never uses. In this part we put MicroPython on that RISC-V core and drive it from Python on Linux. You type a line, the E906 reads a compass over I²C or a GPS over a UART, and the answer comes back over the link between the two processors. No firmware rebuild, no reflash, no reboot between experiments. While it runs, AbstractX Studio—our native desktop engineering workbench running on your PC—provides real-time visibility into the coprocessor: CPU utilization, per-interrupt microsecond costs, line-by-line source profiling, and an interactive prompt, all without stalling hard real-time execution.
Live from the board: AbstractX Studio displays the E906’s real-time heartbeat. Once a second it updates CPU load, per-task execution percentages, and exact per-interrupt microsecond costs using sub-microsecond non-intrusive binary telemetry.
In short
- One chip, two worlds. Eight Cortex-A55 cores run Linux: networking, files, a full Python. One small RISC-V core, the E906, is free for real-time work: sensors, timing, control loops.
- The device tree decides who owns which pins. A
one-line setting in
/boot/config.txtmakes U-Boot hand the GPS UART, the compass I²C bus and some DMA channels to the E906 instead of Linux. - Linux starts the E906 by loading a firmware file, and the two talk by passing messages through shared memory, with a hardware doorbell to say “look now”. That message system is called RPMsg.
- AbstractX is the firmware on the E906: a small event loop with interrupt-driven drivers. MicroPython runs inside it, so the E906 can run Python you send it.
- Python on Linux talks to Python on the E906. A
20-line Linux script can run code on the E906 or read its CPU load.
AbstractX Studio, a dedicated desktop engineering
workbench running on your PC, gives you an interactive REPL and live
silicon observability instruments (bare-metal
top,perf, and a software logic analyzer timeline). - The point is speed of learning: try the hardware in Python, measure what it costs, and only then write the fast version in C++ on the same drivers.
Where this fits in the series
You do not need the earlier parts to follow this one: each section below starts from what you need to know and points back to the part that goes deeper.
1. The board and what we hang on it
The Cubie A5E’s Allwinner T527 combines:
On our test bench two sensors are wired to the 40-pin header:
- a u-blox NEO-M8N GPS on UART3 (pins PI11 / PI12), and
- an HMC5883L compass on I²C1, also called TWI1 (pins PB4 / PB5).
Part 1 explains why a core like the E906 is worth having next to Linux: deterministic timing, offloading the A55s, and isolating real-time work from everything Linux does. This part is about making that core quick to experiment with.
2. Who owns which pins: the device tree and U-Boot
Linux does not probe the hardware to find out what is on the board; it reads a device tree, a data file that lists every peripheral, its registers, its interrupt, its clock and which driver should use it. The board’s base device tree gives everything to Linux.
To give UART3 and I²C1 to the E906 instead, we do not edit that base
file. We apply an overlay: a small patch file
(.dtbo) that changes a few nodes. The device-tree
overlay article explains the mechanism in depth. In short:
- The boot partition holds a plain text file,
/boot/config.txt. - U-Boot runs a boot script (
boot.scr, compiled fromboot.cmd) that readsconfig.txt, loads the base device tree into RAM, and applies every overlay listed on thedtoverlay=line, one after another. - Linux boots with the merged tree and never knows overlays were involved.
On our board config.txt contains:
dtoverlay=cubie-a5e-flight-stack-e906
That overlay (cubie-a5e-flight-stack-e906.dtso)
does four things:
- UART3, I²C1 and SPI1 go to
generic-uio. Linux still sets the pin multiplexing, turns on the clocks and releases the resets, but it attaches no driver, so the E906 has the registers to itself. - The message box and
remoteprocare enabled, with the firmware nameriscv-firmware.elfand the memory region for the shared rings. - The DMA controller is split:
dma-channels = <8>tells Linux it has only channels 0-7, so channels 8-15 are free for the E906 (section 10). - Pins are documented in the file, header pin by header pin.
Changing the hardware split is therefore an edit, a compile and a reboot:
# PC: compile the overlay (dtc from a kernel build tree)
dtc -@ -I dts -O dtb -o cubie-a5e-flight-stack-e906.dtbo cubie-a5e-flight-stack-e906.dtso
# Board: install it and reboot
scp -O cubie-a5e-flight-stack-e906.dtbo root@192.168.3.3:/boot/
ssh root@192.168.3.3 rebootThis week’s DMA change was exactly that: one line,
dma-channels = <8>, and after the reboot
/proc/device-tree/soc/dma-controller@3002000/dma-channels
read 8 instead of 16. There is also a
cubie-a5e-flight-stack-linux overlay that gives the same
pins back to Linux drivers (/dev/ttyAS3,
/dev/i2c-1), so you can switch between “Linux owns the
sensors” and “the E906 owns the sensors” with one line in
config.txt.
3. Starting the E906
from Linux: remoteproc
The E906 has no flash of its own. Linux loads its firmware into the on-chip SRAM and starts it, using the kernel’s remoteproc framework (built and explained in Part 2). The firmware is an ordinary ELF file:
ls -l /lib/firmware/riscv-firmware.elf # a link to the firmware you want
echo stop > /sys/class/remoteproc/remoteproc0/state
echo start > /sys/class/remoteproc/remoteproc0/state
cat /sys/class/remoteproc/remoteproc0/state # running
cat /sys/kernel/debug/remoteproc/remoteproc0/trace0 # the E906's boot logInside the ELF is a small resource table that tells
Linux what the firmware needs: where its boot log lives
(trace0) and that it wants an RPMsg link. Swapping firmware
is a copy and a stop/start; Linux keeps running the whole time. On our
board an init script starts the E906 at boot.
4. How Linux and the E906 talk: RPMsg from the ground up
Two processors that share memory still need rules to exchange data. RPMsg is built in layers, and each layer solves one problem:
- Shared memory. Both processors can read and write the upper 128 KB of SRAM Space 1. That is where messages live. But shared memory alone cannot tell the other side that something new arrived.
- A doorbell. The message box is a small hardware mailbox: writing to it raises an interrupt on the other processor (on the E906, interrupt line 17). Now nobody has to poll memory; the doorbell says “look now”. Part 3 measures this layer on its own.
- Rings of buffers. Shared memory is organised as two VirtIO rings (vrings), one per direction. Each ring holds a fixed number of 512-byte buffers. The sender takes a free buffer, fills it, puts it in the ring and rings the doorbell; the receiver takes it, uses it, and hands it back.
- Messages with addresses. Each buffer starts with a 16-byte RPMsg header (source address, destination address, length) followed by up to 496 bytes of data. One buffer is one message.
- Endpoints with names. The destination address picks an endpoint on the other side, like a port number. When the E906 firmware starts, it announces its endpoints by name, and Linux creates a channel for each.
- Files on Linux. Linux’s
rpmsg_chardriver gives each endpoint a device file,/dev/rpmsgN. To your program the E906 is now a file:write()sends one message,read()returns one message.
Follow the numbers: (1) your write fills a buffer in ring 1, (2) the message box rings the E906’s doorbell, (3) the E906 takes the buffer and hands it to the endpoint named in the header, (4) replies come back the same way in ring 2.
Three streams on top of messages
RPMsg carries messages, not a continuous byte stream. Each message is at most 496 bytes and arrives whole. Our firmware builds three different streams on top of that, one per endpoint, so the three kinds of traffic never mix:
Linux creates the /dev/rpmsgN file for an endpoint when
a program asks for it, through /dev/rpmsg_ctrl0. The
numbers depend on the order, so tools find “their” endpoint by name
rather than by number.
Two rules that keep the link healthy
Buffers are a shared, limited resource, and Linux never tells the E906 that a reader went away. Two rules came out of that (the story is in section 12):
- The trace stream needs a lease. The E906 sends trace packets only while a reader has said hello in the last 3 seconds. Readers send a 4-byte hello when they open the endpoint and then once a second.
- Long-running scripts need a heartbeat. A streaming
script expects a
PINGline at least once a second and stops itself after 3 seconds without one, so a closed PC tool never leaves the E906 talking to nobody.
Try it: read the E906’s CPU load from Linux in Python
This script runs on the board, under Linux, next to two files from
the repo: e906_rpmsg_bridge.py
(finds or creates an endpoint) and runtime_stats.py
(decodes the status report). It opens the trace endpoint, keeps the
lease alive and prints the E906’s once-a-second report:
# e906_load.py: the E906's CPU load, read from Linux
import os, time
from e906_rpmsg_bridge import ENDPOINTS, HELLO, create_endpoint
import runtime_stats as rs
ep = ENDPOINTS["trace"] # 'abstractx-trace', 1025
fd = os.open(create_endpoint("/dev/rpmsg_ctrl0", ep["name"], ep["addr"]), os.O_RDWR)
os.write(fd, HELLO) # "I'm listening": starts the 3 s lease
prev, last_hello = None, time.monotonic()
while True:
msg = os.read(fd, 512) # one RPMsg message per read
if time.monotonic() - last_hello > 1:
os.write(fd, HELLO); last_hello = time.monotonic()
report = rs.decode(msg) # None unless it is a status report
if report:
r = rs.rates(prev, report) if prev else None
if r:
print(f"busy {r['busy_pct']:.1f}%",
{t['name']: round(t['cpu_pct'], 1) for t in r['tasks']},
{d['name']: round(d['irq_per_s']) for d in r['drivers']})
prev = reportOn our board, with the E906 idle and the GPS sending:
busy 0.3% {'rpmsg_rx': 0.0, 'repl_rx': 0.0, 'repl_tx': 0.0} {'timer': 1001, 'uart3': 30, 'msgbox': 1, 'twi1': 0, 'irq88': 0}
busy 0.4% {'rpmsg_rx': 0.0, 'repl_rx': 0.0, 'repl_tx': 0.0} {'timer': 1001, 'uart3': 30, 'msgbox': 1, 'twi1': 0, 'irq88': 0}
The E906 is 0.3 % busy, the timer interrupts 1,001 times a second (the profiler’s sample clock, section 7), and UART3 takes about 30 interrupts a second from the GPS. No compile happened anywhere: a Python script on Linux is reading the E906 firmware’s own counters, live, from the other processor.
5. MicroPython on the E906, with AbstractX underneath
The three columns are three separate machines as far as software is concerned: a PC running AbstractX Studio, Linux on the Cortex-A55 cores, and the E906 coprocessor.
Notice how the three tiers divide the engineering labor: 1. The Development PC: Runs AbstractX Studio, rendering rich 60 FPS Dear ImGui observability instruments, decoding binary telemetry, and parsing DWARF ELF symbols so the embedded silicon never wastes compute or SRAM on GUI rendering or string formatting. 2. The Cortex-A55 Linux Cores: Act as a transparent high-bandwidth bridge, routing RPMsg message streams and binary trace buffers over SSH or network sockets to your PC. 3. The XuanTie E906 RISC-V Core: Runs the lightweight, freestanding AbstractX event loop and MicroPython, driving I²C and UART hardware peripherals in real time.
The firmware on the E906 is AbstractX: an event
loop that runs small cooperative tasks (C++20 coroutines, Part 4) and
interrupt-driven drivers, with no heap. This firmware (apps/e906_repl_app)
runs three tasks:
rpmsg_rx: receives RPMsg messages from Linux,repl_rx: MicroPython, which runs whatever you send it, andrepl_tx: sends MicroPython’s output back.
MicroPython is not the firmware; it is one task inside it. That has three consequences you will see in the experiments:
- Interrupts keep running under Python. The GPS UART
and the compass I²C bus are handled by AbstractX interrupt handlers.
When Python waits for the bus, the E906 sleeps (
wfi) until the interrupt wakes it; it never spins. - Python cannot break the firmware’s memory. MicroPython’s heap lives in the RAM of the chip’s unused HiFi4 DSP (128 KB), separate from the firmware.
- Python’s cost can be measured. AbstractX counts CPU
time per task and per interrupt, so the price of running Python shows up
as the
repl_rxtask.
Scripts reach the hardware through one built-in module,
abstractx (modabstractx.c):
The board’s MicroPython is a minimal build: bytes.decode
and bytes.fromhex do not exist (use
str(b, "ascii") and int(h, 16)), and there is
no filesystem, so scripts arrive over RPMsg and are compiled in
memory.
5.1
Concurrency in Python: Why AbstractX Coroutines Instead of
uasyncio?
A natural question when looking at concurrent I/O in Python is:
“Python has asyncio (and MicroPython has
uasyncio), so why aren’t we using that? Is
asyncio flawed? Or did uasyncio miss what
microcontrollers actually need?”
Is Python’s asyncio
Flawed?
No, asyncio is not flawed for its intended
domain.
Python’s asyncio was designed for network
applications—web servers (FastAPI, aiohttp), database connection pools,
and microservices on Linux and cloud servers with gigabytes of RAM. In
that domain: - Waiting 10–50 ms for an HTTP response or a database query
over TCP is typical. - Allocating 500 bytes of memory per
Task is completely unnoticeable. - A 50-microsecond garbage
collection pause causes no issues whatsoever.
The
Historical Mismatch: Why uasyncio Should Have Been Built
Differently
MicroPython is called MICROPython. Yet when
uasyncio was created, the project prioritized
desktop CPython compatibility over
microcontroller physics: - “If someone writes
asyncio code on their PC, it should copy-paste onto an
ESP32 or PyBoard.”
By mimicking CPython’s desktop API, uasyncio inherited
all of its desktop assumptions: 1. Dynamic Heap Allocation on
Every Step: Every async def, Task,
Future, and await asyncio.sleep() in
MicroPython allocates objects on the Python heap. 2. The 10 ms
Garbage Collection Freeze: MicroPython uses a mark-and-sweep
Garbage Collector. When the heap fragments or fills up,
gc.collect() freezes CPU execution for 5 to 15
milliseconds. On an embedded drone or robot balancing an 8 kHz
gyroscope and 200 Hz compass, a 15 ms freeze means lost sensor packets,
missed attitude corrections, and flight instability. 3. ROM
& Symbol Table Footprint: Supporting full
async/await syntax requires enabling
MICROPY_PY_ASYNC_AWAIT = 1 and large QSTR symbol tables
(__aiter__, __anext__,
StopAsyncIteration), which exceed the lean 192 KB
SRAM_FAST budget.
An embedded coroutine engine should never have been a desktop socket scheduler. It should have been designed for silicon: static channels, zero allocation in the loop pass, and direct hardware interrupt sleep.
What
an Embedded Coroutine Engine Actually Looks Like
(abstractx_coro.py)
Rather than relying on heap-allocating task wrappers, AbstractX brings the exact C++20 coroutine model into MicroPython:
# The AbstractX Coroutine Pattern: 0 Heap Allocations in Steady State
import abstractx
import abstractx_coro
mag_channel = abstractx_coro.AsyncQueue(16) # Pre-allocated fixed ring buffer
buf = bytearray(6) # Reusable zero-copy buffer
def compass_task(channel):
while True:
# Hardware interrupt reads into pre-allocated buffer
n = abstractx.i2c_read_into(0x1E, 0x03, buf)
if n == 6:
channel.try_push(bytes(buf))
# Yields execution; E906 enters wfi sleep (0.01% CPU)
yield abstractx_coro.sleep_ms(20)- 0 bytes allocated during steady-state execution → 0 Garbage Collection pauses.
- Wakes on hardware interrupts:
abstractx.sleep_ms()callswfi, letting the core sleep while UART DMA and I2C hardware state machines run in parallel. - Direct 1:1 Mapping to C++20: When the prototype is
proven, running the
micropython-to-cppskill translates the Python generator coroutine directly into a native C++20coro::Task<void>with zero conceptual friction.
6. Experiments without compiling
Everything in this section is Python. The firmware stays loaded the whole time.
6.1 Python on Linux runs Python on the E906
The board tests include a helper, repl_exec(), that
sends a script to the E906’s REPL endpoint and returns its output. Here
it checks out the compass from a Linux Python script:
# e906_run.py: run Python on the E906 from Python on Linux
from e906_link import repl_exec
code = """
import abstractx
abstractx.i2c_init(400000)
print('found', abstractx.i2c_scan())
print('id', bytes(abstractx.i2c_read(0x1E, 0x0A, 3)))
"""
out, err = repl_exec(code, 10.0)
print(out, err)Output on our board:
found [30]
id b'H43'
30 is 0x1E, and H43 is the
identification string of an HMC5883L. That one experiment settled a real
question: the board had been assumed to carry a QMC5883L at
0x0D, and every earlier script read the wrong registers. e906_link.py
and the trace example above are all it takes to build your own
Linux-side test programs around the E906.
6.2 An interactive prompt
For poking around by hand, open the REPL endpoint on the board:
grep -l abstractx-repl /sys/class/rpmsg/rpmsg*/name # which /dev/rpmsgN is the REPL
microcom -p /dev/rpmsgN>>> import abstractx
>>> abstractx.version()
'AbstractX 1.0.0 (E906 MicroPython REPL)'
>>> abstractx.irq_stats() # which interrupt lines have fired, and how oftenFrom the PC, e906_workbench.py
does the same over ssh, including one-shot commands
(-c "..."). Only one program can hold the REPL at a
time.
6.3 Check out the compass
>>> abstractx.i2c_init(400000)
>>> bytes(abstractx.i2c_read(0x1E, 0x0A, 3)) # b'H43'
>>> abstractx.i2c_write(0x1E, 0x00, b"\x70") # 8-sample average, 15 Hz
>>> abstractx.i2c_write(0x1E, 0x01, b"\x20") # +-1.3 gauss range
>>> abstractx.i2c_write(0x1E, 0x02, b"\x00") # continuous measurement
>>> abstractx.i2c_read(0x1E, 0x03, 6) # X, Z, Y, 16-bit big-endian eachTurn the board and read again: the numbers change. The decoder used
by the sensor streamer is mag_xyz()
in raw_sensor_streamer.py.
6.4 Check out the GPS
The u-blox powers up at 9600 baud:
>>> abstractx.uart_init(9600)
>>> abstractx.sleep_ms(500) # sleeps; UART interrupts fill a buffer
>>> print(str(abstractx.uart_read(512), "ascii")) # NMEA sentencesYou can also enable the autonomous 16×64-byte circular DMA
ring in .sram_c:
>>> abstractx.uart_dma(True) # activates circular DMA on UART3
>>> buf = bytearray(512)
>>> n = abstractx.uart_read_into(buf) # zero-copy drain from DMA ring
>>> print(str(buf[:n], "ascii"))Switching the GPS to 115,200 baud is a binary configuration message
to the GPS followed by uart_init(115200); the streamer
shows the exact bytes. At 115,200 baud, circular DMA absorbs bursts
without dropping a byte.
6.5 Time things, then put it in a file
>>> t0 = abstractx.cycles()
>>> d = abstractx.i2c_read(0x1E, 0x03, 6)
>>> (abstractx.cycles() - t0) / 200 # microseconds, including Python's own workWhen the experiment grows past a few lines, write it into a file on the PC, copy it over and run it:
scp -O my_probe.py root@192.168.3.3:/root/
ssh root@192.168.3.3 python3 run_e906_script.py my_probe.py 30 # stop after 30 srun_e906_script.py
sends the file in chunks, prints what it prints, and Ctrl-C stops the
script on the E906. Edit, copy, run: a few seconds per try. The
equivalent C++ change would be a cross-compile, a firmware copy and a
remoteproc restart.
6.6 Python beside a running stream: Meet AbstractX Studio’s CLI
While command-line scripts like run_e906_script.py are
convenient for batch runs, real-time embedded work is vastly more
productive with a dedicated engineering workbench. This brings us to
AbstractX Studio (tools/visualizer),
our native cross-platform desktop cockpit.
Connecting to the Cubie A5E over SSH, Studio provides an interactive CLI tab. It allows you to inject Python statements directly into the E906’s MicroPython engine while the compass and GPS streaming tasks continue running concurrently in the background:
Captured from the live board: ps and
drivers answer from the E906’s status report (here
repl_rx at 7.6 % while the 100 ms sensor streamer runs),
and abstractx.version() and
i2c_read(0x1E, 0x0A, 3) were executed by MicroPython on the
E906; [72, 52, 51] is b"H43".
A command travels from Studio over ssh to a bridge script on Linux, then over the REPL stream to the E906; the answer comes back the same way in about 55 ms. Variables are kept between commands, and an exception is reported without stopping the stream.
6.7 Parallel Multi-Rate Sensor Ingestion (The Coroutine Way)
Instead of sequential polling scripts that waste bus time, the AbstractX Coroutine Pattern lets developers orchestrate multiple sensors running at different rates concurrently:
# parallel_sensor_coros.py: Multi-rate concurrent sensor ingestion on the E906
import abstractx
import abstractx_coro
# 1. Initialize hardware buses & circular DMA
abstractx.i2c_init(400000)
abstractx.i2c_write(0x1E, 0x00, b"\x70") # CRA: 8-sample average, 15 Hz
abstractx.i2c_write(0x1E, 0x01, b"\x20") # CRB: +-1.3 gauss
abstractx.i2c_write(0x1E, 0x02, b"\x00") # MR: continuous measurement
abstractx.uart_init(115200)
abstractx.uart_dma(True) # activate 16x64B circular DMA
# 2. Channels matching AbstractX C++ AsyncQueue<T, N>
mag_channel = abstractx_coro.AsyncQueue(16)
gps_channel = abstractx_coro.AsyncQueue(16)
# 3. Dedicated Sensor Producers
def compass_producer(channel):
buf = bytearray(6)
while True:
# Hardware interrupt reads into pre-allocated buffer
n = abstractx.i2c_read_into(0x1E, 0x03, buf)
if n == 6:
channel.try_push(bytes(buf))
# Yields for 20 ms; E906 enters wfi sleep (0.01% CPU)
yield abstractx_coro.sleep_ms(20)
def gps_producer(channel):
buf = bytearray(512)
while True:
# Autonomous DMA ring drained in zero heap allocations
n = abstractx.uart_read_into(buf)
if n > 0:
channel.try_push(bytes(buf[:n]))
yield abstractx_coro.sleep_ms(100)
# 4. Primary-Paced Consumer Loop
def flight_fusion_task(primary_q, aux_q):
last_gps = None
epoch = 0
while True:
# Await next compass sample (acts as the physical pacing clock)
mag_sample = yield primary_q.pop()
# Non-blockingly drain auxiliary lower-rate sensor (GPS)
while True:
gps = aux_q.try_pop()
if gps is None:
break
last_gps = gps
epoch += 1
# Decode 16-bit big-endian X, Z, Y
x = (mag_sample[0] << 8) | mag_sample[1]
z = (mag_sample[2] << 8) | mag_sample[3]
y = (mag_sample[4] << 8) | mag_sample[5]
if epoch % 10 == 0: # Print every 10th epoch (5 Hz output rate)
print("Epoch", epoch, "Mag:", (x, y, z), "GPS bytes:", len(last_gps) if last_gps else 0)
# 5. Cooperative Dispatcher
dispatcher = abstractx_coro.Dispatcher()
dispatcher.spawn(compass_producer(mag_channel))
dispatcher.spawn(gps_producer(gps_channel))
dispatcher.spawn(flight_fusion_task(mag_channel, gps_channel))
# Runs the event loop with hardware wfi idle sleep
dispatcher.run(timeout_ms=10000)Running this on the board gives sub-millisecond epoch accuracy, 0% CPU waste during bus phases, and 0 dropped GPS sentences.
7. Watching the E906 Work: Inside AbstractX Studio
When developing software for an asymmetric multi-core processor (AMP)—where Linux runs on high-performance application cores while a bare-metal coprocessor handles real-time I/O—the single biggest obstacle is observability.
On Linux, developers take a rich suite of diagnostic tools for
granted: top, htop, perf,
ftrace, gdb, core dumps, and syslog. But on a
bare-metal RISC-V coprocessor, developers are usually flying
blind: - No OS means no top:
Without an operating system scheduler slicing threads, there is no
built-in way to know whether your coprocessor is 5% busy or 99% busy, or
how much CPU time MicroPython’s garbage collector is consuming relative
to hardware drivers. - The printf trap &
Heisenbugs: Inserting debug printf() calls over a
serial port stalls execution. At 115,200 baud, printing a single
100-character line takes 8.7 milliseconds. In a 1 kHz
sensor loop, that delay is fatal—it causes I²C arbitration timeouts,
overflows UART FIFOs, and completely distorts the timing you were
attempting to measure. The bug disappears or mutates the moment you try
to observe it. - JTAG halts break real-time physics:
Halting the CPU with a traditional JTAG hardware breakpoint stops the
core, but external hardware (the compass, GPS, and DMA controllers)
keeps running. Registers overflow and communication interfaces time out,
making live debugging impossible.
To eliminate this blindness, AbstractX includes AbstractX
Studio (tools/visualizer):
a unified desktop engineering workbench and observability cockpit
designed specifically for bare-metal and heterogeneous systems.
7.1 What is AbstractX Studio?
AbstractX Studio is a native desktop application written in modern C++20 using Dear ImGui and HelloImGui. It runs on your development PC (Linux, macOS, or Windows) at a silky 60 FPS and connects to the Cubie A5E over a standard SSH connection or network socket.
The workbench is fully customizable: developers can
drag, re-dock, split horizontally or vertically, and stack tabs anywhere
on the canvas. With multi-viewport docking enabled by default, any tool
can be torn off into its own independent OS window on a second monitor
with a 1-click Pop In button to re-dock. Crucially, Studio
is not a fixed dashboard—it allows developers to embed their own
custom tabs and instruments directly into the workbench using
an open Python plugin architecture.
Instead of heavy text logging or intrusive polling, Studio relies on
zero-copy Common Trace Format (CTF 1.8) binary
telemetry. The E906 firmware writes compact 64-byte binary
Transaction Layer Packets (TLPs) into lock-free shared SRAM rings
(.sram_c).
Emitting a binary trace packet takes only 4 to 12 RISC-V instructions (~20 to 60 nanoseconds). The coprocessor spends less than 0.4% CPU overhead reporting its internal state, completely eliminating the probe effect.
Linux on the Cortex-A55 cores simply forwards the raw binary ring buffers over SSH to your workstation. AbstractX Studio on the PC does all the heavy lifting: unpacking binary frames, reading the firmware’s ELF DWARF debug symbols to resolve addresses to source lines, and rendering real-time graphs and swimlanes.
7.2 The Four Studio Instruments
Studio translates this binary stream into four real-time observability instruments, giving bare-metal developers the same visibility Linux developers expect:
1. CPU & Driver
Telemetry (Bare-Metal top)
Studio’s CPU tab updates once a second, displaying: - A real-time CPU
busy meter and rolling history graph. - A task table
(rpmsg_rx, repl_rx, repl_tx)
showing exact cycle percentages consumed by MicroPython versus message
routing. - An interrupt driver table covering every physical CLIC
interrupt line (Timer 92, UART3 90, TWI1 91, Msgbox 88), displaying its
trigger frequency (Hz), exact execution cost per interrupt in
microseconds (e.g. 1.5 µs for I²C, 25 µs for UART byte handler, 2.1 µs
for circular DMA), and hardware error counters. - Memory pool high-water
marks, verifying zero memory leaks in steady state.
Captured from the live board with the sensor streamer running:
the E906 is about 8 % busy; the timer line takes about 1,000 interrupts
a second (the profiler’s samples), UART3 costs about 25 µs per interrupt
and TWI1 about 1.5 µs. In DMA mode
(abstractx.uart_dma(True)), UART3 drops 0 bytes even during
continuous 115200-baud GPS streaming bursts (section 10).
2. Live
Statistical Source-Line Profiler (Bare-Metal perf)
To find performance bottlenecks without modifying code: - A 1,000 Hz
hardware timer interrupt samples the E906 Program Counter (PC). - If the
core is sleeping in wfi, the ISR samples the idle loop and
returns immediately to sleep, preserving the low-power idle state
without waking up the event loop. - On the PC, Studio cross-references
the sampled addresses against the firmware ELF binary’s DWARF symbol
table, displaying a live sorted table of hot functions and source lines.
- You can instantly see where CPU cycles go: e.g. 92% in
wfi idle sleep, 4% in MicroPython’s bytecode dispatch
(mp_execute_bytecode), 2% in garbage collection
(gc_alloc), and <1% in hardware drivers. Clicking any
line links directly to source code.
Captured from the live board, 5 seconds of samples. The top line
(92 %) is the E906 sleeping in its idle wfi; it is listed
under now_cycles() because the lookup reports the innermost
inlined function at that address. The rest is MicroPython’s interpreter
(qstr_find_strn, gc_alloc,
mp_execute_bytecode), each line clickable to its
source.
3. Software Logic Analyzer Timeline (Microsecond Swimlanes)
Like an oscilloscope or logic analyzer for software execution: -
Displays synchronized horizontal swimlanes on a shared 200 MHz CPU cycle
clock. - Shows when tasks run, when each physical interrupt line fires
(as microsecond-accurate tick marks), and when the core sleeps in
wfi. - Allows sub-microsecond inspection of bus events
(e.g. seeing the 11 individual CLIC interrupt pulses of an I²C
transaction unfold over time while UART DMA packets arrive in
parallel).
Captured from the live board, last 100 ms: the long grey bar is the E906 idle, each tick is one interrupt (the timer and UART3 here); a task gets its own lane when it runs inside the window.
4. Interactive REPL & System CLI (Live Command & Inspection)
Studio includes a dedicated terminal tab connected directly to the
E906 over RPMsg (shown in Section
6.6): - Allows developers to execute Python one-liners, inspect
global variables, and query system diagnostics (ps,
drivers, irq_stats, mem) live on
the coprocessor. - Operates concurrently with ongoing sensor streaming
and control loops—you can poke registers and inspect state without
stopping or restarting the stream. - Commands travel over SSH and RPMsg,
returning output in approximately 55 ms with variables preserved between
statements.
7.3 How Studio is Programmed: Declarative JSON Schemas & Python Plugins
A common limitation of embedded diagnostic GUIs is rigidity: they are compiled binary monoliths with hardcoded packet parsers and static layouts. If an engineer adds a magnetometer axis, swaps an IMU, or changes a telemetry rate, someone has to modify C++ GUI parsing code and recompile the desktop application.
AbstractX Studio takes a completely different approach: it is declarative and programmable.
1.
Dynamic barectf & CTF 1.8 Telemetry Schemas
(trace_schema.json &
barectf_config.yaml)
Telemetry streams in AbstractX are governed by barectf—an open-source generator that produces ultra-fast, zero-allocation ANSI C tracers emitting the standardized Common Trace Format (CTF 1.8).
Instead of burning cycles on string formatting or JSON serialization
on the microcontroller, the E906 firmware bit-packs trace events
directly into shared SRAM rings in 4 to 12 instructions. Furthermore,
MicroPython scripts on the E906 can call
abstractx.trace(task_id, state, "msg") to emit structured
barectf binary events into this exact same stream!
On the host PC, Python tools and AbstractX Studio decode these binary streams dynamically using external schemas rather than hardcoded struct offsets:
{
"application": "gps_imu_app",
"wire_protocol": { "frame_size_bytes": 64, "header_size_bytes": 20, "payload_size_bytes": 40 },
"streams": {
"1": {
"name": "telemetry",
"events": {
"1": {
"name": "imu_sample",
"rate_hz": 8000,
"source_device": "ICM-42688-P (SPI1)",
"unpack_format": "<i6h",
"fields": [
{ "name": "sample_seq", "type": "int32", "scale": 1.0, "display_name": "Sequence" },
{ "name": "accel_x_mg", "type": "int16", "unit": "g", "scale": 0.001, "widget": "oscilloscope" },
{ "name": "gyro_z_dps", "type": "int16", "unit": "deg/s", "scale": 0.1, "widget": "oscilloscope" }
]
},
"2": {
"name": "mag_sample",
"rate_hz": 50,
"source_device": "HMC5883L (I2C1)",
"unpack_format": "<3hH",
"fields": [
{ "name": "mag_x_mgauss", "type": "int16", "unit": "mG", "scale": 1.0 },
{ "name": "heading_cdeg", "type": "uint16", "unit": "deg", "scale": 0.01, "widget": "compass" }
]
}
}
}
}
}When Studio or a Python tool connects to a stream: 1. Dynamic
barectf Unpack Compilation: Studio’s
CtfSchemaLoader
(tools/visualizer/ctf_schema_loader.py) reads the barectf
YAML or JSON configuration and compiles the unpack_format
(e.g. <i6h or <3hH) into native Python
struct.unpack byte decoders on the fly. 2.
Automatic Unit Scaling: Raw integer values from
hardware ADCs are automatically scaled into human-readable engineering
units using the "scale" multiplier (e.g. 0.001
converts milligauss or milli-g
to g, and 0.01
converts centidegrees to degrees). 3. Automatic UI Widget
Binding: The "widget" tag binds decoded signals
directly to Studio’s visualization components—routing high-rate
accelerometer and gyroscope data to the 8 kHz oscilloscope, magnetic
heading to the vector compass dial, or altitude to the altimeter—without
writing any UI wiring code.
2. Declarative
Hardware Profiles (profiles/*.json)
Hardware profiles describe the physical target board and its interconnect topology:
{
"profile_id": "cubie_a5e_pure",
"platform_name": "Allwinner Cubie A5E (Pure Silicon)",
"board_ip": "192.168.3.3",
"udp_port": 9870,
"active_cores": [
{"id": 0, "role": "Host Linux ARM64", "task": "Flight Supervisor & Telemetry", "clock_mhz": 1400},
{"id": 1, "role": "XuanTie E906 RISC-V", "task": "Real-Time I/O Reactor & Sensors", "clock_mhz": 200}
],
"hardware_accels": ["UART3 Circular DMA (GPS)", "TWI1 I2C (Compass)", "sun6i-msgbox"],
"schema_file": "apps/gps_imu_app/trace_schema.json"
}Switching Studio between targets—from the bare-metal XuanTie E906 on
the Cubie A5E, to an ESP32-P4, a Raspberry Pi Pico 2 W, or a desktop
SITL simulator—is simply a matter of passing
--profile profiles/<target>.json. Studio
automatically reconfigures its core topology views, network endpoints,
and telemetry parsers.
3.
Customizing Windows & Embedding Your Own Tabs
(AbstractXStudioPlugin)
A hallmark of AbstractX Studio is that developers are never forced into a single pre-baked layout or rigid set of tools.
Every engineering project has unique telemetry needs—a robotics team needs motor demand bars and Joint PID curves; a drone project needs artificial horizons and compass roses; a battery management system needs cell voltage heatmaps.
Studio makes it trivial for users to customize their
workspace and embed their own tabs directly into the workbench
using the Python Plugin SDK (tools/visualizer/sdk/plugin.py):
from visualizer.sdk.plugin import AbstractXStudioPlugin
from imgui_bundle import imgui, implot
class CustomSensorDashboard(AbstractXStudioPlugin):
def __init__(self):
super().__init__("Custom Dashboard", "1.0")
self.latest_heading = 0.0
def on_tlp_packet(self, header, payload_dict):
# Asynchronously invoked when relevant telemetry packets arrive
if "heading_cdeg" in payload_dict:
self.latest_heading = payload_dict["heading_cdeg"] * 0.01
def render_ui(self, delta_time, state):
# Rendered at 60 FPS inside Studio's dockable tab
imgui.text_colored(imgui.ImVec4(0.3, 0.85, 1.0, 1.0), "Custom Flight Deck")
imgui.text(f"Live Magnetic Heading: {self.latest_heading:.1f}°")
# Interactive controls sending commands to E906 over RPMsg
if imgui.button("Zero Compass Bias"):
state.send_cli("abstractx.compass_calibrate()")By subclassing AbstractXStudioPlugin, your custom tool
becomes a first-class citizen on the workbench: -
Automatic Docking & Layout Customization: Your tab
can be dragged, nested into tab groups, or split side-by-side with CPU
gauges, the timeline, or the memory inspector. - Multi-Monitor
“Pop to Canvas”: Any embedded tab gets a native OS window
tear-off button (🗖 Pop to Canvas). You can pull your custom
dashboard onto a second monitor, resize it, and dock it back with 1
click (🗗 Pop In). - Direct Real-Time Target
Interaction: Through state.send_cli(...), your
custom tab can send Python expressions, calibrate sensors, or tweak
control gains on the live E906 coprocessor while streaming continues
uninterrupted. - Zero GUI Boilerplate: No OpenGL
shaders, GLFW event loops, or thread synchronizers. You write high-level
ImGui and ImPlot Python code, and Studio handles the rendering at 60
FPS.
8. Under the hood: how one call reaches the hardware
Follow the left column top to bottom; the green box is what
happens between the wfi and the wake; the amber box shows
where Studio’s numbers come from.
One compass read, abstractx.i2c_read(0x1E, 0x03, 6),
step by step:
- Python calls a built-in C function. Everything from
here runs inside the
repl_rxtask, which is why its time is reported under that name. modabstractx.cconverts the arguments and calls the I²C driver.e906_periph.cppcopies the request into a static record (so an interrupt can never write into a stack frame that a Ctrl-C has already unwound) and tells the I²C block to send START.- The wait: arm a 20 ms timeout, let pending work
run, then sleep in
wfi. The loop never reads an I²C register. - The interrupt: each bus step raises the I²C interrupt (CLIC line 91). The handler takes exactly one step: send the address, send the register, repeated START, address with the read bit, store a byte, or STOP. A 6-byte register read is 11 steps.
- Back in Python: the bytes come back as a
bytesobject. On a timeout the driver sends STOP, counts the error (it shows in Studio’s driver table) and Python getsb''.
uart_read() is simpler: it never waits; it takes what
the UART interrupt handler (CLIC line 90) has already put in its
buffer.
9. Where everything sits in memory
The E906 runs entirely from on-chip SRAM; nothing is in DRAM (Part 1 maps the windows):
Space 0 had filled to 99.3 %. Moving the MicroPython compiler (only needed while a script is being compiled) into Space 1 brought it to 85 %; with the profiler and DMA code added since, it is at 89.6 %.
10. DMA, shared with Linux
Reading a UART one byte at a time costs about 0.29 µs per register read on the E906 (measured, section 11). At high data rates that adds up, so the next step is DMA: hardware that copies data while the CPU does something else.
The T527 has one main DMA controller with 16 channels, normally all Linux’s. Sharing it takes three things:
- Channels: the overlay’s
dma-channels = <8>(section 2) leaves channels 8-15 to the E906. - Interrupts: two registers decide which processor sees each channel’s “done” interrupt. Both default to “everyone”. The E906 routes 8-15 to itself and away from Linux before using them; otherwise Linux would get interrupts it cannot clear.
- Addresses: the DMA controller uses physical addresses, which differ from the E906’s view by a fixed offset; the driver converts them.
What it buys, measured with abstractx.dma_bench() (SRAM
to SRAM):
DMA has a fixed cost of about 4.4 µs, and the CPU is free during the transfer: the “done” interrupt itself costs 1.33 µs. The rule we adopted: a transfer bigger than the peripheral’s hardware FIFO uses DMA; one that fits uses the FIFO.
UART receive streams are always bigger than the FIFO, so we
implemented circular UART receive by DMA into a 16×64-byte ring
buffer (1,024 B total in .sram_c) with hardware RX
timeout triggers (IIR = 0x0C) to deliver partial NMEA
frames immediately. At 115,200 baud, this 1,024-byte ring provides
88.8 ms of buffer headroom, completely absorbing any
temporary MicroPython GC pauses without dropping a single byte or
corrupting checksums. You can query or toggle it live from Python with
abstractx.uart_dma(True).
11. Measured on the board
All on the Cubie A5E with the E906 at 200 MHz, 2026-10-21 to 2026-10-23:
The two lessons: the interpreter, not the drivers, is where the time goes in Python; and register access from this core is expensive enough that DMA matters at high data rates.
12. What went wrong
The console flood. The first profiler firmware sent about 20 trace packets a second. After a reader on Linux closed the trace endpoint, the E906 kept sending, because Linux never tells the other side that a reader went away. Linux logged
virtio_rpmsg_bus: msg received with no recipientfor every packet on the serial console, the console became unusable and the board had to be rebooted twice. The older status report had the same flaw at one packet a second, which is why nobody had noticed. The fix is the 3-second hello lease from section 4, plus a board test that checks the kernel log stays quiet after the reader closes.
wfi + 4. A profiler sample that interrupts the idlewfishould return to thewfi. The first fix compared the saved return address with thewfiinstruction, but this E906 saves the address of the next instruction. The check never matched and the idle loop still woke about 1,050 times a second. The firmware now accepts both addresses, and idle wakes dropped to about one a second.The DMA bring-up & the “last byte” trap. Getting hardware DMA working across asymmetric processors took days of grueling bring-up. First, Linux claimed all 16 DMA channels by default, so we had to patch the device tree (
dma-channels = <8>) and reprogram interrupt routing registers so the Linux kernel wouldn’t panic on unhandled coprocessor interrupts. Second, the E906 views SRAM through its local core addresses (0x3FFC0000/0x40000000), but the system DMA controller requires physical bus addresses (0x00020000); passing raw C++ pointers silently caused DMA bus errors. But the most insidious bug was the “last byte” issue: UART GPS sentences are variable in length (e.g. 73 bytes). If you configure DMA to transfer 64 or 512 bytes, the transfer only triggers when the block completes—leaving the trailing bytes of a sentence trapped in the hardware FIFO indefinitely until the next burst arrived! The fix was pairing circular DMA mode (a 16×64-byte ring buffer in shared SRAM) with the UART’s hardware character timeout interrupt (IIR = 0x0C,UART_DMA_REQ_ENbit 2). When the line goes idle for 4 character times, the timeout interrupt flushes the pipeline and delivers the trailing bytes to Python immediately.Studio pop-outs, multi-viewports & the disappearing canvas. Giving AbstractX Studio multi-monitor pop-out capabilities took three iterations to get right. Our first attempt was a crude hack: clicking “Pop Out” spawned an external Python subprocess (
flight_display.py). Clicking it repeatedly spawned an avalanche of rogue Python processes that didn’t share memory, and clicking “Pop In” was a broken no-op. When we moved to native Dear ImGui multi-viewports (enable_viewports = True), three subtle bugs hit: 1. The Auto-Merge Trap: ImGui’s defaultio.config_viewports_no_auto_merge = Falseautomatically swallowed any floating window that overlapped the main workbench back into the main OS window, making pop-out look completely broken. 2. Pruned Dock Nodes: When a window was popped out, ImGui dynamically pruned its parent dock split. Callingdock_builder_dock_window()on a pruned node failed silently. A naive fallback calledrestore_default_layout(), which reset every window globally—inadvertently slamming the user’s flight instruments back into the main window and destroying their layout! We had to detect pruned nodes, re-split the parent node independently (dock_builder_split_node), and calldock_builder_finish()to stage and commit docking changes cleanly. 3. The Sizing Bar Minimum Width Clamp: Preset buttons (800×600, 1024×720) were chained on a single horizontal line measuring 1,345 pixels wide. Because ImGui windows can never shrink smaller than their content, clicking the 800×600 preset did nothing until the sizing bar was wrapped into multiple rows.
These problems did not show up in simulation or static analysis. They were found on the physical board and during real workbench testing, and are fully recorded in the engineering log.
13. Limits and what is not done yet
Several early bring-up friction points noted in previous drafts have now been fully resolved in the AbstractX tree:
- UART Receive DMA is live: Circular DMA
(
abstractx.uart_dma(True)) is now fully operational on E906 with a 16×64-byte circular ring in shared SRAM and hardware character timeout interrupts (IIR = 0x0C), dropping CPU UART load to 0.4 %. - Dual-Mode SPI1 DMA Engine is live: Hardware SPI1 on
Port B (PB11..PB14) is fully operational with Single-SPI 1-bit and
Dual-SPI 2-bit burst (
BCC[28]) streaming (abstractx.spi_init(),abstractx.spi_xfer(),abstractx.spi_xfer_into()). Transfers ≤ 64 B use direct FIFO MMIO while bulk transfers use dual-channel DMA. - DMA Physical Address Translation & Firewall
Handling: All bus drivers (
spi.cpp,uart.cpp,i2c.cpp) translate E906 addresses throughDmaController::pa()and stage descriptors in SRAM Space 1 (.sram_c). Channels 9 and 10 are identified as hardware-firewalled by SoC security policy, reserving channels 8 and 11..15 for autonomous coprocessor DMA. - Native Binary Telemetry & Trace are built:
Scripts no longer rely solely on printing text over the console.
abstractx.tlp_send(buf)streams raw 64-byte binary telemetry frames directly across RPMsg endpoint 1024, andabstractx.trace(task_id, state, msg)emits CTF 1.8 binary events directly over endpoint 1025. - Profiler Idle Attribution is fixed: The profiler
classification was patched to accept both
wfiandwfi + 4return addresses (live_profile.py), correctly mapping idle CPU time toidleacross Studio’s gauges and timeline swimlanes.
Remaining Boundaries & Next Horizons
- UART APB1 Clock Ceiling: The UART clock (APB1) is fixed at 24 MHz on this board, limiting standard integer divisors to 1.5 Mbit/s. Pushing past this to 3.0 or 6.0 Mbit/s requires patching the Linux clock tree driver to reconfigure the parent APB1 PLL.
- MicroPython in Tight Loops (The Interpreter Tax): Interpreted Python bytecode consumes ~90 % CPU when executing tight polling loops (e.g. busy-waiting on I2C/UART). MicroPython is designed for rapid interactive bring-up, peripheral prototyping, and scriptable instrumentation; flight-critical real-time control loops (such as 8 kHz IMU fusion) belong in freestanding C++20 coroutines on the exact same HAL drivers (< 1 % CPU).
- Automated Script-to-Coro Migration: Tooling is
being expanded (see the
micropython-to-cppworkflow) to translate proven MicroPython async tasks directly into freestanding C++20 coroutines, bridging the gap between interactive REPL experimentation and hardened bare-metal deployment. - Full FPGA Switch Fabric Loopback: Exercising
physical loopback tests between the E906 Dual-SPI burst DMA engine and
the Tang Primer 20K FPGA crossbar (
asp_router.sv) on real hardware headers.
14. Where to go next
- The series: Part
1 (architecture, memory, boot), Part
2 (remoteproc), Part
3 (message box and RPMsg), Part
4 (coroutines vs FreeRTOS), device-tree
overlays (U-Boot and
config.txt) - The code: E906
MicroPython app (wiring, overlays, build, Studio), AbstractX
Studio, board
tests (including
e906_link.pyand the timing scripts) - The same sensors in C++: Straight-Line Async: GPS + Compass with C++20 Coroutines
- Bare metal vs FreeRTOS on this core: AbstractX on the E906 with and without FreeRTOS
- For engineers who want the exact requirements: the E906 app specification and the E906 target specification define everything this article describes, and say how each part is tested.