воскресенье, 14 июля 2013 г.

PhantomEx: Сегментная адресация в защищенном режиме

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

Начнем с того, что загрузившись с помощью GRUB2 мы уже находимся в защищенном режиме с включенной сегментной адресацией. Беда только в том, что основные структуры для работы с сегментной памятью созданы загрузчиком без нашего ведома, расположены они неизвестно где, их состав и настройки для нас тайна. А это крайне неприятно - мы должны полностью отдавать себе отчет в том что происходит в нашей системе.

Поэтому сегментную адресацию придется организовать заново. И организуем мы её для того... чтобы практически не использовать! Дело в том что данный механизм является рудиментарным и не используется в современных операционных системах. В архитектуре x86-64 была предпринята попытка совсем отказаться от этой модели, однако проблемы с реализацией аппаратной виртуализации на этой платформе (использующей сегментную модель) вынудили разработчиков AMD вернуть данную схему в ограниченном виде.

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



1. Сегментная модель памяти


Рассмотрим схему, поясняющую принципы сегментной адресации в защищенном режиме.


Адресное пространство 32-разрядного защищенного режима - непрерывная область памяти размером 4 Гб лежащая в диапазоне адресов от 0x00000000 до 0xFFFFFFFF. Этот адрес называется физическим или линейным и именно он выставляется на адресной шине процессора при обращении к памяти.

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

При реализации многозадачности необходим защищенный механизм доступа к памяти и сегментная адресация является одним из таких механизмов.

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

Если доступ к памяти удовлетворяет всем условиям защиты, процессор преобразует логический адрес в 32- или 36-битный (для Pentium Pro) линейный адрес. Чтобы получить линейный адрес из логического, процессор добавляет к смещению линейный адрес начала сегмента, который хранится в поле базы в сегментном дескрипторе

Сегментный дескриптор - это 8-байтная структура данных, хранимая в глобальной (GDT) или локальной (LDT) таблице дескрипторов, причем она может располагаться в памяти где угодно. Адрес таблицы дескрипторов хранится в регистре GDTR или LDTR. 

Формат селектора представлен в следующей таблице

Таблица 5. Формат селектора сегмента.

Бит Назначение
0-1 Запрашиваемый уровень привилегий
при обращении к сегменту и текущий уровень привилегий для селектора загруженного в CS.
21 - используется LDT;
0 - используется GDT.
3-15Номер дескриптора в таблице (от 0 до 8191).

Думаю что данная таблица в пояснениях не нуждается. "Живут" селекторы в сегментных регистрах CS, DS, ES, FS, GS и SS. Таким образом, отличие от реального режима, сегментные регистры указывают не на сам сегмент, а на номер дескриптора сегмента в таблице дескрипторов.

Дескриптор представляет собой структуру, размером 8 байт. Формат дескриптора описан следующей таблицей.

Таблица 6. Формат сегментного дескриптора

БайтНазначение
0-115-0 биты лимита сегмента
2-315-0 биты базы сегмента
423-16 биты базы сегмента
5Байт доступа:

бит 0: бит досута (1 - к сегменту было обращение);
бит 1: бит разрешения чтения для кода, бит разрешения записи для данных;
бит 2: бит подчиненности для кода, бит расширения вниз для данных;
бит 3: тип сегмента (0 - данные; 1 - код)
бит 4: тип дескриптора (1 - не системный; 0 - системный)
бит 5 - 6: уровень привилегий дескриптора;
бит 7: бит присутствия сегмента.
6бит 3-0: 19-16 биты лимита;
бит 4: зарезервировано для операционной системы;
бит 5: 0
бит 6: бит разрядности (0 - 16-битный; 1 - 32-битный сегмент)
бит 7: бит гранулярности (0 - лимит в байтах; 1 - лимит в 4-килобайтных единицах)

7биты 31-24 базы сегмента.

Если в дескрипторе бит 4 байта доступа равен 0, дескриптор называют системным. В этом случае биты 0 - 3 байта доступа определяют один из 16 возможных типов дескриптора.

Таблица 7. Типы системных дескрипторов.

0Зарезервированный тип8Зарезервированный тип
1Свободный 16-битный TSS9Свободный 32-битный TSS
2Дескриптор таблицы LDTAЗарезервированный тип
3Занятый 16-битный TSSBЗанятый 32-битный TSS
416-битный шлюз вызоваC32-битный шлюз вызова
5Шлюз задачиDЗарезервированный тип
616-битный шлюз прерыванияE32-битный шлюз прерывания
716-битный шлюз ловушкиF32-битный шлюз ловушки

большая часть механизмов, работающих в сегментной модели, нам попросту не нужна, поскольку мы сразу перейдем к страничной модели памяти, и ту же многозадачность будем реализовывать именно там. Если вас интересуют подробности работы сегментной модели, отсылаю к книге "Assembler для DOS, Windows и UNIX" автора Зубкова С. В. Этот теоретический материал взят именно оттуда.

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

Для чего конкретно нужна нам сегментная адресация?

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

Ядро операционной системы работает в нулевом кольце, где доступны привилегированные команды процессора, такие как cti и sti, например.

Уровень кольца при сегментной адресации задается в битах 5-6 байта доступа дескриптора сегмента. Нам необходимо 4 сегмента: сегмент кода и данных нулевого кольца, и сегмент кода и данных пользовательского режима, так как рано или поздно мы туда перепрыгнем. Кроме того необходим, просто обязателен так называемый нулевой сегмент.  Итого нам как минимум необходимы 5 сегментов.

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

Итак, мы созрели до практической реализации сегментной адресации в нашей ОС.

2. Пишем код ядра.


Работу с таблицами дескрипторов вынесем в отдельный объектный модуль модуль descriptor_tables.o. И как всегда, необходимы некоторые декларации

Листинг 18. Заголовочный файл descriptor_tables.h

#ifndef        DESCRIPTOR_TABLES_H
#define        DESCRIPTOR_TABLES_H

#include    "common.h"


/*---------------------------------------------------
//    Запись глобальной таблицы дескрипторов (GDT)
//-------------------------------------------------*/

struct gdt_entry_struct
{
  u16int    limit_low;   /* Младшее слово лимита */
  u16int    base_low;    /* Младшее слово базы */
  u8int     base_middle; /* Средняя часть базы */
  u8int     access;      /* Байт доступа */
  u8int     granularity; /* Байт гранулярности */
  u8int     base_high;   /* Старшая часть базы */
 
}__attribute__((packed));

typedef    struct    gdt_entry_struct    gdt_entry_t;

/*---------------------------------------------------
//  Структура с указателями на GDT
//-------------------------------------------------*/

struct gdt_ptr_struct
{
  u16int    limit;
  u32int    base;
 
}__attribute__((packed));

typedef    struct    gdt_ptr_struct    gdt_ptr_t;

/* Инициализация таблиц дескрипторов */
void init_descriptor_tables(void);

#endif

Определяем две структуры - запись в таблице дескрипторов gdt_entry_t, и структура с указателями gdt_ptr_t , адрес которой загружается в регистр GDTR. Кроме того, описываем прототип функции инициализации таблиц дескрипторов init_descriptor_tables().

Начинаем с объявления необходимых переменных.

Листинг 19. Реализация работы с сегментной памятью (файл descriptor_tables.c)

#include    "descriptor_tables.h"
#include    "text_framebuffer.h"

/* Загрузка регистра GDT */
extern void gdt_flush(u32int);
/* Инициализация таблицы GDT */
static void init_gdt(void);
/* Создание записи в GDT */
static void gdt_set_gate(s32int, u32int, u32int, u8int, u8int);
/* Сама глобальная таблица дескрипторов */
gdt_entry_t    gdt_entries[5];
/* Структура указателей на GDT */
gdt_ptr_t      gdt_ptr;


