194 lines
11 KiB
Python
194 lines
11 KiB
Python
"""Проверенная оффлайновая карта 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
|