С какого системного вызова начинается выполнение программы в linux
Системный вызов (англ. system call) — обращение прикладной программы к ядру операционной системы для выполнения какой-либо операции.
В Unix, Unix-like и других POSIX-совместимых операционных системах популярными системными вызовами являются open, read, write, close, wait, exec, fork, exit и kill. Многие современные ОС имеют сотни системных вызовов. Например, Linux и OpenBSD каждые имеют порядка 380 разных вызовов.
Реализация архитектурно-зависима. Типичный способ — программное прерывание. На x86 есть лучшие способы, например инструкции SYSCALL/SYSRET и SYSENTER/SYSEXIT (используются в Linux начиная с 2.5).
strace
Отладочная утилита пользовательского режима, позволяет отслеживать взаимодействия между процессами и ядром Linux. Работает через функциональность ядра под названием ptrace.
Сигналы
Сигналы являются одним из способов взаимодействия между процессами (IPC, inter-process communication) в Unix-системах. Фактически, сигнал — это асинхронное уведомление процесса или отдельного потока о каком-либо событии.
Механизм сигналов был изобретён в 1970-х в Bell Labs.
Когда сигнал послан процессу, операционная система прерывает выполнение процесса. Выполнение может быть прервано на любой неатомарной инструкции. Если процесс установил собственный обработчик сигнала, операционная система запускает этот обработчик, передав ему информацию о сигнале. Если процесс не установил обработчик, то выполняется обработчик по умолчанию.
Названия сигналов «SIG…» являются числовыми константами (макроопределениями Си) со значениями, определяемыми в заголовочном файле signal.h . Числовые значения сигналов могут меняться от системы к системе, хотя основная их часть имеет в разных системах одни и те же значения. Утилита kill позволяет задавать сигнал как числом, так и символьным обозначением.
Посылка сигналов
- с терминала, нажатием специальных клавиш или комбинаций (например, нажатие Ctrl-C генерирует SIGINT, Ctrl-\ SIGQUIT, а Ctrl-Z SIGTSTP);
- ядром системы:
- при возникновении аппаратных исключений (недопустимых инструкций, нарушениях при обращении в память, системных сбоях и т. п.);
- ошибочных системных вызовах;
- для информирования о событиях ввода-вывода;
- из shell, утилитой /bin/kill.
Сигналы не могут быть посланы завершившемуся процессу, находящемуся в состоянии «зомби».
Обработка сигналов
Обработчик по умолчанию для большинства сигналов завершает выполнение процесса. Для альтернативной обработки всех сигналов, за исключением SIGKILL и SIGSTOP, процесс может назначить свой обработчик или игнорировать их возникновение модификацией своей сигнальной маски.
Безопасность
Процесс (или пользователь из оболочки) с эффективным UID, не равным 0 (UID суперпользователя), может посылать сигналы только процессам с тем же UID.
Обработчики сигналов
Опасность написания обработчика
Распространено мнение, что обработчик сигнала — это сложно и ничего в нем делать не надо.
Обработчик сигнала — это не какая-то неведомая сущность, которая может всё нестандартным образом испортить и из нее физически можно вызывать только малый набор функций. Обработчик сигнала — обычная функция, которая может выполнять все те же действия, что и другой код, которая работает в userspace, как и другой код. Единственные отличия:
- функция вызывается через обёртку, которая может например поменять стек (если это задано специальным параметром) и может завершить программу при выходе из обработчика сигнала;
- функция может вызываться в почти произвольных местах кода.
Первая особенность практически никак не влияет, вторая особенность влечет за собой побочные эффекты:
- вызывая некоторые функции из сигнала, которые работают в многопоточном режиме, мы можем попасть в deadlock, так как попали в обработчик сигнала из критической секции этой самой функции;
- в однопоточном коде мы можем нарваться на структуры данных в неконсистентном состоянии, если попали в обработчик сигнала из кода, который работает с этими структурами;
- некоторые библиотечные функции небезопасно вызывать, так как их выполнение тоже может прерываться обработчиком сигнала и они не reentrant. Есть гарантированный список POSIX-функций, которые безопасно вызывать в обработчике сигнала async-signal-safe (табличка в POSIX 2.4.3 Signal Actions). Функций вообще говоря не мало, например fork, exec, mkdir, open, write, connect, sigsuspend и прочие нетривиальные действия. Библиотечные функции sigprocmask, alarm, raise являются async-signal-safe. Такие функции, как malloc, free, запись в лог, обычно многопоточны, и здесь возможен deadlock.
Ещё надо понимать, что обработчик сигнала может быть вызван из состояния, когда уже всё нестандартным образом испорчено. Например, после "проезда по памяти", когда данные произвольным образом повреждены.
Группы процессов
Группа процессов (pgrp) — набор из одного и более процессов. Группе процессов можно послать сигнал одновременно.
Каждый процесс является членом некоторой группы процессов, которая идентифицируется своим process group ID (PGID). Это целое число типа pid_t, как и идентификатор процесса.
Когда создаётся новый процесс, он становится членом группы его родительского процесса.
По соглашению, process group ID равен PID первого члена этой группы процессов, называемого лидером группы (process group leader). В группе процессов в любой момент не обязательно есть лидер: он мог уже завершиться, но важно, что группа начинается с лидера и его PID фиксируется в PGID. POSIX запрещает переиспользование PID, пока группа с таким идентификатором существует (т. е. лидер уже завершился, но какие-то процессы группы ещё нет). Поэтому новый произвольный процесс не может случайно стать лидером какой-то существующей группы.
Процесс может узнать ID своей группы при помощи системного вызова getpgrp(), или, что то же самое, getpgid(0). Можно получить process group ID для произвольного процесса p при помощи getpgid(p).
Можно использовать команду
для просмотра PPID (parent process ID), PID (process ID), PGID (process group ID) и SID (session ID).
Job control
- В шеллах, которые не имеют возможности управления заданиями (job control), например ash, каждый дочерний процесс будет в той же сессии и в той же группе, как и сам шелл.
- В более продвинутых шеллах, например bash, процессы конвейера, например
формируют отдельную группу.
Группы процессов являются низкоуровневым механизмом, который лежит в основе понятия задания (job). Те job ID, которые отображаются в шелле по команде jobs, создаются самим шеллом и искусственно начинаются с единицы.
Создание
Системный вызов setpgid() используется для того, чтобы установить process group ID для процесса, либо посредством присоединения процесса к уже существующей группе, либо посредством создания новой группы (процесс становится её лидером).
Процесс можно внеси в группу при помощи
Если pgid == pid или pgid == 0, то создаётся новая группа с лидером pid. Иначе процесс вносится в уже существующую группу pgid. Нулевой pid значит текущий процесс. Вызов setpgrp() эквивалентен вызову setpgid(0,0).
Ограничения setpgid()
Вызывающий процесс сам должен быть указанным pid'ом или его родителем, то есть можно переместить в другую группу себя или какого-либо своего потомка.
Обычный пример использования
Так оно работает независимо от того, кто будет выполняться первым (родительский или дочерний).
Сигналы и ожидание
Можно послать сигнал всем процессам группы:
Можно подождать дочерние процессы в своей группе:
или в указанной группе:
Сессии
Процессы группируются в группы. Группы процессов формируют сессии.
Сессия — коллекция из одной и более групп процессов.
Группе процессов не разрешается мигрировать из одной сессии в другую, процесс не может создать группу в другой сессии. Более того, процессу не разрешено присоединиться к группе в другой сессии, то есть переходить из одной сессии в другую. Процесс не может также переместить свои дочерние процессы в другие сессии.
Системный вызов setsid() используется для создания новой сессии, содержащей одну (новую) группу процессов, текущий процесс становится лидером группы и лидером сессии. Если процесс покидает сессию, он не может вернуться обратно.
В POSIX нет такого отдельного понятия, как Session ID (SID), но в Linux такое понятие есть. Сессия идентифицируется при помощи process group ID лидера сессии. POSIX запрещает изменение process group ID лидера сессии.
Вызов exec не изменяет группу и сессию.
У сессии несколько другой смысл, чем у группы. Группа — это задача (job), а сессия — это, неформально говоря, сессия пользователя: вы входите в систему через локальный терминал, или по ssh на удалённую машину, тем самым начиная сессию, и завершаете её, выходя из системы.
Контролирующий терминал (Controlling terminal)
Казалось бы, если сессии нужны шеллу для слежения за всеми процессами-потомками, зачем вводить это понятие в ядро ОС.
Сессия может иметь контролирующий терминал (ctty), а может не иметь. Важно, что у всех процессов сессии контролирующий терминал один и тот же. Один терминал может быть контролирующим для не более чем одной сессии.
Терминал ctty управляет процессами, посылая им сигналы, поэтому он так и называется. Процесс никогда не получит сигнал от терминала, который не является его ctty. Хоть процесс и может получить сигнал от другого процесса, работающего в другом ctty.
В стандарте POSIX не предусмотрено способа, как сессия может получить себе ctty. Там только сказано, что если лидер сессии, в которой нет ctty, открывает tty и не указывает флаг O_NOCTTY, то этот терминал может стать ctty для этой сессии (в Linux это так). Если терминал открывает не лидер сессии или устройство терминала открывается через вызов open с флагом O_NOCTTY, то терминал не станет контролирующим.
Также в стандарте не сказано, как можно отвязаться от ctty.
Для любого процесса фиктивное устройство /dev/tty связано с ctty сессии. Его можно открыть даже непривилегированному пользователю, потому что права на /dev/tty стоят rw-rw-rw-.
Когда процесс делает setsid(), у него получается новая сессия без ctty.
background и foreground
В сессии одна группа процессов может быть на переднем плане (foreground), а все остальные группы на заднем плане (background). Задумка такая: с foreground-процессами пользователь непосредственно взаимодействует через терминал в данный момент времени, а background выполняются в фоне.
Обычно, когда вы запускаете новый процесс в шелле, он стартует в foreground, а сам шелл уходит в background, затем по завершении процесса шелл снова выходит в foreground. Обратите внимание, что когда шелл в background, то любой ввод пользователя идёт в foreground, а не в шелл.
Background-процессам не разрешатеся читать из ctty, но им можно разрешить или не разрешить писать в ctty. Клавиатурные сигналы, типа SIGINT по нажатию Ctrl+C, посылаются только foreground-группе.
Если процесс в background пытается прочитать с терминала, он получает сигнал SIGTTIN. Действие по умолчанию — остановить процесс. Если он пытается писать в терминал, то результат зависит от того, выставлен ли флаг TOSTOP на терминале.
Когда лидер сессии умирает, сессия теряет свой ctty.
Демоны
Демон (daemon, dæmon, божество) — программа в ОС семейства Unix, запускаемая самой системой и работающая в фоновом режиме без прямого взаимодействия с пользователем.
В системах Windows аналогичный класс программ называется службой (Services).
Название "демон" появилось ещё до Unix, в 1960-x годах в системе Project MAC. Названо в честь демона Максвелла из физики, занимающегося сортировкой молекул в фоновом режиме. Демон также является персонажем греческой мифологии, выполняющим задачи, за которые не хотят браться боги.
Демонизация через fork()
В этом случае получившийся процесс демона является лидером сессии.
Специальная функция daemon()
Есть нестандартная функция daemon() (её нет в стандарте POSIX, но она реализована в Linux, BSD и др.).
- делает один fork,
- сменяет текущий каталог на / (опционально),
- переоткрывает стандартные дескрипторы потоков ввода-вывода 0, 1, 2 на /dev/null (опционально).
Переоткрывать дескрипторы на /dev/null может быть лучше, чем просто закрывать их. Потому что, если вы впоследствии откроете какой-то файл, лог и пр., новый дескриптор примет одно из стандартных значений, и какая-то библиотека, которая пишет в stderr, может внезапно записать в ваш файл, имеющий дескриптор 2.
Технология двойного fork()
Включает последовательность вызовов fork(), setsid(), fork(). Иначе эту технику можно назвать fork-decouple-fork.
Зачем это нужно? Такой трюк позволяет убедиться, что процесс демона не является лидером сессии.
Первый процесс в группе является лидером группы. Первый процесс сессии является лидером сессии. Каждая сессия имеет некоторый TTY, ассоциированный с ней. Только лидер сессии может взять контроль над TTY. Для настоящей демонизации (выполнения в фоне) мы должны убедиться, что лидер сессии убит, так что у него точно нет возможности взять контроль над TTY.
Рассмотрим более подробный пример:
Обратите внимание, что после decouple процесс является лидером сессии, потому что PID = SID. Он по-прежнему может управлять TTY.
Проще говоря, двойной fork — это окольный путь для полного отсоединения себя от терминала, в котором был запущен демон.
Системный вызов — это механизм взаимодействия пользовательских программ с ядром Linux, а strace — мощный инструмент, для их отслеживания. Для лучшего понимания работы операционной системы полезно разобраться с тем, как они работают.В операционной системе можно выделить два режима работы:
- Режим ядра (kernel mode) — привилегированный режим, используемый ядром операционной системы.
- Пользовательский режим (user mode) — режим, в котором выполняется большинство пользовательских приложений.
Системные вызовы очень похожи на вызовы функций, в том смысле, что в них передаются аргументы и они возвращают значения. Единственное отличие состоит в том, что системные вызовы работают на уровне ядра, а функции нет. Переключение из пользовательского режима в режим ядра осуществляется с помощью специального механизма прерываний.
Большая часть этих деталей скрыта от пользователя в системных библиотеках (glibc в Linux-системах). Системные вызовы по своей природе являются универсальными, но несмотря на это, механика их выполнения во многом аппаратно-зависима.
В этой статье рассматривается несколько практических примеров анализа системных вызовов с помощью strace . В примерах используется Red Hat Enterprise Linux, но все команды должны работать и в других дистрибутивах Linux:
Для начала убедитесь, что в вашей системе установлены необходимые инструменты. Проверить установлен ли strace можно с помощью приведенной ниже команды. Для просмотра версии strace запустите ее с параметром -V:
Если strace не установлен, то установите запустив:
Для примера создайте тестовый каталог в /tmp и два файла с помощью команды touch :
(Я использую каталог /tmp только потому, что доступ к нему есть у всех, но вы можете использовать любой другой.)С помощью команды ls проверьте, что в каталоге testdir создались файлы:
Вероятно, вы используете команду ls каждый день, не осознавая того, что под капотом работают системные вызовы. Здесь в игру вступает абстракция. Вот как работает эта команда:
Команда ls вызывает функции из системных библиотек Linux (glibc). Эти библиотеки, в свою очередь, вызывают системные вызовы, которые выполняют большую часть работы.Если вы хотите узнать, какие функции вызывались из библиотеки glibc, то используйте команду ltrace со следующей за ней командой ls testdir/ :
Если ltrace не установлен, то установите:
На экране будет много информации, но не беспокойтесь — мы это рассмотрим далее. Вот некоторые из важных библиотечных функций из вывода ltrace :
Изучив этот вывод, вы, вероятно, поймете, что происходит. Каталог с именем testdir открывается с помощью библиотечной функции opendir , после чего следуют вызовы функций readdir , читающих содержимое каталога. В конце происходит вызов функции closedir , которая закрывает каталог, открытый ранее. Пока проигнорируйте остальные функции, такие как strlen и memcpy .Как вы видите, можно легко посмотреть вызываемые библиотечные функции, но в этой статье мы сфокусируемся на системных вызовах, которые вызываются функциями системных библиотек.
Для просмотра системных вызовов используйте strace с командой ls testdir , как показано ниже. И вы снова получите кучу бессвязной информации:
В результате выполнения strace вы получите список системных вызовов, выполненных при работе команды ls . Все системные вызовы можно разделить на следующие категории:- Управление процессами
- Управление файлами
- Управление каталогами и файловой системой
- Прочие
На этот раз на экране не будет никаких данных — команда ls отработает, как и ожидается, показав список файлов и записав весь вывод strace в файл trace.log . Для простой команды ls файл содержит почти 100 строк:
Взгляните на первую строку в файле trace.log :- В начале строки находится имя выполняемого системного вызова — это execve.
- Текст в круглых скобках — это аргументы, передаваемые системному вызову.
- Число после знака = (в данном случае 0) — это значение, возвращаемое системным вызовом.
Обратите внимание на ту единственную команду, которую вы вызвали — ls testdir . Вам известно имя каталога, используемое командой ls , так почему бы не воспользоваться grep для testdir в файле trace.log и не посмотреть, что найдется? Посмотрите внимательно на результат:
Возвращаясь к приведенному выше анализу execve , можете ли вы сказать, что делает следующий системный вызов?
Не нужно запоминать все системные вызовы и то, что они делают: все есть в документации. Man-страницы спешат на помощь! Перед запуском команды man убедитесь, что установлен пакет man-pages :
Помните, что вам нужно добавить «2» между командой man и именем системного вызова. Если вы прочитаете в man про man ( man man ), то увидите, что раздел 2 зарезервирован для системных вызовов. Аналогично если вам нужна информация о библиотечных функциях, то нужно добавить 3 между man и именем библиотечной функции.Ниже приведены номера разделов man :
Для просмотра документации по системному вызову запустите man с именем этого системного вызова.
В соответствии с документацией системный вызов execve выполняет программу, которая передается ему в параметрах (в данном случае это ls ). В него также передаются дополнительные параметры для ls. В этом примере это testdir . Следовательно, этот системный вызов просто запускает ls с testdir в качестве параметра:
В следующий системный вызов stat передается параметр testdir :
Для просмотра документации используйте man 2 stat . Системный вызов stat возвращает информацию об указанном файле. Помните, что все в Linux — файл, включая каталоги.Далее системный вызов openat открывает testdir . Обратите внимание, что возвращается значение 3. Это дескриптор файла, который будет использоваться в последующих системных вызовах:
Теперь откройте файл и обратите внимание на строку, следующую после системного вызова openat . Вы увидите системный вызов getdents , который делает большую часть необходимой работы для выполнения команды ls testdir . Теперь выполним grep getdents для файла trace.log :
В документации ( man getdents ) говорится, что getdents читает записи каталога, это, собственно, нам и нужно. Обратите внимание, что аргумент для getdent равен 3 — это дескриптор файла, полученный ранее от системного вызова openat .Теперь, когда получено содержимое каталога, нужен способ отобразить информацию в терминале. Итак, делаем grep для другого системного вызова write , который используется для вывода на терминал:
В аргументах вы можете видеть имена файлов, которые будут выводится: file1 и file2 . Что касается первого аргумента (1), вспомните, что в Linux для любого процесса по умолчанию открываются три файловых дескриптора:- 0 — стандартный поток ввода
- 1 — стандартный поток вывода
- 2 — стандартный поток ошибок
Теперь вы знаете, какие системные вызовы сделали большую часть работы для команды ls testdir/ . Но что насчет других 100+ системных вызовов в файле trace.log ?
Операционная система выполняет много вспомогательных действий для запуска процесса, поэтому многое из того, что вы видите в файле trace.log — это инициализация и очистка процесса. Посмотрите файл trace.log полностью и попытайтесь понять, что происходит во время запуска команды ls .
Теперь вы можете анализировать системные вызовы для любых программ. Утилита strace так же предоставляет множество полезных параметров командной строки, некоторые из которых описаны ниже.
По умолчанию strace отображает не всю информацию о системных вызовах. Однако у нее есть опция -v verbose , которая покажет дополнительную информацию о каждом системном вызове:
Хорошая практика использовать параметр -f для отслеживания дочерних процессов, созданных запущенным процессом:
А если вам нужны только имена системных вызовов, количество их запусков и процент времени, затраченного на выполнение? Вы можете использовать опцию -c , чтобы получить эту статистику:
Если вы хотите отследить определенный системный вызов, например, open , и проигнорировать другие, то можно использовать опцию -e с именем системного вызова:
А что, если нужно отфильтровать по нескольким системным вызовам? Не волнуйтесь, можно использовать ту же опцию -e и разделить необходимые системные вызовы запятой. Например, для write и getdent :
До сих пор мы отслеживали только явный запуск команд. Но как насчет команд, которые были запущены ранее? Что, если вы хотите отслеживать демонов? Для этого у strace есть специальная опция -p , которой вы можете передать идентификатор процесса.Мы не будем запускать демона, а используем команду cat , которая отображает содержимое файла, переданного ему в качестве аргумента. Но если аргумент не указать, то команда cat будет просто ждать ввод от пользователя. После ввода текста она выведет введенный текст на экран. И так до тех пор, пока пользователь не нажмет Ctrl+C для выхода.
Запустите команду cat на одном терминале.
На другом терминале найдите идентификатор процесса (PID) с помощью команды ps :
Теперь запустите strace с опцией -p и PID'ом, который вы нашли с помощью ps . После запуска strace выведет информацию о процессе, к которому он подключился, а также его PID. Теперь strace отслеживает системные вызовы, выполняемые командой cat . Первый системный вызов, который вы увидите — это read, ожидающий ввода от потока с номером 0, то есть от стандартного ввода, который сейчас является терминалом, на котором запущена команда cat :
Теперь вернитесь к терминалу, где вы оставили запущенную команду cat , и введите какой-нибудь текст. Для демонстрации я ввел x0x0 . Обратите внимание, что cat просто повторил то, что я ввел и x0x0 на экране будет дважды.
Вернитесь к терминалу, где strace был подключен к процессу cat . Теперь вы видите два новых системных вызова: предыдущий read , который теперь прочитал x0x0 , и еще один для записи write , который записывает x0x0 обратно в терминал, и снова новый read , который ожидает чтения с терминала. Обратите внимание, что стандартный ввод (0) и стандартный вывод (1) находятся на одном и том же терминале:
Представляете, какую пользу может принести вам запуск strace для демонов: вы можете увидеть все, что делается в фоне. Завершите команду , нажав . Это также прекратит сеанс , так как отслеживаемый процесс был прекращен.Для просмотра отметок времени системных вызовов используйте опцию -t :
А если вы хотите узнать время, проведенное между системными вызовами? Есть удобная опция -r , которая показывает время, затраченное на выполнение каждого системного вызова. Довольно полезно, не так ли?Заключение
Утилита strace очень удобна для изучения системных вызовов в Linux. Чтобы узнать о других параметрах командной строки, обратитесь к man и онлайн-документации.
В этой статье я хотел бы рассказать о том, какой жизненный путь проходят процессы в семействе ОС Linux. В теории и на примерах я рассмотрю как процессы рождаются и умирают, немного расскажу о механике системных вызовов и сигналов.Данная статья в большей мере рассчитана на новичков в системном программировании и тех, кто просто хочет узнать немного больше о том, как работают процессы в Linux.
Всё написанное ниже справедливо к Debian Linux с ядром 4.15.0.Содержание
- Введение
- Атрибуты процесса
- Жизненный цикл процесса
- Рождение процесса
- Состояние «готов»
- Состояние «выполняется»
- Перерождение в другую программу
- Состояние «ожидает»
- Состояние «остановлен»
- Завершение процесса
- Состояние «зомби»
- Забытье
Введение
Системное программное обеспечение взаимодействует с ядром системы посредством специальных функций — системных вызовов. В редких случаях существует альтернативный API, например, procfs или sysfs, выполненные в виде виртуальных файловых систем.
Атрибуты процесса
Процесс в ядре представляется просто как структура с множеством полей (определение структуры можно прочитать здесь).
Но так как статья посвящена системному программированию, а не разработке ядра, то несколько абстрагируемся и просто акцентируем внимание на важных для нас полях процесса:- Идентификатор процесса (pid)
- Открытые файловые дескрипторы (fd)
- Обработчики сигналов (signal handler)
- Текущий рабочий каталог (cwd)
- Переменные окружения (environ)
- Код возврата
Жизненный цикл процесса
Рождение процесса
Только один процесс в системе рождается особенным способом — init — он порождается непосредственно ядром. Все остальные процессы появляются путём дублирования текущего процесса с помощью системного вызова fork(2) . После выполнения fork(2) получаем два практически идентичных процесса за исключением следующих пунктов:
- fork(2) возвращает родителю PID ребёнка, ребёнку возвращается 0;
- У ребёнка меняется PPID (Parent Process Id) на PID родителя.
Состояние «готов»
Сразу после выполнения fork(2) переходит в состояние «готов».
Фактически, процесс стоит в очереди и ждёт, когда планировщик (scheduler) в ядре даст процессу выполняться на процессоре.Состояние «выполняется»
Как только планировщик поставил процесс на выполнение, началось состояние «выполняется». Процесс может выполняться весь предложенный промежуток (квант) времени, а может уступить место другим процессам, воспользовавшись системным вывозом sched_yield .
Перерождение в другую программу
В некоторых программах реализована логика, в которой родительский процесс создает дочерний для решения какой-либо задачи. Ребёнок в данном случае решает какую-то конкретную проблему, а родитель лишь делегирует своим детям задачи. Например, веб-сервер при входящем подключении создаёт ребёнка и передаёт обработку подключения ему.
Однако, если нужно запустить другую программу, то необходимо прибегнуть к системному вызову execve(2) :
или библиотечным вызовам execl(3), execlp(3), execle(3), execv(3), execvp(3), execvpe(3) :
Все из перечисленных вызовов выполняют программу, путь до которой указан в первом аргументе. В случае успеха управление передаётся загруженной программе и в исходную уже не возвращается. При этом у загруженной программы остаются все поля структуры процесса, кроме файловых дескрипторов, помеченных как O_CLOEXEC , они закроются.Как не путаться во всех этих вызовах и выбирать нужный? Достаточно постичь логику именования:
- Все вызовы начинаются с exec
- Пятая буква определяет вид передачи аргументов:
- l обозначает list, все параметры передаются как arg1, arg2, . NULL
- v обозначает vector, все параметры передаются в нуль-терминированном массиве;
Есть соглашение, которое подразумевает, что argv[0] совпадает с нулевым аргументов для функций семейства exec*. Однако, это можно нарушить.
Любопытный читатель может заметить, что в сигнатуре функции int main(int argc, char* argv[]) есть число — количество аргументов, но в семействе функций exec* ничего такого не передаётся. Почему? Потому что при запуске программы управление передаётся не сразу в main. Перед этим выполняются некоторые действия, определённые glibc, в том числе подсчёт argc.
Состояние «ожидает»
Некоторые системные вызовы могут выполняться долго, например, ввод-вывод. В таких случаях процесс переходит в состояние «ожидает». Как только системный вызов будет выполнен, ядро переведёт процесс в состояние «готов».
В Linux так же существует состояние «ожидает», в котором процесс не реагирует на сигналы прерывания. В этом состоянии процесс становится «неубиваемым», а все пришедшие сигналы встают в очередь до тех пор, пока процесс не выйдет из этого состояния.
Ядро само выбирает, в какое из состояний перевести процесс. Чаще всего в состояние «ожидает (без прерываний)» попадают процессы, которые запрашивают ввод-вывод. Особенно заметно это при использовании удалённого диска (NFS) с не очень быстрым интернетом.Состояние «остановлен»
В любой момент можно приостановить выполнение процесса, отправив ему сигнал SIGSTOP. Процесс перейдёт в состояние «остановлен» и будет находиться там до тех пор, пока ему не придёт сигнал продолжать работу (SIGCONT) или умереть (SIGKILL). Остальные сигналы будут поставлены в очередь.
Завершение процесса
Ни одна программа не умеет завершаться сама. Они могут лишь попросить систему об этом с помощью системного вызова _exit или быть завершенными системой из-за ошибки. Даже когда возвращаешь число из main() , всё равно неявно вызывается _exit .
Хотя аргумент системного вызова принимает значение типа int, в качестве кода возврата берется лишь младший байт числа.Состояние «зомби»
Сразу после того, как процесс завершился (неважно, корректно или нет), ядро записывает информацию о том, как завершился процесс и переводит его в состояние «зомби». Иными словами, зомби — это завершившийся процесс, но память о нём всё ещё хранится в ядре.
Более того, это второе состояние, в котором процесс может смело игнорировать сигнал SIGKILL, ведь что мертво не может умереть ещё раз.Забытье
Код возврата и причина завершения процесса всё ещё хранится в ядре и её нужно оттуда забрать. Для этого можно воспользоваться соответствующими системными вызовами:
Вся информация о завершении процесса влезает в тип данных int. Для получения кода возврата и причины завершения программы используются макросы, описанные в man-странице waitpid(2) .Передача argv[0] как NULL приводит к падению.
Бывают случаи, при которых родитель завершается раньше, чем ребёнок. В таких случаях родителем ребёнка станет init и он применит вызов wait(2) , когда придёт время.
После того, как родитель забрал информацию о смерти ребёнка, ядро стирает всю информацию о ребёнке, чтобы на его место вскоре пришёл другой процесс.
Благодарности
Спасибо Саше «Al» за редактуру и помощь в оформлении;
Спасибо Саше «Reisse» за понятные ответы на сложные вопросы.
Они стойко перенесли напавшее на меня вдохновение и напавший на них шквал моих вопросов.
Про системные вызовы уже много было сказано, например здесь или здесь. Наверняка вам уже известно, что системный вызов — это способ вызова функции ядра ОС. Мне же захотелось копнуть глубже и узнать, что особенного в этом системном вызове, какие существуют реализации и какова их производительность на примере архитектуры x86-64. Если вам также интересны ответы на данные вопросы, добро пожаловать под кат.
System call
Каждый раз, когда мы хотим что-то отобразить на мониторе, записать в устройство, считать с файла, нам приходится обращаться к ядру ОС. Именно ядро ОС отвечает за любое общение с железом, именно там происходит работа с прерываниями, режимами процессора, переключениями задач… Чтобы пользователь программой не смог завалить работу всей операционной системы, было решено разделить пространство памяти на пространство пользователя (область памяти, предназначенная для выполнения пользовательских программ) и пространство ядра, а также запретить пользователю доступ к памяти ядра ОС. Реализовано это разделение в x86-семействе аппаратно при помощи сегментной защиты памяти. Но пользовательской программе нужно каким-то образом общаться с ядром, для этого и была придумана концепция системных вызовов.
Системный вызов — способ обращения программы пользовательского пространства к пространству ядра. Со стороны это может выглядеть как вызов обычной функции со своим собственным calling convention, но на самом деле процессором выполняется чуть больше действий, чем при вызове функции инструкцией call. Например, в архитектуре x86 во время системного вызова как минимум происходит увеличение уровня привилегий, замена пользовательских сегментов на сегменты ядра и установка регистра IP на обработчик системного вызова.
Программист обычно не работает с системными вызовами напрямую, так как системные вызовы обернуты в функции и скрыты в различных библиотеках, например libc.so в Linux или же ntdll.dll в Windows, с которыми и взаимодействует прикладной разработчик.
Теоретически, реализовать системный вызов можно при помощи любого исключения, хоть при помощи деления на 0. Главное — это передача управления ядру. Рассмотрим реальные примеры реализаций исключений.
Способы реализации системных вызовов
Выполнение неверной инструкции.
Ранее, ещё на 80386 это был самый быстрый способ сделать системный вызов. Для этого обычно применялась бессмысленная и неверная инструкция LOCK NOP, после исполнения которой процессором вызывался обработчик неверной инструкции. Это было больше 20 лет назад и, говорят, этим приёмом обрабатывались системные вызовы в корпорации Microsoft. Обработчик неверной инструкции в наши дни используется по назначению.
Call gates
Для того, чтобы иметь доступ к сегментам кода с различным уровнем привилегий, в Intel был разработан специальный набор дескрипторов, называемый gate descriptors. Существует 4 вида таких дескрипторов:
- Call gates
- Trap gates (для исключений, вроде int 3, требующих выполнения участка кода)
- Interrupt gates (аналогичен trap gates, но с некоторыми отличиями)
- Task gates (полагалось, что будут использоваться для переключения задач)
Нам интересны только call gates, так как именно через них планировалось реализовывать системные вызовы в x86.
Call gate реализован при помощи инструкции call far или jmp far и принимает в качестве параметра call gate-дескриптор, который настраивается ядром ОС. Является достаточно гибким механизмом, так как возможен переход и на любой уровень защитного кольца, и на 16-битный код. Считается, что call gates производительней прерываний. Этот способ использовался в OS/2 и Windows 95. Из-за неудобства использования в Linux механизм так и не был реализован. Со временем совсем перестал использоваться, так как появились более производительные и простые в обращении реализации системных вызовов (sysenter/sysexit).
Системные вызовы, реализованные в Linux
В архитектуре x86-64 ОС Linux существует несколько различных способов системных вызовов:
- int 80h
- sysenter/sysexit
- syscall/sysret
- vsyscall
- vDSO
В реализации каждого системного вызова есть свои особенности, но в общем, обработчик в Linux имеет примерно одинаковую структуру:
- Включается защита от чтения/записи/исполнения кода пользовательского пространства.
- Заменяется пользовательский стек на стек ядра, сохраняются callee-saved регистры.
- Выполняется обработка системного вызова
- Восстановление стека, регистров
- Отключение защиты
- Выход из системного вызова
Рассмотрим немного подробнее каждый системный вызов.
int 80h
Изначально, в архитектуре x86, Linux использовал программное прерывание 128 для совершения системного вызова. Для указания номера системного вызова, пользователь задаёт в eax номер системного вызова, а его параметры располагает по порядку в регистрах ebx, ecx, edx, esi, edi, ebp. Далее вызывается инструкция int 80h, которая программно вызывает прерывание. Процессором вызывается обработчик прерывания, установленный ядром Linux ещё во время инициализации ядра. В x86-64 вызов прерывания используется только во время эмуляции режима x32 для обратной совместимости.
В принципе, никто не запрещает пользоваться инструкцией в расширенном режиме. Но вы должны понимать, что используется 32-битная таблица вызовов и все используемые адреса должны помещаться в 32-битное адресное пространство. Согласно SYSTEM V ABI [4] §3.5.1, для программ, виртуальный адрес которых известен на этапе линковки и помещается в 2гб, по умолчанию используется малая модель памяти и все известные символы находятся в 32-битном адресном пространстве. Под это определение подходят статически скомпилированные программы, где и возможно использовать int 80h. Пошаговая работа прерывания подробно описана на stackoverflow.
В ядре обработчиком этого прерывания является функция entry_INT80_compat и находится в arch/x86/entry/entry_64_compat.S
Или в расширенном режиме (программа работает так как компилируется статически)
sysenter/sysexit
Спустя некоторое время, ещё когда не было x86-64, в Intel поняли, что можно ускорить системные вызовы, если создать специальную инструкцию системного вызова, тем самым минуя некоторые издержки прерывания. Так появилась пара инструкций sysenter/sysexit. Ускорение достигается за счёт того, что на аппаратном уровне при выполнении инструкции sysenter опускается множество проверок на валидность дескрипторов, а так же проверок, зависящих от уровня привилегий [3] §6.1. Также инструкция опирается на то, что вызывающая её программа использует плоскую модель памяти. В архитектуре Intel, инструкция валидна как для режима совместимости, так и для расширенного режима, но у AMD данная инструкция в расширенном режиме приводит к исключению неизвестного опкода [3]. Поэтому в настоящее время пара sysenter/sysexit используется только в режиме совместимости.
В ядре обработчиком этой инструкции является функция entry_SYSENTER_compat и находится в arch/x86/entry/entry_64_compat.S
Несмотря на то, что в реализации архитектуры от Intel инструкция валидна, в расширенном режиме скорее всего такой системный вызов никак не получится использовать. Это из-за того, что в регистре ebp сохраняется текущее значение стека, а адрес верхушки независимо от модели памяти находится вне 32-битного адресного пространства. Это всё потому, что Linux отображает стек на конец нижней половины каноничного адреса пространства.
Разработчики ядра Linux предостерегают пользователей от жесткого программирования sysenter из-за того, что ABI системного вызова может измениться. Из-за того, что Android не последовал этому совету, Linux пришлось откатить свой патч для сохранения обратной совместимости. Правильно реализовывать системный вызов нужно используя vDSO, речь о которой будет идти далее.
syscall/sysret
Так как именно AMD разработали x86-64 архитектуру, которая и называется AMD64, то они решили создать свой собственный системный вызов. Инструкция разрабатывалась AMD, как аналог sysenter/sysexit для архитектуры IA-32. В AMD позаботились о том, чтобы инструкция была реализована как в расширенном режиме, так и в режиме совместимости, но в Intel решили не поддерживать данную инструкцию в режиме совместимости. Несмотря на всё это, Linux имеет 2 обработчика для каждого из режимов: для x32 и x64. Обработчиками этой инструкции является функции entry_SYSCALL_64 для x64 и entry_SYSCALL_compat для x32 и находится в arch/x86/entry/entry_64.S и arch/x86/entry/entry_64_compat.S соответственно.
Кому интересно более подробно ознакомиться с инструкциями системных вызовов, в мануале Intel [0] (§4.3) приведён их псевдокод.
Для тестирования следующего примера потребуется ядро с конфигурацией CONFIG_IA32_EMULATION=y и компьютер AMD. Если же у вас компьютер фирмы Intel, то можно запустить пример на виртуалке. Linux может без предупреждения изменить ABI и этого системного вызова, поэтому в очередной раз напомню: системные вызовы в режиме совместимости правильнее исполнять через vDSO.
Непонятна причина, по которой AMD решили разработать свою инструкцию вместо того, чтобы расширить инструкцию Intel sysenter на архитектуру x86-64.
vsyscall
При переходе из пространства пользователя в пространство ядра происходит переключение контекста, что является не самой дешёвой операцией. Поэтому, для улучшения производительности системных вызовов, было решено их обрабатывать в пространстве пользователя. Для этого было зарезервировано 8 мб памяти для отображения пространства ядра в пространство пользователя. В эту память для архитектуры x86 поместили 3 реализации часто используемых read-only вызова: gettimeofday, time, getcpu.
Со временем стало понятно, что vsyscall имеет существенные недостатки. Фиксированное размещение в адресном пространстве является уязвимым местом с точки зрения безопасности, а отсутствие гибкости в размере выделяемой памяти может негативно сказаться на расширении отображаемой области ядра.
Для того, чтобы пример работал, необходимо, чтобы в ядре была включена поддержка vsyscall: CONFIG_X86_VSYSCALL_EMULATION=y
Linux не отображает vsyscall в режиме совместимости.
На данный момент, для сохранения обратной совместимости, ядро Linux предоставляет эмуляцию vsyscall. Эмуляция сделана для того, чтобы залатать дыры безопасности в ущерб производительности.
Эмуляция может быть реализована двумя способами.
Первый способ — при помощи замены адреса функции на системный вызов syscall. В таком случае виртуальный системный вызов функции gettimeofday на x86-64 выглядит следующим образом:
Где 0x60 — код системного вызова функции gettimeofday.
Второй же способ немного интереснее. При вызове функции vsyscall генерируется исключение Page fault, которое обрабатывается Linux. ОС видит, что ошибка произошла из-за исполнения инструкции по адресу vsyscall и передаёт управление обработчику виртуальных системных вызовов emulate_vsyscall (arch/x86/entry/vsyscall/vsyscall_64.c).
Реализацией vsyscall можно управлять при помощи параметра ядра vsyscall. Можно как отключить виртуальный системный вызов при помощи параметра vsyscall=none , задать реализацию как при помощи инструкции syscall syscall=native , так и через Page fault vsyscall=emulate .
vDSO (Virtual Dynamic Shared Object)
Также vDSO используется в качестве выбора наиболее производительного способа системного вызова, например в режиме совместимости.
Список разделяемых функций можно посмотреть в руководстве.
Для режима совместимости:
Правильнее всего искать функции vDSO при помощи извлечения адреса библиотеки из вспомогательного вектора AT_SYSINFO_EHDR и последующего парсинга разделяемого объекта. Пример парсинга vDSO из вспомогательного вектора можно найти в исходном коде ядра: tools/testing/selftests/vDSO/parse_vdso.c
Или если интересно, то можно покопаться и посмотреть, как парсится vDSO в glibc:
- Парсинг вспомогательных векторов: elf/dl-sysdep.c
- Парсинг разделяемой библиотеки: elf/setup-vdso.h
- Установка значений функций: sysdeps/unix/sysv/linux/x86_64/init-first.c, sysdeps/unix/sysv/linux/x86/gettimeofday.c, sysdeps/unix/sysv/linux/x86/time.c
Согласно System V ABI AMD64 [4] вызовы должны происходить при помощи инструкции syscall. На практике же к этой инструкции добавляются вызовы через vDSO. Поддержка системных вызовов в виде int 80h и vsyscall остались для обратной совместимости.
Сравнение производительности системных вызовов
С тестированием скорости системных вызовов всё неоднозначно. В архитектуре x86 на выполнение одной инструкции влияет множество факторов таких как наличие инструкции в кэше, загруженность конвейера, даже существует таблица задержек для данной архитектуры [2]. Поэтому достаточно сложно определить скорость выполнения участка кода. У Intel есть даже специальный гайд по замеру времени для участка кода [1]. Но проблема в том, что мы не можем замерить время согласно документу из-за того, что нам нужно вызывать объекты ядра из пользовательского пространства.
Поэтому было решено замерить время при помощи clock_gettime и тестировать производительность вызова gettimeofday, так как он есть во всех реализациях системных вызовов. На разных процессорах время может отличаться, но в целом, относительные результаты должны быть схожи.
Программа запускалась несколько раз и в итоге бралось минимальное время исполнения.
Тестирование int 80h, sysenter и vDSO-32 производилось в режиме совместимости.О системе
cat /proc/cpuinfo | grep "model name" -m 1 — Intel® Core(TM) i7-5500U CPU @ 2.40GHz
uname -r — 4.14.13-1-ARCHТаблица Результатов
Реализация время (нс) int 80h 498 sysenter 338 syscall 278 vsyscall emulate 692 vsyscall native 278 vDSO 37 vDSO-32 51
Как можно увидеть, каждая новая реализация системного вызова является производительней предыдущей, не считая vsysvall, так как это эмуляция. Как вы наверное уже догадались, если бы vsyscall был таким, каким его задумывали, время вызова было бы аналогично vDSO.
Все текущие сравнения производительности были произведены с патчем KPTI, исправляющим уязвимость meltdown.
Бонус: Производительность системных вызовов без KPTI
Патч KPTI был разработан специально для исправления уязвимости meltdown. Как известно, данный патч замедляет производительность ОС. Проверим производительность с выключенным KPTI (pti=off).
Таблица результатов с выключенным патчем
Реализация Время (нс) Увеличение времени исполнения после патча (нс) Ухудшение производительности после патча (t1 - t0) / t0 * 100% int 80h 317 181 57% sysenter 150 188 125% syscall 103 175 170% vsyscall emulate 496 196 40% vsyscall native 103 175 170% vDSO 37 0 0% vDSO-32 51 0 0%
Переход в режим ядра и обратно в среднем после патча стал занимать примерно на 180 нс. больше времени, видимо это и есть цена сброса TLB-кэша.
Производительность системного вызова через vDSO не ухудшилась по причине того, то в данном типе вызова нет перехода в режим ядра, и, следовательно, нет причин сбрасывать TLB-кэш.
При программировании на C мы используем средства стандартной библиотеки языка, такие как тип FILE*, функции fopen(), fread(), fwrite(), fclose(). Эти функции кроссплатформенные и основываются внутри на более низкоуровневых функциях.
Например, C-функция fread и fwrite:
На POSIX-совместимых системах (в том числе Linux) она сводится к функции
На Windows она сводится к функциям из WinAPI:
Если использовать только функции из библиотеки C, то можно писать переносимые программы, которые будут работать под разными ОС. Но если использовать API операционной системы, то можно получить больше возможностей и несколько лучшую производительность за счёт меньшего числа копирований данных в промежуточные буферы.
Системные вызовы
Как же устроены те самые POSIX-функции read() и write()?
Они реализуются посредством системных вызовов. Системный вызов (англ. system call) — обращение прикладной программы к ядру операционной системы для выполнения какой-либо операции.
Современные операционные системы предусматривают разделение полномочий, препятствующее обращению исполняемых программ к данным других программ и оборудованию. Ядро ОС исполняется в привилегированном режиме работы процессора. Для выполнения межпроцессной операции или операции, требующей доступа к оборудованию, программа обращается к ядру, которое, в зависимости от полномочий вызывающего процесса, исполняет либо отказывает в исполнении такого вызова.
Обычное пользовательское приложение работает в непривилегированном режиме в своём виртуальном адресном пространстве. В обычном случае для выполнения вычислительной работы, для доступа к памяти и пр. не требуются системные вызовы. Например, функции библиотеки C, такие как strlen() и memcpy(), не имеют ничего общего с ядром и всегда выполняются целиком в приложении. Однако такие функции, как malloc() и printf(), могут делать внутри системные вызовы.
Набор системных вызовов разный в разных операционных системах. Итого в ядре Linux около 310 системных вызовов. С ними можно познакомиться в таблице. Для сравнения, в ОС Windows системных вызовов около 460.
GNU C Library
Когда мы компилируем обычную программу на C под Linux, она автоматически линкуется с библиотекой glibc.
Библиотека GNU C Library (часто используется название glibc) — это вариант реализации стандартной библиотеки С от проекта GNU. Является одним из основных компонентов операционной системы GNU/Linux.
Реализует как стандартные C-функции типа malloc(), strcpy(), fopen() (они являются частью стандарта языка программирования C и доступны на всех платформах), так и POSIX-функции типа getpid(), open() (эти функции не входят в стандарт C и, как правило, скажем, под Windows не реализованы).
Библиотека GNU C Library предоставляет программисту удобный интерфейс для работы с ОС в виде интерфейсных функций. Многие функции в libc являются тонкими обёртками над системными вызовами. Однако не каждая POSIX-функция является системным вызовом. Так и наоборот, не для каждого системного вызова есть соответствующая C-функция.Размещение
Библиотека libc является одним файлом (динамическая so и статическая a), размещается в каталоге /usr/lib. Кроме того, в состав glibc (GNU libc) входят ещё несколько библиотек:
- libm — математическая библиотека (там реализованы функции вида sin(), cos(). )
- libpthread — POSIX Threads — библиотека для работы с потоками (мы обратимся к ней на следующих занятиях)
Всего функций в glibc много, мы рассмотрим только несколько.
Пример: fwrite
Вот шаги, которые включает в себя вызов C-функции fwrite:
- fwrite вместе с остальной частью стандартной библиотеки C реализован в glibc.
- fwrite вызывает более низкоуровневую функцию write.
- write загружает идентификатор системного вызова (который равен 1 для write) и аргументы в регистры процессора, а затем заставит процессор переключиться на уровень ядра. То, как это делается, зависит от архитектуры процессора, а иногда и от модели процессора. Например, процессоры x86 обычно вызывают прерывание 80, а процессоры x86-64 используют инструкцию процессора syscall.
- Процессор, который теперь работает в режиме ядра, передает идентификатор системного вызова в таблицу системных вызовов, извлекает указатель функции со смещением 1 и вызывает функцию. Эта функция, sys_write, является реализацией записи в файл.
Как работают системные вызовы
Из пользовательского пространства (ring 3) нельзя просто так вызвать функцию из ядра (ring 0), как обычную функцию. На шаге №3 в предыдущем примере используется тот или иной механизм перехода в режим ядра в зависимости от архитектуры компьютера. На компьютерах самой популярной архитектуры x86 для системный вызов делается тем или иным методом:
- через программное прерывание,
- через инструкцию sysenter,
- через инструкцию syscall.
Программное прерывание
Прерывания (англ. interrupts) — это как бы сигнал процессору, что надо прервать выполнение (их поэтому и назвали прерываниями) текущего кода и срочно сделать то, что указано в обработчике.
Прерывание извещает процессор о наступлении высокоприоритетного события, требующего прерывания текущего кода, выполняемого процессором. Процессор отвечает приостановкой своей текущей активности, сохраняя свое состояние, и выполняя функцию, называемую обработчиком прерывания (или программой обработки прерывания), который реагирует на событие и обслуживает его, после чего возвращает управление в прерванный код.
Программное прерывание — синхронное прерывание, которое может осуществить программа с помощью специальной инструкции.
В процессорах архитектуры x86 для явного вызова синхронного прерывания имеется инструкция int, аргументом которой является номер прерывания (от 0 до 255). В защищённом и длинном режиме обычные программы не могут обслуживать прерывания, эта функция доступна только системному коду (операционной системе).
В ОС Linux номер прерывания 0x80 (в десятичной системе — 128) используется для выполнения системных вызовов. Обработчиком прерывания 0x80 является ядро Linux. Программа перед выполнением прерывания помещает в регистр eax номер системного вызова, который нужно выполнить. Когда управление переходит в ring 0, то ядро считывает этот номер и вызывает нужную функцию.
Метод этот широко применялся на 32-битных системах, на 64-битных он считается устаревшим и не применяется, но тоже работает, хотя с целым рядом ограничений (например, нельзя в качестве параметра передать 64-битный указатель).
- Поместить номер системного вызова в eax.
- Поместить аргументы в регистры ebx, ecx, edx, esi, edi, ebp.
- Вызвать инструкцию int 0x80.
- Получить результат из eax.
Пример реализации mygetpid() (получение PID текущего процесса) на ассемблере (для системного вызова getpid используется номер 20):
Инструкция sysenter
Спустя некоторое время, ещё когда не было x86-64, в Intel поняли, что можно ускорить системные вызовы, если создать специальную инструкцию системного вызова, тем самым минуя некоторые издержки прерывания. Ускорение достигается за счёт того, что на аппаратном уровне при выполнении инструкции sysenter опускается множество проверок на валидность дескрипторов, а так же проверок, зависящих от уровня привилегий.
На сегодня эти инструкции (sysenter и sysexit) поддерживаются процессорами Intel в 32- и 64-битных режимах, процессорами AMD — только в 32-битном (на 64-битном приводит к исключению неизвестного опкода).
Поскольку 32-битные архитектуры теряют популярность, рассматривать не будем.
Инструкция syscall
Так как именно AMD разработали x86-64 архитектуру, которая и называется AMD64, то они решили создать свою собственную инструкцию для системных вызовов.
Эти инструкции (syscall и парная sysret) поддерживаются процессорами Intel только в 64-битном режиме, процессорами AMD — во всех режимах.
Системные вызовы при помощи этой инструкции делаются в современных версиях 64-битного Linux.
- Номер системного вызова помещается в rax.
- Аргументы записываются в rdi, rsi, rdx, r10, r8 и r9.
- Затем вызывается syscall.
- Когда управление возвращается, результат находится в rax.
- Значения всех регистров, кроме r11, rcx и rax, системным вызовом не изменяются, дополнительно сохранять их не требуется.
Пример реализации mygetpid() (получение PID текущего процесса) на ассемблере (для системного вызова getpid по таблице используется номер 39):
Производительность
Системные вызовы требуют переключения контекста и перехода процессора в режим с высоким уровнем привилегий. Поэтому системный вызов выполняется относительно медленно по сравнению с вызовом обычной C-функции. Ещё хуже стало после обнаружения уязвимости Meltdown: патч KPTI (kernel page-table isolation), помогающий против уязвимости, приводит к сбросу TLB-кешей и дополнительному падению производительности.
Ориентировочные цифры, сколько занимает системный вызов на конкретном процессоре [1]:
- int 80h — 500 нс,
- sysenter — 340 нс,
- syscall — 280 нс,
- патч KPTI увеличивает эти числа на 180 нс,
- вызов обычной C-функции — единицы нс.
Есть приёмы под названием vsyscall (уже устарел) и vDSO (virtual dynamic shared object) [2], которые позволяют в некоторых случаях избежать переключения контекста и ускорить выполнение. Помогает для системных вызовов, которым реально не нужны высокие привилегии, например gettimeofday. Удобно, если надо часто получать таймстемпы, например, для логов.
Мониторинг системных вызовов
Существует несколько инструментов, которые можно использовать для просмотра системных вызовов, которые выполняются программами. Самый известный из них, strace, доступен во многих операционных системах, и, вероятно, он уже установлен на вашем компьютере.
strace может запустить новый процесс или подключиться к уже запущенному. Вы можете многое узнать, подглядывая за системными вызовами, сделанными различными программами.
Использование в коде на C
Заголовочный файл unistd.h — основной, его наличие и содержимое обеспечивается стандартом POSIX.1. Видимо, «uni» пошло от UNIX.
Типы данных
Примеры типов данных POSIX (тем не менее, они не являются стандартными типами в языке C).
- pid_t — идентификаторы процессов.
- ssize_t — аналогичен size_t, но обязан быть знаковым и, главное, обязан уметь хранить минус единицу (это число используется как возвращаемое значение в случае ошибки во многих функциях).
- off_t и off64_t — знаковый тип для хранения смещения в файле.
Получение справки
Сигнатуры функций и информацию об использовании можно почерпнуть из man-страниц:
Бонус: пишем «Hello, world!» на ассемблере
Теперь посмотрим, что, кроме служебных вызовов при загрузке бинарника, наша программа выполнила именно ожидаемые два системных вызова.
Читайте также: