§ 9. Системные вызовы

Введение

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

image/svg+xmlоперативная памятьLinuxпрограммапроцессор
Системный вызов.

Как программы взаимодействуют с ядром?

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;
}
Функция-обертка для системных вызовов из библиотеки Musl. Файлы syscall.h и syscall_ret.c.
.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
Функция из библиотеки Musl, реализующая системный вызов. Файл syscall_cp.s.

03 Процессоры разных архитектур имеют разные способы осуществления системных вызовов, а также разные способы передачи аргументов для этих системных вызовов, из-за чего для каждого системного вызова пишутся функции-обертки. Библиотека с такими функциями называется libc. Помимо оберток она полностью включает в себя стандартную библиотеку языка Си, а также некоторые дополнительные функции, которые упрощают работу с  системой. Эта библиотека подключается ко всем программам, собираемым на Linux, независимо от языка, на котором они написаны.

04 Существует несколько реализаций библиотеки libc, которые представлены на картинке ниже. Основной реализацией считается glibc — это библиотека, которая поддерживается разработчиками ядра и которая всегда первой получает обновления. Поскольку glibc довольно старая и поскольку разработчики ядра очень трепетно относятся к обратной совместимости, она использует много памяти. Альтернативные библиотеки лишены данного недостатка. Наиболее популярной из альтернатив является библиотека musl, она часто используется в образах для Docker-контейнеров из-за своих скромных размеров и малого потребления памяти. Библиотеки uclibc и dietlibc используются во встраиваемых системах, они потребляют еще меньше памяти, но используют более простые и более медленные алгоритмы (например, алгоритм сортировки).

image/svg+xmlLinuxlibclibstdc++программаmusl, glibc, uclibc, dietlibc
Ядро + стандартные библиотеки + программа.

Как ядро взаимодействует с программами?

05 Мы с вами поговорили о том, как приложения могут взаимодействовать с  ядром. Перефразируя, приложения смотрят на ядро через призму системных вызовов. А как ядро смотрит на приложения? Для ядра все приложения, будь то системная утилита, вроде команды ls, или Docker-контейнер, являются процессами. Процесс — это единица планирования ресурсов в Linux.

06 Процессы объединяются в строгую иерархию, всегда имеющую топологию дерева. Первый процесс в иерархии всегда имеет номер 1 и создается ядром после завершения инициализации. Все остальные процессы создаются путем копирования из первого процесса и его дочерних процессов. Задачей первого процесса является дальнейшая инициализация системы: запуск резидентных процессов, монтирование сетевых каталогов и прочее. Также, дочерние процессы, родители которых завершились раньше (не подождав завершения потомков), становятся дочерними процессами первого процесса. Это делается для предотвращения утечек памяти и поддержания иерархии в связном виде.

07 Процессы создаются с помощью функции-обертки fork. Эта функция в каком-то смысле уникальна: у нее нет аргументов, зато она возвращается два раза. Первый раз в дочерний процесс, и в данном случае возвращаемое значение равно нулю. Второй раз — в родительский процесс, и в данном случае возвращаемое значение равно номеру дочернего процесса. Как же ей удается вернуться два раза? Дело в том что системный вызов, осуществляемый данной функцией, копирует текущий процесс вместе со всеми структурами ядра и всеми его страницами памяти, включая стек, глобальные переменные и код программы, загруженный в память. Изменяются только номера процессов и некоторые другие поля, которые должны быть уникальными. Это приводит к тому, что в памяти оказываются два идентичных процесса, различить которые можно лишь, проверив выходное значение данного системного вызова.

08 Может показаться, что копирование всей памяти текущего процесса занимает большое количество времени. Раньше это действительно было так, однако в современных операционных системах используется копирование при записи, что значительно ускоряет порождение новых процессов. Я написал небольшой тест, измеряющий количество времени, затрачиваемого для создания определенного количества процессов. В данном тесте дочерние процессы сразу завершались. На создание 100 процессов система потратила чуть меньше девяти миллисекунд.

01234567890102030405060708090100Время, мсКоличество процессовfork()
Время порождения процессов с помощью системного вызова fork.

09 Для того чтобы изучить то, как Linux создает процессы, нам понадобится команда strace. Эта команда отслеживает системные вызовы, которые делает ваше приложение. Например, чтобы отследить, какие файлы открывает ваша программа, достаточно указать в фильтре системный вызов openat. Ниже показан вывод для программы, которая ничего не делает. Как видно из вывода, программа открывает только лишь библиотеки: библиотеку libc и libstdc++.

$ strace -e openat ./empty-main # отследить открытие файла
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, "/lib64/libstdc++.so.6", O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, "/lib64/libm.so.6", O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, "/lib64/libgcc_s.so.1", O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, "/lib64/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
Отслеживание системных вызовов пустой программы с помощью команды strace.

10 Давайте теперь применим strace, чтобы отследить, какой системный вызов использует функция-обертка fork. Это системный вызов clone. В него передается три аргумента, наиболее важным из которых является flags. Первые два флага являются вспомогательными, а третий флаг обозначает сигнал, который будет послан родительскому процессу при завершении дочернего. Здесь указан специально предназначенный для этого сигнал SIGCHLD. Системный вызов возвращает номер созданного процесса.

pid_t pid = fork(); // создание дочернего процесса
if (pid == 0) {
    exit(0);        // завершение дочернего процесса
}
int status = 0;
wait(&status);      // подождать завершения дочернего процесса
Тестовая программа №1.
clone(
    child_stack=NULL,
    flags=CLONE_CHILD_CLEARTID | CLONE_CHILD_SETTID | SIGCHLD,
    child_tidptr=0x7f25ede00990
) = 7296
Вывод тестовой программы №1.

11 Теперь применим strace для отслеживания системных вызовов, участвующих в создании потока. И опять мы видим системный вызов clone, но с гораздо большим количеством аргументов и флагов. Помимо флагов здесь есть указатель на стек потока. Стек — это область памяти, которая используется потоком для локальных переменных (переменных, объявленных внутри функций и методов). Также передается указатель на thread local storage — область для хранения уникальных для потока переменных (ключевое слово thread_local в С++). Системный вызов также возвращает номер дочернего процесса (потока).

image/svg+xmlinitBCDE
Древовидная иерархия процессов.
int pid = fork(); // копирование процесса
if (pid == 0) {
    // дочерний процесс
}
// родительский процесс
Порождение дочернего процесса.
ФлагОбъяснение
CLONE_VMобщая память
CLONE_FSобщие рабочая директория, корень ФС и маска
CLONE_FILESобщие файловые дескрипторы
CLONE_SIGHANDобщие обработчики сигналов
CLONE_SYSVSEMобщие примитивы синхронизации
CLONE_THREADдобавление в группу потоков
CLONE_SETTLSвыделение локальной памяти потока
Флаги системного вызова clone для потоков и процессов.

12 В таблице перечислены флаги, используемые для создания потоков. Наиболее важными являются первые пять флагов. Эти флаги фактически делают так, что дочерний процесс наследует все ресурсы родительского процесса, то есть может использовать ту же самую память, те же самые файловые дескрипторы, обработчики сигналов и примитивы синхронизации (мьютексы, блокировки, семафоры). Остальные флаги являются вспомогательными и в пояснении не нуждаются.

std::thread t{[] () {}}; // создание дочернего потока
t.join(); // подождать завершения дочернего потока
Тестовая программа №2.
clone(
    child_stack=0x7f901f84cfb0,
    flags=CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND |
          CLONE_THREAD | CLONE_SYSVSEM | CLONE_SETTLS |
          CLONE_PARENT_SETTID | CLONE_CHILD_CLEARTID,
    parent_tidptr=0x7f901f84d9d0,
    tls=0x7f901f84d700,
    child_tidptr=0x7f901f84d9d0
) = 7345
Вывод тестовой программы №2.

13 Наконец, вызовем функцию-обертку clone напрямую. В этот раз выберем флаги, используемые для создания контейнеров, и укажем флаг SIGCHLD, для того чтобы сработала функция wait. Системный вызов опять вернул номер созданного процесса, и в выводе strace появились ровно те же самые флаги, что мы указали. Здесь мы выбираем размер стека произвольно и передаем указатель на конец стека в функцию clone. Дело в том что стек растет вниз на большинстве архитектур, поэтому нужно передать указатель именно на конец стека. В качестве первого аргумента мы передаем указатель на функцию child_main, выполняющую роль функции main дочернего процесса.

14 Флаги, используемые для создания контейнеров, перечислены в таблице. Все эти флаги имеют префикс NEW и используются для создания новых пространств имен Linux. Именно эта технология является основой более высокоуровневых технологий создания контейнеров. Всего несколько флагов функции clone способны обеспечить работу всех современных технологий создания контейнеров!

15 Создание пространств имен — это сложно, и мы не будем рассматривать эту тему в рамках данного курса. Я поясню только флаги, которые были использованы в примере. Это флаг CLONE_NEWUTS, с помощью которого создается новое пространство имен, в котором определено имя машины и доменное имя. Доменное имя давно устарело и всегда равно нулю, а имя машины — это короткое имя, используемое администраторами. Создание нового пространства означает, что дочерний процесс, а также всего дочерние процессы могут менять имя машины, и эти изменения не отразятся на имени машины, которое видят все остальные процессы. Таким образом, с  помощью пространств имен можно изолировать процессы друг от друга так, чтобы они, работая на одной и той же физической машине, жили как бы в параллельных вселенных.

16 Флаг CLONE_NEWUSER создает новое пространство пользователей и групп, в котором дочерний процесс получает полные права доступа (права суперпользователя!). С помощью этого флага дочерний процесс получает право изменять имя машины (обычно, это право есть только у суперпользователя). В данном примере это вспомогательный флаг.

int child_main(void* ptr) {
  std::string s = "spicy";
  sethostname(s.data(), s.size());
  return 0;
}

int main() {
  size_t stack_size = 1024*10;
  std::unique_ptr<char[]> child_stack(new char[stack_size]);
  pid_t pid = clone(child_main, child_stack.get() + stack_size,
    CLONE_NEWUTS | CLONE_NEWUSER | SIGCHLD, 0);
  int status = 0;
  wait(&status);
  return 0;
}
Тестовая программа №3.
clone(..., flags=CLONE_NEWUTS | CLONE_NEWUSER | SIGCHLD) = 7427
Вывод тестовой программы №3.

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;
}
Тестовая программа №4.
...
HOME=/home/myuser
LANG=ru_RU.utf8
...
Вывод тестовой программы №4.

Задания

Системные вызовы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, что имя компьютера разное в дочернем и родительском процессе.

Видео

Запись лекции 30.10.2021.