Многопоточность на ZX Spectrum

Реализовав перехват прерывания на ZX Spectrum (о чём есть огромное количество статей) можно стать на шаг ближе к одной странной затее — вытесняющей многопоточности на этой 8–битной машине. Сразу же может возникнуть вопрос — а зачем оно надо?


(подсказка для самых нетерпеливых: видео с результатом находится в самом конце статьи)

Многопоточность подразумевает, что на компьютере одновременно выполняются несколько участков кода. Но на однопроцессорной (и одноядерной) машине которой ZX Spectrum является физически это невозможно и как и на IBM PC до появления многоядерных процессоров технически такое положение вещей можно сделать через быстрые переключения хода выполнения от одного кода к другому. Когда такие переключения осуществляются без участия самих выполняющихся программ и они даже не обязаны знать что выполняются параллельно с чем то — такая многопоточность называется вытесняющей.

Способ перехватывать поток управления с помощью прерывания 50 раз в секунду в принципе известен давно и много, так что его объяснять я лично тут не буду, осталось только научить обработчик прерывания возвращаться не в ту программу на которой он сработал, а в другую по списку — эта идея является здесь ключевой.

Реализовав первый вариант многопоточности я из любопытства поискал в интернете что–то похожее и наткнулся на один любопытный пост тут: www.linux.org.ru/forum/talks/12579983

… Вплоть до того, что я запускал 3 программы: одна играла музыку, воторая гоняла по экрану точку, которая отпрыгивала от занятых пикселей и от краёв экрана, а третьей была сама среда программирования бейсик встроенная в ZX. Забавно было наблюдать, как точка отпрыгивала от набираемого мной текста на бейсике....

А ведь действительно, почему бы не вернуть один из потоков выполнения бейсику? Это будет забавно.

Чтобы переключаться между потоками выполнения, очевидно что надо сохранять полностью их состояния чтобы их можно было возобновить. Состояние процессора в ZX Spectrum — это значения всех основных его регистров. Для Z80 это:
— регистровые пары AF, HL, BC, DE и их «теневые копии» AF', HL', BC', DE'
— индексные регистры IX и IY
— указатель стека SP и указатель текущей инструкции IP

Когда срабатывает прерывание и вызывается обработчик IP уже сохранён в стеке в рамках штатной процедуры перехода на подпрограмму. Довольно логично и все остальные регистры кроме SP сохранить так же в стек — нам становятся не нужны какие то дополнительные массивы данных и остаётся только запомнить сам указатель стека SP — вот его уже точно надо отложить в какое то заранее выделенное место. По сути массив указателей вершин стеков выполняющихся задач и будет являться необходимым и достаточным буфером для сохранения контекстов потоков. По сути весь контекст потока — это стек и его содержимое + все регистры процессора, но т.к. мы почти все регистры сохраним в стек же, то остаётся только сохранить указатель на его вершину.

Начинаем писать код (используется ассемблер SjAsmPlus):

		device ZXSPECTRUM48

		org $8000
irq_table	block 257, $81		; таблица векторов прерываний (адрес $8181)
active_context	dw context_table	; указатель на текущий контекст (изначально - первый слот)
		; нижний байт конца таблицы контекстов. в первом слоте осн. поток, поэтому +2
context_end_low	db low context_table + 2	
contexts_tmp	dw 0			; пока времянка для старого sp для add_task
context_table				; таблица контекстов (чуть меньше $80 байт)
		; макрос сохранения всех регистров в стек
	macro	push_all_registers
		push af
		push bc
		push de
		push hl
		ex af, af'
		exx
		push af
		push bc
		push de
		push hl
		push ix
		push iy
	endm			
		; макрос восстановления всех регистров из стека
	macro	pop_all_registers
		pop iy
		pop ix
		pop hl
		pop de
		pop bc
		pop af
		exx
		ex af, af'
		pop hl
		pop de
		pop bc
		pop af
	endm


Т.к. мы перехватываем прерывание на ZX (что само по себе отдельная история), то прежде всего с адреса $8000 располагаем таблицу векторов прерываний которую заполняем так чтобы все они указывали на адрес обработчика $8181 (одинаковые младший и старший байты здесь есть необходимое условие). Нетрудно подсчитать, что между концом этой таблицы началом обработчика останется чуть больше 120 неиспользованных байт. Вот в них мы и разместим таблицу с сохранёнными вершинами стеков потоков (context_table), указатель на тот элемент в этом массиве который сейчас является текущим выполняющимся потоком (active_context) и количество контекстов в таблице. Причём т.к. все элементы таблицы находятся по адресу с верхним байтом $81, то вместо количества удобнее сохранить последний байт адреса на элемент следующий за последним (context_end_low).

