#!/usr/bin/env python3 """ check_bank_calls.py — две проверки банкового кода, которых нет ни у компилятора, ни у линкера. ПРОВЕРКА 1 — ПРЯМОЙ вызов в ЧУЖОЙ банк. Зачем. Трамплин у SDCC выбирается по `__banked` в ОБЪЯВЛЕНИИ, а не по тому, в каком банке функция реально лежит (memory sdcc_banked_call_rules). Поэтому функция, объявленная без `__banked`, зовётся прямым `call`, и это верно ровно до тех пор, пока вызывающий и вызываемый лежат в ОДНОМ банке. Стоит перенести модуль в другой банк ради разгрузки — и тот же `call` уходит по адресу, где у нового банка пустой хвост (0xFF). Процессор ползёт по нему вверх, переваливает на 0x0000, и DSS убивает процесс. Собирается это МОЛЧА: ни компилятор, ни линкер такой вызов не ловят. Поймано вживую 2026-08-27 — `hof_draw_rows` (банк 10) звал `pop_text_map` (банк 9): `call $E503` в пустоту, крэш финала. Как проверяем. По .map берём банк каждого глобального символа (старшее слово адреса: 0 = резидент, N = банк N), по сгенерированным bankN_*.asm — все прямые `call`/`jp` по ИМЕНИ символа. Вызов законен, если цель резидентная или лежит в том же банке; иначе это мина. ПРОВЕРКА 2 — УКАЗАТЕЛЬ НА ДАННЫЕ СВОЕГО БАНКА, ОТДАННЫЙ В ЧУЖОЙ. Обратная сторона той же медали. Пока вызывающий и вызываемый лежали в одном банке, указатель на литерал или const-таблицу банка работал. Стоит пометить вызываемую функцию `__banked` — и трамплин на время вызова переключает W3: тот же указатель показывает уже в чужой банк, и функция читает оттуда мусор. Поймано вживую 2026-08-27: `hof_edit_draw` (банк 10) передавал литерал "_" в `pop_text_draw_mapped` (банк 9), и экран заливало знаками вопроса на каждое мигание курсора. Ловим ФОРМУ, а не поток данных: аргументы банкового вызова SDCC кладёт непосредственно перед ним, поэтому идём от каждого `call ___sdcc_bcall_ehl` назад по блоку подготовки и смотрим, не грузился ли там адрес метки из области _BANKn этого же модуля. НЕ ПРОВЕРЕНО: режим, в котором const/статики банковых модулей размещаются в РЕЗИДЕНТНОЙ памяти, а не в области банка (SprPoP им не пользуется). По построению там всё должно быть тихо — «данными банка» считаются только метки из `.area _BANKn`, а резидентные в этот список не попадут, и передавать такой указатель в чужой банк законно. Но живьём это не гонялось: если режим включат, проверку надо прогнать и убедиться, что она не начала молчать там, где не должна. ЧЕГО ЭТА ПРОВЕРКА НЕ ВИДИТ (и не может): указатель, положенный в переменную и переданный позже; адрес, посчитанный арифметикой; указатель, отданный резидентной функции, которая сохранит его на будущее. Для этого нужен анализ потока данных. Правило остаётся за человеком: всё, что уходит указателем в другой банк, обязано лежать в W2 (стек, _DATA, копия). Использование: check_bank_calls.py # каталог с .map и bankN_*.asm Возврат: 0 — чисто, 1 — найдено (печатается списком). """ import re import sys from pathlib import Path # `call _foo`, `jp _foo`, с условием и без: `call NZ,_foo` / `jp C,_foo`. CALL_RE = re.compile( r'^\s*(call|jp)\s+(?:[A-Za-z]{1,2}\s*,\s*)?(_[A-Za-z_0-9]+)\s*(?:;.*)?$', re.IGNORECASE) # Строка карты: " 0009E503 _pop_text_map pop_ui" MAP_RE = re.compile(r'^\s+([0-9A-F]{8})\s+(\S+)\s') BANK_ASM_RE = re.compile(r'^bank(\d+)_') # Механика самих банковых вызовов — не наша забота. IGNORE = {'___sdcc_bcall_ehl', '___sdcc_bcall'} # `ld hl,#_метка` / `ld de,#___str_3` — материализация адреса. # # Скобочную форму принимаем обязательно: адрес ПОЛЯ структуры SDCC пишет как # `ld bc, #(_mstream + 6)`, и без неё проверка молча пропускала целый класс. # Поймано вживую 2026-09-03 в порту Loom: `mask.c` (банк 5, --bank-data 5) # отдавал `res_copy` (банк 11) адрес поля своей структуры — маска читалась как # «всё заполнено», а сборка при этом была «чистой». LOADADDR_RE = re.compile( r'^\s*ld\s+(hl|de|bc|iy|ix)\s*,\s*#\(?\s*(_{1,3}[A-Za-z_0-9]+)' r'(?:\s*[+-]\s*[\w()+\- ]+?)?\s*\)?\s*(?:;.*)?$', re.IGNORECASE) PUSH_RE = re.compile(r'^\s*push\s+(hl|de|bc|iy|ix)\s*(?:;.*)?$', re.IGNORECASE) # `ld e,#b_foo` перед трамплином называет банк цели. BANKARG_RE = re.compile(r'^\s*ld\s+e\s*,\s*#(b_[A-Za-z_0-9]+)\s*(?:;.*)?$', re.IGNORECASE) AREA_RE = re.compile(r'^\s*\.area\s+(\S+)') LABEL_RE = re.compile(r'^([A-Za-z_][A-Za-z_0-9]*)::?') # Директивы данных: по ним отличаем метку ДАННЫХ от метки функции. Без # этого проверка принимала бы за указатель саму механику трамплина # (`ld hl,#_функция` перед `call ___sdcc_bcall_ehl`). DATA_DIR_RE = re.compile(r'^\s*\.(ascii|asciz|str|db|byte|dw|word|ds|blkb)\b', re.IGNORECASE) # Сколько строк назад от трамплина считаем «подготовкой аргументов». ARG_WINDOW = 40 def load_symbol_banks(map_path): """symbol -> номер банка (0 = резидент). Берём ПЕРВОЕ определение: карта повторяет один и тот же список в каждой секции Area.""" banks = {} for line in map_path.read_text(errors='replace').splitlines(): m = MAP_RE.match(line) if not m: continue value, name = m.group(1), m.group(2) if name.startswith('l__') or name.startswith('s__') or name == '.__.ABS.': continue banks.setdefault(name, int(value[:4], 16)) return banks def main(argv): if len(argv) != 2: print(__doc__.strip()) return 2 build = Path(argv[1]) maps = list(build.glob('*.map')) if not maps: print(f'check_bank_calls: нет .map в {build}', file=sys.stderr) return 2 banks = load_symbol_banks(maps[0]) bad = [] for asm in sorted(build.glob('bank*_*.asm')): m = BANK_ASM_RE.match(asm.name) if not m: continue self_bank = int(m.group(1)) for n, line in enumerate(asm.read_text(errors='replace').splitlines(), 1): c = CALL_RE.match(line) if not c: continue target = c.group(2) if target in IGNORE: continue tb = banks.get(target) if tb is None or tb == 0 or tb == self_bank: continue bad.append((asm.name, n, self_bank, target, tb, line.strip())) bad_ptr = check_bank_local_pointers(build, banks) if not bad and not bad_ptr: print('check_bank_calls: чисто — ни прямых вызовов в чужой банк, ' 'ни указателей на данные своего банка наружу') return 0 if bad: print('check_bank_calls: ПРЯМОЙ ВЫЗОВ В ЧУЖОЙ БАНК ' '(объявить цель __banked либо свести модули в один банк):', file=sys.stderr) for name, line_no, sb, target, tb, text in bad: print(f' {name}:{line_no}: банк {sb} -> {target} (банк {tb}): ' f'{text}', file=sys.stderr) if bad_ptr: print('check_bank_calls: ВНИМАНИЕ — похоже, указатель на данные ' 'своего банка уходит в чужой (проверить глазами; лечится ' 'переносом данных в W2: стек, _DATA или копия):', file=sys.stderr) for name, line_no, sb, label, target, tb in bad_ptr: print(f' {name}:{line_no}: банк {sb}: #{label} -> {target} ' f'(банк {tb})', file=sys.stderr) # Сборку валит ТОЛЬКО проверка 1: она доказательна. Проверка 2 — # эвристика по форме кода, её дело предупредить, а не останавливать. return 1 if bad else 0 def bank_local_labels(lines): """Метки ДАННЫХ, определённые в области _BANKn этого модуля: их адреса видны только пока банк подключён в W3. Метки функций сюда не берём — их адрес законно кладут в HL для самого трамплина.""" labels, area = set(), None for i, line in enumerate(lines): a = AREA_RE.match(line) if a: area = a.group(1) continue m = LABEL_RE.match(line) if not (m and area and area.upper().startswith('_BANK')): continue for nxt in lines[i + 1:i + 4]: # пропускаем пустые/метки if not nxt.strip() or LABEL_RE.match(nxt): continue if DATA_DIR_RE.match(nxt): labels.add(m.group(1)) break return labels def check_bank_local_pointers(build, banks): """Адрес метки своего банка, положенный в аргументы банкового вызова в ЧУЖОЙ банк. Ищем форму, а не поток данных (см. шапку файла).""" bad = [] for asm in sorted(build.glob('bank*_*.asm')): m = BANK_ASM_RE.match(asm.name) if not m: continue self_bank = int(m.group(1)) lines = asm.read_text(errors='replace').splitlines() local = bank_local_labels(lines) if not local: continue for i, line in enumerate(lines): if '___sdcc_bcall_ehl' not in line or 'call' not in line.lower(): continue # Банк цели: ближайший `ld e,#b_...` выше вызова. target, tbank = None, None for j in range(i - 1, max(-1, i - 6), -1): b = BANKARG_RE.match(lines[j]) if b: target = b.group(1)[2:] # b_foo -> foo tbank = banks.get('_' + target) break if tbank is None or tbank == self_bank: continue # Назад по блоку подготовки аргументов. Останавливаемся на # первом же `call`: аргументы НАШЕГО вызова не могут готовиться # раньше предыдущего — иначе тот бы их и потребил. # # Признак «указатель ушёл наружу» — адрес метки данных банка, # который затем ПОЛОЖЕН В СТЕК как аргумент. Для банкового # вызова это единственный способ передать указатель: HL занят # самим трамплином. Адрес, загруженный ради индексной # арифметики (`ld de,#tbl` + `add hl,de` + `ld a,(hl)`), в стек # не кладётся — так отсеиваются чтения таблиц своего банка. pushed = {} # регистр -> строка push for j in range(i - 1, max(-1, i - ARG_WINDOW), -1): if LABEL_RE.match(lines[j]): # начало другого блока break if re.match(r'^\s*call\b', lines[j], re.IGNORECASE): break pu = PUSH_RE.match(lines[j]) if pu: pushed.setdefault(pu.group(1).lower(), j) continue a = LOADADDR_RE.match(lines[j]) if not (a and a.group(2) in local): continue reg = a.group(1).lower() if reg not in pushed: continue # Между загрузкой адреса и push регистр не должен меняться: # иначе в стек уходит не адрес, а посчитанное по нему # значение (индексная арифметика по своей таблице). halves = {'hl': 'hl', 'de': 'de', 'bc': 'bc'}.get(reg, reg) touched = re.compile( r'^\s*(?:ld\s+[' + halves[0] + halves[1] + r']\s*,' r'|ld\s+' + reg + r'\s*,' r'|(?:inc|dec)\s+(?:' + reg + r'|[' + halves[0] + halves[1] + r'])\b' r'|add\s+' + reg + r'\b' r'|ex\s+de\s*,\s*hl)', re.IGNORECASE) if any(touched.match(t) for t in lines[j + 1:pushed[reg]]): break bad.append((asm.name, j + 1, self_bank, a.group(2), target, tbank)) break return bad if __name__ == '__main__': sys.exit(main(sys.argv))