diff --git a/.gitignore b/.gitignore index 840b033d..ae418798 100644 --- a/.gitignore +++ b/.gitignore @@ -51,4 +51,4 @@ vivado*.str **/*.svg.bkp # cobble local env -BUILD.vars \ No newline at end of file +BUILD.vars diff --git a/cos_soak_100.json b/cos_soak_100.json new file mode 100644 index 00000000..6d91eb9b --- /dev/null +++ b/cos_soak_100.json @@ -0,0 +1,53 @@ +{ + "seed": 478, + "steps": [ + { + "divisor": 2, + "freq_mhz": 100.0, + "ok": true, + "udp_timeouts_retried": 9, + "phases": { + "A_const_reads": { + "ok": true, + "values": [ + "0x000001de" + ] + }, + "B_scratch": { + "ok": true, + "patterns": 68, + "mismatches": [] + }, + "C_stress": { + "ok": true, + "write_burst": 1024, + "read_words": 1024, + "mismatches": [] + }, + "D_soak": { + "ok": true, + "ops": 14969449, + "mismatches": [] + }, + "E_throughput": { + "ok": true, + "wire_write_MBps": 1.228, + "wire_read_MBps": 1.003, + "single_read_us": 226.7, + "fmc_read_MBps": 11.41, + "fmc_read_ns_per_word": 351, + "fmc_read_fixed_ns_per_word": 309, + "fmc_write_MBps": 50.17, + "fmc_write_ns_per_word": 80, + "note": "fmc_* numbers are bus line rate (delta-timed block ops); wire_* include per-word network cost and are insensitive to the FMC clock" + } + }, + "btr1": "0x0010ffff", + "clkdiv_verified": true, + "sp_ticks_before": 63172, + "sp_ticks_after": 3664254, + "sp_rebooted": false + } + ], + "restored_baseline_ok": true +} \ No newline at end of file diff --git a/hdl/ip/vhd/fmc_if/docs/fmc_if.adoc b/hdl/ip/vhd/fmc_if/docs/fmc_if.adoc index fd1a1f2c..a195778d 100644 --- a/hdl/ip/vhd/fmc_if/docs/fmc_if.adoc +++ b/hdl/ip/vhd/fmc_if/docs/fmc_if.adoc @@ -2,8 +2,8 @@ :toc: left :numbered: :icons: font -:revision: 1.0 -:revdate: 2024-04-24 +:revision: 2.0 +:revdate: 2026-09-10 = FMC Target interface @@ -37,6 +37,83 @@ The AXI state machine monitors the transaction FIFO (and the WDATA FIFO in the case of writes) and wiggles the AXI signals appropriately to issue the read/write transactions. +=== Pacing contract + +The wait line is held asserted by default and released under FSM control. +After the SP samples the wait line released at rising edge N, its current +data beat is valid on the bus at edge N+1, and each further release-sampled +edge advances one beat; on reads the SP samples data on the same edge it +samples the released wait. This cadence was proven on hardware; the FSM +and the simulation model both encode it. + +All FMC inputs pass through dedicated per-pin capture registers (clocked by +fmc_capture_clk, a later-phased MMCM sibling on boards that have one), so +the FSM runs one cycle behind the bus; NWAIT pacing absorbs the delay. +A 32-bit write occupies 6 fmc_clk cycles of bus time (address capture, +dispatch, two arm cycles, two back-to-back beats); the AXI write is posted +and completes behind the SP's back. A 32-bit read stalls through the +CDC/AXI round trip and then streams its two beats on consecutive cycles. +The `extra_beat_setup` generic re-inserts a dead cycle between read beats +for a board that cannot close single-cycle data-out timing (the constraints +must then carry matching setup-2 multicycle exceptions). + +=== Clocking, or how 100 MHz timing closes + +The wait line is sampled by the SP on every rising edge, so it is a +single-cycle output path with no multicycle tricks available: the FPGA has +period - tsu(NWAIT) - trace, about 6 ns at 10 ns, to get from its clock pin +through a flop and back out. A plain IBUF+BUFG spends 3.5-6.5 ns of that on +clock insertion alone, so at 100 MHz the internal FMC clock instead comes +from an MMCM in phase-alignment mode (BUFG in the feedback path), which +nulls the insertion delay and leaves the whole budget for the flop and +output buffer. The output/enable/nwait flops are additionally packed into +the IOBs, which is why data_out_hiz is one registered bit per pin in OBUFT +T polarity. + +Inputs get the opposite treatment. The SP launches on its falling edge, so +data arrives late in the cycle and the valid window at the pin is only a +few nanoseconds wide; worse, STA charges the full corner spread of the MMCM +clock network against a virtual clock with no pessimism removal. Rather +than fight that, the MMCM produces a second output for the capture +registers, phased later than the FSM clock (+135 deg vs +45 deg, see +fmc_pll_ip.tcl), which parks the sampling instant comfortably inside the +data-valid window. The capture registers are the only logic on that clock; +the FSM consumes their outputs on its own clock a cycle later, which is +where the "FSM runs one cycle behind the bus" above comes from. The +45 deg +FSM/output phase is itself pinned by output hold on the NWAIT/data pins +(earlier violates hold), so the two phases are not adjustable +independently of the analysis in cosmo_timing.xdc. + +Both phases are specified in degrees, and the MMCM implements phase as +VCO-referenced taps, so they are true fractions of the period: at 50 MHz +the same bitstream places every edge proportionally later and every margin +only grows. The constraints are therefore written once at 10 ns as a +strict superset of all slower rates. The MMCM's VCO multiplier (M=12) is +chosen so the VCO stays in range from a 50 MHz input through a 100 MHz +one, letting one bitstream lock at any SP CLKDIV setting; a clock-stop +monitor in the fabric holds the MMCM in reset while the SP's clock is +absent and relocks it on return. A board without a clock-capable pin for +the FMC clock (grapefruit) skips the MMCM, ties fmc_capture_clk to +fmc_clk, and is bounded by the plain-BUFG budget to 66.67 MHz. + +=== Robustness + +* NWAIT timeout: a transaction that would stall the SP longer than + `timeout_cycles` (a wedged AXI responder, full FIFOs that never drain, or + a transaction start the FSM never observed -- chip select pinned with no + address latch seen, e.g. a miscaptured NADV) is abandoned: reads return + 0xFFFF poison beats, writes are swallowed, and the sticky `timeout_count` + output increments. Data from a timed-out read that later arrives is + discarded before any live read is served. Without the missed-start case + the SP would hang on its bus with nothing to free it, which from software + looks like a silent death followed by a watchdog reboot. +* Contention tripwire: the tristate control is re-derived from the live NOE/NE + pins every cycle it could drive, and `contention_count` increments if the + enable is ever up while the SP owns the bus (NADV or NWE low). +* A mid-transaction chip-select abort cleans up within a few cycles; a write + whose transaction was already queued is completed with zero filler so the + AXI side never wedges waiting on write data. + == Sim Env The simulation environment has a model of the STM32's FMC peripheral with some @@ -50,18 +127,19 @@ testbench to properly interop with our AXI-Lite controller interface. == Current status -This has been simulated against FMC models for single 32-bit read/writes. No -bursting is supported currently. Only 32bit access with no consecutive access is -implemented. - +Simulated against the FMC model for single 32-bit read/writes at 50, 66.67, +and 100 MHz fmc_clk against a 125 MHz AXI clock, including back-to-back +traffic, FIFO-full backpressure, slow and wedged AXI responders, timeout +recovery, mid-transaction aborts, reset between transactions, and a +randomized soak (`buck2 run //hdl/ip/vhd/fmc_if:fmc_tb`). No bursting beyond +the two beats of a 32-bit access is supported; that is a deliberate scope +decision. The model implements the WAITCFG=1/DATLAT=0 pacing contract, +blocking writes via wait_until_idle, the ES0491 dummy read cycles, and +one-shot abort injection. == Open Actions -* Support multi-word transactions on FMC interface - -* Support byte-enables FMC - AXI for <32bit access - -* Clean up FMC model, especially around writes and figuring out blocking +* Support byte-enables FMC - AXI for <32bit access == Future Investigation/Next steps diff --git a/hdl/ip/vhd/fmc_if/docs/fmc_verification_plan.adoc b/hdl/ip/vhd/fmc_if/docs/fmc_verification_plan.adoc new file mode 100644 index 00000000..69dca327 --- /dev/null +++ b/hdl/ip/vhd/fmc_if/docs/fmc_verification_plan.adoc @@ -0,0 +1,203 @@ +:showtitle: +:toc: left +:numbered: +:icons: font +:revdate: 2026-09-10 + += FMC speed-up verification plan + +Verification for the FMC line-rate work: streaming FSM + input capture stage, +cosmo at 100 MHz behind the fmc_pll MMCM, grapefruit at 66.67 MHz. Each phase +gates the next; a failure stops the progression at a known-good configuration. +Companion pieces: `fmc_if.adoc` (design + pacing contract), +`tools/fmc_sweep/fmc_sweep.py` (the sweep driver), and the hubris revs +(demo-server fixes, then the gated CLKDIV flip). + +Throughout: FMC_CLK divisor N means FMC_CLK = 200 MHz / N, so divisor 4 = +50 MHz (shipping), 3 = 66.67 MHz, 2 = 100 MHz. + +== Phase 0: desk checks (no hardware) + +. Simulation regression: `buck2 run //hdl/ip/vhd/fmc_if:fmc_tb` -- all 19 + tests, which cover single and back-to-back reads/writes, varied addresses, + FIFO backpressure, slow and wedged AXI responders, NWAIT timeout recovery + (including a missed transaction start), mid-transaction aborts, reset + between transactions, and randomized soaks, exercised at 50/66.67/100 MHz + fmc_clk against the 125 MHz AXI clock. + The harness fails any test on bus contention (model and DUT driving in the + same phase). +. Timing closure: build both bitstreams and read the post-route summaries -- + the build does NOT fail on timing, the reports are the authority: ++ +---- +buck2 build //hdl/projects/cosmo_seq:cosmo_seq //hdl/projects/grapefruit:grapefruit +grep -A6 "Design Timing Summary" buck-out/.../cosmo_seq_route_timing.rpt +grep -A6 "Design Timing Summary" buck-out/.../grapefruit_route_timing.rpt +---- ++ +Required: WNS and WHS both positive on both boards, zero failing endpoints, +and the Inter Clock Table must show nonzero endpoint counts for every FMC +class (fmc_virt_clk -> capture clock, capture -> FSM clock, FSM clock -> +fmc_virt_clk). A class with zero endpoints means a constraint silently +stopped matching (renamed cell or clock) and the "pass" is vacuous. +Margins land near +0.1 setup / +0.02 hold on cosmo at 10 ns and somewhat +looser on grapefruit at 15 ns, moving a few tens of picoseconds per rebuild; +cosmo's output hold is the phase-limiting margin and the one to watch. +. Datasheet re-verification (once, on paper): confirm against DS12110 that + the XDC's SP timing numbers (td(CLKL-NExL)=1, td(CLKL-AV)=2.5, + td(CLKL-ADV)=3, tsu(NWAIT-CLKH)=3, th(CLKH-NWAIT)=2) match the synchronous + multiplexed PSRAM tables at the relevant conditions, and that 100 MHz + FMC_CLK is within spec at VOS1. The constraints inherited these values + from the 66.67 MHz era and have not been re-read against rev-current + silicon docs. +. hubris demo-server rev: land before any sweep. Runtime timing control + does not exist without it -- the task previously lacked the FMC controller + register region and faulted on every setter call (reported by hiffy as + success), and the setters themselves clobbered sibling BTR1 fields via + `btr1.write()`. It also adds the BTR1/BCR1 readback ops the sweep uses to + verify each divisor change and the block-transfer ops behind the phase-E + line-rate numbers. + +== Phase 1: grapefruit at 50 MHz (new RTL, old frequency) + +Goal: prove the streamlined FSM + capture stage is a drop-in at the +shipping frequency, before any clock change. The FPGA is NWAIT-paced, so no +behavioral difference is expected. + +. Build the archive with the new bitstream + (`buck2 run //tools/fpga_releaser:cli -- --fpga grapefruit --hubris ` + or the usual release flow) with hubris still at CLKDIV=3: the + demo-server rev included, the CLKDIV flip not included. +. Flash, confirm hubris boots and the FPGA loads (spartan7-loader token + checks pass, grapefruit_seq comes up). +. Baseline functional pass: ++ +---- +./tools/fmc_sweep/fmc_sweep.py --ip --interface \ + --archive --json gf_baseline.json +---- ++ +This runs phases A-E at divisor 4 only: constant-register reads with gaps, +scratch write/readback (info scratchpad at +0x10; fpga_checksum at +0xC is +off-limits -- hubris uses it to decide whether to reprogram the FPGA), +back-to-back write bursts into the scratchpad verified by ordering/last +value, double-pass read sweeps of the post-code buffer (an external capture +mem: readable, but AXI writes to it are dropped, so bulk +write-verification is the simulation suite's job, not the bench's), a +500-op seeded soak, and a throughput measurement. All must pass. +. Record the phase-E numbers as the throughput baseline. +. Leave a longer soak running with `--soak-seconds 3600` (the soak is + otherwise op-counted, and 500 UDP round trips finish in seconds). + Acceptance: zero mismatches, no UDP timeouts. + +== Phase 2: grapefruit sweep to 66.67 MHz + +. `--sweep 4,3` first. Phase ordering inside each step is deliberate: + constant reads come first because the SP never drives the bus during read + data phases, so a timing miss at a new frequency shows up as wrong data, + never as a driver fight. Do not reorder. +. If divisor 3 passes: rerun with `--sweep 4,3,2`. Divisor 2 (100 MHz) is + expected to FAIL on grapefruit -- its clock enters on non-clock-capable + F17 with ~6 ns of detour insertion delay and no MMCM. The point is to + record where the cliff is and confirm the tool's failure handling: + it must stop at the first failing phase, restore divisor 4, and re-verify + phase A (`restored_baseline_ok: true` in the JSON). +. Acceptance for 66.67 MHz: full A-E pass plus a >= 1 hour soak + (`--soak-seconds 3600`) at divisor 3, + and phase-E throughput ~1.33x the baseline batched numbers. + +== Phase 3: cosmo at 50 MHz (MMCM bitstream, old frequency) + +Goal: prove the MMCM clocking infrastructure -- dual-frequency lock, the +clock-stop monitor, reset gating -- before touching the frequency. + +. Flash the new archive (CLKDIV still 3). First FMC access happening at all + proves the MMCM locked at a 50 MHz input (the VCO sits at its 600 MHz + minimum here -- this IS the risky corner of the dual-frequency config, so + a boot-loop or dead FMC points straight at lock failure; see the rollback + note below). +. Run the full fmc_sweep at divisor 4; record baseline JSON. +. Regenerate the ILA against the new netlist (the old fmc_ila.tcl net names + are stale: the clock is now `clk_fmc_fmc_pll`, the enable regs are + `data_out_hiz_int_reg[*]`, and `mark_debug` already tags fmc_state, + axi_state, txn, timeouts, contentions). Probe at minimum: fmc_state, + NWAIT, CS/ADV/WE/OE IBUFs, DA in/out, timeouts, contentions, and the + monitor's mmcm_reset/locked. +. With the ILA, verify on live traffic: + * NWAIT cadence matches the model: one release-sample per beat, two + consecutive released samples per 32-bit access, wait re-asserted between + transactions. + * ES0491 dummy read cycles appear after bursts and provoke no state + change. + * `timeouts == 0` and `contentions == 0` after every soak. Nonzero + contention is a stop-ship: it means the pacing contract disagrees with + real silicon somewhere. +. Robustness loops (the monitor/reset machinery): + * SP warm reset under active traffic, >= 50 iterations scripted. Each + time: FMC clock stops, monitor must hold the MMCM in reset, relock on + clock return, and peek/poke must work immediately after the SP is back. + No FPGA reprogram in this loop -- the bitstream rides through. + * FPGA reprogram (SP-driven, SP idle on FMC), >= 10 iterations, traffic + resumes cleanly each time. + * Full power cycle x5. + * Acceptance: zero hangs (an SP stuck in an FMC access = the timeout + machinery or reset gating failed), counters clean afterward. + +== Phase 4: cosmo sweep to 100 MHz + +. `--sweep 4,3,2` with the lab-image demo-server. All phases must pass at + every step; there is no acceptable-failure divisor on cosmo. +. One-hour soak (`--soak-seconds 3600`) at divisor 2, counters zero. +. MMCM phase margin sweep: rebuild bitstream variants with the phases + moved +/-18.75 deg = 5 MMCM taps = ~0.52 ns (the phase grid is + 45/CLKOUT_DIVIDE = 3.75 deg per tap; edit `fmc_pll_ip.tcl`: FSM/output + phase 26.25/45/63.75, capture phase 116.25/135/153.75 -- move them + together first, then independently if a failure needs isolating). Run + the divisor-2 soak on each variant. Acceptance: the shipped phases pass + with both neighbors also passing, demonstrating >= ~0.5 ns of real + margin beyond STA. If a neighbor fails, the shipped point is marginal: + investigate before un-gating, don't ship on one passing point. +. Re-verify the SP warm-reset loop at divisor 2 (relock now returns to a + 1200 MHz VCO -- the other edge of the range). + +== Phase 5: end-to-end throughput + +. fmc_sweep phase E reports two families of numbers. `fmc_*` values come + from delta-timed server-side block ops (demo-server network ops 17-19: + checksum block reads and a fixed-address fill), where the round trip and + per-packet costs cancel and what remains is bus accesses -- these are the + line-rate measurements and must scale with the divisor (~1.33x going + 4 -> 3, ~2x going 4 -> 2). `wire_*` values come from plain batched + peek/poke: ~9 network bytes per word swamps the ~0.4 us bus + access, so they are expected NOT to move with the divisor -- they are a + network/health reference, not a bus measurement. An `fmc_*` number that + fails to scale with the divisor means the divisor did not actually + change (or the block ops hit an error, which the checksum cross-checks + catch). +. Timed cosmo-hf host-flash operations (the hot path: one status poll + one + data read per u32) before/after -- same image content, hubris timestamps + or a host-side stopwatch around a full read-back. Expected ~1.6-1.8x at + 100 MHz; writes limited by flash program time, so use read-back as the + metric. +. The phase-E `fmc_*` table across divisors goes in the PR description. + +== Phase 6: un-gate the hubris CLKDIV rev + +Per board, only after that board's phases pass: land the hubris rev that +raises the boot-time divider (cosmo CLKDIV=1, grapefruit CLKDIV=2) in the +same archive as its validated bitstream -- the archive carries both, so +image and clock setting move atomically and no mixed-field state exists. + +Rollback at any point is the one-line CLKDIV revert: every bitstream in this +series is NWAIT-paced and runs at 50 MHz unchanged. The single caveat is a +cosmo MMCM that fails to lock at one input frequency (dual-VCO corner): that +is a bitstream-level rollback, which is why Phase 3 proves the 50 MHz lock +corner before anything else depends on it. + +== Open observability item + +`timeout_count` / `contention_count` are ports (left open at the tops) and +mark_debug signals. Wiring them into a spare info/debug register would make +the Phase 3/4 counter checks possible from software instead of the ILA and +would let fmc_sweep gate every step on them; worth doing before the Phase 4 +soaks. diff --git a/hdl/ip/vhd/fmc_if/model/stm32h7_fmc_model.vhd b/hdl/ip/vhd/fmc_if/model/stm32h7_fmc_model.vhd index ad1f166b..62cc83c1 100644 --- a/hdl/ip/vhd/fmc_if/model/stm32h7_fmc_model.vhd +++ b/hdl/ip/vhd/fmc_if/model/stm32h7_fmc_model.vhd @@ -2,11 +2,29 @@ -- License, v. 2.0. If a copy of the MPL was not distributed with this -- file, You can obtain one at https://mozilla.org/MPL/2.0/. ---! FMC controller model based on ST's RM0433 rev8 ---! figures 115 and 116 for simulation of the ---! FPGA's target interface ---! Oxide's internal doc mirror link: ---! https://drive.google.com/file/d/1wPaZAHS3-0HdMkXOC8tvGYgOPOrM0qRQ/view?usp=drive_link +-- FMC controller model based on ST's RM0433 rev8 +-- figures 115 and 116 for simulation of the +-- FPGA's target interface +-- Oxide's internal doc mirror link: +-- https://drive.google.com/file/d/1wPaZAHS3-0HdMkXOC8tvGYgOPOrM0qRQ/view?usp=drive_link +-- +-- Timing semantics modeled here (WAITCFG=1, DATLAT=0, the configuration +-- hubris programs): NWAIT is sampled on rising clock edges once the address +-- phase is over. For writes, each NWAIT-released rising edge advances one +-- data beat onto the bus at the following falling edge, so the beat is +-- captured by the target on the rising edge after the release was sampled. +-- For reads, data is sampled on the same rising edge where NWAIT is seen +-- released. This matches the cadence proven on hardware against the +-- pre-streaming target FSM; if hardware ILA captures ever disagree with +-- this contract, fix it here first. +-- +-- ES0491 (dummy read cycles): after every burst read the controller +-- performs two dummy read accesses with the chip still selected; the model +-- reproduces them so the target's idle-return is exercised under them. +-- +-- Also handled on the bus actor: wait_until_idle (making writes blockable), +-- and the control messages in stm32h7_fmc_model_pkg (inter-transaction gap, +-- one-shot mid-transaction abort). library ieee; use ieee.std_logic_1164.all; @@ -16,6 +34,9 @@ library vunit_lib; context vunit_lib.vunit_context; context vunit_lib.com_context; use vunit_lib.bus_master_pkg.all; +use vunit_lib.sync_pkg.all; + +use work.stm32h7_fmc_model_pkg.all; entity stm32h7_fmc_model is generic ( @@ -26,7 +47,6 @@ entity stm32h7_fmc_model is a : out std_logic_vector(address_length(bus_handle) - 1 downto 16); ad : inout std_logic_vector(data_length(bus_handle) - 1 downto 0); ne : out std_logic_vector(3 downto 0); - -- todo missing byte enables? noe : out std_logic; nwe : out std_logic; nl : out std_logic; @@ -36,18 +56,10 @@ end entity; architecture model of stm32h7_fmc_model is - type txn_type is (read_txn, write_txn); - signal delayed_wait : std_logic := '0'; + type txn_type is (read_txn, write_txn); begin - -- wait_delay : process (clk) - -- begin - -- if rising_edge(clk) then - -- delayed_wait <= not nwait; - -- end if; - -- end process; - bfm: process variable request_msg : msg_t; variable reply_msg : msg_t; @@ -56,6 +68,13 @@ begin variable addr : std_logic_vector(address_length(bus_handle) - 1 downto 0); variable data : std_logic_vector(data_length(bus_handle) - 1 downto 0); variable rem_data_cnt : integer; + variable beats_done : natural; + variable aborted : boolean; + -- extra idle cycles between transactions; 0 = back-to-back + variable gap_cycles : natural := 0; + -- one-shot: abort the next transaction after this many beats + -- (negative = disarmed) + variable abort_beats : integer := -1; procedure bus_idle is begin @@ -96,57 +115,122 @@ begin end; begin bus_idle; - nl <= '1'; - receive(net, BUS_HANDLE.p_actor, request_msg); - msg_type := message_type(request_msg); - -- All bus transactions begin with the FMC_CLK - -- low - wait until falling_edge(clk); - if msg_type = bus_burst_write_msg then - -- Figure 116 - -- activate address, chipsel, write, and latch - transaction_start(WRITE_TXN); - wait until falling_edge(clk); - wait until falling_edge(clk); - -- on next falling edge of clock, apply wdata - while rem_data_cnt > 0 loop - wait on clk; - -- on every rising edge that wait isn't asserted, - -- we've done a transfer, so get the data, dec the counter, - -- apply to bus - if falling_edge(clk) and nwait = '1' then - data := pop_std_ulogic_vector(request_msg); - rem_data_cnt := rem_data_cnt - 1; - ad <= data; + loop + receive(net, BUS_HANDLE.p_actor, request_msg); + msg_type := message_type(request_msg); + if msg_type = set_txn_gap_msg then + gap_cycles := pop_integer(request_msg); + elsif msg_type = abort_next_msg then + abort_beats := pop_integer(request_msg); + elsif msg_type = wait_until_idle_msg then + -- Messages are handled in order, so reaching this one means + -- every previously requested bus cycle has completed; this is + -- what makes fmc_write32 blockable. + handle_wait_until_idle(net, msg_type, request_msg); + elsif msg_type = bus_burst_write_msg then + -- Figure 116: all bus transactions begin with FMC_CLK low + wait until falling_edge(clk); + beats_done := 0; + aborted := false; + transaction_start(WRITE_TXN); + -- NWAIT sampling starts on the first rising edge after the + -- address phase. The abort check sits after the edge wait so + -- an already-applied beat is held through its capture edge + -- before the bus deasserts. + while rem_data_cnt > 0 loop + wait until rising_edge(clk); + if abort_beats >= 0 and beats_done = abort_beats then + aborted := true; + abort_beats := -1; + exit; + end if; + if nwait = '1' then + wait until falling_edge(clk); + data := pop_std_ulogic_vector(request_msg); + rem_data_cnt := rem_data_cnt - 1; + ad <= data; + beats_done := beats_done + 1; + end if; + end loop; + if aborted then + -- drain the un-sent beats so the message queue stays + -- consistent + while rem_data_cnt > 0 loop + data := pop_std_ulogic_vector(request_msg); + rem_data_cnt := rem_data_cnt - 1; + end loop; + bus_idle; + -- The target takes a few cycles to notice the deselect + -- (its view of the bus is one capture-register cycle + -- behind) and clean up; a real SP cannot restart within + -- one cycle of an abort either, so give it room before + -- the next transaction. + for i in 1 to 4 loop + wait until falling_edge(clk); + end loop; + else + -- hold the final beat through its capture edge + wait until falling_edge(clk); + bus_idle; end if; - end loop; - wait until falling_edge(clk); - elsif msg_type = bus_burst_read_msg then - reply_msg := new_msg; - -- Figure 115 - -- activate address, chipsel, and latch - transaction_start(READ_TXN); - push_integer(reply_msg, rem_data_cnt); - -- on next falling edge of clock, data could be on the bus - wait until falling_edge(clk); - wait until rising_edge(clk); - while rem_data_cnt > 0 loop - wait on clk; - if rising_edge(clk) and nwait = '1' then - -- sample data, dec remaining data - push_std_ulogic_vector(reply_msg, ad); - rem_data_cnt := rem_data_cnt - 1; + elsif msg_type = bus_burst_read_msg then + wait until falling_edge(clk); + beats_done := 0; + aborted := false; + reply_msg := new_msg; + -- Figure 115 + transaction_start(READ_TXN); + push_integer(reply_msg, rem_data_cnt); + -- data cannot be valid before the edge after the address + -- phase completes + wait until falling_edge(clk); + while rem_data_cnt > 0 loop + wait until rising_edge(clk); + if abort_beats >= 0 and beats_done = abort_beats then + aborted := true; + abort_beats := -1; + exit; + end if; + if nwait = '1' then + -- sample data on the same edge the released wait is + -- sampled + push_std_ulogic_vector(reply_msg, ad); + rem_data_cnt := rem_data_cnt - 1; + beats_done := beats_done + 1; + end if; + end loop; + if aborted then + -- fill the reply so burst_read_bus completes; the values + -- are meaningless by construction (note: must carry a + -- downto range to match the reader's slice) + data := (others => '0'); + while rem_data_cnt > 0 loop + push_std_ulogic_vector(reply_msg, data); + rem_data_cnt := rem_data_cnt - 1; + end loop; + else + -- ES0491: two dummy read cycles, chip still selected + wait until rising_edge(clk); + wait until rising_edge(clk); end if; + reply(net, request_msg, reply_msg); + wait until falling_edge(clk); + bus_idle; + if aborted then + -- as for writes: let the target finish its abort cleanup + for i in 1 to 4 loop + wait until falling_edge(clk); + end loop; + end if; + else + -- This shouldn't happen but will provide + -- proper error reporting if it does + unexpected_msg_type(msg_type); + end if; + for i in 1 to gap_cycles loop + wait until falling_edge(clk); end loop; - -- data out - -- tbd waits - reply(net, request_msg, reply_msg); - wait on clk; - else - -- This shouldn't happen but will provide - -- proper error reporting if it does - unexpected_msg_type(msg_type); - end if; + end loop; end process; end model; diff --git a/hdl/ip/vhd/fmc_if/model/stm32h7_fmc_model_pkg.vhd b/hdl/ip/vhd/fmc_if/model/stm32h7_fmc_model_pkg.vhd new file mode 100644 index 00000000..8710d6a6 --- /dev/null +++ b/hdl/ip/vhd/fmc_if/model/stm32h7_fmc_model_pkg.vhd @@ -0,0 +1,65 @@ +-- This Source Code Form is subject to the terms of the Mozilla Public +-- License, v. 2.0. If a copy of the MPL was not distributed with this +-- file, You can obtain one at https://mozilla.org/MPL/2.0/. + +-- Control messages for the STM32H7 FMC controller model. These ride the +-- same actor as the VUnit bus-master traffic so they stay ordered with the +-- transactions they configure. + +library ieee; +use ieee.std_logic_1164.all; + +library vunit_lib; + context vunit_lib.vunit_context; + context vunit_lib.com_context; +use vunit_lib.bus_master_pkg.all; + +package stm32h7_fmc_model_pkg is + + constant set_txn_gap_msg : msg_type_t := new_msg_type("fmc_set_txn_gap"); + constant abort_next_msg : msg_type_t := new_msg_type("fmc_abort_next"); + + -- Insert this many extra idle fmc_clk cycles between subsequent bus + -- transactions (0 = back-to-back, the default). + procedure fmc_set_txn_gap ( + signal net : inout network_t; + constant bus_handle : bus_master_t; + constant cycles : natural + ); + + -- Arm a one-shot mid-transaction abort: the next transaction deasserts + -- chip select after `after_beats` data beats have transferred + -- (0 = abort right after the address phase). + procedure fmc_abort_next ( + signal net : inout network_t; + constant bus_handle : bus_master_t; + constant after_beats : natural + ); + +end package; + +package body stm32h7_fmc_model_pkg is + + procedure fmc_set_txn_gap ( + signal net : inout network_t; + constant bus_handle : bus_master_t; + constant cycles : natural + ) is + variable request_msg : msg_t := new_msg(set_txn_gap_msg); + begin + push_integer(request_msg, cycles); + send(net, bus_handle.p_actor, request_msg); + end; + + procedure fmc_abort_next ( + signal net : inout network_t; + constant bus_handle : bus_master_t; + constant after_beats : natural + ) is + variable request_msg : msg_t := new_msg(abort_next_msg); + begin + push_integer(request_msg, after_beats); + send(net, bus_handle.p_actor, request_msg); + end; + +end package body; diff --git a/hdl/ip/vhd/fmc_if/model/stm32h7_fmc_sim_pkg.vhd b/hdl/ip/vhd/fmc_if/model/stm32h7_fmc_sim_pkg.vhd index 1b7a6f63..068566f7 100644 --- a/hdl/ip/vhd/fmc_if/model/stm32h7_fmc_sim_pkg.vhd +++ b/hdl/ip/vhd/fmc_if/model/stm32h7_fmc_sim_pkg.vhd @@ -2,8 +2,7 @@ -- License, v. 2.0. If a copy of the MPL was not distributed with this -- file, You can obtain one at https://mozilla.org/MPL/2.0/. ---! Bus master model based on ST's RM0433 ---! figures 115 and 116 +-- Testbench-facing helpers for driving the STM32H7 FMC controller model. library ieee; use ieee.std_logic_1164.all; @@ -13,6 +12,7 @@ library vunit_lib; context vunit_lib.vunit_context; context vunit_lib.com_context; use vunit_lib.bus_master_pkg.all; +use vunit_lib.sync_pkg.all; package stm32h7_fmc_sim_pkg is @@ -24,12 +24,28 @@ package stm32h7_fmc_sim_pkg is variable data : inout std_logic_vector ); + -- Blocks until the bus cycle has completed on the FMC pins. Note the + -- posted write may still be crossing into the AXI domain when this + -- returns; a subsequent fmc_read32 orders behind it, or wait for the + -- CDC/AXI latency before checking memory directly. procedure fmc_write32 ( signal net : inout network_t; constant address : std_logic_vector; variable data : inout std_logic_vector ); + -- Fire-and-forget variant, for queuing back-to-back traffic. + procedure fmc_write32_nb ( + signal net : inout network_t; + constant address : std_logic_vector; + variable data : inout std_logic_vector + ); + + -- Blocks until every previously queued transaction's bus cycle is done. + procedure fmc_wait_idle ( + signal net : inout network_t + ); + end package; package body stm32h7_fmc_sim_pkg is @@ -41,32 +57,46 @@ package body stm32h7_fmc_sim_pkg is ) is variable queue : queue_t; - constant butst_length : integer := 2; + constant burst_length : integer := 2; begin queue := new_queue; - burst_read_bus(net, SP_BUS_HANDLE, address, BUTST_LENGTH, queue); + burst_read_bus(net, SP_BUS_HANDLE, address, BURST_LENGTH, queue); data(15 downto 0) := pop_std_ulogic_vector(queue); data(31 downto 16) := pop_std_ulogic_vector(queue); end; - procedure fmc_write32 ( + procedure fmc_write32_nb ( signal net : inout network_t; constant address : std_logic_vector; variable data : inout std_logic_vector ) is variable queue : queue_t; - constant butst_length : integer := 2; + constant burst_length : integer := 2; begin queue := new_queue; push_std_ulogic_vector(queue, data(15 downto 0)); push_std_ulogic_vector(queue, data(31 downto 16)); - burst_write_bus(net, SP_BUS_HANDLE, address, BUTST_LENGTH, queue); - -- A bit of a hack until I figure out how to make this blocking - -- See https://github.com/VUnit/vunit/issues/1012 - wait for 150 ns; + burst_write_bus(net, SP_BUS_HANDLE, address, BURST_LENGTH, queue); + end; + + procedure fmc_write32 ( + signal net : inout network_t; + constant address : std_logic_vector; + variable data : inout std_logic_vector + ) is + begin + fmc_write32_nb(net, address, data); + fmc_wait_idle(net); + end; + + procedure fmc_wait_idle ( + signal net : inout network_t + ) is + begin + wait_until_idle(net, SP_BUS_HANDLE.p_actor); end; end package body; diff --git a/hdl/ip/vhd/fmc_if/sims/fmc_tb.vhd b/hdl/ip/vhd/fmc_if/sims/fmc_tb.vhd index 53758baa..2120d410 100644 --- a/hdl/ip/vhd/fmc_if/sims/fmc_tb.vhd +++ b/hdl/ip/vhd/fmc_if/sims/fmc_tb.vhd @@ -12,6 +12,7 @@ library vunit_lib; context vunit_lib.vunit_context; context vunit_lib.vc_context; use work.stm32h7_fmc_sim_pkg.all; +use work.stm32h7_fmc_model_pkg.all; use work.fmc_tb_pkg.all; entity fmc_tb is @@ -30,15 +31,101 @@ begin bench: process -- Note: External names are broken in GHDL llvm backends https://github.com/ghdl/ghdl/issues/2610 -- So this sim only works in other simulators, like nvc - -- reset_a uses the absolute path form (starting with a '.') and - -- reset_b uses the relative path form of external naming for example purposes. alias reset is << signal th.reset : std_logic >>; + alias fmc_half_period is << signal th.fmc_half_period : time >>; + alias timeout_count is << signal th.timeout_count : std_logic_vector(7 downto 0) >>; + alias ne_pins is << signal th.ne : std_logic_vector(3 downto 0) >>; + alias nwait_pin is << signal th.nwait : std_logic >>; + alias contention_count is << signal th.contention_count : std_logic_vector(7 downto 0) >>; variable address : std_logic_vector(25 downto 0) := (others => '0'); variable data : std_logic_vector(31 downto 0) := (others => '0'); variable expected_data : std_logic_vector(31 downto 0) := (others => '0'); variable buf : buffer_t; variable buf2 : buffer_t; + variable rand_state : unsigned(31 downto 0) := x"1234_5678"; + variable addr_nat : natural; + + -- deterministic LCG so the soak needs no external randomization + -- packages and reproduces exactly + impure function rand32 return unsigned is + begin + rand_state := resize(rand_state * 1664525, 32) + 1013904223; + return rand_state; + end; + + -- backing pattern for pre-filled read memory + function rd_pattern ( + addr : natural + ) return std_logic_vector is + begin + return std_logic_vector(resize(to_unsigned(addr, 32) * 1664525, 32) xor x"A5A5_A5A5"); + end; + + function to_addr ( + addr : natural + ) return std_logic_vector is + begin + return std_logic_vector(to_unsigned(addr, 26)); + end; + + procedure basic_write_read_pair is + begin + buf := allocate(wmemory, 4096); + buf2 := allocate(rmemory, 4096); + data := X"DEAD_BEEF"; + set_expected_word(wmemory, 16#40#, data); + fmc_write32(net, to_addr(16#40#), data); + expected_data := X"CAFE_F00D"; + write_word(rmemory, 16#80#, expected_data); + fmc_read32(net, to_addr(16#80#), data); + check_equal(data, expected_data, "Read data did not match expected"); + wait for 2 us; -- let the posted write land before checking memory + check_expected_was_written(buf); + end; + + procedure soak ( + constant num_ops : natural + ) is + constant rd_words : natural := 1024; + constant wr_base : natural := 16#10000#; + variable wr_index : natural := 0; + variable do_read : boolean; + variable r : unsigned(31 downto 0); + begin + buf := allocate(wmemory, 16#20000#); + buf2 := allocate(rmemory, 4 * rd_words); + for i in 0 to rd_words - 1 loop + write_word(rmemory, i * 4, rd_pattern(i * 4)); + end loop; + for i in 1 to num_ops loop + -- occasionally change the inter-transaction gap + r := rand32; + if r(2 downto 0) = "000" then + fmc_set_txn_gap(net, sp_bus_handle, to_integer(r(5 downto 3))); + end if; + r := rand32; + do_read := r(0) = '1'; + if do_read then + addr_nat := to_integer(r(11 downto 2)) * 4; + fmc_read32(net, to_addr(addr_nat), data); + check_equal(data, rd_pattern(addr_nat), + "soak read mismatch at addr " & to_string(addr_nat)); + else + -- distinct write addresses so in-flight writes can never + -- race a later expectation on the same word + addr_nat := wr_base + wr_index * 4; + wr_index := wr_index + 1; + data := std_logic_vector(rand32); + set_expected_word(wmemory, addr_nat, data); + fmc_write32_nb(net, to_addr(addr_nat), data); + end if; + end loop; + fmc_set_txn_gap(net, sp_bus_handle, 0); + fmc_wait_idle(net); + wait for 2 us; + check_expected_was_written(buf); + end; begin -- Always the first thing in the process, set up things for the VUnit test runner test_runner_setup(runner, runner_cfg); @@ -59,11 +146,13 @@ begin set_expected_word(wmemory, to_integer(address), data); -- Do the FMC -> AXI write transaction fmc_write32(net, address, data); + wait for 2 us; -- posted write crosses the CDC behind us check_expected_was_written(buf); -- Do a second transaction data := X"ADEADBAD"; set_expected_word(wmemory, to_integer(address), data); fmc_write32(net, address, data); + wait for 2 us; check_expected_was_written(buf); elsif run("basic_fmc_read_test") then buf := allocate(rmemory, 4 * 2, alignment => 32); @@ -78,11 +167,11 @@ begin -- Now do the FMC transaction, and check that returned data matches fmc_read32(net, address, data); check_equal(data, expected_data, "Read data did not match exptected"); - -- -- Do a second transaction - -- expected_data := X"ADEADBAD"; - -- write_word(rmemory, base_address(buf), expected_data); - -- fmc_read32(net, address, data); - -- check_equal(data, expected_data, "Read data did not match exptected"); + -- Do a second transaction back-to-back + expected_data := X"ADEADBAD"; + write_word(rmemory, base_address(buf), expected_data); + fmc_read32(net, address, data); + check_equal(data, expected_data, "2nd read data did not match exptected"); elsif run("basic_fmc_read_after_write") then data := X"DEADBEEF"; -- Set up the buffer used by the AXI write target @@ -94,25 +183,252 @@ begin set_expected_word(wmemory, to_integer(address), data); -- Do the FMC -> AXI write transaction fmc_write32(net, address, data); - check_expected_was_written(buf); + -- the posted write must land before its expectation is + -- replaced with the second value + wait for 2 us; -- Do a second transaction expected_data := X"ADEADBAD"; set_expected_word(wmemory, to_integer(address), expected_data); fmc_write32(net, address, expected_data); - check_expected_was_written(buf); write_word(rmemory, base_address(buf2), expected_data); set_permissions(rmemory, base_address(buf2), read_only); + -- The read orders behind both writes in the transaction FIFO, + -- so it also acts as the write-completion flush fmc_read32(net, address, data); check_equal(data, expected_data, "Read data did not match exptected"); + check_expected_was_written(buf); + elsif run("varied_address_write_read") then + -- walk a one over the address bits; 512 KB keeps the VUnit + -- memory model inside nvc's heap while still covering the + -- non-muxed a(18:16) upper-address path + buf := allocate(wmemory, 2 ** 19); + buf2 := allocate(rmemory, 2 ** 19); + for bit in 2 to 18 loop + addr_nat := 2 ** bit; + data := rd_pattern(addr_nat); + set_expected_word(wmemory, addr_nat, data); + fmc_write32_nb(net, to_addr(addr_nat), data); + write_word(rmemory, addr_nat, not data); + fmc_read32(net, to_addr(addr_nat), expected_data); + check_equal(expected_data, not data, + "read mismatch at address bit " & to_string(bit)); + end loop; + wait for 2 us; + check_expected_was_written(buf); + elsif run("back_to_back_writes") then + -- more writes than the transaction FIFO holds, with a slowed + -- AXI responder, so the dispatch-stall backpressure engages + buf := allocate(wmemory, 4096); + set_response_latency(net, axi_write_target, 500 ns); + for i in 0 to 31 loop + data := std_logic_vector(rand32); + set_expected_word(wmemory, i * 4, data); + fmc_write32_nb(net, to_addr(i * 4), data); + end loop; + fmc_wait_idle(net); + wait for 25 us; + check_expected_was_written(buf); + check_equal(unsigned(timeout_count), 0, + "backpressure must stall, not time out, at this latency"); + elsif run("back_to_back_reads") then + buf := allocate(rmemory, 4096); + for i in 0 to 31 loop + write_word(rmemory, i * 4, rd_pattern(i * 4)); + end loop; + for i in 0 to 31 loop + fmc_read32(net, to_addr(i * 4), data); + check_equal(data, rd_pattern(i * 4), + "b2b read mismatch at word " & to_string(i)); + end loop; + elsif run("slow_read_responder") then + -- long AXI read latency, but below the wait timeout: the SP + -- just stalls and then gets correct data + buf := allocate(rmemory, 64); + expected_data := X"0BAD_CAFE"; + write_word(rmemory, 16, expected_data); + set_response_latency(net, axi_read_target, 2 us); + fmc_read32(net, to_addr(16), data); + check_equal(data, expected_data, "slow read returned wrong data"); + check_equal(unsigned(timeout_count), 0, "no timeout expected"); + elsif run("read_timeout_recovery") then + -- AXI read latency far beyond the wait timeout: the SP gets + -- poison instead of a hung bus, and the stale response is + -- discarded before the next read + buf := allocate(rmemory, 64); + write_word(rmemory, 0, X"1111_2222"); + write_word(rmemory, 4, X"3333_4444"); + set_response_latency(net, axi_read_target, 50 us); + fmc_read32(net, to_addr(0), data); + check_equal(data, std_logic_vector'(X"FFFF_FFFF"), + "timed-out read must return poison"); + check_equal(unsigned(timeout_count), 1, "expected one timeout"); + -- let the stale response land and drain + wait for 60 us; + set_response_latency(net, axi_read_target, 0 ns); + fmc_read32(net, to_addr(4), data); + check_equal(data, std_logic_vector'(X"3333_4444"), + "post-timeout read must return fresh data"); + check_equal(unsigned(timeout_count), 1, "no further timeouts expected"); + elsif run("write_backpressure_timeout") then + -- wedge the write responder so the FIFOs fill and dispatch + -- stalls past the timeout: the overflowing writes are + -- swallowed rather than hanging the SP + buf := allocate(wmemory, 4096); + set_response_latency(net, axi_write_target, 20 us); + for i in 0 to 15 loop + data := std_logic_vector(rand32); + set_expected_word(wmemory, i * 4, data); + fmc_write32_nb(net, to_addr(i * 4), data); + end loop; + -- these overflow the queue; some will be swallowed by the + -- timeout, so no expectations are set on them + for i in 16 to 19 loop + data := std_logic_vector(rand32); + fmc_write32_nb(net, to_addr(i * 4), data); + end loop; + fmc_wait_idle(net); + check_true(unsigned(timeout_count) > 0, + "expected at least one swallowed write"); + set_response_latency(net, axi_write_target, 0 ns); + wait for 500 us; -- drain the wedged queue + check_expected_was_written(buf); + -- interface must still be alive + data := X"600D_600D"; + set_expected_word(wmemory, 16#100#, data); + fmc_write32(net, to_addr(16#100#), data); + wait for 2 us; + check_expected_was_written(buf); + elsif run("cs_abort_read_recovery") then + buf := allocate(rmemory, 64); + write_word(rmemory, 0, X"AAAA_BBBB"); + write_word(rmemory, 4, X"CCCC_DDDD"); + -- abort right after the address phase: the read is already in + -- flight and its data must be silently discarded + fmc_abort_next(net, sp_bus_handle, 0); + fmc_read32(net, to_addr(0), data); -- returned data is undefined + fmc_read32(net, to_addr(4), data); + check_equal(data, std_logic_vector'(X"CCCC_DDDD"), + "read after phase-0 abort must be clean"); + -- abort after one beat: the DUT finishes the word on its own + fmc_abort_next(net, sp_bus_handle, 1); + fmc_read32(net, to_addr(0), data); -- returned data is undefined + fmc_read32(net, to_addr(4), data); + check_equal(data, std_logic_vector'(X"CCCC_DDDD"), + "read after one-beat abort must be clean"); + check_equal(unsigned(timeout_count), 0, "aborts are not timeouts"); + elsif run("cs_abort_write_recovery") then + buf := allocate(wmemory, 64); + -- aborted writes complete with zero filler on the AXI side by + -- design; no expectations on them + data := X"1234_5678"; + fmc_abort_next(net, sp_bus_handle, 0); + fmc_write32(net, to_addr(0), data); + fmc_abort_next(net, sp_bus_handle, 1); + fmc_write32(net, to_addr(4), data); + -- interface must still work + data := X"8765_4321"; + set_expected_word(wmemory, 8, data); + fmc_write32(net, to_addr(8), data); + wait for 2 us; + check_expected_was_written(buf); + check_equal(unsigned(timeout_count), 0, "aborts are not timeouts"); + elsif run("reset_between_transactions") then + buf := allocate(wmemory, 64); + buf2 := allocate(rmemory, 64); + data := X"BEF0_4E00"; + set_expected_word(wmemory, 0, data); + fmc_write32(net, to_addr(0), data); + wait for 2 us; + check_expected_was_written(buf); + -- yank chip_reset between transactions and confirm the + -- interface comes back + reset <= force '1'; + wait for 300 ns; + reset <= release; + wait for 500 ns; + data := X"5EC0_4D00"; + set_expected_word(wmemory, 4, data); + fmc_write32(net, to_addr(4), data); + write_word(rmemory, 8, X"1357_2468"); + fmc_read32(net, to_addr(8), data); + check_equal(data, std_logic_vector'(X"1357_2468"), + "read after reset must work"); + wait for 2 us; + check_expected_was_written(buf); + elsif run("missed_start_timeout") then + -- Pin chip select with no address latch, emulating the FSM + -- having miscaptured a transaction start: the SP would be + -- stalled on its bus, and only the idle timeout can free it. + ne_pins <= force "1110"; + wait until nwait_pin = '1' for 30 us; + check_equal(nwait_pin, '1', + "wait must release after a missed-start timeout"); + wait for 100 ns; -- let the counter output settle + check_equal(unsigned(timeout_count), 1, "expected one timeout"); + ne_pins <= release; + wait for 2 us; + -- interface must still be alive + buf := allocate(wmemory, 64); + buf2 := allocate(rmemory, 64); + data := X"0DDB_A115"; + set_expected_word(wmemory, 0, data); + fmc_write32(net, to_addr(0), data); + write_word(rmemory, 4, X"BEA7_ED00"); + fmc_read32(net, to_addr(4), data); + check_equal(data, std_logic_vector'(X"BEA7_ED00"), + "read after missed-start recovery must work"); + wait for 2 us; + check_expected_was_written(buf); + check_equal(unsigned(timeout_count), 1, + "no further timeouts expected"); + elsif run("random_soak") then + soak(60); + elsif run("basic_write_read_66mhz") then + fmc_half_period <= force 7.5 ns; + wait for 100 ns; + basic_write_read_pair; + elsif run("basic_write_read_100mhz") then + fmc_half_period <= force 5 ns; + wait for 100 ns; + basic_write_read_pair; + elsif run("back_to_back_100mhz") then + fmc_half_period <= force 5 ns; + wait for 100 ns; + buf := allocate(wmemory, 4096); + buf2 := allocate(rmemory, 4096); + for i in 0 to 31 loop + data := std_logic_vector(rand32); + set_expected_word(wmemory, i * 4, data); + fmc_write32_nb(net, to_addr(i * 4), data); + end loop; + for i in 0 to 15 loop + write_word(rmemory, i * 4, rd_pattern(i * 4)); + fmc_read32(net, to_addr(i * 4), data); + check_equal(data, rd_pattern(i * 4), + "100mhz b2b read mismatch at word " & to_string(i)); + end loop; + fmc_wait_idle(net); + wait for 2 us; + check_expected_was_written(buf); + elsif run("random_soak_66mhz") then + fmc_half_period <= force 7.5 ns; + wait for 100 ns; + soak(60); + elsif run("random_soak_100mhz") then + fmc_half_period <= force 5 ns; + wait for 100 ns; + soak(60); end if; end loop; + -- every test finishes with a quiet bus and zero observed contention + check_equal(unsigned(contention_count), 0, + "DUT contention counter must be zero"); wait for 2 us; test_runner_cleanup(runner); wait; end process; - -- -- Example total test timeout dog - test_runner_watchdog(runner, 1 ms); + test_runner_watchdog(runner, 5 ms); end tb; diff --git a/hdl/ip/vhd/fmc_if/sims/fmc_th.vhd b/hdl/ip/vhd/fmc_if/sims/fmc_th.vhd index 6be6ddd8..c4d92737 100644 --- a/hdl/ip/vhd/fmc_if/sims/fmc_th.vhd +++ b/hdl/ip/vhd/fmc_if/sims/fmc_th.vhd @@ -19,8 +19,14 @@ end entity; architecture th of fmc_th is - signal clk : std_logic := '0'; - signal reset : std_logic := '1'; + -- fmc_clk and aclk are deliberately unrelated so the real CDC gets + -- exercised. fmc_half_period defaults to 50 MHz (the shipped SP CLKDIV) + -- and is forced by the testbench to 7.5 ns / 5 ns for the 66.67 and + -- 100 MHz ratio runs. + signal fmc_half_period : time := 10 ns; + signal fmc_clk : std_logic := '0'; + signal aclk : std_logic := '0'; + signal reset : std_logic := '1'; signal a : std_logic_vector(25 downto 16); signal ad : std_logic_vector(15 downto 0); @@ -30,31 +36,36 @@ architecture th of fmc_th is signal nl : std_logic; signal nwait : std_logic := '1'; - signal rdata : std_logic_vector(31 downto 0); - signal arid : std_logic_vector(3 downto 0) := std_logic_vector(to_unsigned(0, 4)); signal bid : std_logic_vector(3 downto 0); signal awid : std_logic_vector(3 downto 0) := std_logic_vector(to_unsigned(0, 4)); signal rid : std_logic_vector(3 downto 0); - signal data_out_tris : std_logic_vector(15 downto 0); - signal data_out_tris_en : std_logic; + signal data_out_tris : std_logic_vector(15 downto 0); + signal data_out_tris_hiz : std_logic_vector(15 downto 0); + + signal timeout_count : std_logic_vector(7 downto 0); + signal contention_count : std_logic_vector(7 downto 0); signal axi_if : axil_t; begin - -- set up a fastish, clock for the sim - -- env and release reset after a bit of time - clk <= not clk after 4 ns; + aclk <= not aclk after 4 ns; reset <= '0' after 200 ns; + fmc_clk_gen: process + begin + wait for fmc_half_period; + fmc_clk <= not fmc_clk; + end process; + -- sim infrastructure from VUnit axi_read_sim_infra: entity vunit_lib.axi_read_slave generic map ( axi_slave => axi_read_target ) port map ( - aclk => clk, + aclk => aclk, arvalid => axi_if.read_address.valid, arready => axi_if.read_address.ready, @@ -77,7 +88,7 @@ begin axi_slave => axi_write_target ) port map ( - aclk => clk, + aclk => aclk, awvalid => axi_if.write_address.valid, awready => axi_if.write_address.ready, awid => awid, @@ -103,7 +114,7 @@ begin bus_handle => SP_BUS_HANDLE ) port map ( - clk => clk, + clk => fmc_clk, a => a, ad => ad, ne => ne, @@ -113,26 +124,46 @@ begin nwait => nwait ); - ad <= data_out_tris when data_out_tris_en = '1' else (others => 'Z'); + ad <= (others => 'Z') when data_out_tris_hiz(0) = '1' else data_out_tris; + + -- Contention tripwire: the model owns the bus during the address phase + -- (NADV low) and write data beats (NWE low); the DUT driving then means + -- the two sides disagree about the transaction phase. Every test fails + -- fast on this instead of silently resolving the fight. + contention_check: process(fmc_clk) + begin + if rising_edge(fmc_clk) then + assert not (data_out_tris_hiz(0) = '0' and (nl = '0' or nwe = '0')) + report "BUS CONTENTION: DUT driving during SP address/write phase" + severity failure; + end if; + end process; dut: entity work.stm32h7_fmc_target + generic map ( + -- short enough to keep timeout tests quick, long enough that the + -- slow-responder (non-timeout) tests stay under it + timeout_cycles => 512 + ) port map ( -- Interface to the STM32H7's FMC periph - --! Write full flag, sync to write clock domain chip_reset => reset, - fmc_clk => clk, + fmc_clk => fmc_clk, + fmc_capture_clk => fmc_clk, a => a(24 downto 16), addr_data_in => ad, data_out => data_out_tris, - data_out_en => data_out_tris_en, + data_out_hiz => data_out_tris_hiz, ne => ne, - -- todo missing byte enables? noe => noe, nwe => nwe, nl => nl, nwait => nwait, + + timeout_count => timeout_count, + contention_count => contention_count, -- FPGA interface - aclk => clk, + aclk => aclk, aresetn => not reset, axi_if => axi_if diff --git a/hdl/ip/vhd/fmc_if/stm32h7_fmc_target.vhd b/hdl/ip/vhd/fmc_if/stm32h7_fmc_target.vhd index 90a42be0..0fc255f9 100644 --- a/hdl/ip/vhd/fmc_if/stm32h7_fmc_target.vhd +++ b/hdl/ip/vhd/fmc_if/stm32h7_fmc_target.vhd @@ -2,10 +2,28 @@ -- License, v. 2.0. If a copy of the MPL was not distributed with this -- file, You can obtain one at https://mozilla.org/MPL/2.0/. ---! This block provides an FMC target interface from the STM32H7's ---! local bus, crosses clock domains into the FPGA's core logic ---! clock domain, and issues AXI transactions. ---! figures 115 and 116 +-- This block provides an FMC target interface from the STM32H7's +-- local bus (synchronous multiplexed PSRAM mode, RM0433 figures 115/116), +-- crosses clock domains into the FPGA's core logic clock domain, and +-- issues AXI transactions. +-- +-- Pacing contract with the SP, proven on hardware and also encoded in the +-- simulation model: the wait line is held asserted +-- by default and released under FSM control; after the SP samples the wait +-- line released at rising edge N, its current data beat is valid on the +-- bus at edge N+1, and each further release-sampled edge advances one +-- beat. Reads therefore present data one full cycle before the release is +-- sampled, and the SP samples read data on the same edge it samples the +-- released wait. +-- +-- All FMC inputs are captured in dedicated per-pin registers clocked by +-- fmc_capture_clk before the FSM sees them, so the FSM runs one cycle +-- behind the bus. The interface is entirely NWAIT-paced, so that delay is +-- absorbed by the pacing (one extra stall cycle per transaction). On +-- boards with the FMC MMCM the capture clock is a later-phased sibling of +-- fmc_clk, which is what gives the input pins setup margin at 10 ns; +-- boards without an MMCM tie both clock ports to the same clock and get +-- identical cycle behavior. -- ES0491 FMC Errata: -- Dummy read cycles inserted when reading synchronous memories @@ -15,6 +33,8 @@ -- The extra data values read are not used by the FMC and there is no functional failure. -- Workaround -- None +-- (The dummy cycles land while this FSM is back in idle with the wait line +-- asserted and NADV high, so they cannot start a new transaction.) library ieee; use ieee.std_logic_1164.all; @@ -24,27 +44,51 @@ use work.stm32h7_fmc_target_pkg.all; use work.axil26x32_pkg.all; entity stm32h7_fmc_target is + generic ( + -- Bound on how long the SP's bus may be stalled by the wait line, in + -- fmc_clk cycles. An AHB stall is unrecoverable on the SP side, so a + -- wedged AXI responder is converted into poisoned read data (0xFFFF + -- beats) or a swallowed write, plus a bump of timeout_count, instead + -- of a hung SP. + timeout_cycles : positive := 4096; + -- Re-inserts the dead cycle between read beats (the pre-streaming + -- cadence), restoring a 2-cycle data-out window for a board that + -- cannot close single-cycle output timing. The data-out multicycle + -- constraints must match this setting. + extra_beat_setup : boolean := false + ); port ( -- Interface to the STM32H7's FMC periph chip_reset : in std_logic; - --! fmc_clk from STM32's clock generator + -- fmc_clk from STM32's clock generator (deskewed/phase-shifted on + -- boards with the FMC MMCM); clocks the FSM and all outputs fmc_clk : in std_logic; - --! non-multiplexed upper address bits from STM32 + -- clock for the input capture registers only. Tie to fmc_clk when + -- there is no MMCM; with one, the later-phased sibling output. + fmc_capture_clk : in std_logic; + -- non-multiplexed upper address bits from STM32 a : in std_logic_vector(24 downto 16); - --! multiplexed lower address bits/databits to/from STM32 + -- multiplexed lower address bits/databits to/from STM32 addr_data_in : in std_logic_vector(15 downto 0); data_out : out std_logic_vector(15 downto 0); - data_out_en : out std_logic; - --! active-low chip selects + -- Tristate control in OBUFT T polarity: '1' releases the pin, '0' + -- drives it. One bit per pin, all driven identically, registered with + -- no logic between flop and T input -- an active-high enable infers + -- an inverter LUT there, which adds ~1.5 ns and blocks IOB packing. + data_out_hiz : out std_logic_vector(15 downto 0); + -- active-low chip selects ne : in std_logic_vector(3 downto 0); - --! active-low output enable + -- active-low output enable noe : in std_logic; - --! active-low write enable + -- active-low write enable nwe : in std_logic; - --! active-low address latch for address phase + -- active-low address latch for address phase nl : in std_logic; - --! active-low pipelined wait to STM32, asserted 1 cycle before stall + -- active-low wait to STM32 nwait : out std_logic; + -- Saturating diagnostic counters, sticky until chip_reset. + timeout_count : out std_logic_vector(7 downto 0); + contention_count : out std_logic_vector(7 downto 0); -- FPGA interface aclk : in std_logic; aresetn : in std_logic; @@ -55,22 +99,37 @@ end entity; architecture rtl of stm32h7_fmc_target is + -- Driven-'1' bits pass, everything else ('0', and any undriven + -- 'Z'/'X'/'U' from a released bus) becomes '0'. Lets the final write + -- beat be captured unconditionally: qualifying it on chip select would + -- race NE's end-of-burst deassert, which is rising-edge timed and lands + -- within nanoseconds of the capture instant, differently per board. + function drive_or_zero ( + v : std_logic_vector + ) return std_logic_vector is + variable r : std_logic_vector(v'range); + begin + for i in v'range loop + r(i) := '1' when v(i) = '1' else '0'; + end loop; + return r; + end; + attribute mark_debug : string; type fmc_state_type is ( idle, - addr_delay, - addr_delay1, - read_setup, - read_word0_setup_delay, - read_word0, - read_word1_setup_delay, - read_word1, - write_setup, - write_wait_delay, - write_word0, - write_word1, - timeout_cleanup + dispatch, + rd_wait_data, + rd_arm, + rd_beat0, + rd_rearm, + rd_beat1, + wr_arm, + wr_arm2, + wr_beat0, + wr_beat1, + timeout_drain ); type axi_state_type is ( @@ -86,7 +145,6 @@ architecture rtl of stm32h7_fmc_target is attribute mark_debug of axi_state : signal is "TRUE"; signal txn : txn_type; attribute mark_debug of txn : signal is "TRUE"; - signal axi_fifo_rd_path_rdata : std_logic_vector(31 downto 0); signal axi_fifo_rd_path_rd_ack : std_logic; @@ -105,7 +163,50 @@ architecture rtl of stm32h7_fmc_target is attribute mark_debug of axi_addr : signal is "TRUE"; signal axi_fifo_wr_path_wdata : std_logic_vector(31 downto 0); signal axi_fifo_wr_path_write : std_logic; - signal txn_stored : boolean; + signal axi_fifo_wr_path_wfull : std_logic; + + -- One physical flop per pin, so each tristate T can pack into its + -- IOB; synthesis must not merge the identical registers. + signal data_out_hiz_int : std_logic_vector(15 downto 0); + -- IOB rather than keep: both prevent register merging, but keep maps + -- to a dont_touch that also blocks the pad packing it was meant to + -- enable. + attribute IOB : string; + attribute IOB of data_out_hiz_int : signal is "TRUE"; + -- fmc_clk cycles spent in a state that stalls the SP + signal timeout_cntr : natural range 0 to timeout_cycles; + signal timeouts : unsigned(7 downto 0); + attribute mark_debug of timeouts : signal is "TRUE"; + -- Both sides driving the muxed bus at once means the two ends disagree + -- about where in a transaction we are; the count is a tripwire for the + -- testbench and for ILA debug on hardware. + signal contentions : unsigned(7 downto 0); + attribute mark_debug of contentions : signal is "TRUE"; + -- Reads abandoned by timeout still complete on the AXI side eventually; + -- this many arriving read words belong to nobody and must be discarded + -- before data is served to a live read. + signal drop_pending : unsigned(4 downto 0); + + -- Per-pin input capture registers, clocked by fmc_capture_clk. IOB so + -- they land in the pad's ILOGIC flop: the input timing budget is pin -> + -- flop with nothing in between. + -- Deliberately unreset (initial values cover the pre-clock sim window): + -- they track the live bus whenever the clock runs, so by the time the + -- FSM leaves reset they already hold real pin state -- and a reset pin + -- on them would create cross-phase reset recovery paths with only the + -- inter-phase spacing to deassert in. + signal ne_q : std_logic_vector(3 downto 0) := (others => '1'); + signal noe_q : std_logic := '1'; + signal nwe_q : std_logic := '1'; + signal nl_q : std_logic := '1'; + signal a_q : std_logic_vector(24 downto 16) := (others => '0'); + signal ad_q : std_logic_vector(15 downto 0) := (others => '0'); + attribute IOB of ne_q : signal is "TRUE"; + attribute IOB of noe_q : signal is "TRUE"; + attribute IOB of nwe_q : signal is "TRUE"; + attribute IOB of nl_q : signal is "TRUE"; + attribute IOB of a_q : signal is "TRUE"; + attribute IOB of ad_q : signal is "TRUE"; alias awready is axi_if.write_address.ready; alias wready is axi_if.write_data.ready; @@ -123,8 +224,6 @@ architecture rtl of stm32h7_fmc_target is signal araddr : std_logic_vector(25 downto 0); signal rready : std_logic; - signal int_toggle : std_logic; - begin axi_if.write_address.valid <= awvalid; @@ -137,162 +236,281 @@ begin axi_if.read_address.addr <= araddr; axi_if.read_data.ready <= rready; + data_out_hiz <= data_out_hiz_int; + timeout_count <= std_logic_vector(timeouts); + contention_count <= std_logic_vector(contentions); + + -- Input capture stage: the only logic on fmc_capture_clk. + input_capture: process(fmc_capture_clk) + begin + if rising_edge(fmc_capture_clk) then + ne_q <= ne; + noe_q <= noe; + nwe_q <= nwe; + nl_q <= nl; + a_q <= a; + ad_q <= addr_data_in; + end if; + end process; + -- State machine dealing with fmc interface fmc_if_sm: process(fmc_clk, chip_reset) variable chip_selected : boolean; + -- The tristate control is re-derived from the live control pins + -- every cycle it could be driving, so a bus desync (SP deasserting + -- OE/CS under us) releases the bus within one cycle instead of + -- holding a fight until the FSM notices. hiz_v is in T polarity: + -- '1' = release. + variable hiz_v : std_logic; begin if chip_reset then - data_out <= (others => '0'); - data_out_en <= '0'; -- release bus - nwait <= '0'; - txn <= ('0', (others => '0')); + fmc_state <= idle; + data_out <= (others => '0'); + data_out_hiz_int <= (others => '1'); -- release bus + nwait <= '0'; + txn <= ('0', (others => '0')); axi_fifo_wr_path_wdata <= (others => '0'); axi_fifo_rd_path_rd_ack <= '0'; axi_fifo_txn_path_write <= '0'; axi_fifo_wr_path_write <= '0'; - txn_stored <= false; - int_toggle <= '0'; + timeout_cntr <= 0; + timeouts <= (others => '0'); + contentions <= (others => '0'); + drop_pending <= (others => '0'); elsif rising_edge(fmc_clk) then -- some variable naming for more legibility - chip_selected := ne(0) = '0'; + chip_selected := ne_q(0) = '0'; + hiz_v := noe_q or ne_q(0); -- single-cycle flags, unconditionally cleared axi_fifo_rd_path_rd_ack <= '0'; axi_fifo_txn_path_write <= '0'; - axi_fifo_wr_path_write <= '0'; + axi_fifo_wr_path_write <= '0'; + + -- The timeout counter runs in every situation that can stall the + -- SP indefinitely. That includes sitting in idle with chip + -- select asserted: a miscaptured NADV means we never saw the + -- transaction start, the SP is stalled on its bus waiting for a + -- wait release that will never come, and nothing but this + -- counter can free it. (Normal idle-with-CS dwell -- the address + -- cycle itself, ES0491 dummy reads -- lasts a handful of cycles, + -- nowhere near the timeout.) + if fmc_state = dispatch or fmc_state = rd_wait_data + or (fmc_state = idle and chip_selected) then + if timeout_cntr /= timeout_cycles then + timeout_cntr <= timeout_cntr + 1; + end if; + else + timeout_cntr <= 0; + end if; + + -- Contention tripwire: the SP drives the muxed bus during the + -- address phase (NADV low) and during write data beats (NWE low); + -- our enable being up in either is a protocol desync. + if data_out_hiz_int(0) = '0' and (nl_q = '0' or nwe_q = '0') then + if contentions /= x"FF" then + contentions <= contentions + 1; + end if; + end if; + case fmc_state is when idle => - nwait <= '0'; - data_out_en <= '0'; -- release bus - -- Look for a starting transition - -- ( chip sel and address latch) - if chip_selected and nl = '0' then + nwait <= '0'; + data_out_hiz_int <= (others => '1'); -- release bus + if drop_pending /= 0 and axi_fifo_rd_path_rempty = '0' + and axi_fifo_rd_path_rd_ack = '0' then + axi_fifo_rd_path_rd_ack <= '1'; + drop_pending <= drop_pending - 1; + elsif timeout_cntr = timeout_cycles then + -- chip select has been pinned with no transaction + -- start observed: we missed one. Free the SP's bus + -- and swallow whatever beats it clocks out. + if timeouts /= x"FF" then + timeouts <= timeouts + 1; + end if; + data_out <= x"FFFF"; + nwait <= '1'; + fmc_state <= timeout_drain; + elsif chip_selected and nl_q = '0' then -- Bus outputs right-shifted so we shift left here to -- recover byte addrs - txn.addr <= unsigned(a & addr_data_in & "0"); - txn.read_not_write <= nwe; - - fmc_state <= addr_delay; - end if; - when addr_delay => - -- We get here after latching the address - -- We'll delay for an additional cycle - -- so that the next cycle will be checking - fmc_state <= addr_delay1; - when addr_delay1 => - -- We need to immediately stall the bus at this point if we have a full txn fifo - -- other stall conditions will be checked in the read/write setup phase - -- since the conditions differ - if axi_fifo_txn_path_wfull then - nwait <= '0'; - end if; - if txn.read_not_write = '1' then - fmc_state <= read_setup; - -- For reads, we unconditionally wait here since we have to - -- do an axi transaction to even fetch the first data to return - -- which takes more than 1 cycle :) - nwait <= '0'; - else - fmc_state <= write_setup; + txn.addr <= unsigned(a_q & ad_q & "0"); + txn.read_not_write <= nwe_q; + fmc_state <= dispatch; end if; - when read_setup => - -- TODO: need a wait timeout mech here, we're potentially stalling - -- the SP's bus here! - - -- We need to issue this transaction 1x to the txn fifo + when dispatch => + -- Queue the transaction. Stalling here (the wait line is + -- still asserted from idle) is the back-pressure path + -- when the AXI side has fallen behind. if not chip_selected then fmc_state <= idle; - else - -- We're going to be doing a read here we must be waited already - -- We need to immediately stall the bus at this point if we have a full txn fifo - -- not that transactions are processed in order so any writes pending - -- will necessarily happen first. This is important since the writes could - -- have side-effects that affect the reads - if not txn_stored and axi_fifo_txn_path_wfull = '0' then - -- Store the transaction, set the stored flag so we don't - -- do it again while we wait - axi_fifo_txn_path_write <= '1'; - txn_stored <= true; + elsif timeout_cntr = timeout_cycles then + -- Nothing queued yet, so nothing is in flight: free + -- the SP and swallow/poison its beats. + if timeouts /= x"FF" then + timeouts <= timeouts + 1; end if; - -- Wait is held here until we've done the AXI transaction - -- to fetch the data and have the data back in the fifo - if not axi_fifo_rd_path_rempty then - -- Register the data - -- apply the data to the bus - -- take away the wait - data_out <= axi_fifo_rd_path_rdata(15 downto 0); - -- noe should always be active here, but this provides a safety net - -- in case there is a bus-desync of some kind, we don't want to cross-drive with - -- the sp - data_out_en <= '1' and (not noe); - fmc_state <= read_word0_setup_delay; + data_out <= x"FFFF"; + nwait <= '1'; + fmc_state <= timeout_drain; + elsif axi_fifo_txn_path_wfull = '0' and + (txn.read_not_write = '1' or axi_fifo_wr_path_wfull = '0') then + axi_fifo_txn_path_write <= '1'; + if txn.read_not_write then + -- Reads keep the SP waited: the data has to + -- round-trip the AXI side before anything can be + -- returned. + fmc_state <= rd_wait_data; + else + nwait <= '1'; + fmc_state <= wr_arm; end if; end if; - when read_word0_setup_delay => - nwait <= '1'; - fmc_state <= read_word0; - when read_word0 => - nwait <= '0'; - txn_stored <= false; - data_out <= axi_fifo_rd_path_rdata(31 downto 16); - if not chip_selected then + when rd_wait_data => + if axi_fifo_rd_path_rd_ack = '1' then + -- rempty/rdata are one cycle behind a pop; deciding + -- anything off them now would serve the word that was + -- just discarded + null; + elsif drop_pending /= 0 then + -- Stale words from earlier timed-out reads arrive + -- ahead of ours; discard them first. + if axi_fifo_rd_path_rempty = '0' then + axi_fifo_rd_path_rd_ack <= '1'; + drop_pending <= drop_pending - 1; + end if; + elsif not chip_selected then + -- The read was queued on entry to this state, so an + -- SP abort here still leaves it in flight and its + -- data must be discarded when it lands. + if drop_pending /= "11111" then + drop_pending <= drop_pending + 1; + end if; fmc_state <= idle; - data_out_en <= '0'; -- release bus - axi_fifo_rd_path_rd_ack <= '1'; -- clear the read word - else -- not done, move to next word - fmc_state <= read_word1_setup_delay; + elsif timeout_cntr = timeout_cycles then + -- The read is in flight; whenever its data lands it + -- belongs to nobody. + if drop_pending /= "11111" then + drop_pending <= drop_pending + 1; + end if; + if timeouts /= x"FF" then + timeouts <= timeouts + 1; + end if; + data_out <= x"FFFF"; + data_out_hiz_int <= (others => hiz_v); + nwait <= '1'; + fmc_state <= timeout_drain; + elsif axi_fifo_rd_path_rempty = '0' then + -- Present the first beat a full cycle before the wait + -- release can be sampled. + data_out <= axi_fifo_rd_path_rdata(15 downto 0); + data_out_hiz_int <= (others => hiz_v); + fmc_state <= rd_arm; end if; - when read_word1_setup_delay => - nwait <= '1'; - fmc_state <= read_word1; - when read_word1 => - -- TODO: if we want to allow shorter transactions - -- we'd need to do the right thing here, which would - -- be termingating early, and doing the read anyway. - -- we can only do 32bit wide reads on the AXI side - -- so care should be excersized by the user if there - -- are read side-effects on the addresses next to this - -- read since we'd be doing a 32bit axi read in this case - -- and dropping the latter part on the floor since it wasn't - -- requested. - - -- normal case: pop the rdata fifo since we're done with it + when rd_arm => + if not chip_selected then + axi_fifo_rd_path_rd_ack <= '1'; + data_out_hiz_int <= (others => '1'); + fmc_state <= idle; + else + nwait <= '1'; + data_out_hiz_int <= (others => hiz_v); + fmc_state <= rd_beat0; + end if; + when rd_beat0 => + -- SP samples word0 at this edge. + if not chip_selected then + -- Shortened read: the full 32-bit AXI read already + -- happened, so any read side effects have occurred; + -- all we can do is clean up. + axi_fifo_rd_path_rd_ack <= '1'; + data_out_hiz_int <= (others => '1'); + nwait <= '0'; + fmc_state <= idle; + else + data_out <= axi_fifo_rd_path_rdata(31 downto 16); + data_out_hiz_int <= (others => hiz_v); + if extra_beat_setup then + nwait <= '0'; + fmc_state <= rd_rearm; + else + -- wait stays released; word1 is sampled on the + -- very next edge (this word0->word1 transition is + -- the one single-cycle data-out path). + fmc_state <= rd_beat1; + end if; + end if; + when rd_rearm => + -- extra_beat_setup only: one dead cycle re-opens the + -- 2-cycle data-out window of the pre-streaming cadence. + nwait <= '1'; + data_out_hiz_int <= (others => hiz_v); + fmc_state <= rd_beat1; + when rd_beat1 => + -- SP samples word1 at this edge; done with the word. axi_fifo_rd_path_rd_ack <= '1'; - fmc_state <= idle; nwait <= '0'; - data_out_en <= '0'; -- release bus - when write_setup => + data_out_hiz_int <= (others => '1'); -- release bus + fmc_state <= idle; + when wr_arm => + -- The SP samples the released wait at this edge and + -- launches beat0 on the following falling edge. if not chip_selected then - fmc_state <= idle; + -- The transaction is already queued; complete it with + -- zeros rather than leaving the AXI side wedged + -- waiting for write data that will never come. + axi_fifo_wr_path_wdata <= (others => '0'); + axi_fifo_wr_path_write <= '1'; + nwait <= '0'; + fmc_state <= idle; else - if not txn_stored and axi_fifo_txn_path_wfull = '0' then - -- Store the transaction, set the stored flag so we don't - -- do it again while we wait - axi_fifo_txn_path_write <= '1'; - txn_stored <= true; - nwait <= '1'; - -- no waits needed until the fifo fills up - fmc_state <= write_wait_delay; - end if; + fmc_state <= wr_arm2; + end if; + when wr_arm2 => + -- beat0 is on the bus this cycle; it lands in ad_q for + -- wr_beat0 to consume on the next edge + if not chip_selected then + axi_fifo_wr_path_wdata <= (others => '0'); + axi_fifo_wr_path_write <= '1'; + nwait <= '0'; + fmc_state <= idle; + else + fmc_state <= wr_beat0; + end if; + when wr_beat0 => + if not chip_selected then + axi_fifo_wr_path_wdata <= (others => '0'); + axi_fifo_wr_path_write <= '1'; + nwait <= '0'; + fmc_state <= idle; + else + axi_fifo_wr_path_wdata(15 downto 0) <= ad_q; + fmc_state <= wr_beat1; end if; - when write_wait_delay => - fmc_state <= write_word0; - when write_word0 => - axi_fifo_wr_path_wdata(15 downto 0) <= addr_data_in; - txn_stored <= false; - fmc_state <= write_word1; - when write_word1 => - axi_fifo_wr_path_wdata(31 downto 16) <= addr_data_in; + when wr_beat1 => + -- Unconditional: reaching this state means the SP + -- committed the burst, and its data-hold contract covers + -- this capture even as NE deasserts. An aborted burst + -- leaves the bus released, which drive_or_zero turns + -- into zero filler rather than 'Z'/'X' in the AXI data. + axi_fifo_wr_path_wdata(31 downto 16) <= drive_or_zero(ad_q); axi_fifo_wr_path_write <= '1'; - nwait <= '0'; + nwait <= '0'; fmc_state <= idle; - -- We need to immediately stall the bus at this point if we have a full txn fifo - -- other stall conditions will be checked in the write setup phase - -- since the conditions differ - if axi_fifo_txn_path_wfull then + when timeout_drain => + -- Wait is released and poison is on the bus for read + -- phases; let the SP clock through its remaining beats + -- without capturing anything, then return to idle once + -- it deselects. Drive purely off the live OE/CS pins: + -- during write beats NOE is high so this never drives + -- against the SP, and the recorded transaction type may + -- be stale when the drain was entered from idle. + data_out_hiz_int <= (others => hiz_v); + if not chip_selected then + nwait <= '0'; + data_out_hiz_int <= (others => '1'); + fmc_state <= idle; end if; - when timeout_cleanup => - null; end case; end if; end process; @@ -362,7 +580,7 @@ begin reset => chip_reset, write_en => axi_fifo_wr_path_write, wdata => axi_fifo_wr_path_wdata, - wfull => open, + wfull => axi_fifo_wr_path_wfull, wusedwds => open, -- Read interface rclk => aclk, @@ -414,15 +632,16 @@ begin arvalid <= '0'; end if; when axi_write_init => - awvalid <= '1'; + -- AW and W are raised together, and only once the write + -- data has crossed the FIFO: raising AW alone lets a + -- downstream decoder start a write it then has to stall + -- on, and this block's own W data pops on the handshake. if not axi_fifo_wr_path_rempty then + awvalid <= '1'; wvalid <= '1'; + bready <= '1'; axi_state <= axi_write_wait; end if; - if awready and awvalid then - awvalid <= '0'; - end if; - bready <= '1'; when axi_write_wait => if awready and awvalid then awvalid <= '0'; @@ -432,7 +651,7 @@ begin end if; if bvalid then axi_state <= idle; - bready <= '0'; + bready <= '0'; end if; end case; end if; diff --git a/hdl/projects/cosmo_seq/black_box_entities/fmc_pll.vhd b/hdl/projects/cosmo_seq/black_box_entities/fmc_pll.vhd new file mode 100644 index 00000000..032aebe3 --- /dev/null +++ b/hdl/projects/cosmo_seq/black_box_entities/fmc_pll.vhd @@ -0,0 +1,23 @@ +-- This Source Code Form is subject to the terms of the Mozilla Public +-- License, v. 2.0. If a copy of the MPL was not distributed with this +-- file, You can obtain one at https://mozilla.org/MPL/2.0/. + +-- A no synth, no sim, black entity to make analysis happy. +-- Generated by xilinx_ip_gen/fmc_pll_ip.tcl (clk_wiz in phase-alignment +-- mode on the SP's FMC clock). + +library ieee; +use ieee.std_logic_1164.all; +use ieee.numeric_std.all; +use ieee.numeric_std_unsigned.all; + +entity fmc_pll is + port ( + clk_fmc_in : in std_logic; + clk_fmc : out std_logic; + clk_fmc_capture : out std_logic; + reset : in std_logic; + locked : out std_logic +); + +end entity; diff --git a/hdl/projects/cosmo_seq/board_support/board_support_top.vhd b/hdl/projects/cosmo_seq/board_support/board_support_top.vhd index 0e13a17f..c865b0fa 100644 --- a/hdl/projects/cosmo_seq/board_support/board_support_top.vhd +++ b/hdl/projects/cosmo_seq/board_support/board_support_top.vhd @@ -21,6 +21,11 @@ entity board_support is reset_125m : out std_logic; clk_200m : out std_logic; reset_200m : out std_logic; + -- deskewed/phase-shifted FMC clock from the FMC MMCM; everything in + -- the FMC domain must use this, not the raw pin + fmc_clk_buf : out std_logic; + -- later-phased sibling for the FMC input capture registers only + fmc_capture_clk_buf : out std_logic; reset_fmc : out std_logic; -- misc board signals fpga1_status_led : out std_logic; @@ -38,6 +43,10 @@ architecture rtl of board_support is signal sp_system_reset_syncd : std_logic; signal pll_locked_async : std_logic; signal led_counter : unsigned(27 downto 0); + signal fmc_clk_g : std_logic; + signal fmc_capture_clk_g : std_logic; + signal fmc_mmcm_locked : std_logic; + signal fmc_mmcm_reset : std_logic; begin @@ -76,15 +85,46 @@ begin ); - -- Reset synchronizer into the clock domains + -- MMCM on the SP's (continuous) FMC clock: BUFG-in-feedback deskew plus + -- a small phase shift, which is what closes the single-cycle FMC pin + -- timing at 10 ns. See xilinx_ip_gen/fmc_pll_ip.tcl for the VCO and + -- phase reasoning. + fmc_pll_inst: entity work.fmc_pll + port map( + clk_fmc_in => sp_fmc_clk, + clk_fmc => fmc_clk_g, + clk_fmc_capture => fmc_capture_clk_g, + reset => fmc_mmcm_reset, + locked => fmc_mmcm_locked + ); + + -- Hold the MMCM in reset while the SP's clock is stopped (SP reset or + -- reconfiguration) and retry the lock if the input frequency changes. + fmc_clk_monitor_inst: entity work.fmc_clk_monitor + port map( + clk => clk_125m, + reset => reset_125m, + fmc_clk_raw => sp_fmc_clk, + mmcm_locked => fmc_mmcm_locked, + mmcm_reset => fmc_mmcm_reset + ); + + fmc_clk_buf <= fmc_clk_g; + fmc_capture_clk_buf <= fmc_capture_clk_g; + + -- Reset synchronizer into the clock domains. The FMC branch is clocked + -- by the MMCM output and additionally gated on MMCM lock: while + -- unlocked there are no FMC-domain clock edges and the async assert is + -- what keeps the FMC target's bus drive released. reset_sync_inst: entity work.reset_sync port map( pll_locked_async => pll_locked_async, + aux_locked_async => fmc_mmcm_locked, clk_125m => clk_125m, reset_125m => reset_125m, clk_200m => clk_200m, reset_200m => reset_200m, - sp_fmc_clk => sp_fmc_clk, + sp_fmc_clk => fmc_clk_g, reset_fmc_clk => reset_fmc ); diff --git a/hdl/projects/cosmo_seq/board_support/fmc_clk_monitor.vhd b/hdl/projects/cosmo_seq/board_support/fmc_clk_monitor.vhd new file mode 100644 index 00000000..5a99b062 --- /dev/null +++ b/hdl/projects/cosmo_seq/board_support/fmc_clk_monitor.vhd @@ -0,0 +1,124 @@ +-- This Source Code Form is subject to the terms of the Mozilla Public +-- License, v. 2.0. If a copy of the MPL was not distributed with this +-- file, You can obtain one at https://mozilla.org/MPL/2.0/. + +-- Supervises the MMCM that sits on the SP's continuous FMC clock. +-- +-- The SP's clock stops whenever the SP resets or reconfigures, and an +-- MMCME2 whose input clock went away is not guaranteed to relock on its +-- own when the clock returns -- AMD's guidance is to assert its reset +-- across a clock interruption. This block watches the raw clock pin as +-- ordinary data in the always-running 125 MHz domain and holds the MMCM +-- in reset while the clock is absent, releasing it (and letting it +-- relock) once the clock is back. While the MMCM is unlocked the FMC +-- domain has no clock and reset_fmc is held asserted (see reset_sync), +-- so the wait line sits asserted and the SP's first post-reset access +-- simply stalls until lock -- nothing needs to sequence against this. +-- +-- The transition count is aliased (a 100 MHz toggle sampled at 125 MHz) +-- so it is a presence detector, not a frequency measurement: an absent +-- clock counts zero transitions, any live SP rate counts hundreds per +-- window. Frequency changes (the CLKDIV flip) drop the MMCM's own LOCKED +-- while the clock keeps toggling; the relock timer covers that case by +-- pulsing reset if LOCKED stays low too long with a clock present. + +library ieee; +use ieee.std_logic_1164.all; +use ieee.numeric_std.all; + +entity fmc_clk_monitor is + generic ( + -- observation window, in clk cycles (256 @ 125 MHz ~= 2 us) + window_cycles : positive := 256; + -- fewer transitions than this in a window means "clock absent" + min_transitions : positive := 16; + -- windows of LOCKED low with a live clock before a relock retry + -- (128 windows ~= 260 us, comfortably past the ~100 us max lock time) + relock_windows : positive := 128; + -- windows of live clock required before releasing mmcm_reset + settle_windows : positive := 4 + ); + port ( + clk : in std_logic; + reset : in std_logic; + -- the FMC clock pin, sampled as data + fmc_clk_raw : in std_logic; + -- LOCKED from the MMCM, async + mmcm_locked : in std_logic; + mmcm_reset : out std_logic + ); +end entity; + +architecture rtl of fmc_clk_monitor is + + signal raw_meta : std_logic_vector(1 downto 0); + signal raw_prev : std_logic; + signal locked_meta : std_logic_vector(1 downto 0); + + signal window_cntr : natural range 0 to window_cycles - 1; + signal transition_cntr : natural range 0 to window_cycles; + signal clk_present : boolean; + + signal settle_cntr : natural range 0 to settle_windows; + signal unlock_cntr : natural range 0 to relock_windows; + +begin + + monitor: process(clk, reset) + begin + if reset then + raw_meta <= (others => '0'); + raw_prev <= '0'; + locked_meta <= (others => '0'); + window_cntr <= 0; + transition_cntr <= 0; + clk_present <= false; + settle_cntr <= 0; + unlock_cntr <= 0; + mmcm_reset <= '1'; + elsif rising_edge(clk) then + raw_meta <= raw_meta(0) & fmc_clk_raw; + raw_prev <= raw_meta(1); + locked_meta <= locked_meta(0) & mmcm_locked; + + if window_cntr = window_cycles - 1 then + window_cntr <= 0; + clk_present <= transition_cntr >= min_transitions; + transition_cntr <= 0; + + if transition_cntr < min_transitions then + -- clock gone: hold the MMCM in reset and start over + mmcm_reset <= '1'; + settle_cntr <= 0; + unlock_cntr <= 0; + elsif settle_cntr /= settle_windows then + -- clock is back; give it a few clean windows before + -- releasing the MMCM + settle_cntr <= settle_cntr + 1; + unlock_cntr <= 0; + elsif mmcm_reset = '1' then + mmcm_reset <= '0'; + elsif locked_meta(1) = '0' then + -- clock alive but no lock (e.g. the input frequency + -- changed): retry after a generous wait + if unlock_cntr = relock_windows then + mmcm_reset <= '1'; + settle_cntr <= 0; + unlock_cntr <= 0; + else + unlock_cntr <= unlock_cntr + 1; + end if; + else + unlock_cntr <= 0; + end if; + else + window_cntr <= window_cntr + 1; + if (raw_meta(1) xor raw_prev) = '1' + and transition_cntr /= window_cycles then + transition_cntr <= transition_cntr + 1; + end if; + end if; + end if; + end process; + +end architecture; diff --git a/hdl/projects/cosmo_seq/board_support/reset_sync.vhd b/hdl/projects/cosmo_seq/board_support/reset_sync.vhd index d1d244da..043f9c29 100644 --- a/hdl/projects/cosmo_seq/board_support/reset_sync.vhd +++ b/hdl/projects/cosmo_seq/board_support/reset_sync.vhd @@ -10,6 +10,13 @@ use ieee.numeric_std.all; entity reset_sync is port ( pll_locked_async : in std_logic; + -- Additional lock qualifier for the FMC domain (the FMC MMCM on + -- cosmo). Defaults to "locked" so boards without one (grapefruit) + -- are unaffected. Asserting reset on lock loss happens + -- asynchronously in the bridge, which matters: when this MMCM + -- unlocks the FMC domain has no clock edges, and the async assert + -- is what still clears the FMC target's bus drive. + aux_locked_async : in std_logic := '1'; clk_125m : in std_logic; reset_125m : out std_logic; @@ -50,7 +57,7 @@ begin ) port map( clk => sp_fmc_clk, - reset_async => pll_locked_async, + reset_async => pll_locked_async and aux_locked_async, reset_sync => reset_fmc_clk ); diff --git a/hdl/projects/cosmo_seq/cosmo_seq_top.vhd b/hdl/projects/cosmo_seq/cosmo_seq_top.vhd index 94b8fbb4..42240d3b 100644 --- a/hdl/projects/cosmo_seq/cosmo_seq_top.vhd +++ b/hdl/projects/cosmo_seq/cosmo_seq_top.vhd @@ -335,6 +335,10 @@ architecture rtl of cosmo_seq_top is signal v1p2_nic_enet_a0hp_pg : std_logic; signal v1p1_nic_enet_a0hp_pg : std_logic; alias fmc_clk : std_logic is fmc_sp_to_fpga1_clk; + -- deskewed/phase-shifted FMC clock from the MMCM in board_support; the + -- FMC domain runs on this, never on the raw pin + signal fmc_clk_buf : std_logic; + signal fmc_capture_clk_buf : std_logic; constant INFO_RESP_IDX : integer := 0; constant SPINOR_RESP_IDX: integer := 1; constant SEQ_RESP_IDX: integer := 2; @@ -364,7 +368,7 @@ architecture rtl of cosmo_seq_top is signal responders_8b : axil8x32_pkg.axil_array_t(config_array'range); signal responders_15b : axil15x32_pkg.axil_array_t(config_array'range); signal fmc_internal_data_out : std_logic_vector(15 downto 0); - signal fmc_data_out_enable: std_logic; + signal fmc_data_out_hiz: std_logic_vector(15 downto 0); signal spinor_io_o : std_logic_vector(3 downto 0); signal spinor_io_oe : std_logic_vector(3 downto 0); @@ -470,25 +474,32 @@ begin stm32h7_fmc_target_inst: entity work.stm32h7_fmc_target port map( chip_reset => reset_fmc, - fmc_clk => fmc_clk, + fmc_clk => fmc_clk_buf, + fmc_capture_clk => fmc_capture_clk_buf, a(24 downto 20) => "00000", a(19 downto 16) => fmc_sp_to_fpga1_a(19 downto 16), --a(23 downto 16) => fmc_sp_to_fpga1_a, addr_data_in => fmc_sp_to_fpga1_da, data_out => fmc_internal_data_out, - data_out_en => fmc_data_out_enable, + data_out_hiz => fmc_data_out_hiz, ne(3 downto 1) => "111", ne(0) => fmc_sp_to_fpga1_cs_l, noe => fmc_sp_to_fpga1_oe_l, nwe => fmc_sp_to_fpga1_we_l, nl => fmc_sp_to_fpga1_adv_l, nwait => fmc_sp_to_fpga1_wait_l, + timeout_count => open, + contention_count => open, aclk => clk_125m, aresetn => not reset_125m, axi_if => fmc_axi_if ); -- tristate control for the FMC data bus - fmc_sp_to_fpga1_da <= fmc_internal_data_out when fmc_data_out_enable = '1' else (others => 'Z'); + -- per-bit tristate, hiz already in OBUFT T polarity so each pin's T + -- flop packs into its IOB with no inverter in between + fmc_da_tris: for i in fmc_sp_to_fpga1_da'range generate + fmc_sp_to_fpga1_da(i) <= 'Z' when fmc_data_out_hiz(i) = '1' else fmc_internal_data_out(i); + end generate; -- Axi decode/interconnect axil_interconnect_inst: entity work.axil_interconnect @@ -510,6 +521,8 @@ begin port map( board_50mhz_clk => clk_50mhz_fpga1_1, sp_fmc_clk => fmc_clk, + fmc_clk_buf => fmc_clk_buf, + fmc_capture_clk_buf => fmc_capture_clk_buf, sp_system_reset_l => sp_to_fpga1_system_reset_l, clk_125m => clk_125m, reset_125m => reset_125m, diff --git a/hdl/projects/cosmo_seq/cosmo_timing.xdc b/hdl/projects/cosmo_seq/cosmo_timing.xdc index 8dae48f1..04dd36d1 100644 --- a/hdl/projects/cosmo_seq/cosmo_timing.xdc +++ b/hdl/projects/cosmo_seq/cosmo_timing.xdc @@ -1,20 +1,27 @@ create_clock -add -name sys_clk_pin -period 20.000 -waveform {0 10.000} [get_ports { clk_50mhz_fpga1_1 }]; -create_clock -add -name fmc_clk_pin -period 15.000 -waveform {0 7.500} [get_ports { fmc_sp_to_fpga1_clk }]; +create_clock -add -name fmc_clk_pin -period 10.000 -waveform {0 5.000} [get_ports { fmc_sp_to_fpga1_clk }]; # # FMC interface constraints # Create a virtual clock, to represent the source clock of the FMC interface -create_clock -name fmc_virt_clk -period 15.000; +create_clock -name fmc_virt_clk -period 10.000; -set_clock_groups -asynchronous -group {fmc_clk_pin fmc_virt_clk} -group {clk_125m_cosmo_pll} -group {clk_200m_cosmo_pll} +# The FMC MMCM's output clock is derived from fmc_clk_pin and must stay in +# the synchronous group with it and the virtual clock. +set_clock_groups -asynchronous -group [get_clocks -include_generated_clocks {fmc_clk_pin fmc_virt_clk}] -group {clk_125m_cosmo_pll} -group {clk_200m_cosmo_pll} # ####################### # FMC Interface # ####################### -# SP output a continuous clock here. -# The FMC interface is clocked at 66.67MHz, which is a 15ns period. +# SP outputs a continuous clock here. +# The FMC interface is constrained at 100MHz (10ns period), the CLKDIV=1 +# target rate; the same analysis is a strict superset of 50 and 66.67 MHz +# operation. The internal FMC domain clock comes from an MMCM in +# phase-alignment mode (see xilinx_ip_gen/fmc_pll_ip.tcl), so the flops +# see the pin clock plus the deliberate +45deg phase shift and STA +# accounts for it via the generated clock. # FPGA's input delays have to be low enough that they don't run into the uncertainty region due to any possible skew. # skew_bre is the shortest trace delay vs the clock, and skew_are is the longest trace delay vs the clock. # On cosmo, clock trace is 60.787 rev1, 53.026mm rev2 . @@ -57,17 +64,21 @@ set min_wait_delay 0.3635 # Source sync so external_clk_delay is 0. # Setup time is 1ns, and we include the 1/2 period due to SP shifting the data out on the falling edge. -set sp_output_half_period 7.5 +set sp_output_half_period 5.0 set sp_0_hold 0 set sp_clk_delay 0 -# We have our 1/2 period of 7.5 ns due to SP outputting on falling edges, plus the td in the datasheet +# We have our 1/2 period of 5 ns due to SP outputting on falling edges, plus the td in the datasheet set td_clkl_nehl 1 set nl_output_delay [expr {$sp_output_half_period + $td_clkl_nehl}] set max_nl [expr {$sp_clk_delay + $nl_output_delay + $max_data_delay - $min_clock_delay}] -# latest clock, earliest data. We assume a hold time of 0 for the SP. +# latest clock, earliest data. We assume a hold time of 0 for the SP, but the SP +# holds its outputs until the *next falling edge*, so the earliest change is a +# half period after the capture edge. Without that term Vivado assumes the data +# can change at the capture edge itself, which manufactures a phantom +# input-hold requirement of nearly a half period. # min external: fastest data, slowest clock -set min_nl [expr {$sp_clk_delay + $sp_0_hold + $min_data_delay - $max_clock_delay}] +set min_nl [expr {$sp_clk_delay + $sp_output_half_period + $sp_0_hold + $min_data_delay - $max_clock_delay}] # Apply to all of these pins with similar or better timing relationships. set_input_delay -clock fmc_virt_clk -max $max_nl [get_ports fmc_sp_to_fpga1_cs_l] @@ -85,8 +96,8 @@ set_input_delay -clock fmc_virt_clk -min $min_nl [get_ports fmc_sp_to_fpga1_bl_l set td_clkl_av 2.5 set a_output_delay [expr {$sp_output_half_period + $td_clkl_av}] set max_a [expr {$sp_clk_delay + $a_output_delay + $max_data_delay - $min_clock_delay}] -# Still 0 hold on these pins. -set min_a [expr {$sp_clk_delay + $sp_0_hold + $min_data_delay - $max_clock_delay}] +# Still 0 hold on these pins, held to the next falling edge as above. +set min_a [expr {$sp_clk_delay + $sp_output_half_period + $sp_0_hold + $min_data_delay - $max_clock_delay}] set_input_delay -clock fmc_virt_clk -max $max_a [get_ports fmc_sp_to_fpga1_a[*]] set_input_delay -clock fmc_virt_clk -min $min_a [get_ports fmc_sp_to_fpga1_a[*]] @@ -94,11 +105,23 @@ set_input_delay -clock fmc_virt_clk -min $min_a [get_ports fmc_sp_to_fpga1_a[*]] set td_clkl_adv 3 set ad_output_delay [expr {$sp_output_half_period + $td_clkl_adv}] set max_ad [expr {$sp_clk_delay + $ad_output_delay + $max_data_delay - $min_clock_delay}] -# Still 0 hold on these pins. -set min_ad [expr {$sp_clk_delay + $sp_0_hold + $min_data_delay - $max_clock_delay}] +# Still 0 hold on these pins, held to the next falling edge as above. +set min_ad [expr {$sp_clk_delay + $sp_output_half_period + $sp_0_hold + $min_data_delay - $max_clock_delay}] set_input_delay -clock fmc_virt_clk -max $max_ad [get_ports fmc_sp_to_fpga1_da[*]] set_input_delay -clock fmc_virt_clk -min $min_ad [get_ports fmc_sp_to_fpga1_da[*]] +# The MMCM's +45deg phase shift puts the internal capture edge at 1.25 ns, +# and STA's default edge relationship then times input paths from the +# virtual-clock launch at 0 to that 1.25 ns edge -- a nonsense 1.25 ns +# requirement. The intended capture edge is the *next* shifted edge at +# 11.25 ns, which is exactly what setup-2 selects. The default hold +# relationship (one cycle before the setup edge, back at 1.25 ns) is the +# right check and passes with the half-period of real SP hold, so no -hold +# adjustment. Output paths need nothing: launch at 1.25 ns against the SP's +# capture at 10 ns is already the correct single-cycle relationship. +set_multicycle_path 2 -setup -from [get_clocks fmc_virt_clk] -to [get_clocks *fmc_pll*] +set_multicycle_path 2 -setup -from [get_clocks fmc_clk_pin] -to [get_clocks *fmc_pll*] + #### END Of inputs # ################# @@ -129,12 +152,17 @@ set_output_delay -clock fmc_virt_clk -max $max_da [get_ports fmc_sp_to_fpga1_da[ set_output_delay -clock fmc_virt_clk -min $min_da [get_ports fmc_sp_to_fpga1_da[*]] -# assuming wait_l works, we have multiple cycles to get the data out. This is likely needed due to the tri-state stuff here -# and it has trouble meeting timing without the additional cycles. The fpga design compensates for this with wait_l. -set_multicycle_path -from [get_pins {stm32h7_fmc_target_inst/data_out*/C}] -to [get_ports {fmc_sp_to_fpga1_da[*]}] -setup 2 -set_multicycle_path -from [get_pins {stm32h7_fmc_target_inst/data_out*/C}] -to [get_ports {fmc_sp_to_fpga1_da[*]}] -hold 1 -set_multicycle_path -from [get_pins {stm32h7_fmc_target_inst/data_out_en_reg*/C}] -to [get_ports {fmc_sp_to_fpga1_da[*]}] -setup 2 -set_multicycle_path -from [get_pins {stm32h7_fmc_target_inst/data_out_en_reg*/C}] -to [get_ports {fmc_sp_to_fpga1_da[*]}] -hold 1 +# The streaming FSM presents read beats on consecutive cycles, so the +# word0->word1 transition is a true single-cycle path and no multicycle +# exception applies to the data pins. Single-cycle closes because the output +# and tristate flops pack into the IOBs: data_out_reg and data_out_hiz_int_reg +# are one flop per pin by construction, and nwait, a single-cycle path the SP +# samples every rising edge, gets the same treatment. If a board cannot close +# this way, set the extra_beat_setup generic and add setup-2/hold-1 exceptions +# on the data/tristate paths to match. +set_property IOB TRUE [get_cells -hier -filter {NAME =~ *stm32h7_fmc_target*/data_out_reg[*]}] +set_property IOB TRUE [get_cells -hier -filter {NAME =~ *stm32h7_fmc_target*/data_out_hiz_int_reg[*]}] +set_property IOB TRUE [get_cells -hier -filter {NAME =~ *stm32h7_fmc_target*/nwait_reg}] # End FMC diff --git a/hdl/projects/cosmo_seq/xilinx_ip_gen/fmc_pll_ip.tcl b/hdl/projects/cosmo_seq/xilinx_ip_gen/fmc_pll_ip.tcl new file mode 100644 index 00000000..c1a81044 --- /dev/null +++ b/hdl/projects/cosmo_seq/xilinx_ip_gen/fmc_pll_ip.tcl @@ -0,0 +1,58 @@ +# MMCM on the SP's FMC clock, in phase-alignment (deskew) mode: the feedback +# path through a BUFG zeroes out the clock insertion delay so the FMC-domain +# flops launch and capture aligned to the clock at the pin (plus the phase +# shift below). This is what closes the single-cycle NWAIT and data-out paths +# at 10 ns; a plain IBUF+BUFG eats 3.5-6.5 ns of insertion delay across PVT +# and cannot. +# +# VCO choice (M=12, D=1, O=12): VCO = 12 x f_in, so 600 MHz at a 50 MHz input +# and 1200 MHz at 100 MHz -- exactly the -1 speed grade MMCM limits per +# DS189, and the only M that spans both. One bitstream therefore locks at +# either SP CLKDIV setting, which is what lets this bitstream ship before the +# hubris CLKDIV flip. If a Vivado DRC ever rejects the edge-of-range VCO, +# drop the dual-frequency property and coordinate the rollout as a single +# archive bump instead (the hubris archive carries the bitstream, so the pair +# still moves atomically). +# +# Two output phases: +# - clk_fmc (+45 deg = +1.25 ns at 10 ns) clocks the FSM and the output +# flops. Its phase is hold-limited on the NWAIT/data pins (going earlier +# breaks output hold), so it cannot be pushed later for input margin. +# - clk_fmc_capture (+135 deg = +3.75 ns) clocks only the dedicated input +# capture registers. The late phase is what gives the input paths setup +# margin against STA's uncredited clock-network corner spread; input hold +# still has a half period of real SP hold behind it. The FSM consumes the +# captured values a cycle later, which the NWAIT pacing absorbs. +# Sweep either phase in the lab under the fmc_sweep soak to confirm real +# margin exceeds STA margin; the grid is 45/CLKOUT_DIVIDE = 3.75 deg per +# MMCM tap, so +/-5 taps = +/-18.75 deg = ~0.52 ns. Phases are fractions of +# the period, so every hold-side margin only grows at 50 MHz operation. +# +# OVERRIDE_MMCM is required: without it clk_wiz silently recalculates M +# (it picked M=11, whose 550 MHz VCO at a 50 MHz input cannot lock, killing +# the dual-frequency property). +create_ip -name clk_wiz -vendor xilinx.com -library ip -version 6.0 -module_name fmc_pll +set_property -dict [list \ + CONFIG.Component_Name {fmc_pll} \ + CONFIG.PRIM_IN_FREQ {100.000} \ + CONFIG.PRIMARY_PORT {clk_fmc_in} \ + CONFIG.CLK_OUT1_PORT {clk_fmc} \ + CONFIG.CLKOUT1_REQUESTED_OUT_FREQ {100.000} \ + CONFIG.CLKOUT1_REQUESTED_PHASE {45.000} \ + CONFIG.CLKOUT2_USED {true} \ + CONFIG.CLK_OUT2_PORT {clk_fmc_capture} \ + CONFIG.CLKOUT2_REQUESTED_OUT_FREQ {100.000} \ + CONFIG.CLKOUT2_REQUESTED_PHASE {135.000} \ + CONFIG.USE_PHASE_ALIGNMENT {true} \ + CONFIG.JITTER_SEL {Min_O_Jitter} \ + CONFIG.OVERRIDE_MMCM {true} \ + CONFIG.MMCM_DIVCLK_DIVIDE {1} \ + CONFIG.MMCM_CLKFBOUT_MULT_F {12.000} \ + CONFIG.MMCM_CLKIN1_PERIOD {10.000} \ + CONFIG.MMCM_CLKOUT0_DIVIDE_F {12.000} \ + CONFIG.MMCM_CLKOUT0_PHASE {45.000} \ + CONFIG.MMCM_CLKOUT1_DIVIDE {12} \ + CONFIG.MMCM_CLKOUT1_PHASE {135.000} \ + CONFIG.NUM_OUT_CLKS {2} \ +] [get_ips fmc_pll] +synth_ip [get_ips fmc_pll] diff --git a/hdl/projects/grapefruit/grapefruit_timing.xdc b/hdl/projects/grapefruit/grapefruit_timing.xdc index 395863df..66ba0401 100644 --- a/hdl/projects/grapefruit/grapefruit_timing.xdc +++ b/hdl/projects/grapefruit/grapefruit_timing.xdc @@ -64,10 +64,14 @@ set_output_delay -clock fmc_virt_clk -min 1.414 [get_ports fmc_sp_to_fpga_wait_l set_output_delay -clock fmc_virt_clk -max 3.851 [get_ports fmc_sp_to_fpga_da[*]] set_output_delay -clock fmc_virt_clk -min 0.830 [get_ports fmc_sp_to_fpga_da[*]] -set_multicycle_path -from [get_pins {stm32h7_fmc_target_inst/data_out*/C}] -to [get_ports {fmc_sp_to_fpga_da[*]}] -setup 2 -set_multicycle_path -from [get_pins {stm32h7_fmc_target_inst/data_out*/C}] -to [get_ports {fmc_sp_to_fpga_da[*]}] -hold 1 -set_multicycle_path -from [get_pins {stm32h7_fmc_target_inst/data_out_en_reg*/C}] -to [get_ports {fmc_sp_to_fpga_da[*]}] -setup 2 -set_multicycle_path -from [get_pins {stm32h7_fmc_target_inst/data_out_en_reg*/C}] -to [get_ports {fmc_sp_to_fpga_da[*]}] -hold 1 +# The streaming FSM presents read beats on consecutive cycles, so the +# word0->word1 transition is a true single-cycle path with no multicycle +# exception; IOB packing of the per-pin output, tristate, and nwait flops is +# what closes single-cycle at 15 ns. See cosmo_timing.xdc for the derivation; +# extra_beat_setup on the target is the escape hatch if this fails to close. +set_property IOB TRUE [get_cells -hier -filter {NAME =~ *stm32h7_fmc_target*/data_out_reg[*]}] +set_property IOB TRUE [get_cells -hier -filter {NAME =~ *stm32h7_fmc_target*/data_out_hiz_int_reg[*]}] +set_property IOB TRUE [get_cells -hier -filter {NAME =~ *stm32h7_fmc_target*/nwait_reg}] # ####################### # SPI NOR flash interface (Winbond W25Q01JV) diff --git a/hdl/projects/grapefruit/grapefruit_top.vhd b/hdl/projects/grapefruit/grapefruit_top.vhd index fd6dd60d..d1bb6204 100644 --- a/hdl/projects/grapefruit/grapefruit_top.vhd +++ b/hdl/projects/grapefruit/grapefruit_top.vhd @@ -197,7 +197,7 @@ architecture rtl of grapefruit_top is signal reset_200m : std_logic; signal reset_fmc: std_logic; signal fmc_internal_data_out : std_logic_vector(15 downto 0); - signal fmc_data_out_enable: std_logic; + signal fmc_data_out_hiz: std_logic_vector(15 downto 0); signal fmc_axi_if : axil26x32_pkg.axil_t; @@ -307,17 +307,22 @@ begin port map( chip_reset => reset_fmc, fmc_clk => fmc_sp_to_fpga_clk, + -- no MMCM here (F17 is not clock-capable); the capture stage still + -- exists, it just runs on the same clock + fmc_capture_clk => fmc_sp_to_fpga_clk, a(24 downto 20) => "00000", a(19 downto 16) => fmc_sp_to_fpga_a, addr_data_in => fmc_sp_to_fpga_da, data_out => fmc_internal_data_out, - data_out_en => fmc_data_out_enable, + data_out_hiz => fmc_data_out_hiz, ne(3 downto 1) => "111", ne(0) => fmc_sp_to_fpga_cs1_l, noe => fmc_sp_to_fpga_oe_l, nwe => fmc_sp_to_fpga_we_l, nl => fmc_sp_to_fpga_adv_l, nwait => fmc_sp_to_fpga_wait_l, + timeout_count => open, + contention_count => open, aclk => clk_125m, aresetn => not reset_125m, axi_if => fmc_axi_if @@ -337,7 +342,11 @@ begin ); -- tristate control for the FMC data bus - fmc_sp_to_fpga_da <= fmc_internal_data_out when fmc_data_out_enable = '1' else (others => 'Z'); + -- per-bit tristate, hiz already in OBUFT T polarity so each pin's T + -- flop packs into its IOB with no inverter in between + fmc_da_tris: for i in fmc_sp_to_fpga_da'range generate + fmc_sp_to_fpga_da(i) <= 'Z' when fmc_data_out_hiz(i) = '1' else fmc_internal_data_out(i); + end generate; resize_axil(fabric_responders(0), responders_8b(0)); info_regs: entity work.info generic map( diff --git a/tools/fmc_sweep/fmc_sweep.py b/tools/fmc_sweep/fmc_sweep.py new file mode 100755 index 00000000..0143939d --- /dev/null +++ b/tools/fmc_sweep/fmc_sweep.py @@ -0,0 +1,567 @@ +#!/usr/bin/env python3 +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. + +"""FMC interface frequency sweep and verification driver. + +Drives the FPGA's FMC target through hubris' fmc-demo-server: bulk traffic +uses the UDP peek/poke protocol (tools/speeker/udp_if.py), and the FMC_CLK +frequency is changed between phases through humility hiffy calls to the +FmcDemo timing Idol operations, and each change is verified by reading +BTR1 back. + +The phases are ordered so that a timing miss at a new frequency shows up as +bad data before it can show up as bus contention: reads of a known-constant +register first (the SP never drives the bus during read data phases, so a +miscapture is data-corruption-only), then scratch write/readback, then +batched back-to-back stress, then a seeded random soak, then a throughput +measurement. + +Usage +----- + # sanity run at the current frequency + ./tools/fmc_sweep/fmc_sweep.py --ip fe80::0c1d:beff:fe3f:0001 --interface eno1 + + # sweep 50 -> 66.67 -> 100 MHz (CLKDIV divisor values, i.e. kernel/N) + ./tools/fmc_sweep/fmc_sweep.py --ip ... --interface eno1 \ + --sweep 4,3,2 --archive /path/to/build-grapefruit.zip + + # frequency control done by hand (or by re-flashing), just verify + ./tools/fmc_sweep/fmc_sweep.py --ip ... --interface eno1 --no-timing-control + +Notes +----- +* --sweep takes *divisor* values (FMC_CLK = 200 MHz / N), so 4 = 50 MHz, + 3 = 66.67 MHz, 2 = 100 MHz. The Idol op takes the same divisor and + subtracts one for the register field itself. +* Neither board exposes a bulk R/W RAM over FMC, so bulk traffic is split: + back-to-back *reads* sweep a read-only region (default: the eSPI post-code + buffer at FPGA offset 0x8100, an external capture mem -- AXI writes to it + are silently dropped, which is exactly why it cannot be used for write + verification), and back-to-back *writes* hammer a single R/W scribble + register (default: the info block scratchpad at offset 0x10). Write + verification is last-value-plus-ordering here; per-word write verification + is the simulation suite's job. Do NOT point --scratch-addr at + fpga_checksum (offset 0xC): hubris uses it to decide whether the FPGA + needs reprogramming. +* On any phase failure the sweep restores the baseline divisor and + re-verifies phase A so the board is left usable. +""" + +import argparse +import json +import os +import random +import re +import subprocess +import sys +import time + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +from speeker.udp_if import Request, UDPMem # noqa: E402 + +FPGA_WINDOW = 0xC0000000 +KERNEL_CLK_MHZ = 200.0 +# ops per packet: each peek_adv4 is 1 byte, each poke_adv4 is 5 bytes, plus +# the 2-byte header and 6-byte address op; 128 words stays well inside the +# 1500-byte cap in both directions (a read reply is 4 bytes per word). +BATCH_WORDS = 128 + + +class TimingCtl: + """FMC_CLK control through humility hiffy FmcDemo calls.""" + + def __init__(self, humility, archive, dry_run=False, verbose=False): + self.cmd_base = [humility] + if archive: + self.cmd_base += ["-a", archive] + self.dry_run = dry_run + self.verbose = verbose + + def _run(self, call, args): + cmd = list(self.cmd_base) + ["hiffy", "-c", call] + for key, value in args: + cmd += ["-a", "%s=%s" % (key, value)] + if self.verbose or self.dry_run: + print(" $ " + " ".join(cmd)) + if self.dry_run: + return + out = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + # humility exits 0 even when the served op returns Err, including + # "" from a task that faulted and restarted, so the + # output text has to be checked too. + if out.returncode != 0 or "Err(" in out.stdout: + raise SystemExit( + "humility hiffy failed: %s\n%s%s" + % (" ".join(cmd), out.stdout, out.stderr) + ) + + def set_divisor(self, divisor): + self._run("FmcDemo.set_clock_divider", [("n", str(divisor))]) + + def get_btr1(self): + """FMC_BTR1 readback via the get_btr1 Idol op, or None on older + firmware / dry runs. CLKDIV is bits [23:20].""" + if self.dry_run: + return None + cmd = list(self.cmd_base) + ["hiffy", "-c", "FmcDemo.get_btr1"] + try: + out = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + except (subprocess.TimeoutExpired, FileNotFoundError): + return None + if out.returncode != 0 or "Err(" in out.stdout: + return None + # parse only the success-reply form "FmcDemo.get_btr1() => "; + # anything looser can match numbers inside an Err message + m = re.search(r"=>\s*(0x[0-9a-fA-F]+|\d+)\s*$", out.stdout.strip()) + return int(m.group(1), 0) if m else None + + def verify_divisor(self, divisor): + """Returns (btr1, ok_or_None): ok compares the CLKDIV field against + what set_divisor should have programmed (divisor - 1); None when the + firmware has no readback op.""" + btr1 = self.get_btr1() + if btr1 is None: + return None, None + clkdiv = (btr1 >> 20) & 0xF + return btr1, clkdiv == divisor - 1 + + def system_time(self): + """Kernel tick count from `humility tasks`, or None if it could not + be read. Ticks reset on an SP reboot, which is how a hang-then- + watchdog event is told apart from a dropped packet: the tick clock + going backwards across a step means the SP restarted under us.""" + if self.dry_run: + return None + cmd = list(self.cmd_base) + ["tasks"] + try: + out = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + except (subprocess.TimeoutExpired, FileNotFoundError): + return None + if out.returncode != 0: + return None + m = re.search(r"system time = (\d+)", out.stdout) + return int(m.group(1)) if m else None + + +def freq_mhz(divisor): + return KERNEL_CLK_MHZ / divisor + + +def batched_read(mem, base, count): + """Read count 32-bit words starting at base using peek_adv4 batches.""" + words = [] + for chunk_at in range(0, count, BATCH_WORDS): + n = min(BATCH_WORDS, count - chunk_at) + req = Request() + req.set_address(base + 4 * chunk_at) + req.add_read32_advances(n) + resp = mem.execute_prebuilt_request(req) + words += [r.payload for r in resp.expected_responses] + return words + + +def phase_a_const_reads(mem, args, golden): + """Repeated single reads of a known-constant register, with gaps.""" + seen = set() + for i in range(args.const_iters): + seen.add(mem.read32(args.const_addr)) + time.sleep(0.001) + ok = len(seen) == 1 and (golden is None or seen == {golden}) + return ok, {"values": sorted("0x%08x" % v for v in seen)} + + +def phase_b_scratch(mem, args): + """Single write/readback of walking and random patterns.""" + addr = args.scratch_addr + patterns = ( + [1 << b for b in range(32)] + + [0xFFFFFFFF ^ (1 << b) for b in range(32)] + + [0x00000000, 0xFFFFFFFF, 0xA5A5A5A5, 0x5A5A5A5A] + ) + bad = [] + for pat in patterns: + mem.write32(addr, pat) + got = mem.read32(addr) + if got != pat: + bad.append({"wrote": "0x%08x" % pat, "read": "0x%08x" % got}) + return not bad, {"patterns": len(patterns), "mismatches": bad[:8]} + + +def scratch_write_burst(mem, addr, values): + """Back-to-back non-advancing writes to one register per packet batch.""" + for chunk_at in range(0, len(values), BATCH_WORDS): + chunk = values[chunk_at : chunk_at + BATCH_WORDS] + req = Request() + req.set_address(addr) + req.add_write32s(chunk) + mem.execute_prebuilt_request(req) + + +def phase_c_stress(mem, args, rng): + """Back-to-back stress: a write burst into the scratch register (posted + writes queue back-to-back; the last value read back proves ordering and + that nothing was dropped or hung), then two read sweeps of the read-only + stress region compared against each other (content is arbitrary but must + be stable).""" + values = [rng.getrandbits(32) for _ in range(args.stress_words)] + scratch_write_burst(mem, args.scratch_addr, values) + got = mem.read32(args.scratch_addr) + bad = [] + if got != values[-1]: + bad.append({"scratch_last": "0x%08x" % values[-1], + "read": "0x%08x" % got}) + pass1 = batched_read(mem, args.stress_base, args.stress_words) + pass2 = batched_read(mem, args.stress_base, args.stress_words) + for i, (x, y) in enumerate(zip(pass1, pass2)): + if x != y and len(bad) < 8: + bad.append({"index": i, "pass1": "0x%08x" % x, "pass2": "0x%08x" % y}) + return not bad, {"write_burst": len(values), "read_words": args.stress_words, + "mismatches": bad} + + +def phase_d_soak(mem, args, rng): + """Seeded random mix of scratch writes/readbacks, constant reads, and + read-region bursts, scoreboarding the scratch register and the golden + constant. Runs for --soak-ops operations or --soak-seconds, whichever + lasts longer (each op is one UDP round trip, so op counts alone finish + in seconds).""" + snapshot = batched_read(mem, args.stress_base, args.stress_words) + golden = mem.read32(args.const_addr) + last_scratch = None + bad = [] + ops = 0 + deadline = time.monotonic() + args.soak_seconds + while ops < args.soak_ops or time.monotonic() < deadline: + ops += 1 + op = rng.randrange(4) + if op == 0: + last_scratch = rng.getrandbits(32) + mem.write32(args.scratch_addr, last_scratch) + elif op == 1 and last_scratch is not None: + got = mem.read32(args.scratch_addr) + if got != last_scratch and len(bad) < 8: + bad.append({"scratch_expect": "0x%08x" % last_scratch, + "read": "0x%08x" % got}) + elif op == 2: + got = mem.read32(args.const_addr) + if got != golden and len(bad) < 8: + bad.append({"const_expect": "0x%08x" % golden, + "read": "0x%08x" % got}) + else: + index = rng.randrange(args.stress_words - 16) + got = batched_read(mem, args.stress_base + 4 * index, 16) + if got != snapshot[index : index + 16] and len(bad) < 8: + bad.append({"region_index": index}) + if bad and len(bad) >= 8: + break # no point soaking further on a badly broken link + return not bad, {"ops": ops, "mismatches": bad} + + +def _timed_block_op(mem, build_request, small, large, repeats=5): + """Median duration delta between a `large`-count and a `small`-count + server-side block op: the round trip, stack, and per-packet costs cancel, + leaving (large - small) bus accesses. Returns (seconds_per_word, checksum + of the last large op).""" + def run(count): + req = build_request(count) + t0 = time.monotonic() + resp = mem.execute_prebuilt_request(req) + dt = time.monotonic() - t0 + payload = resp.expected_responses[0].payload if resp.expected_responses else None + return dt, payload + deltas = [] + checksum = None + for _ in range(repeats): + t_small, _ = run(small) + t_large, checksum = run(large) + deltas.append(t_large - t_small) + deltas.sort() + return deltas[len(deltas) // 2] / (large - small), checksum + + +def phase_e_throughput(mem, args, rng): + """FMC line rate via the server-side block ops (delta-timed so network, + stack, and per-packet costs cancel), with the wire-level batched numbers + kept for reference. Falls back to wire-level only against firmware + without ops 17-19.""" + results = {} + ok = True + + # wire-level reference numbers (dominated by per-word network bytes) + values = [rng.getrandbits(32) for _ in range(args.stress_words)] + t0 = time.monotonic() + scratch_write_burst(mem, args.scratch_addr, values) + t_write = time.monotonic() - t0 + ok = mem.read32(args.scratch_addr) == values[-1] + t0 = time.monotonic() + snapshot = batched_read(mem, args.stress_base, args.stress_words) + t_read = time.monotonic() - t0 + nbytes = 4 * args.stress_words + results["wire_write_MBps"] = round(nbytes / t_write / 1e6, 3) + results["wire_read_MBps"] = round(nbytes / t_read / 1e6, 3) + + n = 200 + t0 = time.monotonic() + for _ in range(n): + mem.read32(args.const_addr) + results["single_read_us"] = round((time.monotonic() - t0) / n * 1e6, 1) + + # bus-level numbers via ops 17-19 + try: + golden = mem.read32(args.const_addr) + retried_before = mem.timeouts_retried + + def rd_req(count): + req = Request() + req.set_address(args.stress_base) + req.add_peek_block_checksum(count, advance=True) + return req + + spw, checksum = _timed_block_op(mem, rd_req, 64, args.stress_words) + results["fmc_read_MBps"] = round(4 / spw / 1e6, 2) + results["fmc_read_ns_per_word"] = round(spw * 1e9) + expect = sum(snapshot) & 0xFFFFFFFF + if checksum != expect: + ok = False + results["read_checksum_mismatch"] = { + "got": "0x%08x" % checksum, "expect": "0x%08x" % expect} + + def rdf_req(count): + req = Request() + req.set_address(args.const_addr) + req.add_peek_block_checksum(count, advance=False) + return req + + spw, checksum = _timed_block_op(mem, rdf_req, 64, 8192) + results["fmc_read_fixed_ns_per_word"] = round(spw * 1e9) + if checksum != (golden * 8192) & 0xFFFFFFFF: + ok = False + results["fixed_checksum_mismatch"] = "0x%08x" % checksum + + fill_value = rng.getrandbits(32) + + def wr_req(count): + req = Request() + req.set_address(args.scratch_addr) + req.add_poke_block_fill(count, fill_value) + return req + + spw, _ = _timed_block_op(mem, wr_req, 64, 8192) + results["fmc_write_MBps"] = round(4 / spw / 1e6, 2) + results["fmc_write_ns_per_word"] = round(spw * 1e9) + if mem.read32(args.scratch_addr) != fill_value: + ok = False + results["fill_readback_mismatch"] = True + if mem.timeouts_retried != retried_before: + # a resend during a timed op makes that sample garbage + results["retried_during_timing"] = True + except Exception as exc: + results["block_ops"] = "unsupported or failed: %r" % exc + + results["note"] = ("fmc_* numbers are bus line rate (delta-timed block " + "ops); wire_* include per-word network cost and are " + "insensitive to the FMC clock") + return ok, results + + +PHASES = [ + ("A_const_reads", phase_a_const_reads), + ("B_scratch", phase_b_scratch), + ("C_stress", phase_c_stress), + ("D_soak", phase_d_soak), + ("E_throughput", phase_e_throughput), +] + + +def run_phases(mem, args, rng, golden): + results = {} + all_ok = True + for name, fn in PHASES: + if name == "A_const_reads": + ok, detail = fn(mem, args, golden) + elif name == "B_scratch": + ok, detail = fn(mem, args) + else: + ok, detail = fn(mem, args, rng) + results[name] = {"ok": ok, **detail} + status = "ok" if ok else "FAIL" + print(" %-16s %s %s" % (name, status, json.dumps(detail)[:120])) + if not ok: + all_ok = False + break # keep the electrical exposure of later phases off a bad link + return all_ok, results + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--ip", required=True, help="SP link-local IPv6 address") + ap.add_argument("--interface", required=True, help="host network interface") + ap.add_argument("--port", type=int, default=11114) + ap.add_argument("--sweep", default=None, + help="comma-separated FMC_CLK divisors to sweep, e.g. 4,3,2") + ap.add_argument("--baseline-divisor", type=int, default=4, + help="known-good divisor to calibrate at and restore to") + ap.add_argument("--no-timing-control", action="store_true", + help="never call humility; frequency is managed externally") + ap.add_argument("--humility", default="humility") + ap.add_argument("--archive", default=None, help="hubris archive for humility") + ap.add_argument("--const-addr", type=lambda x: int(x, 0), + default=FPGA_WINDOW, + help="address of a read-only constant register") + ap.add_argument("--const-iters", type=int, default=50) + ap.add_argument("--scratch-addr", type=lambda x: int(x, 0), + default=FPGA_WINDOW + 0x10, + help="address of a harmless 32-bit r/w scratch register " + "(default: info block scratchpad; never point this " + "at fpga_checksum)") + ap.add_argument("--stress-base", type=lambda x: int(x, 0), + default=FPGA_WINDOW + 0x8100, + help="base of a readable region for bulk read traffic " + "(default: eSPI post-code buffer; reads only, its " + "content just has to be stable during the run)") + ap.add_argument("--stress-words", type=int, default=1024) + ap.add_argument("--soak-ops", type=int, default=500, + help="minimum soak operations (each is one UDP round trip)") + ap.add_argument("--soak-seconds", type=int, default=0, + help="minimum soak duration; the soak runs until BOTH " + "this and --soak-ops are satisfied. Applies to every " + "divisor in a sweep, so budget accordingly.") + ap.add_argument("--udp-retries", type=int, default=3, + help="resend attempts after a UDP timeout before a step " + "fails (0 = old fail-on-first-loss behavior)") + ap.add_argument("--seed", type=int, default=0x1DE) + ap.add_argument("--json", default=None, help="write results here") + ap.add_argument("--dry-run", action="store_true", + help="print humility commands without running them") + args = ap.parse_args() + + # retries make a dropped frame on a flaky link (USB NICs especially) a + # logged statistic instead of a dead hour-long soak; three consecutive + # losses still fails the step, which is the "target actually gone" case + mem = UDPMem(args.ip, args.interface, target_port=args.port, + retries=args.udp_retries) + # The archive enables observation (BTR1 readback, SP uptime/reboot + # detection) even when frequency control is off; --no-timing-control + # only means "never change the divisor" -- the mode for soaking a board + # at its kernel-configured boot frequency. + ctl = TimingCtl(args.humility, args.archive, + dry_run=args.dry_run) if args.archive else None + control = ctl is not None and not args.no_timing_control + + report = {"seed": args.seed, "steps": []} + + # Calibrate the constant register at the known-good divisor. + if control: + ctl.set_divisor(args.baseline_divisor) + time.sleep(0.1) + golden = mem.read32(args.const_addr) + print("golden const read @%s: 0x%08x" % (hex(args.const_addr), golden)) + + if args.sweep: + if not control: + raise SystemExit("--sweep requires timing control " + "(an --archive, without --no-timing-control)") + divisors = [int(d) for d in args.sweep.split(",")] + elif control: + divisors = [args.baseline_divisor] + else: + # frequency untouched: label the step with the divisor the SP + # actually booted with, when readable + btr1 = ctl.get_btr1() if ctl else None + divisors = [((btr1 >> 20) & 0xF) + 1 if btr1 is not None + else args.baseline_divisor] + if btr1 is not None: + print("boot BTR1 = 0x%08x -> divisor %d (%.2f MHz)" + % (btr1, divisors[0], freq_mhz(divisors[0]))) + overall_ok = True + for divisor in divisors: + print("== divisor %d (FMC_CLK %.2f MHz) ==" % (divisor, freq_mhz(divisor))) + if control: + ctl.set_divisor(divisor) + time.sleep(0.1) + rng = random.Random(args.seed) + btr1, div_ok = ctl.verify_divisor(divisor) if ctl else (None, None) + if btr1 is not None: + print(" BTR1 = 0x%08x (CLKDIV field %d, %s)" % + (btr1, (btr1 >> 20) & 0xF, + "matches" if div_ok else "DOES NOT MATCH requested divisor")) + if div_ok is False and not control: + # informational only: we did not request this divisor + div_ok = None + if div_ok is False: + # the programmed divider never landed: nothing this step would + # measure is at the requested frequency, so fail before phases + report["steps"].append({ + "divisor": divisor, + "freq_mhz": freq_mhz(divisor), + "ok": False, + "btr1": "0x%08x" % btr1, + "clkdiv_verified": False, + }) + overall_ok = False + print(" divisor %d FAILED (CLKDIV readback mismatch); " + "restoring baseline" % divisor) + break + ticks_before = ctl.system_time() if ctl else None + try: + ok, results = run_phases(mem, args, rng, golden) + except Exception as exc: # timeouts etc. count as a hard step failure + ok, results = False, {"exception": repr(exc)} + print(" step raised: %r" % exc) + step = { + "divisor": divisor, + "freq_mhz": freq_mhz(divisor), + "ok": ok, + "udp_timeouts_retried": mem.timeouts_retried, + "phases": results, + } + if btr1 is not None: + step["btr1"] = "0x%08x" % btr1 + step["clkdiv_verified"] = div_ok + if ctl: + ticks_after = ctl.system_time() + step["sp_ticks_before"] = ticks_before + step["sp_ticks_after"] = ticks_after + if ticks_before is not None and ticks_after is not None: + # ticks are milliseconds since boot and only ever increase + # while the SP stays up + step["sp_rebooted"] = ticks_after < ticks_before + if step["sp_rebooted"]: + ok = False + step["ok"] = False + print(" SP REBOOTED during this step (ticks %d -> %d):" + " it hung and was reset, this was not packet loss" + % (ticks_before, ticks_after)) + elif ticks_after is None: + step["sp_unresponsive"] = True + print(" could not read SP system time after the step " + "(SP hung or humility unavailable)") + report["steps"].append(step) + if not ok: + overall_ok = False + print(" divisor %d FAILED; restoring baseline" % divisor) + break + + if control: + # also covers the reboot case: a restarted SP booted back at its + # kernel-configured divisor, and this re-asserts the sweep baseline + ctl.set_divisor(args.baseline_divisor) + time.sleep(0.1) + ok, _ = phase_a_const_reads(mem, args, golden) + report["restored_baseline_ok"] = ok + print("baseline restore check: %s" % ("ok" if ok else "FAIL")) + overall_ok = overall_ok and ok + + if args.json: + with open(args.json, "w") as f: + json.dump(report, f, indent=2) + print("results written to", args.json) + + sys.exit(0 if overall_ok else 1) + + +if __name__ == "__main__": + main() diff --git a/tools/speeker/udp_if.py b/tools/speeker/udp_if.py index ad1158fe..4390bb6b 100644 --- a/tools/speeker/udp_if.py +++ b/tools/speeker/udp_if.py @@ -37,6 +37,11 @@ class Request: poke_adv2_arg = 14 # write 2 byte to addr, increment internal addr by 2 poke_adv4_arg = 15 # write 4 byte to addr, increment internal addr by 3 poke_adv8_arg = 16 # write 8 byte to addr, increment internal addr by 4 + # server-side block ops: N bus accesses per op with a constant-size + # response, so request duration is dominated by the FMC accesses + peek_block_checksum_arg = 17 # u16 count; advancing; returns u32 sum + peek_block_checksum_fixed_arg = 18 # u16 count; same address; returns u32 sum + poke_block_fill_arg = 19 # u16 count + u32 value; same address def __init__(self): # Build a bytearray to represent the packet we're going to send @@ -99,6 +104,24 @@ def add_write32s(self, values: list) -> None: else: raise Exception(f"Invalid type {type(value)} for value") + def add_peek_block_checksum(self, count, advance=True) -> None: + """Server-side read of `count` 32-bit words returning one u32 + wrapping-sum checksum; `advance` selects walking memory vs + re-reading one address.""" + op = self.peek_block_checksum_arg if advance else self.peek_block_checksum_fixed_arg + self.bytes += op.to_bytes(1, byteorder='little') + self.bytes += count.to_bytes(2, byteorder='little') + self.response.add_expected_peek(ResponsePeek(self.cur_addr, 4)) + if advance: + self.cur_addr += 4 * count + + def add_poke_block_fill(self, count, value) -> None: + """Server-side write of `value` to the current address `count` + times (non-advancing).""" + self.bytes += self.poke_block_fill_arg.to_bytes(1, byteorder='little') + self.bytes += count.to_bytes(2, byteorder='little') + self.bytes += value.to_bytes(4, byteorder='little') + def add_write32_advances(self, values: list) -> None: for value in values: self.bytes += self.poke_adv4_arg.to_bytes(1, byteorder='little') @@ -157,9 +180,18 @@ class UDPMem: Note that the SP is IPv6 only and runs on a link-local address so specification of the pc's output interface is required. """ - def __init__(self, target_ip, ifname, target_port=11114, timeout=2): + def __init__(self, target_ip, ifname, target_port=11114, timeout=2, retries=0): self.debug = False self.timeout = timeout + # Additional attempts after a receive timeout. Requests are + # idempotent (peeks re-read, pokes re-write the same value), so a + # resend after a dropped frame is safe; the socket is drained before + # each send so a late-arriving response can never be matched to a + # newer request. + self.retries = retries + # count of timed-out attempts that were subsequently retried; lets a + # caller report link flakiness instead of dying on it + self.timeouts_retried = 0 # Basic UDP IPv6 socket setup self.sock = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM) # Build the target address using getaddrinfo and the interface name @@ -199,18 +231,37 @@ def _get_resp_from_request(self, request: Request) -> Response: request.response.process_bytes(resp_bytes) return request.response - def _send_get_reply_handshake(self, request: Request) -> bytes: - # Send the request out the wire - if self.debug: - print(f"Sending request: {request.hex()}") - self.sock.sendto(bytes(request), self.target_addr) - # try rx up to mtu size for timeout time and return - # response or exception on timeout - self.sock.settimeout(self.timeout) + def _drain(self) -> None: + """Discard any stale datagrams (late responses from a timed-out + attempt) so request/response pairing stays in lockstep.""" + self.sock.setblocking(False) try: - resp = self.sock.recv(1500) - except socket.timeout: - raise Exception("Timeout- no response back from target") - if self.debug: - print(f"Got response: {resp.hex()}") - return resp \ No newline at end of file + while True: + self.sock.recv(1500) + except (BlockingIOError, OSError): + pass + finally: + self.sock.setblocking(True) + + def _send_get_reply_handshake(self, request: Request) -> bytes: + for attempt in range(1 + self.retries): + self._drain() + if self.debug: + print(f"Sending request: {request.hex()}") + self.sock.sendto(bytes(request), self.target_addr) + # try rx up to mtu size for timeout time and return + # response or exception on timeout + self.sock.settimeout(self.timeout) + try: + resp = self.sock.recv(1500) + except socket.timeout: + if attempt < self.retries: + self.timeouts_retried += 1 + continue + raise Exception( + "Timeout- no response back from target" + + (f" after {1 + self.retries} attempts" if self.retries else "") + ) + if self.debug: + print(f"Got response: {resp.hex()}") + return resp \ No newline at end of file