Save this as a Python file and run:
#!/usr/bin/env python
# Author: Pavel Kirienko <pavel.kirienko@zubax.com>
from __future__ import annotations
import os
import sys
import logging
import pathlib
import tempfile
import argparse
import subprocess
import dataclasses
import urllib.request
from functools import partial
from typing import Optional
TOOLCHAIN_PREFIX = "arm-none-eabi-"
_logger = logging.getLogger(__name__.strip("_"))
class GDBError(RuntimeError):
pass
class GDBInterface:
def __init__(self, port_name: str, toolchain_prefix: str):
self._port_name: str = port_name
self._toolchain_prefix: str = toolchain_prefix
def _check_port(self) -> None:
try:
with open(self._port_name):
pass
except FileNotFoundError:
raise FileNotFoundError(f"GDB port {self._port_name!r} does not exist; debugger disconnected?") from None
def _run_tc(self, fmt, *a):
self._check_port()
cmd = self._toolchain_prefix + fmt
if a:
cmd %= a
p = subprocess.run(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if p.stdout:
_logger.debug("Toolchain call stdout:\n%s", p.stdout.decode())
if p.stderr:
_logger.debug("Toolchain call stderr:\n%s", p.stderr.decode())
if p.returncode != 0:
raise GDBError(f"GDB command {cmd!r} failed with exit code {p.returncode}")
def write_memory(self, start_address: int, data: bytes) -> None:
with tempfile.TemporaryDirectory("-drwatson") as tmpdir:
_logger.debug("Executable scratchpad directory: %r", tmpdir)
fn = partial(os.path.join, tmpdir)
# Generating ELF from the provided data.
# We can't just use "restore" (which would have been so much easier) because it doesn't support writing
# to flash, failing with message "Writing to flash memory forbidden in this context".
with open(fn("fw.bin"), "wb") as f:
f.write(data)
with open(fn("link.ld"), "w") as f:
f.write("SECTIONS { . = %s; .text : { *(.text) } }" % start_address)
self._run_tc("ld -b binary -r -o %s %s", fn("tmp.elf"), fn("fw.bin"))
self._run_tc(
"objcopy --rename-section .data=.text --set-section-flags .data=alloc,code,load %s", fn("tmp.elf")
)
self._run_tc("ld %s -T %s -o %s", fn("tmp.elf"), fn("link.ld"), fn("output.elf"))
# Loading the ELF onto the target.
with open(fn("script.gdb"), "w") as f:
f.write(
"\n".join(
[
"target extended-remote %s" % self._port_name,
"mon swdp_scan",
"attach 1",
"set mem inaccessible-by-default off",
"load",
"kill",
"quit 0",
]
)
)
self._run_tc("gdb %s --batch -x %s -return-child-result", fn("output.elf"), fn("script.gdb"))
def read_memory(self, start_address: int, size: int) -> bytes:
with tempfile.TemporaryDirectory("-drwatson") as tmpdir:
fn = partial(os.path.join, tmpdir)
with open(fn("script.gdb"), "w") as f:
f.write(
"\n".join(
[
"target extended-remote %s" % self._port_name,
"mon swdp_scan",
"attach 1",
"set mem inaccessible-by-default off",
"dump bin memory %s 0x%x 0x%x" % (fn("dump.bin"), start_address, start_address + size),
"kill",
"quit 0",
]
)
)
self._run_tc("gdb --batch -x %s", fn("script.gdb"))
try:
data = open(fn("dump.bin"), "rb").read()
except FileNotFoundError:
raise GDBError("Could not read memory. Is the interface connected?") from None
if len(data) != size:
raise GDBError(f"Memory dump size mismatch: expected to read {size} bytes, got {len(data)} bytes")
return data
@dataclasses.dataclass(frozen=True)
class Parameters:
image_uri: str
offset: int
gdb_port: str
def execute(par: Parameters) -> None:
iface = GDBInterface(par.gdb_port, TOOLCHAIN_PREFIX)
_logger.info("Image URI: %s", par.image_uri)
image = urllib.request.urlopen(par.image_uri, timeout=5).read()
assert isinstance(image, bytes)
_logger.info(
"Downloaded image of %.3f KiB from %r; writing at 0x%08x with verification...",
len(image) / 1024,
par.image_uri,
par.offset,
)
iface.write_memory(par.offset, image)
_logger.debug("Verifying...")
ver_image = iface.read_memory(par.offset, len(image))
if ver_image != image:
mismatch_off = next(i for i, (left, right) in enumerate(zip(image, ver_image)) if left != right)
mismatch = image[mismatch_off], ver_image[mismatch_off]
with open("unsuccessful_verification_result.bin", "wb") as f:
f.write(ver_image)
raise RuntimeError(
f"Write verification failed; maybe the device is damaged? "
f"First mismatch offset #{mismatch_off} of {len(image)} bytes: expected 0x{mismatch[0]:02x} got 0x{mismatch[1]:02x}"
)
_logger.info("Image written successfully")
def _init() -> Parameters:
parser = argparse.ArgumentParser(formatter_class=argparse.RawTextHelpFormatter, description=globals()["__doc__"])
parser.add_argument(
"offset",
type=lambda x: int(x, 0),
help="Offset from the origin where to write the image",
)
parser.add_argument(
"image",
help="Path or URI of the firmware binary image (*.bin).",
)
parser.add_argument(
"--gdb-port",
"-g",
metavar="LOCATION",
help="GDB port to use with 'target extended-remote'; e.g., /dev/ttyACM0. "
"If not specified, the correct port will be auto-detected, unless there is more than one available.",
)
parser.add_argument(
"--verbose",
"-v",
action="count",
default=0,
help="Enable verbose output. Twice for extra verbosity.",
)
args = parser.parse_args()
logging.basicConfig(
stream=sys.stderr,
format="%(asctime)s %(levelname)-5.5s %(name)s: %(message)s",
level={
0: logging.WARNING,
1: logging.INFO,
}.get(args.verbose, logging.DEBUG),
)
_logger.debug("CLI arguments: %r", args)
if not args.gdb_port:
options = list(pathlib.Path("/dev/serial/by-id/").glob("usb*Black*Magic*Probe*if00"))
if not options:
raise RuntimeError("Debugger port is not specified explicitly and automatic search returned no matches")
if len(options) > 1:
raise RuntimeError(f"Not sure which debugger to use, please specify one explicitly: {options}")
args.gdb_port = str(options[0].resolve())
if not "://" in args.image:
args.image = "file://" + urllib.request.pathname2url(os.path.abspath(args.image))
par = Parameters(
image_uri=str(args.image),
offset=int(args.offset),
gdb_port=str(args.gdb_port),
)
_logger.debug("Using parameters: %r", par)
return par
if __name__ == "__main__":
try:
execute(_init())
except KeyboardInterrupt:
_logger.warning("Interrupted")
exit(2)
except Exception as ex:
_logger.debug("Unhandled exception: %r", ex, exc_info=True)
_logger.fatal("Failure (use --help to get usage info): %s: %s", type(ex).__name__, str(ex) or repr(ex))
exit(1)
The script has no external dependencies aside from the Python runtime itself and a working GNU ARM GDB executable. Run with --help
for usage info.
The script is similar to and is based on this one, except that it is general-purpose and can be used with any MCU: