Что такое операционная система

Предпосылки: аппаратное обеспечение (CPU, кеш, RAM, хранилище, DMA).

Режимы CPU и системные вызовы

Одна программа — проблем нет

Контроллер двигателя в автомобиле работает без операционной системы. Прошивка стартует при подаче питания, инициализирует периферию (датчики оборотов, форсунки, шину CAN (Controller Area Network)) и входит в бесконечный цикл: прочитать датчик — рассчитать время впрыска — записать команду в регистр форсунки. Программа одна, вся память принадлежит ей, все устройства — её. Такой режим называется bare-metal (буквально «голое железо»): код работает напрямую на процессоре, без посредников.

На встраиваемых системах (embedded systems) это норма. Прошивка контроллера ABS (Anti-lock Braking System), код термостата, прошивка (firmware) SSD — все они работают без ОС, потому что выполняют единственную задачу и владеют всем оборудованием монопольно. Никто не конкурирует за процессор, никто не пишет в чужую память, никто не обращается к тому же устройству одновременно.

Проблемы начинаются, когда программ становится две.

Две программы — три проблемы

Допустим, на одном компьютере нужно запустить две программы: веб-сервер, обрабатывающий HTTP-запросы, и фоновый агент, пишущий метрики на диск. Оба работают на одном процессоре, оба обращаются к оперативной памяти, оба записывают данные на NVMe SSD (Non-Volatile Memory Express). Без ОС каждый из них — bare-metal программа с полным доступом ко всему оборудованию. Три вещи ломаются немедленно.

Защита памяти

Оперативная память — плоское адресное пространство. На машине с 16 ГБ RAM адреса идут от 0x0 до 0x3FFFFFFFF. Без ОС обе программы видят все эти адреса и могут писать куда угодно.

Веб-сервер хранит буфер входящего HTTP-запроса по адресу 0x00A00000. Агент метрик выделяет массив для накопления данных и тоже получает адрес 0x00A00000 — ничто не мешает, ведь никакого менеджера памяти нет. Агент записывает число 0x41414141 в этот адрес. Веб-сервер на следующей итерации читает «HTTP-запрос», получает мусор и ведёт себя непредсказуемо — на bare-metal нет ядра, которое остановит программу при обращении к чужой памяти. В лучшем случае программа аварийно завершится сама, в худшем — интерпретирует мусор как валидные данные и отправит их клиенту.

Проблема не в ошибке программиста, а в отсутствии границ. Даже если разработчики договорились «сервер использует адреса 0x00A000000x00FFFFFF, агент — 0x010000000x01FFFFFF», одна ошибка в вычислении индекса массива, одно переполнение буфера (buffer overflow) — и чужая память повреждена. На bare-metal нет механизма, который отклонит запись по чужому адресу: процессор выполнит инструкцию mov безусловно.

Совместный доступ к устройствам

NVMe SSD управляется через пару очередей в оперативной памяти: Submission Queue (очередь отправки, SQ) и Completion Queue (очередь завершения, CQ). Программа записывает команду (адрес буфера, номер блока, размер) в SQ и звонит в дверной звонок контроллера — пишет номер tail-позиции в регистр Doorbell. Контроллер забирает команду, выполняет DMA-передачу и кладёт результат в CQ.

Когда веб-сервер и агент метрик обращаются к диску одновременно, происходит следующее. Оба создают собственные SQ по одному и тому же базовому адресу — контроллер NVMe поддерживает одну пару очередей Admin Queue, и оба процесса конфигурируют её независимо.

            RAM
  +-----------------------+
  | SQ[0]: read  blk 1000 |  <-- веб-сервер записал команду чтения
  +-----------------------+
            |
            v  агент перезаписывает ту же позицию
  +-----------------------+
  | SQ[0]: write blk 5000 |  <-- команда веб-сервера потеряна
  +-----------------------+
            |
            v
  +----------------------+
  |    NVMe controller   |  получает write вместо read
  +----------------------+

Веб-сервер записывает команду чтения блока 1000 в позицию SQ[0]. Агент записывает команду записи блока 5000 в ту же позицию SQ[0], затирая первую команду. Контроллер получает команду записи вместо чтения, данные веб-сервера потеряны, а блок 5000 на диске перезаписан содержимым буфера агента — возможно, мусором, если буфер указывал на чужую память (проблема выше).

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

Справедливость

Процессор в каждый момент времени выполняет инструкции одной программы. Если веб-сервер вошёл в бесконечный цикл из-за бага — например, парсер HTTP зациклился на некорректном заголовке — процессор никогда не переключится на агент метрик. На bare-metal нет механизма принудительного переключения: программа сама должна вернуть управление, а зациклившаяся программа этого не сделает.

Даже без багов проблема остаётся. Веб-сервер обрабатывает запрос, требующий 500 мс вычислений. Всё это время агент метрик не может записать ни одного замера — его код просто не выполняется. На сервере с сотнями процессов (типичный Linux-сервер запускает 200–400 процессов) ситуация неуправляема: один тяжёлый вычислительный процесс блокирует все остальные.

Процессор с частотой 3 ГГц выполняет порядка 3 миллиардов тактов в секунду. Если эти такты достаются одному процессу, остальные 399 не получают ни одного. Нужен арбитр, который принудительно забирает процессор у одной программы и передаёт другой — даже если та не хочет отдавать.

