Skip to content

Commit 92efc27

Browse files
authored
Merge pull request #24 from lcapossio/startup-arm-force-idle
Add `force_idle()` to fix intermittent captures on startup-armed cores
2 parents 9ceae26 + dab2000 commit 92efc27

6 files changed

Lines changed: 345 additions & 3 deletions

File tree

docs/05_ela_core.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -648,6 +648,28 @@ cfg = CaptureConfig(
648648
)
649649
```
650650

651+
> **Host-initiated captures on a startup-armed bitstream.** Because
652+
> `STARTUP_ARM=1` makes the core boot armed, it may already be armed — or even
653+
> triggered/done — before your host code runs. A bare `configure(cfg); arm()`
654+
> then races that power-up window and can read back a valid-but-unexpected
655+
> capture (clean data, wrong window). Call `Analyzer.force_idle()` between
656+
> `configure()` and `arm()` to reset the core and poll `STATUS` until it is
657+
> verifiably idle:
658+
>
659+
> ```python
660+
> a.configure(cfg) # cfg.startup_arm defaults False -> clears STARTUP_ARM reg
661+
> a.force_idle() # discard the boot window; start from known idle
662+
> a.arm()
663+
> result = a.capture()
664+
> ```
665+
>
666+
> `force_idle()` issues a soft reset and re-resets if the cleared `STARTUP_ARM`
667+
> register has not yet crossed the clock domain, so a single call is enough. It
668+
> is intentionally lossy — skip it when you *want* the from-boot capture (e.g.
669+
> the `STARTUP_ARM` validation tests, which deliberately use raw
670+
> `reset()`/`arm()`). For streaming, `capture_continuous(force_idle=True)`
671+
> establishes idle once before the first arm.
672+
651673
What happens at runtime:
652674
653675
1. The core arms (explicit `ARM`, segmented auto-rearm, or RESET with

docs/09_python_api.md

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -238,10 +238,13 @@ result = a.capture(timeout=10.0)
238238
print(f"got {len(result.samples)} samples, overflow={result.overflow}")
239239
```
240240

241-
### `capture_continuous(count=0, timeout_per=10.0)`
241+
### `capture_continuous(count=0, timeout_per=10.0, force_idle=False)`
242242

243243
Generator that arms, waits, captures, re-arms, repeats. `count=0`
244-
means "forever".
244+
means "forever". Pass `force_idle=True` to establish a known-idle state
245+
once before the first arm (see `force_idle()` below) — this matters on a
246+
bitstream that boots with `STARTUP_ARM=1`, where the first iteration would
247+
otherwise inherit the power-up capture window.
245248

246249
```python
247250
for i, result in enumerate(a.capture_continuous(count=10)):
@@ -254,6 +257,7 @@ For `NUM_SEGMENTS > 1`:
254257

255258
```python
256259
a.configure(cfg)
260+
a.force_idle() # on a STARTUP_ARM=1 bitstream; start from known idle
257261
a.arm()
258262
a.wait_all_segments_done(timeout=10.0)
259263

@@ -286,6 +290,31 @@ abort an in-progress capture or recover from a stuck state. If
286290
`CaptureConfig.startup_arm` is enabled, a later RESET leaves the
287291
core armed again instead of idle.
288292

