Урок 4. GDT и IDT

GDT и IDT - это таблицы дескрипторов. Они представляют собой массивы флагов и битов, описывающих работу системы сегментации и таблицы векторов прерываний соответственно.


4.1. GDT. Теория.

Архитектура x86 предусматривает два метода защиты и управления памятью: сегментная адресация и страничная адресация.

При сегментной адресации, вычисление адреса осуществляется относительно сегмента. Смещение добавляется к базовому адресу сегмента. Можно представить сегмент как окно в адресном пространстве: процесс ничего не знает об окне, все чем он оперирует - это линейные адреса начинающиеся в нуле и продолжающиеся до конца сегмента.

При страничной адресации, все адресное пространство разбито на блоки одинаковой длины (обычно размером 4 кБ), называемые страницами. Каждая страница может быть отображена на область физической памяти, обычно называемую 'кадр' (frame). При помощи этого механизма можно управлять виртуальной памятью.

Оба этих метода обладают своими достоинствами, однако страничная адресации для наших целей гораздо лучше. Сегментная адресация быстро устаревает, хотя все еще широко применяется. К примеру, архитектура x86-64 требует модели сплошной памяти (один сегмент с базой 0 и смещением 0xFFFFFFFF) для правильной работы некоторых специфических инструкций.

Тем не менее, сегментная адресация встроена в архитектуру x86. Невозможно игнорировать этот факт. Здесь мы покажем как настроить собственную GDT - список дескрипторов сегментов.

Как упоминалось ранее, мы собираемся использовать модель сплошной памяти. Начало сегмента будет располагаться по адресу 0x00000000 и заканчиваться по адресу 0xFFFFFFFF (конец памяти). Тем не менее, существует одна вещь, которую может обеспечить сегментная адресация, а страничная не может: установить кольцо привелегий (ring level).

Всего существует 4 кольца привелегий. 0 - кольцо ядра. На этом кольце можно использовать такие инструкции процессора как cli и sti, которые обычные процессы использовать не могут. УКольца 1 и 2 обычно не используются. Некоторые архитектуры микроядер предусматривают использование этих колец для запуска процессов-серверов или драйверов. Кольцо 3 используется для процессов пользовательского уровня.

Дескриптор сегмента содержит номер кольца привелегий. Чтобы изменить уровень привелегий процесса нам понадобятся оба вида дескрипторов - кольца 0 и кольца 3.

4.2. GDT. Практика.

Мы подробно рассмотрели теорию - теперь давайте в деталях посмотрим на реализацию.

Я забыл упомянуть об одной вещи: GRUB создает GDT за нас. Проблема в том, что мы не знаем где находится эта самая GDT, а также не знаем состав этой таблицы. Поэтому Вы можете легко затереть область памяти, где располагается эта таблица, поймать triple-fault и воспользоваться кнопкой reset. Не практично.

У нас есть 6 сегментных регистров. Каждый из них содержит смещение в таблице GDT. Регистр cs должен ссылаться на дескриптор, который настроен как дескриптор сегмента кода. Для этого существует специальный флаг. Остальные дескрипторы должны быть настроены как дескрипторы сегментов данных.

4.2.1. descriptor_tables.h

Одна запись GDT выглядит так:
// Эта структура содержит значения для одной записи GDT
struct gdt_entry_struct {
    u16int limit_low;    // Младшие 16 бит смещения
    u16int base_low;    // Младшие 16 бит базы
    u8int  base_middle;    // Следующие восемь бит базы
    u8int  access;        // Флаг определяет уровень доступа
    u8int  granularity;
    u8int  base_high;    // старшие 8 бит базы
} __attribute__((packed));
typedef struct gdt_entry_struct gdt_entry_t;

Большинство полей не требуют разъяснений. Формат байта, описывающего уровень доступа такой:
 7 6 5   4  3  0
|P|DPL| DT |Type|

Формат байта гранулярности:
 7 6 5 4 3    0
|G|D|0|A|length|
P
    сегмент существует? (Да = 1)

DPL
    Дескриптор кольца привелегий (0-3)

DT
    Тип дескриптора

Type
    Тип сегмента (кода / данных)

G
    Гранулярность (0 = 1 байт, 1 = 1 килобайт)

D
    размер операнда (0 = 16 бит, 1 = 32 бит)

0
    всегда должно быть равно нулю

A
    доступно для использования

