Руководство по созданию ядра для x86-системы. Часть 1. Просто ядро
42К открытий43К показов
Рассказывает Arjun Sreedharan
Давайте напишем простое ядро, которое можно загрузить при помощи бутлоадера GRUB x86-системы. Это ядро будет отображать сообщение на экране и ждать.
Как загружается 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 и загружает ядро в реальном режиме. Ядро самостоятельно переключается в защищённый режим.
Что нам нужно?
- x86-компьютер;
- Linux;
- ассемблер NASM;
- gcc;
- ld (GNU Linker);
- grub;
Исходники можно найти на 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
, если она есть.
Перезагрузите компьютер, и вы увидите список ядер с вашим в том числе. Выберите его, и вы увидите:
Это ваше ядро! В следующей части добавим систему ввода / вывода.
P.S.
- Для любых фокусов с ядром лучше использовать виртуальную машину.
- Для запуска ядра в grub2 конфиг должен выглядеть так:menuentry 'kernel 7001' {tset root='hd0,msdos1'tmultiboot /boot/kernel-7001 ro}
- если вы хотите использовать эмулятор
qemu
, используйте:qemu-system-i386 -kernel kernel
42К открытий43К показов