Как устроена современная ОС

Карта операционной системы: слои от железа до вашей программы, ядро и его устройство, загрузка, прерывания, драйверы, время, права и песочницы — и чем Linux, macOS и Windows отличаются для разработчика.

Листайте стрелками ← →, свайпом или кнопками внизу. В схемах нажимайте кнопки и двигайте ползунки.

Три работы операционной системы

  • Абстракция. Вместо регистров NVMe-контроллера — файл, вместо сетевой карты — сокет, вместо ядер процессора — поток.
  • Мультиплексирование. Одно железо делится между сотнями процессов: процессор — по времени, память — по пространству, диск и сеть — очередями.
  • Изоляция и защита. Ошибка или злой умысел одной программы не должны задеть остальные и само ядро.

Выберите ресурс — увидите все три работы и что будет, если программа поведёт себя плохо.

Слои: от железа до приложения

Каждый слой прячет сложность нижнего и даёт верхнему стабильный интерфейс. Самый важный рубеж — между ядром и всем остальным: ниже него код работает с полными правами.

  • Linux обещает стабильность системных вызовов: «we don't break userspace». Поэтому Go собирает статические бинарники, которые сами делают syscall.
  • macOS и Windows стабильность обещают только для библиотек: libSystem и Win32. Номера вызовов меняются между версиями, и Go там ходит в ядро через системные DLL и dylib.

Нажмите на слой и переключите ОС.

Архитектуры ядра

  • Монолитное (Linux, FreeBSD): файловые системы, сеть и драйверы — внутри ядра, общаются вызовами функций. Быстро, но ошибка в любом драйвере роняет всю систему.
  • Микроядро (seL4, QNX, MINIX 3): в ядре только адресные пространства, планирование и обмен сообщениями. Остальное — процессы, которые можно перезапустить. seL4 — около 10 тыс. строк с формальным доказательством корректности.
  • Гибридное (XNU в macOS, NT в Windows): задумано с оглядкой на микроядро, но ради скорости почти всё работает в режиме ядра.

Выберите архитектуру и нажмите действие.

Что происходит при загрузке

От кнопки питания до приглашения входа — цепочка программ, каждая из которых загружает следующую и уступает ей место. Ядро стартует где-то в середине.

  • Время по этапам покажет systemd-analyze, самые медленные сервисы — systemd-analyze blame, а то, что реально задержало загрузку, — systemd-analyze critical-chain.
  • Сообщения ядра с момента старта — dmesg или journalctl -k -b.

Выберите машину и идите по шагам.

Проверьте себя

Юнит сервиса содержит After=network.target. После перезагрузки сервера сервис падает: «не удалось разрешить имя db.internal». Ручной systemctl restart помогает. В чём дело?

network.target означает лишь, что запущены службы управления сетью; адреса и маршруты ещё могут не быть настроены. Ждать сеть — Wants=network-online.target вместе с After=network-online.target. Но надёжнее, чтобы сервис сам повторял подключение: сеть и база могут пропасть и после загрузки.

Прерывания и исключения

  • Прерывание — асинхронный сигнал от устройства: пакет пришёл, диск дочитал, таймер сработал. Процессор бросает текущий код, где бы тот ни был, и прыгает в обработчик ядра.
  • Исключение — синхронная реакция на инструкцию: ошибка страницы, деление на ноль, системный вызов.
  • Обработчик делится на две половины: верхняя — микросекунды с запрещёнными прерываниями, только подтвердить и запланировать; нижняя (softirq, workqueue) — вся остальная работа.

Выберите событие и пройдите путь по шагам.

Когда прерываний слишком много

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

  • NAPI: под нагрузкой драйвер выключает прерывания и опрашивает очередь пачками по 64 пакета.
  • RSS: у карты несколько очередей, каждая со своим прерыванием на своё ядро.
  • В top это столбцы hi и si, по ядрам — mpstat -P ALL 1, счётчики — /proc/interrupts и /proc/softirqs.

Драйверы и «всё — файл»

