A script for programming MCUs using the Dronecode Probe

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: