test: gate autobaud's logic floor, and say what an RC oscillator costs

The ATtiny13A run left autobaud's low-clock lock looking unreliable: 1/5 at
19200 on a 1.2 MHz RC part. The simulator does not reproduce it. At an exact
clock the calibration is solid down to ~36 cycles a bit and fails outright by
~31 — a sharp edge, not a fraying one — where the real part was already 1 in 5
by ~59. So the effect is the oscillator's own jitter and not backend logic, and
the two floors are different quantities about a factor of two apart.

Both are worth having. pureboot.autobaud gates a tight-bit point, since its two
existing clock points both sat near 100 cycles a bit and would not notice the
floor moving. The README carries the other half: both floors side by side, the
per-clock envelope measured on silicon, and the reason budgeting the logic's ~36
on an RC part is wrong.

It also carries the trap that produced the confusion. On a patched-vector chip an
erased application region walks back up into the loader, so every expired window
opens another and the host's retries eventually catch the pulse — 5/5 where the
same part with an application resident gives 1/5. Measure with an application in
place, or the fixture flatters the backend.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 16:44:21 +02:00
committed by BlackMark
parent 77dd45aeca
commit 7702c6b700
2 changed files with 54 additions and 1 deletions

View File

@@ -98,6 +98,27 @@ needs its divisor programmed) and that activation counts poll iterations rather
than seconds, since there is no clock to convert them against
(`PUREBOOT_AUTOBAUD_POLLS`, default 4,000,000).
**Pick the rate by cycles a bit, and leave the oscillator room.** What the
calibration can measure is bounded by how many clock cycles one bit lasts, so a
rate is only ever sensible relative to the clock. Two different floors matter:
| | cycles a bit |
|---|---|
| the logic's floor — exact clock, simulated | solid to ~36, fails outright by ~31 (`pureboot.autobaud` gates a point here) |
| a factory-trimmed internal RC, measured on an ATtiny13A | reliable at ~118; already locking 1 attempt in 5 by ~59 |
The gap is the oscillator's own jitter, and no exact-clock simulation shows it.
So on an RC part, **budget about 100 cycles a bit** — the same order as the
fixed-baud software receiver's floor — rather than the logic's ~36. Measured
envelope on that ATtiny13A, with an application resident: 9.6 and 4.8 MHz reach
115200, 1.2 MHz reaches 9600, 600 kHz reaches 4800, 128 kHz reaches 2400.
One trap in testing this: on a patched-vector chip an **erased** application
region walks straight back up into the loader, so every expired window opens
another one and the host's retries eventually catch the pulse. That reads as far
more reliable than the same part with an application resident, which gets one
window per reset. Measure with an application in place.
A downstream project brings its usual libavr setup (the `libavr` target, the
chip via the `LIBAVR_MCU` toolchain preset), consumes this directory, and
states its deployment — an ATmega328P on its shipped 1 MHz fuses with the

View File

@@ -141,13 +141,45 @@ def main():
print(f" {label}: locked at {hz} Hz / {baud} Bd, flash+EEPROM verified"
+ (", hand-over ok" if hand_over else ""))
def must_lock(hz, baud, label):
"""The calibration alone, at a tight bit period. Nothing is programmed —
the question is only whether the loader can still measure the pulse."""
dump = os.path.join(workdir, f"flash_{label}.bin")
device = pbsim.Device(device_bin, elf, mcu, str(hz), base_hex, page, baud, dump,
link="sw:B0,B1")
try:
port = pb.Port(device.pty, baud)
try:
live = pb.Loader(port).connect_autobaud(15)
if live.version != pb.NEWEST_LOADER:
fail(f"{label}: loader reports pureboot {live.version}")
finally:
port.close()
finally:
device.stop()
print(f" {label}: locked at {hz} Hz / {baud} Bd ({hz / baud:.0f} cycles a bit)")
# The app fixture is built for one clock; the hand-over banners there. A
# second point at double that clock, same loader binary, proves the lock is
# measured, not baked in — the whole point of autobaud. (Doubling keeps the
# bit period healthy; halving would drop it below the software UART's floor.)
round_trip(app_hz, app_baud, "clock-a", hand_over=True)
round_trip(app_hz * 2, app_baud, "clock-b", hand_over=False)
print("pbautobaud: calibration lock and flash/EEPROM/fuse round-trip pass at both clocks")
# Both points above sit near 100 cycles a bit, which is comfortable. The
# calibration's real floor is far tighter, and it is worth a gate: measured
# here, the lock is solid down to ~36 cycles a bit and fails outright by ~31
# — a sharp edge, not a fraying one. This pins the tightest standard rate the
# fixture's clock reaches, so a change that raises the floor is caught.
#
# It does *not* bound what a real deployment can use. On silicon the
# oscillator's own jitter costs roughly a factor of two: an ATtiny13A on its
# factory RC trim was reliable at ~118 cycles a bit and already locking only
# 1 attempt in 5 by ~59, which no exact-clock simulation can show. The
# deployable envelope is a README matter; this is the logic's floor.
must_lock(app_hz, app_baud * 2, "tight-bit")
print("pbautobaud: calibration lock and flash/EEPROM/fuse round-trip pass at both clocks, "
"and the tight bit period still locks")
if __name__ == "__main__":