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

46 KiB
Raw Blame History

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:1086READ_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)

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

Пример использования:

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:
    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:
    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
  5. 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)

#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 — и весь плоский 0x4000–0xBFFF корректен. Это работает, потому что:

  • в 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