Операционная система UNIX

Робачевский Андрей М.

Глава 2

Среда программирования UNIX

 

 

Одной из целей, которые изначально ставились перед разработчиками UNIX, являлось создание удобной среды программирования. Во многом это справедливо и сегодня.

Разговор в данной главе пойдет о программировании в UNIX. Может показаться, что предлагаемый материал интересен лишь разработчикам программного обеспечения. Это не совсем так. Безусловно, разработка программ невозможна без знания интерфейса системных вызовов и без понимания внутренних структур и функций, предоставляемых операционной системой. Однако осмысленное администрирование системы также затруднительно без представления о том, как работает UNIX. Программный интерфейс UNIX позволяет наглядно показать внутренние механизмы этой операционной системы.

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

Следующие два раздела посвящены подробному обсуждению программного интерфейса двух важнейших подсистем операционной системы UNIX: файловой подсистемы и подсистемы управления процессами и памятью. В них рассматриваются важнейшие системные вызовы работы с файлами, функции стандартной библиотеки ввода/вывода, системные вызовы создания процесса, запуска новой программы и управления процессами.

В заключение приводятся два типичных приложения: демон и командный интерпретатор, на примере которых проиллюстрированы темы, затронутые в данной главе.

 

Программный интерфейс UNIX

 

Системные вызовы и функции стандартных библиотек

Все версии UNIX предоставляют строго определенный ограниченный набор входов в ядро операционной системы, через которые прикладные задачи имеют возможность воспользоваться базовыми услугами, предоставляемыми UNIX. Эти точки входа получили название системных вызовов (system calls). Системный вызов, таким образом, определяет функцию, выполняемую ядром операционной системы от имени процесса, выполнившего вызов, и является интерфейсом самого низкого уровня взаимодействия прикладных процессов с ядром. Седьмая редакция UNIX включала около 50 системных вызовов, современные версии, например, SVR4, предлагают более 120.

Системные вызовы обычно документированы в разделе 2 электронного справочника. В среде программирования UNIX они определяются как функции С, независимо от фактической реализации вызова функции ядра операционной системы. В UNIX используется подход, при котором каждый системный вызов имеет соответствующую функцию (или функции) с тем же именем, хранящуюся в стандартной библиотеке языка С (в дальнейшем эти функции будем для простоты называть системными вызовами). Функции библиотеки выполняют необходимое преобразование аргументов и вызывают требуемую процедуру ядра, используя различные приемы. Заметим, что в этом случае библиотечный код выполняет только роль оболочки, в то время как фактические инструкции расположены в ядре операционной системы.

Помимо системных вызовов программисту предлагается большой набор функций общего назначения. Эти функции не являются точками входа в ядро операционной системы, хотя в процессе выполнения многие из них выполняют системные вызовы. Например, функция printf(3S) использует системный вызов write(2) для записи данных в файл, в то время как функции strcpy(3C) (копирование строки) или atoi(3C) (преобразование символа в его числовое значение) вообще не прибегают к услугам операционной системы. Функции, о которых идет речь, хранятся в стандартных библиотеках С и наряду с системными вызовами составляют основу среды программирования в UNIX. Подробное описание этих функций приведено в разделе 3 электронного справочника.

Таким образом, часть библиотечных функций является "надстройкой" над системными вызовами, обеспечивающей более удобный способ получения системных услуг. В качестве примера рассмотрим процесс получения текущей даты и времени. Соответствующий системный вызов time(2) возвращает время в секундах, прошедшее с момента Epoch: 1 января 1970 года. Дополнительная интерпретация этого значения, такая как преобразование в вид, удобный для восприятия (дата и время) с учетом временной зоны, осуществляется библиотечными функциями (ctime(3C), localtime(3C) и т.д.). К этим функциям можно отнести функции библиотеки ввода/вывода, функции распределения памяти, часть функций управления процессами и т.д.

На рис. 2.1 показана схема взаимодействия приложения с ядром операционной системы при использовании системных вызовов и библиотечных функций.

Рис. 2.1. Системные вызовы и библиотечные функции

 

Обработка ошибок

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

Обычно в случае возникновения ошибки системные вызовы возвращают и устанавливают значение переменной errno, указывающее причину возникновения ошибки. Так, например, существует более десятка причин завершения вызова open(2) с ошибкой, и все они могут быть определены с помощью переменной errno. Файл заголовков содержит коды ошибок, значения которых может принимать переменная errno, с краткими комментариями.

Библиотечные функции, как правило, не устанавливают значение переменной errno, а код возврата различен для разных функций. Для уточнения возвращаемого значения библиотечной функции необходимо обратиться к электронному справочнику man(1).

Поскольку базовым способом получения услуг ядра являются системные вызовы, рассмотрим более подробно обработку ошибок в этом случае.

Переменная errno определена следующим образом:

external int errno;

Следует обратить внимание, что значение errno не обнуляется следующим нормально завершившимся системным вызовом. Таким образом, значение errno имеет смысл только после системного вызова, который завершился с ошибкой.

Стандарт ANSI С определяет две функции, помогающие сообщить причину ошибочной ситуации: strerror(3C) и perror(3C).

Функция strerror(3C) имеет вид:

#include

char *strerror(int errnum);

Функция принимает в качестве аргумента errnum номер ошибки и возвращает указатель на строку, содержащую сообщение о причине ошибочной ситуации.

Функция perror(3C) объявлена следующим образом:

#include

#include

void perror(const char *s);

Функция выводит в стандартный поток сообщений об ошибках информацию об ошибочной ситуации, основываясь на значении переменной errno. Строка s, передаваемая функции, предваряет это сообщение и может служить дополнительной информацией, например содержа название функции или программы, в которой произошла ошибка.

Следующий пример иллюстрирует использование этих двух функций:

#include

#include

main(int argc, char *argv[]) {

 fprintf(stderr, "ENOMEM: %s\n", strerror(ENOMEM));

 errno = ENOEXEC;

 perror(argv[0]);

}

Запустив программу, мы получим следующий результат на экране:

$ a.out

ENOMEM: Not enough space

a.out: Exec format error

Эти функции используются, в частности, командным интерпретатором и большинством стандартных утилит UNIX. Например:

$ rm does_not_exist

does_not_exist: No such file or directory ошибка ENOENT

$ pg do_not_read

do_not_read: Permission denied            ошибка EACCESS

$

В табл. 2.1 приведены наиболее общие ошибки системных вызовов, включая сообщения, которые обычно выводят функции strerror(3C) и perror(3C), а также их краткое описание.

Таблица 2.1. Некоторые ошибки системных вызовов

Код ошибки и сообщение Описание
E2BIG Arg list too long Размер списка аргументов, переданных системному вызову exec(2), плюс размер экспортируемых переменных окружения превышает ARG_MAX байт
EACCESS Permission denied Попытка доступа к файлу с недостаточными правами для данного класса (определяемого эффективным UID и GID процесса и соответствующими идентификаторами файла)
EAGAIN Resource temporarily unavailable Превышен предел использования некоторого ресурса, например, переполнена таблица процессов или пользователь превысил ограничение по количеству процессов с одинаковым UID. Причиной также может являться недостаток памяти или превышение соответствующего ограничения (см. раздел "Ограничения" далее в этой главе)
EALREADY Operation already in progress Попытка операции с неблокируемым объектом, уже обслуживающим некоторую операцию
EBADF Bad file number Попытка операции с файловым дескриптором, не адресующим никакой файл; также попытка операции чтения или записи с файловым дескриптором, полученным при открытии файла на запись или чтение, соответственно
EBADFD File descriptor in bad state Файловый дескриптор не адресует открытый файл или попытка операции чтения с файловым дескриптором, полученным при открытии файла только на запись
EBUSY Device busy Попытка монтирования устройства (файловой системы), которое уже примонтировано; попытка размонтировать файловую систему, имеющую открытые файлы; попытка обращения к недоступным ресурсам (семафоры, блокираторы и т.п.)
ECHILD No child processes Вызов функции wait(2) процессом, не имеющим дочерних процессов или процессов, для которых уже был сделан вызов wait(2)
EDQUOT Disk quota exceeded Попытка записи в файл, создание каталога или файла при превышении квоты пользователя на дисковые блоки, попытка создания файла при превышении пользовательской квоты на число inode
EEXIST File exists Имя существующего файла использовано в недопустимом контексте, например, сделана попытка создания символической связи с именем уже существующего файла
EFAULT Bad address Аппаратная ошибка при попытке использования системой аргумента функции, например, в качестве указателя передан недопустимый адрес
EFBIG File too large Размер файла превысил установленное ограничение RLIMIT_FSIZE или максимально допустимый размер для данной файловой системы (см. раздел "Ограничения" далее в этой главе)
EINPROGRESS Operation now in progress Попытка длительной операции (например, установление сетевого соединения) для неблокируемого объекта
EINTR Interrupted system call Получение асинхронного сигнала, например, сигнала SIGINT или SIGQUIT, во время обработки системного вызова. Если выполнение процесса будет продолжено после обработки сигнала, прерванный системный вызов завершится с этой ошибкой
EINVAL Invalid argument Передача неверного аргумента системному вызову. Например, размонтирование устройства (файловой системы), которое не было примонтировано. Другой пример — передача номера несуществующего сигнала системному вызову kill(2)
EIO I/O error Ошибка ввода/вывода физического устройства
EISDIR Is a directory Попытка операции, недопустимой для каталога, например, запись в каталог с помощью вызова write(2)
ELOOP Number of symbolic links encountered during path name traversal exceeds MAXSYMLINKS При попытке трансляции имени файла было обнаружено недопустимо большое число символических связей, превышающее значение MAXSYMLINKS
EMFILE Too many open files Число открытых файлов для процесса превысило максимальное значение OPEN_MAX
ENAMETOOLONG File name too long Длина полного имени файла (включая путь) превысила максимальное значение PATH_MAX
ENFILE File table overflow Переполнение файловой таблицы
ENODEV No such device Попытка недопустимой операции для устройства. Например, попытка чтения устройства только для записи или операция для несуществующего устройства
ENOENT No such file or directory Файл с указанным именем не существует или отсутствует каталог, указанный в полном имени файла
ENOEXEC Exec format error Попытка запуска на выполнение файла, который имеет права на выполнение, но не является файлом допустимого исполняемого формата
ENOMEM Not enough space При попытке запуска программы ( exec(2) ) или размещения памяти ( brk(2) ) размер запрашиваемой памяти превысил максимально возможный в системе
ENOMSG No message of desired type Попытка получения сообщения определенного типа, которого не существует в очереди (см. раздел "Сообщения" в главе 3)
ENOSPC No space left on device Попытка записи в файл или создания нового каталога при отсутствии свободного места на устройстве (в файловой системе)
ENOSR Out of stream resources Отсутствие очередей или головных модулей при попытке открытия устройства STREAMS. Это состояние является временным. После освобождения соответствующих ресурсов другими процессами операция может пройти успешно
ENOSTR Not a stream device Попытка применения операции, определенной для устройств типа STREAMS (например системного вызова putmsg(2) или getmsg(2) ), для устройства другого типа
ENOTDIR Not a directory В операции, предусматривающей в качестве аргумента имя каталога, было указано имя файла другого типа (например, в пути для полного имени файла)
ENOTTY Inappropriate ioctl for device Попытка системного вызова ioctl(2) для устройства, которое не является символьным
EPERM Not owner Попытка модификации файла, способом, разрешенным только владельцу и суперпользователю и запрещенным остальным пользователям. Попытка операции, разрешенной только суперпользователю
EPIPE Broken pipe Попытка записи в канал (pipe), для которого не существует процесса, принимающего данные. В этой ситуации процессу обычно отправляется соответствующий сигнал. Ошибка возвращается при игнорировании сигнала
EROFS Read-only file system Попытка модификации файла или каталога для устройства (файловой системы), примонтированного только на чтение
ESRCH No such process Процесс с указанным PID не существует в системе

 

Создание программы

 

Создание любой программы обычно начинается с базовой идеи (но не всегда), разработки ее блок-схемы (современные программисты часто пропускают этот этап), интерфейса пользователя (весьма ответственный процесс) и написания исходного текста. Далее следуют этапы компиляции и отладки.

В этом разделе рассмотрен процесс создания приложения, написанного на языке С и разработанного для операционной системы UNIX. Предвидя обвинения в архаизме, мы все-таки остановимся на добротном ANSI С и базовой среде разработки UNIX, во-первых, полагая, что старый друг лучше новых двух, а во-вторых потому, что объектом нашего обсуждения все же является UNIX, а не современные средства создания приложений. Заметим также, что язык программирования С является "родным" языком UNIX, поскольку ядро операционной системы написано на этом языке. Это, безусловно, не ограничивает возможности других языков и технологий программирования, которые сегодня, наверное, используются даже чаще, чем обсуждаемый нами традиционный подход.

Опустим также процесс рождения базовой идеи и разработку блок-схем, полагая, что все это уже сделано. Итак, начнем с исходного текста будущей программы.

 

Исходный текст

 

Исходные тексты программы, разработанной для UNIX, по большому счету мало отличаются от текстов приложений, создаваемых для других операционных систем. Можно сказать уверенно, что синтаксис языка определяется не операционной системой. Все, что вам потребуется, это хорошее знание самого языка и особенностей системы UNIX, а именно — ее системных вызовов.Во-первых, не забудьте включить в исходный текст необходимые файлы заголовков. Во-вторых, уточните синтаксис вызова библиотечных и системных функций. В-третьих, используйте их по назначению. В-четвертых, не пренебрегайте комментариями.

В этом (за исключением, пожалуй, четвертого совета) вам помогут электронный справочник man(1), ваш опыт, и, надеюсь, эта книга.

 

Заголовки

Использование системных функций обычно требует включения в текст программы файлов заголовков, содержащих определения функций — число передаваемых аргументов, типы аргументов и возвращаемого значения. Большинство системных файлов заголовков расположены в каталогах /usr/include или /usr/include/sys. Если вы планируете использовать малознакомую системную функцию, будет нелишним изучить соответствующий раздел электронного справочника man(1). Там же, помимо описания формата функции, возвращаемого значения и особых ситуаций, вы найдете указание, какие файлы заголовков следует включить в программу.

Файлы заголовков включаются в программу с помощью директивы #include. При этом, если имя файла заключено в угловые скобки (<>), это означает, что поиск файла будет производиться в общепринятых каталогах хранения файлов заголовков. Если же имя файла заголовка заключено в кавычки, то используется явно указанное абсолютное или относительное имя файла.

Например, системный вызов creat(2) служащий для создания обычного файла, объявлен в файле следующим образом:

#include

#include

#include

int creat(const char *path, mode_t mode);

Включение в исходный текст прототипа системного вызова creat(2) позволяет компилятору произвести дополнительную проверку правильности использования этой функции, а именно — числа аргументов и их типов. Можно заметить, что наряду со стандартными типами языка С, например char, для второго аргумента creat(2) используется производный тип — mode_t. В ранних версиях UNIX большинство системных вызовов использовали стандартные типы, например, creat(2) для второго аргумента охотно принимала тип int. Производные типы переменных, имеющие окончание _t, которые вы в большом количестве встретите при программировании в UNIX, получили название примитивов системных данных. Большинство этих типов определены в файле , а их назначение заключается в улучшении переносимости написанных программ. Вместо конкретных типов данных, каковыми являются int, char и т.п., приложению предлагается набор системных типов, гарантированно неизменных в контексте системных вызовов. Другими словами, во всех версиях UNIX сегодня и спустя десять лет, системный вызов creat(2) в качестве второго аргумента будет принимать переменную типа mode_t. Фактический размер переменных этого типа может быть разным для различных версий системы, но это отразится в изменении соответствующего файла заголовков и потребует только перекомпиляции вашей программы.

Среда программирования UNIX определяется несколькими стандартами, обсуждавшимися во введении, и может незначительно различаться для разных версий системы. В частности, стандарты ANSI С, POSIX. 1 и XPG4, определяют названия и назначения файлов заголовков, приведенных в табл. 2.2.

Таблица 2.2. Стандартные файлы заголовков

Файл заголовка Назначение
<assert.h> Содержит прототип функции assert(3C) , используемой для диагностики
<cpio.h> Содержит определения, используемые для файловых архивов cpio(1)
<ctype.h> Содержит определения символьных типов, а также прототипы функций определения классов символов (ASCII, печатные, цифровые и т.д.) — isascii(3C) , isprint(3C) , isdigit(3C) и т.д.
<dirent.h> Содержит определения структур данных каталога, а также прототипы функций работы с каталогами opendir(3C) , readdir(3C) и т.д.
<errno.h> Содержит определения кодов ошибок (см. раздел "Обработка ошибок" в начале главы)
<fcntl.h> Содержит прототипы системных вызовов fcntl(2) , open(2) и creat(2) , а также определения констант и структур данных, необходимых при работе с файлами
<float.h> Содержит определения констант, необходимых для операций с плавающей точкой
<ftw.h> Содержит прототипы функций, используемых для сканирования дерева файловой системы (file tree walk) ftw(3C) и nftw(3C) , a также определения используемых констант
<grp.h> Содержит прототипы функций и определения структур данных, используемых для работы с группами пользователей: getgrnam(3C) , getgrent(3C) , getgrgid(3C) и т.д.
<langinfo.h> Содержит определения языковых констант: дни недели, названия месяцев и т.д., а также прототип функции langinfo(3C)
<limits.h> Содержит определения констант, определяющих значения ограничений для данной реализации: минимальные и максимальные значения основных типов данных, максимальное значение файловых связей, максимальная длина имени файла и т.д.
<locale.h> Содержит определения констант, используемых для создания пользовательской среды, зависящей от языковых и культурных традиций (форматы дат, денежные форматы и т.д.), а также прототип функции setlocale(3C)
<math.h> Содержит определения математических констант (π, е, √2 и т.д.)
<nl_types.h> Содержит определения для каталогов сообщений (message catalog), а также прототипы функций catopen(3C) и catclose(3C)
<pwd.h> Содержит определение структуры файла паролей /etc/passwd, а также прототипы функций работы с ним: getpwnam(3C) , getpwent(3C) , getpwuid(3C) и т.д.
<regex.h> Содержит определения констант и структур данных, используемых в регулярных выражениях, а также прототипы функций для работы с ними: regcomp(3C) , regexec(3C) и т.д.
<search.h> Содержит определения констант и структур данных, а также прототипы функций, необходимых для поиска: hsearch(3C) , hcreate(3C) , hdestroy(3C)
<setjmp.h> Содержит прототипы функций перехода setjmp(3C) , sigsetjmp(3C) , longjmp(3C) , siglongjmp(3C) , а также определения связанных с ними структур данных
<signal.h> Содержит определения констант и прототипы функций, необходимых для работы с сигналами: sigsetops(3C) , sigemptyset(3C) , sigaddset(3C) и т.д. (см. раздел "Сигналы" далее в этой главе)
<stdarg.h> Содержит определения, необходимые для поддержки списков аргументов переменной длины
<stddef.h> Содержит стандартные определения (например size_t)
<stdio.h> Содержит определения стандартной библиотеки ввода/вывода
<stdlib.h> Содержит определения стандартной библиотеки
<string.h> Содержит прототипы функций работы со строками string(3C) , strcasecmp(3C) , strcat(3C) , strcpy(3C) и т.д.
<tar.h> Содержит определения, используемые для файловых архивов tar(1)
<termios.h> Содержит определения констант, структур данных и прототипы функций для обработки терминального ввода/вывода
<time.h> Содержит определения типов, констант и прототипы функций для работы со временем и датами: time(2) , ctime(3C) , localtime(3C) , tzset(3C) , а также определения, относящиеся к таймерам getitimer(2) , setitimer(2) . Таймеры будут рассмотрены в главе 3
<ulimit.h> Содержит определения констант и прототип системного вызова ulimit(2) для управления ограничениями, накладываемыми на процесс. См. также раздел "Ограничения" далее в этой главе
<unistd.h> Содержит определения системных символьных констант, а также прототипы большинства системных вызовов
<utime.h> Содержит определения структур данных и прототип системного вызова utime(2) для работы с временными характеристиками файла (временем доступа и модификации)
<sys/ipc.h> Содержит определения, относящиеся к системе межпроцессного взаимодействия (IPC), которые рассматриваются в главе 3
<sys/msg.h> Содержит определения, относящиеся к (сообщениям) подсистеме IPC. См. также раздел "Сообщения" главы 3
<sys/resource.h> Содержит определения констант и прототипы системных вызовов управления размерами ресурсов, доступных процессу: getrlimit(2) и setrlimit(2) . Более подробно ограничения на ресурсы обсуждаются в разделе "Ограничения" далее в этой главе
<sys/sem.h> Содержит определения, относящиеся к (семафорам) подсистеме IPC. См. также раздел "Семафоры" главы 3
<sys/shm.h> Содержит определения, относящиеся к (разделяемой памяти) подсистеме IPC. См. также раздел "Разделяемая память" главы 3
<sys/stat.h> Содержит определения структур данных и прототипы системных вызовов, необходимых для получения информации о файле: stat(2) , lstat(2) , fstat(2) . Подробнее эти системные вызовы рассмотрены в разделе "Метаданные файла" далее в этой главе
<sys/times.h> Содержит определения структур данных и прототипа системного вызова times(2) , служащего для получения статистики выполнения процесса (времени выполнения в режиме ядра, задачи и т.д.)
<sys/types.h> Содержит определения примитивов системных данных
<sys/utsname.h> Содержит определения структур данных и прототип системного вызова uname(2) , используемого для получения имен системы (компьютера, операционной системы, версии и т.д.)
<sys/wait.h> Содержит определения констант и прототипы системных вызовов wait(2) , waitpid(2) , используемых для синхронизации выполнения родственных процессов

 

Компиляция

Процедура создания большинства приложений является общей и приведена на рис. 2.2.

Рис. 2.2. Схема компиляции программы

Первой фазой является стадия компиляции, когда файлы с исходными текстами программы, включая файлы заголовков, обрабатываются компилятором cc(1). Параметры компиляции задаются либо с помощью файла makefile (или Makefile), либо явным указанием необходимых опций компилятора в командной строке. В итоге компилятор создает набор промежуточных объектных файлов. Традиционно имена созданных объектных файлов имеют суффикс ".o".

На следующей стадии эти файлы с помощью редактора связей ld(1) связываются друг с другом и с различными библиотеками, включая стандартную библиотеку по умолчанию и библиотеки, указанные пользователем в качестве параметров. При этом редактор связей может выполняться в двух режимах: статическом и динамическом, что задается соответствующими опциями. В статическом, наиболее традиционном режиме связываются все объектные модули и статические библиотеки (их имена имеют суффикс ".а"), производится разрешение всех внешних ссылок модулей и создается единый исполняемый файл, содержащий весь необходимый для выполнения код. Во втором случае, редактор связей по возможности подключает разделяемые библиотеки (имена этих библиотек имеют суффикс ".so"). В результате создается исполняемый файл, к которому в процессе запуска на выполнение будут подключены все разделяемые объекты. В обоих случаях по умолчанию создается исполняемый файл с именем a.out.

Для достаточно простых задач все фазы автоматически выполняются вызовом команды:

$ make prog

или эквивалентной ей

$ cc -о prog prog.c

которые создают исполняемый файл с именем prog. В этом случае умалчиваемое имя исполняемого файла (a.out) изменено на prog с помощью опции -о.

Впрочем, указанные стадии можно выполнять и раздельно, с использованием команд cc(1) и ld(1). Заметим, что на самом деле команда cc(1) является программной оболочкой и компилятора и редактора связей, которую и рекомендуется использовать при создании программ.

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

$ cc -с file1.c file2.c

$ cc -о prog

Создадим промежуточные объектные файлы file1.o и file2.o

$ cc -o prog file1.o file2.o -lnsl

Создадим исполняемый файл с именем prog, используя промежуточные объектные файлы и библиотеку libnsl.a или libnsl.so

 

Форматы исполняемых файлов

 

Виртуальная память процесса состоит из нескольких сегментов или областей памяти. Размер, содержимое и расположение сегментов в памяти определяется как самой программой, например, использованием библиотек, размером кода и данных, так и форматом исполняемого файла этой программы. В большинстве современных операционных систем UNIX используются два стандартных формата исполняемых файлов — COFF (Common Object File Format) и ELF (Executable and Linking Format).

Описание форматов исполняемых файлов может показаться лишним, однако представление о них необходимо для описания базовой функциональности ядра операционной системы. В частности, информация, хранящаяся в исполняемых файлах форматов COFF и ELF позволяет ответить на ряд вопросов весьма важных для работы приложения и системы в целом:

□ Какие части программы необходимо загрузить в память?

□ Как создается область для неинициализированных данных?

□ Какие части процесса должны быть сохранены в дисковой области свопинга (специальной области дискового пространства, предназначенной для временного хранения фрагментов адресного пространства процесса), например, при замещении страниц, а какие могут быть при необходимости считаны из файла, и таким образом не требуют сохранения?

□ Где в памяти располагаются инструкции и данные программы?

□ Какие библиотеки необходимы для выполнения программы?

□ Как связаны исполняемый файл на диске, образ программы в памяти и дисковая область свопинга?

На рис. 2.3 приведена базовая структура памяти для процессов, загруженных из исполняемых файлов форматов COFF и ELF, соответственно. Хотя расположение сегментов различается для этих двух форматов, основные компоненты одни и те же. Оба процесса имеют сегменты кода (text), данных (data), стека (stack). Как видно из рисунка, размер сегментов данных и стека может изменяться, а направление этого изменения определяется форматом исполняемого файла. Размер стека автоматически изменяется операционной системой, в то время как управление размером сегмента данных производится самим приложением. Эти вопросы мы подробно обсудим в разделе "Выделение памяти" далее в этой главе.

Рис. 2.3. Исполняемые образы программ форматов COFF и ELF

Сегмент данных включает инициализированные данные, копируемые в память из соответствующих разделов исполняемого файла, и неинициализированные данные, которые заполняются нулями перед началом выполнения процесса. Неинициализированные данные часто называют сегментом BSS.

 

Формат ELF

Формат ELF имеет файлы нескольких типов, которые до сих пор мы называли по-разному, например, исполняемый файл или объектный файл. Тем не менее стандарт ELF различает следующие типы:

1. Перемещаемый файл (relocatable file), хранящий инструкции и данные, которые могут быть связаны с другими объектными файлами. Результатом такого связывания может быть исполняемый файл или разделяемый объектный файл.

2. Разделяемый объектный файл (shared object file) также содержит инструкции и данные, но может быть использован двумя способами. В первом случае, он может быть связан с другими перемещаемыми файлами и разделяемыми объектными файлами, в результате будет создан новый объектный файл. Во втором случае, при запуске программы на выполнение операционная система может динамически связать его с исполняемым файлом программы, в результате чего будет создан исполняемый образ программы. В последнем случае речь идет о разделяемых библиотеках.

3. Исполняемый файл хранит полное описание, позволяющее системе создать образ процесса. Он содержит инструкции, данные, описание необходимых разделяемых объектных файлов, а также необходимую символьную и отладочную информацию.

На рис. 2.4 приведена структура исполняемого файла, с помощью которого операционная система может создать образ программы и запустить программу на выполнение.

Рис. 2.4. Структура исполняемого файла в формате ELF

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

Поскольку заголовок ELF-файла определяет его структуру, рассмотрим его более подробно (табл. 2.4).

Таблица 2.3. Поля заголовка ELF-файла

Поле Описание
е_ident[] Массив байт, каждый из которых определяет некоторую общую характеристику файла: формат файла (ELF), номер версии, архитектуру системы (32-разрядная или 64-разрядная) и т.д.
e_type Тип файла, поскольку формат ELF поддерживает несколько типов
e_machine Архитектура аппаратной платформы, для которой создан данный файл. В табл. 2.4 приведены возможные значения этого поля
e_version Номер версии ELF-формата. Обычно определяется как EV_CURRENC (текущая), что означает последнюю версию
e_entry Виртуальный адрес, по которому системой будет передано управление после загрузки программы (точка входа)
e_phoff Расположение (смещение от начала файла) таблицы заголовков программы
е_shoff Расположение таблицы заголовков секций
е_ehsize Размер заголовка
e_phentsize Размер каждого заголовка программы
e_phnum Число заголовков программы
e_shentsize Размер каждого заголовка сегмента (секции)
е_shnum Число заголовков сегментов (секций)
e_shstrndx Расположение сегмента, содержащего таблицу строк

Таблица 2.4. Значения поля e_machine заголовка ELF-файла

Значение Аппаратная платформа
ЕМ_М32 AT&T WE 32100
ЕМ_SPARC Sun SPARC
ЕМ_386 Intel 80386
ЕМ_68K Motorola 68000
EM_88K Motorola 88000
ЕМ_486 Intel 80486
ЕМ_860 Intel i860
ЕМ_MIPS MIPS RS3000 Big-Endian
EM_MIPS_RS3_LE MIPS RS3000 Little-Endian
EM_RS6000 RS6000
EM_PA_RISC PA-RISC
EM_nCUBE nCUBE
EM_VPP500 Fujitsu VPP500
EM_SPARC32PLUS Sun SPARC 32+

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

Каждый заголовок сегмента программы описывает один сегмент и содержит следующую информацию:

□ Тип сегмента и действия операционной системы с данным сегментом

□ Расположение сегмента в файле

□ Стартовый адрес сегмента в виртуальной памяти процесса

□ Размер сегмента в файле

□ Размер сегмента в памяти

□ Флаги доступа к сегменту (запись, чтение, выполнение)

Часть сегментов имеет тип LOAD, предписывающий ядру при запуске программы на выполнение создать соответствующие этим сегментам структуры данных, называемые областями, определяющие непрерывные участки виртуальной памяти процесса и связанные с ними атрибуты. Сегмент, расположение которого в ELF-файле указано в соответствующем заголовке программы, будет отображен в созданную область, виртуальный адрес начала которой также указан в заголовке программы. К сегментам такого типа относятся, например, сегменты, содержащие инструкции программы (код) и ее данные. Если размер сегмента меньше размера области, неиспользованное пространство может быть заполнено нулями. Такой механизм, в частности используется при создании неинициализированных данных процесса (BSS). Подробнее об областях мы поговорим в главе 3.

В сегменте типа INTERP хранится программный интерпретатор. Данный тип сегмента используется для программ, которым необходимо динамическое связывание. Суть динамического связывания заключается в том, что отдельные компоненты исполняемого файла (разделяемые объектные файлы) подключаются не на этапе компиляции, а на этапе запуска программы на выполнение. Имя файла, являющегося динамическим редактором связей, хранится в данном сегменте. В процессе запуска программы на выполнение ядро создает образ процесса, используя указанный редактор связей. Таким образом, первоначально в память загружается не исходная программа, а динамический редактор связей. На следующем этапе динамический редактор связей совместно с ядром UNIX создают полный образ исполняемого файла. Динамический редактор загружает необходимые разделяемые объектные файлы, имена которых хранятся в отдельных сегментах исходного исполняемого файла, и производит требуемое размещение и связывание. В заключение управление передается исходной программе.

Наконец, завершает файл таблица заголовков разделов или секций (section). Разделы (секций) определяют разделы файла, используемые для связывания с другими модулями в процессе компиляции или при динамическом связывании. Соответственно, заголовки содержат всю необходимую информацию для описания этих разделов. Как правило разделы содержат более детальную информацию о сегментах. Так, например, сегмент кода может состоять из нескольких разделов, таких как хэш-таблица для хранения индексов используемых в программе символов, раздел инициализационного кода программы, таблица связывания, используемая динамическим редактором, а также раздел, содержащий собственно инструкции программы.

Мы еще вернемся к формату ELF в главе 3 при обсуждении организации виртуальной памяти процесса, а пока перейдем к следующему распространенному формату — COFF.

 

Формат COFF

На рис. 2.5 приведена структура исполняемого файла формата COFF. Исполняемый файл содержит два основных заголовка — заголовок COFF и стандартный заголовок системы UNIX — a.out. Далее следуют заголовки разделов и сами разделы файла, в которых хранятся инструкции и данные программы. Наконец, в файле также хранится символьная информация, необходимая для отладки.

Рис. 2.5. Структура исполняемого файла в формате COFF

В файле находятся только инициализированные данные. Поскольку неинициализированные данные всегда заполняются нулями при загрузке программы на выполнение, для них необходимо хранить только размер и расположение в памяти.

Символьная информация состоит из таблицы символов (symbol table) и таблицы строк (string table). В первой таблице хранятся символы, их адреса и типы. Например, мы можем определить, что символ locptr является указателем и его виртуальный адрес равен 0x7feh0. Далее, используя этот адрес, мы можем выяснить значение символа для выполняющегося процесса. Записи таблицы символов имеют фиксированный размер. Если длина символа превышает восемь знаков, его имя хранится во второй таблице — таблице строк. Обычно обе эти таблицы присутствуют в объектных и исполняемых файлах, если они явно не удалены, например, командой strip(1).

Как и в случае ELF-файла, заголовок содержит общую информацию, позволяющую определить местоположение остальных компонентов (табл. 2.5).

Таблица 2.5. Поля заголовка COFF-файла

Поле Описание
f_magic Аппаратная платформа, для которой создан файл
f_nscns Количество разделов в файле
f_timdat Время и дата создания файла
f_symptr Расположение таблицы символов в файле
f_nsyms Количество записей в таблице символов
f_opthdr Размер заголовка
f_flags Флаги, указывающие на тип файла, присутствие символьной информации и т.д.

Заголовок COFF присутствует в исполняемых файлах, промежуточных объектных файлах и библиотечных архивах. Каждый исполняемый файл кроме заголовка COFF содержит заголовок a.out, хранящий информацию, необходимую ядру системы для запуска программы (табл. 2.6).

Таблица 2.6. Поля заголовка a.out

Поле Описание
vstamp Номер версии заголовка
tsize Размер раздела инструкций (text)
dsize Размер инициализированных данных (data)
bsize Размер неинициализированных данных (bss)
entry Точка входа программы
text_start Адрес в начала сегмента инструкций виртуальной памяти
data_start Адрес в начала сегмента данных виртуальной памяти

Все файлы формата COFF имеют один или более разделов, каждый из которых описывается своим заголовком. В заголовке хранится имя раздела (.text, .data, .bss или любое другое, установленное соответствующей директивой ассемблера), размер раздела, его расположение в файле и виртуальной адрес после запуска программы на выполнение. Заголовки разделов следуют сразу за заголовком файла.

Таблицы символов и строк являются основой системы отладки. Символом является любая переменная, имя функции или метка, определенные в программе.

Каждая запись в таблице символов хранит имя символа, его виртуальный адрес, номер раздела, в котором определен символ, тип, класс хранения (автоматический, регистровый и т.д.). Если имя символа занимает больше восьми байт, то оно хранится в таблице строк. В этом случае в поле имени символа указывается смещение имени символа в таблице строк.

С помощью символьной информации можно определить виртуальный адрес некоторого символа. Одним из очевидных применений этой возможности является использование символьной информации в программах- отладчиках. Эта возможность используется некоторыми программами, например, утилитой ps(1), отображающей состояние процессов в системе.

 

Выполнение программы в операционной системе UNIX

 

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

 

Запуск C-программы

Функция main() является первой функцией, определенной пользователем (т. е. явно описанной в исходном тексте программы), которой будет передано управление после создания соответствующего окружения запускаемой на выполнение программы. Традиционно функция main() определяется следующим образом:

main(int argc, char *argv[], char *envp[]);

Первый аргумент (argc) определяет число параметров, переданных программе, включая ее имя.

Указатели на каждый из параметров передаются в массиве argv[], таким образом, через argv[0] адресуется строка, содержащая имя программы, argv[1] указывает на первый параметр и т.д.. до argv[argc-1].

Массив envp[] содержит указатели на переменные окружения, передаваемые программе. Каждая переменная представляет собой строку вида имя_переменной=значение_переменной. Мы уже познакомились с переменными окружения в главе 1, когда обсуждали командный интерпретатор. Сейчас же мы остановимся на их программной "анатомии".

Стандарт ANSI С определяет только два первых аргумента функции main() — argc и argv. Стандарт POSIX.1 определяет также аргумент envp, хотя рекомендует передачу окружения программы производить через глобальную переменную environ, как это показано на рис. 2.6:

extern char *environ;

Рекомендуется следовать последнему формату передачи для лучшей переносимости программ на другие платформы UNIX.

Рис. 2.6. Передача переменных окружения

Приведем пример программы, соответствующую стандарту POSIX.1, которая выводит значения всех аргументов, переданных функции main(): число переданных параметров, сами параметры и значения первых десяти переменных окружения.

#include

extern char **environ;

main(int argc, char *argv[]) {

 int i;

 printf("число параметров, переданных программе %s равно %d\n",

  argv[0], argc-1);

 for (i=1; i

  if (environ[i] != NULL)

   printf("environ[%d] : %s\n", i, environ[i]);

}

В результате компиляции будет создан исполняемый файл программы (по умолчанию a.out). Запустив его, мы увидим следующую информацию:

$ a.out first second 3

число параметров, переданных программе a.out равно 3

argv[1] = first

argv[2] = second

argv[3] = 3

environ[0] : LOGNAME=andy

environ[1] : MAIL=/var/mail/andy

environ[2] : LD_LIBRARY_PATH=/usr/openwin/lib:/usr/ucblib

environ[3] : PAGER=/usr/bin/pg

environ[4] : TERM=vt100

environ[5] : PATH=/usr/bin:/bin:/etc:/usr/sbin:/sbin:/usr/ccs/bin:/usr/local/bin

environ[6] : HOME=/home/andy

environ[7] : SHELL=/usr/local/bin/bash

Максимальный объем памяти для хранения параметров и переменных окружения программы ограничен величиной ARG_MAX, определенной в файле . Это и другие системные ограничения могут быть получены с помощью функции sysconf(2).

Для получения и установки значений конкретных переменных окружения используются две функции: getenv(3C) и putenv(3C):

#include

char *getenv(const char *name);

возвращает значение переменной окружения name, a

int putenv(const char *string);

помещает переменную и ее значение (var_name=var_value) в окружение программы.

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

#include

#include

#include

main(int argc, char *argv[]) {

 char *term;

 char buf[200], var[200];

 /* Проверим, определена ли переменная TERM */

 if ((term = getenv("TERM")) == NULL)

  /* Если переменная не определена, получим от пользователя ее значение и

     поместим переменную в окружение программы */

 {

  printf("переменная TERM не определена, введите значение: ");

  putenv(var);

 } else

  /* Если переменная TERM определена, предоставим пользователю возможность

     изменить ее значение, после чего поместим ее в окружение процесса */

 {

  printf("TERM=%s. Change? [N]", getenv("TERM"));

  gets(buf);

  if (buf[0] == 'Y' || buf[0] == 'y') {

   printf("TERM=");

   gets{buf);

   sprintf(var, "TERM=%s", buf);

   putenv(var);

   printf("new %s\n", var);

  }

 }

}

Сначала программа проверяет, определена ли переменная TERM. Если переменная TERM не определена, пользователю предлагается ввести ее значение. Если же переменная TERM определена, пользователю предлагается изменить ее значение, после чего новое значение помещается в окружение программы.

Запуск этой программы приведет к следующим результатам:

$ а.out

TERM=ansi. Change? [N] y

TERM= vt100

new TERM=vt100

$

К сожалению, введенное значение переменной будет действительно только для данного процесса и порожденных им процессов: если после завершения программы a.out вывести значение TERM, то видно, что оно не изменилось:

$ echo $TERM

ansi

$

Наследование окружения программы мы обсудим в разделе "Создание и управление процессами" далее в этой главе.

Переменные окружения, как и параметры, позволяют передавать программе некоторую информацию. Однако если программа является интерактивной, основную информацию она, скорее всего, будет получать непосредственно от пользователя. В связи с этим встает вопрос: каким образом программа узнает, где находится пользователь, чтобы правильно считывать и выводить информацию? Другими словами, программе необходимо знать, с каким терминальным устройством работает пользователь, запустивший ее.

Обычно при запуске программы на выполнение из командной строки shell автоматически устанавливает для нее три стандартных потока ввода/вывода: для ввода данных, для вывода информации и для вывода сообщений об ошибках. Начальную ассоциацию этих потоков (их файловых дескрипторов) с конкретными устройствами производит терминальный сервер (в большинстве систем это процесс getty(1M)), который открывает специальный файл устройства, связанный с терминалом пользователя, и получает соответствующие дескрипторы. Эти потоки наследует командный интерпретатор shell и передает их запускаемой программе. При этом shell может изменить стандартные направления (по умолчанию все три потока связаны с терминалом пользователя), если пользователь указал на это с помощью специальных директив перенаправления потока (>, <, >>, <<) см. главу 1, раздел "Пользовательская среда UNIX"). Раздел "Группы и сеансы" внесет окончательную ясность в этот вопрос при описании управляющего терминала.

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

Завершая разговор о запуске программ, заметим, что при компиляции программы редактор связей устанавливает точку входа в программу, указывающую на библиотечную функцию _start(). Эта функция инициализирует процесс, создавая кадр стека, устанавливая значения переменных и, в конечном итоге, вызывая функцию main().

 

Завершение C-программы

Существует несколько способов завершения программы. Основными являются возврат из функции main() и вызов функций exit(2), оба приводят к завершению выполнения задачи. Заметим, что процесс может завершиться по не зависящим от него обстоятельствам, например, при получении сигнала, действие по умолчанию для большинства из которых приводит к завершению выполнения процесса (см. раздел "Сигналы" далее в этой главе). В этом случае функция exit(2) будет вызвана ядром от имени процесса.

Системный вызов exit(2) выглядит следующим образом:

#include

void exit(int status);

Аргумент status, передаваемый функции exit(2), возвращается родительскому процессу и представляет собой код возврата программы. По соглашению программа возвращает 0 в случае успеха и другую величину в случае неудачи. Значение кода неудачи может иметь дополнительную трактовку, определяемую самой программой. Например, программа grep(1), выполняющая поиск заданных подстрок в файлах, определяет следующие коды возврата:

0 совпадение было найдено
1 совпадений найдено не было
2 синтаксическая ошибка или недоступны файлы поиска

Наличие кода возврата позволяет программам взаимодействовать друг с другом. Например, следующая программа (назовем ее fail) может являться условием неудачи и использоваться в соответствующих синтаксических конструкциях shell:

main() {

 exit(1);

}

$ fail

$ echo $?           Выведем код возврата программы fail

1

$ fail || echo fail Конструкция shell, использующая условие неудачи fail

fail

Помимо передачи кода возврата, функция exit(2) производит ряд действий, в частности выводит буферизованные данные и закрывает потоки ввода/вывода. Альтернативой ей является функция _exit(2), которая не производит вызовов библиотеки ввода/вывода, а сразу вызывает системную функцию завершения ядра. Более подробно о процедурах завершения процесса см. раздел "Создание и управление процессами".

Задача может зарегистрировать обработчики выхода (exit handler), — функции, которые вызываются после вызова exit(2), но до окончательного завершения процесса. Эти обработчики, вызываемые по принципу LIFO (последний зарегистрированный обработчик будет вызван первым), запускаются только при "добровольном" завершении процесса. Например, при получении процессом сигнала обработчики выхода вызываться не будут. Для обработки таких ситуаций следует использовать специальные функции — обработчики сигналов (см. раздел "Сигналы" далее в этой главе).

Обработчики выхода регистрируются с помощью функции atexit(3C):

#include

int atexit(void(*func)(void));

Функцией atexit(1) может быть зарегистрировано до 32 обработчиков.

На рис. 2.7 проиллюстрированы возможные варианты запуска и завершения программы, написанной на языке С.

Рис. 2.7. Запуск и завершение C-программы

 

Работа с файлами

 

В среде программирования UNIX существуют два основных интерфейса для файлового ввода/вывода:

1. Интерфейс системных вызовов, предлагающий системные функции низкого уровня, непосредственно взаимодействующие с ядром операционной системы.

2. Стандартная библиотека ввода/вывода, предлагающая функции буферизованного ввода/вывода.

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

В следующих разделах будут рассмотрены:

□ оба интерфейса, и особенно первый, поскольку именно он представляет набор базовых услуг ядра;

□ программный интерфейс управления жесткими и символическими связями файла;

□ функции изменения владельцев файла и прав доступа;

□ метаданные файла;

□ пример программы, выводящей на экран наиболее существенную информацию о файле, подобно тому, как это делает утилита ls(1).

 

Основные системные функции для работы с файлами

 

В табл. 2.7 приведены основные системные функции работы с файлами, являющиеся образами системных вызовов в программе С.

Функции более высокого уровня, предлагаемые стандартной библиотекой ввода/вывода, которые в конечном счете используют описанные здесь системные вызовы, рассматриваются в следующем разделе.

Таблица 2.7. Основные системные функции работы с файлами

Системная функция Описание
open(2) Служит для получения доступа на чтение и/или запись к указанному файлу. Если файл существует, он открывается, и процессу возвращается файловый дескриптор, адресующий дальнейшие операции с файлом. Если файл не существует, он может быть создан
creat(2) Служит для создания файла
close(2) Закрывает файловый дескриптор, связанный с предварительно открытым файлом
dup(2) Возвращает дубликат файлового дескриптора
dup2(2) Возвращает дубликат файлового дескриптора, но позволяет явно указать его значение
lseek(2) Устанавливает файловый указатель на определенное место файла. Дальнейшие операции чтения/записи будут производиться, начиная с этого смещения
read (2) Производит чтение указанного количества байтов из файла
readv(2) Производит несколько операций чтения указанного количества байтов из файла
write(2) Производит запись указанного количества байтов в файл
writev(2) Производит несколько операций записи указанного количества байтов в файл
pipe(2) Создает коммуникационный канал, возвращая два файловых дескриптора
fcntl(2) Обеспечивает интерфейс управления открытым файлом

Кратко рассмотрим каждую из этих функций.

 

Функция

open(2)

Открывает указанный файл для чтения или записи и имеет следующий вид:

#include

int open(const char *path, int oflag, mode_t mode);

Первый аргумент (path) является указателем на имя файла. Это имя может быть как абсолютным (начинающимся с корневого каталога /), так и относительным (указанным относительно текущего каталога). Аргумент oflag указывает на режим открытия файла и представляет собой побитное объединение флагов, приведенных в табл. 2.8, с помощью операции ИЛИ. Напомним, что если права доступа к файлу не разрешают указанного режима работы с файлом, операция открытия файла будет запрещена, и функция open(2) завершится с ошибкой (errno=EACCESS). Аргумент mode, определяющий права доступа к файлу, используется только при создании файла (как показано в табл. 2,8, функция open(2) может использоваться и для создания файла) и рассматривается при описании функции creat(2) в разделе "Права доступа" этой главы.

Таблица 2.8. Флаги, определяющие режим открытия файла

Флаг Описание
O_RDONLY Открыть файл только для чтения
O_WRONLY Открыть файл только для записи
O_RDWR Открыть файл для чтения и записи
O_APPEND Производить добавление в файл, т.е. устанавливать файловый указатель на конец файла перед каждой записью в файл
O_CREAT Если указанный файл уже существует, этот флаг не принимается во внимание. В противном случае, создается файл, атрибуты которого установлены по умолчанию (см. разделы "Владельцы файлов" и "Права доступа к файлу" в главе 1), или с помощью аргумента mode
O_EXCL Если указан совместно с O_CREAT, то вызов open(2) завершится с ошибкой, если файл уже существует
O_NOCTTY Если указанный файл представляет собой терминал, не позволяет ему стать управляющим терминалом
O_SYNC Все записи в файл, а также соответствующие им изменения в метаданных файла будут сохранены на диске до возврата из вызова write(2)
O_TRUNC Если файл существует и является обычным файлом, его длина будет установлена равной 0
O_NONBLOCK Изменяет режим выполнения операций read(2) и write(2) для этого файла на неблокируемый. При невозможности произвести запись или чтение, например, если отсутствуют данные, соответствующие вызовы завершатся с ошибкой EAGAIN

Если операция открытия файла закончилась удачно, то будет возвращен файловый дескриптор — указатель на файл, использующийся в последующих операциях чтения, записи и т.д. Значение файлового дескриптора определяется минимальным свободным слотом в таблице дескрипторов процесса. Так, если дескрипторы 0 и 2 уже заняты (указывают на открытые файлы), вызов open(2) возвратит значение 1. Это свойство может быть использовано в коде командного интерпретатора при перенаправлении потоков ввода-вывода.

$ runme >/home/andrei/run.log

Фрагмент кода

...

/* Закроем ассоциацию стандартного потока вывода (1)

   с файлом (терминалом) */

close(1);

/* Назначим стандартный поток вывода в файл /home/andrei/run.log.

   Поскольку файловый дескриптор 1 свободен, мы можем рассчитывать

   на его получение. */

fd = open("/home/andrei/run.log",

 O_WRONLY | O_CREATE | O_TRUNC);

...

В случае неудачи open(1) возвратит -1, а глобальная переменная errno будет содержать код ошибки (см. раздел "Обработка ошибок").

Заметим, что только один из флагов O_RDONLY, O_WRONLY и O_RDWR может быть указан в аргументе oflag.

Флаг O_SYNC гарантирует, что данные, записанные в файл и связанные с операцией записи изменения метаданных файла, будут сохранены на диске до возврата из функции write(2). Ядро кэширует данные, считываемые или записываемые на дисковое устройство, для ускорения этих операций. Обычно запись данных в файл ограничивается записью в буферный кэш ядра операционной системы, данные из которого впоследствии записываются на диск. По умолчанию возврат из функции write(2) происходит после записи в буферный кэш, не дожидаясь записи данных на диск. Более подробно работу буферного кэша мы рассмотрим в главе 4.

Флаг O_NONBLOCK изменяет стандартное поведение функций чтения/записи файла. При указании этого флага возврат из функций read(2) и write(2) будет происходить немедленно с кодом ошибки и установленным значением errno = EAGAIN, если ядро не может передать данные при чтении, например, ввиду их отсутствия, или процессу требуется перейти в состояние сна при записи данных.

 

Функция

creat(2)

Функция служит для создания обычного файла или изменения его атрибутов и имеет следующий вид:

#include

int creat(const char *path, mode_t mode);

Как и в случае open(2), аргумент path определяет имя файла в файловой системе, a mode — устанавливаемые права доступа к файлу. При этом выполняется ряд правил:

□ Если идентификатор группы (GID) создаваемого файла не совпадает с эффективным идентификатором группы (EGID) или идентификатором одной из дополнительных групп процесса, бит SGID аргумента mode очищается (если он был установлен).

□ Очищаются все биты, установленные в маске процесса

□ Очищается флаг Sticky bit.

Права доступа к файлу обсуждались в главе 1. Более детальная информация приведена в разделе "Права доступа" этой главы.

Если файл уже существует, его длина сокращается до 0, а права доступа и владельцы сохраняются прежними. Вызов creat(2) эквивалентен следующему вызову функции open(2):

open(path, O_WRONLY | O_CREAT | O_TRUNC, mode);

 

Функция

close(2)

Функция close(2) разрывает связь между файловым дескриптором и открытым файлом, созданную функциями creat(2), open(2), dup(2), pipe(2) или fcntl(2). Функция имеет вид:

#include

int close(int fildes);

В случае успеха close(2) возвращает нулевое значение, в противном случае возвращается -1, а значение переменной errno указывает на причину неудачи.

Многие программы явно не используют close(2) при завершении выполнения. Дело в том, что функция exit(2), вызываемая явно или неявно при завершении выполнения программы, автоматически закрывает открытые файлы.

 

Функции

dup(2)

и

dup2(2)

Функция dup(2) используется для дублирования существующего файлового дескриптора:

int dup(int fildes);

Файловый дескриптор fildes должен быть предварительно получен с помощью функций open(2), creat(2), dup(2), dup2(2) или pipe(2). В случае успешного завершения функции dup(2) возвращается новый файловый дескриптор, свойства которого идентичны свойствам дескриптора fildes. Оба указывают на один и тот же файл, одно и то же смещение, начиная с которого будет производиться следующая операция чтения или записи (файловый указатель), и определяют один и тот же режим работы с файлом. Правило размещения нового файлового дескриптора аналогично используемому в функции open(2).

Функция dup2(2) делает то же самое, однако позволяет указать номер файлового дескриптора, который требуется получить после дублирования:

int dup2(int fildes, int fildes2);

Файловый дескриптор, подлежащий дублированию, передается в первом аргументе (fildes), а новый дескриптор должен быть равен fildes2. Если дескриптор fildes2 уже занят, сначала выполняется функция close(fildes2).

В качестве примера использования системного вызова dup2(2) рассмотрим вариант реализации слияния потоков в командном интерпретаторе shell:

$ runme >/tmp/file1 2>&1

Фрагмент кода

...

/* Закроем ассоциацию стандартного потока вывода (1)

   с файлом (терминалом) */

close(1);

/* Назначим стандартный поток вывода в файл

   /tmp/file1 (fd==1) */

fd = open("/tmp/file1", O_WRONLY | O_CREAT | O_TRUNC);

/* Выполним слияние потоков */

dup2(fd, 2);

...

 

Функция

lseek(2)

С файловым дескриптором связан файловый указатель, определяющий текущее смещение в файле, начиная с которого будет произведена последующая операция чтения или записи. В свою очередь каждая операция чтения или записи увеличивают значение файлового указателя на число считанных или записанных байт. При открытии файла, файловый указатель устанавливается равным 0 или, если указан флаг O_APPEND, равным размеру файла. С помощью функции lseek(2) можно установить файловый указатель на любое место файла и тем самым обеспечить прямой доступ к любой части файла. Функция имеет следующий вид:

#include

off_t lseek(int fildes, off_t offset, int whence);

Интерпретация аргумента offset зависит от аргумента whence, который может принимать следующие значения:

SEEK_CUR Указатель смещается на offset байт от текущего положения
SEEK_END Указатель смещается на offset байт от конца файла
SEEK_SET Указатель устанавливается равным offset

В случае успеха функция возвращает положительное целое, равное текущему значению файлового указателя.

Относительно системного вызова lseek(2) необходимо сделать два замечания. Во-первых, lseek(2) не инициирует никакой операции ввода/вывода, лишь изменяя значения файлового указателя в файловой таблице ядра. Во-вторых, смещение, указанное в качестве аргумента lseek(2), может выходить за пределы файла. В этом случае, последующие операции записи приведут к увеличению размера файла и, в то же время, к образованию дыры — пространства, формально незаполненного данными. В реальности, дыры заполняются нулями, но могут в ряде случаев привести к неприятным последствиям, с причиной и описанием которых вы сможете ознакомиться в главе 4 при обсуждении внутренней структуры файла.

 

Функция

read(2)

и

readv(2)

Функции read(2) и readv(2) позволяют считывать данные из файла, на который указывает файловый дескриптор, полученный с помощью функций open(2), creat(2), dup(2), dup2(2), pipe(2) или fcntl(2). Функции имеют следующий вид:

#include

ssize_t read(int fildes, void *buf, size_t nbyte);

#include

#include

ssize_t readv(int fildes, struct iovec *iov, int iovcnt);

Аргументы, передаваемые функции read(2), указывают, что следует считать nbyte байт из файла, связанного с дескриптором fildes, начиная с текущего значения файлового указателя. Считанные данные помещаются в буфер приложения, указатель на который передается в аргументе buf. После завершения операции значение файлового указателя будет увеличено на nbyte.

Функция readv(2) позволяет выполнить iovcnt последовательных операций чтения за одно обращение к readv(2). Аргумент iov указывает на массив структур, каждый элемент которого имеет вид:

struct {

 void *iov_base; Указатель на начало буфера

 size_t iov_len; Размер буфера

} iovec;

Функция readv(2) считывает данные из файла и последовательно размещает их в нескольких буферах, определенных массивом iov. Такой характер работы, проиллюстрированный на рис. 2.8, получил название scatter read (от scatter (англ.) — разбрасывать). Общее число считанных байт в нормальной ситуации равно сумме размеров указанных буферов.

Рис. 2.8. Чтение файла с использованием нескольких буферов

 

Функции

write(2)

и

writev(2)

Функции write(2) и writev(2) очень похожи на функции read(2) и readv(2), но используются для записи данных в файл. Функции имеют следующий вид:

#include

ssize_t write(int fildes, void *buf, size_t nbyte);

#include

#include

ssize_t writev(int fildes, struct iovec *iov, int iovcnt);

Аргументы, передаваемые функции write(2), указывают, что следует записать nbyte байт в файл, связанный с дескриптором fildes, начиная с текущего значения файлового указателя. Данные для записи находятся в буфере приложения, указанном аргументом buf. После завершения операции значение файлового указателя будет увеличено на nbyte.

Аналогично функции readv(2), функция writev(2) позволяет выполнить iovcnt последовательных операций записи за одно обращение к writev(2).

Такая операция ввода/вывода получила название gather (собирать), а функции ввода/вывода, использующие набор буферов, — общее название scatter-gather.

 

Функция

pipe(2)

Функция pipe(2) служит для создания однонаправленного (симплексного) канала (также называемого анонимным каналом) обмена данными между двумя родственными процессами. Дело в том, что только родственные процессы (например, родительский и дочерний) имеют возможность получить доступ к одному и тому же каналу. Этот аспект станет более понятным в ходе обсуждения в разделе "Создание и управление процессами" далее в этой главе. Функция имеет вид:

#include

int pipe(int fildes[2]);

Функция возвращает два файловых дескриптора в массиве fildes[], причем fildes[0] служит для чтения данных из канала, a fildes[1] — для записи данных в канал.

Каналы являются одним из способов организации межпроцессного взаимодействия и будут подробно рассмотрены в главе 3. В качестве примера использования pipe(2) можно привести возможность командного интерпретатора — создание программных каналов, рассмотренное в главе 1.

Отметим, что буферизация данных в канале стандартно осуществляется путем выделения дискового пространства в структуре файловой системы. Таким образом, чтение и запись в канал связаны с дисковым вводом/выводом, что, безусловно, сказывается на производительности этого механизма. Современные операционные системы наряду с более совершенными средствами межпроцессного взаимодействия предлагают и более эффективные механизмы каналов. Так, например, SCO UNIX (OpenServer 5.0) обеспечивает работу каналов через специальную файловую систему — HPPS (High Performance Pipe System). С помощью HPPS данные буферизуются в оперативной памяти, что существенно ускоряет операции записи и чтения.

 

Функция

fcntl(2)

После открытия файла и получения ссылки на него в виде файлового дескриптора процесс может производить различные файловые операции. Функция fcntl(2) позволяет процессу выполнить ряд действий с файлом, используя его дескриптор, передаваемый в качестве первого аргумента:

#include

int fcntl (int fildes, int cmd, ...);

Функция fcntl(2) выполняет действие cmd с файлом, а возможный третий аргумент зависит от конкретного действия:

F_DUPFD Разместить новый файловый дескриптор, значение которого больше или равно значению третьего аргумента. Новый файловый дескриптор будет указывать на тот же открытый файл, что и fildes . Действие аналогично вызову функции dup(2) или dup2(2) : fddup = fcntl(fd, F_DUPFD, fildes2)
F_GETFD Возвратить признак сохранения дескриптора при запуске новой программы (выполнении системного вызова exec(2) ) — флаг close-on-exec ( FD_CLOEXEC ). Если флаг установлен, то при вызове exec(2) файл, ассоциированный с данным дескриптором, будет закрыт
F_SETFD Установить флаг close-on-exec согласно значению, заданному третьим аргументом
F_GETFL Возвратить режим доступа к файлу, ассоциированному с данным дескриптором. Флаги, установленные в возвращаемом значении, полностью соответствуют режимам открытия файла, задаваемым функции open(2) . Их значения приведены в табл. 2.8. Рассмотрим пример: oflags = fcntl(fd, F_GETFL, 0); /* Выделим биты, определяющие режим доступа */ accbits = oflags & O_ACCMODE; if (accbits == O_RDONLY) printf("Файл открыт только для чтения\n"); else if (accbits == O_WRONLY) printf("Файл открыт только для записи\n"); else if (accbits == O_RDWR) printf("Файл открыт для чтения и записи\n");
F_SETFL Установить режим доступа к файлу согласно значению, переданному в третьем аргументе. Могут быть изменены только флаги O_APPEND , O_NONBLOCK , O_SYNC и O_ASYNC .
F_GETLK Проверить существование блокирования записи файла. Блокирование записи, подлежащее проверке, описывается структурой flock , указатель на которую передается в качестве третьего аргумента. Если существующие установки не позволяют выполнить блокирование, определенное структурой flock, последняя будет возвращена с описанием текущего блокирования записи. Данная команда не устанавливает блокирование, а служит для проверки его возможности. Более подробно блокирование записей описано в главе 4, в разделе "Блокирование доступа к файлу".
F_SETLK Установить блокирование записи файла. Структура flock описывает блокирование, и указатель на нее передается в качестве третьего аргумента. При невозможности блокирования fcntl(2) возвращается С ошибкой EACCESS или EAGAIN .
F_SETLKW Аналогично предыдущему, но при невозможности блокирования по причине уже существующих блокировок, процесс переходит в состояние сна, ожидая, пока последние будут освобождены. Последняя буква W в названии действия означает wait (ждать).

 

