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

Пишем свою операционную систему. Драйвер текстового экрана


До этого момента мы выводили текст на экран лишь с помощью прямого копирования байт в видео-память. Было бы не плохо реализовать функции вроде printf для более удобного вывода на экран.

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

Консоль в некоторых её проявлениях называется телетайпом, я буду придерживаться этой же терминологии. Код драйвера будет располагаться в tty.c. Для начала опишем заголовочный файл tty.h, который определяет доступные другим модулям функции:

#ifndef TTY_H
#define TTY_H

void init_tty();
void out_char(char chr);
void out_string(char *str);
void clear_screen();
void set_text_attr(char attr);
void move_cursor(unsigned int pos);

#endif 

Имена функций говорят сам за себя:
init_tty - инициализация драйвера, необходимо вызвать до обращения к любым другим функциям.
out_char - вывод одиночного символа, поддерживается символ переноса строки '\n'
out_string - вывод целой строки символов
clear_screen - очистка экрана и перевод курсора в левый верхний угол
set_text_attr - смена текущего цвета и фона символов, описание формата цвета будет чуть ниже.
move_cursor - перемещение курсора на заданную позицию.

Видео-память в текстовом режиме представляет собой массив из 2 тысяч слов (для разрешения экрана 80x25 символов). Первый байт слова содержит сам код символа, а второй - его атрибуты (цвет фона и текста). Младшие 4 бита атрибутов символа содержат код цвета текста. Таким образом доступно 16 цветов. Следующие 3 бита содержат код цвета фона (значит существует 8 цветов фона для текста), а самый старший бит включает мерцание символа.

Вот начало файла tty.c:

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

typedef struct {
	uint8 chr;
	uint8 attr;
} TtyChar;

unsigned int tty_width;
unsigned int tty_height;
unsigned int cursor;
uint8 text_attr;
TtyChar *tty_buffer;
uint16 tty_io_port;

Мы подключаем стандартную библиотеку и описываем несколько внутренних переменных драйвера, а также структуру одиночного символа. Про назначение tty_io_port я расскажу чуть позже. В первую очередь необходимо инициализировать телетайп. Было бы не плохо подхватить старую позицию курсора от BIOS. Также, для перемещения аппаратного курсора (мерцающая горизонтальная черта) нам необходимо узнать tty_io_port. Все эти данные можно извлечь из области данных BIOS, которая расположена по адресу 0x400 - 0x600. Поскольку 1-ый мегабайт примонтирован, мы можем использовать обычное чтение.

Нас интересуют следующие данные из области данных BIOS:

АдресКомментарий 
0x44A 2 байта. Количество столбцов на экране. Если количество строк постоянно - 25, то количество столбцов теоретически может быть не только 80, но и 40. Будет красиво прочитать это отсюда. 
0x4632 байта. Базовый порт ввода-вывода для управления контроллером дисплея. 
0x4501 байт. Координата курсора Y 
0x4511 байт. Координата курсора X  

Задачей init_tty будет как раз прочитать эти параметры и сохранить их в нужные переменные:

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;
}

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

void out_char(char chr) {
	switch (chr) {
		case '\n':
			move_cursor((cursor / tty_width + 1) * tty_width);
			break;
		default:
			tty_buffer[cursor].chr = chr;
			tty_buffer[cursor].attr = text_attr;
			move_cursor(cursor + 1);
	}
}

Как многие уже могли догадаться, вывод строки будет выглядеть максимально примитивно:

void out_string(char *str) {
	while (*str) {
		out_char(*str);
		str++;
	}
} 

Функция очистки экрана просто заполняет экран пробелами с текущим цветом и переводит курсор в нулевую позицию:

void clear_screen() {
	memset_word(tty_buffer, (text_attr << 8) + ' ', tty_width * tty_height);
	move_cursor(0);
}

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

void set_text_attr(char attr) {
	text_attr = attr;
} 

Ну и наконец самая сложная функция - move_cursor. Для начала приведу упрощённый вариант, который не перемещает аппаратный курсор:

void move_cursor(unsigned int pos) {
	cursor = pos;
	if (cursor >= tty_width * tty_height) {
		cursor = (tty_height - 1) * tty_width;
		memcpy(tty_buffer, tty_buffer + tty_width, tty_width * tty_height * sizeof(TtyChar));
		memset_word(tty_buffer + tty_width * (tty_height - 1), (text_attr << 8) + ' ', tty_width);
	}
} 

