ГЛАВА 7. УПРАВЛЕНИЕ ПРОЦЕССАМИ
Оцените этот текст
В предыдущей главе был рассмотрен контекст процесса и описаны алгоритмы для работы с ним; в данной главе речь пойдет об использовании и реализации системных функций, управляющих контекстом процесса. Системная функция fork создает новый процесс, функция exit завершает выполнение процесса, а wait дает возможность родительскому процессу синхронизировать свое продолжение с завершением порожденного процесса. Об асинхронных событиях процессы информи- руются при помощи сигналов. Поскольку ядро синхронизирует выполнение функций exit и wait при помощи сигналов, описание механизма сигналов предваряет со- бой рассмотрение функций exit и wait. Системная функция exec дает процессу возможность запускать "новую" программу, накладывая ее адресное пространство на исполняемый образ файла. Системная функция brk позволяет динамически вы- делять дополнительную память; теми же самыми средствами ядро динамически на- ращивает стек задачи, выделяя в случае необходимости дополнительное прост- ранство. В заключительной части главы дается краткое описание основных групп операций командного процессора shell и начального процесса init. На Рисунке 7.1 показана взаимосвязь между системными функциями, рассмат- риваемыми в данной главе, с одной стороны, и алгоритмами, описанными в пре- дыдущей главе, с другой. Почти во всех функциях используются алгоритмы sleep и wakeup, отсутствующие на рисунке. Функция exec, кроме того, взаимодейству- ет с алгоритмами работы с файловой системой, речь о которых шла в главах 4 и 5. +-----------------------------+---------------------+------------+ | Системные функции, имеющие | Системные функции, | Функции | | ющие дело с управлением па- | связанные с синхро- | смешанного | | мятью | низацией | типа | +-------+-------+-------+-----+--+----+------+----+-+-----+------+ | fork | exec | brk | exit |wait|signal|kill|setrgrр|setuid| +-------+-------+-------+--------+----+------+----+-------+------+ |dupreg |detach-|growreg| detach-| | |attach-| reg | | reg | | | reg |alloc- | | | | | | reg | | | | | |attach-| | | | | | reg | | | | | |growreg| | | | | |loadreg| | | | | |mapreg | | | | +-------+-------+-------+--------+-------------------------------+ Рисунок 7.1. Системные функции управления процессом и их связь с другими алгоритмами Единственным способом создания пользователем нового процесса в операци- онной системе UNIX является выполнение системной функции fork. Процесс, вы- зывающий функцию fork, называется родительским (процесс-родитель), вновь создаваемый процесс называется порожденным (процесс-потомок). Синтаксис вы- зова функции fork: 179 pid = fork(); В результате выполнения функции fork пользовательский контекст и того, и другого процессов совпадает во всем, кроме возвращаемого значения переменной pid. Для родительского процесса в pid возвращается идентификатор порожденно- го процесса, для порожденного - pid имеет нулевое значение. Нулевой процесс, возникающий внутри ядра при загрузке системы, является единственным процес- сом, не создаваемым с помощью функции fork. В ходе выполнения функции ядро производит следующую последовательность действий: 1. Отводит место в таблице процессов под новый процесс. 2. Присваивает порождаемому процессу уникальный код идентификации. 3. Делает логическую копию контекста родительского процесса. Поскольку те или иные составляющие процесса, такие как область команд, могут разде- ляться другими процессами, ядро может иногда вместо копирования области в новый физический участок памяти просто увеличить значение счетчика ссылок на область. 4. Увеличивает значения счетчика числа файлов, связанных с процессом, как в таблице файлов, так и в таблице индексов. 5. Возвращает родительскому процессу код идентификации порожденного процес- са, а порожденному процессу - нулевое значение. Реализацию системной функции fork, пожалуй, нельзя назвать тривиальной, так как порожденный процесс начинает свое выполнение, возникая как бы из воздуха. Алгоритм реализации функции для систем с замещением страниц по зап- росу и для систем с подкачкой процессов имеет лишь незначительные различия; все изложенное ниже в отношении этого алгоритма касается в первую очередь традиционных систем с подкачкой процессов, но с непременным акцентированием внимания на тех моментах, которые в системах с замещением страниц по запросу реализуются иначе. Кроме того, конечно, предполагается, что в системе имеет- ся свободная оперативная память, достаточная для размещения порожденного процесса. В главе 9 будет отдельно рассмотрен случай, когда для порожденного процесса не хватает памяти, и там же будут даны разъяснения относительно ре- ализации алгоритма fork в системах с замещением страниц. На Рисунке 7.2 приведен алгоритм создания процесса. Сначала ядро должно удостовериться в том, что для успешного выполнения алгоритма fork есть все необходимые ресурсы. В системе с подкачкой процессов для размещения порожда- емого процесса требуется место либо в памяти, либо на диске; в системе с за- мещением страниц следует выделить память для вспомогательных таблиц (в част- ности, таблиц страниц). Если свободных ресурсов нет, алгоритм fork заверша- ется неудачно. Ядро ищет место в таблице процессов для конструирования кон- текста порождаемого процесса и проверяет, не превысил ли пользователь, вы- полняющий fork, ограничение на максимально-допустимое количество параллельно запущенных процессов. Ядро также подбирает для нового процесса уникальный идентификатор, значение которого превышает на единицу максимальный из сущес- твующих идентификаторов. Если предлагаемый идентификатор уже присвоен друго- му процессу, ядро берет идентификатор, следующий по порядку. Как только бу- дет достигнуто максимально-допустимое значение, отсчет идентификаторов опять начнется с 0. Поскольку большинство процессов имеет короткое время жизни, при переходе к началу отсчета значительная часть идентификаторов оказывается свободной. На количество одновременно выполняющихся процессов накладывается ограни- чение (конфигурируемое), отсюда ни один из пользователей не может занимать в таблице процессов слишком много места, мешая тем самым другим пользователям создавать новые процессы. Кроме того, простым пользователям не разрешается создавать процесс, занимающий последнее свободное место в таблице процессов, в противном случае система зашла бы в тупик. Другими словами, поскольку в таблице процессов нет свободного места, то ядро не может гарантировать, что все существующие процессы завершатся естественным образом, поэтому новые 180 +------------------------------------------------------------+ | алгоритм fork | | входная информация: отсутствует | | выходная информация: для родительского процесса - идентифи-| | катор (PID) порожденного процесса | | для порожденного процесса - 0 | | { | | проверить доступность ресурсов ядра; | | получить свободное место в таблице процессов и уникаль- | | ный код идентификации (PID); | | проверить, не запустил ли пользователь слишком много | | процессов; | | сделать пометку о том, что порождаемый процесс находится| | в состоянии "создания"; | | скопировать информацию в таблице процессов из записи, | | соответствующей родительскому процессу, в запись, соот-| | ветствующую порожденному процессу; | | увеличить значения счетчиков ссылок на текущий каталог и| | на корневой каталог (если он был изменен); | | увеличить значение счетчика открытий файла в таблице | | файлов; | | сделать копию контекста родительского процесса (адресное| | пространство, команды, данные, стек) в памяти; | | поместить в стек фиктивный уровень системного контекста | | над уровнем системного контекста, соответствующим по- | | рожденному процессу; | | фиктивный контекстный уровень содержит информацию, | | необходимую порожденному процессу для того, чтобы | | знать все о себе и будучи выбранным для исполнения | | запускаться с этого места; | | если (в данный момент выполняется родительский процесс) | | { | | перевести порожденный процесс в состояние "готовности| | к выполнению"; | | возвратить (идентификатор порожденного процесса); | | /* из системы пользователю */ | | } | | в противном случае /* выполняется порожденный | | процесс */ | | { | | записать начальные значения в поля синхронизации ад- | | ресного пространства процесса; | | возвратить (0); /* пользователю */ | | } | | } | +------------------------------------------------------------+ Рисунок 7.2. Алгоритм fork процессы создаваться не будут. С другой стороны, суперпользователю нужно дать возможность исполнять столько процессов, сколько ему потребуется, ко- нечно, учитывая размер таблицы процессов, при этом процесс, исполняемый су- перпользователем, может занять в таблице и последнее свободное место. Пред- полагается, что суперпользователь может прибегать к решительным мерам и за- пускать процесс, побуждающий остальные процессы к завершению, если это вызы- вается необходимостью (см. раздел 7.2.3, где говорится о системной функции kill). Затем ядро присваивает начальные значения различным полям записи таблицы 181 процессов, соответствующей порожденному процессу, копируя в них значения по- лей из записи родительского процесса. Например, порожденный процесс "насле- дует" у родительского процесса коды идентификации пользователя (реальный и тот, под которым исполняется процесс), группу процессов, управляемую роди- тельским процессом, а также значение, заданное родительским процессом в фун- кции nice и используемое при вычислении приоритета планирования. В следующих разделах мы поговорим о назначении этих полей. Ядро передает значение поля идентификатора родительского процесса в запись порожденного, включая послед- ний в древовидную структуру процессов, и присваивает начальные значения раз- личным параметрам планирования, таким как приоритет планирования, использо- вание ресурсов центрального процессора и другие значения полей синхрониза- ции. Начальным состоянием процесса является состояние "создания" (см. Рису- нок 6.1). После того ядро устанавливает значения счетчиков ссылок на файлы, с ко- торыми автоматически связывается порождаемый процесс. Во-первых, порожденный процесс размещается в текущем каталоге родительского процесса. Число процес- сов, обращающихся в данный момент к каталогу, увеличивается на 1 и, соответ- ственно, увеличивается значение счетчика ссылок на его индекс. Во-вторых, если родительский процесс или один из его предков уже выполнял смену корне- вого каталога с помощью функции chroot, порожденный процесс наследует и но- вый корень с соответствующим увеличением значения счетчика ссылок на индекс корня. Наконец, ядро просматривает таблицу пользовательских дескрипторов для родительского процесса в поисках открытых файлов, известных процессу, и уве- личивает значение счетчика ссылок, ассоциированного с каждым из открытых файлов, в глобальной таблице файлов. Порожденный процесс не просто наследует права доступа к открытым файлам, но и разделяет доступ к файлам с родитель- ским процессом, так как оба процесса обращаются в таблице файлов к одним и тем же записям. Действие fork в отношении открытых файлов подобно действию алгоритма dup: новая запись в таблице пользовательских дескрипторов файла указывает на запись в глобальной таблице файлов, соответствующую открытому файлу. Для dup, однако, записи в таблице пользовательских дескрипторов файла относятся к одному процессу; для fork - к разным процессам. После завершения всех этих действий ядро готово к созданию для порожден- ного процесса пользовательского контекста. Ядро выделяет память для адресно- го пространства процесса, его областей и таблиц страниц, создает с помощью алгоритма dupreg копии всех областей родительского процесса и присоединяет с помощью алгоритма attachreg каждую область к порожденному процессу. В систе- ме с подкачкой процессов ядро копирует содержимое областей, не являющихся областями разделяемой памяти, в новую зону оперативной памяти. Вспомним из раздела 6.2.4 о том, что в пространстве процесса хранится указатель на соот- ветствующую запись в таблице процессов. За исключением этого поля, во всем остальном содержимое адресного пространства порожденного процесса в начале совпадает с содержимым пространства родительского процесса, но может расхо- диться после завершения алгоритма fork. Родительский процесс, например, пос- ле выполнения fork может открыть новый файл, к которому порожденный процесс уже не получит доступ автоматически. Итак, ядро завершило создание статической части контекста порожденного процесса; теперь оно приступает к созданию динамической части. Ядро копирует в нее первый контекстный уровень родительского процесса, включающий в себя сохраненный регистровый контекст задачи и стек ядра в момент вызова функции fork. Если в данной реализации стек ядра является частью пространства про- цесса, ядро в момент создания пространства порожденного процесса автомати- чески создает и системный стек для него. В противном случае родительскому процессу придется скопировать в пространство памяти, ассоциированное с по- рожденным процессом, свой системный стек. В любом случае стек ядра для по- рожденного процесса совпадает с системным стеком его родителя. Далее ядро создает для порожденного процесса фиктивный контекстный уровень (2), в кото- ром содержится сохраненный регистровый контекст из первого контекстного уровня. Значения счетчика команд (регистр PC) и других регистров, сохраняе- 182 мые в регистровом контексте, устанавливаются таким образом, чтобы с их по- мощью можно было "восстанавливать" контекст порожденного процесса, пусть да- же последний еще ни разу не исполнялся, и чтобы этот процесс при запуске всегда помнил о том, что он порожденный. Например, если программа ядра про- веряет значение, хранящееся в регистре 0, для того, чтобы выяснить, является ли данный процесс родительским или же порожденным, то это значение переписы- вается в регистровый контекст порожденного процесса, сохраненный в составе первого уровня. Механизм сохранения используется тот же, что и при переклю- чении контекста (см. предыдущую главу). Родительский процесс +---------------------------------------------+ Таблица | +---------+ Частная Адресное простран- | файлов | | Область | таблица ство процесса | +---------+ | | данных | областей +------------------+| | - | | +---------+ процесса | Открытые файлы --||-- + | - | | | +------+ | || - | - | | -- - - + | + +| Текущий каталог -||+ | +---------+ | - +------+ -| ||- -- -| | | +---------+ + + | || Измененный корень||| | | | | | Стек | +------+ -+------------------+|- - +---------+ | | задачи + - + | |+------------------+|| | | - | | +---------+ +------+ -| - ||- - | - | | || - ||| | | - | | -| - ||- - +---------+ | -- - - - - - - - -+| Стек ядра ||| + - + | | | +------------------+|- - | | +---------------------------------------------+| | +---------+ - - - | - | +----+----+ | | | - | |Разделяе-| - - | - | | мая | | | +---------+ | область | - -- -| | | команд | | | | | +----+----+ - - +---------+ - | | +---------+ + - - - - - - - - + - - +---------------------------------------------++ -|+ Таблица | +---------+ Частная | Адресное простран- | -- файлов | | Область | таблица - ство процесса | || +---------+ | | данных | областей |+------------------+| -- | - | | +---------+ процесса -| Открытые файлы --||-- +| | - | | | +------+ || || - | - | | -- - - + | +--| Текущий каталог -||+ | +---------+ | - +------+ | ||- -- + | | +---------+ + + | | Измененный корень||+ - - -| | | | Стек | +------+ +------------------+| +---------+ | | задачи + - + | +------------------+| | - | | +---------+ +------+ | - || | - | | | - || +---------+ | | Стек ядра || | | | +------------------+| | | +---------------------------------------------+ +---------+ Порожденный процесс | - | | - | +---------+ Рисунок 7.3. Создание контекста нового процесса при выполне- нии функции fork 183 Если контекст порожденного процесса готов, родительский процесс заверша- ет свою роль в выполнении алгоритма fork, переводя порожденный процесс в состояние "готовности к запуску, находясь в памяти" и возвращая пользователю его идентификатор. Затем, используя обычный алгоритм планирования, ядро вы- бирает порожденный процесс для исполнения и тот "доигрывает" свою роль в ал- горитме fork. Контекст порожденного процесса был задан родительским процес- сом; с точки зрения ядра кажется, что порожденный процесс возобновляется после приостанова в ожидании ресурса. Порожденный процесс при выполнении функции fork реализует ту часть программы, на которую указывает счетчик ко- манд, восстанавливаемый ядром из сохраненного на уровне 2 регистрового кон- текста, и по выходе из функции возвращает нулевое значение. На Рисунке 7.3 представлена логическая схема взаимодействия родительско- го и порожденного процессов с другими структурами данных ядра сразу после завершения системной функции fork. Итак, оба процесса совместно пользуются файлами, которые были открыты родительским процессом к моменту исполнения функции fork, при этом значение счетчика ссылок на каждый из этих файлов в таблице файлов на единицу больше, чем до вызова функции. Порожденный процесс имеет те же, что и родительский процесс, текущий и корневой каталоги, значе- ние же счетчика ссылок на индекс каждого из этих каталогов так же становится на единицу больше, чем до вызова функции. Содержимое областей команд, данных и стека (задачи) у обоих процессов совпадает; по типу области и версии сис- темной реализации можно установить, могут ли процессы разделять саму область команд в физических адресах. Рассмотрим приведенную на Рисунке 7.4 программу, которая представляет собой пример разделения доступа к файлу при исполнении функции fork. Пользо- вателю следует передавать этой программе два параметра - имя существующего файла и имя создаваемого файла. Процесс открывает существующий файл, создает новый файл и - при условии отсутствия ошибок - порождает новый процесс. Внутри программы ядро делает копию контек- ста родительского процесса для порожденного, при этом родительский процесс исполняется в одном адресном пространстве, а порожденный - в другом. Каждый из процессов может работать со своими собственными копиями глобальных пере- менных fdrd, fdwt и c, а также со своими собственными копиями стековых пере- менных argc и argv, но ни один из них не может обращаться к переменным дру- гого процесса. Тем не менее, при выполнении функции fork ядро делает копию адресного пространства первого процесса для второго, и порожденный процесс, таким образом, наследует доступ к файлам родительского (то есть к файлам, им ранее открытым и созданным) с правом использования тех же самых деск- рипторов. Родительский и порожденный процессы независимо друг от друга, конечно, вызывают функцию rdwrt и в цикле считывают по одному байту информацию из ис- ходного файла и переписывают ее в файл вывода. Функция rdwrt возвращает уп- равление, когда при считывании обнаруживается конец файла. Ядро перед тем уже увеличило значения счетчиков ссылок на исходный и результирующий файлы в таблице файлов, и дескрипторы, используемые в обоих процессах, адресуют к одним и тем же строкам в таблице. Таким образом, дескрипторы fdrd в том и в другом процессах указывают на запись в таблице файлов, соответствующую ис- ходному файлу, а дескрипторы, подставляемые в качестве fdwt, - на запись, соответствующую результирующему файлу (файлу вывода). Поэтому оба процесса никогда не обратятся вместе на чтение или запись к одному и тому же адресу, вычисляемому с помощью смещения внутри файла, поскольку ядро смещает внутри- файловые указатели после каждой операции чтения или записи. Несмотря на то, что, казалось бы, из-за того, что процессы распределяют между собой рабочую нагрузку, они копируют исходный файл в два раза быстрее, содержимое резуль- тирующего файла зависит от очередности, в которой ядро запускает процессы. Если ядро запускает процессы так, что они исполняют системные функции попе- ременно (чередуя и спаренные вызовы функций read-write), содержимое резуль- 184 +------------------------------------------------------------+ | #include | | int fdrd, fdwt; | | char c; | | | | main(argc, argv) | | int argc; | | char *argv[]; | | { | | if (argc != 3) | | exit(1); | | if ((fdrd = open(argv[1],O_RDONLY)) == -1) | | exit(1); | | if ((fdwt = creat(argv[2],0666)) == -1) | | exit(1); | | | | fork(); | | /* оба процесса исполняют одну и ту же программу */ | | rdwrt(); | | exit(0); | | } | | | | rdwrt(); | | { | | for(;;) | | { | | if (read(fdrd,&c,1) != 1) | | return; | | write(fdwt,&c,1); | | } | | } | +------------------------------------------------------------+ Рисунок 7.4. Программа, в которой родительский и порожденный процессы разделяют доступ к файлу тирующего файла будет совпадать с содержимым исходного файла. Рассмотрим, однако, случай, когда процессы собираются считать из исходного файла после- довательность из двух символов "ab". Предположим, что родительский процесс считал символ "a", но не успел записать его, так как ядро переключилось на контекст порожденного процесса. Если порожденный процесс считывает символ "b" и записывает его в результирующий файл до возобновления родительского процесса, строка "ab" в результирующем файле будет иметь вид "ba". Ядро не гарантирует согласование темпов выполнения процессов. Теперь перейдем к программе, представленной на Рисунке 7.5, в которой процесс-потомок наследует от своего родителя файловые дескрипторы 0 и 1 (со- ответствующие стандартному вводу и стандартному выводу). При каждом выполне- нии системной функции pipe производится назначение двух файловых дескрипто- ров в массивах to_par и to_chil. Процесс вызывает функцию fork и делает ко- пию своего контекста: каждый из процессов имеет доступ только к своим собст- венным данным, так же как и в предыдущем примере. Родительский процесс зак- рывает файл стандартного вывода (дескриптор 1) и дублирует дескриптор запи- си, возвращаемый в канал to_chil. Поскольку первое свободное место в таблице дескрипторов родительского процесса образовалось в результате только что вы- полненной операции закрытия (close) файла вывода, ядро переписывает туда дескриптор записи в канал и этот дескриптор становится дескриптором файла стандартного вывода для to_chil. Те же самые действия родительский процесс выполняет в отношении дескриптора файла стандартного ввода, заменяя его дес- 185 криптором чтения из канала to_par. И порожденный процесс закрывает файл стандартного ввода (дескриптор 0) и так же дублирует дескриптор чтения из канала to_chil. Поскольку первое свободное место в таблице дескрипторов фай- лов прежде было занято файлом стандартного ввода, его дескриптором становит- ся дескриптор чтения из канала to_chil. Аналогичные действия выполняются и в отношении дескриптора файла стандартного вывода, заменяя его дескриптором записи в канал to_par. И тот, и другой процессы закрывают файлы, дескрипторы +------------------------------------------------------------+ | #include | | char string[] = "hello world"; | | main() | | { | | int count,i; | | int to_par[2],to_chil[2]; /* для каналов родителя и | | потомка */ | | char buf[256]; | | pipe(to_par); | | pipe(to_chil); | | if (fork() == 0) | | { | | /* выполнение порожденного процесса */ | | close(0); /* закрытие прежнего стандартного ввода */ | | dup(to_chil[0]); /* дублирование дескриптора чтения | | из канала в позицию стандартного | | ввода */ | | close(1); /* закрытие прежнего стандартного вывода */| | dup(to_par[0]); /* дублирование дескриптора записи | | в канал в позицию стандартного | | вывода */ | | close(to_par[1]); /* закрытие ненужных дескрипторов | | close(to_chil[0]); канала */ | | close(to_par[0]); | | close(to_chil[1]); | | for (;;) | | { | | if ((count = read(0,buf,sizeof(buf))) == 0) | | exit(); | | write(1,buf,count); | | } | | } | | /* выполнение родительского процесса */ | | close(1); /* перенастройка стандартного ввода-вывода */| | dup(to_chil[1]); | | close(0); | | dup(to_par[0]); | | close(to_chil[1]); | | close(to_par[0]); | | close(to_chil[0]); | | close(to_par[1]); | | for (i = 0; i < 15; i++) | | { | | write(1,string,strlen(string)); | | read(0,buf,sizeof(buf)); | | } | | } | +------------------------------------------------------------+ Рисунок 7.5. Использование функций pipe, dup и fork 186 которых возвратила функция pipe - хорошая традиция, в чем нам еще предстоит убедиться. В результате, когда родительский процесс переписывает данные в стандартный вывод, запись ведется в канал to_chil и данные поступают к по- рожденному процессу, который считывает их через свой стандартный ввод. Когда же порожденный процесс пишет данные в стандартный вывод, запись ведется в канал to_par и данные поступают к родительскому процессу, считывающему их через свой стандартный ввод. Так через два канала оба процесса обмениваются сообщениями. Результаты этой программы не зависят от того, в какой очередности про- цессы выполняют свои действия. Таким образом, нет никакой разницы, возвраща- ется ли управление родительскому процессу из функции fork раньше или позже, чем порожденному процессу. И так же безразличен порядок, в котором процессы вызывают системные функции перед тем, как войти в свой собственный цикл, ибо они используют идентичные структуры ядра. Если процесс-потомок исполняет функцию read раньше, чем его родитель выполнит write, он будет приостановлен до тех пор, пока родительский процесс не произведет запись в канал и тем са- мым не возобновит выполнение потомка. Если родительский процесс записывает в канал до того, как его потомок приступит к чтению из канала, первый процесс не сможет в свою очередь считать данные из стандартного ввода, пока второй процесс не прочитает все из своего стандартного ввода и не произведет запись данных в стандартный вывод. С этого места порядок работы жестко фиксирован: каждый процесс завершает выполнение функций read и write и не может выпол- нить следующую операцию read до тех пор, пока другой процесс не выполнит па- ру read-write. Родитель- ский процесс после 15 итераций завершает работу; порожденный процесс натал- кивается на конец файла ("end-of-file"), поскольку канал не связан больше ни с одним из записывающих процессов, и тоже завершает работу. Если порожденный процесс попытается произвести запись в канал после завершения родительского процесса, он получит сигнал о том, что канал не связан ни с одним из процес- сов чтения. Мы упомянули о том, что хорошей традицией в программировании является закрытие ненужных файловых дескрипторов. В пользу этого говорят три довода. Во-первых, дескрипторы файлов постоянно находятся под контролем системы, ко- торая накладывает ограничение на их количество. Во-вторых, во время исполне- ния порожденного процесса присвоение дескрипторов в новом контексте сохраня- ется (в чем мы еще убедимся). Закрытие ненужных файлов до запуска процесса открывает перед программами возможность исполнения в "стерильных" условиях, свободных от любых неожиданностей, имея открытыми только файлы стандартного ввода-вывода и ошибок. Наконец, функция read для канала возвращает признак конца файла только в том случае, если канал не был открыт для записи ни од- ним из процессов. Если считывающий процесс будет держать дескриптор записи в канал открытым, он никогда не узнает, закрыл ли записывающий процесс работу на своем конце канала или нет. Вышеприведенная программа не работала бы над- лежащим образом, если бы перед входом в цикл выполнения процессом-потомком не были закрыты дескрипторы записи в канал. 7.2 СИГНАЛЫ Сигналы сообщают процессам о возникновении асинхронных событий. Посылка сигналов производится процессами - друг другу, с помощью функции kill, - или ядром. В версии V (вторая редакция) системы UNIX существуют 19 различных сигналов, которые можно классифицировать следующим образом: * Сигналы, посылаемые в случае завершения выполнения процесса, то есть тогда, когда процесс выполняет функцию exit или функцию signal с пара- метром death of child (гибель потомка); * Сигналы, посылаемые в случае возникновения вызываемых процессом особых ситуаций, таких как обращение к адресу, находящемуся за пределами вирту- 187 ального адресного пространства процесса, или попытка записи в область памяти, открытую только для чтения (например, текст программы), или по- пытка исполнения привилегированной команды, а также различные аппаратные ошибки; * Сигналы, посылаемые во время выполнения системной функции при возникно- вении неисправимых ошибок, таких как исчерпание системных ресурсов во время выполнения функции exec после освобождения исходного адресного пространства (см. раздел 7.5); * Сигналы, причиной которых служит возникновение во время выполнения сис- темной функции совершенно неожиданных ошибок, таких как обращение к не- существующей системной функции (процесс передал номер системной функции, который не соответствует ни одной из имеющихся функций), запись в канал, не связанный ни с одним из процессов чтения, а также использование недо- пустимого значения в параметре "reference" системной функции lseek. Ка- залось бы, более логично в таких случаях вместо посылки сигнала возвра- щать код ошибки, однако с практической точки зрения для аварийного за- вершения процессов, в которых возникают подобные ошибки, более предпоч- тительным является именно использование сигналов (*); * Сигналы, посылаемые процессу, который выполняется в режиме задачи, нап- ример, сигнал тревоги (alarm), посылаемый по истечении определенного пе- риода времени, или произвольные сигналы, которыми обмениваются процессы, использующие функцию kill; * Сигналы, связанные с терминальным взаимодействием, например, с "зависа- нием" терминала (когда сигнал-носитель на терминальной линии прекращает- ся по любой причине) или с нажатием клавиш "break" и "delete" на клавиа- туре терминала; * Сигналы, с помощью которых производится трассировка выполнения процесса. Условия применения сигналов каждой группы будут рассмотрены в этой и последующих главах. Концепция сигналов имеет несколько аспектов, связанных с тем, каким об- разом ядро посылает сигнал процессу, каким образом процесс обрабатывает сиг- нал и управляет реакцией на него. Посылая сигнал процессу, ядро устанавлива- ет в единицу разряд в поле сигнала записи таблицы процессов, соответствующий типу сигнала. Если процесс находится в состоянии приостанова с приоритетом, допускающим прерывания, ядро возобновит его выполнение. На этом роль отпра- вителя сигнала (процесса или ядра) исчерпывается. Процесс может запоминать сигналы различных типов, но не имеет возможности запоминать количество полу- чаемых сигналов каждого типа. Например, если процесс получает сигнал о "за- висании" или об удалении процесса из системы, он устанавливает в единицу со- ответствующие разряды в поле сигналов таблицы процессов, но не может ска- зать, сколько экземпляров сигнала каждого типа он получил. Ядро проверяет получение сигнала, когда процесс собирается перейти из режима ядра в режим задачи, а также когда он переходит в состояние приоста- нова или выходит из этого состояния с достаточно низким приоритетом планиро- вания (см. Рисунок 7.6). Ядро обрабатывает сигналы только тогда, когда про- цесс возвращается из режима ядра в режим задачи. Таким образом, сигнал не оказывает немедленного воздействия на поведение процесса, исполняемого в ре- жиме ядра. Если процесс исполняется в режиме задачи, а ядро тем временем об- рабатывает прерывание, послужившее поводом для посылки процессу сигнала, яд- ро распознает и обработает сигнал по выходе из прерывания. Таким образом, процесс не будет исполняться в режиме задачи, пока какие-то сигналы остаются необработанными. На Рисунке 7.7 представлен алгоритм, с помощью которого ядро определяет,
(*) Использование сигналов в некоторых обстоятельствах позволяет обнаружить ошибки при выполнении программ, не проверяющих код завершения вызываемых системных функций (сообщил Д.Ричи). 188 Выполняется в режиме задачи +-------+ | | Проверка | 1 | и Вызов функ- | | + - обработка ции, преры- ++------+ -+ - сигналов вание | ^ ^- -+- Преры- +-----+ +-------+ |- -|- - + вание, | | | +-------+ +---+ Возврат в возврат| | | | Возврат | режим задачи из пре-| | | | | рыва-| v v | Выполняет- | +-------+ ния | +------++ся в режи- ++------+ | | +-->| |ме ядра | | | 9 |<-----------+ 2 +------------>| 7 | | | Выход | | Резервирует-| | +-------+ ++------+ ся +-------+ Прекращение | ^ - Зарезер- существования |- - -|- - - - - - - - + - вирован | |- - - - - - - + + -- -+ +---------------+ +------+ -------- Проверка | Приостанов Запуск | - + - - - сигналов v | - При-+-------+ +-+-----+ Готов к ос- | | Возобновление | | запуску та- | 4 +----------------------->| 3 | в памяти нов-| | | | лен +---+---+ ++------+ в па- | | ^ ^ мяти | | | | Достаточно | | | | памяти | | | +---+ | Вы- Вы- | | | | грузка грузка | | | Создан | | |За- ++------+ | | |груз-| | fork | | |ка | 8 |<----- | | | | | | | | ++------+ | | | | | | | | Недоста- | | | +---+ точно | | | | памяти | | | | (только система | | | | подкачки) v v | v +-------+ +---+---+ | | Возобновление | | | 6 +----------------------->| 5 | | | | | +-------+ +-------+ Приостановлен, Готов к запуску, выгружен выгружен Рисунок 7.6. Диаграмма переходов процесса из состояние в состояние с указанием моментов проверки и обработки сигналов 189 получил ли процесс сигнал или нет. Условия, в которых формируются сигналы типа "гибель потомка", будут рассмотрены позже. Мы также увидим, что процесс может игнорировать отдельные сигналы, если воспользуется функцией signal. В алгоритме issig ядро просто гасит индикацию тех сигналов, на которые процесс не желает обращать внимание, и привлекает внимание процесса ко всем осталь- ным сигналам. +------------------------------------------------------------+ | алгоритм issig /* проверка получения сигналов */ | | входная информация: отсутствует | | выходная информация: "истина", если процесс получил сигна- | | лы, которые его интересуют | | "ложь" - в противном случае | | { | | выполнить пока (поле в записи таблицы процессов, содер- | | жащее индикацию о получении сигнала, хранит ненулевое | | значение) | | { | | найти номер сигнала, посланного процессу; | | если (сигнал типа "гибель потомка") | | { | | если (сигналы данного типа игнорируются) | | освободить записи таблицы процессов, которые | | соответствуют потомкам, прекратившим существо-| | вание; | | в противном случае если (сигналы данного типа при-| | нимаются) | | возвратить (истину); | | } | | в противном случае если (сигнал не игнорируется) | | возвратить (истину); | | сбросить (погасить) сигнальный разряд, установленный | | в соответствующем поле таблицы процессов, хранящем | | индикацию получения сигнала; | | } | | возвратить (ложь); | | } | +------------------------------------------------------------+ Рисунок 7.7. Алгоритм опознания сигналов

    7.2.1 Обработка сигналов

