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>
This commit is contained in:
Julian Appel 2026-08-05 23:27:02 +02:00
parent cd27639e44
commit 01c5e0930e
4 changed files with 151 additions and 8 deletions

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."
),
)