Как написать ядро операционной системы

UNIX-подобная операционная система интересна для разбора, а также для написания собственного ядра, которое выведет сообщение. Ну что, напишем?

UNIX-подобная операционная система

UNIX-подобная операционная система и загрузка x86 машины

Что такое UNIX-подобная операционка? Это ОС, созданная под влиянием UNIX. Но прежде чем заняться написанием ядра для нее, давайте посмотрим, как машина загружается и передает управление ядру.

Большинство регистров x86 процессора имеют четко определенные значения после включения питания. Регистр указателя инструкций (EIP) содержит адрес памяти для команды, выполняемой процессором. EIP жестко закодирован на значение 0xFFFFFFF0. Таким образом, у процессора есть четкие инструкции по физическому адресу 0xFFFFFFF0, что, по сути, – последние 16 байт 32-разрядного адресного пространства. Этот адрес называется вектором сброса.

Теперь карта памяти чипсета гарантирует, что 0xFFFFFFF0 сопоставляется с определенной частью BIOS, а не с ОЗУ. Между тем, BIOS копирует себя в ОЗУ для более быстрого доступа. Это называется затенением (shadowing). Адрес 0xFFFFFFF0 будет содержать только инструкцию перехода к адресу в памяти, где BIOS скопировал себя.

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

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

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

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

Обратите внимание, что в случае обнаружения ядра Linux, GRUB получит протокол загрузки и загрузит ​​Linux-ядро в реальном режиме. А ядро Linux сделает переключение в защищенный режим.

Что нам понадобится?

  • x86 компьютер (разумеется)
  • Linux
  • Ассемблер NASM
  • GCC
  • ld (GNU Linker)
  • GRUB
  • Исходный код

Ну и неплохо было бы иметь представление о том, как работает UNIX-подобная ОС. Исходный код можно найти в репозитории на Github.

Точка входа и запуск ядра

Для начала напишем небольшой файл в x86 ассемблере, который будет отправной точкой для запуска ядра. Этот файл будет вызывать внешнюю функцию на C, а затем остановит поток программы.

Как убедиться, что этот код послужит отправной точкой для ядра?

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

Вот код сборки:

;;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 и останавливает CPU с помощью команды hlt. Прерывания могут пробудить CPU из выполнения инструкции hlt. Поэтому мы предварительно отключаем прерывания, используя инструкцию cli.

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

В 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] != '') {
		/* 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 в первом байте адреса видеопамяти и значение 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.

В первом цикле while программа записывает пустой символ с атрибутом 0x07 по всем 80 столбцам из 25 строк. Таким образом, экран очищается.

Во втором цикле while символы строки «my first kernel» записываются в кусок видеопамяти. Для каждого символа атрибутный байт содержит значение 0x07.

Таким образом, строка отобразится на экране.

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

Мы собираем 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, символ периода (.) – представляет собой счетчик местоположения.

Счетчик местоположения всегда инициализируется до 0x0 в начале блока SECTIONS. Его можно изменить, присвоив ему новое значение.

Как уже говорилось, код ядра должен начинаться с адреса 0x100000. Таким образом, мы установили счетчик местоположения в 0x100000.

Посмотрите на следующую строку .text: {*(.text)}

Звездочка (*) является спецсимволом, который будет соответствовать любому имени файла. То есть, выражение *(.text) означает все секции ввода .text из всех входных файлов.

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

После того, как компоновщик разместит секцию вывода текста, значение счетчика местоположения установится в 0x1000000 + размер раздела вывода текста.

Аналогично, разделы данных и bss объединяются и помещаются на значения счетчика местоположения.

Grub и Multiboot

Теперь все файлы, необходимые для сборки ядра, готовы. Но, поскольку мы намеренны загружать ядро с помощью GRUB, нужно еще кое-что.

Существует стандарт для загрузки различных x86 ядер с использованием загрузчика, называемый спецификацией Multiboot.

GRUB загрузит ядро только в том случае, если оно соответствует Multiboot-спецификации.

Согласно ей, ядро должно содержать заголовок в пределах его первых 8 килобайт.

Кроме того, этот заголовок должен содержать дополнительно 3 поля:

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

Итак, kernel.asm будет выглядеть таким образом:

;;kernel.asm

;nasm directive - 32 bit
bits 32
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-<version>. Переименуйте ядро, к примеру, в kernel-701.

Теперь поместите его в каталог /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-подобная ОС и ее ядро, а также сможете без труда написать последнее.

Рассказывает 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 и загружает ядро в реальном режиме. Ядро самостоятельно переключается в защищённый режим.

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

  • x86-компьютер;
  • Linux;
  • ассемблер NASM;
  • gcc;
  • ld (GNU Linker);
  • grub;

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

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

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

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

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

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

;;kernel.asm
bits 32			;nasm directive - 32 bit
section .text

global start
extern kmain	        ;kmain is defined in the c file

start:
  cli 			;block interrupts
  mov esp, stack_space	;set stack pointer
  call kmain
  hlt		 	;halt the CPU

section .bss
resb 8192		;8KB for stack
stack_space:

Первая инструкция, 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():

/*
*  kernel.c
*/
void kmain(void)
{
	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;
	}

	j = 0;

	/* this loop writes the string to video memory */
	while(str[j] != '') {
		/* the character's ascii */
		vidptr[i] = str[j];
		/* attribute-byte: give character black bg and light grey fg */
		vidptr[i+1] = 0x07;
		++j;
		i = i + 2;
	}
	return;
}