Ядро обрабатывает сигналы в контексте того процесса, который получает их, поэтому чтобы обработать сигналы, нужно запустить процесс. Существует три способа обработки сигналов: процесс завершается по получении сигнала, не обращает внимание на сигнал или выполняет особую (пользовательскую) функцию по его получении. Реакцией по умолчанию со стороны процесса, исполняемого в режиме ядра, является вызов функции exit, однако с помощью функции signal процесс может указать другие специальные действия, принимаемые по получении тех или иных сигналов. Синтаксис вызова системной функции signal: oldfunction = signal(signum,function); где signum - номер сигнала, при получении которого будет выполнено действие, связанное с запуском пользовательской функции, function - адрес функции, oldfunction - возвращаемое функцией значение. Вместо адреса функции процесс может передавать вызываемой процедуре signal числа 1 и 0: если function = 1, процесс будет игнорировать все последующие поступления сигнала с номером 190 signum (особый случай, связанный с игнорированием сигнала "гибель потомка", рассматривается в разделе 7.4), если = 0 (значение по умолчанию), процесс по получении сигнала в режиме ядра завершается. В пространстве процесса поддер- живается массив полей для обработки сигналов, по одному полю на каждый опре- деленный в системе сигнал. В поле, соответствующем сигналу с указанным номе- ром, ядро сохраняет адрес пользовательской функции, вызываемой по получении сигнала процессом. Способ обработки сигналов одного типа не влияет на обра- ботку сигналов других типов. +------------------------------------------------------------+ | алгоритм psig /* обработка сигналов после проверки их | | существования */ | | входная информация: отсутствует | | выходная информация: отсутствует | | { | | выбрать номер сигнала из записи таблицы процессов; | | очистить поле с номером сигнала; | | если (пользователь ранее вызывал функцию signal, с по- | | мощью которой сделал указание игнорировать сигнал дан- | | ного типа) | | возвратить управление; | | если (пользователь указал функцию, которую нужно выпол- | | нить по получении сигнала) | | { | | из пространства процесса выбрать пользовательский | | виртуальный адрес функции обработки сигнала; | | /* следующий оператор имеет нежелательные побочные | | эффекты */ | | очистить поле в пространстве процесса, содержащее | | адрес функции обработки сигнала; | | внести изменения в пользовательский контекст: | | искусственно создать в стеке задачи запись, ими- | | тирующую обращение к функции обработки сигнала; | | внести изменения в системный контекст: | | записать адрес функции обработки сигнала в поле | | счетчика команд, принадлежащее сохраненному ре- | | гистровому контексту задачи; | | возвратить управление; | | } | | если (сигнал требует дампирования образа процесса в па- | | мяти) | | { | | создать в текущем каталоге файл с именем "core"; | | переписать в файл "core" содержимое пользовательско-| | го контекста; | | } | | немедленно запустить алгоритм exit; | | } | +------------------------------------------------------------+ Рисунок 7.8. Алгоритм обработки сигналов Обрабатывая сигнал (Рисунок 7.8), ядро определяет тип сигнала и очищает (гасит) разряд в записи таблицы процессов, соответствующий данному типу сиг- нала и установленный в момент получения сигнала процессом. Если функции об- работки сигнала присвоено значение по умолчанию, ядро в отдельных случаях перед завершением процесса сбрасывает на внешний носитель (дампирует) образ процесса в памяти (см. упражнение 7.7). Дампирование удобно для программис- 191 тов тем, что позволяет установить причину завершения процесса и посредством этого вести отладку программ. Ядро дампирует состояние памяти при поступле- нии сигналов, которые сообщают о каких-нибудь ошибках в выполнении процес- сов, как например, попытка исполнения запрещенной команды или обращение к адресу, находящемуся за пределами виртуального адресного пространства про- цесса. Ядро не дампирует состояние памяти, если сигнал не связан с программ- ной ошибкой. Например, прерывание, вызванное нажатием клавиш "delete" или "break" на терминале, имеет своим результатом посылку сигнала, который сооб- щает о том, что пользователь хочет раньше времени завершить процесс, в то время как сигнал о "зависании" является свидетельством нарушения связи с ре- гистрационным терминалом. Эти сигналы не связаны с ошибками в протекании процесса. Сигнал о выходе (quit), однако, вызывает сброс состояния памяти, несмотря на то, что он возникает за пределами выполняемого процесса. Этот сигнал, обычно вызываемый одновременным нажатием клавиш , дает прог- раммисту возможность получать дамп состояния памяти в любой момент после за- пуска процесса, что бывает необходимо, если процесс попадает в бесконечный цикл выполнения одних и тех же команд (зацикливается). Если процесс получает сигнал, на который было решено не обращать внима- ние, выполнение процесса продолжается так, словно сигнала и не было. Пос- кольку ядро не сбрасывает значение соответствующего поля, свидетельствующего о необходимости игнорирования сигнала данного типа, то когда сигнал поступит вновь, процесс опять не обратит на него внимание. Если процесс получает сиг- нал, реагирование на который было признано необходимым, сразу по возвращении процесса в режим задачи выполняется заранее условленное действие, однако прежде чем перевести процесс в режим задачи, ядро еще должно предпринять следующие шаги: 1. Ядро обращается к сохраненному регистровому контексту задачи и выбирает значения счетчика команд и указателя вершины стека, которые будут возв- ращены пользовательскому процессу. 2. Сбрасывает в пространстве процесса прежнее значение поля функции обра- ботки сигнала и присваивает ему значение по умолчанию. 3. Создает новую запись в стеке задачи, в которую, при необходимости выде- ляя дополнительную память, переписывает значения счетчика команд и ука- зателя вершины стека, выбранные ранее из сохраненного регистрового кон- текста задачи. Стек задачи будет выглядеть так, как будто процесс произ- вел обращение к пользовательской функции (обработки сигнала) в той точ- ке, где он вызывал системную функцию или где ядро прервало его выполне- ние (перед опознанием сигнала). 4. Вносит изменения в сохраненный регистровый контекст задачи: устанавлива- ет значение счетчика команд равным адресу функции обработки сигнала, а значение указателя вершины стека равным глубине стека задачи. Таким образом, по возвращении из режима ядра в режим задачи процесс приступит к выполнению функции обработки сигнала; после ее завершения управ- ление будет передано на то место в программе пользователя, где было произве- дено обращение к системной функции или произошло прерывание, тем самым как бы имитируется выход из системной функции или прерывания. В качестве примера можно привести программу (Рисунок 7.9), которая при- нимает сигналы о прерывании (SIGINT) и сама посылает их (в результате выпол- нения функции kill). На Рисунке 7.10 представлены фрагменты программного ко- да, полученные в результате дисассемблирования загрузочного модуля в опера- ционной среде VAX 11/780. При выполнении процесса обращение к библиотечной процедуре kill имеет адрес (шестнадцатиричный) ee; эта процедура в свою оче- редь, прежде чем вызвать системную функцию kill, исполняет команду chmk (пе- ревести процесс в режим ядра) по адресу 10a. Адрес возврата из системной функции - 10c. Во время исполнения системной функции ядро посылает процессу сигнал о прерывании. Ядро обращает внимание на этот сигнал тогда, когда про- цесс собирается вернуться в режим задачи, выбирая из сохраненного регистро- вого контекста адрес возврата 10c и помещая его в стек задачи. При этом ад- 192 рес функции обработки сигнала, 104, ядро помещает в сохраненный регистровый контекст задачи. На Рисунке 7.11 показаны различные состояния стека задачи и сохраненного регистрового контекста. В рассмотренном алгоритме обработки сигналов имеются некоторые несоот- ветствия. Первое из них и наиболее важное связано с очисткой перед возвраще- нием процесса в режим задачи того поля в пространстве процесса, которое со- держит адрес пользовательской функции обработки сигнала. Если процессу снова понадобится обработать сигнал, ему опять придется прибегнуть к помощи сис- темной функции signal. При этом могут возникнуть нежелательные последс- +-------------------------------------------+ | #include | | main() | | { | | extern catcher(); | | signal(SIGINT,catcher); | | kill(0,SIGINT); | | } | | | | catcher() | | { | | } | +-------------------------------------------+ Рисунок 7.9. Исходный текст программы приема сигналов +--------------------------------------------------------+ | **** VAX DISASSEMBLER **** | | | | _main() | | e4: | | e6: pushab Ox18(pc) | | ec: pushl $Ox2 | | # в следующей строке вызывается функция signal | | ee: calls $Ox2,Ox23(pc) | | f5: pushl $Ox2 | | f7: clrl -(sp) | | # в следующей строке вызывается библиотечная процеду-| | ра kill | | f9: calls $Ox2,Ox8(pc) | | 100: ret | | 101: halt | | 102: halt | | 103: halt | | _catcher() | | 104: | | 106: ret | | 107: halt | | _kill() | | 108: | | # в следующей строке вызывается внутреннее прерывание| | операционной системы | | 10a: chmk $Ox25 | | 10c: bgequ Ox6 | | 10e: jmp Ox14(pc) | | 114: clrl r0 | | 116: ret | +--------------------------------------------------------+ Рисунок 7.10. Результат дисассемблирования программы приема сигналов 193 До После | | | | | | +-->+--------------------+ | | Вершина | | Новая запись с вы- | | | +-- стека --+ | зовом функции | | | | задачи | | | | | ---->|Адрес возврата (10c)| +--------------------+<--+ - +--------------------+ | Стек задачи | - | Стек задачи | | до | - | до | | получения сигнала | - | получения сигнала | +--------------------+ - +--------------------+ Стек задачи - Стек задачи - +--------------------+ - +--------------------+ | Адрес возврата | - | Адрес возврата | | в процессе (10c) -|---------------- | в процессе (104) | +--------------------+ +--------------------+ | Сохраненный регист-| | Сохраненный регист-| | ровый контекст за- | | ровый контекст за- | | дачи | | дачи | +--------------------+ +--------------------+ Системный контекстный Системный контекстный уровень 1 уровень 1 Область сохранения Область сохранения регистров регистров Рисунок 7.11. Стек задачи и область сохранения структур ядра до и после получения сигнала твия: например, могут создасться условия для конкуренции, если второй раз сигнал поступит до того, как процесс получит возможность запустить системную функцию. Поскольку процесс выполняется в режиме задачи, ядру следовало бы произвести переключение контекста, чтобы увеличить тем самым шансы процесса на получение сигнала до момента сброса значения поля функции обработки сиг- нала. Эту ситуацию можно разобрать на примере программы, представленной на Ри- сунке 7.12. Процесс обращается к системной функции signal для того, чтобы дать указание принимать сигналы о прерываниях и исполнять по их получении функцию sigcatcher. Затем он порождает новый процесс, запускает системную функцию nice, позволяющую сделать приоритет запуска процесса-родителя ниже приоритета его потомка (см. главу 8), и входит в бесконечный цикл. Порожден- ный процесс задерживает свое выполнение на 5 секунд, чтобы дать родительско- му процессу время исполнить системную функцию nice и снизить свой приоритет. После этого порожденный процесс входит в цикл, в каждой итерации которого он посылает родительскому процессу сигнал о прерывании (посредством обращения к функции kill). Если в результате ошибки, например, из-за того, что родитель- ский процесс больше не существует, kill завершается, то завершается и порож- денный процесс. Вся идея состоит в том, что родительскому процессу следует запускать функцию обработки сигнала при каждом получении сигнала о прерыва- нии. Функция обработки сигнала выводит сообщение и снова обращается к функ- ции signal при очередном появлении сигнала о прерывании, родительский же процесс продолжает 194 +------------------------------------------------------------+ | #include | | sigcatcher() | | { | | printf("PID %d принял сигнал\n",getpid()); /* печать | | PID */ | | signal(SIGINT,sigcatcher); | | } | | | | main() | | { | | int ppid; | | | | signal(SIGINT,sigcatcher); | | | | if (fork() == 0) | | { | | /* дать процессам время для выполнения установок */ | | sleep(5); /* библиотечная функция приостанова на| | 5 секунд */ | | ppid = getppid(); /* получить идентификатор родите- | | ля */ | | for (;;) | | if (kill(ppid,SIGINT) == -1) | | exit(); | | } | | | | /* чем ниже приоритет, тем выше шансы возникновения кон-| | куренции */ | | nice(10); | | for (;;) | | ; | | } | +------------------------------------------------------------+ Рисунок 7.12. Программа, демонстрирующая возникновение соперничества между процессами в ходе обработки сигналов исполнять циклический набор команд. Однако, возможна и следующая очередность наступления событий: 1. Порожденный процесс посылает родительскому процессу сигнал о прерывании. 2. Родительский процесс принимает сигнал и вызывает функцию обработки сиг- нала, но резервируется ядром, которое производит переключение контекста до того, как функция signal будет вызвана повторно. 3. Снова запускается порожденный процесс, который посылает родительскому процессу еще один сигнал о прерывании. 4. Родительский процесс получает второй сигнал о прерывании, но перед тем он не успел сделать никаких распоряжений относительно способа обработки сигнала. Когда выполнение родительского процесса будет возобновлено, он завершится. В программе описывается именно такое поведение процессов, поскольку вы- зов родительским процессом функции nice приводит к тому, что ядро будет чаще запускать на выполнение порожденный процесс. По словам Ричи (эти сведения были получены в частной беседе), сигналы были задуманы как события, которые могут быть как фатальными, так и проходя- щими незаметно, которые не всегда обрабатываются, поэтому в ранних версиях системы конкуренция процессов, связанная с посылкой сигналов, не фиксирова- лась. Тем не менее, она представляет серьезную проблему в тех программах, где осуществляется прием сигналов. Эта проблема была бы устранена, если бы 195 поле описания сигнала не очищалось по его получении. Однако, такое решение породило бы новую проблему: если поступающий сигнал принимается, а поле очи- щено, вложенные обращения к функции обработки сигнала могут переполнить стек. С другой стороны, ядро могло бы сбросить значение функции обработки сигнала, тем самым делая распоряжение игнорировать сигналы данного типа до тех пор, пока пользователь вновь не укажет, что нужно делать по получении подобных сигналов. Такое решение предполагает потерю информации, так как процесс не в состоянии узнать, сколько сигналов им было получено. Однако, информации при этом теряется не больше, чем в том случае, когда процесс по- лучает большое количество сигналов одного типа до того, как получает возмож- ность их обработать. В системе BSD, наконец, процесс имеет возможность бло- кировать получение сигналов и снимать блокировку при новом обращении к сис- темной функции; когда процесс снимает блокировку сигналов, ядро посылает процессу все сигналы, отложенные (повисшие) с момента установки блокировки. Когда процесс получает сигнал, ядро автоматически блокирует получение следу- ющего сигнала до тех пор, пока функция обработки сигнала не закончит работу. В этих действиях ядра наблюдается аналогия с тем, как ядро реагирует на ап- паратные прерывания: оно блокирует появление новых прерываний на время обра- ботки предыдущих. Второе несоответствие в обработке сигналов связано с приемом сигналов, поступающих во время исполнения системной функции, когда процесс приостанов- лен с допускающим прерывания приоритетом. Сигнал побуждает процесс выйти из приостанова (с помощью longjump), вернуться в режим задачи и вызвать функцию обработки сигнала. Когда функция обработки сигнала завершает работу, проис- ходит то, что процесс выходит из системной функции с ошибкой, сообщающей о прерывании ее выполнения. Узнав об ошибке, пользователь запускает системную функцию повторно, однако более удобно было бы, если бы это действие автома- тически выполнялось ядром, как в системе BSD. Третье несоответствие проявляется в том случае, когда процесс игнорирует поступивший сигнал. Если сигнал поступает в то время, когда процесс находит- ся в состоянии приостанова с допускающим прерывания приоритетом, процесс во- зобновляется, но не выполняет longjump. Другими словами, ядро узнает о том, что процесс проигнорировал поступивший сигнал только после возобновления его выполнения. Логичнее было бы оставить процесс в состоянии приостанова. Одна- ко, в момент посылки сигнала к пространству процесса, в котором ядро хранит адрес функции обработки сигнала, может отсутствовать доступ. Эта проблема может быть решена путем запоминания адреса функции обработки сигнала в запи- си таблицы процессов, обращаясь к которой, ядро получало бы возможность ре- шать вопрос о необходимости возобновления процесса по получении сигнала. С другой стороны, процесс может немедленно вернуться в состояние приостанова (по алгоритму sleep), если обнаружит, что в его возобновлении не было необ- ходимости. Однако, пользовательские процессы не имеют возможности осознавать собственное возобновление, поскольку ядро располагает точку входа в алгоритм sleep внутри цикла с условием продолжения (см. главу 2), переводя процесс вновь в состояние приостанова, если ожидаемое процессом событие в действи- тельности не имело места. Ко всему сказанному выше следует добавить, что ядро обрабатывает сигналы типа "гибель потомка" не так, как другие сигналы. В частности, когда процесс узнает о получении сигнала "гибель потомка", он выключает индикацию сигнала в соответствующем поле записи таблицы процессов и по умолчанию действует так, словно никакого сигнала и не поступало. Назначение сигнала "гибель по- томка" состоит в возобновлении выполнения процесса, приостановленного с до- пускающим прерывания приоритетом. Если процесс принимает такой сигнал, он, как и во всех остальных случаях, запускает функцию обработки сигнала. Дейст- вия, предпринимаемые ядром в том случае, когда процесс игнорирует поступив- ший сигнал этого типа, будут описаны в разделе 7.4. Наконец, когда процесс вызвал функцию signal с параметром "гибель потомка" (death of child), ядро посылает ему соответствующий сигнал, если он имеет потомков, прекративших существование. В разделе 7.4 на этом моменте мы остановимся более подробно. 196

    7.2.2 Группы процессов

