Простейшая командная строка на NASM и QEMU

image


Итак, сразу к делу. Писать будем под Linux, на NASM и с использованием QEMU. Установить это легко, так что пропустим этот шаг.


Подразумевается, что читатель знаком с синтаксисом NASM хотя бы на базовом уровне (впрочем, ничего особо сложного здесь не будет) и понимает, что такое регистры.


Базовая теория


Первое, что запускает процессор при включении компьютера — это код BIOS (или же UEFI, но здесь я буду говорить только про BIOS), который "зашит" в памяти материнской платы (конкретно — по адресу 0xFFFFFFF0).


Сразу после включения BIOS запускает Power-On Self-Test (POST) — самотестирование после включения. BIOS проверяет работоспособность памяти, обнаруживает и инициализирует подключенные устройства, проверяет регистры, определяет размер памяти и так далее и так далее.


Следующий шаг — определение загрузочного диска, с которого можно загрузить ОС. Загрузочный диск — это диск (или любой другой накопитель), у которого последние 2 байта первого сектора (под первым сектором подразумевается первые 512 байт накопителя, т.к. 1 сектор = 512 байт) равны 55 и AA (в шестнадцатеричном формате). Как только загрузочный диск будет найден, BIOS загрузит первые его 512 байт в оперативную память по адресу 0x7c00 и передаст управление процессору по этому адресу.


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


С самого начала процессор работает в Real Mode (= 16-битный режим). Это означает, что он может работать лишь с 16-битными данными и использует сегментную адресацию памяти, а также может адресовать только 1 Мб памяти. Но вторым мы пользоваться здесь не будем. Картинка ниже показывает состояние оперативной памяти при передаче управления нашему коду (картинка взята отсюда).


image


Последнее, о чем стоит сказать перед практической частью — прерывания. Прерывание — это особый сигнал (например, от устройства ввода, такого, как клавиатура или мышь) процессору, который говорит, что нужно немедленно прервать исполнение текущего кода и выполнить код обработчика прерывания. Все адреса обработчиков прерывания находятся в Interrupt Descriptor Table (IDT) в оперативной памяти. Каждому прерыванию соответствует свой обработчик прерывания. Например, при нажатии клавиши клавиатуры вызывается прерывание, процессор останавливается, запоминает адрес прерванной инструкции, сохраняет все значения своих регистров (на стеке) и переходит к выполнению обработчика прерывания. Как только его выполнение заканчивается, процессор восстанавливает значения регистров и переходит обратно, к прерванной инструкции и продолжает выполнение.


Например, чтобы вывести что-то на экран в BIOS используется прерывание 0x10 (шестнадцатеричный формат), а для ожидания нажатия клавиши — прерывание 0x16. По сути, это все прерывания, что нам понадобятся здесь.


Также, у каждого прерывания есть своя подфункция, определяющая особенность его поведения. Чтобы вывести что-то на экран в текстовом формате (!), нужно в регистр AH занести значение 0x0e. Помимо этого, у прерываний есть свои параметры. 0x10 принимает значения из ah (определяет конкретную подфункцию) и al (символ, который нужно вывести). Таким образом,


mov ah, 0x0e
mov al, 'x'
int 0x10

выведет на экран символ 'x'. 0x16 принимает значение из ah (конкретная подфункция) и загружает в регистр al значение введенной клавиши. Мы будем использовать функцию 0x0.


Практическая часть


Начнем с вспомогательного кода. Нам понадобятся функции сравнения двух строк и функция вывода строки на экран. Я попытался максимально понятно описать работу этих функций в комментариях.


str_compare.asm:


compare_strs_si_bx:
    push si                   ; сохраняем все нужные в функции регистры на стеке
    push bx
    push ax

comp:
    mov ah, [bx]              ; напрямую регистры сравнить не получится,
    cmp [si], ah              ; поэтому переносим первый символ в ah
    jne not_equal             ; если символы не совпадают, то выходим из функции

    cmp byte [si], 0          ; в обратном случае сравниваем, является ли символ
    je first_zero             ; символом окончания строки

    inc si                    ; переходим к следующему байту bx и si
    inc bx

    jmp comp                  ; и повторяем

first_zero:
    cmp byte [bx], 0          ; если символ в bx != 0, то значит, что строки
    jne not_equal             ; не равны, поэтому переходим в not_equal

    mov cx, 1                 ; в обратном случае строки равны, значит cx = 1

    pop si                    ; поэтому восстанавливаем значения регистров
    pop bx
    pop ax

    ret                       ; и выходим из функции

