Ещё в школе, когда я впервые начал писать на Pascal, меня заинтересовал вопрос: что именно происходит, когда запускается программа? Программа читает клавиатуру, выводит текст, выделяет память. Но кто её запускает? Кто выделяет ей процессорное время? Почему она не может прочитать память другого процесса? Как нажатие клавиши превращается в событие для приложения? И что происходит между включением компьютера и первым приглашением командной строки? Вместо того чтобы ограничиться прикладным программированием, я полез разбираться в загрузчиках, памяти и работе процессора.

Теперь у этой коротенькой истории есть имя — GoOS. Если вам интересно почитать об этом подробнее — добро пожаловать!

Сразу уточню: я не разрабатываю «убийцу Linux», замену Windows или Болженос. GoOS — это экспериментальная операционная система для x86_64, написанная преимущественно на Go с использованием TinyGo. Я делаю её, потому что мне интересно, что получится, если изначально выбрать другую архитектуру. В этой статье я хочу рассказать не столько о том, что в ней есть, сколько о том, как оно устроено внутри и почему именно так.

Pascal, C++ и студенты

Сама идея глубже разбираться и пробовать писать что‑то своё появилась ещё в школе, но в 2011 году я уже начал писать код собственного проекта на Free Pascal. Первые шаги были посвящены загрузке, памяти, прерываниям и взаимодействию с оборудованием. Это ещё не была цельная ОС. Я писал отдельные прототипы и экспериментировал с устройством ядра, к которым я возвращался, когда появлялось время или очередная мысль. Позже я пробовал C++. Менялись языки, архитектура, представления о том, что следует держать в ядре, а что стоит от него отделить. Некоторые части было проще переписать заново, чем сохранять решения, придуманные до знакомства с очередным пластом аппаратной документации.

К 2014 году получился небольшой проект — скорее лабораторная работа. Тогда я вёл учебную практику у студентов колледжа и хотел показывать этот проект, чтобы объяснять устройство системы на работающем примере. Разумеется, это сложно было назвать операционной системой. Это можно было назвать скорее мини‑прошивкой, которая просто могла загружаться с ISO в память, выполнить пару действий. И благополучно упасть, спустя некоторое время.

Позже я много работал с Linux (продолжаю работать и по сей день), инфраструктурой, сетями и сервисами. Интерес к устройству ОС от этого только усилился. Когда годами диагностируешь проблемы в чужих системах, неизбежно начинаешь задавать вопросы об их внутренних компромиссах. Почему ошибка в драйвере, работающем в ядре, может вывести из строя всю машину? Можно ли уменьшить набор прав, который получает сервис? Что изменится, если доступ к ресурсу определять не только по UID и пути, но и по явно переданному полномочию?

Текущее состояние моей ОС — это новая реализация старой идеи. В какой‑то момент я решил перестать накапливать разрозненные прототипы. Захотелось довести до работающего состояния конкретную архитектуру и проверить, что в ней окажется хорошим, а что придётся выбросить.

Почему Go, если есть C, C++ и Rust?

Для разработки ОС идеально подходят C, C++ и Rust. Я не считаю Go каким‑то универсально лучшим вариантом. Мне просто стало интересно попробовать именно Go для этой задачи — язык, который обычно живёт поверх существующей ОС (FreePascal ранее выбирался по тем же причинам). Тем более, что для личных (и не только) нужд я использую именно его довольно долгое время.

Но тут возникает очевидная проблема: обычный Go runtime рассчитывает на память, системные вызовы, таймеры и потоки, предоставляемые той самой системой, которая не написана. И получается замкнутый круг:

— Мне нужна операционная система, чтобы запустить код операционной системы.

— А операционной системы нет.

— Приехали.

Я нашел практическое решение — TinyGo. На этапе загрузки ядро GoOS представляет собой UEFI‑приложение: TinyGo собирает PE‑образ BOOTX64.EFI, который запускает прошивка. Компилятор, конечно, не избавляет от кучи низкоуровневой работы. Для переходов между режимами, входа в ядро, переключения контекста и запуска дополнительных ядер нужен ассемблер — в GoOS его немного, около трёх тысяч строк, и весь он живёт в нескольких строго очерченных местах.

Тут стоит упомянуть одну важную, на мой взгляд, деталь: в сборке GoOS переходы между ассемблером и скомпилированным кодом учитывают Microsoft x64 ABI, применяемый в x86_64 UEFI. В частности, при вызовах из ассемблера нужно соблюдать выравнивание стека и резервировать shadow space. Это не универсальное правило для любого Go‑компилятора и любой платформы, речь о конкретном ABI на границах вызовов в GoOS. Позже особенности сохранения регистров мне, разумеется, аукнулись.

Почему Go для всего остального? Мне на нём очень удобно описывать типизированные интерфейсы, сообщения, сервисы и структуру системы. Разумеется, язык не отменяет гонок, неправильной синхронизации, ошибок управления ресурсами и ошибок проектирования безопасности. В ядре ничто не заменит понимания того, что реально делает процессор.

Поэтому, мой выбор именно Go — это одна из проверяемых гипотез проекта. И чтобы гипотеза была честной, весь unsafe в пользовательском пространстве собран ровно в двух пакетах: userspace/runtime/sys (типизированные обёртки над системными вызовами) и userspace/runtime/arena (аллокатор). Все сервисы, драйверы и утилиты выше — это обычный Go без сырых указателей. Мне так проще проводить аудит участков с прямой работой с указателями. Но ошибки в ядре, компиляторе, runtime и протоколах этим, разумеется, не исключаются.

Архитектуры ОС: в чём, собственно, разница?

Мне кажется, что сперва нужно разобраться, что принято называть монолитными, гибридными и микроядерными системами. Эти термины, как мне кажется, часто используют слишком вольно, а иногда и просто как способ обругать чужой продукт. Рассмотрим несколько популярных ОС

Linux

Linux относится к монолитным ядрам. Это не значит, что всё содержится в одном файле или что нельзя загружать модули. Модульность в Linux вполне себе есть. Главное тут в другом: планировщик, VFS, сетевой стек и большинство драйверов работают в привилегированном режиме процессора (Ring 0) и используют общее адресное пространство ядра. Они взаимодействуют через внутренние интерфейсы и могут напрямую обращаться к общим структурам данных без необходимости организовывать межпроцессный обмен. Это уменьшает накладные расходы на взаимодействие подсистем и позволяет тесно интегрировать их друг с другом. Есть и другое важное преимущество Linux, уже не связанное напрямую с монолитной архитектурой — десятилетиями развивавшаяся поддержка оборудования и огромная база совместимого программного обеспечения.

