Руководство по созданию ядра для x86-системы. Часть 2. Система ввода / вывода

Рассказывает Arjun Sreedharan 


В прошлой статье я писал о том, как создать простейшее x86-ядро, использующее GRUB, работающее в защищённом режиме и выводящее на экран строку. В этот раз мы подключим к ядру драйвер клавиатуры, который может считывать символы a–z и 0–9 с клавиатуры и выводить их на экран. Весь используемый код можно найти на GitHub

tumblr_inline_ncyxrtfyjm1rivrqc

Мы общаемся с устройствами ввода / вывода, используя I/O-порты. Эти порты — просто определённые адреса на шине ввода / вывода x86-системы. Операции чтения / записи на этих портах обрабатываются специальными инструкциями, встроенными в процессор.

Чтение из портов и запись в них

read_port:
	mov edx, [esp + 4]
	in al, dx	
	ret

write_port:
	mov   edx, [esp + 4]    
	mov   al, [esp + 4 + 4]  
	out   dx, al  
	ret

Доступ к портам I/O можно получить, используя инструкции in и out, являющиеся частью набора инструкций x86.

В read_port номер порта принимается как аргумент. Когда компилятор вызывает вашу функцию, он пушит все её аргументы в стек. Аргумент копируется в регистр edx по указателю на стек. Регистр dx — это младшие 16 бит edx. Инструкция in читает из порта, номер которого хранится в dx, и помещает результат в al. Регистр al — это младшие 8 бит eax. Если вы помните, чему вас учили, то знаете, что возвращаемые функциями значения передаются через регистр eax register. Таким образом, read_port обеспечивает чтение из I/O-портов.

write_port очень похожа. Мы принимаем два аргумента: номер порта и данные для записи. Инструкция out записывает данные в указанный порт.

Прерывания

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

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

Устройство, называемое контроллером прерываний (Programmable Interrupt Controller, PIC), отвечает за обработку аппаратных прерываний и отправку их соответствующим системным прерываниям.

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

Если бы у нас не было контроллера прерываний, то нам пришлось бы постоянно опрашивать все устройства на наличие произошедших событий.

Рассмотрим случай с клавиатурой. Она работает через I/O-порты 0x60 и 0x64. Порт 0x60 передаёт данные (о нажатой клавише), а порт 0x64 — состояние. Однако нужно знать наверняка, когда и из какого порта читать данные.

Для этого отлично подходят прерывания. Когда происходит нажатие клавиши, клавиатура посылает сигнал на контроллер прерываний по линии IRQ1. Контроллер обладает значением offset, полученным во время его инициализации. Он добавляет номер входной линии к этому offset и получает число прерывания. Затем процессор обращается к специальной структуре данных, называющейся дескрипторной таблицей прерываний (Interrupt Descriptor Table, IDT), для передачи обработчику прерываний адреса, соответствующего числу прерывания.

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

Настраиваем IDT

struct IDT_entry{
	unsigned short int offset_lowerbits;
	unsigned short int selector;
	unsigned char zero;
	unsigned char type_attr;
	unsigned short int offset_higherbits;
};

struct IDT_entry IDT[IDT_SIZE];

