The co-simulation core (RP2040CoSim)#
A circuit simulator drives the RP2040 in steps and answers its pads in between (Proteus through the C ABI,
iCircuit through RP2040Mcu). Both go through one loop in the core library, RP2040CoSim
(src/RP2040Sharp/Peripherals/RP2040CoSim.cs), with its pad-event recorder and clock timeline. It mirrors
RP2350Sharp’s RP2350CoSim: the request, result and flag names are the same, so the chip-free types can move to
a shared package later. The core is register-accurate: CPU instructions and peripheral registers behave as on
silicon; timing is modelled, not cycle-exact.
The run#
var cosim = new RP2040CoSim(machine); // using RP2040.CoSim; its PadEventRecorder, attached, reports deferred
var r = cosim.RunUntilEdge(new RunRequest
{
MaxSlots = 1_000_000, // instruction slots per core
TargetCycle = 0, // core-0 cycle to stop at (0: none)
Guard = 75, Watch = 1u << 15, // slices of 75 while GP15 is watched
Flags = CoSimFlags.All,
});
// r.Slots, r.Cycles (core-0 cycles), r.Reason (Budget, Target, WatchedEdge, Reset)
cosim.Pads.Drain((gpio, level, oe, cycle) => Console.WriteLine($"GP{gpio}={level} oe {oe} @{cycle}"));
A run is a sequence of slices, each of the guard while a pad is watched and the guard is nonzero (twice it while
both cores sleep, AdaptiveSleep), else of Quantum (512) instructions per core, never more than the quantum.
After every slice the peripherals sweep. Run(n) runs n slots per core with nothing watched (RunFlags), in
slices of the quantum counted across calls, as RP2350’s RunChunk chunks: a call that starts inside a slice first
finishes it, so a host’s call size moves no boundary and only adds a sweep where a call ends inside a slice (the
boundaries depend only on the calls, never on the firmware). CallSizeTests: MicroPython booted with 150M
instructions in calls of 1.5M and of 10M lands on the same cycle (151,048,460) with the same CDC text; counted from
each call’s start, as before, the 1.5M calls ended 2,250 cycles later.
Flag |
What it does |
Result |
|---|---|---|
|
both cores quietly asleep: the sleeping slices before the next peripheral event are booked at once |
that of the slices |
|
core 0 awake, core 1 inert: core 0’s slices in one interpreter loop, booked while their ends change nothing |
that of the slices |
|
pin-level buses, a watch and a guard above 1: core 0’s slices end at the next engine edge on a watched pad and right after an instruction that moved one |
guard 1’s exchange at any guard (pin-level-buses.md) |
|
return after the slice in which a watched pad changed |
|
|
watched slices are twice the guard while both cores sleep (a wake-up follows an event) |
|
Stretches and bounded runs need deferred peripheral ticks (RP2040Machine.DeferPeripheralTicks); without them
the loop runs every slice plainly, which is the reference the differential tests compare against
(peripheral-deadlines.md).
Target cycle. With TargetCycle set, every slice of core 0 is a cut run (RP2040Machine.CutLimit): it stops
before the first instruction that would start at or after the target and sleeps only up to it, so a run ends at
the first instruction boundary at or past the target (a sleeping core exactly on it). Core 1 then runs as many
instructions as core 0 did, and the peripherals sweep. Bounded runs end at the target too; sleeping stretches
book only the slices that end before it. Both cores locked up: time still passes to the target
(RP2040Machine.ChargeStopped; with core 0 locked up and core 1 running, core 1 is not run for that rest).
Retired[core] counts retired instructions conservatively (a slice is credited only to a core that was neither
asleep, locked up nor unlaunched at its start and end), as the C ABI’s st_rp2040_instructions.
Host inputs and reads#
SetInputs(mask, levels)/ReleaseInputs(mask): pads already held at the level (or not held) are left alone; for the rest the deferred peripherals are brought up first (RP2040Machine.SyncForHostInput), so the cycles already run see the old level. Called from inside a run (a handler answering an edge from a sweep or a CPU access) that sync stops at the sweeps so far, which is what the eager machine has ticked by then; a peripheral advancing in its own tick does not re-enter it, so no cycle is ticked twice.SyncToNow()(RP2040Machine.SyncForHostRead): before the host reads peripheral state from outside; nothing from inside a sweep.CyclesUntilPadEdge(watch): the next step of a pin-level engine with work on a watched pad.
Pad events (PadEventRecorder)#
Every change that goes through IO_BANK0 is recorded while it happens, with its own core-0 cycle (Stamp):
Change made by |
Cycle |
|---|---|
a CPU write (SIO, IO_BANK0, PADS) |
the instruction’s; core 1’s mapped onto core 0’s clock from the start of its turn ( |
a pin-level UART / SPI / I2C engine |
the edge’s ( |
a PWM counter step ( |
the step’s, the divider’s fractional phase included; |
a PWM register write |
the instruction’s |
anything else in a sweep (PIO, DMA into SIO), |
core 0’s clock: the end of the slice |
Nothing is reported past core 0’s own clock (core 1 may run more cycles in a slice) nor before the previous report
(ReportedTo): a stamp is clamped into that window on delivery. PadReportMode.Deferred keeps every change and
Drain hands them over sorted by cycle, pad order among equal cycles (they are in order but for core 1’s, which
follow core 0’s of the same slice), one pad’s changes at one cycle folded into one; PadReportMode.Immediate raises each batch through Immediate (a PadChangeHandler: the pads that
moved, output enable and driven levels of the ImmediateWatch pads, the cycle) while it happens, so a handler can
answer at once (the managed adapter). The counter of a PWM slice still advances at the sweeps, so in immediate mode a
register write and the counter edges of the same slice can arrive out of cycle order. LevelsOnlyWhenDriven
gives the SiliconTwin.Abstractions rule (an output register moving under an undriven pad is no change); the C ABI
counts every level and output-enable change.
Who uses it#
The C ABI chip (
src/RP2040Sharp.Native/Exports.cs, native-abi.md) is a thin wrapper: its run calls, pin input andcycles_until_edgego to the co-simulation; the pin callback is its recorder drained after each call.RP2040Mcu.Run(budget)(the SiliconTwin.Abstractions adapter) isRunUntilEdgewithTargetCycle = now + budget,SleepingStretches | BoundedRuns, no watch, andMaxSliceInstructionsas the quantum; the pin bank’s recorder in immediate mode raisesPinChanged. The step ends at the first instruction boundary at or past the target (it used to be sized from the cycles per instruction seen so far, narrowing to single instructions at the tail), with nothing owed.RP2040McuOptions.CoSim = false(orRP2040Mcu.CoSim) keeps the oldRP2040Machine.RunCyclespath.RP2040McuLockstepTestschecks both paths, eager against deferred.
|
before |
after |
|---|---|---|
busy loop, |
0.55 |
0.45 |
MicroPython idle REPL, |
0.11 |
0.063 |
MicroPython idle REPL, |
0.032 |
0.0011 |
busy loop, |
0.45 |
0.45 |
With one slice per step (Run(750)), the deferred machine is a little slower than the eager one through the new
path (idle 0.063 against 0.050): it ticks the same peripherals at the end of the step (Run leaves nothing owed)
after deciding per sweep which ones are due. Whole stretches are where deferral pays (Run(125000) idle: 0.0011
against 0.033 eager).
RP2040Mcu.RunUntilEdge(max, guard, watch) is the managed run-until-edge a generic st_mcu_* library calls through
McuDescriptor.RunUntilEdge (RP2350Sharp’s SiliconTwin.Native.Exports), with CyclesUntilPadEdge for
st_mcu_cycles_until_edge: RunUntilEdge with TargetCycle = now + max, the guard (capped at
MaxSliceInstructions), the watch and CoSimFlags.All, until the target or a watched edge; a change counts while the
pin bank is armed (a PinChanged subscriber). McuRunUntilEdgeTests: the same calls on pico-sdk blink and a pin-level
UART program, guards 1 and 75, return after the same cycles with the same driven pad changes as
st_rp2040_run_until_edge.
AOT#
Everything here is plain classes and closed struct instantiations (CutRunLimit, SliceCutRunLimit of the
interpreter loop): no generic virtual methods, nothing created by reflection, so it runs under NativeAOT and the
iOS no-JIT constraints.
Differences from RP2350Sharp to reconcile#
RunRequest,RunResult,StopReasonandCoSimFlagshave RP2350’s member sets (MaxSlotsan int,TargetCycle0 for none,Watcha ulong,CoSimFlags.All). The constructor is RP2350’s shape,RP2040CoSim(machine, pinCount = 30, attachPads = true)(the co-simulation owns its recorder; RP2040Mcu’s pin bank uses it), without RP2350’ssyncToNow: an RP2040 access already meets a deferred peripheral at the sweeps so far. The diagnostics carry RP2350’s names:AwakeRuns/AwakeRunSlots(bounded runs, slots covered),SleepStretches/SleepStretchCycles,CutSlices,PlainSlices(added to RP2350 too);ClockChangedexists and is never raised. RP2350-only:EdgeBoundSlicesand the per-pathProbe(an RP2040 slice cut at an engine edge counts inCutSlices). RP2040-only:Quantum,RunFlags,BeforeSlice,AllPads.Run(n)now counts itsQuantum(512) slices across calls, as RP2350 counts itsRunChunk(1024) chunks: the 512 stays (it is the slice every sweep follows here, where RP2350 coalesces its ticks to a cycle quantum), the absolute boundaries make it call-size independent all the same.SyncForHostInput: one contract on both chips, two mechanisms. The contract: the cycles already run see the old level, the eager machine’s state at that point is where the input meets the peripherals, and no cycle is ticked twice. RP2350 flushes its coalesced accumulators outside a run or a plain slice, turns the input into a peripheral write that ends a bounded or cut run, and does nothing inside a sweep (the accumulators are zeroed before it sweeps). RP2040 has no accumulators: a sweep marks each peripheral applied before it ticks it, soSyncPeripheralsinside a sweep brings the others toSweptFor, which is where the eager machine has them, and inside a bounded run it bumpsScheduleEpoch, so the run plans again at the next slice end instead of ending. Decided byRP2040McuLockstepTests.Host_input_answered_from_the_pin_event_matches_the_eager_machine: a wire answering a SIO edge (plain slices and bounded runs) and a PWM counter edge (from inside the PWM’s own tick) into a PWM gate gives the eager machine’s counter, GPIO_IN samples, events and cycles with the RP2040 rule as it is. The accumulate-then-zero bug RP2350 had in its fine Hazard3 ticks has no RP2040 counterpart: both sweeps (eager andDeferredTick.OnSweep) setAppliedbefore they callTick.Namespace
RP2040.CoSim, as RP2350’sRP2350.CoSim.ClockTimelineis RP2350’s (segments of constant clk_sys,CycleToPs,PsIn,NoteWrite,Track); on the RP2040 it keeps one segment (RP2040CoSim.Clock). The mapping of the peripherals’ swept cycles onto core 0’s clock isSweepTimeline, which RP2350 does not need (its peripherals run on core 0’s clock).PadEventRecorderdeferred mode reports every change on both chips now (RP2350’s kept each pad’s last change per report and lost the pulses of a plain call), in cycle order with pad order among equal cycles, the changes of one pad at one cycle folded into one (none when the pad is back: a zero-width pulse depends on how a sweep batched the work; RP2350’sCyw43CallSizeTestsneeded it). RP2350’s also hooks SIO, CTRL and PWM itself and stamps PIO by lockstep round (PioRounds); here IO_BANK0’sPadsChangedcarries all of them (PWM throughExactPwmPadEdges), and PIO edges carry the end of the slice. The extra members here:Watch/WatchedChanged/WatchedEdge(the run loop’s),LevelsOnlyWhenDriven,Poll,Record,Stamp,Pending,Baseline.