not_equal:
    mov cx, 0                 ; не равны, значит cx = 0

    pop si                    ; восстанавливаем значения регистров
    pop bx
    pop ax

    ret                       ; и выходим из функции

В качестве параметров функция принимает регистры SI и BX. Если строки равны, то в CX устанавливается 1, в обратном случае — 0.


Еще стоит отметить, что регистры AX, BX, CX и DX делятся на две однобайтовых части: AH, BH, CH, и DH для старшего байта, а AL, BL, CL и DL для младшего байта.


Изначально подразумевается, что в bx и si находятся указатели (!) (то есть хранит адрес в памяти) на какой-то адрес в памяти, в котором находится начало строки. Операция [bx] возьмет из bx указатель, пройдет по этому адресу и возьмет оттуда какое-то значение. inc bx значит, что теперь указатель будет ссылаться на адрес, идущий сразу после изначального адреса.


print_string.asm:


print_string_si:
    push ax                   ; сохраняем ax на стеке

    mov ah, 0x0e              ; устанавливаем ah в 0x0e, чтобы вызвать функцию
    call print_next_char      ; прерывания

    pop ax                    ; восстанавливаем ax
    ret                       ; и выходим

print_next_char:
    mov al, [si]              ; загрузка одного символа
    cmp al, 0                 ; если si закончилась

    jz if_zero                ; то выходим из функции

    int 0x10                  ; в обратном случае печатаем al
    inc si                    ; и инкрементируем указатель

    jmp print_next_char       ; и начинаем заново...

if_zero:
    ret

В качестве параметра функция принимает регистр SI и байт за байтом печатает строку.


Теперь перейдем к основному коду. Для начала определимся со всеми переменными (этот код будет находиться в самом конце файла):


; 0x0d - символ возварата картки, 0xa - символ новой строки
wrong_command: db "Wrong command!", 0x0d, 0xa, 0
greetings: db "The OS is on. Type 'help' for commands", 0x0d, 0xa, 0xa, 0
help_desc: db "Here's nothing to show yet. But soon...", 0x0d, 0xa, 0
goodbye: db 0x0d, 0xa, "Goodbye!", 0x0d, 0xa, 0
prompt: db ">", 0
new_line: db 0x0d, 0xa, 0
help_command: db "help", 0
input: times 64 db 0          ; размер буфера - 64 байта

times 510 - ($-$$) db 0
dw 0xaa55

Символ возврата каретки перемещает каретку к левому краю экрана, то есть в начало строки.


input: times 64 db 0 

значит, что мы выделяем под буфер для ввода 64 байта и заполняем их нулями.


Остальные переменные нужны для вывода какой-то информации, дальше по коду вы поймете, зачем они все нужны.


times 510 - ($-$$) db 0 
dw 0xaa55

значит, что мы явно устанавливаем размер выходного файла (с расширением .bin) как 512 байт, заполняем первые 510 байт нулями (само собой, они заполняются перед исполнением всего кода), а последние два байта — теми самыми "магическими" байтами 55 и AA. $ означает адрес текущей инструкции, а $$ — адрес самой первой инструкции нашего кода.


Перейдем к собственно коду:


org 0x7c00                    ; (1)
bits 16                       ; (2)

jmp start                     ; сразу переходим в start

%include "print_string.asm"   ; импортируем наши вспомогательные функции
%include "str_compare.asm"

; ====================================================

start:
    mov ah, 0x00              ; очистка экрана (3)
    mov al, 0x03
    int 0x10

    mov sp, 0x7c00            ; инициализация стека (4)

    mov si, greetings         ; печатаем приветственное сообщение
    call print_string_si      ; после чего сразу переходим в mainloop

