Assembler
System Programming
Rust
CPU
March 27

OS1: примитивное ядро на Rust для x86. Часть 2. VGA, GDT, IDT

Первая часть


Первая статья еще не успела остыть, а я решил не держать вас в интриге и написать продолжение.


Итак, в предыдущей статье мы поговорили о линковке, загрузке файла ядра и первичной инициализации. Я дал несколько полезных ссылок, рассказал, как размещается загруженное ядро в памяти, как соотносятся виртуальные и физические адреса при загрузке, а так же как включить поддержку механизма страниц. В последнюю очередь управление перешло в функцию kmain моего ядра, написанного на Rust. Пришло время двигаться дальше и узнать, насколько глубока кроличья нора!


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


Настройка Rust


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


Некоторые фичи, необходимые для низкоуровневой разработки, стабильный Rust все еще не поддерживает, поэтому, чтобы отключить стандартную библиотеку и собираться на Bare Bones, нам необходим Rust nightly. Будьте внимательны, как-то раз после обновления до latest я получил полностью нерабочий компилятор и пришлось откатываться до ближайшей стабильной. Если вы уверены, что вчера ваш компилятор работал, а обновился и не работает — выполните команду, подставив нужную вам дату


rustup override add nightly-YYYY-MM-DD

За деталями механизма можно обратиться сюда.


Далее настроим целевую платформу, под которую будем собираться. Я основывался на блоге Филиппа Оппермана, поэтому многие вещи в этом разделе взяты у него, разобраны по косточкам и адаптированы под мои нужды. Филипп в своем блоге разрабатывает для x64, я же изначально выбрал x32, поэтому мой target.json будет несколько отличаться. Привожу его полностью


{
    "llvm-target": "i686-unknown-none",
    "data-layout": "e-m:e-p:32:32-f64:32:64-f80:32-n8:16:32-S128",
    "arch": "x86",
    "target-endian": "little",
    "target-pointer-width": "32",
    "target-c-int-width": "32",
    "os": "none",
    "executables": true,
    "linker-flavor": "ld.lld",
    "linker": "rust-lld",
    "panic-strategy": "abort",
    "disable-redzone": true,
    "features": "-mmx,-sse,+soft-float"
  }

Самое сложное здесь — параметр “data-layout”. Документация LLVM говорит нам, что это параметры раскладки данных, разделенные “-”. Самый первый символ “e” отвечает за индианность — в нашем случае это little-endian, как того требует платформа. Второй символ — m, “искажение”. Отвечает за имена символов при компоновке. Так как наш выходной формат будет ELF (смотри скрипт компоновки), мы выбираем значение “m:e”. Третий символ — размер указателя в битах и ABI (Application binary interface). Тут все просто, у нас 32 бита, так что смело ставим “p:32:32”. Далее — числа с плавающей точкой. Мы сообщаем, что поддерживаем 64-разрядные числа по ABI 32 с выравниванием 64 — “f64:32:64”, а также 80-ти разрядные числа с выравниванием по умолчанию — “f80:32”. Следующий элемент — целые числа. Начинаем с 8 бит и двигаемся к максимуму платформы в 32 бита — “n8:16:32”. Последний — выравнивание стека. Мне нужны даже 128 разрядные целые, так что пусть будет S128. В любом случае, LLVM этот параметр может смело проигнорировать, это наше предпочтение.


По поводу остальных параметров можно подсмотреть у Филиппа, он хорошо все объясняет.


Еще нам понадобится cargo-xbuild — инструмент, который позволяет делать кросс-компиляцию rust-core при сборке под незнакомую платформу target.
Устанавливаем.


cargo install cargo-xbuild

Собирать будем вот так.


cargo xbuild -Z unstable-options --manifest-path=kernel/Cargo.toml --target kernel/targets/$(ARCH).json --out-dir=build/lib

Указание манифеста мне понадобилось для корректной работы Make, так как он запускается из корневого каталога, а ядро лежит в каталоге kernel.


Из особенностей манифеста могу выделить только crate-type = ["staticlib"], который дает на выходе линкуемый файл. Его мы в дальнейшем скормим в LLD.


kmain и первоначальная настройка


Согласно соглашениям Rust, если мы создаем статическую библиотеку (или “плоский” бинарный файл), в корне крэйта должен находиться файл lib.rs, который является точкой входа. В нем с помощью атрибутов настраиваются фичи языка, а также располагается заветная kmain.


Итак, на первом шаге нам понадобится отключить std-библиотеку. Это делается макросом


#![no_std]

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


Многие туториалы где-то на этом месте и заканчиваются, выводя “Hello World” и не объясняя как же жить дальше. Мы пойдем другим путем. В первую очередь, нам нужно задать сегменты кода и данных для защищенного режима, настроить VGA, настроить прерывания, чем мы и займемся.


#![no_std]

#[macro_use]
pub mod debug;

#[cfg(target_arch = "x86")]
#[path = "arch/i686/mod.rs"]
pub mod arch;

#[no_mangle]
extern "C" fn kmain(pd: usize, mb_pointer: usize, mb_magic: usize) {
    arch::arch_init(pd);
......
}

#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    println!("{}", _info);
    loop {}
}

Что здесь происходит? Как я уже сказал, мы отключаем стандартную библиотеку. Еще мы объявлем два очень важных модуля — debug (в котором будем писать на экран) и arch (в котором будет жить вся платформозависимая магия). Я использую фичу Rust с конфигурациями, чтобы в разных архитектурных реализациях объявить одинаковые интерфейсы и использовать их на полную катушку. Здесь я останавливаюсь только на x86 и дальше говорим только о нем.


Я объявил совершенно примитивный panic handler, наличия которого требует Rust. Потом можно будет его дорабатывать.


kmain принимает три аргумента, а также экспортируется в нотации Си без искажения имени, чтобы линкер смог корректно связать функцию с вызовом из _loader, который я описывал в предыдущей статье. Первый аргумент — адрес таблицы страниц PD, второй — физический адрес структуры GRUB, откуда мы будем доставать карту памяти, третий — магическое число. В будущем я бы хотел реализовать как поддержку Multiboot 2, так и собственный загрузчик, поэтому использую магическое число для идентификации способа загрузки.


Первый же вызов kmain — платформозависимая инициализация. Идем внутрь. Функция arch_init располагается в файле arch/i686/mod.rs, публична, специфична для платформы x86 в 32 бит, и выглядит так:


pub fn arch_init(pd: usize) {
    unsafe {
        vga::VGA_WRITER.lock().init();
        gdt::setup_gdt();
        idt::init_idt();
        paging::setup_pd(pd);
    }
}

Как можно увидеть, для x86 по порядку инициализируется вывод, сегментация, прерывания и страничная организация памяти. Начнем с VGA.


Инициализация VGA


Каждый туториал считает своим долгом напечатать Hello World, поэтому как работать с VGA вы найдете везде. По этой причине пройдусь максимально кратко, остановлюсь только на фишках, которые сделал сам. По использованию lazy_static отправлю вас в блог Филиппа и не буду детально разъяснять. const fn еще не в релизе, поэтому красиво статические инициализации сделать пока нельзя. А еще добавим спин-блокировку, дабы не получилась полная каша.


use lazy_static::lazy_static;
use spin::Mutex;

lazy_static! {
    pub static ref VGA_WRITER : Mutex<Writer> = Mutex::new(Writer {
            cursor_position: 0,
            vga_color: ColorCode::new(Color::LightGray, Color::Black),
            buffer: unsafe { &mut *(0xC00B8000 as *mut VgaBuffer) }
        });
}

Как известно, буфер экрана находится по физическому адресу 0xB8000 и имеет размер 80x25x2 байт (ширина и высота экрана, по байту на символ и атрибуты: цвета, мерцание). Так как мы уже включили виртуальную память, обращение по этому адресу вызовет крах, поэтому добавляем 3 ГБ. Также мы разыменовываем сырой указатель, что небезопасно — но мы ведь знаем, что делаем.
Из интересного в этом файле пожалуй только реализация структуры Writer, которая позволяет не только выводить символы подряд, но и делать скроллинг, переход в любое место экрана и прочую приятную мелочь.


