Разработка операционной системы с нуля на C и Assembler

Курс охватывает полный цикл создания ОС: от написания загрузчика и настройки GDT до реализации многозадачности и HAL [habr.com](https://habr.com/ru/articles/935058). Вы научитесь работать с видеопамятью, прерываниями и управлением памятью, используя связку C и Assembler [habr.com](https://habr.com/ru/articles/939698).

1. Настройка окружения, создание загрузчика (Bootloader) и переход в защищенный режим

Настройка окружения, создание загрузчика (Bootloader) и переход в защищенный режим

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

В этой статье мы подготовим рабочий инструментарий, напишем наш первый загрузочный сектор на Assembler, разберем, как компьютер включается, и переведем процессор из реального режима (Real Mode) в защищенный режим (Protected Mode).

Инструментарий и настройка окружения

Для разработки ОС нам потребуется Linux. Это стандарт де-факто для системного программирования благодаря мощным утилитам командной строки. Если у вас Windows, рекомендую использовать WSL2 или виртуальную машину.

Нам понадобятся три главных инструмента:

  • NASM — ассемблер. Он превращает наш код на языке ассемблера в машинные инструкции.
  • GCC — компилятор C. Понадобится нам чуть позже для написания ядра.
  • QEMU — эмулятор. Он позволит запускать нашу ОС в окне, не перезагружая реальный компьютер и не рискуя стереть данные на жестком диске.
  • Установка в Ubuntu/Debian:

    Теория загрузки: BIOS и MBR

    Когда вы нажимаете кнопку питания, происходит магия, называемая POST (Power-On Self-Test). После проверки железа BIOS (Basic Input/Output System) ищет загрузочное устройство. Как BIOS понимает, что диск является загрузочным?

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

    > BIOS считывает носители на наличие загрузочной сигнатуры - слова 0х55АА по смещению 0x1FE. Если она присутствует, то первые 512 байт с носителя загружается в ОЗУ по адресу 0х7С00. > > habr.com

    Адресация памяти в Реальном режиме (Real Mode)

    При старте процессоры семейства x86 (даже самые современные Intel Core i9 или AMD Ryzen) работают в режиме совместимости с процессором 8086. Этот режим называется Real Mode.

    В этом режиме процессор 16-битный. Это накладывает серьезные ограничения:

  • Доступно только 1 МБ оперативной памяти.
  • Нет защиты памяти (любая программа может стереть код ядра).
  • Используется сегментная адресация.
  • Чтобы получить физический адрес в памяти, процессор использует два значения: Сегмент и Смещение. Формула вычисления физического адреса выглядит так:

    Где:

  • — итоговый линейный адрес в оперативной памяти.
  • — значение в сегментном регистре (например, ds, cs), указывающее начало блока памяти.
  • — множитель (сдвиг влево на 4 бита), так как сегменты выровнены по границам параграфов.
  • — смещение внутри сегмента.
  • Например, если ds = 0x1000, а смещение bx = 0x0050, то физический адрес будет:

    Где:

  • — это значение сегмента , умноженное на 16 (дописан ноль справа в hex).
  • — смещение.
  • — результирующий адрес.
  • Пишем первый загрузчик

    Создадим файл boot.asm. Наша задача — вывести текст на экран, используя прерывания BIOS, и бесконечно зациклить процессор.

    > Современные загрузчики представляют собой сложные программы, способные загружать множество операционных систем различными способами. Я решил начать изучение с максимально простого подхода. > > habr.com

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

    Итоги

  • BIOS и MBR: Компьютер начинает выполнение кода с адреса 0x7C00, если находит сигнатуру 0xAA55 в конце первого сектора диска.
  • Реальный режим: Процессор стартует в 16-битном режиме с ограничением памяти в 1 МБ и сегментной адресацией.
  • Защищенный режим: Для полноценной работы ОС необходим переход в 32-битный режим, который требует настройки таблицы GDT и манипуляций с регистром CR0.
  • Потеря BIOS: После перехода в защищенный режим прерывания BIOS недоступны, и драйверы (например, для вывода на экран) нужно писать самостоятельно.
  • 2. Архитектура ядра: инициализация GDT и запуск кода на C

    Архитектура ядра: инициализация GDT и запуск кода на C

    В предыдущей статье мы совершили прорыв: перевели процессор в 32-битный защищенный режим. Однако мы столкнулись с суровой реальностью — писать сложную логику операционной системы на чистом Assembler крайне трудоемко и неэффективно.

    В этой статье мы перейдем к разработке на языке C. Для этого нам придется решить архитектурную задачу: научить наш загрузчик считывать ядро с диска в память и корректно передавать ему управление, предварительно настроив стек и таблицу дескрипторов (GDT).

    Проблема 512 байт

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

    Поэтому мы изменим архитектуру загрузки:

  • Bootloader (512 байт): Загружается BIOS. Его задача — считать следующие секторы диска (где лежит наше ядро) в оперативную память, пока мы еще в реальном режиме.
  • Kernel Entry: Небольшая прослойка на Assembler, которая вызовет функцию main из C.
  • Kernel (C): Основной код нашей ОС.
  • Чтение ядра с диска (BIOS Interrupts)

    Пока процессор находится в реальном режиме (Real Mode), мы можем использовать прерывания BIOS. Для чтения с диска используется прерывание int 0x13.

    Нам нужно загрузить ядро по определенному адресу. Договоримся, что наше ядро будет лежать в памяти по адресу 0x1000. Это безопасное место: оно выше таблицы прерываний BIOS и ниже видеопамяти.

    Функция чтения диска на Assembler выглядит так:

    > Современные загрузчики представляют собой сложные программы, способные загружать множество операционных систем различными способами. Я решил начать изучение с максимально простого подхода. > > habr.com

    Глобальная таблица дескрипторов (GDT) в деталях

    В прошлой статье мы использовали «магические числа» для создания GDT. Чтобы писать ядро осознанно, нужно понимать структуру дескриптора сегмента. В защищенном режиме каждый дескриптор занимает ровно 8 байт (64 бита).

    Структура дескриптора выглядит пугающе, но она логична. Это наследие попыток Intel сохранить совместимость с 16-битными системами.

    Байт доступа (Access Byte)

    Самая важная часть дескриптора — байт доступа. Он определяет права сегмента. Рассмотрим его структуру:

    Где:

  • (Present) — 1 бит. Если 1, сегмент присутствует в памяти.
  • (Privilege) — 2 бита. Уровень привилегий (Ring). 00 = ядро (Ring 0), 11 = пользователь (Ring 3).
  • (Descriptor Type) — 1 бит. 1 для кода/данных, 0 для системных сегментов.
  • (Executable) — 1 бит. 1 для кода (можно выполнять), 0 для данных.
  • (Direction/Conforming) — 1 бит. Для данных: направление роста (0 — вверх). Для кода: возможность выполнения с низкими привилегиями.
  • (Read/Write) — 1 бит. Для кода: можно ли читать (1). Для данных: можно ли писать (1).
  • (Accessed) — 1 бит. Процессор ставит 1, когда обращается к сегменту.
  • Согласно rus-linux.net, GDT и IDT являются таблицами дескрипторов. Это массивы флагов и однобитовых значений, описывающих работу системы сегментации.

    Для нашего ядра мы создаем два перекрывающихся сегмента (Flat Model), охватывающих всю память (4 ГБ):

  • Code Segment: 10011010b (Present, Ring0, Code, Executable, Readable).
  • Data Segment: 10010010b (Present, Ring0, Data, Writable).
  • Стек и вызов C-кода

    Язык C не может работать без стека. Локальные переменные и адреса возврата функций хранятся именно там. Перед передачей управления коду на C мы обязаны настроить регистры esp (Stack Pointer) и ebp (Base Pointer).

    В файле boot.asm после перехода в защищенный режим:

    bash gcc -ffreestanding -c kernel.c -o kernel.o bash ld -o kernel.bin -Ttext 0x1000 --oformat binary kernel.o bash cat boot.bin kernel.bin > os-image.bin ``

    Теперь os-image.bin можно запустить в QEMU. Если вы увидите букву 'X' в левом верхнем углу — вы успешно запустили C-код в своем ядре!

    > KintsugiOS — это минималистичная x86 операционная система, написанная на (N)ASM и C, созданная в образовательных целях для глубокого понимания принципов работы операционных систем. > > github.com

    Итоги

  • Загрузка ядра: Загрузочный сектор (512 байт) слишком мал для ядра. Мы используем прерывание BIOS int 0x13 в реальном режиме, чтобы считать основной код ядра с диска в память по адресу 0x1000.
  • GDT: Для работы в защищенном режиме необходима корректная настройка GDT. Байт доступа определяет права сегментов (код vs данные, уровень привилегий Ring 0 vs Ring 3).
  • Стек: Перед вызовом функций C необходимо инициализировать регистр esp, так как скомпилированный код полагается на стек для хранения локальных переменных.
  • Freestanding C: Код ядра компилируется без стандартных библиотек. Мы работаем с памятью напрямую, используя указатели (например, 0xb8000` для вывода текста).