Как стать автором
Обновить

Низкоуровневое программирование под 8086 для любопытных, часть 2

Уровень сложностиСредний
Время на прочтение15 мин
Количество просмотров9.2K

Длинное вступление

Утренняя работа над второй частью статьи началась не с запаха кофе, а с запаха нафталина, толстым слоем покрывающего микропроцессоры эпохи конца 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) изображена последовательность передач управления для организации такой многопоточности. Ось времени направлена вниз, стрелочки показывают передачу управления между отдельными логическими модулями программы. Надписи под стрелочками - события, "провоцирующие" передачу выполнения кода.

Рис. 1. Взаимодействия внутри нашей программы, реализующие мультипоточность.
Рис. 1. Взаимодействия внутри нашей программы, реализующие мультипоточность.

Со временем и стрелочками предварительно разобрались, перейдём к шампурам и сосискам линиям жизни и фрагментам выполнения. Линии жизни показаны вертикальным пунктиром, а фрагменты выполнения - узким вертикальным прямоугольником на линии жизни.

В нашей программе фрагмент выполнения - это одна или несколько машинных инструкций. Линия жизни показывает, что между фрагментами выполнения сохраняется некий важный для целостности логики программы контекст: значения регистров, включая регистр флагов FLAGS, локальные переменные (у нас в явном виде их нет, мы работаем с регистрами), состояние стека.

Фрагменты выполнения объединены в объекты. На схеме они помечены красными надписями. Иначе говоря, шампур и сосиски линия жизни и фрагменты выполнения на этой линии относятся к одному объекту. Рассмотрим их назначение:

  • Main code - это главный код, который начинает выполняться при запуске программы и делает все подготовительные действия для старта нитей. В этот же код происходит передача управления при завершении работы переключателя нитей.

  • Switcher - переключатель нитей. В нём реализована основная логика переключения выполнения, или, другими словами, передача управления между нитями.

  • T1, T2, T3 - сами нити. Для примера их здесь три. Каждая нить просто выполняет некий код и бесконечно зациклена. То есть, для упрощения примера мы не предусматриваем никакого специального выхода или завершения работы однажды запущенной нити. Позднее мы добавим эту интересную возможность.

Взгляд поближе: идейка

Как прервать работу бесконечного цикла? Ответ есть у меня, и он до безобразия банален: аппаратным прерыванием. Например, прерыванием таймера. Сколько код ни зацикливай, если аппаратные прерывания не запрещены сбросом флага IF в регистре FLAGS, процессор будет реагировать на внешние раздражители в виде сигналов от клавиатуры или таймера переключением на выполнение кода обработчика прерывания (ISR).

В первой части статьи подробно описан механизм прерываний, здесь я продублирую самое важное и это, конечное же, картинка:

Рис. 2. Последовательность вызова обработчика аппаратного прерывания в процессоре 8086.
Рис. 2. Последовательность вызова обработчика аппаратного прерывания в процессоре 8086.

Внимательный читатель всё понял верно: обработчик прерывания после своего завершения возвращает управление аккуратно на ту инструкцию, которая должна была выполниться перед самым прерыванием, да не успела. Вызовы обработчика прерываний никогда не "рвут" уже выполняемую инструкцию. Они вклиниваются аккуратно после завершения инструкции и перед стартом следующей.

Первое, что в этом механизме нас более всего интересует, это то, что происходит как бы само по себе: помещение в текущий стек, адресуемый парой SS:SP, содержимого регистра FLAGS, регистра CS и регистра IP.

Каждый раз перед помещением в стек нового значения, регистр SP уменьшается на 2 (размер слова), затем по адресу SS × 16dec + SP записывается сохраняемое значение размером ровно одно слово (2 байта).

На это мы повлиять не можем, но нам и не надо. Нужно точно представлять себе этот этап обработки прерывания. Мы представили. Мы - умницы!

Теперь наш обработчик прерывания сделал свою важную работу и собирается вернуть управление прерванному коду инструкцией IRET. Смотрим на Рис. 2 и понимаем, что из стека будет извлечен адрес возврата в виде пары значений CS:IP и FLAGS.