Эта функция обновляет значение переменной cursor, а также следит за тем, чтобы его позиция не вышла за границу экрана. Если такое случается курсор перемещается на начало последней строки, всё содержимое экрана сдвигается на 1 строку вверх, а последняя строка очищается.

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

Для начала расскажу про порты ввода-вывода. В архитектуре x86 общение с устройствами может осуществляться двумя способами - через память и через порты ввода-вывода. Обращение через память обозначает, что какой-то блок физических адресов не является на самом деле ОЗУ, а все запросы чтения-записи уходят к устройству, которое реагирует на них согласно своим функциям (например, что-то вроде "при записи единицы по такому-то физическому адресу такое-то устройство перейдёт в активный режим"). Помимо этого способа программисту доступно 65536 портов ввода-вывода. Из каждого из них можно прочитать или записать 1 байт, однако можно объединять их в группы - например, если записать слово в порт 0x100, то его младшая половина уйдёт в 0x100, а старшая в 0x101. Для работы с портами существует две ассемблерных команды - in и out. Они существуют в единственной форме - in al/ax/eax, dx и out dx, al/ax/eax. Аргументы указывать обязательно, но в итоге номер порта хранится непременно в DX, а значение для ввода или вывода в AL/AX/EAX (в зависимости от того сколько данных мы хотим записать или прочитать).

Контроллер дисплея имеет два порта - tty_io_port и tty_io_port + 1. Первый задаёт номер его внутреннего регистра. После записи туда значения, второй порт содержит значение этого регистра. Если произвести запись во второй порт, значение соответствующего регистра изменится.

Нас интересует два внутренних регистра контроллера - 0x0E и 0x0F. Первый из них хранит старший байт позиции курсора, второй младший байт.

Доработаем move_cursor:

void
move_cursor(unsigned int pos) {
	cursor = pos;
	if (cursor >= tty_width * tty_height) {
		cursor = (tty_height - 1) * tty_width;
		memcpy(tty_buffer, tty_buffer + tty_width, tty_width * tty_height * sizeof(TtyChar));
		memset_word(tty_buffer + tty_width * (tty_height - 1), (text_attr << 8) + ' ', tty_width);
	}
	asm("movw %w0, %%dx \n movl %1, %%ecx \n movb $0x0E, %%al \n movb %%ch, %%ah \n outw %%ax, %%dx \n incb %%al \n movb %%cl, %%ah \n outw %%ax, %%dx"
		::"d"(tty_io_port),"c"(cursor));
} 

Вот и всё. Наш драйвер телетайпа готов! Теперь можно будет удобно и просто выводить текстовую информацию на экран. Например, вот так:

#include "stdlib.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_tty();
	out_string("Hello world!\n");
} 

При этом вывод продолжится с того места, где остановился загрузчик. Или так:

#include "stdlib.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_tty();
	set_text_attr(0x1F);
	clear_screen();
	out_string("Hello world!\n");
}

В этом случае ядро заполняет экран синим цветом и выводит белую надпись "Hello world".

Как обычно, в заключение выпуска привожу полный код написанного модуля tty.c:

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

typedef struct {
	uint8 chr;
	uint8 attr;
} TtyChar;

unsigned int tty_width;
unsigned int tty_height;
unsigned int cursor;
uint8 text_attr;
TtyChar *tty_buffer;
uint16 tty_io_port;

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;
}

void out_char(char chr) {
	switch (chr) {
		case '\n':
			move_cursor((cursor / tty_width + 1) * tty_width);
			break;
		default:
			tty_buffer[cursor].chr = chr;
			tty_buffer[cursor].attr = text_attr;
			move_cursor(cursor + 1);
	}
}

void out_string(char *str) {
	while (*str) {
		out_char(*str);
		str++;
	}
}

void clear_screen() {
	memset_word(tty_buffer, (text_attr << 8) + ' ', tty_width * tty_height);
	move_cursor(0);
}

void set_text_attr(char attr) {
	text_attr = attr;
}