Несмотря на то, что в системе UNIX процессы идентифицируются уникальным кодом (PID), системе иногда приходится использовать для идентификации про- цессов номер "группы", в которую они входят. Например, процессы, имеющие об- щего предка в лице регистрационного shell'а, взаимосвязаны, и поэтому когда пользователь нажимает клавиши "delete" или "break", или когда терминальная линия "зависает", все эти процессы получают соответствующие сигналы. Ядро использует код группы процессов для идентификации группы взаимосвязанных процессов, которые при наступлении определенных событий должны получать об- щий сигнал. Код группы запоминается в таблице процессов; процессы из одной группы имеют один и тот же код группы. Для того, чтобы присвоить коду группы процессов начальное значение, при- равняв его коду идентификации процесса, следует воспользоваться системной функцией setpgrp. Синтаксис вызова функции: grp = setpgrp(); где grp - новый код группы процессов. При выполнении функции fork про- цесс-потомок наследует код группы своего родителя. Использование функции setpgrp при назначении для процесса операторского терминала имеет важные особенности, на которые стоит обратить внимание (см. раздел 10.3.5).

    7.2.3 Посылка сигналов процессами

Для посылки сигналов процессы используют системную функцию kill. Синтак- сис вызова функции: kill(pid,signum) где в pid указывается адресат посылаемого сигнала (область действия сигна- ла), а в signum - номер посылаемого сигнала. Связь между значением pid и со- вокупностью выполняющихся процессов следующая: * Если pid - положительное целое число, ядро посылает сигнал процессу с идентификатором pid. * Если значение pid равно 0, сигнал посылается всем процессам, входящим в одну группу с процессом, вызвавшим функцию kill. * Если значение pid равно -1, сигнал посылается всем процессам, у которых реальный код идентификации пользователя совпадает с тем, под которым ис- полняется процесс, вызвавший функцию kill (об этих кодах более подробно см. в разделе 7.6). Если процесс, пославший сигнал, исполняется под ко- дом идентификации суперпользователя, сигнал рассылается всем процессам, кроме процессов с идентификаторами 0 и 1. * Если pid - отрицательное целое число, но не -1, сигнал посылается всем процессам, входящим в группу с номером, равным абсолютному значению pid. Во всех случаях, если процесс, пославший сигнал, исполняется под кодом идентификации пользователя, не являющегося суперпользователем, или если коды идентификации пользователя (реальный и исполнительный) у этого процесса не совпадают с соответствующими кодами процесса, принимающего сигнал, kill за- вершается неудачно. В программе, приведенной на Рисунке 7.13, главный процесс сбрасывает ус- тановленное ранее значение номера группы и порождает 10 новых процессов. При рождении каждый процесс-потомок наследует номер группы процессов своего ро- дителя, однако, процессы, созданные в нечетных итерациях цикла, сбрасывают это значение. Системные функции getpid и getpgrp возвращают значения кода идентификации выполняемого процесса и номера группы, в которую он входит, а функция pause приостанавливает выполнение процесса до момента получения сиг- нала. В конечном итоге родительский процесс запускает функцию kill и посыла- ет сигнал о прерывании всем процессам, входящим в одну с ним группу. Ядро 197 +------------------------------------------------------------+ | #include | | main() | | { | | register int i; | | | | setpgrp(); | | for (i = 0; i < 10; i++) | | { | | if (fork() == 0) | | { | | /* порожденный процесс */ | | if (i & 1) | | setpgrp(); | | printf("pid = %d pgrp = %d\n",getpid(),getpgrp());| | pause(); /* системная функция приостанова вы- | | полнения */ | | } | | } | | kill(0,SIGINT); | | } | +------------------------------------------------------------+ Рисунок 7.13. Пример использования функции setpgrp посылает сигнал пяти "четным" процессам, не сбросившим унаследованное значе- ние номера группы, при этом пять "нечетных" процессов продолжают свое выпол- нение.

    7.3 ЗАВЕРШЕНИЕ ВЫПОЛНЕНИЯ ПРОЦЕССА

В системе UNIX процесс завершает свое выполнение, запуская системную функцию exit. После этого процесс переходит в состояние "прекращения сущест- вования" (см. Рисунок 6.1), освобождает ресурсы и ликвидирует свой контекст. Синтаксис вызова функции: exit(status); где status - значение, возвращаемое функцией родительскому процессу. Процес- сы могут вызывать функцию exit как в явном, так и в неявном виде (по оконча- нии выполнения программы: начальная процедура (startup), компонуемая со все- ми программами на языке Си, вызывает функцию exit на выходе программы из функции main, являющейся общей точкой входа для всех программ). С другой стороны, ядро может вызывать функцию exit по своей инициативе, если процесс не принял посланный ему сигнал (об этом мы уже говорили выше). В этом случае значение параметра status равно номеру сигнала. Система не накладывает никакого ограничения на продолжительность выпол- нения процесса, и зачастую процессы существуют в течение довольно длительно- го времени. Нулевой процесс (программа подкачки) и процесс 1 (init), к при- меру, существуют на протяжении всего времени жизни системы. Продолжительными процессами являются также getty-процессы, контролирующие работу терминальной линии, ожидая регистрации пользователей, и процессы общего назначения, вы- полняемые под руководством администратора. На Рисунке 7.14 приведен алгоритм функции exit. Сначала ядро отменяет обработку всех сигналов, посылаемых процессу, поскольку ее продолжение ста- новится бессмысленным. Если процесс, вызывающий функцию exit, возглавляет 198 +------------------------------------------------------------+ | алгоритм exit | | входная информация: код, возвращаемый родительскому про- | | цессу | | выходная информация: отсутствует | | { | | игнорировать все сигналы; | | если (процесс возглавляет группу процессов, ассоцииро- | | ванную с операторским терминалом) | | { | | послать всем процессам, входящим в группу, сигнал о | | "зависании"; | | сбросить в ноль код группы процессов; | | } | | закрыть все открытые файлы (внутренняя модификация алго-| | ритма close); | | освободить текущий каталог (алгоритм iput); | | освободить области и память, ассоциированную с процессом| | (алгоритм freereg); | | создать запись с учетной информацией; | | прекратить существование процесса (перевести его в соот-| | ветствующее состояние); | | назначить всем процессам-потомкам в качестве родителя | | процесс init (1); | | если кто-либо из потомков прекратил существование, | | послать процессу init сигнал "гибель потомка"; | | послать сигнал "гибель потомка" родителю данного процес-| | са; | | переключить контекст; | | } | +------------------------------------------------------------+ Рисунок 7.14. Алгоритм функции exit группу процессов, ассоциированную с операторским терминалом (см. раздел 10.3.5), ядро делает предположение о том, что пользователь прекращает рабо- ту, и посылает всем процессам в группе сигнал о "зависании". Таким образом, если пользователь в регистрационном shell'е нажмет последовательность кла- виш, означающую "конец файла" (Ctrl-d), при этом с терминалом остались свя- занными некоторые из существующих процессов, процесс, выполняющий функцию exit, пошлет им всем сигнал о "зависании". Кроме того, ядро сбрасывает в ноль значение кода группы процессов для всех процессов, входящих в данную группу, поскольку не исключена возможность того, что позднее текущий код идентификации процесса (процесса, который вызвал функцию exit) будет присво- ен другому процессу и тогда последний возглавит группу с указанным кодом. Процессы, входившие в старую группу, в новую группу входить не будут. После этого ядро просматривает дескрипторы открытых файлов, закрывает каждый из этих файлов по алгоритму close и освобождает по алгоритму iput индексы теку- щего каталога и корня (если он изменялся). Наконец, ядро освобождает всю выделенную задаче память вместе с соответ- ствующими областями (по алгоритму detachreg) и переводит процесс в состояние прекращения существования. Ядро сохраняет в таблице процессов код возврата функции exit (status), а также суммарное время исполнения процесса и его по- томков в режиме ядра и режиме задачи. В разделе 7.4 при рассмотрении функции wait будет показано, каким образом процесс получает информацию о времени вы- полнения своих потомков. Ядро также создает в глобальном учетном файле за- пись, которая содержит различную статистическую информацию о выполнении про- цесса, такую как код идентификации пользователя, использование ресурсов цен- трального процессора и памяти, объем потоков ввода-вывода, связанных с про- 199 цессом. Пользовательские программы могут в любой момент обратиться к учетно- му файлу за статистическими данными, представляющими интерес с точки зрения слежения за функционированием системы и организации расчетов с пользователя- ми. Ядро удаляет процесс из дерева процессов, а его потомков передает про- цессу 1 (init). Таким образом, процесс 1 становится законным родителем всех продолжающих существование потомков завершающегося процесса. Если кто-либо из потомков прекращает существование, завершающийся процесс посылает процес- су init сигнал "гибель потомка" для того, чтобы процесс начальной загрузки мог удалить запись о потомке из таблицы процессов (см. раздел 7.9); кроме того, завершающийся процесс посылает этот сигнал своему родителю. В типичной ситуации родительский процесс синхронизирует свое выполнение с завершающимся потомком с помощью системной функции wait. Прекращая существование, процесс переключает контекст и ядро может теперь выбирать для исполнения следующий процесс; ядро с этих пор уже не будет исполнять процесс, прекративший сущес- твование. В программе, приведенной на Рисунке 7.15, процесс создает новый процесс, который печатает свой код идентификации и вызывает системную функцию pause, приостанавливаясь до получения сигнала. Процесс-родитель печатает PID своего потомка и завершается, возвращая только что выведенное значение через пара- метр status. Если бы вызов функции exit отсутствовал, начальная процедура сделала бы его по выходе процесса из функции main. Порожденный процесс про- должает ожидать получения сигнала, даже если его родитель уже завершился.

    7.4 ОЖИДАНИЕ ЗАВЕРШЕНИЯ ВЫПОЛНЕНИЯ ПРОЦЕССА

Процесс может синхронизировать продолжение своего выполнения с моментом завершения потомка, если воспользуется системной функцией wait. Синтаксис вызова функции: +------------------------------------------------------------+ | main() | | { | | int child; | | | | if ((child = fork()) == 0) | | { | | printf("PID потомка %d\n",getpid()); | | pause(); /* приостанов выполнения до получения | | сигнала */ | | } | | /* родитель */ | | printf("PID потомка %d\n",child); | | exit(child); | | } | +------------------------------------------------------------+ Рисунок 7.15. Пример использования функции exit pid = wait(stat_addr); где pid - значение кода идентификации (PID) прекратившего свое существование потомка, stat_addr - адрес переменной целого типа, в которую будет помещено возвращаемое функцией exit значение, в пространстве задачи. Алгоритм функции wait приведен на Рисунке 7.16. Ядро ведет поиск потом- ков процесса, прекративших существование, и в случае их отсутствия возвраща- ет ошибку. Если потомок, прекративший существование, обнаружен, ядро переда- ет его код идентификации и значение, возвращаемое через параметр функции exit, процессу, вызвавшему функцию wait. Таким образом, через параметр функ- 200 ции exit (status) завершающийся процесс может передавать различные значения, в закодированном виде содержащие информацию о причине завершения процесса, однако на практике этот параметр используется по назначению довольно редко. Ядро передает в соответствующие поля, принадлежащие пространству родитель- ского процесса, накопленные значения продолжительности исполнения процес- са-потомка в режиме ядра и в режиме задачи и, наконец, освобождает в таблице процессов место, которое в ней занимал прежде прекративший существование процесс. Это место будет предоставлено новому процессу. Если процесс, выполняющий функцию wait, имеет потомков, продолжающих су- ществование, он приостанавливается до получения ожидаемого сигнала. Ядро не возобновляет по своей инициативе процесс, приостановившийся с помощью функ- ции wait: такой процесс может возобновиться только в случае получения сигна- ла. На все сигналы, кроме сигнала "гибель потомка", процесс реагирует ранее рассмотренным образом. Реакция процесса на сигнал "гибель потомка" проявля- ется по-разному в зависимости от обстоятельств: * По умолчанию (то есть если специально не оговорены никакие другие дейст- вия) процесс выходит из состояния останова, в которое он вошел с помощью функции wait, и запускает алгоритм issig для опознания типа поступившего сигнала. Алгоритм issig (Рисунок 7.7) рассматривает особый случай пос- тупления сигнала типа "гибель потомка" и возвращает "ложь". Поэтому ядро не выполняет longjump из функции sleep, а возвращает управление функции wait. Оно перезапускает функцию wait, находит потомков, прекративших су- ществование (по крайней мере, одного), освобождает место в таблице про- цессов, занимаемое этими потомками, и выходит из функции wait, возвращая +------------------------------------------------------------+ | алгоритм wait | | входная информация: адрес переменной для хранения значения| | status, возвращаемого завершающимся | | процессом | | выходная информация: идентификатор потомка и код возврата | | функции exit | | { | | если (процесс, вызвавший функцию wait, не имеет потом- | | ков) | | возвратить (ошибку); | | | | для (;;) /* цикл с внутренним циклом */ | | { | | если (процесс, вызвавший функцию wait, имеет потом-| | ков, прекративших существование) | | { | | выбрать произвольного потомка; | | передать его родителю информацию об использова-| | нии потомком ресурсов центрального процессора;| | освободить в таблице процессов место, занимае- | | мое потомком; | | возвратить (идентификатор потомка, код возврата| | функции exit, вызванной потомком); | | } | | если (у процесса нет потомков) | | возвратить ошибку; | | приостановиться с приоритетом, допускающим прерыва-| | ния (до завершения потомка); | | } | | } | +------------------------------------------------------------+ Рисунок 7.16. Алгоритм функции wait 201 управление процессу, вызвавшему ее. * Если процессы принимает сигналы данного типа, ядро делает все необходи- мые установки для запуска пользовательской функции обработки сигнала, как и в случае поступления сигнала любого другого типа. * Если процесс игнорирует сигналы данного типа, ядро перезапускает функцию wait, освобождает в таблице процессов место, занимаемое потомками, прек- ратившими существование, и исследует оставшихся потомков. Например, если пользователь запускает программу, приведенную на Рисунке 7.17, с параметром и без параметра, он получит разные результаты. Сначала рассмотрим случай, когда пользователь запускает программу без параметра (единственный параметр - имя программы, то есть argc равно 1). Родительский процесс порождает 15 потомков, которые в конечном итоге завершают свое вы- полнение с кодом возврата i, номером процесса в порядке очередности созда- ния. Ядро, исполняя функцию wait для родителя, находит потомка, прекративше- го существование, и передает родителю его идентификатор и код возврата функ- ции exit. При этом заранее не известно, какой из потомков будет обнаружен. Из текста программы, реализующей системную функцию exit, написанной на языке Си и включенной в библиотеку стандартных подпрограмм, видно, что программа запоминает код возврата функции exit в битах 8-15 поля ret_code и возвращает функции wait идентификатор процесса-потомка. Таким образом, в ret_code хра- нится значение, равное 256*i, где i - номер потомка, а в ret_val заносится значение идентификатора потомка. Если пользователь запускает программу с параметром (то есть argc > 1), родительский процесс с помощью функции signal делает распоряжение игнориро- вать сигналы типа "гибель потомка". Предположим, что родительский процесс, выполняя функцию wait, приостановился еще до того, как его потомок произвел обращение к функции exit: когда процесс-потомок переходит к выполнению функ- ции exit, он посылает своему родителю сигнал "гибель потомка"; родительский процесс возобновляется, поскольку он был приостановлен с приоритетом, допус- кающим прерывания. Когда так или иначе родительский процесс продолжит свое +------------------------------------------------------------+ | #include | | main(argc,argv) | | int argc; | | char *argv[]; | | { | | int i,ret_val,ret_code; | | | | if (argc >= 1) | | signal(SIGCLD,SIG_IGN); /* игнорировать гибель | | потомков */ | | for (i = 0; i < 15; i++) | | if (fork() == 0) | | { | | /* процесс-потомок */ | | printf("процесс-потомок %x\n",getpid()); | | exit(i); | | } | | ret_val = wait(&ret_code); | | printf("wait ret_val %x ret_code %x\n",ret_val,ret_code);| | } | +------------------------------------------------------------+ Рисунок 7.17. Пример использования функции wait и игнорирова- ния сигнала "гибель потомка" 202 выполнение, он обнаружит, что сигнал сообщал о "гибели" потомка; однако, поскольку он игнорирует сигналы этого типа и не обрабатывает их, ядро удаляет из таблицы процессов запись, соответствующую прекратившему существование потомку, и продолжает выполнение функции wait так, словно сигнала и не было. Ядро выполняет эти действия вся- кий раз, когда родительский процесс получает сигнал типа "гибель потомка", до тех пор, пока цикл выполнения функции wait не будет завершен и пока не будет установлено, что у процесса больше потомков нет. Тогда функция wait возвращает значение, равное -1. Разница между двумя способами запуска прог- раммы состоит в том, что в первом случае процесс-родитель ждет завершения любого из потомков, в то время как во втором случае он ждет, пока завершатся все его потомки. В ранних версиях системы UNIX функции exit и wait не использовали и не рассматривали сигнал типа "гибель потомка". Вместо посылки сигнала функция exit возобновляла выполнение родительского процесса. Если родительский про- цесс при выполнении функции wait приостановился, он возобновляется, находит потомка, прекратившего существование, и возвращает управление. В противном случае возобновления не происходит; процесс-родитель обнаружит "погибшего" потомка при следующем обращении к функции wait. Точно так же и процесс на- чальной загрузки (init) может приостановиться, используя функцию wait, и за- вершающиеся по exit процессы будут возобновлять его, если он имеет усынов- ленных потомков, прекращающих существование. В такой реализации функций exit и wait имеется одна нерешенная проблема, связанная с тем, что процессы, прекратившие существование, нельзя убирать из системы до тех пор, пока их родитель не исполнит функцию wait. Если процесс создал множество потомков, но так и не исполнил функцию wait, может произой- ти переполнение таблицы процессов из-за наличия потомков, прекративших су- ществование с помощью функции exit. В качестве примера рассмотрим текст программы планировщика процессов, приведенный на Рисунке 7.18. Процесс про- изводит считывание данных из файла стандартного ввода до тех пор, пока не будет обнаружен конец файла, создавая при каждом исполнении функции read но- вого потомка. Однако, процесс-родитель не дожидается завершения каждого по- томка, поскольку он стремится запускать процессы на выполнение как можно быстрее, тем более, что может пройти довольно много времени, прежде чем про- цесс-потомок завершит свое выполнение. Если, обратившись к +------------------------------------------------------------+ | #include | | main(argc,argv) | | { | | char buf[256]; | | | | if (argc != 1) | | signal(SIGCLD,SIG_IGN); /* игнорировать гибель | | потомков */ | | while (read(0,buf,256)) | | if (fork() == 0) | | { | | /* здесь процесс-потомок обычно выполняет | | какие-то операции над буфером (buf) */ | | exit(0); | | } | | } | +------------------------------------------------------------+ Рисунок 7.18. Пример указания причины появления сигнала "ги- бель потомков" 203 функции signal, процесс распорядился игнорировать сигналы типа "гибель по- томка", ядро будет очищать записи, соответствующие прекратившим существова- ние процессам, автоматически. Иначе в конечном итоге из-за таких процессов может произойти переполнение таблицы.

    7.5 ВЫЗОВ ДРУГИХ ПРОГРАММ