Чтобы сообщить процессору где можно найти GDT, мы должны указать ему адрес специальной структуры-указателя.
struct gdt_ptr_struct {
    u16int limit;    // старшие 16 бит смещения селектора
    u32int base;    // адрес первой структуры gdt_entry_t
} __attribute__((packed));

typedef struct gdt_ptr_struct gdt_ptr_t;

base - это адрес первой записи в нашей таблице. limit - размер таблицы минус 1.

Эти структуры должны быть определены в файле descriptor_tables.h. Там же должна быть объявлена функция:
// Инициализирующая функция
void init_descriptor_tables();

4.2.2. descriptor_tables.c

В файле descriptor_tables.c мы сделаем несколько объявлений.
// 
// descriptor_tables.c -- Инициализирует GDT и IDT и определяет
// дефолтные обработчики аппаратных прерываний
#include "common.h"
#include "descriptor_tables.h"

// Сделаем доступными наши функции из кода на ассемблере
extern void gdt_flush(u32int);

static void init_gdt();
static void gdt_set_gate(s32int,u32int,u32int,u8int,u8int);

gdt_entry_t    gdt_entries[5];
gdt_ptr_t    gdt_ptr;
idt_entry_t    idt_entries[256];
idt_ptr_t    idt_ptr;

Функция gdt_flush() будет объявлена в ASM файле и будет загружать нашу таблицу GDT.
void init_descriptor_tables()
{
    init_gdt();
}

static void init_gdt()
{
    gdt_ptr.limit = (sizeof(gdt_entry_t)*5) - 1;
    gdt_ptr.base  = (u32int)&gdt_entries;

    gdt_set_gate(0, 0, 0, 0, 0);                // Нулевой сегмент
    gdt_set_gate(1, 0, 0xFFFFFFFF, 0x9A, 0xCF);    // Сегмент кода
    gdt_set_gate(2, 0, 0xFFFFFFFF, 0x92, 0xCF);    // Сегмент данных
    gdt_set_gate(3, 0, 0xFFFFFFFF, 0xFA, 0xCF);    // Сегмент кода уровня пользовательских процессов
    gdt_set_gate(4, 0, 0xFFFFFFFF, 0xF2, 0xCF);    // Сегмент данных уровня пользовательских процессов

    gdt_flush((u32int)&gdt_ptr);
}

static void gdt_set_gate(s32int num, u32int base, u32int limit, u8int access, u8int gran)
{
    gdt_entries[num].base_low     = (base & 0xFFFF);
    gdt_entries[num].base_middle = (base >> 16) & 0xFF;
    gdt_entries[num].base_high     = (base >> 24) & 0xFF;

    gdt_entries[num].limit_low     = (limit & 0xFFFF);
    gdt_entries[num].granularity = (limit >> 16) & 0x0F;

    gdt_entries[num].granularity |= gran & 0xF0;
    gdt_entries[num].access         = access;
}
Давайте на минутку рассмотрим это код. Функция init_gdt устанавливает значения для структуры указывающей на таблицу. limit содержит размер пяти записей таблицы. Почему пять? У нас есть сегменты кода и данных уровня ядра, сегменты кода и данных уровня пользовательских процессов, и еще обязательная нулевая запись.

Затем эта функция устанавливает значения для пяти дескрипторов при помощи функции gdt_set_gate().

Наконец, у нас есть определение функции, которая сообщает процессору адрес таблицы.
[GLOBAL gdt_flush]            ; позволяет вызвать gdt_flsuh из кода на С

gdt_flush:
    mov eax, [esp+4]        ; получаем указатель на GDT
    lgdt [eax]                ; загружаем этот указатель в специальный регистр
    
    mov ax, 0x10            ; 0x10 - это смещение в GDT для нашего сегмента данных
    mov ds, ax
    mov es, ax
    mov fs, ax
    mov gs, ax
    mov ss, ax
    jmp 0x08:.flush            ; 0x08 - смещение для нашего сегмента кода: дальний переход
.flush:
    ret

Эта функция загружает в специальный регистр адрес таблицы GDT, переданный ей в качестве параметра, а затем загружает селекторы для сегментов кода и данных. Каждая запись таблицы GDT размером 8 байт, и дескриптор сегмента кода уровня ядра - это вторая запись. Значит ее смещение равно 0x08. Дескриптор сегмента данных располагается третьим - его смещение 0x10. Поэтому мы загружаем в селекторы сегментов значение 0x10. Изменение селектора сегмента кода происходит по-другому. Мы должны выполнить дальний переход, чтобы изменить значение CS.

4.3. IDT. Теория.

Есть моменты, когда необходимо прервать выполнение текущей программы и заставить процессор выполнять какой-либо другой код. Например, когда происходит прерывание по таймеру или при нажатии клавиш клавиатуры. Прерывание похоже на сигнал POSIX - оно сообщает, что произошло что-то, что может нас заинтересовать. Процессор позволяет регистрировать 'обработчики сигналов' (обработчики прерываний). Прерывания могут быть внешними (создаваемыми оборудованием) или внутренними, создаваемыми инструкцией 'int N'. Есть несколько причин для вызова прерываний из различного ПО, но все эти причины будут описаны в другой главе.

Таблица векторов прерываний (IDT)  как раз и сообщает процессору где искать обработчики соответствующих прерываний. Она очень похожа на таблицу GDT. Таблица представляет собой просто массив записей, каждая из которых соответствет номеру прерывания. Всего возможно 256 различных прерываний, так что таблица должна содержать 256 записей. Если происходит прерывание, для которого в таблице не задан обработчик, процессор выдаст предупреждение и остановит выполнение программы.

4.3.1. Ошибки, ловушки и исключения.

Иногда процессору необходимо сообщить что-то ядру. Например, что произошла ошибка деления на ноль, или ошибка страничной адресации. Для этих целей он резервирует первые 32 прерывания. Крайне важно, чтобы для этих прерываний существовали обработчики, иначе процессор выдаст сообщение об ошибке triple-fault и остановит выполнение программ.
Список таких прерываний:
 0 - деление на ноль;
 1 - Отладочное исключение
 2 - Не маскируемое прерывание
 3 - Breakpoint exception
 4 - Переполнение
 5 - Исключение выхода за границы
 6 - Исключение неверного кода операции
 7 - Исключение 'no coprocessor'
 8 - Double fault
 9 - Coprocessor segment overrun
10 - Bad TSS
11 - Segment not present
12 - Stack fault
13 - General protection fault
14 - Page fault
15 - Неизвестное прерывание
16 - Ошибка сопроцессора
17 - Исключение проверки выравнивания
18 - Machine check exception
19-31 - Зарезервированы

4.4. IDT. Практика.

4.4.1. descriptor_tables.h

// Структура описывает запись в IDT
struct idt_entry_struct {
    u16int base_lo;        // Первые 16 бит адреса начала обработчика прерывания
    u16int sel;            // Селектор сегмента ядра
    u8int  always0;        // Всегда должно быть равно нулю
    u8int  flags;        // Флаги. RTFM.
    u16int base_hi;        // Старшие 16 бит адреса начала обработчика прерывания
}__attribute__((packed));
typedef struct idt_entry_struct idt_entry_t;

// Структура описывает указатель на массив обработчиков прерываний
// в формате пригодном для загрузки в специальный регистр
struct idt_ptr_struct {
    u16int limit;
    u32int base;
}__attribute__((packed));
typedef struct idt_ptr_struct idt_ptr_t;

// Следующие директивы позволят нам обращаться к адресам обработчиков
// описанных в ASM файле
extern void isr0 ();
...
extern void isr31();

Все очень похоже на код для GDT. Формат поля флагов следующий:
76543210
PDPLalways 14

Младшие пять бит всегда должны быть равны 0x0110=14. Поле DPL определяет уровень привилегий с которых можно обратиться к данному прерыванию. В нашем случае это поле будет содержать нулевое значение, но по мере развития нашей системы, мы изменим это значение на 3. Бит P показывает, что обработчик задан. Любой дескриптор со сброшенным битом P вызовет исключение 'Interrupt Not Handled'.

4.4.2. descriptor_tables.c

// Сделаем доступной функцию из кода на ассемблере 
extern void idt_flush(u32int);

static void init_idt();
static void idt_set_gate(u8int,u32int,u16int,u8int);

gdt_entry_t    gdt_entries[5];
gdt_ptr_t    gdt_ptr;
idt_entry_t    idt_entries[256];
idt_ptr_t    idt_ptr;

void init_descriptor_tables()
{
    // Инициализируем таблицу GDT 
    init_gdt();
    // и таблицу IDT 
    init_idt();
}