Стандартная библиотека ввода/вывода

Функции, которые мы только что рассмотрели представляют интерфейс ввода/вывода между приложениями и ядром операционной системы. Хотя их использование напоминает использование библиотечных функций С, по существу они представляют собой лишь "обертки" к функциям ядра UNIX, фактически выполняющим операции ввода/вывода.

Однако программисты редко используют этот интерфейс низкого уровня, предпочитая возможности, предоставляемые стандартной библиотекой ввода/вывода. Функции этой библиотеки обеспечивают буферизованный ввод/вывод и более удобный стиль программирования. Для использования функций этой библиотеки в программу должен быть включен файл заголовков . Эти функции входят в стандартную библиотеку С (libc.so или libc.a), которая, как правило, подключается по умолчанию на этапе связывания.

Вместо использования файлового дескриптора библиотека определяет указатель на специальную структуру данных (структура FILE), называемый потоком или файловым указателем. Стандартные потоки ввода/вывода обозначаются символическими именами stdin, stdout, stderr соответственно для потоков ввода, вывода и сообщений об ошибках. Они определены следующим образом:

extern FILE *stdin;

extern FILE *stdout;

extern FILE *stderr;

Связь потоков стандартной библиотеки с файловыми дескрипторами приведена в табл. 2.9.

Таблица 2.9. Стандартные потоки и их дескрипторы

Файловый дескриптор Поток (указатель) Описание
0 stdin Стандартный ввод
1 stdout Стандартный вывод
2 stderr Сообщения об ошибках

Таблица 2.10. Наиболее употребительные функции стандартной библиотеки ввода/вывода

Функция Назначение
fopen(3S) Открывает файл с указанным именем и возвращает файловый указатель, ассоциированный с данным файлом
fclose(3S) Закрывает поток, освобождая буферы
fflush(3S) Очищает буфер потока, открытого на запись
getc(3S) Считывает символ из потока
putc(3S) Записывает символ в поток
gets(3S) Считывает строку из потока
puts(3S) Записывает строку в поток
fread(3S) Считывает указанное число байтов из потока (бинарный ввод)
fwrite(3S) Записывает указанное число байтов в поток (бинарный вывод)
fseek(3S) Позиционирует указатель в потоке
printf(3S) Производит форматированный вывод
scanf(3S) Производит форматированный ввод
fileno(3S) Возвращает файловый дескриптор данного потока

Выбор между функциями интерфейса системных вызовов и стандартной библиотеки зависит от многих факторов, в частности, степени контроля ввода/вывода, переносимости программы, простоты. Взгляните, например, на следующие эквивалентные строки программы:

write (1, "Здравствуй, Мир!\n", 16);

printf("Здравствуй, Мир!\n");

В первой строке сообщение выводится с использованием системной функции write(2), во второй — с помощью библиотечной функции printf(3S). Помимо того, что второй вариант кажется более лаконичным, отметим еще ряд особенностей. В первом варианте пришлось сделать предположение о том, что файловый дескриптор стандартного вывода равен 1, что может оказаться несправедливым для некоторых систем. Также пришлось явно указать число символов в строке, т.к. write(2) не делает никаких предположений о формате вывода, трактуя его как последовательность байтов. В отличие от wite(2), printf(3S) распознает строки, представляющие собой последовательность символов, заканчивающихся нулем. Функция printf(3S) также позволяет отформатировать выводимые данные для представления их в требуемом виде.

Но основным достоинством функций библиотеки является буферизация ввода/вывода, позволяющая минимизировать число системных вызовов read(2) и write(2). При открытии файла и создании потока функции библиотеки автоматически размещают необходимые буферы, позволяя приложению не заботиться о них.

Библиотека предоставляет три типа буферизации:

□ Полная буферизация. В этом случае операция чтения или записи завершается после того, как будет заполнен буфер ввода/вывода. Ввод/вывод для дисковых файлов, как правило, полностью буферизуется. Буфер размещается с помощью функции malloc(3C) при первом обращении к потоку для чтения или записи и заполняется системными вызовами read(2) или write(2). Это означает, что последующие вызовы getc(3S), gets(3S), putc(3S), puts(3S) и т.д. не инициируют обращений к системным функциям, а будут производить чтение или запись из буфера библиотеки. Содержимое буфера очищается (т.е. данные сохраняются на диске) автоматически, либо при вызове функции fflush(3S).

□ Построчная буферизация. В этом случае библиотека выполняет фактический ввод/вывод (т.е. производит системные вызовы read(2) или write(2)) построчно при обнаружении конца строки (символа перевода каретки). Такой тип буферизации обычно используется для ассоциированных с терминальными устройствами потоков, которыми, как правило являются стандартные потоки ввода и вывода.

□ Отсутствие буферизации. В этом случае библиотека не производит никакой буферизации, фактически являясь только программной оболочкой системных вызовов. При этом достигаются минимальные задержки операций чтения и записи, необходимые, например, при выводе сообщений об ошибках. Отсутствие буферизации характерно для стандартного потока вывода сообщений об ошибках.

Характер буферизации может быть изменен с помощью функций:

#include

void setbuf(FILE *stream, char *buf);

int setvbuf(FILE *stream, char *buf, int type, size_t size);

Функция setbuf(3S) позволяет включить или отключить буферизацию для потока stream. В первом случае buf должен указывать на буфер размером BUFSIZ, во втором его значение должно быть равно NULL.

Функция setvbuf(3S) позволяет производить более тонкое управление буферизацией, явно указывая, какой ее тип мы хотим установить. Для этого используется аргумент type, который может принимать следующие значения:

_IOFBF Полная буферизация
_IOLBF Построчная буферизация
_IONBF Отсутствие буферизации

В случае полной или построчной буферизации аргумент size определяет размер буфера, адресованного указателем buf.

Каждый поток стандартной библиотеки представлен указателем на структуру FILE, показанную на рис. 2.9, в которой хранится указатель на буфер _base, указатель на следующий символ, подлежащий чтению или записи _ptr, число байт в буфере _cnt, указатель на файловый дескриптор _file, с которым ассоциирован данный поток, а также флаги состояния потока _flag. При создании буфера библиотека выбирает оптимальный размер для данного потока. Обычно этот размер равен значению поля st_blksize структуры stat, возвращаемой системным вызовом stat(2), рассмотренный в разделе "Метаданные файла" этой главы. Если определить оптимальный размер невозможно, например для каналов или специальных файлов устройств, выбирается стандартное значение BUFSIZ, определенное в файле .

Рис. 2.9. Структуры данных потока

 

Связи

В метаданных каждого файла файловой системы UNIX хранится число связей, определяющее количество имен, которое имеет данный файл. Например, файлы /etc/init.d/lp (или /etc/lp), /etc/rc0.d/K201p, /etc/rc2.d/K201p и /etc/rc2.d/S801p имеют различные имена, но ссылаются на один и тот же физический файл (точнее, метаданные файла) и тем самым обеспечивают доступ к одним и тем же данным. В данном случае число связей файла равно 4. Каждый раз, когда одно из имен файла удаляется, число связей соответственно уменьшается. Когда оно достигнет нуля — данные файла будут удалены. Такой тип связи называется жесткой.

Жесткая связь создается с помощью системного вызова link(2):

#include

int link(const char *existing, const char *new);

При этом будет образована новая запись каталога с именем new и номером inode указывающим на метаданные файла existing. Также будет увеличено число связей. Этим системным вызовом, в частности, пользуется команда ln(1), рассмотренная в главе 1.

Для удаления жесткой связи используется системный вызов unlink(2):

#include

int unlink(const char *path);

Эту функцию вызывает команда rm(1) при удалении файла. При этом не обязательно будут удалены данные файла. Заметим, что системный вызов, явно удаляющий данные файла, отсутствует, поскольку у файла может существовать несколько жестких связей, часть из которых может быть недоступна процессу, вызывающему такую функцию (например, одно из имен файла может быть расположено в недоступном каталоге).

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

Символическая связь позволяет косвенно адресовать другой файл файловой системы. Системный вызов symlink(2) служит для создания символической связи. Этим вызовом, кстати, пользуется команда ln -s.

#include

int symlink (const char *name, const char *synmame);

После создания символической связи, доступ к целевому файлу name может осуществляться с помощью symname. При этом, функция open(2), принимая в качестве аргумента имя символической связи, на самом деле открывает целевой файл. Такая особенность называется следованием символической связи. Не все системные вызовы обладают этим свойством. Например, системный вызов unlink(2), удаляющий запись в каталоге, действует только на саму символическую связь. В противном случае, мы не имели бы возможности удалить ее. В табл. 2.11 показано, как работают с символическими связями различные системные вызовы.

Таблица 2.11. Интерпретация символической связи различными системными вызовами

Системный вызов Следует символической связи Не следует символической связи
access(2) +
chdir(2) +
chmod(2) +
chown(2) +
lchown(2) +
creat(2) +
exec(2) +
link(2) +
mkdir(2) +
mknod(2) +
open(2) +
readlink(2) +
rename(2) +
stat(2) +
lstat(2) +
unlink(2) +

Для чтения содержимого файла — символической связи используется системный вызов readlink(2):

#include

int readlink(const char *path, void *buf, size_t bufsiz);

Аргумент path содержит имя символической связи. В буфере buf размером bufsiz возвращается содержимое файла — символической связи.

Для иллюстрации к вышеприведенным рассуждениям приведем пример программы, которая сначала выводит содержимое символической связи, а затем — целевого файла, пользуясь в обоих случаях символическим именем:

#include

#include

#include

#include

#define BUFSZ 256

/* В качестве аргумента программа принимает имя

   символической связи */

main(int argc, char *argv[]) {

 char buf[BUFSZ+1];

 int nread, fd;

 /* Прочитаем содержимое самой символической связи */

 printf("Читаем символическую связь\n");

 nread = readlink(argv[1], buf, BUFSZ);

 if (nread < 0) {

  perror("readlink");

  exit(1);

 }

 /* readlink не завершает строку '\0' */

 printf("Символическая связь:\n %s\n", buf);

 /* Теперь прочитаем содержимое целевого файла */

 printf("Читаем целевой файл\n");

 fd = open(argv[1], O_RDONLY);

 if (fd < 0) {

  perror("open");

  exit(2);

 }

 nread = read(fd, buf, BUFSIZ);

 if (nread < 0) {

  perror("read");

  exit(3);

 }

 buf[nread] = '\0';

 printf("Целевой файл:\n %s\n", buf);

 close(fd);

 exit(0);

}

Перед тем как запустить программу, создадим символическую связь с файлом unix0.txt:

$ ln -s unix0.txt symlink.txt

$ ls -l

lrwxrwxrwx 1 andy user  10 Jan 6 09:54 symlink.txt -> unix0.txt

-rw-r--r-- 1 andy user 498 Jan 6 09:53 unix0.txt

$ a.out symlink.txt

Читаем символическую связь

Символическая связь:

unix0.txt

Читаем целевой файл

Целевой файл:

Начиная с 1975 года фирма AT&T начала предоставлять лицензии на

использование операционной системы как научно-образовательным

учреждениям, так и коммерческим организациям. Поскольку основная

часть системы поставлялась в исходных текстах, написанных на

языке С, опытным программистам не требовалось детальной

документации, чтобы разобраться в архитектуре UNIX. С ростом

популярности микропроцессоров

...

 

Файлы, отображаемые в памяти

Системный вызов mmap(2) предоставляет механизм доступа к файлам, альтернативный вызовам read(2) и write(2). С помощью этого вызова процесс имеет возможность отобразить участки файла в собственное адресное пространство. После этого данные файла могут быть получены или записаны путем чтения или записи в память. Функция mmap(2) определяется следующим образом:

#include

#include

caddr_t mmap(caddr_t addr, size_t len, int prot,

 int flags, int fildes, off_t off);

Этот вызов задает отображение len байтов файла с дескриптором fildes, начиная со смещения off, в область памяти со стартовым адресом addr. Разумеется, перед вызовом mmap(2) файл должен быть открыт с помощью функции open(2). Аргумент prot определяет права доступа к области памяти, которые должны соответствовать правам доступа к файлу, указанным в системном вызове open(2). В табл. 2.12 приведены возможные значения аргумента prot и соответствующие им права доступа к файлу. Возможно логическое объединение отдельных значений prot. Так значение PROT_READ | PROT_WRITE соответствует доступу O_RDWR к файлу.

Таблица 2.12. Права доступа к области памяти

Значение аргумента prot Описание Права доступа к файлу
PROT_READ Область доступна для чтения r
PROT_WRITE Область доступна для записи w
PROT_EXEC Область доступна для исполнения x
PROT_NONE Область недоступна -

Обычно значение addr задается равным 0, что позволяет операционной системе самостоятельно выбрать виртуальный адрес начала области отображения. В любом случае, при успешном завершении возвращаемое системным вызовом значение определяет действительное расположение области памяти.

Операционная система округляет значение len до следующей страницы виртуальной памяти. Например, если размер файла 96 байтов, а размер страницы 4 Кбайт, то система все равно выделит область памяти размером 4096 байтов. При этом 96 байтов займут собственно данные файла, а остальные 4000 байтов будут заполнены нулями. Процесс может модифицировать и оставшиеся 4000 байтов, но эти изменения не отразятся на содержимом файла. При обращении к участку памяти, лежащему за пределами файла, ядро отправит процессу сигнал SIGBUS. Несмотря на то что область памяти может превышать фактический размер файла, процесс не имеет возможности изменить его размер.

Использование права на исполнение (prot = PROT_EXEC) позволяет процессу определить собственный механизм загрузки кода. В частности, такой подход используется редактором динамических связей при загрузке динамических библиотек, когда библиотека отображается в адресное пространство процесса. Значение PROT_NONE позволяет приложению определить собственные механизмы контроля доступа к разделяемым объектам (например, к разделяемой памяти), разрешая или запрещая доступ к области памяти.

Аргумент flags определяет дополнительные особенности управления областью памяти. В табл. 2.13 приведены возможные типы отображения, определяемые аргументом flags.

Таблица 2.13. Типы отображения

Значение аргумента flags Описание
MAP SHARED Область памяти может совместно использоваться несколькими процессами
MAP PRIVATE Область памяти используется только вызывающим процессом
MAP_FIXED Требует выделения памяти, начиная точно с адреса addr
MAP_NORESERVE He требует резервирования области свопинга

В случае указания MAP_PRIVATE, для процесса, определившего этот тип отображения, будет создана собственная копия страницы памяти, которую он пытается модифицировать. Заметим, что копия будет создана только при вызове операции записи, до этого остальные процессы, определившие тип отображения как MAP_SHARED могут совместно использовать одну и ту же область памяти.

Не рекомендуется использовать флаг MAP_FIXED, т.к. это не позволяет системе максимально эффективно распределить память. В случае отсутствия этого флага, ядро пытается выделить область памяти, начиная с адреса наиболее близкого к значению addr. Если же значение addr установлено равным 0, операционная система получает полную свободу в размещении области отображения.

Отображение автоматически снимается при завершении процесса. Процесс также может явно снять отображение с помощью вызова munmap(2). Закрытие файла не приводит к снятию отображения. Следует отметить, что снятие отображения непосредственно не влияет на отображаемый файл, т. е. содержимое страниц области отображения не будет немедленно записано на диск. Обновление файла производится ядром согласно алгоритмам управления виртуальной памятью. В то же время в ряде систем существует функция msync(3C), которая позволяет синхронизировать обновление памяти с обновлением файла на диске.

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

#include

#include

#include

#include

#include

main(int argc, char *argv[]) {

 int fd_src, fd_dst;

 caddr_t addr_src, addr_dst;

 struct stat filestat;

 /* Первый аргумент - исходный файл, второй - целевой */

 fd_dst=open(argv[2], O_RDWR | O_CREAT);

 /* Определим размер исходного файла */

 fstat(fd_src, &filestat);

 /* Сделаем размер целевого файла равным исходному */

 lseek(fd_dst, filestat.st_size - 1, SEEK_SET);

 /* Зададим отображение */

 addr_src=mmap((caddr_t)0, filestat.st_size,

  PROT_READ, MAP_SHARED, fd_src, 0);

 addr_dst=mmap((caddr_t)0, filestat.st_size,

  PROT_READ | PROT_WRITE, MAP_SHARED, fd_dst, 0);

 /* Копируем области памяти */

 memcpy(addr_dst, addr_src, filestat.st_size);

 exit(0);

}

Поскольку, как обсуждалось выше, с помощью вызова mmap(2) нельзя изменить размер файла, это было сделано с помощью вызова lseek(2) с последующей записью одного байта так, что размер целевого файла стал равным размеру исходного. При этом в целевом файле образуется "дыра", которая, к счастью, сразу же заполняется содержимым копируемого файла.

 

Владение файлами

Владелец-пользователь и владелец-группа файла могут быть изменены с помощью системных вызовов chown(2), fchown(2) и lchown(2):

#include

#include

int chown(const char *path, uid_t owner, gid_t group);

int fchown(int fildes, uid_t owner, gid_t group);

int lchown(const char *path, uid_t owner, gid_t group);

Все три вызова работают одинаково за исключением ситуации, когда адресуемый файл является символической связью. В последнем случае вызов lchown(2) действует на сам файл — символическую связь, а не на целевой файл (т.е. не следует символической связи). В функциях chown(2) и lchown(2) файл адресуется по имени, а в fchown(2) — по файловому дескриптору. Если значение owner или group установлено равным -1, соответствующий владелец файла не изменяется.

В версиях BSD UNIX только суперпользователь может изменить владение файлом. Это ограничение призвано, в первую очередь, не допустить "скрытие" файлов под именем другого пользователя, например, при установке квотирования ресурсов файловой системы. Владельца-группу можно изменить только для файлов, которыми вы владеете, причем им может стать одна из групп, членом которой вы являетесь. Эти же ограничения определены и стандартом POSIX.1.

В системах ветви System V эти ограничения являются конфигурируемыми, и в общем случае в UNIX System V пользователь может изменить владельца собственных файлов.

В случае успешного изменения владельцев файла биты SUID и SGID сбрасываются, если процесс, вызвавший chown(2) не обладает правами суперпользователя.

 

Права доступа

Как уже обсуждалось в предыдущей главе, каждый процесс имеет четыре пользовательских идентификатора — UID, GID, EUID и EGID. В то время как UID и GID определяют реального владельца процесса, EUID и EGID определяют права доступа процесса к файлам в процессе выполнения. В общем случае реальные и эффективные идентификаторы эквивалентны. Это значит, что процесс имеет те же привилегии, что и пользователь, запустивший его. Однако, как уже обсуждалось выше, возникают ситуации, когда процесс должен получить дополнительные привилегии, чаще всего — привилегии суперпользователя. Это достигается установкой битов SUID и SGID. Примером такого процесса может служить утилита passwd(1), изменяющая пароль пользователя.

Права доступа к файлу могут быть изменены с помощью системных вызовов chmod(2) и fchmod(2):

#include

#include

int chmod(const char *path, mode_t mode);

