05bcd8197e
Проверка 2 искала форму «адрес метки своего банка положен в аргументы вызова в чужой банк», но регулярка принимала только `ld hl,#_метка` и `#_метка+6`. Адрес поля структуры SDCC пишет в скобках: ld bc, #(_mstream + 6) push bc ... call ___sdcc_bcall_ehl Из-за этого целый класс ошибок собирался «чисто». Поймано вживую 2026-09-03 в порту Loom: mask.c (банк 5, --bank-data 5) отдавал res_copy (банк 11) адрес поля своей структуры — поток маски читался в чужую страницу, маска выходила «всё заполнено», тайлов в комнате стало 30 вместо 17. Фон при этом оставался побайтово верным, потому что его путь идёт из резидентного кода, так что попиксельная сверка экрана молчала. Класс проверки не меняется: адрес метки ДАННЫХ своего банка, положенный в стек как аргумент вызова в ЧУЖОЙ банк, неверен всегда — это по-прежнему доказательный случай, а не эвристика. Проверено в трёх режимах: с ошибкой срабатывает, на исправленном коде молчит, и на всех банковых сборках самого тулчейна (banktest, w3probe, banked, bankedbg, banklocl, w3bankgfx, roomtest, сборка SprPoP) ложных срабатываний нет. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qxu9rwsmyDyYPxG198nzSR
266 lines
15 KiB
Python
Executable File
266 lines
15 KiB
Python
Executable File
#!/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 <build-dir> # каталог с .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))
|