VGA Writer
pub struct Writer {
    cursor_position: usize,
    vga_color: ColorCode,
    buffer: &'static mut VgaBuffer,
}

impl Writer {
    pub fn init(&mut self) {
        let vga_color = self.vga_color;
        for y in 0..(VGA_HEIGHT - 1) {
            for x in 0..VGA_WIDTH {
                self.buffer.chars[y * VGA_WIDTH + x] = ScreenChar {
                    ascii_character: b' ',
                    color_code: vga_color,
                }
            }
        }
        self.set_cursor_abs(0);
    }

    fn set_cursor_abs(&mut self, position: usize) {
        unsafe {
            outb(0x3D4, 0x0F);
            outb(0x3D5, (position & 0xFF) as u8);
            outb(0x3D4, 0x0E);
            outb(0x3D4, ((position >> 8) & 0xFF) as u8);
        }
        self.cursor_position = position;
    }

    pub fn set_cursor(&mut self, x: usize, y: usize) {
        self.set_cursor_abs(y * VGA_WIDTH + x);
    }

    pub fn move_cursor(&mut self, offset: usize) {
        self.cursor_position = self.cursor_position + offset;
        self.set_cursor_abs(self.cursor_position);
    }

    pub fn get_x(&mut self) -> u8 {
        (self.cursor_position % VGA_WIDTH) as u8
    }

    pub fn get_y(&mut self) -> u8 {
        (self.cursor_position / VGA_WIDTH) as u8
    }

    pub fn scroll(&mut self) {
        for y in 0..(VGA_HEIGHT - 1) {
            for x in 0..VGA_WIDTH {
                self.buffer.chars[y * VGA_WIDTH + x] = self.buffer.chars[(y + 1) * VGA_WIDTH + x]
            }
        }
        for x in 0..VGA_WIDTH {
            let color_code = self.vga_color;
            self.buffer.chars[(VGA_HEIGHT - 1) * VGA_WIDTH + x] = ScreenChar {
                ascii_character: b' ',
                color_code
            }
        }
    }

    pub fn ln(&mut self) {
        let next_line = self.get_y() as usize + 1;
        if next_line >= VGA_HEIGHT {
            self.scroll();
            self.set_cursor(0, VGA_HEIGHT - 1);
        } else {
            self.set_cursor(0, next_line)
        }
    }

    pub fn write_byte_at_xy(&mut self, byte: u8, color: ColorCode, x: usize, y: usize) {
        self.buffer.chars[y * VGA_WIDTH + x] = ScreenChar {
            ascii_character: byte,
            color_code: color
        }
    }

    pub fn write_byte_at_pos(&mut self, byte: u8, color: ColorCode, position: usize) {
        self.buffer.chars[position] = ScreenChar {
            ascii_character: byte,
            color_code: color
        }
    }

    pub fn write_byte(&mut self, byte: u8) {
        if self.cursor_position >= VGA_WIDTH * VGA_HEIGHT {
            self.scroll();
            self.set_cursor(0, VGA_HEIGHT - 1);
        }
        self.write_byte_at_pos(byte, self.vga_color, self.cursor_position);
        self.move_cursor(1);
    }

    pub fn write_string(&mut self, s: &str) {
        for byte in s.bytes() {
            match byte {
                0x20...0xFF => self.write_byte(byte),
                b'\n' => self.ln(),
                _ => self.write_byte(0xfe),
            }
        }
    }
}

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


  • Выводится абсолютное смещение курсора, а не координаты
  • Выводить в контроллер можно по одному байту за раз
  • Вывод одного байта происходит в две команды — сначала пишем команду контроллеру, потом данные.
  • Порт для команд — 0x3D4, порт для данных — 0x3D5
  • Сначала выводим нижний байт положения командой 0x0F, затем верхний командой 0x0E

out.asm