Всё, что сделает наше ядро — очистит экран и выведет строку «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 в качестве аргумента.

/*
*  link.ld
*/
OUTPUT_FORMAT(elf32-i386)
ENTRY(start)
SECTIONS
 {
   . = 0x100000;
   .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 + размер полученной секции.

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

Grub и Multiboot

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

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

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

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

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

;;kernel.asm

;nasm directive - 32 bit
bits 32
section .text
        ;multiboot spec
        align 4
        dd 0x1BADB002            ;magic
        dd 0x00                  ;flags
        dd - (0x1BADB002 + 0x00) ;checksum. m+f+c should be zero

global start
extern kmain	        ;kmain is defined in the c file

start:
  cli 			;block interrupts
  mov esp, stack_space	;set stack pointer
  call kmain
  hlt		 	;halt the CPU

section .bss
resb 8192		;8KB for stack
stack_space:

Строим ядро

Теперь мы создадим объектные файлы из 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 и запускаем ядро

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

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

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

title myKernel
	root (hd0,0)
	kernel /boot/kernel-701 ro

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

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

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

P.S.

  • Для любых фокусов с ядром лучше использовать виртуальную машину.
  • Для запуска ядра в grub2 конфиг должен выглядеть так:
    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»

Простое x86 ядро

Оригинал статьи: http://os.phil-opp.com/multiboot-kernel.html

В этой статье мы рассмотрим как сделать небольшое ядро операционной системы. Оно
будет загружаться и выводить OK на экран. В следующих статьях мы продолжим
писать ядро с помощью языка программирования Rust.

Я постараюсь рассказать о написании операционной системы как можно подробнее и
писать простой понятный код. Если у вам возникли какие-либо вопросы, предложения
или прочие проблемы, то можете писать в комментарии или (создать
issue) на Github. Исходный код
можно найти в моём
репозитории.

Хочу заметить, что эта серия статей написана для работы в операционной системе
Linux. В MacOS были замечены проблемы, которые можно посмотреть
здесь. Если вы хотите
использовать виртуальную машину с Linux, то можете воспользоваться инструкцией и
Vagrantfile в репозитории Ashley
Williams.

Введение

Когда вы включаете компьютер, то сначала загружается
BIOS со специального flash-накопителя
(ПЗУ). BIOS запускает само-тестирование и инициализацию оборудования. Затем
производит поиск загружаемых устройств. Если такое устройство будет обнаружено,
то загружается загрузчик и передаётся ему управление. Загрузчик — небольшой
исполняемый код, хранящийся в загрузочной части устройства. Цель загрузчика —
найти где расположено ядро операционной системы и загрузить его в оперативную
память. Может также потребоваться переключить центральный процессор в защищённый
режим, т.к. x86 совместимые процессоры запускаются в очень ограниченном
реальном режиме (для совместимости программ
со времён 1978 года).

Мы не станем писать свой загрузчик, т.к. это большая сложная задача для нашего
учебного проекта, но если вам всё же захочется написать, то можете прочитать
статью Rolling Your Own
Bootloader. Вместо этого мы
воспользуемся одним из множества загрузчиков. Но которым?

Multiboot

Тут нам повезло. Существует стандарт для загрузчиков: Multiboot
спецификация. Нам будет
достаточно реализовать в ядре поддержку Multiboot и любой загрузчик
поддерживающий стандарт Multiboot будет загружать наше ядро операционной
системы. Мы будем использовать Multiboot 2 спецификацию
(PDF), которая
поддерживается в популярном загрузчике GRUB 2.

Чтобы показать, что наше ядро имеет поддержку Multiboot 2 нужно будет добавить
Mutliboot заголовок в начало ядра, который имеет следующий формат:

Поле Тип Значение
magic number u32 0xE85250D6
architecture u32 0 для i386, 4 для MIPS
header length u32 Весь размер заголовка, включая теги
checksum u32 -(magic + architecture + header_length)
tags variable
end tag (u16, u16, u32) (0, 0, 8)

Напишем код на x86 ассемблере (Intel синтаксис):

section .multiboot_header
header_start:
    dd 0xe85250d6                ; магическое число (multiboot 2)
    dd 0                         ; архитектура 0 (защищенный режим i386)
    dd header_end - header_start ; размер заголовка
    ; контрольная сумма
    dd 0x100000000 - (0xe85250d6 + 0 + (header_end - header_start))

    ; дополнительные теги multiboot добавляются сюда

    ; завершающий тег
    dw 0    ; тип
    dw 0    ; флаги
    dd 8    ; размер
header_end:

Если вы не знаете x86 ассемблера, то кратко о том что мы написали:

  • заголовок будет располагаться в секции с именем .multiboot_header (он
    понадобится нам позже);

  • Метки header_start и header_end отмечают адреса памяти, которые мы
    используем для получения размера заголовка;

  • dd означает добавление 32-битного значения, а dw добавление 16-битного
    значения. С их помощью мы вставляем константы в выходной бинарный файл;

  • При вычислении контрольной суммы мы добавили константу 0x100000000 для
    маленькой хитрости, чтобы компилятор не выводил предупреждений при компиляции.
    По формуле, приведённой в таблице, -(magic + architecture + header_length)
    мы получаем отрицательное значение, которое не влазит в 32 бита. Вычитая из
    0x100000000 (2 в 32 степени), мы получаем положительное значение, и оно
    влазит в 32-бита, и компилятор доволен. :)