293+
### `force_idle(timeout=1.0, poll_interval=0.01, max_resets=3) -> None`
294+
295+
Drives the core to a *verified* idle state: soft-resets, then polls
296+
`STATUS` until `armed`/`triggered`/`done` are all clear, re-resetting if a
297+
startup-armed core re-armed before the cleared `STARTUP_ARM` register
298+
crossed the sample-clock domain. Raises `RuntimeError` if it cannot reach
299+
idle within `max_resets` resets.
300+
301+
Use it between `configure()` and `arm()` on a bitstream built with the
302+
`STARTUP_ARM=1` RTL parameter: such a core boots already armed and may even
303+
be triggered/done before the host runs, so a bare `configure(); arm()` can
304+
read back a valid-but-unexpected window. It is intentionally lossy (it
305+
discards any in-flight or boot capture) — skip it when you *want* the
306+
from-boot window.
307+
308+
```python
309+
a.configure(cfg)
310+
a.force_idle() # discard the boot window; start from known idle
311+
a.arm()
312+
result = a.capture()
313+
```
314+
315+
See [chapter 5](05_ela_core.md#startup-arm-and-trigger-holdoff) for the
316+
full startup-arm discussion.
317+
289318
## `CaptureConfig` and friends
290319

291320
### `CaptureConfig`

docs/17_troubleshooting.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,27 @@ trigger sample is at `samples[pretrigger]`.
183183

184184
**Fix**: `result.samples[result.config.pretrigger]`.
185185

186+
### Capture returns valid data but not the window you armed (intermittent, passes on rerun)
187+
188+
**Cause**: the bitstream was built with the `STARTUP_ARM=1` RTL parameter,
189+
so the core boots already armed — it may be armed, or even triggered/done,
190+
before your host code runs. A bare `configure(); arm()` then races that
191+
power-up window and reads back a clean-but-unexpected capture.
192+
Reprogramming or rerunning often "fixes" it because the timing shifts.
193+
194+
**Fix**: call `force_idle()` between `configure()` and `arm()` to reset the
195+
core and poll `STATUS` until it is verifiably idle:
196+
197+
```python
198+
a.configure(cfg)
199+
a.force_idle()
200+
a.arm()
201+
result = a.capture()
202+
```
203+
204+
For streaming, use `capture_continuous(force_idle=True)`. See
205+
[chapter 5](05_ela_core.md#startup-arm-and-trigger-holdoff).
206+
186207
## GUI
187208

188209
### `qt.qpa.plugin: Could not load the Qt platform plugin "xcb"`

examples/arty_a7/test_hw_integration.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,8 @@ def test_first_ela_captures_150mhz_counter(self):
250250
sample_clock_hz=ELA0_SAMPLE_CLOCK_HZ,
251251
)
252252
a.configure(cfg)
253+
# bitstream boots STARTUP_ARM=1 — establish known idle before arming
254+
a.force_idle()
253255
a.arm()
254256
result = a.capture(timeout=5.0)
255257
samples = [s & 0xFF for s in result.samples]
@@ -289,6 +291,8 @@ def test_second_ela_captures_130mhz_xored_counter(self):
289291
sample_clock_hz=ELA1_SAMPLE_CLOCK_HZ,
290292
)
291293
a.configure(cfg)
294+
# bitstream boots STARTUP_ARM=1 — establish known idle before arming
295+
a.force_idle()
292296
a.arm()
293297
result = a.capture(timeout=5.0)
294298
samples = [s & 0xFF for s in result.samples]
@@ -404,6 +408,8 @@ def _capture(self, pretrig, posttrig, trig_val=0, trig_mask=0xFF, mode="value_ma
404408
depth=1024,
405409
)
406410
self.a.configure(cfg)
411+
# bitstream boots STARTUP_ARM=1 — establish known idle before arming
412+
self.a.force_idle()
407413
self.a.arm()
408414
return self.a.capture(timeout=5.0)
409415

@@ -429,6 +435,8 @@ def test_trigger_delay_shifts_window(self):
429435
trigger_delay=4,
430436
)
431437
self.a.configure(cfg)
438+
# bitstream boots STARTUP_ARM=1 — establish known idle before arming
439+
self.a.force_idle()
432440
self.a.arm()
433441
result = self.a.capture(timeout=5.0)
434442
self.assertEqual(len(result.samples), 6)
@@ -454,6 +462,8 @@ def test_trigger_delay_zero_equivalence(self):
454462
trigger_delay=0,
455463
)
456464
self.a.configure(cfg)
465+
# bitstream boots STARTUP_ARM=1 — establish known idle before arming
466+
self.a.force_idle()
457467
self.a.arm()
458468
result = self.a.capture(timeout=5.0)
459469
self.assertEqual(len(result.samples), 6)
@@ -702,6 +712,8 @@ def _capture_result(self):
702712
sample_width=8, depth=1024,
703713
)
704714
self.a.configure(cfg)
715+
# bitstream boots STARTUP_ARM=1 — establish known idle before arming
716+
self.a.force_idle()
705717
self.a.arm()
706718
return self.a.capture(timeout=5.0)
707719