Обратите внимание на работу с переданными переменными в стеке. Так как стек начинается с конца пространства и уменьшает указатель стека при вызове функции, чтобы получить параметры, точку возврата и прочее, к регистру ESP необходимо добавлять размер аргумента, выровненный на выравнивание стека — в нашем случае 4 байта.


global writeb
global writew
global writed
section .text

writeb:
    push ebp
    mov ebp, esp
    mov edx, [ebp + 8] ;port in stack: 8 = 4 (push ebp) + 4 (parameter port length is 2 bytes but stack aligned 4 bytes)
    mov eax, [ebp + 8 + 4] ;value in stack - 8 = see ^, 4 = 1 byte value aligned 4 bytes
    out dx, al ;write byte by port number an dx - value in al

    mov esp, ebp
    pop ebp
    ret

writew:
    push ebp
    mov ebp, esp

    mov edx, [ebp + 8] ;port in stack: 8 = 4 (push ebp) + 4 (parameter port length is 2 bytes but stack aligned 4 bytes)
    mov eax, [ebp + 8 + 4] ;value in stack - 8 = see ^, 4 = 1 word value aligned 4 bytes
    out dx, ax ;write word by port number an dx - value in ax

    mov esp, ebp
    pop ebp
    ret

writed:
    push ebp
    mov ebp, esp

    mov edx, [ebp + 8] ;port in stack: 8 = 4 (push ebp) + 4 (parameter port length is 2 bytes but stack aligned 4 bytes)
    mov eax, [ebp + 8 + 4] ;value in stack - 8 = see ^, 4 = 1 double word value aligned 4 bytes
    out dx, eax ;write double word by port number an dx - value in eax

    mov esp, ebp
    pop ebp
    ret

Настройка сегментов


Мы подобрались к самой головоломной, но в то же время самой простой теме. Как я уже говорил в предыдущей статье, в моей голове смешались страничная и сегментная организация памяти, я загружал адрес таблицы страниц в GDTR и хватался за голову. Мне потребовалось несколько месяцев, чтобы вдоволь начитаться материала, переварить его и суметь осознать. Возможно, я пал жертвой учебника Питера Абеля “Ассемблер. Язык и программирование для IBM PC” (великолепная книга!), в которой описана сегментация для Intel 8086. В те приятные времена мы загружали в сегментный регистр верхние 16 бит двадцатиразрядного адреса, и это был именно адрес в памяти. Жестоким разочарованием оказалось, что начиная с i286 в защищенном режиме все совсем не так.


Итак, голая теория гласит, что x86 поддерживает сегментную модель память, так как старые программы только так могли вырваться за пределы 640 КБ, а потом и 1 МБ памяти.


Программистам приходилось думать, как размещать исполняемый код, как размещать данные, как соблюдать их безопасность. Приход страничной организации сделал сегментную организацию ненужной, но она осталась с целью совместимости и защиты (разделения привилегий на kernel-space и user-space), так что без нее просто никуда. Некоторые инструкции процессора запрещены при уровне привилегий слабее 0, а доступ между сегментами программ и ядра вызовет ошибку сегментации.


Давайте еще раз (надеюсь в последний) о преобразовании адресов
Линейный адрес [0x08:0xFFFFFFFF] -> Проверка прав сегмента 0x08 -> Виртуальный адрес [0xFFFFFFFF] -> Таблица страниц + TLB -> Физический адрес [0xAAAAFFFF]


Сегмент используется только внутри процессора, хранится в специальном сегментном регистре (CS, SS, DS, ES, FS, GS) и используется исключительно для проверки прав выполнения кода и передачи управления. Именно поэтому нельзя просто так взять и вызвать функцию ядра из пространства пользователя. Сегмент с дескриптором 0x18 (у меня такой, у вас другой) имеет права уровня 3, а сегмент с дескриптором 0x08 имеет права уровня 0. Согласно конвенции x86, для защиты от несанкционированного доступа, сегмент с меньшими правами доступа не может напрямую вызвать сегмент с большими правами через jmp 0x08:[EAX], а обязан использовать другие механизмы, такие как трапы, гейты, прерывания.


