Что такое операционная система
Предпосылки: аппаратное обеспечение (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 нет ядра, которое остановит программу
при обращении к чужой памяти. В лучшем случае программа аварийно завершится сама,
в худшем — интерпретирует мусор как валидные данные и отправит их клиенту.
Проблема не в ошибке программиста, а в отсутствии границ. Даже если
разработчики договорились «сервер использует адреса
0x00A00000–0x00FFFFFF, агент — 0x01000000–0x01FFFFFF», одна
ошибка в вычислении индекса массива, одно переполнение буфера (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
- Abraham Silberschatz, Peter B. Galvin, Greg Gagne, 2018, Operating System Concepts — 10th edition: https://www.os-book.com/OS10/
- Andrew S. Tanenbaum, Herbert Bos, 2014, Modern Operating Systems — 4th edition: https://www.pearson.com/en-us/subject-catalog/p/modern-operating-systems/P200000003295/