Системная функция exec дает возможность процессу запускать другую прог- рамму, при этом соответствующий этой программе исполняемый файл будет распо- лагаться в пространстве памяти процесса. Содержимое пользовательского кон- текста после вызова функции становится недоступным, за исключением передава- емых функции параметров, которые переписываются ядром из старого адресного пространства в новое. Синтаксис вызова функции: execve(filename,argv,envp) где filename - имя исполняемого файла, argv - указатель на массив парамет- ров, которые передаются вызываемой программе, а envp - указатель на массив параметров, составляющих среду выполнения вызываемой программы. Вызов сис- темной функции exec осуществляют несколько библиотечных функций, таких как execl, execv, execle и т.д. В том случае, когда программа использует пара- метры командной строки main(argc,argv) , +------------------------------------------------------------+ | алгоритм exec | | входная информация: (1) имя файла | | (2) список параметров | | (3) список переменных среды | | выходная информация: отсутствует | | { | | получить индекс файла (алгоритм namei); | | проверить, является ли файл исполнимым и имеет ли поль- | | зователь право на его исполнение; | | прочитать информацию из заголовков файла и проверить, | | является ли он загрузочным модулем; | | скопировать параметры, переданные функции, из старого | | адресного пространства в системное пространство; | | для (каждой области, присоединенной к процессу) | | отсоединить все старые области (алгоритм detachreg);| | для (каждой области, определенной в загрузочном модуле) | | { | | выделить новые области (алгоритм allocreg); | | присоединить области (алгоритм attachreg); | | загрузить область в память по готовности (алгоритм | | loadreg); | | } | | скопировать параметры, переданные функции, в новую об- | | ласть стека задачи; | | специальная обработка для setuid-программ, трассировка; | | проинициализировать область сохранения регистров задачи | | (в рамках подготовки к возвращению в режим задачи); | | освободить индекс файла (алгоритм iput); | | } | +------------------------------------------------------------+ Рисунок 7.19. Алгоритм функции exec 204 массив argv является копией одноименного параметра, передаваемого функции exec. Символьные строки, описывающие среду выполнения вызываемой программы, имеют вид "имя=значение" и содержат полезную для программ информацию, такую как начальный каталог пользователя и путь поиска исполняемых программ. Про- цессы могут обращаться к параметрам описания среды выполнения, используя глобальную пере- менную environ, которую заводит начальная процедура Си-интерпретатора. На Рисунке 7.19 представлен алгоритм выполнения системной функции exec. Сначала функция обращается к файлу по алгоритму namei, проверяя, является ли файл исполнимым и отличным от каталога, а также проверяя наличие у пользова- теля права исполнять программу. Затем ядро, считывая заголовок файла, опре- деляет размещение информации в файле (формат файла). На Рисунке 7.20 изображен логический формат исполняемого файла в файло- вой системе, обычно генерируемый транслятором или загрузчиком. Он разбивает- ся на четыре части: 1. Главный заголовок, содержащий информацию о том, на сколько разделов де- лится файл, а также содержащий начальный адрес исполнения процесса и не- которое "магическое число", описывающее тип исполняемого файла. 2. Заголовки разделов, содержащие информацию, описывающую каждый раздел в файле: его размер, виртуальные адреса, в которых он располагается, и др. 3. Разделы, содержащие собственно "данные" файла (например, текстовые), ко- торые загружаются в адресное пространство процесса. 4. Разделы, содержащие смешанную информацию, такую как таблицы идентифика- торов и другие данные, используемые в процессе отладки. +---------------------------+ | Тип файла | Главный заголовок | Количество разделов | | Начальное состояние регис-| | тров | +---------------------------+ | Тип раздела | Заголовок 1-го раздела | Размер раздела | | Виртуальный адрес | +---------------------------+ | Тип раздела | Заголовок 2-го раздела | Размер раздела | - | Виртуальный адрес | - +---------------------------+ - | - | - | - | - +---------------------------+ - | Тип раздела | Заголовок n-го раздела | Размер раздела | | Виртуальный адрес | +---------------------------+ Раздел 1 | Данные (например, текст) | +---------------------------+ Раздел 2 | Данные | - +---------------------------+ - | - | - | - | - +---------------------------+ Раздел n | Данные | +---------------------------+ | Другая информация | +---------------------------+ Рисунок 7.20. Образ исполняемого файла 205 Указанные составляющие с развитием самой системы видоизменяются, однако во всех исполняемых файлах обязательно присутствует главный заголовок с по- лем типа файла. Тип файла обозначается коротким целым числом (представляется в машине полусловом), которое идентифицирует файл как загрузочный модуль, давая тем самым ядру возможность отслеживать динамические характеристики его выполне- ния. Например, в машине PDP 11/70 определение типа файла как загрузочного модуля свидетельствует о том, что процесс, исполняющий файл, может использо- вать до 128 Кбайт памяти вместо 64 Кбайт (**), тем не менее в системах с за- мещением страниц тип файла все еще играет существенную роль, в чем нам пред- стоит убедиться во время знакомства с главой 9. Вернемся к алгоритму. Мы остановились на том, что ядро обратилось к ин- дексу файла и установило, что файл является исполнимым. Ядру следовало бы освободить память, занимаемую пользовательским контекстом процесса. Однако, поскольку в памяти, подлежащей освобождению, располагаются передаваемые но- вой программе параметры, ядро первым делом копирует их из адресного прост- ранства в промежуточный буфер на время, пока не будут отведены области для нового пространства памяти. Поскольку параметрами функции exec выступают пользовательские адреса массивов символьных строк, ядро по каждой строке сначала копирует в систем- ную память адрес строки, а затем саму строку. Для хранения строки в разных версиях системы могут быть выбраны различные места. Чаще принято хранить строки в стеке ядра (локальная структура данных, принадлежащая программе яд- ра), на нераспределяемых участках памяти (таких как страницы), которые можно занимать только временно, а также во внешней памяти (на устройстве выгруз- ки). С точки зрения реализации проще всего для копирования параметров в новый пользовательский контекст обратиться к стеку ядра. Однако, поскольку размер стека ядра, как правило, ограничивается системой, а также поскольку парамет- ры функции exec могут иметь произвольную длину, этот подход следует сочетать с другими подходами. При рассмотрении других вариантов обычно останавливают- ся на способе хранения, обеспечивающем наиболее быстрый доступ к строкам. Если доступ к страницам памяти в системе реализуется довольно просто, строки следует размещать на страницах, поскольку обращение к оперативной памяти осуществляется быстрее, чем к внешней (устройству выгрузки). После копирования параметров функции exec в системную память ядро отсое- диняет области, ранее присоединенные к процессу, используя алгоритм detachreg. Несколько позже мы еще поговорим о специальных действиях, выпол- няемых в отношении областей команд. К рассматриваемому моменту процесс уже лишен пользовательского контекста и поэтому возникновение в дальнейшем любой ошибки неизбежно будет приводить к завершению процесса по сигналу. Такими ошибками могут быть обращение к пространству, не описанному в таблице облас- тей ядра, попытка загрузить программу, имеющую недопустимо большой размер или использующую области с пересекающимися адресами, и др. Ядро выделяет и присоединяет к процессу области команд и данных, загружает в оперативную па- мять содержимое исполняемого файла (алгоритмы allocreg, attachreg и loadreg, соответственно). Область данных процесса изначально поделена на две части:
(**) В PDP 11 "магические числа" имеют значения, соответствующие командам перехода; при выполнении этих команд в ранних версиях системы управле- ние передавалось в разные места программы в зависимости от размера за- головка и от типа исполняемого файла. Эта особенность больше не исполь- зуется с тех пор, как система стала разрабатываться на языке Си. 206 данные, инициализация которых была выполнена во время компиляции, и данные, не определенные компилятором ("bss"). Область памяти первоначально выделяет- ся для проинициализированных данных. Затем ядро увеличивает размер области данных для размещения данных типа "bss" (алгоритм growreg) и обнуляет их значения. Напоследок ядро выделяет и присоединяет к процессу область стека и отводит пространство памяти для хранения параметров функции exec. Если пара- метры функции размещаются на страницах, те же страницы могут быть использо- ваны под стек. В противном случае параметры функции размещаются в стеке за- дачи. В пространстве процесса ядро стирает адреса пользовательских функций об- работки сигналов, поскольку в новом пользовательском контексте они теряют свое значение. Однако и в новом контексте рекомендации по игнорированию тех или иных сигналов остаются в силе. Ядро устанавливает в регистрах для режима задачи значения из сохраненного регистрового контекста, в частности первона- чальное значение указателя вершины стека (sp) и счетчика команд (pc): перво- начальное значение счетчика команд было занесено загрузчиком в заголовок файла. Для setuid-программ и для трассировки процесса ядро предпринимает особые действия, на которых мы еще остановимся во время рассмотрения глав 8 и 11, соответственно. Наконец, ядро запускает алгоритм iput, освобождая ин- декс, выделенный по алгоритму namei в самом начале выполнения функции exec. Алгоритмы namei и iput в функции exec выполняют роль, подобную той, которую они выполняют при открытии и закрытии файла; состояние файла во время выпол- нения функции exec похоже на состояние открытого файла, если не принимать во внимание отсутствие записи о файле в таблице файлов. По выходе из функции процесс исполняет текст новой программы. Тем не менее, процесс остается тем же, что и до выполнения функции; его идентификатор не изменился, как не из- менилось и его место в иерархии процессов. Изменению подвергся только поль- зовательский контекст процесса. +-------------------------------------------------------+ | main() | | { | | int status; | | if (fork() == 0) | | execl("/bin/date","date",0); | | wait(&status); | | } | +-------------------------------------------------------+ Рисунок 7.21. Пример использования функции exec В качестве примера можно привести программу (Рисунок 7.21), в которой создается процесс-потомок, запускающий функцию exec. Сразу по завершении функции fork процесс-родитель и процесс-потомок начинают исполнять независи- мо друг от друга копии одной и той же программы. К моменту вызова процес- сом-потомком функции exec в его области команд находятся инструкции этой программы, в области данных располагаются строки "/bin/date" и "date", а в стеке - записи, которые будут извлечены по выходе из exec. Ядро ищет файл "/bin/date" в файловой системе, обнаружив его, узнает, что его может испол- нить любой пользователь, а также то, что он представляет собой загрузочный модуль, готовый для исполнения. По условию первым параметром функции exec, включаемым в список параметров argv, является имя исполняемого файла (пос- ледняя компонента имени пути поиска файла). Таким образом, процесс имеет доступ к имени программы на пользовательском уровне, что иногда может ока- заться полезным (***). Затем ядро копирует строки "/bin/date" и "date" во внутреннюю структуру хранения и освобождает области команд, данных и стека, занимаемые процессом. Процессу выделяются новые области команд, данных и стека, в область команд переписывается командная секция файла "/bin/date", в 207
(***) Например, в версии V стандартные программы переименования файла (mv), копирования файла (cp) и компоновки файла (ln), поскольку исполняют похожие действия, вызывают один и тот же исполняемый файл. По имени вызываемой программы процесс узнает, какие действия в настоящий момент требуются пользователю. область данных - секция данных файла. Ядро восстанавливает первоначальный список параметров (в данном случае это строка символов "date") и помещает его в область стека. Вызвав функцию exec, процесс-потомок прекращает выпол- нение старой программы и переходит к выполнению программы "date"; когда программа "date" завершится, процесс-родитель, ожидающий этого момента, получит код завершения функции exit. Вплоть до настоящего момента мы предполагали, что команды и данные раз- мещаются в разных секциях исполняемой программы и, следовательно, в разных областях текущего процесса. Такое размещение имеет два основных преимущест- ва: простота организации защиты от несанкционированного доступа и возмож- ность разделения областей различными процессами. Если бы команды и данные находились в одной области, система не смогла бы предотвратить затирание ко- манд, поскольку ей не были бы известны адреса, по которым они располагаются. Если же команды и данные находятся в разных областях, система имеет возмож- ность пользоваться механизмами аппаратной защиты области команд процесса. Когда процесс случайно попытается что-то записать в область, занятую коман- дами, он получит отказ, порожденный системой защиты и приводящий обычно к аварийному завершению процесса. +------------------------------------------------------------+ | #include | | main() | | { | | int i,*ip; | | extern f(),sigcatch(); | | | | ip = (int *)f; /* присвоение переменной ip значения ад-| | реса функции f */ | | for (i = 0; i < 20; i++) | | signal(i,sigcatch); | | *ip = 1; /* попытка затереть адрес функции f */ | | printf("после присвоения значения ip\n"); | | f(); | | } | | | | f() | | { | | } | | | | sigcatch(n) | | int n; | | { | | printf("принят сигнал %d\n",n); | | exit(1); | | } | +------------------------------------------------------------+ Рисунок 7.22. Пример программы, ведущей запись в область команд В качестве примера можно привести программу (Рисунок 7.22), которая присваивает переменной ip значение адреса функции f и затем делает распоря- 208 жение принимать все сигналы. Если программа скомпилирована так, что команды и данные располагаются в разных областях, процесс, исполняющий программу, при попытке записать что-то по адресу в ip встретит порожденный системой за- щиты отказ, поскольку область команд защищена от записи. При работе на компьютере AT&T 3B20 ядро посылает процессу сигнал SIGBUS, в других системах возможна посылка других сигналов. Процесс принимает сигнал и завершается, не дойдя до выполнения команды вывода на печать в процедуре main. Однако, если программа скомпилирована так, что команды и данные располагаются в одной об- ласти (в области данных), ядро не поймет, что процесс пытается затереть ад- рес функции f. Адрес f станет равным 1. Процесс исполнит команду вывода на печать в процедуре main, но когда запустит функцию f, произойдет ошибка, связанная с попыткой выполнения запрещенной команды. Ядро пошлет процессу сигнал SIGILL и процесс завершится. Расположение команд и данных в разных областях облегчает поиск и предот- вращение ошибок адресации. Тем не менее, в ранних версиях системы UNIX ко- манды и данные разрешалось располагать в одной области, поскольку на машинах PDP размер процесса был сильно ограничен: программы имели меньший размер и существенно меньшую сегментацию, если команды и данные занимали одну и ту же область. В последних версиях системы таких строгих ограничений на размер процесса нет и в дальнейшем возможность загрузки команд и данных в одну об- ласть компиляторами не будет поддерживаться. Второе преимущество раздельного хранения команд и данных состоит в воз- можности совместного использования областей процессами. Если процесс не мо- жет вести запись в область команд, команды процесса не претерпевают никаких изменений с того момента, как ядро загрузило их в область команд из команд- ной секции исполняемого файла. Если один и тот же файл исполняется несколь- кими процессами, в целях экономии памяти они могут иметь одну область команд на всех. Таким образом, когда ядро при выполнении функции exec отводит об- ласть под команды процесса, оно проверяет, имеется ли возможность совместно- го использования процессами команд исполняемого файла, что определяется "ма- гическим числом" в заголовке файла. Если да, то с помощью алгоритма xalloc ядро ищет существующую область с командами файла или назначает новую в слу- чае ее отсутствия (см. Рисунок 7.23). Исполняя алгоритм xalloc, ядро просматривает список активных областей в поисках области с командами файла, индекс которого совпадает с индексом ис- полняемого файла. В случае ее отсутствия ядро выделяет новую область (алго- ритм allocreg), присоединяет ее к процессу (алгоритм attachreg), загружает ее в память (алгоритм loadreg) и защищает от записи (read-only). Последний шаг предполагает, что при попытке процесса записать что-либо в область ко- манд будет получен отказ, вызванный системой защиты памяти. В случае обнару- жения области с командами файла в списке активных областей осуществляется проверка ее наличия в памяти (она может быть либо загружена в память, либо выгружена из памяти) и присоединение ее к процессу. В завершение выполнения алгоритма xalloc ядро снимает с области блокировку, а позднее, следуя алго- ритму detachreg при выполнении функций exit или exec, уменьшает значение счетчика областей. В традиционных реализациях системы поддерживается таблица команд, к которой ядро обращается в случаях, подобных описанному. Таким об- разом, совокупность областей команд можно рассматривать как новую версию этой таблицы. Напомним, что если область при выполнении алгоритма allocreg (Раздел 6.5.2) выделяется впервые, ядро увеличивает значение счетчика ссылок на ин- декс, ассоциированный с областью, при этом значение счетчика ссылок нами уже было увеличено в самом начале выполнения функции exec (алгоритм namei). Пос- кольку ядро уменьшает значение счетчика только один раз в завершение выпол- нения функции exec (по алгоритму iput), значение счетчика ссылок на индекс файла, ассоциированного с разделяемой областью команд и исполняемого в нас- тоящий момент, равно по меньшей мере 1. Поэтому когда процесс разрывает связь с файлом (функция unlink), содержимое файла остается нетронутым (не претерпевает изменений). После загрузки в память сам файл ядру становится 209 ненужен, ядро интересует только указатель на копию индекса файла в памяти, содержащийся в таблице областей; этот указатель и будет идентифицировать +------------------------------------------------------------+ | алгоритм xalloc /* выделение и инициализация области | | команд */ | | входная информация: индекс исполняемого файла | | выходная информация: отсутствует | | { | | если (исполняемый файл не имеет отдельной области команд)| | вернуть управление; | | если (уже имеется область команд, ассоциированная с ин- | | дексом исполняемого файла) | | { | | /* область команд уже существует ... подключиться к | | ней */ | | заблокировать область; | | выполнить пока (содержимое области еще не доступно) | | { | | /* операции над счетчиком ссылок, предохраняющие | | от глобального удаления области | | */ | | увеличить значение счетчика ссылок на область; | | снять с области блокировку; | | приостановиться (пока содержимое области не станет| | доступным); | | заблокировать область; | | уменьшить значение счетчика ссылок на область; | | } | | присоединить область к процессу (алгоритм attachreg);| | снять с области блокировку; | | вернуть управление; | | } | | /* интересующая нас область команд не существует -- соз- | | дать новую */ | | выделить область команд (алгоритм allocreg); /* область | | заблоки- | | рована */| | если (область помечена как "неотъемлемая") | | отключить соответствующий флаг; | | подключить область к виртуальному адресу, указанному в | | заголовке файла (алгоритм attachreg); | | если (файл имеет специальный формат для системы с замеще-| | нием страниц) | | /* этот случай будет рассмотрен в главе 9 */ | | в противном случае /* файл не имеет специального фор-| | мата */ | | считать команды из файла в область (алгоритм | | loadreg); | | изменить режим защиты области в записи частной таблицы | | областей процесса на "read-only"; | | снять с области блокировку; | | } | +------------------------------------------------------------+ Рисунок 7.23. Алгоритм выделения областей команд файл, связанный с областью. Если бы значение счетчика ссылок стало равным 0, 210 ядро могло бы передать копию индекса в памяти другому файлу, тем самым делая сомнительным значение указателя на индекс в записи таблицы областей: если бы пользователю пришлось исполнить новый файл, используя функцию exec, ядро по ошибке связало бы его с областью команд старого файла. Эта проблема устраня- ется благодаря тому, что ядро при выполнении алгоритма allocreg увеличивает значение счетчика ссылок на индекс, предупреждая тем самым переназначение индекса в памяти другому файлу. Когда процесс во время выполнения функций exit или exec отсоединяет область команд, ядро уменьшает значение счетчика ссылок на индекс (по алгоритму freereg), если только связь индекса с об- ластью не помечена как "неотъемлемая". Таблица индексов Таблица областей +----------------+ что могло бы прои- +----------------+ | - | зойти, если бы счет- | - | | - | чик ссылок на индекс | - | | - | файла /bin/date был | - | | - | равен 0 +----------------+ | - | | область команд | | - | -- - - - - -|- для файла | | - | | | /bin/who | +----------------+ - +----------------+ | копия индекса -|- - - - - -+ | - | | файла /bin/date| | - | | в памяти <+-----------+ | - | +----------------+ | +----------------+ | - | | | область команд | | - | +-----------+- для файла | | - | указатель на| /bin/date | | - | копию индек-+----------------+ | - | са в памяти | - | | - | | - | +----------------+ +----------------+ Рисунок 7.24. Взаимосвязь между таблицей индексов и таблицей областей в случае совместного использования процессами одной области команд Рассмотрим в качестве примера ситуацию, приведенную на Рисунке 7.21, где показана взаимосвязь между структурами данных в процессе выполнения функции exec по отношению к файлу "/bin/date" при условии расположения команд и дан- ных файла в разных областях. Когда процесс исполняет файл "/bin/date" первый раз, ядро назначает для команд файла точку входа в таблице областей (Рисунок 7.24) и по завершении выполнения функции exec оставляет счетчик ссылок на индекс равным 1. Когда файл "/bin/date" завершается, ядро запускает алгорит- мы detachreg и freereg, сбрасывая значение счетчика ссылок в 0. Однако, если ядро в первом случае не увеличило значение счетчика, оно по завершении функ- ции exec останется равным 0 и индекс на всем протяжении выполнения процесса будет находиться в списке свободных индексов. Предположим, что в это время свободный индекс понадобился процессу, запустившему с помощью функции exec файл "/bin/who", тогда ядро может выделить этому процессу индекс, ранее при- надлежавший файлу "/ bin/date". Просматривая таблицу областей в поисках ин- декса файла "/bin/who", ядро вместо него выбрало бы индекс файла "/bin/date". Считая, что область содержит команды файла "/bin/who", ядро ис- полнило бы совсем не ту программу. Поэтому значение счетчика ссылок на ин- декс активного файла, связанного с разделяемой областью команд, должно быть не меньше единицы, чтобы ядро не могло переназначить индекс другому файлу. Возможность совместного использования различными процессами одних и тех же областей команд позволяет экономить время, затрачиваемое на запуск прог- раммы с помощью функции exec. Администраторы системы могут с помощью систем- 211 ной функции (и команды) chmod устанавливать для часто исполняемых файлов ре- жим "sticky-bit", сущность которого заключается в следующем. Когда процесс исполняет файл, для которого установлен режим "sticky-bit", ядро не освобож- дает область памяти, отведенную под команды файла, отсоединяя область от процесса во время выполнения функций exit или exec, даже если значение счет- чика ссылок на индекс становится равным 0. Ядро оставляет область команд в первоначальном виде, при этом значение счетчика ссылок на индекс равно 1, пусть даже область не подключена больше ни к одному из процессов. Если же файл будет еще раз запущен на выполнение (уже другим процессом), ядро в таб- лице областей обнаружит запись, соответствующую области с командами файла. Процесс затратит на запуск файла меньше времени, так как ему не придется чи- тать команды из файловой системы. Если команды файла все еще находятся в па- мяти, в их перемещении не будет необходимости; если же команды выгружены во внешнюю память, будет гораздо быстрее загрузить их из внешней памяти, чем из файловой системы (см. об этом в главе 9). Ядро удаляет из таблицы областей записи, соответствующие областям с ко- мандами файла, для которого установлен режим "sticky-bit" (иными словами, когда область помечена как "неотъемлемая" часть файла или процесса), в сле- дующих случаях: 1. Если процесс открыл файл для записи, в результате соответствующих опера- ций содержимое файла изменится, при этом будет затронуто и содержимое области. 2. Если процесс изменил права доступа к файлу (chmod), отменив режим "sticky-bit", файл не должен оставаться в таблице областей. 3. Если процесс разорвал связь с файлом (unlink), он не сможет больше ис- полнять этот файл, поскольку у файла не будет точки входа в файловую систему; следовательно, и все остальные процессы не будут иметь доступа к записи в таблице областей, соответствующей файлу. Поскольку область с командами файла больше не используется, ядро может освободить ее вместе с остальными ресурсами, занимаемыми файлом. 4. Если процесс демонтирует файловую систему, файл перестает быть доступным и ни один из процессов не может его исполнить. В остальном - все как в предыдущем случае. 5. Если ядро использовало уже все пространство внешней памяти, отведенное под выгрузку задач, оно пытается освободить часть памяти за счет облас- тей, имеющих пометку "sticky-bit", но не используемых в настоящий мо- мент. Несмотря на то, что эти области могут вскоре понадобиться другим процессам, потребности ядра являются более срочными. В первых двух случаях область команд с пометкой "sticky-bit" должна быть освобождена, поскольку она больше не отражает текущее состояние файла. В ос- тальных случаях это делается из практических соображений. Конечно же ядро освобождает область только при том условии, что она не используется ни одним из выполняющихся процессов (счетчик ссылок на нее имеет нулевое значение); в противном случае это привело бы к аварийному завершению выполнения системных функций open, unlink и umount (случаи 1, 3 и 4, соответственно). Если процесс запускает с помощью функции exec самого себя, алгоритм вы- полнения функции несколько усложняется. По команде sh script командный процессор shell порождает новый процесс (новую ветвь), который инициирует запуск shell'а (с помощью функции exec) и исполняет команды файла "script". Если процесс запускает самого себя и при этом его область команд допускает совместное использование, ядру придется следить за тем, чтобы при обращении ветвей процесса к индексам и областям не возникали взаимные блоки- ровки. Иначе говоря, ядро не может, не снимая блокировки со "старой" области команд, попытаться заблокировать "новую" область, поскольку на самом деле это одна и та же область. Вместо этого ядро просто оставляет "старую" об- 212 ласть команд присоединенной к процессу, так как в любом случае ей предстоит повторное использование. Обычно процессы вызывают функцию exec после функции fork; таким образом, во время выполнения функции fork процесс-потомок копирует адресное простран- ство своего родителя, но сбрасывает его во время выполнения функции exec и по сравнению с родителем исполняет образ уже другой программы. Не было бы более естественным объединить две системные функции в одну, которая бы заг- ружала программу и исполняла ее под видом нового процесса ? Ричи высказал предположение, что возникновение fork и exec как отдельных системных функций обязано тому, что при создании системы UNIX функция fork была добавлена к уже существующему образу ядра системы (см. [Ritchie 84a], стр.1584). Однако, разделение fork и exec важно и с функциональной точки зрения, поскольку в этом случае процессы могут работать с дескрипторами файлов стандартного вво- да-вывода независимо, повышая тем самым "элегантность" использования кана- лов. Пример, показывающий использование этой возможности, приводится в раз- деле 7.8.

    7.6 КОД ИДЕНТИФИКАЦИИ ПОЛЬЗОВАТЕЛЯ ПРОЦЕССА

