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

Предпосылки: что такое операционная система (абстракция, изоляция, разделение ресурсов, процесс как экземпляр программы); регистры CPU — именованные ячейки хранения внутри процессора, к которым он обращается за наносекунды (см. ISA).

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

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

Кольца привилегий

Процессоры архитектуры x86 реализуют четыре кольца привилегий (privilege rings) — уровни доступа от 0 до 3. На практике используются два: кольцо 0 (kernel mode, режим ядра) и кольцо 3 (user mode, пользовательский режим).

Текущий уровень привилегий хранится в двух младших битах регистра CS (Code Segment) — это CPL (Current Privilege Level). Когда CPL = 0, процессору доступны все инструкции. Когда CPL = 3, целый класс операций запрещён аппаратно.

кольцо 3 (user mode)      кольцо 0 (kernel mode)
  CPL = 3                    CPL = 0
  +-----------+              +-----------+
  | программа |  -- нет -->  | ядро      |
  |           |  прямого     | драйверы  |
  |           |  доступа     | железо    |
  +-----------+              +-----------+

Что именно запрещено в кольце 3? Любая инструкция, которая может нарушить изоляцию. Инструкция out отправляет данные в порт ввода-вывода — через неё можно напрямую обратиться к диску. Инструкция hlt останавливает процессор. Инструкция mov cr3, ... переключает таблицу страниц, что позволило бы читать память другого процесса. Если программа с CPL = 3 попытается выполнить любую из них, процессор немедленно генерирует исключение General Protection Fault (#GP) — ядро перехватывает его и, как правило, завершает процесс сигналом SIGSEGV (сигнал нарушения сегментации, который ОС посылает процессу).

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

Кольца 1 и 2 были задуманы для драйверов и системных сервисов, но на практике ни Linux, ни Windows их не используют — только 0 и 3. Виртуализация добавила ещё один уровень: гипервизор работает в специальном режиме VMX root (Virtual Machine Extensions) (иногда неформально называемом «ring -1»), а гостевые ОС получают аппаратное кольцо 0, но под контролем гипервизора.

Проблема: как программа обращается к ядру

Изоляция работает: программа не может напрямую писать на диск, отправлять пакеты по сети или выделять страницы памяти. Но программе всё это нужно. Обычная инструкция call здесь не поможет — она передаёт управление по адресу, но не меняет CPL. Программа остаётся в кольце 3, а код ядра ожидает кольцо 0.

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

Механизм системного вызова

Системный вызов (system call, syscall) — единственный штатный способ для программы попросить ядро выполнить привилегированную операцию. Инструкция syscall была разработана специально для этого: она обходится без таблицы прерываний и стоит ~100-300 наносекунд на системах без защит от Spectre/Meltdown; с включённой KPTI и другими митигациями — до 200-700 нс в зависимости от поколения CPU.

Весь процесс проходит в три фазы.

Фаза 1: подготовка запроса

Программа помещает номер системного вызова и аргументы в регистры процессора. Для Linux на x86-64 соглашение следующее: номер syscall — в rax, аргументы — в rdi, rsi, rdx, r10, r8, r9 (до шести аргументов). Это чистая конвенция между ядром и пользовательским кодом — процессор не знает, что означают значения в этих регистрах.

Стандартная библиотека C

На практике программист редко работает с регистрами напрямую. Стандартная библиотека C (glibc — GNU C Library, musl) предоставляет функции-обёртки: read(), write(), open(). Обёртка заполняет регистры, выполняет инструкцию syscall, проверяет результат и в случае ошибки устанавливает errno. Но суть остаётся той же — под каждой обёрткой лежит ровно один переход в кольцо 0.

Фаза 2: переключение в ядро

Программа выполняет инструкцию syscall. В этот момент процессор делает несколько вещей как единая операция: сохраняет адрес возврата в регистр rcx, сохраняет флаги в r11, устанавливает CPL = 0, загружает из специального регистра LSTAR (Long System Target Address Register) адрес точки входа в ядро и передаёт туда управление. Адрес в LSTAR ядро записало при загрузке системы — программа не может его изменить.

Это ключевой момент: программа не выбирает, куда прыгнуть в ядре. Процессор всегда передаёт управление в одну и ту же фиксированную точку — entry_SYSCALL_64 в ядре Linux. Программа влияет только на номер вызова в rax.

Фаза 3: выполнение в ядре

Обработчик entry_SYSCALL_64 сохраняет регистры пользовательского процесса, переключает стек на стек ядра и по номеру из rax находит нужную функцию в таблице системных вызовов (sys_call_table). В Linux ~340 системных вызовов: read (номер 0), write (1), open (2), close (3), mmap (9) и так далее.

Ядро выполняет запрошенную операцию: копирует данные с диска в буфер, отправляет пакет через сетевую карту, выделяет страницы памяти. Результат помещается в rax (успех — значение >= 0, ошибка — отрицательный код). Инструкция sysret восстанавливает CPL = 3 и возвращает управление по адресу из rcx.

flowchart TB
    subgraph U["программа (ring 3)"]
        Regs["rax=1 (write)<br>rdi=1 (stdout)<br>rsi=buf, rdx=80"]
    end

    Regs -->|"syscall"| CPU["CPU: CPL 3→0<br>jump LSTAR"]

    subgraph K["ядро (ring 0)"]
        Entry["entry_SYSCALL_64"]
        Table["sys_call_table[1]"]
        Write["ksys_write(fd=1, buf, 80)"]
        Result["rax = 80 (написано байт)"]
        Entry --> Table --> Write --> Result
    end

    CPU --> Entry
    Result -->|"sysret<br>CPU: CPL 0→3"| Check["проверяет rax"]

Цена системного вызова

Обычный вызов функции внутри программы — инструкция call — стоит 1-5 наносекунд. Системный вызов обходится в 100-300 наносекунд без защитных митигаций: примерно 50-100 раз дороже. Сам аппаратный переход — swapgs, сохранение/восстановление регистров, переключение стека, диспатч по таблице, проверка флагов, sysret — занимает 50-100 наносекунд (подробнее — в механизме системных вызовов). Остальное добавляют косвенные эффекты: сброс конвейера процессора (pipeline flush — процессор заранее декодирует следующие инструкции; при смене привилегии эта предвыборка сбрасывается), промахи TLB после переключения контекста, прогрев кеша, проверки безопасности и возможная обработка сигналов при возврате. На процессорах с защитой от Spectre и Meltdown добавляется переключение таблиц страниц (KPTI) и ограничение спекулятивного выполнения (IBRS — Indirect Branch Restricted Speculation), что увеличивает стоимость до 200-700 наносекунд в зависимости от поколения CPU и набора включённых защит.

Для единичного вызова 200 наносекунд — ничто. Но системные вызовы совершаются тысячами и миллионами раз в секунду, и накладные расходы складываются. Утилита strace -c позволяет посчитать, сколько системных вызовов сделала программа и сколько времени на них ушло. Нередко оказывается, что короткоживущая программа тратит на системные вызовы больше времени, чем на собственные вычисления.

Анатомия read()

Чтобы увидеть цену конкретно, разберём один вызов целиком. Рассмотрим read(fd, buf, count). Три аргумента: fd (file descriptor) — целое число, идентифицирующее открытый ресурс (файл, сокет, пайп); buf — адрес в памяти программы, куда ядро запишет прочитанные данные; count — сколько байт запрашивается.

fdфайловый дескриптор: индекс в таблице открытых файлов процесса. Открытие файла через open() возвращает следующий свободный номер — обычно 3.

Вызов read(3, buf, 4096) означает: «из ресурса номер 3 прочитай до 4096 байт и положи их по адресу buf». Ядро проверяет, что дескриптор 3 действительно принадлежит этому процессу, определяет тип ресурса (файл на диске, сокет, устройство), вызывает соответствующий драйвер, копирует данные из пространства ядра в буфер программы и возвращает количество фактически прочитанных байт.

Возвращаемое значение read() может быть меньше запрошенного count — это нормальное поведение, а не ошибка. Если в файле осталось 500 байт, а запрашивалось 4096, read() вернёт 500. Если дескриптор указывает на сокет, данные приходят порциями — read() вернёт столько, сколько доступно прямо сейчас. Возврат 0 означает конец файла — EOF (End Of File). Возврат -1 — ошибка, код которой записывается в глобальную переменную errno.

vDSO: системный вызов без системного вызова

Некоторые операции запрашиваются так часто, что 200 наносекунд на каждую становятся узким местом. Функция gettimeofday() — типичный пример: веб-сервер вызывает её при логировании каждого запроса, профилировщик — тысячи раз в секунду, база данных — при каждой фиксации транзакции.

При этом gettimeofday() только читает данные — текущее время. Ядро и так обновляет эти данные периодически (при каждом тике таймера, обычно каждые 1-4 миллисекунды). Полноценное переключение в кольцо 0 для чтения одного числа — расточительство.

Решение — vDSO (virtual Dynamic Shared Object, виртуальный динамический разделяемый объект). При запуске каждого процесса ядро отображает (map) в его адресное пространство небольшую область памяти — обычно две страницы, 8 КБ. В этой области лежит код и данные, подготовленные ядром. Ядро периодически обновляет данные в этой странице (записывая текущее время), а программа читает их обычной инструкцией mov — без переключения колец.

адресное пространство процесса
+---------------------------+
|  код программы            |
+---------------------------+
|  куча (heap)              |
+---------------------------+
|  ...                      |
+---------------------------+
|  vDSO (8 КБ)             |  <-- отображена ядром
|  - код gettimeofday()     |      при запуске процесса
|  - данные: текущее время  |      ядро обновляет данные
+---------------------------+      каждые 1-4 мс
|  стек                     |
+---------------------------+

Вызов gettimeofday() через vDSO занимает ~20-50 наносекунд: чтение базовых данных из отображённой страницы, чтение аппаратного счётчика TSC и вычисление текущего времени — всё в пользовательском режиме, без переключения колец. Для сравнения: тот же вызов через полный syscall стоит ~200 наносекунд. При 10 000 вызовов в секунду экономия составляет 10000 * 170 нс = ~1.7 мс — заметно для приложений, чувствительных к латентности.

В Linux через vDSO ускорены несколько функций: gettimeofday(), clock_gettime(), getcpu(), time(). Общий принцип: если данные обновляются ядром редко, а читаются пользователем часто, и при этом только на чтение — vDSO позволяет избежать переключения колец.

Буферизация: сокращение числа системных вызовов

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

Представим программу, которая пишет лог: каждая строка — 80 байт. Без буферизации каждая строка — отдельный write():

write(fd, "2026-03-23 ...\n", 80)    -- 200 нс
write(fd, "2026-03-23 ...\n", 80)    -- 200 нс
write(fd, "2026-03-23 ...\n", 80)    -- 200 нс
...
1000 строк/сек * 200 нс = 200 мкс/сек только на переключение колец

1000 системных вызовов в секунду, каждый по ~200 наносекунд — 200 микросекунд чистых накладных расходов. Само копирование 80 байт внутри ядра занимает считанные наносекунды — почти всё время уходит на входы-выходы из кольца 0.

Альтернатива — накопить данные в буфере на стороне программы и отправить одним вызовом. Стандартная библиотека C (libc) делает именно это. Функции fprintf(), fwrite(), fputs() не вызывают write() напрямую. Вместо этого они копируют данные во внутренний буфер — на glibc (Linux) его размер по умолчанию 8192 байт (константа BUFSIZ), на musl и BSD — обычно 4096 или 1024. Когда буфер заполняется — одним вызовом write() сбрасываются накопленные данные.

fprintf() -> буфер (8192 байт)     <- без syscall, копирование в памяти
fprintf() -> буфер                  <- без syscall
fprintf() -> буфер                  <- без syscall
...
после ~102 строк (102 * 80 = 8160):
  write(fd, buf, 8160)              <- один syscall на 102 строки

Для 1000 строк по 80 байт: 80 000 байт / 8192 ≈ 10 системных вызовов вместо 1000. Накладные расходы: 10 * 200 нс = 2 микросекунды вместо 200. Разница в 100 раз.

Три режима буферизации stdio

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

Полная буферизация (fully buffered) — данные сбрасываются, когда буфер заполнен. Это поведение по умолчанию для файлов. Размер буфера обычно 4096 или 8192 байт.

Построчная буферизация (line buffered) — данные сбрасываются при появлении символа новой строки \n или при заполнении буфера. Это поведение по умолчанию для stdout, когда он подключён к терминалу. Каждая строка printf("...\n") немедленно появляется на экране.

Без буферизации (unbuffered) — каждый вызов функции записи немедленно выполняет write(). Это поведение по умолчанию для stderr: сообщения об ошибках должны появляться немедленно, даже если программа аварийно завершится до сброса буферов. Именно поэтому при fprintf(stderr, "error: ...\n") сообщение гарантированно дойдёт до терминала, а fprintf(stdout, "status: ...\n") может потеряться, если процесс упадёт до вызова fflush() или до заполнения буфера.

Неожиданное следствие: одна и та же программа ведёт себя по-разному в зависимости от того, куда направлен её вывод. Если stdout подключён к терминалу — построчная буферизация, printf() с \n в конце выводит строку сразу. Если stdout перенаправлен в файл или пайп (./program | grep error) — полная буферизация, данные могут задерживаться на тысячи строк. Это частый источник путаницы при отладке: «в терминале вижу вывод, а в пайпе — нет».

Буферизация при чтении

Буферизация работает и в обратную сторону. Функция fread() при первом вызове читает не запрошенное количество байт, а целый блок — 8192 байт на glibc — одним вызовом read(). Последующие вызовы fread() берут данные из этого буфера, пока он не опустеет.

Наглядный пример: программа читает файл размером 1 мегабайт (1 048 576 байт) по одному байту за раз.

Вариант с прямыми системными вызовами — read(fd, &c, 1) в цикле: 1 048 576 вызовов read(), каждый по ~200 наносекунд. Только на переключения колец: 1 048 576 * 200 нс = ~210 миллисекунд. При этом ядро каждый раз проверяет дескриптор, определяет позицию в файле, копирует один байт — избыточная работа для чтения следующего символа, который уже лежит на той же странице файлового кеша.

Вариант с fread(&c, 1, 1, fp): первый вызов делает read(fd, internal_buf, 8192) — один системный вызов, 8192 байт. Следующие 8191 вызовов fread() — копирование из буфера в памяти программы, без системных вызовов. На весь файл: 1 048 576 / 8192 = 128 системных вызовов. Время на переключения: 128 * 200 нс ≈ 26 микросекунд. Плюс ~1 048 576 операций копирования одного байта из буфера — порядка 1 миллисекунды. Итого ~1 мс против ~210 мс. Разница — примерно 200 раз.

побайтовый read():                 fread() с буфером 8192:

read(fd, &c, 1) -- syscall         fread() -- read(fd, buf, 8192) syscall
read(fd, &c, 1) -- syscall         fread() -- копия из буфера
read(fd, &c, 1) -- syscall         fread() -- копия из буфера
...                                 ...
read(fd, &c, 1) -- syscall         fread() -- копия из буфера (x8191)
                                    fread() -- read(fd, buf, 8192) syscall
                                    ...

1 048 576 syscalls                  128 syscalls
~210 мс на переключения            ~0.03 мс на переключения + ~1 мс копирование

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

Важная оговорка: буферизация stdio — это буферизация в пространстве программы, в библиотеке C. Она не имеет отношения к буферному кешу ядра (page cache), который кеширует страницы файлов в оперативной памяти. Данные проходят два уровня буферизации: сначала stdio-буфер в процессе, затем write() передаёт данные ядру, где они попадают в page cache, и только потом — на диск. Вызов fflush() сбрасывает stdio-буфер, но не гарантирует запись на диск. Для этого нужен fsync() — ещё один системный вызов, который заставляет ядро записать страницы из page cache на физический носитель.

Итого: иерархия стоимости

операция                           время              кратность
-----------------------------------------------------------------
чтение из регистра CPU             ~0.3 нс            1x
копирование из stdio-буфера        ~1 нс              3x
вызов функции (call/ret)           ~1-5 нс            3-15x
вызов через vDSO (clock_gettime)   ~20-50 нс          60-150x
системный вызов (user->kernel)*    ~100-700 нс        300-2000x
чтение страницы с SSD              ~50-100 мкс        150 000-300 000x
чтение страницы с HDD              ~5-10 мс           15 000 000-30 000 000x

* нижняя граница — без защит от Spectre/Meltdown;
  верхняя — с KPTI + IBRS на старших поколениях CPU

Каждый переход на следующий уровень стоит на порядки дороже. Буферизация, vDSO, кеширование — разные техники с одной целью: удержать данные на более дешёвом уровне и реже пересекать границы. Программист не всегда управляет всеми уровнями напрямую — но понимание иерархии стоимости объясняет, почему fread() в 200 раз быстрее побайтового read(), почему gettimeofday() через vDSO на порядок дешевле полного системного вызова, и почему даже на быстром SSD сокращение числа системных вызовов остаётся значимой оптимизацией.

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

См. также

  • O и GVL — блокирующий syscall (File.read, Socket.read) освобождает GVL: пока поток в kernel mode, другие Ruby-потоки выполняют bytecode

Sources


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