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

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

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

      +4
      Продолжайте, чёрт возьми! :)
        0

        +1, тысяча чертей!

          0
          В текущем состоянии моего кода материала на 4-5 статей должно хватить, так что будут еще :) Пока перерыв от разработки и переваривание информации.
        +3
        и имеет размер 80*25 байт

        Эм, 80*25*2 байт. Байт на символ и байт на атрибуты (цвет).
          0
          Благодарю, поправил. Я использую структуру для символа, их как раз у меня 80*25, поэтому отложилось так :)
          #[derive(Debug, Clone, Copy, PartialEq, Eq)]
          pub struct ColorCode(u8);
          
          #[derive(Debug, Clone, Copy, PartialEq, Eq)]
          #[repr(C)]
          struct ScreenChar {
              ascii_character: u8,
              color_code: ColorCode,
          }
          
          0
          outb(0x3D5, (position & 0xFF) as u8)

          Интересно, а транслятор сможет это оптимизировать в
          out dx, al
          Без наложения маски и инлайном? Я не к тому, что это надо, просто интересно.

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

          Или в локальной LDT.
          Если мы собираемся использовать не плоские глобальные сегменты, а выдавать каждой задаче свои, то места в GDT всем не хватит. Разместив дескрипторы в LDT (скажем, код-данные-стек-служебное что-то), мы займем в GDT только одну ячейку и 4 — в LDT данной задачи.

            0
            Интересно, а транслятор сможет это оптимизировать

            Думаю, что не сможет — я же не встраиваю ассемблер в rs-файлы и не пишу интринсики. Более того, у меня опасные ассемблерные методы обернуты в безопасные методы Rust, поэтому там на два вызова больше. А линкер скорее всего такой оптимизацией не занимается. Хотя как знать.

            Или в локальной LDT.
            Если мы собираемся использовать не плоские глобальные сегменты, а выдавать каждой задаче свои, то места в GDT всем не хватит. Разместив дескрипторы в LDT (скажем, код-данные-стек-служебное что-то), мы займем в GDT только одну ячейку и 4 — в LDT данной задачи.

            Где-то я встречал утверждение, что современные компиляторы не могут в сегментную модель, только в плоскую. Я не берусь утверждать, что оно верное, но около 100% программ собираются под плоскую модель. Так что да, можно. Но я не увидел смысла. И даже можно было бы запускать плоские программы в сегментированной памяти — как вы описываете, выделяя по одному сегменту каждой задаче, но опять же — зачем, если никто так не делает, и пишут, что это устаревшая система.
              0
              Просто отметил, что так тоже можно — при написании хоббийных ос бывают нестандартные ходы.
          • НЛО прилетело и опубликовало эту надпись здесь
              +2
              const fn еще не в релизе

              Так они уже стабильны, правда не очень много в с ними можно делать
              https://blog.rust-lang.org/2019/02/28/Rust-1.33.0.html

                +2
                Похоже, я пропустил. Писал код когда еще не были стабильны. Надо освежить голову и пересмотреть код. Спасибо.
                0
                Интересный цикл статей, я как раз несколько лет вынашивал желание сделать что-то подобное, и в последний месяц оно начало реализовываться. Rust мне не интересен и даже противен, но все «железные» вещи из ваших статей должно быть легко перенести на другие языки.

                А загрузка у вас через MBR? UEFI не изучали? Там всё совсем по-другому, и если материнка не поддерживает legacy BIOS режим, вы не сможете загрузиться.
                P. S. Перечитал ещё раз первую статью (на этот раз — включив мозг), и понял, что эту работу за вас выполняет GRUB. Поправьте, пожалуйста, если всё равно неправильно понимаю.
                  0

                  Да, все через GRUB. Начальная загрузка пока для меня выглядит сложной, так как я ещё не разобрался на приличном уровне с носителями и файловыми системами, чтобы корректно прочитать и загрузить образ. Думаю, что когда доберусь до реализации работы с HDD, можно будет и в сторону своего загрузчика посмотреть

                    0
                    С MBR на удивление не сложно именно загрузиться, даже с флэшки. Заморочки, как я понял, только на флопиках были, там какая-то дополнительная структура нужна. Сложности и челленджи дальше с инициализацией защищённого режима, памяти и т. п.
                      +1
                      С MBR на удивление не сложно именно загрузиться, даже с флэшки. Заморочки, как я понял, только на флопиках были, там какая-то дополнительная структура нужна.

                      Везде одинаково: BIOS грузит нулевой сектор по 7c00h и передает туда управление. Все остальное — личное интимное дело конкретной системы.
                  0
                  Решил просто сказать спасибо(плюсануть сейчас не могу, к сожалению). :)

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

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