Создаем свою ОС (Часть 1)
Всем привет недавно я и мой друг решили создать свою операционную систему на ассемблере и C и я решил то что будет неплохо написать об это цикл статей на хабре!
Загрузка x86 машины
Большинство регистров x86-процессора имеют чётко определённые значения после включения питания. Регистр указателя инструкций (EIP) содержит адрес памяти для команды, выполняемой процессором. EIP жёстко закодирован на значение 0xFFFFFFF0.
Таким образом, у процессора есть чёткие инструкции по физическому адресу 0xFFFFFFF0, который представляет собой последние 16 байт 32-разрядного адресного пространства. Этот адрес называется вектором сброса.
Карта памяти чипсета гарантирует, что 0xFFFFFFF0 сопоставляется с определённой частью BIOS, а не с ОЗУ. Между тем, BIOS копирует себя в ОЗУ для более быстрого доступа. Это называется затенением (shadowing). Адрес 0xFFFFFFF0 будет содержать только инструкцию перехода к адресу в памяти, где BIOS скопировал себя.
Таким образом, код BIOS начинает своё выполнение. Сначала BIOS ищет загрузочное устройство в соответствии с настроенным порядком загрузочных устройств. Он ищет определённое магическое число, чтобы определить, является ли устройство загрузочным или нет (байты 511 и 512 первого сектора равны 0xAA55).
После того как BIOS обнаружил загрузочное устройство, он копирует содержимое первого сектора устройства в оперативную память, начиная с физического адреса 0×7c00; затем переходит по адресу и выполняет только что загруженный код. Этот код называется системным загрузчиком (bootloader).
Все x86-процессоры стартуют в упрощённом 16-битном режиме, называемом режимом реальных адресов. Загрузчик GRUB переключается в 32-битный защищённый режим, устанавливая младший бит регистра CR0 равным 1. Таким образом, ядро загружается в 32-разрядный защищённый режим.
В случае обнаружения ядра Linux GRUB получит протокол загрузки и загрузит Linux-ядро в реальном режиме. А ядро Linux сделает переключение в защищённый режим.
Что вам понадобиться!
Точка входа и запуск ядра
Для начала создадим небольшой файл на языке ассемблера x86, который станет отправной точкой для нашего ядра. Этот файл будет вызывать внешнюю функцию на языке C, а затем завершит работу программы.
Как же нам гарантировать, что этот код действительно станет основой для ядра?
Для этого мы воспользуемся скриптом компоновщика, который свяжет объектные файлы, чтобы сформировать окончательный исполняемый файл ядра. В этом скрипте мы явно укажем, что бинарный файл должен быть загружен по адресу 0×100000. Именно этот адрес станет тем местом, где будет располагаться ядро.
Вот код сборки:
;;kernel.asm
bits 32 ;директива nasm - 32 bit
section .text
global start
extern kmain ;kmain определена в C-файле
start:
cli ;блокировка прерываний
mov esp, stack_space ;установка указателя стека
call kmain
hlt ;остановка процессора
section .bss
resb 8192 ;8KB на стек
stack_space:
В данном примере представлена первая инструкция bits 32, которая не является инструкцией сборки x86. Это директива для ассемблера NASM, которая указывает на необходимость генерации кода для работы на процессоре, функционирующем в 32-битном режиме. Хотя это не является обязательным требованием для данного примера, рекомендуется явно указывать такие вещи.
Вторая строка начинается с текстового раздела, где будет размещён весь наш код.
global — ещё одна директива NASM, которая служит для установки символов исходного кода как глобальных.
kmain — это собственная функция, которая будет определена в нашем файле kernel.c. extern объявляет, что функция определена в другом месте.
Функция start вызывает функцию kmain и останавливает процессор с помощью команды hlt. Прерывания могут пробудить процессор из выполнения инструкции hlt. Поэтому мы предварительно отключаем прерывания с помощью инструкции cli.
В идеале необходимо выделить некоторый объём памяти для стека и указать на него с помощью указателя стека (esp). Однако GRUB делает это за нас, и указатель стека уже установлен. Тем не менее, для надёжности, мы выделим некоторое пространство в разделе BSS и поместим указатель стека в начало выделенной памяти. Для этого используем команду resb, которая резервирует память в байтах. После этого остаётся метка, которая указывает на край зарезервированного фрагмента памяти. Перед вызовом kmain указатель стека (esp) используется для указания этого пространства с помощью команды mov.
Ядро на C (или нет?)
В kernel.asm мы сделали вызов функции kmain (). Таким образом, код на C начнет выполнятся в kmain ():
/*
* kernel.c
*/
void kmain(void)
{
const char *str = "my first kernel";
char *vidptr = (char*)0xb8000; //видео пямять начинается здесь
unsigned int i = 0;
unsigned int j = 0;
/* этот цикл очищает экран*/
while(j < 80 * 25 * 2) {
/* пустой символ */
vidptr[j] = ' ';
/* байт атрибутов */
vidptr[j+1] = 0x07;
j = j + 2;
}
j = 0;
/* в этом цикле строка записывается в видео память */
while(str[j] != '\0') {
/* ascii отображение */
vidptr[i] = str[j];
vidptr[i+1] = 0x07;
++j;
i = i + 2;
}
return;
}
Наше ядро будет очищать экран и выводить на него строку «my first kernel».
Прежде всего, мы создаем указатель vidptr, который указывает на адрес 0xb8000. Этот адрес представляет собой начало видеопамяти в защищенном режиме. Текстовая память экрана — это просто участок памяти в нашем адресном пространстве. Ввод/вывод для экрана на карте памяти начинается с 0xb8000 и поддерживает 25 строк по 80 ascii символов каждая.
Каждый элемент символа в этой текстовой памяти представлен 16 битами (2 байтами), а не 8 битами (1 байт), как мы привыкли. Первый байт должен содержать представление символа, как в ASCII. Второй байт является атрибутным байтом и описывает форматирование символа, включая различные атрибуты, такие как цвет.
Чтобы напечатать символ зеленым цветом на черном фоне, мы сохраним символ s в первом байте адреса видеопамяти и значение 0×02 во втором байте. 0 означает черный фон, а 2 — зеленый.
Ниже приведена таблица кодов для разных цветов:
0 | 1 | 2 | 3 | 4 | 5 | 6 |
Black | Blue | Green | Cyan | Red | Magenta | Brown |
7 | 8 | 9 | 10/a | 11/b | 13/d | 15/f |
LGrey | DGrey | Light blue | Light Green | Light Cyan | Light magenta | White |
В нашем ядре мы будем использовать светло-серые символы на черном фоне. Поэтому наш байт атрибутов должен иметь значение 0×07.
В первом цикле while программа записывает пустой символ с атрибутом 0×07 по всем 80 столбцам из 25 строк. Таким образом, экран очищается.
Во втором цикле while символы строки «my first kernel» записываются в кусок видеопамяти. Для каждого символа атрибутный байт содержит значение 0×07.
Таким образом, строка отобразится на экране.
Связующая часть
Мы собираем kernel.asm и NASM в объектный файл, а затем с помощью GCC компилируем kernel.c в другой объектный файл. Теперь наша задача — связать эти объекты с исполняемым загрузочным ядром.
Для этого мы используем явный скрипт компоновщика, который можно передать как аргумент ld (наш компоновщик).
/*
* link.ld
*/
OUTPUT_FORMAT(elf32-i386)
ENTRY(start)
SECTIONS
{
. = 0x100000;
.text : { *(.text) }
.data : { *(.data) }
.bss : { *(.bss) }
}
В первую очередь, мы устанавливаем формат исполняемого файла как 32-битный исполняемый ELF. ELF является стандартным форматом двоичного файла для Unix-подобных систем на архитектуре x86.
ENTRY принимает один аргумент, который указывает имя символа, являющегося точкой входа нашего исполняемого файла.
SECTIONS — это наиболее важная часть, где мы определяем разметку исполняемого файла. Здесь указывается, как должны быть объединены различные разделы и в каком месте они будут располагаться.В фигурных скобках, следующих за инструкцией SECTIONS, символ периода (.) представляет собой счётчик местоположения.
Счётчик местоположения всегда инициализируется до 0×0 в начале блока SECTIONS. Его можно изменить, присвоив ему новое значение.
Как уже было сказано, код ядра должен начинаться с адреса 0×100000. Таким образом, мы установили счётчик местоположения в 0×100000.
Рассмотрим следующую строку text: {text}*. Звездочка () является специальным символом, который будет соответствовать любому имени файла. То есть выражение (*text) означает все секции ввода .text из всех входных файлов.
Таким образом, компоновщик объединяет все текстовые разделы объектных файлов в текстовый раздел исполняемого файла по адресу, хранящемуся в счётчике местоположения. Раздел кода исполняемого файла начинается с 0×100000.
После того как компоновщик разместит секцию вывода текста, значение счётчика местоположения установится в 0×1000000 + размер раздела вывода текста.
Аналогично разделы данных и bss объединяются и помещаются на значения счётчика местоположения.
Grub и Multiboot (Мой ад)
Теперь все файлы, необходимые для сборки ядра, готовы. Но, поскольку мы намеренны загружать ядро с помощью GRUB, нужно еще кое-что.
Существует стандарт для загрузки различных x86 ядер с использованием загрузчика, называемый спецификацией Multiboot.
GRUB загрузит ядро только в том случае, если оно соответствует Multiboot-спецификации.
Согласно ей, ядро должно содержать заголовок в пределах его первых 8 килобайт.
Кроме того, этот заголовок должен содержать дополнительно 3 поля:
поле магического числа: содержит магическое число 0×1BADB002, для идентификации заголовка.
поле флагов: сейчас оно не нужно, просто установим его значение в ноль.
поле контрольной суммы: когда задано, должно возвращать ноль для суммы с первыми двумя полями.
Итак, kernel.asm будет выглядеть таким образом:
;;kernel.asm
bits 32 ;директива nasm
section .text
;multiboot spec
align 4
dd 0x1BADB002 ;магические числа
dd 0x00 ;флаги
dd - (0x1BADB002 + 0x00) ;контрольная сумма. мч+ф+кс должно равняться нулю
global start
extern kmain ;kmain определена во внешнем файле
start:
cli ;блокировка прерываний
mov esp, stack_space ;указатель стека
call kmain
hlt ;остановка процессора
section .bss
resb 8192 ;8KB на стек
stack_space:
Сборка ядра
Теперь создадим объектные файлы из kernel.asm и kernel.c, а затем свяжем их с помощью скрипта компоновщика.
nasm -f elf32 kernel.asm -o kasm.o
запустит ассемблер для создания объектного файла kasm.o в формате 32-битного ELF.
gcc -m32 -c kernel.c -o kc.o
Параметр »-c» гарантирует, что после компиляции связывание не произойдет неявным образом.
ld -m elf_i386 -T link.ld -o kernel kasm.o kc.o
запустит компоновщик с нашим скриптом и сгенерирует исполняемое именованное ядро.
Настройка GRUB и запуск ядра
UNIX-подобная ОС с ее ядром почти поддалась. GRUB требует, чтобы ядро имело имя вида kernel-
Теперь поместите его в каталог /boot. Для этого вам потребуются права суперпользователя.
В конфигурационном файле GRUB grub.cfg вы должны добавить запись такого вида:
title myKernel
root (hd0,0)
kernel /boot/kernel-701 ro
Не забудьте удалить директиву hiddenmenu, если она существует.
Перезагрузите компьютер, и вы сможете наблюдать список с именем вашего ядра. Выберите его, и вы увидите:
Ура
Это ваше ядро! Оказывается, UNIX-подобная операционная система и ее составляющие не так уж сложны, верно?
PS:
Всегда желательно использовать виртуальную машину для всех видов взлома ядра.
Чтобы запустить это ядро на grub2, который является загрузчиком по умолчанию для более новых дистрибутивов, ваша конфигурация должна выглядеть так:
menuentry 'kernel 701' {
set root='hd0,msdos1'
multiboot /boot/kernel-701 ro
}
Если вы хотите запустить ядро на эмуляторе qemu вместо загрузки с помощью GRUB, вы можете сделать так:
qemu-system-i386 -kernel kernel
Теперь вы имеете представление о том, как устроены UNIX-подобная ОС и ее ядро, а также сможете без труда написать последнее.
Источники помощи:
UNIX-подобная операционная система: пишем ядро на языке C
Давай напишем ядро! Создаем простейшее рабочее ядро операционной системы — Хакер
GitHub — arjun024/mkernel: Минималистичное ядро
Как самому за один вечер собрать минимальную ОС Linux из исходного кода / Хабр
ИИ
Спасибо :)