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