The ISS board: what is modelled, what is not¶
Tracking list for running the real P2 firmware image on p2core/p2iss
with every part of the PCBA present as a node on the net.
The rule this list is measured against: components interact only through
TheveninDrive levels or periodic (pulse) drives. No byte routes, no stream
routes, no shared tables. A signal that cannot be expressed that way is called
out explicitly rather than quietly special-cased.
Status keys: done · partial · todo · broken
Pin map — settled, and previously contradictory¶
Three sources disagreed. Resolved against the hardware and the toolchain:
| Pin | Flash (boot) | microSD | Source |
|---|---|---|---|
| P58 | DO (MISO) | DAT0 / MISO | P2 Edge module guide; MaD_Edge.kicad_sch labels it P58/FLASH_MISO |
| P59 | DI (MOSI) | CMD / MOSI | as above |
| P60 | CLK | DAT3 / CS | as above |
| P61 | CS | CLK | as above |
Flash and microSD share four pins and swap CLK/CS between them. That is why the three maps disagreed:
p2core/src/board.rs(DO=58, DI=59, CS=60, CLK=61) — correct for SD, and independently confirmed: FlexC's_vfs_open_sdcard()is_vfs_open_sdcardx(pclk=61, pss=60, pdi=59, pdo=58)(toolchain-flexcc/include/filesys/fatfs/fatfs_vfs.c:82).- The P2 boot ROM uses
P61=#CS, P60=CLK, P59=DI, P58=DO— correct for flash, and the opposite assignment. Firmware/MaDCore/src/HAL/Include/HW_pins.h(SD_CLK=61, SD_MOSI=60, SD_MISO=59, SD_CS=58) — wrong, and dead: nothing reads it for SD, because the firmware calls_vfs_open_sdcard()which carries its own pins. Misleading; should be corrected or deleted. See Open items.
Components¶
| Component | Transport | Status |
|---|---|---|
Host protocol link (P32/P30: on P2Iss directly; on the project files through the carrier's J4 and IC2) |
UART framed on Thevenin levels, rate derived from the guest's own WXPIN divisor |
done |
| Debug console (P62) | Byte-level inside p2core::Board, forwarded to the emulator log |
partial — not a node |
| DS2 piggy board (netlist) + ADS122U04 | UART on levels; analog inputs via MNA cluster solve | done — see F1 |
| Load cell bridge | Two Thevenin drives into AIN0/AIN1 | done |
Servo drive — STEP |
Timed level transitions (p2iss::pulse::PulseDriver), both P_TRANSITION and P_NCO_FREQ |
done |
Servo drive — DIR, ENA |
Thevenin levels | done |
Servo RDY |
Sensed level | done |
| Quadrature encoder (A/B/Z) | Encoder model drives levels; p2core decodes Gray phases with slip counting |
done |
| End stops (upper, lower) | EndSwitch dry contacts + weak pull-downs |
done |
| Door end stop | Weak pull only — no model | partial |
| ESD lines (upper, lower, switch) | Sensed levels, idle high (activeLow in HAL_GPIO_config.c) |
done |
| ESD power, charge pump | Driven levels | done |
| microSD card | On nets as a decoupled component; the ISS reads its smart-pin roles from the mode words (p2core::smartbus) and holds no SPI knowledge; firmware mounts it |
done — see S1, S2, G4 |
| SPI boot flash | On the net (p2iss::flashnode), bit-banged edge-by-edge |
done — see B1 |
| P2 ROM bootloader | Executes the real Parallax ROM and boots from the net flash | done — see B2 |
Open items¶
F1 — ADS122U04 never answers — FIXED¶
Root cause was not in the ADC model or the wiring: the shared UART deframer required a full nominal frame time before it would accept the next start bit.
A P2 smart pin asked for 115'200 baud at clkfreq = 160 MHz actually clocks
115'273 — 0.06 % fast, a rate any real UART accepts without noticing.
Integer bit periods make the sender's frame 86'750 ns against the receiver's
86'800, so the second byte's start bit landed 50 ns before the deadline,
was absorbed into the frame in progress, and the stream desynchronized
permanently. The model read the protocol's 0x55 sync byte as register data
and never recovered.
Fix (embsim, board/src/uart.rs): close a frame at stop_sampled_ns() — the
midpoint of the stop bit, which is where real hardware samples it — rather
than at end-of-frame. That buys the standard half-bit of tolerance in both
directions. Regression tests
a_marginally_fast_sender_still_frames_back_to_back_bytes and its slow
counterpart; the fast one fails without the fix.
Also fixed in p2iss: every transmit pin now idles high from attach, so a
far end has a mark level to detect a start bit against before the guest has
configured any rate.
This is an upstream embsim change and needs its own PR.
Consequence worth noting: with the force gauge actually streaming, the ISS changed the speed figure of the day (0.74x → 0.35x), but both numbers predate the G3 time-model fix and were artifacts of it; see G3 for the honest measurement.
S1 — microSD as a node — DONE¶
The framing gap is closed. Root cause: AKPIN assembles as WRPIN #1,S
(confirmed against flexspin's listing), so the acknowledge rcvr_mmc opens with
never reached the bit-level receiver; the stale word it had latched during the
preceding transmit satisfied TESTP immediately, the guest ran a byte ahead of
the bus and deselected mid-frame. Fixed by catching the WRPIN #1 acknowledge
in the SPI path, and restarting the receiver's bit counter on WXPIN. The full
SDv2 initialisation now decodes off the wire, and the firmware mounts the
card (see S2).
Original detail follows.
S1 (original) — microSD as a node — built, one gap left¶
The bus is real. p2core::SpiShift is a bit-level shifter pair
(p2core/src/spi.rs), p2iss::spi_bus::SpiSequencer drives the clock and MOSI
as TheveninDrive levels one transition at a time, and
p2iss::sdnode::SdCardNode is a component that senses CLK/CS/MOSI and drives
MISO. p2iss::P2Iss::with_spi puts the guest's four SPI pins on nets.
Working and asserted (p2iss/tests/sd_node.rs): clock edges cross a net,
chip select gates them (a deselected card ignores the 64 dummy init clocks),
bytes assemble out of bits, and the card decodes CMD0 — frame, argument and
CRC — off the wire.
Four hardware behaviours had to be modelled to get there, each found by reading
sdmm.cc rather than guessing:
- SPI mode 3, not mode 0. The driver sets
spm_ck = P_PULSE | P_OE | P_INVERT_OUTPUTand calls itCPOL = 1. The clock idles high, leads with a falling edge, drives data on the leading edge and samples on the trailing one. DIRL/DIRHon the transmit pin are reset/liven, not direction.WYPINloads the shifter while reset and the buffer once livened.- The transmitter is two registers deep.
xmit_mmc's continuous-mode loop queues the next longword and spins onTESTP PIN_DIfor the buffer to empty. A one-register model loses every word but the last. - The receiver holds one word, not a queue. A word completing before the
last was read overwrites it. Queueing them made
TESTPread true when nothing had arrived, so the driver stopped waiting for the bus and fired burst after burst into a transfer that had not happened.
The remaining gap: every command after CMD0 arrives misframed, currently
by four bit times — the card reads 0xF4 where 0x48 was sent, which is four
idle ones followed by the first four bits of CMD8. The byte boundary slips
somewhere between the first response and the second frame, a region containing
a deselect, a reselect and wait_ready's idle bytes. It is a count of clock
edges going astray, not a wire-order or polarity error — either of those
would have broken CMD0 too.
Captured as the_card_completes_the_full_initialisation_sequence, #[ignore]d
rather than weakened, to be un-ignored when it passes.
Cost, measured (p2core/examples/sd_cost.rs): a three-second boot clocks
194 SPI bytes ≈ 4 656 engine events; a sustained 512-byte block is ~12 k.
Affordable — an earlier estimate against this was wrong.
S2 — FlexC mount() — DONE¶
The mount succeeds. There were two defects between the card and a working mount, both fixed:
- A
p2coreBITHspan bug. flexspin encodes a method-pointer tag asobj | (index << 20), which compiles toBITH obj, #20 ADDBITS 4— a five-bit span, not one bit.p2core'sBITH/BITL/BITNOTset a single bit, turning method tag 31 into tag 1, somount()'s(*v->init)(name)dispatched into the cog manager's task table instead of the filesystem'sv_initand returned 1. Fixed with a span implementation and a regression test (bit_instructions_cover_their_addbits_span). - A toolchain heap-reuse bug in
fatfs_vfs.c: theFFSblock (which carries ff.c'sFatFs[]statics as members) was not zeroed, sof_mountread a garbage "old volume" and randisk_deinitializeon it — closing the block handle that had just been opened. Fixed by zeroing the two managed blocks in the toolchain source (a local patch; see Toolchain patches).
With both fixed the firmware reads block 0 (boot sector) then block 129 (root
directory) over the wire and advances past the mount. Tests
p2iss/tests/sd_mount.rs (formatted card mounts, blank card is probed and
rejected) and sd_node.rs (the full init sequence completes).
S2 (original) — FlexC mount() returns -1 — narrowed¶
dev_nvram.c:218: failed to mount sd card: /sd, so the firmware boots on
failsafe records. The image is a valid FAT16 volume — fsck_msdos reads it
clean, and p2iss::sdimage's own tests walk the directory tree and cluster
chains — so the fault is not in the image.
Narrowed with p2iss/examples/sd_mount_probe.rs: against the byte-routed
card the guest issues CMD0, CMD0, CMD8, CMD55, ACMD41, CMD58 — a complete,
correct SDv2 initialisation — and then never reads a single block
(reads: []). FatFs cannot have started mounting, so the failure is inside
_sdmm_open (fatfs_vfs.c:30): either disk_setpins/pin reservation or
disk_initialize returning STA_NOINIT. The byte-routed model cannot get
further because it cannot represent what disk_initialize does next — the 15 kΩ
pull-up on the receive pin, or the smart-pin reconfiguration
(_wxpin(PIN_CLK, ck_div), _wrpin(PIN_DI, spm_tx)) at its tail.
Likely subsumed by S1. A faithful bit-level bus represents all of it; this should be re-tested once S1's framing gap closes rather than chased separately.
B1 — SPI boot flash — DONE (on the net)¶
p2iss::flashnode::FlashNode is a first-class component on real nets. It wraps
p2core::SpiFlash (the shared protocol: $66/$99 reset, $04 write-disable,
$05 status→$00, $03 read-data), senses CLK/CS/MOSI and drives MISO only
while the part sends data, status or its ID (SpiFlash::drives_miso), releasing
it otherwise. That is what a microSD on the same four Edge-module pins needs,
with CLK/CS swapped (flash CLK=P60, CS=P61): the flash's CS is the
card's clock, so every low half of a card clock pulse selects it. No test puts
a card beside this node yet; rom_boot_net.rs has the flash alone.
It works because the ISS yields at every net-pin event — the mechanism that
answers "why can't embsim handle a bit-banged device". See The yield mechanism
below. p2iss/tests/rom_boot_net.rs boots the real ROM from a net flash: the
flash sees ~17k clock edges cross the net, and the payload runs.
p2core::SpiFlash also has a Board-resident host (Board::with_flash) used by
p2core/tests/rom_boot_chain.rs — a fast, instant-pin path for the CPU-level
boot test, with no net. Same protocol logic, two mounts (exactly as the SD card
is both byte-routed in Board and net-routed via SdCardNode).
B1 (original) — SPI boot flash as a node¶
The P2 Edge carries a serial flash on P58–P61 with CLK = 60, CS = 61 — the same four wires as the microSD, with those two exchanged. Nothing models it.
The infrastructure now exists: SpiPins::FLASH is defined alongside
SpiPins::SD, and a flash component would sense the same bus behind its own
chip select, exactly as the hardware distinguishes them. Blocked on S1's
framing gap — there is no point adding a second device to a bus whose byte
boundaries drift.
B2 — P2 ROM bootloader — DONE¶
The real Parallax boot ROM executes end to end. p2core::Machine::with_boot_rom
loads the 16 KB ROM into the top of hub RAM and starts COG 0 at $FC000
(wrapping to $7C000), exactly as silicon does. The ROM seeds its RNG, loads
its own cog/LUT images, walks the pull-up decision tree on P59/P60/P61, and —
finding the flash strap on P61 — bit-bangs the SPI flash, loads its first
kilobyte, verifies the 256 longs sum to "Prop", copies them to cog RAM and
jumps.
That kilobyte is p2iss/rom/stage1.spin2, this repository's stage-1 loader: it
reads the application length at flash $400, streams the application into hub 0,
and relaunches the cog on it — the same entry convention the ROM used on
stage-1. p2iss::flashimage::boot_flash builds the layout.
Getting the ROM to run needed a batch of instructions the MaD firmware never
uses, each added to p2core with its behaviour read off the ROM source or
flexspin's output: RCL/RCR, the hub FIFO (WRFAST/RDFAST/WF*/RF*),
SETD/SETS, SETQ2→LUT block loads, DRVC/DRVNC/DRVZ/DRVNZ/DRVNOT,
BITC/BITNC/BITZ/BITNZ, the SETSE/POLLSE/SETINT event ops (inert),
and the full J{n}CT/SE/INT/ATN/PAT/… jump-on-event family. Two structural
fixes came with them: the 20-bit PC now wraps rather than trapping (so
$FC000 hub-exec works), and an augmented S is a 32-bit literal, never a
PTRA/PTRB expression — without that the ROM's own self-load read from the
wrong place. Test: p2core/tests/rom_boot_chain.rs.
Board time on a ROM boot is the clock the chip runs on (p2core::Machine::
time_map): RCFAST's nominal 20 MHz out of reset, then whatever each HUBSET
clock word selects — RCSLOW, or for the crystal and PLL the rate the program
recorded at hub $14 — carried on from the instant of the switch. Hub $14
is the ROM's scratch until a program is loaded, so the ROM's serial window
(1.8e9 clocks, a minute on the fastest 30 MHz part) lasts 90 s of board time,
as it would at nominal. Tests: p2core/tests/rom_time_base.rs,
p2iss/tests/rom_serial_window.rs. A P2IssCore keeps its own map from the
clock word and the crystal on XI.
Reference: p2iss/rom/rom_booter_v33k.spin2 (trimmed Parallax source; see
p2iss/rom/README.md).
B2 (original) — P2 ROM bootloader¶
Real silicon loads 16 KB of boot code from an internal ROM into the top of hub RAM; COG 0 runs it, sets RC-FAST, samples pull-ups/downs on P59/P60/P61 to pick a boot source, and loads the application from serial, SPI flash, or SD. The ISS skips all of it and writes the image into hub RAM directly.
What the ROM does, from the Parallax documentation: on reset the P2 loads 16 KB of boot code from an internal serial ROM into the top of hub RAM; COG 0 runs it, sets RC-FAST, and samples pull-ups/pull-downs on P59/P60/P61 to choose between serial, SPI flash and SD as the boot source.
Reference: ROM_Booter_v33k.spin2 in parallaxinc/propeller
(resources/FPGA Examples/) — permissively available, worth reading before
implementing rather than inferring the decision tree.
Depends on B1: there is nothing to boot from until the flash is a device.
B3 — the ISS as the P2 package's core, booting off the P2-EC32MB's flash — DONE¶
p2iss::P2IssCore (p2iss/src/package.rs; embsim MIGRATING-MAD.md step 2)
is the same p2core machine and the same net bus as P2Iss, as an
embsim_boards::p2::P2Core: it fills the P2-EC32MB's processor slot U100
inside embsim's P2Package, which holds it until the module's reset has
released and the datasheet's 3 ms restart delay has run out (5.5 ms from the
module's fingers), then starts it on the engine thread. It takes all 64 pads
and reads every one; no pin list says what a pin is:
- a pad drives from the guest's
DIR,OUTand mode word, throughBankSupplies::pad_drive— high at its bank's supply, at the strength the word's drive field sets (fast, 1.5 k, 15 k, 150 k, float); - a
P_ASYNC_TXpad marks its line from the moment it is enabled and frames bytes at the rate its bit period and the guest's clock give, and takes one byte at a time: it reads busy until the byte's frame is out, in the guest's clocks, so a sender waits a frame time a byte, as on silicon; aP_ASYNC_RXpad gets a deframer at its own rate and holds one byte, which the next overwrites: since the guest runs ahead, a byte lands at its own instant or the guest's present, whichever is later, and no closer to an unread byte before it than the wire put them (package.rs, "Receive"), so an overwritten byte is the firmware's own read gap (p2iss/tests/package_rx_overrun.rs); a pad made a receive pin before its rate is written holds its line's transitions for the deframer the rate gives it; aP_TRANSITIONorP_NCO_FREQpad is a step train (p2iss::pulse), and aWRPINto any other mode banks its count and hands the pad back to the word, so_pinclearreleases it and the firmware's software step pulses (HAL_pulseOut.c, below about 1.22 kHz) reach the net; the sync-serial pins are the smart bus (G4), clocked on the guest's own clock, so the module's flash and card shareP58–P61, and a bus pin withDIRclear or its word cleared is released; - a pad whose published drive is strong reads its
OUT, every other pad its net (p2core::Board::sensed, the strong-mask rule); - the guest's clock is the one its
HUBSETword selects (reported to the package, whoseXIreading depends on it) and the crystal the package reads onXIgives: RCFAST from START, then the PLL's 160 MHz from the module's 20 MHz TCXO. Hub$14plays no part; - the guest leads, and every change waits for its own instant (embsim
DESIGN.mdrule 3). A wake runs the cogs in clock order, at most one slice past the engine; an instruction that does something outside — a pad, a mode word, a byte for a transmit pin, a train, the clock word — ends the pass, and what it did is carried out by the wake at the instant it ran at (its cog's clock after it, through the clock map), which then wakes again at that instant so the engine resolves the change, and delivers any answer, before the guest's next instruction. The bus clock's edges are clock counts the pass stops at, carried out the same way. Two wakes a change; - nothing straps a pin:
S301does.
Known gaps, written up for embsim and the serial-boot step: the pads ignore
P_INVERT_OUTPUT in a logic-mode word (BankSupplies::pad_drive reads only
the drive fields; QEMU's core has the same gap), so the 15 kΩ pull-up
flexspin's sdmm.cc sets on the card's data-out pin P58 (OUT low,
inverted) presents as a pull-down; TESTP on P0/P1 reads high once
P63 has carried anything since it became a receive pin (a byte waiting,
or, before the guest has given P63 a bit period, its line moving, held
for the deframer that bit period gives it) for the ROM's autobaud, a
shortcut no net or mode word gives, with RDPIN on them p2core::Board's
fixed 2 000 000 baud measurement; a receive pin takes bytes whatever its
DIR, where silicon's starts at the ROM's dirh after the autobaud >
(the ROM drops each > and its parser scrolls past the rest); and
stage-1's relaunch (setq #$FF / rdlong 0, #0 / jmp #0 at cog $019)
works on the ISS only because the reloaded $019 happens to be harmless:
p2core executes the reloaded long where silicon runs the jmp #0 it had
already fetched.
p2iss/tests/ec32mb_flash_boot.rs, stepped, builds the module from its
vendor netlist, powers it from J203's fingers, closes S301 positions 2
(FLASH) and 4 (the P59 pull-down), and holds that the ROM reads stage-1 at
0 and stage-1 the 324 512-byte MaDEdge program (the debug build of the
time, now the one MaD build) at $400, that the
core starts at 5.5 ms, and that a terminal at 230 400 baud on J203.50
(P62/PRG_DBG_TXD) reads Starting MaD Board at 1.254 s of board time, with
the firmware on 160 MHz from the crystal. p2iss/tests/package_pads.rs,
stepped, boots small programs the same way with a scope on one pad: the
boot's 18 373 flash clock edges each on a nanosecond of its own, every one on
a tick of the 20 MHz RC clock from START, 100 ns apart at the closest; a step
train's pin released by _pinclear and then driven as a plain pin; and the
sync-serial clock's transitions half its period apart from the WYPIN, the
pad released once the pin is cleared. P2Iss keeps its Component impl and
pin lists for the p2iss tests that still use them. mad-emulator, their
last consumer outside those tests, has retired (MaD #362); they go in step
11's follow-up, which MaD #298 tracks until it lands.
p2iss/tests/ec32mb_sd_mount.rs, stepped, is the same module with the card
make sd-image writes in J301: the firmware's boot-time mount and its
logging core's mount both succeed, and the card is read at block 0 and then
at its root directory, block 129. It needs embsim with the W25Q128JV's DO
released until it has data to shift out (RileyMcCarthy/embsim#141). Before
that the flash, whose /CS is the card's clock P61 with S301 FLASH
closed, drove P58 high through every low half of the card's mode-3 clock
at 25 Ω against the card's 265 Ω (R304), and was deselected on each
rising edge. The failure took a second departure from silicon, in
p2core: the smart bus latches MISO from the level held just before the
rising edge, while sdmm.cc asks for the sample after it
(wxpin #7 | 32) and p2core drops that bit (RileyMcCarthy/MaD#269). So
every byte read $FF, the card heard only CMD0, and the mount failed
with errno 12. Just after each edge P58 already carried the card's bit
(measured in #268's review), so a p2core that sampled after the edge
would have read the card even beside the old flash (inferred). The flash's
fix is needed regardless: the part leaves DO at high impedance until it has
data to send (W25Q128JV Revision F §8.2.6, Figure 14, p.28). One gap
remains on that net: before the card has had a command, embsim's card
drives MISO whenever P60, the flash's clock, selects it, so the ROM's and
stage-1's flash reads fight it (the flash wins, at ten times the strength,
and the boot is unaffected; RileyMcCarthy/embsim#142).
G1 — Debug console is not a node¶
Inside the package (B3) it is: P62 is an async transmit pad on the
module's net, and a terminal on J203.50 reads it. For P2Iss:
P62/P63 are still byte-level inside p2core::Board. They should be a
SerialLink like the protocol and force-gauge links.
G2 — HW_pins.h SD block is wrong and unused¶
Correct it to the real microSD map (CLK=61, CS=60, MOSI=59, MISO=58) or
delete it, and note that _vfs_open_sdcard() carries the pins.
G3 — ISS runs below real time — measured, and mostly the firmware's polling¶
The old "0.74×" was an artifact. system_clocks() returned the maximum cog
clock and step_until round-robined by turn, so one cog's waitx/waitct1
dragged the machine's "now" forward and froze every other cog for the length of
the wait: the protocol cog answered ~2.5 s late, and the phantom
Scheduling overrun (19994/1000 us) came from the same jump. Time is now the
frontier — the minimum over running cogs; GETCT/WAITCT1 use the cog's own
clock; a coginit'd cog inherits its starter's clock. Request→reply is
50–100 ms of wall at --speed 1.
Honest speed after that: ~0.1× on an M2, ~22–29 M guest instructions/s of wall.
A profile (sample, 8 s, steady state) puts ~61% in p2core interpretation,
~23% in the pacer's nanosleep (genuine guest idle — unpaced reaches 0.10×
against 0.09×), and ~7% in the embsim net engine. A transaction-level bus
would trim that 7%, not the interpreter; see docs/dev scope notes before
reopening the byte route (embsim c254a64 deleted it on purpose).
Where the guest instructions go, on the Board model: the firmware's own
free-running polls. After the G5 fix below, HAL_serial_recieveBytes's
1024-iteration idle spin (SERIAL cog) is ~85% of all executed instructions, the
force-gauge _getus timeout loop most of the rest. waitx/waitct1 are free
clock jumps in the ISS (and cheaper on silicon), so a short backoff in those
idle paths would make the ISS nearly real-time without touching the
interpreter. The existing idle-poll fast-forward (PollState) only skips pure
register-only self-loops; it saves nothing while any other cog is busy, because
step_until still steps every cog under the deadline once per pass.
Cheap interpreter wins measured on a copy, not yet landed: precompute the
per-pass deadline in clocks instead of now_us() + a mul/div per cog (+5.5%),
hoist the std::env lookups out of the hot path and a 4-byte rd_long fast
path (+4.5%); a decode cache is worth only ~2%.
G5 — SETQ + WRLONG #imm is a block fill — FIXED¶
flexcc lowers memset(p, 0, len) to setq #len/4-1 / wrlong #0, p
(31 sites in this image; every module's _init). p2core executed the immediate
form as the register form — a block copy from cog register 0 upward — so each
_init sprayed cog 0's FCACHE contents over its own data. The visible chain:
app_testManagement_data.request.pendingManualMoveCount received the bytes of a
jmp word (0x5D9000), the CONTROL cog drained six million phantom moves under
the CONTROL-channel lock forever (lib_staticQueue_push: data is FULL on every
one), and MONITOR spent 100% of its instructions in locktry on that lock —
"live readout never populated" in the app e2e. Found by tracing lock-table
transitions per instruction, then watching the corrupted hub long for writers
(the writer was _app_testManagement_init+0x18, i.e. the memset itself).
Symbols came from re-assembling the flexcc .p2asm with flexspin -2 -l,
which the PlatformIO build does not produce.
After the fix: zero queue-full prints, guest demand 172 M → 75 M instructions per
virtual second, app e2e smoke 2/20 → 8/20. Regression: p2core/tests/block_fill.rs.
What the corrected ISS then surfaced — firmware, not ISS, and not yet acted on:
- MONITOR stack overflow. 1024-byte stack, 1000 bytes (97%) peak; the upper
canary trips at the first test start (Stack underflow detected on channel 0)
→ channel ERROR → APP_CONTROL_FAULT_COG → every later WRITE_MOTION_ENABLE
NACKed. The debug image's %0.3f DEBUG_* formatting on that cog is the
likely straw.
- NVRAM save unmounts the LOGGER's volume. A runtime profile save
(dev_nvram_run: 4 -> 3) mounts and umounts the single FatFs volume on
MAIN while LOGGER owns it; LOGGER's next opens fail with errno 12 and
WRITE_TEST_RUN is NACKed (invalidatedPartialUpload). dev_nvram.c:464
already works around the boot-time version of the same race.
- LOGGER overruns of 1.4–5.3 ms on file open/close (FatFs directory work inside
a 1 kHz tick) — real, noisy, not fatal.
- p2core's LOCKTRY lets the owning cog re-acquire (reentrant); CLAUDE.md
documents HAL locks as non-reentrant. Verify against the silicon doc — the
firmware's shared CONTROL-channel lock (app_motion / app_testManagement /
app_control) has nested-acquire paths that only work if silicon agrees.
U1 — the UART deframer fix must go upstream¶
embsim/board/src/uart.rs gained UartFraming::stop_sampled_ns and two
regression tests (see F1). The submodule is on the unmerged branch
feat/delete-byte-routes; this needs its own PR to
embsim before the MaD pin bump.
G4 — the SD smart-pin path is generalized — DONE¶
The SD card is now as decoupled from the CPU as the flash. p2core::smartbus
models the P2's synchronous-serial smart pins as CPU hardware: it reads the
clock/TX/RX roles from the mode words the firmware programs (P_PULSE clock,
P_SYNC_TX/P_SYNC_RX with the clock-pin offset in bits 26:24 — the same field
the quadrature decoder reads), shifts and samples on the clock's edges, and
exposes the driven pin levels. The ISS no longer declares an "SPI bus" or knows
any pin roles: it lifts the four SD pins onto nets generically (with_sync_serial
enables the board's smart-pin model), pumps the clock pin's edges over virtual
time, and moves levels. SdCardNode is a plain net-sensing component.
Two bugs were found completing this, both worth recording:
- WYPIN y on a pulse clock is y pulses = 2y transitions — the old
sequencer knew this (transitions = pulses * 2); the first cut clocked half.
- One edge per distinct virtual instant. The yield re-wakes and the periodic
slice all call the pump at the same instant; firing an edge on each burst
several transitions into one net resolution, which collapse on the wire and
make the device miss edges. The pump now gates on a scheduled edge time
(NetPins::next_edge_ns) — the same discipline the flash bit-bang needs, made
explicit for the smart-pin clock.
p2core::Board keeps its byte-routed SD for standalone runs and unit tests (off
unless enable_smart_bus), exactly as it keeps the byte path beside the net one
for other peripherals.
G6 — the SD write path had never been exercised — FIXED¶
Every failure that looked like a filesystem or firmware fault on the ISS —
fopen(..., "wb") returning errno 12, WRITE_TEST_RUN NACKed, "New Test
started" never appearing — was the card model. errno 12 is EIO in
FlexC's non-standard errno.h (ENOMEM is 7, EMFILE 11), and
_set_dos_error maps it from FR_DISK_ERR; the guest was reporting a disk
fault, accurately. Four defects, all in p2core, none in the firmware:
CMD24could not receive its block. The command arm setPhase::ReceivingBlock, thenxfer_inner'sCommandarm overwrote it withRespondingbecause a response was now queued, and draining that response returned the card toCommand.ReceivingBlockwas unreachable. The host read$FFwherexmit_datablockwants the$05data-accepted token, and the 512 payload bytes went to the command collector, which latches on any%01xxxxxxbyte and manufactured commands out of file data — a directory entry's"BIN "decoded asCMD2. Fixed withpending_write+after_responding().CMD18/CMD25were not implemented at all.sdmm.ccpicks the multi-block form for anycount > 1, which is most of what FatFs asks for, and the model answeredR1_ILLEGAL—send_cmd() == 0reads that as failure. Single-sector metadata writes worked, so a file would be created and then fail on its contents, which reads as an intermittent fault.- Releasing CS did not abandon a transfer.
xmit_datablockstarts withwait_ready(), and on timeout returns without sending the token: the card is left waiting for a block that never comes. Only a deselect frees it, and the net-side node stops exchanging bytes entirely while CS is high — so a card that only noticed the release during an exchange never noticed at all. It stayed inReceivingBlockand swallowed every later command frame as payload; the captured wire shows the host re-sending58 00 00 00 a5 01into a card answering$FF, with the bus dead for the rest of the run. Fixed by making itSdCard::set_selected, called from the CS sense. - Idle-poll fast-forward starved the bus. See G7.
Regression tests: p2core/tests/sd_block_write.rs drives the card exactly as
sdmm.cc does (command frame, R1 poll, token, payload, CRC, data response)
for single block, multi-block read and write, a deselect mid-block, and the
"command accepted but no block sent" stall.
G7 — fast-forward must not skip a spin on a pin mid-transfer — FIXED¶
testp changes nothing and passes every purity test, so the idle-poll detector
counted testp / jmp #$-1 as a confirmed idle loop and jumped the cog's clock
to the end of the slice. But step_until is called with the engine's current
instant, so a fast-forwarded cog executes about one iteration per wake — while
sdmm.cc times its own transfers out in guest clocks (500 ms in wait_ready,
125 ms for a data token). The transfer crawled, the timeout expired, and
select() failed with no command ever reaching the card: 31 writes per run
became zero. A/B with P2CORE_NO_FF=1 was decisive.
The rule is now: a loop that reads a pin (TESTP/TESTPN/RDPIN/RQPIN, or
INA/INB as a source) may still be fast-forwarded when nothing is clocking —
a level can then only change on a net wake, which ends the slice anyway — but
never while PinBus::external_transfer_busy() reports a burst in flight. The
blanket version of this rule cost 4x throughput (0.10x → 0.03x); gated on an
in-flight transfer it costs nothing measurable.
Firmware findings the corrected ISS surfaced — FIXED¶
- MONITOR overflowed its stack. 1024 bytes against a measured 1032-byte
deepest path (
app_monitor_run→processLogging→IO_SDCard_push→lib_staticQueue_push→DEBUG_ERROR→fprintf→__dofmt→__fmtfloat; the%0.3ftimestamp inDEBUG_*is most of it). The canary tripped at the first test start, the channel went to ERROR,APP_CONTROL_FAULT_COGlatched, and every later motion-enable was NACKed — six e2e scenarios waiting on "Motion: enabled". Now 2048. - The two canary messages were swapped. The stack grows up from
stack[0], so corrupting the lower canary is an underflow and running off the top is the overflow; the strings said the opposite.
G8 — the guest slept a full slice after every SD byte — FIXED¶
Measured, not guessed: a burst-timing probe over a whole run put a byte's wire
time at a median 0.40 us and the gap after it at a median 93.6 us, so
99.9% of SD virtual time was dead gap and a sector cost ~50 ms of guest
clock instead of ~0.2 ms. service_smart_clock armed nothing when a burst
finished, so the guest waited for the next periodic SLICE_NS wake before it
could clock the next byte. sdmm.cc times its own transfers out in guest
clocks, so this alone can fail a transfer the card answered perfectly.
The fix arms a wake at the next microsecond, which is the finest step
step_until can act on. It must be computed from now_ns, not from the
edge's own due: due can already be in the past when the wake fires, and
arming there makes the wheel refire at an instant virtual time cannot leave —
that deadlocked the engine and was the reason the first attempt was reverted.
After the fix the median gap is 0.60 us (156x better) with idle throughput
unchanged at 0.10-0.12x.
G9 — DIR/OUT are per-cog; the pad sees the OR — FIXED¶
This was the lost data token, and the cause was nowhere near the SD model.
Board mirrored DIRA/DIRB/OUTA/OUTB into one global pair, but they are
per-cog registers on the P2 and the pad sees the OR across all eight — the
firmware says so itself at dev_nvram.c:462 ("The P2 ORs pin DIR/OUT across all
8 cogs"). So any cog's write erased every other cog's. The debug console (P62)
and the SD card (P58..P61) share DIRB, and a DEBUG_* print issues about
327,000 DIRB writes in 30 seconds, every one of them with the SD bits
clear. Each made dir_of(P59) read low, and Board::dir_out_changed forwards
that to the smart bus as set_tx_livened(false) — which is a reset of the
sync-serial transmit shifter, not a direction change.
When one landed between sdmm.cc's dirh PIN_DI and its clock burst, the
loaded $FE data token was wiped and the card was clocked $FF instead. The
card then sat in ReceivingBlock { got: 0 } discarding all 512 payload bytes,
xmit_datablock failed, and FatFs never persisted the file's directory entry or
FAT — so the G-code file could not be re-opened and no test could start. It was
intermittent precisely because it depended on a print landing inside that
window.
Fixed by tracking dir/out per cog and OR-ing them at the pad, with the cog
index threaded through PinBus::dir_out_changed. Regression tests in
p2core/tests/per_cog_dir.rs cover the console-versus-SD case directly, that a
pin is released only once every cog has released it, and that OUT is OR-ed the
same way. After the fix an isolated run completes the whole sequence for the
first time: G-code written, closed, re-opened for read, sample file opened,
moves processed, test started.
Note the class of bug: any peripheral sharing a DIR bank with a pin another
cog drives was exposed to this. The SD was simply the first to notice, because
its transmit path treats DIR as a reset.
G10 — with the SD actually working, the engine is the bottleneck¶
A profile taken during SD traffic inverts the idle picture completely:
| idle | during SD traffic | |
|---|---|---|
p2core interpretation |
~61% | 83 samples |
net engine (EngineCore::run) |
~7% | 3727 samples |
The hot spots are per-edge overhead — the wake heap, mutex traffic,
publish_levels, and malloc/free from PinHandle churn — not CPU emulation.
publish_levels has since been made allocation-free (fixed arrays instead of
HashMaps, and the component facade is no longer cloned per call), which
removed the malloc traffic but did not move the throughput needle: the cost is
in the engine's per-edge resolve, at roughly 24 engine events per SD byte.
This corrects the earlier answer in G3. The claim that a transaction-level SPI/I2C bus "would trim 7%, not the interpreter" was measured with the SD idle, when the interpreter genuinely dominated. Now that writes work, an SD-heavy phase drops the ISS to ~0.00-0.01x and the engine dominates. For the SD path specifically a transaction-level hand-off is the right lever after all — which is what the fidelity-first design (a shared edge-to-transaction decoder plus batched drive sequences, with the capability-gated word hand-off held in reserve) was scoped to deliver.
Still open — SD throughput¶
The suite is unchanged at 8/20, and the binding constraint has moved: the SD is now correct but slow enough that the app's 15 s waits expire during a test run. Speed alternates between 0.10-0.11x when idle and 0.00x whenever the SD is busy. The remaining work is the throughput programme above, not another correctness hunt.
G11 — the boot off the flash, and every pad on a net — measured¶
The two costs embsim's NODES.md §13 review item 10 left open (embsim
MIGRATING-MAD.md step 3), measured by p2iss/examples/iss_speed.rs with
P2IssCore in the P2-EC32MB's package (B3) against P2Iss on no board.
Every mode runs stepped, so the run's host time is its cost. Release build,
Apple M2 (8 CPUs) shared with other work, 2026-10-19, two runs each. The host
was loaded (its load average is printed with each run), which inflates the
wall figures by up to three times; the CPU figures (user and system, every
thread) are the comparable ones:
cd SIL && make p2image && cargo build --release -p p2iss --examples
./target/release/examples/iss_speed ec32mb 2 # P2IssCore, the module, its flash
./target/release/examples/iss_speed # P2Iss, the image in hub RAM, the link's 2 pins on nets
./target/release/examples/iss_speed levels # the same with 14 GPIO pins on nets as levels
| Route | Phase | Board time | Host s per board s, wall (CPU) | Load average |
|---|---|---|---|---|
ec32mb |
boot: START to the firmware's first line | 1.247 s | 32.4 (10.7); 21.1 (10.6) | 28.3; 23.4 |
ec32mb |
running, every pad on a net | 2.0 s | 23.7 (11.0); 19.3 (11.1) | 28.3; 23.4 |
| bare | running, the link on nets | 2.0 s | 39.5 (10.5); 21.5 (10.1) | 32.3; 27.0 |
levels |
running, the link and 14 pins on nets | 2.0 s | 27.2 (10.4); 11.2 (9.3) | 34.4; 24.8 |
- The boot off the flash is 1.247 s of board time from START (5.5 ms
after the fingers are powered), all but 0.3 ms of it the ROM and stage-1 on
the 20 MHz RC oscillator: stage-1 bit-bangs the 324 512-byte program, about
5.2 million pad changes, each a stop of the guest, a wake at the change's
own instant and one more after the net has resolved (about 2.5 µs of host
CPU each). In the dev profile
cargo testbuilds,ec32mb_flash_boot.rstook 165 s on CI's runner and 264–596 s here (a host at a load of 20–100), both measured before the stamping below. - What stamping each change at its own instant costs (embsim
DESIGN.mdrule 3), against the same core stamping changes at the wake's instant, alternating runs on the same host at a load of 23–31: the boot 9.3 and 8.9 CPU s per board s before, 10.7 and 10.6 after, about 17 % more; running 10.0 and 9.9 before, 11.0 and 11.1 after, about 11 % more. The second wake per change is most of it; publishing only the pads a write moved took the boot's overhead from 31 % to 17 %. - Every pad on a net costs about what today's routes cost: 11.0–11.1 CPU s per board s against 9.3–10.5, the spread between runs nearly as large as the difference. All 64 pads read their nets; only the pads the guest changes are published.
- A fast load is not called for by these numbers. The boot is a fixed
1.25 board s per board, at the same host cost per board second as the
firmware running, so it weighs only where boards are started often (the
e2e's board per scenario: about 13 s of host CPU each, release). Its host
cost is per pad change, so what would shrink it is fewer clock edges — a
stage-1 that reads the flash two bits a clock, as Parallax's own loader
does with Fast Read Dual Output (
p2iss/rom/stage1.spin2) — not a faster guest clock, and not a load that skips the chain.
The project files: the host on J4, the firmware in the flash¶
Since 2026-11-01 there is one firmware build, MaD (pio run -e MaD,
make p2image), and its MAIN channel is always on P30/P32. The
flash-boot project files (SIL/mad.toml, mad-cosim.toml,
mad-playground.toml, mad-hard-stop-cosim.toml) put the host on the
carrier's control port J4, through IC2 to P30/P32 (USB 0403:6001),
with that image in the module's flash (make flash-image). The serial-boot
files (mad-serial-boot*.toml) keep the host on the debug header J1
(P62, P63, GND; USB 0403:6015), where the machine's Prop Plug sits,
to flash. P62 carries the debug log. make p2image-production and
MAD_P2_PRODUCTION_IMAGE are gone.
What follows records the route before that change (2026-10-21): the host on
J1 and the module's flash holding the single-port production build
(pio run -e P2Eval), whose MAIN channel received on P63 and transmitted
on P62 at 2,000,000 baud, with the FlexC console's own smart pins
(HAL_SERIAL_TYPE_BUILTIN). The debug build (MaDEdge) had its MAIN
channel on P30/P32, and the project files left J4 empty
(board design).
The measurements below are of those images.
mad-catalog/tests/mad.rs boots the production image in P2IssCore off the
module's flash and sends ProtoEmb frames on J1: the firmware answers the
version read and the state read on P62, at 2,000,000 baud by the rate the
core derives, and acknowledges a request to enable motion. Two things differ
from the debug image there:
- the production image still prints
Starting MaD BoardonP62(main.c) at the FlexC console's default rate (230,400 baud, inferred) before its serial core sets the pin to 2,000,000, so a host at the protocol's rate reads that line as framing errors (27 of them, measured), and the core's report keepsP62's bytes as the console: it says the boot line, then the protocol's bytes as text (measured); - a request that lands before the serial core has armed its receive interrupt would go unanswered (inferred), so the case asks again every 20 ms of board time. In the runs here the first request, sent at 1.226 s of board time, was answered at 1.235 s (measured).
The machine's faults on the project files¶
Read off the ISS's hub RAM (dev_cogManager's channel table and
app_control's fault flags, at the addresses the build's listing gives)
every 20 ms of board time, both images in mad.toml (measured, 2026-10-21):
- A core fault while the channels come up. The cog manager moves a
channel one state per pass of the main cog's 100 ms loop (
Main/MaD.c): the first pass launches its cog, the second sees the launch and reports the channel running (dev_cogManager.c), and the firmware reports a core fault until all seven have (app_control.c,processFaults). The production image's first state read, at 1.244 s, 65 ms after its boot line, falls in that window and answers the core fault; by 1.308 s every channel is running, its lock allocated (ids 2 to 8) and its stack canaries intact, and the core fault is gone. It is the boot's own window, on both images, and not a fault that stands. - The emergency-stop circuit's power. With nothing on the carrier's
J17(GPI_3), every read after that window answeredESD_POWER, to 3.1 s of board time, on both images.P22readsJ17's loop throughIC12andU8withR8's pull-up toVIO_16_23(read:mad_edge.net): with no current in the loop it rests high, and the firmware readsP22high as the emergency-stop circuit without power (HAL_GPIO_config.c, active high), which faults the machine and refuses motion. The retiredmad-emulatorheldP22low with a bench pull (SIL/MaDSim/src/iss_description.rsat 4bf392614,IDLE_PULLS), which is why motion enabled there. The project files closeJ17's loop from the carrier's5V_IOtoGND_IO, as the switch loops take them, standing for a powered circuit (inferred); with it the machine reads unfaulted from 1.314 s and enabling motion is acknowledged (measured). What the machine has onJ17is not confirmed: with nothing there, the firmware on the machine would fault the same way (inferred from the netlist and the firmware).
The core fault is first in the firmware's order and the reported reason is
the first fault set, so during the window it also hides the power fault
(app_control_private_processFaults). A case that reads the same command
twice takes only the answer to its own request: the first answer stays in
what the host received, and a parser started at an earlier request finds
it again (mad.rs, HostPort::answer).
The p2core/p2iss suites and mad-catalog/tests/boot.rs
kept the debug image (now the one MaD image): they put the host on the bare chip's P30/P32, or
read the log on P62. boot.rs's console case is ec32mb_flash_boot.rs's
boot said by a project file. It passes with the production image too
(measured, 2026-10-21): no request reaches the firmware there, and the
summary keeps P62 at the console's rate.
The host: Chrome on the board's clock, or a PTY¶
The ISS interprets every instruction, so a run's virtual time falls far behind the wall clock (a few percent of real time with the app attached, a fraction of that while the firmware works its SD card), and MaD Control's 2 s protocol budget is counted on the browser's clock. A browser on the host's own clock, behind the emulator's PTY, times out a live board: worthless for asserting, and misleading for looking.
So the browser runs on the board's clock. A project file puts embsim's
chrome-cdp node where the PTY host would sit, on the host's pins with its
rail wired. The node launches the host's Chrome at the board's first slice
and holds every page and dedicated worker to the board's time over
DevTools, a 1 ms quantum at a time; every page's navigator.serial is its
shim, whose one port is this line. It is built through embsim's catalog
(embsim_cdp::catalog, the chrome-cdp kind), and a failure it reports (a
grant that sticks, a page that crashes, a Chrome that exits) stops the run.
The run prints Chrome's DevTools URL in its "reached" line; a suite attaches
there
(SIL testing).
Until MaD #298 retired it, mad-emulator --chrome put the same node on the
bare chip's P30/P32.
The e2e runs on the machine's project (step 10): make e2e-emulator
is embsim run mad-cosim.toml, whose HOST is that kind on the
carrier's control port J4 (USB 0403:6001, DevTools on 9222, the port
granted to every origin; on J1, USB 0403:6015, with the production image,
until 2026-11-01), the module booting the firmware off its flash. Measured
on an Apple M2, 2026-10-21 (on J1), one board per scenario
(BOARD_PER_SCENARIO=1): the 23 scenarios that run there (14 on a
board, and 9 that need none) passed in 753 s of host time, boards
included, the per-PR ten in 512 s, and BUNDLE-live on the bundle in 36 s;
each board boots the firmware off the flash first,
about 1.3 s of board time in 24 s of host, and the board then ran at about
0.1x with the app attached. On J1 the
production image's boot line went out on P62 at the console's rate,
which the node's receiver at 2,000,000 baud read as framing errors (27 in
a run, measured); on J4 the host sees only the protocol. The scenarios that move the carriage run on the same
file (make e2e-motion), per PR G2+G3+H2+I and nightly the whole set:
since the 2026-11-01 re-export of mad_edge.net (Edge v0.3), IC14's
drive-side grounds are on EN_GND (MaD #299) and the carriage moves. The
suite skips SERVO-stall there, since nothing stops the carriage
(run-all.mjs, PROJECT_CANNOT), and runs it on that file with a hard
stop 3 mm above the carriage's start, mad-hard-stop-cosim.toml (make
e2e-stall, per PR).
| Target | Host |
|---|---|
make playground, playground-iss |
mad-playground.toml (mad-cosim.toml, headed): Chrome, a window on the app, on J4 (USB 0403:6001), DevTools on 9222 |
make playground-rom |
mad-serial-boot-playground.toml: Chrome, a window on the app's flasher, the ROM's serial boot on J1 (USB 0403:6015) |
make e2e-emulator, make e2e, make e2e-bundle |
mad-cosim.toml: Chrome, headless, on J4 (USB 0403:6001), DevTools on 9222 |
make e2e-rom |
mad-serial-boot-cosim.toml: Chrome, headless, on J1 (USB 0403:6015), the mask ROM's serial boot, for FW-ISS |
make e2e-motion |
mad-cosim.toml: Chrome, headless, on J4, DevTools on 9222, for the scenarios that move the carriage |
make e2e-stall |
mad-hard-stop-cosim.toml (a hard stop on the carriage): Chrome, headless, on J4, DevTools on 9222, for SERVO-stall alone |
make playground-pty |
mad.toml: a PTY on J4 at /tmp/tty.iss, for a serial console |
embsim run mad.toml --pty PATH |
mad.toml: a PTY on J4 (mad-catalog/tests/pty_protocol.rs) |