void move_cursor(unsigned int pos) {
	cursor = pos;
	if (cursor >= tty_width * tty_height) {
		cursor = (tty_height - 1) * tty_width;
		memcpy(tty_buffer, tty_buffer + tty_width, tty_width * tty_height * sizeof(TtyChar));
		memset_word(tty_buffer + tty_width * (tty_height - 1), (text_attr << 8) + ' ', tty_width);
	}
	asm("movw %w0, %%dx \n movl %1, %%ecx \n movb $0x0E, %%al \n movb %%ch, %%ah \n outw %%ax, %%dx \n incb %%al \n movb %%cl, %%ah \n outw %%ax, %%dx"
		::"d"(tty_io_port),"c"(cursor));
}

И новый Makefile:

LDFLAGS = -melf_i386
CFLAGS = -m32 -ffreestanding

all: startup.o stdlib.o main.o tty.o script.ld
	ld $(LDFLAGS) -T script.ld -o kernel.bin startup.o stdlib.o main.o tty.o
	objcopy kernel.bin -O binary
startup.o: startup.i386.asm
	fasm startup.i386.asm startup.o
stdlib.o: stdlib.c stdlib.h
	gcc -c $(CFLAGS) -o stdlib.o stdlib.c
main.o: main.c stdlib.h tty.h
	gcc -c $(CFLAGS) -o main.o main.c
tty.o: tty.c tty.h stdlib.h
	gcc -c $(CFLAGS) -o tty.o tty.c
clean:
	rm -v *.o kernel.bin 

Ещё немного информации для пользователей Windows

Оказывается ld для win32 не умеет собирать исполняемые ELF файлы, поэтому для успешной компиляции придётся изменить строку LDFLAGS = -melf_i386 на LDFLAGS = -mi386pe.

Один из подписчиков моей рассылки - DragoN - предложил специальный bat-файл для упрощения сборки ОС под Windows:

make -C src
cp src/boot/boot.bios.bin bin/
cp src/kernel/kernel.bin bin/
cp src/make_listfs/make_listfs bin/

dd if=bin\boot.bios.bin of=bin\boot_sector.bin bs=512 count=1
dd if=bin\boot.bios.bin of=disk\boot.bin bs=1 skip=512
cp bin/kernel.bin disk/kernel.bin
bin\make_listfs of=disk.img bs=512 size=2880 boot=bin/boot_sector.bin src=./disk  

Вот его комментарии касательно особенностей сборки:

Сборка ОС MyOS под Win32.

Инструменты:

1. FASM - чтобы собрать загрузчик
2. MinGW - из него GCC чтобы собрать ядро
3. MSYS - понадобятся утилиты sh, cp, rm, dd
3. DD - утилита понадобится отдельно, так как не идћт с MinGW, MSYS
4. Bochs

Процесс:

1. Устанавливаем FASM, MinGW, MSYS, DD (я взял отсюда http://www.chrysocome.net/dd), Bochs
2. Настраиваем пути к FASM, MinGW, MSYS, DD, Bochs (переменная окружения PATH)
3. Перезагружаем шелл или тотал командер, чтобы новые пути вступили в силу
4. Запускаем в папке MyOS команду makew, получим:

MyOS\src\boot\boot.bios.bin
MyOS\src\kernel\main.o
MyOS\src\kernel\startup.o
MyOS\src\kernel\stdlib.o
MyOS\src\kernel\kernel.bin
MyOS\src\make_listfs\make_listfs.exe
MyOS\bin\boot.bios.bin
MyOS\bin\kernel.bin
MyOS\bin\make_listfs.exe
MyOS\bin\boot_sector.bin
MyOS\disk\kernel.bin
MyOS\disk\boot.bin
MyOS\disk.img

5. Запускаем bochsrc.bxrc (бочс при установке создаст ассоциацию .bxrc с собой)

Если видим жћлтый Hello, World!, значит ОС запустилась успешно.
Если что то пошло не так, сначала проверяем список файлов по пункту 4, скорее всего чего то не хватает,
и дальше действуем по обстоятельствам. 

 От себя добавлю, что надо обязательно убрать строку OUTPUT_FORMAT из script.ld.

Заключение

Итак, сегодня мы разработали драйвер текстового экрана. Теперь можно выводить различные информационные сообщения на экран.

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

Задавайте вопросы на мой e-mail kiv.apple@gmail.com! До встречи ;-) 


В избранное