(1). Эта команда дает понять NASM'у, что мы выполняем код, начиная с адреса 0x7c00. Это позволяет ему автоматически смещать все адреса относительно этого адреса, чтобы мы не делали это явно.
(2). Эта команда дает указание NASM'у, что мы работаем в 16-битном режиме.
(3). При запуске QEMU печатает на экране много ненужной нам информации. Для этого устанавливаем в ah 0x00, в al 0x03 и вызываем 0x10, чтобы очистить экран от всего.
(4). Чтобы сохранять регистры в стеке, необходимо указать, по какому адресу будет находиться его вершина с помощью указателя стека SP. SP будет указывать на область в памяти, в которую будет записано очередное значение. Добавляем значение на стек — SP спускается вниз по памяти на 2 байта (т.к. мы в Real Mode, где все операнды регистра — 16-битные, т.е. двухбайтные, значения). Мы указали 0x7c00, поэтому значения в стеке будут сохраняться прямо возле нашего кода в памяти. Еще раз — стек растет вниз (!). Это значит, что чем больше значений будет в стеке, тем на меньшую память будет указывать указатель стека SP.


mainloop:
    mov si, prompt            ; печатаем стрелочку
    call print_string_si

    call get_input            ; вызываем функцию ожидания ввода

    jmp mainloop              ; повторяем mainloop...

Главный цикл. Здесь с каждой итерацией мы печатаем символ ">", после чего вызываем функцию get_input, реализующее работу с прерыванием клавиатуры.


get_input:
    mov bx, 0                 ; инициализируем bx как индекс для хранения ввода

input_processing:
    mov ah, 0x0               ; параметр для вызова 0x16
    int 0x16                  ; получаем ASCII код

    cmp al, 0x0d              ; если нажали enter
    je check_the_input        ; то вызываем функцию, в которой проверяем, какое
                              ; слово было введено

    cmp al, 0x8               ; если нажали backspace
    je backspace_pressed

    cmp al, 0x3               ; если нажали ctrl+c
    je stop_cpu

    mov ah, 0x0e              ; во всех противных случаях - просто печатаем
                              ; очередной символ из ввода
    int 0x10

    mov [input+bx], al        ; и сохраняем его в буффер ввода
    inc bx                    ; увеличиваем индекс

    cmp bx, 64                ; если input переполнен
    je check_the_input        ; то ведем себя так, будто был нажат enter

    jmp input_processing      ; и идем заново

(1) [input+bx] значит, что мы берем адрес начала буфера ввода input и прибавляем к нему bx, то есть получаем к bx+1-ому элементу буфера.


stop_cpu:
    mov si, goodbye           ; печатаем прощание
    call print_string_si

    jmp $                     ; и останавливаем компьютер
                              ; $ означает адрес текущей инструкции

Здесь все просто — если нажали Ctrl+C, компьютер просто бесконечно выполняет функцию jmp $.


backspace_pressed:
    cmp bx, 0                 ; если backspace нажат, но input пуст, то
    je input_processing       ; ничего не делаем

    mov ah, 0x0e              ; печатаем backspace. это значит, что каретка
    int 0x10                  ; просто передвинется назад, но сам символ не сотрется

    mov al, ' '               ; поэтому печатаем пробел на том месте, куда
    int 0x10                  ; встала каретка

    mov al, 0x8               ; пробел передвинет каретку в изначальное положение
    int 0x10                  ; поэтому еще раз печатаем backspace

    dec bx
    mov byte [input+bx], 0    ; и убираем из input последний символ

    jmp input_processing      ; и возвращаемся обратно

Чтобы не стирать символ '>' при нажатии backspace, проверяем, пуст ли input. Если нет, то ничего не делаем.


check_the_input:
    inc bx
    mov byte [input+bx], 0    ; в конце ввода ставим ноль, означающий конец
                              ; стркоки (тот же '\0' в Си)

    mov si, new_line          ; печатаем символ новой строки
    call print_string_si

    mov si, help_command      ; в si загружаем заранее подготовленное слово help
    mov bx, input             ; а в bx - сам ввод
    call compare_strs_si_bx   ; сравниваем si и bx (введено ли help)

    cmp cx, 1                 ; compare_strs_si_bx загружает в cx 1, если
                              ; строки равны друг другу
    je equal_help             ; равны => вызываем функцию отображения
                              ; текста help

    jmp equal_to_nothing      ; если не равны, то выводим "Wrong command!"

Тут, думаю все понятно по комментариям.


equal_help:
    mov si, help_desc
    call print_string_si

    jmp done

equal_to_nothing:
    mov si, wrong_command
    call print_string_si

    jmp done

В зависимости от того, что было введено, выводим либо текст переменной help_desc, либо текст переменной wrong_command.


