Длинное вступление
Утренняя работа над второй частью статьи началась не с запаха кофе, а с запаха нафталина, толстым слоем покрывающего микропроцессоры эпохи конца 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. callGetSavedSPOffset ; 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
Заключение
Если эта тема была интересна и автору стоит продолжать в подобном ключе, но ориентируясь на современные платформы, голосуйте за статью и ставьте плюсики в карму.
На работу над этой частью, включая пример кода, потрачено около недели времени и я буду признателен за ваши отзывы. Замечания, очепятки, дополнения — добро пожаловать в комментарии.
ссылка на оригинал статьи https://habr.com/ru/articles/907312/
Добавить комментарий