Отправляет email-рассылки с помощью сервиса Sendsay
  Все выпуски  

Пишем свою операционную систему. Обработка прерываний


Доброго времени суток!

В предыдущем выпуске мы реализовали более-менее полноценный вывод текстовой информации на экран с возможностью управления позицией курсора и цветом текста, однако до сих пор пользователь никак не мог взаимодействовать с нашей ОС. Сегодня мы исправим этот недостаток, написав простой драйвер клавиатуры PS/2 (USB клавиатуры эмулируют PS/2, так что и они будут поддерживаться).

Прерывания

Нам уже известно два механизма взаимодействия процессора с остальными устройствами - через специальные регионы оперативной памяти (например, тот самый буфер текстового экрана по адресу 0xB8000 является примером этого механизма, который ещё называется Memory-Mapped Input Output - MMIO) и через порты ввода-вывода. Управление устройствами обеими способами всегда происходит только по инициативе процессора - что бы не случилось, процессор получит данные от устройства только когда явно выполнит чтение из памяти или порта. Чтобы отреагировать на внешнее событие (например, нажатие пользователем на клавишу клавиатуры, движение мышью, приход пакета по сети, окончание операции чтения с диска и т. д.) ему придётся непрерывно в цикле проверять значение регистра устройства, тратя на это достаточно много ресурсов. Для решения этой проблемы практически на всех существующих архитектурах процессоров был введён третий механизм взаимодействия с внешними устройствами - прерывания.

При поступлении прерывания (как правило, процессор узнаёт об этом по повышению уровня сигнала на одном из своих специальных выводов) процессор приостанавливает выполнение кода, сохраняет текущее значение CS, IP и FLAGS (пока рассмотрим работу в реальном режиме), извлекает из таблицы прерываний адрес обработчика и продолжает исполнение с него. Работа этого обработчика во многом аналогична работе функции (только в данном случае обязательно нужно сохранить все регистры, чтобы не нарушить ход выполнения прерванной программы), но вместо команды RET следует использовать IRET, которая в отличии от RETF, восстанавливает не только CS и IP, но и FLAGS.

Каждое прерывание характеризуется номером. Всего в архитектуре x86 существует 256 различных прерываний. Соответственно, таблица прерываний содержит адреса 256 обработчиков и каждый раз вызывается соответствующий номеру прерывания.

Прерывания можно разделить на два вида программные и аппаратные. Это деление базируется на способе вызова обработчика. В случае программного прерывания никакого сигнала от внешнего устройства не приходило, а в исполняемом коде встретилась инструкция INT, которая имеет единственный аргумент - номер прерывания, которое следует вызвать. Программные прерывания это один из способов реализации системных вызовов - приложение не может напрямую обратиться к коду ядра, но может выполнить эту команду. Параметры обычно в таком случае передаются в регистрах и какие из них надо сохранить зависит лишь о соглашении вызова функции. В реальном режиме, в котором нет разделения прав доступа кода, этот метод также используется, потому что позволяет обращаться к системным функциям (BIOS, DOS) не по адресу, который может меняться от версии к версии, а по постоянному номеру. При написании начального загрузчика мы использовали несколько сервисов BIOS, которые предоставлялись прерываниями с номерами 0x10 (управление экраном), 0x13 (работа с дисковой подсистемой), 0x15 (определение конфигурации оперативной памяти) и 0x16 (работа с клавиатурой).

Контроллер прерываний 

Сигналы от внешних устройств, прежде чем поступить в процессор, попадают в программируемый контроллер прерываний (Programmable Interrupt Controller - PIC), он транслирует номер IRQ (Interrupt Request) от устройства в номер прерывания процессора (не все 256 прерываний могут быть аппаратными). В случае с PIC (существует ещё его расширенная версия - Advanced Programmable Interrupt Controller - APIC, но мы пока его не рассматриваем) существует 16 IRQ. То есть прерывания могут приходить от 16 различных устройств.

