This commit is contained in:
Joppe Blondel
2026-10-01 12:44:11 +02:00
parent d19ffc70a1
commit 47fffc2e71
9 changed files with 467 additions and 19 deletions
+1
View File
@@ -1 +1,2 @@
build build
__pycache__/
+3 -2
View File
@@ -9,7 +9,7 @@ METAL_RELFILES := $(METAL_ASM_SRCFILES:%.asm=$(BUILDDIR)/metal/%.rel) \
METAL_CC := sdcc METAL_CC := sdcc
METAL_AS := sdasz80 METAL_AS := sdasz80
METAL_MAKEBIN := makebin 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_CPPFLAGS := -I src -I $(BUILDDIR)/metal/generated --disable-warning 283
METAL_BIN_SIZE := 32768 METAL_BIN_SIZE := 32768
METAL_RAM_SIZE := 16384 METAL_RAM_SIZE := 16384
@@ -48,7 +48,8 @@ $(BUILDDIR)/metal/$(TARGET).flat.bin: $(BUILDDIR)/metal/$(TARGET).ihx
$(METAL_MAKEBIN) -s $(METAL_IMAGE_SIZE) $< $@ $(METAL_MAKEBIN) -s $(METAL_IMAGE_SIZE) $< $@
$(BUILDDIR)/metal/$(TARGET).prog.bin: $(BUILDDIR)/metal/$(TARGET).flat.bin $(TINYBASIC_BUILD)/tinybasic.bin $(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))); \ payload_size=$$((payload_end - $(RESIDENT_BASE))); \
test $$payload_size -gt 0; \ test $$payload_size -gt 0; \
test $$payload_size -le $$(( $(TINYBASIC_ROM_OFFSET) - $(RESIDENT_ROM_OFFSET) )); \ test $$payload_size -le $$(( $(TINYBASIC_ROM_OFFSET) - $(RESIDENT_ROM_OFFSET) )); \
+62 -8
View File
@@ -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`. 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 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, 16 KiB at `C000`. Before copying, it writes `06` to banking port `00`: ROM
then starts the monitor. Applications remain in ROM and execute in place while remains enabled, banking is enabled, and `C000-FFFF` maps to the upper physical
calling the stable BIOS jump table in high RAM. Tiny BASIC is the first such 16 KiB. The BIOS then initializes interrupt mode 2 and the UART and starts the
application, linked at ROM address `1000`. 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**. 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 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` | Dump 40 bytes starting at address 8000 |
| `D 8000 20` | Dump 20 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 | | `W 8000 3E 41 D3 25 C9` | Write bytes starting at address 8000 |
| `I 20` | Read I/O port 20 | | `I 20` | Read I/O port 20 |
| `O 20 57` | Write 57 to 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 `RET` returns to the monitor. Code that changes the stack pointer, interrupt
mode, or UART/CTC setup must restore it before returning. 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: Build the ROM and programmer-format images with:
```sh ```sh
make 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: The outputs are:
- `build/metal/monitor.bin`: complete 32 KiB ROM image - `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: The current compact image is 6,157 bytes. Its ROM layout is:
- `0000-007F`: reset/copy bootstrap - `0000-007F`: reset/copy bootstrap
- `0080-0F9D`: packed BIOS+monitor image copied to RAM at `C000-CF1D` - `0080-0FEA`: packed BIOS+monitor image copied to RAM at `C000-CF6A`
- `0F9E-0FFF`: unused space reserved for the resident image to grow - `0FEB-0FFF`: unused space reserved for the resident image to grow
- `1000-180C`: Tiny BASIC, executed directly from ROM - `1000-180C`: Tiny BASIC, executed directly from ROM
- `180D-7FFF`: unused space available for additional ROM content - `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 - `8000-AFFF`: Tiny BASIC workspace and BASIC program text
- `B000-B077`: Tiny BASIC variables and input buffer - `B000-B077`: Tiny BASIC variables and input buffer
- `B078-BFFF`: Tiny BASIC stack space - `B078-BFFF`: Tiny BASIC stack space
- `C000-CF1D`: resident BIOS, vectors, services, and monitor - `C000-CF6A`: resident BIOS, vectors, services, and monitor
- `CF1E-EFFF`: free space for resident-image growth - `CF6B-EFFF`: free space for resident-image growth
- `F000-FFFF`: BIOS globals, UART buffers, and monitor stack - `F000-FFFF`: BIOS globals, UART buffers, and monitor stack
The BIOS and monitor continue to work if low ROM is disabled. ROM-resident The BIOS and monitor continue to work if low ROM is disabled. ROM-resident
applications naturally require ROM to remain mapped while they execute. 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 ## BIOS interface
Assembly applications include `include/bios.inc` and call these fixed entries: 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` | | `C05C` | `BIOS_IO_IN` | Read port `A`, returning the value in `A` |
| `C05F` | `BIOS_DELAY_MS` | Delay approximately `HL` milliseconds | | `C05F` | `BIOS_DELAY_MS` | Delay approximately `HL` milliseconds |
| `C062` | `BIOS_MONITOR` | Warm-start the monitor; does not return | | `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 The service wrappers preserve working registers except for documented return
values. The monitor uses the corresponding C API declared in `bios.h`. 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: Project organization:
- `boot.asm`: ROM bootstrap, high vectors, BIOS jump table, and assembly glue - `boot.asm`: ROM bootstrap, high vectors, BIOS jump table, and assembly glue
+5
View File
@@ -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 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. 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, 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 overlay its binary in the `monitor.prog.bin` recipe, and add a monitor command
or use `G address` to launch it. or use `G address` to launch it.
+4
View File
@@ -11,6 +11,10 @@ char bios_getc(void);
uint8_t bios_try_getc(void); uint8_t bios_try_getc(void);
void bios_flush(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. */ /* Low-level routines implemented by the resident assembly BIOS. */
void io_out(uint8_t port, uint8_t value); void io_out(uint8_t port, uint8_t value);
uint8_t io_in(uint8_t port); uint8_t io_in(uint8_t port);
+61 -1
View File
@@ -2,6 +2,12 @@ STACK_TOP = 0xFFFF
DELAY_LOOPS_PER_MS = 40 DELAY_LOOPS_PER_MS = 40
RESIDENT_BASE = 0xC000 RESIDENT_BASE = 0xC000
RESIDENT_ROM_SOURCE = 0x0080 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" .include "bios.inc"
@@ -16,6 +22,9 @@ RESIDENT_ROM_SOURCE = 0x0080
.globl _bios_try_getc .globl _bios_try_getc
.globl _bios_getc .globl _bios_getc
.globl _bios_init .globl _bios_init
.globl _bios_bank
.globl _bios_rom_disable
.globl _bank_control_shadow
.globl resident_image_end .globl resident_image_end
.globl s__DATA .globl s__DATA
.globl l__DATA .globl l__DATA
@@ -30,6 +39,10 @@ RESIDENT_ROM_SOURCE = 0x0080
.org 0x08 .org 0x08
boot_copy: boot_copy:
di 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 sp, #STACK_TOP
ld hl, #RESIDENT_ROM_SOURCE ld hl, #RESIDENT_ROM_SOURCE
ld de, #RESIDENT_BASE ld de, #RESIDENT_BASE
@@ -66,7 +79,8 @@ boot_copy:
; Stable BIOS jump table for ROM-resident applications. ; Stable BIOS jump table for ROM-resident applications.
; C050 putc(A), C053 try_getc(), C056 getc(), C059 io_out(A,L), ; 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 .org BIOS_PUTC
jp bios_service_putc jp bios_service_putc
.org BIOS_TRY_GETC .org BIOS_TRY_GETC
@@ -81,6 +95,17 @@ boot_copy:
jp bios_service_delay_ms jp bios_service_delay_ms
.org BIOS_MONITOR .org BIOS_MONITOR
jp init 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 .area _HOME
init: init:
@@ -135,6 +160,41 @@ resident_image_end:
.area _CODE .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 ABI services preserve registers except documented return values.
bios_service_putc: bios_service_putc:
push af push af
+2
View File
@@ -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_IO_IN = 0xC05C ; A = port; returns value in A
BIOS_DELAY_MS = 0xC05F ; HL = milliseconds; preserves all registers BIOS_DELAY_MS = 0xC05F ; HL = milliseconds; preserves all registers
BIOS_MONITOR = 0xC062 ; warm-start the monitor; does not return 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
+33 -8
View File
@@ -91,14 +91,15 @@ static uint8_t split_line(char *line, char **argv)
static void print_help(void) static void print_help(void)
{ {
bios_puts( bios_puts(
"Commands (hexadecimal numbers):\r\n" "Commands (hex numbers):\r\n"
" D|DUMP address [length] Dump memory (default 0040, max 0100)\r\n" " D addr [len] Dump memory\r\n"
" W|WRITE address byte ... Write one or more memory bytes\r\n" " C start end 16-bit checksum, inclusive\r\n"
" I|IN port Read an I/O port\r\n" " W addr byte... Write memory\r\n"
" O|OUT port byte Write an I/O port\r\n" " I port Read I/O\r\n"
" G|GO address Execute code; RET returns here\r\n" " O port byte Write I/O\r\n"
" B|BASIC Start ROM Tiny BASIC at 1000\r\n" " G addr Execute\r\n"
" H|HELP|? Show this help\r\n"); " B Tiny BASIC\r\n"
" H Help\r\n");
} }
static void command_dump(uint8_t argc, char **argv) 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) static void command_write(uint8_t argc, char **argv)
{ {
uint16_t address; uint16_t address;
@@ -222,6 +245,8 @@ static void execute_line(char *line)
print_help(); print_help();
} else if (strings_equal(argv[0], "d") || strings_equal(argv[0], "dump")) { } else if (strings_equal(argv[0], "d") || strings_equal(argv[0], "dump")) {
command_dump(argc, argv); 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")) { } else if (strings_equal(argv[0], "w") || strings_equal(argv[0], "write")) {
command_write(argc, argv); command_write(argc, argv);
} else if (strings_equal(argv[0], "i") || strings_equal(argv[0], "in")) { } else if (strings_equal(argv[0], "i") || strings_equal(argv[0], "in")) {
+296
View File
@@ -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())