DuoS little core (C906) — physical memory access from an Arduino sketch
Assessment date: 2026-09-19 Board: Milk-V DuoS (SG2000 / CV181x), little core cv181x-c906_1 (“RT core”) Status: Reasoned from source — not yet board-tested. The mechanism below is established from the Arduino core’s startup and linker script; the test plan is outlined but has not been run on hardware.
Build/load background is in
fishwaldo-arduino.md; board prep is insetup.md.
Summary
Yes. A sketch running on the little C906 core has direct physical-address access to memory and peripherals. The core starts in RISC-V M-mode with no MMU page tables and no PMP entries configured, so every load and store is a physical access and there is no isolation layer between the sketch and the SoC.
The 2 MB carveout at 0x9fe00000 is where the image is linked and loaded — it is not a runtime protection boundary. Nothing stops the running sketch from forming a pointer to any other physical address.
Why: M-mode, no MMU, no PMP
cores/sg200x/bsp/startup/start.S (shipped with the sophgo:SG200X core) is plain machine-mode startup:
- It programs only M-mode CSRs:
mscratch,mie,mtvec,mstatus,mepc,mbadaddr, and returns viamret. - It never writes
satp, so no page tables are installed. In RISC-V,satpgoverns S/U-mode translation only; M-mode always issues physical addresses. - It never writes
pmpcfg0..15/pmpaddr0..15. After reset those entries are “off”, so M-mode has no PMP restriction.
The only addressing constraint comes from the toolchain flags in platform.txt:
compiler.c_cpp.flags=-mcmodel=medany -mabi=lp64d -march=rv64imfd_zicsr
medany limits PC-relative references (code and static data) to ±2 GiB. A constant peripheral or RAM address is still materialized as an absolute lui/addi pair, which covers the whole 32-bit physical space, so *(volatile uint32_t *)0x03021050 compiles fine even though it is ~2.6 GiB below the load address.
What “full access” means — and its limits
Reachable directly
- Carveout RAM, e.g.
0x9FEF0000(used in thefishwaldo-arduino.mdRAM test). - Any other DRAM address, including memory Linux owns.
- Peripheral registers, e.g. the pad register
EXT_PORTAat0x03021050(bit 18 =XGPIOB18, the J3 pin 7 LED) — this is how Blink drives the LED without a Linux driver.
Real caveats
- No isolation. A stray write can corrupt the running Linux kernel, other processes, or the FSBL region. There is no MMU fault and no notification to the big core — the write simply happens.
- Shared DDR. The big core and little core share DRAM; there is no arbitration you control.
- Cache coherency. The C906 has its own L1 I/D caches and is not cache- coherent with the C920 Linux core. A physical address written by the sketch can sit dirty in the little core’s cache; Linux may read stale data (and vice versa). Correct sharing needs cache maintenance (
fence,cbo.*if available) or an uncached/device mapping. - SoC bus access control. The CV181x has secure/firewalled regions. Some addresses can still be rejected by the fabric; that faults through the startup’s
trap_entry→handle_trap, it does not silently succeed. - PMP from earlier stages. This startup leaves PMP untouched, so default is unrestricted. A locked entry left by an earlier boot stage would persist — not expected here, but worth knowing if a probe unexpectedly traps.
Worked examples
Read a peripheral register (safe; same pad Blink uses):
#define EXT_PORTA (*(volatile uint32_t *)0x03021050)
uint32_t pad = EXT_PORTA; // bit 18 = XGPIOB18Write a physical DRAM address by raw pointer:
#define PROBE (*(volatile uint32_t *)0x9FEF0000)
PROBE = 0xC0FFEE42;The compiler emits lui/addi to build the constant, then a plain load/store — no translation, no driver, no syscall.
Test plan (outlined, not run)
Goal: show that a sketch’s physical writes to carveout RAM are visible from the big core, and that a physical peripheral read works. Everything here stays inside the carveout or touches a pad already known safe, so it cannot corrupt Linux.
0. Prerequisites
As in fishwaldo-arduino.md: arduino-cli with the sophgo:SG200X core, passwordless SSH + NOPASSWD sudo to debian@10.42.0.1.
1. The test sketch
hints/work/PhysMem/PhysMem.ino:
#define MAGIC_VALUE 0xC0FFEE42UL
#define PROBE_BASE 0x9FEF0000UL
static volatile uint32_t *const probe = (volatile uint32_t *)PROBE_BASE;
void setup() {
probe[0] = MAGIC_VALUE; // magic, written once
probe[1] = 0; // counter
}
void loop() {
probe[0] = MAGIC_VALUE;
probe[1]++; // increments on the little core
delay(100);
}0x9FEF0000 is 1 MB into the 2 MB carveout, above the stack (0x40000) and heap (0x80000) that variants/duos/link.ld reserves. Confirm from the build’s .map that _end is below PROBE_BASE before trusting it; if not, pick a higher free address.
2. Build
arduino-cli compile --fqbn sophgo:SG200X:duos \
--build-path work/build-physmem work/PhysMem
readelf -h work/build-physmem/PhysMem.ino.elf | grep Entry # 0x9fe00000
grep -E '_end|_stack_top' work/build-physmem/PhysMem.ino.mapSanity-check the constant materialization:
riscv-none-elf-objdump -d work/build-physmem/PhysMem.ino.elf | grep -A3 '<loop>'
# expect lui/addi building 0x9fef0000, then sw/ldAlso confirm the shipped startup has no satp/pmp writes:
riscv-none-elf-objdump -d work/build-physmem/PhysMem.ino.elf \
| grep -Ei 'csrw?[[:space:]]+(satp|pmpcfg|pmpaddr)' # expect no output3. Load via remoteproc
Same manual flow as fishwaldo-arduino.md step 3 — get the core to offline, copy the image into /lib/firmware, select it by name, then start it:
scp work/build-physmem/PhysMem.ino.elf debian@10.42.0.1:/tmp/physmem.elf
ssh debian@10.42.0.1 'sudo cp /tmp/physmem.elf /lib/firmware/physmem.elf'
ssh debian@10.42.0.1 'cat /sys/class/remoteproc/remoteproc0/state' # want: offline
ssh debian@10.42.0.1 'echo stop | sudo tee /sys/class/remoteproc/remoteproc0/state'
ssh debian@10.42.0.1 'echo physmem.elf | sudo tee /sys/class/remoteproc/remoteproc0/firmware'
ssh debian@10.42.0.1 'echo start | sudo tee /sys/class/remoteproc/remoteproc0/state'4. Observe from the big core
Read the same physical page via /dev/mem:
ssh debian@10.42.0.1 'sudo python3 -' <<'PY'
import mmap, struct, os, time
f = os.open("/dev/mem", os.O_RDONLY | os.O_SYNC)
m = mmap.mmap(f, 0x1000, mmap.MAP_SHARED, mmap.PROT_READ, offset=0x9FEF0000)
for _ in range(20):
m.seek(0)
magic, counter = struct.unpack("<II", m.read(8))
print("magic=%#x counter=%d" % (magic, counter))
time.sleep(0.2)
PYExpected: magic=0xc0ffee42, counter increasing ~10/s. That proves the little core wrote physical DRAM and the big core can see it.
If counter is stuck but the sketch is running, that is the cache-coherency caveat in action (the little core’s dirty line never reaches DRAM). That result is itself informative; note it rather than assuming failure.
5. Peripheral probe (optional)
A variant of setup() that reads EXT_PORTA (0x03021050) and mirrors bit 18 into probe[2] would confirm peripheral physical reads. Keep it to the pad already known safe from Blink.
6. Restore the stock firmware
ssh debian@10.42.0.1 '
S=/sys/class/remoteproc/remoteproc0/state
echo stop | sudo tee $S
echo c906-mcu.elf | sudo tee /sys/class/remoteproc/remoteproc0/firmware
echo start | sudo tee $S
sudo rm -f /lib/firmware/physmem.elf
'Don’t probe outside the carveout (without a reservation)
Probing or writing physical DRAM outside the carveout on a live system is how you corrupt the kernel. If that capability needs to be demonstrated, reserve the region first (a reserved-memory/memmap= carveout that Linux will not use) or stop Linux entirely. The test above deliberately stays inside the carveout.
Open questions
- Whether the C906 L1 caches are enabled by this startup (would explain either coherent or stale
/dev/memreads; the test distinguishes them). - Whether any SoC firewall actually rejects non-carveout DRAM from the little core, or whether it is truly unrestricted.
- Whether
cbo.clean/cbo.flushare available on this C906 to make explicit coherency possible from the sketch.