@@ -771,6 +783,8 @@ def test_decim_zero_baseline(self):
771783
decimation=0,
772784
)
773785
self.a.configure(cfg)
786+
# bitstream boots STARTUP_ARM=1 — establish known idle before arming
787+
self.a.force_idle()
774788
self.a.arm()
775789
result = self.a.capture(timeout=5.0)
776790
self.assertEqual(len(result.samples), 6)
@@ -791,6 +805,8 @@ def test_decim_3_stores_every_4th(self):
791805
decimation=3,
792806
)
793807
self.a.configure(cfg)
808+
# bitstream boots STARTUP_ARM=1 — establish known idle before arming
809+
self.a.force_idle()
794810
self.a.arm()
795811
result = self.a.capture(timeout=5.0)
796812
self.assertEqual(len(result.samples), 8)
@@ -842,6 +858,8 @@ def test_timestamps_monotonic(self):
842858
trigger=self.TriggerConfig(mode="value_match", value=0x30, mask=0xFF),
843859
)
844860
self.a.configure(cfg)
861+
# bitstream boots STARTUP_ARM=1 — establish known idle before arming
862+
self.a.force_idle()
845863
self.a.arm()
846864
result = self.a.capture(timeout=5.0)
847865
self.assertGreater(len(result.timestamps), 0, "No timestamps returned")
@@ -861,6 +879,8 @@ def test_timestamps_with_decimation(self):
861879
decimation=3,
862880
)
863881
self.a.configure(cfg)
882+
# bitstream boots STARTUP_ARM=1 — establish known idle before arming
883+
self.a.force_idle()
864884
self.a.arm()
865885
result = self.a.capture(timeout=5.0)
866886
self.assertGreater(len(result.timestamps), 1)
@@ -912,6 +932,8 @@ def test_four_segments_captured(self):
912932
trigger=self.TriggerConfig(mode="value_match", value=0x00, mask=0xFF),
913933
)
914934
self.a.configure(cfg)
935+
# bitstream boots STARTUP_ARM=1 — establish known idle before arming
936+
self.a.force_idle()
915937
self.a.arm()
916938
# Wait for all 4 segments to complete
917939
done = self.a.wait_all_segments_done(timeout=10.0)
@@ -934,6 +956,8 @@ def test_segment_data_independent(self):
934956
trigger=self.TriggerConfig(mode="value_match", value=0x00, mask=0xFF),
935957
)
936958
self.a.configure(cfg)
959+
# bitstream boots STARTUP_ARM=1 — establish known idle before arming
960+
self.a.force_idle()
937961
self.a.arm()
938962
done = self.a.wait_all_segments_done(timeout=10.0)
939963
self.assertTrue(done)
@@ -982,6 +1006,8 @@ def test_ext_trigger_disabled_normal_capture(self):
9821006
ext_trigger_mode=0,
9831007
)
9841008
self.a.configure(cfg)
1009+
# bitstream boots STARTUP_ARM=1 — establish known idle before arming
1010+
self.a.force_idle()
9851011
self.a.arm()
9861012
result = self.a.capture(timeout=5.0)
9871013
self.assertEqual(len(result.samples), 6)

host/fcapz/analyzer.py

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,12 +102,21 @@ def expected_ela_version_reg() -> int:
102102
_ADDR_SQ_MASK = 0x0038
103103
_ADDR_DATA_BASE = 0x0100
104104