Сегменты и их типы (код, данные, трапы, гейты) должны быть описаны в глобальной дескрипторной таблице GDT, виртуальный адрес и размер которой загружается в регистр GDTR. При переходе между сегментами (для упрощения, я допущу, что прямой переход возможен) необходимо вызвать инструкцию jmp 0x08:[EAX], где 0x08 — смещение первого валидного дескриптора в байтах от начала таблицы, а EAX — регистр, содержащий адрес перехода. Смещение (селектор) будет загружен в регистр CS, а соответствующий ему дескриптор — в теневой регистр процессора. Каждый дескриптор — структура размером 8 байт. Она хорошо задокументирована и ее описание можно найти как на OSDev, так и в документации Intel (см. первую статью).


Резюмирую. Когда мы инициализируем GDT и выполним переход jmp 0x08:[EAX], состояние процессора будет следующим:


  • GDTR содержит виртуальный адрес GDT
  • CS содержит значение 0x08
  • В теневой регистр CS из памяти скопирован дескриптор по адресу [GDTR + 0x08]
  • Регистр EIP содержит адрес из регистра EAX

Нулевой дескриптор всегда должен быть неинициализирован и обращение по нему запрещено. На дескрипторе TSS и его значении я остановлюсь подробнее когда будем обсуждать многопоточность. Сейчас моя таблица GDT выглядит следующим образом:


extern {
    fn load_gdt(base: *const GdtEntry, limit: u16);
}

pub unsafe fn setup_gdt() {
    GDT[5].set_offset((&super::tss::TSS) as *const _ as u32);
    GDT[5].set_limit(core::mem::size_of::<super::tss::Tss>() as u32);
    let gdt_ptr: *const GdtEntry = GDT.as_ptr();
    let limit = (GDT.len() * core::mem::size_of::<GdtEntry>() - 1) as u16;
    load_gdt(gdt_ptr, limit);
}

static mut GDT: [GdtEntry; 7] = [
    //null descriptor - cannot access
    GdtEntry::new(0, 0, 0, 0),
    //kernel code
    GdtEntry::new(0, 0xFFFFFFFF, GDT_A_PRESENT | GDT_A_RING_0 | GDT_A_SYSTEM | GDT_A_EXECUTABLE | GDT_A_PRIVILEGE, GDT_F_PAGE_SIZE | GDT_F_PROTECTED_MODE),
    //kernel data
    GdtEntry::new(0, 0xFFFFFFFF, GDT_A_PRESENT | GDT_A_RING_0 | GDT_A_SYSTEM | GDT_A_PRIVILEGE, GDT_F_PAGE_SIZE | GDT_F_PROTECTED_MODE),
    //user code
    GdtEntry::new(0, 0xFFFFFFFF, GDT_A_PRESENT | GDT_A_RING_3 | GDT_A_SYSTEM | GDT_A_EXECUTABLE | GDT_A_PRIVILEGE, GDT_F_PAGE_SIZE | GDT_F_PROTECTED_MODE),
    //user data
    GdtEntry::new(0, 0xFFFFFFFF, GDT_A_PRESENT | GDT_A_RING_3 | GDT_A_SYSTEM | GDT_A_PRIVILEGE, GDT_F_PAGE_SIZE | GDT_F_PROTECTED_MODE),
    //TSS - for interrupt handling in multithreading
    GdtEntry::new(0, 0, GDT_A_PRESENT | GDT_A_RING_3 | GDT_A_TSS_AVAIL, 0),
    GdtEntry::new(0, 0, 0, 0),
];

А вот так выглядит инициализация, о которой я столько рассказывал выше. Загрузка адреса и размера GDT выполняется через отдельную структуру, которая содержит всего два поля. В команду lgdt передается именно адрес этой структуры. В регистры сегментов данных загружаем следующий дескриптор со смещением 0x10.


global load_gdt
section .text

gdtr dw 0 ; For limit storage
     dd 0 ; For base storage

load_gdt:
    mov   eax, [esp + 4]
    mov   [gdtr + 2], eax
    mov   ax, [esp + 8]
    mov   [gdtr], ax
    lgdt  [gdtr]
    jmp   0x08:.reload_CS
