Checkpoint EEPROM, firmware analysis and instrument protocol updates

This commit is contained in:
2026-10-08 21:51:44 +03:00
parent b0c0c05cfa
commit fe9b5a246e
24 changed files with 848 additions and 62 deletions

View File

@@ -0,0 +1,64 @@
"""Renderer-independent comparison of arbitrary binary files."""
from __future__ import annotations
from dataclasses import dataclass
import hashlib
def compare_sha256(image: bytes, readback: bytes):
"""Compare a complete physical readback against an immutable BIN snapshot."""
if not image or len(image) != len(readback):
raise ValueError('Для SHA-256 нужен полный прочитанный диапазон размером с BIN.')
expected = hashlib.sha256(image).hexdigest()
actual = hashlib.sha256(readback).hexdigest()
return expected == actual, expected, actual
@dataclass(frozen=True)
class Comparison:
left: bytes
right: bytes
offsets: tuple[int, ...]
changed_bits: int
@property
def changed_bytes(self) -> int:
return len(self.offsets)
@property
def size(self) -> int:
return max(len(self.left), len(self.right))
def compare(left: bytes, right: bytes) -> Comparison:
"""Count bit differences in overlap; unmatched bytes count as changed bytes."""
common = min(len(left), len(right))
offsets = tuple(i for i in range(common) if left[i] != right[i])
bits = sum(bin(left[i] ^ right[i]).count('1') for i in offsets)
return Comparison(left, right, offsets + tuple(range(common, max(len(left), len(right)))), bits)
@dataclass(frozen=True)
class MultiComparison:
images: tuple[bytes, ...]
offsets: tuple[int, ...]
pairs: tuple
@property
def size(self): return max(map(len, self.images))
@property
def changed_bytes(self): return len(self.offsets)
def compare_many(images):
"""Highlight offsets where any pair differs, including missing bytes."""
images = tuple(images)
if len(images) not in (1, 2, 3):
raise ValueError('Выберите от 1 до 3 BIN-файлов.')
if len(images) == 2:
return compare(*images)
pairs = tuple((a, b, compare(images[a], images[b]))
for a in range(len(images)) for b in range(a + 1, len(images)))
offsets = tuple(sorted({offset for _, _, result in pairs for offset in result.offsets}))
return MultiComparison(images, offsets, pairs)

View File