Драйвер — код ядра, который знает регистры конкретного устройства. Наружу он отдаёт общий интерфейс: open, read, write, ioctl, mmap. Поэтому cat умеет читать и диск, и терминал, и счётчики ядра.

  • Большие объёмы устройства пишут в память сами, через DMA: процессор только готовит буферы и получает прерывание по готовности.
  • Узлы в /dev создаёт udev, когда ядро находит устройство. Буква c или b в ls -l — символьное или блочное, пара чисел — номер драйвера и экземпляра.

Выберите файл.

Граница — системный вызов

Всё, что программа хочет от ядра, проходит через одну дверь: номер вызова в регистр, аргументы в регистры, инструкция syscall (svc на ARM). Ядро проверяет аргументы и права, делает работу, возвращает число или код ошибки.

  • Ваш язык почти никогда не делает это сам: open() в Python, fs.open в Node, FileInputStream в Java вызывают libc, а та — ядро.
  • Цена вызова, strace, режимы процессора, процессы и сигналы — в презентации про процессы и потоки.

# Python: open("data.txt")
#   → CPython: _io.FileIO → open() из libc
#   → glibc: mov $257, %eax      # номер openat
#            syscall              # кольцо 3 → кольцо 0
#   → ядро:  do_sys_openat2 → VFS → ext4
#   ← rax = 3 (fd) или -ENOENT → errno

$ strace -e trace=openat python3 -c 'open("data.txt")'
openat(AT_FDCWD, "data.txt", O_RDONLY|O_CLOEXEC) = 3

# Сколько дверей: Linux x86-64 — около 370 вызовов,
# macOS — ~550 BSD-вызовов плюс Mach-ловушки,
# Windows — ~480 в ntoskrnl и ~1200 в win32k,
# и номера меняются от сборки к сборке

Время: часы и таймеры

  • Настенные часы (CLOCK_REALTIME) — секунды с 1970 года. Их переставляет NTP, администратор, гипервизор после паузы ВМ. Длительности ими мерить нельзя.
  • Монотонные (CLOCK_MONOTONIC) только растут, на сне ноутбука стоят. CLOCK_BOOTTIME учитывает и сон.
  • Таймер ядра тикает 100–1000 раз в секунду, но простаивающее ядро процессора тики пропускает (tickless) и спит до ближайшего таймера. Чтение часов через vDSO — 20 нс без входа в ядро.

Запустите замер и устройте часам неприятность.

Проверьте себя

Сервис на Python пишет в метрики time.time() - start. Ночью на графике появилась задержка запроса −1,8 секунды. Что произошло?

time.time() считает секунды в UTC, часовые пояса и летнее время на неё не влияют. А вот шаг NTP после рассинхронизации — например, когда виртуальную машину сняли с паузы — двигает часы на секунды назад или вперёд. Длительности мерьте монотонными часами: time.monotonic() или perf_counter() в Python, performance.now() в JS, System.nanoTime() в Java. В Go time.Now() несёт монотонное показание, и time.Since безопасен.

Пользователи, группы, права

У процесса есть uid и список групп, у файла — владелец, группа и 12 бит режима. Ядро выбирает одну тройку бит — владельца, группы или остальных — и проверяет только её.

  • setuid на исполняемом файле: процесс получает права владельца файла. Так passwd пишет в /etc/shadow.
  • root проходит проверку чтения и записи всегда, а выполнения — если стоит хоть один бит x.

Кликайте по битам режима, выбирайте пользователя и действие.

root, capabilities и песочницы

  • Всемогущество root в Linux разрезано на ~40 capabilities: CAP_NET_BIND_SERVICE — порты ниже 1024, CAP_SYS_TIME — менять часы, CAP_SYS_ADMIN — почти всё остальное.
  • seccomp — фильтр системных вызовов, срабатывает раньше всех проверок. SELinux и AppArmor — правила поверх прав: какой программе какие файлы можно. В macOS — Seatbelt-песочница и TCC (запросы «разрешить доступ к папке»).
  • root в Docker — тот же uid 0, но с 14 capabilities, seccomp-профилем и AppArmor.