static void init_idt()
{
    idt_ptr.limit = sizeof(idt_entry_t) * 256 - 1;
    idt_ptr.base  = (u32int)&idt_entries;

    memset(&idt_entries, 0, sizeof(idt_entry_t)*256);

    idt_set_gate( 0, (u32int)isr0, 0x08, 0x8E);
    idt_set_gate( 1, (u32int)isr1, 0x08, 0x8E);
    ...
    idt_set_gate( 31, (u32int)isr31, 0x08, 0x8E);

    idt_flush((u32int)&idt_ptr);
}

static void idt_set_gate(u8int num, u32int base, u16int sel, u8int flags)
{
    idt_entries[num].base_lo = base & 0xFFFF;
    idt_entries[num].base_hi = (base >>16) & 0xFFFF;

    idt_entries[num].sel = sel;
    idt_entries[num].always0 = 0;
    /* Если мы захотим использовать привелегии пользовательского режима
     * нужно просто раскомментировать OR
     */
    idt_entries[num].flags = flags /* | 0x60 */;
}

Кроме того необходимо добавить следующий код на языке ассемблера:

[GLOBAL idt_flush]            ; позволяет вызвать idt_flsuh из кода на С

idt_flush:
    mov eax, [esp+4]
    lidt [eax]
    ret

4.4.3. interrupt.s

Отлично! У нас есть код, который сообщит процессору где искать наши обработчики прерываний, но у нас все еще нет самих обработчиков.

Когда процессор получает сигнал прерывания, он сохраняет состояние основных регистров (eip,esp,cs,eflags). проталкивая их в стек. Затем он находит адрес перехода на обработчик прерывания в IDT и делает переход на этот адрес.

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

Однако, есть еще одна проблема: некоторые прерывания проталкивают в стек код ошибки. Мы не можем вызвать универсальный обработчик без универсального набора параметров вызова, помещаемыми в стек. Поэтому для тех прерываний которые не проталкивают в стек код ошибки, это должен сделать наш обработчик.

Взглянув мельком на документацию, предоставленную Intel, мы заметим, что только прерывания с номерами 8,10-14 проталкивают в стек код ошибки. Для уменьшения количества кода воспользуемся удобными особенностями NASM - макросами.
%macro ISR_NOERRCODE 1
[GLOBAL isr%1]
isr%1:
    cli
    push byte 0
    push byte %1
    jmp isr_common_stub
%endmacro

%macro ISR_ERRCODE 1
[GLOBAL isr%1]
isr%1:
    cli
    push byte %1
    jmp isr_common_stub
%endmacro

ISR_NOERRCODE 0
ISR_NOERRCODE 1
...
Осталось сделать всего две вещи: универсальный обработчик прерываний, и высокоуровневый обработчик на С.
[EXTERN isr_handler]

isr_common_stub:
    pusha            ; проталкивает в стек значение из edi,esi,ebp,esp,ebx,edx,ecx,eax

    mov ax, ds        ; младшие 16 бит eax = ds
    push eax        ; сохраняем регистр сегмента данных
    
    mov ax, 0x10    ; загружаем смещение для сегмента данных ядра
    mov ds, ax
    mov es, ax
    mov fs, ax
    mov gs, ax

    call isr_handler

    pop eax            ; возвращаем оригинальное значение сегмента данных
    mov ds, ax
    mov es, ax
    mov fs, ax
    mov gs, ax

    popa
    add esp,8        ; очищаем стек от значений кода ошибки и номера вектора прерывания
    sti
    iret

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

4.4.4. isr.c

#include "common.h"
#include "isr.h"
#include "monitor.h"

// Данная функция вызывается из нашего обработчика из файла interrupt.h
void isr_handler(registers_t regs)
{
    monitor_write("recieved interrupt: ");
    monitor_write_dec(regs.int_no);
    monitor_put('\n');
}

4.4.5. isr.h

#include "common.h"

typedef struct registers {
    u32int ds;        // Селектор сегмента данных
    u32int edi, esi, ebp, esp, ebx, edx, ecx, eax;
    u32int int_no, err_code;
    u32int eip,cs,eflags,useresp,ss;
} registers_t;

4.5. Заключение

Давайте добавим код в нашу функцию main():
asm volatile ("int $0x03");
asm volatile ("int $0x04");
и протестируем что у нас получилось.

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

Код уже на github.
Также код доступен по ссылке: http://dl.dropbox.com/u/40211944/Lesson4.tar.gz

1 комментарий: