libc/irq: цепочка кадровых обработчиков + all-modes W1-remap

_irq_user → _irq_chain[4]+_irq_chain_n (W2-BSS); трамплин tr_frame
проходит слоты (один тяжёлый сейв на всю цепь, пустой слот пропуск).
API irq_chain_add (0/-1+ENOMEM) / irq_chain_remove(h); irq_install →
обёртка chain_add (EBUSY исчез), irq_remove() рвёт всю цепь; refcount
таблицы на первом/последнем слоте, мутации под IRQ_DISABLE. Лимит
4 кадровых + 1 CTC.

All-modes: трамплин copy-safe (только jr/djnz + литерал jp 0x0038),
_irq_table_ref копирует его в _irq_tramp_w2buf (W2) когда оригинал в W1
(small/huge), вектор → на копию; вокруг вызова хендлеров восстанавливает
базовую W1-страницу (_irq_app_w1_page = IN 0xA2). Хендлер может лежать
где угодно в плоском 0x4000-0xBFFF.

Проверено в MAME (tests/irqtest, 2 хендлера): tiny/big/huge — chain
h1=h2 → remove h2 → h1 жив/h2=0; huge = код в W1, remap работает.
Follow-up: CTC в small/huge = EINVAL (нужна W2-копия _irq_ctc_tramp);
small для мелких программ (BSS в W1) = irq_install EINVAL, safe.