void idt_init(void)
{
	unsigned long keyboard_address;
	unsigned long idt_address;
	unsigned long idt_ptr[2];

	/* populate IDT entry of keyboard's interrupt */
	keyboard_address = (unsigned long)keyboard_handler; 
	IDT[0x21].offset_lowerbits = keyboard_address & 0xffff;
	IDT[0x21].selector = 0x08; /* KERNEL_CODE_SEGMENT_OFFSET */
	IDT[0x21].zero = 0;
	IDT[0x21].type_attr = 0x8e; /* INTERRUPT_GATE */
	IDT[0x21].offset_higherbits = (keyboard_address & 0xffff0000) >> 16;
	

	/*     Ports
	*	 PIC1	PIC2
	*Command 0x20	0xA0
	*Data	 0x21	0xA1
	*/

	/* ICW1 - begin initialization */
	write_port(0x20 , 0x11);
	write_port(0xA0 , 0x11);

	/* ICW2 - remap offset address of IDT */
	/*
	* In x86 protected mode, we have to remap the PICs beyond 0x20 because
	* Intel have designated the first 32 interrupts as "reserved" for cpu exceptions
	*/
	write_port(0x21 , 0x20);
	write_port(0xA1 , 0x28);

	/* ICW3 - setup cascading */
	write_port(0x21 , 0x00);  
	write_port(0xA1 , 0x00);  

	/* ICW4 - environment info */
	write_port(0x21 , 0x01);
	write_port(0xA1 , 0x01);
	/* Initialization finished */

	/* mask interrupts */
	write_port(0x21 , 0xff);
	write_port(0xA1 , 0xff);

	/* fill the IDT descriptor */
	idt_address = (unsigned long)IDT ;
	idt_ptr[0] = (sizeof (struct IDT_entry) * IDT_SIZE) + ((idt_address & 0xffff) << 16);
	idt_ptr[1] = idt_address >> 16 ;

	load_idt(idt_ptr);
}

Мы реализуем IDT как массив структур IDT_entry. Мы обсудим то, как прерывание клавиатуры привязано к своему обработчику, позже. Сперва посмотрим, как работают контроллеры прерываний.

Современные x86-системы имеют два контроллера прерываний по 8 входных линий. Назовём их PIC1 и PIC2. PIC1 получает сигналы с IRQ0 до IRQ7, а PIC2 — с IRQ8 до IRQ15. PIC1 использует порт 0x20 для команд и 0x21 — для данных. PIC2 использует порт 0xA0 для команд и 0xA1 — для данных.

Контроллеры инициализируются 8-битными инициализирующими командными словами (Initialization command words, ICW). Подробный синтаксис этих команд можно изучить здесь.

В защищённом режиме первой командой, которую вы должны передать двум контроллерам прерываний, является инициализирующая команда ICW1 (0x11). Эта команда заставляет контроллер ждать поступления ещё трёх инициализирующих слов на порт данных.

Эти команды сообщают контроллерам следующее:

  • сдвиг (ICW2);
  • состояние подключения (master/slave) (ICW3);
  • дополнительную информацию об окружении (ICW4).

Вторая инициализирующая команда — это ICW2, которая записывает в порты данных каждого контроллера его сдвиг.

Контроллеры допускают каскадирование выводов и вводов, что настраивается командой ICW3, но мы не будем его использовать, поэтому установим все значения в ноль.

ICW4 устанавливает дополнительные параметры окружения. Мы  заполним младший бит, чтобы сказать контроллерам, что мы работаем в режиме 80×86.

Та-дам! Контроллеры прерываний инициализированы!

У каждого контроллера есть внутренний 8-битный регистр, называющийся регистр масок прерывания (Interrupt Mask Register, IMR). Этот регистр хранит битмэп линий IRQ, идущих в контроллер. Если бит установлен, контроллер игнорирует запрос. Это означает, что мы можем включать и выключать любую линию IRQ, меняя соответствующее значение в IMR. Чтение из порта данных возвращает значение из IMR, а запись — задаёт это значение. В нашем коде мы отключаем все линии IRQ, чтобы позже включить ту, что соответствует клавиатуре.

Если линии IRQ включены, наши контроллеры могут получать идущие по ним сигналы и преобразовывать их в числа прерываний, добавляя сдвиг. Теперь нам нужно найти IDT такую, чтобы число прерывания для клавиатуры было привязано к адресу обработчика событий клавиатуры, который мы напишем.

Какое число прерывания должно соответствовать обработчику событий клавиатуры?

