Ядро
UNIX: создание процесса Авторы нашего журнала уже обращались
к теме создания процессов в операционной системе UNIX
Вниманию читателей предлагается статья, несущая знания «на
уровень больше», а точнее – на уровень глубже. В ней детально
рассматривается процедура инициирования нового процесса с
позиции ядра операционной системы.
Abstract
Понятие процесса для многозадачной системы является фундаментальным.
Совокупность процессов, выполняющихся в системе, обеспечивает
ее
функциональность. Для программиста важно понимать и хорошо
представлять
себе логику работы ядра ОС при выполнении таких критических
задач, как
создание нового процесса. Ни для кого не секрет, что по-настоящему
человек
становится экспертом в какой-то области только тогда, когда
он владеет
опытом "на уровень больше", чем того требует его
непосредственная сфера
деятельности. Эта статья предназначена для прикладных UNIX
(и не только)
программистов, желающих прояснить для себя логику работы ядра
в том,
что касается создания процесса.
Системные вызовы
Современные многозадачные операционные системы обеспечивают
одновременное
выполнение многих процессов, а также предоставляют процессам
набор сервисов,
посредством которых процесс осуществляет взаимодействие с
"окружающим миром".
Сервисы необходимы для защиты самой системы от некорректной
работы приложения.
В UNIX такой набор сервисов называется интерфейсом системных
вызовов.
Защита от некорректной работы приложения основана на принципе
режима
исполнения. Традиционно в UNIX используются два режима: привилегированный
и непривилегированный, или режим ядра и режим задачи. Системные
сервисы исполняются в режиме ядра, в то время как код приложения
исполняется в режиме задачи. Таким образом, код приложения
оказывается как бы запертым в каменный мешок от внешнего мира,
окнами же служат системные вызовы. Даже такое тривиальное,
как казалось бы, действие, как чтение файла, приложение не
способно сделать 'само по себе', а вынуждено запрашивать систему
сделать это.
Системных вызовов имеется несколько сотен; их код и данные
недоступны для
изменения приложением - ОС помечает соответствующие участки
памяти как
доступные только в привилегированном режиме. Передача управления
на
привилегированный код может осуществляться одним или несколькими
способами,
предусмотренными архитектурой процессора. На x86, например,
используется
инструкция программного прерывания (INT), инструкция межсегментного
перехода
(CALL), и другие способы; на архитектуре SPARC используется
инструкция
программного прерывания ta.
Остановка текущего процесса и переключение CPU на выполнение
следующего процесса для обеспечения многозадачности называется
переключением контекста, в то время как переключение режима
выполнения в рамках одной задачи называется переключением
режима. Подчеркну этот важный момент - при переключении режимов,
то есть при вызове ядра, продолжает выполняться все тот же
процесс, который сделал вызов. Но код, который он выполняет,
и данные, которыми он манипулирует,
недоступны приложению. Следует ясно понимать эту разницу между
переключением
контекста и переключением режима. Многие программисты представляют
себе ядро
как некоторый сервер, который выполняет запросы клиентов-процессов.
Это
представление является неверным. Нет никакого специального
процесса ядра,
который ждет запросов. Процесс сам выполняет тот вызов, который
он запросил,
только выполнение происходит в режиме ядра. Приложение осуществляет
системный вызов следующим образом:
1. Помещает параметры, предусмотренные системным вызовом,
в стек(система Linux помещает параметры в регистры).
2. Помещает номер системного вызова в условленное место, для
того чтобы
после переключения режимов процесс смог узнать, какой именно
системный
вызов запрошен. Как правило, номер системного вызова помешается
в
определенный регистр (на x86 - в регистр EAX)
3. Вызывает инструкцию, приводящую к переключению режимов.
После переключения режима управление передается в место,
называемое
точкой входа в ядро. Механизм передачи управления на точку
входа зависит
от конкретного способа, каким было совершено переключение
(межсегментный
вызов, прерывание, или другое), но результат один - после
выполнения
любого системного вызова процесс оказывается в привилегированном
режиме,
в заранее определенной точке. Начиная с этого момента, процесс
выполняет
код ядра. Он прост: процесс извлекает номер системного вызова
и вызывает
соответствующий обработчик. Для этого он использует таблицу
указателей на
функции-обработчики, номер системного вызова служит индексом
в этой таблице.
Функция - обработчик выполняет запрос, помещает код успеха/ошибки
в
условленное место (на x86 - в регистр EAX), и выполняет обратное
переключение
процесса в режим задачи.
Вот пример кода, читающего в локальный буфер из стандартного
ввода для
архитектуры x86, OC FreeBSD:
pushl $256 /* размер буфера
256 байт, помещаем в стек */
pushl $ibuf /* указатель на буфер помещаем в стек */
pushl $0 /* файловый дескриптор 0 (stdin), в стек */
movl $3, %eax /* номер вызова read - 3, в регистр eax */
pushl %eax /* также его помещаем в стек */
int $0x80 /* инструкция прерывания, переключение режима */
addl $16, %esp /* очистка стека от параметров */
Разумеется, прикладной программист выполняет системный вызов
не так, как
это показано выше. Стандартная библиотека C предоставляет
функции, которые
также называются системными вызовами (описаны в разделе 2
справочника man),
и представляют собой С функции-заглушки, содержащие весь низкоуровневый
код.
Например, код, показанный выше, выполняет системный вызов
read, и прикладной
программист вызывает его следующим образом:
read(0, ibuf, 256);
Хотя функции-заглушки в библиотеке С являются, конечно, библиотечными
функциями, тем не менее, их принято называть 'системными вызовами'.
Так
случилось потому, что они никакой другой работы, кроме как
непосредственно
выполнения системного вызова, не делают. Поэтому эти функции
описаны в разделе
2 справочника man, а не в разделе 3, предназначенном для описания
библиотечных
функций.
Системные вызовы создания процесса.
Традиционно, в UNIX предоставляет два системных вызова для
манипулирования
процессами. Это системные вызовы fork() и execve().
Системный вызов fork() создает копию процесса, сделавшего
вызов. В то время
как fork() вызывается в одном процессе, возврат из него осуществляется
уже
в два процесса - в родителя и в потомка. Родитель получит
идентификатор (PID)
потомка как возвращаемое fork() значение, потомок же получает
0. После вызова
fork() получаются два совершенно одинаковых процесса, которые
выполняются
независимо с инструкции, следующей за вызовом fork().
Для того, чтобы процесс начал исполнять код, содержащийся
в другом исполнимом
файле, предуcмотрен вызов execve(). Вызов execve() полностью
замещает код
и данные текущего процесса, на код и данные, содержащиеся
в файле, указанном
в одном из параметров execve(), и управление переходит на
точку входа новой
программы. При нормальном течении событий execve(), разумеется,
не возвращает
управление в вызвавший процесс - кода, вызвавшего execve(),
уже не будет
как такового в памяти процесса, он будет замещен новым кодом.
Командный интерпретатор действует именно таким образом -
если пользователь
ввел команду листинга файлов в текущей директории, `ls', то
интерпретатор
сначала найдет абсолютное имя файла, потом сделает вызов fork(),
породив
вторую копию самого себя. Родительский процесс вызовет функцию
wait(),
которая блокирует управление до завершения процесса-потомка.
Процесс-потомок
вызывает execve(), передавая туда полный путь к программе
листинга файлов.
Вызов execve() уничтожит текущее адресное пространство потомка,
и создаст
новое, содержашее код и данные программы ls. Код ls, получив
управление,
напечатает содержимое каталога и завершит выполнение системным
вызовом exit().
Системный вызов exit() уведомит спящего родителя о завершении
потомка,
после чего wait() вернет управление в родительский процесс
с
информацией о закончившемся процессе-потомке. Командный интерпретатор,
возвратившийся после блокирующего вызова wait(), напечатает
приглашение,
и таким образом, будет готовым к обработке следующей команды.
Я не стану подробно описывать системный вызов fork(), так
как основной интерес
в рамках данной статьи представляет собой вызов execve().
Приведу лишь
перечень основных действий, которые делает fork():
1. Генерирует уникальный идентификатор процесса
2. Создает в области данных ядра карты отображения физических
адресов в виртуальные для нового процесса
3. Создает в области данных ядра дескриптор нового процесса
(структуру proc)
4. Создает область приватных данных процесса в пространстве
ядра
(u-area), где находятся аппаратный контекст, стек, и т.д.
5. Копирует u-area родителя в u-area потомка
6. Для нового процесса сбрасывает очередь сигналов, ждущих
доставки
7. Устанавливает возвращаемое значение 0 в процесс-потомок,
и
PID потомка в процесс-родитель
8. Помещает новый процесс в очередь на выполнение
Важно отметить, что процесс-потомок наследует файловые дескрипторы
родителя.
Более того, дескрипторы открытых файлов сохраняются даже при
вызове execve().
Именно поэтому пользователь может видеть результат работы
программ, запускаемых командным интерпретатором - все они
используют стандартный ввод-вывод, наследуемый от интерпретатора
и ассоциированный с терминалом.
Таким же образом работают сетевые демоны. Например, логика
работы сетевого
супердемона inetd такова - демон, получив сетевой
запрос по протоколу TCP, создает сокет вызовом accept(), затем
вызывает
fork(). Процесс-потомок переопределяет сокет на файловый дескриптор
0 и 1,
таким образом, ассоциируя стандартный ввод-вывод с сокетом,
и делает
вызов execve() соответствующей программы, например, telnetd.
Демон
telnetd должен быть написан согласно этому соглашению - а
именно, что сокет
ассоциирован со стандартным вводом-выводом. Таким образом,
демон telnetd
может использовать библиотечные функции работы с файлами,
типа printf(), для
посылки данных в сокет. Родительский же процесс inted после
вызова fork()
закрывает сокет, созданный вызовом accept().
Вызов execve()
Прототип системного вызова execve имеет следующий вид:
#include <unistd.h>
int execve(const char *path, char *const argv[], char *const
envp[]);
Первый аргумент представляет собой имя исполняемого файла,
второй аргумент
есть массив указателей на параметры к запускаемой программе,
третий -
массив на указатели на строки переменных окружения.
Процесс, сделавший вызов execve(), переключается в режим
ядра, в котором
выполняет функцию-обработчик этого системного вызова. Алгоритм
ее работы
следующий:
1. Блокирует метаданные процесса на время работы обработчика.
2. Транслирует переданное имя файла в указатель vnode. Здесь
необходимо сделать пояснение. Внутри ядра файловая
система представлена в виде древовидной структуры, элементами
которой служат vnode (Virtual Node). В то время как файл в
режиме
задачи представлен файловым дескриптором, в режиме ядра он
представлен
структурой vnode. Vnode - унифицированное для всех файловых
систем
представление файла.
3. Анализирует права доступа к файлу. Если у пользователя
недостаточно
прав для запуска, переход к конечной стадии обработчика
4. Читает начало файла, и переданные параметры, в буфер памяти.
5. Обращается к таблице так называемых бинарных активаторов.
Эта
таблица представляет собой массив указателей на структуру,
определенную следующим образом:
struct execsw {
int (*ex_imgact) (void *);
const char *ex_name;
} *execsw [] = {
...
Имя execsw означает "EXECutable SWitch", то есть
коммутатор
обработчиков бинарных файлов, что кореллирует с другими важными
структурами данных UNIX, такими как cdevsw (Character DEVice
SWitch,
коммутатор символьных устройств), bdevsw, и так далее.
Обращение к таблице сводится к простому перебору -
по очереди вызываются активаторы (поле ex_imgact) до тех пор,
пока какой-нибудь из них не вернет код удачного завершения,
после
чего итерация завершается. Если все активаторы вернули код
ошибки,
то осуществляется переход к конечной стадии обработчика.
Это можно проиллюстрировать следующим кодом:
for (i = 0; error == -1 &&
execsw[i]; ++i)
error = (*execsw[i]->ex_imgact)(imgp);
Механизм активаторов представляет собой средство поддержки
множества
форматов исполняемых файлов. Каждый активатор 'ответственен'
за свой
формат. Активатору передается указатель на буфер памяти, содержащий
начало файла. По этому заголовку активатор в состоянии определить,
является ли формат файла 'знакомым' ему форматом, и если нет,
то
возвращает код ошибки. Как правило, такое сканирование сводится
к
поиску 'магических последовательностей' в заголовке файла.
К примеру,
активатор скриптов ожидает символы "#!" как начальные
байты файла,
активатор файлов формата ELF ожидает символы "\0x7fELF",
и так далее.
Смысл работы активатора в том, что только он способен правильно
загрузить в память файл того формата, за который он ответственен.
Поэтому зависимая от формата часть вынесена в отдельный
обработчик-активатор. Общая для всех форматов файлов часть
обработки
производится непосредственно самим обработчиком execve().
Если активатор определил запускаемый файл как 'знакомый'
ему, то
дальнейшая логика работы уникальна для каждого активатора.
Например, активатор скриптов загружает в память код и данные
программы-интерпретатора скрипта, то есть той программы, имя
которой
указано после символов "#!". Логика активатора ELF
файлов зависит
от того, является ли загружаемый файл динамически или статически
скомпонованным: если он статически скомпонован, то размещение
файла в
памяти и передача управления на его точку входа осуществляется
самим активатором, если же файл скомпонован динамически, то
активатор
разместит в памяти только динамический загрузчик (который
указан в
одной из секций ELF файла), и передаст управление на точку
входа
загрузчика, который, в свою очередь, проделает всю оставшуюся
работу
по загрузке кода/данных программы и необходимых библиотек.
Логика работы активаторов других форматов сходна с описанными
выше.
Активатор также ответственен за инициализацию аппаратного
контекста
процесса - то есть за корректную установку аппаратных регистров.
Так, активатор устанавливает регистр-указатель команд (pc,
npc для
SPARC, eip для x86 и т.д.) на точку входа программы. Таким
образом,
программа начнет исполняться в режиме задачи, после выхода
из
вызова execve(), начиная со своей точки входа.
Дальнейшие шаги предпринимаются активатором. Активатор:
6. Уничтожает старое адресное пространство процесса, и создает
новое.
При этом создаются и инициализируются карты отображения.
7. Инициализирует область данных и стек режима задачи. Копирует
в
стек параметры программы и переменные окружения.
Здесь сделаем небольшое отступление. Программы, написанные
на С/C++,
компонуются со специальным объектным файлом библиотеки С (как
правило,
он называется crt1.o). Этот файл содержит точку входа приложения,
которая традиционно называется _start. Активатор (или динамический
загрузчик) передает управление именно на эту точку входа.
Функция
_start подготавливает параметры argc, argv и envp, значения
для
которых она берет из стека (либо вызывая функцию динамического
компоновщика) и в конце концов, вызывает функцию main(), определенную
создателем приложения.
Код функции _start может быть таким:
void
_start(char *ap, ...)
{
int argc;
char **argv;
char **env;
argv = ≈
argc = *(long *)(void *)(argv - 1);
env = argv + argc + 1;
environ = env;
atexit(_fini);
_init();
exit(main(argc, argv, env));
}
Инициализацией аппаратного контекста процесса - выставлением
аппаратных регистров - работа активатора заканчивается.
7. Инициализирует атрибуты процесса - атрибуты UID, GID,
EUID, EGID,
очищает маску сигналов, восстанавливает обработчики сигналов
на
принятые по умолчанию. Установки для игнорируемых и заблокированных
сигналов не изменяются.
8. Конечная стадия функции-обработчика вызова execve():
снимает
блокировки с метаданных процесса, и ставит процесс в очередь
на выполнение.
Производительность
Как, вероятно, уже известно любому UNIX-программисту,
связка fork()-execve() для порождения нового процесса не является
совершенством с точки зрения производительности. Вызов fork()
проделывает работу по размещению виртуальной памяти для процесса
и
соответствующих метаданных в области данных ядра, тогда как
последующий
вызов execve() полностью освобождает выделенную память, чтобы
активатор (и динамический загрузчик) снова проделали аналогичную
работу.
Соответствующие вызовы для порождения нового процесса в других
операционных
системах, например, вызов CreateProcess() в системе Windows,
лишены этого
недостатка. В них отсутствует аналог fork(). Например, вызов
CreateProcess()
создает новый процесс, а не замещает текущий, как это делает
execve().
Для преодоления этого затруднения реализовано несколько механизмов.
Один из них - это новый системный вызов vfork(). Этот системный
вызов
нужно использовать только тогда, когда потомок 'намерен' делать
execve().
При вызове vfork() родительский процесс блокируется до того
момента,
пока потомок не сделает вызов execve(). Потомок же выполняется
в промежутке
от vfork() до execve() в адресном пространстве родителя, при
этом никакого
ненужного копирования не происходит. Разумеется, использование
vfork()
потенциально опасно - налицо явное использование одним процессом
адресного
пространства другого. Тем не менее, это наиболее эффективный
метод с точки
зрения производительности.
Я не буду рассматривать другие методы наподобие механизма
COW (Copy-On-Write),
так как они достаточно хорошо освещены в литературе.
Следует отметить, что на быстродействие вызова execve() влияет
порядок
следования активаторов в массиве execsw. Так как массив проверяется
последовательно при каждом вызове, разумно разместить наиболее
часто
используемые активаторы первыми в списке. Разумеется, UNIX
системы
спроектированы именно таким образом.
Сергей Любка |