Ядро связывает с процессом два кода идентификации пользователя, не зави- сящих от кода идентификации процесса: реальный (действительный) код иденти- фикации пользователя и исполнительный код или setuid (от "set user ID" - ус- тановить код идентификации пользователя, под которым процесс будет испол- няться). Реальный код идентифицирует пользователя, несущего ответственность за выполняющийся процесс. Исполнительный код используется для установки прав собственности на вновь создаваемые файлы, для проверки прав доступа к файлу и разрешения на посылку сигналов процессам через функцию kill. Процессы мо- гут изменять исполнительный код, запуская с помощью функции exec программу setuid или запуская функцию setuid в явном виде. Программа setuid представляет собой исполняемый файл, имеющий в поле ре- жима доступа установленный бит setuid. Когда процесс запускает программу setuid на выполнение, ядро записывает в поля, содержащие реальные коды иден- тификации, в таблице процессов и в пространстве процесса код идентификации владельца файла. Чтобы как-то различать эти поля, назовем одно из них, кото- рое хранится в таблице процессов, сохраненным кодом идентификации пользова- теля. Рассмотрим пример, иллюстрирующий разницу в содержимом этих полей. Синтаксис вызова системной функции setuid: setuid(uid) где uid - новый код идентификации пользователя. Результат выполнения функции зависит от текущего значения реального кода идентификации. Если реальный код идентификации пользователя процесса, вызывающего функцию, указывает на су- перпользователя, ядро записывает значение uid в поля, хранящие реальный и исполнительный коды идентификации, в таблице процессов и в пространстве про- цесса. Если это не так, ядро записывает uid в качестве значения исполнитель- ного кода идентификации в пространстве процесса и то только в том случае, если значение uid равно значению реального кода или значению сохраненного кода. В противном случае функция возвращает вызывающему процессу ошибку. Процесс наследует реальный и исполнительный коды идентификации у своего ро- дителя (в результате выполнения функции fork) и сохраняет их значения после вызова функции exec. На Рисунке 7.25 приведена программа, демонстрирующая использование функ- ции setuid. Предположим, что исполняемый файл, полученный в результате тран- сляции исходного текста программы, имеет владельца с именем "maury" (код идентификации 8319) и установленный бит setuid; право его исполнения предос- тавлено всем пользователям. Допустим также, что пользователи "mjb" (код идентификации 5088) и "maury" являются владельцами файлов с теми же именами, каждый из которых доступен только для чтения и только своему владельцу. Во время исполнения программы пользователю "mjb" выводится следующая информа- 213 ция: uid 5088 euid 8319 fdmjb -1 fdmaury 3 after setuid(5088): uid 5088 euid 5088 fdmjb 4 fdmaury -1 after setuid(8319): uid 5088 euid 8319 Системные функции getuid и geteuid возвращают значения реального и исполни- тельного кодов идентификации пользователей процесса, для +------------------------------------------------------------+ | #include | | main() | | { | | int uid,euid,fdmjb,fdmaury; | | | | uid = getuid(); /* получить реальный UID */ | | euid = geteuid(); /* получить исполнительный UID */| | printf("uid %d euid %d\n",uid,euid); | | | | fdmjb = open("mjb",O_RDONLY); | | fdmaury = open("maury",O_RDONLY); | | printf("fdmjb %d fdmaury %d\n",fdmjb,fdmaury); | | | | setuid(uid); | | printf("after setuid(%d): uid %d euid %d\n",uid, | | getuid(),geteuid()); | | | | fdmjb = open("mjb",O_RDONLY); | | fdmaury = open("maury",O_RDONLY); | | printf("fdmjb %d fdmaury %d\n",fdmjb,fdmaury); | | | | setuid(uid); | | printf("after setuid(%d): uid %d euid %d\n",euid, | | getuid(),geteuid()); | | } | +------------------------------------------------------------+ Рисунок 7.25. Пример выполнения программы setuid пользователя "mjb" это, соответственно, 5088 и 8319. Поэтому процесс не мо- жет открыть файл "mjb" (ибо он имеет исполнительный код идентификации поль- зователя (8319), не разрешающий производить чтение файла), но может открыть файл "maury". После вызова функции setuid, в результате выполнения которой в поле исполнительного кода идентификации пользователя ("mjb") заносится зна- чение реального кода идентификации, на печать выводятся значения и того, и другого кода идентификации пользователя "mjb": оба равны 5088. Теперь про- цесс может открыть файл "mjb", поскольку он исполняется под кодом идентифи- кации пользователя, имеющего право на чтение из файла, но не может открыть файл "maury". Наконец, после занесения в поле исполнительного кода идентифи- кации значения, сохраненного функцией setuid (8319), на печать снова выво- дятся значения 5088 и 8319. Мы показали, таким образом, как с помощью прог- раммы setuid процесс может изменять значение кода идентификации пользовате- ля, под которым он исполняется. Во время выполнения программы пользователем "maury" на печать выводится следующая информация: uid 8319 euid 8319 fdmjb -1 fdmaury 3 after setuid(8319): uid 8319 euid 8319 fdmjb -1 fdmaury 4 214 after setuid(8319): uid 8319 euid 8319 Реальный и исполнительный коды идентификации пользователя во время выполне- ния программы остаются равны 8319: процесс может открыть файл "maury", но не может открыть файл "mjb". Исполнительный код, хранящийся в пространстве про- цесса, занесен туда в результате последнего исполнения функции или программы setuid; только его значением определяются права доступа процесса к файлу. С помощью функции setuid исполнительному коду может быть присвоено значение сохраненного кода (из таблицы процессов), т.е. то значение, которое исполни- тельный код имел в самом начале. Примером программы, использующей вызов системной функции setuid, может служить программа регистрации пользователей в системе (login). Параметром функции setuid при этом является код идентификации суперпользователя, таким образом, программа login исполняется под кодом суперпользователя из корня системы. Она запрашивает у пользователя различную информацию, например, имя и пароль, и если эта информация принимается системой, программа запускает функцию setuid, чтобы установить значения реального и исполнительного кодов идентификации в соответствии с информацией, поступившей от пользователя (при этом используются данные файла "/etc/passwd"). В заключение программа login инициирует запуск командного процессора shell, который будет исполняться под указанными пользовательскими кодами идентификации. Примером setuid-программы является программа, реализующая команду mkdir. В разделе 5.8 уже говорилось о том, что создать каталог может только про- цесс, выполняющийся под управлением суперпользователя. Для того, чтобы пре- доставить возможность создания каталогов простым пользователям, команда mkdir была выполнена в виде setuid-программы, принадлежащей корню системы и имеющей права суперпользователя. На время исполнения команды mkdir процесс получает права суперпользователя, создает каталог, используя функцию mknod, и предоставляет права собственности и доступа к каталогу истинному пользова- телю процесса.

    7.7 ИЗМЕНЕНИЕ РАЗМЕРА ПРОЦЕССА

С помощью системной функции brk процесс может увеличивать и уменьшать размер области данных. Синтаксис вызова функции: brk(endds); где endds - старший виртуальный адрес области данных процесса (адрес верхней границы). С другой стороны, пользователь может обратиться к функции следую- щим образом: oldendds = sbrk(increment); где oldendds - текущий адрес верхней границы области, increment - число байт, на которое изменяется значение oldendds в результате выполнения функ- ции. Sbrk - это имя стандартной библиотечной подпрограммы на Си, вызывающей функцию brk. Если размер области данных процесса в результате выполнения функции увеличивается, вновь выделяемое пространство имеет виртуальные адре- са, смежные с адресами увеличиваемой области; таким образом, виртуальное ад- ресное пространство процесса расширяется. При этом ядро проверяет, не превы- шает ли новый размер процесса максимально-допустимое значение, принятое для него в системе, а также не накладывается ли новая область данных процесса на виртуальное адресное пространство, отведенное ранее для других целей (Рису- нок 7.26). Если все в порядке, ядро запускает алгоритм growreg, присоединяя к области данных внешнюю память (например, таблицы страниц) и увеличивая значение поля, описывающего размер процесса. В системе с замещением страниц ядро также отводит под новую область пространство основной памяти и обнуляет его содержимое; если свободной памяти нет, ядро освобождает память путем выгрузки процесса (более подробно об этом мы поговорим в главе 9). Если с помощью функции brk процесс уменьшает размер области данных, ядро освобожда- ет часть ранее выделенного адресного пространства; когда процесс попытается обратиться к данным по виртуальным адресам, принадлежащим освобожденному 215 пространству, он столкнется с ошибкой адресации. +------------------------------------------------------------+ | алгоритм brk | | входная информация: новый адрес верхней границы области | | данных | | выходная информация: старый адрес верхней границы области | | данных | | { | | заблокировать область данных процесса; | | если (размер области увеличивается) | | если (новый размер области имеет недопустимое зна-| | чение) | | { | | снять блокировку с области; | | вернуть (ошибку); | | } | | изменить размер области (алгоритм growreg); | | обнулить содержимое присоединяемого пространства; | | снять блокировку с области данных; | | } | +------------------------------------------------------------+ Рисунок 7.26. Алгоритм выполнения функции brk На Рисунке 7.27 приведен пример программы, использующей функцию brk, и выходные данные, полученные в результате ее прогона на машине AT&T 3B20. Вызвав функцию signal и распорядившись принимать сигналы о нарушении сегмен- тации (segmentation violation), процесс обращается к подпрограмме sbrk и вы- водит на печать первоначальное значение адреса верхней границы области дан- ных. Затем в цикле, используя счетчик символов, процесс заполняет область данных до тех пор, пока не обратится к адресу, расположенному за пределами области, тем самым давая повод для сигнала о нарушении сегментации. Получив сигнал, функция обработки сигнала вызывает подпрограмму sbrk для того, чтобы присоединить к области дополнительно 256 байт памяти; процесс продолжается с точки прерывания, заполняя информацией вновь выделенное пространство памяти и т.д. На машинах со страничной организацией памяти, таких как 3B20, наблю- дается интересный феномен. Страница является наименьшей единицей памяти, с которой работают механизмы аппаратной защиты, поэтому аппаратные средства не в состоянии установить ошибку в граничной ситуации, когда процесс пытается записать информацию по адресам, превышающим верхнюю границу области данных, но принадлежащим т.н. "полулегальной" странице (странице, не полностью заня- той областью данных процесса). Это видно из результатов выполнения програм- мы, выведенных на печать (Рисунок 7.27): первый раз подпрограмма sbrk возв- ращает значение 140924, то есть адрес, не дотягивающий 388 байт до конца страницы, которая на машине 3B20 имеет размер 2 Кбайта. Однако процесс полу- чит ошибку только в том случае, если обратится к следующей странице памяти, то есть к любому адресу, начиная с 141312. Функция обработки сигнала прибав- ляет к адресу верхней границы области 256, делая его равным 141180 и, таким образом, оставляя его в пределах текущей страницы. Следовательно, процесс тут же снова получит ошибку, выдав на печать адрес 141312. Исполнив подпрог- рамму sbrk еще раз, ядро выделяет под данные процесса новую страницу памяти, так что процесс получает возможность адресовать дополнительно 2 Кбайта памя- ти, до адреса 143360, даже если верхняя граница области располагается ниже. Получив ошибку, процесс должен будет восемь раз обратиться к подпрограмме sbrk, прежде чем сможет продолжить выполнение основной программы. Таким об- разом, процесс может иногда выходить за официальную верхнюю границу области данных, хотя это и нежелательный момент в практике программирования. 216 Когда стек задачи переполняется, ядро автоматически увеличивает его раз- мер, выполняя алгоритм, похожий на алгоритм функции brk. Первоначально стек задачи имеет размер, достаточный для хранения параметров функции exec, одна- ко при выполнении процесса +-------------------------------------------------------+ | #include | | char *cp; | | int callno; | | | | main() | | { | | char *sbrk(); | | extern catcher(); | | | | signal(SIGSEGV,catcher); | | cp = sbrk(0); | | printf("original brk value %u\n",cp); | | for (;;) | | *cp++ = 1; | | } | | | | catcher(signo); | | int signo; | | { | | callno++; | | printf("caught sig %d %dth call at addr %u\n", | | signo,callno,cp); | | sbrk(256); | | signal(SIGSEGV,catcher); | | } | +-------------------------------------------------------+ +-------------------------------------------+ | original brk value 140924 | | caught sig 11 1th call at addr 141312 | | caught sig 11 2th call at addr 141312 | | caught sig 11 3th call at addr 143360 | | ...(тот же адрес печатается до 10-го | | вызова подпрограммы sbrk) | | caught sig 11 10th call at addr 143360 | | caught sig 11 11th call at addr 145408 | | ...(тот же адрес печатается до 18-го | | вызова подпрограммы sbrk) | | caught sig 11 18th call at addr 145408 | | caught sig 11 19th call at addr 145408 | | - | | - | +-------------------------------------------+ Рисунок 7.27. Пример программы, использующей функцию brk, и результаты ее контрольного прогона этот стек может переполниться. Переполнение стека приводит к ошибке адреса- ции, свидетельствующей о попытке процесса обратиться к ячейке памяти за пре- делами отведенного адресного пространства. Ядро устанавливает причину воз- никновения ошибки, сравнивая текущее значение указателя вершины стека с раз- мером области стека. При расширении области стека ядро использует точно та- кой же механизм, что и для области данных. На выходе из прерывания процесс 217 +------------------------------------------------------------+ | /* чтение командной строки до символа конца файла */ | | while (read(stdin,buffer,numchars)) | | { | | /* синтаксический разбор командной строки */ | | if (/* командная строка содержит & */) | | amper = 1; | | else | | amper = 0; | | /* для команд, не являющихся конструкциями командного | | языка shell */ | | if (fork() == 0) | | { | | /* переадресация ввода-вывода ? */ | | if (/* переадресация вывода */) | | { | | fd = creat(newfile,fmask); | | close(stdout); | | dup(fd); | | close(fd); | | /* stdout теперь переадресован */ | | } | | if (/* используются каналы */) | | { | | pipe(fildes); | | | +------------------------------------------------------------+ Рисунок 7.28. Основной цикл программы shell имеет область стека необходимого для продолжения работы размера.

    7.8 КОМАНДНЫЙ ПРОЦЕССОР SHELL

Теперь у нас есть достаточно материала, чтобы перейти к объяснению прин- ципов работы командного процессора shell. Сам командный процессор намного сложнее, чем то, что мы о нем здесь будем излагать, однако взаимодействие процессов мы уже можем рассмотреть на примере реальной программы. На Рисунке 7.28 приведен фрагмент основного цикла программы shell, демонстрирующий асинхронное выполнение процессов, переназначение вывода и использование ка- налов. Shell считывает командную строку из файла стандартного ввода и интерпре- тирует ее в соответствии с установленным набором правил. Дескрипторы файлов стандартного ввода и стандартного вывода, используемые регистрационным shell'ом, как правило, указывают на терминал, с которого пользователь регис- трируется в системе (см. главу 10). Если shell узнает во введенной строке конструкцию собственного командного языка (например, одну из команд cd, for, while и т.п.), он исполняет команду своими силами, не прибегая к созданию новых процессов; в противном случае команда интерпретируется как имя испол- няемого файла. Командные строки простейшего вида содержат имя программы и несколько па- раметров, например: who grep -n include *.c ls -l 218 +------------------------------------------------------------+ | if (fork() == 0) | | { | | /* первая компонента командной строки */| | close(stdout); | | dup(fildes[1]); | | close(fildes[1]); | | close(fildes[0]); | | /* стандартный вывод направляется в ка- | | нал */ | | /* команду исполняет порожденный про- | | цесс */ | | execlp(command1,command1,0); | | } | | /* вторая компонента командной строки */ | | close(stdin); | | dup(fildes[0]); | | close(fildes[0]); | | close(fildes[1]); | | /* стандартный ввод будет производиться из| | канала */ | | } | | execve(command2,command2,0); | | } | | /* с этого места продолжается выполнение родительского | | * процесса... | | * процесс-родитель ждет завершения выполнения потомка,| | * если это вытекает из введенной строки | | * / | | if (amper == 0) | | retid = wait(&status); | | } | +------------------------------------------------------------+ Рисунок 7.28. Основной цикл программы shell (продолжение) Shell "ветвится" (fork) и порождает новый процесс, который и запускает прог- рамму, указанную пользователем в командной строке. Родительский процесс (shell) дожидается завершения потомка и повторяет цикл считывания следующей команды. Если процесс запускается асинхронно (на фоне основной программы), как в следующем примере nroff -mm bigdocument & shell анализирует наличие символа амперсанд (&) и заносит результат проверки во внутреннюю переменную amper. В конце основного цикла shell обращается к этой переменной и, если обнаруживает в ней признак наличия символа, не вы- полняет функцию wait, а тут же повторяет цикл считывания следующей команды. Из рисунка видно, что процесс-потомок по завершении функции fork получа- ет доступ к командной строке, принятой shell'ом. Для того, чтобы переадресо- вать стандартный вывод в файл, как в следующем примере nroff -mm bigdocument > output процесс-потомок создает файл вывода с указанным в командной строке именем; 219 если файл не удается создать (например, не разрешен доступ к каталогу), про- цесс-потомок тут же завершается. В противном случае процесс-потомок закрыва- ет старый файл стандартного вывода и переназначает с помощью функции dup дескриптор этого файла новому файлу. Старый дескриптор созданного файла зак- рывается и сохраняется для запускаемой программы. Подобным же образом shell переназначает и стандартный ввод и стандартный вывод ошибок. +-----------+ | Shell | +-----+-----+ wait | ^ | | +-----+-----+ exit | wc | +-----+-----+ read | ^ | | +-----+-----+ write | ls - l | +-----------+ Рисунок 7.29. Взаимосвязь между процессами, исполняющими ко- мандную строку ls -l|wc Из приведенного текста программы видно, как shell обрабатывает командную строку, используя один канал. Допустим, что командная строка имеет вид: ls -l|wc После создания родительским процессом нового процесса процесс-потомок созда- ет канал. Затем процесс-потомок создает свое ответвление; он и его потомок обрабатывают по одной компоненте командной строки. "Внучатый" процесс испол- няет первую компоненту строки (ls): он собирается вести запись в канал, поэ- тому он закрывает старый файл стандартного вывода, передает его дескриптор каналу и закрывает старый дескриптор записи в канал, в котором (в дескрипто- ре) уже нет необходимости. Родитель (wc) "внучатого" процесса (ls) является потомком основного процесса, реализующего программу shell'а (см. Рисунок 7.29). Этот процесс (wc) закрывает свой файл стандартного ввода и передает его дескриптор каналу, в результате чего канал становится файлом стандартно- го ввода. Затем закрывается старый и уже не нужный дескриптор чтения из ка- нала и исполняется вторая компонента командной строки. Оба порожденных про- цесса выполняются асинхронно, причем выход одного процесса поступает на вход другого. Тем временем основной процесс дожидается завершения своего потомка (wc), после чего продолжает свою обычную работу: по завершении процесса, вы- полняющего команду wc, вся командная строка является обработанной. Shell возвращается в цикл и считывает следующую командную строку.

    7.9 ЗАГРУЗКА СИСТЕМЫ И НАЧАЛЬНЫЙ ПРОЦЕСС

Для того, чтобы перевести систему из неактивное состояние в активное, администратор выполняет процедуру "начальной загрузки". На разных машинах эта процедура имеет свои особенности, однако во всех случаях она реализует одну и ту же цель: загрузить копию операционной системы в основную память машины и запустить ее на исполнение. Обычно процедура начальной загрузки включает в себя несколько этапов. Переключением клавиш на пульте машины ад- министратор может указать адрес специальной программы аппаратной загрузки, а может, нажав только одну клавишу, дать команду машине запустить процедуру загрузки, исполненную в виде микропрограммы. Эта программа может состоять из нескольких команд, подготавливающих запуск другой программы. В системе UNIX 220 процедура начальной загрузки заканчивается считыванием с диска в память бло- ка начальной загрузки (нулевого блока). Программа, содержащаяся в этом бло- ке, загружает из файловой системы ядро ОС (например, из файла с именем "/unix" или с другим именем, указанным администратором). После загрузки ядра системы в память управление передается по стартовому адресу ядра и ядро за- пускается на выполнение (алгоритм start, Рисунок 7.30). Ядро инициализирует свои внутренние структуры данных. Среди прочих структур ядро создает связные списки свободных буферов и индексов, хеш-оче- реди для буферов и индексов, инициализирует структуры областей, точки входа в таблицы страниц и т.д. По окончании этой фазы ядро монтирует корневую фай- ловую систему и формирует среду выполнения нулевого процесса, среди всего прочего создавая пространство процесса, инициализируя нулевую точку входа в таблице процесса и делая корневой каталог текущим для процесса. Когда формирование среды выполнения процесса заканчивается, система ис- полняется уже в виде нулевого процесса. Нулевой процесс "ветвится", запуская алгоритм fork прямо из ядра, поскольку сам процесс исполняется в режиме яд- +------------------------------------------------------------+ | алгоритм start /* процедура начальной загрузки системы */| | входная информация: отсутствует | | выходная информация: отсутствует | | { | | проинициализировать все структуры данных ядра; | | псевдо-монтирование корня; | | сформировать среду выполнения процесса 0; | | создать процесс 1; | | { | | /* процесс 1 */ | | выделить область; | | подключить область к адресному пространству процесса| | init; | | увеличить размер области для копирования в нее ис- | | полняемого кода; | | скопировать из пространства ядра в адресное прост- | | ранство процесса код программы, исполняемой процес-| | сом; | | изменить режим выполнения: вернуться из режима ядра | | в режим задачи; | | /* процесс init далее выполняется самостоятельно -- | | * в результате выхода в режим задачи, | | * init исполняет файл "/etc/init" и становится | | * "обычным" пользовательским процессом, производя- | | * щим обращения к системным функциям | | */ | | } | | /* продолжение нулевого процесса */ | | породить процессы ядра; | | /* нулевой процесс запускает программу подкачки, управ- | | * ляющую распределением адресного пространства процес- | | * сов между основной памятью и устройствами выгрузки. | | * Это бесконечный цикл; нулевой процесс обычно приоста-| | * навливает свою работу, если необходимости в нем боль-| | * ше нет. | | */ | | исполнить программу, реализующую алгоритм подкачки; | | } | +------------------------------------------------------------+ Рисунок 7.30. Алгоритм загрузки системы 221 ра. Порожденный нулевым новый процесс, процесс 1, запускается в том же режи- ме и создает свой пользовательский контекст, формируя область данных и при- соединяя ее к своему адресному пространству. Он увеличивает размер области до надлежащей величины и переписывает программу загрузки из адресного прост- ранства ядра в новую область: эта программа теперь будет определять контекст процесса 1. Затем процесс 1 сохраняет регистровый контекст задачи, "возвра- щается" из режима ядра в режим задачи и исполняет только что переписанную программу. В отличие от нулевого процесса, который является процессом сис- темного уровня, выполняющимся в режиме ядра, процесс 1 относится к пользова- тельскому уровню. Код, исполняемый процессом 1, включает в себя вызов сис- темной функции exec, запускающей на выполнение программу из файла "/etc/init". Обычно процесс 1 именуется процессом init, поскольку он отвеча- ет за инициализацию новых процессов. Казалось бы, зачем ядру копировать программу, запускаемую с помощью фун- кции exec, в адресное пространство процесса 1 ? Он мог бы обратиться к внут- реннему варианту функции прямо из ядра, одна- ко, по сравнению с уже описанным алгоритмом это было бы гораздо труднее реа- лизовать, ибо в этом случае функции exec пришлось бы производить анализ имен файлов в пространстве ядра, а не в пространстве задачи. Подобная деталь, требующаяся только для процесса init, усложнила бы программу реализации фун- кции exec и отрицательно отразилась бы на скорости выполнения функции в бо- лее общих случаях. Процесс init (Рисунок 7.31) выступает диспетчером процессов, который по- рождает процессы, среди всего прочего позволяющие пользователю регистриро- ваться в системе. Инструкции о том, какие процессы нужно создать, считывают- ся процессом init из файла "/etc/inittab". Строки файла включают в себя идентификатор состояния "id" (однопользовательский режим, многопользователь- ский и т. д.), предпринимаемое действие (см. упражнение 7.43) и спецификацию программы, реализующей это действие (см. Рисунок 7.32). Процесс init прос- матривает строки файла до тех пор, пока не обнаружит идентификатор состоя- ния, соответствующего тому состоянию, в котором находится процесс, и создает процесс, исполняющий программу с указанной спецификацией. Например, при за- пуске в многопользовательском режиме (состояние 2) процесс init обычно по- рождает getty-процессы, управляющие функционированием терминальных линий, входящих в состав системы. Если регистрация пользователя прошла успешно, getty-процесс, пройдя через процедуру login, запускает на исполнение регист- рационный shell (см. главу 10). Тем временем процесс init находится в состо- янии ожидания (wait), наблюдая за прекращением существования своих потомков, а также "внучатых" процессов, оставшихся "сиротами" после гибели своих роди- телей. Процессы в системе UNIX могут быть либо пользовательскими, либо управля- ющими, либо системными. Большинство из них составляют пользовательские про- цессы, связанные с пользователями через терминалы. Управляющие процессы не связаны с конкретными пользователями, они выполняют широкий спектр системных функций, таких как администрирование и управление сетями, различные периоди- ческие операции, буферизация данных для вывода на устройство построчной пе- чати и т.д. Процесс init может порождать управляющие процессы, которые будут существовать на протяжении всего времени жизни системы, в различных случаях они могут быть созданы самими пользователями. Они похожи на пользовательские процессы тем, что они исполняются в режиме задачи и прибегают к услугам сис- темы путем вызова соответствующих системных функций. Системные процессы выполняются исключительно в режиме ядра. Они могут порождаться нулевым процессом (например, процесс замещения страниц vhand), который затем становится процессом подкачки. Системные процессы похожи на управляющие процессы тем, что они выполняют системные функции, при этом они обладают большими возможностями приоритетного выполнения, поскольку лежащие в их основе программные коды являются составной частью ядра. Они могут обра- щаться к структурам данных и алгоритмам ядра, не прибегая к вызову системных функций, отсюда вытекает их исключительность. Однако, они не обладают такой 222 +------------------------------------------------------------+ | алгоритм init /* процесс init, в системе именуемый | | "процесс 1" */ | | входная информация: отсутствует | | выходная информация: отсутствует | | { | | fd = open("/etc/inittab",O_RDONLY); | | while (line_read(fd,buffer)) | | { | | /* читать каждую строку файлу */ | | if (invoked state != buffer state) | | continue; /* остаться в цикле while */ | | /* найден идентификатор соответствующего состояния | | */ | | if (fork() == 0) | | { | | execl("процесс указан в буфере"); | | exit(); | | } | | /* процесс init не дожидается завершения потомка */ | | /* возврат в цикл while */ | | } | | | | while ((id = wait((int*) 0)) != -1) | | { | | /* проверка существования потомка; | | * если потомок прекратил существование, рассматри- | | * вается возможность его перезапуска */ | | /* в противном случае, основной процесс просто про- | | * должает работу */ | | } | | } | +------------------------------------------------------------+ Рисунок 7.31. Алгоритм выполнения процесса init +------------------------------------------------------------+ | Формат: идентификатор, состояние, действие, спецификация | | процесса | | Поля разделены между собой двоеточиями | | Комментарии в конце строки начинаются с символа '#' | | | | co::respawn:/etc/getty console console #Консоль в машзале| | 46:2:respawn:/etc/getty -t 60 tty46 4800H #комментарии | +------------------------------------------------------------+ Рисунок 7.32. Фрагмент файла inittab же гибкостью, как управляющие процессы, поскольку для того, чтобы внести из- менения в их программы, придется еще раз перекомпилировать ядро.

    7.10 ВЫВОДЫ

В данной главе были рассмотрены системные функции, предназначенные для работы с контекстом процесса и для управления выполнением процесса. Систем- ная функция fork создает новый процесс, копируя для него содержимое всех об- ластей, подключенных к родительскому процессу. Особенность реализации функ- ции fork состоит в том, что она выполняет инициализацию сохраненного регист- 223 рового контекста порожденного процесса, таким образом этот процесс начинает выполняться, не дожидаясь завершения функции, и уже в теле функции начинает осознавать свою предназначение как потомка. Все процессы завершают свое вы- полнение вызовом функции exit, которая отсоединяет области процесса и посы- лает его родителю сигнал "гибель потомка". Процесс-родитель может совместить момент продолжения своего выполнения с моментом завершения процесса-потомка, используя системную функцию wait. Системная функция exec дает процессу воз- можность запускать на выполнение другие программы, накладывая содержимое ис- полняемого файла на свое адресное пространство. Ядро отсоединяет области, ранее занимаемые процессом, и назначает процессу новые области в соответст- вии с потребностями исполняемого файла. Совместное использование областей команд и наличие режима "sticky-bit" дают возможность более рационально ис- пользовать память и экономить время, затрачиваемое на подготовку к запуску программ. Простым пользователям предоставляется возможность получать приви- легии других пользователей, даже суперпользователя, благодаря обращению к услугам системной функции setuid и setuid-программ. С помощью функции brk процесс может изменять размер своей области данных. Функция signal дает про- цессам возможность управлять своей реакцией на поступающие сигналы. При по- лучении сигнала производится обращение к специальной функции обработки сиг- нала с внесением соответствующих изменений в стек задачи и в сохраненный ре- гистровый контекст задачи. Процессы могут сами посылать сигналы, используя системную функцию kill, они могут также контролировать получение сигналов, предназначенных группе процессов, прибегая к услугам функции setpgrp. Командный процессор shell и процесс начальной загрузки init используют стандартные обращения к системным функциям, производя набор операций, в дру- гих системах обычно выполняемых ядром. Shell интерпретирует команды пользо- вателя, переназначает стандартные файлы ввода-вывода данных и выдачи ошибок, порождает процессы, организует каналы между порожденными процессами, синхро- низирует свое выполнение с этими процессами и формирует коды, возвращаемые командами. Процесс init тоже порождает различные процессы, в частности, уп- равляющие работой пользователя за терминалом. Когда такой процесс завершает- ся, init может породить для выполнения той же самой функции еще один про- цесс, если это вытекает из информации файла "/etc/inittab".

    7.11 УПРАЖНЕНИЯ