Клавиатура использует IRQ1. Это линия ввода 1 контроллера PIC1. Его сдвиг равен 0x20. Для получения числа прерывания сложим 1 + 0x20 и получим 0x21. Таким образом, адрес обработчика событий должен быть связан с прерыванием 0x21 в IDT.

Теперь нам нужно определить IDT для прерывания 0x21. Мы привяжем это прерывание к функции keyboard_handler, которую запишем в ассемблерном файле.

Каждое значение в IDT состоит из 64 битов. В значении IDT для прерывания мы не храним адрес функции-обработчика целиком, а делим его на 2 части по 16 бит. Младшие биты хранятся в первых 16 битах значения IDT, а старшие 16 битов — в последних. Это сделано в целях совместимости с 286. Да, такие костыли от Intel можно встретить часто!

В значении IDT мы также должны задать тип — это нужно для того, чтобы поймать исключение. Нам также нужно передать коду ядра смещение. GRUB создаст для нас глобальную таблицу дескрипторов, каждое значение которое занимает 8 байт. Дескриптор кода ядра — это второй сегмент, поэтому его сдвиг равен 0x08. Окно прерывания равно 0x8e. Оставшиеся посередине 8 бит должны быть заполнены нулями. Таким образом, мы заполнили значение IDT в соответствии с прерыванием клавиатуры.

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

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

У нас есть указатель в переменной idt_ptr, который мы передаём в lidt, используя функцию load_idt().

load_idt:
	mov edx, [esp + 4]
	lidt [edx]
	sti
	ret

Кроме того, функция load_idt() включает прерывания, используя инструкцию sti.

Когда IDT настроена, мы можем включить линию IRQ клавиатуры, используя маску прерываний, о которой мы говорили ранее.

void kb_init(void)
{
	/* 0xFD is 11111101 - enables only IRQ1 (keyboard)*/
	write_port(0x21 , 0xFD);
}

Функция обработки прерываний клавиатуры

Итак, наши прерывания клавиатуры привязаны к функции keyboard_handler через значение IDT для прерывания 0x21. Каждый раз, когда мы нажимаем клавишу, мы можем быть уверены, что функция будет вызвана.

keyboard_handler:                 
	call    keyboard_handler_main
	iretd

Эта функция просто вызывает другую функцию, написанную на Си, и возвращает значение, используя класс инструкций iret. Мы могли бы написать здесь весь необходимый код, но на Си писать безусловно приятнее. iret/iretd используются вместо ret при работе с прерываниями.

void keyboard_handler_main(void) {
	unsigned char status;
	char keycode;

	/* write EOI */
	write_port(0x20, 0x20);

	status = read_port(KEYBOARD_STATUS_PORT);
	/* Lowest bit of status will be set if buffer is not empty */
	if (status & 0x01) {
		keycode = read_port(KEYBOARD_DATA_PORT);
		if(keycode < 0)
			return;
		vidptr[current_loc++] = keyboard_map[keycode];
		vidptr[current_loc++] = 0x07;	
	}
}

Сперва мы отправляем сигнал EOI (конец прерывания), записывая его в порт команд контроллера. Только после этого контроллер принимает следующие прерывания. Нам нужно читать два порта — порт данных 0x60 и порт команд / данных 0x64.

Сперва мы читаем порт 0x64 для получения состояния. Если младший бит состояния равен 0, то буфер пуст, и данных для чтения нет. В обратном случае мы читаем порт данных 0x60. Этот порт даёт нам код каждой нажатой клавиши. Мы используем простой массив символов в файле keyboard_map.h для связывания кодов и символов.

В этой статье мы обрабатываем только строчные буквы и цифры. Конечно, можно настроить и обработку других клавиш, а также их сочетаний.

Вы можете собрать ядро и запустить его на реальной машине или в эмуляторе (QEMU) так же, как и в прошлой статье.

Печатайте!

tumblr_inline_ncy1p0ksgj1rivrqc

Перевод статьи «Kernel 201 - Let’s write a Kernel with keyboard and screen support»