Теперь мы можем скомпилировать файл (который я назвал multiboot_header.asm),
используя компилятор nasm. В результате мы должны получить плоский двоичный
файл, который будет содержать 24 байта (Little-Endian порядок битов, если вы
работаете на x86-совместимой системе):

$ nasm multiboot_header.asm
$ hexdump -x multiboot_header
0000000    50d6    e852    0000    0000    0018    0000    af12    17ad
0000010    0000    0000    0008    0000
0000018

Код ядра

Мы теперь добавим код который будет выполняться после загрузки ядра загрузчиком.
Создадим файл с именем boot.asm:

global start

section .text
bits 32
start:
    ; print `OK` to screen
    mov dword [0xb8000], 0x2f4b2f4f
    hlt

А теперь рассмотрим код поближе:

  • global экспортирует метку (делает открытый доступ). Метка start у нас
    будет точкой входа в наше ядро;

  • Секция .text является секцией по умолчанию для исполняемого кода;

  • bits 32 определяет разрядность инструкций, которые следуют после этой
    директивы. Эта директива нужна нам, т.к. GRUB перед запуском нашего ядра
    переводить центральный процессор в Защищённый
    режим. В следующей статье мы
    будем переключаться в Long режим,
    где будет использоваться директива bits 64 (для использования 64-битных
    инструкций);

  • Инструкция mov dword записывает 32-битную константу 0x2f4b2f4f в память по
    адресу 0xb8000 (Это выведет OK на экран, об этом подробнее поговорим в
    следующей статье);

  • Инструкция hlt останавливает работу центрального процессора.

Теперь соберём наш код, посмотрим что получилось и дисассемблируем файл, чтобы
увидеть коды операций:

$ nasm boot.asm
$ hexdump -x boot
0000000    05c7    8000    000b    2f4b    2f4f    00f4
000000b
$ ndisasm -b 32 boot
00000000  C70500800B004B2F  mov dword [dword 0xb8000],0x2f4b2f4f
         -4F2F
0000000A  F4                hlt

Сборка ядра

GRUB требует, чтобы ядро было исполняемым файлом в формате
ELF. Нужно
nasm сообщить о том, что нужно сделать ELF объектный
файл вместо плоского двоичного файла. Для
нужно лишь добавить аргумент -f elf64 к вызову компилятора.

Для создания ELF исполняемого файла нам нужно
скомпоновать объектные файлы
вместе. Для этого мы напишем скрипт для
компоновщика linker.ld:

ENTRY(start)

SECTIONS {
    . = 1M;

    .boot :
    {
        /* Мы должны быть уверены, что multiboot заголовок будет вначале файла */
        *(.multiboot_header)
    }

    .text :
    {
        *(.text)
    }
}

Рассмотрим код поближе:

  • start у нас точка входа. Загрузчик будет передавать управление туда после
    загрузки ядра в память;

  • . = 1M указываем адрес в памяти куда будет выполнена загрузка первой секции
    ядра загрузчиком. Это сделано для того чтобы защитить специальную область
    памяти которая находится в первом мегабайте оперативной памяти (например, VGA
    буфер расположен по адресу 0xb800, куда мы выводим OK на экран);

  • Исполняемый файл имеет две секции: .boot который будет вначале перед секцией
    .text;

  • На выходе секция .text будет содержать в себе секции .text в входящих
    файлах;

  • Во входящих объектных файлах данные из секции .multiboot_header будут
    добавлены в выходную секцию .boot, которая будет расположена сразу после ELF
    заголовка в исполняемом файле, т.к. GRUB будет искать Multiboot заголовок в
    этом месте.

