Что происходит при execve: путь запуска программы пошагово

В операционных системах семейства UNIX (и подобных, включая Linux) системный вызов execve играет ключевую роль в запуске новых программ. Этот механизм не просто открывает или копирует исполняемый файл — он полностью заменяет текущий образ процесса в памяти новым, загружая указанную программу. Чтобы понять, что именно происходит при вызове execve, важно пошагово разобрать его работу, начиная с пользовательского уровня и заканчивая глубокими внутренними преобразованиями в ядре операционной системы.

Начало: вызов execve из пользовательского пространства

Всё начинается с пользовательского пространства, когда программа вызывает execve, передавая путь к исполняемому файлу, массив аргументов (argv[]) и переменных окружения (envp[]). Часто этот вызов происходит не напрямую, а через более высокоуровневые обёртки вроде execl, execvp или execvpe, но все они в конечном итоге сводятся к вызову execve.

Синтаксис системного вызова выглядит так:

c
int execve(const char *pathname, char *const argv[], char *const envp[]);

Программа, из которой вызывается execve, не продолжает своё выполнение — если execve завершается успешно, она заменяется, и выполнение продолжается уже с новой программой. Если возникает ошибка, execve возвращает -1, а errno указывает причину (например, «файл не найден» или «отказано в доступе»).

Проверка прав доступа и существования файла

Первое, что делает ядро после получения вызова execve, — проверяет, существует ли файл по заданному пути и имеет ли текущий процесс права на его выполнение. Здесь ядро обращается к файловой системе, проверяя флаги доступа (исполняемый, читаемый), а также UID и GID процесса в сравнении с владельцем файла.

Если файл не найден или не имеет нужных прав, выполнение обрывается, и управление возвращается вызывающей программе с ошибкой.

Загрузка ELF-исполняемого файла

Если файл прошёл проверку, ядро читает его заголовок. Наиболее распространённый формат исполняемых файлов в Linux — это ELF (Executable and Linkable Format). Ядро считывает ELF-заголовок и определяет, какие сегменты необходимо загрузить в память.

В этот момент ядро начинает замену образа текущего процесса:

  1. Завершаются все потоки, кроме главного.

  2. Закрываются файловые дескрипторы, которые были отмечены как close-on-exec.

  3. Старый адресное пространство процесса полностью очищается.

Далее начинается загрузка новой программы. Ядро создаёт новое адресное пространство, в котором размещаются:

  • Сегмент кода (текстовый сегмент);

  • Сегмент данных (инициализированные глобальные переменные);

  • Сегмент BSS (неинициализированные глобальные переменные);

  • Стек, включая аргументы argv и переменные окружения envp.

Формирование стека процесса

Одним из критически важных этапов является формирование нового стека. Именно туда помещаются строки аргументов и переменных окружения, а также таблицы указателей на них. Всё это подготавливается ядром в строгом порядке, чтобы при старте программы она могла получить доступ к argc, argv[] и envp[] согласно стандарту вызова функций.

На этом этапе также могут быть добавлены так называемые «auxiliary vectors» — это специальные структуры, содержащие информацию, полезную для запускающейся программы, например, идентификатор платформы, адрес VDSO (virtual dynamic shared object) и прочее.

Инициализация динамического загрузчика

Если программа была скомпилирована с использованием динамических библиотек (а это почти всегда так), следующим шагом становится запуск динамического загрузчика (например, /lib64/ld-linux-x86-64.so.2). Он подгружается первым и получает управление для инициализации зависимостей.

Динамический загрузчик анализирует заголовки ELF-файла, определяет список библиотек, необходимых для работы, и загружает их в память. После этого он настраивает таблицы GOT и PLT (используемые для вызова внешних функций), а затем передаёт управление точке входа основной программы.

Переход к пользовательскому коду

После завершения всех внутренних этапов — загрузки ELF, формирования стека, инициализации динамических библиотек — управление передаётся начальной функции программы, чаще всего main, если говорить с точки зрения языка C. С этого момента пользовательская программа начинает выполняться, не имея представления о предыдущем процессе, чьё место она заняла.

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

Особые случаи: скрипты, setuid-файлы и безопасность

Если execve вызывается на файл, который начинается с «shebang» (#!), например скрипт на Python или Bash, ядро распознаёт это и вызывает интерпретатор, указанный в первой строке файла. При этом аргументы корректно формируются, и фактически запускается не сам скрипт, а, например, /usr/bin/python3 скрипт.py.

Также особое поведение проявляется при работе с файлами, помеченными как setuid. Это механизм, позволяющий временно запускать программу с правами её владельца, что критично для системных утилит (например, passwd). Ядро строго проверяет такие файлы, чтобы избежать повышения привилегий.

Заключение

Системный вызов execve — это один из самых фундаментальных механизмов в UNIX-подобных операционных системах. Он позволяет не просто запустить новую программу, а полностью трансформировать текущий процесс, создавая чистую среду для выполнения. От проверки прав доступа и чтения ELF-заголовков до инициализации динамических библиотек и передачи управления в точку входа — каждый этап тщательно отлажен, чтобы обеспечить безопасность, изоляцию и стабильность исполнения.

Понимание того, что происходит при execve, критично для разработчиков системного программного обеспечения, специалистов по безопасности и инженеров, работающих с низкоуровневой архитектурой Linux.

Comments are closed.