/index.html#toc">Содержание

Вперед


Глава 1. Программирование в среде UNIX.

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

    Предполагается, что читатель знаком с языком программирования Си и имеет навыки построения прикладных задач в несложных операционных средах таких, например, как MS DOS. Кроме того, считается, что читатель уже прочел одну из книг, посвященных тому, как обращаться с UNIX [1-5] (а возможно уже и работал в ней ) и ему знакомы такие понятия, как "вход в систему" ( login ), "командный процессор" ("оболочка" ( shell )) и т.д.

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

  1. История создания и развития системы UNIX.
  2. Основные понятия системы UNIX.
  3. Начальная загрузка UNIX. Вход пользователя в систему.
  4. Файловая система.
    1. Соединение многих файловых систем в одну ("монтирование").
    2. Работа с каталогами.
    3. Создание и уничтожение файлов. Получение информации о файлах.
    4. Ввод-вывод данных.
  5. Процессы.
  6. Сигналы.
  7. Обмен данными между процессами.
    1. Разделяемые файлы.
    2. Каналы межпроцессорного обмена.
    3. Другие способы обмена данными.
      1. Очереди сообщений.
      2. Семафоры.
      3. Разделяемая память.
  8. Распределение памяти.
  9. Инструментальные средства программирования в системе UNIX.
    1. Получение подсказки. Программа man.
    2. Файлы системы UNIX, используемые при компиляции и компоновке программ.
    3. Компилятор языка Си.
    4. Создание библиотек файлов. Программа ar.
    5. Программа make.
    6. Системы контроля исходного кода.
  10. Проблемы переносимости программного обеспечения.

1.1. История создания и развитая системы UNIX.

    UNIX - это многозадачная, многопользовательская операционная система (ОС) общего назначения. Многозадачность означает, что в среде одновременно могут выполняться несколько программ. Термин многопользовательская "говорит" о том, что в системе одновременно могут работать несколько пользователей.

    Операционная система UNIX была создана в первой половине 70-х годов в подразделении фирмы АТ&T, называемом Веll Теlephone Labs. Первая версия системы была реализована на мини-ЭВМ РDP/7 и предназначалась, в основном, для обработки текстовой информации. Пройдя успешную проверку, система получила дальнейшее развитие. Большая часть кодов была переписана с ассемблера РDP на язык высокого уровня Си, разрабатываемый в то же время в упомянутой фирме. UNIX удачно отражал особенности существовавших в то время операционных систем и был применен в ряде университетов Северной Америки для обучения студентов принципам построения ОС. В начале 80-х годов UNIX был установлен на ряде вычислительных машин, отличных по своей архитектуре от РDР. В середине 80-х система привлекла внимание фирм-производителей компьютерного оборудования и программного обеспечения. Появились первые коммерческие версии UNIX. Они получили широкое распространение во всем мире. Сегодня трудно назвать ЭВМ, для которой не была бы разработана версия этой ОС (в дальнейшем версии системы мы также буден называть ее диалектами).

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


1.2. Основные понятия системы UNIX.

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

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

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

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

    Все процессы в системе работают параллельно, используя один центральный процессор по принципу разделения времени. Каждому из них присваивается определенный приоритет. Чем он выше, тем больше "внимания" уделяет ОС задаче.

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

    Важным компонентом операционной системы являются файлы. В UNIX это именованный набор данных, хранящихся на внешнем устройстве (например магнитном диске), который доступен для чтения и (или) записи. Единицей информации, хранящейся в файле, является байт, состоящий из 8 битов. Каждый файл кроме имени имеет дополнительные атрибуты, такие как размер, дата создания, права доступа к нему пользователей и некоторые другие (см. Более подробно, например [3,4]). Файлы объединяются в единую файловую систему (см. ниже).

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


1.3. Начальная загрузка UNIX. Вход пользователя в систему.

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

    При включении машины начинает действовать программа, которая "находит" на устройстве файл, содержащий ядро UNIX, и помещает его в память ЭВМ. Поскольку ядро самостоятельно не выполняет каких-либо действий, для начала работы системы создается самый первый процесс. Ему присваивается идентификатор 0. Данный процесс называется swapper и выполняет действия, связанные с распределением вычислительных ресурсов (памяти и машинного времени) между другими программами. swapper это единственный процесс, который создается не другой задачей, а как бы "сам на себе".

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

    Процесс, обеспечивающий вход пользователя в систему (он называется getty) выводит на терминал сообщение:

login:

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

    login проверяет наличие пользователя с введенным именем в системном учетном файле и выводит на терминал "просьбу" ввести пароль:

password:

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

    Обычно задача, запускаемая процессом login, представляет собой интерпретатор команд (командный процессор (shell)). Он позволяет управлять операционной системой. shell воспринимает ввод с клавиатуры, анализирует полученные строки, определяет содержащиеся в них инструкции и выполняет предписываемые ими действия. Взаимодействие пользователя с системой подробно описано в многочисленной литературе по UNIX и мы не будем рассматривать этот вопрос в данном издании ( см. например, [1-4] ).

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

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


1.4. Файловая система.

    Файловые системы существуют во многих операционных системах и служат для упорядоченного хранения наборов данных (файлов) на внешних запоминающих устройствах. Файловая система UNIX обладает следующими возможностями:

    Физически файловая система располагается на устройствах ввода-вывода с прямым доступом. Обычно это магнитные диски. Каждый из них имеет свой специальный файл, посредством которого производятся операции обмена данными на уровне ядра. Диски в системе UNIX относятся к так называемым блочно-ориентированным устройствам. Это означает, что операции ввода-вывода для них выполняются порциями (блоками). Их размер, как правило, равен или кратен 512 байтам. Таким образом, система рассматривает диск как набор блоков, пронумерованных от 0 до N, где N зависит от размера устройства.

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

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

    На логическом уровне файловая система UNIX организована, как иерархическая последовательность каталогов (директорий), содержащих сами файлы. Директория самого верхнего уровня называется "корневой" и имеет имя "/" (косая черта). Она создается в момент установки ОС на компьютер. Все остальные файлы и директории входят в корневую директорию или в ее подкаталоги. Указание ее местоположения на диске содержится в суперблоке.

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

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

    Например, если надо указать полное имя файла "prog.c", содержащегося в директории "src", которая, в свою очередь, входит в "корневую" директорию, то полное имя (путь) в системе UNIX будет выглядеть следующим образом:

/src/prog.c

    Если в имя файла входит точка ('.'), то часть имени файла, стоящую после нее, называют "расширением". Файлы, имеющие одинаковое назначение или принадлежащие одному типу, имеют одинаковое расширение. Так, программы на языке Си имеют расширение ".с", файлы, содержащие объектный код, расширение ".о", и т.д.

  1. Соединение многих файловых систем в одну ("монтирование").
  2. Работа с каталогами.
  3. Создание и уничтожение файлов. Получение информации о файлах.
  4. Ввод-вывод данных.

1.4.1. Соединение многих файловых систем в одну ("монтирование").

    ОС UNIX по умолчанию имеет, по крайней мере, одну файловую систему, создаваемую при ее генерации. К ней можно присоединить файловые системы, находящиеся на других устройствах. Для этого необходимо выполнить процедуру, которую называют "монтированием" (mount). "Монтирование" производится при помощи системного вызова mount ( ). Формат его следующий:

#include <sys/types.h> #include <sys/mount.h> int mount (const char *spec, const char *path, int mode);

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

    Аргумент spec задает имя специального файла, соответствующего устройству, содержащему файловую систему, path - полное имя директории в существующей файловой системе, mode - режим "монтирования". Указание режима необходимо при присоединении файловых систем, отличающихся по своей природе от файловой системы UNIX (например, файловой системы MS DOS). mount ( ) возвращает значение 0 при успешном завершении и - 1, если в процессе выполнения операции произошли ошибки.

    Новая файловая система присоединяется к существующей в точке, указанной параметром path. Другими словами, содержимое директории path заменяется содержимым "корневой" директории новой файловой системы.

    Рассмотрим пример присоединения файловой системы, находящейся на дискете, к директории "/mnt". Предполагается, что устройству соответствует специальный файл с именем "/dev/diskette" (см. прототип функции perror ( ) в [6]).

if (( err = mount ("/dev/diskette", "/mnt", 0 )) = = - 1 ) perror ("mount: ошибка монтированная");

    Операция обратная "монтированию" - отсоединение ("де-монтирование") производится системным вызовом umount ( ):

#include <sys/types.h> #include <sys/mount.h> int mount (const char *path);

    Ее аргумент задает полное имя директории, к которой файловая система была ранее присоединена. При успешном завершении функция возвращает 0. "Де-монтирование" файловой системы не может быть произведено, если какой-либо процесс производит операции ввода-вывода с файлами, входящими в ее состав.