На самом деле обработка прерывания немного более сложная - каждый PIC способен обрабатывать лишь 8 прерываний, поскольку этого мало в системе установлено два таких контроллера. Один обрабатывает IRQ с 0 по 7, а другой с 8 по 15. Второй подключен к выводу IRQ2 первого. Нам нет необходимости беспокоится о том, как они маршрутизируют прерывания, достаточно помнить, что надо настраивать сразу два PIC. Их настройка ОС обычно сводится к установке базового вектора IRQ (номер прерывания ещё называется вектором). Например, если у первого PIC он равен 0x20, а у второго 0x40, то IRQ0 проецируется в прерывание процессора 0x20, IRQ1 в 0x21, IRQ8 в 0x40, IRQ9 в 0x41 и т. д. Для удобства можно объединить прерывания IRQ в один блок - например, поставив базовый вектор прерывания у первого PIC в 0x20, а у второго в 0x28, тогда IRQ0-IRQ15 будут отображены в прерывания 0x20-0x2F соответственно. Чтобы не затруднять преобразование номера IRQ в номер прерывания мы так и поступим.

Исключения

Не все прерывания пораждаются командой INT или сигналом от устройства, у процессора есть свои внутренние прерывания, которые служат реакцией на различные события. Как правило это ошибки - деление на ноль, некорректная инструкция, доступ к несуществующей памяти (при страничной адресации), нарушение прав доступа - и т. д. Такие прерывания позволяют ОС обработать различные ошибки в коде приложения. Всего системных прерываний 32 штуки - с нулевого по 31-ое. Не все из них сейчас имеют смысл, часть зарезервированы для будущего расширение архитектуры.

Одно из самых важных исключений защищённого режима - 0x13 - General Protection Fault. Оно вызывается при неверных правах доступа (код приложения пытается обратиться к ядру), при обнаружении ошибки в системных структурах (например, попытке доступа к некорректному дескриптору сегмента), и наконец оно вызывается в случае, если для нужного прерывания нету обработчика.

Ещё одно важное исключение - 0x14 - General Page Fault - обработка ошибки страничной адресации. Возникает при обращении к непримонтированной странице, либо при обращении к странице с не теми правами доступа. При этом с CR2 помещается адрес, который вызвал исключение. Это позволяет реализовать механизм подкачки - ядро может проанализировав адрес понять, что страница была выгружена в своп файл и подгрузить её. После возврата из этого исключения выполнение продолжится с той же самой инструкции, поэтому приложение даже ничего не заметит.

Таким образом прерывания с 0 до 0x1F не следует использовать для своих собственных обработчиков (как и для IRQ). Пока мы можем не создавать обработчики исключений, если ядро таки совершит ошибку процессор будет просто перезагружен.

Таблица дескрипторов прерываний

В реальном режиме таблица прерываний представляет собой простой массив из 256 двойных слов (младшее слово - сегмент, старшее - смещение), располагающийся в первом килобайте оперативной памяти. В защищённом режиме эта таблица приобретает более сложный формат - это массив из 256 8-байтных дескрипторов, помимо адреса и селектора сегментна дескриптор содержит различные атрибуты прерывания. Можно представить дескриптор в виде такой структуры на Си:

typedef struct {
	uint16 address_0_15;
	uint16 selector;
	uint8 reserved;
	uint8 type;
	uint16 address_16_31;
} IntDesc;

Адрес обработчика хранится в виде двух частей - младшая половина в address_0_15, а старшая в address_16_31. Селектор в selector, поле reserved зарезервировано и должно быть равно нулю, а type обозначает тип обработчика. Пока нам хватит типа 0x8E, который отлично подходит для IRQ.

В отличии от реального режима, где адрес таблицы строго фиксирован (адреса 0x0000 - 0x03FF), в защищённом режиме адрес таблицы дескрипторов прерываний задаётся системой с помощью команды LIDT (Load Interrupt Descriptor Table), аналогично адресу GDT. Размер таблицы в данном случае должен быть 256 * 8 = 2048 байт.

Простейшая обработка прерываний

Реализуем простейшую обработку прерываний - загрузим значение в IDTR и разрешим прерывания от PIC (команда STI). На самом деле этого мало - нужно как минимум обрабатывать IRQ0, на котором висит таймер, генерирующий прерывания примерно 18,5 раз в секунду (он нужен для реализации вытесняющей многозадачности и подсчёта времени). Также нам не помешает перенастроить контроллеры PIC на новые адреса - по умолчанию IRQ0 приходит на 8-ое прерывание, но в защищённом режиме первые 32 прерывания заняты системными, поэтому лучше вынести IRQ повыше - пусть это будет блок адресов с 0x20 до 0x2F. Для начала напишем заголовочный файл interrupts.h с описанием прототипов функций:

#ifndef INTERRUPTS_H
#define INTERRUPTS_H

uint8 irq_base;
uint8 irq_count;

void init_interrupts();
void set_int_handler(uint8 index, void *handler, uint8 type);

#endif 

Помимо пары функций с говорящими названиями, мы описываем две переменных, служащих для определения другими модулями базового адреса и количества IRQ-прерываний, чтобы потом легко добавить поддержку APIC.

Файл interrupts.c будет начинаться с подключения необходимых заголовочных файлов и описания двух полезных типов данных, а также указателя на таблицу прерываний (разместим её перед стеком ядра):

#include "stdlib.h"
#include "interrupts.h"

typedef struct {
	uint16 address_0_15;
	uint16 selector;
	uint8 reserved;
	uint8 type;
	uint16 address_16_31;
} __attribute__((packed)) IntDesc;

typedef struct {
	uint16 limit;
	void *base;
} __attribute__((packed)) IDTR;

IntDesc *idt = (void*)0xFFFFC000;

У обеих структур присутствует специальный атрибут packed, который отключает для них выравнивание. Выравнивание нужно для ускорения доступа к памяти (лучше когда адрес скалярной переменной кратен её размеру), но в случае со служебными структурами не допустимо, чтобы смещения полей отклонялись от заданных. Например, без этого атрибута структура IDTR будет занимать не 6, а 8 байт, потому что base будет выравнен на 4 байта (сейчас он выравнен лишь на 2, хотя занимает 4). В результате структура не будет корректной. К сожалению, каждый компилятор предоставляет возможность отключить выравнивание разными способами, я показал вариант для GCC и MinGW, но он не подойдёт, например, для компилятора MSVC, для которого существует свой синтаксис.

Сначала напишем более простую функцию - set_int_handler:

void set_int_handler(uint8 index, void *handler, uint8 type) {
	asm("pushf \n cli");
	idt[index].selector = 8;
	idt[index].address_0_15 = (size_t)handler & 0xFFFF;
	idt[index].address_16_31 = (size_t)handler >> 16;
	idt[index].type = type;
	idt[index].reserved = 0;
	asm("popf"); 
}

Помимо собственно заполнения элемента таблицы прерываний эта функция отключает приём прерываний на время своей работы - вдруг прерывание произойдёт во время изменения его дескриптора, в этом случае результат может быть непредсказуемым (от простого исключения до передачи управления по произвольному адресу). Инструкция PUSHF сохраняет в стек старое значение регистра файлов, в POPF восстанавливает. Поскольку флаг обработки прерывания содержится в нём (и именно его меняют STI и CLI) мы восстановим старое значение обработки прерываний - если вызвать set_int_handler при запрещённых прерываниях, он не разрешит их.

Начнём писать функции init_interrupts - первым делом надо спроецировать таблицу прерываний в виртуальное адресное пространство и загрузить правильное значение в IDTR:

void init_interrupts() {
	*((size_t*)0xFFFFEFF0) = 0x8000 | 3;
	memset(idt, 0, 256 * sizeof(IntDesc));
        IDTR idtr = {256 * sizeof(IntDesc), idt};
	asm("lidt (,%0,)"::"a"(&idtr)); 

Когда мы напишем простейший менеджер памяти, нам больше не придётся вручную делать так. а лишь вызвать специальную функцию, которая сама найдёт свободные физические страницы (сейчас мы выделили последнюю известную нам не нужную страницу - на ней раньше был кусок начального загрузчика) и модифицирует таблицы страниц.

Итак, таблица прерываний спроецирована и очищена. Теперь можно настроить PIC на правильный базовый вектор прерываний:

       irq_base = 0x20;
	irq_count = 16;
	outportb(0x20, 0x11);
	outportb(0x21, irq_base);
	outportb(0x21, 4);
	outportb(0x21, 1);
	outportb(0xA0, 0x11);
	outportb(0xA1, irq_base + 8);
	outportb(0xA1, 2);
	outportb(0xA1, 1);

Работа с первым PIC производится через порты ввода-вывода 0x20 и 0x21, а со вторым 0xA0 и 0xA1. После этих строк IRQ0-15 проецируются в прерывания процессора 0x20-0x2F.

Осталось назначить обработчик прерывания таймера и разрешить прерывания:

	set_int_handler(irq_base, timer_int_handler, 0x8E);
	asm("sti");
} 

На этом инициализация подсистемы обработки прерываний завершена, но мы ещё не написали обработчик прерывания таймера timer_int_handler. Вот тут первый раз появляется проблема связанная с тем, что мы используем язык высокого уровня - как описать функцию с произвольным кодом пролога и эпилога? Обычная функция в Си транслируется в Ассемблерный код примерно так:

function_name:
        push ebp
        mov ebp, esp
        ...
        leave ; Равносильно mov esp, ebp и pop ebp
        ret 

Вместо iret все функции возвращаются с помощью команды ret. К тому же не сохраняют значения многих регистров. Пока временно решим проблему специальным макросом, но когда мы дойдём до реализации многозадачности вновь столкнёмся с этой проблемой (нам будет нужна предсказуемость содержимого стека, а в зависимости от опций оптимизации компилятор может как выполнять push ebp, так и не выполнять его). Этот макрос опишем в файле interrupts.h, чтобы можно было создавать обработчики прерываний и в других модулях ядра (например, обработчик прерывания клавиатуры будет в tty.c):

#ifndef INTERRUPTS_H
#define INTERRUPTS_H

uint8 irq_base;
uint8 irq_count;

#define IRQ_HANDLER(name) void name(); \
	asm(#name ": pusha \n call _" #name " \n movb $0x20, %al \n outb %al, $0x20 \n outb %al, $0xA0 \n popa \n iret"); \
	void _ ## name()

void init_interrupts();
void set_int_handler(uint8 index, void *handler, uint8 type);

#endif

На самом деле этот макрос сначала описывает функцию обработки IRQ на чистом Assembler - обработчик сохраняет все регистры процессора в стек с помощью команды PUSHA, затем выполняет вызов функции на Си, потом отсылает байт 0x20 в порты контроллеров PIC (контроллер ждёт сигнала End Of Interrupt (EOI) - до его прихода другие IRQ обработаны не будут, это сделано для того, чтобы два одновременно пришедших прерывания обслуживались по очереди) и наконец восстанавливает регистры и выходит из обработчика с помощью команды POPA и IRET соответственно.

Теперь мы можем описать обработчик таймерного прерывания в interrupts.c:

IRQ_HANDLER(timer_int_handler) {
	(*((char*)(0xB8000 + 79 * 2)))++;
}

Можно было оставить обработчик пустым, но я добавил туда увеличение на 1 кода символа в правом верхнем углу текстового экрана для наглядности работы. Осталось добавить вызов функции инициализации обработки прерываний в kernel_main:

#include "stdlib.h"
#include "interrupts.h"
#include "tty.h"

typedef struct {
	uint64 base;
	uint64 size;
} BootModuleInfo;

void kernel_main(uint8 boot_disk_id, void *memory_map, BootModuleInfo *boot_module_list) {
	init_interrupts();
	init_tty();
	set_text_attr(15);
	printf("Welcome to MyOS!\n");
} 

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

Теперь можно скомпилировать очередную версию системы и запустить - в углу экрана мы увидим постоянно меняющийся символ - доказательство того, что таймер работает. Если нажать на любую клавишу система упадёт, потому что мы не сделали обработчик клавиатуры, но это уже хорошо. Следует отметить, что смена символа происходит как бы в фоновом режиме - мы можем выполнять в ядре другую полезную работу, но это никак не отразится на обновлении экрана. Главное не помещать в прерывания слишком ресурсоёмкие процедуры, потому что это скажется на производительности всей системы не лучшим образом.

Полный листинг файла interrupts.c:

#include "stdlib.h"
#include "interrupts.h"

typedef struct {
	uint16 address_0_15;
	uint16 selector;
	uint8 reserved;
	uint8 type;
	uint16 address_16_31;
} __attribute__((packed)) IntDesc;

typedef struct {
	uint16 limit;
	void *base;
} __attribute__((packed)) IDTR;

IntDesc *idt = (void*)0xFFFFC000;

void timer_int_handler();

void init_interrupts() {
	*((size_t*)0xFFFFEFF0) = 0x8000 | 3;
	memset(idt, 0, 256 * sizeof(IntDesc));
	IDTR idtr = {256 * sizeof(IntDesc), idt};
	asm("lidt (,%0,)"::"a"(&idtr));
	irq_base = 0x20;
	irq_count = 16;
	outportb(0x20, 0x11);
	outportb(0x21, irq_base);
	outportb(0x21, 4);
	outportb(0x21, 1);
	outportb(0xA0, 0x11);
	outportb(0xA1, irq_base + 8);
	outportb(0xA1, 2);
	outportb(0xA1, 1);
	set_int_handler(irq_base, timer_int_handler, 0x8E);
	asm("sti");
}

void set_int_handler(uint8 index, void *handler, uint8 type) {
	asm("pushf \n cli");
	idt[index].selector = 8;
	idt[index].address_0_15 = (size_t)handler & 0xFFFF;
	idt[index].address_16_31 = (size_t)handler >> 16;
	idt[index].type = type;
	idt[index].reserved = 0;
	asm("popf"); 
}

IRQ_HANDLER(timer_int_handler) {
	(*((char*)(0xB8000 + 79 * 2)))++;
}

Работа с контроллером клавиатуры

На этом выпуск в общем-то можно было закончить, но я уже давно обещал реализацию ввода с клавиатуры, поэтому придётся продолжить :-)

