2 Commits

Author SHA1 Message Date
BlackMark
e85840fa39 pureboot: take the emitted loader HEX for --update-loader
The build emits an Intel HEX beside every loader image, but --update-loader
could not consume one: load_image() anchors every image at address zero, and
a loader HEX links at its base, so it decoded to a 32760-byte blob carrying
504 bytes of loader at the end. staging_content() then refused it as "loader
image is 32760 B, the slot holds 512" - an error naming neither the cause nor
the raw .bin the tool wanted instead.

Drop the blank below the base in the update path. The base comes from the
image's own info block rather than the device's, so an image built for
another target survives the slice intact and the preflight still reports it
as another target rather than failing to find an info block at all.

Verified on an ATmega328P: the full self-update flow driven straight from
pureboot_timeout-5s.hex, resident slot byte-for-byte against the image
afterwards, application preserved; both refusal paths unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 22:46:50 +02:00
BlackMark
045372a2bc pureboot: drive the serial port on Windows too
The host tool was standard-library-only but POSIX-only with it: termios and
select() bound the port layer, and importing termios failed outright on
Windows, so the module could not even load there.

Split Port into PosixPort (unchanged) and a WindowsPort over the Win32 serial
API through ctypes, picked by os.name; every call site keeps the Port name.
kernel32 only, so the standard-library constraint holds.

Windows has no select() for a COM handle, so the read deadlines move into the
driver as COMMTIMEOUTS, re-armed per read: read_available() ends on a gap
longer than a USB-serial latency timer coalesces (16 ms on FTDI parts),
read_exact() on the count or its deadline. Opening asserts DTR and RTS as a
POSIX open does, so a board wiring DTR to reset still pulses it. A failed
configuration closes the handle before raising - a COM handle is exclusive,
and the leak met the next open as "Access is denied". Win32 takes any integer
baud and a driver may accept one its hardware cannot produce (an FT232R
reports back a baud of 3 and keeps the old divisor), so obvious nonsense is
refused where termios' table would have.

Tested against an ATmega328P on COM6: info, fuses, both memories programmed
and verified, session reconnect, hand-over, the loader self-update, and the
write guard on its own slot. test_planner runs on Windows now as well.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 22:46:14 +02:00
2 changed files with 207 additions and 11 deletions

View File

@@ -124,7 +124,9 @@ the loader; an erase runs top-down for the same reason.
`pureboot.py --update-loader new_pureboot.bin` replaces the resident loader
with any pureboot build — a re-timed window, a newer protocol — using the
loader itself as its own staging loader:
loader itself as its own staging loader. The image is the loader's own 512
bytes as a raw binary, or the Intel HEX the build emits beside it, which
links the loader at its base inside an otherwise blank flash image:
1. The staging slot `[base512, base)` is saved to a host-side state file
(on the 1 KB tiny13A that is the whole application, vectors included).
@@ -150,8 +152,13 @@ reflashing the application. The mega needs its fuses for the preflight
## Host tool
`pureboot.py` — Python 3, standard library only (termios drives any tty,
a USB adapter as well as a simavr pty):
`pureboot.py` — Python 3, standard library only. The port layer is the one
platform-specific part: termios drives any tty on POSIX (a USB adapter as
well as a simavr pty), the Win32 serial API through `ctypes` drives a COM
port on Windows (`--port COM6`; the `\\.\` form for two-digit ports is
supplied by the tool). Opening the port asserts DTR and RTS on both, so a
board that wires DTR to reset gets its reset pulse and opens the activation
window by itself.
pureboot.py --port /dev/ttyUSB0 --baud 57600 \
--info --fuses --flash app.hex
@@ -192,3 +199,7 @@ Per chip preset, `ctest` runs:
then every power-fail phase: the device is killed mid-write, restarted
from its flash dump, and a re-run must complete the update with the
application intact throughout.
`size`, `pi`, and `planner` are host logic and run anywhere; the three
simulator-driven targets need simavr and a pty, so they are POSIX-only —
on Windows the tool is exercised against real hardware.

View File

@@ -16,18 +16,24 @@ the resident slot, and restores what the staging slot held — resumable at
every phase from the flash state plus a host-side state file carrying the
saved bytes.
Python standard library only; the serial port is driven with termios, so any
tty works — a USB adapter as well as a simavr pty.
Python standard library only; the serial port is driven with termios on POSIX
and the Win32 serial API (through ctypes) on Windows, so any tty or COM port
works — a USB adapter as well as a simavr pty.
"""
import argparse
import json
import os
import select
import sys
import termios
import time
if os.name == "nt":
import ctypes
from ctypes import wintypes
else:
import select
import termios
PROMPT = b"+"
PROTOCOL_VERSION = 1
SLOT = 512 # the loader slot size; also the self-update staging distance
@@ -40,8 +46,8 @@ class Error(Exception):
# ---------------------------------------------------------------- serial ---
class Port:
"""A raw serial port with deadline-based reads."""
class PosixPort:
"""A raw serial port with deadline-based reads, over termios."""
def __init__(self, path, baud):
self.fd = os.open(path, os.O_RDWR | os.O_NOCTTY)
@@ -86,6 +92,171 @@ class Port:
return data
if os.name == "nt":
# The same port, over the Win32 serial API — kernel32 through ctypes, so
# the tool stays standard-library only. Timeouts live in the driver
# (COMMTIMEOUTS) rather than in a readiness call: Windows has no select()
# for a COM handle, so each read asks the driver for its own deadline.
_GENERIC_READ, _GENERIC_WRITE = 0x80000000, 0x40000000
_OPEN_EXISTING, _PURGE_RXCLEAR = 3, 0x0008
_INVALID_HANDLE = wintypes.HANDLE(-1).value
# A gap this long ends a read_available(): longer than the coalescing a
# USB-serial adapter's latency timer imposes (16 ms on FTDI parts), so a
# burst is not split, short enough to stay responsive.
_GAP_MS = 30
class _DCB(ctypes.Structure):
_fields_ = [
("DCBlength", wintypes.DWORD),
("BaudRate", wintypes.DWORD),
("fBits", wintypes.DWORD), # the packed flag bitfield, set below
("wReserved", wintypes.WORD),
("XonLim", wintypes.WORD),
("XoffLim", wintypes.WORD),
("ByteSize", wintypes.BYTE),
("Parity", wintypes.BYTE),
("StopBits", wintypes.BYTE),
("XonChar", ctypes.c_char),
("XoffChar", ctypes.c_char),
("ErrorChar", ctypes.c_char),
("EofChar", ctypes.c_char),
("EvtChar", ctypes.c_char),
("wReserved1", wintypes.WORD),
]
class _COMMTIMEOUTS(ctypes.Structure):
_fields_ = [
("ReadIntervalTimeout", wintypes.DWORD),
("ReadTotalTimeoutMultiplier", wintypes.DWORD),
("ReadTotalTimeoutConstant", wintypes.DWORD),
("WriteTotalTimeoutMultiplier", wintypes.DWORD),
("WriteTotalTimeoutConstant", wintypes.DWORD),
]
_k32 = ctypes.WinDLL("kernel32", use_last_error=True)
_LPDWORD = ctypes.POINTER(wintypes.DWORD)
# Declared, not inferred: a HANDLE is a pointer, and a defaulted int
# return would truncate it on 64-bit.
_k32.CreateFileW.restype = wintypes.HANDLE
_k32.CreateFileW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD,
wintypes.LPVOID, wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE]
_k32.ReadFile.argtypes = [wintypes.HANDLE, wintypes.LPVOID, wintypes.DWORD, _LPDWORD, wintypes.LPVOID]
_k32.WriteFile.argtypes = [wintypes.HANDLE, wintypes.LPCVOID, wintypes.DWORD, _LPDWORD, wintypes.LPVOID]
_k32.GetCommState.argtypes = [wintypes.HANDLE, ctypes.POINTER(_DCB)]
_k32.SetCommState.argtypes = [wintypes.HANDLE, ctypes.POINTER(_DCB)]
_k32.SetCommTimeouts.argtypes = [wintypes.HANDLE, ctypes.POINTER(_COMMTIMEOUTS)]
_k32.PurgeComm.argtypes = [wintypes.HANDLE, wintypes.DWORD]
_k32.CloseHandle.argtypes = [wintypes.HANDLE]
def _fail(what):
code = ctypes.get_last_error()
raise Error(f"{what}: {ctypes.FormatError(code).strip()} (Windows error {code})")
class WindowsPort:
"""A raw serial port with deadline-based reads, over Win32."""
def __init__(self, path, baud):
# Win32 takes the rate as a plain integer, so unlike termios any
# rate the hardware can divide down to is available — but a driver
# may also accept one it cannot produce (an FT232R takes a baud of
# 3, reports it back, and goes on using the previous divisor).
# Only obvious nonsense is refusable; the rest is the driver's word.
if baud < 50:
raise Error(f"unsupported baud rate {baud}")
# \\.\COM6: the device-namespace form. A bare COMn resolves only
# for n < 10, and double-digit ports are routine on Windows.
if path.lower().startswith("com") and path[3:].isdigit():
path = rf"\\.\{path}"
self.handle = _k32.CreateFileW(
path, _GENERIC_READ | _GENERIC_WRITE, 0, None, _OPEN_EXISTING, 0, None
)
if self.handle == _INVALID_HANDLE:
_fail(f"cannot open {path}")
self.timeouts = None
try:
dcb = _DCB()
dcb.DCBlength = ctypes.sizeof(_DCB)
if not _k32.GetCommState(self.handle, ctypes.byref(dcb)):
_fail(f"cannot read the state of {path}")
dcb.BaudRate, dcb.ByteSize, dcb.Parity, dcb.StopBits = baud, 8, 0, 0 # 8N1
# fBinary, and DTR/RTS asserted (fDtrControl and fRtsControl,
# two bits each, = _ENABLE); every other flag clear, so no
# parity and no flow control. Raising both matches what opening
# a POSIX tty does — including the reset pulse on the boards
# that wire DTR to it.
dcb.fBits = 0x1 | (1 << 4) | (1 << 12)
if not _k32.SetCommState(self.handle, ctypes.byref(dcb)):
_fail(f"cannot configure {path} for {baud} baud 8N1")
# Arm them once here too: reads re-arm per call, but the write
# timeout would otherwise stay at the driver's default — which
# may be "wait forever" — until the first read.
self._deadline(_GAP_MS, 1000)
except Error:
# An open port outlives the exception otherwise, and a COM
# handle is exclusive: the next attempt would meet its own
# leftover as "Access is denied".
self.close()
raise
def close(self):
_k32.CloseHandle(self.handle)
def _deadline(self, interval, total):
"""Arm the driver's read timeouts: `interval` ms of quiet ends a
read once bytes have arrived, `total` ms ends it regardless."""
if self.timeouts == (interval, total):
return
spec = _COMMTIMEOUTS()
spec.ReadIntervalTimeout = interval
spec.ReadTotalTimeoutConstant = total
spec.WriteTotalTimeoutConstant = 5000
if not _k32.SetCommTimeouts(self.handle, ctypes.byref(spec)):
_fail("cannot set the port timeouts")
self.timeouts = (interval, total)
def _read(self, count):
buffer = ctypes.create_string_buffer(count)
got = wintypes.DWORD()
if not _k32.ReadFile(self.handle, buffer, count, ctypes.byref(got), None):
_fail("read failed")
return buffer.raw[: got.value]
def write(self, data):
written = wintypes.DWORD()
if not _k32.WriteFile(self.handle, data, len(data), ctypes.byref(written), None):
_fail("write failed")
if written.value != len(data):
raise Error(f"short write: {written.value} of {len(data)} bytes")
def flush_input(self):
if not _k32.PurgeComm(self.handle, _PURGE_RXCLEAR):
_fail("cannot flush the input buffer")
def read_available(self, wait):
"""Everything that arrives within `wait` seconds of quiet start."""
# A zero total means *no* timeout to the driver, so never round
# down to it — the same trap on the deadline below.
self._deadline(_GAP_MS, max(1, round(wait * 1000)))
return self._read(4096)
def read_exact(self, count, timeout):
data = b""
deadline = time.monotonic() + timeout
while len(data) < count:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise Error(f"timeout: got {len(data)} of {count} bytes")
# No interval timeout here: only the count or the deadline
# ends the read, so a gap mid-reply is simply waited out.
self._deadline(0, max(1, round(remaining * 1000)))
data += self._read(count - len(data))
return data
Port = WindowsPort if os.name == "nt" else PosixPort
# -------------------------------------------------------------- protocol ---
@@ -343,6 +514,20 @@ def image_info(image):
return Info(image[at : at + 12]) if 0 <= at <= len(image) - 12 else None
def loader_image(path):
"""A loader update image, as the slot's own content. A raw binary is that
already; an Intel HEX links the loader at its base inside an otherwise
blank flash image, and load_image() anchors every image at zero, so the
blank below the base is dropped here. The base comes from the image's own
info block rather than the device's, so an image built for somewhere else
survives intact and the preflight can say so."""
image = load_image(path)
embedded = image_info(image)
if embedded and len(image) > embedded.base:
image = image[embedded.base :]
return image
def staging_content(image, info):
"""The 512-byte staging-slot content: the image, padding, and — on
chips whose hand-over jumps through the word below the resident loader —
@@ -472,7 +657,7 @@ def op_update_loader(loader, wait, path, state_path, fuse_bytes):
actual flash state, so a re-run after any interruption resumes; the
state file carries the bytes the staging slot held."""
info = loader.info
image = load_image(path)
image = loader_image(path)
for warning in update_preflight(image, info, fuse_bytes):
print(f"note: {warning}")
staged = staging_content(image, info)
@@ -637,7 +822,7 @@ def main():
parser = argparse.ArgumentParser(
description="pureboot host tool", epilog="operations run in the order listed above"
)
parser.add_argument("--port", required=True, help="serial device (or simavr pty)")
parser.add_argument("--port", required=True, help="serial device: COM6, /dev/ttyUSB0, or a simavr pty")
parser.add_argument("--baud", type=int, default=115200, help="115200 mega, 57600 tinies")
parser.add_argument("--wait", type=float, default=30.0, help="seconds to keep knocking")
parser.add_argument("--info", action="store_true", help="print the device info block")