@@ -0,0 +1,107 @@
"""TI C28x tool adapter. Decode instructions using TI's own assembler tools."""
from pathlib import Path
import subprocess
import tempfile
import os
import re
def align_disassembly(*texts):
"""Align TI listings by word offset even when instruction sizes differ."""
listings = []
for text in texts:
if text.startswith('Ошибка:'):
return texts
listing = {}
previous = None
for line in text.splitlines():
match = re.match(r'^([0-9A-Fa-f]{8})\s+', line)
if match:
previous = int(match.group(1), 16)
listing[previous] = line
elif previous is not None and line.strip():
listing[previous] += ' ' + line.strip()
if not listing:
return texts
listings.append(listing)
addresses = sorted({address for listing in listings for address in listing})
return tuple('\n'.join(listing.get(address, f'{address:08X} —') for address in addresses)
for listing in listings)
def find_ghidra(root):
configured = os.environ.get('GHIDRA_INSTALL_DIR', '')
if configured and (Path(configured) / 'support/analyzeHeadless.bat').is_file():
return configured
return next((str(path) for path in sorted(Path(root).glob('ghidra_*'), reverse=True)
if (path / 'support/analyzeHeadless.bat').is_file()), '')
def decompile_c28x(data: bytes, tools: str, base: int = 0) -> str:
"""Run the configured Ghidra C28x processor and decompiler locally."""
root = Path(tools)
headless = root / 'support/analyzeHeadless.bat'
if not tools or not headless.is_file():
raise ValueError('Укажите папку Ghidra с установленным процессором TMS320C28x.')
if not data or len(data) % 2 or len(data) > 262144:
raise ValueError('Нужен непустой чётный диапазон до 256 КиБ.')
if not 0 <= base <= 0x3fffff:
raise ValueError('Адрес C28x должен быть в диапазоне 0…0x3FFFFF (в словах).')
scripts = Path(__file__).parent / 'disassembly_scripts'
env = dict(os.environ)
jdks = sorted(path for path in root.parent.glob('jdk*')
if path.is_dir() and (path / 'bin/java.exe').is_file())
if jdks:
env['JAVA_HOME'] = str(jdks[-1])
with tempfile.TemporaryDirectory(prefix='setgui-decompile-') as folder:
work = Path(folder)
binary, output = work / 'range.bin', work / 'result.c'
binary.write_bytes(data)
arguments = [str(headless), str(work), 'analysis', '-import', str(binary),
'-processor', 'TMS320C28x:LE:32:default', '-loader', 'BinaryLoader',
'-loader-baseAddr', f'{base:x}', '-noanalysis', '-scriptPath', str(scripts),
'-postScript', 'SetguiExportC.java', str(output), '-deleteProject']
result = subprocess.run(arguments, env=env, capture_output=True, text=True,
errors='replace', timeout=180, stdin=subprocess.DEVNULL,
creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0))
if result.returncode or not output.is_file():
raise RuntimeError((result.stderr + '\n' + result.stdout)[-6000:])
text = output.read_text(encoding='utf-8')
if not text.rstrip().endswith('/* SETGUI_DECOMPILE_SUCCESS */'):
raise RuntimeError('Декомпиляция не завершена. ' + (result.stderr + '\n' + result.stdout)[-6000:])
return text.removesuffix('/* SETGUI_DECOMPILE_SUCCESS */\n')
def find_c28x_tools():
candidates = sorted(Path('C:/ti').glob('ti-cgt-c2000_*/bin'), reverse=True)
candidates += sorted(Path('C:/ti').glob('ccs*/ccs/tools/compiler/ti-cgt-c2000_*/bin'), reverse=True)
return next((str(path) for path in candidates
if (path / 'cl2000.exe').is_file() and (path / 'dis2000.exe').is_file()), '')
def disassemble_c28x(data: bytes, tools: str) -> str:
"""Wrap raw little-endian words in a temporary text section for dis2000."""
if not data or len(data) % 2:
raise ValueError('Для C28x нужен непустой диапазон с чётным числом байтов.')
if len(data) > 262144:
raise ValueError('Выберите диапазон не более 256 КиБ.')
directory = Path(tools)
for executable in ('cl2000.exe', 'dis2000.exe'):
if not (directory / executable).is_file():
raise ValueError(f'Не найден {executable} в {directory}')
with tempfile.TemporaryDirectory(prefix='setgui-disasm-') as folder:
root = Path(folder)
source = root / 'dump.asm'
source.write_text(' .text\n' + '\n'.join(
f' .word 0x{int.from_bytes(data[i:i + 2], "little"):04X}'
for i in range(0, len(data), 2)) + '\n', encoding='ascii')
def run(arguments):
result = subprocess.run(arguments, cwd=root, capture_output=True,
text=True, errors='replace', timeout=30,
creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0))
if result.returncode:
raise RuntimeError(result.stderr or result.stdout or 'Ошибка TI tools')
return result.stdout
run([str(directory / 'cl2000.exe'), '--silicon_version=28', '--compile_only',
'--output_file=dump.obj', str(source)])
return run([str(directory / 'dis2000.exe'), '--data_as_text', 'dump.obj'])

View File

@@ -0,0 +1,38 @@
// Local C pseudocode export for a raw, user-selected C28x code range.
import ghidra.app.script.GhidraScript;
import ghidra.app.decompiler.DecompInterface;
import ghidra.app.decompiler.DecompileResults;
import ghidra.program.model.address.Address;
import ghidra.program.model.listing.Function;
import ghidra.program.model.listing.FunctionIterator;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
public class SetguiExportC extends GhidraScript {
public void run() throws Exception {
Address entry = currentProgram.getMinAddress();
disassemble(entry);
createFunction(entry, null);
analyzeAll(currentProgram);
DecompInterface decompiler = new DecompInterface();
try (PrintWriter out = new PrintWriter(getScriptArgs()[0], StandardCharsets.UTF_8.name())) {
out.println("/* Ghidra C pseudocode. Recovered types/names may be incomplete. */");
if (!decompiler.openProgram(currentProgram))
throw new Exception(decompiler.getLastMessage());
FunctionIterator functions = currentProgram.getFunctionManager().getFunctions(true);
int count = 0;
while (functions.hasNext() && !monitor.isCancelled()) {
Function function = functions.next();
if (function.isExternal()) continue;
DecompileResults result = decompiler.decompileFunction(function, 10, monitor);
out.println("/* Address: " + function.getEntryPoint() + " */");
if (result.decompileCompleted() && result.getDecompiledFunction() != null) {
out.println(result.getDecompiledFunction().getC());
count++;
} else out.println("/* Decompilation failed: " + result.getErrorMessage() + " */");
}
if (count == 0) throw new Exception("No functions decompiled. Select a range beginning at a function entry.");
out.println("/* SETGUI_DECOMPILE_SUCCESS */");
} finally { decompiler.dispose(); }
}
}