105+
_STATUS_ARMED = 1 << 0
106+
_STATUS_TRIGGERED = 1 << 1
105107
_STATUS_DONE = 1 << 2
106108
_STATUS_OVERFLOW = 1 << 3
109+
# Capture FSM is "busy" (i.e. not idle) if any of armed/triggered/done is set.
110+
_STATUS_BUSY = _STATUS_ARMED | _STATUS_TRIGGERED | _STATUS_DONE
107111

108112
_CTRL_ARM = 1 << 0
109113
_CTRL_RESET = 1 << 1
110114

115+
# force_idle(): per-reset settle window. A re-armed core (startup-arm CDC race)
116+
# reveals itself within a couple of sample clocks of reset deassert, so a short
117+
# bounded wait suffices before spending another reset.
118+
_FORCE_IDLE_SETTLE_S = 0.05
119+
111120
_TRIG_VALUE_MATCH = 1 << 0
112121
_TRIG_EDGE_DETECT = 1 << 1
113122

@@ -197,7 +206,10 @@ class CaptureConfig:
197206
stor_qual_mode: int = 0 # 0=disabled; 1=store when match, 2=store when no match
198207
stor_qual_value: int = 0 # storage qualification comparison value
199208
stor_qual_mask: int = 0 # storage qualification mask
200-
startup_arm: bool = False # if True, reset leaves the core armed
209+
startup_arm: bool = False # runtime STARTUP_ARM register (0xD8): if True a
210+
# soft reset re-arms the core. Distinct from the
211+
# compile-time STARTUP_ARM RTL parameter, which
212+
# sets the power-up (boot-armed) value.
201213
trigger_holdoff: int = 0 # sample-clock cycles to ignore triggers after arm/re-arm
202214
trigger_delay: int = 0 # post-trigger delay in sample-clock cycles
203215
# (0..65535) — shifts the committed trigger
@@ -329,6 +341,101 @@ def reset(self) -> None:
329341
self._select_instance()
330342
self.transport.write_reg(_ADDR_CTRL, _CTRL_RESET)
331343