1.4.2. Работа с каталогами.

    Рассмотрим подробнее информацию, содержащуюся в файле, описывающим директорию. Он состоит из записей, характеризующих содержащиеся в каталоге наборы данных. Для каждого из них указаны имя, возможно некоторые другие атрибуты, и ссылка на элемент в таблице символов узлов. Последнее необходимо для доступа к содержимому файла. С точки зрения прав доступа все пользователи делятся на три категории: владелец файла, пользователи, входящие в одну группу с владельцем, и остальные. Для каждой категории устанавливаются правила на использование файла для чтения, записи и выполнения. Все файлы, описывающие директории, за исключением файла, соответствующего корневой директории, содержат по две записи для имен "." и ". .". Первое из них используется для указания текущей директории, второе - для обозначения директории, являющейся родительской по отношению к текущей. Использование данного имени позволяет "продвигаться" вверх в иерархии файловой системы (см. подробнее, например [3,4].

    Директории создаются системным вызовом mkdir( ) и удаляются rmdir( ). Если директория не является пустой , т.е. содержит некоторое количество файлов, удалить ее нельзя. Формат функций, реализующих эти системные вызовы, следующий:

#include <sys/types.h> #include <sys/stat.h> int mkdir (const char *path, mode_t mode); int rmdir (const char *path);

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

S_IRWXU Владелец может читать, писать и выполнять.
S_IRUSR Владелец может читать.
S_IWUSR Владелец может писать.
S_IXUSR Владелец может выполнять.
S_IRWXG Пользователи, входящие в одну группу с владельцем (группа), могут читать, писать и выполнять.
S_IRGRP Группа может читать.
S_IWGRP Группа может писать.
S_IXGRP Группа может выполнять.
S_IRWXO Все остальные пользователи (остальные) могут читать, писать и выполнять.
S_IROTH Остальные могут читать.
S_IWOTH Остальные могут писать.
S_IXOTH Остальные могут выполнять.

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

#include <sys/dir.h> DIR *opendir (char *dirname); struct direct *readdir (DIR *dirp); int closedir (DIR *dirp);

    (Заметим, что процедуры системно-зависимы. Так, например, в диалекте Solaris 2.x (SunSoft), соответствующем стандарту System V Release 4, вместо файла "sys/dir.h" используется "dirent.h", а вместо структуры direct - структура dirent).

    Функция opendir( ) открывает каталог для чтения. Ее параметр dirname - это имя директории. Возвращает процедура указатель на структуру типа DIR, которая затем используется при работе.

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

struct direct { ino_t d_ino; /*Номер индексного узла */ char d_name [DIRSIZ]; /* Имя файла */ };

Функция closedir( ) закрывает каталог и освобождает необходимые для работы с ним ресурсы.

В следующем примере иллюстрируется приведенный выше материал.

#include <stdio.h> #include <dirent.h> vold main( ) { DIR *dirp; struct direct *directp; dirp = opendir ( "."); while ( (directp = readdir (dirp) ) != NULL) (void) printf ( "%s\n", directp->d_name ); (void) closedir (dirp); return 0; }

Здесь мы открываем текущий каталог и печатаем его содержимое на экране.


1.4.3. Создание и уничтожение файлов. Получение информации о файлах.

    Для создания обычных файлов используется системный вызов creat( ):

#include <sys/types.h> #include <sys/stat.h> #include <fcnt1.h> int creat (char *path, mode_t mode);

    Аргументы функции: path - имя файла, mode - задает атрибуты создаваемого файла (см. описание системного вызова mkdir( ) в предыдущем разделе).

    Специальные файлы, соответствующие устройствам, обычно располагаются в директории "/dev". Создать их можно системным вызовом mknod( ):

#include <sys/types.h> #include <sys/stat.h> int mknod (char *path, mode_t, dev_t dev);

Аргумент mode задает права доступа к файлу. deu представляет собой структуру, задающую тип и номер устройства, соответствующего специальному файлу.

Когда файл более не нужен, его можно удалить с помощью функции unlink ( ):

#include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> int unlink (char *path);

где path - имя файла. Последний должен быть закрыт.

Чтобы получить информацию о наборе данных, в UNIX предусмотрена функция stat( ):

#include <sys/types.h> #include <sys/stat.h> int stat (char *path, struct stat *fstat);

Она по имени файла path находит соответствующую информацию и помешает ее в структуру, на которую указывает fstat. Среди полей структуры stat особый интерес представляют следующие:

st_mode - тип файла; это комбинация флагов:
S_IFDIR - каталог;
S_IFCHR - символьно-ориентированное устройство;
S_IFBLK - блочно-ориентированное устройство;
S_IFREG - обычный файл;
st_uid - идентификатор владельца файла;
st_gid - идентификатор группы, которой принадлежит владелец файла;
st_size - размер файла;
st_atime - время последнего использования;
st_mtime - время последней модификации.

1.4.4. Ввод-вывод данных.

    Функции ввода-вывода используются в системе UNIX для доступа к содержимому файлов. Они делятся на две основные группы. Первая представляют собой группу системных вызовов и реализует минимальный набор действий, необходимых для чтения (записи) информации. Это как бы нижний уровень. Процедуры, входящие во вторую группу, называются функциями буферизованного или форматированного ввода-вывода. Они используют в своей работе обращения к системным вызовам нижнего уровня для выполнения непосредственного ввода-вывода данных. Функции, описываемые в данном разделе, относятся к первой группе. Процедуры буферизованного ввода-вывода можно найти в [6].

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

#include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> int open (const char *path, int oflag, mode_t mode);

    Здесь path - имя файла, oflag - режим открытия файла, задаваемый при помощи констант:

O_RDONLY - открыть для чтения;
O_WRONLY - открыть для записи;
O_RDWR - открыть для чтения и записи.

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

O_APPEND - добавить данные в конец файла;
O_CREAT - создать файл;
O_EXCL - возвратить код ошибки, если задан флаг O_CREAT, и файл уже существует;
O_TRUNC - урезать открываемый файл до нулевой длины.

    Аргумент mode - задает атрибуты создаваемого файла (см. описание системного вызова mkdir( ) в разделе 1.4.2).

    При успешном завершении системный вызов open( ) возвращает дескриптор файла. В случае ошибки открытия файла функция возвращает -1.

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

    Ядро системы устанавливает ограничение на количество одновременно открытых одним процессом файлов Их максимальное число может 16 до 64, в зависимости от версии UNIX и конфигурации ядра системы, заданной при генерации.

    После того как файл открыт, данные вводятся из него посредством функции read( ):

#include <unistd.h> int read (int fd, void *buff, size_t nbytes);

    Здесь fd - дескриптор файла, buff - адрес буфера для приема данных, и nbytes - количество байт, подлежащих вводу из файла. Функция возвращает число считанных байт.

    Данные выводятся из памяти в файл системным вызовом write( ):

#include <unistd.h> int write (int fd, void *buff, size_t nbytes);

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

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

#include <sys/types.h> #include <unistd.h> int lseek (int fd, off_t offset, int whence);

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

SEEK_SET - относительно начала файла;
SEEK_CUR - относительно текущей позиции;
SEEK_END - относительно конца файла.

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

#include <unistd.h> int close (int fd);

    Все открытые файлы автоматически закрываются при завершении процесса.

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

#include <sys/types.h> #include <sys/stat.h> #include <unistd.h> #include <fcntl.h> #include <strings.h> char *file_name = "file.txt"; char *string = "This string to add.\n"; int main ( ) { int fd; if ( (fd = open (file_name, O_WRONLY)) < 0) perror ("open file error"); if (lseek (fd, o, SEEK_END) < 0) perror ("lseek error"); if (write (fd, string, strlen (string)) != strlen (string) perror ("write error"); close (fd); }

1.5. Процессы.

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

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

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

    Здесь argv - массив указателей на строки, содержащие параметры, переданные задаче при запуске, argc - число элементов массива argv, envir - массив строк, содержащих переменные среды (environment) и их значения.

    Завершение процесса происходит при возврате из функции main( ) или вызове программой функции exit( ).

    Во время выполнения задача может запустить другой процесс. Для этого в системе UNIX предусмотрена системная функция fork( ):

#include <sys/types.h> #include <unistd.h> pid_t fork (void);

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

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

#include <unistd.h> int exec ( const char *path, int argc, char *argv[ ] );

    Здесь path - имя выполняемого файла, argv - массив указателей на строки, передаваемые загружаемой программе в качестве параметров, argc - количество строк массива argv. Прототипы и объяснение других процедур, подобных exec( ), можно найти в соответствующих разделах подсказки UNIX (см. подробнее 1.9.1.).

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

#include <sys/types.h> #include <sys/wait.h> pid_t wait (int *status);

    Функция возвращает идентификатор процесса, окончившего работу. Код его завершения записывается по адресу, заданному аргументом status.

    Заметим, что в различных диалектах UNIX имеются более развитые варианты функции wait( ). Это wait3( ), waitpid( ), waitid( ) (более подробную информацию можно найти в документации по той системе, на которой работает пользователь).

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

#include <sys/types.h> #include <sys/wait.h> #include <unistd.h> int system (const char *cmd_string) { pid_t pid; int status; if (cmd_string = = NULL) return (1); if ( (pid = fork ( ) ) < 0) return (-1); if (pid == 0) { /* процесс-"потомок" */ execl ("/bin/sh", "sh", "-c", cmd_string, (char*) 0); exit (-1); /* выполняется при ошибке в execl */ } else { /* процесс-"родитель" ожидает завершения */ /* выполнения процесса-"потомка" */ wait (&status); } }

1.6. Сигналы.

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

  1. При нажатии пользователем определенных клавиш на клавиатуре терминала. Например, <Ctrl+С> - завершить процесс и <Ctrl+Z> - приостановить процесс.
  2. При аппаратных сбоях или попытке выполнения процессом ошибочных с точки зрения системы действий, таких, как деление на ноль или обращение к неверному адресу.
  3. Сигналы могут посылаться одним процессом другому при помощи системного вызова kill( ).
  4. При возникновении программных ошибок в системе, например, при переполнении буфера во время выполнении операции буферизованного вывода.

    Когда программа получает сигнал, вызывается либо функция, зарегистрированная как реакция на него, либо выполняется стандартное действие ОС. Большинство нетривиальных программ в системе UNIX используют сигналы. Каждый сигнал имеет целочисленный идентификатор. Некоторые сигналы являются стандартными. Их идентификаторам соответствуют символические константы, определенные в файле-заголовке <signal.h>.

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

SIGABRT Сигнал генерируется вызовом функции abort( ) и вызывает аварийное завершение процесса.
SIGALARM Сигнал посылается по истечении времени, установленного функцией alarm( ).
SIGBUS Сигнал возникает при сбое оборудования.
SIGCONT Сигнал позволяет возобновить выполнение процесса, прерванного по сигналу SIGSTOP.
SIGFPE Ошибка при выполнении арифметической операции с действительными числами.
SIGILL Неверная инструкция процессора при выполнении программы.
SIGINT Сигнал возникает при вводе с терминала <Ctrl+С>.
SIGIO Завершение асинхронной операции ввода-вывода.
SIGIOT Ошибочное завершение асинхронной операции ввода-вывода.
SIGKILL Немедленно завершить процесс.
SIGPIPE Ошибки при записи в канал межпроцессного ввода-вывода (pipe) (см. 1.7.2.).
SIGSEGV Ошибка при использовании процессом неверного адреса.
SIGSTOP Приостановить выполнение процесса.
SIGSYS Ошибка при выполнении системного вызова.
SIGTERM Завершить процесс.
SIGTRAP Аппаратная ошибка при выполнении процесса.
SIGTSTP Ввод с клавиатуры <Ctrl+Z> (приостановить процесс).

    Программа, получив сигнал, может обработать его одним из трех способов.

  1. Процесс может проигнорировать сигнал. Это невозможно только для SIGKILL и SIGSTOP. Эти два сигнала позволяют администратору системы UNIX прерывать или приостанавливать процессы в случае необходимости.
  2. Процесс может зарегистрировать собственную функцию обработки сигнала. Рекомендуется, по возможности, обрабатывать сигналы, приводящие к преждевременному завершению программы.
  3. Если не задана функция обработки сигнала, ядро системы выполняет действия, предусмотренные для его обработки по умолчанию. Если пришел сигнал, связанный с программными и аппаратными ошибками, процесс, как правило, завершается с созданием в текущей директории файла с именем "core". В последний помещается содержимое области оперативной памяти, которая была занята задачей в момент прихода сигнала.

    Для задания способа обработки сигнала используется системный вызов signal( ):

#include <signal.h> int (*signal (int sig, void (*func) (int))) (int);

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

    Приведен пример работы с сигналами.

#include <stdio.h> #include <signal.h> void SignalCtrlC (int sig_no) { printf ("Receive signal : %d\n", sig_no); signal (SIGINT, SignalCtrlC); }; void main (int argc, char *argv []) { char c = 0; signal (SIGINT, SignalCtrlC); while (c!='q') c = getchar( ); }

    Программа заказывает реакцию на нажатие комбинации <Ctrl+C> (SIGINT). После чего ожидается ввод с терминала символа 'q'. Когда он приходит, процесс завершается. Функция SignalCtrlC( ) реагирует на сигнал SIGINT, просто печатая соответствующее сообщение.


1.7. Обмен данными между процессами.

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

  1. Разделяемые файлы.
  2. Каналы межпроцессорного обмена.
  3. Другие способы обмена данными.
    1. Очереди сообщений.
    2. Семафоры.
    3. Разделяемая память.

1.7.1. Разделяемые файлы.

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

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

    Для блокирования записи используется системный вызов lockf( ):

#include <unistd.h> int lockf (int fd, int function, long size);

    Здесь fd - дескриптор файла. Аргумент function задает выполняемую операцию и может принимать следующие значения:

F_ULOCK - отменить предыдущую блокировку;
F_LOCK - блокировать запись;
F_TLOCK - блокировать запись с проверкой, не блокирована ли она другим процессом;
F_TEST - проверить, не блокирована ли запись другим процессом.

     Начало записи определяется текущим положением указателя в файле. Длина записи задается аргументом size. При неудачной попытке блокирования записи функция возвращает в качестве своего значения (-1).

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


1.7.2. Каналы межпроцессного обмена.

    В ранних версиях системы UNIX основным средством для обмена данными между задачами были каналы межпроцессного обмена (pipes).

    Для создания канала используется системный вызов pipe( ). Его формат следующий:

#include <inistd.h> int pipe (int fd[2]);

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

    После создания pipe, процесс может при помощи обычного системного вызова write( ) выводить данные в него, а затем вводить их, вызывая соответственно функцию read( ). При выполнении вызова fork( ) дескрипторы канала наследуются процессом-"потомком". Таким образом, оба процесса получают возможность обмениваться данными.

    Ограничением в данном случае является то, что канал должен работать лишь в одну сторону. Либо "родитель" должен писать, а "потомок" читать, либо наоборот. В первом случае для передачи данных от процесса-"родителя" к процессу-"потомку", первый должен закрыть fd[0], а второй fd[1]. Во втором случае выполняются противоположные действия. Если предполагается передавать данные в обе стороны, необходимо создать два канала.

    Приведенный ниже пример иллюстрирует использование pipe. Процесс-"родитель" создает канал и порождает новый процесс. Затем выводит в pipe строку, которую "потомок" читает и выводит на терминал.

#include <stdio.h> #include <inistd.h> int main () { int n, fd [2]; pid_t pid; char line [128]; if (pipe (fd) == -1) perror ("pipe: ошибка создания канала"); if ( (pid = fork ( )) == -1) perror ("fork: ошибка создания процесса"); if (pid > 0) { /*процесс-"родитель" */ close (fd [0] ); write (fd [1], "Hello, world!\n", 12); } else { /*процесс-"потомок" */ memset (line, 0, 120); close (fd [1] ); n = read (fd [0], line, 120); puts (line); } exit (0); }

    К недостаткам каналов следует отнести то, что они имеют свойство переполняться. Это значит, что если вывод в pipe происходит более интенсивно, чем ввод из него, возникает ошибка, и ядро системы генерирует сигнал SIGPIPE. Существенным ограничением является и то, что взаимодействовать через pipe могут только процессы, находящиеся "в родственных отношениях" ("родитель"-"потомок").


1.7.3. Другие способы обмена данными.

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

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

  1. Очереди сообщений.
  2. Семафоры.
  3. Разделяемая память.

1.7.3.1. Очереди сообщений.

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

    Создание объекта или доступ к нему производится при помощи функции msgget( ):

#include <sys/types.h> #include <sys/ipc.h> #include <sys/msg.h> int msgget (key_t key, int flag);

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

MSG_R 00400 владелец может читать;
MSG_W 00200 владелец может писать;
(MSG_R>>3) 00040 пользователи, входящие в одну группу с владельцем (группа), могут читать;
(MSG_R>>3) 00020 группа может писать;
(MSG_R>>6) 00004 все остальные пользователи (остальные) могут читать;
(MSG_R>>6) 00002 остальные могут писать;
IPC_PRIVATE   создается объект, который может использоваться лишь данным процессом;
IPC_CREAT   создать объект, если он не существует;
IPC_EXCL   используется в сочетании с IPC_CREAT; если этот флаг выставлен, и объект с заданным ключом существует, то функция возвращает код ошибки.

     (При описании приведены также численные значения констант). Функция возвращает процессу идентификатор, используемый в дальнейшем для работы с объектом IPC. В случае возникновения ошибок msgget( ) возвращает значение -1.

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

struct msg_struct { long msg_type; /* тип сообщения */ char msg_data [MAX_MSG__DATA]; /* данные сообщения */ };

    Тип используется при выборе сообщения из очереди. Следует отметить, что операционная система накладывает ограничения на максимальный размер сообщений, количество сообщений в одной очереди и общее количество сообщений, записанное во все очереди. Соответствующие ограничения задаются константами MSGMAX, MSGNNB, MSGTQL в файле <sys/msg.h>.

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

#include <sys/types.h> #include <sys/ipc.h> #include <sys/msg.h> int msgsnd (int msgid, const void *ptr, size_t nbytes, int flag;

    Здесь :

msgid - идентификатор очереди, полученный при вызове msgget( );
ptr - указатель на сообщение, записываемое в очередь;
nbytes - размер сообщения в байтах;
flag - либо 0, либо IPC_NOWAIT; в первом случае, если очередь полна, то msgsnd( ) ждет ее освобождения; во втором случае функция при полной очереди возвращается сразу с кодом -1.

     Получение сообщений из очереди производится вызовом функции msgrcv( ). Формат вызова функции следующий:

#include <sys/types.h> #include <sys/ipc.h> #include <sys/msg.h> int msgrcv (int msgid, void *ptr, size_t nbytes, long type, int flag);

    Здесь:

msgid - идентификатор очереди, полученный при вызове msgget( );
ptr - указатель на буфер для приема сообщения;
nbytes - размер буфера;
type - тип выбираемого из очереди сообщения; если значение этого параметра равно 0, из очереди выбирается первое по порядку сообщение; если type больше 0, из очереди выбирается первое сообщение, поле msg_type которого (см. определение msg_struct) содержит значение равное значению, хранящемуся в первых четырех байтах буфера приема сообщения; если же type меньше 0, будет выбрано сообщение, имеющее минимальное значение поля msg_type;
flag - либо 0, либо IPC_NOWAIT; в первом случае, если очередь пуста, то msgrcv() ждет прихода события; во втором случае функция при пустой очереди возвращается сразу с кодом -1.

     Для управления состоянием очереди сообщений используется системный вызов msgctl( ). Функция позволяет получить информацию о состоянии очереди, изменить права доступа процессов к ней или удалить объект. Ее прототип следующий:

#include <sys/types.h> #include <sys/ipc.h> #include <sys/msg.h> int msgctl (int msgid, int cmd, struct msqid_ms *buf);

Здесь:

msgid - идентификатор очереди, полученный при вызове msgget( );
cmd - команда управления очередью; значения параметра определяются константами: IPC_STAT - получить состояние очереди, IPC_SET - установить параметры очереди, и IPC_RMID - удалить очередь сообщений;
buf - адрес структуры данных, используемой при выполнении команд, задаваемых параметром cmd.

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

Оба процесса используют файл заголовок "message.h".

#include <sys/types.h> #include <sys/ips.h> #include <sys/msg.h> #define MSQ_ID 2001 /* уникальный ключ очереди */ #define PERMS 00666 /* права доступа - все могут читать и писать */ #define MSG_TYPE_STRING 1 /* тип сообщения о том, что передана непустая строка */ #define MSG_TYPE_FINISH 2 /* тип сообщения о том, что пора завершать обмен */ #define MAX_STRING 120 /* максимальная длина строки */ typedef struct /* структура сообщения */ { int type; char string [MAX_STRING]; } message_t;

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

#include <stdio.h> #include <string.h> #include "message.h" void sys_err (char * msg) { puts (msg); exit (1); } int main () { int msqid; /* идентификатор очереди сообщений */ message_t msg; /* сообщение */ char s [MAX_STRING]; /* создание очереди */ if ( (msqid = msgget (MSQ_ID, 0)) < 0) sys_err ("client:can not get msg queue"); while (1) { scanf ("%s", s); /* ввод строки */ if (strlen (s) != 1) { msg.type = MSG_TYPE_STRING; strncpy (msg.string, s, MAX_STRING); } else { msg.type = MSG_TYPE_FINISH; }; /* посылка сообщения процессу-серверу */ if (msgsnd (msqid, &msg, sizeof (message_t), 0) != 0) sys_err ("client: message send error"); if (strlen (s) == 1) /* пустая строка - выход */ break; } exit (0); }

    Код программы-сервера:

#include <stdio.h> #include <string.h> #include "message.h" void sys_err (char * msg) { puts (msg); exit (1); }; int main () { int msqid; message_t msg; char s [MAX_STRING]; /* создание очереди сообщений */ if ( (msqid = msgget (MSQ_ID, PERMS | IPC_CREAT) ) < 0) sys_err ("server: can not create msg queue"); while (1) { /* получение очередного сообщения */ if (msgrcv (msqid, &msg, sizeof (message_t), 0, 0) < 0) sys_err ("server: msg recive error"); if (msg.type == MSG_TYPE_STRING) /* печать строки */ printf ("%s", msg.string); if (msg.type == MSG_TYPE_FINISH) /* выход из цикла */ break; } /* удаление очереди сообщений */ if (msgctl (msqid, IPC_RMID, (struct msqid_ds *) 0) < 0) sys_err ("server: msq queue remove error"); exit (0); }

1.7.3.2. Семафоры.

    Семафоры представляют собой стандартный способ разрешения или запрещения выполнять те или иные действия. Семафор - это обычно целое число. Если оно имеет значение 1 (установлен), то операция запрещена. Если его значение 0, то - разрешена. UNIX позволяет создавать сразу целые массивы семафоров. Они, как и другие объекты IPC, идентифицируются с помощью уникального ключа, задаваемого неотрицательным целым числом.

    Для создания массива семафоров используется системный вызов semget( ):

#include <sys/types.h> #include <sys/ipc.h> #include <sys/sem.h> int semget (key_t key, int nsems, int flag);

    Здесь:

key - уникальный ключ объекта IPC;
nsems - количество семафоров в массиве;
flag - задание режима создания массива семафоров; аналогично параметру flag в msgget( ), только префикс "MSG" заменен "SEM".

    Функция semget( ) возвращает целое число, используемое для доступа к массиву семафоров.

    Устанавливать и получать значения элементов, а также управлять состоянием всего массива семафоров можно при помощи системного вызова semctl( ).

#include <sys/types.h> #include <sys/ipc.h> #include <sys/sem.h> int semctl (int semid, int semnum, int cmd, union semun arg);

    Здесь:

semid - идентификатор массива семафоров, возвращаемый функцией semget( );
semnum - номер изменяемого элемента в массиве;
cmd - выполняемая операция; основные из них следующие:
GETVAL - получить значение семафора;
SETVAL - задать значение семафора;
IPC_RMID - удалить объект;
arg - объединение, используемое для передачи данных, необходимых для выполнения операции и/или получения ее результатов; например, ее поле val служит для задания и получения значения семафора.

    Пример работы с массивами семафоров приведен в следующем разделе.


1.7.3.3. Разделяемая память.

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

    Для получения доступа к сегменту разделяемой памяти используется системный вызов shmget( ). Формат его следующий:

#include <sys/types.h> #include <sys/ipc.h> #include <sys/shm.h> int shmget (key_t key, int size, int flag);

    Здесь:

key - уникальный ключ разделяемого сегмента памяти;
size - размер сегмента; в некоторых версиях системы UNIX он ограничен 128 К байтами;
flag - задание режима создания разделяемой памяти; значение этого параметра то же, что и в msgget( ), только префикс "MSG" заменен на "SHM".

    При успешном создании или, если объект с заданным ключом key существует, функция возвращает идентификатор сегмента. В случае ошибки функция возвращает значение -1.

    После создания разделяемой памяти, надо получить ее адрес. Для этого используется вызов shmat( ):

#include <sys/types.h> #include <sys/ipc.h> #include <sys/shm.h> void *shmat (int shmid, void *addr, int flag);

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

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

#include <sys/types.h> #include <sys/ipc.h> #include <sys/shm.h> void shmdt (void *ptr); int shmctl (int shmid, int cmd, struct shmid_ds *buf);

    Здесь :

ptr - указатель на сегмент разделяемой памяти, полученный при вызове shmat( );
shmid - идентификатор сегмента, возвращаемый функцией shmget( );
cmd - выполняемая операция, основная из них IPC_RMID - удалить объект;
buf структура, используемая для передачи данных, необходимых для выполнения операции и/или получения ее результатов.

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

    Оба процесса используют файл-заголовок "message.h".

#include <sys/types.h> #include <sys/ipc.h> #include <sys/sem.h> #include <sys/shm.h> #include <stdio.h> #define SEM_ID 2001 /* ключ массива семафоров */ #define SHM_ID 2002 /* ключ разделяемой памяти */ #define PERMS 0666 /* права доступа */ /* коды сообщений */ #define MSG_TYPE_EMPTY 0 /* пустое сообщение */ #define MSG_TYPE_STRING 1 /* тип сообщения о том, что передана непустая строка */ #define MSG_TYPE_FINISH 2 /* тип сообщения о том, что пора завершать обмен */ #define MAX_STRING 120 /* структура сообщения, помещаемого в разделяемую память */ typedef struct { int type; char string [MAX_STRING]; } message_t;

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

#include <stdio.h> #include <string.h> #include "message.h" void sys_err (char *msg) { puts (msg); exit (1); } int main () { int semid; /* идентификатор семафора */ int shmid; /* идентификатор разделяемой памяти */ message_t *msg_p; /* адрес сообщения в разделяемой памяти */ char s[MAX_STRING]; /* получение доступа к массиву семафоров */ if ((semid = semget (SEM_ID, 1, 0)) < 0) sys_err ("client: can not get semaphore"); /* получение доступа к сегменту разделяемой памяти */ if ((shmid = shmget (SHM_ID, sizeof (message_t), 0)) < 0) sys_err ("client: can not get shared memory segment"); /* получение адреса сегмента */ if ((msg_p = (message_t *) shmat (shmid, 0, 0)) == NULL) sys_err ("client: shared memory attach error"); while (1) { scanf ("%s", s); while (semctl (semid, 0, GETVAL, 0) || msg_p->type != MSG_TYPE_EMPTY) /* * если сообщение не обработано или сегмент блокирован - ждать * */ ; semctl (semid, 0, SETVAL, 1); /* блокировать */ if (strlen (s) != 1) { /* записать сообщение "печать строки" */ msg_p->type = MSG_TYPE_STRING; strncpy (msg_p->string, s, MAX_STRING); } else { /* записать сообщение "завершение работы" */ msg_p->type = MSG_TYPE_FINISH; }; semctl (semid, 0, SETVAL, 0); /* отменить блокировку */ if (strlen (s) == 1) break; } shmdt (msg_p); /* отсоединить сегмент разделяемой памяти */ exit (0); }

    Код программы-сервера:

#include <stdio.h> #include <string.h> #include "message.h" void sys_err (char *msg) { puts (msg); exit (1); } int main () { int semid; /* идентификатор семафора */ int shmid; /* идентификатор разделяемой памяти */ message_t *msg_p; /* адрес сообщения в разделяемой памяти */ char s[MAX_STRING]; /* создание массива семафоров из одного элемента */ if ((semid = semget (SEM_ID, 1, PERMS | IPC_CREAT)) < 0) sys_err ("server: can not create semaphore"); /* создание сегмента разделяемой памяти */ if ((shmid = shmget (SHM_ID, sizeof (message_t), PERMS | IPC_CREAT)) < 0) sys_err ("server: can not create shared memory segment"); /* подключение сегмента к адресному пространству процесса */ if ((msg_p = (message_t *) shmat (shmid, 0, 0)) == NULL) sys_err ("server: shared memory attach error"); semctl (semid, 0, SETVAL, 0); /* установка семафора */ msg_p->type = MSG_TYPE_EMPTY; while (1) { if (msg_p->type != MSG_TYPE_EMPTY) { if (semctl (semid, 0, GETVAL, 0)) /* блокировка - ждать */ continue; semctl (semid, 0, SETVAL, 1); /* установить блокировку */ /* обработка сообщения */ if (msg_p->type == MSG_TYPE_STRING) printf ("%s\n", msg_p->string); if (msg_p->type == MSG_TYPE_FINISH) break; msg_p->type = MSG_TYPE_EMPTY; /* сообщение обработано */ semctl (semid, 0, SETVAL, 0); /* снять блокировку */ } } /* удаление массива семафоров */ if (semctl (semid, 0, IPC_RMID, (struct semid_ds *) 0) < 0) sys_err ("server: semaphore remove error"); /* удаление сегмента разделяемой памяти */ shmdt (msg_p); if (shmctl (shmid, IPC_RMID, (struct shmid_ds *) 0) < 0) sys_err ("server: shared memory remove error"); exit (0); }

1.8. Распределение памяти.

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

Форматы вызова перечисленных процедур следующие:

#include <sys/types.h> #include <stdlib.h> void *malloc (size_t size); void *calloc (size_t nobj, size_t size); void *realloc (void *ptr, size_t newsize); void free (void *ptr);

    Здесь:

size - в случае malloc( ) - размер выделяемой области памяти в байтах; в случае calloc( ) - размер в байтах элемента массива данных, под который выделяется память;
nobj - число элементов массива данных;
ptr - указатель на ранее выделенную память;
newsize - новый размер области памяти.

    Функции malloc( ), calloc( ) и realloc( ) возвращают указатель на выделенную память или NULL, если свободного сегмента указанного размера найти не удалось. Адрес памяти, возвращаемый процедурами, гарантировано выравнивается на границу максимального по размеру типа данных в системе. Обычно это тип double, размер которого равен восьми байтам.

    В приводимом примере мы заказываем память под матрицу чисел и заполняем ее единицами.

#include <stdlib.h> void FillMatrix (int **matrix, int nrows, int ncols) { int i, j; for (i=0; i <nrows; i++) for (j=0; j<nrows; j++) matrix [i] [j] = 1; } void main( ) { int **matrix; if ((matrix=(int**)malloc(100*sizeof(int*)) == NULL) { puts ("no memory\n"); exit (1); } for (i=0; i<100; i++) if ( (matrix [i] = (int*)calloc(100, sizeof(int)) == NULL) { puts ("no memory\n"); exit (1); } } FillMatrix (matrix, 100, 100); for (i=0; i<100; i++) free (void*)matrix[i]); free ( (void*)matrix); exit (0); }

    Здесь мы намеренно, в демонстрационных целях, применяли разные функции для выделения памяти.


 1.9. Инструментальные средства программирования в системе UNIX.

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

    Мы не приводим, правда, сведений о, пожалуй, самой необходимой компоненте - текстовом редакторе. Но их можно найти в [2, 3, 4, 16]. В этих книгах достаточно подробно разобраны такие стандартные утилиты как ed, vi, а в [16] перечислены основные приемы работы с очень распространенным в UNIX редактором emacs.

  1. Получение подсказки. Программа man.
  2. Файлы системы UNIX, используемые при компиляции и компоновке программ.
  3. Компилятор языка Си.
  4. Создание библиотек файлов. Программа ar.
  5. Программа make.
  6. Системы контроля исходного кода.

1.9.1. Получение подсказки. Программа man.

    В UNIX с самого начала существует полезная программа, позволяющая получить подсказку по интересующей пользователя команде, стандартной библиотечной процедуре или системному вызову. Это программа - man ( от manual - руководство ).

    Ее формат следующий:

man [ ключи ] имя

где "имя" - это строка, идентифицирующая объект, о котором надо получить подсказку. Опции man приведены ниже.

    Работает программа следующим образом. Она просматривает файлы, находящиеся в поддиректориях каталога "/usr/man" (заметим, что это имя может меняться в разных версиях ОС, так в системе Solaris 2.x фирмы SunSoft просматривается директория "usr/share/man"). Эти поддиректории называются секции (разделы). Их имена образуются следующим образом: "man<идентификатор>", например "man1", "man1m" и т.д. Если при поиске обнаруживается файл с именем, совпадающим с указанным в командной строке, и расширением, совпадающим с идентификатором раздела, то этот файл показывается на экране. Для просмотра используются клавиши:

<Enter>            - на строку вниз;
<Пробел>       - на экран вниз;
<Ctrl+b>, <b> - на экран вверх.

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

имя ( идентификатор_секции )

    Например:

mount(1m) - команда mount из раздела "1m";
mount(2)  - системный вызов mount( ) из раздела "2".

    Приведем теперь основные ключи команды man.

-M
задает путь для поиска поддиректорий, содержащих файлы подсказки; по умолчанию это "/usr/man" ("/usr/share/man");
-a
находятся и показываются на экране по порядку тексты из всех секций, относящихся к указанному имени;
-l
находятся и предъявляются пользователю ссылки на подсказки, относящиеся к   указанному имени, но находящиеся в разных разделах; так, команда

     man - l mount

выводит на экран

    mount(1m) -M /usr/man
    mount(2) -M /usr/man

-s
"идентификатор" задает идентификатор просматриваемой при поиске секции; другие разделы при этом игнорируются; так, команда

    man -sim mount

выдаст подсказку по команде mount, а

    man -s2 mount

по системному вызову mount( ).


1.9.2. Файлы системы UNIX, используемые при компиляции и компоновке программ.

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

    При программировании на Си в файлы исходного кода (имеющие расширение ".с") включаются файлы-заголовки (расширение ".h"). Система имеет довольно много таких файлов, располагающихся в директории "/usr/include" и ее поддиректориях. Они содержат прототипы системных функций, различные структуры и типы данных и именованные константы.

    Когда программа скомпилирована, из полученных объектных файлов (расширение ".o") создается выполнимый файл. Этот процесс называется компоновкой. В порождаемый на этом шаге программный модуль должны включаться коды всех используемых приложением процедур. Системные функции находятся в так называемых библиотечных (архивных) файлах. Они имеют расширение ".a" и располагаются в директории "/usr/lib". Основным из них является файл "libc.a".

    Каждый архивный файл представляет собой совокупность объектных модулей. По принятому в системе соглашению их имена начинаются с префикса "lib".


1.9.3. Компилятор языка Си.

    Си-компилятор является основным средством для создания программ в системе UNIX. Переоценить его значение для системы невозможно. Достаточно сказать, что около 90% ядра и почти 100% утилит и библиотек системы UNIX написаны на языке Си.

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

    Как и большинство сложных программ в системе UNIX, компилятор состоит из нескольких выполняемых файлов. Основной из команд, с которой приходится иметь дело пользователю, является команда cc. Ее функцией является проанализировать введенную командную строку и выполнить действия, предписанные указанными после имени команды параметрами. Для выполнения описанной выше последовательности действий требуется вызвать программы:

/lib/cpp - препроцессор; /lib/cc0 - синтаксический анализатор; /lib/cc1 - генератор кода; /bin/as - ассемблер; /bin/ld - загрузчик.

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

    Следует отметить, что Си-компилятор зависит от версии системы. UNIX может иметь стандартный Си-компилятор, разработанный еще при создании первых версий ОС. Этот компилятор описан в [6] и в честь авторов его обычно называют "Керниган-Ритчи Си". Современные версии системы UNIX имеют в своем составе ANSI Си-компилятор.

    Для получения выполняемого файла программы, исходный текст которой содержится в одном файле "myprog.c", достаточно ввести следующую строку:

cc myprog.c

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

    Синтаксис командной строки для Си-компилятора следующий:

cc [ключ [имя файла]] ...

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

.c - исходные тексты программ;
.i - выходные файлы препроцессора;
.s - код на языке ассемблера;
.h - включаемые препроцессором файлы-заголовки;
.o - объектные файлы;
.a - архивы объектных файлов (библиотеки).

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

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

    Опция '-c' сообщает компилятору, что входные файлы должны быть откомпилированы или ассемблированы, но не объединены в выполнимую программу. Полученные объектные модули по умолчанию помещаются в файлы с именами, полученными заменой расширений ".c", ".i" или ".s" на ".o". Так команда

cc -c myprog.c

порождает объектный файл "myprog.o".

    По умолчанию компилятор создает выполнимый файл с именем "a.out". Изменить это имя можно с помощью ключа '-o'. Так, в результате работы команды

cc -o myprog myprog.c

из файла "myprog.c" будет создан выполнимый модуль с именем myprog.

    Оптимизировать объектный код можно, указав в командной строке ключ '-O'. Некоторые реализации Си-компилятора поддерживают несколько уровней (степеней) оптимизации генерируемого объектного кода. Для этого используется ключ '-On', где n - число, задающее уровень (например, '-01' или '-02').

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

    Ключ '-D' позволяет задать директиву препроцессора "#define" без изменения исходного текста программы. Наличие в командной строке записи "-Dname=value" эквивалентно указанию в файле с исходным кодом строки:

#define name value

    Если поле value в командной строке отсутствует, константе присваивается значение 1. Задание знаачений в командной строке имеет приоритет перед определением их в тексте программы.

    Одним из важнейших свойств языка а программирования Си является использование включаемых файлов (или файлов-заголовков). Имена их, как правило, заканчиваются расширением ".h". Как мы уже упоминали, стандартные файлы-заголовки в системе UNIX располагаются в директории "/usr/include". Если имя включаемого файла-заголовка в тексте программы указывается в скобках ("<...>"), и имя файла не начинается с символа '/', препроцессор составляет полное имя файла по правилу: "/usr/include" + "имя файла". Если включаемый файл находится в директории, не являющейся поддиректорией "/usr/include", можно указать его местоположение при помощи ключа "-I". Например, если есть необходимость использовать файл-заголовок, находящийся в директории "/work/include", нужно указать его местоположение следующим образом:

cc -I/work/include -c myprog.c

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

    Для указания загрузчику того, что при создании выполняемого файла требуется использовать какой-либо библиотечный файл, используется ключ "-l". Мы уже говорили, что библиотеки объектных модулей хранятся в файлах, имена которых начинаются с "lib" и заканчиваются расширением ".а". Часть имени файла между ними является собственно именем библиотеки. В системе UNIX все стандартные системные библиотеки располагаются в директории "/usr/lib". Стандартная Си-библиотека системы располагается в файле "/usr/lib/libc.a" и подключается загрузчиком автоматически без указания ее в командной строке. Все остальные библиотеки являются дополнительными и требуют явного указания для использования. Например, если в программу надо включить библиотеку математических функций, находящуюся в файле "/lib/libm.a", командная строка должна содержать ссылку на нее в виде "-lm":

cc -o myprog myprog.c -lm

    Если необходимая библиотека находится в директории, отличной от стандартных ("lib" и "/usr/lib"), необходимо указать ее расположение при помощи ключа "-L". Действие ключа "-L" и правила поиска архивных файлов те же, что и при поиске файлов-заголовков с использованием ключа "-I". Например:

cc -L. . /lib -o myprog myprog.c -lmyprog

    Здесь при компоновке программы будет подключаться архив с именем "libmyprog.a", находящийся в директории "../lib".


1.9.4. Создание библиотек файлов. Программа ar.

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

    Формат команды, запускающей архиватор, следующий:

ar [команда] имя_библиотеки имена_файлов

    При этом "команда" задает действие над перечисленными в строке файлами. Эти действия следующие:

r
добавить или заменить файлы "имена_файлов" в библиотеке "имя_библиотеки"; если архива нет - он создается;
d
удалить файлы "имена_файлов" из библиотеки "имя_библиотеки";
t
распечатать содержимое библиотеки "имя_библиотеки";
x
достать файлы "имена_файлов" из библиотеки "имя_ библиотеки".

    Команды могут иметь модификаторы:

v
печатать на экране все, что делает программа ar;
u
при использовании с командой "r" заменяются лишь файлы, версии которых в архиве отличаются от новых.

    Например, команда

ar r mylib a.c

    создает библиотеку "mylib" и помещает в нее файл "a.c". На экран ничего не выводится. Команда

ar rv mylib a.out

    добавляет в "mylib" файл "a.out", при этом печатается соответствующее сообщение. Команда

ar t mylib

    показывает содержимое библиотеки.

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

ranlib mylib

    После этого библиотека готова для компоновки.


1.9.5. Программа make.

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

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

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

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

    Решить проблему помогает утилита make. Она позволяет задавать связь модулей, умеет делать сравнения времен их модификации и, на этой основе, выполняет реконструкцию (например, перекомпиляцию) системы.

    При использовании интерпретатора make, рекомендуется следующая технология разработки программного комплекса:

редактор -> make -> проверка -> редактор

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

    Задание для утилиты make представляет собой программу на специальном языке (ее мы будем называть make-программой). make-программа содержит структуру зависимостей файлов и действия над ними, оформленные в виде списка правил. Выполнение действий приводит к требуемой перестройке комплекса. Текст программы может содержать определения переменных, используемых затем в описаниях правил и специальных директив, понимаемых утилитой.

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

    Таким образом, правило make-программы в общем виде выглядит так:

имя_цели . . . : [имя_подцели] [действие] . . [действие]

    Cинтаксис строк, задающих действия, соответствует синтаксису командных строк shell. Первым символом такой строки в make-программе должен быть символ табуляции - это обязательное условие! Если строка слишком длинная, то ее можно разбить на подстроки. В конце каждой из них, кроме последней, ставится символ '\'. Все последовательности символов, начиная от символа "#"' и до конца строки, являются комментарием. Пустые строки и лишние пробелы игнорируются. Интерпретатор make передает строку, задающую действие, на выполнение shell без ведущего символа табуляции.

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

    Если make-программа размещена в файле с именем "Makefile" или "makefile", то при запуске утилиты имя файла с программой можно не указывать в командой строке. В противном случае его надо задать при помощи опции '-f'. Таким образом, вызов

make

    выполняет программу из файла "Makefile" или "makefile", а

make -f mymakeprog

    программу из файла "mymakeprog".

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

    Рассмотрим пример, иллюстрирующий применения make для создания программы, исходный текст которой содержится в файлах: "file1.c" и "file2.c". Текст make-программы выглядит следующим образом:

prog: file1.o file2.o cc -o proj file1.o file2.o file1.o: file1.c cc -c file1.c file2.o: file2.c cc -c file2.c

    Первая строка программы определяет, что целью программы является получение файла "prog", зависящего от файлов "file1.o" и "file2.o". Во второй строке указывается действие, по которому получается целевой файл "prog". Остальные строки содержат описание правил получения подцелей "file1.o" и "file2.o".

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

<идентификатор>=<значение>

    Здесь <идентификатор> - имя переменной, <значение> - это произвольная строка, возможно пустая. В последнем случае переменная считается неопределенной. Чтобы использовать переменную в make-программе, надо поставить ее идентификатор в скобки, перед которыми находится символ '$'. В следующем примере иллюстрируется применение переменных.

PROGNAME = prog OBJS = file1.o \ file2.o $(PROGNAME) : $(OBJS) cc -o $(PROGNAME) $(OBJS)

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

make PROGNAME=prog1

    Если в этом случае значение переменной состоит из нескольких слов, то его надо обрамить двойными кавычками.

    make составляет список имен переменных и присваивает им значения в процессе чтения программы. Если значение переменной задается в командной строке при запуске утилиты, то все ее определения в make-программе игнорируются и используется значение, взятое из командной строки. Рекурсия при присвоении значения переменным не допускается.

    Существуют переменные с предопределенными именами, значения которых устанавливаются при выполнении make-программы. К ним относятся: переменная с именем '@', ее значение - имя текущей цели; переменная с именем '?', принимает значение имен тех файлов-подцелей, которые "моложе" файла-цели; переменная '<' - имя текущей обрабатываемой подцели (используется в правилах, описывающих "неявные" зависимости (см. ниже)); '*' - имя текущей цели без расширения. Предопределенные переменные '@', '?' и '<' используются только в списке действий правила и в каждом правиле имеют свои значения. При ссылках на все перечисленные переменные, имена последних скобками не обрамляются.

    Ниже демонстрируется употребление предопределенных переменных:

CC = cc LIBS = -lgen -lm OBJS = file1.o file2.o prog: $(OBJS) $(CC) -o $@ $(OBJS) $(LIBS)

    Кроме описанных выше явных зависимостей между файлами, make позволяет определять так называемые "неявные" зависимости. Они "говорят", каким образом файлы с одним расширением создаются из файлов с другим расширением. Так, например, можно сообщить make, как из исходных ( .c ) файлов получить объектные модули ( .o ):

.o.c : cc -c $<

    В приведенном примере make сравнивает времена модификации файлов, имеющих одинаковые имена и расширения, соответственно ".o" и ".c". Если o-файл "старше", то выполняется перекомпиляция. При этом в указанной в правиле команде перекомпиляции переменная '<', в соответствии с ее смыслом, заменяется на имя c-файла.

    Из примера виден общий синтаксис правил с "неявными" зависимостями:

<расширение1>. <расширение2> : [командная строка, задающая действие] . . [командная строка, задающая действие]

    Проверяется зависимость файлов с расширением 1 от файлов с расширением 2.

    Некоторые "неявные" зависимости определяются make по умолчанию. Таковой, в частности, является зависимость ".o" от ".c". Учитывая сказанное, приведенный в начале раздела пример можно переписать так:

prog: file1.o file2.o cc -o proj file1.o file2.o

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

    В заключении отметим еще несколько особенностей программы make. Как правило, при выполнении действий соответствующие команды печатаются на экране. Запретить это можно, если поставить перед ней символ '@'.

    Если текущая выполняемая команда возвращает не нулевой код завершения, то make выводит сообщение об ошибке и останавливается. Чтобы этого не происходило, перед командой должен стоять символ '-'.

    В следующем комплексном примере показаны make-программы, используемые для сборки проекта, разбитого на поддиректории. Предполагается, что проект располагается в каталоге "proj". Последний имеет два подкаталога: "lib" с файлами, образующими библиотеку (пусть это "lib1.c", "lib2.c", а имя самой библиотеки "libproj.a") и "main" с файлами, образующими программу (пусть это "main1.c" и "main2.c", а имя самой программы "main").

    make-программа для сборки проекта.

# Makefile проекта SUBDIRS = lib main # Создание проекта all: @for i in $(SUBDIRS); \ do \ cd $$i ; \ make ; \ cd . . ; \ done # "Очистка" проекта - удаление порождаемых ".a" и ".o" файлов clean: @for i in $(SUBDIRS); \ do \ cd $$i ; \ make clean; \ cd . . ; \ done

    Она "пробегает" по подкаталогам проекта, вызывая make для выполнения находящихся там программ.

    make-программа для сборки библиотеки.

# Makefile для библиотеки. Располагается в директории # proj/lib. OFILES = lib1.o lib2.o # Создание библиотеки lib : $(OFILES) ar rv libproj.a $(OFILES) # "Очистка" - удаление порождаемых ".a" и ".o" файлов clean : rm -rf *.o *.a

    make-программа для сборки программы.

# Makefile для создания программы. Располагается в директории # proj/main LIBDIR = . ./lib LIB = proj OFILES = main1.o main2.o PROG = main # Создание программы prog : $(OFILES) cc -o $(PROG) $(LIBDIR) $(OFILES) -l$(LIB) #"Очистка" - удаление порождаемых ".o" файлов и программы clean : rm -rf *.o $(PROG)

    В приведенных make-программах файлы ".o" порождаются по соответствующим ".c" файлам в соответствии с "неявными" зависимостями, принятыми make по умолчанию.

    Описанные характеристики make являются общими для большинства версий UNIX. Но отдельные ее диалекты могут иметь дополнительные свойства. Так, make фирмы SunSoft (OC Solaris 2.x) имеет специальную цель .KEEP_STATE. Если она встречается в make-программе, то утилита автоматически создает список зависимостей исходных файлов ( .c ) от файлов-заголовков ( .h ).


1.9.6. Система контроля исходного кода.

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

    Для такого сопровождения проектов, в UNIX разработано несколько систем. Первой из них, по-видимому, является "Система контроля исходного кода (Source Code Control System - SCCS). Но наиболее распространенным и общепринятым в настоящее время является комплекс "Система контроля версий" (Revision Control System - RCS). О ней и пойдет речь в данном разделе.

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

    Система RCS обеспечивает возможности, перечисленные ниже.

  1. Ведение для файлов учета всех изменений, которые собираются и заносятся автоматически. Для каждой версии указывается автор, дата и время записи, и краткое описание сути внесенных изменений. Наличие этой информации позволяет проследить историю развития файла, не требуя утомительной операции сравнения листингов различных его модификаций.
  2. Хранение, поиск и выдачу версий файла. RCS сохраняет все версии достаточно экономичным (с точки зрения использования дисковой памяти) способом. Различные варианты объекта, находящиеся под контролем RCS, образуют как бы дерево. Начальная модификация файла получает номер 1.1, следующая 1.2 и т.д. Если необходимо, пользователь может явно задать номер и другие атрибуты модификации, вносимой в RCS (см. ниже). Так, можно из версии 1.2 начать две разных ветви 1.2.1.1 и 1.2.2.1. На рис. 1.1 приведен пример истории развития версий некоторого объекта.

pict-1-1.gif (3542 bytes)

Рис. 1.1. Дерево версий файла, находящегося под контролем RCS.

  1. Версии могут получать символьные имена и иметь статус: экспериментальная, стабильная и т.д. Это обеспечивает достаточно простой способ описания необходимой конфигурации собираемых модификаций. Когда из RCS необходимо "достать" какую-либо из модификаций объекта, то для ее указания можно использовать номер, символьное имя, дату создания, имя автора или статус.
  2. Решение проблем, возникающих при попытке одновременного редактирования одной и той же версии файла несколькими пользователями. Если объект изменяется в настоящее время одним лицом, то попытки других получить к файлу доступ блокируются.
  3. Слияние версий. Две разных ветви развития файла можно слить в одну. При этом система проверяет возможное пересечение редакций и, если такая ситуация обнаружена, сообщает об этом пользователю.
  4. Автоматическое указание при "выписывании" файла из RCS для каждой версии имени, номера, времени создания, автора и т.д. Эта информация может заноситься системой в любое место файла, указанное пользователем.

    Рассмотрим основные команды, которые необходимы для использования системы RCS. Для начала работы с системой достаточно знать всего две из них: ci и co. Первая помещает содержимое текстового файла под контроль RCS, а, если он там уже есть, то образует новую версию. Команда co находит и выдает указанную версию, хранящуюся в системе.

    Рассмотрим использование этих команд на следующем примере. Предположим, что имеется файл "f.c", который требуется передать под контроль RCS. В результате выполнения команды:

ci f.c

    в текущей директории будет создан файл "f.c,v", содержимое файла "f.c" переписано в "f.c,v" в качестве исходной версии 1.1, а сам файл "f.c" уничтожен. Кроме того, при выполнении команды, система потребует ввести краткое описание образованной версии. Все последующие вызовы программы ci будут запрашивать краткий комментарий, который должен отражать суть внесенных изменений (заметим, что если в текущем каталоге есть поддиректория с именем RCS, то файл "f.c,v" будет помещен в него) Файлы с суффиксами ",v" называются файлами RCS.

    Для получения рабочей версии файла "f.c" из предыдущего примера, необходимо вызвать команду:

co f.c

    Она найдет последнюю версию в "f.c,v" и запишет ее в файл "f.c". Можно отредактировать данный файл (его мы будем называть "рабочим") и попытаться занести новую версию в "f.c,v":

ci f.c

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

ci error: no lock set by <имя>

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

co -l f.c

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

    Существует еще одна возможность зарезервировать за собой право создания очередной версии. Это команда:

rcs -l f.c

    Она эквивалентна "co -l f.c', но "рабочий" файл "f.c" не создается. Команда бывает полезна, если при выписывании файла ключ "-l" не был указан (например, по невнимательности). Если в момент выполнения инструкции, файл уже был захвачен кем-либо, будет выдано сообщение о невозможности блокировки. В этом случае единственно, что можно сделать, это попытаться как-нибудь договориться с соответствующим пользователем, не рассчитывая на мудрость системы RCS.

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

rcs -U f.c

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

    Включение режима блокировки выполняется с помощью команды:

rcs -L f.c

    Как правило, создание новой версии связано с получением "рабочего" файла, его редактирования и записи новой версии с помощью команды ci, которая уничтожает "рабочий" файл. При использовании ключа -l команда:

co -l f.c

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

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

    Использование обоих ключей приводит к модификации информационных записей в файле (см. ниже).

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

$Header$

    то при получении версии из RCS, в "рабочем" файле она будет заменена на строку вида:

$Header: файл версия дата время автор $

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

static char rcsid[ ] = "$Header$";

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

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

$Author$
Идентификатор пользователя, записавшего данную версию.
$Date$
Дата и время записи версии.
$Header$
Стандартный заголовок, содержащий имя файла RCS, номер версии, дату записи, идентификатор пользователя и статус версии.
$Locker$
Идентификатор пользователя, зарезервировавшего за собой право записи модифицированной версии. Если версия не блокирована, выдается строка нулевой длины .
$Log$
Комментарий, записанный пользователем при занесении версии в RCS. Перед ним помещается строка, содержащая: имя исходного файла RCS, номер версии, идентификатор пользователя и дата записи.
$Revision$
Номер версии.
$Source$
Полное имя исходного файла RCS.
$State$
Статус версии, присвоенный командой ci.

    В заключении приведем более детальное описание команд co и ci. Формат командной строки для co выглядит следующим образом:

co [options] file . . .

    Параметр file командной строки задает имя "рабочего" или файла RCS. Параметр options служит для указания ключей, определяющих режим работы команды.

    При задании имен файлов в команде co возможны три случая, указанные ниже.

  1. Заданы как файл RCS, так и "рабочий" файл. Имя первого имеет вид: "path1/workfile,v", а имя второго задано в форме "path2/workfile", где "path1/" и "path2/" являются полными именами директорий, а "workfile" - имя самого файла.
  2. Задан только файл RCS. Тогда предполагается, что "рабочий" файл находится в текущей директории, а его имя получается из имени файла RCS удалением "path1/" и суффикса ",v".
  3. Задан только "рабочий" файл. В этом случае имя файла RCS получается из имени "рабочего" файла удалением "path2/" и добавкой суффикса ",v".

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

    Выбор версии файла может производится по номеру, времени создания, автору или статусу. При отсутствии указаний о том, какую версию файла требуется получить, выдается последняя версия. Если задано несколько условий поиска, выбирается "старшая" из множества подходящих версий. Указание даты, автора или статуса может использоваться при поиске необходимой версии какой-либо ветви дерева. При этом берется либо указанная ветвь, либо, если она не задана, самая "старшая" ветвь во всем дереве версий. Имя или номер искомой модификации можно указывать при задании ключей: "-l", "-p", "-q" и "-r" (см. ниже).

    Список ключей команды co и правила их использования следующие.

-l [rev]
Зарезервировать право модификации данной версии только за текущим пользователем. Правила задания номера или имени версии приведены при описании ключа "-r"
-p [rev]
Выдавать найденную версию в стандартный файл вывода. "Рабочий" файл не создается.
-q [rev]
Неинтерактивный режим. Диагностические сообщения на дисплей не выдаются.
-ddate
Выдать версию; дата создания задана параметром date. Если искомая версия отсутствует, выдается "старшая" версия из созданных до указанной даты. Дата может задаваться в любом формате, допустимом в системе UNIX.
-r [rev]
Получить версию с номером rev. Если таковая отсутствует, выдается "старшая" из множества версий с меньшими номерами. Номер версии состоит из последовательности полей, разделенных символом '.' (точка). Каждое поле содержит либо номер, либо символьное имя, которое может присваиваться пользователем с помощью ключа "-n" в команде ci.
-sstate
Выдать "старшую" версию, имеющую статус state. Статус файла задается при записи файла в архив командой ci (ключ "-s").
-w [login]
Выдать "старшую" версию, записанную пользователем с именем login. Если имя пользователя опущено, поиск осуществляется среди версий, записанных пользователем, выполнившим команду co.

    Команда ci, как было уже сказано выше, записывает новые версии "рабочих" файлов в файлы системы RCS. Формат командной строки ci следующий:

ci [ options ] file . . .

    Указываемые параметры и правила поиска "рабочих" и файлов RCS те же, что и в командной строке co.

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

    Для того чтобы добавить новую версию, "старшая" из них должна быть заблокирована пользователем. В противном случае может создаваться только новая ветвь версий. Это ограничение не распространяется на владельца файла. Блокировка, установленная кем-то другим, может быть снята, как было сказано ранее, с помощью команды "rcs -l"'.

    Как правило, команда ci проверяет, отличается ли вновь записываемая версия от предыдущей. Если различий не обнаружено, запись не производится. Пользователю выдается соответствующее сообщение с запросом о необходимости внесения файла в RCS.

    При попытке записать версию, команда ci требует ввести краткий комментарии, который должен отражать суть внесенных изменений и заканчиваться либо отдельно стоящей '.' либо нажатием <Ctrl+D>. Если файл RCS не существует, ci создает его (по умолчанию этой версии присваивается номер 1.1). При этом заводится пустой список доступа.

    При записи файлов в архив системы RCS используются ключи, перечисленные ниже.

-r [rev]
Присвоить записанной версии файла номер rev, снять блокировку, уничтожить "рабочий" файл.
-f [rev]
Принудительная запись. Новая версия записывается без возможных дополнительных сообщений.
-l [rev]
Выполняет функции, аналогичные функциям ключа "-r", но для сохраняемой версии дополнительно исполняется команда co с ключом "-l".
-q [rev]
Неинтерактивный режим. Диагностических сообщений не выдается .
-mmsg
Использует строку msg в качестве комментария для всех заносимых версий.
-sstate
Устанавливает статус записываемой версии state. По умолчанию версия получает статус "Exp" (экспериментальная).

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

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

    Формат командной строки команды rcs:

rcs [ options ] file . . .

    В качестве входных файлов используются файлы RCS. Ключи команды rcs перечислены ниже.

-i
Создать и инициализировать пустой файл RCS. Если не задано полное имя файла RCS, система пытается разместить его в директории "./RCS". Если она отсутствует, файл создается в текущей директории. Если указанный файл RCS уже существует, выдается сообщение об ошибке.
-alogins
Добавить указанные идентификаторы пользователей (logins) в список доступа архивного файла. В качестве разделителя в строке logins используется запятая.
-e [logins]
Исключить имена пользователей из списка доступа заданного архивного файла. Если имена пользователей не указаны, из списка доступа исключаются все имена.
-l [rev]
Блокировать версию rev. Если номер версии (rev) не указан, подразумевается "старшая" версия.
-L
Включить механизм блокировки. При работе в данном режиме владелец файла RCS не освобождается от обязанности блокировать версию для последующей записи ее модификаций. Этот режим необходим, если изменением файла занимается несколько пользователей.
-U
Выключить механизм блокировки. В данном режиме владелец файла может записывать новые версии без предварительного блокирования.

1.10. Проблемы переносимости программного обеспечения.

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

    В начале 80-х годов фирма AT&T утратила монополию на разработку и развитие ОС. Работы по созданию своей версии системы начались в Калифорнийском университете в городе Беркли. Диалекты UNIX, разработанные там, получили название BSD 4.* (Berkley Software Distribution версии 4.*). Свою версию системы UNIX для IBM PC разработала и фирма Microsoft. Она получила название Microsoft Xenix. Существуют и другие варианты ОС. Каждый из них обладает своими достоинствами, но их общим недостатком стала проблема переносимости программ из одной системы в другую. Чтобы исправить положение, фирмой AT&T, при поддержке разработчиков других версий системы UNIX, была создана версия, включающая в себя особенности всех созданных UNIX-систем. Она получила название UNIX System V Release 4.

    В то же время разработан ряд стандартов для операционных систем типа UNIX. Рассмотрим из них три основных.

    Первый касается языка программирования Си, который является основным инструментом создания программного обеспечения в среде UNIX. Стандартизация коснулась не только синтаксиса языка. Был определен набор основных функций и интерфейсов к ним. Стандарт был разработан в 1988 г. Американским Национальным Институтом Стандартов и получил название ANSI C. ANSI C получил широкое распространение. Почти все Си-компиляторы, разработанные с того времени, в той или иной степени соответствуют ему.

    Следующие два стандарта касаются более детального определения среды программирования системы UNIX. Первый из них разработан международной инженерной ассоциацией IEEE Std 1003.1-1990 Portable Operating System Interface Part 1. В литературе этот стандарт более известен под сокращенным названием POSIX.1. Второй стандарт разработан группой фирм-разработчиков вычислительной техники и программного обеспечения. Он получил название X/Open Portability Guide 3. Сокращенно этот стандарт называется XPG 3. Оба стандарта подробно описывают все структуры и типы данных, константы и библиотеки функций, определяющие среду операционной системы (Operating System Environment), в которой осуществляется процесс программирования.

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

 

1

2

3

4

5

 
<ctype.h>

*

   

*

*

символьные типы
<dirent.h>  

*

*

*

*

формат директорий
<errno.h>

*

   

*

*

обработка ошибок
<fcntl.h>  

*

*

*

*

управление файлами
<float.h>

*

   

*

*

константы с "плавающей" запятой
<ftw.h>    

*

*

  дерево файловой системы
<grp.h>  

*

*

*

*

формат файла групп
<langinfo.h>    

*

*

  языковые константы
<limits.h>

*

   

*

*

ограничения реализации
<locate.h>

*

   

*

*

локальные категории
<math.h>

*

   

*

*

математические константы
<nl_types.h>    

*

*

  каталог сообщений
<pwd.h>  

*

*

*

*

формат файла паролей
<regex.h>    

*

*

*

регулярные выражения
<search.h>    

*

*

  таблицы поиска
<setjmp.h>

*

   

*

*

нелокальные переходы
<signal.h>

*

   

*

*

сигналы
<stdarg.h>

*

   

*

*

список аргументов
<stddef.h>

*

   

*

*

стандартные типы
<stdio.h>

*

   

*

*

форматный ввод-вывод
<stdlib.h>

*

   

*

*

стандартная библиотека
<string.h>

*

   

*

*

операции со строками
<tar.h>  

*

*

*

  формат архивов tar
<termios.h>  

*

 

*

*

терминальный ввод-вывод
<time.h>

*

   

*

*

время и дата
<ulimit.h>    

*

*

  ограничения пользователя
<unistd.h>  

*

*

*

*

символьные константы
<utime.h>  

*

*

*

*

время для файлов
<sys/ipc.h>    

*

*

*

взаимодействие процессов
<sys/msg.h>    

*

*

  очереди сообщений
<sys/sem.h>    

*

*

  семафоры
<sys/shm.h>    

*

*

*

разделяемая память
<sys/stat.h>  

*

*

*

*

статус файлов
<sys/times.h>  

*

*

*

*

время для процессов
<sys/types.h>  

*

*

*

*

типы данных
<sys/utsname.h>  

*

*

*

  имя системы
<sys/wait.h>  

*

*

*

*

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

Здесь:

    1. Стандарт ANSI C.
    2. Стандарт POSIX.1.
    3. Стандарт XPG3.
    4. Версия BSD 4.3.
    5. Версия UNIX System V Release 4.