Native C ABI (st_rp2040_*)#
src/RP2040Sharp.Native exposes the RP2040 to C and C++ hosts (the Proteus VSM model of the
Silicon Twin demo, first of all) through a flat C ABI, declared in
include/silicontwin_rp2040.h. It
mirrors silicontwin_rp2350.h of RP2350Sharp wherever the RP2040 allows it, so one host can drive
a Pico, a Pico W and a Pico 2 with the same code. The core is register-accurate: CPU instructions
and peripheral registers behave as on silicon; timing is modelled, not cycle-exact.
Packaging: an exports library, not a DLL of its own#
Only one NativeAOT runtime can live in a process, so RP2040Sharp.Native is a plain class library
whose [UnmanagedCallersOnly] entry points become exports of the single NativeAOT library the host
ships. In the host’s .csproj:
<ProjectReference Include="...\src\RP2040Sharp.Native\RP2040Sharp.Native.csproj" />
<UnmanagedEntryPointsAssembly Include="RP2040Sharp.Native" />
For local tests it can also be published alone (never shipped that way):
dotnet publish src/RP2040Sharp.Native -c Release -r win-x64 -p:StNativeLib=true -o out
# -> out/silicontwin_rp2040.dll (.so / .dylib), import library under bin/.../native/
-p:StTestHooks=true adds st_rp2040_test_inject_fault for the fault-isolation tests.
The ABI#
Function |
Notes |
|---|---|
|
|
|
|
|
Opaque handles, a sticky FAULTED state, no exception ever crosses an export (same model as RP2350) |
|
RP2040 family |
|
|
|
Same semantics as RP2350: slices of |
|
Cycles until the next step of a pin-level engine with work on a watched pad, |
|
Core-0 cycles at a fixed 125 MHz (the core does not model clock changes): 8000 ps per cycle. Monotonic: a watchdog reset, |
|
Conservative retired count, as on RP2350 |
|
|
|
Channels 0..3 (GP26..29); the temperature sensor reads 27 °C |
|
The chip’s 64-bit unique id: what the flash answers to |
|
Channels UART0, UART1 and USB CDC: an emulated USB host ( |
|
Pico W ( |
|
The CYW43439’s WL GPIO outputs, bit 0 = the onboard LED (MicroPython |
|
Pico W only: port forwarding from |
|
Pico W only: outbound bridges from the gateway’s |
|
CircuitPython: files copied onto CIRCUITPY the way a PC does, into the FAT volume in flash, then a reboot (below) |
Type names carry the chip (st_rp2040_pin_cb, ST_RP2040_STATUS_OK…) so that a translation unit
can include both headers; values and callback signatures are those of the RP2350 header.
Files onto CIRCUITPY#
CircuitPython keeps its CIRCUITPY drive read-only to its own code while USB is connected, so a host cannot write files
through the raw REPL (OSError: [Errno 30] Read-only filesystem). These calls do what a PC does when it copies files
onto the drive: they write them into the FAT volume in emulated flash (FatVolume, src/RP2040Sharp/Peripherals/Fs,
no third-party code), then reboot the chip so CircuitPython runs the new code.py. Same calls and struct layout as
st_rp2350_fs_* (RP2350Sharp docs/circuitpy-files.md); the struct is st_rp2040_fs_info, the flag
ST_RP2040_FS_BOOT_OUT.
Finding the volume. CircuitPython keeps no partition table: it puts CIRCUITPY after its image, up to the end of flash, and formats it with FatFs (no MBR) on its first boot. The search starts at the end of the image
st_rp2040_load_uf2loaded (its highest RP2040-family block; absolute and data family blocks do not count), rounded up to 4 KiB, and takes the first erase sector with a consistent FAT12/FAT16 boot sector (0x55AA, jump, BPB fields, a volume inside the flash, FATs large enough, FAT[0] repeating the media byte). CircuitPython 9.2.1 on the 2 MiB Pico: 1 MiB FAT12 at0x100000.Writing. FAT12 and FAT16, long file names, directories created as needed, an existing file replaced (its old clusters freed), all FAT copies kept equal. All or nothing: on an error (no volume yet, the volume or root directory full, a name over 255 or a path over 256 characters, a path through a file) flash is untouched and
st_rp2040_last_errorsays why. A commit empties the queue either way.When.
st_rp2040_fs_putqueues at any time. On a fresh flash, run untilst_rp2040_fs_statusreturns 1 withST_RP2040_FS_BOOT_OUTandflash_erases + flash_programmed(counted for the SSI and the bootrom flash calls) has not moved for about 100 ms of simulated time, thenst_rp2040_fs_commit(chip, 1). Writing under a running CircuitPython without that reboot is unsafe: it caches flash sectors in RAM and writes them back. The reboot does not restart the chip’s time:st_rp2040_cycles,st_rp2040_time_psand the pad-event cycles continue from where they were.
Wi-Fi (Pico W)#
With ST_RP2040_CYW43 the core speaks the CYW43439’s gSPI host protocol and translates it into a virtual network
(the CYW43439 is not emulated: its radio firmware is parked, never executed; no RF): one access point the host sets with
st_rp2040_wifi_set_ap(chip, ssid, passphrase, channel, rssi) (open with a NULL or empty passphrase, else WPA2-PSK),
and a gateway at 192.168.4.1/24 that answers ARP, DHCP (the station gets 192.168.4.2) and DNS (NXDOMAIN). The
guest’s own driver and lwIP run unmodified, so MicroPython’s WLAN.scan(), .connect(ssid, key) and .ifconfig()
work as on the board; a wrong passphrase ends in STAT_WRONG_PASSWORD (-3), an unknown SSID in STAT_NO_AP_FOUND
(-2). st_rp2040_wifi_status fills an st_rp2040_wifi_info (join state, DHCP address, gateway, netmask, MAC, SSID,
RSSI, Ethernet bytes and frames each way; a caller-set size, so the struct can grow at the end) and
st_rp2040_set_wifi_callback reports every change (scan, join, lease, leave, wrong passphrase, unknown SSID) when
the run call that raised it has finished, never from inside the emulation. Protocol level only: no RF, and the guest
reaches nothing of the host’s network except what the host sets up: port forwards in on 127.0.0.1
(st_rp2040_wifi_forward_tcp) and bridges out to the host:port it names (st_rp2040_wifi_bridge_tcp), wireless.md.
It is the RP2350 ABI with st_rp2040_ names: the same functions, values and 208-byte layout (the struct is
st_wifi_status there; here it is st_rp2040_wifi_info because st_rp2040_wifi_status is the function). The model,
the compatibility rules are described in RP2350Sharp’s docs/wireless.md; port forwarding (W2) and the outbound
bridges (W3) in wireless.md.
Pad-change cycles#
Every pad change that goes through IO_BANK0 is recorded while it happens, not found by a scan at
the end of the slice. A change made by a CPU instruction (a SIO, IO_BANK0 or PADS write, on either
core) carries the cycle of that instruction, so the cycles a host sees, and pulse widths, are the
same at every guard: the tests check a timer-timed 37 µs pulse and a square wave at guards 1, 75
and 300. Changes made by the pin-level UART / SPI / I2C engines carry the cycle of the edge (the
engines run on core 0’s clock). A PWM counter edge carries the cycle of the counter step that made it
(the divider’s fractional phase included; RP2040Machine.ExactPwmPadEdges, on in the chip), a PWM register
write the cycle of the instruction. Changes made by other peripherals (PIO, DMA into SIO) carry the end of
the slice in which they happened. Changes are reported in cycle order (PadEventRecorder, stable), core 1’s
never past core 0’s clock.
The PWM counter itself still advances at the end of each slice (as every peripheral does), so it starts counting from the start of the slice in which firmware enabled it: the period and every later edge are exact, the phase of the first one follows where that slice began.
Inside: the co-simulation core#
The chip is a thin C face over RP2040CoSim (in the core library, cosim.md), the loop that the
managed adapter RP2040Mcu runs too: st_rp2040_run(n) is Run(n) (slices of the 512-instruction quantum counted
across calls), st_rp2040_run_until_edge is RunUntilEdge with the guard, the watch and
CoSimFlags.Default (event guard, sleeping stretches, bounded runs, stop on a watched edge, twice the guard
asleep); pad events come from its PadEventRecorder in deferred mode, st_rp2040_pin_set_input goes through
RP2040CoSim.SetInputs / ReleaseInputs.
Missing compared with RP2350#
Exact peripheral-driven edges for PIO and DMA: their pad changes carry the end of their slice (the pin-level UART / SPI / I2C engines’ and the PWM counter’s carry their own cycle).
Pin-level slaves and flow control: SPI and I2C slave modes stay at transaction level; UART CTS / RTS are not modelled; the UART RX interrupt is raised per byte (no RX trigger level or receive timeout), as for bytes from the serial channel.
Clock changes. clk_sys is fixed at 125 MHz;
machine.freq()changes nothing. (The pin-level engines scale their dividers by the clocks firmware programmed, so buses run at the requested rate.)SRAM-only UF2 images (
no_flashbinaries) are not loaded.
Throughput#
run_until_edge(1000000, guard, all 30 pads), Windows x64, host seconds per simulated second (lower is
better), best of 3. “Before” is the phase-1 tree (every peripheral ticked after every slice, every slice
run on its own); “after” adds deferred ticks, sleeping stretches and bounded awake runs
(peripheral-deadlines.md), with the same results. JIT, CoSimBench
(RP2040_NATIVE_PERF=1); idle and PWM are MicroPython v1.28.0 RPI_PICO after the REPL is up (PWM:
PWM(Pin(14), freq=1000, duty_u16=20000)), 200 ms simulated; busy is a hand-assembled core-0 loop, 50 ms.
Workload |
guard 1 before / after |
guard 75 before / after |
guard 300 before / after |
|---|---|---|---|
Idle REPL |
7.02 / 0.057 |
0.189 / 0.025 |
0.032 / 0.025 |
Idle REPL + 1 kHz PWM |
8.35 / 0.067 |
1.39 / 0.034 |
0.183 / 0.016 |
Busy loop |
9.38 / 0.82 |
0.64 / 0.58 |
0.51 / 0.53 |
The two columns were measured an hour apart on a host shared with other work; CoSimBench now also
alternates both modes on the same chip within one run, which gives the same picture under load (idle
guard 1: 15.3 / 0.044, PWM guard 1: 17.6 / 0.14, busy guard 1: 18.9 / 1.65, busy guard 300: 0.69 /
0.70). The busy loop at coarse guards is the interpreter itself (about 100-170 MIPS depending on the
load); the plain RP2040Machine.Run loop is unchanged (old and new builds alternated: 95-112 and
92-116 MIPS).
With pin-level buses (pin-level-buses.md) and nothing routed, the fast paths cost 8-20 % more; without bus flags the figures above are unchanged.
NativeAOT (abi_selftest.c, idle REPL, single runs): guard 1 0.090x real time before, 18.4x after;
guard 75 5.3x before, 67x after.
Tests#
tests/RP2040Sharp.Native.Tests: export-level tests through unmanaged function pointers (handles, rejected arguments, UF2 families, time, pulls, re-entrancy, destroy from a callback, injected faults with-p:StTestHooks=true),run_until_edgesemantics and guard invariance on a hand-assembled firmware, MicroPython on the Pico (REPL over CDC, anexec’d script toggling GP15) and on the Pico W (network.WLAN(network.STA_IF).active(True)withST_RP2040_CYW43;PicoWWifiTests: the Wi-Fi exports with a scan, a WPA2 connect up to the DHCP lease, a wrong passphrase, an open network and an unknown SSID, run as the Proteus model runs it, guard 75). The Pico image is the one undertests/RP2040Sharp.IntegrationTests/Firmware; the Pico W image comes fromRP2040_MICROPYTHON_PICOW_UF2or the integration tests’ firmware cache, and the test skips without it.SliceDifferentialTests,MicroPythonDifferentialTests: the speed-ups against plain slices (see peripheral-deadlines.md);CoSimBench: the measurements above.PinLevelDifferentialTests,PinLevelLockstepTests,MicroPythonBusTests: the pin-level buses (pin-level-buses.md).tests/RP2040Sharp.Native.Tests/abi_selftest.c: the same ABI from C against the NativeAOT library (with a Pico W image as second argument also the Wi-Fi join, a wrong passphrase and the Wi-Fi callback’s re-entrancy rules).