Длинное вступление
Утренняя работа над второй частью статьи началась не с запаха кофе, а с запаха нафталина, толстым слоем покрывающего микропроцессоры эпохи конца 1970-х годов. В этой знаменитой плеяде такие имена, как Zilog Z80, Motorola 68000, Intel 8086. Все они были выпущены с разницей буквально года два-три, и вполне могут считаться ровесниками.
Первая часть удостоилась некоторого количества критических замечаний, касающихся старости используемой автором платформы, что немало удивляет: 16-битная система команд 8086 до сих пор аппаратно поддерживается x86-совместимыми CPU. В то же самое время, Z80 и его клоны остались в своём первозданном виде, но статьи по программированию или аппаратному использованию Z80 не считаются устаревшими. Посему, автор решил написать вторую, заключительную, часть по 8086.
Далее, если звёзды и пожелания публики сойдутся меж собой, будут статьи по современному разноуровневому программированию, включая ассемблер amd64. 32-битные и 64-битные команды x86 "растут" из старого 16-битного режима, знание которого не будет лишним.
В этой части
Нас ждёт погружение в один из способов организации мультипоточности на базе единственного ядра процессора. Мы научимся принудительно переключать выполнение между несколькими полностью зацикленными участками кода, ничего не "знающими" о каком-то другом коде, конкурирующем за процессорное внимание. По ходу повествования будут даны все необходимые пояснения и читателю не придётся обращаться к другим источникам, кроме первой части статьи.
Примеры кода написаны на Flat Assembler и вместо числовых смещений, которые нам приходилось писать в отладчике, используются привычные программистам имена переменных, функций и меток. Во время трансляции исходного кода Flat Assembler заменяет символические имена на реальные числовые смещения. Читатели, знакомые с Microsoft Macro Assembler или Borland Turbo Assembler, не увидят ничего неожиданного, за исключением некоторых особенностей синтаксиса адресации, например:
mov [es:bx], ax ; аналогично mov es:[bx], ax
mov [name], ax ; то же самое, что mov name, ax в masm/tasm
mov name, ax ; ошибка! нужно указывать [name]
mov ax, name ; это вместо mov ax, offset name
jmp dword [handler] ; здесь dword вместо dword ptr в indirect far jump or call
Константы тоже объявляются иначе:
TimerVector = 8h ; вместо TimerVector equ 8h
Имена локальных меток начинаются с точки. Транслятор для внутренней обработки автоматически склеивает имена ближайшей вверх по исходнику обычной метки и локальной:
MyProc1: ; "Normal" label
.ThisIsALocalLabel: ; Local label in scope between two nearesе normal lables
cmp ax, cx
ret
MyProc2:
ret
Документация по Flat assembler.
Flat assembler поддерживает DOS, Linux, Windows и доступен бесплатно.
Ситуация
У нас в памяти размещены несколько кусков очень нужного кода, каждый кусок "крутится" внутри бесконечного цикла. Каждый "думает", что он может сколько угодно занимать процессор своими, очень важными, вычислениями. Назовём такой кусок кода в цикле нитью.
Иногда я буду использовать термин поток в том же самом смысле, что и нить: последовательность выполнения машинных инструкций внутри одной нити. Таким образом, "многопоточность" у нас будет означать то же самое, что и "многонитевость" (multithreading).
Как организовать переключение исполнения таких нитей на одном ядре? В настоящих программах этим занимается операционная система, но мы лёгких путей не ищем.
Взгляд издалека
На схемке ниже (рис. 1) изображена последовательность передач управления для организации такой многопоточности. Ось времени направлена вниз, стрелочки показывают передачу управления между отдельными логическими модулями программы. Надписи под стрелочками - события, "провоцирующие" передачу выполнения кода.

Со временем и стрелочками предварительно разобрались, перейдём к шампурам и сосискам линиям жизни и фрагментам выполнения. Линии жизни показаны вертикальным пунктиром, а фрагменты выполнения - узким вертикальным прямоугольником на линии жизни.
В нашей программе фрагмент выполнения - это одна или несколько машинных инструкций. Линия жизни показывает, что между фрагментами выполнения сохраняется некий важный для целостности логики программы контекст: значения регистров, включая регистр флагов FLAGS
, локальные переменные (у нас в явном виде их нет, мы работаем с регистрами), состояние стека.
Фрагменты выполнения объединены в объекты. На схеме они помечены красными надписями. Иначе говоря, шампур и сосиски линия жизни и фрагменты выполнения на этой линии относятся к одному объекту. Рассмотрим их назначение:
Main code - это главный код, который начинает выполняться при запуске программы и делает все подготовительные действия для старта нитей. В этот же код происходит передача управления при завершении работы переключателя нитей.
Switcher - переключатель нитей. В нём реализована основная логика переключения выполнения, или, другими словами, передача управления между нитями.
T1, T2, T3 - сами нити. Для примера их здесь три. Каждая нить просто выполняет некий код и бесконечно зациклена. То есть, для упрощения примера мы не предусматриваем никакого специального выхода или завершения работы однажды запущенной нити. Позднее мы добавим эту интересную возможность.
Взгляд поближе: идейка
Как прервать работу бесконечного цикла? Ответ есть у меня, и он до безобразия банален: аппаратным прерыванием. Например, прерыванием таймера. Сколько код ни зацикливай, если аппаратные прерывания не запрещены сбросом флага IF
в регистре FLAGS
, процессор будет реагировать на внешние раздражители в виде сигналов от клавиатуры или таймера переключением на выполнение кода обработчика прерывания (ISR
).
В первой части статьи подробно описан механизм прерываний, здесь я продублирую самое важное и это, конечное же, картинка:

Внимательный читатель всё понял верно: обработчик прерывания после своего завершения возвращает управление аккуратно на ту инструкцию, которая должна была выполниться перед самым прерыванием, да не успела. Вызовы обработчика прерываний никогда не "рвут" уже выполняемую инструкцию. Они вклиниваются аккуратно после завершения инструкции и перед стартом следующей.
Первое, что в этом механизме нас более всего интересует, это то, что происходит как бы само по себе: помещение в текущий стек, адресуемый парой SS:SP
, содержимого регистра FLAGS
, регистра CS
и регистра IP
.
Каждый раз перед помещением в стек нового значения, регистр
SP
уменьшается на 2 (размер слова), затем по адресуSS × 4 + SP
записывается сохраняемое значение размером ровно одно слово (2 байта).
На это мы повлиять не можем, но нам и не надо. Нужно точно представлять себе этот этап обработки прерывания. Мы представили. Мы - умницы!
Теперь наш обработчик прерывания сделал свою важную работу и собирается вернуть управление прерванному коду инструкцией IRET
. Смотрим на Рис. 2 и понимаем, что из стека будет извлечен адрес возврата в виде пары значений CS:IP
и FLAGS
.
Всякий раз при извлечении значения из стека происходит сначала чтение одного слова (2 байта) памяти по адресу
SS × 4 + SP
, в котором было сохранено значение, а затемSP
увеличивается на 2 (размер слова).
А вот тут мы можем повлиять! Сам обработчик прерывания может разместить в памяти стека какой-то другой адрес возврата вместо того, который был помещён туда автоматически. И тогда IRET
вернёт управление совсем другому коду, не тому, который был прерван!
Как вы уже догадываетесь, этот подход позволяет принудительно переключать выполнение даже между бесконечно зацикленными блоками кода, выделяя каждому блоку строго определённое время, измеряемое периодами таймерных прерываний.
Рассматриваем шестерёнки
Здесь есть некоторые сложности, и нам потребуется задействовать воображение. Нужно иметь его примерно на три грибочка звоночка из пяти по шкале воображулистости.
Вместо записи в стек каких-то новых адресов возврата каждый раз при вызове обработчика прерывания, мы пойдём более верной дорогой (Рис. 3).

Снабдим каждую нить своим отдельным стеком, и тогда, при срабатывании обработчика прерывания, адрес возврата к коду нити автоматически попадёт именно в стек, принадлежащий этой самой нити.
На рис. 4 показано, как может выглядеть подобная конфигурация из трёх нитей в самом начале работы программы.

