Всем привет! Давно не писал статей и вот наконец решил вернуться.
Эта статья будет не столько о написании собственной операционной системы, сколько о личных переживаниях, которые, возможно, знакомы и вам. Может быть, она повлияет на ваше отношение к обучению и поможет не совершить тех же ошибок, которые совершил я.
Введение
Я Python-программист: пишу сайты, Telegram-ботов, парсеры и десктопные приложения. Раньше я никогда всерьёз не задумывался о том, чтобы изучить Си. Однако в 2025 году, когда после колледжа я поступил в университет, этот язык начали преподавать в рамках учебной программы.
Си всегда казался мне сложным и совершенно ненужным. Но когда я познакомился с ним ближе, оказалось, что язык не настолько сложен, как я представлял. При этом у меня всё ещё оставалось непонимание: где именно я могу его применять?
Что бы я ни придумал, почти всё можно было реализовать на Python. Поэтому я решил воплотить в жизнь давнюю мечту — написать собственное ядро операционной системы.
Сейчас мы живём во время, когда нейросети окружают нас буквально повсюду. Если ты ими не пользуешься, то в глазах многих выглядишь человеком, отставшим от технологий.
После появления нейросетей и обесценивания самостоятельного поиска информации я начал замечать, что боюсь терять время. Раньше я мог разбираться в незнакомом языке или читать код другого программиста, написанный «правильными» руками, чтобы понять, как работает какая-нибудь формула. В то время это был расчёт верхней и нижней недели — вы наверняка знаете, о чём речь, если учились в колледже или вузе.
Сейчас же мне страшно потратить лишний час. Возможно, дело в том, что почти в 21 год я, как мне кажется, так ничего глобального и не достиг. А может быть, мы просто живём в такое время. Но факт остаётся фактом: я начал деградировать.
Все знают, что умение искать информацию для программиста порой важнее знания конкретного языка. Однако многие перестают развивать этот навык, что приводит к проблемам, о которых я расскажу ниже.
Главная проблема сверхдоступной информации — её цена.
Когда нейросеть отвечает на запрос, она, как ни странно, отвечает именно на него. При этом она часто отбрасывает информацию, которую считает лишней. В результате ты получаешь минимальный объём знаний, необходимый для выполнения конкретной задачи.
Кроме того, снижается ценность полученной информации, из-за чего она крайне быстро исчезает из памяти.
Когда ты самостоятельно ищешь что-то по интересующей теме, тебе приходится читать книги, документацию и обсуждения на форумах. Всё это требует времени и усилий, поэтому для мозга такая информация оказывается ценнее прямого и уже пережёванного ответа нейросети.
Начало разработки
Наконец перейдём к разработке ядра.
Разработка ядра — очень сложный процесс, который требует большого количества знаний и времени. Своё ядро я разрабатывал как на Windows 11, так и на Arch Linux — в моменты, когда у меня не было доступа к основному компьютеру и под рукой оставался только старенький ноутбук с Linux.
Поэтому всё, о чём я буду писать дальше, можно применять как на компьютере под управлением Windows, так и на Linux.
Также прошу не воспринимать эту статью как полноценное руководство по написанию ядра или изучению языка Си. Я и сам всё ещё учусь и во многом могу говорить или поступать неправильно. Тем не менее у меня всё это работает.
Что нам понадобится для написания ядра?
-
Среда разработки.
-
Загрузчик.
-
Линковщик.
Среда разработки
Если вы планируете работать на Windows, сначала вам понадобится MSYS2.
После установки необходимо добавить утилиты, которые мы будем использовать в дальнейшей разработке. Для этого запустите среду MSYS2 UCRT64 и выполните следующую команду:
pacman -Syupacman -S --needed base-devel git xorriso mtools nasm mingw-w64-ucrt-x86_64-toolchain mingw-w64-ucrt-x86_64-qemu
Проверка:
gcc --versionmake --versiongit --versionqemu-system-x86_64 --versionxorriso --versionnasm --version
gcc/make → собрать ядро
xorriso → собрать ISO
QEMU → запустить виртуальный компьютер
Если вы работаете на Linux, то просто установите все эти утилиты через терминал.
Загрузчик
Чтобы наша операционная система могла работать, её необходимо правильно запустить. Для этого нужен загрузчик, который загрузит ядро в оперативную память, инициализирует его и позволит процессору начать с ним работать.
Конечно, можно написать собственный загрузчик, но на старте я советую не тратить на это время, поскольку есть вероятность быстро перегореть и забросить проект.
Я выбрал загрузчик Limine. Скажу честно: его мне посоветовал ИИ, а теперь уже я советую его вам.
Именно в этом месте мои рассуждения из введения связываются с разработкой собственного ядра. Как я уже говорил, Си я не знаю, а время стараюсь экономить, поэтому решил воспользоваться помощью ИИ.
Нейросеть не писала код за меня. Она лишь рассказывала, с чего начать разработку, объясняла синтаксис Си и помогала выстраивать архитектуру операционной системы.
Чтобы начать работать с Limine, сначала необходимо скопировать его репозиторий в каталог external/ внутри проекта.
cd externalgit clone https://github.com/limine-bootloader/limine.gitcd limine
Теперь нам необходимо собрать загрузчик. Для этого используется команда make.
Из всех полученных файлов нам понадобится только limine.h. Его необходимо перенести в каталог kernel/ или в любую другую директорию, в которой вы собираетесь хранить исходный код ядра.
Далее нам потребуется конфигурационный файл, чтобы загрузчик понимал, как именно загружать наше ядро.
Для этого создайте каталог boot в корне проекта, а внутри него — файл limine.conf. В этот файл необходимо добавить следующий код:
timeout: 5default_entry: 1/SafeAssOS protocol: limine path: boot():/boot/kernel.elf
/SafeAssOS — это название нашей операционной системы.
protocol — протокол, по которому будет загружаться ядро. Мы выбираем limine, чтобы наше ядро не загружалось как Linux, Multiboot или EFI-приложение.
path — путь к файлу ядра внутри загрузочного ISO-образа. В нашем случае ядро будет находиться по адресу /boot/kernel.elf.
Линковщик
Следующий файл не относится непосредственно к коду ядра. Это инструкция для линковщика.
В руководстве OSDev Limine Bare Bones файл linker.ld выделяется отдельно. Он описывает, где секции ядра будут располагаться в памяти. Там также указано, что при использовании протокола Limine отдельный загрузочный stub на ассемблере не требуется, если используемый язык поддерживает соглашение о вызовах SysV x86.
Сначала создадим файл linker.ld в каталоге kernel/, а затем добавим в него следующий код:
OUTPUT_FORMAT(elf64-x86-64)OUTPUT_ARCH(i386:x86-64)ENTRY(kmain)PHDRS{ limine_requests PT_LOAD; text PT_LOAD; rodata PT_LOAD; data PT_LOAD;}SECTIONS{ . = 0xffffffff80000000; .limine_requests : { KEEP(*(.limine_requests_start)) KEEP(*(.limine_requests)) KEEP(*(.limine_requests_end)) } :limine_requests . = ALIGN(CONSTANT(MAXPAGESIZE)); .text : { *(.text .text.*) } :text . = ALIGN(CONSTANT(MAXPAGESIZE)); .rodata : { *(.rodata .rodata.*) } :rodata .note.gnu.build-id : { *(.note.gnu.build-id) } :rodata . = ALIGN(CONSTANT(MAXPAGESIZE)); .data : { *(.data .data.*) } :data .bss : { *(.bss .bss.*) *(COMMON) } :data /DISCARD/ : { *(.eh_frame*) *(.note .note.*) }}
Эта структура близка к linker script из руководства Limine Bare Bones. В нём ядро размещается в верхней части виртуального адресного пространства, отдельно выделяются секции .limine_requests, .text, .rodata, .data и .bss, а ненужные секции .eh_frame и .note отбрасываются.
Этот файл был полностью сгенерирован с помощью нейросети, поскольку он не относится непосредственно к коду ядра. Он нужен для того, чтобы загрузчик понимал, какие секции памяти необходимо выделить и по каким адресам они должны располагаться.
Инструкции для make
Теперь в корне проекта необходимо создать файл Makefile.
Makefile не является частью операционной системы. Это набор инструкций для утилиты make, который определяет, какие команды необходимо выполнить для сборки проекта.
После создания файла добавьте в него следующий код:
.RECIPEPREFIX := >PROJECT_NAME := SafeAssOSCC := clangLD := ld.lldQEMU := qemu-system-x86_64BUILD_DIR := buildISO_ROOT := iso_rootKERNEL_DIR := kernelKERNEL_ELF := $(BUILD_DIR)/kernel.elfISO := $(BUILD_DIR)/$(PROJECT_NAME).isoifeq ($(OS),Windows_NT)HOST_OS := windowsLIMINE_ROOT := external/limineLIMINE_BIN := $(shell find $(LIMINE_ROOT) -maxdepth 3 -type f \( -name "limine.exe" -o -name "limine" \) | head -n 1)QEMU_DISPLAY :=elseHOST_OS := linuxLIMINE_ROOT := /usr/share/limineLIMINE_BIN := $(shell command -v limine)QEMU_DISPLAY := -display gtkendifLIMINE_BIOS_CD := $(shell find $(LIMINE_ROOT) -type f -name "limine-bios-cd.bin" | head -n 1)LIMINE_BIOS_SYS := $(shell find $(LIMINE_ROOT) -type f -name "limine-bios.sys" | head -n 1)LIMINE_UEFI_CD := $(shell find $(LIMINE_ROOT) -type f -name "limine-uefi-cd.bin" | head -n 1)LIMINE_EFI := $(shell find $(LIMINE_ROOT) -type f -name "BOOTX64.EFI" | head -n 1)KERNEL_C := $(shell find $(KERNEL_DIR) -type f -name "*.c")KERNEL_OBJ := $(patsubst $(KERNEL_DIR)/%.c,$(BUILD_DIR)/%.o,$(KERNEL_C))CFLAGS := \-target x86_64-unknown-none \-std=c11 \-Wall \-Wextra \-ffreestanding \-fno-builtin \-fno-stack-protector \-fno-stack-check \-fno-lto \-fno-pic \-m64 \-march=x86-64 \-mabi=sysv \-mno-red-zone \-I$(KERNEL_DIR) \-I$(KERNEL_DIR)/libLDFLAGS := \-m elf_x86_64 \-nostdlib \-static \-z max-page-size=0x1000 \-T $(KERNEL_DIR)/linker.ld.PHONY: all kernel iso run clean check-limine infoall: isoinfo:> @echo "HOST_OS = $(HOST_OS)"> @echo "LIMINE_ROOT = $(LIMINE_ROOT)"> @echo "LIMINE_BIN = $(LIMINE_BIN)"> @echo "KERNEL_C = $(KERNEL_C)"> @echo "KERNEL_OBJ = $(KERNEL_OBJ)"check-limine:> @test -n "$(LIMINE_BIN)" || (echo "limine executable not found"; exit 1)> @test -n "$(LIMINE_BIOS_CD)" || (echo "limine-bios-cd.bin not found in $(LIMINE_ROOT)"; exit 1)> @test -n "$(LIMINE_BIOS_SYS)" || (echo "limine-bios.sys not found in $(LIMINE_ROOT)"; exit 1)> @test -n "$(LIMINE_UEFI_CD)" || (echo "limine-uefi-cd.bin not found in $(LIMINE_ROOT)"; exit 1)> @test -n "$(LIMINE_EFI)" || (echo "BOOTX64.EFI not found in $(LIMINE_ROOT)"; exit 1)$(BUILD_DIR):> mkdir -p $(BUILD_DIR)$(BUILD_DIR)/%.o: $(KERNEL_DIR)/%.c | $(BUILD_DIR)> mkdir -p $(dir $@)> $(CC) $(CFLAGS) -c $< -o $@kernel: $(KERNEL_OBJ)> $(LD) $(LDFLAGS) $(KERNEL_OBJ) -o $(KERNEL_ELF)iso: check-limine kernel> rm -rf $(ISO_ROOT)> mkdir -p $(ISO_ROOT)/boot> mkdir -p $(ISO_ROOT)/EFI/BOOT> cp $(KERNEL_ELF) $(ISO_ROOT)/boot/kernel.elf> cp boot/limine.conf $(ISO_ROOT)/boot/limine.conf> cp $(LIMINE_BIOS_CD) $(ISO_ROOT)/boot/limine-bios-cd.bin> cp $(LIMINE_BIOS_SYS) $(ISO_ROOT)/boot/limine-bios.sys> cp $(LIMINE_UEFI_CD) $(ISO_ROOT)/boot/limine-uefi-cd.bin> cp $(LIMINE_EFI) $(ISO_ROOT)/EFI/BOOT/BOOTX64.EFI> xorriso -as mkisofs \-b boot/limine-bios-cd.bin \-no-emul-boot \-boot-load-size 4 \-boot-info-table \--efi-boot boot/limine-uefi-cd.bin \-efi-boot-part \--efi-boot-image \--protective-msdos-label \$(ISO_ROOT) \-o $(ISO)> $(LIMINE_BIN) bios-install $(ISO)run: iso> $(QEMU) -cdrom $(ISO) -m 256M $(QEMU_DISPLAY)clean:> rm -rf $(BUILD_DIR) $(ISO_ROOT)
Разберём основные части файла.
PROJECT_NAME := SafeAssOS — название проекта.
Пути и используемые инструменты:
-
CC := clang— компилятор языка Си. -
LD := ld.lld— линковщик, используемый для сборки ядра с Limine. -
BUILD_DIR := build— каталог, в котором будут храниться результаты сборки. -
ISO_ROOT := iso_root— корневой каталог будущего ISO-образа. -
KERNEL_DIR := kernel— каталог с исходным кодом ядра. -
LIMINE_DIR := external/limine— путь к загрузчику Limine.
Сильно концентрироваться на этом файле мы не будем, поскольку он также не относится непосредственно к коду ядра. Makefile нужен только для того, чтобы система сборки понимала, как собирать наш проект.
Разработка ядра
Самая неинтересная часть статьи закончилась, и теперь мы наконец переходим к разработке ядра.
На данный момент написанное мной ядро обладает следующим функционалом:
-
вывод закрашенного прямоугольника;
-
вывод текста;
-
ввод с клавиатуры;
-
вывод данных;
-
создание файлов;
-
запись данных в файлы;
-
вывод содержимого файлов.
Начнём с главного файла — main.c.
Все файлы с исходным кодом будут располагаться в каталоге kernel/.
В начале файла обязательно необходимо добавить директиву:
#include "limine.h"
Limine будет обрабатывать наши запросы и заполнять структуры с ответами. Благодаря этому мы сможем получать необходимые данные, например информацию из видеопамяти, и работать с ними.
Далее в файл необходимо добавить следующий код:
__attribute__((used, section(".limine_requests")))static volatile uint64_t limine_base_revision[] = LIMINE_BASE_REVISION(6);__attribute__((used, section(".limine_requests_start")))static volatile uint64_t limine_requests_start_marker[] = LIMINE_REQUESTS_START_MARKER;__attribute__((used, section(".limine_requests")))static volatile struct limine_framebuffer_request framebuffer_request = { .id = LIMINE_FRAMEBUFFER_REQUEST_ID, .revision = 0};__attribute__((used, section(".limine_requests_end")))static volatile uint64_t limine_requests_end_marker[] = LIMINE_REQUESTS_END_MARKER;static void halt(void) { for (;;) { __asm__ volatile ("hlt"); }}
Этот код нужен для того, чтобы необходимые нам переменные и структуры не удалялись из памяти и помещались в соответствующие секции.
Функция halt() используется для «остановки» системы. Поскольку мы пишем ядро, после завершения главной функции программе некуда передавать управление. Поэтому нам необходимо остановить процессор до наступления следующего события.
Теперь создадим главную функцию ядра — kmain():
void kmain(void) { if (LIMINE_BASE_REVISION_SUPPORTED(limine_base_revision) == false) { halt(); } if (framebuffer_request.response == NULL) { halt(); } if (framebuffer_request.response->framebuffer_count < 1) { halt(); } halt(); }
В этой функции мы выполняем несколько проверок:
-
проверяем, передал ли Limine данные о видеопамяти:
if (framebuffer_request.response == NULL)
-
проверяем наличие хотя бы одного подключённого монитора:
if (framebuffer_request.response->framebuffer_count < 1)
-
проверяем, поддерживается ли используемая версия протокола Limine:
if (LIMINE_BASE_REVISION_SUPPORTED(limine_base_revision) == false)
На этом этапе мы уже можем собрать наше ядро и запустить его:
make cleanmake kernelmake isomake run
После запуска мы ничего не увидим, поскольку в нашем ядре пока не реализованы функции вывода на экран.
До этого момента мы выполняли самую простую часть работы: практически не затрагивали код самого ядра.
Теперь создадим модуль для работы с framebuffer и реализуем первый вывод на экран.
В каталоге kernel/ создадим два файла:
-
framebuffer.c; -
framebuffer.h.
В файл framebuffer.c добавим следующий код:
#include "framebuffer.h"#include "limine.h"#include <stdint.h>static struct limine_framebuffer *current_fb;void fb_init(struct limine_framebuffer *framebuffer) { current_fb = framebuffer;}void fb_put_pixel(uint64_t x, uint64_t y, uint32_t color) { if (current_fb == 0) { return; } if (x > current_fb->width || y > current_fb->height) { return; } uint8_t *pixel_addres = (uint8_t *)current_fb->address + y * current_fb->pitch + x * 4; volatile uint32_t *pixel = (volatile uint32_t *)pixel_addres; *pixel = color;}
Сначала создадим структуру с указателем на структуру нашего framebuffer. Указатель нужен потому, что мы будем напрямую изменять framebuffer, а не работать с его копией.
В первой функции мы просто инициализируем буфер, чтобы затем использовать его в остальных функциях.
Во второй функции реализуется закрашивание пикселя по координатам x и y. Для этого необходимо получить адрес нужного пикселя в памяти и записать его в указатель *pixel_address.
Сначала из структуры current_fb мы получаем адрес начала видеопамяти. Затем с помощью умножения y на current_fb->pitch переходим к нужной строке экрана. Значение pitch указывает количество байт, которое занимает одна строка.
После этого переходим к нужному пикселю внутри строки, умножая x на 4, поскольку один пиксель занимает четыре байта.
Чтобы закрасить пиксель, по полученному адресу необходимо записать значение цвета.
В результате мы получили функцию для закрашивания отдельного пикселя. Чтобы использовать её в main.c, сначала необходимо заполнить заголовочный файл framebuffer.h.
#ifndef FRAMEBUFFER_H#define FRAMEBUFFER_H#include <stdint.h>#include "limine.h"void fb_init(struct limine_framebuffer *framebuffer);void fb_put_pixel(uint64_t x, uint64_t y, uint32_t color);#endif
Теперь переходим к main.c и после нашего кода (перед вызовом halt();) пишем следующее:
struct limine_framebuffer *fb = framebuffer_request.response->framebuffers[0];fb_init(fb);fb_put_pixel(4, 4, 0x80FF0000);
Теперь, если приглядеться, то можно увидеть что на черном фоне появилась красная точка.
Если можно выводить пиксель, то можно рисовать фигуры. Следующим этапом мы нарисуем прямоугольник. Создаем функцию fb_fill_rect и пишем такой код:
void fb_fill_rect(uint64_t x, uint64_t y, uint64_t width, uint64_t height, uint32_t color) { uint64_t end_x = x + width; uint64_t end_y = y + height; for (uint64_t py = y; py < end_y; py++) { for (uint64_t px = x; px < end_x; px++) { fb_put_pixel(px, py, color); } }}
Здесь у функции появляются параметры ширины и высоты прямоугольника. Чтобы его нарисовать, нам необходимо знать начальные и конечные координаты.
С помощью первого цикла мы перемещаемся по координате y до конечной точки, а с помощью второго, вложенного цикла — по координате x.
Внутри второго цикла вызывается функция закрашивания отдельного пикселя. В результате она последовательно закрашивает всю область между указанными координатами.
Как и функцию закрашивания пикселя, функцию рисования прямоугольника необходимо вызвать внутри kmain(). После сборки и запуска ядра мы получим следующий результат:
Заключение
Теперь вы умеете собирать собственное ядро и писать для него первые функции.
Если я увижу, что эта тема вам интересна, то выпущу вторую часть. В ней я расскажу, как реализовать вывод текста, добавить форматированный вывод, создать подобие терминала и использовать «встроенную» библиотеку, которую я начал писать специально для этого ядра.
ссылка на оригинал статьи https://habr.com/ru/articles/1060354/