Выберите, кто запускает процесс, и действие.

Проверьте себя

В вашем домашнем каталоге лежит файл -r--r--r-- root root report.txt. Вы не root. Получится ли rm report.txt?

Имя файла — это запись в каталоге, и удаление меняет каталог, а не файл. Нужны права w и x на каталог. rm лишь спросит подтверждение, -f убирает вопрос, а не проверку. Исключение — sticky bit (t) на каталоге, как у /tmp: там удалить чужой файл может только его владелец.

Как заглянуть в ядро: /proc, /sys, eBPF

  • /proc и /sys — файловые системы без диска: файлы генерирует ядро при чтении. dmesg — кольцевой журнал ядра: OOM killer, ошибки дисков, сегфолты.
  • eBPF — маленькие программы, которые ядро загружает к себе, проверив верификатором, и вызывает на событиях: системный вызов, функция ядра, функция в вашем бинарнике.
  • strace останавливает процесс на каждом вызове и замедляет его в разы. eBPF считает прямо в ядре: ~100 нс на событие, можно на проде.

Выберите вопрос и запустите однострочник bpftrace.

Linux, macOS, Windows: что важно разработчику

Рантаймы прячут различия, но не все. Код, который работает на ноутбуке, падает в CI или у коллеги на Windows как раз в этих местах.

Выберите тему.

Проверьте себя

В проекте на Node есть файл config.js, а в коде — require('./Config'). На Mac всё работает, в CI на Linux — Cannot find module './Config'. Почему?

macOS и Windows по умолчанию хранят регистр имени, но при поиске его не учитывают: Config.js находит config.js. Linux ищет точное совпадение. Отсюда же проблемы git с файлами, отличающимися только регистром. Ловите это заранее: линтер (import/no-unresolved с проверкой регистра), CaseSensitivePathsPlugin в webpack, сборка в Linux-контейнере.

Как посмотреть у себя

  • uname -a, lsmod, modinfo — какое ядро и какие модули загружены.
  • systemd-analyze, journalctl -b, dmesg -T — загрузка и журнал ядра.
  • /proc/interrupts, mpstat — кто и на каком ядре прерывает.
  • id, getpcaps, grep Cap /proc/PID/status — права и capabilities процесса.
  • bpftrace и утилиты bcc: execsnoop, opensnoop, biolatency.
  • macOS: sysctl, log show, dtruss. Windows: Process Monitor и Process Explorer.

uname -r; lsmod | head
systemd-analyze blame | head
systemd-analyze critical-chain myapp.service
journalctl -k -b -p warning

watch -n1 'grep -E "CPU|eth0|nvme" /proc/interrupts'
mpstat -P ALL 1          # %irq %soft по ядрам

ls -l /dev/null /dev/nvme0n1
cat /sys/class/net/eth0/mtu

id; stat -c '%A %U:%G %n' file
capsh --decode=$(awk '/CapEff/{print $2}' /proc/self/status)
grep Seccomp /proc/PID/status   # 2 = фильтр

execsnoop-bpfcc          # кто что запускает
bpftrace -e 'tracepoint:syscalls:sys_enter_openat
  { printf("%s %s\n", comm, str(args.filename)); }'

Главное

  • ОС делает три вещи: прячет железо за абстракциями, делит его между программами и защищает их друг от друга.
  • Ядро — единственный код с полными правами. Linux монолитный с модулями, macOS и Windows — гибриды. Стабильный интерфейс в Linux — системные вызовы, в macOS и Windows — системные библиотеки.
  • Ядро работает по событиям: системные вызовы, исключения и прерывания. Таймер делает многозадачность вытесняющей, NAPI спасает от шторма прерываний.
  • Длительности — монотонными часами, никогда настенными.
  • Права — одна тройка бит, root разрезан на capabilities, контейнер — это uid плюс seccomp и LSM.
  • Смотреть в ядро можно на проде: /proc, /sys, dmesg и eBPF.

Ядро — это не программа, которая работает. Это программа, которая отвечает на события.