Клавиатура сообщает о нажатии на клавишу с помощью IRQ1, который нам и нужно обрабатывать. При этом код нажатой клавиши доступен через порт 0x60. После чтения символа надо установить младший бит в содержимом порта 0x61, чтобы сообщить клавиатуре о готовности принять от неё следующий символ (аналогично PIC клавиатура старается помочь не дать скорости поступления новых данных превысить скорость обработки их процессором образуя очередь символов. Но внутренний буфер клавиатуры не бесконечен и если пользователь будет продолжать ввод, а ОС не будет устанавливать бит, то он переполнится и самые старые символы будут потеряны).

Из порта 0x60 доступен но ASCII-код символа, а так называемый скан-код клавиши, его необходимо будет преобразовать по таблице соответствий (а также в зависимости от состояния - например, если нажат Shift надо использовать большие буквы и т. д.). Преобразованием кодов мы займёмся в следующем выпуске, а сейчас лишь напишем базовый обработчик. Сначала добавим установку обработчика в init_tty в tty.c:

#include <stdarg.h>
#include "stdlib.h"
#include "interrupts.h"
#include "tty.h"

 ...

void keyboard_int_handler();

void init_tty() {
	tty_buffer = (void*)0xB8000;
	tty_width = *((uint16*)0x44A);
	tty_height = 25;
	tty_io_port = *((uint16*)0x463);
	cursor = (*((uint8*)0x451)) * tty_width + (*((uint8*)0x450));
	text_attr = 7;
	set_int_handler(irq_base + 1, keyboard_int_handler, 0x8E);

Обработчик будет простым - просто выведем на экран сообщение с кодом символа. Вот так (это следует дописать в конце файла tty.c):

IRQ_HANDLER(keyboard_int_handler) {
	uint8 key_code;
	inportb(0x60, key_code);
	printf("You pressed key with code %d\n", key_code);
	uint8 status;
	inportb(0x61, status);
	status |= 1;
	outportb(0x61, status);
} 

Попутно стоит исправить мою опечатку в stdlib.h:

...
#define outportw(port, value) asm("outw %w0, %w1"::"a"(value),"d"(port));
#define outportl(port, value) asm("outl %0, %w1"::"a"(value),"d"(port));

#define inportb(port, out_value) asm("inb %w1, %b0":"=a"(out_value):"d"(port));
#define inportw(port, out_value) asm("inw %w1, %w0":"=a"(out_value):"d"(port));
#define inportl(port, out_value) asm("inl %w1, %0":"=a"(out_value):"d"(port));

void memset(void *mem, char value, size_t count);
void memset_word(void *mem, uint16 value, size_t count);
 ... 

Ну вот и всё. Как обычно, компилируем систему. загружаемся в Bochs или на реальном железе и тестируем. Теперь при нажатии на любую клавишу будет выводиться строка на экран. Заметьте, отпускание клавиши тоже порождает приход прерывания, только прочитанный код клавиши на 0x80 больше, чем код нажатия. Проверка старшего бита скан-кода позволяет следить за тем какие клавиши нажаты в данный момент. Примерный результаты работы представлен на иллюстрации ниже:

Заключение 

На этом, уважаемые читатели, всё! В следующем выпуске поговорим про преобразование скан-кодов в нормальные символы, разработку функции ввода строки с клавиатуры, а также начнём писать менеджер памяти для нашей ОС.

Если у вас возникли какие-либо вопросы по этому или предыдущим выпускам, а также по разработке ОС в целом, вы всегда можете задать их мне по электронной почте на адрес kiv.apple@gmail.com. До встречи!


В избранное