Ещё полгода назад я всерьёз считал, что собственная операционная система — слишком большой проект для одного человека, который может заниматься ею только в свободное от основной работы время. Для старта нужно было одновременно разбираться в AArch64, MMU, исключениях, прерываниях, планировании, ELF, CPIO — и при этом как-то отлаживать код в среде, которой ещё толком нет.
Сейчас мой экспериментальный проект genrt загружает high-half-ядро в QEMU, запускает вытесняющий планировщик, пользовательские процессы на EL0 и интерактивную командную оболочку из initramfs. В этой статье я расскажу, как пришёл к такому результату, где именно мне помогли ИИ-инструменты и почему это всё равно не было историей про «написал промпт — получил ОС». Во второй половине статьи разберу архитектуру genrt, последовательность загрузки и ограничения, которые пока не позволяют называть систему полноценной RTOS.
Что получилось за 4 месяца
Начну с результата. genrt — небольшая экспериментальная операционная система для AArch64. Ядро написано преимущественно на Rust, ранняя загрузка и переключение контекста — на AArch64-ассемблере, а пользовательские программы — на freestanding C.
На момент релиза v0.1.0-alpha.2 в проекте около 13 тысяч строк кода ядра, 4,7 тысячи строк инфраструктуры и тестов и ещё примерно 600 строк userspace. Само по себе количество строк мало о чём говорит, но масштаб уже достаточный, чтобы пройти полный путь от собственной точки входа до запуска отдельных ELF-программ.
Пока целевая платформа у проекта одна: одноядерная виртуальная машина QEMU virt с Cortex-A72 и GICv2. Сейчас genrt умеет:
-
загружаться на AArch64 и переходить из физически адресуемого boot-кода в high-half-ядро;
-
строить отдельные виртуальные адресные пространства ядра и пользовательских процессов;
-
вытесняюще планировать потоки по алгоритму Round Robin;
-
обрабатывать таймерные прерывания, исключения из EL0 и ввод с UART;
-
монтировать read-only initramfs в формате CPIO
newc; -
загружать статические ELF-файлы и запускать их на EL0;
-
выполнять цепочку
fork → execve → waitpid; -
запускать интерактивную командную оболочку и небольшие утилиты
echo,cat,lsиpwd; -
проходить воспроизводимые QEMU-тесты локально и в CI.
Долгосрочное направление проекта — эксперименты в области жёсткого реального времени. Но считать текущую версию genrt полноценной RTOS было бы преждевременно: пока нет приоритетного планирования, измеренных верхних границ задержек и даже поддержки реальной аппаратной платформы. Это цель развития, а не характеристика текущего релиза.
Пример загрузки и работы в shell
$ cargo xtask run-aarch64...[INFO ] memory: switched to runtime kernel page tables; TTBR0 cleared[INFO ] initramfs: mounted 8 files, 3 directories[INFO ] sched: irq-return preemptive switching initialized[INFO ] init: spawning first EL0 process[INFO ] init: loading /init from initramfsgenrt shell> lsbinetchello.txtinitreadme.txt> cat /etc/bannergenrt initramfs> cd bin> pwd/bin> lscatecholspwd> exit[INFO ] init: user process exited code=0
Почему я вообще взялся за свою ОС
С Linux я познакомился около пяти лет назад. Для меня это стало входом не только в системное программирование, но и в аппаратную инженерию: постепенно операционная система перестала выглядеть как набор пользовательских программ и превратилась в большой слой, который связывает код с процессором, памятью и периферией.
Тогда же появилась мысль когда-нибудь написать собственную ОС. Правда, довольно долго она оставалась именно мыслью. Причины были приблизительно следующими:
-
нужно было освоить слишком много направлений системного программирования, причём многие из них зависят друг от друга;
-
ошибки в ядре трудно локализовать, особенно пока нет привычных средств диагностики;
-
сделать проект основной работой я не мог;
-
команды, готовой вместе пройти этот путь, рядом не было;
-
последовательное изучение всего необходимого стека заняло бы больше свободного времени, чем я был готов выделить.
И дело не только в количестве кода. Даже небольшой этап вроде запуска первого процесса требует изучить теорию, посмотреть, как похожую задачу решают другие разработчики, определить границы подсистем, написать код и тесты, отладить результат и не забыть обновить документацию.
ИИ не убрал ни один из этих этапов, но он сильно сократил расстояние между вопросом и результатом, который можно собрать, запустить и проверить. Именно это сделало старую идею реалистичной.
Как ИИ встроился в разработку
В моём процессе браузерный чат и кодовые агенты решают разные задачи. Чат нужен в основном до того, как начинается изменение репозитория: с его помощью я изучаю незнакомую или плохо знакомую область знания, обсуждаю варианты архитектуры и превращаю идею в ясное техническое задание. Агенты подключаются позже — читают реальный код, вносят изменения, запускают проверки и разбирают результат.
Чат как интерактивный учебник и собеседник по архитектуре
Больше всего времени чат сэкономил мне там, где обычный поиск быстро превращается в десятки вкладок. Например, при изучении AArch64 я мог начать с общего вопроса про уровни исключений, затем перейти к конкретным регистрам, таблице векторов, устройству GICv2 или последовательности включения MMU — и уточнять каждое непонятное место, не теряя общий контекст.
Обычно я использовал чат, чтобы:
-
разбирать архитектуру AArch64 — от системных регистров до MMU, GICv2 и обработки исключений;
-
сравнивать несколько вариантов устройства новой подсистемы и заранее обсуждать их слабые места;
-
смотреть, как похожие проблемы решены в других ОС, не копируя чужую архитектуру целиком;
-
делить большой замысел на небольшие этапы;
-
превращать выбранное решение в техническое задание с инвариантами и критериями приёмки;
-
анализировать результаты отладки, когда наблюдаемое поведение не совпадало с моей моделью системы.
Для меня особенно важен именно диалоговый режим. При работе с новой темой я часто ещё не знаю, какой поисковый запрос будет правильным. В чате можно начать с неточного вопроса и постепенно дойти до модели, которой уже хватает для конкретного решения в коде.
При этом ответ модели для меня не является архитектурным решением сам по себе. Он скорее помогает быстрее собрать варианты и увидеть вопросы, которые я мог пропустить. Окончательный выбор я всё равно сверяю с кодом и текущими ограничениями проекта.
Агенты как разработчики, тестировщики и ревьюеры
Для работы непосредственно с репозиторием я использовал Codex. Со временем у меня сложилось несколько ролей:
|
Роль |
Что делает |
|---|---|
|
Исследователь |
Находит реальные точки входа, вызовы и зависимости между затронутыми подсистемами |
|
Архитектор |
Формулирует границы решения, инварианты, варианты реализации и необходимые ADR |
|
Разработчик |
Вносит изменения в основной код по согласованному техническому заданию |
|
Тестировщик |
Проектирует проверки, добавляет тесты и разбирает сбои |
|
Ревьюер |
Ищет дефекты, нарушения инвариантов, архитектурных границ и стиля проекта |
Это не означает, что пять агентов одновременно правят одни и те же файлы. Такой режим быстро привёл бы к конфликтам и размыванию ответственности. Исследование и проектирование можно выполнять параллельно, но основной код меняет один разработчик. После этого результат отдельно смотрят тестировщик и ревьюер.
Типичный цикл выглядит примерно так:
идея этапа ↓изучение теории и существующего кода ↓архитектурное решение и критерии приёмки ↓реализация одним агентом ↓format / lint / build / QEMU-контракты ↓независимое ревью ↓исправления → повторная проверка → merge
На практике цикл редко проходит идеально с первого раза. Ревью может обнаружить, что решение нарушает инвариант планировщика, QEMU-тест — поймать ошибку на границе ядра и userspace, а отладка — показать, что исходное техническое задание было неполным. Тогда задача возвращается на предыдущий этап, но уже с конкретной ошибкой, которую можно воспроизвести, а не с общим ощущением «что-то не работает».
Какие решения я не делегирую
ИИ заметно ускоряет работу, но не определяет направление проекта. Я по-прежнему выбираю следующий этап, ограничиваю объём изменения, принимаю архитектурные компромиссы и решаю, можно ли считать результат готовым.
Особенно осторожно я отношусь к изменениям в управлении памятью, обработке исключений и переключении потоков. Там правдоподобная ошибка может успешно скомпилироваться и проявиться намного позже. Поэтому важнее не качество отдельного промпта, а наличие явных инвариантов, небольших изменений и тестов, которые проходят через затронутые границы системы.
Иными словами, ИИ расширил объём работы, который я могу сделать один, но не снял с меня ответственность за результат.
Что пришлось построить вокруг агентов
Чем дольше жил проект, тем заметнее становилась одна проблема: агенту недостаточно открыть репозиторий и сказать «реализуй правильно». Он может предложить вполне разумное локальное решение, которое не соответствует уже принятой архитектуре, выделяет память в неподходящем контексте или незаметно меняет контракт между подсистемами.
Поэтому часть инфраструктуры genrt посвящена не самой ОС, а тому, чтобы следующий исполнитель — человек или агент — мог восстановить правила проекта из репозитория.
Правила в AGENTS.md
В корневом AGENTS.md лежат общие правила работы:
-
какие файлы считаются источниками проектного контекста и в каком порядке их читать;
-
какие архитектурные и RT-инварианты нельзя нарушать;
-
какими командами собирать и проверять проект;
-
как разделяется работа между агентами;
-
когда нужно обновлять документацию и ADR;
-
что считается завершённой задачей.
Для отдельных частей дерева правила уточняются во вложенных файлах. Например, kernel/AGENTS.md описывает допустимые контексты выполнения, запрещает аллокации в IRQ и требует держать доступ к системным регистрам, архитектурным инструкциям и MMIO внутри AArch64-слоя.
Такая вложенность оказалась удобной. Агент, который меняет echo, не обязан загружать в контекст все детали планировщика. Но при работе с IRQ-обработчиком локальные ограничения ядра уже нельзя случайно пропустить.
Skills как готовые рабочие процедуры
AGENTS.md отвечает на вопрос «какие правила здесь действуют», а skills — «как обычно выполнять такой класс задач». В описании агентного процесса сейчас зафиксировано шесть таких процедур:
-
genrt-change-workflow— путь нетривиального изменения от исследования до закрытия задачи; -
genrt-qemu-test— добавление и изменение QEMU-контрактов и тестового машинного протокола; -
genrt-verify— выбор проверок в зависимости от риска изменения; -
genrt-review— независимое ревью с упором на конкретные дефекты и доказательства; -
genrt-adr— создание и замещение архитектурных решений; -
genrt-docs-sync— поиск документации, которую нужно обновить вместе с кодом.
По сути, skill — это короткая воспроизводимая инструкция или чек-лист. Это не отдельная роль и не обязательный ритуал для каждой правки. Например, изменение только документации не требует полного запуска QEMU, а изменение syscall ABI должно одновременно затронуть userspace-заголовки, документацию и интеграционные контракты.
Память проекта вместо памяти конкретного чата
Всё, что должно пережить отдельную сессию с моделью, хранится в репозитории:
-
memory/current-state.mdописывает уже реализованные возможности и текущие границы; -
memory/invariants.mdсобирает сквозные инварианты; -
memory/decisions/содержит ADR и историю архитектурных решений; -
README внутри подсистем объясняют, кто владеет ресурсами и как выглядит их жизненный цикл.
Так новый агент может восстановить состояние проекта из версионируемых файлов. Это надёжнее, чем рассчитывать на историю старого диалога или на то, что я сам вспомню все причины решения, принятого несколько месяцев назад.
Одна точка входа для сборки и тестов
Единой точкой входа в сборку, запуск, тестирование и выпуск артефактов служит xtask:
# Проверить форматирование, lint, тесты инструментов и обычную сборкуcargo xtask check# Показать список QEMU-контрактов и запустить ихcargo xtask test-aarch64 --listcargo xtask test-aarch64# Выполнить полный набор локальных и CI-проверокcargo xtask ci# Собрать образ и запустить интерактивную командную оболочкуcargo xtask run-aarch64
QEMU поднимает одноядерную AArch64-платформу без сети и графики. Ядро, DTB и initramfs загружаются как отдельные артефакты:
Упрощённая команда запуска QEMU
qemu-system-aarch64 \ -machine virt,gic-version=2 \ -cpu cortex-a72 \ -smp 1 \ -display none \ -monitor none \ -nic none \ -serial stdio \ -no-reboot \ -kernel target/aarch64-unknown-none-softfloat/debug/genrt-aarch64.elf \ -device loader,file=target/aarch64-unknown-none-softfloat/debug/qemu-virt.dtb,addr=0x40000000 \ -device loader,file=target/aarch64-unknown-none-softfloat/debug/initramfs.cpio,addr=0x47000000,force-raw=on
Одни и те же команды запускаются локально и в CI. Это принципиальный момент для агентной разработки: после изменения агент должен не написать, что код «выглядит рабочим», а выполнить тот же сквозной сценарий, который будет обязательным перед слиянием.
Четыре QEMU-контракта
Проверять систему по произвольным строкам обычного лога оказалось ненадёжно: текст сообщения может измениться, хотя поведение останется прежним. Поэтому в тестовых образах используется отдельный машинный протокол GTRT/1, а релизные артефакты дополнительно проверяются на отсутствие его маркеров.
Сейчас есть четыре интеграционных контракта:
|
Контракт |
Что он проверяет |
|---|---|
|
|
Планировщик, таймеры, ожидания, mailbox, аллокаторы и гонки между событием и тайм-аутом внутри специального тестового ядра |
|
|
Классификацию исключения из EL0 и завершение только процесса-источника без падения ядра |
|
|
Цепочку |
|
|
Релизную командную оболочку, разбор |
Эти тесты, конечно, не доказывают отсутствие ошибок во всей системе. Но они дают короткую и воспроизводимую обратную связь: изменение проходит через те же границы, которые оно затронуло.
Машинный протокол, состав тестовых образов и раннер на стороне хоста подробнее описаны в docs/testing.md.
Теперь о самой ОС
Агентная инфраструктура — не цель проекта сама по себе. Теперь о том, что получилось внутри ОС. Архитектурно genrt разделена на AArch64-зависимый слой, общее ядро и freestanding userspace. Более формальное описание текущих возможностей хранится в memory/current-state.md.
AArch64-слой
Архитектурный слой отвечает за раннюю загрузку, таблицу векторов исключений, вход и выход из trap-контекста, настройку MMU и доступ к системным регистрам. Там же находятся низкоуровневые части драйверов GICv2, PL011 и ARM Generic Timer.
Я старался не разносить архитектурные детали по общему коду ядра. Планировщик, менеджер памяти или подсистема процессов должны оперировать своими абстракциями, а не напрямую читать TTBR0_EL1 или программировать регистр таймера. Для единственной платформы это может казаться лишним слоем. Зато уже сейчас понятно, какой код придётся менять/дорабатывать при переносе на другую AArch64-плату.
Планировщик, время и ожидания
Планировщик пока довольно простой: одноядерный Round Robin без приоритетов. Единственная планируемая сущность — поток, который может находиться в состояниях Ready, Running, Blocked или Exited.
Таблица потоков и очередь готовых потоков имеют фиксированную вместимость. Слоты со временем переиспользуются, поэтому одного индекса недостаточно: идентификатор потока содержит ещё и номер поколения. Благодаря этому устаревшая ссылка не сможет случайно обратиться к новому потоку, занявшему тот же слот.
Вытеснение выполняется на возврате из IRQ. ARM Generic Timer работает в режиме one-shot: ядро каждый раз программирует его на ближайшее событие — окончание кванта, пробуждение после sleep или тайм-аут ожидания. События времени лежат в заранее выделенной deadline queue на основе минимальной кучи; сам IRQ-путь не меняет размер контейнеров и не выделяет память.
Для блокирующих операций используется WaitToken, в котором есть поколение потока и номер конкретного ожидания. Это защищает от неприятного класса ошибок: поздний тайм-аут или повторное пробуждение не должны воздействовать на уже следующее ожидание потока.
В ядре также есть блокирующий mailbox с тайм-аутом. Пока он недоступен из userspace, но используется для проверки общих механизмов блокировки и пробуждения.
Управление памятью
Во время ранней загрузки AArch64-код строит временные таблицы страниц, которых достаточно для включения MMU и перехода в high-half. После запуска аллокатора физических страниц ядро создаёт постоянные отображения TTBR1, переключается на них и очищает TTBR0 до запуска первого пользовательского процесса.
У каждого процесса собственное TTBR0-пространство. При fork пользовательская память копируется сразу. Copy-on-write и demand paging в проекте пока нет — это дороже в момент создания процесса, зато в текущей модели не переносит скрытое копирование и выделение памяти на более поздний page fault.
Размер кучи ядра задаётся при загрузке. Аллокации допустимы во время bootstrap и в обычном контексте потока, но запрещены в IRQ, ядре планировщика, обработчиках событий времени и на пути передачи trap frame между обработчиком исключения и планировщиком.
Здесь хорошо видно направление проекта: я не пытаюсь максимально эффективно использовать каждый байт памяти. На данном этапе важнее понимать, где и когда ресурс может понадобиться, и не вносить неожиданную работу в невытесняемые участки.
Процессы, ELF и системные вызовы
Таблица процессов тоже ограничена заранее. Процесс владеет адресным пространством, файловыми дескрипторами, текущим каталогом, отношениями родитель–потомок и статусом завершения. Пока у одного процесса может быть только один пользовательский поток.
ELF loader принимает статический AArch64 ELF, проверяет заголовки, отображает загружаемые сегменты, строит пользовательский стек и передаёт управление на EL0 через ERET. Динамического загрузчика, ELF interpreter и libc в системе нет.
Syscall ABI точнее называть Linux-подобным и POSIX-ориентированным, а не POSIX-совместимым. В текущем диспетчере реализованы open, read, write, close, fork, execve, waitpid, getdents64, chdir, getcwd и exit, но только в тех вариантах, которые сейчас нужны проекту.
Например, getdents64 — Linux-специфичный интерфейс, waitpid принимает только конкретный положительный PID при options == 0, а запись в обычные файлы пока не поддерживается. Совпадение номеров и сигнатур отдельных вызовов ещё не делает систему POSIX-совместимой — и в документации я стараюсь не создавать такого впечатления.
Initramfs и userspace
QEMU загружает несжатый CPIO-архив newc в заранее зарезервированный диапазон физической памяти. При старте ядро один раз разбирает архив и строит индекс файлов и каталогов в read-only ramfs.
Полноценной VFS здесь пока нет. Нет writable-файлов, mount API, символьных ссылок, блочных устройств, page cache и большинства привычных метаданных. Файловая подсистема поддерживает абсолютные и относительные пути, текущий каталог, последовательное чтение и Linux-подобные записи dirent64. У каждого процесса есть таблица на 32 файловых дескриптора; первые три заняты stdin, stdout и stderr.
В обычном образе /init — это интерактивная командная оболочка. В /bin лежат отдельные статически слинкованные программы:
-
echoвыводит аргументы вstdout; -
catчитает файл; -
lsперечисляет содержимое каталога; -
pwdпечатает текущий каталог.
Это, разумеется, сильно урезанные версии привычных утилит. cd реализована внутри командной оболочки (built-in utility): если запустить её как отдельный дочерний процесс, изменится каталог только этого процесса, а после его завершения родительская оболочка останется на прежнем месте.
Ввод приходит через ограниченный кольцевой буфер, который заполняет IRQ-драйвер PL011. Полноценной TTY-подсистемы и line discipline пока нет, поэтому интерфейс остаётся намеренно простым.
Как система доходит от _start до оболочки >
Вся цепочка загрузки в сокращённом виде выглядит так:
xtask → QEMU → _start → boot page tables → high-half → kernel_main → scheduler → /init → shell
Полноценного загрузчика здесь нет: его роль частично выполняют xtask и фиксированная конфигурация QEMU. Последовательность получается такой:
-
xtaskсобирает kernel ELF и userspace, получает нормализованный DTB для QEMUvirtи упаковывает initramfs. -
QEMU размещает DTB по физическому адресу
0x4000_0000, точку входа ядра — по0x4008_0000, а initramfs — по0x4700_0000. -
Низко расположенный trampoline
_startоставляет активным только boot CPU, подготавливает физический bootstrap stack и вызывает Rust-код до включения MMU. -
Ранний Rust-код читает DTB по фиксированному адресу, строит временные таблицы TTBR0/TTBR1, включает MMU и переходит в high-half со смещением
0xffff_0000_0000_0000. -
rust_entryустанавливаетVBAR_EL1, восстанавливает сведения о платформе и инициализирует UART, GICv2 и ARM Generic Timer. -
kernel_mainзапускает frame allocator и фиксированную кучу, создаёт постоянные TTBR1-таблицы, очищает TTBR0, монтирует initramfs и подготавливает планировщик. -
Планировщик запускает
kernel_init_thread. Уже из этого потока ядро читает/init, создаёт для него TTBR0-пространство и начальный EL0-контекст, а затем блокируется в ожидании завершения процесса. -
Планировщик выбирает пользовательский поток, активирует его TTBR0 и через
ERETпередаёт управление в/init. Командная оболочка запускает внешние программы по цепочкеfork → execve → waitpid.
Где система пока заканчивается
genrt уже проходит путь от boot-кода до интерактивного пользовательского окружения, но остаётся исследовательским проектом. Самые заметные ограничения сейчас такие:
-
только один CPU: нет SMP-планирования, IPI, TLB shootdown и межъядерных блокировок;
-
только QEMU
virt: реальная ARM-плата пока не поддерживается; -
read-only initramfs вместо VFS, writable-файловой системы и постоянного хранилища;
-
небольшой syscall ABI без сигналов, сокетов,
mmapи многих привычных POSIX-интерфейсов; -
один пользовательский поток на процесс и полное копирование памяти при
fork; -
нет libc, динамического загрузчика, TLS и стандартной userspace-среды;
-
Round Robin без приоритетов, priority inheritance и измеренной верхней границы latency;
-
нет сохранения FP/SIMD-контекста, а само ядро собирается для soft-float target;
-
фиксированное количество процессов, потоков, файловых дескрипторов и событий времени;
-
UART вместо TTY и полноценного терминала.
Не всё из этого я воспринимаю как временные слабые стороны. Фиксированные лимиты и отсутствие отложенного выделения памяти отчасти выбраны сознательно: они удерживают систему небольшой и делают владение ресурсами более явным. А вот SMP, реальное оборудование, приоритетный планировщик и постоянное хранилище — уже естественные кандидаты на следующие крупные этапы.
Как запустить genrt
Статья описывает релиз v0.1.0-alpha.2. Для воспроизведения лучше переключиться именно на этот тег, а не использовать меняющуюся ветку main:
git clone https://github.com/redeemed-sis/genrt.gitcd genrtgit checkout v0.1.0-alpha.2./scripts/setup/install-deps.shcargo xtask run-aarch64
После появления приглашения > можно выполнить ls, pwd, cat /etc/banner, echo hello и exit.
Полный набор проверок запускается отдельно:
cargo xtask ci
Что я вынес из этих четырех месяцев
Главный вывод для меня не в том, что ИИ теперь умеет писать операционные системы. Без понимания архитектуры, ограничений и того, как система должна вести себя на практике, с его помощью так же легко построить правдоподобную, но неработающую конструкцию.
В текущий момент меняется другое — цикл от незнакомого вопроса до проверяемого решения стал намного короче. Я могу быстрее разобраться в новой области, обсудить несколько вариантов, сформулировать небольшую задачу, реализовать её и прогнать через воспроизводимые проверки. Благодаря этому проект, который раньше казался слишком широким для одного человека, удалось разбить на последовательность посильных этапов.
При этом расширение возможностей не уменьшает ответственность. Решение о том, что строить, какие компромиссы принимать и почему тестам можно доверять, всё равно остаётся за инженером.
genrt ещё далека от практического применения: ей не хватает SMP, постоянного хранилища, развитого ABI, RT-политик и поддержки реального оборудования. Но это уже не набор разрозненных экспериментов. Собственный boot-код приводит к запуску изолированного userspace, командная оболочка действительно запускает отдельные ELF-программы, а изменения проходят через сквозные тестовые контракты.
Исходный код находится в репозитории genrt, а состояние, описанное в статье, зафиксировано в релизе v0.1.0-alpha.2. Буду рад технической критике, найденным ошибкам и обсуждению архитектурных решений в GitHub.
ссылка на оригинал статьи https://habr.com/ru/articles/1064844/