; done очищает всю переменную input
done:
    cmp bx, 0                 ; если зашли дальше начала input в памяти
    je exit                   ; то вызываем функцию, идующую обратно в mainloop

    dec bx                    ; если нет, то инициализируем очередной байт нулем
    mov byte [input+bx], 0

    jmp done                  ; и делаем то же самое заново

exit:
    ret

Собственно, весь код целиком:


prompt.asm:


org 0x7c00
bits 16

jmp start                     ; сразу переходим в start

%include "print_string.asm"
%include "str_compare.asm"

; ====================================================

start:
    cli                                ; запрещаем прерывания, чтобы наш код 
                                       ; ничто лишнее не останавливало

    mov ah, 0x00              ; очистка экрана
    mov al, 0x03
    int 0x10

    mov sp, 0x7c00            ; инициализация стека

    mov si, greetings         ; печатаем приветственное сообщение
    call print_string_si      ; после чего сразу переходим в mainloop

mainloop:
    mov si, prompt            ; печатаем стрелочку
    call print_string_si

    call get_input            ; вызываем функцию ожидания ввода

    jmp mainloop              ; повторяем mainloop...

get_input:
    mov bx, 0                 ; инициализируем bx как индекс для хранения ввода

input_processing:
    mov ah, 0x0               ; параметр для вызова 0x16
    int 0x16                  ; получаем ASCII код

    cmp al, 0x0d              ; если нажали enter
    je check_the_input        ; то вызываем функцию, в которой проверяем, какое
                              ; слово было введено

    cmp al, 0x8               ; если нажали backspace
    je backspace_pressed

    cmp al, 0x3               ; если нажали ctrl+c
    je stop_cpu

    mov ah, 0x0e              ; во всех противных случаях - просто печатаем
                              ; очередной символ из ввода
    int 0x10

    mov [input+bx], al        ; и сохраняем его в буффер ввода
    inc bx                    ; увеличиваем индекс

    cmp bx, 64                ; если input переполнен
    je check_the_input        ; то ведем себя так, будто был нажат enter

    jmp input_processing      ; и идем заново

stop_cpu:
    mov si, goodbye           ; печатаем прощание
    call print_string_si

    jmp $                     ; и останавливаем компьютер
                              ; $ означает адрес текущей инструкции

backspace_pressed:
    cmp bx, 0                 ; если backspace нажат, но input пуст, то
    je input_processing       ; ничего не делаем

    mov ah, 0x0e              ; печатаем backspace. это значит, что каретка
    int 0x10                  ; просто передвинется назад, но сам символ не сотрется

    mov al, ' '               ; поэтому печатаем пробел на том месте, куда
    int 0x10                  ; встала каретка

    mov al, 0x8               ; пробел передвинет каретку в изначальное положение
    int 0x10                  ; поэтому еще раз печатаем backspace

    dec bx
    mov byte [input+bx], 0    ; и убираем из input последний символ

    jmp input_processing      ; и возвращаемся обратно

check_the_input:
    inc bx
    mov byte [input+bx], 0    ; в конце ввода ставим ноль, означающий конец
                              ; стркоки (тот же '\0' в Си)

    mov si, new_line          ; печатаем символ новой строки
    call print_string_si

    mov si, help_command      ; в si загружаем заранее подготовленное слово help
    mov bx, input             ; а в bx - сам ввод
    call compare_strs_si_bx   ; сравниваем si и bx (введено ли help)

    cmp cx, 1                 ; compare_strs_si_bx загружает в cx 1, если
                              ; строки равны друг другу
    je equal_help             ; равны => вызываем функцию отображения
                              ; текста help

    jmp equal_to_nothing      ; если не равны, то выводим "Wrong command!"

equal_help:
    mov si, help_desc
    call print_string_si

    jmp done

equal_to_nothing:
    mov si, wrong_command
    call print_string_si

    jmp done

; done очищает всю переменную input
done:
    cmp bx, 0                 ; если зашли дальше начала input в памяти
    je exit                   ; то вызываем функцию, идующую обратно в mainloop

    dec bx                    ; если нет, то инициализируем очередной байт нулем
    mov byte [input+bx], 0

    jmp done                  ; и делаем то же самое заново

exit:
    ret

; 0x0d - символ возварата картки, 0xa - символ новой строки
wrong_command: db "Wrong command!", 0x0d, 0xa, 0
greetings: db "The OS is on. Type 'help' for commands", 0x0d, 0xa, 0xa, 0
help_desc: db "Here's nothing to show yet. But soon...", 0x0d, 0xa, 0
goodbye: db 0x0d, 0xa, "Goodbye!", 0x0d, 0xa, 0
prompt: db ">", 0
new_line: db 0x0d, 0xa, 0
help_command: db "help", 0
input: times 64 db 0          ; размер буффера - 64 байта

times 510 - ($-$$) db 0
dw 0xaa55

Чтобы все это скомпилировать, введем команду:


nasm -f bin prompt.asm -o bootloader.bin

И получим на выходе бинарник с нашим кодом. Теперь запустим эмулятор QEMU с этим файлом (-monitor stdio позволяет в любой момент вывести значение регистра с помощью команды print $reg):


qemu-system-i386 bootloader.bin -monitor stdio

И получим на выходе:


image

Поделиться публикацией

Комментарии 13

    0
    Супер. Кто-то ещё помнит как писать под проц. 8086. В принципе здорово, только непонятно для чего. Какова цель статьи и проделанной работы. Чтобы из нормальной консоли получить самодельную в эмуляторе?
      +3
      Мы вот в универе только 8086 учим. И думаю в многих вузах тоже, так что студентам это точно очень даже полезно
        +2
        8086 — это маленькое подмножество современного x86-64. Странно как-то разделять, все кто умеют в современный ассемблер с минимальными усилиями смогут в 8086.
        Скорее тут больше проблем будет с BIOS прерываниями, вот это ща устарело.
          0

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

            0
            Не только пишут, а уже написали операционную систему на ассемблере — KolibriOS
            В размерах с демонстрационным софтом — одной 1.44 Мб дискеты!
            Правда там ассемблер FASM

            P.S. Базис разработчиков данной ОС — русскоязычные программисты.
              +1
              Колибря всё-таки под i586, а не под 8086, и вполне себе 32-х битная.
            0

            Ну если исключительно для тренировки навыков программирования — то да. Для этого любой asm подойдёт. Qemu получается как эмулятор старого проца, причем за даром. Можно ещё пробросить виртуальный LPT, на стороне хост-машины сделать эмулятор лифта или пульта или дисплея или поливалки или… и можно писать лабораторки.

              0
              Это как затравка для таких возможностей:
              habr.com/ru/post/442384
              habr.com/ru/company/audiomania/blog/442422
                0
                А, ещё есть «подобие» Форт в 512-байтах загрузочного сектора :)
                Пример: github.com/Darksecond/512 (вроде есть и другие подобные примеры)
                и вроде игра змейка в загрузочном секторе.
                  0
                  Привет, автор. Спасибо за статью — обязательно опробую на выходных! Есть пару вопросов, первый про формулировку «у которого последние 2 байта первого сектора (т.е. первые 512 байт, т.к. 1 сектор = 512 байт) равны 55 и AA». Немного сбивает с толку, как по мне. Я бы немного уточнил, примерно так: «у которого последние 2 байта первого сектора (под первым сектором подразумеваются первые 512 байт жесткого диска, т.к. один сектор = 512 байт) равны 55 и AA». И ещё: в соответствии с вышеуказанной моделью памяти очень хорошо согласуется директива «org 0x7c00», однако в руководстве Nasm приводится пример с директивой «org 0». Что это? Упрощённый пример или же авторы документации ошиблись? Ссылочка на доку — www.nasm.us/doc/nasmdo12.html#section-12.1.3
                    0
                    1) Спасибо, поправил
                    2) В руководстве подразумевается, что дальше в коде мы самостоятельно будем смещать все наши указания на память. Ради интереса заменил директиву на «org 0» и вот что получил:

                    image

                    Ну, то есть, NASM вообще ничего не берет из наших переменных — мы просто указали не на тот участок в памяти. Если взять адрес переменной за b, то в случае org 0 NASM посмотрит в память по адресу 0:b, а с org 0x7c00 — в память по адресу 0x7c00:b (это уже сегментная адресация памяти, которой в статье не было)
                      0
                      Спасибо, надеюсь увидеть ещё статьи подобного толка на Хабре. Эта получилась достаточно простая для понимания и проверки, и вместе с этим достаточно глубокая по смыслу. Кстати, для меня было открытием, что Nasm умеет собирать бинарники вообще без мета-данных и заголовков. Только чистые инструкции. Я думал, что эта фича есть только в Фасм =)

                  Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                  Самое читаемое