Доки: im2_isr_design (цепочка), sprite-api §9е (делитель поверх цепи),
fast_ram §8. rpgprof: gfx_sprite_ysort в профиль (+230 Б baseline).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 10:23:39 +03:00
parent 6b6986d9b6
commit 2c6f4e33c3
17 changed files with 700 additions and 128 deletions
+211 -1
View File
@@ -397,7 +397,217 @@ User's ISR НЕ должен:
- ~~CBL/COVOX prerequisite handler~~ — реализован, см. «Phase 2b» выше
- ISA interrupt handler (для ZX-Bus карт)
- Multiple user handler chain (e.g. tick + sound)
- Multiple user handler chain (e.g. tick + sound) — ДИЗАЙН ниже
## Цепочка кадровых обработчиков (план, 2026-07-14)
Мотив: сейчас кадровый слот один (`_irq_user`), второй `irq_install`
даёт `EBUSY`. Как только в программе сойдутся ≥2 потребителя кадрового
прерывания (FPS-делитель `gfx_set_fps_div` + свой тик/звук приложения),
одного слота мало. Решение — фиксированный массив слотов, по которому
трамплин проходит на каждом кадре.
### Модель
- `isr_t _irq_chain[IRQ_CHAIN_MAX]` (`IRQ_CHAIN_MAX = 4`) + счётчик
занятости `uint8_t _irq_chain_n` — в `_irq_state.c` (W2-data;
ЗАМЕНЯЮТ нынешний `isr_t _irq_user`).
- Порядок вызова = порядок индексов 0..3. Взаимный порядок хендлеров
**НЕ гарантируется** после remove/add (переиспользуется первая дыра)
→ хендлеры обязаны быть независимы. Наш `_gfx_frame_isr` (инкремент
байта) независим по построению.
- Дисциплина ISR прежняя (`irq.h`): без ESTEX/BIOS/gfx/банков/акселератора.
### Трамплин (`_irq_tramp.c`, секция `_irq_frame`)
Полный сейв теневого+индексного набора делается ОДИН раз (амортизируется
на всю цепь), затем цикл по слотам с пропуском NULL:
```
_irq_frame:
push bc/de/hl
ld a, (__irq_chain_n) ; ранний выход: цепь пуста → без сейва
or a, a
jr Z, _irq_pop3
... сейв shadow + IX/IY ...
ld hl, #_irq_chain
ld b, #IRQ_CHAIN_MAX
_irq_chain_loop:
ld e,(hl) / inc hl / ld d,(hl) / inc hl ; DE = слот
ld a,d / or a,e / jr Z, _irq_chain_skip ; NULL → пропуск
push bc / push hl
call _irq_call_de ; jp (de)-обёртка (как _irq_call_hl)
pop hl / pop bc
_irq_chain_skip:
djnz _irq_chain_loop
... restore ...
_irq_pop3:
pop hl/de/bc
_irq_chain: ... jp DSS ... ; как сейчас
```
Пустой проход 4 слотов ≈ 4×(load+or+skip) ≈ 40Т — копейки на кадре.
Ранний выход по `_irq_chain_n==0` сохраняет текущую оптимизацию «нет
хендлера → без тяжёлого сейва» (трамплин может быть установлен ради
CBL/CTC при пустой кадровой цепи).
### API (`irq.h`)
```c
#define IRQ_CHAIN_MAX 4
int irq_chain_add(isr_t h); /* 0 / -1+ENOMEM (слоты кончились) */
void irq_chain_remove(isr_t h); /* снять ОДИН слот (по указателю) */
```
Совместимость (без изменения существующих call-sites):
- `irq_install(h)` → тонкая обёртка `return irq_chain_add(h)`. `EBUSY`
исчезает (двойной install теперь легален); при 4 занятых — `ENOMEM`.
- `irq_remove()` (без аргумента) — снимает **ВСЮ** кадровую цепь и
возвращает IM 1. Семантика exit/atexit сохранена (exit рвёт всё перед
возвратом в DSS).
- `gfx_set_fps_div` использует `irq_chain_add`/`irq_chain_remove`
(ТАРГЕТНО свой `_gfx_frame_isr`), НЕ `irq_remove` — чтобы выключение
делителя не снесло собственный хендлер приложения.
### Refcount таблицы
`irq_chain_add`: если `_irq_chain_n == 0` (первый слот) → `_irq_table_ref()`
(ставит IM2). `irq_chain_remove`: если слот был последним (`_irq_chain_n`
уходит в 0) → `_irq_table_unref()` (возврат IM1). CBL/CTC-ссылки на
таблицу считаются отдельно, как сейчас.
### Гонка чтения слота (ОБЯЗАТЕЛЬНО)
Трамплин читает слот двумя байтовыми `ld`; запись указателя в
add/remove — 16-бит store. Порванное чтение (низкий байт новый, старший
старый) = прыжок в мусор. Поэтому мутации слота+счётчика в
`irq_chain_add`/`irq_chain_remove` ОБЯЗАНЫ идти под `IRQ_DISABLE()` /
`IRQ_ENABLE()`. Дёшево и обязательно.
### Файлы (канон 1 модуль = 1 функция)
- `_irq_state.c` — `_irq_user` → `_irq_chain[IRQ_CHAIN_MAX]` + `_irq_chain_n`.
- `_irq_tramp.c` — переписать секцию `_irq_frame` на цикл (выше).
- `_irq.h` — объявить `_irq_chain`, `_irq_chain_n`, `IRQ_CHAIN_MAX`.
- `irq_chain_add.c`, `irq_chain_remove.c` — НОВЫЕ.
- `irq_install.c` — обёртка над chain_add; `irq_remove.c` — teardown-all
(чистит все слоты + unref до нуля).
- `include/irq.h` — публичные объявления + документировать «порядок не
гарантирован, хендлеры независимы; двойной install легален; 4 слота».
- `tests/irqtest` — добавить кейс двух хендлеров (оба тикают свои
счётчики; remove одного не глушит другого; exit рвёт оба).
### Ограничения (СНИМАЮТСЯ — см. «все режимы» ниже)
- ~~Только tiny/big~~ — цель теперь **все режимы**, дизайн ниже.
- CTC-цепочка (`irq_ctc_*`) — тем же паттерном, ОТДЕЛЬНЫЙ массив
(вектор 0x06); делать по потребности, не сейчас.
## Вариант «работает во всех режимах памяти» (план 2026-07-14)
Постановка: IM2-прерывания должны работать и в small/huge, где CODE
лежит в W1. Нельзя просто «загнать всё в W2» — обработчик пишет
пользователь как обычный C, и он компилируется в `_CODE` (=W1 в
small/huge). Задача — разложить *путь прерывания* так, чтобы он был
корректен при ЛЮБОМ содержимом W1/W3 в момент прихода прерывания.
### Что на пути прерывания и что реально требует W2
Прерывание фетчит и исполняет по цепочке: **вектор-таблица → трамплин →
обработчик(и) → chain на DSS 0x0038**. В момент прерывания стабильно
замаплены только W0 (ROM/DSS) и **W2** (там стек — DSS это требует,
окно не перемапливается). W1 перемапливается банками кода (big) и самим
DSS при iff=1 во время сисколлов (подтверждено артефактом). W3 — банки
(huge).
Ревизия по компонентам:
1. **Вектор-таблица** — УЖЕ в W2 (`_irq_vec_buf`, BSS; BSS садится в W2
во всех режимах). Ничего менять не надо.
2. **Трамплин** (`_irq_tramp`) — сейчас в `_CODE` (=W1 в small/huge).
ЕДИНСТВЕННОЕ, что валит проверку `tramp < 0x8000` в
`_irq_table_ref()`. Нужно сделать W2-резидентным.
3. **Обработчик пользователя** — обычный C в W1/W3. НЕ трогаем — решаем
через remap (см. ниже).
4. **Данные цепи** (`_irq_chain`, `_irq_chain_n`, …) — BSS → W2. ОК.
### Ключевой приём: не двигать обработчик, а подсунуть ему страницу
ВАЖНО (уточнение 2026-07-14): в small/huge программа видит **плоские
32 КБ 0x40000xBFFF** — две статические страницы (W1+W2 подряд), их
номера фиксированы на всё время работы. Обработчик может лежать ГДЕ
УГОДНО в этом диапазоне: в W1 (0x40000x7FFF), в W2 (0x80000xBFFF)
или даже НА СТЫКЕ через 0x8000. Значит не нужно знать, в каком окне
хендлер — нужно лишь на время его вызова сделать весь плоский регион
адресуемым.
Обработчик оставляем на месте. Трамплин (в W2) ПЕРЕД вызовом каждого
хендлера восстанавливает базовую W1-страницу приложения — паттерн
`__banked`-трамплина (bank.s: `in a,(0xA2)` / set / call / restore).
W2 всегда = страница приложения (там стек, DSS не перемапливает), так
что достаточно вернуть только W1 — и весь плоский 0x40000xBFFF
корректен. Это работает, потому что:
- в small/huge **базовая W1-страница статична** (пользователь её не
переключает — модель памяти); её номер crt0 фиксирует один раз при
старте (`IN A,(0xA2)`) в W2-переменную `_irq_app_w1_page`;
- если прерывание пришло вне сисколла — W1 уже = базовая, set/restore
это no-op; если ВНУТРИ сисколла (DSS подменил W1) — трамплин ставит
базовую на время хендлера и возвращает DSS-страницу перед chain на
0x0038, прерванный сисколл продолжается корректно.
Единый «restore W1=base» покрывает все положения хендлера БЕЗ детекции:
- целиком в W1 → W1=base делает его код валидным;
- целиком в W2 → W1-restore безвреден (хендлер оттуда не фетчит), W2
и так = приложение;
- на стыке 0x8000 → нижняя часть по W1=base, верхняя по W2=приложение
— обе корректны.
Итог по режимам:
- **tiny/big**: базовый код/данные в W2 (всегда замаплен) → remap НЕ
нужен и ВРЕДЕН (в tiny/big нет осмысленной «базовой W1»; W1 —
банк-окно в big). Как сейчас.
- **small/huge**: плоские 32 КБ → трамплин ставит `_irq_app_w1_page`
в 0xA2 вокруг вызова.
Флаг «нужен remap W1» = (mode ∈ {small,huge}); crt0/sprinter-cc его
выставляет, трамплин честит.
**Scope v1:** любые хендлеры в плоском образе приложения (не-banked) —
т.е. обычный C где угодно в 0x4000–0xBFFF; покрывает практически все
ISR (счётчик/звук-тик). `__banked`-хендлер (в W3 huge / отдельном
банке big) потребовал бы хранить его страницу per-слот и мапить в его
окно — отдельное расширение, не сейчас.
### Как сделать трамплин W2-резидентным — два пути
**(A) Резерв W2-области (проще, рекомендую для v1).** Трамплин
(и, при желании, таблица) — в отдельной area `_IM2` с абсолютным
адресом в верхней части W2 (напр. под стеком). Область НЕнулевая только
если слинкованы irq-модули (DCE) → цена платится лишь при использовании
IRQ; heap-top при слинкованном IRQ = `s__IM2`. В small/huge W2-часть
статического образа грузится crt0 как есть — код окажется в W2 без
рантайм-копирования. Правка `_irq_table_ref()`: снять проверку
`tramp < 0x8000` (трамплин теперь по построению в W2).
**(B) Рантайм-эмит стаба в W2-BSS (без резерва области).** Скопировать
PIC-шаблон трамплина в BSS-буфер (W2) при первом install, пропатчив
абсолютные само-ссылки (SMC, как `_irq_dss_target` уже сейчас). Плюс:
никакой зарезервированной области, чисто DCE. Минус: трамплин надо
писать позиционно-независимым (внутренний control-flow только `jr`;
`call (hl)`-идиому инлайнить или патчить адрес хелпера при копии).
Оптимизация поверх (A), если резерв области жмёт бюджет small.
Ключевой приём (remap базовой страницы) от выбора (A)/(B) не зависит.
### Изменения к плану цепочки (выше)
- `_irq_table_ref()`: убрать EINVAL-проверку `tramp/buf < 0x8000` после
переезда трамплина в W2 (таблица уже там).
- crt0 (все варианты): записать `_irq_app_w1_page = IN A,(0xA2)` при
старте (для small/huge; в tiny/big просто не используется).
- Трамплин `_irq_frame`: вокруг `call` хендлера — save/set/restore
0xA2 под флагом remap; перед `jp 0x0038` вернуть прерванную W1.
- Тест: собрать `tests/irqtest` во ВСЕХ четырёх режимах; критично —
small/huge прогон с активным сисколлом в main (файловый цикл), чтобы
словить прерывание при DSS-подменённой W1.
## Альтернатива: отдельный memory mode "im2"