Какой системный вызов запускает выполнение программы в Linux
Обновлено: 21.11.2024
Получите полный доступ к Understanding the Linux Kernel, Second Edition и более чем 60 000 других книг с бесплатной 10-дневной пробной версией O'Reilly.
Есть также прямые онлайн-мероприятия, интерактивный контент, материалы для подготовки к сертификации и многое другое.
Ядра Unix обеспечивают среду выполнения, в которой могут работать приложения. Поэтому ядро должно реализовать набор сервисов и соответствующих интерфейсов. Приложения используют эти интерфейсы и обычно не взаимодействуют напрямую с аппаратными ресурсами.
Модель процесса/ядра
Как уже упоминалось, ЦП может работать как в пользовательском режиме, так и в режиме ядра. На самом деле некоторые процессоры могут иметь более двух состояний выполнения. Например, микропроцессоры 80 × 86 имеют четыре различных состояния выполнения. Но все стандартные ядра Unix используют только режим ядра и режим пользователя.
Когда программа выполняется в пользовательском режиме, она не может напрямую обращаться к структурам данных ядра или программам ядра. Однако когда приложение выполняется в режиме ядра, эти ограничения больше не применяются. Каждая модель ЦП предоставляет специальные инструкции для переключения из пользовательского режима в режим ядра и наоборот. Программа обычно выполняется в пользовательском режиме и переключается в режим ядра только при запросе службы, предоставляемой ядром. Когда ядро удовлетворяет запрос программы, оно возвращает программу в режим пользователя.
Процессы — это динамические сущности, срок действия которых в системе обычно ограничен. Задача создания, устранения и синхронизации существующих процессов возложена на группу подпрограмм в ядре.
Ядро само по себе является не процессом, а диспетчером процессов. Модель процесс/ядро предполагает, что процессы, которым требуется служба ядра, используют определенные программные конструкции, называемые системными вызовами . Каждый системный вызов устанавливает группу параметров, которая идентифицирует запрос процесса, а затем выполняет аппаратно-зависимую инструкцию ЦП для переключения из пользовательского режима в режим ядра.
Помимо пользовательских процессов, системы Unix включают несколько привилегированных процессов, называемых потоками ядра, со следующими характеристиками:
Они работают в режиме ядра в адресном пространстве ядра.
Они не взаимодействуют с пользователями и поэтому не требуют терминальных устройств.
Обычно они создаются во время запуска системы и остаются активными до тех пор, пока система не будет выключена.
В однопроцессорной системе одновременно выполняется только один процесс, который может выполняться либо в режиме пользователя, либо в режиме ядра. Если он работает в режиме ядра, процессор выполняет какую-то подпрограмму ядра. Рисунок 1-3 иллюстрирует примеры переходов между пользовательским режимом и режимом ядра. Процесс 1 в пользовательском режиме выполняет системный вызов, после чего процесс переключается в режим ядра, и системный вызов обслуживается. Затем процесс 1 возобновляет выполнение в пользовательском режиме до тех пор, пока не произойдет прерывание таймера и планировщик не будет активирован в режиме ядра. Происходит переключение процесса, и процесс 2 начинает выполнение в пользовательском режиме до тех пор, пока аппаратное устройство не вызовет прерывание. В результате прерывания процесс 2 переключается в режим ядра и обслуживает прерывание.
Рисунок 1-3. Переходы между пользовательским режимом и режимом ядра
Ядра Unix делают гораздо больше, чем просто обрабатывают системные вызовы; на самом деле подпрограммы ядра можно активировать несколькими способами:
Процесс вызывает системный вызов.
ЦП, выполняющий процесс, сигнализирует исключение , которое представляет собой необычное состояние, например недопустимую инструкцию. Ядро обрабатывает исключение от имени вызвавшего его процесса.
Периферийное устройство посылает ЦП сигнал прерывания, чтобы уведомить его о событии, таком как запрос внимания, изменение состояния или завершение операции ввода-вывода. Каждый сигнал прерывания обрабатывается программой ядра, называемой обработчиком прерываний. Поскольку периферийные устройства работают асинхронно по отношению к ЦП, прерывания возникают в непредсказуемое время.
Выполняется поток ядра. Поскольку она работает в режиме ядра, соответствующая программа должна считаться частью ядра.
Внедрение процесса
Чтобы ядро могло управлять процессами, каждый процесс представлен дескриптором процесса, который включает информацию о текущем состоянии процесса.
Когда ядро останавливает выполнение процесса, оно сохраняет текущее содержимое нескольких регистров процессора в дескрипторе процесса. К ним относятся:
Регистры счетчика программ (PC) и указателя стека (SP)
Регистры общего назначения
Регистры с плавающей запятой
Регистры управления процессором (слово состояния процессора), содержащие информацию о состоянии процессора
Регистры управления памятью, используемые для отслеживания ОЗУ, к которому обращается процесс
Когда ядро решает возобновить выполнение процесса, оно использует соответствующие поля дескриптора процесса для загрузки регистров ЦП. Поскольку сохраненное значение программного счетчика указывает на инструкцию, следующую за последней выполненной инструкцией, процесс возобновляет выполнение с того места, где он был остановлен.
Когда процесс не выполняется на ЦП, он ожидает какого-то события. Ядра Unix различают множество состояний ожидания, которые обычно реализуются очередями дескрипторов процессов; каждая (возможно, пустая) очередь соответствует набору процессов, ожидающих определенного события.
Реентерабельные ядра
Все ядра Unix являются реентерабельными . Это означает, что несколько процессов могут выполняться в режиме ядра одновременно. Конечно, в однопроцессорных системах может выполняться только один процесс, но многие из них могут быть заблокированы в режиме ядра при ожидании процессора или завершения какой-либо операции ввода-вывода. Например, после выдачи чтения на диск от имени какого-либо процесса ядро позволяет контроллеру диска обработать его и возобновляет выполнение других процессов. Прерывание уведомляет ядро, когда устройство завершило чтение, поэтому прежний процесс может возобновить выполнение.
Один из способов обеспечения повторного входа – написать функции, которые изменяют только локальные переменные и не изменяют глобальные структуры данных. Такие функции называются реентерабельными функциями . Но реентерабельное ядро не ограничивается только такими реентерабельными функциями (хотя именно так реализованы некоторые ядра реального времени). Вместо этого ядро может включать нереентерабельные функции и использовать механизмы блокировки, чтобы гарантировать, что только один процесс может одновременно выполнять нереентерабельную функцию. Каждый процесс в режиме ядра действует в своем собственном наборе ячеек памяти и не может мешать другим.
Если происходит аппаратное прерывание, ядро с повторным входом может приостановить текущий запущенный процесс, даже если этот процесс находится в режиме ядра. Эта возможность очень важна, так как она повышает пропускную способность контроллеров устройств, выдающих прерывания. Как только устройство выдало прерывание, оно ожидает, пока ЦП не подтвердит его. Если ядро способно быстро ответить, контроллер устройства сможет выполнять другие задачи, пока ЦП обрабатывает прерывание.
Теперь давайте рассмотрим повторный вход ядра и его влияние на организацию ядра. Путь управления ядром – это последовательность инструкций, выполняемых ядром для обработки системного вызова, исключения или прерывания.
В простейшем случае ЦП выполняет путь управления ядром последовательно от первой инструкции до последней. Однако когда происходит одно из следующих событий, ЦП чередует пути управления ядром:
Процесс, выполняемый в пользовательском режиме, вызывает системный вызов, и соответствующий путь управления ядром проверяет, что запрос не может быть немедленно удовлетворен; затем он вызывает планировщик, чтобы выбрать новый процесс для запуска. В результате происходит переключение процесса. Первый путь управления ядром остается незавершенным, и ЦП возобновляет выполнение какого-либо другого пути управления ядром. В этом случае два пути управления выполняются от имени двух разных процессов.
ЦП обнаруживает исключение — например, доступ к странице, отсутствующей в ОЗУ, — при выполнении пути управления ядром. Первый путь управления приостанавливается, и ЦП начинает выполнение подходящей процедуры. В нашем примере процедура такого типа может выделить для процесса новую страницу и прочитать ее содержимое с диска. Когда процедура завершается, первый путь управления может быть возобновлен. В этом случае два пути управления выполняются от имени одного и того же процесса.
Аппаратное прерывание происходит, когда ЦП выполняет путь управления ядром с включенными прерываниями. Первый путь управления ядром остается незавершенным, и ЦП начинает обработку другого пути управления ядром для обработки прерывания. Первый путь управления ядром возобновляется, когда обработчик прерывания завершает работу. В этом случае два пути управления ядром выполняются в контексте выполнения одного и того же процесса, и для него учитывается общее истекшее системное время. Однако обработчик прерывания не обязательно действует от имени процесса.
Рисунок 1-4 иллюстрирует несколько примеров нечередующихся и чередующихся путей управления ядром. Рассматриваются три различных состояния ЦП:
Это четвертая часть главы, описывающая системные вызовы в ядре Linux, и, как я писал в заключении предыдущей, эта часть будет последней в этой главе. В предыдущей части мы остановились на двух новых концепциях:
которые связаны и очень похожи по концепции системных вызовов.
Эта часть будет последней в этой главе, и, как вы можете понять из названия части, мы увидим, что происходит в ядре Linux, когда мы запускаем наши программы. Итак, приступим.
как мы запускаем наши программы?
Существует множество различных способов запуска приложения с точки зрения пользователя. Например, мы можем запустить программу из оболочки или дважды щелкнуть значок приложения. Неважно. Ядро Linux обрабатывает запуск приложения независимо от того, как мы запускаем это приложение.
В этой части мы рассмотрим способ, когда мы просто запускаем приложение из оболочки. Как вы знаете, стандартный способ запуска приложения из оболочки следующий: мы просто запускаем приложение эмулятора терминала и просто пишем название программы и передаем или не передаем аргументы нашей программе, например:
Давайте рассмотрим, что происходит, когда мы запускаем приложение из оболочки, что делает оболочка, когда мы пишем имя программы, что делает ядро Linux и т. д. Но прежде чем мы приступим к рассмотрению этих интересных вещей, хочу предупредить, что эта книга о ядре Linux. Вот почему в этой части мы увидим внутренности ядра Linux. Мы не будем подробно рассматривать, что делает оболочка, не будем рассматривать сложные случаи, например подоболочки и т.д.
Моей оболочкой по умолчанию является bash, поэтому я рассмотрю, как оболочка bash запускает программу. Итак, начнем. Оболочка bash, как и любая программа, написанная на языке программирования C, запускается из функции main. Если вы посмотрите на исходный код оболочки bash, вы найдете основную функцию в файле исходного кода shell.c. Эта функция делает много разных вещей до того, как основной цикл bash начал работать. Например, эта функция:
- проверяет и пытается открыть /dev/tty ;
- проверьте, что оболочка работает в режиме отладки;
- анализирует аргументы командной строки;
- считывает среду оболочки;
- загружает .bashrc , .profile и другие файлы конфигурации;
- и многое другое.
После всех этих операций мы видим вызов функции reader_loop. Эта функция определена в файле исходного кода eval.c и представляет собой цикл основного потока или, другими словами, она читает и выполняет команды. Поскольку функция reader_loop выполнила все проверки и прочитала заданное имя программы и аргументы, она вызывает функцию execute_command из файла исходного кода execute_cmd.c. Функция execute_command через цепочку вызовов функций:
делает различные проверки, например, нужно ли нам запускать subshell , была ли это встроенная функция bash или нет и т. д. Как я уже писал выше, мы не будем рассматривать все подробности о вещах, не связанных с ядром Linux. В конце этого процесса функция shell_execve вызывает системный вызов execve:
и выполняет программу с заданным именем файла, с заданными аргументами и переменными среды. Этот системный вызов в нашем случае первый и единственный, например:
Итак, пользовательское приложение (в нашем случае bash) вызывает системный вызов, и, как мы уже знаем, следующим шагом является ядро Linux.
выполнить системный вызов
Мы видели подготовку перед системным вызовом, вызванным пользовательским приложением, и после того, как обработчик системного вызова завершил свою работу во второй части этой главы. Мы остановились на вызове системного вызова execve в предыдущем пункте. Этот системный вызов определен в файле исходного кода fs/exec.c и, как мы уже знаем, принимает три аргумента:
Реализация execve здесь довольно проста, поскольку мы видим, что она просто возвращает результат функции do_execve. Функция do_execve, определенная в том же файле исходного кода, выполняет следующие действия:
- Инициализировать два указателя на данные пользовательского пространства с заданными аргументами и переменными среды;
- вернуть результат do_execveat_common .
Мы видим его реализацию:
Функция do_execveat_common выполняет основную работу — запускает новую программу. Эта функция принимает аналогичный набор аргументов, но, как видите, она принимает пять аргументов вместо трех. Первый аргумент — это дескриптор файла, представляющий каталог с нашим приложением, в нашем случае AT_FDCWD означает, что данный путь интерпретируется относительно текущего рабочего каталога вызывающего процесса. Пятый аргумент — флаги. В нашем случае мы передали 0 в do_execveat_common. Мы проверим на следующем шаге, так что увидим позже.
Прежде всего функция do_execveat_common проверяет указатель имени файла и возвращает значение NULL . После этого проверяем флаги текущего процесса, чтобы не превышался лимит запущенных процессов:
Если эти две проверки прошли успешно, мы сбрасываем флаг PF_NPROC_EXCEEDED в флагах текущего процесса, чтобы предотвратить сбой execve. Вы можете видеть, что на следующем шаге мы вызываем функцию unshare_files, определенную в файле kernel/fork.c, и удаляем файлы текущей задачи и проверяем результат этой функции:
Нам нужно вызвать эту функцию, чтобы устранить возможную утечку файлового дескриптора исполняемого двоичного файла. На следующем шаге мы начинаем подготовку bprm, представленного структурой struct linux_binprm (определенной в заголовочном файле include/linux/binfmts.h). Структура linux_binprm используется для хранения аргументов, используемых при загрузке двоичных файлов. Например, он содержит поле vma, которое имеет тип vm_area_struct и представляет собой одну область памяти на непрерывном интервале в заданном адресном пространстве, где будет загружено наше приложение, поле mm, которое является дескриптором памяти двоичного файла, указатель на верхнюю часть памяти и многое другое. разные поля.
Прежде всего мы выделяем память для этой структуры с помощью функции kzalloc и проверяем результат выделения:
После этого мы начинаем готовить учетные данные binprm с помощью вызова функции prepare_bprm_creds:
Иными словами, инициализация учетных данных binprm — это инициализация структуры cred, хранящейся внутри структуры linux_binprm. Структура cred содержит контекст безопасности задачи, например, реальный uid задачи, реальный guid задачи, uid и guid для операций с виртуальной файловой системой и т. д. На следующем шаге, выполняя подготовку учетных данных bprm, мы проверяем, что теперь мы можем безопасно выполнить программу вызовом функции check_unsafe_exec и установить текущий процесс в состояние in_execve.
После всех этих операций мы вызываем функцию do_open_execat, которая проверяет флаги, которые мы передали функции do_execveat_common (помните, что в флагах у нас стоит 0), и ищет и открывает исполняемый файл на диске, проверяет, что наш файл будет загружен. двоичный файл из точек монтирования noexec (нам нужно избегать запуска двоичного файла из файловых систем, которые не содержат исполняемых двоичных файлов, таких как proc или sysfs), инициализирует файловую структуру и возвращает указатель на эту структуру. Далее мы можем увидеть вызов sched_exec после этого:
Функция sched_exec используется для определения наименее загруженного процессора, который может выполнить новую программу, и для переноса на него текущего процесса.
После этого нам нужно проверить файловый дескриптор данного исполняемого файла. Мы пытаемся проверить, начинается ли имя нашего бинарного файла с символа / или интерпретируется ли путь данного исполняемого бинарника относительно текущего рабочего каталога вызывающего процесса или, другими словами, дескриптор файла AT_FDCWD (читайте выше о это).
Если одна из этих проверок прошла успешно, мы устанавливаем имя файла бинарного параметра:
В противном случае, если имя файла пусто, мы устанавливаем имя файла двоичного параметра в /dev/fd/%d или /dev/fd/%d/%s в зависимости от имени файла данного исполняемого двоичного файла, что означает, что мы будем выполнять файл, на который ссылается файловый дескриптор:
Обратите внимание, что мы устанавливаем не только bprm->имя файла, но и bprm->interp, который будет содержать имя интерпретатора программы. Пока мы просто пишем туда то же имя, но позже оно будет дополнено реальным именем интерпретатора программы в зависимости от бинарного формата программы. Вы можете прочитать выше, что мы уже подготовили кредит для linux_binprm. Следующим шагом является инициализация других полей linux_binprm. Прежде всего мы вызываем функцию bprm_mm_init и передаем ей bprm:
Функция bprm_mm_init определена в том же файле исходного кода, и, как мы можем понять из названия функции, она выполняет инициализацию дескриптора памяти или, другими словами, функция bprm_mm_init инициализирует структуру mm_struct. Эта структура определена в заголовочном файле include/linux/mm_types.h и представляет собой адресное пространство процесса. Мы не будем рассматривать реализацию функции bprm_mm_init, потому что мы не знаем многих важных вещей, связанных с диспетчером памяти ядра Linux, но нам просто нужно знать, что эта функция инициализирует mm_struct и заполняет его временным стеком vm_area_struct .
После этого мы вычисляем количество аргументов командной строки, которые были переданы нашему исполняемому двоичному файлу, количество переменных среды и устанавливаем их в bprm->argc и bprm->envc соответственно:
Как видите, мы выполняем эти операции с помощью функции count, которая определена в том же файле исходного кода и вычисляет количество строк в массиве argv. Макрос MAX_ARG_STRINGS определен в заголовочном файле include/uapi/linux/binfmts.h и, как мы можем понять из названия макроса, представляет собой максимальное количество строк, которые были переданы системному вызову execve. Значение MAX_ARG_STRINGS:
После того, как мы подсчитали количество аргументов командной строки и переменных среды, мы вызываем функцию prepare_binprm. Мы уже вызывали функцию с похожим именем до этого момента. Эта функция называется prepare_binprm_cred, и мы помним, что эта функция инициализирует структуру cred в linux_bprm. Теперь функция prepare_binprm:
заполняет структуру linux_binprm идентификатором пользователя из inode и считывает 128 байтов из исполняемого двоичного файла. Мы читаем только первые 128 из исполняемого файла, потому что нам нужно проверить тип нашего исполняемого файла. Мы прочитаем остальную часть исполняемого файла на следующем шаге. После подготовки структуры linux_bprm копируем имя исполняемого бинарного файла, аргументы командной строки и переменные окружения в linux_bprm вызовом функции copy_strings_kernel:
И установите указатель на вершину стека новой программы, которую мы установили в функции bprm_mm_init:
Вершина стека будет содержать имя файла программы, и мы сохраняем это имя файла в поле exec структуры linux_bprm.
Теперь мы заполнили структуру linux_bprm, вызываем функцию exec_binprm:
Прежде всего мы сохраняем pid и pid, видимые из пространства имен текущей задачи в exec_binprm :
функция. Эта функция просматривает список обработчиков, содержащих различные двоичные форматы. В настоящее время ядро Linux поддерживает следующие двоичные форматы:
Итак, search_binary_handler пытается вызвать функцию load_binary и передать ей linux_binprm. Если двоичный обработчик поддерживает заданный формат исполняемого файла, он начинает подготовку исполняемого двоичного файла к выполнению:
Где load_binary например для эльфа проверяет магическое число (каждый бинарный файл эльфа содержит магическое число в заголовке) в буфере linux_bprm (помните, что мы читаем первые 128 байт из исполняемого бинарного файла): и выходим, если оно не является бинарным эльфом:
Если данный исполняемый файл имеет формат elf, load_elf_binary продолжает выполняться. load_elf_binary делает много разных вещей для подготовки исполняемого файла к исполнению. Например, он проверяет архитектуру и тип исполняемого файла:
и выйти, если есть неправильная архитектура и исполняемый файл, неисполняемый, необщий. Пытается загрузить таблицу заголовков программы:
который описывает сегменты. Считайте с диска интерпретатор программы и библиотеки, связанные с нашим исполняемым бинарным файлом, и загрузите его в память. Интерпретатор программы указан в секции .interp исполняемого файла и, как вы можете прочитать в части, описывающей линкеры, это - /lib64/ld-linux-x86-64.so.2 для x86_64. Он устанавливает стек и сопоставляет двоичный файл elf в правильном месте в памяти. Он сопоставляет разделы bss и brk и выполняет множество других действий для подготовки исполняемого файла к выполнению.
В конце выполнения load_elf_binary мы вызываем функцию start_thread и передаем ей три аргумента:
- Набор регистров для новой задачи;
- Адрес точки входа новой задачи;
- Адрес вершины стека для новой задачи.
Как мы поняли из названия функции, она запускает новый поток, но это не так. Функция start_thread просто подготавливает регистры новой задачи к запуску. Давайте посмотрим на реализацию этой функции:
Как мы видим, функция start_thread просто вызывает функцию start_thread_common, которая сделает все за нас:
Функция start_thread_common заполняет регистр сегмента fs нулем, а es и ds значением регистра сегмента данных. После этого мы присваиваем новые значения указателю инструкции, сегментам cs и т. д. В конце функции start_thread_common мы видим макрос force_iret, который вызывает возврат системного вызова через инструкцию iret. Хорошо, мы подготовили новый поток для запуска в пользовательском пространстве, и теперь мы можем вернуться из exec_binprm, и теперь мы снова в do_execveat_common. После того, как exec_binprm завершит свое выполнение, мы освобождаем память для структур, которые были выделены ранее, и возвращаемся.
После того, как мы вернемся из обработчика системного вызова execve, начнется выполнение нашей программы. Мы можем это сделать, потому что вся информация, связанная с контекстом, уже настроена для этой цели. Как мы видели, системный вызов execve не возвращает управление процессу, а код, данные и другие сегменты вызывающего процесса просто перезаписываются сегментами программы. Выход из нашего приложения будет реализован через системный вызов exit.
Это все. С этого момента наша программа будет выполняться.
Заключение
Это конец четвертой части концепции системных вызовов в ядре Linux. В этих четырех частях мы увидели почти все, что связано с концепцией системных вызовов. Мы начали с понимания концепции системного вызова, мы узнали, что это такое и зачем пользовательские приложения нуждаются в этой концепции. Затем мы увидели, как Linux обрабатывает системный вызов из пользовательского приложения. Мы познакомились с двумя концепциями, схожими с концепцией системных вызовов, это vsyscall и vDSO, и, наконец, мы увидели, как ядро Linux запускает пользовательскую программу.
Если у вас есть вопросы или предложения, не стесняйтесь пинговать меня в твиттере 0xAX, напишите мне по электронной почте или просто создайте проблему.
Обратите внимание, что английский не является моим родным языком, и я приношу извинения за причиненные неудобства. Если вы нашли какие-либо ошибки, пришлите мне PR в linux-insides.
Системный вызов – это программный способ, с помощью которого программа запрашивает службу у ядра, а strace – мощный инструмент, позволяющий отслеживать тонкий слой между пользовательскими процессами и ядром Linux.
Чтобы понять, как работает операционная система, сначала нужно понять, как работают системные вызовы. Одной из основных функций операционной системы является предоставление абстракций пользовательским программам.
Операционную систему можно условно разделить на два режима:
- Режим ядра: привилегированный и мощный режим, используемый ядром операционной системы.
- Режим пользователя. В нем запускается большинство пользовательских приложений.
В основном пользователи работают с утилитами командной строки и графическими пользовательскими интерфейсами (GUI) для выполнения повседневных задач. Системные вызовы работают в фоновом режиме, взаимодействуя с ядром для выполнения работы.
Дополнительные ресурсы по Linux
Системные вызовы очень похожи на вызовы функций, что означает, что они принимают аргументы и возвращаемые значения и работают с ними. Единственное отличие состоит в том, что системные вызовы входят в ядро, а вызовы функций — нет. Переключение из пространства пользователя в пространство ядра осуществляется с помощью специального механизма прерывания.
Большая часть этого скрыта от пользователя с помощью системных библиотек (также известных как glibc в системах Linux). Несмотря на то, что системные вызовы являются общими по своей природе, механика выполнения системного вызова во многом зависит от машины.
В этой статье рассматриваются некоторые практические примеры с использованием некоторых общих команд и анализом системных вызовов, выполняемых каждой командой с помощью strace. В этих примерах используется Red Hat Enterprise Linux, но команды должны работать так же и в других дистрибутивах Linux:
Во-первых, убедитесь, что в вашей системе установлены необходимые инструменты. Вы можете проверить, установлен ли strace, с помощью приведенной ниже RPM-команды; если это так, вы можете проверить номер версии утилиты strace с помощью параметра -V:
Если это не сработает, установите strace, выполнив:
Для этого примера создайте тестовый каталог в /tmp и создайте два файла с помощью команды touch, используя:
(Я использовал каталог /tmp, потому что у всех есть к нему доступ, но вы можете выбрать другой каталог, если хотите.)
Убедитесь, что файлы были созданы с помощью команды ls в каталоге testdir:
Вероятно, вы используете команду ls каждый день, не осознавая, что за ней работают системные вызовы. Здесь действует абстракция; вот как работает эта команда:
Команда ls вызывает внутренние функции из системных библиотек (также известных как glibc) в Linux. Эти библиотеки вызывают системные вызовы, выполняющие большую часть работы.
Если вы хотите узнать, какие функции были вызваны из библиотеки glibc, используйте команду ltrace, за которой следует обычная команда ls testdir/:
Если ltrace не установлен, установите его, введя:
Куча вывода будет выведена на экран; не беспокойтесь об этом — просто следуйте за вами. Некоторые из важных библиотечных функций из вывода команды ltrace, относящиеся к этому примеру, включают:
Посмотрев на вывод выше, вы, вероятно, сможете понять, что происходит. Каталог с именем testdir открывается библиотечной функцией opendir, после чего следуют вызовы функции readdir, которая считывает содержимое каталога. В конце происходит вызов функцииclosedir, которая закрывает открытую ранее директорию. Пока игнорируйте другие функции strlen и memcpy.
Вы можете видеть, какие библиотечные функции вызываются, но в этой статье основное внимание будет уделено системным вызовам, которые вызываются системными библиотечными функциями.
Вывод на экране после запуска команды strace был просто системным вызовом для запуска команды ls. Каждый системный вызов служит определенной цели для операционной системы, и их можно разделить на следующие разделы:
- Вызовы системы управления процессами
- Вызовы системы управления файлами
- Системные вызовы управления каталогами и файловыми системами
- Другие системные вызовы
Проще всего проанализировать информацию, выводимую на ваш экран, — записать вывод в файл с помощью удобного флага strace -o. Добавьте подходящее имя файла после флага -o и снова запустите команду:
На этот раз выходные данные не выводятся на экран — команда ls сработала, как и ожидалось, показывая имена файлов и записывая все выходные данные в файл trace.log. В файле почти 100 строк содержимого только для простой команды ls:
Взгляните на первую строку в файле trace.log примера:
- Первое слово строки, execve, — это имя выполняемого системного вызова.
- Текст в круглых скобках — это аргументы, переданные системному вызову.
- Число после знака = (в данном случае это 0) — это значение, возвращаемое системным вызовом execve.
Теперь результат не кажется слишком пугающим, не так ли? И вы можете применить ту же логику, чтобы понять другие строки.
Теперь сузьте свое внимание до одной команды, которую вы вызвали, т. е. ls testdir. Вы знаете имя каталога, используемого командой ls, так почему бы не найти testdir в вашем файле trace.log и не посмотреть, что вы получите? Подробно рассмотрите каждую строку результатов:
Вспоминая анализ execve выше, можете ли вы сказать, что делает этот системный вызов?
Вам не нужно запоминать все системные вызовы или то, что они делают, потому что при необходимости вы можете обратиться к документации. Man-страницы на помощь! Перед запуском команды man убедитесь, что установлен следующий пакет:
Помните, что вам нужно добавить 2 между командой man и именем системного вызова. Если вы прочитаете справочную страницу man, используя man man, вы увидите, что раздел 2 зарезервирован для системных вызовов. Точно так же, если вам нужна информация о библиотечных функциях, вам нужно добавить 3 между man и именем библиотечной функции.
Ниже приведены номера разделов руководства и типы страниц, которые они содержат:
Выполните следующую команду man с именем системного вызова, чтобы просмотреть документацию по этому системному вызову:
Согласно справочной странице execve, это выполняет программу, которая передается в аргументах (в данном случае это ls). Есть дополнительные аргументы, которые могут быть предоставлены ls, такие как testdir в этом примере. Поэтому этот системный вызов просто запускает ls с testdir в качестве аргумента:
Следующий системный вызов с именем stat использует аргумент testdir:
Используйте man 2 stat для доступа к документации. stat — это системный вызов, который получает статус файла — помните, что в Linux все является файлом, включая каталог.
Затем системный вызов openat открывает testdir. Следите за 3, которые возвращаются. Это описание файла, которое будет использоваться последующими системными вызовами:
Пока все хорошо. Теперь откройте файл trace.log и перейдите к строке, следующей за системным вызовом openat. Вы увидите, что вызывается системный вызов getdents, который делает большую часть того, что требуется для выполнения команды ls testdir. Теперь grep getdents из файла trace.log:
Справочная страница getdents описывает это как получение записей каталога, что вам и нужно сделать. Обратите внимание, что аргумент getdents равен 3, что является дескриптором файла из системного вызова openat выше.
Теперь, когда у вас есть список каталогов, вам нужен способ отобразить его в терминале. Итак, grep для другого системного вызова write, который используется для записи в терминал, в логах:
В этих аргументах вы можете увидеть имена файлов, которые будут отображаться: файл1 и файл2. Что касается первого аргумента (1), помните, что в Linux при запуске любого процесса для него по умолчанию открываются три файловых дескриптора. Ниже приведены файловые дескрипторы по умолчанию:
- 0 – стандартный ввод
- 1 – стандартный выход
- 2 – Стандартная ошибка
Итак, системный вызов записи отображает файл1 и файл2 на стандартном дисплее, который является терминалом, обозначенным цифрой 1.
Теперь вы знаете, какие системные вызовы выполняли большую часть работы для команды ls testdir/. Но как насчет других 100+ системных вызовов в файле trace.log? Операционная система должна выполнять множество операций для запуска процесса, поэтому большая часть того, что вы видите в файле журнала, — это инициализация и очистка процесса. Прочитайте весь файл trace.log и попытайтесь понять, что происходит, чтобы команда ls работала.
Теперь, когда вы знаете, как анализировать системные вызовы для данной команды, вы можете использовать эти знания для других команд, чтобы понять, какие системные вызовы выполняются. strace предоставляет множество полезных флагов командной строки, облегчающих вам задачу, и некоторые из них описаны ниже.
По умолчанию strace не включает всю информацию о системных вызовах. Однако у него есть удобная опция -v verbose, которая может предоставить дополнительную информацию о каждом системном вызове:
Рекомендуется всегда использовать параметр -f при запуске команды strace. Он позволяет strace отслеживать любые дочерние процессы, созданные отслеживаемым в данный момент процессом:
Скажем, вам нужны только названия системных вызовов, количество их запусков и процент времени, затраченного на каждый системный вызов. Вы можете использовать флаг -c для получения этой статистики:
Предположим, вы хотите сосредоточиться на определенном системном вызове, например, сосредоточиться на открытых системных вызовах и проигнорировать остальные. Вы можете использовать флаг -e, за которым следует имя системного вызова:
Что делать, если вы хотите сосредоточиться более чем на одном системном вызове? Не беспокойтесь, вы можете использовать тот же флаг командной строки -e с запятой между двумя системными вызовами.Например, чтобы увидеть системные вызовы write и getdents:
Примеры до сих пор отслеживали явно запускаемые команды. Но как насчет команд, которые уже были запущены и находятся в процессе выполнения? Что, например, если вы хотите отслеживать демоны, которые являются просто долго работающими процессами? Для этого в strace предусмотрен специальный флаг -p, которому вы можете указать идентификатор процесса.
Вместо того, чтобы запускать strace на демоне, возьмем в качестве примера команду cat, которая обычно отображает содержимое файла, если вы указываете имя файла в качестве аргумента. Если аргумент не указан, команда cat просто ждет на терминале, пока пользователь не введет текст. После ввода текста он повторяется до тех пор, пока пользователь не нажмет Ctrl+C для выхода.
Запустите команду cat с одного терминала; он покажет вам приглашение и просто подождите (помните, что кошка все еще работает и не вышла):
На другом терминале найдите идентификатор процесса (PID) с помощью команды ps:
Теперь запустите strace в запущенном процессе с флагом -p и PID (который вы нашли выше, используя ps). После запуска strace в выводе указывается, к чему был присоединен процесс, а также номер PID. Теперь strace отслеживает системные вызовы, сделанные командой cat. Первый системный вызов, который вы видите, это чтение, ожидание ввода с 0 или стандартного ввода, который является терминалом, на котором была запущена команда cat:
Теперь вернитесь к терминалу, где вы оставили запущенной команду cat, и введите текст. Я ввел x0x0 для демонстрационных целей. Обратите внимание, как кошка просто повторила то, что я ввел; следовательно, x0x0 появляется дважды. Я ввел первый, а второй был выводом, повторенным командой cat:
Вернитесь к терминалу, где к процессу cat был подключен strace. Теперь вы видите два дополнительных системных вызова: более ранний системный вызов чтения, который теперь считывает x0x0 в терминале, и еще один вызов записи, который записывает x0x0 обратно в терминал, и снова новый вызов чтения, который ожидает чтения с терминала. Обратите внимание, что стандартный вход (0) и стандартный выход (1) находятся на одном и том же терминале:
Представьте, насколько это полезно при запуске strace против демонов, чтобы видеть все, что они делают в фоновом режиме. Убейте команду cat, нажав Ctrl+C; это также убивает ваш сеанс strace, поскольку процесс больше не выполняется.
Если вы хотите видеть метку времени для всех ваших системных вызовов, просто используйте опцию -t со strace:
Что делать, если вы хотите узнать время, прошедшее между системными вызовами? В strace есть удобная команда -r, которая показывает время, затраченное на выполнение каждого системного вызова. Довольно полезно, не так ли?
Заключение
Утилита strace очень удобна для понимания системных вызовов в Linux. Чтобы узнать о других флагах командной строки, обратитесь к справочным страницам и онлайн-документации.
Вы хотите запустить исполняемый файл из вашей программы? Или выполнить команду оболочки программно? Или, может быть, просто распараллелить свой код? Вы прочитали много информации о семействе функций execve() и fork(), но у вас все еще неразбериха в голове? Тогда эта статья для вас.
В Slack полно оповещений Prometheus? Попробуйте Robusta — платформу с открытым исходным кодом, которая ускоряет устранение неполадок Kubernetes. Robusta выявляет шаблоны предупреждений, обогащает предупреждения контекстными данными и помогает избавиться от ложных срабатываний. Проверьте это!
Как запустить процесс Linux
Системные вызовы
Давайте не будем усложнять и начнем с самого начала. Мы разрабатываем программу для Linux. Давайте посмотрим на так называемые системные вызовы — интерфейс, который Linux предоставляет нам для запроса функций ядра.
В Linux есть следующие системные вызовы для работы с процессами:
- fork(void) ( man 2 fork ) — создает полную копию вызывающего процесса. Звучит неэффективно из-за необходимости копирования адресного пространства процесса ввода, но использует оптимизацию копирования при записи. Это единственный (идеологический) способ создать процесс в Linux. Однако в свежих версиях ядра функция fork() реализована поверх сложного системного вызова clone(), и теперь можно использовать clone() напрямую для создания процессов, но для простоты мы опустим эти детали.
- execve(path, args, env) (man 2 execve) — преобразует вызывающий процесс в новый процесс, выполняя файл по указанному пути. По сути, он заменяет текущий образ процесса новым образом процесса и не создает новых процессов.
- pipe(fildes[2] __OUT) ( man 2 pipe ) — создает канал, который является примитивом межпроцессного взаимодействия. Обычно каналы представляют собой однонаправленные потоки данных. Первый элемент массива соединяется с концом чтения канала, а второй элемент соединяется с концом записи. Данные, записанные в fildes[1], можно прочитать из fildes[0].
Мы не будем рассматривать исходный код вышеупомянутых системных вызовов, потому что он является частью ядра и может быть трудным для понимания.
Также важной частью нашего рассмотрения является оболочка Linux — утилита-интерпретатор команд (т.е. обычная программа). Процесс оболочки постоянно читает из стандартного ввода. Пользователь обычно взаимодействует с оболочкой, вводя некоторые команды и нажимая клавишу ввода. Затем процесс оболочки выполняет предоставленные команды. Стандартные выходные данные этих процессов подключаются к стандартному выводу процесса оболочки. Однако процесс оболочки можно запустить как отдельный подпроцесс, а команду для выполнения можно указать с помощью аргумента -c. Например. bash -c "дата" .
Стандартная библиотека C
Конечно, мы разрабатываем нашу программу на C, чтобы она была максимально приближена к примитивам уровня ОС. C имеет так называемую стандартную библиотеку libc — широкий набор функций для упрощения написания программ на этом языке. Также он обеспечивает обтекание системных вызовов.
Стандартная библиотека C имеет следующие функции (в дистрибутивах на базе Debian apt-get download glibc-source ):
- system(command) ( man 3 system ) — запускает процесс shell для выполнения предоставленной команды. Вызывающий процесс блокируется до завершения выполнения основного процесса shell. system() возвращает код выхода процесса оболочки. Давайте посмотрим на реализацию этой функции в stdlib:
По сути, system() просто использует комбинацию fork() + exec() + waitpid() .
- popen(command, mode = 'r|w') ( man 3 popen ) — разветвляется и заменяет разветвленный процесс экземпляром оболочки, выполняющим предоставленную команду. Звучит так же, как system() ? Разница заключается в возможности общаться с дочерним процессом через его стандартный ввод или стандартный вывод. Но обычно в одностороннем порядке. Для связи с этим процессом используется канал. Реальные реализации можно найти здесь и здесь, но основная идея заключается в следующем:
Поздравляем!
Примечание 1. Реализация запуска подпроцесса в shell почти такая же. то есть fork() + execve() .
Примечание 2. Стоит отметить, что другие языки программирования обычно реализуют привязки к libc ОС (и для удобства делают некоторые оболочки), чтобы обеспечить функциональность, специфичную для ОС.
Зачем запускать процесс Linux
Распараллелить выполнение
Самый простой. Нам нужен только fork() . Вызов fork() фактически дублирует ваш программный процесс. Но поскольку этот процесс использует для связи с ним совершенно отдельное адресное пространство, нам все равно нужны примитивы межпроцессного взаимодействия. Даже набор инструкций разветвленного процесса такой же, как у родительского, это другой экземпляр программы.
Просто запустите программу из своего кода
Если вам нужно просто запустить программу без связи с ее стандартным вводом/выводом, функция libc system() является самым простым решением. Да, вы также можете fork() вашего процесса, а затем запустить exec() в дочернем процессе, но поскольку это довольно распространенный сценарий, есть функция system().
Запустить процесс и прочитать его стандартный вывод (или записать в его стандартный ввод)
Нам нужна функция popen() libc. Да, вы по-прежнему можете достичь цели, просто комбинируя pipe() + fork() + exec(), как показано выше, но popen() позволяет сократить объем шаблонного кода.
Запуск процесса, запись в его стандартный ввод и чтение из его стандартного вывода
Самый интересный. По некоторым причинам реализация popen() по умолчанию обычно однонаправленная. Но похоже, что мы можем легко придумать двунаправленное решение: нам нужно два канала, первый будет прикреплен к дочернему стандартному вводу, а второй — к дочернему стандартному выходу. Оставшаяся часть — fork() дочернего процесса, подключение каналов через dup2() к дескрипторам ввода-вывода и выполнение команды execve(). Одну из потенциальных реализаций можно найти в моем проекте GitHub popen2(). Дополнительная вещь, которую вы должны знать при разработке такой функции, это утечка дескрипторов открытых файлов каналов из ранее открытых процессов popen(). Если мы забудем явно закрыть дескрипторы сторонних файлов в каждой дочерней вилке, появится возможность выполнять операции ввода-вывода с одноуровневыми stdin и stdout s. Звучит как уязвимость. Чтобы иметь возможность закрыть все эти файловые дескрипторы, мы должны их отслеживать. Я использовал статическую переменную со связанным списком таких дескрипторов:
Несколько слов о Windows
В семействе ОС Windows используется несколько иная парадигма работы с процессами. Если мы пропустим неотерический уровень совместимости Unix, представленный в Windows 10, и попытаемся портировать поддержку POSIX API для Windows, у нас останется только две функции из старого школьного WinAPI:
-
- запускает новый процесс для данного исполняемого файла. - запускает процесс оболочки (да, в Windows тоже есть концепция оболочки) для выполнения предоставленной команды.
Итак, никаких fork и execve. Однако для связи с запущенными процессами также можно использовать каналы.
Вместо выводов
Сделайте код, а не войну!
Автор: Иван Величко
Подпишитесь на меня в Twitter @iximiuz
Присоединяйтесь к более чем тысяче довольных подписчиков, получающих мой обзор Cloud Native, и получайте подробные технические статьи из этого блога прямо на свой почтовый ящик.
Читайте также: