В операционных системах семейства UNIX (и подобных, включая Linux) системный вызов execve
играет ключевую роль в запуске новых программ. Этот механизм не просто открывает или копирует исполняемый файл — он полностью заменяет текущий образ процесса в памяти новым, загружая указанную программу. Чтобы понять, что именно происходит при вызове execve
, важно пошагово разобрать его работу, начиная с пользовательского уровня и заканчивая глубокими внутренними преобразованиями в ядре операционной системы.
Начало: вызов execve из пользовательского пространства
Всё начинается с пользовательского пространства, когда программа вызывает execve
, передавая путь к исполняемому файлу, массив аргументов (argv[]) и переменных окружения (envp[]). Часто этот вызов происходит не напрямую, а через более высокоуровневые обёртки вроде execl
, execvp
или execvpe
, но все они в конечном итоге сводятся к вызову execve
.
Синтаксис системного вызова выглядит так:
Программа, из которой вызывается execve
, не продолжает своё выполнение — если execve
завершается успешно, она заменяется, и выполнение продолжается уже с новой программой. Если возникает ошибка, execve
возвращает -1, а errno
указывает причину (например, «файл не найден» или «отказано в доступе»).
Проверка прав доступа и существования файла
Первое, что делает ядро после получения вызова execve
, — проверяет, существует ли файл по заданному пути и имеет ли текущий процесс права на его выполнение. Здесь ядро обращается к файловой системе, проверяя флаги доступа (исполняемый, читаемый), а также UID и GID процесса в сравнении с владельцем файла.
Если файл не найден или не имеет нужных прав, выполнение обрывается, и управление возвращается вызывающей программе с ошибкой.
Загрузка ELF-исполняемого файла
Если файл прошёл проверку, ядро читает его заголовок. Наиболее распространённый формат исполняемых файлов в Linux — это ELF (Executable and Linkable Format). Ядро считывает ELF-заголовок и определяет, какие сегменты необходимо загрузить в память.
В этот момент ядро начинает замену образа текущего процесса:
-
Завершаются все потоки, кроме главного.
-
Закрываются файловые дескрипторы, которые были отмечены как
close-on-exec
. -
Старый адресное пространство процесса полностью очищается.
Далее начинается загрузка новой программы. Ядро создаёт новое адресное пространство, в котором размещаются:
-
Сегмент кода (текстовый сегмент);
-
Сегмент данных (инициализированные глобальные переменные);
-
Сегмент 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.