Давайте сделаем ELF объектные файлы и скомпонуем их, используя наш скрипт для
компоновки. Важно указать аргумент -n компоновщику, который отключит
автоматическое выравнивание секций в выходном исполняемом файле. Иначе
компоновщик может выровнять секцию .boot, то тогда GRUB не сможет найти
Multiboot заголовок, т.к. он будет не сразу после ELF заголовка в исполняемом
файле. Мы можем воспользоваться objdump для отображения секций полученного
исполняемого файла и проверить, что секция .boot …:

$ nasm -f elf64 multiboot_header.asm
$ nasm -f elf64 boot.asm
$ ld -n -o kernel.bin -T linker.ld multiboot_header.o boot.o
$ objdump -h kernel.bin
kernel.bin:     file format elf64-x86-64

Sections:
Idx Name          Size      VMA               LMA               File off  Algn
  0 .boot         00000018  0000000000100000  0000000000100000  00000080  2**0
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  1 .text         0000000b  0000000000100020  0000000000100020  000000a0  2**4
                  CONTENTS, ALLOC, LOAD, READONLY, CODE

Примечание: утилиты ld и objdump платформо-зависимы. Если вы работаете не
на машине с x86_64 архитектурой, то нужно воспользоваться binutils
собранным для x86_64.
Затем использовать утилиты x86_64-elf-ld и x86_64-elf-objdump вместо ld и
objdump.

Создаём ISO образ

Последний шаг — создание загрузочного ISO образа с GRUB загрузчиком. Нам нужно
создать структуру каталогов и скопировать kernel.bin в правильное место:

isofiles
└── boot
    ├── grub
    │   └── grub.cfg
    └── kernel.bin

Файл grub.cfg задаёт имя файла с нашим ядром и указанием поддержки Multiboot
2:

set timeout=0
set default=0

menuentry "my os" {
    multiboot2 /boot/kernel.bin
    boot
}

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

grub-mkrescue -o os.iso isofiles

Примечание: Если у вас эта команда не работает, то проверьте установлен ли у
вас xorriso и попытайтесь добавить к команде аргумент --verbose.

Загрузка

Пора попробовать загрузить нашу операционную систему. Для этого мы воспользуемся
QEMU:

qemu-system-x86_64 -drive format=raw,file=os.iso

Наша ОС в QEMU

Обратите внимание на зелёный OK в верхнем левом углу.

А теперь по-порядку о том, что происходит:

  1. BIOS загружает загрузчик (GRUB) с виртуального жесткого диска (ISO образ);

  2. Загрузчик читает исполняемый файл ядра и находит Multiboot заголовок;

  3. Копирует секции .boot и .text в память (адресу в памяти 0x100000 и
    0x100020);

  4. Передаёт управление в точку входа (0x1000020, вы можете это узнать с
    помощью objdump -f);

  5. Наше ядро выводит зелёное OK на экран и останавливает работу центрального
    процессора.

Вы можете протестировать нашу ОС на настоящем железе. Запишите ISO образа на
CD/DVD или на флеш-накопитель и загрузитесь с него.

Автоматическая сборка

Сейчас нам нужно выполнить 4 команды по порядку каждый раз когда мы изменяем
код. Это очень не удобно. Мы можем автоматизировать сборку используя
Makefile. Для начала мы сделаем дерево
каталогов более чище, отделив архитектуро-зависимые файлы:

…
├── Makefile
└── src
    └── arch
        └── x86_64
            ├── multiboot_header.asm
            ├── boot.asm
            ├── linker.ld
            └── grub.cfg

Makefile выглядит так (отступы вместо пробелов должны быть табуляцией):

arch ?= x86_64
kernel := build/kernel-$(arch).bin
iso := build/os-$(arch).iso

linker_script := src/arch/$(arch)/linker.ld
grub_cfg := src/arch/$(arch)/grub.cfg
assembly_source_files := $(wildcard src/arch/$(arch)/*.asm)
assembly_object_files := $(patsubst src/arch/$(arch)/%.asm, 
    build/arch/$(arch)/%.o, $(assembly_source_files))

.PHONY: all clean run iso

all: $(kernel)

clean:
    @rm -r build

run: $(iso)
    @qemu-system-x86_64 -drive format=raw,file=$(iso)

iso: $(iso)

$(iso): $(kernel) $(grub_cfg)
    @mkdir -p build/isofiles/boot/grub
    @cp $(kernel) build/isofiles/boot/kernel.bin
    @cp $(grub_cfg) build/isofiles/boot/grub
    @grub-mkrescue -o $(iso) build/isofiles 2> /dev/null
    @rm -r build/isofiles

$(kernel): $(assembly_object_files) $(linker_script)
    @ld -n -T $(linker_script) -o $(kernel) $(assembly_object_files)

# compile assembly files
build/arch/$(arch)/%.o: src/arch/$(arch)/%.asm
    @mkdir -p $(shell dirname $@)
    @nasm -felf64 $< -o $@

Поговорим об этом Makefile немного:

  • $(wildcard src/arch/$(arch)/*.asm) выбирает все *.asm файлы в
    src/arch/$(arch) каталоге. Так можно добавить еще файлов в этот каталог без
    изменения Makefile.

  • Операция patsubst для assembly_source_files делает из
    src/arch/$(arch)/XYZ.asm в build/arch/$(arch)/XYZ.o;

  • В сборке *.asm файлов $< и $@ являются автоматическими
    переменными:

Сейчас мы можем вызвать make и все изменённые файлы будут скомпилированы и
скомпонованы. Вызвав make iso, создадим ISO образ и make run запустит QEMU.

Что дальше?

В следующей статье мы создадим таблицу страниц и переключим центральный
процессор в 64-битный Long режим.

Как выйти на путь разработки ОС +37

Системное программирование, C, Assembler, Из песочницы


Рекомендация: подборка платных и бесплатных курсов Smm — https://katalog-kursov.ru/

Данная статья служит одной простой цели: помочь человеку, который вдруг решил разработать свою операционную систему (в частности, ядро) для архитектуры x86, выйти на тот этап, где он сможет просто добавлять свой функционал, не беспокоясь о сборке, запуске и прочих слабо относящихся к самой разработке деталей. В интернете и на хабре в частности уже есть материалы по данной теме, но довольно трудно написать хотя бы “Hello world”-ядро, не открывая десятков вкладок, что я и попытаюсь исправить. Примеры кода будут по большей части на языке C, но многие другие языки тоже можно адаптировать для OSDev. Давно желавшим и только что осознавшим желание разработать свою операционную систему с нуля — добро пожаловать под кат.

Теория

Чтобы понять, в чем заключается роль разработчика ОС, представим, что происходит после нажатия на кнопку включения ПК.

Сначала запускается BIOS и подготавливает жизненно важное оборудование, после чего загружает в память MBR загрузочного диска, содержащую код первой части загрузчика. Под непосредственно исполняемую часть отведено всего 446 байт, чего крайне недостаточно, поэтому мало загрузчиков действительно укладываются в эти границы. В связи с этим загрузчик обычно разделяется на две части, и единственное, что делает первая часть загрузчика — читает с диска и запускает вторую часть. Вторая часть уже может занимать хоть весь диск, и обычно переводит процессор в защищенный режим, загружает в память ядро и модули, после чего передает управление ядру.

Ядро полностью подготавливает оборудование и запускает первые пользовательские процессы, обеспечивая им и их потомкам runtime-поддержку.

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

Инструментарий

Теоретически, разработку можно вести на любой ОС, но большинство инструментов рассчитаны на UNIX-подобные системы, и хотя бы собрать их на Windows уже будет страданием. Более того, поскольку WSL не поддерживает модули ядра, смонтировать образ диска не получится, и придется настраивать коммуникацию между WSL и Windows. На этом этапе уже становится проще поставить виртуальную машину с Linux. В статье будут предоставлены инструкции для Linux и macOS.

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

На место виртуальной машины лучше всего подходят Bochs и QEMU, поскольку они быстро запускаются и предоставляют возможность отладки запущенного ядра.

Загрузчик некоторые

мазох

особенно идейные разработчики пишут сами, но, если речь идет о разработке собственно операционной системы, писать загрузчик будет очень скучно, а также ненужно, поскольку есть готовые решения. Благодаря спецификации Multiboot можно написать ядро, которое будет почти из коробки загружаться с помощью, например, GRUB или LILO.

Со сборкой же всё не так просто: понадобится кросс-компилятор под x86. Зачем кросс-компилятор, если собирать под ту же архитектуру? Дело в том, что стандартный компилятор генерирует код, опирающийся на ту же ОС, на которой он запущен, или т.н. hosted-код. Hosted-код использует системные вызовы, взаимодействует с другими процессами, но привязан к операционной системе. Freestanding-код существует сам по себе и для запуска требует только само оборудование. Ядро ОС относится к freestanding, а программы, им запускаемые — к hosted. Кросс-компилятору достаточно соответствующего флага, и будет сгенерирован freestanding-код.

Подготовка

Сборка инструментов

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

$ export TARGET=i686-elf
$ export PREFIX=<путь к кросс-компилятору>

$TARGET — система, под которую будет собирать полученный компилятор. Обычно она называется наподобие i686-linux-gnu, но здесь результат запускается без ОС, поэтому указывается просто формат исполняемого файла. Почему i686, а не i386? Просто архитектуре 80386 уже, кхм, много лет, и с тех пор многое изменилось; в частности, появились кэши, многоядерные и многопроцессорные системы, встроенные FPU, “большие” атомарные инструкции вроде CMPXCHG, так что, собирая под i386, можно сильно потерять в быстродействии и немного приобрести в поддержке старых компьютеров.

$PREFIX — то, куда будут установлены инструменты. Обычно используются пути вроде /usr/i686-elf, /usr/local/i686-elf и подобные, но можно установить и в произвольную папку. Этот каталог также называется sysroot, поскольку он будет представлять собой корневой каталог для кросс-компилятора и утилит. Говоря точнее, это не полноправный путь, а именно префикс к пути; таким образом, для установки в корень $PREFIX будет представлять из себя пустую строку, а не /. На время сборки GCC потребуется добавить в PATH путь $PREFIX/bin.

Если потом ОС понадобится собирать под другую архитектуру, достаточно будет установить другие переменные окружения, а команды скопировать.

Binutils

Загружаем и распаковываем последнюю версию с официального FTP. Осторожно: minor-версии давно перешагнули за 10, вследствие чего сортировка по алфавиту сломалась, для поиска актуальной версии можно использовать сортировку по дате последнего изменения. На момент написания статьи актуальной версией Binutils является 2.29.

Binutils не поддерживает сборку в каталоге с исходным кодом, поэтому создаем каталог рядом с распакованным кодом и заходим в него. Далее обычная сборка из исходников:

$ ../binutils-2.29/configure --target=$TARGET --prefix="$PREFIX" --with-sysroot --disable-nls --disable-werror

Подробнее о параметрах:

--with-sysroot — использовать sysroot;
--disable-nls — выключить поддержку родного языка. OSDev-сообщество не так велико, чтобы на какую-нибудь непонятную ошибку сборки обязательно нашёлся человек, говорящий на языке того, у кого она возникла;
--disable-werror — компилятор при сборке Binutils выдает предупреждения, а с -Werror это приводит к остановке сборки.

$ make
$ make install

GCC

Так же загружаем, распаковываем и создаем каталог для сборки. Процесс сборки немного отличается. Понадобятся библиотеки GMP, MPFR и MPC. Их можно установить из стандартных репозиториев многих пакетных менеджеров, а можно запустить из каталога с исходным кодом скрипт contrib/download_prerequisites, который их скачает и использует при сборке. Конфигурацию выполняем так:

$ ../gcc-7.2.0/configure --target=$TARGET --prefix="$PREFIX" --disable-nls --enable-languages=c,c++ --without-headers

--without-nls — то же самое, что и для Binutils;
--without-headers — не предполагать, что на целевой системе будет стандартная библиотека (этим, собственно, и отличается необходимый нам компилятор от стандартного);
--enable-languages=c,c++ — собрать компиляторы только для выбранных языков. Опционально, но существенно ускоряет сборку.

В условиях отсутствия целевой ОС обычный make && make install не подойдет, поскольку некоторые компоненты GCC ориентируются на готовую операционную систему, поэтому собираем и устанавливаем только необходимое:

$ make all-gcc all-target-libgcc
$ make install-gcc install-target-libgcc

libgcc — библиотека, в которой содержатся внутренние функции компилятора. Компилятор вправе вызывать их для некоторых вычислений, например, для 64-битного деления на 32-битной платформе.

GRUB

На большинстве дистрибутивов Linux эту секцию можно пропустить, поскольку на них уже установлены подходящие утилиты для работы с GRUB. Для других же ОС его потребуется загрузить и собрать. Также понадобится маленькая утилита objconv:

$ git clone https://github.com/vertis/objconv.git
$ cd objconv
$ g++ -o objconv -O2 src/*.cpp

На время сборки GRUB потребуется добавить в PATH только что собранный objconv
и кросс-инструменты (i686-elf-*).

$ cd ../grub
$ ./autogen.sh
$ mkdir ../build-grub
$ cd ../build-grub
$ ../grub-2.02/configure --disable-werror TARGET_CC=$TARGET-gcc TARGET_OBJCOPY=$TARGET-objcopy TARGET_STRIP=$TARGET-strip TARGET_NM=$TARGET-nm TARGET_RANLIB=$TARGET-ranlib --target=$TARGET
$ make
$ make install

GDB (для macOS)

Стандартная версия GDB не знает об ELF-файлах, поэтому при использовании GDB его потребуется пересобрать с их поддержкой. Загрузка, распаковка, сборка:

$ mkdir build-gdb
$ cd build-gdb
$ ../gdb-8.0.1/configure --target=$TARGET --prefix="$PREFIX"
$ make
$ make install

Образ диска

Процесс создания такового в разных ОС происходит по-своему, поэтому здесь я приведу отдельные инструкции.

Для Linux (из командной строки)

Создаем пустой файл:

$ dd if=/dev/zero of=disk.img bs=1048576 count=<размер в МБ>

Создаем таблицу разделов:

$ fdisk disk.img

    Welcome to fdisk (util-linux 2.27.1).
    Changes will remain in memory only, until you decide to write them
    Be careful before using the write command.

    Device does not contain a recognized partition table.
    Created a new DOS disklabel with disk identifier 0x########.

    Command (m for help): n
    Partition type
    p   primary (0 primary, 0 extended, 4 free)
    e   extended (container for logical partitions)
    Select (default p): <Enter>

    Using default response p.
    Partition number (1-4, default 1): <Enter>
    First sector (2048-N, default 2048): <Enter>
    Last sector, +sectors or +size{K,M,G,T,P} (2048-N, default N): <Enter>

    Created a new partition 1 of type 'Linux' and of size N MiB.

    Command (m for help): t
    Selected partition 1
    Partition type (type L to list all types): 0B
    Changed type of partition 'Linux' to 'W95 FAT32'.
    Command (m for help): a
    Selected partition 1
    The bootable flag on partition 1 is enabled now.

    Command (m for help): w
    The partition table has been altered.
    Syncing disks.

Создаём файловую систему:

$ losetup disk.img --show -f -o 1048576 # выведет <устройство>
$ mkfs.fat -F 32 <устройство>
$ mount <device> <точка монтирования>

В дальнейшем можно будет монтировать посредством

$ mount -o loop,offset=1048576 disk.img <точка монтирования>

Устанавливаем загрузчик (здесь GRUB):

$ grub-install --modules="part_msdos biosdisk fat multiboot configfile" --root-directory="<точка монтирования>" ./disk.img
$ sync

Для macOS (из командной строки)

Создаем пустой файл:

$ dd if=/dev/zero of=disk.img bs=1048576 count=<размер в МБ>

Таблица разделов:

$ fdisk -e disk.img
Would you like to initialize the partition table? [y] y
fdisk:*1> edit 1
Partition id ('0' to disable) [0 - FF]: [0] (? for help) 0B
Do you wish to edit in CHS mode? [n] n
Partition offset [0 - n]: [63] 2047
Partition size [1 - n]: [n] <Enter>
fdisk:*1> write
fdisk: 1> quit

Разделяем таблицу разделов и единственный раздел:

$ dd if=disk.img of=mbr.img bs=512 count=2047
$ dd if=disk.img of=fs.img bs=512 skip=2047

Подключаем раздел как диск:

$ hdiutil attach -nomount fs.img # выведет <устройство>

Создаем ФС, здесь FAT32:

$ newfs_msdos -F 32 <устройство>

Отключаем:

$ hdiutil detach <устройство>

“Склеиваем” MBR и ФС обратно:

$ cat mbr.img fs.img > disk.img

Подключаем и запоминаем точку монтирования (обычно “/Volumes/NO NAME”):

$ hdiutil attach disk.img

Устанавливаем загрузчик:

$ /usr/local/sbin/grub-install --modules="part_msdos biosdisk fat multiboot configfile" --root-directory="<точка монтирования>" ./disk.img

Образ диска после этого спокойно подключается встроенными средствами системы. Можно на собственное усмотрение создать иерархию директорий и настроить загрузчик. Например, для GRUB можно создать такой grub.cfg в /boot/grub:

set default=0
set timeout=0
menuentry "BetterThanLinux" {
	multiboot /путь/к/ядру/ядро.elf
	boot
}

Настройка сборочной системы

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

Ассемблерные файлы собираем в объектные формата ELF (32 бита):

$ nasm -f elf -o file.o file.s

C-файлы собираем при помощи кросс-компилятора с флагом -ffreestanding:

$ i686-elf-gcc -c -ffreestanding -o file.o file.c

Для компоновки используем всё тот же кросс-компилятор, но указываем чуть больше информации:

$ i686-elf-gcc -T linker.ld -o file.elf -ffreestanding -nostdlib file1.o file2.o -lgcc

-ffreestanding — генерировать freestanding-код;
-nostdlib — не включать стандартную библиотеку, поскольку ее реализация является hosted-кодом и будет совершенно бесполезна;
-lgcc — подключаем описанную выше libgcc. Ее подключение всегда идет после остальных объектных файлов, иначе компоновщик будет жаловаться на неразрешенные ссылки;
-T — поскольку нужно где-то разместить заголовок Multiboot, обычная раскладка ELF-файла не подойдёт. Ее можно изменить при помощи скрипта компоновщика, который и задает этот флаг. Вот готовый его вариант:

/* Исполнение начнется с этой функции */
ENTRY(_start)

/* Как расположить секции в файле */
SECTIONS
{
	/* Ядра обычно загружаются по смещению 1Мб. Можно указать любое значение */
	. = 1M;

	/* Сначала заголовок Multiboot, чтобы его нашел загрузчик, а также исполняемый код */
	.text BLOCK(4K) : ALIGN(4K)
	{
		*(.multiboot)
		*(.text)
	}

	/* Данные (только чтение) */
	.rodata BLOCK(4K) : ALIGN(4K)
	{
		*(.rodata)
	}

	/* Данные (чтение и запись, проинициализированные) */
	.data BLOCK(4K) : ALIGN(4K)
	{
		*(.data)
	}

	/* Неинициализированная область (данные для чтения и записи, стек) */
	.bss BLOCK(4K) : ALIGN(4K)
	{
		*(COMMON)
		*(.bss)
	}

	/* Сюда можно добавлять все, что только можно */
}

Минимальное ядро

Получаем управление

Получаем управление от загрузчика в небольшом ассемблерном файле:

FLAGS equ  0 ; пока никакие флаги не нужны
MAGIC equ  0x1BADB002        ; 'magic number' lets bootloader find the header
CHECKSUM equ -(MAGIC + FLAGS)   ; checksum of above, to prove we are multiboot
; Собственно заголовок
section .multiboot
align 4
	dd MAGIC
	dd FLAGS
	dd CHECKSUM

section .bss
align 16
stack_bottom:
resb 16384 ; 16 KiB
stack_top:

section .text
global _start:function (_start.end - _start)
_start:
	mov esp, stack_top ; настраиваем стек
	push ebx ; указатель на данные от загрузчика
	extern kernel_main
	call kernel_main
	cli ; если почему-то вышли из ядра, отключить прерывания (то, что может внезапно вернуть управление в ядро)
	.hang: hlt ; зависнуть
	jmp .hang ; если процессор пробудился, обратно зависнуть
	.end:

Proof of Work

Чтобы хоть как-то увидеть, что код действительно выполняется, можно вывести что-то на экран. Полноценный драйвер терминала — тема большая, но, вкратце, по адресу 0xB8000 располагается буфер на 2000 записей, каждая из которых состоит из атрибутов и символа. Белому тексту на черном фоне соответствует байт атрибутов 0x0F. Попробуем что-либо вывести при помощи заранее подготовленной строки:

#include <stddef.h>

void kernel_main(void* multiboot_structure) {
	const char str[] = "Hx0F""ex0Flx0Flx0Fox0F x0Fwx0Fox0Frx0Flx0F""dx0F";
	char* buf = (char*) 0xB8000;
	char c;
	for(size_t i = 0; c = str[i]; i++) {
		buf[i] = str[i];
	}
	while(1);
}

Запуск

Копируем ядро в образ диска по нужному пути, и после этого любая виртуальная машина должна его успешно загрузить.

Отладка

Для отладки в QEMU можно задать флаги -s -S. QEMU будет дожидаться отладчика и включит сетевую отладку. Также стоит заметить, что отладка не будет работать при использовании ускорителя, так что флаг --enable-kvm придется убрать, если он используется.

Bochs понадобится собрать с --enable-gdb-stub, а в конфиг включить строку наподобие gdbstub: enabled=1, port=1234, text_base=0, data_base=0, bss_base=0.
В GDB можно подключиться и запустить машину таким образом (kernel.elf — файл ядра):

(gdb) file kernel.elf
(gdb) target remote localhost:1234
(gdb) c

Все остальное работает так же, как и всегда — точки останова, чтение памяти и пр. Также можно включить отладчик в само ядро, что позволит производить отладку на реальной машине. Можно написать его самостоятельно, но отладка ошибки в отладчике принесет много-много радости. GNU распространяет почти готовые отладчики, требующие от ядра только несколько функций. Например, для i386. Впрочем, пока это делать рано, поскольку еще нет необходимых функций, таких как установка обработчика прерывания или получения/отправки данных через последовательный порт.

Заключение

На текущий момент до минимальной рабочей операционной системы остается настроить следующее:

  • Примитивный терминал для отладки;
  • Глобальная таблица дескрипторов и прерывания;
  • Драйвер PCI;
  • Драйвер для IDE-контроллера (SATA-диски умеют работать в режиме IDE) и хотя бы одной ФС;
  • Страничная адресация (если только не планируется однозадачная ОС без защиты памяти, такая как DOS);
  • Запуск пользовательского кода;
  • Системные вызовы;
  • Стандартная библиотека;
  • Компилятор под созданную ОС.

Полезные ресурсы

  • OSDev wiki — необходимая теория;
  • OSDev forum — здесь (вероятно) помогут в случае возникновения редких проблем;
  • The little book about OS development — весьма неплохая выжимка информации по теме;
  • JamesM’s kernel development tutorials — набор уроков по написанию ядра. Не лишен изъянов;
  • Пишем свою операционную систему — теории мало, но можно посмотреть готовую реализацию некоторых непонятных вещей.

Понравилась статья? Поделить с друзьями:
  • Как научить ребенка не писаться ночью
  • Как научить ребенка написать сочинение
  • Как написать ядро на python
  • Как научить ребенка написать изложение
  • Как написать ядерные реакции