Files
Sprinter-SDCC/toolchain/check_bank_calls.py
T
snark13 05bcd8197e check_bank_calls: ловить адрес ПОЛЯ структуры в аргументах банкового вызова
Проверка 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
2026-09-03 11:04:27 +03:00

266 lines
15 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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))