Всякий раз при извлечении значения из стека происходит сначала чтение одного слова (2 байта) памяти по адресу SS × 16dec + SP, в котором было сохранено значение, а затем SP увеличивается на 2 (размер слова).

А вот тут мы можем повлиять! Сам обработчик прерывания может разместить в памяти стека какой-то другой адрес возврата вместо того, который был помещён туда автоматически. И тогда IRET вернёт управление совсем другому коду, не тому, который был прерван!

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

Рассматриваем шестерёнки

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

Вместо записи в стек каких-то новых адресов возврата каждый раз при вызове обработчика прерывания, мы пойдём более верной дорогой (Рис. 3).

Рис. 3. Благословляет идти верным путём. Источник https://neolurk.org/wiki/Верной_дорогой_идёте,_товарищи!
Рис. 3. Благословляет идти верным путём. Источник https://neolurk.org/wiki/Верной_дорогой_идёте,_товарищи!

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

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

Рис. 4. Начальное состояние стеков нитей.
Рис. 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 в текущий стек. Далее выполняется ряд проверок и предпринимаются те или иные действия:

  1. Если обработчик ранее не вызывался, то ThreadIndex == -1 (красный прямоугольник на рис. 4) и прерывание возникло во время выполнения главного кода программы, а не одной из нитей, т.к. нить может стартовать только из самого обработчика.
    В этом случае SP сохраняется в MainThreadSP (см. рис. 4, коричневый прямоугольник), а ThreadIndex инкрементируется и становится равным нулю: ThreadIndex = ThreadIndex + 1 = 0

  2. Если обработчик уже вызывался ранее, то регистр SP сохраняется в один из элементов массива Saved SPs array, индекс которого извлекается из переменной ThreadIndex:
    SavedSP[ThreadIndex] = SP
    После этого ThreadIndex модифицируется так, чтобы показывать на следующий элемент массива Saved SPs array, или на нулевой элемент, если дошли до конца массива:
    ThreadIndex = (ThreadIndex + 1) % NumThreads
    В нашем примере NumThreads = количество нитей = 3.

  3. В регистр SP помещается значение из элемента массива Saved SPs array с индексом ThreadIndex:
    SP = SavedSP[ThreadIndex]

  4. Обработчик извлекает из, теперь уже другого, стека ранее сохранённые там значения регистров в обратной последовательности DS. ES, BP, DI, SI, DX, CX, BX, AX инструкциями POP.

  5. Обработчик выполняет дальний переход
    JMP DWORD [PrevTimerVector]
    на сохранённый в 32-битную переменную PrevTimerVector сегмент:смещение оригинального таймерного прерывания.

  6. Оригинальный обработчик прерывания выполняет все необходимые операции по обновлению системных часов, отправляет команду завершения контроллеру прерываний и делает возврат с помощью 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 чтобы получилась вот такая структура:

Рис. 5. Структура рабочей директории
Рис. 5. Структура рабочей директории

Далее модифицируем конфигурационный файл 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), то всё хорошо:

Рис. 6. Запустили DOSBox Staging c CWDPMI
Рис. 6. Запустили DOSBox Staging c CWDPMI

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

Рис. 7. Fasm успешно запускается
Рис. 7. Fasm успешно запускается

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

fasm demo.asm

Рис. 8. Трансляция исходника примера
Рис. 8. Трансляция исходника примера

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

Рис. 9. Пример отработал нормально.
Рис. 9. Пример отработал нормально.

Скачать исходник можно с моего репозитория на github.com

Заключение

Если эта тема была интересна и автору стоит продолжать в подобном ключе, но ориентируясь на современные платформы, голосуйте за статью и ставьте плюсики в карму.

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

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Нужно ли низкоуровненое продолжение под современные платформы?
97.37% Хочу продолжение!74
2.63% Автор, выпей Йоду!2
Проголосовали 76 пользователей. Воздержались 7 пользователей.
Теги:
Хабы:
+58
Комментарии30

Публикации

Ближайшие события