Недостаток — общая зона доверия. Ошибка в привилегированном драйвере может повредить память всего ядра.

Стоит отметить, что монолитность не означает отсутствия защиты. Namespaces, seccomp, LSM, механизмы защиты памяти — всё это есть. Но они не превращают произвольный код драйвера в изолированный userspace‑сервис.

Windows

Windows NT обычно описывают как гибридную архитектуру: NT Executive, Kernel, HAL, модель объектов, дескрипторов, security tokens и ACL. Идеи микроядерного проектирования есть, и довольно заметны, но многие существенные компоненты работают в kernel mode, и ошибка привилегированного драйвера может стать системной.

При этом было бы несправедливо сказать, что в NT «нет модели прав» — там сложная и зрелая модель безопасности.

macOS

В macOS используется XNU: Mach дал задачи, потоки и сообщения, BSD — Unix‑интерфейсы и семантику, I/O Kit — драйверную модель. Присутствие Mach, конечно, не делает XNU чистым микроядром, хотя Apple постепенно выносит часть драйверов в userspace через DriverKit.

В macOS лично мне нравится цельность платформы. Но даже хорошо интегрированная система несёт десятилетия исторических решений.

Микроядра

QNX, MINIX 3, L4, seL4 — многие из них намного последовательнее моей GoOS в минимизации ядра и формализации границ безопасности. Изоляция драйверов и capability‑интерфейсы — давно известные идеи. Хорошие идеи. Отличные идеи!

Но у них есть своя цена. IPC требует переключений контекста, планирования, обработки таймаутов. Если передавать большие объёмы данных с постоянным копированием, накладные расходы быстро станут существенными. Если плохо спроектировать протокол восстановления, отказ одного сервиса может затронуть его клиентов.

В итоге, все компромиссы зависят от интерфейсов, нагрузки, железа и качества реализации.

Общая картина GoOS

GoOS лично я называю прагматичной микроядерной/гибридной ОС. В привилегированном ядре остаются состояние CPU, прерывания, физическая и виртуальная память, планирование, процессы, примитивы IPC, handles, проверка capabilities и таймеры. Файловые системы, терминал, конфигурация, логирование, сетевой стек и выбранные драйверы работают в отдельных процессах ring 3. Это не абсолютная граница между «механизмами» и «смыслом данных». Например, совместимость с Linux ABI реализована в ядре.

Главный принцип: компрометация одного компонента не должна автоматически означать компрометацию всей системы. Ключевое слово — «автоматически». Capability‑подход не гарантирует безошибочность. Он ограничивает набор объектов и операций, доступных процессу, но неаккуратная выдача полномочий способна разрушить всю идею. Поэтому приходится думать не только о том, что сервис должен уметь, но и о том, чего он не должен получить даже по ошибке.

Вот как распределены полномочия между несколькими компонентами:

— Сетевой сервис netd разбирает Ethernet, ARP, IPv4, TCP — но не имеет доступа к устройству. С железом говорит отдельный драйвер.

— Драйвер virtio‑net имеет доступ к портам, одной линии прерывания и своему DMA‑окну — но ничего не знает про IP.

— Shell не имеет доступа к консоли. Только к потоку вывода терминала и к своей сессии.

— Утилита вроде grep получает stdin/stdout/stderr и, если её манифест этого требует, одну сессию VFS. И всё.

Каждое заметное архитектурное решение я оформляю как ADR. В репозитории их уже порядка двухсот. И их количество только растет. Некоторые прямо отменяют или исправляют предыдущие — и я считаю, что это правильно: видно не только то, к чему пришли, но и где ошиблись по дороге. Всегда стараюсь вести любую документацию в подобном стиле, не только тут, а вообще.

От включения питания до goos>

Начнём с самого начала: что происходит, когда виртуальная машина включается.

Фаза прошивки. Ядро стартует как UEFI‑приложение. Первым делом инициализируется последовательный порт COM1, чтобы любая следующая ошибка могла быть хотя бы сказана. Затем через протоколы UEFI с загрузочного раздела читается initramfs.tar — архив USTAR с PID 1, сервисами хранилища, терминалом, shell и recovery‑утилитами. Потом ядро находит GOP (графический вывод UEFI), выбирает режим и копирует себе только проверенные параметры фреймбуфера. Никакой указатель на структуры прошивки не пересекает границу загрузки.

Точка невозврата. Ядро снимает финальную карту памяти UEFI, нормализует типы регионов и вызывает ExitBootServices. С этого момента прошивка больше как‑бы не существует. Перед самым выходом ядро запоминает указатель на ACPI RSDP, и после сразу же один раз читает таблицы FADT/DSDT (проверяя каждую контрольную сумму), чтобы знать, как выключить машину, и MADT, чтобы знать, сколько у неё процессоров.

Подъём ядра. Дальше по порядку:

1. Физическая память. Строится битмап‑аллокатор по сегментам обычной памяти. Аллокатор выделяет и освобождает одну страницу в качестве самотеста — прежде чем ему кто‑то доверится.

2. Виртуальная память. Ядро требует от процессора поддержку NX и PAT, строит собственные четырёхуровневые таблицы страниц, прямую проекцию всей физической памяти в верхнюю половину адресного пространства, включает EFER.NXE и CR0.WP — и переключает CR3. Секции PE‑образа ядра получают права из своих флагов: исполняемое — только для чтения, записываемое — неисполняемое. Секция, которая просит одновременно запись и исполнение, отвергается

3. Таблицы дескрипторов и прерывания. GDT, IDT, TSS, LAPIC и IOAPIC, маршрутизация прерываний клавиатуры, UART и дисков.

4. Время. PIT программируется на частоту тиков, затем по нему калибруется TSC — примерно за 100 мс.

5. Планировщик и затем остальные процессоры (об этом ниже).

Первый процесс. Ядро создаёт ровно один пользовательский процесс — PID 1, загруженный из system/init. Его таблица handles заполняется в строго фиксированном порядке: канал жизненного цикла, консоль, каналы сервисов, полномочия на порты, прерывания и DMA для драйвера диска, системный контроль, образ системы. Если какой‑то слот оказался не на своём месте — ядро паникует. Здесь лучше остановить загрузку, чем запустить PID 1 с перепутанными полномочиями, так как от него зависит выдача прав остальным сервисам.

После этого загрузочный поток ядра превращается в цикл обслуживания: отрабатывает истёкшие таймауты, доставляет уведомления о завершении процессов, обрабатывает запросы PID 1 на запуск — и засыпает до следующего события.

Handles и capabilities: доступ по выданным полномочиям

Это сердце всей системы, поэтому остановлюсь подробнее.

В GoOS процесс никогда не видит указателей на объекты ядра. У каждого процесса своя таблица handles фиксированного размера. Handle — это непрозрачный идентификатор, который кодирует номер слота и поколение (generation). Каждый слот хранит идентичность объекта, его тип и маску прав.

Поиск handle успешен, только если совпали идентификатор, поколение, тип и полный набор требуемых прав. При закрытии handle поколение слота увеличивается. Поэтому:

— старый, уже закрытый handle не найдёт новый объект, даже если слот переиспользован

— устаревший handle с прежним поколением не пройдёт проверку даже после повторного использования слота; поколение защищает от ошибочного повторного использования идентификатора, а не служит криптографическим секретом

— handle канала, предъявленный операции записи в поток, провалится на проверке типа — до того, как ядро прикоснётся к памяти пользователя

Передача полномочий требует права Transfer на исходном handle. Получатель получает новый handle в своей таблице, и права у него могут быть только уже или равны, но никогда не шире. Повысить права при передаче невозможно в принципе. Как это выглядит на практике? Когда PID 1 запускает сервис, он передаёт ядру манифест: какие handles из своей таблицы и с какими правами выдать новому процессу. Ядро проверяет каждый пункт против таблицы PID 1, отвергает любое повышение прав, и если хоть одна выдача не удалась — откатывает все уже установленные handles ребёнка. Процесс либо получает ровно то, что указано, либо не запускается вовсе. А когда shell просит PID 1 запустить grep, PID 1 смотрит в индекс команд: нужна ли этой утилите сессия VFS? Если в манифесте команды не написано, что нужна, — grep её просто не получит, и прочитать файл сможет только через stdin.

Вот здесь и проявляется одно из ключевых отличий GoOS от традиционной Unix‑модели. В Unix доступ к ресурсам обычно определяется идентификатором пользователя (UID), правами доступа и расположением объекта в файловой системе. В GoOS в основе лежит другой принцип: процесс может работать только с теми ресурсами, на которые ему явно предоставлены полномочия (capabilities). При этом привычная файловая система никуда не девается. VFS по‑прежнему предоставляет пространство имён и работу с путями, но доступ к нему возможен только через соответствующую сессию, выданную процессу.

Typed IPC: сообщения, права и контроль запросов

Компоненты общаются через каналы — двунаправленные объекты ядра фиксированной ёмкости. У каждой стороны есть входящая очередь на 16 сообщений, и хранилище очереди является частью самого объекта канала. Это значит, что отправка сообщения никогда не вызывает выделения памяти в ядре. Если очередь полна, отправитель получает ResourceExhausted — а не блокируется навечно и не раздувает ядро.

Каждое сообщение — это конверт со стабильным little‑endian форматом, не зависящим от раскладки структур Go. Заголовок сообщения содержит сигнатуру GIPC, идентификатор и версию протокола, тип сообщения (запрос, ответ или уведомление), correlation ID для сопоставления запросов с ответами, размер полезной нагрузки и количество передаваемых дескрипторов (handles). Одно сообщение может содержать до 256 байт полезной нагрузки и до четырёх handles. Канал сам отслеживает, какие запросы ещё ждут ответа. Дубликат запроса отвергается. Ответ на запрос, которого не было, отвергается. Это убирает целый класс ошибок, когда сервис отвечает не туда или дважды.

Ограничение в 256 байт заставляет отделять управляющие сообщения от передачи больших данных. Всё объёмное ходит через разделяемую память: процесс создаёт объект разделяемой памяти (от одной страницы до 256 МиБ), прикрепляет handle на него к запросу, и получатель отображает те же физические страницы к себе. По каналу идёт только «вот буфер, возьми из него столько‑то байт». Сетевые кадры, блоки диска, страницы файлового кеша — всё работает именно так.

А что, если «собеседник» умер? Если одна сторона канала закрылась, другая может дочитать уже стоящие в очереди сообщения, а затем получает PeerDied. Это не исключительная ситуация, а нормальная часть протокола — и, как вы увидите дальше, на ней держится вся история с перезапуском сервисов.

Ожидание нескольких событий сразу

Сервис вроде VFS обслуживает одновременно управляющий канал и десятки клиентских сессий. Для этого есть WaitAny: процесс передаёт до 16 пар «handle + маска событий» и засыпает, пока хоть что‑то не станет готово. Ядро проверяет каждый handle, его поколение, тип и права до регистрации ожидания.

Тут есть довольно тонкий момент — это порядок операций. Каждое блокирующее ожидание сначала регистрирует себя, а потом перепроверяет условие. Если сделать наоборот, событие, пришедшее между проверкой и регистрацией, не разбудит никого, и задача проспит до таймаута. Таймауты, кстати, обрабатывает само ядро, так что они остаются последней линией обороны против потерянного пробуждения.

А как блокирующий вызов вообще засыпает? Механизм тут такой: если канал пуст, обработчик системного вызова возвращает действие Block, мост прерываний откатывает RIP назад на инструкцию системного вызова и переключается на другую задачу. Когда приходит сообщение, задача просыпается — и просто выполняет тот же вызов ещё раз.

Потоки байтов — отдельный механизм

Типизированный IPC — это управляющая плоскость: запросы, ответы, передача полномочий. Неструктурированный поток байтов — отдельный объект ядра, byte stream. У него кольцевой буфер на 4096 байт и два разных handle: читатель с правами Read|Transfer и писатель с правами Write|Transfer. Предъявить читающий конец пишущей операции нельзя.

Одна запись — до 256 байт, и она атомарна: либо ляжет целиком, либо не ляжет вовсе. Поэтому два процесса, пишущих в один stdout, никогда не перемешают байты внутри одного вызова. Именно на этих потоках построены конвейеры shell, перенаправления и stdin/stdout/stderr.

Процессы, задачи и запуск программ

У меня в GoOS процесс и задача — это разные объекты. Процесс владеет адресным пространством, таблицей handles и метаданными. Задача — это контекст исполнения на CPU: регистры, стек ядра. У процесса может быть несколько задач. Так модель явно различает владение ресурсами и выполнение кода.

Каждый процесс получает собственную таблицу страниц верхнего уровня. Ядро он не видит, в его адресном пространстве отображены только крошечные высокоадресные трамплины входа и выхода, дескрипторы и стек ядра текущей задачи — всё с запретом доступа из ring 3. При входе в ядро трамплин первым делом переключает CR3 на корень ядра. На загрузке система буквально проверяет изоляцию: пишет разные байты в две физические страницы, отображает их по одному виртуальному адресу в два разных процесса, переключает CR3 и убеждается, что прочитались разные значения.

Память процессов выдаётся лениво. У каждого процесса есть карта регионов: сортированная таблица из не более чем 128 непересекающихся областей с правами и источником (анонимная память, образ, разделяемая, устройство, guard). Таблицы страниц говорят, что сейчас отображено, а регион — чем страница может стать. Обращение к ещё не отображённой странице внутри анонимного региона даёт обнулённую страницу, списанную с квоты процесса. Обращение к guard‑странице, к адресу вне регионов или с недопустимыми правами убивает только этот процесс, с понятной записью в журнал.

W^X соблюдается для любого отображения: страница не может быть одновременно записываемой и исполняемой. Ни в ядре, ни в процессах.

Ядро не знает путей к файлам

Мне нравится это разделение ответственности. Когда PID 1 хочет запустить программу, он не передаёт ядру путь. Ядро не разрешает пути к исполняемым файлам.

Для программ из загрузочного архива PID 1 сам отображает архив к себе (только чтение), находит в нём нужный файл и просит ядро: «запусти образ, который лежит в таком‑то диапазоне байт архива, вот handle на архив с правом Execute». Ядро проверяет только полномочия, границы диапазона и корректность формата образа.

Для программ с диска PID 1 открывает файл через свою сессию VFS, просит VFS закрепить этот открытый объект от изменений и передаёт ядру «исполняемый источник», привязанный к этому объекту. Ядро держит одну страницу заголовка, а остальные страницы запрашивает по смещению, когда процесс к ним обращается. Пути в этом источнике нет вообще.

Получается, что вопрос «какую программу запустить» целиком решается в userspace, а ядро отвечает только на вопрос «есть ли у тебя право исполнить именно эти байты».

Сам нативный формат исполняемых файлов — маленький версионированный конверт: заголовок на 40 байт с сигнатурой GOOSNAT\0, точкой входа и размером стека, и до восьми сегментов. Проверяется всё до того, как что‑либо отобразится: выравнивание, пересечения, права, переполнения смещений. Read‑only сегменты программ из системного образа загружаются один раз и разделяются всеми процессами, которые их запускают.

PID 1: запуск сервисов и распределение полномочий

В GoOS PID 1 — это свободностоящая TinyGo‑программа, которая держит декларативную таблицу сервисов. Каждое определение называет исполняемый файл, зависимости, точный набор передаваемых capabilities и политику перезапуска: never, on-failure или always. Зависимости должны быть объявлены раньше зависимых — так порядок старта становится явным и ацикличным без построения графа в рантайме.

Перезапуски ограничены: фиксированное окно, ограниченное число попыток, фиксированная пауза. Если сервис падает слишком часто, он помечается как failed вместо бесконечного цикла «упал‑поднялся». Сервисы, зависящие от упавшего, помечаются нездоровыми и восстанавливаются, когда зависимость поднимется.

О смерти процессов PID 1 узнаёт через канал жизненного цикла, который есть только у него. Ядро присылает типизированное уведомление с PID и кодом завершения — но только когда умерла последняя задача процесса.

Файловая система

Чтобы написать простой файловый менеджер, пришлось сначала решить, откуда вообще берутся файлы. Путь от cat file.txt до сектора на диске в GoOS проходит через пять изолированных компонентов, и ни один из них не работает в ядре.

  • VFS владеет пространством имён и всей обработкой путей. Он канонизирует пути, выбирает точку монтирования по самому длинному совпадающему префиксу и раздаёт бэкендам только непрозрачные идентификаторы узлов и одиночные компоненты имён. Ни ядро, ни FAT32 никогда не разбирают POSIX‑пути.

  • FAT32 — это отдельный процесс. Он разбирает недоверенный том и держит соединение с блочным сервисом, ограниченное своим разделом.

  • Block‑сервис читает таблицу разделов (MBR или GPT) и выдаёт файловой системе срез диска. Он проверяет диапазоны и переполнения каждого запроса.

  • Драйвер virtio‑blk — единственный, у кого есть доступ к портам устройства, его прерыванию и DMA‑окну. И он ничего не знает о файлах.

Каждый процесс, который работает с файлами, получает собственную сессию VFS — отдельный канал, серверный конец которого PID 1 передаёт VFS, а клиентский в манифест процесса. Чужие ответы прочитать нельзя.

Почему FAT32? Она простая, хорошо документирована и совместима с другими системами. Это важно: том можно проверить внешними инструментами, а не только моей реализацией, которая потенциально повторяет мои же ошибки. Есть длинные имена VFAT, проверка и восстановление тома, поддержка разных геометрий. И разумеется, её легче всего было реализовать.

Установленная система раскладывается так: /system — неизменяемая установленная система (VFS запрещает любые изменения в ней под любым именем, включая /persist/system и варианты регистра), /home — пользовательские данные, /state — изменяемое системное состояние, /bootstrap — загрузочный архив для recovery.

Когда VFS падает

Самая интересная часть хранилища — это то, что происходит при отказе. Раньше у меня VFS был единственным сервисом, чьё падение заканчивало работу хранилища до перезагрузки. Перезапустить сам процесс легко. Проблема в том, что все его клиенты держат каналы, которые умирают вместе с ним: shell, сервисы конфигурации и логов, FAT32. Перезапускать клиентов — это значит потерять, например, все фоновые задачи shell.