В желтоватеньких прямоугольниках у нас живут три нити: Thread1
, Thread2
, Thread3
. Поле Offset
показывает возможный вариант смещения, начиная с которого, в принципе, мог бы располагаться код соответствующей нити. В реальной программе смещения будут другими, но для более предметного понимания я указал некоторые допустимые значения.
Справа от нитей голубыми прямоугольниками показаны соответствующие блоки памяти, выделенные под хранение стека. Для каждой нити свой отдельный блок памяти. Если присмотреться к схеме блока, то можно заметить заголовок "High byte | Low byte". Он показывает, что блок памяти используется как последовательность слов, каждое размером 2 байта. Смещения слов внутри стека обозначены заголовком Offset
и возрастают снизу вверх. Такое представление нагляднее показывает логику работы стека: при добавлении новых элементов он растёт вниз, к меньшим адресам (смещениям). Как и в случае с нитями, смещения Offset
указаны как возможные величины. Они вычислены корректно, однако в реальности будут другими.
Стек растёт вниз
Когда говорят, что стек растёт "вниз", то вот что имеют ввиду: по мере добавления элементов (инструкция PUSH) стек растет в сторону младших адресов, ведь значение регистра-указателя стека SP уменьшается. Извлечение элементов из стека происходит в обратной последовательности инструкцией POP. Исторически принято, что увеличение адресов памяти происходит "снизу вверх", а уменьшение - "сверху вниз", как будто память это такая ось Y на графике. Другими словами, стек заполняется сверху вниз: первое слово записывается в самый верх стека (в слово с наибольшим адресом), а следующее записывается "под" ним (внизу).
Зелёными прямоугольниками показан массив записей SavedSP
, содержащих смещения актуальных вершин стека для каждой прерванной нити. Элементами массива являются слова, каждое слово хранит значение, которое нужно записать в SP
при восстановлении регистров и передаче управления в соответствующую нить.
В красном прямоугольнике ThreadIndex
хранится номер активной нити. Он используется обработчиком таймерного прерывания как индекс в массиве записей SavedSP
для выборки следующей нити.
Коричневый MainThreadSP
нужен для хранения SP
главного кода программы. Во время обработки самого первого прерывания от таймера обработчик помещает в основной стек программы все регистры, а затем само значение из SP
сохраняет в MainThreadSP
.
Как это всё крутится?
Чтобы всё работало, главный код подготавливает стеки нитей (см. рис. 4, голубые прямоугольники), помещая туда начальное значение регистра FLAGS
, сегмент кода нити CS
, смещение точки входа в код нити IP
и значения регистров AX
, BX
, CX
, DX
, SI
, DI
, BP
, ES
, DS
. Какие-то особые значения регистров и флагов на этом этапе не требуются. Важно, чтобы сегмент и смещение входа в нить были правильно указаны, а регистр флагов FLAGS
не был сконфигурирован как-то необычно для типичного кода. Так мы создаём контекст, нужный для старта нити на выходе из обработчика таймерного прерывания. Подготовкой стека нити в нашем примере занимается процедура AddThread
:
; ==== Adds a new Thread to multithreading manager
; Input:
; DX: Thread routine offset
; Uses:
; AX, BX, CX, DX
AddThread:
; Save the current Thread counter to BL for future usage in GetThreadSPAddr call.
mov bl, [AddedThreadCounter] ; bl = *AddedThreadCounter
; Calculate the new Thread stack pointer address.
; Stack pointer must point to the top of the stack.
mov al, bl ; al = bl
inc al ; al++
mov [AddedThreadCounter], al ; *AddedThreadCounter = al
mov bh, ThreadStackSize ; bh = ThreadStackSize -- number of bytes in the stack
mul bh ; ax = al * bh -- select the top of the stack
add ax, ThreadStacks ; ax += &ThreadStacks -- now ax points to the top of the Thread stack
; Save the current value of SP to CX.
mov cx, sp ; cx = sp
; Switch SP to the top of a Thread stack.
mov sp, ax ; sp = ax
; Prepare the Thread stack to switch to the beginning of the Thread with IRET instruction.
pushf ; *((ss << 4) + (--sp)) = flags
push cs ; *((ss << 4) + (--sp)) = cs -- code segment
push dx ; *((ss << 4) + (--sp)) = dx -- Thread offset
; Save reqired registers to be restored when switching to the Thread.
pushregs ; use macro pushregs here
; Save SP to the array of Thread-specific stack pointers.
; SP will be restored from the array element when switching to the Thread.
call GetSavedSPOffset ; call GetSavedSPOffset, input parameter is BL, output is BX
; Now BX points to the Thread-specific stack pointer address, save SP to it.
mov [bx], sp ; *(ds << 4) + bx) = sp
; Restore previously saved SP from CX. Required to return from 'AddThread'back to a calling code.
mov sp, cx ; sp = cx
ret ; return
; ===== Calculate the address to store a stack pointer for specific Thread
; Input:
; BL: Thread index
; Output:
; BX: Thread-specific Stack pointer address
GetSavedSPOffset:
; bx = bl * 2 + &SavedSPs
xor bh, bh ; bh = 0
shl bx, 1 ; bx <<= 1 -- multiply bx by 2 to get the index of the Thread stack pointer in SavedSPs array
add bx, SavedSPs ; bx += &SavedSPs -- add SavedSPs's offset
ret ; return
В коде используются константы и переменные:
TimerVector = 8h ; constant: Timer interrupt vector
ThreadStackSize = 128; constant: Size of the Thread stack, bytes
NThreads = 3 ; constant: Max number of Threads
ThreadStacks db (ThreadStackSize * NThreads) dup (0) ; The memory block for stacks
SavedSPs dw NThreads dup (0); Array of NThreads words to store Thread-specific SP (stack pointer) addresses.
MainThreadSP dw 0 ; Word variable to store a stack pointer for the main (startup) code.
CurrentThreadIndex db -1 ; Byte variable to store currently executing Thread number.
AddedThreadCounter db 0 ; Byte variable used as counter of added Threads.
PrevTimerVector dd 0 ; Double word variable to save the original Timer interrupt vector here.
TickCounter dw 0 ; Counter used to count the number of ticks since switcher is started.
MsgDone db 'Done', 10, 13, '$' ; Message to print on exit.
Done db 0 ; Flag to indicate that the switcher is done.
Код использует макрокоманды pushregs и popregs
Используемый нами Flat assembler разворачивает определённые в исходном тексте программы макрокоманды в последовательность инструкций:
; ===== Macro command which saves all the required registers to stack
macro pushregs
{
push ax ; *((ss << 4) + (--sp)) = ax
push bx ; *((ss << 4) + (--sp)) = bx
push cx ; *((ss << 4) + (--sp)) = cx
push dx ; *((ss << 4) + (--sp)) = dx
push si ; *((ss << 4) + (--sp)) = si
push di ; *((ss << 4) + (--sp)) = di
push bp ; *((ss << 4) + (--sp)) = bp
push es ; *((ss << 4) + (--sp)) = es
push ds ; *((ss << 4) + (--sp)) = ds
}
; ===== Macro command which loads the previously saved registers from stack
macro popregs
{
pop ds ; ds = *((ss << 4) + (sp++))
pop es ; es = *((ss << 4) + (sp++))
pop bp ; bp = *((ss << 4) + (sp++))
pop di ; di = *((ss << 4) + (sp++))
pop si ; si = *((ss << 4) + (sp++))
pop dx ; dx = *((ss << 4) + (sp++))
pop cx ; cx = *((ss << 4) + (sp++))
pop bx ; bx = *((ss << 4) + (sp++))
pop ax ; ax = *((ss << 4) + (sp++))
}
В комментариях показан эквивалент на C. Нужно иметь ввиду, что регистр SP
в инструкциях PUSH
и POP
всегда изменяется на 2 чтобы указывать на слова размером 2 байта.
При возникновении прерывания обработчик инструкциями PUSH
сохраняет регистры AX
, BX
, CX
, DX
, SI
, DI
, BP
, ES
, DS
в текущий стек. Далее выполняется ряд проверок и предпринимаются те или иные действия:
Если обработчик ранее не вызывался, то
ThreadIndex
== -1 (красный прямоугольник на рис. 4) и прерывание возникло во время выполнения главного кода программы, а не одной из нитей, т.к. нить может стартовать только из самого обработчика.
В этом случаеSP
сохраняется вMainThreadSP
(см. рис. 4, коричневый прямоугольник), аThreadIndex
инкрементируется и становится равным нулю:ThreadIndex = ThreadIndex + 1 = 0
Если обработчик уже вызывался ранее, то регистр
SP
сохраняется в один из элементов массиваSaved SPs array
, индекс которого извлекается из переменнойThreadIndex
:SavedSP[ThreadIndex] = SP
После этогоThreadIndex
модифицируется так, чтобы показывать на следующий элемент массиваSaved SPs array
, или на нулевой элемент, если дошли до конца массива:ThreadIndex = (ThreadIndex + 1) % NumThreads
В нашем примереNumThreads
= количество нитей = 3.В регистр SP помещается значение из элемента массива
Saved SPs array
с индексомThreadIndex
:SP = SavedSP[ThreadIndex]
Обработчик извлекает из, теперь уже другого, стека ранее сохранённые там значения регистров в обратной последовательности
DS
.ES
,BP
,DI
,SI
,DX
,CX
,BX
,AX
инструкциямиPOP
.Обработчик выполняет дальний переход
JMP DWORD [PrevTimerVector]
на сохранённый в 32-битную переменнуюPrevTimerVector
сегмент:смещение оригинального таймерного прерывания.Оригинальный обработчик прерывания выполняет все необходимые операции по обновлению системных часов, отправляет команду завершения контроллеру прерываний и делает возврат с помощью
IRET
. Поскольку стек возврата у нас отличается от стека вызова, тоIRET
вернёт управление в следующую по порядку нить в соответствии с актуальным стеком.
Действия по сохранению и восстановлению регистров чрезвычайно важны. Именно в регистрах хранятся и обрабатываются оперативные данные. Нить должна работать так, как будто она монопольно владеет регистрами.
Из-за того, что наши нити зациклены внутри себя, последующие вызовы таймерного обработчика всегда прерывают активную нить. Последовательность действий обработчика повторяется и каждая нить получает одинаковое время, равное периоду таймера. По-умолчанию это примерно 55 мс.
Во время работы переключателя нитей управление не возвращается главному коду программы. Так сделано для упрощения примера.
Код обработчика таймерных прерываний:
; ==== Time interrupt handler, switches threads. It is called every 55 ms.
TimerHandler:
; Save registers to a current stack.
pushregs ; use macro pushregs here
; Set DS=CS to address our data
mov ax, cs ; ax = cs
mov ds, ax ; ds = ax
; Increment the tick counter.
inc [TickCounter] ; *(TickCounter)++
; Check if the handler interrupted the main code.
cmp [CurrentThreadIndex], -1; compare *CurrentThreadIndex to -1
jne .SaveThread ; if *CurrentThreadIndex != -1 goto .SaveThread
; save main SP to MainThreadSP
mov [MainThreadSP], sp ; *MainThreadSP = sp
jmp short .NextThread ; goto .NextThread
.SaveThread:
; save SP to a Thread-specific pointer variable.
mov bl, [CurrentThreadIndex]; bl = *CurrentThreadIndex
call GetSavedSPOffset ; call GetSavedSPOffset, input is BL, returns offset in bx
mov [bx], sp ; *bx = sp
.NextThread:
; Select next Thread.
; Correct the Thread number: CurrentThreadIndex %= NThreads
xor ah, ah ; ah = 0
mov al, [CurrentThreadIndex]; al = *CurrentThreadIndex
inc al ; al++
mov bl, NThreads ; bl = NThreads
div bl ; al = ax / bl , ah = ax % bl
mov [CurrentThreadIndex], ah; *CurrentThreadIndex = ah
; Load SP from a Thread-specific pointer variable.
mov bl, ah ; bl = ah
call GetSavedSPOffset ; call GetSavedSPOffset
mov sp, [bx] ; sp = *bx
popregs ; Restore the registers from stack.
jmp dword [PrevTimerVector]; far jump to segment:offset saved in PrevTimerVector
Завершение работы переключателя нитей
Сколько нити не крутиться, а завершение так или иначе неизбежно. Лучше всего делать это красиво и элегантно. И мы так сможем!
Чтобы корректно завершить работу переключателя, нить запрещает прерывания инструкцией CLI
(CLear Interrupt flag), восстанавливает вектор таймера из переменной PrevTimerVector
, записывает в регистр SP вершину главного стека программы, ранее сохранённую в MainThreadSP
(см рис. 4), восстанавливает регистры DS
. ES
, BP
, DI
, SI
, DX
, CX
, BX
, AX
инструкциями POP
и делает IRET
в главный код, в место, где возникло самое первое прерывание таймера и сработал наш обработчик.
На ассемблере код завершения переключателя выглядит так:
; ===== TerminateSwitcher
; This function restores the data segment, stack segment, timer vector, and main SP.
; It also sets the Done flag to 1 and returns from the interrupt to the main code.
TerminateSwitcher:
xor ax, ax
mov es, ax
cli ; disable interrupts
mov ax, word [PrevTimerVector] ; ax = *PrevTimerVector
mov word [es:4 * TimerVector], ax ; *((es << 4) + 4 * TimerVector) = ax
mov ax, word [PrevTimerVector + 2]; ax = *(PrevTimerVector + 2)
mov word [es:4 * TimerVector + 2], ax; *((es << 4) + 4 * TimerVector + 2) = ax
; restore main SP
mov sp, [MainThreadSP] ; sp = *MainThreadSP
mov [Done], 1 ; *Done = 1
popregs ; restore registers
iret ; return from interrupt
Его можно вызвать инструкцией CALL
или JMP
из любой нити. Как именно он будет вызван, значения не имеет, т.к. стек возврат настраивается на главный код (main thread).
Вот код нитей. В примере их 4 штуки:
; ===== Thread 1
Thread1:
mov ax, 0b800h ; ax = 0xB800, a segment address of a text line 0
mov es, ax ; es = ax
mov ah, 01h ; ah = 01h -- attribute to print "blue on black"
.Loop:
mov al, '0' ; al = '0' -- character to print
.NextDigit:
mov [es:0], ax ; print the character in ax at the very first
inc al ; al++ -- change the character
cmp al, '9' ; compare al to '9'
jbe .NextDigit ; if al <= '9' goto .NextDigit
mov al, '0' ; al = '0'
jmp .Loop ; goto .Loop
; ===== Thread 2
Thread2:
mov ax, 0b800h ; ax = 0xB800, a segment address of a text line 0
mov es, ax ; es = ax
mov ah, 02h ; ah = 02h -- attribute to print "green on black"
.Loop:
mov al, 'A' ; al = 'A' -- character to print
.NextChar:
mov [es:2], ax ; print the character in ax at the second position
inc al ; al++ -- change the character
cmp al, 'Z' ; compare al to 'Z'
jbe .NextChar ; if al <= 'Z' goto .NextChar
jmp .Loop ; goto .Loop
; ===== Thread 3
Thread3:
mov ax, 0b800h ; ax = 0xB800, a segment address of a text line 0
mov es, ax ; es = ax
mov ax, 0040h ; ah = 00h -- attribute to print "black on black", al = '@'
.Loop:
mov [es:4], ax ; print the character '@' at the third position
inc ah ; ah++ -- change the attribute
jmp .Loop ; goto .Loop
; ===== Thread 4
Thread4:
mov dx, [TickCounter]
mov bx, 0100h ; line 1, column 0
call DumpHex ; dump the tick counter in hex
cmp [TickCounter], 100h ; compare *TickCounter to 100h (256)
jb Thread4 ; if *TickCounter < 100h goto Thread4
jmp TerminateSwitcher ; go to TerminateSwitcher
Вспомогательная процедура DumpHex
==== DumpHex
; Input:
; DX: value to dump
; BH: line number
; BL: column number
; Output:
; None
; Uses:
; AX, BX, CX, DX, ES
; Description:
; This function dumps the value in DX to the screen at the specified line and column.
; It converts the value to a string of hex digits and stores them directly in the video memory.
DumpHex:
mov ax, 0b800h ; ax = 0xB800, a segment address of a text line 0
mov es, ax ; es = ax
mov al, 160 ; al = 160 -- number of bytes per line (2 bytes per character)
mul bh ; ax = bh * al -- calculate the offset of the line
xor bh, bh ; bh = 0
shl bx, 1 ; bx <<= 1 -- multiply by 2 to get the index of the column
add bx, ax ; bx += ax -- add the offset of the line
mov cl, 12 ; cl = 12 -- counter for the number of bits to shift
.DoLoop:
mov ax, dx ; ax = dx -- copy the value to ax
shr ax, cl ; shift right to get the next nibble
and al, 0fh ; mask the nibble
cmp al, 9 ; compare the nibble to 9
jbe .Decimal ; if the nibble is less than 9, jump to .Decimal
add al, 7 ; correct for hex digits
.Decimal:
add al, '0' ; convert to ascii
mov ah, 02h ; character attribute 'green on black
mov [es:bx], ax ; store character and attribute
add bx, 2 ; move to next position
sub cl, 4 ; move to next nibble
jge .DoLoop ; if cl >= 0, jump to .DoLoop
ret ; return
Инструментарий для запуска примеров
В первой части нам было достаточно эмулятора DOSBox Staging и встроенного в него отладчика debug. Теперь дополним наш набор инструментов транслятором с ассемблера в бинарный код - Flat Assembler. Скачать нужно версию для DOS. Для своей работы Flat assembler требует DPMI (DOS Protected Mode Interface). Скачиваем нужный нам архив.
Распаковываем оба архива в директорию dos чтобы получилась вот такая структура:

Далее модифицируем конфигурационный файл DOSBox Staging. Я установил DOSBox Staging из Flatpak и конфиг, в моём случае, расположен в /home/user/.var/app/io.github.dosbox-staging/config/dosbox/dosbox-staging.conf
. Открываем файл в текстовом редакторе и модифицируем секцию [autoexec]
в самом конце. Должно получиться вот так:
[autoexec]
# Each line in this section is executed at startup as a DOS command.
mount c ~/dos
c:
c:\csdpmi7b\bin\cwsdpmi.exe -p
path %PATH%;c:\fasm
Как найти конфигурационный файл я рассказывал в первой части. Сохраняем изменения и запускаем DOSBox Staging. Если увидели окошко как на картинке внизу (рис. 6), то всё хорошо:

Последняя проверка. В DOSBox запускаем команду fasm. Если увидели такой ответ, как на картинке ниже (рис. 7), то день точно будет удачным:

Если у вас вдруг возникнет желание собрать и запустить пример, то для этого нужно перекопировать в директорию dos на хост-машине файл demo.asm
и выполнить команду:
fasm demo.asm

Получится бинарный файл DEMO.COM
, который можно сразу запустить.

Скачать исходник можно с моего репозитория на github.com
Заключение
Если эта тема была интересна и автору стоит продолжать в подобном ключе, но ориентируясь на современные платформы, голосуйте за статью и ставьте плюсики в карму.
На работу над этой частью, включая пример кода, потрачено около недели времени и я буду признателен за ваши отзывы. Замечания, очепятки, дополнения - добро пожаловать в комментарии.
Комментарии (27)
kmatveev
10.05.2025 12:11Статья очень хорошая. Но у меня есть пара вопросов.
В первом листинге я сначала удивился, зачем вы загружаете значение из AddedThreadCounter сначала в BL, а оттуда в AL, можно же было сразу. Только потом я увидел, что BL используется как параметр в GetSavedSPOffset, сильно ниже по листингу. Неочевидно. И ещё по этому листингу получается, что самый первый элемент в области ThreadStacks не используется, теоретически он под main зарезервирован?
AGalilov Автор
10.05.2025 12:11Возможно, я не очень понял часть вопроса на счёт ThreadStacks... Отвечу на то, как понял :)
Стек начинается со старших адресов. По сути, стек первой нити начинается... да, с младшего адреса блока памяти, предназначенного для второй нити :) Но мы не пугаемся, потому что указатель стека - регистр
SP
- уменьшается перед записью в стек нового значения.Операции со стеком по сути не сложные, но часто вызывают скрежет шестерёнок мозга.
AGalilov Автор
10.05.2025 12:11Да, на счёт BL/AL не особо наглядно. Дело в том. что прямой инкремент значения в памяти - это чтение-изменение-запись. Но мы уже и так прочитали переменную в регистр, поэтому инкремент переменной в памяти я решил не делать и заменил на инкремент регистра. В итоге потерялась наглядность.
Спасибо за ваш комментарий, от меня лучи добра и плюсики!
kinh
10.05.2025 12:11Если развивать систему многопоточности из статьи, то замечу следующее.
В своё время, когда встраивали многопоточность в Windows 95 - упустили интересную возможность: возбуждение исключения в другом потоке. В чём суть: допустим, главный поток отвечает лишь за пользовательский ввод и другой интерфейс, и программа запускает дополнительный поток, который нагружает длительными вычислениями. А потом пользователь решает закрыть программу, когда дополнительный поток ещё не завершил работу. В Windows 95 было, по сути дела, всего два способа прервать дополнительный поток: либо вызвать TerminateThread, либо заставить дополнительный поток периодически проверять какой-то флаг, и если этот флаг изменил значение - завершиться. Оба эти способа - плохие. Если вызвать TerminateThread, то поток убивается практически сразу, не вызвав никаких завершающих функций, может быть посреди важного вызова, который испортит файлы или системные данные с последующим "синим экраном". А если использовать флаг, то из-за его проверок замедлятся вычисления, усложнится алгоритм расчётов, и может появиться заметная задержка между попыткой пользователя закрыть программу и реальным её завершением.
Что можно было сделать.
Когда планировщик переключает потоки, то он в специальной структуре сохраняет контекст потока, включая адрес, с которого следует продолжить вычисления. Так вот, в этой структуре второй поток мог бы явно установить адрес обработчика исключения на случай, если понадобится досрочно его завершить. И когда главному потоку понадобится прервать дополнительный поток, то он бы вызвал какую-нибудь функцию RaiseTermination( thread ), что заставило бы планировщик при следующем переключении на дополнительный поток возобновить работу не с последнего адреса, а с адреса обработчика исключений. А это бы позволило нормально вызвать все деструкторы и выйти из функции потока.Ещё можно было бы добавить в контекст потока специальный счётчик запретов на исключение. Планировщик перед исключением проверяет значение этого счётчика, и возбуждает исключение сразу же, лишь если этот счётчик равен нулю. Иначе просто устанавливает флаг, что поток должен будет вызвать обработчик исключений, когда счётчик обнулится.
Этот счётчик используется для запрета исключений в потоке, на время вызова важных функций.Программно бы процедура потока на псевдокоде могла выглядеть примерно так:
int threadProc(void*) { // Сохраняем в контексте потока адрес обработчика уничтожения потока SetTerminationHandler(termination); try { // Долгие вычисления // ... LockTermination(); // Увеличение счётчика запрета уничтожения // Вызов системных функций - в это время поток не может быть уничтожен // ... UnlockTermination(); // Уменьшение счётчика запрета уничтожения // ... } on termination // Специальный обработчик исключения уничтожения потока { // Сохраняем данные, освобождаем ресурсы // ... } return 0; }
AGalilov Автор
10.05.2025 12:11Спасибо за такое развёрнутое дополнение. Оно стоит отдельной статьи. Я пока решил показать минимально работающий пример. Конечно, там нет многих важных для реальных систем вещей. Вам от меня плюсики.
Oangai
10.05.2025 12:11Кстати, может быть Вам будет интересно, действительно существует порт FreeRTOS под относительно живую x86 архитектуру: https://www.freertos.org/Documentation/02-Kernel/03-Supported-devices/04-Demos/x86/RTOS_Intel_Quark_Galileo_GCC
Advantech похоже выпускает платы с этим SoC, видимо гдето в промышленности используются
Gredko
10.05.2025 12:11Нас ждёт погружение в один из способов организации мультипоточности на базе единственного ядра процессора
Если "мультипоточность" или "многопоточность" обозначает то, о чем пишет автор, то что такое тогда "многозадачность"?
Зачем брать давно известные термины с общепринятыми значениями и менять их значения?
Это же у людей, не читавших Питера Нортона и Кернигана с Пайком вызывает иллюзию понимания предмета, в то время как в этой статье "потоки" и "нити" имеют мало общего с общепринятыми терминами.
Скорее речь о прерываниях и их обработке в однозадачном однопоточном режиме.
Подобным образом, например организовано в BIOS IBM PC чтение клавиатуры.
По прерыванию от клавиатуры int9h читается сканкод и меняется регистр состояния клавиатуры.
Любая другая программа в свою очередь может этот сканкод прочитать и интерпретировать по своим алгоритмам.
При этом нет ни процессов ни потоков в общепринятом понимании.
Прерывания как аппаратные так и программные выполняются в рамках одного потока и процесса.
salnicoff
10.05.2025 12:11Человек пытается запилить аналог DESQview и использует терминологию того времени, с поправками на маркетинговые материалы Quarterdeck Office Systems.
Gredko
10.05.2025 12:11Человек пытается запилить аналог DESQview
Терминология того времени называет многозадачность под 8086 в стиле Desqview "кооперативной многозадачностью".
Так-то похоже на "ореховку" Швейка....
pfemidi
10.05.2025 12:11Не, доска более продвинутая и более навороченная. Тут больше похоже на CTask: https://jacobfilipp.com/DrDobbs/articles/CUJ/1993/9304/volkman/volkman.htm
Siemargl
10.05.2025 12:11Наверное все же нет. Прерывание выполняется/является в отдельном процессе, со своим стеком, контекстом итп
medstrax
10.05.2025 12:11Это приблуды ядра. А по факту прерывание приходит в текущем контексте. Железное переключение контекста возможно только при вызове прерывания/исключения через task gate, что изначально выпилено в 64-битном режиме.
Gredko
10.05.2025 12:11Прерывание выполняется/является в отдельном процессе, со своим стеком, контекстом итп
"Процесс" - в DOS?
"Мама дорогая..."
Стеки и контексты приложение обеспечивает в DOS. Такое как упомянутый Desqview.
Содержимое стека и регистров по IRQ копируется в память, а по IRET - восстанавливается.
Вот и все "отдельные процессы".
Siemargl
10.05.2025 12:11Ес-но, а что ещё вы хотели от реального режима =)
Gredko
10.05.2025 12:11От реального режима - ничего.
А вот от написателя статьи хочется некоторого понимания того, о чем он пишет.
"Началось с того, что я вырезал из английского журнала "Country Life" картинку, изображающую птичку, сидящую на ореховом дереве. Я назвал ее "ореховкой", точно так же, как не поколебался бы назвать птицу, сидящую на рябине, "рябиновкой"."
(С) Гашек Я.;"Похождения бравого солдата Швейка".
AGalilov Автор
10.05.2025 12:11В моём примере всё намного проще. Первое, после настройки нового вектора таймера, прерывание вызывает обработчик в том же самом "процессе", где работает вся программа примера. Нити (threads) тоже работают в том же самом процессе, но им выделены отдельные стеки. Процессор 8086 никакими "взрослыми" средствами изоляции процессов не обладает. По сути, пример в статье показывает многозадачность (или многонитевость) примерно на том уровне, как это сделано в RTOS, работающей на одноядерном микроконтроллере.
Oangai
10.05.2025 12:11автор показал как сделать вытесняющую многозадачность с переключением контекста, что не так? Современная FreeRTOS например делала бы тоже самое, если её на эту архитектуру портировать
LaptevVV
10.05.2025 12:11На мой взгляд, надо сразу написать, что мы прошли путь от 16-битного через 32-битный к 64-битному процессору интел. 16-битный работал вот так...
(был еще 8-битный - наблюдаем рудименты в виде 1-байтных регистров, но нам он не достался... :)))))
16-битный режим назывался реальным режимом, а 32-битный - защищенным.
Как называется 64-битный, я не в курсе.Цикл статей про это очень полезен народу, который пишет проги для встроенных микропроцессоров. И весьма полезен для понимания "мультипрограммирования" - аналог термина многозадачность.
Термин многопоточность, как и поток в отношении параллельных процессов мне абсолютно не нравится уже не одно десятилетие. Термин нить - слишком бытовой.
Поэтому предлагаю называть ни/потоки тредами. В конце-концов мы же называем файлы файлами, а фреймворки - фреймворками. Тред - и сразу понятно, что это. Ибо есть ведь еще и fiber'ы...
А потомки оставим для stream. Кстати, stream - это же стремнина. Вполне себе русское слово.
RCgoff
10.05.2025 12:11mov
al, bl ; al = bl
inc
al ; al++
Ваш код перегружен лишними комментариями. Комментарии, не поясняющие то, что происходит в программе, а лишь растолковывающие смысл очевидной команды ассемблера - визуальный шум. Даже немного неловко такое писать, это в некотором роде азбука
AGalilov Автор
10.05.2025 12:11Понимаю вашу точку зрения. Теперь уж выкидывать их бессмысленно, но полезные комментарии там тоже есть. Спасибо за замечание.
vde69
ой, давно это было, могу где-то и соврать, и так начнем накидывать :)
прерывание по таймеру можно перепрограмировать на более маленькие промежутки времени (я например делал 0.01 сек), но это чревато проблеммами с дисками, раньше рекомендовалось на время изменение частоты отключать все операции с дисками
память имеет кучу и стек, при чем стек имеет ограниченый размер (и расположение в конце памяти), что может помешать (переполнение стека) запускать много нитей
изменять стек вроде нелья было, да и вообще адресация строго в рамках сегмента (65к), конечно есть оператор извращения типа проекции файла в память, или глобальный mem (точно уже не помню все нюансы), но прямое изменение любой уже выделеной памяти вне своего сегмента данных для кода была проблеммой
в приведенной схеме есть слабое место - возникновение программного исключения в нити, после этого может случится все барабум :)
не забываем, что в системе могут быть чужие резидентные программы которые то-же могут пытатся использовать прерывания.
Короче тема интересная, но если реально пытатся ее реализовать, то нужно ориентироватся на совсем низкий уровень (на уровень микросхем или на уровне переопределения 1Ch а не 8h, хотя 1Ch вроде на время выполнения блокирует все прерывания)
Хотя опять-же давно это было мог все перепутать.
добавлю ссылку в тему таймеров https://frolov-lib.ru/books/bsp.old/v02/ch5.htm
kmatveev
С чего бы?
pfemidi
Если мне не изменяет моя старческая память, то можно использовать прерывание IRQ 8 от RTC, которое сидит на INT 70h. Правда это было ещё в далёкие времена 80286 и DOS, не знаю как с этим обстоят дела в DOSBox и поддерживает ли он такую вот древность.
zatim
Я думаю что без проблем поддерживает. Более того, все эти рудименты поддерживаются и во всех х86 современных компах.