344+
def force_idle(
345+
self,
346+
timeout: float = 1.0,
347+
poll_interval: float = 0.01,
348+
max_resets: int = 3,
349+
) -> None:
350+
"""Drive the core to a *verified* idle state before arming.
351+
352+
A bitstream built with ``STARTUP_ARM=1`` (e.g. the Arty example) comes
353+
up already armed -- it may even be triggered/done -- before the host
354+
issues a single command. Arming and capturing on top of that yields a
355+
valid but *unexpected* window. ``force_idle`` issues a soft reset and
356+
confirms the core actually settled to idle (armed=0, triggered=0,
357+
done=0) so the next capture is deterministic.
358+
359+
This is a *soft* reset only: it clears the sample-domain capture FSM
360+
(armed/triggered/done and the buffer pointers). It does **not** touch
361+
configuration registers, so the canonical sequence is::
362+
363+
a.configure(cfg) # cfg.startup_arm defaults False, clearing the
364+
# runtime STARTUP_ARM register (addr 0xD8)
365+
a.force_idle()
366+
a.arm()
367+
result = a.capture()
368+
369+
Note the boot-armed behavior comes from the *compile-time* ``STARTUP_ARM``
370+
RTL parameter, which seeds the auto-arm flag at configuration time and
371+
cannot be changed from the host. :meth:`configure` instead clears the
372+
*runtime* STARTUP_ARM register (addr 0xD8), which overrides that flag for
373+
subsequent resets.
374+
375+
A startup-armed core re-loads its auto-arm flag from that register on
376+
every soft reset, so a single reset can race the configuration
377+
clock-domain crossing and come back still armed. ``force_idle`` polls
378+
STATUS after the reset and, if the core re-armed, resets again -- by which
379+
point the runtime STARTUP_ARM register cleared by :meth:`configure` has
380+
crossed into the sample domain -- up to ``max_resets`` times.
381+
382+
Timing assumption: a STATUS read round-trips over JTAG far slower than
383+
the core's reset-to-re-arm latency (a couple of sample clocks), so the
384+
first post-reset STATUS read reliably observes the settled state. This
385+
holds for any practical sample clock; only a sample clock so slow that a
386+
full JTAG access spans fewer than ~2 sample cycles could read a
387+
transient idle before a pending re-arm propagates.
388+
389+
Note this is intentionally lossy: it discards any in-flight or
390+
startup-armed capture. Workflows that *want* the from-boot capture
391+
(including tests that validate ``STARTUP_ARM`` behavior) should not call
392+
it and should use :meth:`reset`/:meth:`arm` directly.
393+
394+
Args:
395+
timeout: overall budget, in seconds, to reach idle across all resets.
396+
poll_interval: STATUS poll spacing in seconds.
397+
max_resets: maximum soft resets to attempt.
398+
399+
Raises:
400+
RuntimeError: if the core is still busy after ``max_resets`` resets
401+
(e.g. ``STARTUP_ARM`` is still enabled because :meth:`configure`
402+
was not called first).
403+
"""
404+
read_status = getattr(
405+
self.transport, "read_reg_verified", self.transport.read_reg
406+
)
407+
deadline = time.monotonic() + timeout
408+
last_status = -1
409+
attempt = 0
410+
for attempt in range(max(1, max_resets)):
411+
with self.transport.transaction_lock():
412+
self._select_instance()
413+
self.transport.write_reg(_ADDR_CTRL, _CTRL_RESET)
414+
# A re-armed core (startup-arm CDC race) will not clear on its own,
415+
# so cap each attempt's wait and fall through to another reset.
416+
attempt_deadline = time.monotonic() + _FORCE_IDLE_SETTLE_S
417+
while True:
418+
with self.transport.transaction_lock():
419+
self._select_instance()
420+
last_status = read_status(_ADDR_STATUS)
421+
if (last_status & _STATUS_BUSY) == 0:
422+
return
423+
now = time.monotonic()
424+
if now >= attempt_deadline or now >= deadline:
425+
break
426+
time.sleep(poll_interval)
427+
if time.monotonic() >= deadline:
428+
break
429+
raise RuntimeError(
430+
f"force_idle: core still busy after {attempt + 1} reset(s); "
431+
f"last STATUS=0x{last_status & 0xFFFFFFFF:08x} "
432+
f"(armed={bool(last_status & _STATUS_ARMED)}, "
433+
f"triggered={bool(last_status & _STATUS_TRIGGERED)}, "
434+
f"done={bool(last_status & _STATUS_DONE)}). "
435+
f"If the core keeps re-arming, the runtime STARTUP_ARM register is "
436+
f"still set -- call configure() (startup_arm=False) before force_idle()."
437+
)
438+
332439
@staticmethod
333440
def _validate_probes(probes: List[ProbeSpec], sample_width: int) -> None:
334441
occupied: set[int] = set()
@@ -758,6 +865,7 @@ def capture_continuous(
758865
self,
759866
count: int = 0,
760867
timeout_per: float = 10.0,
868+
force_idle: bool = False,
761869
):
762870
"""Generator that yields successive ``CaptureResult`` objects.
763871
@@ -766,9 +874,18 @@ def capture_continuous(
766874
iteration calls :meth:`arm`, then :meth:`capture` to wait for the next
767875
trigger and read back. Yields *count* results, or runs indefinitely if
768876
*count* is 0.
877+
878+
On a bitstream that boots with ``STARTUP_ARM=1`` the first iteration
879+
would otherwise inherit the power-up capture window. Pass
880+
``force_idle=True`` to call :meth:`force_idle` once before the first
881+
arm so the stream starts from a known-idle state. Defaults to ``False``
882+
to preserve the original behavior (and leave the from-boot window
883+
observable for callers that want it).
769884
"""
770885
if self._config is None:
771886
raise RuntimeError("call configure() before capture_continuous()")
887+
if force_idle:
888+
self.force_idle()
772889
yielded = 0
773890
while count == 0 or yielded < count:
774891
self.arm()

0 commit comments

Comments
 (0)