Отдельного внимания заслуживает функция gdt_flush(...) выполняющая загрузку регистра GDTR. В ней необходимо использовать ассемблерный код. Можно обойтись и ассемблерными вставками, но мы поступим более красиво - реализуем эту функцию на ассемблере и вынесем в отдельный объектный модуль.

Листинг 20. Функция загрузки GDTR (файл gdt.s)

.global    gdt_flush

gdt_flush:

/* Загружаем регистр GDTR */
    mov     4(%esp), %eax /* Берем из стека переданный указатель */
    lgdt    (%eax) /* Загружаем этим указателем GDTR */
/* Загрузка всех селекторов сегментов данных */   
    mov    $0x10, %ax
    mov    %ax, %ds
    mov    %ax, %es
    mov    %ax, %fs
    mov    %ax, %gs
    mov    %ax, %ss
/* Косвенно загружаем селектор сегмента кода CS */

/* осуществляя длинный переход */   
    ljmp    $0x08,$flush
  
flush:
    ret


Прежде всего мы выполняем загрузку регистра GDTR значением указателя, переданного в качестве параметра функции. Дальше необходимо загрузить значения селекторов сегментов во все сегментные регистры. У нас будет 5 сегментов

Таблица 8. GDT  "игрушечного" ядра

0Нулевой сегмент
1Сегмент кода ядра
2Сегмент данных ядра
3Сегмент кода пользовательского режима
4Сегмент данных пользовательского режима

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

Для кода ядра: 0000000000001000b = 0x08
Для данных ядра:  0000000000010000b = 0x10

Селекторы сегмента данных грузим непосредственно, а сегмента кода - осуществляя длинный переход.

Обратите внимание, что в файле gdt.s функция gdt_flush() объявлена глобальной, то есть будет доступна их объектного модуля. В коде на C мы "цепляем" эту функцию использованием директивы extern при описании её прототипа.

Следующее что необходимо - создание записи в GDT

Листинг 21. Создание записи в GDT (файл descriptor_tables.c)

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) & 0xF;   

  /* Заполняем байты доступа и гранулярности */
  gdt_entries[num].granularity |= gran & 0xF0;
  gdt_entries[num].access = access;   
}


Здесь происходит банальное заполнение полей структуры нужной записи таблицы gdt_entries. Из-за того что адрес базы размазан по полям структуры записи GDT, приходится выполнять все эти шаманства со сдвигами и наложениями масок. В принципе код тривиален, и в общем он облегчает нам жизнь при создании сегментов.

Ну а теперь инициализируем GDT

Листинг 22. Инициализация GDT (файл descriptor_tables.c)

/*---------------------------------------------------
// Инициализация глобальной таблицы дескрипторов
//-------------------------------------------------*/
void init_gdt(void)
{
  /* Определяем размер GDT */
  gdt_ptr.limit = (sizeof(gdt_entry_t)*5) - 1;
  /* Вычисляем адрес размещения GDT в памяти*/
  gdt_ptr.base = (u32int) &gdt_entries;
 
  /* Нулевой дескриптор */
  gdt_set_gate(0, 0, 0, 0, 0);   
  /* Дескриптор кода ядра  (ring 0)*/
  gdt_set_gate(1, 0, 0xFFFFFFFF, 0x9A, 0xCF);
  /* Дескриптор данных ядра (ring 0)*/  
  gdt_set_gate(2, 0, 0xFFFFFFFF, 0x92, 0xCF);
 
/* Дескриптор кода пользовательского режима (ring 3)*/ 
  gdt_set_gate(3, 0, 0xFFFFFFFF, 0xFA, 0xCF);
  /* Дескриптор данных пользовательского режима (ring 3)*/  
  gdt_set_gate(4, 0, 0xFFFFFFFF, 0xF2, 0xCF);   

  /* Включаем сегментную адресацию  */
  gdt_flush( (u32int) &gdt_ptr);
}