int fchmod(int fildes, mode_t mode);

Значение аргумента mode определяет устанавливаемые права доступа и дополнительные атрибуты (такие как SUID, SGID и Sticky bit), и создается путем логического объединения различных флагов, представленных в табл. 2.14. Вторая колонка таблицы содержит восьмеричные значения для девяти битов прав доступа (чтение, запись и выполнение для трех классов доступа) и трех битов дополнительных атрибутов.

Таблица 2.14. Флаги аргумента mode

Флаг Биты Значение
S_ISUID 04000 Установить бит SUID
S_ISGID 020#0 Установить бит SGID, если # равно 7, 5, 3 или 1. Установить обязательное блокирование файла, если # равно 6, 4, 2 или 0
S_ISVTX 01000 Установить Sticky bit
S_IRWXU 00700 Установить право на чтение, запись и выполнение для владельца-пользователя
S_IRUSR 00400 Установить право на чтение для владельца-пользователя
S_IWUSR 00200 Установить право на запись для владельца-пользователя
S_IXUSR 00100 Установить право на выполнение для владельца-пользователя
S_IRWXG 00070 Установить право на чтение, запись и выполнение для владельца-группы
S_IRGRP 00040 Установить право на чтение для владельца-группы
S_IWGRP 00020 Установить право на запись для владельца-группы
S_IXGRP 00010 Установить право на выполнение для владельца-группы
S_IRWXO 00007 Установить право на чтение, запись и выполнение для остальных пользователей
S_IROTH 00004 Установить право на чтение для остальных пользователей
S_IWOTH 00002 Установить право на запись для остальных пользователей
S_IXOTH 00001 Установить право на выполнение для остальных пользователей

Некоторые флаги, представленные в таблице, уже являются объединением нескольких флагов. Так, например, флаг S_RWXU эквивалентен S_IRUSR | S_IWUSR | S_IXUSR. Значение флага S_ISGID зависит от того, установлено или нет право на выполнение для группы (S_IXGRP). В первом случае, он будет означать установку SGID, а во втором — обязательное блокирование файла.

Для иллюстрации приведем небольшую программу, создающую файл с полными правами доступа для владельца, а затем изменяющую их. После каждой установки прав доступа в программе вызывается библиотечная функция system(3S), позволяющая запустить утилиту ls(1) и отобразить изменение прав доступа и дополнительных атрибутов.

#include

#include

#include

main() {

 int fd;

 /* Создадим файл с правами rwx------ */

 fd = creat("my_file", S_IRUSR | S_IWUSR | S_IXUSR);

 system("ls -l my_file");

 /*Добавим флаг SUID */

 fchmod(fd, S_IRWXU | S_ISUID);

 /* Установим блокирование записей файла */

 fchmod(fd, S_IRWXU | S_ISUID | S_ISGID);

 system("ls -l my_file");

 /* Теперь установим флаг SGID */

 fchmod(fd, S_IRWXU | S_ISUID | S_ISGID | S_IXGRP);

 system("ls -l my_file");

}

В результате запуска программы на выполнение, получим следующий вывод:

$ a.out

-rwx------ 1 andy user 0 Jan 6 19:28 my_file

-rws------ 1 andy user 0 Jan 6 19:28 my_file

-rws--1--- 1 andy user 0 Jan 6 19:28 my_file

-rws--s--- 1 andy user 0 Jan 6 19:28 my_file

 

Перемещение по файловой системе

Каждый процесс имеет два атрибута, связанных с файловой системой — корневой каталог (root directory) и текущий рабочий каталог (current working directory). Когда некоторый файл адресуется по имени (например, в системных вызовах open(2), creat(2) или readlink(2)), ядро системы производит поиск файла, начиная с корневого каталога, если имя файла задано как абсолютное, либо текущего каталога, если имя файла является относительным. Абсолютное имя файла начинается с символа '/', обозначающего корневой каталог. Все остальные имена файлов являются относительными. Например, имя /usr/bin/sh является абсолютным, в то время как mydir/test1.c или ../andy/mydir/test1.c — относительным, при котором фактическое расположение файла в файловой системе зависит от текущего каталога.

Процесс может изменить свой корневой каталог с помощью системного вызова chroot(2) или fchroot(2).

#include

int chroot (const char *path);

int fchroot(int fildes);

После этого поиск всех адресуемых файлов с абсолютными именами будет производиться, начиная с нового каталога, указанного аргументом path. Например, после изменения корневого каталога на домашний каталог пользователя абсолютное имя инициализационного скрипта .profile станет /.profile.

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

Процесс также может изменить и текущий каталог. Для этого используются системные вызовы chdir(2) или fchdir(2):

#include

int chdir(const char* path);

int fchdir(int fildes);

Например, внутренняя команда командного интерпретатора cd может быть реализована следующим кодом:

...

char newdir[PATH_MAX];

...

/* Предположим, что имя нового каталога,

   введенного пользователем, уже находится

   в переменной newdir*/

if (chdir(newdir) == -1) perror("sh: cd");

...

 

Метаданные файла

Как уже говорилось, каждый файл помимо собственно данных содержит метаданные, описывающие его характеристики, например, владельцев, права доступа, тип и размер файла, а также содержащие указатели на фактическое расположение данных файла. Метаданные файла хранятся в структуре inode. Часть полей этой структуры могут быть получены с помощью системных вызовов stat(2):

#include

#include

int stat(const char *path, struct stat *buf);

int lstat (const char *path, struct stat *buf);

int fstat(int fildes, struct stat *buf);

В качестве аргумента функции принимают имя файла или файловый дескриптор (fstat(2)) и возвращают заполненные поля структуры stat, которые приведены в табл. 2.15.

Таблица 2.15. Поля структуры stat

Поле Значение
mode_t st_mode Тип файла и права доступа
ino_t st_ino Номер inode. Поля st_ino и st_dev однозначно определяют обычные файлы
dev_t st_dev Номер устройства, на котором расположен файл (номер устройства файловой системы)
dev_t st_rdev Для специального файла устройства содержит номер устройства, адресуемого этим файлом
nlink_t st_link Число жестких связей
uid_t st_uid Идентификатор пользователя-владельца файла
gid_t st_gid Идентификатор группы-владельца файла
off_t st_size Размер файла в байтах. Для специальных файлов устройств это поле не определено
time_t st_atime Время последнего доступа к файлу
time_t st_mtime Время последней модификации данных файла
time_t st_ctime Время последней модификации метаданных файла
long st_blksize Оптимальный размер блока для операций ввода/вывода. Для специальных файлов устройств и каналов это поле не определено
long st_blocks Число размещенных 512-байтовых блоков хранения данных. Для специальных файлов устройств это поле не определено

Для определения типа файла служат следующие макроопределения, описанные в файле :

Таблица 2.16. Определение типа файла

Макроопределение Тип файла
S_ISFIFO(mode) FIFO
S_ISCHR(mode) Специальный файл символьного устройства
S_ISDIR(mode) Каталог
S_ISBLK(mode) Специальный файл блочного устройства
S_ISREG(mode) Обычный файл
S_ISLNK(mode) Символическая связь
S_ISSOCK(mode) Сокет

Все значения времени, связанные с файлом (время доступа, модификации данных и метаданных) хранятся в секундах, прошедших с 0 часов 1 января 1970 года. Заметим, что информация о времени создания файла отсутствует.

Приведенная ниже программа выводит информацию о файле, имя которого передается ей в качестве аргумента:

#include

#include

#include

main(int argc, char *argv[]) {

 struct stat s;

 char* ptype;

 lstat(argv[1] , &s); /* Определим тип файла */

 if (S_ISREG(s.st_mode)) ptype = "Обычный файл";

 else if (S_ISDIR(s.st_mode)) ptype = "Каталог";

 else if (S_ISLNK(s.st_mode)) ptype = "Симв. Связь";

 else if (S_ISCHR(s.st_mode)) ptype = "Симв. Устройство";

 else if (S_ISBLK(s.st_mode)) ptype = "Бл.устройство";

 else if (S_ISSOCK(s.st_mode)) ptype = "Сокет";

 else if (S_ISFIFO(s.st_mode)) ptype = "FIFO";

 else ptype = "Неизвестный тип";

 /* Выведем информацию о файле */

 /* Его тип */

 printf("type = %s\n", ptype);

 /* Права доступа */

 printf("perm =%o\n", s.st_mode & S_IAMB);

 /* Номер inode */

 printf("inode = %d\n", s.st_ino);

 /* Число связей */

 printf("nlink = %d\n", s.st_nlink);

 /* Устройство, на котором хранятся данные файла */

 printf("dev = (%d, %d)\n", major(s.st_dev), minor(s.st_dev));

 /* Владельцы файла */

 printf("UID = %d\n", s.st_uid);

 printf("GID = %d\n", s.st_gid);

 /* Для специальных файлов устройств - номера устройства */

 printf("rdev = (%d, %d)\n", major(s.st_rdev),

 minor(s.st_rdev));

 /* Размер файла */

 printf("size = %d\n", s.st_size);

 /* Время доступа, модификации и модификации метаданных */

 printf("atime = %s", ctime(&s.st_atime));

 printf("mtime = %s", ctime(&s.st_mtime));

 printf("ctime = %s", ctime(&s.st_ctime));

}

Программа использует библиотечные функции major(3C) и minor(3C), возвращающие, соответственно, старший и младший номера устройства. Функция ctime(3C) преобразует системное время в удобный формат.

Запуск программы на выполнение приведет к следующим результатам:

$ а.out ftype.c

type = Обычный файл

perm = 644

inode = 13

nlink = 1

dev = (1, 42)

UID = 286

GID = 100

rdev = (0, 0)

size = 1064

atime = Wed Jan 8 17:25:34 1997

mtime = Wed Jan 8 17:19:27 1997

ctime = Wed Jan 8 17:19:27 1997

$ ls -il /tmp/ftype.c

13 -rw-r--r-- 1 andy user 1064 Jan 8 17:19 ftype.c

 

Процессы

 

В главе 1 уже упоминались процессы. Однако знакомство ограничивалось пользовательским, или командным интерфейсом операционной системы. В этом разделе попробуем взглянуть на них с точки зрения программиста.

Процессы являются основным двигателем операционной системы. Большинство функций выполняется ядром требованию того или иного процесса. Выполнение этих функций контролируется привилегиями процесса, которые соответствуют привилегиям пользователя, запустившего его.

В этом разделе рассматриваются:

□ Идентификаторы процесса

□ Программный интерфейс управления памятью: системные вызовы низкого уровня и библиотечные функции, позволяющие упростить управление динамической памятью процесса.

□ Важнейшие системные вызовы, обеспечивающие создание нового процесса и запуск новой программы. Именно с помощью этих вызовов создается существующая популяция процессов в операционной системе и ее функциональность.

□ Сигналы и способы управления ими. Сигналы можно рассматривать как элементарную форму межпроцессного взаимодействия, позволяющую процессам сообщать друг другу о наступлении некоторых событий. Более мощные средства будут рассмотрены в разделе "Взаимодействие между процессами" главы 3.

□ Группы и сеансы; взаимодействие процесса с пользователем.

□ Ограничения, накладываемые на процесс, и функции, которые позволяют управлять этими ограничениями.

 

Идентификаторы процесса

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

Однако нельзя не отметить еще четыре идентификатора, играющие решающую роль при доступе к системным ресурсам: идентификатор пользователя UID, эффективный идентификатор пользователя EUID, идентификатор группы GID и эффективный идентификатор группы EGID. Эти идентификаторы определяют права процесса в файловой системе, и как следствие, в операционной системе в целом. Запуская различные команды и утилиты, можно заметить, что порожденные этими командами процессы полностью отражают права пользователя UNIX. Причина проста — все процессы, которые запускаются, имеют идентификатор пользователя и идентификатор группы. Исключение составляют процессы с установленными флагами SUID и SGID.

При регистрации пользователя в системе утилита login(1) запускает командный интерпретатор, — login shell, имя которого является одним из атрибутов пользователя. При этом идентификаторам UID (EUID) и GID (EGID) процесса shell присваиваются значения, полученные из записи пользователя в файле паролей /etc/passwd. Таким образом, командный интерпретатор обладает правами, определенными для данного пользователя.

При запуске программы командный интерпретатор порождает процесс, который наследует все четыре идентификатора и, следовательно, имеет те же права, что и shell. Поскольку в конкретном сеансе работы пользователя в системе прародителем всех процессов является login shell, то и их пользовательские идентификаторы будут идентичны.

Казалось бы, эту стройную систему могут "испортить" утилиты с установленными флагами SUID и SGID. Но не стоит волноваться — как правило, такие программы не позволяют порождать другие процессы, в противном случае, эти утилиты необходимо немедленно уничтожить!

На рис. 2.10. показан процесс наследования пользовательских идентификаторов в рамках одного сеанса работы.

Рис. 2.10. Наследование пользовательских идентификаторов

Для получения значений идентификаторов процесса используются следующие системные вызовы:

#include

#include

uid_t getuid(void);

uid_t geteuid(void);

gid_t getgid(void);

gid_t getegid(void);

Эти функции возвращают для сделавшего вызов процесса соответственно реальный и эффективный идентификаторы пользователя и реальный и эффективный идентификаторы группы.

Процесс также может изменить значения этих идентификаторов с помощью системных вызовов:

#include

#include

int setuid(uid_t uid);

int setegid(gid_t egid);

int seteuid(uid_t euid);

int setgid(gid_t gid);

Системные вызовы setuid(2) и setgid(2) устанавливают сразу реальный и эффективный идентификаторы, а системные вызовы seteuid(2) и setegid(2) — только эффективные.

Ниже приведен фрагмент программы login(1), изменяющей идентификаторы процесса на значения, полученные из записи файла паролей. В стандартной библиотеке имеется ряд функций работы с записями файла паролей, каждая из которых описывается структурой passwd, определенной в файле . Поля этой структуры приведены в табл. 2.17.

Таблица 2.17. Поля структуры passwd

Поле Значение
char *pw_name Имя пользователя
char *pw_passwd Строка, содержащая пароль в зашифрованном виде; из соображения безопасности в большинстве систем пароль хранится в файле /etc/shadow, а это поле не используется
uid_t pw_uid Идентификатор пользователя
gid_t pw_gid Идентификатор группы
char *pw_gecos Комментарий (поле GECOS), обычно реальное имя пользователя и дополнительная информация
char *pw_dir Домашний каталог пользователя
char *pw_shell Командный интерпретатор

Функция, которая потребуется для нашего примера, позволяет получить запись файла паролей по имени пользователя. Она имеет следующий вид:

#include

struct passwd *getpwnam(const char *name);

Итак, перейдем к фрагменту программы:

...

struct passwd *pw;

char logname[MAXNAME];

/* Массив аргументов при запуске

   командного интерпретатора */

char *arg[MAXARG];

/* Окружение командного интерпретатора */

char *envir[MAXENV];

...

/* Проведем поиск записи пользователя с именем logname,

   которое было введено на приглашение "login:" */

pw = getpwnam(logname);

/* Если пользователь с таким именем не найден, повторить

   приглашение */

if (pw == 0)

 retry();

 /* В противном случае установим идентификаторы процесса

    равными значениям, полученным из файла паролей и запустим

    командный интерпретатор */

else {

 setuid(pw->pw_uid);

 setgid(pw->pw_gid);

 execve(pw->pw_shell, arg, envir);

}

...

Вызов execve(2) запускает на выполнение программу, указанную в первом аргументе. Мы рассмотрим эту функцию в разделе "Создание и управление процессами" далее в этой главе.

 

Выделение памяти

При обсуждении формата исполняемых файлов и образа программы в памяти мы отметили, что сегменты данных и стека могут изменять свои размеры. Если для стека операцию выделения памяти операционная система производит автоматически, то приложение имеет возможность управлять ростом сегмента данных, выделяя дополнительную память из хипа (heap — куча). Рассмотрим этот программный интерфейс.

Память, которая используется сегментами данных и стека, может быть выделена несколькими различными способами как во время создания процесса, так и динамически во время его выполнения. Существует четыре способа выделения памяти:

1. Переменная объявлена как глобальная, и ей присвоено начальное значение в исходном тексте программы, например:

char ptype = "Unknown file type";

Строка ptype размещается в сегменте инициализированных данных исполняемого файла, и для нее выделяется соответствующая память при создании процесса.

2. Значение глобальной переменной неизвестно на этапе компиляции, например:

char ptype[32];

В этом случае место в исполняемом файле для ptype не резервируется, но при создании процесса для данной переменной выделяется необходимое количество памяти, заполненной нулями, в сегменте BSS.

3. Переменные автоматического класса хранения, используемые в функциях программы, используют стек. Память для них выделяется при вызове функции и освобождается при возврате. Например:

func1() {

 int a;

 char *b;

 static int с = 4;

 ...

}

В данном примере переменные а и b размещаются в сегменте стека. Переменная с размешается в сегменте инициализированных данных и загружается из исполняемого файла либо во время создания процесса, либо в процессе загрузки страниц по требованию. Более подробно страничный механизм описан в главе 3.

4. Выделение памяти явно запрашивается некоторыми системными вызовами или библиотечными функциями. Например, функция malloc(3C) запрашивает выделение дополнительной памяти, которая в дальнейшем используется для динамического размещения данных. Функция ctime(3C), предоставляющая системное время в удобном формате, также требует выделения памяти для размещения строки, содержащей значения текущего времени, указатель на которую возвращается программе.

Напомним, что дополнительная память выделяется из хипа (heap) — области виртуальной памяти, расположенной рядом с сегментом данных, размер которой меняется для удовлетворения запросов на размещение. Следующий за сегментом данных адрес называется разделительным или брейк-адресом (break address). Изменение размера сегмента данных по существу заключается в изменении брейк-адреса. Для изменения его значения UNIX предоставляет процессу два системных вызова — brk(2) и sbrk(2).

#include

int brk(void *endds);

void *sbrk(int incr);

Системный вызов brk(2) позволяет установить значение брейк-адреса равным endds и, в зависимости от его значения, выделяет или освобождает память (рис. 2.11). Функция sbrk(2) изменяет значение брейк-адреса на величину incr. Если значение incr больше 0, происходит выделение памяти, в противном случае, память освобождается.

Рис 2.11. Динамическое выделение памяти с помощью brk(2)

Существуют четыре стандартные библиотечные функции, предназначенные для динамического выделения/освобождения памяти.

#include

void *malloc(size_t size);

void *calloc(size_t nelem, size_t elsize);

void *realloc(void *ptr, size_t size);

void free(void *ptr);

Функция malloc(3C) выделяет указанное аргументом size число байтов.

Функция calloc(3C) выделяет память для указанного аргументом nelem числа объектов, размер которых elsize. Выделенная память инициализируется нулями.

Функция realloc(3C) изменяет размер предварительно выделенной области памяти (увеличивает или уменьшает, в зависимости от знака аргумента size). Увеличение размера может привести к перемещению всей области в другое место виртуальной памяти, где имеется необходимое свободное непрерывное виртуальное адресное пространство.

Функция free(3C) освобождает память, предварительно выделенную с помощью функций malloc(3C), calloc(3C) или realloc(3C), указатель на которую передается через аргумент ptr.

Указатель, возвращаемый функциями malloc(3C), calloc(3C) и realloc(3C), соответствующим образом выровнен, таким образом выделенная память пригодна для хранения объектов любых типов. Например, если наиболее жестким требованием по выравниванию в системе является размещение переменных типа double по адресам, кратным 8, то это требование будет распространено на все указатели, возвращаемыми этими функциями.

Упомянутые библиотечные функции обычно используют системные вызовы sbrk(2) или brk(2). Хотя эти системные вызовы позволяют как выделять, так и освобождать память, в случае библиотечных функций память реально не освобождается, даже при вызове free(3C). Правда, с помощью функций malloc(3C), calloc(3C) или realloc(3C) можно снова выделить и использовать эту память и снова освободить ее, но она не передается обратно ядру, а остается в пуле malloc(3C).

Для иллюстрации этого положения приведем небольшую программу, выделяющую и освобождающую память с помощью функций malloc(3C) и free(3C), соответственно. Контроль действительного значения брейк-адреса осуществляется с помощью системного вызова sbrk(2):