.reload_CS:
    mov   ax, 0x10 ; 0x10 points at the new data selector
    mov   ds, ax
    mov   es, ax
    mov   fs, ax
    mov   gs, ax
    mov   ss, ax

    mov ax, 0x28
    ltr ax

    ret

Дальше все будет немного легче, но не менее интересно.


Прерывания


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


Лирическое отступление о стиле кода.


Благодаря усилиям сообщества и конкретно Филиппа Оппермана, в Rust была добавлена конвенция вызовов x86-interrupt, которая позволяет писать обработчики прерываний, выполняющие iret. Однако я осознанно решил не идти этим путем, так как я решил разделять ассемблер и Rust по разным файлам, а значит и функциям. Да, я неразумно использую стековую память, осознаю это, но это все еще вкусовщина. Мои обработчики прерываний написаны на ассемблере и делают ровно одну вещь: вызывают почти одноименные обработчики прерываний, написанные на Rust. Пожалуйста, примите этот факт и отнеситесь снисходительно.


В целом, процесс инициализации прерываний похож на инициализацию GDT, но проще для понимания. С другой стороны, нужно много однообразного кода. Разработчики Redox OS делают красивое решение, используя все прелести языка, однако я пошел “в лоб” и решил допустить дублирование кода.


Согласно конвенции x86, у нас есть прерывания, а есть исключительные ситуации. В данном контексте настройки они для нас практически не отличаются. Единственное отличие состоит в том, что при срабатывании исключительной ситуации, в стеке может содержаться дополнительная информация. Например, я использую ее для обработки отсутствия страницы при работе с кучей (но всему свое время). И прерывания, и исключения обрабатываются из одной таблицы, которую нам с вами и нужно заполнить. Также необходимо запрограммировать PIC (Programmable Interrupt Controller). Есть еще APIC, но с ним я пока не разобрался.


По работе с PIC я не буду давать много комментариев, так как в сети много примеров по работе с ним. Начну с обработчиков в ассемблере. Они все совершенно однотипны, поэтому я уберу код под спойлер.


IRQ
global irq0
global irq1
......
global irq14
global irq15

extern kirq0
extern kirq1
......
extern kirq14
extern kirq15

section .text

irq0:
  pusha
  call kirq0
  popa
  iret

irq1:
  pusha
  call kirq1
  popa
  iret

......

irq14:
  pusha
  call kirq14
  popa
  iret

irq15:
  pusha
  call kirq15
  popa
  iret

Как можно заметить, все вызовы Rust функций начинаются с префикса “k” — для различия и удобства. Обработка исключений абсолютно аналогична. Для ассемблерных функций выбран префикс “e”, для Rust — “k”. Отличается обработчик Page Fault, но о нем — в заметках по управлению памятью.


Исключения
global e0_zero_divide
global e1_debug
......
global eE_page_fault
......
global e14_virtualization
global e1E_security

extern k0_zero_divide
extern k1_debug
......
extern kE_page_fault
......
extern k14_virtualization
extern k1E_security

section .text

e0_zero_divide:
    pushad
    call k0_zero_divide
    popad
    iret

e1_debug:
    pushad
    call k1_debug
    popad
    iret

......

eE_page_fault:
    pushad
    mov eax, [esp + 32]
    push eax
    mov eax, cr2
    push eax
    call kE_page_fault
    pop eax
    pop eax
    popad
    add esp, 4
    iret

......

e14_virtualization:
    pushad
    call k14_virtualization
    popad
    iret

e1E_security:
    pushad
    call k1E_security
    popad
    iret

Объявляем ассемблерные обработчики:


extern {
    fn load_idt(base: *const IdtEntry, limit: u16);

    fn e0_zero_divide();
    fn e1_debug();
......
    fn e14_virtualization();
    fn e1E_security();

    fn irq0();
    fn irq1();
......
    fn irq14();
    fn irq15();
}

Определяем Rust обработчики, которые вызываем выше. Обратите внимание, что для прерывания клавиатуры я просто вывожу полученный код, который получаю с порта 0x60 — так работает клавиатура в простейшем режиме. В дальнейшем это трансформируется в полноценный драйвер, надеюсь. После каждого прерывания нужно вывести в контроллер сигнал конца обработки 0x20, это важно! Иначе больше прерываний вы не получите.


#[no_mangle]
pub unsafe extern fn kirq0() {
    // println!("IRQ 0");
    outb(0x20, 0x20);
}

#[no_mangle]
pub unsafe extern fn kirq1() {
    let ch: char = inb(0x60) as char;
    crate::arch::vga::VGA_WRITER.force_unlock();
    println!("IRQ 1 {}", ch);
    outb(0x20, 0x20);
}

#[no_mangle]
pub unsafe extern fn kirq2() {
    println!("IRQ 2");
    outb(0x20, 0x20);
}

...

Инициализация IDT и PIC. Про PIC и его ремаппинг я нашел большое количество туториалов разной степени подробности, начиная с OSDev и заканчивая любительскими сайтами. Так как процедура программирования оперирует константной последовательностью операций и константными командами, приведу этот код без дальнейших пояснений. Обратите внимание только на то, что обработчики аппаратных прерываний занимает диапазон индексов 0x20-0x2F в таблице, и в функцию настройки передаются аргументы 0x20 и 0x28, которые как раз покрывают 16 прерываний в диапазоне IDT.


unsafe fn setup_pic(pic1: u8, pic2: u8) {
    // Start initialization
    outb(PIC1, 0x11);
    outb(PIC2, 0x11);

    // Set offsets
    outb(PIC1 + 1, pic1);   /* remap */
    outb(PIC2 + 1, pic2);   /*  pics */

    // Set up cascade
    outb(PIC1 + 1, 4);  /* IRQ2 -> connection to slave */
    outb(PIC2 + 1, 2);

    // Set up interrupt mode (1 is 8086/88 mode, 2 is auto EOI)
    outb(PIC1 + 1, 1);
    outb(PIC2 + 1, 1);

    // Unmask interrupts
    outb(PIC1 + 1, 0);
    outb(PIC2 + 1, 0);

    // Ack waiting
    outb(PIC1, 0x20);
    outb(PIC2, 0x20);
}

pub unsafe fn init_idt() {
    IDT[0x0].set_func(e0_zero_divide);
    IDT[0x1].set_func(e1_debug);
......
    IDT[0x14].set_func(e14_virtualization);
    IDT[0x1E].set_func(e1E_security);

    IDT[0x20].set_func(irq0);
    IDT[0x21].set_func(irq1);
......
    IDT[0x2E].set_func(irq14);
    IDT[0x2F].set_func(irq15);

    setup_pic(0x20, 0x28);

    let idt_ptr: *const IdtEntry = IDT.as_ptr();
    let limit = (IDT.len() * core::mem::size_of::<IdtEntry>() - 1) as u16;
    load_idt(idt_ptr, limit);
}

Таблицу прерываний загружаем в регистр IDTR совершенно аналогично GDTR — через дополнительную структуру с адресом и размером. Инструкцией STI разрешаем прерывания и можем пробовать нажимать клавиатуру — на экран будут выводиться кракозябры в позиции курсора — это сканкоды, напрямую преобразованные в символы, без ASCII-перехода и обработки скан-кодов.


global load_idt
section .text

idtr dw 0 ; For limit storage
     dd 0 ; For base storage

load_idt:
    mov   eax, [esp + 4]
    mov   [idtr + 2], eax
    mov   ax, [esp + 8]
    mov   [idtr], ax
    lidt  [idtr]

    sti

    ret

Послесловие


Что ж, эта статья получилась весьма объемной, поэтому про инициализацию памяти и управление ей я расскажу в следующий раз. Я краем кода зацепил функцию setup_pd, но рассказ про ее назначение и устройство оставлю на следующий заход. Пожалуйста, не стесняйтесь писать, что можно улучшить в содержании, в коде.


Исходный код по-прежнему доступен на GitLab.


Спасибо за внимание!


UPD: Часть 3

+66
7.1k 88
Support the author
Comments 16
Top of the day