Решение придумал такое: клиентам выдают новые сессии, а не перезапускают их. Перед запуском нового VFS PID 1 создаёт свежий управляющий канал и первую сессию, а затем рассылает уведомление с клиентским концом новой сессии по каналам, которые у клиентов с PID 1 уже есть. Shell принимает новую сессию сразу и сохраняет свои задачи, рабочий каталог и терминал. Сервис конфигурации ждёт, пока снова подключится файловая система, и дописывает настройку, которую не смог сохранить во время сбоя.

Отдельная история — идентичность открытых файлов. Изначально VFS хранил в открытом handle узел FAT32, который кодировал кластер каталога и номер записи. Стоило переместить файл, и уже открытый handle указывал в никуда. Теперь же бэкенд выдаёт для открытого файла стабильную «печеньку» со слотом и поколением, а rename обновляет живой объект. Открытый файл продолжает читаться и писаться после переименования и даже перемещения между каталогами. А после перезапуска FAT32-сервиса VFS заново получает объекты для всех открытых файлов у нового экземпляра.

При этом, Unix‑семантики «удалить открытый файл» нет — эдакий компромисс. Попытка удалить файл с живым объектом получает отказ, удаление сработает после закрытия последнего handle. Это проще, чем модель отложенного удаления, и исключает осиротевшие кластеры.

Терминал, shell и первые полноэкранные программы

Приглашение командной строки — это, конечно же, круто. Но хочется же ls, перенаправлений, редактора, нормального способа ходить по каталогам, и много всего другого.

Немного скриншотов GoOS
GFM - GoOS File Manager
GFM — GoOS File Manager

Терминал — отдельный сервис, и только он может читать консоль. Shell консоли не видит вообще: у него есть поток вывода терминала и терминальная сессия. Позже ядро терминала было вынесено в переиспользуемый движок: строковое редактирование, raw‑режим, перерисовка, прерывания. Один и тот же движок обслуживает последовательную консоль, графическую консоль на фреймбуфере и — забегая вперёд — SSH‑сессии.

Графическая консоль рисует шрифтом Spleen прямо в фреймбуфер, отображённый в ядро в некешируемом окне. Никакого теневого буфера и истории: только курсор, состояние UTF-8 и разбора escape‑последовательностей.

Собственная нативная shell умеет кавычки, последовательности ;, конвейеры, перенаправления <, >, >>, 2>, фоновые задачи, jobs/kill, Ctrl‑C и Ctrl‑D. Это не POSIX shell, и копировать bash я (возможно, пока) не собираюсь. stdin, stdout и stderr здесь не номера дескрипторов 0, 1, 2, а три типизированных handle на потоки, переданные при запуске.

Поверх этого — полноэкранные программы: gfm (файловый менеджер), gview (просмотрщик) и edit (редактор). Редактор, кстати, сохраняет файл через безопасную замену целиком, чтобы сбой записи не заменил исходный файл частично записанным содержимым.

И вот тут можно сказать, что операционной системой (разумеется, пока — с оговорками) уже можно пользоваться.

Многопроцессорность: запуск AP и TLB shootdown

На x86_64 дополнительные процессоры нужно запускать отдельно: после старта системы они сами не переходят к коду ядра GoOS.

Есть начальный процессор (BSP), остальные (AP) нужно разбудить. Для этого BSP копирует небольшой трамплин на свободную страницу ниже 1 МиБ, строит временную таблицу страниц, где этот трамплин отображён и исполняем, и для каждого процессора по очереди готовит защищённый стек, отдельный стек для double fault и собственный TSS. Затем шлёт INIT и до двух Startup IPI с архитектурными задержками.

Проснувшийся процессор проходит путь, который сам x86 проходил десятилетиями: из real mode через protected mode в long mode, загружает таблицы страниц ядра и вызывает Go‑функцию на своём стеке. Если процессор не ответил вовремя, он остаётся офлайн, а загрузка продолжается с теми, кто ответил. Обе стороны меняют состояние процессора через compare‑and‑swap, так что опоздавший процессор найдёт отмену и тихо припаркуется.

Но как код входа в ядро узнаёт, на каком процессоре он выполняется, если ещё не может доверять ни одному регистру? GDT общая, но у каждого процессора свой дескриптор TSS, и процессор n загружает селектор 0x28 + 16n. Код входа выполняет инструкцию STR, вычисляет номер процессора из селектора и по нему находит свой блок данных.

TLB shootdown через принудительный вход в ядро

Когда потоки одного процесса работают на нескольких процессорах, возникает классическая проблема: процессор A убрал отображение страницы, а процессор B всё ещё держит её трансляцию в TLB. Если страница успеет вернуться в аллокатор, её следующий владелец увидит чужие данные. В GoOS эта задача решается довольно просто благодаря тому, что при каждом входе в ядро загружается корень таблиц ядра (без глобальных страниц и PCID). Значит, процессор держит трансляции процесса, только пока находится в ring 3 на его корне. И задача shootdown сводится к одному — заставить каждый процессор, который сейчас может быть в этом процессе, один раз войти в ядро.

Освобождение памяти идёт в два прохода: сначала очищаются записи в таблицах страниц, затем рассылается прерывание shootdown и ожидается, пока каждый процессор войдёт в ядро или уйдёт с этого корня, и только потом страницы возвращаются в аллокатор. Если shootdown не завершился за отведённое время, машина останавливается: протокол не имеет права «немного не сработать».

Ошибка переключения контекста: регистры XMM

Проверять многозадачность на одном ядре бессмысленно — а иногда бессмысленно и на нескольких, если не поднять частоту таймера. Одна из самых поучительных историй проекта произошла ещё до того, как я взялся за SMP.

