Пояснения читателю

Для начала я бы хотел устроить небольшой разбор полетов. В прошлой части я забыл
написать, что исходные коды программ на ассемблере я предполагаю компилировать в
Nasm в виде бинарных файлов, а запись полученного результата отправлять на
отформатированную дискету с помощью, скажем, программы RawWrite. При том
записаны они должны быть сразу друг за другом, поэтому в первый загрузчик можно
включить в самом конце incbin 'name2.bin' (name2 - название второго загрузчика),
соответственно тогда он включит в себя текст второго. И сразу отвечу на вопрос
читателей, который мне после этой статьи задавали. Я понимаю, что описать
написание ОС в нескольких статьях невозможно, потому я бы предложил читателю для
начала прочитать следующие книги(помимо Зубкова, указанного ранее):

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

Тем не менее сейчас я дополнительно опишу минимум теории, необходимый для
прошлой статьи, который я из нее убрал. Процессор может работать в двух режимах
- реальный и защищенный (их гибрид я не учитываю). Защищенный режим предназначен
для того, чтобы можно было использовать такие возможности как многозадачность,
виртуальная память и пр. Основная структура данных PM (protected mode) -
дескриптор (8 байт), необходимый для контроля сегментации, аппаратной
многозадачности, обработки прерываний. В этом режиме память делится на
защищенные части - сегменты. В дескрипторе сегмента содержатся данные о его
типе, базе (адресе), размере, уровне привилегий и пр. Сегментная память
используется для того, чтобы защитить программы при их исполнении от других
(благодаря привилегиям).
Для перевода процессора в PM используется глобальная таблица дескрипторов, в
которой хранится для каждого сегмента отдельный дескриптор. Его вид:

0,1 байты - размер
2-4 байты - база
5 байт - бит наличия сегмента, уровень привилегий, тип сегмента (0010 - данные,
1010 - код)
6 байт - 1, разрядность (0 - 16, 1 - 32), 0, 0, размер
7 байт - база

Итак, задача для перехода в PM такова: построить GDT с дескрипторами для кода и
данных (база - 0, размер - 4 Гб), включить адресную линию A20 (она отключена для
совместимости со старыми процессорами), изменить в регистре CR0 один бит в 1
(флаг того, что мы в PM), после чего прыгнуть в 32-битный сегмент.

Введение

Итак, в прошлой части
мы подготовили плацдарм для наших действий. Теперь перед нами обширные
горизонты, от которых надо постараться не потерять голову. Давайте немного
подумаем, какие первоочередные задачи сейчас перед нами стоят? Во-первых, мы
умеем только загружать нашу ОС, у нас нету так ядра, которому бы передавалось
управление. Далее наша ОС должна иметь минимальный набор функция для контакта с
пользователем, а потому перед нами встает еще задача обеспечения функций работы
с железом. Для этого нам будет необходимо написать таблицу прерываний, а так же
научить наше детище работать, скажем, для начала с клавиатурой и жестким диском.
Начнем с ядра. Кстати, немного о его концепции. Оно бывает нескольких видов:
микроядро, монолитное, экзоядро. Различаются они по делению исполняемых задач по
привилегиям. Так в микроядре на правах супервизора работает крайне малая часть
функций, остальное - подключаемые библиотеки. Монолитное же ядро очень велико,
все основные функции ОС сосредоточены именно в нем. Микроядерная архитектура
имеет огромные преимущества над традиционной монолитной и только один серьезный
недостаток - пониженное быстродействие. Экзоядерная архитектура превосходит все
другие архитектуры по производительности в несколько раз, однако она слишком
сложна. Она отличается тем, что если микро и монолитные ядра обеспечивают задачи
распределения ресурсов и создания виртуальной машины, то экзоядра эти задачи
разделают. В каком смысле? Например, дать возможность пользовательским
программам обращаться к конкретной ячейке памяти, конкретному сектору диска,
конкретным портам других внешних устройств. Мы будем рассматривать микроядро,
т.е. в ядре будет сосредоточен базовый минимум функций, всю остальную работу
будет обеспечивать набор драйверов. Хотя надо заметить, что в ближайших двух
статьях ссылки на тип ядра у нас не будет.

Ближе к делу. Как вы помните, написанный в предыдущем выпуске загрузчик обладает
возможностью загружать и выполнять код, находящийся в файле kernel.bin и
скомпонованный по адресу 0x200000. Теперь мы уже можем работать на С. Первой же
проблемой, с которой мы столкнемся при создании kernal.bin, будет то, что
содержимое функции main() вынесется компилятором на самый верх программы, т.е.
мы будем из загрузчика передавать управление в никуда. Для того, чтобы от этого
избавиться, надо передавать управление ядру через переходник (c.asm):

