Планета гаджетов / технологий
Содержание
Рассказывает Arjun Sreedharan
Давайте напишем простое ядро, которое можно загрузить при помощи бутлоадера GRUB 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 ;nasm directive — 32 bit extern kmain ;kmain is defined in the c file cli ;block interrupts mov esp, stack_space ;set stack pointer hlt ;halt the CPU resb 8192 ;8KB for stack |
Первая инструкция, 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()
:
const char *str = «my first kernel»; char *vidptr = (char*)0xb8000; //video mem begins here. unsigned int i = 0; unsigned int j = 0; /* this loops clears the screen * there are 25 lines each of 80 columns; each element takes 2 bytes */ while(j < 80 * 25 * 2) { /* blank character */ vidptr[j] = ‘ ‘; /* attribute-byte — light grey on black screen */ vidptr[j+1] = 0x07; j = j + 2; /* this loop writes the string to video memory */ while(str[j] != ‘\0’) { /* the character’s ascii */ vidptr[i] = str[j]; /* attribute-byte: give character black bg and light grey fg */ vidptr[i+1] = 0x07; i = i + 2; |
Всё, что сделает наше ядро — очистит экран и выведет строку «my first kernel».
Сперва мы создаём указатель vidptr
, который указывает на адрес 0xb8000. С этого адреса в защищённом режиме начинается «видеопамять». Для вывода текста на экран мы резервируем 25 строк по 80 ASCII-символов, начиная с 0xb8000.
Каждый символ отображается не привычными 8 битами, а 16. В первом байте хранится сам символ, а во втором — attribute-byte
. Он описывает форматирование символа, например, его цвет.
Для вывода символа s
зелёного цвета на чёрном фоне мы запишем этот символ в первый байт и значение 0x02 во второй. 0
означает чёрный фон, 2
— зелёный цвет текста.
Вот таблица цветов:
0 — Black, 1 — Blue, 2 — Green, 3 — Cyan, 4 — Red, 5 — Magenta, 6 — Brown, 7 — Light Grey, 8 — Dark Grey, 9 — Light Blue, 10/a — Light Green, 11/b — Light Cyan, 12/c — Light Red, 13/d — Light Magenta, 14/e — Light Brown, 15/f – White. |
В нашем ядре мы будем использовать светло-серый текст на чёрном фоне, поэтому наш байт-атрибут будет иметь значение 0x07.
В первом цикле программа выводит пустой символ по всей зоне 80×25. Это очистит экран. В следующем цикле в «видеопамять» записываются символы из нуль-терминированной строки «my first kernel» с байтом-атрибутом, равным 0x07. Это выведет строку на экран.
Мы должны собрать kernel.asm
в объектный файл, используя NASM; затем при помощи GCC скомпилировать kernel.c
в ещё один объектный файл. Затем их нужно присоединить к исполняемому загрузочному ядру.
Для этого мы будем использовать связывающий скрипт, который передаётся ld
в качестве аргумента.
OUTPUT_FORMAT(elf32—i386) .text : { *(.text) } .data : { *(.data) } .bss : { *(.bss) } |
Сперва мы зададим формат вывода как 32-битный Executable and Linkable Format (ELF). ELF — это стандарный формат бинарных файлов Unix-систем архитектуры x86. ENTRY принимает один аргумент, определяющий имя символа, являющегося точкой входа. SECTIONS — это самая важная часть. В ней определяется разметка нашего исполняемого файла. Мы определяем, как должны соединяться разные секции и где их разместить.
В скобках после SECTIONS точка (.) отображает счётчик положения, по умолчанию равный 0x0. Его можно изменить, что мы и делаем.
Смотрим на следующую строку: .text : { *(.text) }
. Звёздочка (*) — это специальный символ, совпадающий с любым именем файла. Выражение *(.text)
означает все секции .text
из всех водных файлов.
Таким образом, линковщик соединяет все секции кода объектных файлов в одну секцию исполняемого файла по адресу в счётчике положения (0x100000). После этого значение счётчика станет равным 0x100000 + размер полученной секции.
Аналогично всё происходит и с другим секциями.
Теперь все файлы готовы к созданию ядра. Но остался ещё один шаг.
Существует стандарт загрузки x86-ядер с использованием бутлоадера, называющийся Multiboot specification. GRUB загрузит наше ядро, только если оно удовлетворяет этим спецификациям.
Следуя им, ядро должно содержать заголовок в своих первых 8 килобайтах. Кроме того, этот заголовок должен содержать 3 поля, являющихся 4 байтами:
Наш kernel.asm
станет таким:
;multiboot spec dd 0x1BADB002 ;magic dd 0x00 ;flags dd — (0x1BADB002 + 0x00) ;checksum. m+f+c should be zero extern kmain ;kmain is defined in the c file cli ;block interrupts mov esp, stack_space ;set stack pointer hlt ;halt the CPU resb 8192 ;8KB for stack |
Теперь мы создадим объектные файлы из kernel.asm
и kernel.c
и свяжем их, используя наш скрипт.
nasm —f elf32 kernel.asm —o kasm.o |
Эта строка запустит ассемблер для создания объектного файла kasm.o в формате ELF-32.
gcc —m32 —c kernel.c —o kc.o |
Опция «-c» гарантирует, что после компиляции не произойдёт скрытого линкования.
ld —m elf_i386 —T link.ld —o kernel kasm.o kc.o |
Это запустит линковщик с нашим скриптом и создаст исполняемый файл, называющийся kernel.
GRUB требует, чтобы имя ядра удовлетворяло шаблону kernel-<version>
. Поэтому переименуйте ядро. Своё я назвал kernel-701.
Теперь поместите его в директорию /boot. Для этого понадобятся права суперпользователя.
В конфигурационном файле GRUB grub.cfg
добавьте следующее:
kernel /boot/kernel—701 ro |
Не забудьте убрать директиву hiddenmenu
, если она есть.
Перезагрузите компьютер, и вы увидите список ядер с вашим в том числе. Выберите его, и вы увидите:
Это ваше ядро!
menuentry ‘kernel 7001’ { set root=‘hd0,msdos1’ multiboot /boot/kernel—7001 ro |
qemu
, используйте:qemu—system—i386 —kernel kernel |
Перевод статьи «Kernel 101 – Let’s write a Kernel»Иван Бирюков, страж правописания