1. Запустите с терминала программу, приведенную на Рисунке 7.33. Переадре- суйте стандартный вывод данных в файл и сравните результаты между со- бой. +------------------------------------+ | main() | | { | | printf("hello\n"); | | if (fork() == 0) | | printf("world\n"); | | } | +------------------------------------+ Рисунок 7.33. Пример модуля, содержащего вызов функции fork и обра- щение к стандартному выводу 2. Разберитесь в механизме работы программы, приведенной на Рисунке 7.34, и сравните ее результаты с результатами программы на Рисунке 7.4. 3. Еще раз обратимся к программе, приведенной на Рисунке 7.5 и показываю- щей, как два процесса обмениваются сообщениями, используя спаренные ка- налы. Что произойдет, если они попытаются вести обмен сообщениями, ис- пользуя один канал ? 4. Возможна ли потеря информации в случае, когда процесс получает несколь- 224 ко сигналов прежде чем ему предоставляется возможность отреагировать на них надлежащим образом ? (Рассмотрите случай, когда процесс подсчитыва- ет количество полученных сигналов о прерывании.) Есть ли необходимость в решении этой проблемы ? 5. Опишите механизм работы системной функции kill. 6. Процесс в программе на Рисунке 7.35 принимает сигналы типа "гибель по- томка" и устанавливает функцию обработки сигналов в исходное состояние. Что происходит при выполнении программы ? 7. Когда процесс получает сигналы определенного типа и не обрабатывает их, ядро дампирует образ процесса в том виде, который был у него в момент получения сигнала. Ядро создает в текущем каталоге процесса файл с име- нем "core" и копирует в него пространство процесса, области команд, данных и стека. Впоследствии пользователь может тщательно изучить дамп образа процесса с помощью стандартных средств отладки. Опишите алго- ритм, которому на Ваш взгляд должно следовать ядро в процессе создания файла "core". Что нужно предпринять в том случае, если в текущем ката- логе файл с таким именем уже существует ? Как должно вести себя ядро, когда в одном и том же каталоге дампируют свои образы сразу несколько процессов? 8. Еще раз обратимся к программе (Рисунок 7.12), описывающей, как один процесс забрасывает другой процесс сигналами, которые принимаются их адресатом. Подумайте, что произошло бы в том случае, если бы алгоритм обработки сигналов был переработан в любом из следующих направлений: +------------------------------------------------------------+ | #include | | int fdrd,fdwt; | | char c; | | | | main(argc,argv) | | int argc; | | char *argv[]; | | { | | if (argc != 3) | | exit(1); | | fork(); | | | | if ((fdrd = open(argv[1],O_RDONLY)) == -1) | | exit(1); | | if (((fdwt = creat(argv[2],0666)) == -1) && | | ((fdwt = open(argv[2],O_WRONLY)) == -1)) | | exit(1); | | rdwrt(); | | } | | rdwrt() | | { | | for (;;) | | { | | if (read(fdrd,&c,1) != 1) | | return; | | write(fdwt,&c,1); | | } | | } | +------------------------------------------------------------+ Рисунок 7.34. Пример программы, в которой процесс-родитель и процесс-потомок не разделяют доступ к файлу * ядро не заменяет функцию обработки сигналов до тех пор, пока пользо- ватель явно не потребует этого; 225 * ядро заставляет процесс игнорировать сигналы до тех пор, пока пользо- ватель не обратится к функции signal вновь. 9. Переработайте алгоритм обработки сигналов так, чтобы ядро автоматически перенастраивало процесс на игнорирование всех последующих поступлений сигналов по возвращении из функции, обрабатывающей их. Каким образом ядро может узнать о завершении функции обработки сигналов, выполняющей- ся в режиме задачи ? Такого рода перенастройка приблизила бы нас к трактовке сигналов в системе BSD. *10. Если процесс получает сигнал, находясь в состоянии приостанова во время выполнения системной функции с допускающим прерывания приоритетом, он выходит из функции по алгоритму longjump. Ядро производит необходимые установки для запуска функции обработки сигнала; когда процесс выйдет из функции обработки сигнала, в версии V это будет выглядеть так, слов- но он вернулся из системной функции с признаком ошибки (как бы прервав свое выполнение). В системе BSD системная функция в этом случае автома- тически перезапускается. Каким образом можно реализовать этот момент в нашей системе? +------------------------------------------------------------+ | #include | | main() | | { | | extern catcher(); | | | | signal(SIGCLD,catcher); | | if (fork() == 0) | | exit(); | | /* пауза до момента получения сигнала */ | | pause(); | | } | | | | catcher() | | { | | printf("процесс-родитель получил сигнал\n"); | | signal(SIGCLD,catcher); | | } | +------------------------------------------------------------+ Рисунок 7.35. Программа, в которой процесс принимает сигналы типа "гибель потомка" 11. В традиционной реализации команды mkdir для создания новой вершины в дереве каталогов используется системная функция mknod, после чего дваж- ды вызывается системная функция link, привязывающая точки входа в ката- лог с именами "." и ".." к новой вершине и к ее родительскому каталогу. Без этих трех операций каталог не будет иметь надлежащий формат. Что произойдет, если во время исполнения команды mkdir процесс получит сиг- нал ? Что если при этом будет получен сигнал SIGKILL, который процесс не распознает ? Эту же проблему рассмотрите применительно к реализации системной функции mkdir. 12. Процесс проверяет наличие сигналов в моменты перехода в состояние при- останова и выхода из него (если в состоянии приостанова процесс нахо- дился с приоритетом, допускающим прерывания), а также в момент перехода в режим задачи из режима ядра по завершении исполнения системной функ- ции или после обработки прерывания. Почему процесс не проверяет наличие сигналов в момент обращения к системной функции ? *13. Предположим, что после исполнения системной функции процесс готовится к возвращению в режим задачи и не обнаруживает ни одного необработанного сигнала. Сразу после этого ядро обрабатывает прерывание и посылает про- 226 цессу сигнал. (Например, пользователем была нажата клавиша "break".) Что делает процесс после того, как ядро завершает обработку прерывания? *14. Если процессу одновременно посылается несколько сигналов, ядро обраба- тывает их в том порядке, в каком они перечислены в описании. Существуют три способа реагирования на получение сигнала - прием сигналов, завер- шение выполнения со сбросом на внешний носитель (дампированием) образа процесса в памяти и завершение выполнения без дампирования. Можно ли указать наилучший порядок обработки одновременно поступающих сигналов ? Например, если процесс получает сигнал о выходе (вызывающий дампирова- ние образа процесса в памяти) и сигнал о прерывании (выход без дампиро- вания), то какой из этих сигналов имело бы смысл обработать первым ? 15. Запомните новую системную функцию newpgrp(pid,ngrp); которая включает процесс с идентификатором pid в группу процессов с но- мером ngrp (устанавливает для процесса новую группу). Подумайте, для каких целей она может использоваться и какие опасности таит в себе ее вызов. 16. Прокомментируйте следующее утверждение: по алгоритму wait процесс может приостановиться до наступления какого-либо события и это не отразилось бы на работе всей системы. 17. Рассмотрим новую системную функцию nowait(pid); где pid - идентификатор процесса, являющегося потомком того процесса, который вызывает функцию. Вызывая функцию, процесс тем самым сообщает ядру о том, что он не собирается дожидаться завершения выполнения свое- го потомка, поэтому ядро может по окончании существования потомка сразу же очистить занимаемое им место в таблице процессов. Каким образом это реализуется на практике ? Оцените достоинства новой функции и сравните ее использование с использованием сигналов типа "гибель потомка". 18. Загрузчик модулей на Си автоматически подключает к основному модулю на- чальную процедуру (startup), которая вызывает функцию main, принадлежа- щую программе пользователя. Если в пользовательской программе отсутст- вует вызов функции exit, процедура startup сама вызывает эту функцию при выходе из функции main. Что произошло бы в том случае, если бы и в процедуре startup отсутствовал вызов функции exit (из-за ошибки загруз- чика) ? 19. Какую информацию получит процесс, выполняющий функцию wait, если его потомок запустит функцию exit без параметра ? Имеется в виду, что про- цесс-потомок вызовет функцию в формате exit() вместо exit(n). Если программист постоянно использует вызов функции exit без параметра, то насколько предсказуемо значение, ожидаемое функцией wait ? Докажите свой ответ. 20. Объясните, что произойдет, если процесс, исполняющий программу на Ри- сунке 7.36 запустит с помощью функции exec самого себя. Как в таком случае ядро сможет избежать возникновения тупиковых ситуаций, связанных с блокировкой индексов ? +----------------------------------+ | main(argc,argv) | | int argc; | | char *argv[]; | | { | | execl(argv[0],argv[0],0); | | } | +----------------------------------+ Рисунок 7.36 21. По условию первым аргументом функции exec является имя (последняя ком- понента имени пути поиска) исполняемого процессом файла. Что произойдет в результате выполнения программы, приведенной на Рисунке 7.37 ? Каков будет эффект, если в качестве файла "a.out" выступит загрузочный мо- 227 дуль, полученный в результате трансляции программы, приведенной на Ри- сунке 7.36 ? 22. Предположим, что в языке Си поддерживается новый тип данных "read-only" (только для чтения), причем процесс, пытающийся записать информацию в поле с этим типом, получает отказ системы защиты. Опишите реализацию этого момента. (Намек: сравните это понятие с понятием "разделяемая об- ласть команд".) В какие из алгоритмов ядра потребуется внести изменения ? Какие еще объекты могут быть реализованы аналогичным с областью обра- зом ? 23. Какие изменения имеют место в алгоритмах open, chmod, unlink и unmount при работе с файлами, для которых установлен режим "sticky-bit" ? Какие действия, например, следует предпринять в отношении такого файла ядру, когда с файлом разрывается связь ? 24. Суперпользователь является единственным пользователем, имеющим право на запись в файл паролей "/etc/passwd", благодаря чему содержимое файла предохраняется от умышленной или случайной порчи. Программа passwd дает пользователям возможность изменять свой собственный пароль, защищая от изменений чужие записи. Каким образом она работает ? +-----------------------------------------------------+ | main() | | { | | if (fork() == 0) | | { | | execl("a.out",0); | | printf("неудачное завершение функции exec\n");| | } | | } | +-----------------------------------------------------+ Рисунок 7.37 *25. Поясните, какая угроза безопасности хранения данных возникает, если setuid-программа не защищена от записи. 26. Выполните следующую последовательность команд, в которой "a. out" - имя исполняемого файла: +-----------------------------------------------------+ | main() | | { | | char *endpt; | | char *sbrk(); | | int brk(); | | | | endpt = sbrk(0); | | printf("endpt = %ud после sbrk\n", (int) endpt); | | | | while (endpt--) | | { | | if (brk(endpt) == -1) | | { | | printf("brk с параметром %ud завершилась | | неудачно\n",endpt); | | exit(); | | } | | } | | } | +-----------------------------------------------------+ Рисунок 7.38 228 chmod 4777 a.out chown root a.out Команда chmod "включает" бит setuid (4 в 4777); пользователь "root" традиционно является суперпользователем. Может ли в результате выполне- ния этой последовательности произойти нарушение защиты информации ? 27. Что произойдет в процессе выполнения программы, представленной на Ри- сунке 7.38 ? Поясните свой ответ. 28. Библиотечная подпрограмма malloc увеличивает область данных процесса с помощью функции brk, а подпрограмма free освобождает память, выделенную подпрограммой malloc. Синтаксис вызова подпрограмм: ptr = malloc(size); free(ptr); где size - целое число без знака, обозначающее количество выделяемых байт памяти, а ptr - символьная ссылка на вновь выделенное пространст- во. Прежде чем появиться в качестве параметра в вызове подпрограммы free, указатель ptr должен быть возвращен подпрограммой malloc. Выпол- ните эти подпрограммы. 29. Что произойдет в процессе выполнения программы, представленной на Ри- сунке 7.39 ? Сравните результаты выполнения этой программы с результа- тами, предусмотренными в системном описании. +-----------------------------------------------------+ | main() | | { | | int i; | | char *cp; | | extern char *sbrk(); | | | | cp = sbrk(10); | | for (i = 0; i < 10; i++) | | *cp++ = 'a' + i; | | sbrk(-10); | | cp = sbrk(10); | | for (i = 0; i < 10; i++) | | printf("char %d = '%c'\n",i,*cp++); | | } | +-----------------------------------------------------+ Рисунок 7.39. Пример программы, использующей подпрограмму sbrk 30. Каким образом командный процессор shell узнает о том, что файл исполня- емый, когда для выполнения команды создает новый процесс ? Если файл исполняемый, то как узнать, создан ли он в результате трансляции исход- ной программы или же представляет собой набор команд языка shell ? В каком порядке следует выполнять проверку указанных условий ? 31. В командном языке shell символы ">>" используются для направления выво- да данных в файл с указанной спецификацией, например, команда: run >> outfile открывает файл с именем "outfile" (а в случае отсутствия файла с таким именем создает его) и записывает в него данные. Напишите прог- рамму, в которой используется эта команда. 32. Процессор командного языка shell проверяет код, возвращаемый функцией exit, воспринимая нулевое значение как "истину", а любое другое значе- ние как "ложь" (обратите внимание на несогласованность с языком Си). Предположим, что файл, исполняющий программу на Рисунке 7.40, имеет имя "truth". Поясните, что произойдет, когда shell будет исполнять следую- щий набор команд: while truth 229 +------------------+ | main() | | { | | exit(0); | | } | +------------------+ Рисунок 7.40 do truth & done 33. Вопрос по Рисунку 7.29: В связи с чем возникает необходимость в созда- нии процессов для конвейерной обработки двухкомпонентной команды в ука- занном порядке ? 34. Напишите более общую программу работы основного цикла процессора shell в части обработки каналов. Имеется в виду, что программа должна уметь обрабатывать случайное число каналов, указанных в командной строке. 35. Переменная среды PATH описывает порядок, в котором shell'у следует просматривать каталоги в поисках исполняемых файлов. В библиотечных функциях execlp и execvp перечисленные в PATH каталоги присоединяются к именам файлов, кроме тех, которые начинаются с символа "/". Выполните эти функции. *36. Для того, чтобы shell в поисках исполняемых файлов не обращался к теку- щему каталогу, суперпользователь должен задать переменную среды PATH. Какая угроза безопасности хранения данных может возникнуть, если shell попытается исполнить файлы из текущего каталога ? 37. Каким образом shell обрабатывает команду cd (создать каталог) ? Какие действия предпринимает shell в процессе обработки следующей командной строки: cd pathname & ? 38. Когда пользователь нажимает на клавиатуре терминала клавиши "delete" или "break", всем процессам, входящим в группу регистрационного shell'а, терминальный драйвер посылает сигнал о прерывании. Пользова- тель может иметь намерение остановить все процессы, порожденные shell'ом, без выхода из системы. Какие усовершенствования в связи с этим следует произвести в теле основного цикла программы shell (Рисунок 7.28) ? 39. С помощью команды nohup command_line пользователь может отменить действие сигналов о "зависании" и о завер- шении (quit) в отношении процессов, реализующих командную строку (command_line). Как эта команда будет обрабатываться в основном цикле программы shell ? 40. Рассмотрим набор команд языка shell: nroff -mm bigfile1 > big1out & nroff -mm bigfile2 > big2out и вновь обратимся к основному циклу программы shell (Рисунок 7.28). Что произойдет, если выполнение первой команды nroff завершится раньше вто- рой ? Какие изменения следует внести в основной цикл программы shell на этот случай ? 41. Часто во время выполнения из shell'а непротестированных программ появ- ляется сообщение об ошибке следующего вида: "Bus error - core dumped" (Ошибка в магистрали - содержимое памяти сброшено на внешний носитель). Очевидно, что в программе выполняются какие-то недопустимые действия; откуда shell узнает о том, что ему нужно вывести сообщение об ошибке ? 42. Процессом 1 в системе может выступать только процесс init. Тем не ме- нее, запустив процесс init, администратор системы может тем самым изме- нить состояние системы. Например, при загрузке система может войти в однопользовательский режим, означающий, что в системе активен только консольный терминал. Для того, чтобы перевести процесс init в состояние 230 2 (многопользовательский режим), администратор системы вводит с консоли команду init 2 . Консольный shell порождает свое ответвление и запускает init. Что имело бы место в системе в том случае, если бы активен был только один про- цесс init ? 43. Формат записей в файле "/etc/inittab" допускает задание действия, свя- занного с каждым порождаемым процессом. Например, с getty-процессом связано действие "respawn" (возрождение), означающее, что процесс init должен возрождать getty-процесс, если последний прекращает существова- ние. На практике, когда пользователь выходит из системы процесс init порождает новый getty-процесс, чтобы другой пользователь мог получить доступ к временно бездействующей терминальной линии. Каким образом это делает процесс init ? 44. Некоторые из алгоритмов ядра прибегают к просмотру таблицы процессов. Время поиска данных можно сократить, если использовать указатели на: родителя процесса, любого из потомков, другой процесс, имеющий того же родителя. Процесс обнаруживает всех своих потомков, следуя сначала за указателем на любого из потомков, а затем используя указатели на другие процессы, имеющие того же родителя (циклы недопустимы). Какие из алго- ритмов выиграют от этого ? Какие из алгоритмов нужно оставить без изме- 231
Last-modified: Thu, 12-Feb-98 07:19:53 GMT