В программе нам часто нужно будет сохранять и восстанавливать содержимое всех регистров общего назначения — поэтому полезно сделать два макроса push/pop_all_registers.

Переходим к обработчику прерывания:

		org $8181
		; обработчик прерываний
irq_handler	di				
		; сохраним регистры
		push_all_registers
		; сохраним в текущем контексте задачи sp
		ld hl, 0
		add hl, sp		; hl = sp
		ld bc, hl		; bc = sp
		ld hl, (active_context)	; hl = активный контекст
		ld (hl), c
		inc hl
		ld (hl), b
		inc hl			; (hl++) = bc, т.е. hl указывает на следующий контекст
		; проверим на выход за пределы таблицы контекстов
		ld a, (context_end_low)
		cp l			; l == context_end_low ?
		jr nz, .save_cur_ctx	; если нет, то идём сохранять sp
		ld hl, context_table	; иначе перенацеливаемся в начало таблицы
.save_cur_ctx	ld (active_context), hl	; сохраняем sp в текущий контекст
		; восстановим sp из следующего контекста
		ld c, (hl)
		inc hl
		ld h, (hl)
		ld l, c			; hl = (hl+)
		ld sp, hl		; sp = hl
		; проверяем не первая ли (основная) задача стала текущей
		ld a, (active_context)
		cp low context_table
		jp z, .ret_to_basic
		; восстанавливаем регистры контекста
		pop_all_registers
		; и возобновляем выполнение задачи
		ei
		ret
.ret_to_basic	; восстанавливаем регистры и уходим в обработку прерывания бейсика
		pop_all_registers
		jp	$38

Первое что он делает — сохраняет все регистры в стек текущего потока. Далее указатель стека извлекается из SP (тут забавно, что в Z80 есть команда передачи HL в SP (ld sp, hl), но нет обратной (ld hl, sp), поэтому таковую приходится делать через другую команду — add hl, sp, т.е. прибавления к сперва занулённому HL регистра SP) и сохраняется по указателю на текущий контекст в таблицу context_table. Далее мы сдвигаемся на следующий элемент таблицы, извлекаем из неё указатель на стек следующего потока и восстановив его восстанавливаем все регистры и выходим из прерывания уже в следующий поток.

Однако, если мы находимся в потоке в котором у нас выполняется бейсик, то этого недостаточно — бейсик не будет работать если мы «отобрали» у него его обработчик прерываний, который как мы помним находится по адресу $0038 в ПЗУ — поэтому если поток который стал текущим является первым в массиве, то мы после восстановления состояния процессора прыгаем на адрес $0038 чтобы бейсик продолжал делать свои дела как прежде. Если бейсик не нужен, то эту проверку можно смело выкинуть.

Для заполнения таблицы и активации нашего новенького и модного 8–битного диспетчера задач напишем две процедуры:

; add_task - добавить новую (спящую пока) задачу
; вход:		hl - адрес стека задачи
;		bc - стартовый адрес кода задачи
; портит:	bc, hl
;	все регистры сохранят свои значения 
;	и могут служить входными параметрами в задачу.
add_task	ld (contexts_tmp), sp	; sp нужен для манипуляций - запоминаем его
		; сохраняем в стек задачи все регистры
		ld sp, hl		; перенацелим sp
		push bc			; адрес возобновления задачи
		push_all_registers	; зафиксируем в стеке задачи все регистры
		ld hl, 0
		add hl, sp		; hl = sp
		ld bc, hl		; bc = sp
		ld a, (context_end_low)
		ld h, high context_table
		ld l, a			; hl = указатель на новый контекст
		add a, 2
		ld (context_end_low), a	; context_end_low += 2
		; сохранить вершину стека новой задачи в (hl)
		ld (hl), c
		inc hl
		ld (hl), b
		ld sp, (contexts_tmp)	; восстанавливаем sp и выходим
		ret

; start_tasks - активировать обработку прерываний и планировщик задач
start_tasks	di			; отключим прерывания
		ld a, high irq_table
		ld i, a			; загружаем в i адрес таблицы векторов прерываний
		im 2			; активируем режим прерываний 2
		ei			; включим прерывания
		ret


Процедура add_task добавляет в таблицу задач новую, еще находящуюся на «паузе». Мы должны передать ей два адреса — откуда начинается код нового потока и где находится его стек. Стек и правда помещается в указанный адрес и в нём запоминаются все регистры — так что когда планировщик задач возьмётся за него у него уже будет сохранённое состояние которое «вспомнится». После этого указатель на конец получившегося стека добавляется в нашу таблицу контекстов.
Чтобы активировать режим многопоточности надо вызывать процедуру start_tasks — она воспринимает поток в котором сама выполняется как текущий (в нулевом слоте задач) и активирует перехват прерывания. Далее этот поток продолжит выполняться как ни в чём не бывало и мы вернём его бейсику.

