diff --git a/.gitignore b/.gitignore index 378eac2..c095e69 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ build +__pycache__/ diff --git a/Makefile b/Makefile index 4ed3ea9..3c862f2 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ METAL_RELFILES := $(METAL_ASM_SRCFILES:%.asm=$(BUILDDIR)/metal/%.rel) \ METAL_CC := sdcc METAL_AS := sdasz80 METAL_MAKEBIN := makebin -METAL_FLAGS := -mz80 --no-std-crt0 --code-loc 0xC080 --code-size 0x0F80 --data-loc 0xF000 +METAL_FLAGS := -mz80 --no-std-crt0 --code-loc 0xC080 --code-size 0x0F80 --data-loc 0xF000 --opt-code-size METAL_CPPFLAGS := -I src -I $(BUILDDIR)/metal/generated --disable-warning 283 METAL_BIN_SIZE := 32768 METAL_RAM_SIZE := 16384 @@ -48,7 +48,8 @@ $(BUILDDIR)/metal/$(TARGET).flat.bin: $(BUILDDIR)/metal/$(TARGET).ihx $(METAL_MAKEBIN) -s $(METAL_IMAGE_SIZE) $< $@ $(BUILDDIR)/metal/$(TARGET).prog.bin: $(BUILDDIR)/metal/$(TARGET).flat.bin $(TINYBASIC_BUILD)/tinybasic.bin - @payload_end=$$(awk '$$1 == "DEF" && $$2 == "resident_image_end" { print $$3 }' $(BUILDDIR)/metal/$(TARGET).noi); \ + @set -e; \ + payload_end=$$(awk '$$1 == "DEF" && $$2 == "resident_image_end" { print $$3 }' $(BUILDDIR)/metal/$(TARGET).noi); \ payload_size=$$((payload_end - $(RESIDENT_BASE))); \ test $$payload_size -gt 0; \ test $$payload_size -le $$(( $(TINYBASIC_ROM_OFFSET) - $(RESIDENT_ROM_OFFSET) )); \ diff --git a/README.md b/README.md index 14a1313..8af579e 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,12 @@ This project provides a resident BIOS, an interactive monitor, and ROM-resident applications for a Z80 system with a UART/CTC card at I/O base `20`. At reset, a small ROM bootstrap copies only the BIOS and monitor into the top -16 KiB of RAM at `C000`. The BIOS initializes interrupt mode 2 and the UART, -then starts the monitor. Applications remain in ROM and execute in place while -calling the stable BIOS jump table in high RAM. Tiny BASIC is the first such -application, linked at ROM address `1000`. +16 KiB at `C000`. Before copying, it writes `06` to banking port `00`: ROM +remains enabled, banking is enabled, and `C000-FFFF` maps to the upper physical +16 KiB. The BIOS then initializes interrupt mode 2 and the UART and starts the +monitor. Applications remain in ROM and execute in place while calling the +stable BIOS jump table in high RAM. Tiny BASIC is the first such application, +linked at ROM address `1000`. Connect a terminal at **2400 baud, 8 data bits, no parity, 1 stop bit**. Commands are case-insensitive and all numeric arguments are hexadecimal; an @@ -17,6 +19,7 @@ optional `0x` prefix is accepted. | --- | --- | | `D 8000` | Dump 40 bytes starting at address 8000 | | `D 8000 20` | Dump 20 bytes starting at address 8000 | +| `C 8000 BFFF` | Compute the 16-bit additive checksum of an inclusive range | | `W 8000 3E 41 D3 25 C9` | Write bytes starting at address 8000 | | `I 20` | Read I/O port 20 | | `O 20 57` | Write 57 to I/O port 20 | @@ -29,12 +32,41 @@ optional `0x` prefix is accepted. `RET` returns to the monitor. Code that changes the stack pointer, interrupt mode, or UART/CTC setup must restore it before returning. +`C` adds every byte from the start through the end address, inclusive, and +prints the low 16 bits of the sum as four hexadecimal digits. The end address +must be greater than or equal to the start address. + Build the ROM and programmer-format images with: ```sh make ``` +## Uploading Intel HEX files + +`program_ihex.py` can write an Intel HEX file to memory through the running +monitor. It sends data in small blocks with `W`, checks every block with `C`, +and retries a failed write or checksum before giving up. It requires Python 3 +and [pySerial](https://pyserial.readthedocs.io/). + +```sh +python3 -m pip install pyserial +python3 program_ihex.py /dev/ttyUSB0 program.hex +``` + +The default connection is 2400 baud, the default block size is 16 bytes, and +three retries are allowed after the initial attempt. For example: + +```sh +python3 program_ihex.py /dev/ttyUSB0 program.hex --retries 5 --block-size 24 +``` + +Use a Windows port such as `COM3` in place of `/dev/ttyUSB0`. Run +`python3 program_ihex.py --help` for all options. The monitor must be sitting at +its `>` prompt before starting the upload. Only data records present in the HEX +file are written; gaps are left unchanged. The HEX addresses must refer to +writable memory and must not overwrite the running monitor at `C000-FFFF`. + The outputs are: - `build/metal/monitor.bin`: complete 32 KiB ROM image @@ -44,8 +76,8 @@ The outputs are: The current compact image is 6,157 bytes. Its ROM layout is: - `0000-007F`: reset/copy bootstrap -- `0080-0F9D`: packed BIOS+monitor image copied to RAM at `C000-CF1D` -- `0F9E-0FFF`: unused space reserved for the resident image to grow +- `0080-0FEA`: packed BIOS+monitor image copied to RAM at `C000-CF6A` +- `0FEB-0FFF`: unused space reserved for the resident image to grow - `1000-180C`: Tiny BASIC, executed directly from ROM - `180D-7FFF`: unused space available for additional ROM content @@ -58,13 +90,20 @@ The resulting RAM layout is: - `8000-AFFF`: Tiny BASIC workspace and BASIC program text - `B000-B077`: Tiny BASIC variables and input buffer - `B078-BFFF`: Tiny BASIC stack space -- `C000-CF1D`: resident BIOS, vectors, services, and monitor -- `CF1E-EFFF`: free space for resident-image growth +- `C000-CF6A`: resident BIOS, vectors, services, and monitor +- `CF6B-EFFF`: free space for resident-image growth - `F000-FFFF`: BIOS globals, UART buffers, and monitor stack The BIOS and monitor continue to work if low ROM is disabled. ROM-resident applications naturally require ROM to remain mapped while they execute. +The banking latch bits used by the bootstrap are bit 0 `ROM_DISABLE`, bit 1 +`BANK_SELECT`, and bit 2 `BANK_ENABLE`. Its initial value `06` leaves ROM on and +selects the lower physical 48 KiB plus the upper physical 16 KiB. Writing `07` +later disables ROM without changing that split mapping. Writing `05` selects +the complete upper physical 64 KiB; the monitor remains available because its +`C000-FFFF` image was placed in that upper bank during boot. + ## BIOS interface Assembly applications include `include/bios.inc` and call these fixed entries: @@ -78,10 +117,25 @@ Assembly applications include `include/bios.inc` and call these fixed entries: | `C05C` | `BIOS_IO_IN` | Read port `A`, returning the value in `A` | | `C05F` | `BIOS_DELAY_MS` | Delay approximately `HL` milliseconds | | `C062` | `BIOS_MONITOR` | Warm-start the monitor; does not return | +| `C065` | `BIOS_BANK` | `A=0`: split lower 48K/upper 16K; otherwise select upper 64K | +| `C068` | `BIOS_ROM_DISABLE` | Disable ROM while retaining the selected RAM mapping | The service wrappers preserve working registers except for documented return values. The monitor uses the corresponding C API declared in `bios.h`. +The banking latch at port `00` is write-only, so the BIOS keeps a persistent +shadow byte initialized to `06` by the copied resident image. `BIOS_BANK` +always leaves banking enabled and changes only the bank selection while +retaining the shadowed ROM-disable bit and auxiliary-output bits. +`BIOS_ROM_DISABLE` sets the ROM-disable bit and is idempotent. Code should not +write port `00` through `BIOS_IO_OUT` or the monitor's `O` command after using +these services, because such writes bypass the BIOS shadow state. + +The equivalent C API is `bios_bank(uint8_t bank)` and +`bios_rom_disable(void)`. A ROM-resident caller cannot return normally after +selecting the complete upper 64 KiB or disabling ROM, because its return +address is no longer mapped; it must arrange its continuation in RAM. + Project organization: - `boot.asm`: ROM bootstrap, high vectors, BIOS jump table, and assembly glue diff --git a/apps/README.md b/apps/README.md index 1fe8a18..1c2fa9f 100644 --- a/apps/README.md +++ b/apps/README.md @@ -21,6 +21,11 @@ the `I` register, and the BIOS UART/CTC configuration intact. They may return normally if they preserve the caller's stack, or jump to `BIOS_MONITOR` for a non-returning monitor warm start after replacing the stack pointer. +Use `BIOS_BANK` and `BIOS_ROM_DISABLE` for the write-only banking latch rather +than writing port `00` directly. The BIOS keeps the ROM-disable state when the +RAM bank selection changes. A ROM application must not expect a normal `RET` +after it maps out the ROM containing its return address. + To add an application, add its assembler target and ROM offset to the Makefile, overlay its binary in the `monitor.prog.bin` recipe, and add a monitor command or use `G address` to launch it. diff --git a/bios.h b/bios.h index 51bb4e8..fc8c72b 100644 --- a/bios.h +++ b/bios.h @@ -11,6 +11,10 @@ char bios_getc(void); uint8_t bios_try_getc(void); void bios_flush(void); +/* Write-only banking latch services. */ +void bios_bank(uint8_t bank); +void bios_rom_disable(void); + /* Low-level routines implemented by the resident assembly BIOS. */ void io_out(uint8_t port, uint8_t value); uint8_t io_in(uint8_t port); diff --git a/boot.asm b/boot.asm index c531770..76b7af3 100644 --- a/boot.asm +++ b/boot.asm @@ -2,6 +2,12 @@ STACK_TOP = 0xFFFF DELAY_LOOPS_PER_MS = 40 RESIDENT_BASE = 0xC000 RESIDENT_ROM_SOURCE = 0x0080 +BANK_CONTROL_PORT = 0x00 +BOOT_BANK_MAPPING = 0x06 ; ROM on, banking on, upper 16K at C000-FFFF +ROM_DISABLE_BIT = 0x01 +BANK_SELECT_BIT = 0x02 +BANK_ENABLE_BIT = 0x04 +BANK_MODE_CLEAR_MASK = 0xF9 ; preserve ROM-disable and auxiliary outputs .include "bios.inc" @@ -16,6 +22,9 @@ RESIDENT_ROM_SOURCE = 0x0080 .globl _bios_try_getc .globl _bios_getc .globl _bios_init + .globl _bios_bank + .globl _bios_rom_disable + .globl _bank_control_shadow .globl resident_image_end .globl s__DATA .globl l__DATA @@ -30,6 +39,10 @@ RESIDENT_ROM_SOURCE = 0x0080 .org 0x08 boot_copy: di + ; Keep ROM enabled while mapping the upper physical 16K at C000-FFFF. + ; The resident copy therefore survives either banked mode after boot. + ld a, #BOOT_BANK_MAPPING + out (BANK_CONTROL_PORT), a ld sp, #STACK_TOP ld hl, #RESIDENT_ROM_SOURCE ld de, #RESIDENT_BASE @@ -66,7 +79,8 @@ boot_copy: ; Stable BIOS jump table for ROM-resident applications. ; C050 putc(A), C053 try_getc(), C056 getc(), C059 io_out(A,L), -; C05C io_in(A), C05F delay_ms(HL), C062 monitor warm start. +; C05C io_in(A), C05F delay_ms(HL), C062 monitor warm start, +; C065 bank(A), C068 disable ROM. .org BIOS_PUTC jp bios_service_putc .org BIOS_TRY_GETC @@ -81,6 +95,17 @@ boot_copy: jp bios_service_delay_ms .org BIOS_MONITOR jp init +.org BIOS_BANK + jp _bios_bank +.org BIOS_ROM_DISABLE + jp _bios_rom_disable + +; Port 00 is write-only. Keep its last BIOS-managed value outside the C data +; areas so a monitor warm start does not reset it. The bootstrap establishes +; this same value in the hardware before the resident image is copied. +.org RESIDENT_BASE + 0x70 +_bank_control_shadow:: + .db BOOT_BANK_MAPPING .area _HOME init: @@ -135,6 +160,41 @@ resident_image_end: .area _CODE +; Select a RAM mapping while retaining the shadowed ROM-disable bit. +; Input: A=0 selects lower 48K + upper 16K; any other value selects upper 64K. +; Both modes keep banking enabled. A and flags are preserved. +_bios_bank:: + push af + or a + jr z, bios_bank_split + + ld a, (_bank_control_shadow) + and #BANK_MODE_CLEAR_MASK + or #BANK_ENABLE_BIT + jr bios_bank_update + +bios_bank_split: + ld a, (_bank_control_shadow) + and #BANK_MODE_CLEAR_MASK + or #(BANK_ENABLE_BIT | BANK_SELECT_BIT) + +bios_bank_update: + ld (_bank_control_shadow), a + out (BANK_CONTROL_PORT), a + pop af + ret + +; Permanently hide ROM until reset, preserving the selected RAM mapping. +; A and flags are preserved. +_bios_rom_disable:: + push af + ld a, (_bank_control_shadow) + or #ROM_DISABLE_BIT + ld (_bank_control_shadow), a + out (BANK_CONTROL_PORT), a + pop af + ret + ; BIOS ABI services preserve registers except documented return values. bios_service_putc: push af diff --git a/include/bios.inc b/include/bios.inc index bae00d5..7de3f54 100644 --- a/include/bios.inc +++ b/include/bios.inc @@ -6,3 +6,5 @@ BIOS_IO_OUT = 0xC059 ; A = port, L = value; preserves all registers BIOS_IO_IN = 0xC05C ; A = port; returns value in A BIOS_DELAY_MS = 0xC05F ; HL = milliseconds; preserves all registers BIOS_MONITOR = 0xC062 ; warm-start the monitor; does not return +BIOS_BANK = 0xC065 ; A=0 split 48K/16K, otherwise upper 64K +BIOS_ROM_DISABLE = 0xC068 ; disable ROM while preserving the bank selection diff --git a/monitor.c b/monitor.c index d6b3b0c..d919ec6 100644 --- a/monitor.c +++ b/monitor.c @@ -91,14 +91,15 @@ static uint8_t split_line(char *line, char **argv) static void print_help(void) { bios_puts( - "Commands (hexadecimal numbers):\r\n" - " D|DUMP address [length] Dump memory (default 0040, max 0100)\r\n" - " W|WRITE address byte ... Write one or more memory bytes\r\n" - " I|IN port Read an I/O port\r\n" - " O|OUT port byte Write an I/O port\r\n" - " G|GO address Execute code; RET returns here\r\n" - " B|BASIC Start ROM Tiny BASIC at 1000\r\n" - " H|HELP|? Show this help\r\n"); + "Commands (hex numbers):\r\n" + " D addr [len] Dump memory\r\n" + " C start end 16-bit checksum, inclusive\r\n" + " W addr byte... Write memory\r\n" + " I port Read I/O\r\n" + " O port byte Write I/O\r\n" + " G addr Execute\r\n" + " B Tiny BASIC\r\n" + " H Help\r\n"); } static void command_dump(uint8_t argc, char **argv) @@ -131,6 +132,28 @@ static void command_dump(uint8_t argc, char **argv) } } +static void command_checksum(uint8_t argc, char **argv) +{ + uint16_t address; + uint16_t end; + uint16_t checksum = 0; + + if (argc != 3 || !parse_hex(argv[1], &address) || + !parse_hex(argv[2], &end) || end < address) { + bios_puts("Usage: C start end\r\n"); + return; + } + + for (;;) { + checksum = (uint16_t)(checksum + *(volatile uint8_t *)address); + if (address == end) break; + ++address; + } + + print_hex16(checksum); + bios_puts("\r\n"); +} + static void command_write(uint8_t argc, char **argv) { uint16_t address; @@ -222,6 +245,8 @@ static void execute_line(char *line) print_help(); } else if (strings_equal(argv[0], "d") || strings_equal(argv[0], "dump")) { command_dump(argc, argv); + } else if (strings_equal(argv[0], "c") || strings_equal(argv[0], "checksum")) { + command_checksum(argc, argv); } else if (strings_equal(argv[0], "w") || strings_equal(argv[0], "write")) { command_write(argc, argv); } else if (strings_equal(argv[0], "i") || strings_equal(argv[0], "in")) { diff --git a/program_ihex.py b/program_ihex.py new file mode 100755 index 0000000..0cdf799 --- /dev/null +++ b/program_ihex.py @@ -0,0 +1,296 @@ +#!/usr/bin/env python3 +"""Program an Intel HEX file through the Z80 UART monitor.""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +try: + import serial +except ImportError: + print( + "error: pySerial is required; install it with: " + "python3 -m pip install pyserial", + file=sys.stderr, + ) + raise SystemExit(2) + + +MAX_BLOCK_SIZE = 24 # Largest W command that fits the monitor's 80-byte line. + + +class ProgramError(Exception): + pass + + +def load_ihex(path: Path) -> dict[int, int]: + """Return the 16-bit-addressed data contained in an Intel HEX file.""" + memory: dict[int, int] = {} + address_base = 0 + saw_eof = False + + try: + lines = path.read_text(encoding="ascii").splitlines() + except (OSError, UnicodeError) as exc: + raise ProgramError(f"cannot read {path}: {exc}") from exc + + for line_number, text in enumerate(lines, 1): + text = text.strip() + if not text: + continue + if saw_eof: + raise ProgramError(f"{path}:{line_number}: data after EOF record") + if not text.startswith(":"): + raise ProgramError(f"{path}:{line_number}: record does not start with ':'") + try: + record = bytes.fromhex(text[1:]) + except ValueError as exc: + raise ProgramError(f"{path}:{line_number}: invalid hexadecimal record") from exc + + if len(record) < 5 or len(record) != record[0] + 5: + raise ProgramError(f"{path}:{line_number}: invalid record length") + if sum(record) & 0xFF: + raise ProgramError(f"{path}:{line_number}: Intel HEX checksum mismatch") + + count = record[0] + offset = (record[1] << 8) | record[2] + record_type = record[3] + data = record[4 : 4 + count] + + if record_type == 0x00: + start = address_base + offset + if start + count > 0x10000: + raise ProgramError( + f"{path}:{line_number}: address is outside the Z80's 64 KiB space" + ) + for index, value in enumerate(data): + address = start + index + old_value = memory.get(address) + if old_value is not None and old_value != value: + raise ProgramError( + f"{path}:{line_number}: conflicting data at {address:04X}" + ) + memory[address] = value + elif record_type == 0x01: + if count != 0: + raise ProgramError(f"{path}:{line_number}: malformed EOF record") + saw_eof = True + elif record_type == 0x02: + if count != 2: + raise ProgramError( + f"{path}:{line_number}: malformed extended segment address" + ) + address_base = int.from_bytes(data, "big") << 4 + elif record_type == 0x04: + if count != 2: + raise ProgramError( + f"{path}:{line_number}: malformed extended linear address" + ) + address_base = int.from_bytes(data, "big") << 16 + elif record_type in (0x03, 0x05): + # Start-address records do not contain bytes to program. + if count != 4: + raise ProgramError( + f"{path}:{line_number}: malformed start-address record" + ) + continue + else: + raise ProgramError( + f"{path}:{line_number}: unsupported record type {record_type:02X}" + ) + + if not saw_eof: + raise ProgramError(f"{path}: missing Intel HEX EOF record") + if not memory: + raise ProgramError(f"{path}: file contains no data") + return memory + + +def make_blocks(memory: dict[int, int], block_size: int) -> list[tuple[int, bytes]]: + """Combine adjacent addresses without filling gaps from the HEX file.""" + blocks: list[tuple[int, bytes]] = [] + addresses = sorted(memory) + index = 0 + + while index < len(addresses): + start = addresses[index] + data = bytearray([memory[start]]) + index += 1 + while ( + index < len(addresses) + and len(data) < block_size + and addresses[index] == start + len(data) + ): + data.append(memory[addresses[index]]) + index += 1 + blocks.append((start, bytes(data))) + + return blocks + + +class Monitor: + def __init__(self, port: str, baud: int, timeout: float): + try: + self.serial = serial.Serial( + port=port, + baudrate=baud, + bytesize=serial.EIGHTBITS, + parity=serial.PARITY_NONE, + stopbits=serial.STOPBITS_ONE, + timeout=timeout, + write_timeout=timeout, + ) + except serial.SerialException as exc: + raise ProgramError(f"cannot open serial port {port}: {exc}") from exc + + def close(self) -> None: + self.serial.close() + + def command(self, command: str) -> str: + try: + self.serial.write(command.encode("ascii") + b"\r") + self.serial.flush() + response = self.serial.read_until(b"> ") + except serial.SerialException as exc: + raise ProgramError(f"serial communication failed: {exc}") from exc + if not response.endswith(b"> "): + raise ProgramError(f"timeout waiting for monitor after '{command}'") + return response.decode("ascii", errors="replace") + + def synchronize(self) -> None: + try: + self.serial.reset_input_buffer() + except serial.SerialException as exc: + raise ProgramError(f"serial communication failed: {exc}") from exc + response = self.command("") + if not response.endswith("> "): + raise ProgramError("monitor prompt not found") + + +def response_has_ok(response: str) -> bool: + return any(line.strip() == "OK" for line in response.splitlines()) + + +def response_checksum(response: str) -> int | None: + matches = [ + int(line.strip(), 16) + for line in response.splitlines() + if re.fullmatch(r"[0-9A-Fa-f]{4}", line.strip()) + ] + return matches[-1] if matches else None + + +def write_and_verify(monitor: Monitor, start: int, data: bytes) -> tuple[bool, str]: + values = " ".join(f"{value:02X}" for value in data) + response = monitor.command(f"W {start:04X} {values}") + if not response_has_ok(response): + return False, "monitor did not acknowledge the write" + + end = start + len(data) - 1 + expected = sum(data) & 0xFFFF + response = monitor.command(f"C {start:04X} {end:04X}") + actual = response_checksum(response) + if actual is None: + return False, "monitor did not return a checksum" + if actual != expected: + return False, f"checksum mismatch (expected {expected:04X}, received {actual:04X})" + return True, "" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Program an Intel HEX file through the Z80 UART monitor." + ) + parser.add_argument("port", help="serial port, for example /dev/ttyUSB0 or COM3") + parser.add_argument("file", type=Path, help="Intel HEX input file") + parser.add_argument("--baud", type=int, default=2400, help="baud rate (default: 2400)") + parser.add_argument( + "--block-size", + type=int, + default=16, + help=f"bytes per verified block, 1-{MAX_BLOCK_SIZE} (default: 16)", + ) + parser.add_argument( + "--retries", + type=int, + default=3, + help="retries after the first attempt (default: 3)", + ) + parser.add_argument( + "--timeout", + type=float, + default=5.0, + help="serial response timeout in seconds (default: 5)", + ) + args = parser.parse_args() + if not 1 <= args.block_size <= MAX_BLOCK_SIZE: + parser.error(f"--block-size must be between 1 and {MAX_BLOCK_SIZE}") + if args.retries < 0: + parser.error("--retries cannot be negative") + if args.baud <= 0: + parser.error("--baud must be positive") + if args.timeout <= 0: + parser.error("--timeout must be positive") + return args + + +def main() -> int: + args = parse_args() + monitor: Monitor | None = None + + try: + memory = load_ihex(args.file) + blocks = make_blocks(memory, args.block_size) + print( + f"Loaded {len(memory)} bytes in {len(blocks)} blocks " + f"({min(memory):04X}-{max(memory):04X})" + ) + + monitor = Monitor(args.port, args.baud, args.timeout) + monitor.synchronize() + + for number, (start, data) in enumerate(blocks, 1): + end = start + len(data) - 1 + for attempt in range(args.retries + 1): + try: + if attempt: + monitor.synchronize() + success, reason = write_and_verify(monitor, start, data) + except ProgramError as exc: + success, reason = False, str(exc) + if success: + print( + f"\rBlock {number}/{len(blocks)} {start:04X}-{end:04X} verified", + end="", + flush=True, + ) + break + if attempt == args.retries: + raise ProgramError( + f"block {start:04X}-{end:04X} failed after " + f"{args.retries + 1} attempts: {reason}" + ) + print( + f"\nRetrying {start:04X}-{end:04X} " + f"({attempt + 1}/{args.retries}): {reason}", + file=sys.stderr, + ) + + print(f"\nProgrammed and verified {len(memory)} bytes") + return 0 + except ProgramError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + except KeyboardInterrupt: + print("\nCancelled", file=sys.stderr) + return 130 + finally: + if monitor is not None: + monitor.close() + + +if __name__ == "__main__": + raise SystemExit(main())