#include

#include

main() {

 char *obrk;

 char *nbrk;

 char *naddr;

 /* Определим текущий брейк-адрес */

 obrk = sbrk(0);

 printf("Текущий брейк-адрес= 0x%x\n", obrk);

 /* Выделим 64 байта из хипа */

 naddr = malloc(64);

 /* Определим новый брейк-адрес */

 nbrk = sbrk(0);

 printf("Новый адрес области malloc= 0x%x,"

  " брейк-адрес= 0х%x (увеличение на %d байтов)\n",

  naddr, nbrk, nbrk — obrk);

 /* "Освободим" выделенную память и проверим, что произошло

    на самом деле */

 free(naddr);

 printf("free(0x%x)\n", naddr);

 obrk = sbrk(0);

 printf("Новый брейк-адрес= 0x%x (увеличение на %d байтов)\n",

  obrk, obrk — nbrk);

}

Откомпилируем и запустим программу:

$ a.out

Текущий брейк-адрес= 0x20ac0

malloc(64)

Новый адрес области malloc = 0x20ac8, брейк-адрес = 0x22ac0

(увеличение на 8192 байтов)

free(0x20ac8)

Новый брейк-адрес = 0x22ac0 (увеличение на 0 байтов)

$

Как видно из вывода программы, несмотря на освобождение памяти функцией free(3C), значение брейк-адреса не изменилось. Также можно заметить, что функция malloc(3C) выделяет больше памяти, чем требуется. Дополнительная память выделяется для необходимого выравнивания и для хранения внутренних данных malloc(3C), таких как размер области, указатель на следующую область и т.п.

 

Создание и управление процессами

Работая в командной строке shell вы, возможно, не задумывались, каким образом запускаются программы. На самом деле каждый раз порождается новый процесс, а затем загружается программа. В UNIX эти два этапа четко разделены. Соответственно система предоставляет два различных системных вызова: один для создания процесса, а другой для запуска новой программы.

Новый процесс порождается с помощью системного вызова fork(2):

#include

#include

pid_t fork(void);

Порожденный, или дочерний процесс, хотя это кажется странным, является точной копией процесса, выполнившего этот вызов, или родительского процесса. В частности, дочерний процесс наследует такие атрибуты родителя, как:

□ идентификаторы пользователя и группы,

□ переменные окружения,

□ диспозицию сигналов и их обработчики,

□ ограничения, накладываемые на процесс,

□ текущий и корневой каталог,

□ маску создания файлов,

□ все файловые дескрипторы, включая файловые указатели,

□ управляющий терминал.

Более того, виртуальная память дочернего процесса не отличается от образа родительского: такие же сегменты кода, данных, стека, разделяемой памяти и т.д. После возврата из вызова fork(2), который происходит и в родительский и в дочерний процессы, оба начинают выполнять одну и ту же инструкцию.

Легче перечислить немногочисленные различия между этими процессами, а именно:

□ дочернему процессу присваивается уникальный идентификатор PID.

□ идентификаторы родительского процесса PPID у этих процессов различны,

□ дочерний процесс свободен от сигналов, ожидающих доставки,

□ значение, возвращаемое системным вызовом fork(2) различно для родителя и потомка.

Последнее замечание требует объяснения. Как уже говорилось, возврат из функции fork(2) происходит как в родительский, так и в дочерний процесс. При этом возвращаемое родителю значение равно PID дочернего процесса, а дочерний, в свою очередь, получает значение, равное 0. Если fork(2) возвращает -1, то это свидетельствует об ошибке (естественно, в этом случае возврат происходит только в процесс, выполнивший системный вызов).

В возвращаемом fork(2) значении заложен большой смысл, поскольку оно позволяет определить, кто является родителем, а кто — потомком, и соответственно разделить функциональность. Поясним это на примере:

main() {

 int pid;

 pid = fork();

 if (pid == -1) {

  perror("fork");

  exit(1);

 }

 if (pid == 0) {

  /* Эта часть кода выполняется дочерним процессом */

  printf("Потомок\n");

 } else {

  /* Эта часть кода выполняется родительским процессом */

  printf("Родитель\n");

 }

}

Таким образом, порождение нового процесса уже не кажется абсолютно бессмысленным, поскольку родитель и потомок могут параллельно выполнять различные функции. В данном случае, это вывод на терминал различных сообщений, однако можно представить себе и более сложные приложения. В частности, большинство серверов, одновременно обслуживающих несколько запросов, организованы именно таким образом: при поступлении запроса порождается процесс, который и выполняет необходимую обработку. Родительский процесс является своего рода супервизором, принимающим запросы и распределяющим их выполнение. Очевидным недостатком такого подхода является то, что вся функциональность по-прежнему заложена в одном исполняемом файле и, таким образом, ограничена.

UNIX предлагает системный вызов, предназначенный исключительно для запуска программ, т.е. загрузки другого исполняемого файла. Это системный вызов exec(2), представленный на программном уровне несколькими модификациями:

#include

int execl(const char *path, const char *arg0, ... ,

 const char *argn, char * /* NULL */);

int execv(const char* path, char* const argv[]);

int execle(const char *path, char *const arg0[], ... ,

 const char *argn, char* /* NULL */, char *const envp[]);

int execve(const char* path, char const argv[],

 char *const envp[]);

int execlp(const char *file, const char *arg0, ... ,

 const char* argn, char * /* NULL */);

int execvp(const char *file, char *const argv[]);

Все эти функции по существу являются надстройками системного вызова execve(2), который в качестве аргументов получает имя запускаемой программы (исполняемого файла), набор аргументов и список переменных окружения. После выполнения execve(2) не создается новый процесс, а образ существующего полностью заменяется на образ, полученный из указанного исполняемого файла. На рис. 2.12 показано, как связаны между собой приведенные выше функции.

Рис. 2.12. Семейство функций exec(2)

В отличие от вызова fork(2), новая программа наследует меньше атрибутов. В частности, наследуются:

□ идентификаторы процесса PID и PPID,

□ идентификаторы пользователя и группы,

□ эффективные идентификаторы пользователя и группы (в случае, если для исполняемого файла не установлен флаг SUID или SGID),

□ ограничения, накладываемые на процесс,

□ текущий и корневой каталоги,

□ маска создания файлов,

□ управляющий терминал,

□ файловые дескрипторы, для которых не установлен флаг FD_CLOEXEC.

Наследование характеристик процесса играет существенную роль в работе операционной системы. Так наследование идентификаторов владельцев процесса гарантирует преемственность привилегий и, таким образом, неизменность привилегий пользователя при работе в UNIX. Наследование файловых дескрипторов позволяет установить направления ввода/вывода для нового процесса или новой программы. Именно так действует командный интерпретатор. Мы вернемся к вопросу о наследовании в главе 3.

В главе 1 уже говорилось о частом объединении вызовов fork(2) и exec(2), получившем специальное название fork-and-exec. Таким образом загружается подавляющее большинство программ, которые выполняются в системе.

При порождении процесса, который впоследствии может загрузить новую программу, "родителю" может быть небезынтересно узнать о завершении выполнения "потомка". Например, после того как запущена утилита ls(1), командный интерпретатор приостанавливает свое выполнение до завершения работы утилиты и только после этого выдает свое приглашение на экран. Можно привести еще множество ситуаций, когда процессам необходимо синхронизировать свое выполнение с выполнением других процессов. Одним из способов такой синхронизации является обработка родителем сигнала SIGCHLD, отправляемого ему при "смерти" потомка. Механизм сигналов мы рассмотрим в следующем разделе. Сейчас же остановимся на другом подходе.

Операционная система предоставляет процессу ряд функций, позволяющих ему контролировать выполнение потомков. Это функции wait(2), waitid(2) и waitpid(2):

#include

#include

pid_t wait(int* stat_loc);

int waitpid(idtype_t idtype, id_t id,

siginfo_t * infop, int options);

pid_t waitpid(pid_t pid, int *stat_loc, int options);

Первый из этих вызовов wait(2) обладает самой ограниченной функциональностью — он позволяет заблокировать выполнение процесса, пока кто-либо из его непосредственных потомков не прекратит существование. Вызов wait(2) немедленно возвратит состояние уже завершившегося дочернего процесса в переменной stat_loc, если последний находится в состоянии зомби. Значение stat_loc может быть проанализировано с помощью следующих макроопределений:

WIFEXITED(status) Возвращает истинное (ненулевое) значение, если процесс завершился нормально.
WEXITSTATUS(status) Если WIFEXITED(status) не равно нулю, определяет код возврата завершившегося процесса (аргумент функции exit(2) ).
WIFSIGNALLED(status) Возвращает истину, если процесс завершился по сигналу.
WTERMSIG(status) Если WIFSIGNALLED(status) не равно нулю, определяет номер сигнала, вызвавшего завершение выполнения процесса.
WCOREDUMP(status) Если WIFSIGNALLED(status) не равно нулю, макрос возвращает истину в случае создания файла core .

Системный вызов waitid(2) предоставляет больше возможностей для контроля дочернего процесса. Аргументы idtype и id определяют, за какими из дочерних процессов требуется следить:

Значение аргумента idtype Описание
P_PID waitid(2) блокирует выполнение процесса, следя за потомком, PID которого равен id .
P_PGID waitid(2) блокирует выполнение процесса, следя за потомками, идентификаторы группы которых равны id .
P_ALL waitid(2) блокирует выполнение процесса, следя за всеми непосредственными потомками.

Аргумент options содержит флаги, объединенные логическим ИЛИ, определяющие, за какими изменениями в состоянии потомков следит waitid(2):

Флаги аргумента options Описание
WEXITED Предписывает ожидать завершения выполнения процесса.
WTRAPPED Предписывает ожидать ловушки (trap) или точки останова (breakpoint) для трассируемых процессов.
WSTOPPED Предписывает ожидать останова процесса из-за получения сигнала.
WCONTINUED Предписывает вернуть статус процесса, выполнение которого было продолжено после останова.
WNOHANG Предписывает завершить свое выполнение, если отсутствует статусная информация (т.е. отсутствует ожидаемое событие).
WNOWAIT Предписывает получить статусную информацию, но не уничтожать ее, оставив дочерний процесс в состоянии ожидания.

Аргумент infop указывает на структуру siginfo_t, которая будет заполнена информацией о потомке. Мы рассмотрим эту структуру в следующем разделе.

Функция waitpid(2), как и функции wait(2) и waitid(2), позволяет контролировать определенное множество дочерних процессов.

В заключение для иллюстрации описанных в этом разделе системных вызовов приведем схему работы командного интерпретатора при запуске команды.

...

/* Вывести приглашение shell*/

write(1, "$ ", 2);

/* Считать пользовательский ввод */

get_input(inputbuf);

/* Произвести разбор ввода: выделить команду cmd

   и ее аргументы arg[] */

parse_input(inputbuf, and, arg);

/* Породить процесс */

pid = fork();

if (pid == 0) {

 /* Запустить программу */

 execvp(cmd, arg);

 /* При нормальном запуске программы эта часть кода

    выполняться уже не будет — можно смело выводить

    сообщение об ошибке */

 pexit(cmd);

} else

 /* Родительский процесс (shell) ожидает завершения

    выполнения потомка */

 wait(&status);

...

 

Сигналы

 

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

Сигналы появились уже в ранних версиях UNIX, но их реализация не была достаточно надежной. Сигнал мог быть "потерян", возникали также определенные сложности с отключением (блокированием) сигналов на время выполнения критических участков кода. В последующие версии системы, как BSD, так и System V, были внесены изменения, позволившие реализовать надежные (reliable) сигналы. Однако модель сигналов, принятая в версиях BSD, была несовместима с моделью версий System V. В настоящее время стандарт POSIX.1 вносит определенность в интерфейс надежных сигналов.

Прежде всего, каждый сигнал имеет уникальное символьное имя и соответствующий ему номер. Например, сигнал прерывания, посылаемый процессу при нажатии пользователем клавиши или +, имеет имя SIGINT. Сигнал, генерируемый комбинацией +<\>, называется SIGQUIT. Седьмая редакция UNIX насчитывала 15 различных сигналов, а в современных версиях их число увеличилось вдвое.

Сигнал может быть отправлен процессу либо ядром, либо другим процессом с помощью системного вызова kill(2):

#include

#include

int kill(pid_t pid, int sig);

Аргумент pid адресует процесс, которому посылается сигнал. Аргумент sig определяет тип отправляемого сигнала.

К генерации сигнала могут привести различные ситуации:

□ Ядро отправляет процессу (или группе процессов) сигнал при нажатии пользователем определенных клавиш или их комбинаций. Например, нажатие клавиши (или +) приведет к отправке сигнала SIGINT, что используется для завершения процессов, вышедших из-под контроля.

□ Аппаратные особые ситуации, например, деление на 0, обращение к недопустимой области памяти и т.д., также вызывают генерацию сигнала. Обычно эти ситуации определяются аппаратурой компьютера, и ядру посылается соответствующее уведомление (например, в виде прерывания). Ядро реагирует на это отправкой соответствующего сигнала процессу, который находился в стадии выполнения, когда произошла особая ситуация.

□ Определенные программные состояния системы или ее компонентов также могут вызвать отправку сигнала. В отличие от предыдущего случая, эти условия не связаны с аппаратной частью, а имеют чисто программный характер. В качестве примера можно привести сигнал SIGALRM, отправляемый процессу, когда срабатывает таймер, ранее установленный с помощью вызова alarm(2).

С помощью системного вызова kill(2) процесс может послать сигнал как самому себе, так и другому процессу или группе процессов. В этом случае процесс, посылающий сигнал, должен иметь те же реальный и эффективный идентификаторы, что и процесс, которому сигнал отправляется. Разумеется, данное ограничение не распространяется на процессы, обладающие привилегиями суперпользователя. Такие процессы имеют возможность отправлять сигналы любым процессам системы.

Как уже говорилось в предыдущей главе, процесс может выбрать одно из трех возможных действий при получении сигнала:

□ игнорировать сигнал,

□ перехватить и самостоятельно обработать

□ позволить действие по умолчанию.

Текущее действие при получении сигнала называется диспозицией сигнала.

Напомним, что сигналы SIGKILL и SIGSTOP невозможно ни игнорировать, ни перехватить. Сигнал SIGKILL является силовым методом завершения выполнения "непослушного" процесса, а от работоспособности SIGSTOP зависит функционирование системы управления заданиями.

Условия генерации сигнала и действие системы по умолчанию приведены в табл. 2.18. Как видно из таблицы, при получении сигнала в большинстве случаев по умолчанию происходит завершение выполнения процесса. В ряде случаев в текущем рабочем каталоге процесса также создается файл core (в таблице такие случаи отмечены как "Завершить+core"), в котором хранится образ памяти процесса. Этот файл может быть впоследствии проанализирован программой-отладчиком для определения состояния процесса непосредственно перед завершением. Файл core не будет создан в следующих случаях:

□ исполняемый файл процесса имеет установленный бит SUID, и реальный владелец-пользователь процесса не является владельцем- пользователем исполняемого файла;

□ исполняемый файл процесса имеет установленный бит SGID, и реальный владелец-группа процесса не является владельцем-группой исполняемого файла;

□ процесс не имеет права записи в текущем рабочем каталоге;

□ размер файла core слишком велик (превышает допустимый предел RLIMIT_CORE, см. раздел "Ограничения" далее в этой главе).

Таблица 2.18. Сигналы

Название Действие по умолчанию Значение
SIGABRT Завершить+core Сигнал отправляется, если процесс вызывает системный вызов abort(2) .
SIGALRM Завершить Сигнал отправляется, когда срабатывает таймер, ранее установленный с помощью системных вызовов alarm(2) или setitimer(2) .
SIGBUS Завершить+core Сигнал свидетельствует о некоторой аппаратной ошибке. Обычно этот сигнал отправляется при обращении к допустимому виртуальному адресу, для которого отсутствует соответствующая физическая страница. Другой случай генерации этого сигнала упоминался при обсуждении файлов, отображаемых в память (сигнал отправляется процессу при попытке обращения к странице виртуальной памяти, лежащей за пределами файла).
SIGCHLD Игнорировать Сигнал, посылаемый родительскому процессу при завершении выполнения его потомка.
SIGEGV Завершить+core Сигнал свидетельствует о попытке обращения к недопустимому адресу или к области памяти, для которой у процесса недостаточно привилегий.
SIGFPE Завершить+core Сигнал свидетельствует о возникновении особых ситуаций, таких как деление на 0 или переполнение операции с плавающей точкой.
SIGHUP Завершить Сигнал посылается лидеру сеанса, связанному с управляющим терминалом, когда ядро обнаруживает, что терминал отсоединился (потеря линии). Сигнал также посылается всем процессам текущей группы при завершении выполнения лидера. Этот сигнал иногда используется в качестве простейшего средства межпроцессного взаимодействия. В частности, он применяется для сообщения демонам о необходимости обновить конфигурационную информацию. Причина выбора именно сигнала SIGHUP заключается в том, что демон по определению не имеет управляющего терминала и, соответственно, обычно не получает этого сигнала.
SIGILL Завершить+core Сигнал посылается ядром, если процесс попытался выполнить недопустимую инструкцию.
SIGINT Завершить Сигнал посылается ядром всем процессам текущей группы при нажатии клавиши прерывания (< Del > или < Ctrl >+< C >).
SIGKILL Завершить Сигнал, при получении которого выполнение процесса завершается. Этот сигнал нельзя ни перехватить, ни игнорировать.
SIGPIPE Завершить Сигнал посылается при попытке записи в канал или сокет, получатель данных которого завершил выполнение (закрыл соответствующий дескриптор).
SIGPOLL Завершить Сигнал отправляется при наступлении определенного события для устройства, которое является опрашиваемым.
SIGPWR Игнорировать Сигнал генерируется при угрозе потери питания. Обычно он отправляется, когда питание системы переключается на источник бесперебойного питания (UPS).
SIGQUIT Завершить+core Сигнал посылается ядром всем процессам текущей группы при нажатии клавиш < Ctrl >+< \ >.
SIGSTOP Остановить Сигнал отправляется всем процессам текущей группы при нажатии пользователем клавиш <Ctrl>+<Z>. Получение сигнала вызывает останов выполнения процесса.
SIGSYS Завершить+core Сигнал отправляется ядром при попытке недопустимого системного вызова.
SIGTERM Завершить Сигнал обычно представляет своего рода предупреждение, что процесс вскоре будет уничтожен. Этот сигнал позволяет процессу соответствующим образом "подготовиться к смерти" — удалить временные файлы, завершить необходимые транзакции и т.д. Команда kill(1) по умолчанию отправляет именно этот сигнал.
SIGTTIN Остановить Сигнал генерируется ядром (драйвером терминала) при попытке процесса фоновой группы осуществить чтение с управляющего терминала.
SIGTTOU Остановить Сигнал генерируется ядром (драйвером терминала) при попытке процесса фоновой группы осуществить запись на управляющий терминал.
SIGUSR1 Завершить Сигнал предназначен для прикладных задач как простейшее средство межпроцессного взаимодействия.
SIGUSR2 Завершить Сигнал предназначен для прикладных задач как простейшее средство межпроцессного взаимодействия.

Простейшим интерфейсом к сигналам UNIX является устаревшая, но по-прежнему поддерживаемая в большинстве систем функция signal(3C). Эта функция позволяет изменить диспозицию сигнала, которая по умолчанию устанавливается ядром UNIX. Порожденный вызовом fork(2) процесс наследует диспозицию сигналов от своего родителя. Однако при вызове exec(2) диспозиция всех перехватываемых сигналов будет установлена на действие по умолчанию. Это вполне естественно, поскольку образ новой программы не содержит функции-обработчика, определенной диспозицией сигнала перед вызовом exec(2). Функция signal(3C) имеет следующее определение:

#include

void(*signal(int sig, void (*disp)(int)))(int);

Аргумент sig определяет сигнал, диспозицию которого нужно изменить.

Аргумент disp определяет новую диспозицию сигнала, которой может быть определенная пользователем функция-обработчик или одно из следующих значений:

SIG_DFL Указывает ядру, что при получении процессом сигнала необходимо вызвать системный обработчик, т.е. выполнить действие по умолчанию.
SIG_IGN Указывает, что сигнал следует игнорировать. Напомним, что не все сигналы можно игнорировать.

В случае успешного завершения signal(3C) возвращает предыдущую диспозицию — это может быть функция-обработчик сигнала или системные значения SIG_DFL или SIG_IGN. Возвращаемое значение может быть использовано для восстановления диспозиции в случае необходимости.

Использование функции signal(3C) подразумевает семантику устаревших или ненадежных сигналов. Процесс при этом имеет весьма слабые возможности управления сигналами. Во-первых, процесс не может заблокировать сигнал, т. е. отложить получение сигнала на период выполнения критического участка кода. Во-вторых, каждый раз при получении сигнала, его диспозиция устанавливается на действие по умолчанию. Данная функция и соответствующая ей семантика сохранены для поддержки старых версий приложений. В связи с этим в новых приложениях следует избегать использования функции signal(3C). Тем не менее для простейшей иллюстрации использования сигналов, приведенный ниже пример использует именно этот интерфейс:

#include

/* Функция-обработчик сигнала */

static void sig_hndlr(int signo) {

 /* Восстановим диспозицию */

 signal(SIGINT, sig_hndlr);

 printf("Получен сигнал SIGINT\n");

}

main() {

 /* Установим диспозицию */

 signal(SIGINT, sih_hndlr);

 signal(SIGUSR1, SIG_DFL);

 signal(SIGUSR2, SIG_IGN);

 /* Бесконечный цикл */

 while(1)

  pause();

}

В этом примере изменена диспозиция трех сигналов: SIGINT, SIGUSR1 и SIGUSR2. При получении сигнала SIGINT вызывается обработчик при получении сигнала SIGUSR1 производится действие по умолчанию (процесс завершает работу), а сигнал SIGUSR2 игнорируется. После установки диспозиции сигналов процесс запускает бесконечный цикл, в процессе которого вызывается функция pause(2). При получении сигнала, который не игнорируется, pause(2) возвращает значение -1, а переменная errno устанавливается равной EINTR. Заметим, что каждый раз при получении сигнала SIGINT мы вынуждены восстанавливать требуемую диспозицию, в противном случае получение следующего сигнала этого типа вызвало бы завершение выполнения процесса (действие по умолчанию).

При запуске программы, получим следующий результат:

$ а.out &

[1] 8365              PID порожденного процесса

$ kill -SIGINT 8365

Получен сигнал SIGINT Сигнал SIGINT перехвачен

$ kill -SIGUSR2 8365  Сигнал SIGUSR2 игнорируется

$ kill -SIGUSR1 8365

[1]+ User Signal 1    Сигнал SIGUSR1 вызывает завер-

a.out                 шение выполнения процесса

$

Для отправления сигналов процессу использована команда kill(1), описанная в предыдущей главе.

 

Надежные сигналы

Стандарт POSIX. 1 определил новый набор функций управления сигналами. основанный на интерфейсе 4.2BSD UNIX и лишенный рассмотренных выше недостатков.

Модель сигналов, предложенная POSIX, основана на понятии набора сигналов (signal set), описываемого переменной типа sigset_t. Каждый бит этой переменной отвечает за один сигнал. Во многих системах тип sigset_t имеет длину 32 бита, ограничивая количество возможных сигналов числом 32.

Следующие функции позволяют управлять наборами сигналов:

#include

int sigempyset(sigset_t *set);

int siufillset(sigset_t *set);

int sigaddset(sigset_t *set, int signo);

int sigdelset(sigset_t *set, int signo);

int sigismember(sigset_t *set, int signo);

В отличие от функции signal(3C), изменяющей диспозицию сигналов, данные функции позволяют модифицировать структуру данных sigset_t, определенную процессом. Для управления непосредственно сигналами используются дополнительные функции, которые мы рассмотрим позже.

Функция sigemptyset(3C) инициализирует набор, очищая все биты. Если процесс вызывает sigfillset(3C), то набор будет включать все сигналы, известные системе. Функции sigaddset(3C) и sigdelset(3C) позволяют добавлять или удалять сигналы набора. Функция sigismember(3C) позволяет проверить, входит ли указанный параметром signo сигнал в набор.

Вместо функции signal(3C) стандарт POSIX. 1 определяет функцию sigaction(2), позволяющую установить диспозицию сигналов, узнать ее текущее значение или сделать и то и другое одновременно. Функция имеет следующее определение:

#include

int sigaction (int sig, const struct sigaction *act,

 struct sigaction *oact);

Вся необходимая для управлением сигналами информация передается через указатель на структуру sigaction, имеющую следующие поля:

void (*sa_handler)() Обработчик сигнала sig
void (*sa_sigaction)(int, siginfo_t*, void*) Обработчик сигнала sig при установленном флаге SA_SIGINFO
sigset_t sa_mask Маска сигналов
int sa_flags Флаги

Поле sa_handler определяет действие, которое необходимо предпринять при получении сигналов, и может принимать значения SIG_IGN, SIG_DFL или адреса функции-обработчика. Если значение sa_handler или sa_sigaction не равны NULL, то в поле sa_mask передается набор сигналов, которые будут добавлены к маске сигналов перед вызовом обработчика. Каждый процесс имеет установленную маску сигналов, определяющую сигналы, доставка которых должна быть заблокирована. Если определенный бит маски установлен, соответствующий ему сигнал будет заблокирован. После возврата из функции-обработчика значение маски возвращается к исходному значению. Заметим, что сигнал, для которого установлена функция-обработчик, также будет заблокирован перед ее вызовом. Такой подход гарантирует, что во время обработки, последующее поступление определенных сигналов будет приостановлено до завершения функции. Как правило, UNIX не поддерживает очередей сигналов, и это значит, что блокировка нескольких однотипных сигналов в конечном итоге вызовет доставку лишь одного.

Поле sa_flags определяет флаги, модифицирующие доставку сигнала. Оно может принимать следующие значения:

SA_ONSTACK Если определена функция-обработчик сигнала, и с помощью функции sigaltstack(2) задан альтернативный стек для функции-обработчика, то при обработке сигнала будет использоваться этот стек. Если флаг не установлен, будет использоваться обычный стек процесса.
SA_RESETHAND * Если определена функция-обработчик, то диспозиция сигнала будет изменена на SIG_DFL , и сигнал не будет блокироваться при запуске обработчика. Если флаг не установлен, диспозиция сигнала остается неизменной.
SA_NODEFER * Если определена функция-обработчик, то сигнал блокируется на время обработки только в том случае, если он явно указан в поле sa_mask . Если флаг не установлен, в процессе обработки данный сигнал автоматически блокируется.
SA_RESTART Если определена функция-обработчик, ряд системных вызовов, выполнение которых было прервано полученным сигналом, будут автоматически перезапущены после обработки сигнала. [25] Если флаг не установлен, системный вызов возвратит ошибку EINTR .
SA_SIGINFO * Если диспозиция указывает на перехват сигнала, вызывается функция, адресованная полем sa_sigaction . Если флаг не установлен, вызывается обработчик sa_handler .
SA_NOCLDWAIT * Если указанный аргументом sig сигнал равен SIGCHLD , при завершении потомки не будут переходить в состояние зомби. Если процесс в дальнейшем вызовет функции wait(2) , wait3(2) , waitid(2) или waitpid(2) , их выполнение будет блокировано до завершения работы всех потомков данного процесса.
SA_NOCLDSTOP * Если указанный аргументом sig сигнал равен SIGCHLD , указанный сигнал не будет отправляться процессу при завершении или останове любого из его потомков.

*Данные флаги не определены для UNIX BSD.

В системах UNIX BSD 4.x структура sigaction имеет следующий вид:

struct sigaction {

 void (*sa_handler)();

 sigset_t sa_mask;

 int sa_flags;

};

где функция-обработчик определена следующим образом:

void handler(int signo, int code, struct sigcontext *scp);

В первом аргументе signo содержится номер сигнала, code определяет дополнительную информацию о причине поступления сигнала, a scp указывает на контекст процесса.

Для UNIX System V реализована следующая возможность получения более полной информации о сигнале. Если установлен флаг SA_SIGINFO, то при получении сигнала sig будет вызван обработчик, адресованный полем sa_sigaction. Помимо номера сигнала, обычно передаваемого обработчику сигнала, ему будет переданы указатель на структуру siginfo_t, содержащую информацию о причинах получения сигнала, а также указатель на структуру ucontext_t, содержащую контекст процесса.

Структура siginfo_t определена в файле и включает следующие поля:

int si_signo Номер сигнала
int si_errno Номер ошибки
int si_code Причина отправления сигнала

В поле si_signo хранится номер сигнала. Поле si_code имеет следующий смысл: если его значение меньше или равно нулю, значит сигнал был отправлен прикладным процессом, в этом случае структура siginfo_t содержит также следующие поля:

pid_t si_pid Идентификатор процесса PID
uid_t si_uid Идентификатор пользователя UID

которые адресуют процесс, пославший сигнал; если значение si_code больше нуля, то оно указывает на причину отправления сигнала. Список возможных значений si_code для некоторых сигналов, соответствующих полю si_signo, приведен в табл. 2.19

Таблица 2.19. Значения поля si_code структуры siginfo_t для некоторых сигналов

Значение поля si_signo Значение поля si_code Описание
SIGILL Попытка выполнения недопустимой инструкции
ILL_ILLOPC Недопустимый код операции (opcode)
ILL_ILLOPN Недопустимый операнд
ILL_ADR Недопустимый режим адресации
ILL_ILLTRP Недопустимая ловушка (trap)
ILL_PRVOPC Привилегированный код операции
ILL_PRVREG Привилегированный регистр
ILL_COPROC Ошибка сопроцессора
ILL_BADSTK Ошибка внутреннего стека
SIGFPE Особая ситуация операции с плавающей точкой
FPE_INTDIV Целочисленное деление на ноль
FPE_INTOVF Целочисленное переполнение
FPE_FLTDIV Деление на ноль с плавающей точкой
FPE_FLTOVF Переполнение с плавающей точкой
FPE_FLTUND Потеря точности с плавающей точкой (underflow)
FPE_FLTRES Неоднозначный результат операции с плавающей точкой
FPE_FLTINV Недопустимая операция с плавающей точкой
FPE_FLTSUB Индекс вне диапазона
SIGSEGV Нарушение сегментации
SEGV_MAPPER Адрес не отображается на объект
SEGV_ACCERR Недостаточно прав на отображаемый объект
SIGBUS Ошибка адресации
BUS_ADRALN Недопустимое выравнивание адреса
BUS_ADRERR Несуществующий физический адрес
BUS_OBJERR Аппаратная ошибка, связанная с объектом
SIGTRAP Ловушка
TRAP_BRKPT Процессом достигнута точка останова
TRAP_TRACE Ловушка трассирования процесса
SIGCHLD Завершение выполнения дочернего процесса
CLD_EXITED Дочерний процесс завершил выполнение
CLD_KILLED Дочерний процесс был "убит"
CLD_DUMPED Ненормальное завершение дочернего процесса
CLD_TRAPPED Трассируемый дочерний процесс находится в ловушке
CLD_STOPPED Выполнение дочернего процесса было остановлено
CLD_CONTINUED Выполнение остановленного дочернего процесса было продолжено
SIGPOLL Событие на опрашиваемом устройстве
POLL_IN Поступили данные для ввода
POLL_OUT Свободны буферы данных
POLL_MSG Сообщение ожидает ввода
POLL_ERR Ошибка ввода/вывода
POLL_PRI Высокоприоритетные данные ожидают ввода
POLL_HUP Устройство отключено

Уже отмечалось, что при получении сигнала от пользовательского процесса структура siginfo_t содержит дополнительные поля (табл. 2.20).

Таблица 2.20. Дополнительные поля структуры siginfo_t

Значение поля si_signo Дополнительные поля Значение
SIGILL SIGFPE caddr_t si_addr Адрес недопустимой инструкции
SIGSEGV SIGBUS caddr_t si_addr Адрес недопустимой области памяти
SIGCHLD pid_t si_pid Идентификатор дочернего процесса
int si_status Код возврата сигнала
SIGPOLL long si_band Ошибка канала (для модулей STREAMS)

Установить маску сигналов или получить текущую маску можно с помощью функции sigprocmask(2):

#include

int sigprocmask(int how, sigset_t *set, sigset_t *oset);

Маска сигналов изменяется в соответствии с аргументом how, который может принимать следующие значения:

SIG_BLOCK Результирующая маска получится путем объединения текущей маски и набора set
SIG_UNBLOCK Сигналы набора set будут удалены из текущей маски
SIG_SETMASK Текущая маска будет заменена на набор set

Если указатель set равен NULL, то аргумент how игнорируется. Если аргумент oset не равен NULL, то в набор, адресованный этим аргументом, помещается текущая маска сигналов.

Функция sigpending(2) используется для получения набора заблокированных сигналов, ожидающих доставки:

#include

int sigpending(int how, sigset_t *set, sigset_t *oset);

Список сигналов, ожидающих доставки, возвращается в наборе, адресованном аргументом set.

Системный вызов sigsuspend(2) замещает текущую маску набором, адресованным аргументом set, и приостанавливает выполнение процесса до получения сигналов, диспозиция которых установлена либо на завершение выполнения процесса, либо на вызов функции-обработчика сигнала.

#include

int sigsuspend(const sigset_t *set);

При получении сигнала, завершающего выполнение процесса, возврата из функции sigsuspend(2) не происходит. Если же диспозиция полученного сигнала установлена на вызов функции-обработчика, возврат из sisuspend(2) происходит сразу после завершения обработки сигнала. При этом восстанавливается маска, существовавшая до вызова sigsuspend(2).

Заметим, что в BSD UNIX вызов signal(3) является упрощенным интерфейсом к более общей функции sigaction(2), в то время как в ветви System V signal(3) подразумевает использование старой семантики ненадежных сигналов.

В заключение для иллюстрации изложенных соображений, приведем версию функции signal(), позволяющую использовать надежные сигналы. Похожая реализация используется в BSD UNIX. С помощью этой "надежной" версии мы повторим пример, рассмотренный нами выше, в измененном виде.

#include

#include

#include

#include

#include

/* Вариант "надежной" функции signal() */

void (*mysignal(int signo, void (*hndlr)(int)))(int) {

 struct sigaction act, oact;

 /* Установим маску сигналов */

 act.sa_handler = hndlr;

 sigemptyset(&act.sa_mask);

 act.sa_flags = 0;

 if (signo != SIGALRM)

  act.sa_flags = SA_RESTART;

 /* Установим диспозицию */

 if (sigaction(signo, &act, &oact) < 0)

  return SIG_ERR;

 return(oact.sa_handler);

}

/* Функция-обработчик сигнала */

static void sig_hndlr(int signo) {

 /* Эта часть кода нам уже не нужна

  mysignal(SIGINT, sig_hndlr);

 */

 printf("Получен сигнал SIGINT\n");

}

main() {

 /* Установим диспозицию */

 mysignal(SIGINT, sig_hndlr);

 mysignal(SIGUSR2, SIG_IGN);

 /* Бесконечный цикл */

 while (1)

  pause();

}

Заметим, что при использовании надежных сигналов, не нужно восстанавливать диспозицию в функции-обработчике при получении сигнала.

 

Группы и сеансы

 

После создания процесса ему присваивается уникальный идентификатор, возвращаемый системным вызовом fork(2) родительскому процессу. Дополнительно ядро назначает процессу идентификатор группы процессов (process group ID). Группа процессов включает один или более процессов и существует, пока в системе присутствует хотя бы один процесс этой группы. Временной интервал, начинающийся с создания группы и заканчивающийся, когда последний процесс ее покинет, называется временем жизни группы. Последний процесс может либо завершить свое выполнение, либо перейти в другую группу.

Многие системные вызовы могут быть применены как к единичному процессу, так и ко всем процессам группы. Например, системный вызов kill(2) может отправить сигнал как одному процессу, так и всем процессам указанной группы. Точно так же функция waitpid(2) позволяет родительскому процессу ожидать завершения конкретного процесса или любого процесса группы.

Каждый процесс, помимо этого, является членом сеанса (session), являющегося набором одной нескольких групп процессов. Понятие сеанса было введено в UNIX для логического объединения процессов, а точнее, групп процессов, созданных в результате регистрации и последующей работы пользователя в системе. Таким образом, термин "сеанс работы" в системе тесно связан с понятием сеанса, описывающего набор процессов, которые порождены пользователем за время пребывания в системе.

Процесс имеет возможность определить идентификатор собственной группы процессов или группы процесса, который является членом того же сеанса. Для этого используются два системных вызова: getpgrp(2) и getpgid(2):

#include

#include

pid_t getpgrp(void);

pid_t getpgid(pid_t pid);

Аргумент pid, который передается функции getpgid(2), адресует процесс, идентификатор группы которого требуется узнать. Если этот процесс не принадлежит к тому же сеансу, что и процесс, сделавший системный вызов, функция возвращает ошибку.

Системный вызов setpgid(2) позволяет процессу стать членом существующей группы или создать новую группу.

#include

#include

int setpgid(pid_t pid, pid_t pgid);

Функция устанавливает идентификатор группы процесса pid равным pgid. Процесс имеет возможность установить идентификатор группы для себя и для своих потомков (дочерних процессов). Однако процесс не может изменить идентификатор группы для дочернего процесса, который выполнил системный вызов exec(2), запускающий на выполнение другую программу.

Если значения обоих аргументов равны, то создается новая группа с идентификатором pgid, а процесс становится лидером (group leader) этой группы. Поскольку именно таким образом создаются новые группы, их идентификаторы гарантированно уникальны. Заметим, что группа не удаляется при завершении ее лидера, пока в нее входит хотя бы один процесс.

Идентификатор сеанса можно узнать с помощью функции getsid(2):

#include

#include

pid_t getsid(pid_t pid);

Как и в случае с группой, идентификатор pid должен адресовать процесс, являющийся членом того же сеанса, что и процесс, вызвавший getsid(2). Заметим, что эти ограничения не распространяются на процессы, имеющие привилегии суперпользователя.

Вызов функции setsid(2) приводит к созданию нового сеанса:

#include

#include

pid_t setsid(void);

Новый сеанс создается лишь при условии, что процесс не является лидером какого-либо сеанса. В случае успеха процесс становится лидером сеанса и лидером новой группы.

Понятия группы и сеанса тесно связаны с терминалом или, точнее, с драйвером терминала. Каждый сеанс может иметь один ассоциированный терминал, который называется управляющим терминалом (controlling terminal), а группы, созданные в данном сеансе, наследуют этот управляющий терминал. Наличие управляющего терминала позволяет ядру контролировать стандартный ввод/вывод процессов, а также дает возможность отправить сигнал всем процессам ассоциированной с терминалом группы, например, при его отключении. Типичным примером является регистрация и работа пользователя в системе. При входе в систему терминал пользователя становится управляющим для лидера сеанса (в данном случае для командного интерпретатора shell) и всех процессов, порожденных лидером (в данном случае для всех процессов, которые запускает пользователь из командной строки интерпретатора). При выходе пользователя из системы shell завершает свою работу и таким образом отключается от управляющего терминала, что вызывает отправление сигнала SIGHUP всем незавершенным процессам текущей группы. Это гарантирует, что после завершения работы пользователя в системе не останется запущенных им процессов.

 

Текущие и фоновые группы процессов

Как было показано, для каждого управляющего терминала существует сеанс, включающий одну или несколько групп процессов. Одна из этих групп является текущей (foreground group), а остальные фоновыми (background group). Сигналы SIGINT и SIGQUIT, которые генерируются драйвером терминала, посылаются всем процессам текущей группы. Попытка процессов фоновых групп осуществить доступ к управляющему терминалу, как правило, вызывает отправление им сигналов SIGSTP, SIGTTIN или SIGTTOU.

Рассмотрим следующие команды:

$ find / -name foo &

$ cat | sort

При этом происходит чтение ввода пользователя с клавиатуры (cat(1) и сортировка введенных данных (sort(1)). Если интерпретатор поддерживает управление заданиями, оба процесса, созданные для программ cat(1) и sort(1), будут помещены в отдельную группу. Это подтверждается выводом команды ps(1):

$ ps -efj | egrep "PID|andy"

UID  PID  PPID PGID SID  С  STIME   TTY   TIME CMD

andy 2436 2407 2435 2407 1 15:51:30 tty01 0:00 sort

andy 2431 2407 2431 2407 0 15:51:25 tty01 0:00 find / -name foo

andy 2407 2405 2407 2407 0 15:31:09 tty01 0:00 -sh

andy 2435 2407 2435 2407 0 15:51:30 tty01 0:00 cat

Все четыре процесса (sh, find, cat и sort) имеют один и тот же идентификатор сеанса, связанного с управляющим терминалом tty01. Процессы cat(1) и sort(1) принадлежат одной группе, идентификатор которой (2435) отличен от идентификатора группы командного интерпретатора (2407). То же самое можно сказать и о процессе find(1), который является лидером отдельной группы (2431). Можно также заметить, что процессы sh(1), find(1) и cat(1) являются лидерами групп, a еще sh(1) и лидером сеанса.

Хотя команда ps(1) не указывает, какие группы являются фоновыми, а какая текущей, синтаксис команд позволяет утверждать, что командный интерпретатор помещает cat(1) и sort(1) в текущую группу. Это, во-первых, позволяет процессу cat(1) читать данные со стандартного потока ввода, связанного с терминалом tty01. Во-вторых, пользователь имеет возможность завершить выполнение обоих процессов путем нажатия клавиши (или +), что вызовет генерацию сигнала SIGINT. Получение процессами этого сигнала вызовет завершение их выполнения (действие по умолчанию), если, конечно, процесс не установил игнорирование SIGINT. На рис. 2.13. представлена схема взаимодействия управляющего терминала, сеанса и групп процессов для приведенного выше примера. Более детально взаимосвязь между терминалом и процессами рассмотрена в следующей главе.

Рис. 2.13. Связь между управляющим терминалом, сеансом и группами

Если командный интерпретатор не поддерживает управление заданиями, оба процесса станут членами той же группы, что и сам shell. В этом случае командный интерпретатор должен позаботиться об игнорировании сигналов SIGINT и SIGQUIT, чтобы допустимые действия пользователя (такие как нажатие клавиши или +) не привели к завершению выполнения shell и выходу из системы.

 

Ограничения

UNIX является многозадачной системой. Это значит, что несколько процессов конкурируют между собой при доступе к различным ресурсам. Для "справедливого" распределения разделяемых ресурсов, таких как память, дисковое пространство и т.п., каждому процессу установлен набор ограничений. Эти ограничения не носят общесистемного характера, как, например, максимальное число процессов или областей, а устанавливаются для каждого процесса отдельно. Для получения информации о текущих ограничениях и их изменения предназначены системные вызовы getrlimit(2) и setrlimit(2):

#include

#include

int getrlimit{int resource, struct rlimit *rlp);

int setrlimit(int resource, const struct rlimit *rlp);

Аргумент resource определяет вид ресурса, для которого мы хотим узнать или изменить ограничения процесса. Структура rlimit состоит из двух полей:

rlim_t rlim_cur;

rlim_t rlim_max;

определяющих, соответственно, изменяемое (soft) и жесткое (hard) ограничение. Первое определяет текущее ограничение процесса на данный ресурс, а второе — максимальный возможный предел потребления ресурса. Например, изменяемое ограничение на число открытых процессом файлов может составлять 64, в то время как жесткое ограничение равно 1024.

Любой процесс может изменить значение текущего ограничения вплоть до максимально возможного предела. Жесткое ограничение может быть изменено в сторону увеличения предела потребления ресурса только процессом с привилегиями суперпользователя. Обычные процессы могут только уменьшить значение жесткого ограничения. Обычно ограничения устанавливаются при инициализации системы и затем наследуются порожденными процессами (хотя в дальнейшем могут быть изменены).

Вообще говоря, максимальный возможный предел потребления ресурса может иметь бесконечное значение. Для этого необходимо установить значение rlim_max равным RLIM_INFINITY. В этом случае физические ограничения системы (например, объем памяти и дискового пространства) будут определять реальный предел использования того или иного ресурса.

Различные ограничения и связанные с ними типы ресурсов приведены в табл. 2.21.

Таблица 2.21. Ограничения процесса (значения аргумента resource)

Ограничение Тип ресурса Эффект
RLIMIT_CORE Максимальный размер создаваемого файла core , содержащего образ памяти процесса. Если предел установлен равным 0, файл core создаваться не будет. После создания файла core запись в этот файл будет остановлена при достижении предельного размера.
RLIMIT_CPU Максимальное время использования процессора в секундах. При превышении предела процессу отправляется сигнал SIGXCPU .
RLIMIT_DATA Максимальный размер сегмента данных процесса в байтах, т.е. максимальное значение смещения брейк-адреса. При достижении этого предела последующие вызовы функции brk(2) завершатся с ошибкой ENOMEM .
RLIMIT_FSIZE Максимальный размер файла, который может создать процесс. Если значение этого предела равно 0, процесс не может создавать файлы. При достижении этого предела процессу отправляется сигнал SIGXFSZ . Если сигнал перехватывается или игнорируется процессом, последующие попытки увеличить размер файла закончатся с ошибкой EFBIG .
RLIMIT_NOFILE Максимальное количество назначенных файловых дескрипторов процесса. При достижении этого предела, последующие попытки получить новый файловый дескриптор закончатся с ошибкой EMFlLE .
RLIMIT_STACK Максимальный размер стека процесса. При попытке расширить стек за установленный предел отправляется сигнал SIGSEGV . Если процесс перехватывает или игнорирует сигнал и не использует альтернативный стек с помощью функции sigaltstack(2) , диспозиция сигнала устанавливается на действие по умолчанию перед отправкой процессу.
RLIMIT_VMEM Максимальный размер отображаемой памяти процесса в байтах. (Предел определен в версиях System V.) При достижении этого предела последующие вызовы brk(2) или mmap(2) завершатся с ошибкой ENOMEM .
RLIMIT_NPROC Максимальное число процессов с одним реальным UID. Определяет максимальное число процессов, которые может запустить пользователь. (Предел определен в версиях BSD UNIX.) При достижении этого предела, последующие вызовы fork(2) для порождения нового процесса завершатся с ошибкой EAGAIN .
RLIMIT_RSS Максимальный размер в байтах резидентной части процесса (RSS — Resident Set Size). Определяет максимальное количество физической памяти, предоставляемой процессу. (Предел определен в версиях BSD UNIX.) Если система ощущает недостаток памяти, ядро освободит память за счет процессов, превысивших свой RSS.
RLIMIT_MEMLOCK Максимальный физической памяти (физических страниц) в байтах, который процесс может заблокировать с помощью системного вызова mlock(2) . (Предел определен в версиях BSD UNIX.) При превышении предела системный вызов mlock(2) завершится с ошибкой EAGAIN .

В заключение приведем пример программы, выводящий на экран установленные ограничения для процесса:

#include

#include

/* Процедура вывода на экран текущего и максимального

   пределов потребления ресурса resource */

void disp_limit(int resource, char *rname) {

 struct rlimit rim;

 getrlimit(resource, &rlm);

 printf("%-13s ", rname);

 /* Значение изменяемого ограничения */

 if (rlm.rlim_curr == RLIM_INFINITY)

  printf("infinite ");

 else

  printf("%101d ", rlm.rlim_cur);

 /* Значение жесткого ограничения */

 if (rlm.rlim_max == RLIM_INFINITY)

  printf("infinite \n");

 else

  printf("%10ld\n", rlm.rlim_max);

}

main() {

 disp_limit(RLIMIT_CORE, "RLIMIT_CORE");

 disp_limit(RLIMIT_CPU, "RLIMIT_CPU");

 disp_limit(RLIMIT_DATA, "RLIMIT_DATA");

 disp_limit(RLIMIT_FSIZE, "RLIMIT_FSIZE");

 disp_limit(RLIMIT_NOFILE, "RLIMIT_NOFILE");

 disp_limit(RLIMIT_STACK, "RLIMIT_STACK");

 /* BSD */

#ifdef RLIMIT_NPROC

 disp_limit(RLIMIT_NPROC, "RLIMIT_NPROC");

#endif

 /* BSD */

#ifdef RLIMIT_RSS

 disp_limit(RLIMIT_RSS, "RLIMIT_RSS");

#endif

 /* BSD */

#ifdef RLIMIT_MEMLOCK

 disp_limit(RLIMIT_MEMLOCK, "RLIMIT_MEMLOCK");

#endif

 /* System V */

#ifdef RLIMIT_VMEM

 disp_limit(RLIMIT_VMEM, "RLIMIT_VMEM");

#endif

}

Запуск программы под управлением операционной системы Solaris 2.5 даст следующие результаты:

$ а.out

RLIMIT_CORE   infinite   infinite

RLIMIT_CPU    infinite   infinite

RLIMIT_DATA   2147479552 2147479552

RLIMIT_FSIZE  infinite   infinite

RLIMIT_NOFILE 64         1024

RLIMIT_STACK  8388608    2147479552

RLIMIT_VMEM   infinite   infinite

 

Примеры программ

 

В качестве заключительной иллюстрации к обсуждавшимся выше вопросам приводятся фрагменты двух приложений, которые в достаточной степени демонстрируют практическое применение программного интерфейса UNIX. Заметим, что приведенные примеры не являются законченными программами — во многих местах участки кода намеренно опущены, а функциональность сведена к минимуму. Задачей являлось показать принцип взаимодействия программ с операционной системой и идеологию программирования в UNIX. Рассмотрим два диаметрально противоположных приложения — неинтерактивную программу-демон и интерактивный командный интерпретатор.

 

Демон

Демоны играют важную роль в работе операционной системы. Достаточно будет сказать, что возможность терминального входа пользователей в систему, доступ по сети, использование системы печати и электронной почты, — все это обеспечивается соответствующими демонами — неинтерактивными программами, составляющими собственные сеансы (и группы) и не принадлежащими ни одному из пользовательских сеансов (групп).

Некоторые демоны работают постоянно, наиболее яркий пример такого демона — процесс init(1M), являющийся прародителем всех прикладных процессов в системе. Другими примерами являются cron(1M), позволяющий запускать программы в определенные моменты времени, inetd(1M) обеспечивающий доступ к сервисам системы из сети, и sendmail(1M), обеспечивающий получение и отправку электронной почты.

При описании взаимодействия процессов с терминалом и пользователем в разделе "Группы и сеансы", отмечалось особое место демонов, которые не имеют управляющего терминала. Теперь в отношении демонов можно сформулировать ряд правил, определяющих их нормальное функционирование, которые необходимо учитывать при разработке таких программ:

1. Демон не должен реагировать на сигналы управления заданиями, посылаемые ему при попытке операций ввода/вывода с управляющим терминалом. Начиная с некоторого времени, демон снимает ассоциацию с управляющим терминалом, но на начальном этапе запуска ему может потребоваться вывести то или иное сообщение на экран.

2. Необходимо закрыть все открытые файлы (файловые дескрипторы), особенно стандартные потоки ввода/вывода. Многие из этих файлов представляют собой терминальные устройства, которые должны быть закрыты, например, при выходе пользователя из системы. Предполагается, что демон остается работать и после того, как пользователь "покинул" UNIX.

3. Необходимо снять его ассоциацию с группой процессов и управляющим терминалом. Это позволит демону избавиться от сигналов, генерируемых терминалом (SIGINT или SIGHUP), например, при нажатии определенных клавиш или выходе пользователя из системы.

4. Сообщения о работе демона следует направлять в специальный журнал с помощью функции syslog(3), — это наиболее корректный способ передачи сообщений от демона.

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

Приведем скелет программы-демона:

#include

#include

#include

#include

#include

#include

main(int argc, char **argv) {

 int fd;

 struct rlimit flim;

 /* Если родительский процесс — init, можно не беспокоиться

    за терминальные сигналы. Если нет — необходимо игнорировать

    сигналы, связанные с вводом/выводом на терминал

    фонового процесса: SIGTTOU, SIGTTIN, SIGTSTP */

 if (getppid() != 1) {

  signal(SIGTTOU, SIG_IGN);

  signal(SIGTTIN, SIG_IGN);

  signal(SIGTSTP, SIG_IGN);

  /* Теперь необходимо организовать собственную группу и сеанс,

     не имеющие управляющего терминала. Однако лидером группы и

     сеанса может стать процесс, если он еще не является лидером.

     Поскольку предыстория запуска данной программы неизвестна,

     необходима гарантия, что наш процесс не является лидером.

     Для этого порождаем дочерний процесс. Т.к. его PID уникален,

     то ни группы, ни сеанса с таким идентификатором не существует,

     а значит нет и лидера. При этом родительский процесс

     немедленно завершает выполнение, поскольку он уже не нужен.

     Существует еще одна причина необходимости порождения

     дочернего процесса. Если демон был запущен из командной строки

     командного интерпретатора shell не в фоновом режиме,

     последний будет ожидать завершения выполнения демона,

     и таким образом, терминал будет заблокирован.

     Порождая процесс и завершая выполнение родителя,

     имитируем для командного интерпретатора завершение

     работы демона, после чего shell выведет свое приглашение */

  if (fork () !=0)

   exit(0); /* Родитель заканчивает работу */

   /* Дочерний процесс с помощью системного вызова

      становится лидером новой группы, сеанса и не имеет

      ассоциированного терминала */

 }

 /* Теперь необходимо закрыть открытые файлы. Закроем

    все возможные файловые дескрипторы. Максимальное число

    открытых файлов получим с помощью функции getrlimit */

 getrlimit(RLIMIT_NOFILE, &flim);

 for (fd = 0; fd < flim.rlim_max; fd++)

  close(fd);

 /* Сменим текущий каталог на корневой */

  chdir("/");

 /* Заявим о себе в системном журнале. Для этого сначала

    установим опции ведения журнала: каждая запись будет

    предваряться идентификатором PID демона, при невозможности

    записи в журнал сообщения будут выводиться на консоль,

    источник сообщений определим как "системный демон"

    (см. комментарии к функциям ведения журнала ниже). */

 openlog("Скелет демона" , LOG_PID | LOG_CONS, LOG_DAEMON);

 /* Отметимся */

 syslog(LOG_INFO, "Демон начал плодотворную работу...");

 closelog();

 /* Далее следует текст программы, реализующий полезные функции

    демона. Эта часть предоставляется читателю для собственной

    разработки. */

 ...

}

В программе использовалось еще не обсуждавшаяся возможность системного журнала сообщений выполняющихся программ. Функцией генерации сообщений является syslog(3), отправляющая сообщение демону системного журнала syslogd(1M), который в свою очередь либо дописывает сообщения в системный журнал, либо выводит на их консоль, либо перенаправляет в соответствии со списком пользователей данной или удаленной системы. Конкретный пункт назначения определяется конфигурационным файлом (/etc/syslog.conf). Функция имеет определение:

#include

void syslog(int priority, char *logstring, /* параметры*/...);

Каждому сообщению logstring назначается приоритет, указанный параметром priority. Возможные значения этого параметра включают:

LOG_EMERG Идентифицирует состояние "паники" в системе. Обычно рассылается всем пользователям.
LOG_ALERT Идентифицирует ненормальное состояние, которое должно быть исправлено немедленно, например, нарушение целостности системной базы данных.
LOG_CRIT Идентифицирует критическое событие, например, ошибку дискового устройства.
LOG_ERR Идентифицирует различные ошибки.
LOG_WARNING Идентифицирует предупреждения.
LOG_NOTICE Идентифицирует события, которые не являются ошибками, но требуют внимания.
LOG_INFO Идентифицирует информационные сообщения, как, например, использованное в приведенной программе.
LOG_DEBUG Идентифицирует сообщение, обычно используемое только при отладке программы.

Последний тип сообщений подсказывает еще одну возможность использования системного журнала — для отладки программ, особенно неинтерактивных.

Строка logstring может включать элементы форматирования, такие же, как и в функции printf(3), с одним дополнительным выражением %m, которое заменяется сообщением, соответствующим ошибке errno. При этом может осуществляться вывод значений дополнительных параметров.

Функция openlog(3) позволяет определить ряд опций ведения журнала. Она имеет следующее определение:

void openlog(char *ident, int logopt, int facility);

Строка ident будет предшествовать каждому сообщению программы. Аргумент logopt задает дополнительные опции, в том числе:

LOG_PID Позволяет указывать идентификатор процесса в каждом сообщении. Эта опция полезна при журналировании нескольких демонов с одним и тем же значением ident, например, когда демоны порождаются вызовом fork(2) .
LOG_CONS Позволяет выводить сообщения на консоль при невозможности записи в журнал.

Наконец, аргумент facility позволяет определить источник сообщений:

LOG_KERN Указывает, что сообщения отправляются ядром.
LOG_USER Указывает, что сообщения отправлены прикладным процессом (используется по умолчанию).
LOG_MAIL Указывает, что инициатором сообщений является система электронной почты.
LOG_DAEMON Указывает, что инициатором сообщений является системный демон.
LOG_NEWS Указывает, что инициатором сообщений является система телеконференций USENET.
LOG_CRON Указывает, что инициатором сообщений является система cron(1) .

Закончив работу с журналом, следует аккуратно закрыть его с помощью функции closelog(3):

void closelog(void);

 

Командный интерпретатор

Для примера интерактивного приложения, мы выбрали простейший командный интерпретатор. Данный пример позволяет продемонстрировать использование системных вызовов для порождения процесса, запуска программы и синхронизации выполнения процессов.

Функции приведенного командного интерпретатора сведены к минимуму: он распознает и выполняет несколько встроенных команд, остальной ввод он расценивает как внешние программы, которые и пытается запустить с помощью системного вызова exec(2).

#include

#include

#include

extern char** environ;

#define CMDSIZE 80

/* Встроенные команды интерпретатора */

#define CD 1

#define ECHO 2

#define EXEC 3 ...

#define PROGRAM 1000

/* Функция, которая производит анализ строки, введенной

   пользователем, выполняет подстановки и определяет,

   встроенная ли это команда или программа. В качестве аргумента

   функция принимает строку cmdbuf, введенную пользователем,

   и возвращает имя команды/программы path и переданные ей

   параметры arguments. Возвращаемое значение указывает на

   внутреннюю команду или внешнюю программу, которую необходимо

   запустить.*/

int parse_command(char* cmdbuf, char* path, char** arguments);

main {

 charcmd[CMDSIZE];

 int command;

 int stat_loc;

 char** args;

 char cmdpath[MAXPATH];

 while (1) {

  /* Выведем сообщение интерпретатора */

  write(1, "$ ", 2);

  /* Считаем ввод пользователя и проанализируем строку */

  cmdsize = read(0, cmd, CMDSIZE);

  cmd[cmdsize-1] ='\0';

  command = parse_command(cmd, cmdpath, args);

  switch(command) {

  /* Если это внутренняя команда, обработаем ее */

  case (CD):

   chdir(args[0]);

   break;

  case(ECHO):

   write(1, args[0], strlen(args[0]));

   break;

  case(EXEC):

   execve(path, args, environ);

   write(2, "shell: cannot execute", 21);

   break;

   ...

  /* Если это внешняя программа, создадим дочерний процесс, который

     и запустит программу */

  case(PROGRAM):

   pid = fork();

   if (pid < 0)

    write(2, "shell: cannot fork", 18);

   else if (pid == 0) {

    /* Дочерний процесс */

    execve(path, args, environ);

    write(2, "shell: cannot execute", 21);

   } else

    /* Родительский процесс */

    /* Ожидаем завершения выполнения программы */

   wait(&stat_lock);

   break;

  }

 }

}

Предложенный командный интерпретатор работает в бесконечном цикле, запрашивая ввод пользователя и анализируя строку с помощью функции parse_command(), текст которой здесь не приведен. В случае, если пользователь ввел встроенную команду интерпретатора, он выполняет команду собственными силами. В противном случае shell порождает дочерний процесс, который с помощью вызова execve(2) запускает указанную программу. В это время родительский процесс выполняет системный вызов wait(2) и приостанавливает свое выполнение до завершения работы программы, после чего на экран вновь выводится приглашение.

 

Заключение

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