Осталось только написать два потока которые будут делать что–то заметное и активировать всё это безобразие:

start           ; НАЧАЛО ПРОГРАММЫ
		push_all_registers	; сохраняем регистры
		; инициализируем Задачу 2
		ld de, $4000		; входной параметр - адрес слова который будем менять
		ld hl, $9000		; адрес стека
		ld bc, word_incrs	; начало кода задачи
		call add_task

		; инициализируем Задачу 3
		ld de, $4000		; входной параметр - адрес слова который будем менять
		ld hl, $9100		; адрес стека
		ld bc, illumination	; начало кода задачи
		call add_task

		; стартуем планировщик задач
		call start_tasks
		pop_all_registers	; восстанавливаем регистры
		ret					; выходим в бейсик

		; задача инкрементирующая слово по адресу в de
word_incrs	ld hl, de		; hl = переданный в задачу параметр 
		ld bc, 0
.loop		inc bc
		ld (hl), c
		inc hl
		ld (hl), b
		dec hl
		jp .loop		

		; задача инкрементирующая цветовые атрибуты экрана
illumination	ld hl, $5800
		ld bc, 32 * 24
		call irq_handler	; отдаём квант времени следующей задаче
.loop		ld a, (hl)
		add a, $1
		ld (hl), a
		inc hl
		dec bc
		ld a, b
		or c
		jp z, illumination
.next		jp .loop

		savesna "test48.sna", start


Дополнительных задач две — word_incrs вечно инкрементирует слово находящееся по адресу $4000 — это начало видеопамяти, где будет из–за этого видна постоянно мельтешащая полоска пикселей. Вторая — illumination инкрементирует байты цветовых атрибутов всего экрана, но т.к. она делает это слишком быстро, то я её замедлил — и это интересный момент. Дело в том, что не обязательно ждать когда обработчик прерывания вызовется аппаратно — совершенно законно сделать call irq_handler и вызвать его таким образом программно. И это действительно сработает и отдаст остаток текущего кванта времени (а у нас это 1/50 секунды) следующему потоку. Таким образом можно отдавать процессорное время от одних задач другим!

Ну и полюбуемся что же получилось:



Замечу, что бейсик реально начинает работать заметно медленнее, т.к. вынужден делить время поровну с жадным инкрементатором, да и поток создающий разноцветную иллюминацию тоже откусывает от производительности.

Бейсик конечно работает, но если написать слишком длинную программу, то она дойдёт до адресов которые мы использовали в своём коде, затрёт их и всё повиснет.

Как уже замечалось в самом начале по такому же принципу работает многопоточность и в современных системах, даже если в них стоят многоядерные процессоры, но каждое ядро способно и в одиночку по таким же лекалам обрабатывать несколько программ. Ну а до многоядерности в Windows 95 и выше наши компьютеры только так и работали. Поэтому если из под одежды у вас сейчас полезли клочья свитера — не смущайтесь, мы действительно в какой то мере прикоснулись к сокровенным местам ядерного системного программирования и сделать это на стареньком спектруме оказалось просто как мало где еще.

Если кому то интересны исходники, то их и все нужные для компиляции и запуска в эмуляторе UnrealSpeccy инструменты можно скачать одним пакетом отсюда: yadi.sk/d/XJzNIt4g3YcMsP

4 комментария

avatar
По существу всё правильно, но для полноты картины хочу напомнить ссылку на предыдущий пост на ту же тему: hype.retroscene.org/blog/dev/271.html
avatar
А ведь я искал по сайту на слова «многопоточность» и «многозадачность», но т.к. эта статья не содержит таких слов, то успешно не нашёл. :)
avatar
Я читал Робуса, и не понял ни слова (это проблема, разумеется, моя, а не Робуса :)
А тут прям всё хорошо и понятно! Даже обидно, что никто не комментирует.
Я бы и сам рад, но нечего добавить :)
  • sq
  • +1
avatar
У меня есть такая проблема — я вырос в детстве на ZX Spectrum 48 (конкретно отечественном клоне Кворум-64), но именно в детстве не стал в это деле опытным и начитанным ассемблерщиком и сейчас возвращаясь к этому и восторгаясь тем или иным вещам просто вынужден описывать как можно более подробно, вплоть для самого себя, с чем сталкиваюсь, причём опять и снова, но уже наученный про всё.
Вероятно действительно выходит ну очень подробно и с остановкой на каждом шаге. Надеюсь так и есть.
Только зарегистрированные и авторизованные пользователи могут оставлять комментарии.