При частоте таймера 1 кГц сервис VFS изредка падал с ошибкой «нет прав на ожидание». Мой стресс‑тест воспроизводил проблему примерно в половине случаев. Диагностическая сборка показала, что VFS просил ядро подождать на handle 0x4000000000000005 — а это вообще не handle, это внутренний идентификатор канала, который существует только в памяти ядра. Первым подозреваемым стало ядро: гонка в ядре. Цикл обслуживания ядра работал с включёнными прерываниями, и его можно было вытеснить посреди изменения структуры. Нашел реальную гонку в физическом аллокаторе, способную выдать одну страницу двум владельцам. Её исправил, ввел единый домен исключения... и через некоторое время ошибка вернулась. Испорченные значения появлялись уже при копировании таблицы сессий внутри VFS, сама таблица оставалась целой, ни одной чужой страницы в памяти VFS не было. Но я обнаружил, что повреждённые данные всегда приходили выровненными парами по 16 байт. А это ровно ширина регистра XMM.

TinyGo копирует массивы через XMM‑регистры. А переключение контекста сохраняло только 15 регистров общего назначения. Задача, вытесненная между загрузкой пары в XMM и её записью, просыпалась с тем, что оставило там ядро или другая задача. Чем чаще таймер, тем чаще вытеснение, отсюда и привязка к 1 кГц. А ядро держит XMM6–XMM15 живыми между вызовами, так что было уязвимо точно так же.

Решение тут тоже придумал — сохранять состояние SSE и x87 (FXSAVE) как часть контекста каждой задачи.

От глобальной блокировки к нескольким доменам

Первая реализация SMP использовала у меня примитивную схему: все системные вызовы, нативные и Linux, все page fault и все шаги обслуживания брали одну блокировку ядра — так называемый Big Kernel Lock. Потоки считали параллельно и выстраивались в очередь у ядра.

Сейчас же состояние ядра разделено на домены, у каждого своя блокировка и свой уровень, и процессор берёт блокировки строго по возрастанию уровня. У адресного пространства процесса — reader‑writer lock: копирование в память процесса и обработка page fault берут его на чтение, так что потоки одного процесса обрабатывают страничные отказы на нескольких процессорах одновременно. Новая таблица страниц или страница вставляется через compare‑and‑swap, проигравший гонку просто находит страницу уже на месте и повторяет доступ. У каналов и Linux‑дескрипторов — блокировки на объект.

Планировщик держит очередь на каждом процессоре, круговой выбор внутри неё и кражу задач у соседей, когда своя очередь пуста. У задач есть маска affinity и счётчики времени по процессорам. Поддерживается до 64 процессоров.

Linux‑программы: отдельный ABI и вход в ядро

Нативные приложения GoOS не живут по правилам Unix. Но мне было интересно взять какую‑нибудь настоящую Linux‑программу и посмотреть, какой системный контракт ей действительно нужен.

Ключевое решение: тип ABI процесса фиксируется ядром при запуске исполняемого файла и впоследствии не изменяется. Нативный образ получает тип Native, проверенный Linux ELF — LinuxX8664. И у каждой личности свой вход: нативные вызовы идут через int 0x80, Linux‑программы — через инструкцию syscall с Linux‑соглашением о регистрах. Нативный шлюз пускает только нативные процессы, Linux‑шлюз только Linux. Ядро определяет ABI по типу процесса.

Слой трансляции живёт в ядре, потому что ему нужен проверенный доступ к памяти вызывающего процесса, а отдельный сервис потребовал бы межпроцессных полномочий на чужую память. Но у него нет собственных полномочий. Каждая операция использует только адресное пространство, handles, квоты, потоки, часы и сессию VFS самого процесса. Транслятору запрещено «срезать путь» и обращаться, например, к FAT32, драйверу диска, портам, консоли или фреймбуферу напрямую. Linux‑программа не получает ничего сверх того, что получила бы нативная программа с тем же манифестом.

В реализации есть несколько принципиальных проверок:

— Неподдерживаемый системный вызов возвращает -ENOSYS и учитывается в диагностике.

— Ядро никогда не уничтожает текущий образ, пока проверка нового ещё может провалиться. Сначала строится второй корень таблиц страниц, новый стек с копией аргументов, списываются страницы — и одной фиксацией меняются корни. Старый образ освобождается только после этого.

— До 256 аргументов, 256 переменных окружения, 32 КиБ на строку, 64 КиБ всего.

ELF‑загрузчик принимает статические ET_EXEC и static PIE, проверяет каждый сегмент и отказывается от PT_INTERP, W+X и исполняемого стека. Страницы, как и у нативных программ, подгружаются лениво. Дальше поверх этого появились процессы‑семейства, fork/vfork/execve, clone, потоки и TLS, futex, конвейеры поверх нативных потоков, сигналы с настоящими сигнальными фреймами и rt_sigreturn, poll/ppoll.

Проверяется это на реальных программах: статические musl и glibc‑бинарники, апплеты BusyBox и неинтерактивный BusyBox sh, многопоточный 7-Zip. Поведение сверяется с настоящим ядром Linux.

Но не стоит читать это как «любой бинарник Linux теперь запускается». Я ещё не реализовывал динамическую линковку, нет Linux‑сокетов и нет интерактивного Linux‑терминала. Однако, эта работа впереди, и я обязательно это реализую. Когда‑нибудь.

Сеть: разделение драйвера и протоколов

Сеть тоже разделена по зонам ответственности.

  • Ядро находит сетевое устройство, маршрутизирует его прерывание и выдаёт драйверу отдельный DMA‑домен. На этом его участие заканчивается.

  • Драйвер virtio‑net перекладывает кадры между устройством и двумя пулами разделяемой памяти. Проверяет длину и заголовок — и всё.

  • netd — сетевой сервис. Ethernet, ARP, IPv4 с обнаружением конфликтов адресов, ICMP, DHCP, loopback, UDP и TCP. Доступа к устройству у него нет.

  • resolved — резолвер имён: таблица hosts, кеш, DNS‑серверы, переход на TCP при усечённом ответе. Сеть он видит только через netd, как любая другая программа.

Кадры ходят через два пула: приёмный (пишет драйвер, читает netd) и передающий (наоборот). По каналу передаётся только номер слота и длина. Владение слотом строгое, у каждой привязки своё поколение, а драйвер копирует кадр из пула прежде, чем его увидит устройство — чтобы netd не мог подменить данные после проверки.

Программам сеть выдаётся так же, как файлы: PID 1 открывает им UDP‑ или TCP‑сессию, если манифест это разрешает. Внутри сессии фиксированное число endpoints, фиксированное хранилище для TCP‑соединений, и даже состояние TIME_WAIT защищено до истечения, когда слот переиспользуется.