[BITS 32]
[EXTERN k_main]
[GLOBAL _go]
_go:
mov esp, 0x200000-4
call k_main

Само ядро у нас будет выглядеть вот так (kernel.c):

void k_main()
{
for(;;);
}

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

При возникновении прерывания, процессору известен только номер прерывания.
Поскольку сам по себе этот номер ничего не говорит о том, где именно находится
процедура-обработчик, эту информацию процессор должен почерпнуть из специальной
таблицы (таблица прерываний). В режиме реальных адресов таблица прерываний
находится по абсолютному адресу 0:0 - 0:0x400 и представляет из себя 256
абсолютных четырехбайтных адресов процедур-обработчиков. Процессору остается
только взять из этой таблицы адрес соответствующего номеру прерывания
обработчика, и выполнить переход на этот адрес. В защищенном режиме таблица
прерываний называется таблицей дескрипторов прерываний IDT (Interrupt
Descriptors Table) и на ее местонахождение указывает регистр IDTR (interrupt
descriptors table register). Уже по названию можно судить, что в ней находятся
не просто адреса обработчиков, а дескрипторы обработчиков прерывания. В качестве
таких дескрипторов могут выступать дескрипторы трех типов: шлюз прерывания, шлюз
ловушки и шлюз задачи. На данном этапе нас интересуют первые два. Они отличаются
тем, что при выполнении обработчика прерывания все прерывания сразу запрещаются
до завершения обработчика, а флаг трассировки сбрасывается.

Дескриптор представляет собой следующую запись:

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

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

Начнем мы с функций (inter.c), которые позволят нам устанавливать обработчики
прерываний, а также запрещать и разрешать обработку прерываний:

#define IT 0x100000
#define IR 0x100800
#define SCS 0x8

// В inst() some является обработчиком intr, тип
шлюза задаем параметром kind; де-факто мы создаем/редактируем дескриптор в
таблице.

void inst(unsigned char int, void (*some)(), unsigned char kind)
{
char * it=IT;
unsigned char i;
unsigned char a[8];
a[0]= (unsigned int)some & 0x000000FF;
a[1]=( (unsigned int)some & 0x0000FF00) >> 8;
a[2]=SCS;
a[3]=0;
a[4]=0;
a[5]=kind;
a[6]=( (unsigned int)some & 0x00FF0000) >> 16;
a[7]=( (unsigned int)some & 0xFF000000) >> 24;
for(k=0;k<8;k++){
*(it+int*8+k)=a[k];
}
}
//
Загружаем IDTR
void int_l()
{
unsigned short *limit = IR;
unsigned int *place = IR+2;
*limit = 256*8 - 1;
*place = IT;

asm("lidt 0(,%0,)"::"a"(IR));
asm("sti");
}
//
Разрешаем прерывания
void int_e()
{
asm("sti");
}

// Запрещаем прерывания
void int_d()
{
asm("cli");
}

Необходимо так же помнить и о работе с портами, иначе прерывания нам становятся
не нужны. Создадим port.c, главными строками в котором будут:

asm("outb %b0,%w1":: "a"(value), "d"(port));
asm("inb %w1, %b0": "=a"(value): "d"(port));

(value - простое число, я надеюсь, что скелет вы напишете сами, раз читаете эту
статью)

Собственно, теперь мы готовы заниматься самими прерываниями. Как я и говорил,
начнем мы с клавиатуры, однако не стоит забывать и о таймере, который тикает у
нас каждую 1/18,3 секунды. Перед выполнением задачи обработчик прерывания должен
сохранить регистры, а по завершении обработки - послать байт 0x20 в порт 0x20
(сигнал "конец прерывания" контроллеру прерываний), после чего восстановить
регистры. Дабы не использовать в тексте каждый раз загромождений кодом из asm, я
введу отдельный макрос:

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

(можно не пытаться в него вникнуть, главное понимайте зачем он нужен)

Возьмем code.h (содержит таблицу символов):

char codes[] = {0, 0, '1', '2', '3', '4', '5', '6', '7',
'8', '9', '0', '-', '=', 8,'\t','q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p',
'[', ']','\n', 0,'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', ';', '\'',
'`',0,'\\', 'z', 'x', 'c', 'v','b', 'n', 'm', ',', '.', '/',0,'*',0,' ',0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,'-', 0, 0, 0,'+', 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0};
char codes_sh[] = {0, 0, '!', '@', '#', '$', '%', '^', '&', '*', '(', ')','_',
'+', 8, '\t', 'Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P', '{', '}', '\n',
0, 'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', ':', '"', '~', 0, '|', 'Z', 'X',
'C', 'V', 'B', 'N', 'M', '<', '>', '?', 0, '*', 0, ' ', 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, '-', 0, 0, 0, '+', 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0};
//
8 - backspace, на ctrl, alt и пр. стоят заглушки
- 0.

Теперь напишем следующий finter.c:

#include "code.h"
//
Я говорил вам о таймере, здесь пока будет
простая заглушка, можно потом будет ввести сюда более серьезные функции.
Например, подсчет времени.

IRQ_HANDLER(irq_time)
{

}
//
Работа с клавиатурой довольно проста - мы должны
считывать с нее коды клавиш, при этом не забывая, что у нас может быть два
состояния - с shift и без него.

char sh = 0;
IRQ_HANDLER(irq_kb)
{
unsigned char code, asc;
unsigned char reg;

//
С 60-го порта вы получаем данные
code = ipb(0x60);
//
ipb, opb - мои названия функций из port.c
switch(code) {
//
С shift
case 0x36:
case 0x2A:
sh = 1;
break;

// Без shift
case 0x36 + 0x80:
case 0x2A + 0x80:
sh = 0;
break;
default:

// Если клавишу отпустили, то не делать ничего, а
если нажали, то в зависимости от значения shift преобразовать скан-код в в
разные символы ASCII

if(code >= 0x80) {
} else {
if(sh){
asc = codes_sh[code];
} else {
asc = codes[code];
}
if(asc != 0) {
//
pc - выводит на экран символ, эту функцию я
опишу ниже.

pc(asc);
}
}
break;
}

// После чего надо показать, что работа по
считыванию символа проделана. Для этого считываем состояние клавиатуры (61
порт), преобразуем в нем старший бит в 1, после чего запишем назад.

reg = ipb(0x61);
reg |= 1;
opb(0x61, creg);
}

// Теперь установка обработчиков прерываний
void iint()
{
inst(0x20, &irq_time, 0x8e);
inst(0x21, &irq_kb, 0x8e);
int_l();
int_e();
}

Вот, теперь у нас есть два прерывания. Надо бы проверить их
работоспособность, да и вообще хотелось бы, чтобы наша ОС умела нам хоть что-то
показывать, потому необходимо описать функции для работы с монитором. Это делать
мы будет напрямую через видеопамять (video.c).

#define VW 80 // Ширина экрана
#define VH 25 //
Его высота
#define RAM 0xb8000 //
Видеопамять

int cur; // Где находится курсор
int param; //
Параметры

// Идея проста - "бегаем" по экрану при прочтении
очередного символа, заносим этот символ в видеопамять, меняем параметры

символа.
void initial()
{
cur = 0;
param = 7;
}

void color(char c)
{
param = c;
}

void clear()
{
char *vid = RAM;
int k;
for (k = 0; k < VH*VW; k++) {
*(vid + k*2) = ' ';
}

cur = 0;
}

// Единственная хоть чем-то интересная для нас
функция, здесь мы как раз и записываем символ, меняем положение курсора.

void pc(char c)
{
char *vid = RAM;
int k;

switch (c) {
case '\n':
cur+=VW;
cur-=cur%VW;
break;

default:
*(vid + cur*2) = c;
*(vid + cur*2+1) = param;
cur++;
break;
}

// Здесь необходимо описать что делать, если
положение курсора вышло за пределы экрана. Решение простое - сдвиг экрана на
одну строку.

if(cur>VW*VH){
for(i=VW*2;i<=VW*VH*2+VW*2;i++){
*(vid+i-VW*2)=*(vid+i);
}
cur-=VW;
}
}
}

На сегодня остался лишь один вопрос. У нас много кода (я описал все за
исключением функций работы с портами), но его же надо как-то заставить работать.
Как это сделать? Стоит добавить в k_main вызовы функций: initial(), iint(), а
так же clear(). Компилируется все это довольно просто, достаточно следующей
последовательности команд:

gcc -ffreestanding -c -o keyboard.o keyboard.c
gcc -ffreestanding -c -o inter.o inter.c
gcc -ffreestanding -c -o finter.o finter.c
gcc -ffreestanding -c -o port.o port.c
gcc -ffreestanding -c -o video.o video.c
gcc -ffreestanding -c -o kernel.o kernel.c
nasm -felf -o c.o c.asm
ld --oformat binary -Ttext 0x200000 -o kernel.bin c.o video.o inter.o port.o
finter.o kernel.o
nasm -fbin -o boot2.bin boot2.asm
nasm -fbin -o all_image.bin boot1.asm

(В текст второго загрузчика в конце теперь надо добавить kernel_binary: incbin 'kernel.bin')

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

  • Подпишись на наc в Telegram!

    Только важные новости и лучшие статьи

    Подписаться

  • Подписаться
    Уведомить о
    0 комментариев
    Межтекстовые Отзывы
    Посмотреть все комментарии