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
+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