HTTPS остаётся в клиентском процессе

Поверх этого работает fetch — HTTP(S) клиент, проверка цепочек X.509, SAN, системное хранилище доверенных корней с подписанными обновлениями. Но это не самописная криптография — используется закреплённая версия стандартной Go‑реализации crypto/tls и crypto/x509, адаптированная для TinyGo.

Кстати, здесь была своя история. Доступный в этой конфигурации TinyGo TLS представлял собой интерфейс для аппаратного TLS‑offload, а не полноценную программную реализацию TLS для произвольного байтового потока. Он рапортовал об успешном рукопожатии, не отправляя ни байта в сеть. Для защиты потока это не годилось, и пришлось собирать настоящую реализацию через слой совместимости, мостить случайность к генератору ядра и ограничивать всё, что можно ограничить: не больше восьми сертификатов в цепочке, не больше 32 КиБ на сертификат, общий дедлайн на рукопожатие.

И главное — TLS живёт в процессе клиента. Ядру, драйверу, netd и резолверу не нужно ничего знать про X.509. Если ошибка обработки сертификата остаётся внутри fetch, она не получает автоматически привилегии ядра или сетевого сервиса.

Здесь я придерживаюсь одного простого правила: возможность должна жить там, где она действительно нужна, а не расползаться по системным слоям.

Сеть пока только IPv4. Нет IPv6 (а нужен ли?), мультикаста, фрагментации, HTTP/2 и Linux‑сокетов.

SSH: обработчики соединений без приватного ключа хоста

Ещё одно важное для меня событие: к собственной ОС теперь можно подключиться обычным OpenSSH‑клиентом.

ssh -T user@10.16.20.69 'net status'
lo:
  state: up
  address: 127.0.0.1/8
  mtu: 65536
  echoes: 0
net0:
  state: ready
  link: up
  mac: 5a:92:29:ae:a6:32
  mode: dhcp
  address: 10.16.20.69/24
  gateway: 10.16.20.1
  mtu: 1500
  lease: 21599784 s
  frames: rx 691 tx 346 drop 411 malformed 0
  claims: probes 3 announcements 2
  conflicts: probe 0 active 0 defended 0 relinquished 0 rate-limited 0
udp:
  endpoints: 0/32 (8 per session)
  datagrams: rx 0 tx 0
  dropped: no-endpoint 0 queue-full 0 checksum 0 too-long 0
  bind failures: 0
  limits: ports 49152-65535 queue 8 payload 4096
tcp:
  endpoints: 2/32 listeners 1 connections 1
  segments: rx 235 tx 339 retransmits 0
  dropped: checksum 0 no-tcb 0 window 0
  limits: ports 49152-65535 backlog 8 buffers 16384/16384

Сервис называется remoted, и это не OpenSSH. В нём нет /dev/pts, нет termios и нет парсера sshd_config. Он использует тот самый движок терминала, что и локальные консоли, поэтому gfm, gview и edit работают по SSH и корректно перерисовываются при изменении размера окна.

Интереснее всего его внутреннее устройство:

  • Контроллер держит слушающий TCP‑сокет и приватный host key.

  • На каждое принятое соединение PID 1 запускает отдельный worker — фиксированный исполняемый файл.

  • Worker получает потоки с шифротекстом, неизменяемую страницу политики только с публичными данными и endpoint подписи — канал, по которому можно попросить контроллер подписать данные ключом хоста.

  • Самого ключа у worker нет. Нет и права слушать сокет, и права запускать произвольные процессы, и доступа к файлам ключей.

  • Если контроллер умирает, endpoints подписи всех worker автоматически становятся бесполезными.

Компрометация worker не даёт ему прямого доступа к приватному ключу хоста, ключ остаётся у контроллера. Разумеется, это не полноценная защита от выдачи себя за сервер во время компрометации, worker способен обращаться к интерфейсу подписи с выбранными 32-байтовыми входными данными, пока его полномочие действует. Изоляция предотвращает прямое извлечение ключа из worker, но не устраняет все риски использования сервера подписи.

Только после аутентификации по публичному ключу Ed25519 PID 1 создаёт для сессии shell, сессии VFS и конфигурации — ровно как для локального пользователя. Файл authorized_keys после установки защищён от записи обычными сессиями файловой системы, изменить его можно только через транзакционную команду remote key. Пароли, SFTP, SCP, проброс портов, агента и X11 (пока) отсутствуют, а сам SSH по умолчанию выключен.

Как всё это проверяется

В реальной разработке львиная доля усилий уходит на довольно скучные вещи: корректно обработать обрыв сессии, запретить повторное использование старого handle, не потерять файл при неудачной записи, не оставить ресурс доступным после смерти сервиса и так далее.

Пример запуска диагностической сборки

Поэтому в GoOS две сборки. Обычная и проверочная (диагностическая). Проверочная при загрузке прогоняет встроенные тесты передачи handles, IPC, RAMFS, VFS, разделяемой памяти, квот, изоляции адресных пространств и копирования пользовательской памяти и останавливает загрузку при любой ошибке.

Кроме того, есть хостовые тесты безопасности (неверные типы handles, недостающие права, устаревшие поколения, попытки повысить права при передаче, исчерпание ёмкости), стресс‑прогоны с множеством параллельных гостей, проверки на KVM и Proxmox, воспроизводимые сборки с закреплёнными версиями тулчейна.

Где такой подход проигрывает (ложка дёгтя)

Разумеется, у такого разделения компонентов есть вполне измеримая цена.

IPC не бесплатен. Сообщения, переключения контекста и проверки полномочий стоят дороже обычного внутреннего вызова. Разделяемая память сокращает копирование, но повышает требования к синхронизации и владению буферами.

Отказы стали сложнее. Перезапуск сетевого или файлового сервиса — это сложно. Нужно учитывать активные операции, недоставленные ответы, старые дескрипторы, повторные запросы и состояние данных, etc.

Совместимость — дорогое удовольствие. Linux‑приложение нередко зависит от одного неожиданного ioctl или особенности сигналов. Можно реализовать десятки вызовов и всё равно не запустить нужную программу. Тут стоит сказать, что я не делаю вообще упор на Linux ABI. Что уже делаю и что планирую — стараюсь продумывать максимально эффективно. Трансляция Linux ABI добавляет накладные расходы на обработку отдельных системных вызовов. Насколько это влияет на производительность относительно нативных приложений GoOS, ещё предстоит измерить. Но что это медленнее нативных приложений - практически наверняка.

