[Из песочницы] Как выйти на путь разработки ОС
Данная статья служит одной простой цели: помочь человеку, который вдруг решил разработать свою операционную систему (в частности, ядро) для архитектуры 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
Образ диска
Процесс создания такового в разных ОС происходит по-своему, поэтому здесь я приведу отдельные инструкции.
$ 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):
Using default response p.
Partition number (1-4, default 1):
First sector (2048-N, default 2048):
Last sector, +sectors or +size{K,M,G,T,P} (2048-N, default N):
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 <точка монтирования>
В дальнейшем можно будет монтировать посредством
$ 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]
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 записей, каждая из которых состоит из атрибутов и символа. Белому тексту на черном фоне соответствует байт атрибутов 0×0F. Попробуем что-либо вывести при помощи заранее подготовленной строки:
#include
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 будет дожидаться отладчика и включит сетевую отладку.
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 — набор уроков по написанию ядра. Не лишен изъянов;
- Пишем свою операционную систему — теории мало, но можно посмотреть готовую реализацию некоторых непонятных вещей.