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

Утренняя работа над второй частью статьи началась не с запаха кофе, а с запаха нафталина, толстым слоем покрывающего микропроцессоры эпохи конца 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 × 4 + SP записывается сохраняемое значение размером ровно одно слово (2 байта).

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

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

Всякий раз при извлечении значения из стека происходит сначала чтение одного слова (2 байта) памяти по адресу SS × 4 + 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

Заключение

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

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

Комментарии (27)


  1. vde69
    10.05.2025 12:11

    ой, давно это было, могу где-то и соврать, и так начнем накидывать :)

    1. прерывание по таймеру можно перепрограмировать на более маленькие промежутки времени (я например делал 0.01 сек), но это чревато проблеммами с дисками, раньше рекомендовалось на время изменение частоты отключать все операции с дисками

    2. память имеет кучу и стек, при чем стек имеет ограниченый размер (и расположение в конце памяти), что может помешать (переполнение стека) запускать много нитей

    3. изменять стек вроде нелья было, да и вообще адресация строго в рамках сегмента (65к), конечно есть оператор извращения типа проекции файла в память, или глобальный mem (точно уже не помню все нюансы), но прямое изменение любой уже выделеной памяти вне своего сегмента данных для кода была проблеммой

    4. в приведенной схеме есть слабое место - возникновение программного исключения в нити, после этого может случится все барабум :)

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

    Короче тема интересная, но если реально пытатся ее реализовать, то нужно ориентироватся на совсем низкий уровень (на уровень микросхем или на уровне переопределения 1Ch а не 8h, хотя 1Ch вроде на время выполнения блокирует все прерывания)

    Хотя опять-же давно это было мог все перепутать.

    добавлю ссылку в тему таймеров https://frolov-lib.ru/books/bsp.old/v02/ch5.htm


    1. kmatveev
      10.05.2025 12:11

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

      С чего бы?


    1. pfemidi
      10.05.2025 12:11

      Если мне не изменяет моя старческая память, то можно использовать прерывание IRQ 8 от RTC, которое сидит на INT 70h. Правда это было ещё в далёкие времена 80286 и DOS, не знаю как с этим обстоят дела в DOSBox и поддерживает ли он такую вот древность.


      1. zatim
        10.05.2025 12:11

        Я думаю что без проблем поддерживает. Более того, все эти рудименты поддерживаются и во всех х86 современных компах.


  1. kmatveev
    10.05.2025 12:11

    Статья очень хорошая. Но у меня есть пара вопросов.

    В первом листинге я сначала удивился, зачем вы загружаете значение из AddedThreadCounter сначала в BL, а оттуда в AL, можно же было сразу. Только потом я увидел, что BL используется как параметр в GetSavedSPOffset, сильно ниже по листингу. Неочевидно. И ещё по этому листингу получается, что самый первый элемент в области ThreadStacks не используется, теоретически он под main зарезервирован?


    1. AGalilov Автор
      10.05.2025 12:11

      Возможно, я не очень понял часть вопроса на счёт ThreadStacks... Отвечу на то, как понял :)

      Стек начинается со старших адресов. По сути, стек первой нити начинается... да, с младшего адреса блока памяти, предназначенного для второй нити :) Но мы не пугаемся, потому что указатель стека - регистр SP - уменьшается перед записью в стек нового значения.

      Операции со стеком по сути не сложные, но часто вызывают скрежет шестерёнок мозга.


    1. AGalilov Автор
      10.05.2025 12:11

      Да, на счёт BL/AL не особо наглядно. Дело в том. что прямой инкремент значения в памяти - это чтение-изменение-запись. Но мы уже и так прочитали переменную в регистр, поэтому инкремент переменной в памяти я решил не делать и заменил на инкремент регистра. В итоге потерялась наглядность.

      Спасибо за ваш комментарий, от меня лучи добра и плюсики!


  1. 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;
    }
    


    1. AGalilov Автор
      10.05.2025 12:11

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


      1. 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, видимо гдето в промышленности используются


  1. Gredko
    10.05.2025 12:11

    Нас ждёт погружение в один из способов организации мультипоточности на базе единственного ядра процессора

    Если "мультипоточность" или "многопоточность" обозначает то, о чем пишет автор, то что такое тогда "многозадачность"?

    Зачем брать давно известные термины с общепринятыми значениями и менять их значения?

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

    Скорее речь о прерываниях и их обработке в однозадачном однопоточном режиме.

    Подобным образом, например организовано в BIOS IBM PC чтение клавиатуры.

    По прерыванию от клавиатуры int9h читается сканкод и меняется регистр состояния клавиатуры.

    Любая другая программа в свою очередь может этот сканкод прочитать и интерпретировать по своим алгоритмам.

    При этом нет ни процессов ни потоков в общепринятом понимании.

    Прерывания как аппаратные так и программные выполняются в рамках одного потока и процесса.


    1. salnicoff
      10.05.2025 12:11

      Человек пытается запилить аналог DESQview и использует терминологию того времени, с поправками на маркетинговые материалы Quarterdeck Office Systems.


      1. Gredko
        10.05.2025 12:11

        Человек пытается запилить аналог DESQview

        Терминология того времени называет многозадачность под 8086 в стиле Desqview "кооперативной многозадачностью".

        Так-то похоже на "ореховку" Швейка....


      1. pfemidi
        10.05.2025 12:11

        Не, доска более продвинутая и более навороченная. Тут больше похоже на CTask: https://jacobfilipp.com/DrDobbs/articles/CUJ/1993/9304/volkman/volkman.htm



    1. Siemargl
      10.05.2025 12:11

      Наверное все же нет. Прерывание выполняется/является в отдельном процессе, со своим стеком, контекстом итп


      1. medstrax
        10.05.2025 12:11

        Это приблуды ядра. А по факту прерывание приходит в текущем контексте. Железное переключение контекста возможно только при вызове прерывания/исключения через task gate, что изначально выпилено в 64-битном режиме.


      1. Gredko
        10.05.2025 12:11

        Прерывание выполняется/является в отдельном процессе, со своим стеком, контекстом итп

        "Процесс" - в DOS?

        "Мама дорогая..."

        Стеки и контексты приложение обеспечивает в DOS. Такое как упомянутый Desqview.

        Содержимое стека и регистров по IRQ копируется в память, а по IRET - восстанавливается.

        Вот и все "отдельные процессы".


        1. Siemargl
          10.05.2025 12:11

          Ес-но, а что ещё вы хотели от реального режима =)


          1. Gredko
            10.05.2025 12:11

            От реального режима - ничего.

            А вот от написателя статьи хочется некоторого понимания того, о чем он пишет.

            "Началось с того, что я вырезал из английского журнала "Country Life" картинку, изображающую птичку, сидящую на ореховом дереве. Я назвал ее "ореховкой", точно так же, как не поколебался бы назвать птицу, сидящую на рябине, "рябиновкой"."

            (С) Гашек Я.;"Похождения бравого солдата Швейка".


      1. AGalilov Автор
        10.05.2025 12:11

        В моём примере всё намного проще. Первое, после настройки нового вектора таймера, прерывание вызывает обработчик в том же самом "процессе", где работает вся программа примера. Нити (threads) тоже работают в том же самом процессе, но им выделены отдельные стеки. Процессор 8086 никакими "взрослыми" средствами изоляции процессов не обладает. По сути, пример в статье показывает многозадачность (или многонитевость) примерно на том уровне, как это сделано в RTOS, работающей на одноядерном микроконтроллере.


    1. Oangai
      10.05.2025 12:11

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


  1. NeriaLab
    10.05.2025 12:11

    Я бы с удовольствием прочитал курс лекций по старому (386 и старше) с нормальными примерами и пояснениями. Пищал бы от полного восторга.


    1. Gummilion
      10.05.2025 12:11

      Так читайте Питера Нортона, он весьма детально пишет


  1. LaptevVV
    10.05.2025 12:11

    1. На мой взгляд, надо сразу написать, что мы прошли путь от 16-битного через 32-битный к 64-битному процессору интел. 16-битный работал вот так...
      (был еще 8-битный - наблюдаем рудименты в виде 1-байтных регистров, но нам он не достался... :)))))
      16-битный режим назывался реальным режимом, а 32-битный - защищенным.
      Как называется 64-битный, я не в курсе.

    2. Цикл статей про это очень полезен народу, который пишет проги для встроенных микропроцессоров. И весьма полезен для понимания "мультипрограммирования" - аналог термина многозадачность.

    3. Термин многопоточность, как и поток в отношении параллельных процессов мне абсолютно не нравится уже не одно десятилетие. Термин нить - слишком бытовой.
      Поэтому предлагаю называть ни/потоки тредами. В конце-концов мы же называем файлы файлами, а фреймворки - фреймворками. Тред - и сразу понятно, что это. Ибо есть ведь еще и fiber'ы...
      А потомки оставим для stream. Кстати, stream - это же стремнина. Вполне себе русское слово.


  1. RCgoff
    10.05.2025 12:11

    mov al, bl ; al = bl

    inc al ; al++

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


    1. AGalilov Автор
      10.05.2025 12:11

      Понимаю вашу точку зрения. Теперь уж выкидывать их бессмысленно, но полезные комментарии там тоже есть. Спасибо за замечание.