Sprinter: добавить отладку C-исходников и интеграцию VS Code

This commit is contained in:
2026-09-15 17:58:41 +03:00
parent 50c6e56b7b
commit e4695b8281
62 changed files with 7147 additions and 27 deletions
+1
View File
@@ -0,0 +1 @@
"""Карта оптимизированного кода SDCC и пакет отладки Sprinter."""
+113
View File
@@ -0,0 +1,113 @@
"""Сборочные операции sdbg: уникальные debug-символы без изменения инструкций."""
from __future__ import annotations
import hashlib
import json
import re
import subprocess
from pathlib import Path
from .macros import anchor_symbols, extract
def digest(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def write_json(path: Path, data) -> None:
temporary = path.with_suffix(path.suffix + '.tmp')
temporary.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n')
temporary.replace(path)
def normalize(assembly: str, adb: str, module: str):
"""Меняет только имена debug-записей; сохраняет число строк .asm."""
match = re.search(r'^\s*\.module\s+(\S+)', assembly, re.M)
if not match:
raise ValueError('В asm отсутствует .module')
old = match.group(1)
# F/XF/L связывают статики, функции и локальные с единицей трансляции.
def rename(text):
text = re.sub(r'\b(X?F)' + re.escape(old) + r'(?=\$)',
lambda m: m[1] + module, text)
return re.sub(r'\bL' + re.escape(old) + r'(?=[.$])', 'L' + module, text)
# Не трогаем .ascii и инструкции: строковый литерал может содержать
# текст, похожий на debug-символ. Переименовываем только определения
# и объявления символов, сгенерированные SDCC.
symbol_line = re.compile(r'^\s*(?:\.globl\s+|(?:X?F|L)' + re.escape(old) + r'[$.])')
assembly = ''.join(rename(line) if symbol_line.match(line) else line
for line in assembly.splitlines(keepends=True))
adb = rename(adb)
assembly = re.sub(r'(?m)^(\s*\.module\s+)\S+', lambda m: m[1] + module, assembly)
adb = re.sub(r'(?m)^M:.*$', 'M:' + module, adb)
markers, active, output = {}, {}, []
for number, line in enumerate(assembly.splitlines(keepends=True), 1):
definition = re.match(r'\s*(C\$[^\s=]+)\s*=\s*\.', line)
if definition:
original = definition[1]
parts = original.split('$')
if len(parts) != 5:
raise ValueError('Неизвестная форма CDB: ' + original)
unique = f'C${module}_{len(markers)}${parts[2]}${parts[3]}${parts[4]}'
active[original] = unique
markers[unique] = {'original': original, 'file': parts[1],
'line': int(parts[2]), 'asm_line': number}
line = line.replace(original, unique)
elif re.match(r'\s*\.globl\s+C\$', line):
original = line.split()[1]
if original not in active:
raise ValueError('CDB .globl без определения: ' + original)
line = line.replace(original, active[original])
output.append(line)
return ''.join(output), adb, markers
def compile_unit(sdcc: Path, assembler: Path, source: Path, output: Path, flags, debug=True):
"""-S и те же опции ассемблера, которые SDCC 4.5 использует с --debug."""
source = source.resolve()
asm, adb = output.with_suffix('.asm'), output.with_suffix('.adb')
compile_flags = [*flags, '-DSPRINTER_SDBG_ANCHORS=1'] if debug else flags
# SDCC -E кодирует внутренний разделитель строк inline asm байтом 0x87;
# для списка #line нужны только ASCII-пути. Само описание логов извлекаем
# отдельным metadata-проходом без asm-развёртки.
preprocessed = subprocess.check_output([str(sdcc), *compile_flags, '-E', str(source)],
text=True, errors='replace')
dependencies = {str(source)}
for filename in re.findall(r'^#(?:line)?\s*\d+\s+"([^"]+)"', preprocessed, re.M):
path = Path(filename).resolve()
if path.is_file(): dependencies.add(str(path))
hashes = {name: digest(Path(name)) for name in dependencies}
if not debug:
subprocess.run([str(sdcc), *flags, '-c', '-o', str(output), str(source)], check=True)
if any(digest(Path(name)) != sha for name, sha in hashes.items()):
raise ValueError('Исходники изменились во время компиляции')
write_json(output.with_suffix('.sdbg-input.json'), {'dependency_hashes': hashes})
return
metadata = subprocess.check_output([str(sdcc), *compile_flags,
'-DSPRINTER_SDBG_METADATA=1', '-E', str(source)], text=True)
macros = extract(metadata, dependencies)
subprocess.run([str(sdcc), *compile_flags, '--debug', '-S', '-o', str(asm), str(source)], check=True)
if any(digest(Path(name)) != sha for name, sha in hashes.items()):
raise ValueError('Исходники изменились во время компиляции')
original = asm.read_text()
# Включаем output stem: один исходник может собираться в разные банки.
module = 'sdbg_' + hashlib.sha256((str(source) + ':' + output.stem).encode()).hexdigest()[:16]
assembly, descriptions, markers = normalize(original, adb.read_text(), module)
assembly, log_macros = anchor_symbols(assembly, module, macros)
asm.write_text(assembly)
adb.write_text(descriptions)
subprocess.run([str(assembler), '-plosgffwy', str(output), str(asm)], check=True)
# Комментарии SDCC сохраняют фактические пути строк, включая заголовки.
sources = {}
for name in dependencies:
sources.setdefault(Path(name).name, set()).add(name)
for filename in re.findall(r'^;(.+?):\d+:', original, re.M):
path = Path(filename).resolve()
if path.is_file():
sources.setdefault(path.name, set()).add(str(path))
write_json(output.with_suffix('.sdbg-unit.json'), {
'module': module, 'source': str(source), 'source_sha256': digest(source),
'asm': asm.name, 'adb': adb.name, 'object': output.name,
'flags': compile_flags, 'markers': markers, 'log_macros': log_macros,
'dependency_hashes': hashes,
'sources': {k: sorted(v) for k, v in sources.items()},
})
+198
View File
@@ -0,0 +1,198 @@
"""Честный минимальный Debug Adapter Protocol поверх постоянной sdbg-сессии."""
from __future__ import annotations
from pathlib import Path
from .server import rpc_call
from .session import SessionError
class DapEngine:
def __init__(self, rpc=None):
self.rpc_path = None
self._rpc_override = rpc
self.attached = None
self.event_sequence = 0
def rpc(self, method, arguments=None, timeout=10):
if self._rpc_override is not None:
return self._rpc_override(method, arguments or {})
if not self.rpc_path:
raise SessionError('DAP ещё не подключён к session server')
return rpc_call(self.rpc_path, method, arguments, timeout)
def handle(self, command: str, arguments: dict) -> tuple[dict, list[tuple[str, dict]]]:
events = []
if command == 'initialize':
body = {
'supportsConfigurationDoneRequest': True,
'supportsFunctionBreakpoints': True,
'supportsInstructionBreakpoints': False,
'supportsEvaluateForHovers': True,
'supportsSetVariable': False,
'supportsStepBack': False,
'supportsRestartRequest': False,
'supportsTerminateRequest': False,
'supportsDisassembleRequest': False,
'supportsSteppingGranularity': True,
}
return body, events
if command == 'attach':
if self._rpc_override is None:
path = arguments.get('socket')
if not isinstance(path, str) or not path:
raise SessionError('В attach требуется socket session server')
self.rpc_path = path
self.attached = self.rpc('status')
self.event_sequence = self.attached['event_sequence']
events.append(('initialized', {}))
return {'buildId': self.attached['build_id']}, events
if command == 'configurationDone':
location = self.rpc('where')
events.append(('stopped', {'reason': 'entry', 'threadId': 1,
'allThreadsStopped': True,
'description': self._description(location)}))
return {}, events
if command == 'disconnect':
return {}, events
if command == 'threads':
return {'threads': [{'id': 1, 'name': 'Sprinter Z80'}]}, events
if command == 'stackTrace':
location = self.rpc('where')
return {'stackFrames': [self._frame(location)], 'totalFrames': 1}, events
if command == 'scopes':
return {'scopes': [
{'name': 'Registers', 'variablesReference': 1, 'expensive': False},
{'name': 'Globals/statics', 'variablesReference': 2, 'expensive': False},
]}, events
if command == 'variables':
reference = int(arguments.get('variablesReference', 0))
if reference == 1:
values = self.rpc('registers')['registers']
variables = [{'name': name, 'value': f'{value:#x}',
'variablesReference': 0} for name, value in sorted(values.items())]
elif reference == 2:
variables = self._global_variables()
else:
variables = []
return {'variables': variables}, events
if command == 'evaluate':
expression = arguments.get('expression', '')
if not expression.isidentifier():
raise SessionError('MVP evaluate принимает только имя переменной')
value = self.rpc('read_variable', {'name': expression})
return {'result': str(value['value']), 'type': value['type'],
'variablesReference': 0, 'memoryReference': hex(value['link_address'])}, events
if command == 'setBreakpoints':
source = arguments.get('source', {})
path = source.get('path')
if not path:
raise SessionError('Для source breakpoint нужен полный path')
specifications = [{name: item[name] for name in
('line','condition','hitCondition','logMessage') if name in item}
for item in arguments.get('breakpoints', [])]
result = self.rpc('set_source_breakpoints',
{'file': path, 'breakpoints': specifications})
return {'breakpoints': [self._breakpoint(item) for item in result['breakpoints']]}, events
if command == 'setFunctionBreakpoints':
names = [item['name'] for item in arguments.get('breakpoints', [])]
result = self.rpc('set_function_breakpoints', {'names': names})
return {'breakpoints': [self._breakpoint(item) for item in result['breakpoints']]}, events
if command == 'continue':
self.rpc('continue')
return {'allThreadsContinued': True}, events
if command == 'pause':
self.rpc('pause')
return {}, events
if command in ('next', 'stepIn'):
if arguments.get('granularity') == 'instruction':
self.rpc('step')
else:
self.rpc('source_step', {
'kind': 'over' if command == 'next' else 'into'})
return {}, events
if command == 'stepOut':
self.rpc('source_step', {'kind': 'out'})
return {}, events
raise SessionError('DAP-команда пока не поддерживается: ' + command)
def poll_events(self, timeout=1):
previous = self.event_sequence
result = self.rpc('events', {'after': self.event_sequence, 'timeout': timeout},
timeout=timeout + 2)
self.event_sequence = result['last']
translated = []
gap = max(0, result.get('first', previous + 1) - previous - 1)
if gap:
translated.append(('output', {'category': 'stderr',
'output': f'sdbg: пропущено {gap} событий журнала; '
'уменьшите частоту logpoint\n'}))
for event in result['events']:
if event['event'] == 'stopped':
translated.append(('stopped', {'reason': event['body'].get('reason', 'breakpoint'),
'threadId': 1, 'allThreadsStopped': True,
'description': self._description(
event['body'].get('location', {}))}))
elif event['event'] == 'continued':
translated.append(('continued', {'threadId': 1, 'allThreadsContinued': True}))
elif event['event'] == 'invalidated':
translated.append(('terminated', {'restart': False}))
elif event['event'] == 'output':
translated.append(('output', {'category': event['body'].get('category','console'),
'output': event['body']['output']}))
return translated, result['closed']
@staticmethod
def _description(location):
status = location.get('status', 'unknown')
bank = location.get('function', {}).get('bank') if location.get('function') else None
suffix = '' if bank is None else f', bank {bank}'
return f'{status}{suffix}, PC={location.get("pc", 0):#x}'
@staticmethod
def _frame(location):
function = location.get('function') or {}
sources = location.get('sources') or []
source = sources[0] if sources else None
frame = {
'id': 1,
'name': function.get('name', '<unknown>') +
(' [ambiguous]' if location.get('status') == 'ambiguous' else ''),
'line': source['line'] if source else 1,
'column': 1,
'instructionPointerReference': hex(location.get('link_address', location.get('pc', 0))),
}
if source:
frame['source'] = {'name': Path(source['file']).name, 'path': source['file']}
return frame
@staticmethod
def _breakpoint(item):
locations = item.get('locations', [])
first = locations[0] if locations else {}
result = {'id': item['id'], 'verified': bool(item.get('verified', locations))}
line = item.get('line', first.get('line'))
if line is not None:
result['line'] = line
if first.get('link_address') is not None:
result['instructionReference'] = hex(first['link_address'])
if len(locations) > 1:
result['message'] = f'Разрешено в {len(locations)} адресов/банков'
return result
def _global_variables(self):
result = []
for variable in self.rpc('variables'):
label = variable['name']
if variable.get('module'):
label += '@' + variable['module']
try:
value = self.rpc('read_variable', {'name': variable['name'],
'module': variable.get('module')})
text = str(value['value'])
except SessionError as error:
text = '<unavailable: ' + str(error) + '>'
result.append({'name': label, 'value': text, 'type': variable['type'],
'variablesReference': 0,
'memoryReference': hex(variable['link_address'])})
return result
+51
View File
@@ -0,0 +1,51 @@
"""Строгий разбор Intel HEX для проверки образа, загруженного в MAME."""
from __future__ import annotations
from pathlib import Path
def read_ihx(path: Path) -> dict[int, int]:
"""Возвращает разреженный образ и отвергает битые/конфликтующие записи."""
memory: dict[int, int] = {}
base = 0
eof = False
for number, raw in enumerate(Path(path).read_text().splitlines(), 1):
if not raw.startswith(':'):
raise ValueError(f'Intel HEX, строка {number}: отсутствует двоеточие')
try:
record = bytes.fromhex(raw[1:])
except ValueError as error:
raise ValueError(f'Intel HEX, строка {number}: неверные hex-цифры') from error
if len(record) < 5 or len(record) != record[0] + 5:
raise ValueError(f'Intel HEX, строка {number}: неверная длина')
if sum(record) & 0xff:
raise ValueError(f'Intel HEX, строка {number}: неверная контрольная сумма')
length = record[0]
address = int.from_bytes(record[1:3], 'big')
kind = record[3]
data = record[4:4 + length]
if eof:
raise ValueError(f'Intel HEX, строка {number}: данные после EOF')
if kind == 0:
for offset, value in enumerate(data):
absolute = base + address + offset
if absolute in memory and memory[absolute] != value:
raise ValueError(f'Intel HEX: конфликт по адресу {absolute:#x}')
memory[absolute] = value
elif kind == 1:
if length or address:
raise ValueError(f'Intel HEX, строка {number}: неверная EOF-запись')
eof = True
elif kind == 2:
if length != 2 or address:
raise ValueError(f'Intel HEX, строка {number}: неверная segment-запись')
base = int.from_bytes(data, 'big') << 4
elif kind == 4:
if length != 2 or address:
raise ValueError(f'Intel HEX, строка {number}: неверная linear-запись')
base = int.from_bytes(data, 'big') << 16
elif kind not in (3, 5):
raise ValueError(f'Intel HEX, строка {number}: неизвестный тип {kind}')
if not eof:
raise ValueError('Intel HEX: отсутствует EOF')
return memory
+152
View File
@@ -0,0 +1,152 @@
"""Извлекает авторские логи из активного препроцессорного потока SDCC."""
from __future__ import annotations
import ast
import bisect
import re
import string
from pathlib import Path
_DIRECTIVE = re.compile(r'^#(?:line)?\s+(\d+)\s+"([^"]+)"')
_CALL = re.compile(r'\bSDBG_METADATA_(LOGIF|LOG)\s*\(')
_IDENT = re.compile(r'[A-Za-z_][A-Za-z_0-9]*\Z')
_LITERAL = re.compile(r'"(?:\\.|[^"\\])*"')
def _calls(text: str):
"""Находит metadata-вызовы вне строк/символьных литералов C."""
position = 0
while position < len(text):
char = text[position]
if char in ('"', "'"):
quote = char
position += 1
while position < len(text):
if text[position] == '\\': position += 2
elif text[position] == quote:
position += 1
break
else: position += 1
continue
match = _CALL.match(text, position)
if match:
yield match
position = match.end()
else:
position += 1
def _arguments(text: str, start: int) -> list[str]:
"""Делит аргументы вызова после `(`, не путая запятые в строках."""
args, begin, depth, quote, escape = [], start, 1, False, False
for position in range(start, len(text)):
char = text[position]
if quote:
if escape: escape = False
elif char == '\\': escape = True
elif char == '"': quote = False
continue
if char == '"': quote = True
elif char == '(': depth += 1
elif char == ')':
depth -= 1
if depth == 0:
args.append(text[begin:position].strip())
return args
elif char == ',' and depth == 1:
args.append(text[begin:position].strip())
begin = position + 1
raise ValueError('Незакрытый вызов SDBG_LOG в препроцессорном потоке')
def _message(value: str) -> str:
literals = []
position = 0
while position < len(value):
while position < len(value) and value[position].isspace(): position += 1
if position == len(value): break
match = _LITERAL.match(value, position)
if not match:
raise ValueError('SDBG_LOG: сообщение должно быть строковым литералом')
try:
decoded = ast.literal_eval(match.group())
except (ValueError, SyntaxError) as error:
raise ValueError('SDBG_LOG: неверный строковый литерал') from error
if not isinstance(decoded, str):
raise ValueError('SDBG_LOG: нужен обычный строковый литерал')
literals.append(decoded)
position = match.end()
message = ''.join(literals)
if not message or len(message) > 1024:
raise ValueError('SDBG_LOG: сообщение должно содержать 1..1024 символа')
validate_log_message(message)
return message
def validate_log_message(message: str) -> None:
"""Одна грамматика для C-макроса и DAP logMessage."""
if not isinstance(message, str) or not message or len(message) > 1024:
raise ValueError('logMessage должен содержать 1..1024 символа')
try:
fields = list(string.Formatter().parse(message))
except ValueError as error:
raise ValueError('Неверные фигурные скобки logMessage') from error
for _, name, spec, conversion in fields:
if name is not None and (not _IDENT.fullmatch(name) or spec or conversion):
raise ValueError('В logMessage разрешены только подстановки {variable}')
def extract(preprocessed: str, dependencies: set[str]) -> list[dict]:
"""Возвращает активные вызовы и проверенные исходные пути/строки."""
lines = preprocessed.splitlines(keepends=True)
offsets, indexed = [], []
offset, filename, number = 0, None, 0
for line in lines:
offsets.append(offset)
directive = _DIRECTIVE.match(line)
if directive:
filename, number = directive[2], int(directive[1])
indexed.append((None, 0))
else:
indexed.append((filename, number))
number += 1
offset += len(line)
found, tags = [], set()
for match in _calls(preprocessed):
index = bisect.bisect_right(offsets, match.start()) - 1
filename, number = indexed[index]
if not filename or number < 1:
raise ValueError('SDBG_LOG: препроцессор не сохранил позицию исходника')
source = str(Path(filename).resolve())
if source not in dependencies:
raise ValueError('SDBG_LOG: вызов вне проверенных исходников: ' + source)
args = _arguments(preprocessed, match.end())
expected = 3 if match[1] == 'LOGIF' else 2
if len(args) != expected:
raise ValueError('SDBG_LOG: неверное число аргументов')
tag = args[0]
if not _IDENT.fullmatch(tag) or tag in tags:
raise ValueError('SDBG_LOG: tag должен быть уникальным идентификатором TU: ' + tag)
tags.add(tag)
condition = args[1] if expected == 3 else None
if condition is not None and not _IDENT.fullmatch(condition):
raise ValueError('SDBG_LOGIF: пока поддержано только имя переменной')
found.append({'tag': tag, 'message': _message(args[-1]),
'condition': condition, 'source': source, 'line': number})
return found
def anchor_symbols(assembly: str, module: str, macros: list[dict]) -> tuple[str, list[dict]]:
"""Уникализирует asm-символы TU и требует ровно один якорь на macro tag."""
descriptions = []
for macro in macros:
old = '_spr_sdbg_log_' + macro['tag']
new = '_spr_sdbg_log_' + module + '_' + macro['tag']
definition = re.compile(r'(?m)^(\s*)' + re.escape(old) + r'(\s*=\s*\.\s*)$')
declaration = re.compile(r'(?m)^(\s*\.globl\s+)' + re.escape(old) + r'(\s*)$')
if len(definition.findall(assembly)) != 1 or len(declaration.findall(assembly)) != 1:
raise ValueError('SDBG_LOG: якорь отсутствует или развёрнут повторно: ' + macro['tag'])
assembly = definition.sub(lambda m: m[1] + new + m[2], assembly)
assembly = declaration.sub(lambda m: m[1] + new + m[2], assembly)
descriptions.append({**macro, 'symbol': new})
return assembly, descriptions
+193
View File
@@ -0,0 +1,193 @@
"""Проверенная оффлайновая карта SDCC; неизвестные диапазоны не угадываются."""
from __future__ import annotations
from dataclasses import asdict, dataclass
import json
from pathlib import Path
import re
from .build import digest
@dataclass(frozen=True)
class Location:
link_address: int
logical_address: int
section: str
bank: int | None
window: int | None
class DebugMap:
def __init__(self, directory):
self.directory = Path(directory).resolve()
self.manifest = json.loads((self.directory/'manifest.json').read_text())
if self.manifest['schema_version'] != 1:
raise ValueError('Неподдержанная версия пакета')
for name, expected in self.manifest['artifacts'].items():
path = (self.directory/name).resolve()
if not path.is_relative_to(self.directory) or digest(path) != expected:
raise ValueError('Повреждённый артефакт: ' + name)
self.stale_sources = [name for name, value in self.manifest['sources'].items()
if not Path(name).is_file() or digest(Path(name)) != value['sha256']]
stem = Path(self.manifest['executable']).stem
self.symbols = {}
for row in (self.directory/(stem+'.noi')).read_text().splitlines():
match = re.fullmatch(r'DEF (\S+) (0x[0-9A-Fa-f]+)', row)
if match:
self.symbols[match[1]] = int(match[2], 16)
self.sections = []
for row in (self.directory/(stem+'.map')).read_text().splitlines():
match = re.match(r'^(\S+)\s+([0-9A-F]{8})\s+([0-9A-F]{8})\s+=', row)
if match and int(match[3],16):
item = (match[1], int(match[2],16), int(match[3],16))
if item not in self.sections: self.sections.append(item)
self.units = {u['module']: u for u in self.manifest['units']}
self.instructions = {}
self.markers = []
self.functions = []
self.variables = []
self.logpoints = []
self.diagnostics = []
self._load((self.directory/(stem+'.cdb')).read_text().splitlines())
def verify_executable(self):
path = Path(self.manifest['executable_path'])
if not path.is_file() or digest(path) != self.manifest.get('executable_sha256', self.manifest['build_id']):
raise ValueError('EXE не соответствует пакету')
def location(self, address):
sections = [s for s in self.sections if s[1] <= address < s[1]+s[2]]
if len(sections) != 1:
raise ValueError(f'Неоднозначная/неизвестная секция адреса {address:#x}')
section = sections[0][0]
match = re.fullmatch(r'_?BANK(\d+)', section)
bank = int(match[1]) if match else None
if address > 0xffff and (bank is None or address >> 16 != bank):
raise ValueError(f'Неподдержанное размещение {section}: {address:#x}')
logical = address & 0xffff
return asdict(Location(address, logical, section, bank, logical >> 14))
def _load(self, records):
addresses, declarations = {}, []
module = None
for record in records:
if record.startswith('M:'): module = record[2:]
elif record.startswith('L:'):
name, value = record[2:].rsplit(':', 1)
address = int(value, 16)
if name in addresses and addresses[name] != address:
raise ValueError('Конфликт отладочного символа: ' + name)
addresses[name] = address
elif record.startswith(('F:', 'S:')):
declarations.append((module, record))
elif record and not record.startswith('T:'):
self.diagnostics.append('Неизвестная запись: '+record)
# A$ у ассемблера использует basename файла, а не .module.
for unit in self.units.values():
asm = (self.directory/unit['asm']).read_text().splitlines()
sizes = {}
listing = (self.directory/Path(unit['asm']).with_suffix('.lst')).read_text()
for row in listing.splitlines():
match = re.match(r'^\s+[0-9A-F]{6,8}\s+(.+?)\s+\[\s*\d+\]\s+(\d+)\s', row)
if match:
sizes[int(match[2])] = len(re.findall(r'[0-9A-F]{2}', match[1]))
prefix = 'A$'+Path(unit['asm']).stem+'$'
for symbol, address in addresses.items():
if symbol.startswith(prefix):
line = int(symbol[len(prefix):])
if line in sizes and sizes[line] > 0:
self.instructions[address] = {
**self.location(address), 'size': sizes[line],
'asm': unit['asm'], 'asm_line': line, 'text': asm[line-1].strip(),
'module': unit['module'],
}
for symbol, marker in unit['markers'].items():
if symbol not in addresses:
raise ValueError('Отсутствует linked CDB-маркер: '+symbol)
paths = unit['sources'].get(Path(marker['file']).name, [])
if len(paths) > 1:
raise ValueError('Неоднозначный путь debug-записи: ' + marker['file'])
self.markers.append({**self.location(addresses[symbol]),
'line': marker['line'], 'sources': paths,
'module': unit['module']})
for module, record in declarations:
match = re.match(r'([FS]):([^($]+\$[^($]+\$[^($]+\$[^($]+)\(\{(\d+)\}(.+)\),([A-Z]),', record)
if not match: continue
kind, key, size, ctype, space = match.groups()
parts = key.split('$')
if kind == 'F':
startkey = '$'.join(parts[:2])+'$0$0'
start, last = addresses.get(startkey), addresses.get('X'+startkey)
if start is None or last not in self.instructions: continue
end = last + self.instructions[last]['size']
if end <= start: continue
function = {'name': parts[1], 'module': module, 'start': start,
'end': end, **self.location(start)}
if function not in self.functions: self.functions.append(function)
elif space == 'E' and parts[0].startswith(('G','F')) and not ctype.startswith('DF,'):
address = addresses.get(key)
if address is None and parts[0] == 'G': address = self.symbols.get('_'+parts[1])
if address is None: continue
supported = bool(re.fullmatch(r'S[ICL]:[SU]', ctype) or ctype.startswith('DG,'))
variable = {'name': parts[1], 'module': module if parts[0] != 'G' else None,
'size': int(size), 'type': ctype,
'signed': ctype.endswith(':S') and not ctype.startswith('D'),
'supported': supported, **self.location(address)}
if variable not in self.variables: self.variables.append(variable)
self.functions.sort(key=lambda f: f['start'])
self.markers.sort(key=lambda m: m['link_address'])
for unit in self.units.values():
for macro in unit.get('log_macros', []):
address = self.symbols.get(macro['symbol'])
if address is None:
raise ValueError('Связанный SDBG_LOG-якорь отсутствует: ' + macro['tag'])
try:
location = self.location(address)
verified = address in self.instructions and self.function_at(address) is not None
reason = None if verified else 'Якорь не совпал с началом исполняемой инструкции'
except ValueError as error:
location, verified, reason = {}, False, str(error)
self.logpoints.append({**macro, **location, 'module': unit['module'],
'verified': verified, 'reason': reason})
def function_at(self, address):
found = [f for f in self.functions if f['start'] <= address < f['end']]
return found[0] if len(found) == 1 else None
def addr2line(self, address):
function = self.function_at(address)
instruction = next((v for k,v in self.instructions.items()
if k <= address < k+v['size']), None)
if not function or not instruction:
return {'address': address, 'status': 'unknown', 'function': function}
markers = [m for m in self.markers if m['module'] == function['module']
and function['start'] <= m['link_address'] <= instruction['link_address']]
nearest = max((m['link_address'] for m in markers), default=None)
sources = []
for marker in markers:
if marker['link_address'] == nearest:
for source in marker['sources']:
item = {'file': source, 'line': marker['line']}
if item not in sources: sources.append(item)
return {'status': 'mapped' if len(sources) == 1 else 'ambiguous' if sources else 'unknown',
'instruction': instruction, 'function': function, 'sources': sources, 'stale_source': any(s['file'] in self.stale_sources for s in sources)}
def line_locations(self, filename, line):
exact = str(Path(filename).resolve())
known = self.manifest['sources']
candidates = [exact] if exact in known else [p for p in known if Path(p).name == filename]
if len(candidates) > 1: raise ValueError('Неоднозначный source; укажите полный путь')
result = []
for marker in self.markers:
address = marker['link_address']
if marker['line'] == line and any(p in candidates for p in marker['sources']):
if address in self.instructions and self.function_at(address):
item = {**marker, 'function': self.function_at(address)['name']}
if item not in result: result.append(item)
stale = any(p in self.stale_sources for p in candidates)
return {'status': 'stale' if stale else 'verified' if result else 'unverified',
'locations': result, 'stale_source': stale}
def source_text(self, filename, line):
source = self.manifest['sources'][filename]
text = (self.directory/source['snapshot']).read_text(errors='replace').splitlines()
return text[line-1] if 0 < line <= len(text) else None
+464
View File
@@ -0,0 +1,464 @@
"""Постоянный владелец DebugSession и локальный JSON-RPC для адаптеров."""
from __future__ import annotations
from collections import deque
import json
from pathlib import Path
import socket
import socketserver
import string
import threading
import time
from .session import DebugSession, SessionError
from .transport import BridgeError
from .macros import validate_log_message
class SessionController:
def __init__(self, session: DebugSession):
self.session = session
self.lock = threading.RLock()
self.changed = threading.Condition()
self.events = deque(maxlen=1024)
self.dropped_events = 0
self.sequence = 0
self.running = False
self.source_step = None
self.closed = False
self.source_breakpoints: dict[str, list[int]] = {}
self.function_breakpoints: list[int] = []
self.breakpoint_info: dict[int, dict] = {}
self.attached = session.attach()
self.mame_console = bool(self.attached.get('capabilities', {}).get('console_print'))
self._emit('stopped', {'reason': 'entry', 'location': self.attached['location']})
self._install_macro_logs()
self.poller = threading.Thread(target=self._poll, name='sdbg-poller', daemon=True)
self.poller.start()
def _install_macro_logs(self) -> None:
"""Авторские точки принадлежат пакету сборки, не DAP source-набору."""
created = []
try:
for macro in getattr(self.session.model, 'logpoints', []):
if not macro['verified'] or macro['source'] in self.session.model.stale_sources:
self._emit('output', {'category': 'stderr',
'output': 'sdbg: SDBG_LOG ' + macro['tag'] +
' не активирован: ' + str(macro.get('reason') or 'устаревший исходник') + '\n'})
continue
self._validate_log_message(macro['message'])
item = self.session.break_anchor(macro, enabled=False)
created.append(item['id'])
self.breakpoint_info[item['id']] = {
'kind': 'log', 'message': macro['message'],
'condition': macro['condition'], 'module': macro['module'],
'tag': macro['tag'], 'locations': item['locations'], 'hits': 0,
}
if created:
self.session.activate_breakpoints()
except BaseException:
for identifier in created:
self.breakpoint_info.pop(identifier, None)
try: self.session.clear_breakpoint(identifier)
except BaseException: pass
raise
def _emit(self, name: str, body: dict) -> None:
with self.changed:
if len(self.events) == self.events.maxlen:
self.dropped_events += 1
self.sequence += 1
self.events.append({'seq': self.sequence, 'event': name, 'body': body})
self.changed.notify_all()
def _poll(self) -> None:
while not self.closed:
if not self.running:
time.sleep(.02)
continue
try:
with self.lock:
snapshot = self.session.bridge.request('snapshot')
if snapshot['state'] == 'stopped':
location = self.session.where(snapshot)
if self.source_step is not None:
self._source_step_stopped(location)
elif self._handle_logpoints(location):
self.session.bridge.request('continue')
continue
else:
self.running = False
self._emit('stopped', {'reason': 'breakpoint', 'location': location})
elif snapshot['state'] == 'invalidated':
self.running = False
self.source_step = None
self.closed = True
self._emit('invalidated', {'reason': 'reset_or_load'})
except (BridgeError, SessionError, ValueError, OSError) as error:
self.running = False
self.source_step = None
self.closed = True
self._emit('invalidated', {'reason': str(error)})
time.sleep(.01)
def close(self) -> None:
self.closed = True
self.poller.join(timeout=1)
self.session.bridge.close()
def call(self, method: str, arguments: dict) -> object:
if method == 'events':
after = int(arguments.get('after', 0))
timeout = min(max(float(arguments.get('timeout', 0)), 0), 30)
deadline = time.monotonic() + timeout
with self.changed:
while self.sequence <= after and not self.closed and time.monotonic() < deadline:
self.changed.wait(deadline - time.monotonic())
return {'events': [event for event in self.events if event['seq'] > after],
'first': self.events[0]['seq'] if self.events else self.sequence + 1,
'last': self.sequence, 'lost': self.dropped_events,
'closed': self.closed}
with self.lock:
if self.closed:
raise SessionError('Сессия закрыта или инвалидирована')
if method == 'status':
return {**self.attached, 'running': self.running,
'event_sequence': self.sequence}
if method == 'snapshot':
return self.session.bridge.request('snapshot')
if method == 'mame_console_tail':
return self.session.bridge.request(
'console_tail', count=int(arguments.get('count', 40)))
if method == 'input_key':
if not self.running and arguments['down'] is not False:
raise SessionError('Нажатие возможно только при running CPU')
return self.session.bridge.request(
'key', tag=arguments['tag'], mask=int(arguments['mask']),
down=arguments['down'])
if method == 'where':
return self.session.where()
if method == 'registers':
mapping = self.session.refresh()
return {'generation': mapping.generation, 'registers': mapping.registers,
'bank_pages': mapping.bank_pages}
if method == 'variables':
return self.session.model.variables
if method == 'read_variable':
return self.session.read_variable(arguments['name'], arguments.get('module'))
if method == 'break_line':
return self.session.break_line(arguments['file'], int(arguments['line']))
if method == 'break_function':
return self.session.break_function(arguments['name'])
if method == 'clear_breakpoint':
return self.session.clear_breakpoint(int(arguments['id']))
if method == 'set_source_breakpoints':
specifications = arguments.get('breakpoints')
if specifications is None:
specifications = [{'line': line} for line in arguments.get('lines', [])]
return self._set_source_breakpoints(arguments['file'], specifications)
if method == 'set_function_breakpoints':
return self._set_function_breakpoints(arguments.get('names', []))
if method == 'continue':
if self.running:
raise SessionError('CPU уже выполняется; сначала Pause')
result = self.session.bridge.request('continue')
self.running = True
self._emit('continued', {})
return result
if method == 'pause':
self.source_step = None
self.session.bridge.request('pause')
location = self.session.where(self.session.bridge.wait_stopped())
self.running = False
self._emit('stopped', {'reason': 'pause', 'location': location})
return location
if method == 'step':
if self.running:
raise SessionError('CPU уже выполняется; сначала Pause')
self.session.bridge.request('step')
self.running = True
self._emit('continued', {'reason': 'step'})
location = self.session.where(self.session.bridge.wait_stopped())
self.running = False
self._emit('stopped', {'reason': 'step', 'location': location})
return location
if method == 'source_step':
return self._source_step(arguments.get('kind', 'into'))
raise SessionError('Неизвестный RPC-метод: ' + method)
@staticmethod
def _source_identity(location: dict) -> frozenset[tuple[str, int]]:
return frozenset((item['file'], int(item['line']))
for item in location.get('sources', []))
def _has_stop_breakpoint(self, location: dict) -> bool:
address = location.get('link_address')
return any(info['kind'] == 'stop' and
any(item.get('link_address') == address for item in info['locations'])
for info in self.breakpoint_info.values())
def _source_step(self, kind: str) -> dict:
commands = {'into': 'step', 'over': 'step_over', 'out': 'step_out'}
if kind not in commands:
raise SessionError('Неизвестный вид source step')
if self.running:
raise SessionError('CPU уже выполняется; сначала Pause')
start = self.session.where()
if start.get('stale_source'):
raise SessionError('Исходник изменён после сборки; пересоберите программу')
initial = self._source_identity(start)
if not initial:
raise SessionError('Текущий PC не имеет проверенной C-позиции')
self.source_step = {
'initial': initial, 'kind': kind, 'command': commands[kind],
'instructions': 1, 'location': start,
}
try:
self.session.bridge.request(commands[kind])
except BaseException:
self.source_step = None
raise
if kind == 'out':
# Первый out выходит из машинного frame. У банкового вызова
# дальше идём через over до первой позиции C вызывающей функции.
self.source_step['command'] = 'step_over'
self.running = True
self._emit('continued', {'reason': 'step'})
return {'accepted': True}
def _source_step_stopped(self, location: dict) -> None:
step = self.source_step
self._handle_logpoints(location)
reason = None
if self._has_stop_breakpoint(location):
reason = 'breakpoint'
elif self._source_identity(location) and self._source_identity(location) != step['initial']:
reason = 'step'
elif step['instructions'] >= 512:
reason = 'step'
self._emit('output', {'category': 'stderr',
'output': 'sdbg: source step достиг лимита; CPU остановлен\n'})
if reason is not None:
self.source_step = None
self.running = False
self._emit('stopped', {'reason': reason, 'location': location})
return
# Машинный over может ждать клавишу сколь угодно долго. Пока CPU
# выполняется, этот автомат не занимает session lock и не ставит
# таймер; пользователь может направить ввод в MAME или нажать Pause.
self.session.bridge.request(step['command'])
step['instructions'] += 1
step['location'] = location
def _set_source_breakpoints(self, filename: str, specifications) -> dict:
filename = str(Path(filename).resolve())
requested = []
for value in specifications:
if not isinstance(value, dict):
raise SessionError('Описание breakpoint должно быть объектом')
line = int(value['line'])
if line <= 0 or any(item['line'] == line for item in requested):
raise SessionError('Номер строки должен быть положительным и уникальным')
if value.get('condition') or value.get('hitCondition'):
raise SessionError('Условия и hitCondition пока не поддержаны')
message = value.get('logMessage')
if message is not None:
self._validate_log_message(message)
requested.append({'line': line, 'logMessage': message})
created = []
results = []
try:
for specification in requested:
line = specification['line']
item = self.session.break_line(filename, line, enabled=False)
created.append(item['id'])
kind = 'log' if specification['logMessage'] is not None else 'stop'
self.breakpoint_info[item['id']] = {
'kind': kind, 'message': specification['logMessage'],
'locations': item['locations'], 'hits': 0,
}
results.append({'line': line, 'verified': True,
'logMessage': specification['logMessage'], **item})
except BaseException:
for identifier in created:
self.breakpoint_info.pop(identifier, None)
try:
self.session.clear_breakpoint(identifier)
except BaseException:
pass
raise
previous = self.source_breakpoints.get(filename, [])
for identifier in previous:
self.breakpoint_info.pop(identifier, None)
self.session.clear_breakpoint(identifier)
self.source_breakpoints[filename] = created
self.session.activate_breakpoints()
return {'file': filename, 'breakpoints': results}
def _set_function_breakpoints(self, names) -> dict:
requested = []
for value in names:
name = str(value)
if not name or name in requested:
raise SessionError('Имя функции должно быть непустым и уникальным')
requested.append(name)
created = []
results = []
try:
for name in requested:
item = self.session.break_function(name, enabled=False)
created.append(item['id'])
self.breakpoint_info[item['id']] = {
'kind': 'stop', 'message': None, 'locations': item['locations'], 'hits': 0}
results.append({'name': name, 'verified': True, **item})
except BaseException:
for identifier in created:
self.breakpoint_info.pop(identifier, None)
try:
self.session.clear_breakpoint(identifier)
except BaseException:
pass
raise
for identifier in self.function_breakpoints:
self.breakpoint_info.pop(identifier, None)
self.session.clear_breakpoint(identifier)
self.function_breakpoints = created
self.session.activate_breakpoints()
return {'breakpoints': results}
@staticmethod
def _validate_log_message(message: str) -> None:
try:
validate_log_message(message)
except ValueError as error:
raise SessionError(str(error)) from error
def _read_log_variable(self, name: str, module: str | None) -> dict:
if module is None:
return self.session.read_variable(name)
candidates = [item for item in self.session.model.variables
if item['name'] == name and item['module'] in (None, module)]
if len(candidates) != 1:
raise SessionError('Переменная не найдена или имя неоднозначно')
return self.session.read_variable(name, candidates[0]['module'])
def _render_log_message(self, message: str, module: str | None = None) -> str:
output = []
for literal, name, _, _ in string.Formatter().parse(message):
output.append(literal)
if name is not None:
try:
output.append(str(self._read_log_variable(name, module)['value']))
except SessionError as error:
output.append('<unavailable: ' + str(error) + '>')
return ''.join(output)
def _handle_logpoints(self, location: dict) -> bool:
address = location.get('link_address')
matched_logs = []
matched_any_log = False
matched_stop = False
for info in self.breakpoint_info.values():
if not any(item.get('link_address') == address for item in info['locations']):
continue
if info['kind'] == 'stop':
matched_stop = True
else:
matched_any_log = True
condition = info.get('condition')
if condition is not None:
try:
if not self._read_log_variable(condition, info.get('module'))['value']:
continue
except SessionError as error:
if not info.get('condition_warned'):
info['condition_warned'] = True
self._emit('output', {'category': 'stderr',
'output': 'sdbg: SDBG_LOGIF ' + info.get('tag', '') +
': ' + str(error) + '\n'})
continue
info['hits'] += 1
matched_logs.append(info)
for info in matched_logs:
rendered = self._render_log_message(info['message'], info.get('module'))
self._emit('output', {'category': 'console',
'output': rendered + '\n',
'location': location, 'hit': info['hits'],
'tag': info.get('tag')})
if self.mame_console:
try:
self.session.bridge.request('console_print', text=rendered)
except (BridgeError, OSError) as error:
if not info.get('console_warned'):
info['console_warned'] = True
self._emit('output', {'category': 'stderr',
'output': 'sdbg: MAME console: ' + str(error) + '\n'})
return matched_any_log and not matched_stop
class _ThreadedUnixServer(socketserver.ThreadingMixIn, socketserver.UnixStreamServer):
daemon_threads = True
class SessionRpcServer:
def __init__(self, path, controller: SessionController):
self.path = Path(path)
self.controller = controller
self.path.parent.mkdir(parents=True, exist_ok=True)
if self.path.exists():
try:
with socket.socket(socket.AF_UNIX) as probe:
probe.connect(str(self.path))
except OSError:
self.path.unlink()
else:
raise OSError('RPC socket уже занят: ' + str(self.path))
controller_ref = controller
class Handler(socketserver.StreamRequestHandler):
def handle(self):
raw = self.rfile.readline(1_048_577)
response = {'id': None, 'ok': False}
try:
if len(raw) > 1_048_576:
raise ValueError('RPC-запрос слишком велик')
request = json.loads(raw)
if not isinstance(request, dict) or not isinstance(request.get('method'), str):
raise ValueError('Неверная структура RPC-запроса')
arguments = request.get('arguments', {})
if not isinstance(arguments, dict):
raise ValueError('RPC arguments должен быть объектом')
response = {'id': request.get('id'), 'ok': True,
'result': controller_ref.call(request['method'], arguments)}
except (BridgeError, SessionError, ValueError, TypeError, OSError, KeyError) as error:
response.update(error=str(error))
self.wfile.write((json.dumps(response, ensure_ascii=False) + '\n').encode())
self.server = _ThreadedUnixServer(str(self.path), Handler)
self.path.chmod(0o600)
def serve_forever(self):
try:
self.server.serve_forever(poll_interval=.1)
finally:
self.close()
def close(self):
self.server.server_close()
self.path.unlink(missing_ok=True)
def rpc_call(path, method: str, arguments=None, timeout=10):
request = {'id': 1, 'method': method, 'arguments': arguments or {}}
with socket.socket(socket.AF_UNIX) as client:
client.settimeout(timeout)
client.connect(str(path))
client.sendall((json.dumps(request, ensure_ascii=False) + '\n').encode())
file = client.makefile('rb')
raw = file.readline(1_048_577)
if not raw:
raise SessionError('RPC server закрыл соединение без ответа')
response = json.loads(raw)
if not response.get('ok'):
raise SessionError(response.get('error', 'Неизвестная RPC-ошибка'))
return response['result']
+276
View File
@@ -0,0 +1,276 @@
"""Высокоуровневая source-debug сессия поверх проверенной карты и IPC."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from .image import read_ihx
from .model import DebugMap
from .transport import FileBridge
class SessionError(RuntimeError):
pass
@dataclass(frozen=True)
class MappingSnapshot:
generation: int
registers: dict[str, int]
bank_pages: dict[int, int]
class DebugSession:
"""Связывает build identity, состояние CPU и логические операции IDE."""
def __init__(self, model: DebugMap, bridge: FileBridge):
self.model = model
self.bridge = bridge
self.mapping: MappingSnapshot | None = None
self.resident_pages: dict[int, int] = {}
self.breakpoints: dict[int, list[int]] = {}
self._next_breakpoint = 1
stem = Path(model.manifest['executable']).stem
self.image = read_ihx(model.directory / (stem + '.ihx'))
def attach(self) -> dict:
"""Проверяет пакет, остановку CPU, резидентный код и bank mapping."""
try:
self.model.verify_executable()
hello = self.bridge.handshake()
snapshot = self.bridge.wait_stopped()
mapping = self._mapping(snapshot)
if mapping.bank_pages and not hello.get('capabilities', {}).get('bank_guard'):
raise SessionError('Backend не поддерживает безопасные банковские точки')
self._verify_loaded_code(mapping)
except (ValueError, OSError) as error:
raise SessionError(str(error)) from error
self.mapping = mapping
self._remember_resident_pages(mapping)
return {
'build_id': self.model.manifest['build_id'],
'state': snapshot['state'],
'protocol': hello['protocol'],
'capabilities': hello.get('capabilities', {}),
'generation': mapping.generation,
'pc': mapping.registers['PC'],
'bank_pages': mapping.bank_pages,
'stale_sources': self.model.stale_sources,
'location': self.where(snapshot),
}
def refresh(self) -> MappingSnapshot:
snapshot = self.bridge.wait_stopped()
self.mapping = self._mapping(snapshot)
self._remember_resident_pages(self.mapping)
self._verify_loaded_code(self.mapping)
return self.mapping
def _remember_resident_pages(self, mapping: MappingSnapshot) -> None:
bank_values = set(mapping.bank_pages.values())
windows = {instruction['window'] for instruction in self.model.instructions.values()
if instruction['bank'] is not None}
for window in windows:
value = self._page(mapping, window)
if value is not None and value not in bank_values:
self.resident_pages[window] = value
@staticmethod
def _page(mapping: MappingSnapshot, window: int) -> int | None:
"""Sprinter state PG может включать старшие флаги; port хранит byte."""
value = mapping.registers.get(f'PG{window}')
return None if value is None else value & 0xff
def _mapping(self, snapshot: dict) -> MappingSnapshot:
registers = snapshot.get('registers')
if snapshot.get('state') != 'stopped' or not isinstance(registers, dict):
raise SessionError('Backend не предоставил регистры остановленного CPU')
if 'PC' not in registers:
raise SessionError('Backend не предоставил PC')
maximum = max((location['bank'] or 0 for location in
[*self.model.functions, *self.model.variables,
*self.model.instructions.values()]), default=0)
pages: dict[int, int] = {}
table = self.model.symbols.get('_bank_pages')
if maximum:
if table is None:
raise SessionError('В банковской сборке отсутствует _bank_pages')
data = bytes.fromhex(self.bridge.request(
'memory', address=table, length=maximum + 1)['hex'])
pages = {bank: data[bank] for bank in range(1, maximum + 1)}
if any(value == 0 for value in pages.values()) or \
len(set(pages.values())) != len(pages):
raise SessionError('_bank_pages ещё не готова или содержит нули/дубликаты')
windows = {location['window'] for location in
[*self.model.functions, *self.model.variables]
if location['bank'] is not None}
missing = [window for window in windows if f'PG{window}' not in registers]
if missing:
raise SessionError('Backend не предоставил PG для банковских окон')
generation = self.bridge.generation
if generation is None:
raise SessionError('Backend не предоставил generation')
return MappingSnapshot(generation, dict(registers), pages)
def _expected_ranges(self, mapping: MappingSnapshot):
expected: dict[int, int] = {}
active_windows = {instruction['window'] for instruction in self.model.instructions.values()
if instruction['bank'] is not None and
self._page(mapping, instruction['window']) ==
mapping.bank_pages[instruction['bank']]}
for instruction in self.model.instructions.values():
bank = instruction['bank']
if bank is not None:
if self._page(mapping, instruction['window']) != mapping.bank_pages[bank]:
continue
elif instruction['window'] in active_windows:
# Физическая страница банка закрыла весь resident window.
continue
start = instruction['link_address']
logical = instruction['logical_address']
for offset in range(instruction['size']):
if start + offset not in self.image:
raise SessionError(f'В IHX нет инструкции по адресу {start + offset:#x}')
value = self.image[start + offset]
if logical + offset in expected and expected[logical + offset] != value:
raise SessionError(f'Неоднозначный ожидаемый байт {logical + offset:#x}')
expected[logical + offset] = value
return expected
def _verify_loaded_code(self, mapping: MappingSnapshot) -> None:
expected = self._expected_ranges(mapping)
addresses = sorted(expected)
ranges: list[tuple[int, int]] = []
for address in addresses:
if not ranges or address != ranges[-1][1] or address - ranges[-1][0] >= 4096:
ranges.append((address, address + 1))
else:
ranges[-1] = (ranges[-1][0], address + 1)
for start, end in ranges:
actual = bytes.fromhex(self.bridge.request(
'memory', address=start, length=end - start)['hex'])
wanted = bytes(expected[address] for address in range(start, end))
if actual != wanted:
mismatch = next(i for i, pair in enumerate(zip(actual, wanted))
if pair[0] != pair[1])
raise SessionError(
f'Образ в MAME не соответствует build по адресу {start + mismatch:#x}')
def _link_address(self, pc: int, mapping: MappingSnapshot) -> int:
candidates = []
for instruction in self.model.instructions.values():
if instruction['bank'] is None:
continue
if not (instruction['logical_address'] <= pc <
instruction['logical_address'] + instruction['size']):
continue
if self._page(mapping, instruction['window']) == mapping.bank_pages[instruction['bank']]:
candidates.append(instruction['link_address'] +
pc - instruction['logical_address'])
candidates = sorted(set(candidates))
if len(candidates) > 1:
raise SessionError(f'Неоднозначное банковское отображение PC={pc:#x}')
return candidates[0] if candidates else pc
def where(self, snapshot: dict | None = None) -> dict:
mapping = self._mapping(snapshot) if snapshot is not None else self.refresh()
if snapshot is not None:
self._verify_loaded_code(mapping)
pc = mapping.registers['PC']
link = self._link_address(pc, mapping)
return {'pc': pc, 'link_address': link, **self.model.addr2line(link)}
def _guard(self, location: dict, mapping: MappingSnapshot) -> dict:
bank = location['bank']
window = location['window']
banked_window = any(instruction['bank'] is not None and
instruction['window'] == window
for instruction in self.model.instructions.values())
if bank is not None:
return {'window': window, 'page': mapping.bank_pages[bank]}
if not banked_window:
return {}
if window not in self.resident_pages:
raise SessionError('Физическая страница resident window ещё не установлена')
return {'window': window, 'page': self.resident_pages[window]}
def _install(self, locations: list[dict], enabled: bool = True) -> dict:
if not locations:
raise SessionError('Для точки остановки нет исполняемых адресов')
mapping = self.refresh()
ids = []
conditions = []
try:
for location in locations:
arguments = self._guard(location, mapping)
if not enabled:
arguments['enabled'] = False
result = self.bridge.request(
'breakpoint', address=location['logical_address'],
**arguments)
ids.append(result['id'])
conditions.append(result.get('condition', ''))
except BaseException:
for identifier in ids:
try:
self.bridge.request('clear', id=identifier)
except BaseException:
pass
raise
logical = self._next_breakpoint
self._next_breakpoint += 1
self.breakpoints[logical] = ids
return {'id': logical, 'backend_ids': ids, 'conditions': conditions,
'locations': locations}
def break_line(self, filename: str, line: int, enabled: bool = True) -> dict:
resolved = self.model.line_locations(filename, line)
if resolved['stale_source']:
raise SessionError('Исходник изменён после сборки; пересоберите программу')
return self._install(resolved['locations'], enabled)
def break_function(self, name: str, enabled: bool = True) -> dict:
locations = [function for function in self.model.functions
if function['name'] == name]
return self._install(locations, enabled)
def break_anchor(self, location: dict, enabled: bool = True) -> dict:
if not location.get('verified') or location.get('source') in self.model.stale_sources:
raise SessionError('SDBG_LOG-якорь не проверен или исходник устарел')
return self._install([location], enabled)
def activate_breakpoints(self) -> dict:
return self.bridge.request('activate_breakpoints')
def deactivate_breakpoints(self) -> dict:
return self.bridge.request('deactivate_breakpoints')
def clear_breakpoint(self, identifier: int) -> dict:
ids = self.breakpoints.pop(identifier, None)
if ids is None:
raise SessionError('Неизвестная логическая точка остановки')
for backend_id in ids:
self.bridge.request('clear', id=backend_id)
return {'cleared': identifier, 'backend_ids': ids}
def read_variable(self, name: str, module: str | None = None) -> dict:
matches = [variable for variable in self.model.variables
if variable['name'] == name and
(module is None or variable['module'] == module)]
if len(matches) != 1:
raise SessionError('Переменная не найдена или имя неоднозначно')
variable = matches[0]
if not variable['supported'] or variable['size'] not in (1, 2, 4):
raise SessionError('Тип переменной пока не поддерживается')
mapping = self.refresh()
if variable['bank'] is not None:
if self._page(mapping, variable['window']) != mapping.bank_pages[variable['bank']]:
raise SessionError('Банк переменной сейчас не отображён')
else:
bank_pages = set(mapping.bank_pages.values())
if self._page(mapping, variable['window']) in bank_pages:
raise SessionError('Resident-страница переменной сейчас закрыта банком')
data = bytes.fromhex(self.bridge.request(
'memory', address=variable['logical_address'], length=variable['size'])['hex'])
value = int.from_bytes(data, 'little', signed=variable['signed'])
return {**variable, 'value': value, 'hex': data.hex()}
+84
View File
@@ -0,0 +1,84 @@
"""Один владелец файлового backend; timeout инвалидирует канал mutations."""
from __future__ import annotations
import fcntl
import json
from pathlib import Path
import threading
import time
import uuid
from .build import write_json
class BridgeError(RuntimeError):
pass
class FileBridge:
def __init__(self, directory, session, timeout=5):
self.directory = Path(directory)
self.session = session
self.timeout = timeout
self.generation = None
self.invalid = False
self._mutex = threading.Lock()
self._owner = (self.directory/'owner.lock').open('a')
try:
fcntl.flock(self._owner, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
self._owner.close()
raise BridgeError('У backend уже есть управляющая сессия')
def close(self):
with self._mutex:
self._owner.close()
def request(self, command, **args):
with self._mutex:
if self._owner.closed:
raise BridgeError('Канал закрыт')
if self.invalid:
raise BridgeError('Канал инвалидирован после timeout; требуется новая сессия')
identity = str(uuid.uuid4().int)
request = self.directory/f'req_{identity}.json'
response = self.directory/f'resp_{identity}.json'
write_json(request, {'session': self.session, 'generation': self.generation,
'command': command, 'args': args})
deadline = time.monotonic()+self.timeout
while time.monotonic() < deadline:
if response.exists():
try:
value = json.loads(response.read_text())
if not isinstance(value, dict) or not {'session', 'generation', 'ok'} <= value.keys():
raise ValueError('Неверная структура ответа')
except (OSError, ValueError) as error:
self.invalid = True
raise BridgeError('Повреждённый ответ backend: ' + str(error)) from error
response.unlink()
if value['session'] != self.session:
self.invalid = True
raise BridgeError('Ответ от другой сессии')
self.generation = value['generation']
if not value['ok']:
raise BridgeError(value['error'])
return value['result']
time.sleep(.005)
self.invalid = True
request.unlink(missing_ok=True)
raise BridgeError('Timeout: результат команды неизвестен; автоматический повтор запрещён')
def wait_stopped(self, timeout=5):
deadline = time.monotonic()+timeout
while time.monotonic() < deadline:
snapshot = self.request('snapshot')
if snapshot['state']=='stopped':
return snapshot
if snapshot['state']=='invalidated':
raise BridgeError('Сессия MAME инвалидирована reset/load')
time.sleep(.005)
raise BridgeError('CPU не остановился за отведённое время')
def handshake(self):
response = self.request('hello')
if response['protocol'] != 1:
raise BridgeError('Неподдержанная версия протокола')
return response