Feat/mcxn947 controller - #4
Merged
Merged
Conversation
Two upstreams, because neither is sufficient alone:
1. MCUXpresso SDK 24.12.00 for FRDM-MCXN947 (BSD-3-Clause) -- CMSIS,
device headers, periph/, system_*.c, the drivers step 1 needs
(fsl_clock, fsl_spc, fsl_gpio) and the FRDM board package's
clock_config/board headers.
2. NXP hal_nxp / mcux-sdk classic layout -- for the four files the SDK
does not ship: MCXN947_cm33_core{0,1}_flash.ld and
startup_MCXN947_cm33_core{0,1}.S. The SDK ships only the
MCUXpresso-IDE managed-linker startup (.c, not .S) and no .ld at all.
Verified rather than assumed: a find over the whole package returns
no MCXN947 .ld and no .S startup, and devices/MCXN947/ has no gcc/
directory.
The core1 linker script and startup are imported now but not built, so
step 5 does not have to re-open the provenance question.
Byte-exact, .gitattributes exempts them from the whitespace gate, and
PROVENANCE.md records both upstreams with their real local paths.
The two trees are different SDK vintages, which matters for one thing
and was checked: hal_nxp's vector table against 24.12.00's own startup
is 172 entries in identical order with no slot inserted or removed, and
slot indices agree with IRQn_Type at EDMA_0_CH0 (1), CTIMER2 (34),
LP_FLEXCOMM6 (41) and QDC0_COMPARE (124). Only names diverge, at slots
this design does not use -- hal_nxp says ENC0_*/ENC1_* where 24.12.00
renamed the peripheral to QDC, so anyone wiring a QDC interrupt must
define the ENC* handler name while the NVIC enum reads QDC*.
Step 1 of the ten-step migration in docs/MCXN947_CONTROLLER.md §9, and
nothing beyond it: one image, CPU0 only, no SPI, no USB, no CPU1, no
display. The point of doing it first is to isolate linker script,
startup, retention roots and toolchain before any of them can be
confused with a link problem.
text 10540 data 112 bss 3448 make check: 20 passed, 0 failed
Opt-in -- the top-level `make verify` is untouched, so a clone without
arm-none-eabi-gcc is unaffected. Build with `make -C firmware/mcxn947`.
THE DESIGN DOC'S RUNG-1 CHECK WOULD NOT HAVE WORKED ON THIS PART.
§7 specified "every --undefined= root is defined and does not resolve to
DefaultISR", an address comparison. But this startup has a TWO-LEVEL
dispatch and three distinct weak shapes:
CTIMER2_DriverIRQHandler W 0x4f8 == DefaultISR
CTIMER2_IRQHandler W 0x598 weak trampoline, own address
HardFault_Handler W 0x500 weak self-loop, own address
SysTick_Handler T 0x1830 ours, strong
The vector table names CTIMER2_IRQHandler, which is a weak trampoline
that branches to CTIMER2_DriverIRQHandler; only the *Driver* name is
.set to DefaultISR. So rooting the vector-table name and comparing
addresses gives a PASS against NXP's own trampoline while the handler is
in fact unimplemented -- precisely the silent failure the rung exists to
catch. The checker therefore tests the nm binding letter (weak vs
strong) as well as the DefaultISR address, and that the named owning
object defines it. Negative-tested: a nonexistent symbol, a wrong
symbol/object pairing, and all three weak shapes each fail distinctly.
The retention roots are a backstop today, not load-bearing: with every
object named on the link line, strong-beats-weak already keeps
SysTick_Handler at T with the --undefined flags removed. The *gate* is
what carries the weight. The roots start earning their keep when a
handler arrives via an archive, or when someone drops an object from the
list expecting the vector table to retain it. Establishing the mechanism
at two lines, while there is almost nothing in it, was the point.
Two more findings for §10, recorded in PROVENANCE.md:
- "NonCacheable region in the vendored .ld" -- there is no such region.
*(NonCacheable.init) and *(NonCacheable) are collected into the
ordinary .data output section in m_data. The attribute buys nothing as
the script stands, so step 2's two-DMA-engine coherency story needs an
MPU region or an explicit placement decision, not this.
- SystemInit() enables LPCAC and disables RAM ECC, so §5's LPCAC
question starts from "on by default in every image" rather than from
an unknown.
Deviations, all in PROVENANCE.md: BOARD_PowerMode_OD reimplemented
rather than vendoring board.c (which drags in the debug console and the
camera/codec/accel I2C helpers for ten lines of SPC calls);
--no-warn-rwx-segments, because the vendored script's .data AT(__DATA_ROM)
puts an RX LMA and an RW VMA in one LOAD segment and ld 14.2.1 flags
every link; and --defsym,__use_shmem__=1, which reuses RPMsg's address
reservation (not RPMsg) so m_data ends at 0x2004C000 and the CPU1 window
is protected from step 1 onward. Both .ld files stay byte-exact. The
newlib "_write is not implemented" notes are deliberately NOT suppressed.
Guard polarity is #if defined(MCXN947) with MMIO inside, so a new file
with no guard is portable by default: `make test` compiles platform.c
and heartbeat.c for the host with no -Ivendor, which demonstrates the
split rather than asserting it.
The doc's §10 is its "Open and UNVERIFIED" list. Three entries are now answered by having actually built the thing, and leaving them marked unverified is the same defect as an over-stated claim: it makes the section stop meaning anything. RESOLVED: - CTIMER2's IRQ symbol is CTIMER2_IRQHandler at vector slot 34. But the root worth declaring is CTIMER2_DriverIRQHandler -- see below. - There is no NonCacheable region. *(NonCacheable.init) and *(NonCacheable) land in the ordinary .data output section in m_data, so the attribute buys nothing as the vendored script stands. Step 2's two-DMA-engine coherency story needs an MPU region or an explicit placement decision. NARROWED: - SystemInit() enables LPCAC, so step 6's question starts from "on by default in every image" rather than from an unknown. It also disables RAM ECC and the aGDET/dGDET chip-reset path. CORRECTED -- §7's rung 1 described a check that would not work here. It said "does not resolve to DefaultISR -- one line of nm". This startup has a two-level dispatch: the vector table names <NAME>_IRQHandler, a weak trampoline, and only <NAME>_DriverIRQHandler is .set to DefaultISR. An address comparison against the vector-table name therefore passes against NXP's own trampoline while the handler is unimplemented. Two traps added to §7 that cost real time to find: - The two upstreams are different SDK vintages. Their vector tables agree -- 172 entries, identical order, spot-checked against IRQn_Type -- but hal_nxp uses ENC0_*/ENC1_* where 24.12.00 renamed the peripheral to QDC, so a QDC interrupt needs the ENC* handler name against a QDC* enum. - Another local MCXN947 project's SDK export declares BOARD_NAME "FRDM-MCXN947" while carrying the EVK's LED pinout (GPIO3[2:4] active-high). Taking pins from it yields a board that never blinks. 24.12.00's FRDM board package is authoritative: P0_10 red, P0_27 green, P1_2 blue, ALT0, active LOW.
§7 said "flash with J-Link, already installed". The board disagrees. The FRDM-MCXN947's on-board MCU-Link runs CMSIS-DAP firmware and enumerates as NXP 0x1fc9:0x0143, "MCU-LINK FRDM-MCXN947 (r0E7) CMSIS-DAP V3.128". JLinkExe cannot drive it. Getting to J-Link would mean reflashing the MCU-Link with SEGGER firmware -- reversible, but it takes the board away from MCUXpresso IDE and every NXP example in exchange for nothing we need. LinkServer 24.12.21 was already installed with MCUXpresso IDE, just not on PATH. It is NXP's tool for NXP's probe on NXP's part and it resolves the target with no configuration: # Description Device Board 1 MCU-LINK FRDM-MCXN947 (r0E7) CMSIS-DAP V3.128 MCXN947 FRDM-MCXN947 `make flash` loads core0.elf rather than the .bin, so load addresses come from the linker script and there is no --addr to drift out of step with it. `make probes` lists what is attached. Neither is reachable from CI. VERIFIED ON HARDWARE: sectors written at 0x0 and 0x400, "Finished writing Flash successfully", target reset, execution started. Also closes §10's "JLINK_DEVICE = MCXN947_M33_0 -- plausible, unverified". The name is correct -- it is in the SDK's own debug configs, with MCXN947_M33_1 for core1 -- but it is now moot for this flow, and only matters to someone who reflashes the probe. The J-Link device list cannot be queried offline, incidentally: JLinkExe accepts any -device string without a probe attached and only validates on connect, so "verify the device name" is not a thing that can be done from a desk.
§9 step 2 said "mcu_ready low -> LPSPI6 + eDMA0 ring -> TX permanently
IDLE", gated on "spi_slots advancing at 8 kHz with every spi_bad_* and
spi_queue_full flat".
That gate is vacuous, and it was measured to be so before writing any
step-2 code. On the bench, against a board running only step 1's blink,
with nothing driving MISO or mcu_ready:
spi_slots advancing at 8,049 slots/s
spi_bad_sof/crc/length/type, spi_queue_full, link_losses all 0
Both halves of the gate were already satisfied. Two facts in the
gateware explain it:
- spi_link.py:281 increments slot_counter on the fixed cadence boundary,
under a comment reading "A fixed slot boundary is generated
independently of transfer state". spi_slots at 8 kHz is the FPGA's own
cadence and says nothing whatever about the MCU.
- Every bad_* counter is gated on transfer_ready, latched from mcu_ready
at slot start (spi_link.py:289), e.g.
`with m.If(transfer_ready & (rx_sof != SOF))` at :433. With mcu_ready
low the FPGA never validates a returned slot, so no error counter can
increment and "all flat" carries no information.
So the TCR[BYSW] question, which this step is supposed to resolve "here
and nowhere else", would have stayed open behind a green gate.
Corrected: arm both DMA directions with mcu_ready low, then RAISE it,
and gate on counters that can now actually move -- plus map_active never
leaving 0, which makes the safety argument checked rather than assumed.
Low is the boot discipline, as ch32-cynthion-wiring.md has always said,
not the steady state.
Safety does not come from mcu_ready being low. It comes from TX being
IDLE: the admission predicate is `deliverable <=> SOF == 0x68 and
known_type and length == expected(type) and CRC ok and type != IDLE`, so
an IDLE slot is never deliverable and no map can activate.
THE DOC ALREADY CONTAINED THE CORRECT FACT. §4's ERR051588 recovery says
of driving mcu_ready low: "all four error counters are gated on
transfer_ready, so the garbage slots are *ignored* rather than counted",
and uses that deliberately to suppress counting during recovery, ending
at "6. Raise mcu_ready." A state whose stated purpose is to stop
counting cannot also be the state a gate reads counters in. §9 step 2
was the half that was wrong.
Byte-exact from MCUXpresso SDK 24.12.00 for FRDM-MCXN947, same as the step-1 subset. fsl_lpspi.c is deliberately NOT vendored -- only its header. LPSPI_SlaveInit() writes TCR wholesale from four fields, which clears BYSW, and ends by enabling the module, so using it would mean enable/disable/rewrite/re-enable for no gain over fifteen ordered register writes. Recorded in PROVENANCE.md. Consequence worth stating: fsl_lpspi.c's built-in ERR051588 workaround lives only inside the transactional slave API this firmware does not use, so the recovery ladder in design doc section 4 is ours to write. Step 3 provokes the erratum deliberately to prove it runs.
LPSPI6 as SPI slave on LP_FLEXCOMM6, both eDMA0 rings self-loading, TX
permanently IDLE, mcu_ready raised once both directions are armed.
Gate, measured over 22 s with link_ready = 1 throughout:
spi_slots +174497 7,976/s
spi_bad_sof +0 0/s
spi_bad_crc +0 0/s
spi_bad_length +0 0/s
spi_bad_type +0 0/s
spi_queue_full +0 0/s
map_active 0 throughout
That is the corrected gate: the error counters were capable of moving
and did not, which is only true because mcu_ready is high.
TCR[BYSW] = 1, which design doc section 10 listed as derived but
unmeasured. It is now measured. With it clear, the eDMA writes each
32-bit word byte-reversed -- an FPGA IDLE slot lands in the RX bank as
`00 00 00 68` -- and symmetrically the TX word for `68 00 00 00` clocks
0x00 out first, which is exactly the rx_sof != 0x68 the FPGA counts.
A WARNING FOR ANYONE DEBUGGING THIS LINK. spi_bad_sof at 1:1 with
spi_slots does NOT identify byte order. Byte order wrong in EITHER
direction saturates that counter at exactly the slot rate, so 1:1 is
what both BYSW settings look like whenever anything else in the chain is
also broken -- and during bring-up something else was: a bad MOSI
connection. BYSW was flipped to 0 on that non-discriminating evidence
and the fault survived the change, which is the only reason the mistake
was caught. The discriminator is the RX BANK CONTENTS, not the counter.
Two more corrections to claims made while debugging, both wrong:
- CH_CSR[DONE] latched does NOT mean a stalled ring. DONE lives in
CH_CSR, not the TCD, so scatter-gather reload never clears it while
ERQ stays set. It is sticky status. Both rings were turning the whole
time; sampling CITER over time shows 1..8, where a frozen ring would
show one value.
- "No bytes move in either direction" was a single-snapshot artifact.
Retained deliberately, against the usual rule: src/dbg_uart.c and the
register/wire probes in link.c are scaffolding, still marked TEMPORARY.
They stay for step 3, which provokes ERR051588 on purpose and will need
to watch the recovery. The LinkServer gdbserver is NOT an alternative --
it stalls the boot ROM on attach, so every register read that way
describes a halted device rather than running firmware. The UART is the
supported instrument on this board.
Also measured, and it changes how the counters must be read: spi_bad_sof
gains ~15,000 across every reflash, because mcu_ready is a GPIO that
holds its last level while LinkServer halts the core, so the FPGA keeps
validating against a peripheral that has stopped feeding it. Absolute
counter values are meaningless across a flash. Compare rates only.
make check 27/27, make test green.
RX retirement through portable host-tested logic, the ERR051588 ladder
built and actually exercised, and the discovery that one boot in five
comes up with the SPI frame permanently offset.
WHAT WORKS, measured:
- Steady state unchanged by retirement. Over 27.2 s: spi_slots 7,992/s,
every spi_bad_* and spi_queue_full flat, link_ready=1, map_active=0.
- Retirement is real, against live traffic rather than only tests. The
FPGA is not just sending keepalives: native_reports runs at 1,000.1/s
and the MCU decodes those as INJ_TYPE_REPORT_FRAGMENT with live
sequence bytes. Over 68,000 deliverable frames: 1 gap, 0 duplicates,
0 stales. bad_crc = 0 across ~550,000 retired slots is the running
proof that the bank-selection rule holds -- a torn read of a live bank
fails CRC immediately, and step 2 measured what one looks like.
- ERR051588 recovery ran. Provoked by taking the TX channel's DMA
request away for 1 s, which latches SR[TEF]; reproduced 4 times.
THE BOOT HAZARD, and it is the important part:
One boot in five comes up with the whole 256-bit slot permanently
offset. Measured 20 boots per arm, -DLINK_SKIP_GAP_WAIT building the
control:
gap wait present 20/20 clean
gap wait removed 16/20 clean 4 mis-framed (20%)
Fisher exact one-tailed p = 0.053 -- well supported, just short of the
conventional threshold, so it is evidence and not proof. An earlier
7-of-7 against 1-of-2 is deliberately NOT pooled in: those runs came
from a session with the code changing underneath, and combining
non-comparable runs to manufacture significance is exactly how this
repo's old four-row yosys table became worthless.
The offset is invisible to every status register -- no underrun, no
overrun, no DMA error, SR reads as on a healthy link. The only FPGA-side
symptom is spi_bad_sof saturated 1:1 with spi_slots, which step 2
already recorded as the non-discriminating signature of ANY whole-slot
corruption. It became nameable only once retirement made the received
bytes readable.
THE FRAMING MONITOR IS NOT A BACKSTOP FOR IT. An earlier claim in
PROVENANCE.md said it "makes the alignment hypothesis not need to be
right". It does not. All four control failures show the ladder firing
once per ~53 ms monitor pass, ~220 times, never succeeding, with every
slot failing throughout:
sof=92917 recov=220 framing=220 ms=11691
A mis-framed boot is not recoverable in software; it needs a reset. The
monitor's one observed success repaired a RECOVERY'S OWN bad re-arm,
which is a different fault, and generalising from it was the error.
Consequence worth internalising: every §9 gate accepted on a single boot
-- including step 2's, which I reported as settled -- was one draw from
a distribution with a 20% failure mode. Quote a rate over N boots.
ALSO: fsl_edma_soc.c removed from vendor/, header kept. It defines
nothing but 32 strong EDMA_<n>_CH<m>_DriverIRQHandler wrappers, and
EDMA_HandleIRQ() opens with assert(handle != NULL); with SDK asserts
live and nosys.specs, newlib's failure path hangs rather than resets.
All 31 unused ones were latent hangs behind any eDMA interrupt a later
step enabled. It surfaced only because our retirement ISR needs the same
name and the link failed loudly -- taking the weak trampoline name
instead would have linked silently and left them in place.
§4's ladder needed three additions, all forced by measurement and
recorded in PROVENANCE.md: rung 3 must also drop CR[MEN]; rung 5 must
not return until the TX FIFO holds a whole slot; rung 6 must clear SR
immediately before raising mcu_ready, because the refill window sets
SR[TEF] by construction and a latched TEF makes the monitor recover
forever.
§9 step 3's stated provocation does not work: halting the core starves
nothing, because enableDebugMode=false leaves the eDMA running and the
rings self-loading.
Instrumentation retained deliberately (dbg_uart.c, wire probe, register
dumps, the LINK_SKIP_GAP_WAIT experiment switch) -- the switch is how
the boot experiment gets re-run, and the alignment mechanism is still
open.
make check 29/29, make test 6 suites, check-copies 4 files, repo pytest
542 passed, make lint clean.
Gate 1 -- enumerates at High Speed:
hurra-adapter 0x1209:0x0001 serial 000000000001
Speed: Up to 480 Mb/s
pid.codes VID, not an invented one: a random VID squats on a real
vendor's assigned ID and can misbind drivers. Serial is the fixed
placeholder per section 10 -- DIEID is revision/die and identical across
boards, real identity is in the PUF, and no PUF transaction may sit on
the boot path between mcu_ready and the foreground loop.
Gate 2 -- console stats match JTAG. Same 22.4 s window, MCU 178,930
slots against the FPGA's 178,960: 99.983% agreement, 30 slots of drift,
which is the ~4 ms sampling skew between the two reads at 8 slots/ms.
Both report zero errors.
Gate 3 -- the step-2 link gate under sustained bulk console load, which
is the empirical check on section 3's core-placement decision:
host read 178,520,064 B (6,339.5 KiB/s in), wrote 947,418 B out
spi_slots 8003.66/s; bad_sof/crc/length/type, queue_full,
link_losses, rx_invalid all 0.00/s over 27.9 s
6.2 MB/s of HS bulk through the console on CPU0 and the link does not
drop a slot. Section 3 argued CPU0 has a 107.93 us STAGING WINDOW rather
than a latency deadline, because LPSPI6's FIFO is 8x32 bits = exactly
one 32-byte slot. That is now measured rather than argued.
THE BOOT MITIGATION IS NOT COMPLETE, and step 3 overstated it.
All 40 trials behind "20/20 clean with the gap wait" used a
flash-triggered reset. A board running the default build -- gap wait
present -- then came up mis-framed after a VBUS power reset, observed
directly at recovery #959 with the ladder looping and not repairing. The
result does not generalise to power-on. The hazard is reduced by an
unknown amount, not removed, and whatever explains the mechanism has to
explain this too. Recorded in section 10.
Corrections to the design doc, all forced by measurement:
- Section 7's "usb_phy.{c,h}, the only file from middleware/usb/" is not
achievable. usb_phy.c includes usb.h, which pulls in usb_misc.h,
usb_spec.h and fsl_os_abstraction.h -- 944 lines of the NXP USB stack
plus the OSA layer, BOTH excluded by name in the same section. Its
whole contribution on this part is TRIM_OVERRIDE_EN, two CTRL bits,
PWD = 0 and the TX trim, so it is hand-written instead, against two
independent upstreams that state the sequence register for register.
- The ISR is USB1_HS, not USB0. USB0_FS is routed nowhere on this board
(UM12018: only the HS controller and PHY are used). Root is
USB1_HS_DriverIRQHandler, verified T 0x3d54 from usb_console.o.
- Section 10's rhport resolves to 1, and re-reading mattered for a
different reason than the number: ci_hs_mcx.h IGNORES the argument --
CI_HS_REG(port) discards port and always uses controller 0 -- so a
wrong rhport would not fail where anyone would look. tusb_init(rhport,
NULL) likewise ignores its own rhport.
- The CH32 console has no command surface to carry over. usb_cdc_fs.c is
644 lines of endpoint transport with no parser, and main_v5f.c never
reads a byte out of its RX ring. The real precedent is step 3's
link_report() UART line, so stats mirrors its field names.
- Section 7's "~17 lines of glue" was optimistic; 179 guarded lines, a
third of which is clock/PHY and the UART status line. The number that
matters is the split: 234 lines of console logic with ZERO guarded
lines and a host test per command.
Two comments in link.c claimed the framing monitor repairs a mis-framed
boot, one concluding "alignment converges rather than depending on a
lucky boot" -- exactly what step 3's own experiment disproved. Corrected
to the distinction that holds: a RECOVERY whose re-arm lands mis-framed
is repaired in 60 ms; a BOOT that comes up mis-framed is not.
TinyUSB pinned at tag 0.20.0 (3af1bec1), extracted read-only with
git archive -- no checkout, worktree or fetch against the user's clone,
verified still at 2a364ca27 and clean. 26 files, byte-exact. MIT is now
the third licence in the tree.
A measurement trap, recorded because it cost one wrong FAIL: step 3's
ERR051588 demo arms ~20 s after boot and runs two deliberately-broken
windows, so a gate measured at 30 s reads spi_bad_sof 174/s and looks
like a hard failure. Measure at least 70 s after a reflash.
make check 39/39, make test 7 suites, check-copies 4/4.
Step 4's commit recorded a mis-framed boot on the default build (gap wait present) as evidence that "the 20/20 result does not generalise to power-on". The event was a mis-plug while J11 was being connected, not a clean VBUS power-on, so it is not evidence about the mitigation in either direction. The honest state is narrower and worth stating exactly: all 40 trials used a flash-triggered reset, so coverage on a power-on reset is UNTESTED. That is different from "shown to fail", which is what the previous wording implied. Establishing the power-on rate needs its own trials. Recorded rather than deleted so the observation is not re-derived later as a finding.
Section 9's gate for this step says to run three configurations and stop if any fails. Configuration (a) failed. With the core1 region erased, both words of CPU1's vector table read 0xFFFFFFFF. CPU1 faults on its first instruction fetch, escalates to LOCKUP, and resets the whole part -- CPU0 boot-looped continuously, re-running link_init() forever, and the link never reached steady state. Section 4 claims that if CPU1 "was never flashed at all, the FPGA link runs normally": that is exactly the clause that does not hold on this silicon. core1_image_valid() is the fix. CPU0 reads the two vector words and releases CPU1 only if the initial MSP lies in CPU1's RAM 8-byte aligned and the reset vector lies in CPU1's flash with bit 0 set. It reads two words and waits for nothing, so section 8's objection to MCMgr does not apply. The lockup-to-reset path itself is inferred, not confirmed against the RM: what is measured is that a blank region boot-loops CPU0 and a valid one does not, nothing else changed. Measured with the fix, FPGA counters over JTAG, each over a ~43 s window: (a) core1 erased, blank-checked 7999.97 slots/s all gated counters +0 (b) both images flashed 7995.75 slots/s all gated counters +0 (c) CPU1 stopped mid-execution 7999.92 slots/s all gated counters +0 spi_bad_sof, spi_bad_crc, spi_bad_length, spi_bad_type and spi_queue_full were flat in all three, link_losses did not move, and no counter on the MCU's own console grew during any window. CPU1 ran at 720 heartbeats/s in (b) and was frozen in (c). Configuration (c) is measured with a console command rather than a debugger. LinkServer's gdbserver attached to cm33_core1 without ever halting it -- the heartbeat advanced at 720/s straight through a supposed halt, so the first (c) result was really a second reading of (b) -- while reporting pc = 0x00000000 and reading the shared window correctly over the same connection. cpu1halt has CPU0 re-assert CPU1's reset: deterministic, no debugger, and harsher than a halt because reset is asynchronous and can land mid-store. CPU0 already owns that reset line, so this is not a command to CPU1 and section 3's one-way data-only IPC rule is intact. cpu1start is the explicit re-release command section 3 asks for in any case. Also measured, and each cost real time: * CPU1 is not the same core. __FPU_PRESENT 0, __DSP_PRESENT 0, __MPU_PRESENT 0 against core0's 1 and 1. Core1 builds -mcpu=cortex-m33+nodsp -mfloat-abi=soft; CMSIS stops the build outright on core0's hard-float ABI, which is how this was found. The missing MPU means section 10's NonCacheable problem cannot be solved with an MPU region on the core that will own the display's eDMA1. * CPU1's vendored SystemInit() writes ten chip-wide locations in SYSCON, SPC0, GDET0/1 and ITRC0, including an RMW of SPC0->CORELDO_CFG, and would run them after mcu_ready is already high. Declined: src/system_core1.c touches only SCB->CPACR and SCB->VTOR. Whether replaying NXP's sequence under a live link would have been tolerated is untested, not disproven. * A halted MCU is indistinguishable from the boot-random frame offset from the FPGA side: mcu_ready floats high and spi_bad_sof saturates 1:1 with spi_slots, which is section 10's signature for that hazard. The debug UART tells them apart -- a mis-framed board still prints its periodic report, a halted one prints nothing. LinkServer only runs what it wrote when its log ends with "restart on reset"; both erase-then-load and load -e leave the part stopped. Its default -u check stalled over three minutes, twice; -u none is now the Makefile default. * boot_multicore_slave.c is not "already in the vendor set" and is not vendorable: it is gated on __MULTICORE_MASTER and refers to __core_m33slave_START__, which the byte-exact core0 script does not define. Reimplemented as src/core1_release.c exactly as section 8 prescribes. New gate rungs, 73 assertions passing: core1's lowest LOAD PhysAddr read from the ELF, both images placing g_shared_window at the same address, per-image forbidden-symbol lists rejecting strong definitions only, a disassembly allowlist proving core1's SystemInit stores only CPACR then VTOR, and rung 6 reconstructing core1_release's CPBOOT/CPUCTRL stores. Rung 6 initially failed a correct image because GCC emits both MMIO constants as Thumb-2 immediates rather than pool words; an intermediate draft contorted the firmware with inline asm to satisfy it, and shipped a silent early-return with it. Both are gone -- the checker learned about immediates, and its self-test now carries the real objdump output of the built function verbatim.
Section 5 called cross-core cache coherency "strongly supported, UNVERIFIED"
and asked for a design where being wrong would be cheap and visible. It is now
measured rather than argued.
CPU0 publishes the section 5 seqlock snapshot from the retirement path at
~8 kHz; CPU1 reads it at ~20 Hz, drives the blue LED from bit 11 of
slot_counter, and echoes the slot_counter it actually read back into the shared
window. Measured on hardware over a 43 s window:
spi_slots 8000.23/s, every gated FPGA counter +0, link_losses 0
cpu1 echo tracked cpu0's live counter, lagging 105 slots (13.1 ms)
snapshot_fail = 0 -- not one bounded read gave up
cpu1 heartbeat 720/s, no console error counter grew
13.1 ms is the 20 Hz sampling interval, not staleness. LPCAC was left enabled
and the window stayed at 0x2004C000, so none of section 5's escalation steps
(__DSB(), disabling LPCAC, moving to SRAMX) were needed. volatile plus __DMB()
is sufficient. That closes the LPCAC item in section 10.
The echo is the reason this is a measurement and not an impression. Section 5
proposed watching slot_counter on the panel and section 9 step 6 proposes the
RGB LED; both need a human looking at the board and yield no number.
cpu1_seen_slot_counter turns the question into two numbers on the console that
either track or do not. It is the same category as the CPU1 heartbeat that
section 3 already endorses -- data CPU0 observes and never waits on -- so the
one-way IPC rule holds. The LED still blinks at ~1.95 Hz; it is simply no
longer the only evidence.
Two more doc discrepancies, both found by building it:
* Section 5's snapshot struct does not add up to the size it claims. The named
fields total 28 bytes, so the _pad[3] shown gives 28, not 32. link_snapshot.h
keeps every named field and widens the padding to _pad[7] to realize the
stated 32-byte ABI rather than silently shipping 28 or hiding it behind an
alignment attribute. The module is link_snapshot.{c,h}; section 5's
shared_link_status.c never existed here.
* Step 6 says "the RGB LED" without saying which. CPU0 owns green for its
foreground heartbeat and section 3 reserves red for a hard link fault, so
CPU1 gets blue (P1_2) -- which also keeps it off Port 0 entirely at this step.
Recorded for step 7 rather than fixed now: both cores necessarily share the
clock-gate registers, and section 4(a)'s ladder is what makes that race-free,
since CPU0 finishes all its clock setup before rung 8 releases CPU1. That gets
sharper at step 7, where section 2 puts the display's CS, D/C, WR and RD on
Port 0 -- the port CPU0's LED code is still writing at 1 Hz. Section 2's "no J8
pin is on Port 3, so there is zero physical overlap with the link" is true and
is not the same claim as section 4(b)'s "disjoint pins": the pins are disjoint,
the port registers are not. PSOR/PCOR are write-1-to-act and safe for disjoint
bits; PDDR is an RMW and must stay inside the boot ordering.
Gate: 78 static assertions, 12 host test binaries, including a seqlock test
that interleaves the reader at every writer store offset via hooks that compile
out of both images.
CPU1 drives the LCD-PAR-S035 on J8 over FlexIO0 8080 16-bit + eDMA1 + DBI + ST7796S, composing a text page from the step-6 snapshot at ~20 Hz. Section 9's gate has two halves and both are measured: repaint running, 45 s spi_slots 8000.82/s all gated counters +0 18.3 fps 5(c) mid-render, 40 s spi_slots 8026.10/s all gated counters +0 halted link_losses was 0 in both and no counter on the MCU's own console grew. cpu1halt during a blit stops CPU1 with an eDMA1 transfer potentially in flight to the panel, and the link is untouched. snapshot_seq kept advancing at 15,376/s through that window with no reader at all, which is section 5's "the writer never waits on CPU1" shown rather than argued. cpu1start restored rendering at 20.5 fps with boot=2. The repaint rate is a counter, not an impression. Section 9 asks for "20 Hz repaint", which is a rate, and a rate cannot be established by looking at a panel. CPU1 publishes cpu1_frames and a panel= state into the shared window and stats prints both, so both halves of the gate are one measurement. panel= reports ABSENT / ok / ok-with-FAULT, because CPU1 deliberately keeps its heartbeat, echo and LED alive when display_init() fails -- correct, since those are how CPU0 observes CPU1 at all, but it leaves a blank screen with a live heartbeat otherwise indistinguishable from a panel that was never detected. This is the same move step 6 made for the LPCAC question. No full framebuffer, per section 2: an 8x16 character/attribute grid with dirty-cell diffing coalesced into row runs, rasterised into two ping-ponged 480x16 row buffers. text_grid and glyphs are portable and host-tested -- the diffing tests are the interesting ones: unchanged text produces no runs, adjacent changes coalesce into one, a clean cell between two changes produces two. core1 RAM lands at 37 KB against section 2's ~33 KB estimate. Two design-doc discrepancies, both recorded rather than quietly worked around: * Section 2's "No J8 pin is on Port 3, so there is zero physical overlap with the link" is true and is NOT section 4(b)'s "disjoint pins". CS (P0_12), D/C (P0_7), WR (P0_9) and RD (P0_8) are all on Port 0 -- the port CPU0's heartbeat LED code is still writing at 1 Hz. The pins are disjoint, the port registers are not. PSOR/PCOR are write-1-to-act and safe for disjoint bits; PDDR and the PCRs are RMW, so CPU1 may only touch them during its own init, which is safe solely because section 4(a) releases CPU1 at rung 8 after CPU0 has finished its own pin and clock setup. Not "fixed" with a lock: a lock between the cores is what section 5 rejects outright. * baudRateDiv = 6 is shown sufficient, not shown correct. Section 10 derives it from an ILI9341 tWC of 66 ns and the LCD-PAR-S035 datasheet is still not on disk. The real panel initialises and holds ~20 Hz at that divider, which is not the same as knowing the ST7796S margin -- a marginal write cycle presents exactly as a working panel. DISPLAY_FLEXIO_BAUD_DIV is one constant carrying that provenance, and section 10 now says "narrowed, not closed". Rung 5's forbidden-symbol lists stop being vacuous here: core0 is now asserted to contain no strong FlexIO / eDMA1 / ST7796S / DBI / display / text_grid symbol, and those symbols finally exist in the other image. What this does NOT establish is the pixels. Every number above concerns the transport running at rate and costing the link nothing; whether the composed page is legible and correct needs someone looking at the panel. Gate: 95 static assertions, 16 host test binaries.
…FIED The panel came up black at step 7 with every counter healthy: panel=ok, blits running at ~537/s, zero rejections, DMA completing. An 8080 bus has no handshake, so a controller that receives nothing looks identical to one that receives everything. A solid-red fill pushed through the identical SelectArea + WritePixels path never appeared either, which rules out the renderer -- text grid, glyphs, dirty-run coalescing and colours are all downstream of a bus that was not working. That fill is kept as display_fill_screen(): "blits succeed and the screen is black" is not a diagnosis, and separating a dead bus from a renderer that draws nothing is worth one obvious look at the board. Checked against NXP's own lvgl_support.c and pin_mux.c for this exact panel on this exact board, and MATCHING, so none of these were the bug: pin assignments (CS P0_12, D/C P0_7, RST P4_7, WR P0_9, RD P0_8, data D16..D31), mux alternates (ALT6 FlexIO, ALT0 GPIO), shifter and timer indices, the baud divider, and the setCSPin/setRSPin callback wiring. Three real differences found, all fixed here: 1. PORT_SetPinConfig, not PORT_SetPinMux. PORT_SetPinMux writes ONLY the PCR MUX field, leaving PCR[IBE] -- input buffer enable, bit 12 -- at its reset value. NXP sets kPORT_InputBufferEnable on every one of these pins. With IBE clear the shifters still drain and every transfer still completes, which is exactly the symptom observed. 2. The reset pulse was 1 us. NXP holds RST low for 1000 us, then waits 5 ms. Three orders of magnitude short, and a controller that never reset ignores the init sequence while acknowledging nothing. 3. Transport before reset, not after. FLEXIO_MCULCD_Init is what puts WR, RD and the data lines into a defined state; releasing the ST7796S from reset before that lets it sample a floating bus as it comes up. NXP brings up FlexIO and the DBI handle first and only then pulses RST. Also adds cpu1_blits / cpu1_blit_rejects to the shared window and `stats`, so "is anything reaching the glass" is a number rather than an inference. frames alone cannot answer it: a frame with no dirty runs completes successfully having sent nothing. NONE OF THIS IS VERIFIED ON HARDWARE. The board became unreachable during this work and has not been recovered: the debug port answers (DpID 6BA02477) but only AP 2, the debug mailbox, is present -- there is no AHB-AP, so CpuID reads 0 and LinkServer reports "check target power" (which means CDBGPWRUPACK did not assert, not that the board is unpowered; its power LED and the panel backlight were on throughout). The mailbox itself returns AHB_OR_ERR/DBG_OR_ERR and will not grant a debug session, and it is the boot ROM that services it. Recovery attempts that all failed are recorded so nobody repeats them: power cycle, panel unplugged, Cynthion ribbon unplugged, SW3/ISP held across RESET, probe-driven ISP on all four ISP_CTRL lines, romstalldelay to 8000, JTAG, gdbserver, blhost on every VCOM, flash resurrect, and a custom .scp doing a 500 ms reset then START_DM_AP before DEBUG_SESSION_REQ. Whether change 1 is implicated in the lockout is NOT established. It alters the electrical behaviour of 16 pins wired to an external panel, which makes it the obvious suspect, but the board stayed dead with the panel unplugged. It should be reintroduced on its own, with the panel and the Cynthion ribbon disconnected, on a board known to be healthy. The steps 5-7 gate results committed earlier are unaffected: they were measured before any of this.
…struction The panel now paints: a full-screen fill clears the power-on GRAM and the text grid renders. Two independent defects, each invisible to every counter the firmware has, because an 8080 write is unacknowledged. 1. CPU1 could not drive GPIO at all. GPIO is TrustZone-aware and carries a per-pin secure filter, PCNS (offset 0x10), which resets to 0 -- every pin secure-access-only. CPU1 has no SAU and can only issue non-secure transactions: AHBSC->MASTER_SEC_LEVEL resets to 0x80000000, CPU1's field [3:2] = 00b. So RGPIO discarded CPU1's writes and returned zero on its reads, with no bus fault. Measured from CPU1: GPIO0->PDDR read 0 while CPU0 visibly drove P0_27, and all four views behaved the same -- non-secure, ALIAS1, both secure aliases. Yet PORT0->PCR[7] read 0x1000, PORT2->PCR[8] read 0x1600, FLEXIO0->CTRL read 0xC0000005 and AHBCLKCTRL0 read 0x04DBE7FF with the GPIO gates set. PORT and FLEXIO have no per-pin filter; that asymmetry is the whole story. CS (P0_12), D/C (P0_7) and RST (P4_7) are GPIO, so none of them were ever driven. The data lines and WR strobe were correct the entire time, feeding a panel that was never selected and never reset. core1_grant_gpio_nonsecure() is the handover, called from main_core0.c strictly before core1_release(). It grants only the pins CPU1 owns -- P0_27 stays secure, so CPU1 still reads that bit as 0, which is correct. PCNS is writable only by a secure-privileged master, so CPU1 could never have done this itself. The cheap diagnostic is the blue LED: CPU1 drives P1_2 via GPIO1, so blue dark while stats reports CPU1 alive means CPU1's GPIO is dead. 2. Every command parameter was silently dropped. Core1 was built -DMCUX_DBI_LEGACY=0, which sends parameters via FLEXIO_MCULCD_WriteDataArrayBlocking(). On a 16-bit bus that does `size /= 2U` and reinterprets the parameter BYTE array as uint16_t, so a one-byte parameter becomes ZERO bus beats. COLMOD, MADCTL, TEON and the CSCON unlock keys were never transmitted; CASET/RASET sent 2 beats instead of 4 and lost both end addresses. Only param_len == 0 commands survived, which is exactly why SLPOUT and DISPON worked while nothing else did. The panel therefore woke and lit but was never configured, showing the random power-on GRAM that ST7796S §9.2.22 says no reset clears. That "frozen static" survived every bus-rate, DMA-path and pin-level change because none of them touched parameter framing. The default path widens each parameter byte into its own beat and is what NXP's own working frdmmcxn947 examples use, so this moves to the legacy DBI API: dbi_flexio_edma_xfer_handle_t, DBI_FLEXIO_EDMA_CreateXferHandle(), the four-arg ST7796S_Init(), and ST7796S_SetMemoryDoneCallback() on the panel handle. fsl_dbi.c implements only the non-legacy helpers, so it leaves CORE1_OBJECTS -- left in, --gc-sections discards it whole and make check rejects that. Also closes the board.h P0_10 vs P0_7 D/C question PROVENANCE left open for whoever wrote step 7: P0_7 is correct, per Zephyr's frdm_mcxn947.dtsi and UM12018 Table 21. Our pin assignments match NXP's pin_mux.c field for field, PCR values included. Pin assignment is not a display-fault candidate. What did NOT matter, all measured rather than argued: bus rate (tested at 12.5 MHz and 1.875 MHz), eDMA versus blocking transfers, FLEXIO_MCULCD_DATA_BUS_WIDTH (correctly 16), posted-write ordering on D/C and CS, the addressable window, the backlight (hardwired), and FlexIO visibility from core1. The three bring-up fixes in 0bd48bc are on the board and were not the cause either, though the IBE half of that change does match NXP. Known and not addressed: ~7 fps at 25 blits/frame, under section 9's 20 Hz target, because legacy framing costs ~10 CS-framed transfers per SelectArea. The display is non-load-bearing and the link is unaffected. Verified on hardware: fill then readable text, link clean (sof/crc/len/typ all zero, framing 0), panel=ok, blitrej=0. 94 image gates and 14 host tests pass.
The panel now says something useful at a glance. Three changes plus a documentation correction that the LED work forced. Two-column page, split at column 30. Left is for a person: link state, faults, reports, rate, USB frame phase, descriptor and map generations, and liveness -- decimal, decoded, and colour-coded. Right is the raw register image in hex, now complete rather than the partial set it was. Neither side is derived from the other, so the left can be read without trusting the decode and the right stays the wire contract verbatim. The page previously used 13 of 20 rows and about a third of the width, and never showed native_report_count, usb_frame, usb_subframe, descriptor_generation or map_generation at all. The report rate is derived from the slot counter, not a clock. CPU1 has no time base, but the link delivers one slot every 125 us, so slot_counter IS a clock at 8 kHz and a ratio against it converts to real time. Note that "slots per second" derived the same way would be tautologically 8000 -- a fake number -- so link health is reported as ALIVE or STALLED instead. display_report_rate() is pure and host-tested directly, including the divide-by-zero and the 64-bit intermediate that keeps a large delta from overflowing before it divides. SHARED_DISPLAY_FLAG_FAULT now means what shared_window.h already documented it to mean. display_start_frame() returns false both for a transport failure and for a repaint tick arriving while the previous frame is still in flight; the caller treated both as faults, so the first overlap latched the sticky flag forever and panel=ok/FAULT stopped carrying information. Only a genuine transport failure sets it now. Overlap is normal pacing and needs no flag -- cpu1_frames already shows the achieved rate. The boot fill no longer holds for three seconds. The fill itself stays and is load-bearing: ST7796S GRAM is random at power-on and no reset clears it (§9.2.22), so without the fill the noise would persist anywhere the text grid never paints. The hold existed only so a human could catch the fill during bring-up, and the delay loop stretched its nominal 3 s to roughly 15 s of CPU1 spinning at every boot. The blue status LED is now a two-beat cardiac pulse rather than a square wave: 2.048 s cycle, 256 ms beat, 192 ms gap, 192 ms beat, 1408 ms rest. A brightness fade was the obvious idea and is not achievable here -- the LED is written from inside the snapshot branch at CORE1_SNAPSHOT_HEARTBEATS = 36, so roughly 20 Hz, which is one bit of brightness at 50 ms granularity and far below flicker fusion. Every segment is at least 1536 slots so the shortest gets three samples at the real call rate and the beats can neither merge nor vanish. The policy stays a pure function of slot_counter, so a stalled link freezes the animation rather than blinking on past the data, and the 21.9% duty parks a stall dark rather than at a misleading steady-on. That last point invalidated a bring-up diagnostic added two commits ago, and PROVENANCE.md is corrected accordingly: "a dark blue LED means CPU1's GPIO is dead" was true of a 50% square wave and is wrong now, because the LED is deliberately dark most of the time. The test is that it never ANIMATES -- watch a full 2.048 s cycle for the double-beat. Two other stale references to the old bit-11 / ~1.95 Hz blink are fixed in the same pass. Cost, stated plainly: the richer page raised blits per frame from 25 to about 49, so the repaint rate fell to roughly 10 fps. The display is non-load-bearing and the link is unaffected. Verified on hardware: page renders and is readable, LED shows the double-beat, panel=ok with the flag no longer latching, link clean. 94 image gates and the host suite pass, with display and status_led tests extended rather than merely adjusted -- the LED test asserts the sampling margin from every phase offset at six sampling rates, and was mutation-checked against the old waveform.
…ld was the only one still paying PROVENANCE.md measures the eDMA retirement ISR at ~10 us of each 125 us slot, "nearly all of it the bitwise CRC-16" over 30 bytes, 8,000 times a second. Two changes, both portable C, no MMIO and no netlist: 1. spi_frame_crc16() becomes the table-free bytewise reduction: the eight shift/xor steps per byte collapse into three shifted xors of one intermediate. ~68 instructions per byte becomes ~10. This only had to be done because of a toolchain asymmetry nobody had noticed. tests/test_wire_cross_language.py already records that GCC 15 rewrites the bitwise loop into a 256-entry table -- and the CH32/RISC-V build gets GCC 15, so it did. The ARM toolchain is GCC 14.2, which has no CRC idiom recognition, so objdump on build/core0.elf shows the MCXN947 image shipping the loop verbatim. Same source, same contract, one target silently paying 7x. The new form's shift counts ARE the polynomial: 12, 5 and 0 spell x^16 + x^12 + x^5 + 1. INJ_CRC16_POLY is generated from protocol/report_injection_wire.json, so a _Static_assert pins it -- changing the poly there must fail the build rather than leave these shifts quietly disagreeing with every other implementation of the contract. 2. link_retire_slot() short-circuits an exact keepalive. PROVENANCE records about 1 slot in 8 carrying a real REPORT_FRAGMENT at ~1 kHz, so roughly 87% of all CRC work in the system was validating a byte-constant keepalive. The compare is EXACT, not an approximation: a slot byte-identical to the canonical keepalive necessarily carries the right SOF, a known type, the right length and a matching CRC, so spi_frame_unpack() could only have returned SPI_FRAME_IDLE. Anything differing falls through untouched. The constant is built by spi_frame_pack() in link_retire_reset() rather than written out as literal bytes, so it cannot drift from the wire contract. Before that reset it is all zero, whose byte 0 is not INJ_FRAME_SOF, so a missed reset costs speed and never correctness. test_a_corrupted_keepalive_is_not_counted_as_idle() pins the reason the compare must stay a FULL compare. Weaken it to "the type byte says IDLE, so skip the CRC" and a frame whose type was corrupted to IDLE is counted healthy, blinding bad_crc -- the counter whose flatness is the running proof the link is clean and which the ERR051588 ladder reads. Verified by deliberately building that weak version: the test fails on it and passes on this one. The speedup is derived from instruction counts, NOT measured on hardware. What is verified: 541 passed / 1 skipped, both firmware trees green, check-copies byte-exact across both trees, and the RISC-V codegen test now compiles the real source under GCC 15.2 and finds no table -- the reduction gives even the table-izing compiler no loop to recognise. Its skip message was updated to say so rather than still claiming a bitwise loop.
The gateware side of report injection was complete and tested; the MCU only
ever staged IDLE, so nothing was ever injected. This adds the controller half.
- inj_command.{c,h}: frame builders for MAP_BEGIN/ENTRY/COMMIT and RELATIVE,
plus a table-free CRC-32 for the map's entries_crc32. The builders fill the
generated, offset- and size-asserted packed payload structs and hand them to
spi_frame_pack(), so the wire layout has one source of truth; the CRC-32
constants come from the generated contract macros so they cannot drift.
- inj_session.{c,h}: the upload/inject state machine. It learns the target
report's addressing (interface/endpoint/report_id/length) and descriptor
generation from an observed REPORT_FRAGMENT rather than hardcoding them,
uploads a fixed boot-mouse field map (X at byte 1, Y at byte 2), waits for
the MAP_STATUS commit, then emits RELATIVE motion. This order is mandatory:
the FPGA's command_fresh gate only acts on a command once a map of the cited
generation is active. On re-enumeration the descriptor generation changes and
the session re-learns and re-uploads.
- link.c: the RX retirement ISR now drains link_retire_receive() into the
session (REPORT_FRAGMENT and MAP_STATUS) and refills the TX bank the DMA does
not own with the session's next frame. Both are gated on mcu_ready, which the
FPGA also uses to invalidate its map and RX sequence window, so an ERR051588
recovery tears down and re-establishes cleanly rather than contending.
Injected motion is a fixed gentle drift (X/Y = -2 at ~40/s); at the 8 kHz slot
rate anything larger flings the cursor. It is static by design -- the milestone
was reaching live traffic, not servoing.
Verified: mcxn947 host tests (inj_command, inj_session) and make check 96/96;
a new end-to-end test drives the map + RELATIVE through the real gateware sim
and asserts the commit is accepted and a live report is mutated additively.
Confirmed on hardware: a mouse's reports drift diagonally from the injected
delta, and hot-plugging a different mouse re-learns and keeps injecting.
The instrument carried a free-running 0..255 counter in the report's Y axis as the motion delta, for drop/reorder detection. As a relative delta at the 8 kHz microframe rate that moved the cursor at roughly half a million counts/second -- far too fast to see anything injected into it. This traces a slow circle instead: sinf/cosf against a float position, emitting the rounded integer delta each report so sub-count motion accumulates rather than truncating to zero. At the default radius and period the deltas are 0 or +/-1, so the motion is smooth and gentle and injected drift is obvious on top of it. Radius and period are constants in main.c. The payload sequence counter, and thus drop/reorder detection, is dropped for this build. The 8 kHz report rate is unchanged, so link-speed-by-measurement still works; the README is updated to match.
The README had drifted: it asserted an unconfirmed ECP5 speed grade as fact, recommended a yosys version that does not close timing on this design, cited a placer seed that had since changed, repeated a timing claim that cannot happen in the production flow, and linked three docs that were deleted long ago. It is rewritten and corrected, trimmed to the practical build/load/use/debug path, and made current on the MCU (a Report injection section covering the MCXN947 and CH32H417 over the shared wire contract). CLAUDE.md and docs/ are now gitignored -- kept locally (Claude Code still reads CLAUDE.md, the engineering notes stay on disk) but out of the published tree. The RP2350 controller design record, a path evaluated and not taken, is removed outright. The firmware PROVENANCE.md files stay tracked: they are vendored-code attribution, not notes.
There was a problem hiding this comment.
🔵 Needs a closer look
TinyUSB’s ci_hs_regs_t uses repeated TU_RESERVED member names in a struct, which is invalid C and will break compilation.
Pull request overview
This PR introduces the MCXN947-based controller firmware stack (including vendored TinyUSB + MCUX SDK subsets and host-side tests) and updates the existing Teensy HS “mouse instrument” to produce a slow, visually legible motion pattern. It also updates cross-language CRC test expectations to match the new table-free CRC implementation.
Changes:
- Add MCXN947 controller firmware modules (platform bring-up, SPI framing/CRC, CPU0→CPU1 snapshot IPC, CPU1 display/LED plumbing) plus host tests and a core0/core1 image merge tool.
- Vendor subsets of TinyUSB and the NXP MCUX SDK required for MCXN947 USB HS + peripherals, with licensing/provenance support.
- Update the Teensy HS mouse demo to trace a slow circle and adjust CRC codegen test skip messaging for the table-free CRC reduction.
File summaries
| File | Description |
|---|---|
| tests/test_wire_cross_language.py | Updates skip messaging for CRC-table expectations. |
| firmware/teensy_hs_mouse/src/main.c | Changes synthetic mouse motion to a slow circle; adds math usage. |
| firmware/teensy_hs_mouse/README.md | Updates instrument description to match slow-circle behavior. |
| firmware/mcxn947/vendor/tinyusb/src/tusb.h | Adds TinyUSB public header (vendored). |
| firmware/mcxn947/vendor/tinyusb/src/portable/chipidea/ci_hs/ci_hs_type.h | Adds ChipIdea HS register/type definitions (vendored). |
| firmware/mcxn947/vendor/tinyusb/src/portable/chipidea/ci_hs/ci_hs_mcx.h | Adds MCX N9 ChipIdea HS glue (vendored). |
| firmware/mcxn947/vendor/tinyusb/src/osal/osal.h | Adds TinyUSB OSAL abstraction header (vendored). |
| firmware/mcxn947/vendor/tinyusb/src/device/usbd_pvt.h | Adds TinyUSB internal USBD definitions (vendored). |
| firmware/mcxn947/vendor/tinyusb/src/common/tusb_verify.h | Adds TinyUSB verify/assert macro utilities (vendored). |
| firmware/mcxn947/vendor/tinyusb/src/common/tusb_debug.h | Adds TinyUSB debug/logging utilities (vendored). |
| firmware/mcxn947/vendor/tinyusb/LICENSE | Adds TinyUSB license file. |
| firmware/mcxn947/vendor/mcux-sdk/devices/MCXN947/system_MCXN947_cm33_core1.h | Adds MCXN947 core1 system header (vendored). |
| firmware/mcxn947/vendor/mcux-sdk/devices/MCXN947/system_MCXN947_cm33_core1.c | Adds MCXN947 core1 system implementation (vendored). |
| firmware/mcxn947/vendor/mcux-sdk/devices/MCXN947/system_MCXN947_cm33_core0.h | Adds MCXN947 core0 system header (vendored). |
| firmware/mcxn947/vendor/mcux-sdk/devices/MCXN947/system_MCXN947_cm33_core0.c | Adds MCXN947 core0 system implementation (vendored). |
| firmware/mcxn947/vendor/mcux-sdk/devices/MCXN947/MCXN947_cm33_core1.h | Adds MCXN947 core1 CMSIS peripheral header (vendored). |
| firmware/mcxn947/vendor/mcux-sdk/devices/MCXN947/MCXN947_cm33_core0.h | Adds MCXN947 core0 CMSIS peripheral header (vendored). |
| firmware/mcxn947/vendor/mcux-sdk/devices/MCXN947/fsl_device_registers.h | Adds MCUX device-register dispatch header (vendored). |
| firmware/mcxn947/vendor/mcux-sdk/devices/MCXN947/drivers/fsl_reset.c | Adds MCUX reset driver implementation (vendored). |
| firmware/mcxn947/vendor/mcux-sdk/devices/MCXN947/drivers/fsl_lpflexcomm.h | Adds MCUX LP_FLEXCOMM driver header (vendored). |
| firmware/mcxn947/vendor/mcux-sdk/devices/MCXN947/drivers/fsl_flexio_mculcd_edma.h | Adds MCUX FlexIO MCULCD eDMA header (vendored). |
| firmware/mcxn947/vendor/mcux-sdk/devices/MCXN947/drivers/fsl_edma_soc.h | Adds MCUX eDMA SoC configuration header (vendored). |
| firmware/mcxn947/vendor/mcux-sdk/COPYING-BSD-3 | Adds BSD-3-Clause license text for SDK subset. |
| firmware/mcxn947/vendor/mcux-sdk/components/video/display/dbi/fsl_dbi.c | Adds MCUX DBI display helper implementation (vendored). |
| firmware/mcxn947/vendor/mcux-sdk/CMSIS/Core/Include/tz_context.h | Adds CMSIS TrustZone context header (vendored). |
| firmware/mcxn947/vendor/mcux-sdk/CMSIS/Core/Include/cmsis_version.h | Adds CMSIS version header (vendored). |
| firmware/mcxn947/vendor/mcux-sdk/CMSIS/Core/Include/CMSIS_Include_core_cm.cmake | Adds CMSIS include CMake fragment (vendored). |
| firmware/mcxn947/vendor/mcux-sdk/boards/frdmmcxn947/project_template/clock_config.h | Adds board clock config header (vendored). |
| firmware/mcxn947/tools/merge_mcxn947_images.py | Adds tool to merge core0/core1 images into a bounded flash blob. |
| firmware/mcxn947/test/text_grid_test.c | Adds host test for text-grid dirty-run behavior. |
| firmware/mcxn947/test/shared_window_test.c | Adds host test for shared-window reset semantics/ABI size. |
| firmware/mcxn947/test/platform_test.c | Adds host test for SysTick reload calculation edge cases. |
| firmware/mcxn947/test/merge_mcxn947_images_test.py | Adds tests for the image merge tool. |
| firmware/mcxn947/test/link_test.c | Adds host test for link idle-slot inertness and ring geometry. |
| firmware/mcxn947/test/link_snapshot_test.c | Adds host test for seqlock snapshot publish/read invariants. |
| firmware/mcxn947/test/inj_command_test.c | Adds host test for CRC32 and command-frame builders. |
| firmware/mcxn947/test/heartbeat_test.c | Adds host test for heartbeat cadence logic. |
| firmware/mcxn947/test/glyphs_test.c | Adds host test for glyph table lookup behavior. |
| firmware/mcxn947/test/display_test.c | Adds host test for display composition and rasterization behavior. |
| firmware/mcxn947/test/display_panel_test.c | Adds host test for rectangle validation geometry. |
| firmware/mcxn947/test/core1_release_test.c | Adds host test for CPU1 release/arithmetic validity checks. |
| firmware/mcxn947/src/usb_console.h | Declares the guarded USB CDC console hardware half. |
| firmware/mcxn947/src/tusb_config.h | Adds TinyUSB configuration for MCXN947 CDC console. |
| firmware/mcxn947/src/text_grid.h | Adds portable text-grid model API. |
| firmware/mcxn947/src/text_grid.c | Implements portable text-grid and dirty-run extraction. |
| firmware/mcxn947/src/system_core1.c | Adds minimal core1 SystemInit/SystemCoreClock definition. |
| firmware/mcxn947/src/status_led.h | Adds CPU1 status LED policy + HW interface. |
| firmware/mcxn947/src/status_led.c | Implements status LED rhythm and (guarded) GPIO writes. |
| firmware/mcxn947/src/spi_frame.h | Adds SPI frame format + sequencing API for injection wire. |
| firmware/mcxn947/src/spi_frame.c | Implements table-free CRC16, pack/unpack, sequence classification. |
| firmware/mcxn947/src/shared_window.h | Defines CPU0→CPU1 shared SRAM window ABI. |
| firmware/mcxn947/src/shared_window.c | Implements shared window storage + reset semantics. |
| firmware/mcxn947/src/platform.h | Declares CPU0 platform init + SysTick utility API. |
| firmware/mcxn947/src/platform.c | Implements portable tick base and guarded clock/SysTick bring-up. |
| firmware/mcxn947/src/main_core1.c | Adds CPU1 main loop for presentation (display/LED/snapshot echo). |
| firmware/mcxn947/src/main_core0.c | Adds CPU0 main boot ladder (platform/link/shared window/USB/CPU1 release). |
| firmware/mcxn947/src/link_snapshot.h | Defines portable seqlock snapshot ABI and API. |
| firmware/mcxn947/src/link_snapshot.c | Implements snapshot publish/read with target vs host barrier selection. |
| firmware/mcxn947/src/link_recovery.h | Defines ERR051588 recovery fault snapshot + ladder API. |
| firmware/mcxn947/src/link_recovery.c | Implements recovery predicate and ordered ladder runner. |
| firmware/mcxn947/src/inj_session.h | Introduces injection-session policy state machine (header). |
| firmware/mcxn947/src/inj_command.h | Declares frame builders + CRC32 for injection commands. |
| firmware/mcxn947/src/inj_command.c | Implements CRC32 and command frame builders via spi_frame_pack(). |
| firmware/mcxn947/src/heartbeat.h | Declares portable heartbeat + target GPIO/ISR hooks. |
| firmware/mcxn947/src/heartbeat.c | Implements heartbeat logic and (guarded) SysTick handler + GPIO writes. |
| firmware/mcxn947/src/glyphs.h | Declares fixed 8x16 glyph table API. |
| firmware/mcxn947/src/display.h | Declares CPU1 page composition + incremental renderer API. |
| firmware/mcxn947/src/display_panel.h | Declares panel transport API + portable rectangle validation. |
| firmware/mcxn947/src/dbg_uart.h | Declares bring-up UART API for early diagnostics. |
| firmware/mcxn947/src/dbg_uart.c | Implements guarded diagnostic UART TX over LPUART4. |
| firmware/mcxn947/src/core1_release.h | Declares CPU1 release logic + safety predicates and commands. |
| firmware/mcxn947/src/console.h | Declares CDC console portable half, stats model, and flood generator. |
| firmware/ch32h417/src/spi_frame.c | Updates CH32 SPI CRC16 implementation to the table-free reduction. |
| docs/hardware/ch32-cynthion-wiring.md | Removes CH32↔Cynthion wiring doc. |
| .gitignore | Ignores local AI/engineering notes and docs directory. |
| .gitattributes | Marks MCXN947 vendor subtree as whitespace-preserved import. |
Review details
- Files reviewed: 73/244 changed files
- Comments generated: 0
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.