[Из песочницы] Простейшая командная строка на NASM и QEMU
Итак, сразу к делу. Писать будем под 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 Мб памяти. Но вторым мы пользоваться здесь не будем. Картинка ниже показывает состояние оперативной памяти при передаче управления нашему коду (картинка взята отсюда).
Последнее, о чем стоит сказать перед практической частью — прерывания. Прерывание — это особый сигнал (например, от устройства ввода, такого, как клавиатура или мышь) процессору, который говорит, что нужно немедленно прервать исполнение текущего кода и выполнить код обработчика прерывания. Все адреса обработчиков прерывания находятся в 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
И получим на выходе: