§ 9. Системные вызовы
Введение
01 Несмотря на то что ядро операционной системы и приложение располагаются в одной и той же физической памяти, в программах используются логические адреса, а значит прямого доступа к памяти ядра приложение получить не может. Для того чтобы осуществить взаимодействие между ядром и приложением используются так называемые системные вызовы — специальные функции, предназначенные для выполнения действий, требующих особых привилегий. Раньше для осуществления системных вызовов использовались прерывания, в современных же процессорах из соображений эффективности для этого используются специальные инструкции. Выполняя такую инструкцию, процессор переходит в привилегированный режим и отдает управление ядру, которое выполняет инструкцию, считывая ее номер и аргументы из регистров.
Как программы взаимодействуют с ядром?
02 Современные приложения не делают системные вызовы напрямую, вместо этого используются функции-обертки на языке Си. Есть и функция-обертка, которая подходит для любого системного вызова, она называется syscall. Первым аргументом этой функции является номер системного вызова, а остальные аргументы у каждого системного вызова свои. Полученные аргументы передаются во вспомогательную функцию, написанную на ассемблере, в которой осуществляется системный вызов. Справа на картинке показана такая функция для архитектуры x86. Для других архитектур функция будет отличаться. В данном случае все сводится к манипуляциям с регистрами и вызову одноименной инструкции syscall. После завершения системного вызова, функция syscall проверяет выходное значение, и если оно меньше нуля, то сохраняет это значение в глобальной переменной errno и возвращает -1, иначе возвращает 0 (то есть завершается успешно).
long syscall(long n, ...) { va_list ap; // от 0 до 6 аргументов long a,b,c,d,e,f; va_start(ap, n); a=va_arg(ap, long); ... va_end(ap); return __syscall_ret(__syscall(n,a,b,c,d,e,f)); } long __syscall_ret(unsigned long r) { if (r > -4096UL) { // (-4096,0) errno = -r; return -1; } return r; } | .global __syscall .hidden __syscall .type __syscall,@function __syscall: movq %rdi,%rax movq %rsi,%rdi movq %rdx,%rsi movq %rcx,%rdx movq %r8,%r10 movq %r9,%r8 movq 8(%rsp),%r9 syscall ret |
03 Процессоры разных архитектур имеют разные способы осуществления системных вызовов, а также разные способы передачи аргументов для этих системных вызовов, из-за чего для каждого системного вызова пишутся функции-обертки. Библиотека с такими функциями называется libc. Помимо оберток она полностью включает в себя стандартную библиотеку языка Си, а также некоторые дополнительные функции, которые упрощают работу с системой. Эта библиотека подключается ко всем программам, собираемым на Linux, независимо от языка, на котором они написаны.
04 Существует несколько реализаций библиотеки libc, которые представлены на картинке ниже. Основной реализацией считается glibc — это библиотека, которая поддерживается разработчиками ядра и которая всегда первой получает обновления. Поскольку glibc довольно старая и поскольку разработчики ядра очень трепетно относятся к обратной совместимости, она использует много памяти. Альтернативные библиотеки лишены данного недостатка. Наиболее популярной из альтернатив является библиотека musl, она часто используется в образах для Docker-контейнеров из-за своих скромных размеров и малого потребления памяти. Библиотеки uclibc и dietlibc используются во встраиваемых системах, они потребляют еще меньше памяти, но используют более простые и более медленные алгоритмы (например, алгоритм сортировки).
Как ядро взаимодействует с программами?
05 Мы с вами поговорили о том, как приложения могут взаимодействовать с ядром. Перефразируя, приложения смотрят на ядро через призму системных вызовов. А как ядро смотрит на приложения? Для ядра все приложения, будь то системная утилита, вроде команды ls, или Docker-контейнер, являются процессами. Процесс — это единица планирования ресурсов в Linux.
06 Процессы объединяются в строгую иерархию, всегда имеющую топологию дерева. Первый процесс в иерархии всегда имеет номер 1 и создается ядром после завершения инициализации. Все остальные процессы создаются путем копирования из первого процесса и его дочерних процессов. Задачей первого процесса является дальнейшая инициализация системы: запуск резидентных процессов, монтирование сетевых каталогов и прочее. Также, дочерние процессы, родители которых завершились раньше (не подождав завершения потомков), становятся дочерними процессами первого процесса. Это делается для предотвращения утечек памяти и поддержания иерархии в связном виде.
07 Процессы создаются с помощью функции-обертки fork. Эта функция в каком-то смысле уникальна: у нее нет аргументов, зато она возвращается два раза. Первый раз в дочерний процесс, и в данном случае возвращаемое значение равно нулю. Второй раз — в родительский процесс, и в данном случае возвращаемое значение равно номеру дочернего процесса. Как же ей удается вернуться два раза? Дело в том что системный вызов, осуществляемый данной функцией, копирует текущий процесс вместе со всеми структурами ядра и всеми его страницами памяти, включая стек, глобальные переменные и код программы, загруженный в память. Изменяются только номера процессов и некоторые другие поля, которые должны быть уникальными. Это приводит к тому, что в памяти оказываются два идентичных процесса, различить которые можно лишь, проверив выходное значение данного системного вызова.
int pid = fork(); // копирование процесса if (pid == 0) { // дочерний процесс } // родительский процесс |
clone(..., flags=CLONE_NEWUTS | CLONE_NEWUSER | SIGCHLD) = 7427
17 Какой можно подвести итог? Потоки, контейнеры и обычные процессы с точки зрения ядра Linux различаются лишь набором флагов, или набором системных структур и ресурсов, которые выделяются процессу и его потомкам. В случае потока большинство структур и ресурсов используются совместно с родительским процессом, в случае контейнера почти все структуры и ресурсы копируются. Если расположить эти сущности на спектре, то получится примерно следующая картина. Потоки — это процессы-иждивенцы, полностью полагающиеся на ресурсы родителей, а контейнеры — это самостоятельные процессы, полностью изолированные от родителей.
Запуск программ в дочерних процессах
18 В примерах, использованных ранее все дочерние процессы выполняли тот же самый код, что и родительский процесс: в их памяти хранились те же самые глобальные переменные, функции и те же самые библиотеки. В реальности же мы часто хотим запустить совершенно другую программу в дочернем процессе. Для этого используется функция-обертка execvp, в названии которой v обозначает вектор, а p — путь. В функцию передается вектор аргументов, представляющий собой массив строк, заканчивающийся нулевым элементом. По соглашению нулевой аргумент является названием программы, которая была запущено. Для соблюдения этого соглашения мы передаем нулевой элемент массива в качестве первого аргументы функции execvp.
pid_t pid = fork(); if (pid == 0) { char* const argv[] = {"ls", "-l", 0}; // аргументы execvp(argv[0], argv); // нулевой аргумент — имя программы exit(0); } int status = 0; wait(&status);
19 Эта функция заменяет текущий бинарный код, загруженный в память, на код из указанного файла. Суффикс p означает, что система должна сама найти исполняемый файл с указанным именем в стандартных директориях. После загрузки в память все потоки останавливаются и запускается функция main из только что загруженной программы. Функция execvp уникальна тем, что при успешном вызове не возвращается. Если же произошла ошибка, то функция возвращает -1. В данном примере в этом случае дочерний процесс завершается с помощью системного вызова exit.
20 Как видите, в Linux создание нового процесса и загрузка кода разделены. Это удобно, поскольку после создания дочернего процесса, родительский может его настроить (ограничить ресурсы, изменить рабочую директорию и т.п.), используя те же самые системные вызовы, что и для себя самого. После настройки вызывается функция exec, заменяющая код на новый. При этом настройки сохраняются (наследуются).
21 В таблице показаны некоторые основные системные вызовы. Здесь есть вызовы для получения номеров текущего и родительского процессов, номеров пользователя и группы, переменных среды и рабочей директории. Для ядра все пользователи и группы являются номерами. Даже если этим номерам не соответствуют зарегистрированные имена и пароли пользователей, ядро все равно может запустить процесс, работающий под номером такого пользователя или группы. Таким образом, пользователь и группа — это всего лишь поля структуры, описывающей процесс (для ядра все является процессом!). Права на ту или иную операцию даются процессу, а не пользователю. А сейчас перейдем к обсуждению переменных среды.
Переменные среды
22 Переменные среды — это вектор (аналогичный вектору аргументов), каждый элемент которого является парой ключ-значение, разделенными знаком =
. Стандартных переменных довольно много, однако вы не можете быть уверены в наличии ни одной из них. Ниже показаны две переменные, которые чаще всего встречаются. Это переменная HOME, содержащая путь к домашней директории текущего пользователя, и переменная LANG, обозначающая текущий язык и кодировку. Эта переменная используется при выборе переводов строк из программ на языки мира.
char** first = environ; while (*first) { std::cout << *first << '\n'; ++first; }
... HOME=/home/myuser LANG=ru_RU.utf8 ...
Задания
Системные вызовы1 балл
23 Сделайте системный вызов getpid с помощью одноименной функции-обертки из заголовочного файла unistd.h. Сделайте тот же самый вызов с помощью универсальной функции-обертки syscall. Удостоверьтесь, что они возвращают одинаковые значения.
Дочерние процессы1 балл
24 Создайте дочерний процесс с помощью функции-обертки fork и запустите в нем команду expr 2 + 2 * 2 с помощью системного вызова execlp. Дождитесь завершения дочернего процесса с помощью функции wait.
Переменные среды1 балл
25 Выведите переменные среды, имя которых содержит символ L
. Для этого воспользуйтесь глобальной переменной environ из заголовочного файла environ.h.
Аргументы и пространства имен2 балла
26 Создайте дочерний процесс, в котором вы измените имя компьютера на имя, переданное родительскому процессу в качестве первого аргумента. Для этого воспользуйтесь системным вызовом clone и подходящими флагами этого вызова, а также системным вызовом sethostname. Проверьте с помощью вызова gethostname, что имя компьютера разное в дочернем и родительском процессе.
Видео
- 00:00 Введение
- 02:50 Как программы взаимодействуют с ядром?
- 11:33 Дерево процессов
- 15:35 Функция
fork - 17:11 Копирование при записи
- 19:29 Команда
strace - 19:29 Вывод
straceдля функцииfork - 25:11 Вывод
straceдля объектаstd::thread - 28:00 Вывод
straceдля функцииclone - 34:45 Запуск программ в дочерних процессах
- 42:29 Некоторые системные вызовы
- 46:02 Переменные среды
- 51:10 Страницы руководства
- 54:22 Вопрос про отличие
forkиstd::thread