Взаимодействие с устройствами
-
Port-mapped IO
Отдельный механизм для обмена данными с устройствами, инициируемого специальными инструкциями процессора.
Работа с портами: инструкции in, out.
Например, PC-совместимая клавиатура позволяла считывать через порт 0x60 сканкоды, соответствующие последней нажатой или отпущенной клавише:
in al, 0x60 # read scancode
Сканкод | Значение |
---|---|
0x01 | нажали Esc |
0x02 | нажали 1 |
0x03 | нажали 2 |
... | |
0x81 | отпустили Esc |
0x82 | отпустили 1 |
... |
Memory-mapped IO
С точки зрения процессора, ввод-вывод выглядит как чтение и запись обычной памяти.
Например, текстовый режим VGA 80×25:
char* const video_memory = (unsigned short*) 0xb8000;
// Output a gray-on-black digit 4 at row 0, column 42
video_memory[42] = 0x0734; // 0x34 == '4', 07 == gray on black
Проблема: необходимость перодически опрашивать устройства.
Загрузка компьютера с BIOS
(сейчас на вашем x86-совместимом компьютере UEFI, но идея та же)
Часть адресов RAM отображена на ROM, где лежит firmware.
Структура адресуемой памяти x86
При включении компьютера процессор работает в режиме совместимости с IBM PC (16-битный real mode), исполнение начинается по адресу 0xFFFF0 (reset vector).
Загрузка с диска
BIOS инициализирует и тестирует оборудование компьютера, а затем читает с загрузочного диска первый сектор (512 байт) и передаёт ему управление.
Учебная ОС yabloko
Учебную операционную систему мы запускаем в эмуляторе. Скрипт установки под macOS и Linux находится в файле setup.sh
.
Среди файлов есть mbr.S
(master boot record). Он должен умещаться в 512 байт и будет складываться в первый сектор эмулируемого жесткого диска.
DRIVERS
В drivers/port.h
лежат обертки port-mapped IO для языка C, написанные в виде ассемблерных вставок:
static inline unsigned char port_byte_in(unsigned short port) {
unsigned char result;
// инструкция in, берет номер порта из регистра %dx, а кладёт результат в регистр %al
__asm__("in %%dx, %%al" : "=a" (result) : "d" (port));
return result;
}
Нам нужно что-то печатать на экран, для этого есть drivers/vga.h
и drivers/vga.с
для работы со стандартным графическим адаптером VGA. Когда компьютер загружается, экран работает в текстовом режиме и содержит 25 строк и 80 столбцов. Линейно лежат пары байт: в одном — символ, в другом — цвет фона и цвет самого символа. Символы нумеруются построчно начиная с левого верхнего края экрана.
char* const video_memory = (char*) 0xb8000; // видео-память отображена по адресу 0xb8000 (memory-mapped IO)
void vga_set_char(unsigned offset, char c) {
video_memory[2 * offset] = c; // сам символ
video_memory[2 * offset + 1] = get_color(light_gray, black); // цвет; пишем светло-серым по черному
}
Так как мы не всегда будем пользоваться эмулятором в графическом режиме (не всегда будет настоящий монитор), необходимо эмулировать также последовательный порт, способный передавать байты. Код в drivers/uart.h
и drivers/uart.c
KERNEL
Посмотрим на kernel.c
:
void _start() {
uartinit(); // запускаем последовательный порт
vga_clear_screen(); // очищаем экран
printk("\nYABLOKO\n"); // выводим текст
asm("hlt"); // остановиться и ничего не делать
}
Заметим несколько вещей в kernel.c
:
- Функция
vga_clear_screen()
заполняет весь экран пробелами. printk("\nYABLOKO\n")
выводит текст не только на экран, но и на последовательный порт. На данный момент у нас не реализованы прокрутка экрана и курсор; мы не умеем работать с клавиатурой и вводом.- У нас нет способа выключить компьютер; нам придется делать это платформо-специфичным путём с помощью
qemu_shutdown()
Существует две реализации языка C:
- Hosted реализация подразумевает, что есть операционная система, есть стандартная библиотека языка C и реализованные в ней функции. На эту реализацию мы рассчитываем при запуске компилятора gcc.
- Freestanding реализация используется для ядра, подразумевает, что мы работаем без операционной системы, сами организуем загрузку бинарника и начало исполнения в нужной точке. Особенность freestanding режима — отсутствие функции main(); пишем _start — это стандартное название для точки входа в ELF-бинарник. При сборке передаем опцию
-ffreestanding
, чтобы работать именно с этой реализацией.
Так же важный момент сборки -Ttext 0x1000
— мы указываем, с какого адреса начинается секция text
. В адресуемой памяти x86 этот адрес как раз попадает в диапазон usable memory.
MASTER BOOT RECORD
BIOS читает загрузчик в память по адресу 0x7c00 и на этот же адрес передает выполнение. Задача — скомпоновать mbr так, чтобы он с этого адреса начинался.
Чтобы запустить под отладчиком целый компьютер, нужен следующий кусок в Makefile
debug-boot-nox: image.bin mbr.elf
qemu-system-i386 -nographic -drive format=raw,file=$< **-s -S** & // "взаимодействуй с отладчиком и подожди, пока отладчик подключится"
$(GDB) mbr.elf \
-ex "set architecture i8086" \ // архитектура 8086 (16-битная архитектура)
-ex "target remote localhost:1234" \ // эмулятор по адресу localhost:1234 запустит gdb сервер и будет ждать, когда подключится отладчик
-ex "break *0x7c00" \ // breakpoint на адрес входа в загрузчик
-ex "continue" // продолжить исполнение
Посмотрим ближе на mbr.S под отладчиком:
.code16
.global _start
_start:
// по договоренностям в %dl BIOS передает загрузчику номер диска,
// с которого он загрузчик прочитал; поэтому этот регистр сохраняется в boot_drive
mov %dl, boot_drive
mov $banner, %si
call print_string // посимвольная печать banner
call get_drive_geometry
call load_kernel
call switch_to_32bit
hlt
jmp . // loop forever
Для ввода-вывода в загрузчике используются функции BIOS. Пример можно увидеть в print_string
: в регистр %ah
кладётся магическое значение 0x0e, а в %al
кладется символ; при вызове int $0x10
символ печатается на экран. Следующий код печатает посимвольно строку:
.code16
print_string:
mov $0x0e, %ah // "teletype output"
repeat:
lodsb // equivalent to mov (%si), %al; inc %si
test %al, %al
je done
int $0x10 // bios interrupt
jmp repeat
done:
ret
В fs/fs.h
описана структура файловой системы: первый сектор она использует для собственных нужд, со следующего сектора начинается первый файл. Пока что у нас один файл kernel.bin, он находится начиная с третьего сектора диска.
/* Directory structure:
32-byte entries
┌───────────────────────────────┐
│Reserved │
│char[32] │
├──────┬──────┬────────┬────────┤
│Offset│Size │Reserved│Name │
│uint32│uint32│uint32 │char[20]│
├──────┼──────┼────────┼────────┤
│ ... │ │ │ │
Offset is in sectors (zero-based),
size is in bytes, name is 0-terminated.
*/
Результирующий образ диска состоит из загрузочного сектора и файловой системы.
Так как нет встроенного драйвера жесткого диска, мы читаем с диска секторы с ядром с помощью BIOS, как и загрузчик. Реализация находится в функции load_kernel
в mbr.S
. Результат работы этой функции:
- В памяти оказывается ядро (то, что идёт после заголовка в ELF-файле)
- В переменной entry оказывается адрес точки входа, на которую мы позже будем передавать управление.
Чтобы загрузить ELF-файл нам надо прочитать программные заголовки и для каждого из них загрузить секцию по соответствующему адресу. Но у нас это реализовано по-другому: мы рассчитываем, что ELF-файл можно загрузить в память одним куском без разграничения доступа, защиты памяти. Мы также находимся в 16-битном режиме и используем функции BIOS (см. int 0x13
)
После load_kernel
мы переходим в 32-битный режим, так как ядро мы собирали как
32-битный бинарник. Поскольку IBM PC умел адресовать только 1МБ памяти, у него
было 20 дорожек на адресной шине (\(2^{20}\) байт = 1 Мбайт): A0-A19. С появлением
компьютеров, способных адресовать большее количество памяти, возникла проблема
совместимости: старый софт рассчитывал, что в адресе больше 1МБ старшие биты
адреса обрезаются. Для того чтобы вывести компьютер из этого режима
совместимости, нужно провести некоторые манипуляции (первые две строки функции):
switch_to_32bit:
mov $2, %al
out %al, $0x92 // enable A20
cli // 1. disable interrupts
// в процессоре есть специальный регистр, указывающий на gdt, и мы загружаем таблицу в него:
lgdt gdt_descriptor // 2. load GDT descriptor
// меняем младший бит в регистре %cr0; так мы переходим из real mode в protected
mov %cr0, %eax
or $1, %eax // 3. enable protected mode
mov %eax, %cr0
// мы не можем загрузить %cs напрямую с помощью mov, поэтому пользуемся long jump - он загружает %cs и %eip
ljmp $SEG_KCODE << 3, $init_32bit // 4. far jump
Сегментные регистры cs, ds, cs, ds, ss, es, fs, gs теперь означают не смещение в памяти, а индекс в таблице дескрипторов (Global Descriptor Table) — структуре данных, где указаны сегменты и их особенности (например, код ядра должен исполняться только на высоком уровне привилегий; старый 16-битный код должен исполняться в режиме совместимости). Таблицу дескрипторов можно увидеть ниже:
gdt_start:
.quad 0x0 // null descriptor, необходимость
SEG_ASM(STA_X|STA_R, 0x0, 0xffffffff) // code seg, доступный на чтение и исполнение, адрес от 0x0 до 0xffffffff
SEG_ASM(STA_W, 0x0, 0xffffffff) // data seg, доступный на запись (и автоматически на чтение)
gdt_end:
То, как раскрываются макросы, можно посмотреть в cpu/gdt.h
Переключившись в 32-битный режим, мы должны создать стек и переключиться на исполнение кода из kernel.c
:
mov $KERN_STACK_BASE, %ebp // создание стека
mov %ebp, %esp
movzwl entry, %esi // сохраненный адрес точки входа
call *%esi // переключение на исполнение кода ядра