[Из песочницы] Простейшая командная строка на 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 байт в оперативную память по адресу 0×7c00 и передаст управление процессору по этому адресу.

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

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

image

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

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

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

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

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


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

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

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’у, что мы выполняем код, начиная с адреса 0×7c00. Это позволяет ему автоматически смещать все адреса относительно этого адреса, чтобы мы не делали это явно.
(2). Эта команда дает указание NASM’у, что мы работаем в 16-битном режиме.
(3). При запуске QEMU печатает на экране много ненужной нам информации. Для этого устанавливаем в ah 0×00, в al 0×03 и вызываем 0×10, чтобы очистить экран от всего.
(4). Чтобы сохранять регистры в стеке, необходимо указать, по какому адресу будет находиться его вершина с помощью указателя стека SP. SP будет указывать на область в памяти, в которую будет записано очередное значение. Добавляем значение на стек — SP спускается вниз по памяти на 2 байта (т.к. мы в Real Mode, где все операнды регистра — 16-битные, т.е. двухбайтные, значения). Мы указали 0×7c00, поэтому значения в стеке будут сохраняться прямо возле нашего кода в памяти. Еще раз — стек растет вниз (!). Это значит, что чем больше значений будет в стеке, тем на меньшую память будет указывать указатель стека 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                  ; get the ASCII code

    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                  ; get the ASCII code

    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

© Habrahabr.ru