Поддержка оборудования узкая. Моя ОС развивается прежде всего в UEFI/QEMU/KVM с выбранными устройствами virtio. У Linux и Windows — это десятилетия разработки драйверов и управления питанием. Это совершенно другой масштаб.

Модель безопасности — это не сертификат безопасности. Загрузочная цепочка пока не подписана и не проверяется. Capabilities, изоляция сервисов, W^X и квоты — это разумные механизмы, но тут нужно ещё очень много работать.

Что дальше

А дальше — полно идей, но не так много свободного времени. Учитывая то, сколько в принципе вся эта идея шла до текущего состояния — успеть бы всё реализовать до пенсии. Но я не опускаю руки. Пока приоритеты по разработке/доработкам такие:

Терминал для Linux‑программ. Ближайшая крупная цель — дать Linux‑программам настоящий терминал. Но не через клон /dev/pts, а через минимальный связный контракт поверх уже существующей модели терминальных сессий: тип дескриптора, связанный с терминалом, isatty, размеры окна, минимальное подмножество termios, сессии и группы процессов, одна foreground‑группа на терминал, терминальные сигналы SIGINT, SIGTSTP, SIGCONT. Критерий готовности тут — интерактивный BusyBox sh, в котором работают редактирование строки, Ctrl‑C и изменение размера окна, причём Linux‑процессы не получают прямых полномочий на нативный терминал или консоль. Следом — редактор nano.

Полнота Linux‑окружения. Дальше — закрывать пробелы: расширенная поддержка файловых атрибутов и временных меток, включая оставшиеся ограничения utimensat, практичные ioctl, адресация сигналов конкретным потокам, лучшая диагностика неподдерживаемых вызовов. Правило остаётся прежним: добавлять поверхность Linux ABI потому, что она нужна полезной программе, а не потому, что такой вызов есть в Linux. То есть, ничего лишнего.

Энтропия и рандомизация загрузки. Источник случайности, который работает и на процессорах без RDRAND, при этом не теряя поведения «отказать, если энтропии нет» для криптографии. И ограниченная рандомизация базового адреса исполняемых файлов — сознательно не полноценный ASLR, пока простой механизм не измерен и не проверен.

Небольшая полировка нативной среды. which, слияние потоков вида 2>&1 — если его удастся чисто выразить в модели типизированных потоков, — улучшение справки и TUI. Без импорта POSIX‑семантики целиком.

Сеть дальше IPv4. IPv6 (всё ещё думаю, а нужен ли?), мультикаст и mDNS, производительность TCP, а когда‑нибудь и Linux‑сокеты — но только как адаптер поверх нативных сетевых сервисов, а не как параллельный привилегированный стек.

Динамическая линковка Linux ELF. Отдельная, более дальняя задача, и наверное — одна из сложных. И начинать её надо не с совместимости с glibc, а с определений. Например, как обрабатывается интерпретатор ELF, как отображаются разделяемые объекты, какая модель релокаций, какая политика доверия загрузчику и путям, как это сочетается с происхождением исполняемых файлов и выдачей полномочий. Первая цель — минимальный динамический бинарник на musl.

Компилятор внутри GoOS. Долгосрочный эксперимент и, пожалуй, самый символичный пункт. Доказать, что система — это рабочая вычислительная среда, а не тупо загружаемый образ. Начинать буду скорее всего не с TinyGo, а с маленького компилятора C, что‑то типа:

goos> cc hello.c -o hello

goos> ./hello

Hello GoOS

Есть и другие не приоритетные идеи, до которых (наверное) когда‑нибудь тоже дойдут руки.

PS № 1

Я никогда не ставил себе цель создать заменитель существующих ОС. У моего проекта довольно простая цель — учебная.

Linux обеспечивает колоссальную совместимость и гибкость. Windows огромное множество программ и серьёзную инфраструктуру. macOS цельность платформы. У seL4 есть глубина исследований, до которой мне как до луны на тракторе.

Мне просто интересно создать свою ОС. Интересно проверить, что получится, если минимизировать привилегированное ядро, вынести сложные сервисы в отдельные процессы, сделать capabilities и typed IPC фундаментом, а Linux‑совместимость отдельным слоем, который не диктует нативную архитектуру.

Вполне возможно, что часть решений не выдержит практических проверок. Может, придётся переписать сервис, сломать собственный ABI или отказаться от слишком дорогой изоляции. Именно ради проверки этих гипотез я веду проект. В ADR сохраняются и решения, которые впоследствии пришлось пересмотреть.

Я не пытаюсь доказать, что существующие ОС спроектированы плохо. Я хочу понять, что будет, если часть решений принять по‑другому.

PS № 2

В школьные годы мне хотелось понять, что происходит, когда я запускаю программу. В 2011-м я начал писать код, который должен был хоть немного приоткрыть устройство компьютера. В 2014-м думал, как объяснить это студентам. Теперь я могу загрузить свою ОС, прочитать файл через пять изолированных процессов, запустить статическую Linux‑программу через слой совместимости, отправить пакет через собственный сетевой стек и подключиться обычным SSH‑клиентом к сервису, который сам разбирал до ключей, потоков и сообщений — и который не знает собственного приватного ключа. Это не означает, что я приблизился к замене Linux. И не означает, что все решения GoOS правильные. Теперь, отвечая на школьные вопросы об устройстве ОС, я могу открыть собственный код, показать эксперимент и объяснить, почему конкретное решение работает — или почему его пришлось исправить.

PS № 3

Для сборки необходимы Go 1.25.5, TinyGo 0.42.0 (LLVM 22.1.4), Apple clang 21.0.0, и QEMU 11.0.1 и многое другое — полный перечень всего необходимого хозяйства можно найти тут. Я работаю на macOS, где использую port для установки портированных приложений (например, тот же QEMU), потому вся сборка прежде всего будет работать именно на macOS. Дойдут руки — переделаю так, чтобы собирать можно было ещё и под Linux (хотя‑бы).

Репозиторий GoOS - тут

Комментарии (0)