Big progress on mainline Linux 7.1 (PREEMPT_RT) for the
Radxa Cubie A7A (Allwinner A733): we now have a working,
zero-copy camera-to-encoder pipeline running on mainline drivers without any
vendor 5.10 BSP blobs.
Highlights:
Hardware H.264 & H.265 (HEVC) encoding via Cedrus VE at 1080p30 using
~0.89%–1.55% of one CPU core, hardware ISP 602
(sun60i-isp) with AE/AWB/CCM/Gamma/BLC, and a dedicated
rkisp1-style libcamera pipeline handler & IPA.
1. Zero-Copy Camera → Hardware Video Encoder (H.264 & H.265)
Using the mainline sun6i-mipi-csi2 → sun6i-csi
capture path with an OV5647 sensor (1080p30, NV12), CMA dma-bufs
are passed zero-copy directly into the Allwinner A733 Cedrus Video Engine
V4L2 stateful M2M encoder:
UAPI & Hardware Blocks: Versioned parameter buffer (sun60i_isp_params_cfg) supporting Black Level Correction (BLC), White Balance (WB), Color Correction Matrix (CCM), Gamma, plus AE/AWB/Histogram statistics readout.
Dedicated libcamera Pipeline Handler & IPA: Built an rkisp1-style sun60i-isp pipeline handler and IPA module (ipa_sun60i_isp.so + sun60i_isp_ipa_proxy) rather than hacking SoftISP into simple — giving a clean, upstreamable userspace counterpart for a future linux-media RFC.
3. What's Next
Finish silicon verification of the sun60i-isplibcamera AE/AWB closed loop on 1080p30 captures.
Prepare an upstream RFC for sun60i-isp with the open libcamera pipeline handler as userspace proof.
Continue A7A bring-up: cpufreq OPP tables, stress_rpmsg on the E902 RISC-V co-processor, and GMAC210 TX DMA fix.
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.
AbstractX Studio’s CPU tab, live: busy
percentage, the coroutine table and per-interrupt costs updating once a
second from the E906
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.txt makes 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
Allwinner T527 RISC-V Firmware &
MicroPython 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.
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 from
boot.cmd) that reads config.txt, loads the
base device tree into RAM, and applies every overlay listed on the
dtoverlay= line, one after another.
Linux boots with the merged tree and never knows overlays were
involved.
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 remoteproc are
enabled, with the firmware name riscv-firmware.elf
and 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 rebootscp-O cubie-a5e-flight-stack-e906.dtbo root@192.168.3.3:/boot/ssh root@192.168.3.3 reboot
This 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 wantecho stop > /sys/class/remoteproc/remoteproc0/stateecho start > /sys/class/remoteproc/remoteproc0/statecat /sys/class/remoteproc/remoteproc0/state # runningcat /sys/kernel/debug/remoteproc/remoteproc0/trace0 # the E906's boot log
Inside 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_char
driver 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.
How one RPMsg message travels: a Python
write() on Linux fills a 512-byte buffer in shared SRAM, the message box
doorbell interrupts the E906, and AbstractX delivers the message to
endpoint 1026, 1024 or 1025
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:
Shared SRAM Virtual Endpoints & Wire
Protocols
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 PING line 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 Linuximport os, timefrom e906_rpmsg_bridge import ENDPOINTS, HELLO, create_endpointimport runtime_stats as rsep = ENDPOINTS["trace"] # 'abstractx-trace', 1025fd = 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 leaseprev, last_hello =None, time.monotonic()whileTrue: msg = os.read(fd, 512) # one RPMsg message per readif time.monotonic() - last_hello >1: os.write(fd, HELLO); last_hello = time.monotonic() report = rs.decode(msg) # None unless it is a status reportif report: r = rs.rates(prev, report) if prev elseNoneif 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 = report
On our board, with the E906 idle and the GPS sending:
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
What runs where: Studio on the PC,
bridges on Linux, the AbstractX event loop with MicroPython as one
coroutine on the E906
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, and
repl_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_rx task.
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)
Desktop Python vs MicroPython vs
AbstractX Coroutines
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 Stateimport abstractximport abstractx_coromag_channel = abstractx_coro.AsyncQueue(16) # Pre-allocated fixed ring bufferbuf =bytearray(6) # Reusable zero-copy bufferdef compass_task(channel):whileTrue:# 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() calls wfi, 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-cpp skill translates the
Python generator coroutine directly into a native C++20
coro::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 Linuxfrom e906_link import repl_execcode ="""import abstractxabstractx.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 REPLmicrocom-p /dev/rpmsgN
>>>import abstractx>>> abstractx.version()'AbstractX 1.0.0 (E906 MicroPython REPL)'>>> abstractx.irq_stats() # which interrupt lines have fired, and how often
From 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.
>>> abstractx.uart_init(9600)>>> abstractx.sleep_ms(500) # sleeps; UART interrupts fill a buffer>>>print(str(abstractx.uart_read(512), "ascii")) # NMEA sentences
You 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 work
When 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 s
run_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:
Studio’s CLI tab running Python on the
E906 while the compass and GPS stream continues
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 E906import abstractximport abstractx_coro# 1. Initialize hardware buses & circular DMAabstractx.i2c_init(400000)abstractx.i2c_write(0x1E, 0x00, b"\x70") # CRA: 8-sample average, 15 Hzabstractx.i2c_write(0x1E, 0x01, b"\x20") # CRB: +-1.3 gaussabstractx.i2c_write(0x1E, 0x02, b"\x00") # MR: continuous measurementabstractx.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 Producersdef compass_producer(channel): buf =bytearray(6)whileTrue:# 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)whileTrue:# 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 Loopdef flight_fusion_task(primary_q, aux_q): last_gps =None epoch =0whileTrue:# Await next compass sample (acts as the physical pacing clock) mag_sample =yield primary_q.pop()# Non-blockingly drain auxiliary lower-rate sensor (GPS)whileTrue: gps = aux_q.try_pop()if gps isNone: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 else0)# 5. Cooperative Dispatcherdispatcher = 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 sleepdispatcher.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.
Studio’s CPU tab: busy percentage over
time, the task table, the driver table with uart3 and twi1, and pool
usage
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.
Studio’s hot-function table from the live
profiler
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.
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).
Studio’s timeline, animated: swimlanes
for idle and each interrupt line on the E906 cycle clock, 100 ms
window
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.
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:
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.
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 AbstractXStudioPluginfrom imgui_bundle import imgui, implotclass CustomSensorDashboard(AbstractXStudioPlugin):def__init__(self):super().__init__("Custom Dashboard", "1.0")self.latest_heading =0.0def on_tlp_packet(self, header, payload_dict):# Asynchronously invoked when relevant telemetry packets arriveif"heading_cdeg"in payload_dict:self.latest_heading = payload_dict["heading_cdeg"] *0.01def 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 RPMsgif 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
The path of abstractx.i2c_read(0x1E,
0x03, 6): Python in repl_rx, modabstractx.c, e906_periph.cpp starting
the I²C transfer, a wfi wait loop, one TWI1 interrupt per bus step, and
the measurement hooks along the way
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_rx task, which is why its time is
reported under that name.
modabstractx.c
converts the arguments and calls the I²C driver.
e906_periph.cpp
copies 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
bytes object. On a timeout the driver sends STOP, counts
the error (it shows in Studio’s driver table) and Python gets
b''.
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
E906 memory: hot firmware in SRAM Space
0, cold compiler code and trace rings in the lower half of Space 1,
Linux RPMsg rings in the upper half, MicroPython heap in DSP
RAM
The E906 runs entirely from on-chip SRAM; nothing is in DRAM (Part 1
maps the windows):
SRAM Space Partitioning for RISC-V
Firmware & MicroPython
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):
Hardware DMA Engine vs XuanTie E906 CPU
memcpy
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 recipient for 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 idle wfi should return to the
wfi. The first fix compared the saved return address with
the wfi instruction, 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_EN bit 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 default
io.config_viewports_no_auto_merge = False automatically
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. Calling
dock_builder_dock_window() on a pruned node failed
silently. A naive fallback called restore_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 call
dock_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
through DmaController::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, and
abstractx.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 wfi and
wfi + 4 return addresses (live_profile.py),
correctly mapping idle CPU time to idle across 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-cpp workflow) 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)