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

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


Давайте напишем простое ядро, которое можно загрузить при помощи бутлоадера GRUB x86-системы. Это ядро будет отображать сообщение на экране и ждать. 

One does simply write a kernel

Как загружается x86-система?

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

В большей части регистров процессора при запуске уже находятся определённые значения. Регистр, указывающий на адрес инструкций (Instruction Pointer, EIP), хранит в себе адрес памяти, по которому лежит исполняемая процессором инструкция. EIP по умолчанию равен 0xFFFFFFF0. Таким образом, x86-процессоры на аппаратном уровне начинают работу с адреса 0xFFFFFFF0. На самом деле это — последние 16 байт 32-битного адресного пространства. Этот адрес называется вектором перезагрузки (reset vector).

Теперь карта памяти чипсета гарантирует, что 0xFFFFFFF0 принадлежит определённой части BIOS, не RAM. В это время BIOS копирует себя в RAM для более быстрого доступа. Адрес 0xFFFFFFF0 будет содержать лишь инструкцию перехода на адрес в памяти, где хранится копия BIOS.

Так начинается исполнение кода BIOS. Сперва BIOS ищет устройство, с которого можно загрузиться, в предустановленном порядке. Ищется магическое число, определяющее, является ли устройство загрузочным  (511-ый и 512-ый байты первого сектора должны равняться 0xAA55).

Когда BIOS находит загрузочное устройство, она копирует содержимое первого сектора устройства в RAM, начиная с физического адреса 0x7c00; затем переходит на адрес и исполняет загруженный код. Этот код называется бутлоадером.

Бутлоадер загружает ядро по физическому адресу 0x100000. Этот адрес используется как стартовый во всех больших ядрах на x86-системах.

Все x86-процессоры начинают работу в простом 16-битном режиме, называющимся реальным режимом. Бутлоадер GRUB переключает режим в 32-битный защищённый режим, устанавливая нижний бит регистра CR0 в 1. Таким образом, ядро загружается в 32-битном защищённом режиме.

Заметьте, что в случае с ядром Linux GRUB видит протоколы загрузки Linux и загружает ядро в реальном режиме. Ядро самостоятельно переключается в защищённый режим.

Что нам нужно?

Исходники можно найти на GitHub.

Задаём точку входа на ассемблере

Как бы не хотелось ограничиться одним Си, что-то придётся писать на ассемблере. Мы напишем на нём небольшой файл, который будет служить исходной точкой для нашего ядра. Всё, что он будет делать — вызывать внешнюю функцию, написанную на Си, и останавливать поток программы.

Как же нам сделать так, чтобы этот код обязательно был именно исходной точкой?

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

Вот код на ассемблере:

Первая инструкция, bits 32, не является x86-ассемблерной инструкцией. Это директива ассемблеру NASM, задающая генерацию кода для процессора, работающего в 32-битном режиме. В нашем случае это не обязательно, но вообще полезно.

Со второй строки начинается секция с кодом.

global — это ещё одна директива NASM, делающая символы исходного кода глобальными. Таким образом, линковщик знает, где находится символ start — наша точка входа.

kmain — это функция, которая будет определена в файле kernel.c. extern значит, что функция объявлена где-то в другом месте.

Затем идёт функция start, вызывающая функцию kmain и останавливающая процессор инструкцией hlt. Именно поэтому мы заранее отключаем прерывания инструкцией cli.

В идеале нам нужно выделить немного памяти и указать на неё указателем стека (esp). Однако, похоже, что GRUB уже сделал это за нас. Тем не менее, вы всё равно выделим немного места в секции BSS и переместим на её начало указатель стека. Мы используем инструкцию resb, которая резервирует указанное число байт. Сразу перед вызовом kmain указатель стека (esp) устанавливается на нужное место инструкцией mov.

Ядро на Си

В kernel.asm мы совершили вызов функции kmain(). Таким образом, наш «сишный» код должен начать исполнение с kmain():

Всё, что сделает наше ядро — очистит экран и выведет строку «my first kernel».

Сперва мы создаём указатель vidptr, который указывает на адрес 0xb8000. С этого адреса в защищённом режиме начинается «видеопамять». Для вывода текста на экран мы резервируем 25 строк по 80 ASCII-символов, начиная с 0xb8000.

Каждый символ отображается не привычными 8 битами, а 16. В первом байте хранится сам символ, а во втором — attribute-byte. Он описывает форматирование символа, например, его цвет.

Для вывода символа s зелёного цвета на чёрном фоне мы запишем этот символ в первый байт и значение 0x02 во второй. 0 означает чёрный фон, 2 — зелёный цвет текста.

Вот таблица цветов:

В нашем ядре мы будем использовать светло-серый текст на чёрном фоне, поэтому наш байт-атрибут будет иметь значение 0x07.

В первом цикле программа выводит пустой символ по всей зоне 80×25. Это очистит экран. В следующем цикле в «видеопамять» записываются символы из нуль-терминированной строки «my first kernel» с байтом-атрибутом, равным 0x07. Это выведет строку на экран.

Связующая часть

Мы должны собрать kernel.asm в объектный файл, используя NASM; затем при помощи GCC скомпилировать kernel.c в ещё один объектный файл. Затем их нужно присоединить к исполняемому загрузочному ядру.

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

Сперва мы зададим формат вывода как 32-битный Executable and Linkable Format (ELF). ELF — это стандарный формат бинарных файлов Unix-систем архитектуры x86. ENTRY принимает один аргумент, определяющий имя символа, являющегося точкой входа. SECTIONS — это самая важная часть. В ней определяется разметка нашего исполняемого файла. Мы определяем, как должны соединяться разные секции и где их разместить.

В скобках после SECTIONS точка (.) отображает счётчик положения, по умолчанию равный 0x0. Его можно изменить, что мы и делаем.

Смотрим на следующую строку: .text : { *(.text) }. Звёздочка (*) — это специальный символ, совпадающий с любым именем файла. Выражение *(.text) означает все секции .text из всех входных файлов.

Таким образом, линковщик соединяет все секции кода объектных файлов в одну секцию исполняемого файла по адресу в счётчике положения (0x100000). После этого значение счётчика станет равным 0x100000 + размер полученной секции.

Аналогично всё происходит и с другим секциями.

Grub и Multiboot

Теперь все файлы готовы к созданию ядра. Но остался ещё один шаг.

Существует стандарт загрузки x86-ядер с использованием бутлоадера, называющийся Multiboot specification. GRUB загрузит наше ядро, только если оно удовлетворяет этим спецификациям.

Следуя им, ядро должно содержать заголовок в своих первых 8 килобайтах. Кроме того, этот заголовок должен содержать 3 поля, являющихся 4 байтами:

  • магическое поле: содержит магическое число 0x1BADB002 для идентификации ядра.
  • поле flags: нам оно не нужно, установим в ноль.
  • поле checksum: если сложить его с предыдущими двумя, должен получиться ноль.

Наш kernel.asm станет таким:

Строим ядро

Теперь мы создадим объектные файлы из kernel.asm и kernel.c и свяжем их, используя наш скрипт.

Эта строка запустит ассемблер для создания объектного файла kasm.o в формате ELF-32.

Опция «-c» гарантирует, что после компиляции не произойдёт скрытого линкования.

Это запустит линковщик с нашим скриптом и создаст исполняемый файл, называющийся kernel.

Настраиваем grub и запускаем ядро

GRUB требует, чтобы имя ядра удовлетворяло шаблону kernel-<version>. Поэтому переименуйте ядро. Своё я назвал kernel-701.

Теперь поместите его в директорию /boot. Для этого понадобятся права суперпользователя.

В конфигурационном файле GRUB grub.cfg добавьте следующее:

Не забудьте убрать директиву hiddenmenu, если она есть.

Перезагрузите компьютер, и вы увидите список ядер с вашим в том числе. Выберите его, и вы увидите:

image

Это ваше ядро! В следующей части добавим систему ввода / вывода.

P.S.

  • Для любых фокусов с ядром лучше использовать виртуальную машину.
  • Для запуска ядра в grub2 конфиг должен выглядеть так:
  • если вы хотите использовать эмулятор qemu, используйте:

Перевод статьи «Kernel 101 – Let’s write a Kernel»