Руководство по созданию ядра для x86-системы. Часть 2. Система ввода / вывода
15К открытий15К показов
Рассказывает Arjun Sreedharan
В прошлой статье я писал о том, как создать простейшее x86-ядро, использующее GRUB, работающее в защищённом режиме и выводящее на экран строку. В этот раз мы подключим к ядру драйвер клавиатуры, который может считывать символы a–z и 0–9 с клавиатуры и выводить их на экран. Весь используемый код можно найти на GitHub.
Мы общаемся с устройствами ввода / вывода, используя I/O-порты. Эти порты — просто определённые адреса на шине ввода / вывода x86-системы. Операции чтения / записи на этих портах обрабатываются специальными инструкциями, встроенными в процессор.
Чтение из портов и запись в них
Доступ к портам 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
Мы реализуем 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()
включает прерывания, используя инструкцию sti
.
Когда IDT настроена, мы можем включить линию IRQ клавиатуры, используя маску прерываний, о которой мы говорили ранее.
Функция обработки прерываний клавиатуры
Итак, наши прерывания клавиатуры привязаны к функции keyboard_handler
через значение IDT для прерывания 0x21
. Каждый раз, когда мы нажимаем клавишу, мы можем быть уверены, что функция будет вызвана.
Эта функция просто вызывает другую функцию, написанную на Си, и возвращает значение, используя класс инструкций iret
. Мы могли бы написать здесь весь необходимый код, но на Си писать безусловно приятнее. iret
/iretd
используются вместо ret
при работе с прерываниями.
Сперва мы отправляем сигнал EOI (конец прерывания), записывая его в порт команд контроллера. Только после этого контроллер принимает следующие прерывания. Нам нужно читать два порта — порт данных 0x60
и порт команд / данных 0x64
.
Сперва мы читаем порт 0x64
для получения состояния. Если младший бит состояния равен 0
, то буфер пуст, и данных для чтения нет. В обратном случае мы читаем порт данных 0x60
. Этот порт даёт нам код каждой нажатой клавиши. Мы используем простой массив символов в файле keyboard_map.h
для связывания кодов и символов.
В этой статье мы обрабатываем только строчные буквы и цифры. Конечно, можно настроить и обработку других клавиш, а также их сочетаний.
Вы можете собрать ядро и запустить его на реальной машине или в эмуляторе (QEMU) так же, как и в прошлой статье.
Печатайте!
15К открытий15К показов