Три задачи операционной системы

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

  +---------------------------------------------------------------------------------+
  |                            Пользовательские процессы                            |
  |   веб-сервер   агент метрик   БД (база данных)   cron (планировщик задач)   ... |
  +---------------------------------------------------------------------------------+
                                           |
           write(номер_ресурса, буфер, размер)   -- абстракция (запись в любой ресурс одним вызовом)
           fork(), mmap()                        -- изоляция (создание процесса, отображение памяти)
           (таймер прерывает)                    -- распределение
                                           |
                                           v
  +---------------------------------------------------------------------------------+
  |                                  Ядро (kernel)                                  |
  |                                                                                 |
  |   виртуальная    драйверы     планировщик                                       |
  |     память       устройств      (scheduler)                                     |
  +---------------------------------------------------------------------------------+
                                           |
                                           v
  +---------------------------------------------------------------------------------+
  |                                  Оборудование                                   |
  |   CPU    RAM    NVMe    NIC (сетевая карта)                                     |
  +---------------------------------------------------------------------------------+

Изоляция (isolation). Каждый процесс считает, что владеет всей памятью машины. Веб-сервер видит адреса от 0x0 до 0xFFFFFFFFFFFF — полные 48 бит виртуального адресного пространства (256 ТБ на x86-64). Агент метрик видит свои 256 ТБ. Адрес 0x00A00000 в одном процессе и адрес 0x00A00000 в другом указывают на разные физические страницы RAM. Запись по адресу в одном процессе физически не может затронуть память другого.

Этот механизм — виртуальная память (virtual memory) — реализуется аппаратно через MMU (Memory Management Unit, блок управления памятью) процессора, но настраивается и управляется операционной системой. MMU транслирует виртуальный адрес процесса в физический адрес RAM при каждом обращении к памяти. Если процесс пытается обратиться к адресу, для которого нет отображения, MMU генерирует аппаратное исключение (page fault — ошибка страницы), и ядро завершает процесс-нарушитель. Чужая память недоступна не по договорённости, а по физике: трансляция не содержит чужих страниц.

Абстракция (abstraction). Вместо очередей NVMe, регистров Doorbell и DMA-дескрипторов программа вызывает write(fd, buffer, size) — записать size байт из buffer в файл. Операционная система знает, что файл лежит на NVMe-диске, что диск подключён через PCIe (Peripheral Component Interconnect Express), что для передачи нужно сформировать SQ-команду и дождаться CQ-ответа. Программе знать это не нужно. Если завтра диск заменят на SATA SSD (Serial ATA), интерфейс write() не изменится — изменится только драйвер внутри ОС.

Абстракция решает и проблему совместного доступа: все запросы к диску проходят через ОС, которая координирует доступ — ставит запросы в очереди и управляет их отправкой контроллеру. Современные NVMe-диски принимают десятки тысяч параллельных запросов через множество аппаратных очередей, но порядок и приоритеты определяет ядро. Два процесса больше не конфигурируют DMA одновременно: они просят ОС, а ОС решает, когда и как отправить каждый запрос.

Распределение ресурсов (resource sharing). Планировщик (scheduler) операционной системы прерывает выполнение процесса через аппаратный таймер — обычно каждые 1–10 мс — и передаёт процессор другому процессу. Зациклившийся веб-сервер получает свои 4 мс, затем управление принудительно переходит к агенту метрик, затем к следующему процессу.

На машине с 400 процессами каждый получает время, пропорциональное его приоритету. Ни один процесс не может монополизировать CPU, потому что таймер срабатывает аппаратно — программа не может его отключить. Такое переключение называется вытесняющей многозадачностью (preemptive multitasking): ОС вытесняет текущий процесс, не спрашивая его согласия.

То же распределение работает для RAM (ОС решает, какому процессу выделить физические страницы, и может отобрать их через механизм подкачки — swap), для дискового I/O (планировщик ввода-вывода упорядочивает запросы) и для сети (каждый сокет получает свой буфер).

Ядро: привилегированная часть ОС

Операционная система — это обычная программа, работающая на том же процессоре, что и пользовательские процессы. Но она не может быть «обычной» в полном смысле: чтобы защитить процессы друг от друга, ОС должна иметь привилегии, недоступные остальным программам. Эта привилегированная часть ОС называется ядром (kernel).

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

Но здесь возникает фундаментальный вопрос: кто запрещает пользовательской программе выполнить ту же инструкцию, что и ядро? Если и ядро, и веб-сервер — машинный код на одном процессоре, что мешает веб-серверу напрямую записать значение в регистр управления MMU и отключить защиту памяти?

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

На x86-64 это реализовано через кольца защиты (protection rings). Ядро работает в ring 0 — кольце с максимальными привилегиями. Пользовательские программы работают в ring 3 — кольце с минимальными привилегиями. Кольца 1 и 2 предусмотрены архитектурой, но в Linux не используются. Процессор хранит текущее кольцо в двух битах регистра CS (Code Segment) и аппаратно проверяет его перед каждой привилегированной инструкцией. Попытка выполнить инструкцию ring 0 из ring 3 вызывает исключение General Protection Fault (#GP) — процессор прерывает программу и передаёт управление обработчику исключений в ядре. Ядро, как правило, завершает процесс-нарушитель.

Как именно пользовательская программа просит ядро выполнить привилегированную операцию — через системные вызовы (syscalls), специальные инструкции процессора, которые переключают кольцо с ring 3 на ring 0 контролируемым образом.

Sources


Режимы CPU и системные вызовы