View File

@@ -0,0 +1,40 @@
"""Portable, read-only TMS firmware verification against an immutable BIN."""
from protocan.tms2812 import build_upload_request, decode_upload_reply
from .bin_compare import compare_sha256
class TmsFirmwareCheck:
def __init__(self, controller, word_address, image, block_size=256):
self.image = bytes(image)
if not 1 <= controller <= 255:
raise ValueError("Для проверки нужен адрес контроллера 1..255")
if not self.image or len(self.image) > 4 * 1024 * 1024:
raise ValueError("Размер BIN должен быть 1..4194304 байт")
if not 0 <= word_address <= 0xFFFFFFFF or word_address + (len(self.image) + 1) // 2 > 0x100000000:
raise ValueError("Диапазон памяти вне 32-битного адресного пространства")
if not 2 <= block_size <= 65534 or block_size % 2:
raise ValueError("Размер блока должен быть чётным: 2..65534 байт")
self.controller, self.word_address = controller, word_address
self.block_size = block_size
self.readback = bytearray()
@property
def done(self):
return len(self.readback) == len(self.image)
@property
def count(self):
return min(self.block_size, len(self.image) - len(self.readback))
def request(self):
if self.done:
raise ValueError("Проверка уже завершена")
return build_upload_request(self.controller, self.word_address + len(self.readback) // 2, self.count)
def accept(self, reply):
if self.done:
raise ValueError("Проверка уже завершена")
self.readback.extend(decode_upload_reply(reply, self.controller, self.count))
def result(self):
return compare_sha256(self.image, bytes(self.readback))

View File

@@ -0,0 +1,110 @@
"""Firmware-info v1 and the adjacent release passport (no device I/O)."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
import hashlib
import json
from pathlib import Path
from protocan.periph28335 import with_crc
MAX_IMAGE_SIZE = 199993
REGISTER_ADDRESS = 0x1200
REGISTER_COUNT = 12
def read_request(controller: int) -> bytes:
if not 1 <= controller <= 255:
raise ValueError("Для чтения версии нужен адрес контроллера 1..255")
return with_crc(bytes((controller, 3)) + REGISTER_ADDRESS.to_bytes(2, 'big')
+ REGISTER_COUNT.to_bytes(2, 'big'))
@dataclass(frozen=True)
class FirmwareIdentity:
version: tuple[int, int, int]
built_at: datetime
build_id: str
def __post_init__(self):
if (len(self.version) != 3 or any(type(v) is not int or not 0 <= v <= limit
for v, limit in zip(self.version, (255, 255, 999)))
or not 2000 <= self.built_at.year <= 2199
or self.built_at.utcoffset() is None
or self.built_at.microsecond
or len(self.build_id) != 8
or any(not 32 <= ord(c) <= 126 for c in self.build_id)):
raise ValueError("Некорректные данные версии прошивки")
def describe(self):
version = '.'.join(map(str, self.version))
return f"{version} · {self.built_at:%Y-%m-%d %H:%M:%S} UTC · Build ID {self.build_id}"
def decode_identity(words):
if len(words) != REGISTER_COUNT or words[0] != 1:
raise ValueError("Неизвестный формат версии (требуется firmware-info v1)")
if any(type(w) is not int or not 0 <= w <= 65535 for w in words):
raise ValueError("Некорректные регистры версии")
try:
stamp = datetime(words[4], words[5] >> 8, words[5] & 255,
words[6] >> 8, words[6] & 255, words[7], tzinfo=timezone.utc)
build_id = bytes(b for w in words[8:] for b in (w >> 8, w & 255)).decode('ascii')
return FirmwareIdentity(tuple(words[1:4]), stamp, build_id)
except (ValueError, UnicodeError) as error:
raise ValueError("Некорректные данные версии: " + str(error)) from error
def inspect_image(path: Path, data: bytes | None = None, *, expected_eeprom=None,
expected_product="Balsam-167-PM35", expected_target="TMS320F28335",
max_image_size=MAX_IMAGE_SIZE):
"""Return None for legacy images; reject a present but invalid passport.
The caller may supply the exact bytes it will upload, avoiding a second
read between hash verification and transmission.
"""
if data is None:
with path.open('rb') as stream:
data = stream.read(max_image_size + 1)
if not 0 < len(data) <= max_image_size:
raise ValueError(f"Размер бинарного файла должен быть 1..{max_image_size} байт")
passport = Path(str(path) + '.manifest.json')
if not passport.exists():
return None
try:
with passport.open('r', encoding='utf-8-sig') as stream:
raw = stream.read(65537)
if len(raw) > 65536:
raise ValueError("Слишком большой паспорт прошивки")
meta = json.loads(raw)
if (meta['schema'] != 1 or meta['identity_contract'] != 1
or meta['identity_register_count'] != REGISTER_COUNT):
raise ValueError("Неизвестный формат паспорта")
if meta['product'] != expected_product or meta['target'] != expected_target:
raise ValueError(f"Паспорт предназначен для другого устройства (ожидается {expected_product} / {expected_target})")
if (type(meta['size_bytes']) is not int or meta['size_bytes'] != len(data)
or meta['sha256'] != hashlib.sha256(data).hexdigest()):
raise ValueError("BIN не соответствует паспорту: размер или SHA-256 не совпадает")
if expected_eeprom is not None and meta.get('eeprom') is not None:
if meta['eeprom'] != expected_eeprom:
raise ValueError(f"BIN для {meta['eeprom']}, а выбрана EEPROM {expected_eeprom}")
stamp = datetime.fromisoformat(meta['built_at_utc'].replace('Z', '+00:00'))
if stamp.utcoffset() is None or stamp.utcoffset().total_seconds() != 0:
raise ValueError("Дата паспорта должна быть в UTC")
return FirmwareIdentity(tuple(meta['version']), stamp, meta['build_id'])
except (KeyError, TypeError, ValueError, AttributeError) as error:
raise ValueError("Ошибка паспорта: " + str(error)) from error
def compare_identity(device, image):
if device is None or image is None:
return "Сравнение недоступно: нужны версия с платы и проверенный паспорт BIN."
if device == image:
return "Версия, дата сборки и Build ID совпадают. Содержимое памяти платы этим не проверяется."
if image.version > device.version:
return "Выбранная версия новее установленной."
if image.version < device.version:
return "Выбранная версия старее установленной (понижение версии)."
return "Номер версии совпадает, но дата сборки или Build ID отличаются."

View File

@@ -8,6 +8,8 @@ See docs/tektronix-dpo4034.md for wiring, commands and scaling.
"""
from dataclasses import dataclass
import math
import struct
import sys
def resource_name(interface, address, board=0):
@@ -52,18 +54,31 @@ class DPO4034:
try:
import pyvisa
except ImportError as exc:
raise RuntimeError("Установите PyVISA и NI-VISA или TekVISA") from exc
if getattr(sys, "frozen", False):
message = "В сборке SETGUI недоступна PyVISA. Используйте исправленную сборку EXE."
else:
message = "В Python-окружении недоступна PyVISA. Установите PyVISA (для Python 3.8: версия 1.14.1)."
raise RuntimeError(message + " Причина: " + str(exc)) from exc
factory = pyvisa.ResourceManager
try:
self.manager = factory()
try:
self.manager = factory()
except (OSError, ValueError) as exc:
if self._factory is not None:
raise
bits = struct.calcsize("P") * 8
raise RuntimeError(
f"Не удалось загрузить VISA-драйвер. Установите NI-VISA или TekVISA {bits}-bit "
"с поддержкой вашей Windows (для Win7 нужна совместимая версия). Причина: " + str(exc)
) from exc
self.instrument = self.manager.open_resource(resource, open_timeout=timeout_ms)
self.instrument.timeout = timeout_ms
self.instrument.write_termination = "\n"
self.instrument.read_termination = "\n"
identity = self.instrument.query("*IDN?").strip()
fields = identity.upper().split(",")
if len(fields) < 2 or "TEKTRONIX" not in fields[0] or fields[1].strip() not in ("DPO4034", "MDO4034C"):
raise ValueError(f"Ожидался Tektronix DPO4034 или MDO4034C, получено: {identity}")
if len(fields) < 2 or "TEKTRONIX" not in fields[0] or fields[1].strip() not in ("DPO4034", "DPO4034B", "MDO4034C"):
raise ValueError(f"Ожидался Tektronix DPO4034, DPO4034B или MDO4034C, получено: {identity}")
self.instrument.write("HEADER OFF")
return identity
except Exception:

View File

@@ -54,6 +54,8 @@ class ScopeSession:
value = CaptureFrame(value, prepare_preview(value, args[3]))
if automatic:
state.consumed(status)
elif action == "sync_settings":
value = client.settings(*args)
elif action == "export":
filename, waves = args
with open(filename, "w", newline="", encoding="utf-8-sig") as stream:

View File

@@ -14,6 +14,7 @@ from dataclasses import dataclass
from protocan import periph28335 as protocol
BASE = 0x1000
PROTECTION = 0x1300 # Balsam 167: versioned voltage protection/test block.
DATA = 0x1010
LIVE = 0x1100
EMU_CONTROL = 0x1200
@@ -79,7 +80,7 @@ def request(controller: int, function: int, address: int, value: int, *, record_
raise ValueError('Неверный размер записи логгера')
capacity = {40: 800, 42: 760}[record_words]
if function == 3:
valid = ((address == BASE and value == 16) or
valid = ((address == PROTECTION and value == 8) or (address == BASE and value == 16) or
(address == DATA and 1 <= value <= 120 and (value % WORDS == 0 or value in (42, 84))) or (address == LIVE and value == 124) or (address == EMU_CONTROL and value == 4)
or (address == FAST_CAPABILITY and value == 4)
or (FAST_DATA <= address < FAST_DATA + capacity * record_words
@@ -87,7 +88,9 @@ def request(controller: int, function: int, address: int, value: int, *, record_
and 1 <= value <= 120 and value % record_words == 0
and address + value <= FAST_DATA + capacity * record_words))
else:
valid = function == 6 and (address in (BASE, BASE + 1, BASE + 2, BASE + 3) or
valid = function == 6 and ((address == PROTECTION + 2 and value in (0, 1, 2, 3)) or
(address == PROTECTION + 3 and 1 <= value <= 60000) or
address in (BASE, BASE + 1, BASE + 2, BASE + 3) or
(address == EMU_CONTROL + 2 and value in (0, 1)))
if not valid:
raise ValueError('Недопустимый запрос логгера УМП')

View File

@@ -11,7 +11,7 @@
"""
from dataclasses import dataclass, field
import struct
from set_devices.ump_logger import BASE, DATA, LIVE, EMU_CONTROL
from set_devices.ump_logger import BASE, DATA, LIVE, EMU_CONTROL, PROTECTION
MARKER = 0x1F00
MAX_WORDS = 124
@@ -26,10 +26,12 @@ def request_ids(mode):
def build_request(mode, token, function, address, value):
if not 1 <= token <= 255:
raise ValueError('Неверный номер CAN-запроса')
valid = ((function == 3 and ((address == BASE and value == 16) or
valid = ((function == 3 and ((address == PROTECTION and value == 8) or (address == BASE and value == 16) or
(address == DATA and value in (40, 80, 120, 42, 84)) or
(address == LIVE and value == MAX_WORDS) or (address == EMU_CONTROL and value == 4))) or
(function == 6 and ((BASE <= address <= BASE+3 and 0 <= value <= 65535) or
(address == PROTECTION+2 and value in (0, 1, 2, 3)) or
(address == PROTECTION+3 and 1 <= value <= 60000) or
(address == EMU_CONTROL+2 and value in (0, 1)))))
if not valid:
raise ValueError('Недопустимый запрос CAN-логгера')

View File

@@ -24,7 +24,8 @@ def sample(time_ms, period_ms=20, *, repeat=True):
else:
current = 4
words = [0] * codec.WORDS
# Extended logger record includes both DC-link voltage measurements.
words = [0] * 42
words[0], words[1] = time_ms & 65535, (time_ms >> 16) & 65535
words[2] = 1 | (0x100 if 5000 <= phase < 5000 + period_ms else 0)
words[3] = (int(started) << 7) | (int(reset) << 6)
@@ -42,6 +43,14 @@ def sample(time_ms, period_ms=20, *, repeat=True):
words[18:24] = [0xE07F, words[3], 6, 16, (time_ms // 100) & 65535, 0]
words[24:31] = [6, 16, 500, 3500, 2000, 500, 3500]
words[31:36] = [ramp if started else 0, 2500, 60, 160, round(ramp * 1000 / 2500)]
if started:
charge = min(1.0, (phase - 1000) / 1500)
elif phase >= 5000:
charge = max(0.0, 1 - (phase - 5000) / 800)
else:
charge = 0.0
words[40] = round(charge * (600 + 3 * math.sin(phase / 80)))
words[41] = round(charge * (590 + 4 * math.sin(phase / 95 + .7)))
return codec.sample(words)