Files
Sprinter-SDCC/docs/im2_isr_design.md
T
snark13 2c6f4e33c3 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>
2026-07-15 10:23:39 +03:00

642 lines
46 KiB
Markdown
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.
# IM2 Interrupt Handlers — Design Document
**Status: Phase 1 (2026-07-06), Phase 2a CTC (2026-07-07), Phase 2b CBL
(2026-07-07, редизайн на callback без кольца — см. ниже) РЕАЛИЗОВАНЫ**
(libc/irq: irq_install/irq_remove + трамплин, irq_ctc_install/remove;
libc/cbl: cbl_open/close/push_otir/push_accel + fill-callback; тесты
tests/irqtest, tests/cbltest, tests/cblwav, tests/cblstream).
Callback-редизайн **verified в MAME 2026-07-07**: все три CBL-теста
(cbltest — матрица 64 комбинации; cblwav — banked-стрим, голос слышен;
cblstream — собственное кольцо приложения) работают.
## Результаты verification (2026-07-06, по docs/samples и исходникам MAME)
Первая версия зависала на первом же прерывании после irq_install.
Причины и проверенные факты:
- **DSS работает в IM 1** (обработчик на 0x0038). I=0x3F — наследие
Spectrum ROM, НЕ признак IM 2-таблицы. Доказательства:
`docs/samples/sprinterIntLib.asm` (intRestoreDefaultInterrupt: `ld i,a`
+ `im 1` безусловно, «обязательно перед функциями дос и биос») и
`docs/samples/SIO_CTC_KEY.asm` (выход: `LD I,A` + `IM 1`). Чтение
«таблицы DSS» по [I<<8+0xFF] давало мусор (0x00BF) и было причиной
зависания. Фикс: чейн из трамплина ВСЕГДА на 0x0038 (jp с
interrupted-PC на стеке = имитация RST 38); irq_remove всегда
восстанавливает IM 1 (I — только регистр).
- **CBL-фильтр по биту 7 порта 0xFE убран из трамплина**: в MAME при
выключенном CBL бит 7 всегда = 1 (sprinter.cpp kbd_fe_r:
`data |= 0xE0`), и каждое кадровое прерывание ложно классифицировалось
как CBL — user-handler не вызывался бы никогда. Признак «#fe.bit7=1»
(sprinterIntLib.asm) имеет смысл только при активном CBL — вернуть в
Phase 2 вместе с поддержкой CBL.
- **Порт 0x19 = SIO-A RR0 (Z84C015), бит 0 = «Rx Character Available»** —
семантика клавиатурного пробника верна (подтверждено
sprinterIntLib.asm: `in a,(COM_A); bit 0,a; Z → кадровое`).
- **Вектора встроенной периферии Z84C015**: SIO — 0x10..0x1E, CTC — 0x06
(базовые вектора задаются записью в WR2 SIO-B / CTC ch0). Штатно их
прерывания выключены (сэмплы включают/выключают их сами); наша
заливка 257×H перехватывает любой вектор на трамплин, а чейн на
0x0038 безопасен для любого источника.
- Внешний вектор действительно 0xFF (MAME sprinter.cpp:
`set_irq_acknowledge_callback` → 0xff).
Отличия реализации от плана ниже:
- **отдельный `--memory im2` НЕ понадобился**: таблица — статический
буфер 513 Б в BSS с выравниванием в рантайме; т.к. внешний вектор —
только 0xFF, значимы лишь байты [0xFF]/[0x100], и 3-байтовый
`jp _irq_tramp` лежит ВНУТРИ таблицы по смещению H (H = старший
байт её адреса, < 0xC0 — не пересекается). Никаких linker-областей
и правок crt0;
- работает в tiny/big (код и данные в W2); в small/huge irq_install
возвращает EINVAL (проверка адресов трамплина/буфера);
- **чейн к DSS — ВСЕГДА и всегда на 0x0038** (и клавиатура, и кадр):
не нужно знать, что DSS делает в своём ISR — SYSTIME/клавиатура/мышь
живут. User-handler зовётся только на кадровых (фильтр: бит 0
порта 0x19 → мимо); финальный jp — SMC-операнд;
- W3-порт в трамплине НЕ сохраняется: gfx держит DI на время свопов,
а user-handler'у banking запрещён; DSS свои окна сохраняет сам;
- irq_remove вешается на atexit (выход без снятия = IM 2/I указывают в
память умершего процесса = крах шелла); восстановление — всегда IM 1.
## Phase 2a — CTC-таймер (РЕАЛИЗОВАН 2026-07-07)
`irq_ctc_install(handler, div2, div3)` / `irq_ctc_remove()` — вектор
0x06, ОТДЕЛЬНЫЙ от кадрового 0xFF (решает проблему «кадр и клавиатура
неразличимы»). Механика (по docs/samples/sprinterIntLib.asm и
SIO_CTC_KEY.asm):
- CTC Z84C015: канал 2 тактируется видеотактом **875 кГц (1 тик =
1 знакоместо)** и работает делителем; канал 3 считает от канала 2 и
прерывает. f = 875000/(div2*div3), div 0 = 256;
- пресет IRQ_CTC_VSYNC_DIV2/3 = 112×160 (2 пикс. линии × 160 = 320
линий) — точное начало кадра ~48.8 Гц;
- порты: CH0=0x10, CH2=0x12, CH3=0x13; control-слова 0x57 (ch2:
counter, int off) / 0xD7 (ch3: counter, int on); базовый вектор
блока пишется в CH0 (0 → вектор ch3 = 0x06); стоп = 0x03 (reset);
- **RETI обязателен** в CTC-трамплине: daisy chain Z84C015 снимает IUS
только по опкоду RETI — с RET следующее прерывание не придёт;
- CTC-путь НЕ чейнится к DSS (личное прерывание); кадровые/клавиатурные
0xFF идут своим путём параллельно;
- общая IM2-таблица под счётчиком ссылок (_irq_table.c): кадровый и
CTC-хендлеры ставятся/снимаются независимо, последний возвращает
I/IM 1; atexit-уборка глушит CTC ОБЯЗАТЕЛЬНО (иначе после выхода
прерывания кГц-частоты душат шелл).
## Phase 2b — CBL/COVOX audio (РЕАЛИЗОВАН 2026-07-07, редизайн без кольца)
`libc/include/cbl.h`: `cbl_open(freq_code, fmt, pump_mode,
underrun_mode, fill)` / `cbl_close()` / `cbl_push_otir(src,n)` /
`cbl_push_accel(src,n)` / `cbl_requests()` / `cbl_underruns()`. По
`docs/samples/Пример для CBL.asm`, разделу «Звук через COVOX-Blaster»
в `docs/converted/Forum.txt`, официальной документации "5.3
COVOX-Blaster" (see below) и `docs/converted/accel_r.txt`:
**Архитектурный редизайн (2026-07-07)**: у CBL УЖЕ ЕСТЬ собственный
аппаратный буфер 256 Б, разбитый на ДВЕ половины по 128 Б (double
buffering целиком на стороне железа — см. официальную доку: "Блок ОЗУ
256 байт условно разбит на две банки по 128 байт, и бит 7 порта #FE
указывает какая из банок ОЗУ выводится в ЦАП... используется программой
вывода для определения, нужно ли подгружать следующие 128 байт").
Держать ЕЩЁ ОДНО кольцо в libc поверх этого — лишний второй буфер и
лишняя копия. Библиотека теперь НЕ хранит кольца: при cbl_open()
регистрируется callback `fill(n)`, который ISR вызывает НАПРЯМУЮ, а
callback сам пропихивает n байт ИЗ ДАННЫХ ПРИЛОЖЕНИЯ (статический
массив, банковая EMM-страница, файл — что угодно) прямо в CBL через
`cbl_push_otir()`/`cbl_push_accel()` — без промежуточной копии в libc.
Пример из офиц. документации делает ровно это: `OUTI` читает прямо из
HL, указывающего в банковую страницу с WAV-данными, без всякого
стейджинга.
`fill(n)` вызывается ИЗ ISR — ОБЯЗАН быть быстрым: никаких
ESTEX/BIOS/gfx-вызовов (тот же констрейнт, что у `irq_install()`-
хендлера). В частности, диск (read() — ESTEX) читать из fill() НЕЛЬЗЯ
— см. tests/cblstream ниже, где под это заведено собственное кольцо
уровня приложения. Возвращает ненулевое при успехе, 0 — недолив.
- **Control-порт 0x004E — 16-битный** (`ld bc,#0x004E` / `out (c),a`,
не 8-битный `out (n),a`). Биты: 7 = CBL on, 6 = stereo, 5 = 16-bit,
4 = interrupt enable, 3..0 = код частоты (8=7.8125 кГц…F=109.375 кГц,
0/1 — legacy-режим без сэмплирования). `cbl_open` шлёт
`0x90|fmt|freq` (on + int + формат + частота) под DI, `cbl_close`
шлёт `0`.
- **Формат — CBL_FMT_MONO8/MONO16/STEREO8/STEREO16** (2026-07-07):
биты 5(16-бит)/6(stereo) напрямую соответствуют константам, можно
OR'ить с freq. Блок запроса — 128 Б для 8-бит, 256 Б для 16-бит
(НЕ зависит от моно/стерео — см. Forum.txt: «для каждых 128 байт
(256 в режиме 16 бит)»); тишина — 0x80 (8-бит unsigned) или 0x0000
(16-бит signed). 8-бит сэмплы центр 0x80, 16-бит центр 0x0000,
stereo — чередование L/R. Runtime-размер блока — `_cbl_block`
(128 или 256), заполняется в `cbl_open` по fmt.
- **Два насоса, выбор — CBL_PUMP_OTIR/CBL_PUMP_ACCEL** (2026-07-07):
- **OTIR** (`_cbl_pump_otir`) — блок в порт 0x4F через `otir`; B=младший
байт `_cbl_block` (128 остаётся 128, 256 идёт как 0 — Z80 OTIR:
B=0 значит 256 итераций). Проверено в MAME (звук слышен, 0
underrun).
- **ACCEL** (`_cbl_pump_accel`) — запись через акселератор в
спец-страницу EMM 0xFD, замапленную в окно W3 на 0xC000 (Forum.txt:
«запись данных в COVOX-Blaster... 128/256 байт с адреса 0xC000»).
Последовательность (по accel_r.txt + рабочему прецеденту
libc/gfx/_gfx_hfill256.c): `LD D,D` (режим размера блока) →
immediate `LD A,n` (SMC-патч, 0 значит 256 — тот же трюк, что и в
OTIR) → `LD L,L` (режим "копирование блока") → `LD A,(HL)` /
`LD (DE),A` (блочное чтение источника → блочная запись в
0xC000@стр.0xFD) → `LD B,B` (выкл). Акселератор НЕ продвигает
HL/DE сам — advance после пересылки делается вручную (`add hl,
(block)`). W3 сохраняется/восстанавливается вокруг переключения
(как `bank_read`/`bank_write`); доп. DI/EI не нужны — весь насос
целиком уже внутри ISR (прерывания замаскированы до EI/RETI
трамплина). **Verified в MAME 2026-07-07** — tests/cbltest, все
32 accel-комбинации матрицы прошли без ошибок/underrun.
- Общий каприз с CBL-примером из docs/samples: там для установки
размера блока акселератора используется `LD C,128` СРАЗУ ЗА
`LD D,D` — это противоречит и accel_r.txt («далее следует команда
типа LD A,dat»), и нашему же подтверждённому на gfx констрейнту
(CLAUDE.md: «block-size ОБЯЗАН быть immediate операндом LD A,n»).
Мы взяли ВЕРИФИЦИРОВАННЫЙ вариант (LD A,n), а не пример — вероятно,
у автора размер уже был установлен раньше (заметка в accel_r.txt:
«если размер блока был установлен ранее, его можно не
устанавливать»), и `LD C,128` в примере готовит BC для последующего
`ADD HL,BC`, а не для акселератора.
- **Data-порт 0x4F** (для OTIR-насоса), блок 128/256 байт.
- **Признак запроса блока — бит 7 порта 0xFE — валиден ТОЛЬКО когда
CBL реально активен.** При выключенном CBL MAME (`kbd_fe_r`)
подтягивает этот бит к 1 всегда (`data |= 0xE0`) — поэтому трамплин
проверяет бит 0xFE.7 не напрямую, а через индирекцию
`_irq_cbl_hook`: пока `cbl_open` не установил хук, кадровые
прерывания даже не читают порт 0xFE (см. Phase 1 — по этой же
причине первая версия фильтра была убрана). Официальная документация
описывает тот же бит как "старший бит счётчика" адреса аппаратного
буфера — по нему же программа определяет, какую половину доливать.
- **Насос (`_cbl_pump_otir`/`_cbl_pump_accel`) теперь тривиален**:
`_cbl_reqs++; ok = _cbl_fill ? _cbl_fill(_cbl_block) : 0; if (!ok) {
_cbl_undr++; ...}`. Обычные (не `__naked`) Си-функции — можно, т.к.
трамплин уже сохраняет ВЕСЬ контекст (оба регистровых набора + IX/IY)
вокруг вызова хука, что бы функция ни наделала с регистрами.
- **Поведение при недоливе — CBL_UNDERRUN_APP/SILENCE** (4-й параметр
cbl_open, 2026-07-07): по умолчанию (`APP`, 0) недолив — не забота
библиотеки, буфер тишины НЕ аллоцируется вовсе, в CBL доигрывает то,
что уже лежало в его аппаратном буфере. `SILENCE` (1) — насос сам
пропихивает тишину (`_cbl_silence`, malloc'ится В cbl_open() ТОЛЬКО
в этом режиме, размером `_cbl_block`, залит 0x80/0x0000 по формату) —
тот же приём, что раньше был жёстко вшит в насос, теперь опционален.
`cbl_underruns()` считает недоливы в ОБОИХ режимах — это только
диагностика.
- **CBL-путь НЕ чейнится к DSS** — личное прерывание CBL, полный сейв
контекста (основной набор + теневой AF/BC/DE/HL + IX/IY, т.к. `fill()`
может клобберить что угодно) → `EI`/`RETI` напрямую, без 0x0038.
- `cbl_open` держит те же анти-повторные гарантии, что и irq/ctc:
занятый хук → EBUSY, `atexit(cbl_close)` регистрируется один раз,
`_irq_table_ref()`/`_irq_table_unref()` для общей IM2-таблицы.
- **tests/cbltest**: полная матрица (2 насоса × 8 форматов × 4 частоты
= 64 комбинации) пилообразного тона, ~1 с каждая; `fill_tone()`
всегда возвращает 1 (period-64 тон никогда не "кончается") —
CBL_UNDERRUN_APP без буфера тишины достаточно. Main не поллит
ничего — просто ждёт halt()'ом. OTIR+16-бит (16/64) пропускаются
заранее (cbl_open вернул бы EINVAL).
- **tests/cblwav**: потоковая речь (78 КБ) с ДИСКЕТЫ через banked EMM
(диск слишком медленный для realtime — клип предзагружается в RAM
ДО cbl_open). `fill_speech()` делает `bank_read()` из уже загруженной
страницы в стейджинг и `cbl_push_otir()` — физические номера страниц
кэшированы в массиве ЗАРАНЕЕ (на этапе загрузки, в main-контексте):
`mem_get_page()` — BIOS-вызов, сам управляет EI/DI, и звать его ИЗ
fill() (то есть из ISR) нельзя — его `ei` при возврате может
преждевременно снять маску прерываний, пока мы ещё внутри ISR.
- **tests/cblstream**: без banked-предзагрузки — чтение с диска
ОДНОВРЕМЕННО с воспроизведением; рассчитан на быстрый носитель (HDD).
Единственный из трёх тестов, где приложению НУЖНО собственное кольцо:
`read()` — ESTEX-вызов, а `fill()` зовётся из ISR, где ESTEX/BIOS под
запретом — поэтому диск читается ТОЛЬКО в main (в кольцо уровня
приложения), а `fill_stream()` лишь копирует уже готовые байты и
пропихивает `cbl_push_otir()`. НЕ входит в общую сборку (`make`/
`make floppy`) — только `cd tests/cblstream && make run`, свой образ
диска.
ISA-вектора, цепочки нескольких хендлеров на одном векторе — не
реализовано (см. «Phase 2 (когда понадобится)» ниже).
**Щелчок перед первым звуком за сессию (2026-07-07, A/B/C-стенд)**:
на РЕАЛЬНОМ файле (tests/cblwav, потоковая речь) перед началом
воспроизведения был слышен щелчок/призвук. Диагностика через
tests/cblwav (banked-стрим с пилой, затем с чистой тишиной вместо
речи, в одном запуске) показала: щелчок слышен ТОЛЬКО на первом
запуске программы после старта MAME и НЕ зависит от содержимого потока
(тон / тишина / речь — одинаково). Проверенное на macOS (`afplay`)
воспроизведение исходного speech.pcm — чистое, артефактов в самом
файле нет. Разбивка `cbl_open()` на две записи в порт 0x004E (сначала
код частоты, потом enable, с паузой) не повлияла — отменена. Вывод:
это одноразовый прогрев звуковой подсистемы MAME при первой активации
канала CBL за сессию эмулятора, не баг протокола/драйвера; на реальном
железе, скорее всего, отсутствует (см. docs/TODO.md).
Этот документ собирает всё, что мы знаем о прерываниях Sprinter и план реализации user-задаваемых ISR через Z80 IM 2 mode. Когда возьмёмся за реализацию — читать этот файл, чтобы не повторять research.
## Зачем нужны прерывания
- **Timer ISR (50/60 Hz)** — счётчик кадров, плавная анимация без busy-loop, тайминги
- **Mouse / keyboard async-обработка** — без polling
- **Music playback** — AY-3-8910, COVOX через прерывания
- **Real-time games** — input + game logic + render в interrupt-driven архитектуре
## Hardware-факты (из docs/converted)
### Sources of vector 0xFF
| Источник | Detect bit | Частота |
|---|---|---|
| Frame (screen refresh) | (default if none of below) | 50/60 Hz |
| Keyboard | port `0x19` (COM_A) bit 0 | event-driven |
| CBL/COVOX (sound) | port `0xFE` bit 7 (sample request) | sample-rate-dependent |
| Mouse | — (hardware interrupt not wired) | — |
| ISA | другой vector (configurable) | depends |
Источники:
- `docs/converted/Forum.txt:956` — кадровые и клавиатурные прерывания приходят с vector 0xFF; различаются по bit 0 порта 0x19. От мыши прерываний нет
- `docs/converted/Forum.txt:758-764` — CBL также vector 0xFF, отличить по bit 7 порта 0xFE
- `docs/converted/IvanMak.txt:1086``READ_KBD`: IN(0x19), bit 0 = "байт принят"; затем IN(0x18) = data byte; нужно drain FIFO (до 3 байт)
- `docs/converted/IvanMak.txt:1471` — ВАЖНОЕ ОГРАНИЧЕНИЕ: vector table + ISR + stack ОБЯЗАНЫ быть в области `0x8000..0xBFFF` (window 2). Иначе BIOS будет отключать прерывания на каждой вызове функции
### Что DSS делает в своём ISR (предположения, требует verification)
DSS shell имеет свой IM 2 handler:
- Drain'ит keyboard FIFO в свой буфер (читается через ESTEX WAITKEY/SCANKEY)
- Возможно обновляет ESTEX SYSTIME ($21) tick counter
- Возможно poll'ит mouse (хотя hardware-IRQ от mouse нет — может быть software polling)
- Refresh курсора мыши (он же видимый и движется в shell)
Без chain'инга к DSS:
- Сломается клавиатура (ESTEX kbd functions не получат байты)
- Может сломаться SYSTIME counter
- Может перестать обновляться mouse cursor
### IM2-трюк
Стандартная схема для одиночного ISR address:
1. Аллоцировать **257-байтный** буфер заполненный одинаковым байтом `H`
2. Загрузить `I = H` (например `H=0xA3` → table at `0xA300`, обращения `0xA300..0xA400`)
3. При прерывании CPU читает байт по `(I<<8)|v` и следующий
4. Если оба байта = `H` → ISR address = `(H<<8)|H` = `HHHH`
5. По адресу `HHHH` положить `jp real_isr`
Поскольку для нас интересен только vector 0xFF: read bytes at `(0xA3FF)` and `(0xA400)`. Если table заполнена H=0xA3 — оба байта читаются как 0xA3. ISR_ADDR = `0xA3A3`. По адресу 0xA3A3 кладём 3-байтовый `jp _trampoline`.
## Предлагаемый дизайн
### Public API (libc/include/irq.h)
```c
typedef void (*isr_t)(void);
int irq_install(isr_t handler); /* 0 OK, -1 error (already installed) */
void irq_remove(void);
/* Convenience macros — wrap DI/EI when modifying volatile globals
* shared between main and ISR. */
#define IRQ_DISABLE() __asm di __endasm
#define IRQ_ENABLE() __asm ei __endasm
```
Пример использования:
```c
volatile uint16_t ticks = 0;
void on_tick(void) { ticks++; }
int main(void) {
irq_install(on_tick);
uint16_t start = ticks;
while (ticks - start < 50) { /* wait 1s */ }
irq_remove();
}
```
### Внутренности
**Аллокация vector page:**
- Static buffer 513 байт в `_BSS` (sprinter.lib).
- Размер 513 = 256 (выравнивание) + 257 (сама table) — в худшем случае выравнивание тратит 256 байт.
- Внутри буфера ищем 256-byte aligned адрес. SDCC может не поддерживать `__attribute__((aligned(256)))` — придётся через ассемблер с `.area _BSS_ALIGNED` и линкер-флаг для выравнивания, или через runtime поиск aligned position.
- **Alternative:** заранее линкуем vector page по фиксированному адресу через linker flag `-Wl-b_VECTORS=0xA300` (как у банков). Стабильнее.
**Trampoline в W2:**
- Маленький asm-блок (~50 байт) который:
1. `ex af,af'; exx; push ix; push iy` — сохранить ВСЕ регистры
2. `in a, (0xE2); push af` — сохранить current W3 page byte
3. `in a, (0x19); bit 0, a; jr z, _not_kbd` — keyboard?
- keyboard path: chain to DSS old ISR (jp/call to saved address)
4. `in a, (0xFE); bit 7, a; jr z, _not_cbl` — CBL? (Phase 2)
5. Frame path: `ld hl, (user_handler); ld a, h; or l; jr z, _no_user; call hl_indirect`
6. `pop af; out (0xE2), a` — restore W3
7. `pop iy; pop ix; exx; ex af,af'; ei; reti`
**Где живёт trampoline:**
- Для `tiny` mode: `_CODE` = W2 → естественно
- Для `big` mode: `_CODE` = W2 → естественно
- Для `small`/`huge`: `_CODE` = W1, **но trampoline ДОЛЖЕН быть в W2** (W1 может swap'нуться)
- **Решение:** новая linker area `_TRAMP_W2` с absolute address в W2 (например 0xBE00). sprinter-cc размещает её через `-Wl-b_TRAMP_W2=0xBE00`. trampoline.s помечает себя `.area _TRAMP_W2`.
**Chain to DSS:**
- В `irq_install`:
```asm
ld a, i ; A = current vector page high byte
ld (old_I), a
ld h, a
ld l, #0xFF
ld a, (hl) ; A = vector_high (= old_I по trick'у)
ld d, a
ld e, a ; DE = address of DSS's IM2 jp
ld hl, (de) ; HL = DSS's old jp target
ld (dss_old_isr), hl
```
- В trampoline keyboard-path:
```asm
ld hl, (dss_old_isr)
push hl
ret ; jumps to DSS ISR which ends with EI; RETI
```
- **Опасность:** DSS's ISR может предполагать что регистры свежие (как только что от CPU) → возможно нужно НЕ saving некоторые регистры до chain'а
**`irq_remove`:**
- DI
- Restore I to old value
- Restore IM mode (обычно был IM 2 → IM 2; редко IM 1 if shell upgraded)
- Free vector page if dynamically allocated
- EI
## Ограничения user handler'а
User's ISR может:
- Читать/писать volatile globals
- Делать дешёвые арифметические операции
- Менять `g_text_attr` (но не вызывать putch/cputs)
User's ISR НЕ должен:
- Вызывать `printf` / `puts` / `malloc` / любые ESTEX/BIOS функции — они могут не быть re-entrant
- Использовать `gfx_*` — они swap'ят W3, наш trampoline уже сохраняет порт но если внутри ISR будет повторный swap то trampoline не сможет восстановить
- Запускать accelerator (LD D,D и т.д.) — accel меняет систему команд CPU
- Долго работать — ISR должен быть быстрым (< 1ms), иначе пропустим следующий
## Открытые вопросы
1. **Что именно DSS делает в своём ISR** — disassemble DSS или вызвать его с инструментировкой
2. **`ld a, i` semantics** на Sprinter — на Z80 P/V flag отражает IFF2; нужно для save/restore
3. **Alignment vector page** — найти SDCC-совместимый способ: либо linker absolute area, либо runtime align внутри 513-байтного буфера
4. **Re-entrancy ESTEX из main во время ISR**:
- Если main вызывает ESTEX и в это время приходит interrupt → DSS chain'инг должен работать корректно (DSS уже спроектирован под IM 2)
- Если main вызывает BIOS (RST 8) — это отключает прерывания на время вызова, OK
4. **Memory budget** — vector page 513 байт в BSS уменьшит heap. В tiny mode с heap ~10KB это ~5%. OK.
## Phase 1 acceptance
- `examples/irq_test/` — счётчик тиков растёт с 50 Hz
- Клавиатура продолжает работать через DSS chain (можно прервать тест клавишей)
- Корректный exit — DSS shell получает управление обратно без crash
- Работает во всех memory modes (tiny, small, big, huge)
- Memory note `memory/sprinter_im2_isr.md` с описанием ABI и ограничений
## Phase 2 (когда понадобится)
- ~~CBL/COVOX prerequisite handler~~ — реализован, см. «Phase 2b» выше
- ISA interrupt handler (для ZX-Bus карт)
- 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"
Идея: вместо того чтобы крутить trampoline location во всех существующих режимах, сделать **отдельный `--memory im2`** который:
- Forces CODE в W2 (как tiny)
- Reserves определённый адрес в W2 под vector page и trampoline (например 0xBE00..0xBFFF)
- crt0_im2.s ставит IM 2 в начале (заменяет DSS handler с chain)
- crt0_im2.s восстанавливает на exit
**Плюсы:**
- Меньше matrix-сложности (irq работает только в одном mode)
- Можно агрессивно reserved'ить W2-память
- Тестируется как единое целое
**Минусы:**
- Программам приходится явно выбирать `--memory im2` для использования прерываний
- Дублирование crt0 и runtime
Текущее предложение — пойти этим путём (отдельный mode) для v2, не лезть в существующие crt0.
## Внешние ссылки
- `docs/converted/IvanMak.txt:1040-1054` — секция 9.3 "Прерывания от ISA" + 9.4 "AT-Клавиатура"
- `docs/converted/IvanMak.txt:1469-1473` — IM 2 ограничения (table/stack/ISR в W2)
- `docs/converted/Forum.txt:758-764` — CBL interrupt discrimination
- `docs/converted/Forum.txt:956` + `:1049` — vector 0xFF disambiguation
- `docs/converted/Parinov.txt:601` — IM 1 alternative (handler по адресу 0x0038, не наш путь)
## История
- 2026-06-01 — research собран в этот документ, реализация отложена до v2