Ничего сложного в плане реализации: функция заполняет структуру определяющую размещение GDT в памяти gdt_ptr; заполняет  GDT - создаем все сегменты (кроме нулевого) с базой 0x00000000 и размером 4 Гб, то есть на всё доступное адресное пространство; и собственно включает сегментную адресацию заново, перезагружая GDTR и сегментные регистры.

И наконец последний штрих - функция которая будет вызываться из main(), выполняющая инициализацию все таблиц дескрипторов (будет ещё IDT, но об этом в следующий раз)

Листинг 23. Инициализация таблиц дескрипторов (файл descriptor_tables.c)

/*---------------------------------------------
// Инициализация таблиц дескрипторов
//-------------------------------------------*/

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

Ну вот, вроде бы мы справились. Не забываем добавить gdt.o и descriptor_tables.o в список сборки, и подключить descriptor_table.h в заголовке main.h.

3. Тестируем сегментную память


Естественно всё надо проверить. Для тестирования различных аспектов работы с памятью нам будет необходима ещё и функция печати дампа памяти на экран. Листинг такой функции я приведу без излишних комментариев, предлагая читателю разбор этого кода в качестве домашнего задания :)

Листинг 24. Печать дампа памяти (файл text_framebuffer.c)

/*-------------------------------------------
// Печать байта
//-----------------------------------------*/

void print_byte(u8int value)
{
  char tmp[2];
 
  byte2hex_str(value, tmp);
   
  print_text(tmp);
}

/*--------------------------------------------
// Печать дампа памяти заданного размера
//------------------------------------------*/

void print_dump(void* address, u32int size)
{
  /* Преобразуем указатель в массив байт */
  u8int* dump = (u8int*) address; 
  /* Преобразуем указатель просто в число */  
  u32int addr_tmp = (u32int) address;
  /* Вспомогательные переменные */
  u32int i = 0;
  u32int mark = 0;
 
  for (i = 0; i < size; i++)
  {
    set_text_color(LIGHT_GRAY);
   
    if (mark == 0)
    {
    print_hex_value(addr_tmp);
    print_text(": ");
    }
   
    if (dump[i] != 0)
      set_text_color(LIGHT_GREEN);
    else
      set_text_color(LIGHT_GRAY);
   
    print_byte(dump[i]);
   
    set_text_color(LIGHT_GRAY);
   
    if ( mark == 7 )
    {
      print_text("|");
      mark++;
    }
    else if ( mark == 15 )
    {
      print_text("\n");
      mark = 0;
     
      addr_tmp += 0x10;
    }
    else
    {
      print_text(" ");
      mark++;
    }
  } 
}

Естественно, прототип этой функции следует вынести в заголовочный файл text_framebuffer.h. Добавим в самый конец функции init_gdt() так же печать таблицы GDT

set_bkground_color(BLACK);
print_text("\nGlobal Descriptors Table\n\n");
print_dump((u32int*) gdt_entries, 40); 
print_dump((u32int*) gdt_entries, 40);

Теперь несколько модифицируем наш main.c

Листинг 25. Тестирование сегментной памяти (файл main.c)

#include    "main.h"

/*--------------------------------------------
//    Точка входа в ядро
//------------------------------------------*/
int main(void)
{
 
  int i = 0;
 
  print_text("Hello from myOSkernel!!!\n\n");
 
  print_text("hex value: ");
  print_hex_value(1000);
  print_text("\n");
 
  print_text("dec value: ");
  print_dec_value(589);
  print_text("\n\n");
 
  print_text("0 1 2 3 4 5 6 7 8 9 A B C D E F\n");
 
  for (i = 0; i < 16; i++)
  {
    set_bkground_color(i);
    print_text("  ");
  }
 
  print_text("\n");
 
  init_descriptor_tables();
     
  return 0;
}

Собрав, инсталлировав и запустив ядро, мы увидим

На экране - содержимое таблицы GDT, видны все пять записей, взятые непосредственно из памяти.

Заключение

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