Compare commits

...
Sign in to create a new pull request.

2 commits

Author SHA1 Message Date
01c5e0930e Guard env:versapad's upload against overwriting the bootloader
pio run -e versapad --target upload writes the app starting at
0x0000 and silently destroyed the installed UF2 bootloader twice
today during testing -- no warning, no error, just a board that
stopped responding to the boot-key hold.

upload_openocd.py now checks for the bootloader (verify_image against
the locally built bootloader/.pio/build/versapad_bootloader/firmware.bin)
before an env:versapad upload and refuses if one is present, pointing
at env:versapad_usb instead. Fails closed: an inconclusive check (e.g.
bootloader not built locally, SWD not responding) blocks rather than
proceeding on a guess -- confirmed necessary the hard way, since a
"fail open" first attempt let the destructive upload through silently.
Scoped to PIOENV == "versapad" only, since bootloader/platformio.ini's
own upload reuses this same script and must always be allowed to write
0x0000. A new erase-bootloader-and-flash custom target remains as the
explicit, deliberate override.

Documented the workflow (bootloader is its own PlatformIO project,
flashed once via SWD; versapad_usb is the normal path afterward;
versapad's upload is now guarded) in README.md and
doc/10_usb_bootloader.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 23:27:02 +02:00
cd27639e44 Merge feature/usb-bootloader: USB firmware flashing via UF2 bootloader
Adds a UF2 bootloader (bootloader/) that lets the app firmware be
updated over USB instead of requiring an Atmel-ICE, after a one-time
SWD bootloader flash. Since this board has no dedicated reset/boot
button, bootloader entry is done by holding the bottom-right Cherry MX
key during power-on/reset.

Verified end to end on real hardware, including two hardware bugs
found and fixed along the way: a missing DSB/ISB barrier in the
bootloader's jump-to-app sequence (hard-faulted on every standalone
boot), and a USB PID collision with Adafruit's own Gemma M0 bootloader
PID. See doc/10_usb_bootloader.md for the full writeup, memory layout,
and known limitations.
2026-08-05 22:50:20 +02:00
4 changed files with 151 additions and 8 deletions

View file

@ -55,6 +55,19 @@ cd bootloader && pio run -e versapad_bootloader --target upload # einmalig
pio run -e versapad_usb --target upload # danach jedes App-Update
```
`bootloader/` ist ein eigenständiges PlatformIO-Projekt (eigene
`platformio.ini`, kein Arduino-Framework). Für die PlatformIO-IDE-Buttons in
VS Code müsste der Ordner separat als eigener Workspace geöffnet werden; über
die Kommandozeile reicht `cd bootloader && pio run ...` im selben Fenster.
Sobald der Bootloader installiert ist, **verweigert `versapad --target
upload` den normalen Upload** — dieser Weg schreibt ab `0x0000` und würde den
Bootloader sonst kommentarlos überschreiben (`upload_openocd.py` prüft das
vorher automatisch). Bauen und Debuggen über `versapad` bleiben uneingeschränkt
möglich, nur der Upload ist betroffen. Für App-Updates danach `versapad_usb`
verwenden; bewusst zurück zu reinem SWD-Betrieb geht über
`pio run -e versapad -t erase-bootloader-and-flash`.
Details, Speicherlayout und die Bootloader-Aktivierung (kein physischer
Reset-Taster auf dieser Platine) stehen in
[10_usb_bootloader.md](doc/10_usb_bootloader.md).

5
bootloader/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
.pio
.vscode/.browse.c_cpp.db*
.vscode/c_cpp_properties.json
.vscode/launch.json
.vscode/ipch

View file

@ -56,6 +56,30 @@ erkennt den Schreibzugriff und springt selbständig in die neue App.
`upload_uf2.py` sucht das Laufwerk aktuell nur über die Windows-API
(`GetVolumeInformationW`) — keine macOS/Linux-Unterstützung.
## Schutz gegen versehentliches Überschreiben (env:versapad)
`env:versapad` (SWD, `boards/versapad_nobl.json`) schreibt die App-Firmware
ab `0x0000` und überschreibt damit einen installierten Bootloader
kommentarlos — genau das ist am 2026-08-05 während der Entwicklung passiert
(zweimal). `upload_openocd.py` prüft das seither vor jedem `versapad`-Upload:
- vergleicht per `verify_image` gegen die lokal gebaute
`bootloader/.pio/build/versapad_bootloader/firmware.bin`
- Bootloader erkannt → Upload wird verweigert, mit Hinweis auf
`versapad_usb` oder den expliziten Override
- lässt sich die Prüfung nicht eindeutig durchführen (z. B. `bootloader/`
noch nicht gebaut, oder die SWD-Verbindung antwortet nicht) → wird
sicherheitshalber ebenfalls verweigert, nicht durchgelassen
- gilt nur für `env:versapad``env:versapad_bootloader` nutzt dasselbe
Skript (`extra_scripts = ../upload_openocd.py`) und schreibt bewusst immer
auf `0x0000`, ungeprüft
Bewusstes Überschreiben (zurück zu reinem SWD-Betrieb ohne Bootloader):
```bash
pio run -e versapad -t erase-bootloader-and-flash
```
## Herkunft und Details
Der Bootloader ist abgeleitet und stark eingekürzt aus
@ -87,6 +111,12 @@ stehen in `bootloader/README.md` unter "Hardwaretest"; kurz zusammengefasst:
immer `.bin` mit expliziter Adresse und getrennte OpenOCD-Aufrufe
verwenden — Details in `bootloader/README.md`, "Achtung bei manuellem
SWD-Flashen".
- **`env:versapad` überschreibt den Bootloader kommentarlos.** Ein normaler
`pio run -e versapad --target upload` (der alte, gewohnte SWD-Weg für
App-Updates) schreibt ab `0x0000` und hat den Bootloader dabei zweimal
ohne jede Warnung zerstört. Fix: automatischer Presence-Check in
`upload_openocd.py`, siehe "Schutz gegen versehentliches Überschreiben"
oben.
## Bekannte Einschränkungen

View file

@ -2,23 +2,118 @@ Import("env")
import os
import subprocess
def upload_via_openocd(source, target, env):
pkg_dir = env.PioPlatform().get_package_dir("tool-openocd")
openocd = os.path.join(pkg_dir, "bin", "openocd.exe")
scripts = os.path.join(pkg_dir, "scripts")
firmware = str(source[0]) # .elf path
# Real, already-built bootloader image used as the reference for the presence
# check below -- a raw synthetic probe blob turned out unreliable with
# verify_image (silent no-op on tiny files), whereas verify_image against a
# real firmware .bin has been solid throughout this project's bring-up.
BOOTLOADER_BIN = os.path.join(
"bootloader", ".pio", "build", "versapad_bootloader", "firmware.bin"
)
def _openocd_paths(env):
pkg_dir = env.PioPlatform().get_package_dir("tool-openocd")
return (
os.path.join(pkg_dir, "bin", "openocd.exe"),
os.path.join(pkg_dir, "scripts"),
)
def _run_openocd(env, extra_cmd, capture=False):
openocd, scripts = _openocd_paths(env)
cmd = [
openocd,
"-s", scripts,
"-f", "interface/cmsis-dap.cfg",
"-f", "target/at91samdXX.cfg",
"-c", 'program "{}" verify reset; shutdown'.format(firmware.replace("\\", "/"))
"-c", extra_cmd,
]
print(" ".join(cmd))
result = subprocess.run(cmd)
if capture:
return subprocess.run(cmd, capture_output=True, text=True)
return subprocess.run(cmd)
def _bootloader_present(env):
bootloader_bin = env.subst(
os.path.join("$PROJECT_DIR", BOOTLOADER_BIN)
)
if not os.path.isfile(bootloader_bin):
print("WARNING: {} not found (build it with".format(BOOTLOADER_BIN))
print("'cd bootloader && pio run -e versapad_bootloader') -- can't check")
print("for an installed bootloader, refusing to flash as a precaution.")
print("Use 'pio run -e versapad -t erase-bootloader-and-flash' to override.")
return True
result = _run_openocd(
env,
'init; reset halt; verify_image "{}" 0x0; shutdown'.format(
bootloader_bin.replace("\\", "/")
),
capture=True,
)
output = result.stdout + result.stderr
if "checksum mismatch" in output or "diff " in output:
return False # something else is at 0x0000 -- not this bootloader
if "halted due to debug-request" in output and "Error" not in output:
return True # verify_image is silent on a match; absence of a
# mismatch/error after a successful connect means it matched
print("WARNING: could not read flash to check for an installed bootloader")
print("(inconclusive) -- refusing to flash as a precaution.")
print("Use 'pio run -e versapad -t erase-bootloader-and-flash' to override.")
return True
def _flash(env, firmware):
result = _run_openocd(
env, 'program "{}" verify reset; shutdown'.format(firmware.replace("\\", "/"))
)
if result.returncode != 0:
env.Exit(1)
def upload_via_openocd(source, target, env):
firmware = str(source[0]) # .elf path
# This script is shared with bootloader/platformio.ini's own upload
# (env:versapad_bootloader), which legitimately writes 0x0000 every time
# -- the guard below only makes sense for the app-without-bootloader
# target (env:versapad).
if env["PIOENV"] == "versapad" and _bootloader_present(env):
print("=" * 78)
print("REFUSING TO FLASH: a UF2 bootloader looks like it's installed at 0x0000.")
print("")
print("This target (env:versapad) writes the app starting at 0x0000 and")
print("would silently overwrite it -- the board would lose its USB flashing")
print("path (see doc/10_usb_bootloader.md).")
print("")
print("Use instead:")
print(" pio run -e versapad_usb --target upload # flash over USB, keeps the bootloader")
print("or, if you deliberately want to erase the bootloader and go back to")
print("standalone SWD-only operation:")
print(" pio run -e versapad -t erase-bootloader-and-flash")
print("=" * 78)
env.Exit(1)
_flash(env, firmware)
env.Replace(UPLOADCMD=upload_via_openocd)
def erase_bootloader_and_flash(*_args, **_kwargs):
firmware = env.subst(os.path.join("$BUILD_DIR", "${PROGNAME}.elf"))
print("Overwriting 0x0000..0x1FAFF -- any installed UF2 bootloader will be erased.")
_flash(env, firmware)
env.AddCustomTarget(
name="erase-bootloader-and-flash",
dependencies=["buildprog"],
actions=[erase_bootloader_and_flash],
title="Erase bootloader + flash (standalone SWD)",
description=(
"Unconditionally overwrites 0x0000..0x1FAFF, wiping any installed UF2 "
"bootloader. Use only to return to standalone SWD-only operation."
),
)