Длинное вступление
Утренняя работа над второй частью статьи началась не с запаха кофе, а с запаха нафталина, толстым слоем покрывающего микропроцессоры эпохи конца 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 × 16dec + SP
записывается сохраняемое значение размером ровно одно слово (2 байта).
На это мы повлиять не можем, но нам и не надо. Нужно точно представлять себе этот этап обработки прерывания. Мы представили. Мы - умницы!
Теперь наш обработчик прерывания сделал свою важную работу и собирается вернуть управление прерванному коду инструкцией IRET
. Смотрим на Рис. 2 и понимаем, что из стека будет извлечен адрес возврата в виде пары значений CS:IP
и FLAGS
.
Всякий раз при извлечении значения из стека происходит сначала чтение одного слова (2 байта) памяти по адресу
SS × 16dec + 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
Заключение
Если эта тема была интересна и автору стоит продолжать в подобном ключе, но ориентируясь на современные платформы, голосуйте за статью и ставьте плюсики в карму.
На работу над этой частью, включая пример кода, потрачено около недели времени и я буду признателен за ваши отзывы. Замечания, очепятки, дополнения - добро пожаловать в комментарии.