Теория
Чтобы понять, в чем заключается роль разработчика ОС, представим, что происходит после нажатия на кнопку включения ПК.
Сначала запускается BIOS и подготавливает жизненно важное оборудование, после чего загружает в память MBR загрузочного диска, содержащую код первой части загрузчика. Под непосредственно исполняемую часть отведено всего 446 байт, чего крайне недостаточно, поэтому мало загрузчиков действительно укладываются в эти границы. В связи с этим загрузчик обычно разделяется на две части, и единственное, что делает первая часть загрузчика — читает с диска и запускает вторую часть. Вторая часть уже может занимать хоть весь диск, и обычно переводит процессор в защищенный режим, загружает в память ядро и модули, после чего передает управление ядру.
Ядро полностью подготавливает оборудование и запускает первые пользовательские процессы, обеспечивая им и их потомкам runtime-поддержку.
Таким образом, минимальное ядро должно уметь читать программы с диска, запускать их и в дальнейшем исполнять системные вызовы. Также крайне желательны вывод на монитор и механизмы защиты.
Инструментарий
Теоретически, разработку можно вести на любой ОС, но большинство инструментов рассчитаны на UNIX-подобные системы, и хотя бы собрать их на Windows уже будет страданием. Более того, поскольку WSL не поддерживает модули ядра, смонтировать образ диска не получится, и придется настраивать коммуникацию между WSL и Windows. На этом этапе уже становится проще поставить виртуальную машину с Linux. В статье будут предоставлены инструкции для Linux и macOS.
Для полного цикла разработки понадобятся редактор кода, сборочная система, отладчик с поддержкой удаленной отладки, загрузчик, виртуальная машина и, желательно, отдельная реальная машина для тестирования.
На место виртуальной машины лучше всего подходят Bochs и QEMU, поскольку они быстро запускаются и предоставляют возможность отладки запущенного ядра.
Загрузчик некоторые
Со сборкой же всё не так просто: понадобится кросс-компилятор под 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
Образ диска
Процесс создания такового в разных ОС происходит по-своему, поэтому здесь я приведу отдельные инструкции.
$ 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
$ 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[] = "H\x0F""e\x0Fl\x0Fl\x0Fo\x0F \x0Fw\x0Fo\x0Fr\x0Fl\x0F""d\x0F";
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 — набор уроков по написанию ядра. Не лишен изъянов;
- Пишем свою операционную систему — теории мало, но можно посмотреть готовую реализацию некоторых непонятных вещей.
Комментарии (21)
Evgen52
02.12.2017 15:31Большое спасибо за интересную статью! Было бы ещё очень здорово в таком же духе, но про x86_64.
jcmvbkbc
02.12.2017 21:17+1Вместо того чтобы руками собирать тулчейн можно взять github.com/crosstool-ng/crosstool-ng. Только непонятно, зачем собирать кросс-компилятор под x86 без libc, если всю ту же функциональность можно получить от хостового компилятора с опцией -ffreestanding.
maisvendoo
02.12.2017 21:44если всю ту же функциональность можно получить от хостового компилятора с опцией -ffreestanding.
У меня аналогичный вопрос. Всё нормально собирается хостовым компилятором с соответствующими опциями.
И ещё один вопрос — зачем NASM? Если есть as, вызываемый gcc автоматом, когда тот натыкается на ассемблерный исходник? Ну да, придется попотеть освоив AT&T-синтаксис, ну так это, во-первых несложно, а во вторых — синтаксис ассемблерных вставок и так AT&T. Зачем нужен плесневелый nasm, ради только intel-нотации?
Еще замечание, касательно qemu. Обязательно надо упомянуть о том, что нельзя категорически использовать опцию -enable-kvm. С ней отладка не будет работатьvodozhaba Автор
02.12.2017 22:24В хостовом компиляторе есть хостовая libgcc, которая может попытаться обратиться к не менее хостовому ядру, к тому же появляются лишние флаги вроде
-nostdinc
,-nostdlib
,-m32
, которые легко забыть.
По поводу NASM — это просто один из инструментов, применение которого не вызывает особых проблем. Можно было использовать GAS, FASM или TASM. А можно было NASM. Что использовать Вам — дело Ваше.
Про-enable-kvm
упомяну.a1ien_n3t
02.12.2017 23:50В хостовом компиляторе есть хостовая libgcc, которая может попытаться обратиться к не менее хостовому ядру, к тому же появляются лишние флаги вроде -nostdinc, -nostdlib, -m32, которые легко забыть.
Мне кажется, что когда настроить флаги стандартного компилятора проще и рациональнее чем собирать компилятор самому.
jcmvbkbc
02.12.2017 23:53В хостовом компиляторе есть хостовая libgcc, которая может попытаться обратиться к не менее хостовому ядру
Ну вообще-то нет, не может. Максимум — к функциям из libc, типа abort. Плюс в коде раскрутки стека при исключении есть проверка, специфичная для ОС, но исключения — это вообще отдельная тема, голого компилятора который вы построили для них тоже не хватит.vodozhaba Автор
03.12.2017 00:12Прошу прощения, имел в виду хостовые стандартные функции, а не ядро. Но, тем не менее, для этого нужна хостовая libc, а она может вызывать проблемы.
jcmvbkbc
03.12.2017 00:17тем не менее, для этого нужна хостовая libc
Вовсе нет, никто не запрещает вам определить abort, memcpy, memmove, memset и memcmp в своём коде, по мере необходимости.
maisvendoo
03.12.2017 09:38Всё решается двумя ключами
-nostdlib — не используем стандартных библиотек
-nostdinc — не используем стандартных заголовков
нет нужды собирать компилятор отдельно под задачу сборки ядра своей ос
Ваш пример пустого ядра я собирал таким makefile
#------------------------------------------------------
#
# Правила сборки кода ядра
#
#------------------------------------------------------
# Исходные объектные модули
SOURCES=init.o main.o
# Флаги компилятора языка C
CFLAGS=-nostdlib -nostdinc -fno-builtin -fno-stack-protector -m32 -g
# Флаги компоновщика
LDFLAGS=-T link.ld -m elf_i386
# Флаги ассемблера
ASFLAGS=--32
# Правило сборки
all: $(SOURCES) link
# Правило очистки
clean:
-rm *.o kernel
# Правило компоновки
link:
ld $(LDFLAGS) -o kernel $(SOURCES)
и все работалоvodozhaba Автор
03.12.2017 11:18С -nostdinc не будет весьма полезных заголовков, которые не требуют libc, таких как stddef.h и stdarg.h. Что легче, переписать stdarg.h или собрать компилятор?
maisvendoo
03.12.2017 11:48+1Переписать ашник. И вот почему: нельзя просто так взять и пересобрать компилятор. Он ещё должен тесты пройти, почитайте книгу LFS или вот этот материал и станет понятно, что гарантированно работоспособный компилятор и комплект binutils собирается не с наскока, а вдумчиво и долго с обязательным прохождением тестов. На моей машине сборка gcc с тестами заняла 2,5 часа, например + примерно столько же времени на чтение и подготовку
jcmvbkbc
03.12.2017 21:10Переписать ашник.
Не, вот это точно неправильно. Скопировать стандартный в отдельный сисрут — может быть. Переписать самому — точно нет.
станет понятно, что гарантированно работоспособный компилятор и комплект binutils собирается не с наскока, а вдумчиво и долго с обязательным прохождением тестов
Порекламирую crosstool-NG ещё раз. Бэкпортированные патчи из мэйнлайнов компонентов тулчейна и коллективное тестирование идут в комплекте.
a1ien_n3t
03.12.2017 12:03Правильно переписать. Пересобрать компилятор не правильно. Если вы пишите туториал. То ненужно в них советовать неправильные подходы.
jcmvbkbc
03.12.2017 21:03Этот makefile будет неустойчиво работать с make -j, потому что link не зависит от $(SOURCES) и перечислен наравне с ними в пререквизитах для all. Правильнее было бы так:
all: kernel kernel: $(SOURCES)
neit_kas
02.12.2017 21:49Спасибо. Довольно редки здесь статейки на такую тематику. Если Вы вдруг решите делать что-то вроде цикла статей, хотелось бы чего-нибудь и по поводу UEFI. В частности интересна тема написания загрузчиков (не ради самих загрузчиков, а ради того, чтобы знать, как оно робит), да и в целом как ОС используют UEFI.
VioletGiraffe
03.12.2017 01:24Плюсую. Это у меня один из проектов, до которых не дошли руки, но сильно чешутся — программирование под голое железо х86_64, начиная с загрузки своего приложения без использования стороннего кода.
CodeRush
03.12.2017 10:06Там почти не о чем писать, к сожалению, потому что любое EFI-приложение может выступать в качестве загрузчика, а написание простейшего EFI-приложения на Хабре уже не раз освещалось, и в тех примерах достаточно заменить в inf-файле DXE_DRIVER на UEFI_APPLICATION, и у вас получится нужный вам загрузчик.
Hixon10
03.12.2017 00:15Спасибо за статью! Крайне приятно видеть на хабре не статью о том, как надо верстать анимацию в андроид приложение на джаваскрипте, а о чём-то действительно серьезном, фундаментальном, так сказать.
maisvendoo
В свое время баловался этим, могу предложить свой цикл статей phantomexos.blogspot.com
Я дошел до многозадачности, реализации системных вызовов, работы в ring3 и простейшей командной оболочки. Но из-за хреновой реализации управления памятью проект зашел в тупик
maisvendoo
И да, я бы не рекомендовал крайне Джеймса Маллоя — его код содержит намеренные ошибки, а реализация многозадачности вообще сделана через одно место. В свое время намучился с этим туториалом. Лучше постить свои потуги на osdev.ru, там много толковых ребят, которые способны подсказать полезное.
Вот например так я пришел к реализации многозадачности
vodozhaba Автор
Ой. Как раз хотел сделать об этом оговорку, но по пути на Хабр на месте фразы «не лишён изъянов» потерялась ссылка на список подобных ошибок. Исправил. Спасибо!