Общая организация X-Window
Как кажется, оконная система X победила потому, что организация системы очень точно соответствует общей идеологии ОС UNIX. UNIX - это традиционно сетевая операционная система. Девиз Билла Джоя и всей компании Sun Microsystems "The Network is the Computer - Сеть - это компьютер" - в полной мере относится к направлению ОС UNIX в целом. Популярная ныне архитектура организации программно-аппаратных средств "клиент-сервер" всегда была совершенно естественной в мире UNIX. Специализация и разделение функций в сети - это и значит, что для пользователя компьютер и сеть неразличимы.
На этих идеях построена и оконная система X. Поскольку ОС UNIX является интерактивной операционной системой, то каждый работающий в системе пользователь взаимодействует с системой через предоставленный ему терминал (скорее всего, рабочую станцию) и, вообще говоря, вызывает программы, которые будут выполняться на других компьютерах локальной или территориально распределенной сети. Именно эти программы обеспечивают интерфейс с данным пользователем, т.е. они должны иметь возможность работать с терминалом пользователя вне зависимости от того, где они выполняются. Более того, чтобы возможности графического интерфейса этих программ удовлетворяли общим требованиям, нужно, чтобы программы могли не заботиться о таких деталях, как многооконная организация экрана, текущее расположение окон, управление мышью и т.д.
Оконная система X предоставляет в точности требуемые возможности. На стороне пользовательского терминала находится сервер системы X, обеспечивающий единообразное управление графическим терминалом вне зависимости от его специфических аппаратных характеристик. В других компьютерах сети (которые, фактически, являются серверами с точки зрения организации вычислительного процесса) установлены клиентские части системы X, создающие впечатление у выполняемой программы, что она взаимодействует с локальным терминалом, а на самом деле поддерживающие точно специфицированный протокол взаимодействий с сервером системы X.
Обычные файлы
Обычные (или регулярные) файлы реально представляют собой набор блоков (возможно, пустой) на устройстве внешней памяти, на котором поддерживается файловая система. Такие файлы могут содержать как текстовую информацию (обычно в формате ASCII), так и произвольную двоичную информацию. Файловая система не предписывает обычным файлам какую-либо структуру, обеспечивая на уровне пользователей представление обычного файла как последовательности байтов. Используя базовые системные вызовы (или функции библиотеки ввода/вывода, которые мы рассмотрим в разделе 4), пользователи могут как угодно структуризовать файлы. В частности, многие СУБД хранят базы данных в обычных файлах ОС UNIX.
Для некоторых файлов, которые должны интерпретироваться компонентами самой операционной системы, UNIX поддерживает фиксированную структуру. Наиболее важным примером таких файлов являются объектные и выполняемые файлы. Структура этих файлов поддерживается компиляторами, редакторами связей и загрузчиком. Однако, эта структура неизвестна файловой системе. Для нее такие файлы по-прежнему являются обычными файлами.
Очереди сообщений
Для обеспечения возможности обмена сообщениями между процессами этот механизм поддерживается следующими системными вызовами:
msgget для образования новой очереди сообщений или получения дескриптора существующей очереди;
msgsnd для посылки сообщения (вернее, для его постановки в указанную очередь сообщений);
msgrcv для приема сообщения (вернее, для выборки сообщения из очереди сообщений);
msgctl для выполнения ряда управляющих действий.
Системный вызов msgget обладает стандартным для семейства "get" системных вызовов синтаксисом:
msgqid = msgget(key, flag);
Ядро хранит сообщения в виде связного списка (очереди), а дескриптор очереди сообщений является индексом в массиве заголовков очередей сообщений. В дополнение к информации, общей для всех механизмов IPC в UNIX System V, в заголовке очереди хранятся также:
указатели на первое и последнее сообщение в данной очереди;
число сообщений и общее количество байтов данных во всех них вместе взятых;
идентификаторы процессов, которые последними послали или приняли сообщение через данную очередь;
временные метки последних выполненных операций msgsnd, msgrsv и msgctl.
Как обычно, при выполнении системного вызова msgget ядро ОС UNIX либо создает новую очередь сообщений, помещая ее заголовок в таблицу очередей сообщений и возвращая пользователю дескриптор вновь созданной очереди, либо находит элемент таблицы очередей сообщений, содержащий указанный ключ, и возвращает соответствующий дескриптор очереди. На рисунке 3.6 показаны структуры данных, используемые для организации очередей сообщений.
Рис. 3.6. Структуры данных, используемые для организации очередей сообщений
Для посылки сообщения используется системный вызов msgsnd:
msgsnd(msgqid, msg, count, flag);
где msg - это указатель на структуру, содержащую определяемый пользователем целочисленный тип сообщения и символьный массив - собственно сообщение; count задает размер сообщения в байтах, а flag определяет действия ядра при выходе за пределы допустимых размеров внутренней буферной памяти.
Для того, чтобы ядро успешно поставило указанное сообщение в указанную очередь сообщений, должны быть выполнены следующие условия: обращающийся процесс должен иметь соответствующие права по записи в данную очередь сообщений; длина сообщения не должна превосходить установленный в системе верхний предел; общая длина сообщений (включая вновь посылаемое) не должна превосходить установленный предел; указанный в сообщении тип сообщения должен быть положительным целым числом. В этом случае обратившийся процесс успешно продолжает свое выполнение, оставив отправленное сообщение в буфере очереди сообщений. Тогда ядро активизирует (пробуждает) все процессы, ожидающие поступления сообщений из данной очереди.
Если же оказывается, что новое сообщение невозможно буферизовать в ядре по причине превышения верхнего предела суммарной длины сообщений, находящихся в одной очереди сообщений, то обратившийся процесс откладывается (усыпляется) до тех пор, пока очередь сообщений не разгрузится процессами, ожидающими получения сообщений. Чтобы избежать такого откладывания, обращающийся процесс должен указать в числе параметров системного вызова msgsnd значение флага с символическим именем IPC_NOWAIT (как в случае использования семафоров), чтобы ядро выдало свидетельствующий об ошибке код возврата системного вызова mgdsng в случае невозможности включить сообщение в указанную очередь.
Для приема сообщения используется системный вызов msgrcv:
count = msgrcv(id, msg, maxcount, type, flag);
Здесь msg - это указатель на структуру данных в адресном пространстве пользователя, предназначенную для размещения принятого сообщения; maxcount задает размер области данных (массива байтов) в структуре msg; значение type специфицирует тип сообщения, которое желательно принять; значение параметра flag указывает ядру, что следует предпринять, если в указанной очереди сообщений отсутствует сообщение с указанным типом. Возвращаемое значение системного вызова задает реальное число байтов, переданных пользователю.
Выполнение системного вызова, как обычно, начинается с проверки правомочности доступа обращающегося процесса к указанной очереди. Далее, если значением параметра type является нуль, ядро выбирает первое сообщение из указанной очереди сообщений и копирует его в заданную пользовательскую структуру данных. После этого корректируется информация, содержащаяся в заголовке очереди (число сообщений, суммарный размер и т.д.). Если какие-либо процессы были отложены по причине переполнения очереди сообщений, то все они активизируются. В случае, если значение параметра maxcount оказывается меньше реального размера сообщения, ядро не удаляет сообщение из очереди и возвращает код ошибки. Однако, если задан флаг MSG_NOERROR, то выборка сообщения производится, и в буфер пользователя переписываются первые maxcount байтов сообщения.
Путем задания соответствующего значения параметра type пользовательский процесс может потребовать выборки сообщения некоторого конкретного типа. Если это значение является положительным целым числом, ядро выбирает из очереди сообщений первое сообщение с таким же типом. Если же значение параметра type есть отрицательное целое число, то ядро выбирает из очереди первое сообщение, значение типа которого меньше или равно абсолютному значению параметра type.
Во всех случаях, если в указанной очереди отсутствуют сообщения, соответствующие спецификации параметра type, ядро откладывает (усыпляет) обратившийся процесс до появления в очереди требуемого сообщения. Однако, если в параметре flag задано значение флага IPC_NOWAIT, то процесс немедленно оповещается об отсутствии сообщения в очереди путем возврата кода ошибки.
Системный вызов
msgctl(id, cmd, mstatbuf);
служит для опроса состояния описателя очереди сообщений, изменения его состояния (например, изменения прав доступа к очереди) и для уничтожения указанной очереди сообщений (детали мы опускаем).
Оконная система X как базовое средство графических интерфейсов в среде ОС UNIX
Понятно, что для нормальной организации работы пользовательских программ с графическими терминалами (если учитывать отмеченные выше стандартные требования к графическому интерфейсу) требуется наличие некоторого базового слоя программного обеспечения, скрывающего аппаратные особенности терминала; обеспечивающего создание окон на экране терминала, управление этими окнами и работу с ними со стороны пользовательской программы; дающего возможность пользовательской программе реагировать на события, происходящие в соответствующем окне (ввод с клавиатуры, движение курсора, нажатие клавиш мыши и т.д.). Такой базовый слой графического программного обеспечения принято называть оконной системой.
В мире ОС UNIX предпринималось несколько попыток создания оконных систем, и большинство из них успешно использовалось практически (упомянем, например, оконную систему NeWS компании Sun Microsystems, интерфейс которой основывался на использовании языка Postscript). Однако ни одна из этих систем не выходила за пределы ведомственного использования, что, естественно, резко ограничивало мобильность программ, обладающих графическим интерфейсом. Успеха удалось добиться группе молодых исследователей и программистов из Масачусетского технологического института, которые создали оконную систему под кратким и предельно скромным названием X (кстати, именно так правильно называть систему; по-английски ее грамотно называют не X-Window, а X window system, т.е. "оконная система X"). В настоящее время оконная система X является фактическим стандартом опорных средств графического интерфейса. Система X, дополнительные библиотеки, а также ряд готовых интерфейсных средств распространяются MIT бесплатно (относясь к категории public domain). В то же время сегодня именно оконная система X является базовым механизмом организации графических интерфейсов пользователя в большинстве UNIX-систем.
OSF была первой коммерческой компанией,
OSF была первой коммерческой компанией, решившейся на полную реализацию ОС UNIX на базе микроядра Mach. Результатом этой работы явилось создание ОС OSF-1. Как утверждают, OSF-1 на самом деле не является полностью лицензионно чистой системой: в ней используется часть исходных текстов SVR 4.0.
На сегодняшний день наиболее серьезным потребителем OSF-1 является компания Digital Equipment на своих платформах, основанных на микропроцессорах Alpha. В OSF-1 поддерживаются все основные стандарты ОС UNIX, хотя многие утверждают, что пока система работает не очень устойчиво.
Организация команды в ОС UNIX
Вообще-то, для создания новой команды не нужно делать почти ничего специального, нужно просто следовать правилам программирования на языке Си. Как известно, каждая правильно оформленная Си-программа начинает свое выполнение с функции main. Эта "полусистемная" функция обладает стандартным интерфейсом, являющимся основой организации команд, которые можно вызывать в среде shell. Внешние команды выполняются интерпретатором shell с помощью связки системных вызовов fork и одного из вариантов exec (см. п. 2.1.6). В число параметров системного вызова exec входит набор текстовых строк. Этот набор текстовых строк передается на вход функции main запускаемой программы.
Более точно, функция main получает два параметра - argc (число передаваемых текстовых строк) и argv (указатель на массив указателей на текстовые строки). Программа, претендующая на ее использование в качестве команды shell, должна обладать точно определенным внешним интерфейсом (параметры обычно вводятся с терминала) и должна контролировать и правильно разбирать входные параметры.
Кроме того, чтобы соответствовать стилю shell, такая программа не должна сама переопределять файлы, соответствующие стандартному вводу, стандартному выводу и стандартному выводу ошибок. Тогда команде может обычным образом перенаправляться ввод/вывод, и она может включаться в конвейеры.
OSF-1 компании Open Software Foundation
ОС OSF/1 1.3 основана на микроядре Mach. IBM является членом OSF, и эти компании обменивались технологиями организации микроядра. Однако по некоторым важным направлениям подходы IBM и OSF различаются. В версии 1.3 весь сервер OSF/1 работает в пользовательском пространстве и использует функции Mach.
Почему же OSF решилась на микроядерную реализацию монолитного сервера UNIX? Как говорят специалисты, OSF, OSF/1 является слишком хорошей и надежной системой, чтобы можно было ее бросить и начать все сначала. В OSF/1 1.3 используется более 90% кода предыдущих версий OSF/1. С другой стороны, чтобы улучшить возможности управления объектами, часть ядра Mach была переписана на Си++.
В результате OSF/1 1.3 получилась не такой модульной, как ОС Workplace. Но использовав значительную часть OSF/1, компания OSF смогла раньше IBM получить более или менее полную микроядерную реализацию системы.
Основание и история
История ОС UNIX началась в недрах Bell Telephone Laboratories (теперь AT&T Bell Laboratories) и связана с известными теперь всем именами Кена Томпсона, Денниса Ритчи и Брайана Кернигана (два последних имени знакомы читателям и как имена авторов популярнейшей книги по языку программирования Си, издававшейся в нашей стране на русском языке).
|
Основные функции
К основным функциям ядра ОС UNIX принято относить следующие:
(a) Инициализация системы - функция запуска и раскрутки. Ядро системы обеспечивает средство раскрутки (bootstrap), которое обеспечивает загрузку полного ядра в память компьютера и запускает ядро.
(b) Управление процессами и нитями - функция создания, завершения и отслеживания существующих процессов и нитей ("процессов", выполняемых на общей виртуальной памяти). Поскольку ОС UNIX является мультипроцессной операционной системой, ядро обеспечивает разделение между запущенными процессами времени процессора (или процессоров в мультипроцессорных системах) и других ресурсов компьютера для создания внешнего ощущения того, что процессы реально выполняются в параллель.
(c) Управление памятью - функция отображения практически неограниченной виртуальной памяти процессов в физическую оперативную память компьютера, которая имеет ограниченные размеры. Соответствующий компонент ядра обеспечивает разделяемое использование одних и тех же областей оперативной памяти несколькими процессами с использованием внешней памяти.
(d) Управление файлами - функция, реализующая абстракцию файловой системы, - иерархии каталогов и файлов. Файловые системы ОС UNIX поддерживают несколько типов файлов. Некоторые файлы могут содержать данные в формате ASCII, другие будут соответствовать внешним устройствам. В файловой системе хранятся объектные файлы, выполняемые файлы и т.д. Файлы обычно хранятся на устройствах внешней памяти; доступ к ним обеспечивается средствами ядра. В мире UNIX существует несколько типов организации файловых систем. Современные варианты ОС UNIX одновременно поддерживают большинство типов файловых систем.
(e) Коммуникационные средства - функция, обеспечивающая возможности обмена данными между процессами, выполняющимися внутри одного компьютера (IPC - Inter-Process Communications), между процессами, выполняющимися в разных узлах локальной или глобальной сети передачи данных, а также между процессами и драйверами внешних устройств.
(f) Программный интерфейс - функция, обеспечивающая доступ к возможностям ядра со стороны пользовательских процессов на основе механизма системных вызовов, оформленных в виде библиотеки функций.
В следующих разделах этой части курса и, более подробно, в третьей части курса мы будем знакомиться с базовыми возможностями ядра ОС UNIX.
Основные функции и компоненты ядра ОС UNIX
В этой части курса мы более подробно остановимся на базовых функциях ядра ОС UNIX. Основная цель этой части - ввести слушателя курса (и читателя этого документа) в основные идеи ядра ОС UNIX, т.е. показать, чем руководствовались разработчики системы при выборе базовых проектных решений. При этом мы не стремимся излагать технические детали организации ядра, поскольку (как это обычно бывает при попытках совместного идейно-технического изложения) мы утратили бы явное различие между принципиальными и техническими решениями.
Возможно, выбор тем этой части довольно субъективен. Не исключено, что кто-то другой обратил бы большее внимание на другие вопросы, связанные с функциями ядра операционной системы. Однако подчеркнем, что мы следуем классическому представлению о функциях ядра ОС, введенному еще профессором Дейкстрой. В соответствии с этим представлением, ядро любой ОС прежде всего отвечает за управление основной памятью компьютера и виртуальной памятью выполняемых процессов, за управление процессором и планирование распределения процессорных ресурсов между совместно выполняемыми процессами, за управление внешними устройствами и, наконец, за обеспечение базовых средств синхронизации и взаимодействия процессов. Именно эти вопросы мы рассмотрим в данной части курса применительно к ОС UNIX (иногда к UNIX вообще, а иногда к UNIX System V).
Основные понятия
Одним из достоинств ОС UNIX является то, что система базируется на небольшом числе интуитивно ясных понятий. Однако, несмотря на простоту этих понятий, к ним нужно привыкнуть. Без этого невозможно понять существо ОС UNIX.
Основные понятия ОС UNIX и базовые системные вызовы
В этой части курса вводятся основные понятия, на которые опирается ОС UNIX, рассматривается общая структура системы и обсуждаются ее основные возможности.
Особенности мобильного программирования на языке Си
Особая роль языка программирования Си состоит в том, что он, с одной стороны, позволяет писать для UNIX-систем практически столь же эффективный код, что и языки ассемблера, а с другой, является основным средством переноса программ между UNIX-системами. Можно сказать, что Си является машинно-независимым языком ассемблера для UNIX-систем. Это делает его основным средством написания эффективных и переносимых программ для этого класса вычислительных систем. Стандартизация языка сначала Американским национальным институтом стандартов (ANSI), а затем и Международной организацией по стандартам (ISO) закрепила эту роль, распространив ее и на персональные компьютеры. Будем ссылаться на версию языка Си, определенную стандартом, как на язык ANSI C.
Сказанное не означает, что любая программа, написанная на ANSI C и отлаженная в одной вычислительной системе (ВС), безусловно переносима на любую другую вычислительную систему, также имеющую компилятор языка Си, отвечающий требованиям ANSI. Однако, язык ANSI C определен таким образом, чтобы можно было писать программы, подвергающиеся минимальным изменениям при их переносе на другие вычислительные системы.
Программа на ANSI C переносима из исходной ВС в целевую, если она успешно компилируется в целевой ВС и ее работа функционально эквивалентна работе в исходной ВС.
На переносимость программы влияют особенности как аппаратного, так и программного окружения языка в исходной и в целевой ВС. Можно выделить четыре фактора, влияющих на переносимость программы:
архитектура вычислительных систем;
метрические ограничения компиляторов;
алгоритмы работы компиляторов;
особенности операционных систем.
Архитектура существенно влияет на семантику языка, а, следовательно, и на переносимость программных файлов. Во-первых, архитектура определяет множества значений арифметических типов, фиксируя тем самым семантику большинства операций языка. Во-вторых, от архитектуры, а именно, от системы команд, зависит интерпретация операций языка, остающихся недоопределенными даже после фиксирования множеств значений соответствующих типов. В-третьих, от архитектуры зависит схема размещения данных тех или иных типов в соответствующих элементах памяти.
Даже если программа удовлетворяет всем ограничениям ANSI C и прошла стадию компиляции в исходной ВС, может случиться, что в целевой ВС она эту стадию не пройдет из-за того, что некоторые метрические характеристики программы не удовлетворяют ограничениям, принятым в целевой ВС. Примерами таких характеристик являются: число уровней вложенностей составных операторов, операторов цикла и операторов выбора варианта; число описателей указателя, массива и функции, модифицирующих базовый тип в описании объекта; число выражений, вложенных друг в друга по круглым скобкам и т.п.
От алгоритмов работы компилятора зависит, например, порядок вычисления выражений, что влияет как на значения выражений, так и на вырабатываемый ими побочный эффект.
Наконец, семантика многих стандартных библиотечных функций (например, функций ввода/вывода) зависит от особенностей операционной системы.
Все перечисленные факторы учтены в определении ANSI C путем фиксирования неуточняемого (стандартом) поведения программ, неопределенного поведения программ и поведения программ, определяемого реализацией.
Неуточняемое поведение (unspecified behavior) - это поведение правильных программ с корректными данными в ситуациях, для которых стандарт не выдвигает никаких требований.
Неопределенное поведение (undefined behavior) - поведение (динамически) ошибочных программ с возможно некорректными данными или объектами с неопределенными значениями, для которых стандарт не выдвигает никаких требований. Диапазон неопределенного поведения может быть очень разнообразен: от полного игнорирования ситуации с непредсказуемыми результатами до поведения (во время трансляции или выполнения) в соответствии с документацией, описывающей характеристики среды (с выдачей диагностических сообщений или без таковой); возможны случаи преждевременного завершения трансляции или вычислений (с обязательной выдачей диагностического сообщения).
Поведение, определяемое реализацией (implementation-defined behavior) - поведение правильно написанной программы с правильными данными, которое зависит от характеристик реализации и которое должно быть документировано каждой реализацией.
В качестве общей рекомендации по написанию переносимых программ можно посоветовать, во-первых, безусловно избегать использования в программах языковых конструкций с неопределенным поведением, во-вторых, избегать конструкций с неуточняемым поведением в случаях, когда результат ее работы не является однозначным, и, наконец, минимизировать число конструкций, чье поведение определяется реализацией и существенно влияет на результат работы программы.
Другая общая рекомендация заключается в использовании возможностей препроцессора Си для локализации непереносимых фрагментов программы. Это касается использования макроимен вместо явных констант, зависящих от реализации; использования условной трансляции для включения в окончательный текст программы того или иного фрагмента в зависимости от вычислительной системы (особенно это касается конструкций, чье поведение определяется реализацией и существенно влияет на результат работы программы) и т.д.
Далее мы перечисляем все случаи неуточняемого, неопределенного и зависящего от реализации поведения программ, а, кроме того, в наименее очевидных случаях объясняем их влияние на переносимость. После этого приводятся требования стандарта к метрическим ограничениям компиляторов.
Неуточняемое поведение
Не уточняются следующие вопросы:
Метод и время инициации статических данных.
В зависимости от того, вычисляются ли инициирующие константные выражения в окружении трансляции или в окружении выполнения программы, статические данные могут получать различные начальные значения.
Ситуация, когда выдается печатный символ, а активная позиция находится в конце строки.
Ситуация, когда выдается символ "шаг назад", а активная позиция находится в начале строки.
Ситуация, когда выдается символ "горизонтальная табуляция", а активная позиция находится "на" или "за" последней определенной позицией горизонтальной табуляции.
Ситуация, когда выдается символ "вертикальная табуляция", а активная позиция находится "на" или "за" последней определенной позицией вертикальной табуляции.
Предыдущие четыре ситуации влияют на вывод текста на дисплей.
Представление плавающих типов.
Переносимая программа не должна использовать информацию о представлении (т.е. о битовой структуре) плавающих типов, поскольку именно в реализации плавающей арифметики существенно различаются разные вычислительные системы.
Порядок вычисления выражений - в любом порядке, учитывающем только правила предшествования операций и расстановку скобок.
Порядок, в котором возникают побочные эффекты.
Порядок, в котором вычисляются параметры вызова функции и само значение этой функции.
За исключением тех случаев, когда порядок вычисления выражения зафиксирован синтаксическими правилами или указан в стандарте каким-либо другим образом (для операции вызова функции (), операций логического умножения, логического сложения, условной операции и операции перечисления выражений), порядок вычисления подвыражений и порядок возникновения побочных эффектов не уточняется. Выражение, содержащее более, чем одно вхождение одной и той же коммутативной и ассоциативной бинарной операции (*, +, &, ^, |), может свободно перегруппировываться, независимо от наличия скобок, при условии, что типы операндов или результаты от такой перегруппировки не изменятся. В переносимой программе следует избегать выражений, порядок вычисления которых существенно влияет на их значения или вырабатываемые побочные эффекты. Если же такое выражение возникает, то содержащий его оператор всегда можно разбить на эквивалентную последовательность из нескольких операторов, не содержащих подобных выражений. Например, оператор
x=f()+g();
можно заменить на последовательность операторов
y=f();
x=y+g();
или
y=g();
x=f()+y;
в зависимости от нужного порядка вызова функций f() и g().
Чтобы зафиксировать некоторое конкретное группирование операций, нужно присвоить значение выражения, которое требуется явно выделить, некоторому объекту данных, либо поставить перед группирующими скобками унарный оператор плюс.
Отведение памяти под формальные параметры.
Переносимая программа не должна использовать информацию о распределении памяти под формальные параметры, поскольку не только разные компиляторы по-разному решают эту задачу, но даже один компилятор может различным образом отводить память под формальные параметры при различных режимах своей работы.
Значение индикатора позиции файла после успешного выполнения функции ungetc для текстового потока до тех пор, пока не будут введены или уничтожены все запомненные символы.
Подробности о значении, запоминаемом в случае успешной работы функции fgetpos.
Подробности о значении, вырабатываемом для текстового потока в случае успешной работы функции ftell.
Порядок и взаимное расположение областей памяти, захватываемых функциями calloc, malloc и realloc.
Какой из двух элементов, оказавшихся равными при сравнении, возвращается функцией bsearch.
Порядок расстановки в отсортированном функцией qsort массиве двух элементов, оказавшихся равными при сравнении.
Структура календарного времени, возвращаемого функцией time.
Переносимая программа не использует перечисленную информацию, поскольку она либо различается для разных реализаций языка, либо даже является случайной в рамках одной реализации.
Неопределенное поведение
Поведение не определяется для следующих ситуаций:
В исходной программе обнаружен символ, не входящий в требуемый набор. Исключение делается для препроцессорных лексем, символьных и строковых констант, а также примечаний.
Делается попытка модифицировать строковую константу.
Идентификаторы, которые должны обозначать одну и ту же сущность, различаются хотя бы одним символом.
В символьной или строковой константе обнаружена неизвестная управляющая последовательность.
Лексически первое описание функции или объекта данных с внешней связью не имеет файловой области видимости, а последующее описание лексически идентичного идентификатора имеет либо внутреннюю, либо внешнюю связь, что противоречит первому описанию.
Арифметическое преобразование дает результат, который не может быть представлен в отведенном пространстве.
Арифметическая операция неверна (например, деление на 0) или выдает результат, который нельзя представить в отведенном пространстве (например, переполнение или потеря значимости).
Число фактических параметров вызова не согласуется с числом формальных параметров функции, которая не имеет действующего в данной области видимости прототипа.
Типы фактических параметров вызова после расширения не согласуются с расширенными типами формальных параметров функции, которая не имеет действующего в данной области видимости прототипа и не имеет прототипа, действующего в области видимости, соответствующей области определения функции.
Прототип функции имеется в области видимости, соответствующей области определения функции, формальный параметр описан с типом, который изменяется в результате действия расширений типа, проводимых по умолчанию, а функция вызывается, когда в области видимости нет семантически эквивалентного прототипа.
Вызывается функция, обрабатывающая переменное число параметров, но прототип с эллиптической нотацией отсутствует в данной области видимости.
Вызывается функция с прототипом, видимым в данной области, ее формальный параметр описан с типом, который изменяется в результате действия расширений типа, проводимых по умолчанию, но в области определения функции не видно семантически эквивалентного прототипа функции.
Встретилась неверная ссылка на массив, ссылка на пустой указатель или ссылка на объект, размещенный в области автоматически распределяемой памяти завершившегося блока.
Указатель на функцию преобразуется в указатель на функцию другого типа и используется для вызова функции, тип которой отличается от первоначального.
Указатель на объект, не являющийся элементом массива, используется в операции прибавления или вычитания константы.
Вычисляется разность указателей, относящихся к разным массивам.
Результат выражения сдвигается на отрицательную величину или на величину, большую или равную (в битах) размеру сдвигаемого результата.
Сравниваются указатели, относящиеся к разным составным объектам.
Значение объекта присваивается перекрывающемуся по памяти объекту.
Делается попытка изменить объект, описанный как константа, с помощью указателя на тип, в котором нет атрибута const.
Объект, описанный с атрибутом volatile, указывается с помощью указателя на тип, не имеющего такого же атрибута.
Описания объекта, имеющего внешнюю связь, в двух разных файлах или в разных областях видимости одного файла, дают этому объекту разные типы.
Значение автоматического неинициированного объекта используется до первого присваивания.
Используется результат работы функции, которая, однако, не возвращает никакого значения.
Функция, обрабатывающая переменное число параметров, определяется без списка типов параметров в эллиптической нотации.
Фактический параметр макровызова не имеет ни одной препроцессорной лексемы.
Внутри списка параметров макровызова имеются препроцессорные лексемы, которые могут быть проинтерпретированы как директивы препроцессора.
В результате выполнения препроцессорной операции слияния лексем (##) получается неверная препроцессорная лексема.
Эффект, возникающий в программе при переопределении зарезервированного внешнего идентификатора.
Параметр identifier в макровызове offset соответствует битовому полю записи.
Фактический параметр библиотечной функции имеет неверное значение, если только поведение этой функции в подобном случае не описано явно.
Библиотечная функция, обрабатывающая переменное число параметров, не описана.
Для доступа к настоящей функции assert использована макродиректива #undef.
Фактический параметр функции, обрабатывающей символы, выходит за область определения.
Вызов функции setjmp производится в ином контексте, нежели при сравнении с целочисленным выражением из констант в переключателе или в условном операторе.
Значение автоматического объекта, не имеющего атрибута volatile, изменилось между вызовами setjmp и longjmp.
Функция longjmp вызывается из динамически вложенной программы обработки сигнала.
Сигнал возникает не в результате работы функций abort или raise, а при обработке сигнала вызывается библиотечная функция, не являющаяся самой функцией signal, или со статическим объектом проделывается не присваивание ему значения статической переменной с атрибутом volatile типа sig_atomic_t.
Параметр parmN макроопределения va_start описывается в классе регистровой памяти.
При вызове макроимени va_arg очередного фактического параметра не оказалось.
Тип фактического параметра из списка параметров не согласуется с типом, указанным в макровызове va_arg.
Функция va_end вызывается без предварительного обращения к макровызову va_start.
Из функции с переменным числом параметров, список которых был проинициирован с помощью макровызова va_start, возврат производится до вызова va_end.
Формат в функциях fprintf и fscanf не соответствует списку фактических параметров.
В формате функций fprintf или fscanf обнаружена неверная спецификация преобразования.
Среди спецификаторов преобразования для спецификации, не входящей в список o, x, X, e, E, f, g и G встретился признак #.
Фактическим параметром функции fprintf, не соответствующим преобразованиям %s и %p, является составной объект или указатель на составной объект.
Отдельное преобразование в функции fprintf породило более 509 выходных символов.
Фактическим параметром преобразования %p функции fscanf является значение указателя, выданное при преобразовании %p функцией fprintf во время предыдущих запусков программы.
Результат преобразования, выполняемого функцией fscanf, не может быть представлен в объеме памяти, отведенной для него, или полученный объект имеет неподходящий тип.
Результат преобразования строки в число с помощью функций atof, atoi или atol не может быть представлен.
Фактический параметр функций free или realloc не совпадает с ранее полученными указателями, выработанными функциями calloc, malloc или realloс, или указывается объект, ранее уничтоженный вызовом функций free или realloc.
Ссылка на память, освобожденную функциями free или realloc.
При вызове из функции exit функция, зарегистрированная обращением к atexit, производит доступ к автоматическому объекту программы.
Результат целочисленных арифметических функций (abs, div, labs или ldiv) не может быть представлен.
Массив, в который идет запись копированием или конкатенацией, слишком мал.
Функции memcpy, strcpy или strncpy копируют объект в перекрывающийся с ним по памяти другой объект.
В формате функции strftime обнаружена неверная спецификация преобразования.
Все перечисленные ситуации являются ошибочными, однако разные реализации могут по-разному реагировать на них. Может даже случиться, что в некоторых реализациях программы с неопределенным поведением работают и выдают нужные результаты. Однако такие программы, как правило, невозможно перенести на другую вычислительную систему.
Например, используя в расчетной программе неверные арифметические операции (деление на ноль или операции, приводящие к переполнению или потере значимости), можно добиться удовлетворительной, с точки зрения конечного результата, работы этой программы за счет использования нюансов обработки таких исключительных ситуаций в рамках конкретной вычислительной системы. На других же вычислительных системах эта программа либо вообще не будет работать, либо будет выдавать неудовлетворительные результаты. Больше того, может потребоваться даже изменение алгоритма, реализуемого программой, из-за невозможности воспроизвести использованные нюансы исходной вычислительной системы хотя бы потому, что программист мог и не знать обо всех использованных тонкостях аппаратуры по принципу "есть результат и ладно" (кстати, техническая документация может и не содержать описания всех тонкостей).
Возникновения ситуаций с неопределенным поведением можно, а при разработке переносимых программ, безусловно, нужно избегать.
Поведение, зависящее от реализации
Каждая реализация должна описать поведение во всех ситуациях, перечисленных в этом разделе.
Семантика фактических параметров функции main.
Для облегчения переноса программы полезно локализовать обработку внешних аргументов.
Число значащих начальных символов (сверх 31) в идентификаторе без внешней связи.
В переносимой программе не используется свыше 31 значащего символа в идентификаторах без внешней связи.
Число значащих начальных символов (сверх 6) в идентификаторе с внешней связью.
В переносимой программе не используется свыше 6 значащих символов в идентификаторах с внешней связью.
Имеет ли значение регистр символов, входящих в идентификаторы с внешней связью.
При разработке переносимых программ лучше исходить из того, что регистр символов, входящих в идентификатор с внешней связью, не имеет значения (т.е. не различаются заглавные и прописные буквы).
Символы входного алфавита, кроме явно определенных в стандарте.
Это касается, в основном, символов, используемых в символьных и строковых константах (например, русские буквы).
Символы из набора времени выполнения (за исключением пустого символа и (в окружении выполнения) явно определенных символов входного символьного набора) и их коды.
В переносимых программах нежелательно использование информации о кодах символов, поскольку они могут различаться в разных реализациях.
Соответствие символов входного алфавита (в символьных и строковых константах) символам алфавита времени выполнения.
В основном это касается управляющих символов. Например, символ "конец строки" (\n) в разных реализациях может быть представлен в потоках ввода-вывода различными последовательностями кодов. Надо стараться писать программу так, чтобы ее поведение не зависело от конкретного представления управляющих символов в окружении выполнения.
Число и порядок символов в целом.
Эти различия несущественны в самостоятельных программах, которые не позволяют себе играть типами (например, преобразуя указатель на целое в указатель на символы и проверяя содержимое памяти по указателю), но могут проявиться при обработке данных, поступающих извне.
Число и порядок следования разрядов в символах из набора символов времени выполнения.
Значение символьной константы, состоящей из символа или управляющей последовательности, не представимой в алфавите времени выполнения.
Переносимой программе не следует использовать информацию этих двух пунктов.
Значение символьной константы, состоящей более, чем из одного символа.
В переносимой программе не следует использовать символьные константы более, чем из одного символа.
Следует ли трактовать "простые" символьные объекты как знаковые или беззнаковые.
Переносимая программа не должна зависеть от того, является ли тип char знаковым или беззнаковым.
Представление и наборы значений различных целочисленных типов.
В переносимой программе лучше всего исходить из минимальных наборов значений, зафиксированных стандартом, а также из той минимальной информации о представлении, которая в приводится в стандарте.
Результат преобразования целого к более короткому знаковому целому или результат преобразования беззнакового целого к знаковому целому той же длины, если значение не может быть представлено.
Переносимая программа не использует эту информацию.
Результаты поразрядных операций над знаковыми целыми.
В переносимой программе следует использовать только такие поразрядные операции, результат которых не зависит от реализации.
Знак остатка целочисленного деления.
Переносимая программа не использует эту информацию.
Является ли сдвиг вправо значения знакового целочисленного типа логическим или арифметическим.
Переносимая программа не должна зависеть от вида сдвига вправо знаковых целых.
Представление и наборы значений различных типов вещественных чисел.
Переносимая программа не зависит от представления вещественных чисел. Наборы значений вещественных типов влияют на точность вычислений.
Способ округления, когда вещественное число преобразуется к более узкому вещественному числу.
В переносимой программе лучше всего исходить из того, что способ округления неизвестен.
Тип целого, которое может вместить максимальный размер массива, то есть тип size_t - тип результата операции sizeof.
Результат преобразования указателя в целое и наоборот.
Тип целого, которое может вместить разность между двумя указателями на один и тот же массив - ptrdiff_t.
Переносимая программа не должна использовать информацию предыдущих трех пунктов.
Элемент смеси union используется как элемент другого типа.
Переносимая программа не должна осуществлять доступ к элементу смеси после того, как был изменен элемент смеси другого типа, поскольку в этом случае используется информация о битовой структуре представления значения соответствующего типа.
Дополнение пустот и выравнивание элементов записей.
Это обычно не доставляет проблем, если только двоичные данные, записанные одной реализацией, не читаются другой. Конечно же, не следует использовать эту информацию в переносимой программе.
Считается ли "простое" целое битовое поле знаковым или беззнаковым.
Переходит ли битовое поле, не умещающееся в одном целом, в следующее.
Порядок расположения битовых полей в целом.
Может ли битовое поле пересекать физические границы ячеек памяти.
Переносимая программа не должна использовать всю эту информацию.
Максимальное число описателей, которые могут модифицировать базовый тип.
Переносимой программе нужно исходить из того, что любая реализация должна допускать использование в модификации базового типа, либо непосредственно, либо через эквивалентность типов, по крайней мере 12 описателей указателей, массивов и функций (в любых комбинациях).
Максимальное число вариантов в переключателе.
Переносимая программа должна исходить из того, что число вариантов в переключателе не должно превышать 255.
Будет ли значение односимвольной символьной константы в выражении, управляющем условным включением фрагментов программ, совпадать со значением такой же константы в наборе символов окружения выполнения. Может ли такая константа иметь отрицательное значение.
Метод связи с входными файлами, подлежащими включению в программу.
Обработка имен в кавычках, относящихся к включаемым файлам.
Поведение каждой директивы #pragma.
Определение имен __DATE__ и __TIME__, когда, соответственно дата и время трансляции не может быть доступно.
Константа, получающаяся при подстановке макроопределения NULL, обозначающая пустой указатель.
Предыдущие 6 пунктов описывают зависящее от реализации поведение препроцессора. Остальные пункты описывают определяемое реализацией поведение библиотечных программ.
Диагностическое сообщение и способ завершения программы, применяемый в функции assert.
Наборы символов, проверяемые в функциях isalnum, isalpha, iscntrl, islower, isprint и isupper.
Значения, выдаваемые математическими функциями при возникновении ошибок области определения.
Устанавливают ли математические функции целое выражение errno в положение ERANGE при возникновении потери значимости.
Набор сигналов для функции signal.
Семантика каждого сигнала, распознаваемого библиотечной функцией signal.
Обработка умолчаний и входов в программу для каждого вида сигналов, распознаваемых функцией signal.
Восстанавливается ли стандартная обработка, если при обработке сигнала функцией, указанной при вызове функции signal, возникает сигнал SIGILL.
Нужно ли заканчивать последнюю строку текстового потока символом "конец строки".
Появятся ли при вводе обычные пробелы, записанные в текстовый поток непосредственно перед символом конца строки текста.
Количество символов NULL, которые дописываются к двоичному потоку.
Характеристики буферизации файлов.
Существует ли файл нулевой длины.
Правила образования правильных имен файлов.
Может ли один файл открываться много раз.
Результат выполнения функции remove над открытым файлом.
Эффект работы функции rename, если файл с новым именем существовал ранее.
Выходная строка, получающаяся при работе преобразования %p в функции fprintf.
Входная строка, поступающая для преобразования %p в функции fscanf.
Интерпретация символа ^, который есть ни первый, ни последний символ в списке сканирования в преобразовании %[ в функции fscanf.
Значение, которое получает errno от функций fgetpos и ftell в случае неудачи.
Сообщения, выдаваемые функцией perror.
Поведение функций calloc, malloc и realloc в случае, если размер запрошенной памяти равен нулю.
Поведение функции abort по отношению к открытым и временным файлам.
Статус, возвращаемый функцией exit, если значение фактического параметра не равно нулю, или значениям макроимен EXIT_SUCCESS и EXIT_FAILURE.
Набор имен окружения и метод изменения списка окружения, используемый функцией getenv.
Содержание и режим выполнения командной строки функцией system.
Знак значения, возвращаемого функцией сравнения (memcmp, strcmp или strncmp), если первая пара различающихся символов разнится в старшем разряде.
Содержание строк сообщений об ошибках, возвращаемых функцией strerror.
Местный временной пояс и летнее время.
Точка отсчета для функции clock.
Метрические ограничения переносимой программы
Переносимая программа должна удовлетворять следующим метрическим ограничениям:
15 уровней вложенности составных операторов, операторов цикла и операторов выбора варианта.
6 уровней вложенности условной трансляции.
12 описателей указателя, массива и функции, модифицирующих базовый тип в описании объекта.
127 выражений, вложенных друг в друга по круглым скобкам.
31 значащий символ в начале идентификатора с внутренней связью или имени макроопределения.
6 значащих символов в начале имен, имеющих внешнюю связь.
511 внешних имен в одном исходном файле.
127 имен в одном блоке.
1024 имени макроопределений, одновременно действующих в одном исходном файле.
31 параметр в вызове или определении функции.
31 параметр в макровызове или макроопределении с параметрами.
509 символов в одной логической исходной строке.
509 символов в строковой константе (после конкатенации).
32767 байтов для размещения объекта.
8 уровней вложенности по включаемым файлам.
255 меток выбора варианта в переключателях.
Пакет Motif
Motif (официальное название этого продукта - OSF/Motif) представляет собой программный пакет, включающий оконный менеджер, набор вспомогательных утилит, а также библиотеку классов, построенных на основе Xt Intrinsics. Для конечных пользователей оконных систем, опирающихся на Motif, основной интерес представляет менеджер окон, хотя, скорее всего, вы не сможете определить, применяется ли оконный менеджер Motif в используемой вами установке.
Для разработчиков же графических интерфейсов важны все три компонента Motif. Новый интерфейс разрабатывается в графическом же режиме с использованием оконного менеджера. При этом полезно использование утилит Motif и необходимо использование библиотеки классов Motif.
Библиотека классов Motif является расширением библиотеки Xt Intrinsics с целью предания этой библиотеке практического смысла (по-другому можно сказать, что Motif - это то, чем должен был бы быть Xt, если бы при его создании ставились коммерческие цели). Все графические объекты (правильнее сказать, классы) Xt Intrinsics включаются в библиотеку классов Motif, хотя в ней используются другие имена.
Но Motif существенно расширяет возможности Xt Intrinsics. В его библиотеке поддерживается большое число классов, позволяющих создавать меню, "нажимаемые" кнопки и т.д. Основное назначение этих классов - определение новых виджетов, связанных с окнами.
Однако в Motif поддерживается и новый вид графических объектов (их классов) - так называемые гаджеты (gadgets). Гаджет отличается от виджета тем, что соответствующий класс также может использоваться для создания элементов интерфейса, но графический объект не привязывается к определенному окну. При отображении на экран гаджета используется окно объекта, относящегося к суперклассу класса гаджета.
Понятно, что здесь мы не можем привести подробное описание Motif (еще раз повторим, что соответствующий материал содержится в нескольких солидных книгах). Однако основная идея должна быть понятна: развитая библиотека классов языка Си++, возможности применения этих классов при использовании обычного стиля программирования и поддержка визуального программирования с немедленным отображением получающихся графических объектов.
Перенаправление ввода/вывода
Механизм перенаправления ввода/вывода является одним из наиболее элегантных, мощных и одновременно простых механизмов ОС UNIX. Цель, которая ставилась при разработке этого механизма, состоит в следующем. Поскольку UNIX - это интерактивная система, то обычно программы вводят текстовые строки с терминала и выводят результирующие текстовые строки на экран терминала. Для того, чтобы обеспечить более гибкое использование таких программ, желательно уметь обеспечить им ввод из файла или из вывода других программ и направить их вывод в файл или на ввод другим программам.
Реализация механизма основывается на следующих свойствах ОС UNIX. Во-первых, любой ввод/вывод трактуется как ввод из некоторого файла и вывод в некоторый файл. Клавиатура и экран терминала тоже интерпретируются как файлы (первый можно только читать, а во второй можно только писать). Во-вторых, доступ к любому файлу производится через его дескриптор (положительное целое число). Фиксируются три значения дескрипторов файлов. Файл с дескриптором 1 называется файлом стандартного ввода (stdin), файл с дескриптором 2 - файлом стандартного вывода (stdout), и файл с дескриптором 3 - файлом стандартного вывода диагностических сообщений (stderr). В-третьих, программа, запущенная в некотором процессе, "наследует" от породившего процесса все дескрипторы открытых файлов.
В головном процессе интерпретатора командного языка файлом стандартного ввода является клавиатура терминала пользователя, а файлами стандартного вывода и вывода диагностических сообщений - экран терминала. Однако при запуске любой команды можно сообщить интерпретатору (средствами соответствующего командного языка), какой файл или вывод какой программы должен служить файлом стандартного ввода для запускаемой программы и какой файл или ввод какой программы должен служить файлом стандартного вывода или вывода диагностических сообщений для запускаемой программы. Тогда интерпретатор перед выполнением системного вызова exec открывает указанные файлы, подменяя смысл дескрипторов 1, 2 и 3.
Конечно, то же самое может проделать и любая другая программа, запускающая третью программу в специально созданном процессе. Следовательно, все, что требуется для нормального функционирования механизма перенаправления ввода/вывода - это придерживаться при программировании соглашения об использовании дескрипторов stdin, stdout и stderr. Это не очень трудно, поскольку в наиболее распространенных функциях библиотеки ввода/вывода printf, scanf и error вообще не требуется указывать дескриптор файла. Функция printf неявно использует stdout, функция scanf - stdin, а функция error - stderr.
Более подробно механизм перенаправления вывода одной программы на ввод другой программы будет рассмотрен в третьей части курса.
Перенаправление ввода/вывода и организация конвейера
Как видно из последнего предложения предыдущего пункта, для обеспечения возможностей перенаправления ввода/вывода и организации конвейера при программировании команд не требуется делать ничего специального. Достаточно просто не трогать три начальные дескриптора файлов и правильно работать с этими файлами, а именно, производить вывод в файл с дескриптором stdout, вводить данные из файла stdin и выводить сообщения об ошибках в файл stderror.
Перспективные ОС, поддерживающие среду ОС UNIX
Микроядро - это минимальная стержневая часть операционной системы, служащая основой модульных и переносимых расширений. По-видимому, большинство операционных систем следующего поколения будут обладать микроядрами. Однако имеется масса разных мнений по поводу того, как следует организовывать службы операционной системы по отношению к микроядру: как проектировать драйверы устройств, чтобы добиться наибольшей эффективности, но сохранить функции драйверов максимально независимыми от аппаратуры; следует ли выполнять операции, не относящиеся к ядру, в пространстве ядра или в пространстве пользователя; стоит ли сохранять программы имеющихся подсистем (например, UNIX) или лучше отбросить все и начать с нуля.
В широкий обиход понятие микроядра ввела компания Next, в операционной системе которой использовалось микроядро Mach. Небольшое привилегированное ядро этой ОС, вокруг которого располагались подсистемы, выполняемые в режиме пользователя, теоретически должно было обеспечить небывалую гибкость и модульность системы. Но на практике это преимущество было несколько обесценено наличием монолитного сервера, реализующего операционную систему UNIX BSD 4.3, которую компания Next выбрала в качестве оболочки микроядра Mach. Однако опора на Mach дала возможность включить в систему средства передачи сообщений и ряд объектно-ориентированных сервисных функций, на основе которых удалось создать элегантный интерфейс конечного пользователя с графическими средствами конфигурирования сети, системного администрирования и разработки программного обеспечения.
Следующей микроядерной операционной системой была Windows NT компании Microsoft, в которой ключевым преимуществом использования микроядра должна была стать не только модульность, но и переносимость. (Заметим, что отсутствует единодушное мнение по поводу того, следует ли на самом деле относить NT к микроядерным ОС.) ОС NT была построена таким образом, чтобы ее можно было применять в одно- и мультипроцессорных системах, основанных на процессорах Intel, Mips и Alpha (и тех, которые придут вслед за ними). Поскольку в среде NT должны были выполняться программы, написанные для DOS, Windows, OS/2 и систем, совместимых со стандартами Posix, компания Microsoft использовала присущую микроядерному подходу модульность для создания общей структуры NT, не повторяющей ни одну из существующих операционных систем. Каждая операционная система эмулируется в виде отдельного модуля или подсистемы.
Позднее микроядерные архитектуры операционных систем были объявлены компаниями Novell/USL, Open Software Foundation (OSF), IBM, Apple и другими. Одним из основных конкурентов NT в области микроядерных ОС является Mach 3.0, система, созданная в университете Карнеги-Меллон, которую как IBM, так и OSF взялись довести до коммерческого вида. (Компания Next в качестве основы для NextStep пока использует Mach 2.5, но тоже внимательно присматривается к Mach 3.0.) Другим конкурентом является микроядро Chorus 3.0 компании Chorus Systems, выбранное USL в качестве основы новых реализаций ОС UNIX. Некоторое микроядро будет использоваться в SpringOS фирмы Sun, объектно-ориентированном преемнике ОС Solaris (если, конечно, Sun доведет работу над SpringOS до конца). Очевидна тенденция к переходу от монолитных к микроядерным системам (хотя, как мы отмечали в предыдущем разделе, этот процесс не является прямолинейным: компания IBM сделала шаг назад и отказалась от перехода к микроядерной технологии). Кстати, это совсем не новость для компаний QNX Software Systems и Unisys, которые уже в течение нескольких лет выпускают пользующиеся успехом микроядерные операционные системы. ОС QNX пользуется спросом на рынке систем реального времени, а CTOS фирмы Unisys популярна в области банковского дела. В обеих системах успешно использована модульность, присущая микроядерным ОС.
Первый перенос ОС UNIX
По-видимому, первый перенос ОС UNIX на компьютер с архитектурой, принципиально отличающейся от PDP-11, был произведен в 1977 году в Австралии. Это произошло вскоре после того, как в университете Воллонгонга была образована компьютерная кафедра. Джюрис Рейндфельдс, ставший заведующим новой кафедры, решил использовать ОС UNIX как основу обучения студентов. Он специально посетил университет г. Беркли и был вдохновлен возможностями, имеющимися в этом университете (PDP-11/40 с ОС UNIX V6). Однако выяснилось, что в университете г.Воллонгонг отсутствовали средства, достаточные для приобретения PDP-11.
Профессор Рейндфельдс был вынужден купить 32-разрядный компьютер Interdata 7/32, который был существенно дешевле, хотя и слабее по производительности. После нескольких попыток здравым образом дополнить "родную" операционную систему Interdata 7/32 OSMT/32 более развитыми средствами многопользовательского режима использования было принято решение попробовать перенести на эту 32-разрядную машину ОС UNIX V6.
Очень замысловатым образом (напомним, что в австралийском университете не было доступного компьютера PDP-11) путем обмена магнитными лентами с университетом г. Беркли Ричард Миллер (канадец, работавший в Австралии) смог к январю 1977 года получить компилятор языка Си, который мог успешно компилировать собственный исходный текст на Interdata 7/32. Это позволило уже через месяц получить некоторый вариант ОС UNIX, работающий на этой же машине.
Система Миллера представляла собой некий гибрид, основанный на ОС UNIX V6 и выполняемый "поверх" OSMT/32. Версия системы не включала собственных средств управления терминалами и обработки прерываний и поддерживала около восьми команд примитивного командного интерпретатора. Тем не менее, это была первая успешная (и быстро выполненная) попытка переноса ОС UNIX на компьютер с 32-разрядной архитектурой.
Подходы к организации нитей и управлению ими в разных вариантах ОС UNIX
Хотя концептуально реализации механизма нитей в разных современных вариантах практически эквивалентны (да и что особенное можно придумать по поводу легковесных процессов?), технически и, к сожалению, в отношении интерфейсов эти реализации различаются. Мы не ставим здесь перед собой цели описать в деталях какую-либо реализацию, однако постараемся в общих чертах охарактеризовать разные подходы.
Начнем с того, что разнообразие механизмов нитей в современных вариантах ОС UNIX само по себе представляет проблему. Сейчас достаточно трудно говорить о возможности мобильного параллельного программирования в среде UNIX-ориентированных операционных систем. Если программист хочет добиться предельной эффективности (а он должен этого хотеть, если для целей его проекта приобретен дорогостоящий мультипроцессор), то он вынужден использовать все уникальные возможности используемой им операционной системы.
Для всех очевидно, что сегодняшняя ситуация далека от идеальной. Однако, по-видимому, ее было невозможно избежать, поскольку поставщики мультипроцессорных симметричных архитектур должны были как можно раньше предоставить своим покупателям возможности эффективного программирования, и времени на согласование решений просто не было (любых поставщиков прежде всего интересует объем продаж, а проблемы будущего оставляются на будущее).
Применяемые в настоящее время подходы зависят от того, насколько внимательно разработчики ОС относились к проблемам реального времени. (Возвращаясь к введению этого раздела, еще раз отметим, что здесь мы имеем в виду "мягкое" реальное время, т.е. программно-аппаратные системы, которые обеспечивают быструю реакцию на внешние события, но время реакции не установлено абсолютно строго.) Типичная система реального времени состоит из общего монитора, который отслеживает общее состояние системы и реагирует на внешние и внутренние события, и совокупности обработчиков событий, которые, желательно параллельно, выполняют основные функции системы.
Понятно, что от возможностей реального распараллеливания функций обработчиков зависят общие временные показатели системы. Если, например, при проектировании системы замечено, что типичной картиной является "одновременное" поступление в систему N внешних событий, то желательно гарантировать наличие реальных N устройств обработки, на которых могут базироваться обработчики. На этих наблюдениях основан подход компании Sun Microsystems.
В системе Solaris (правильнее говорить SunOS 4.x, поскольку Solaris в терминологии Sun представляет собой не операционную систему, а расширенную операционную среду) принят следующий подход. При запуске любого процесса можно потребовать резервирования одного или нескольких процессоров мультипроцессорной системы. Это означает, что операционная система не предоставит никакому другому процессу возможности выполнения на зарезервированном(ых) процессоре(ах). Независимо от того, готова ли к выполнению хотя бы одна нить такого процесса, зарезервированные процессоры не будут использоваться ни для чего другого.
Далее, при образовании нити можно закрепить ее за одним или несколькими процессорами из числа зарезервированных. В частности, таким образом в принципе можно привязать нить к некоторому фиксированному процессору. В общем случае некоторая совокупность потоков управления привязывается к некоторой совокупности процессоров так, чтобы среднее время реакции системы реального времени удовлетворяло внешним критериям. Очевидно, что это "ассемблерный" стиль программирования (слишком много перекладывается на пользователя), но зато он открывает широкие возможности перед разработчиками систем реального времени (которые, правда, после этого зависят не только от особенностей конкретной операционной системы, но и от конкретной конфигурации данной компьютерной установки). Подход Solaris преследует цели удовлетворить разработчиков систем "мягкого" (а, возможно, и "жесткого") реального времени, и поэтому фактически дает им в руки средства распределения критических вычислительных ресурсов.
В других подходах в большей степени преследуется цель равномерной балансировки загрузки мультипроцессора. В этом случае программисту не предоставляются средства явной привязки процессоров к процессам или нитям. Система допускает явное распараллеливание в пределах общей виртуальной памяти и "обещает", что по мере возможностей все процессоры вычислительной системы будут загружены равномерно. Этот подход обеспечивает наиболее эффективное использование общих вычислительных ресурсов мультипроцессора, но не гарантирует корректность выполнения систем реального времени (если не считать возможности установления специальных приоритетов реального времени, которые упоминались в п. 3.1.2).
Отметим существование еще одной аппаратно-программной проблемы, связанной с нитями (и не только с ними). Проблема связана с тем, что в существующих симметричных мультипроцессорах обычно каждый процессор обладает собственной сверхбыстродействующей буферной памятью (кэшем). Идея кэша, в общих чертах, состоит в том, чтобы обеспечить процессору очень быстрый (без необходимости выхода на шину доступа к общей оперативной памяти) доступ к наиболее актуальным данным. В частности, если программа выполняет запись в память, то это действие не обязательно сразу отображается в соответствующем элементе основной памяти; до поры до времени измененный элемент данных может содержаться только в локальном кэше того процессора, на котором выполняется программа. Конечно, это противоречит идее совместного использования виртуальной памяти нитями одного процесса (а также идее использования памяти, разделяемой между несколькими процессами, см. п. 3.4.1).
Это очень сложная проблема, относящаяся к области проблем "когерентности кэшей". Теоретически имеется много подходов к ее решению (например, аппаратное распознавание необходимости выталкивания записи из кэша с синхронным объявлением недействительным содержания всех кэшей, включающих тот же элемент данных). Однако на практике такие сложные действия не применяются, и обычным приемом является отмена режима кэширования в том случае, когда на разных процессорах мультипроцессорной системы выполняются нити одного процесса или процессы, использующие разделяемую память.
После введения понятия нити трансформируется само понятие процесса. Теперь лучше (и правильнее) понимать процесс ОС UNIX как некоторый контекст, включающий виртуальную память и другие системные ресурсы (включая открытые файлы), в котором выполняется, по крайней мере, один поток управления (нить), обладающий своим собственным (более простым) контекстом. Теперь ядро знает о существовании этих двух уровней контекстов и способно сравнительно быстро изменять контекст нити (не изменяя общего контекста процесса) и так же, как и ранее, изменять контекст процесса.
Последнее замечание относится к синхронизации выполнения нитей одного процесса (точнее было бы говорить о синхронизации доступа нитей к общим ресурсам процесса - виртуальной памяти, открытым файлам и т.д.). Конечно, можно пользоваться (сравнительно) традиционными средствами синхронизации (например, семафорами, см. п. 3.4.2). Однако оказывается, что система может предоставить для синхронизации нитей одного процесса более дешевые средства (поскольку все нити работают в общем контексте процесса). Обычно эти средства относятся к классу средств взаимного исключения (т.е. к классу семафоро-подобных средств). К сожалению, и в этом отношении к настоящему времени отсутствует какая-либо стандартизация.
Пользователь
С самого начала ОС UNIX замышлялась как интерактивная система. Другими словами, UNIX предназначен для терминальной работы. Чтобы начать работать, человек должен "войти" в систему, введя со свободного терминала свое учетное имя (account name) и, возможно, пароль (password). Человек, зарегистрированный в учетных файлах системы, и, следовательно, имеющий учетное имя, называется зарегистрированным пользователем системы. Регистрацию новых пользователей обычно выполняет администратор системы. Пользователь не может изменить свое учетное имя, но может установить и/или изменить свой пароль. Пароли хранятся в отдельном файле в закодированном виде. Не забывайте свой пароль, снова узнать его не поможет даже администратор!
Все пользователи ОС UNIX явно или неявно работают с файлами. Файловая система ОС UNIX имеет древовидную структуру. Промежуточными узлами дерева являются каталоги со ссылками на другие каталоги или файлы, а листья дерева соответствуют файлам или пустым каталогам. Каждому зарегистрированному пользователю соответствует некоторый каталог файловой системы, который называется "домашним" (home) каталогом пользователя. При входе в систему пользователь получает неограниченный доступ к своему домашнему каталогу и всем каталогам и файлам, содержащимся в нем. Пользователь может создавать, удалять и модифицировать каталоги и файлы, содержащиеся в домашнем каталоге. Потенциально возможен доступ и ко всем другим файлам, однако он может быть ограничен, если пользователь не имеет достаточных привилегий.
Пользовательская и ядерная составляющие процессов
Каждому процессу соответствует контекст, в котором он выполняется. Этот контекст включает содержимое пользовательского адресного пространства - пользовательский контекст (т.е. содержимое сегментов программного кода, данных, стека, разделяемых сегментов и сегментов файлов, отображаемых в виртуальную память), содержимое аппаратных регистров - регистровый контекст (регистр счетчика команд, регистр состояния процессора, регистр указателя стека и регистры общего назначения), а также структуры данных ядра (контекст системного уровня), связанные с этим процессом. Контекст процесса системного уровня в ОС UNIX состоит из "статической" и "динамических" частей. Для каждого процесса имеется одна статическая часть контекста системного уровня и переменное число динамических частей.
Статическая часть контекста процесса системного уровня включает следующее:
Описатель процесса, т.е. элемент таблицы описателей существующих в системе процессов. Описатель процесса включает, в частности, следующую информацию:
состояние процесса;
физический адрес в основной или внешней памяти u-области процесса;
идентификаторы пользователя, от имени которого запущен процесс;
идентификатор процесса;
прочую информацию, связанную с управлением процессом.
U-область (u-area) - индивидуальная для каждого процесса область пространства ядра, обладающая тем свойством, что хотя u-область каждого процесса располагается в отдельном месте физической памяти, u-области всех процессов имеют один и тот же виртуальный адрес в адресном пространстве ядра. Именно это означает, что какая бы программа ядра не выполнялась, она всегда выполняется как ядерная часть некоторого пользовательского процесса, и именно того процесса, u-область которого является "видимой" для ядра в данный момент времени. U-область процесса содержит:
указатель на описатель процесса;
идентификаторы пользователя;
счетчик времени, в течение которого процесс реально выполнялся (т.е. занимал процессор) в режиме пользователя и режиме ядра;
параметры системного вызова;
результаты системного вызова;
таблица дескрипторов открытых файлов;
предельные размеры адресного пространства процесса;
предельные размеры файла, в который процесс может писать;
и т.д.
Динамическая часть контекста процесса - это один или несколько стеков, которые используются процессом при его выполнении в режиме ядра. Число ядерных стеков процесса соответствует числу уровней прерывания, поддерживаемых конкретной аппаратурой.
Понятие микроядра
Микроядро реализует базовые функции операционной системы, на которые опираются другие системные службы и приложения. Основной проблемой при конструировании микроядерной ОС является распознавание тех функций системы, которые могут быть вынесены из ядра. Такие важные компоненты ОС как файловые системы, системы управления окнами и службы безопасности становятся периферийными модулями, взаимодействующими с ядром и друг с другом.
Когда-то казалось, что многоуровневая архитектура ядра ОС UNIX является вершиной в области конструирования операционных систем. Основные функциональные компоненты операционной системы - файловая система, взаимодействие процессов (IPC - interprocess communications), ввод-вывод и управление устройствами - были разделены на уровни, каждый из которых мог взаимодействовать только с непосредственно примыкающим к нему уровнем.
Несмотря на неплохие практические результаты такая структура теперь все больше воспринимается монолитной, поскольку вся операционная система связана иерархией уровней. Множественность и нечеткость интерфейсов между уровнями затрудняет модификацию системы; для этого требуется хорошее знание операционной системы, масса времени и элемент везения.
В микроядерных архитектурах вертикальное распределение функций операционной системы заменяется на горизонтальное. Компоненты, лежащие выше микроядра, используют средства микроядра для обмена сообщениями, но взаимодействуют непосредственно. Микроядро лишь проверяет законность сообщений, пересылает их между компонентами и обеспечивает доступ к аппаратуре.
Это свойство микроядерных систем позволяет естественно использовать их в распределенных средах. При получении сообщения микроядро может его обработать или переслать другому процессу. Поскольку для микроядра безразлично, поступило ли сообщение от локального или удаленного процесса, подобная схема передачи сообщений является удобной основой удаленных вызовов процедур (RPC - Remote Procedure Calls). Однако пересылка сообщений производится медленнее обычных вызовов функций; оптимизация пересылки сообщений является критическим фактором успеха микроядерной операционной системы. Например, в ОС Windows NT в некоторых случаях для оптимизации используется разделяемая память. Расходы на дополнительную фиксированную память микроядра оправдываются повышением эффективности передачи сообщений.
Поскольку вся машинно-зависимая часть ОС изолирована в микроядре, для переноса системы на новый процессор требуется меньше изменений и эти изменения логически сгруппированы. При имеющемся разнообразии на рынке процессоров способность операционной системы работать на разных процессорах является единственной возможностью убедить пользователей покупать новые машины.
Расширяемость также является необходимым свойством современных операционных систем. В отличие от аппаратных средств, которые устаревают за несколько лет, операционные системы могут с пользой эксплуатироваться в течение десятилетий. В жизни каждой операционной системы настает момент, когда в нее требуется внести функции, не заложенные в исходную конструкцию. Микроядерная организация операционных систем позволяет добиться возможности производства управляемых и надежно работающих расширений на основе ограниченного набора четко определенных интерфейсов микроядра.
В действительности, правильнее говорить не только о расширяемости, но и о масштабируемости микроядерных ОС с возможностью получения варианта операционной системы, в наилучшей степени соответствующей особенностям аппаратной платформы и прикладной области. Микроядерная организация ОС позволяет легко добиться и этого качества.
Одной из проблем традиционно организованных операционных систем является наличие множества интерфейсов прикладного программирования (API - Application Programming Interface), не все из которых хорошо документированы. В результате невозможно гарантировать правильность программ, использующих несколько API, и даже правильность работы самой операционной системы.
Микроядро, обладающее небольшим набором API (микроядро OSF обеспечивает около 200 системных вызовов, а крохотное микроядро QNS - всего лишь 14), увеличивает шансы получения качественных программ. Конечно, этот компактный интерфейс облегчает жизнь только системных программистов; прикладной программист по прежнему должен бороться с сотнями вызовов.
Основным принципом организации микроядерных ОС является включение в состав микроядра только тех функций, которые абсолютно необходимо выполнять в режиме супервизора и в защищенной памяти. Обычно в микроядро включаются машинно-зависимые программы (включая поддержку мультипроцессорной работы), некоторые функции управления процессами, обработка прерываний и поддержка пересылки сообщений.
Во многих случаях в микроядро включается функция планирования процессов, но в реализации Mach компании IBM планировщик процессов размещен вне микроядра, а микроядро используется только для непосредственного управления процессами. Конечно, при этом требуется тесное взаимодействие внешнего планировщика и входящего в состав ядра диспетчера.
В некоторых реализациях (например, в реализации OSF) в микроядро помещаются драйверы устройств. В реализациях IBM и Chorus драйверы размещаются вне микроядра, но для регулирования режимов разрешения и запрещения прерываний часть программы драйвера выполняется в пространстве ядра. В NT драйверы устройств и другие функции ввода-вывода выполняются в пространстве ядра, но реально используют ядро только для перехвата и передачи прерываний. Следует заметить, что оба подхода допускают динамическое подключение драйверов к системе и их отключение.
Однако имеются другие доводы в пользу выделения драйверов из состава микроядра. Например, поскольку во многих случаях драйверы могут не зависеть от особенностей аппаратуры, такой подход облегчает переносимость системы.
Понятие нити (threads)
Понятие "легковесного процесса" (light-weight process), или, как принято называть его в современных вариантах ОС UNIX, "thread" (нить, поток управления) давно известно в области операционных систем. Интуитивно понятно, что концепции виртуальной памяти и потока команд, выполняющегося в этой виртуальной памяти, в принципе, ортогональны. Ни из чего не следует, что одной виртуальной памяти должен соответствовать один и только один поток управления. Поэтому, например, в ОС Multics (раздел 1.1) допускалось (и являлось принятой практикой) иметь произвольное количество процессов, выполняемых в общей (разделяемой) виртуальной памяти.
Понятно, что если несколько процессов совместно пользуются некоторыми ресурсами, то при доступе к этим ресурсам они должны синхронизоваться (например, с использованием семафоров, см. п. 3.4.2). Многолетний опыт программирования с использованием явных примитивов синхронизации показал, что этот стиль "параллельного" программирования порождает серьезные проблемы при написании, отладке и сопровождении программ (наиболее трудно обнаруживаемые ошибки в программах обычно связаны с синхронизацией). Это явилось одной из причин того, что в традиционных вариантах ОС UNIX понятие процесса жестко связывалось с понятием отдельной и недоступной для других процессов виртуальной памяти. Каждый процесс был защищен ядром операционной системы от неконтролируемого вмешательства других процессов. Многие годы авторы ОС UNIX считали это одним из основных достоинств системы (впрочем, это мнение существует и сегодня).
Однако, связывание процесса с виртуальной памятью порождает, по крайней мере, две проблемы. Первая проблема связана с так называемыми системами реального времени. Такие системы, как правило, предназначены для одновременного управления несколькими внешними объектами и наиболее естественно представляются в виде совокупности "параллельно" (или "квази-параллельно") выполняемых потоков команд (т.е. взаимодействующих процессов). Однако, если с каждым процессом связана отдельная виртуальная память, то смена контекста процессора (т.е. его переключение с выполнения одного процесса на выполнение другого процесса) является относительно дорогостоящей операцией. Поэтому традиционный подход ОС UNIX препятствовал использованию системы в приложениях реального времени.
Второй ( и может быть более существенной) проблемой явилось появление так называемых симметричных мультипроцессорных компьютерных архитектур (SMP - Symmetric Multiprocessor Architectures). В таких компьютерах физически присутствуют несколько процессоров, которые имеют одинаковые по скорости возможности доступа к совместно используемой основной памяти. Появление подобных машин на мировом рынке, естественно, поставило проблему их эффективного использования. Понятно, что при применении традиционного подхода ОС UNIX к организации процессов от наличия общей памяти не очень много толка (хотя при наличии возможностей разделяемой памяти (см. п. 3.4.1) об этом можно спорить). К моменту появления SMP выяснилось, что технология программирования все еще не может предложить эффективного и безопасного способа реального параллельного программирования. Поэтому пришлось вернуться к явному параллельному программированию с использованием параллельных процессов в общей виртуальной (а тем самым, и основной) памяти с явной синхронизацией.
Что же понимается под "нитью" (thread)? Это независимый поток управления, выполняемый в контексте некоторого процесса. Фактически, понятие контекста процесса, которое мы обсуждали в п. 3.1.1, изменяется следующим образом. Все, что не относится к потоку управления (виртуальная память, дескрипторы открытых файлов и т.д.), остается в общем контексте процесса. Вещи, которые характерны для потока управления (регистровый контекст, стеки разного уровня и т.д.), переходят из контекста процесса в контекст нити. Общая картина показана на рисунке 3.4.
Рис. 3.4. Соотношение контекста процесса и контекстов нитей
Как видно из этого рисунка, все нити процесса выполняются в его контексте, но каждая нить имеет свой собственный контекст. Контекст нити, как и контекст процесса, состоит из пользовательской и ядерной составляющих. Пользовательская составляющая контекста нити включает индивидуальный стек нити. Поскольку нити одного процесса выполняются в общей виртуальной памяти (все нити процесса имеют равные права доступа к любым частям виртуальной памяти процесса), стек (сегмент стека) любой нити процесса в принципе не защищен от произвольного (например, по причине ошибки) доступа со стороны других нитей. Ядерная составляющая контекста нити включает ее регистровый контекст (в частности, содержимое регистра счетчика команд) и динамически создаваемые ядерные стеки.
Приведенное краткое обсуждение понятия нити кажется достаточным для того, чтобы понять, что внедрение в ОС UNIX механизма легковесных процессов требует существенных переделок ядра системы. (Всегда трудно внедрить в программу средства, для поддержки которых она не была изначально приспособлена.)
Потоки (Streams)
В самых ранних вариантах UNIX коммуникационные средства основывались на символьном вводе/выводе, главным образом потому, что аппаратной основой являлись модемы и терминалы. Поскольку такие устройства являются относительно медленными, в ранних вариантах не требовалось особенно заботиться о модульности и эффективности программного обеспечения. Несколько позже в системе появилась поддержка более развитых устройств, протоколов, операционных режимов и т.д., но программные средства по-прежнему основывались на ограниченных возможностях символьного ввода/вывода.
С появлением многоуровневых сетевых протоколов, таких как TCP/IP (US Defense Advanced Research Project Agency's Transmission Control Protocol/Internet Protocol), SNA (IBM's System Network Architecture), OSI (Open Systems Internetworking), X.25 и др. стало понятно, что в ОС UNIX требуется некоторая общая основа организации сетевых средств, основанных на многоуровневых протоколах. Для решения этой проблемы было реализовано несколько механизмов, обладающих примерно одинаковыми возможностями, но не совместимых между собой, поскольку каждый из них являлся результатом некоторого индивидуального проекта.
Общей проблемой ОС UNIX было то, что слабая развитость подсистемы ввода/вывода требовала решения задачи проектирования и включения в систему нового драйвера при каждом подключении нового устройства. Хотя зачастую уже существовал программный код, обладающий хотя бы частью функций, требуемых в новом драйвере, отсутствовала возможность использования этого существующего кода.
Во многом эта проблема была решена компанией AT&T, которая предложила и реализовала механизм потоков (STREAMS), обеспечивающий гибкие и модульные возможности для реализации драйверов устройств и коммуникационных протоколов. Потоки были впервые реализованы Деннисом Ритчи в 1984 году и были включены в пакет Networking Support Facilities (NSU) UNIX System V Release 3.
В UNIX System V Release 3 потоки были включены как основа реализации существующего символьного ввода/вывода. Однако в Release 4 в реализацию потоков были включены интерфейс драйвера устройства (DDI - Device Driver Interface) и интерфейс между драйвером и ядром (DKI - Device Kernel Interface), которые в совокупности одновременно обеспечивают возможности по взаимодействию драйвера устройства с ядром системы и простоту повторного использования имеющегося исходного кода драйверов. С использованием механизма потоков были переписаны практически все символьные драйверы, полностью переработаны подсистема управления терминалами и механизм программных каналов (pipes).
Если не вдаваться в детали, Streams представляют собой связанный набор средств общего назначения, включающий системные вызовы и подпрограммы, а также ресурсы ядра. В совокупности эти средства обеспечивают стандартный интерфейс символьного ввода/вывода внутри ядра, а также между ядром и соответствующими драйверами устройств, предоставляя гибкие и развитые возможности разработки и реализации коммуникационных сервисов. При этом механизм потоков не навязывает какой-либо конкретной архитектуры сети и/или конкретных протоколов. Как и любой другой драйвер устройства, потоковый драйвер представляется специальным файлом файловой системы со стандартным набором операций: open, close, read, write и ioctl. Простейшая форма организации потокового интерфейса показана на рисунке 2.4.
Рис. 2.4. Простая форма потокового интерфейса
Когда пользовательский процесс открывает потоковое устройство, пользуясь системным вызовом open, ядро связывает с драйвером заголовок потока. После этого пользовательский процесс общается с заголовком потока так, как если бы он представлял собой обычный драйвер устройства. Другими словами, заголовок потока отвечает за обработку всех системных вызовов, производимых пользовательским процессом по отношению к потоковому драйверу. Если процесс выполняет запись в устройство (системный вызов write), заголовок потока передает данные драйверу устройства в нисходящем направлении. Аналогично, при реализации чтения из устройства (системный вызов read) драйвер устройства передает данные заголовку потока в восходящем направлении.
В описанной схеме данные между заголовком потока и драйвером устройства передаются в неизменяемом виде без какой-либо промежуточной обработки. Однако можно добиться того, чтобы данные подвергались обработке при передаче их в любом направлении, если включить в поток между заголовком и драйвером устройства один или несколько потоковых модулей. Потоковый модуль является обработчиком данных, выполняющим определенный набор функций над данными по мере их прохождения по потоку. Простейшими примерами потокового модуля являются разного рода перекодировщики символьной информации. Более сложным примером является потоковый модуль, осуществляющий разборку нисходящих данных в пакеты для их передачи по сети и сборку восходящих данных с удалением служебной информации пакетов.
Каждый потоковый модуль является, вообще говоря, независимым от присутствия в потоке других модулей, обрабатывающих данные. Данные могут подвергаться обработке произвольным числом потоковых модулей, пока в конце концов не достигнут драйвера устройств при движении в нисходящем направлении или заголовка потока при движении в восходящем направлении. Для передачи данных от заголовка к драйверу или модулю, от одного модуля другому и от драйвера или модуля к заголовку потока используется механизм сообщений. Каждое сообщение представляет собой набор блоков сообщения, каждый из которых состоит из заголовка, блока данных и буфера данных.
Потоковые драйверы
Как отмечалось в п. 2.7.1, основным назначением механизма потоков (streams) является повышение уровня модульности и гибкости драйверов со сложной внутренней логикой (более всего это относится к драйверам, реализующим развитые сетевые протоколы). Спецификой таких драйверов является то, что большая часть программного кода не зависит от особенностей аппаратного устройства. Более того, часто оказывается выгодно по-разному комбинировать части программного кода.
Все это привело к появлению потоковой архитектуры драйверов, которые представляют собой двунаправленный конвейер обрабатывающих модулей. В начале конвейера (ближе всего к пользовательскому процессу) находится заголовок потока, к которому прежде всего поступают обращения по инициативе пользователя. В конце конвейера (ближе всего к устройству) находится обычный драйвер устройства. В промежутке может располагаться произвольное число обрабатывающих модулей, каждый из которых оформляется в соответствии с обязательным потоковым интерфейсом.
Преимущества и ограничения
О преимуществах подхода бинарной совместимости мы уже сказали в начале этого раздела: привлечение прикладных программистов возможностью использовать свои старые программы без каких-либо переделок и привлечение конечных пользователей возможностью использовать все накопленные приложения.
Ограничением систем, обеспечивающих бинарную совместимость является то, что позволяя использовать старые приложения, они тормозят использование новых качеств системы (а новые системы всегда обладают новыми качествами, в противном случае их было бы бессмысленно делать). Кроме того, в наше время аппаратные архитектуры развиваются так быстро, что их развитие тормозится требованиями аппаратной совместимости платформ одной линии даже на пользовательском уровне. Как кажется (это субъективное мнение автора), в этих условиях достижение бинарной совместимости операционных систем на одной аппаратной платформе становится слишком дорогостоящей и неблагодарной задачей.
Примеры микроядерных реализаций ОС UNIX
Коротко охарактеризуем некоторые варианты ОС UNIX, построенные на основе технологии микроядра.
Принципы обработки прерываний
Конечно, применяемый в операционных системах механизм обработки внутренних и внешних прерываний в основном зависит от того, какая аппаратная поддержка обработки прерываний обеспечивается конкретной аппаратной платформой. К счастью, к настоящему моменту (и уже довольно давно) основные производители компьютеров де-факто пришли к соглашению о базовых механизмах прерываний.
Если говорить не очень точно и конкретно, суть принятого на сегодня механизма состоит в том, что каждому возможному прерыванию процессора (будь то внутреннее или внешнее прерывание) соответствует некоторый фиксированный адрес физической оперативной памяти. В тот момент, когда процессору разрешается прерваться по причине наличия внутренней или внешней заявки на прерывание, происходит аппаратная передача управления на ячейку физической оперативной памяти с соответствующим адресом - обычно адрес этой ячейки называется "вектором прерывания" (как правило, заявки на внутреннее прерывание, т.е. заявки, поступающие непосредственно от процессора, удовлетворяются немедленно).
Дело операционной системы - разместить в соответствующих ячейках оперативной памяти программный код, обеспечивающий начальную обработку прерывания и инициирующий полную обработку.
В основном, ОС UNIX придерживается общего подхода. В векторе прерывания, соответствующем внешнему прерыванию, т.е. прерыванию от некоторого внешнего устройства, содержатся команды, устанавливающие уровень выполнения процессора (уровень выполнения определяет, на какие внешние прерывания процессор должен реагировать незамедлительно) и осуществляющие переход на программу полной обработки прерывания в соответствующем драйвере устройства. Для внутреннего прерывания (например, прерывания по инициативе программы пользователя при отсутствии в основной памяти нужной страницы виртуальной памяти, при возникновении исключительной ситуации в программе пользователя и т.д.) или прерывания от таймера в векторе прерывания содержится переход на соответствующую программу ядра ОС UNIX.
Принципы организации многопользовательского режима
Основной проблемой организации многопользовательского (правильнее сказать, мультипрограммного) режима в любой операционной системе является организация планирования "параллельного" выполнения нескольких процессов. Операционная система должна обладать четкими критериями для определения того, какому готовому к выполнению процессу и когда предоставить ресурс процессора.
Исторически ОС UNIX является системой разделения времени, т.е. система должна прежде всего "справедливо" разделять ресурсы процессора(ов) между процессами, относящимися к разным пользователям, причем таким образом, чтобы время реакции каждого действия интерактивного пользователя находилось в допустимых пределах. Однако в последнее время возрастает тенденция к использованию ОС UNIX в приложениях реального времени, что повлияло и на алгоритмы планирования. Ниже мы опишем общую (без технических деталей) схему планирования разделения ресурсов процессора(ов) между процессами в UNIX System V Release 4.
Наиболее распространенным алгоритмом планирования в системах разделения времени является кольцевой режим (round robin). Основной смысл алгоритма состоит в том, что время процессора делится на кванты фиксированного размера, а процессы, готовые к выполнению, выстраиваются в кольцевую очередь. У этой очереди имеются два указателя - начала и конца. Когда процесс, выполняющийся на процессоре, исчерпывает свой квант процессорного времени, он снимается с процессора, ставится в конец очереди, а ресурсы процессора отдаются процессу, находящемуся в начале очереди. Если выполняющийся на процессоре процесс откладывается (например, по причине обмена с некоторым внешнем устройством) до того, как он исчерпает свой квант, то после повторной активизации он становится в конец очереди (не смог доработать - не вина системы). Это прекрасная схема разделения времени в случае, когда все процессы одновременно помещаются в оперативной памяти.
Однако ОС UNIX всегда была рассчитана на то, чтобы обслуживать больше процессов, чем можно одновременно разместить в основной памяти. Другими словами, часть процессов, потенциально готовых выполняться, размещалась во внешней памяти (куда образ памяти процесса попадал в результате своппинга). Поэтому требовалась несколько более гибкая схема планирования разделения ресурсов процессора(ов). В результате было введено понятие приоритета. В ОС UNIX значение приоритета определяет, во-первых, возможность процесса пребывать в основной памяти и на равных конкурировать за процессор. Во-вторых, от значения приоритета процесса, вообще говоря, зависит размер временного кванта, который предоставляется процессу для работы на процессоре при достижении своей очереди. В-третьих, значение приоритета, влияет на место процесса в общей очереди процессов к ресурсу процессора(ов).
Схема разделения времени между процессами с приоритетами в общем случае выглядит следующим образом. Готовые к выполнению процессы выстраиваются в очередь к процессору в порядке уменьшения своих приоритетов. Если некоторый процесс отработал свой квант процессорного времени, но при этом остался готовым к выполнению, то он становится в очередь к процессору впереди любого процесса с более низким приоритетом, но вслед за любым процессом, обладающим тем же приоритетом. Если некоторый процесс активизируется, то он также ставится в очередь вслед за процессом, обладающим тем же приоритетом. Весь вопрос в том, когда принимать решение о своппинге процесса, и когда возвращать в оперативную память процесс, содержимое памяти которого было ранее перемещено во внешнюю память.
Традиционное решение ОС UNIX состоит в использовании динамически изменяющихся приоритетов. Каждый процесс при своем образовании получает некоторый устанавливаемый системой статический приоритет, который в дальнейшем может быть изменен с помощью системного вызова nice (см. п. 3.1.3). Этот статический приоритет является основой начального значения динамического приоритета процесса, являющегося реальным критерием планирования. Все процессы с динамическим приоритетом не ниже порогового участвуют в конкуренции за процессор (по схеме, описанной выше). Однако каждый раз, когда процесс успешно отрабатывает свой квант на процессоре, его динамический приоритет уменьшается (величина уменьшения зависит от статического приоритета процесса). Если значение динамического приоритета процесса достигает некоторого нижнего предела, он становится кандидатом на откачку (своппинг) и больше не конкурирует за процессор.
Процесс, образ памяти которого перемещен во внешнюю память, также обладает динамическим приоритетом. Этот приоритет не дает процессу право конкурировать за процессор (да это и невозможно, поскольку образ памяти процесса не находится в основной памяти), но он изменяется, давая в конце концов процессу возможность вновь вернуться в основную память и принять участие в конкуренции за процессор. Правила изменения динамического приоритета для процесса, перемещенного во внешнюю память, в принципе, очень просты. Чем дольше образ процесса находится во внешней памяти, тем более высок его динамический приоритет (конкретное значение динамического приоритета, конечно, зависит от его статического приоритета). Конечно, раньше или позже значение динамического приоритета такого процесса перешагнет через некоторый порог, и тогда система принимает решение о необходимости возврата образа процесса в основную память. После того, как в результате своппинга будет освобождена достаточная по размерам область основной памяти, процесс с приоритетом, достигшим критического значения, будет перемещен в основную память и будет в соответствии со своим приоритетом конкурировать за процессор.
Как вписываются в эту схему процессы реального времени? Прежде всего, нужно разобраться, что понимается под концепцией "реального времени" в ОС UNIX. Известно, что существуют по крайней мере два понимания термина - " мягкое реальное время (soft realtime)" и " жесткое реальное время (hard realtime)".
Жесткое реальное время означает, что каждое событие (внутреннее или внешнее), происходящее в системе (обращение к ядру системы за некоторой услугой, прерывание от внешнего устройства и т.д.), должно обрабатываться системой за время, не превосходящее верхнего предела времени, отведенного для таких действий. Режим жесткого реального времени требует задания четких временных характеристик процессов, и эти временные характеристики должны определять поведение планировщика распределения ресурсов процессора(ов) и основной памяти.
Режим мягкого реального времени, в отличие от этого, предполагает, что некоторые процессы (процессы реального времени) получают права на получение ресурсов основной памяти и процессора(ов), существенно превосходящие права процессов, не относящихся к категории процессов реального времени. Основная идея состоит в том, чтобы дать возможность процессу реального времени опередить в конкуренции за вычислительные ресурсы любой другой процесс, не относящийся к категории процессов реального времени. Отслеживание проблем конкуренции между различными процессами реального времени относится к функциям администратора системы и выходит за пределы этого курса.
В своих самых последних вариантах ОС UNIX поддерживает концепцию мягкого реального времени. Это делается способом, не выходящим за пределы основополагающего принципа разделения времени. Как мы отмечали выше, имеется некоторый диапазон значений статических приоритетов процессов. Некоторый поддиапазон этого диапазона включает значения статических приоритетов процессов реального времени. Процессы, обладающие динамическими приоритетами, основанными на статических приоритетах процессов реального времени, обладают следующими особенностями:
Каждому из таких процессов предоставляется неограниченный сверху квант времени на процессоре. Другими словами, занявший процессор процесс реального времени не будет с него снят до тех пор, пока сам не заявит о невозможности продолжения выполнения (например, задав обмен с внешним устройством).
Процесс реального времени не может быть перемещен из основной памяти во внешнюю, если он готов к выполнению, и в оперативной памяти присутствует хотя бы один процесс, не относящийся к категории процессов реального времени (т.е. процессы реального времени перемещаются во внешнюю память последними, причем в порядке убывания своих динамических приоритетов).
Любой процесс реального времени, перемещенный во внешнюю память, но готовый к выполнению, переносится обратно в основную память как только в ней образуется свободная область соответствующего размера. (Выбор процесса реального времени для возвращения в основную память производится на основании значений динамических приоритетов.)
Тем самым своеобразным, но логичным образом в современных вариантах ОС UNIX одновременно реализована как возможность разделения времени для интерактивных процессов, так и возможность мягкого реального времени для процессов, связанных с реальным управлением поведением объектов в реальном времени.
Принципы системной буферизации ввода/вывода
Традиционным способом снижения накладных расходов при выполнении обменов с устройствами внешней памяти, имеющими блочную структуру, является буферизация блочного ввода/вывода. Это означает, что любой блок устройства внешней памяти считывается прежде всего в некоторый буфер области основной памяти, называемой в ОС UNIX системным кэшем, и уже оттуда полностью или частично (в зависимости от вида обмена) копируется в соответствующее пользовательское пространство.
Принципами организации традиционного механизма буферизации является, во-первых, то, что копия содержимого блока удерживается в системном буфере до тех пор, пока не возникнет необходимость ее замещения по причине нехватки буферов (для организации политики замещения используется разновидность алгоритма LRU, см. п. 3.1.1). Во-вторых, при выполнении записи любого блока устройства внешней памяти реально выполняется лишь обновление (или образование и наполнение) буфера кэша. Действительный обмен с устройством выполняется либо при выталкивании буфера вследствие замещения его содержимого, либо при выполнении специального системного вызова sync (или fsync), поддерживаемого специально для насильственного выталкивания во внешнюю память обновленных буферов кэша.
Эта традиционная схема буферизации вошла в противоречие с развитыми в современных вариантах ОС UNIX средствами управления виртуальной памятью и в особенности с механизмом отображения файлов в сегменты виртуальной памяти (см. пп. 2.4.5 и 3.1.2). (Мы не будем подробно объяснять здесь суть этих противоречий и предложим читателям поразмышлять над этим.) Поэтому в System V Release 4 появилась новая схема буферизации, пока используемая параллельно со старой схемой.
Суть новой схемы состоит в том, что на уровне ядра фактически воспроизводится механизм отображения файлов в сегменты виртуальной памяти. Во-первых, напомним о том, что ядро ОС UNIX действительно работает в собственной виртуальной памяти. Эта память имеет более сложную, но принципиально такую же структуру, что и пользовательская виртуальная память. Другими словами, виртуальная память ядра является сегментно-страничной, и наравне с виртуальной памятью пользовательских процессов поддерживается общей подсистемой управления виртуальной памятью. Из этого следует, во-вторых, что практически любая функция, обеспечиваемая ядром для пользователей, может быть обеспечена одними компонентами ядра для других его компонентов. В частности, это относится и к возможностям отображения файлов в сегменты виртуальной памяти.
в ядре ОС UNIX главным
Новая схема буферизации в ядре ОС UNIX главным образом основывается на том, что для организации буферизации можно не делать почти ничего специального. Когда один из пользовательских процессов открывает не открытый до этого времени файл, ядро образует новый сегмент и подключает к этому сегменту открываемый файл. После этого (независимо от того, будет ли пользовательский процесс работать с файлом в традиционном режиме с использованием системных вызовов read и write или подключит файл к сегменту своей виртуальной памяти) на уровне ядра работа будет производиться с тем ядерным сегментом, к которому подключен файл на уровне ядра. Основная идея нового подхода состоит в том, что устраняется разрыв между управлением виртуальной памятью и общесистемной буферизацией (это нужно было бы сделать давно, поскольку очевидно, что основную буферизацию в операционной системе должен производить компонент управления виртуальной памятью).
Почему же нельзя отказаться от старого механизма буферизации? Все дело в том, что новая схема предполагает наличие некоторой непрерывной адресации внутри объекта внешней памяти (должен существовать изоморфизм между отображаемым и отображенным объектами). Однако, при организации файловых систем ОС UNIX достаточно сложно распределяет внешнюю память, что в особенности относится к i-узлам. Поэтому некоторые блоки внешней памяти приходится считать изолированными, и для них оказывается выгоднее использовать старую схему буферизации (хотя, возможно, в завтрашних вариантах UNIX и удастся полностью перейти к унифицированной новой схеме).
Принципы взаимодействия с ядром
В любой операционной системе поддерживается некоторый механизм, который позволяет пользовательским программам обращаться за услугами ядра ОС. В операционных системах наиболее известной советской вычислительной машины БЭСМ-6 соответствующие средства общения с ядром назывались экстракодами, в операционных системах IBM они назывались системными макрокомандами и т.д. В ОС UNIX такие средства называются системными вызовами.
Название не изменяет смысл, который состоит в том, что для обращения к функциям ядра ОС используются "специальные команды" процессора, при выполнении которых возникает особого рода внутреннее прерывание процессора, переводящее его в режим ядра (в большинстве современных ОС этот вид прерываний называется trap - ловушка). При обработке таких прерываний (дешифрации) ядро ОС распознает, что на самом деле прерывание является запросом к ядру со стороны пользовательской программы на выполнение определенных действий, выбирает параметры обращения и обрабатывает его, после чего выполняет "возврат из прерывания", возобновляя нормальное выполнение пользовательской программы.
Понятно, что конкретные механизмы возбуждения внутренних прерываний по инициативе пользовательской программы различаются в разных аппаратных архитектурах. Поскольку ОС UNIX стремится обеспечить среду, в которой пользовательские программы могли бы быть полностью мобильны, потребовался дополнительный уровень, скрывающий особенности конкретного механизма возбуждения внутренних прерываний. Этот механизм обеспечивается так называемой библиотекой системных вызовов.
Для пользователя библиотека системных вызовов представляет собой обычную библиотеку заранее реализованных функций системы программирования языка Си. При программировании на языке Си использование любой функции из библиотеки системных вызовов ничем не отличается от использования любой собственной или библиотечной Си-функции. Однако внутри любой функции конкретной библиотеки системных вызовов содержится код, являющийся, вообще говоря, специфичным для данной аппаратной платформы.
Наиболее важные системные вызовы ОС UNIX рассматриваются в оставшихся разделах этой части курса и в следующей части.
Принципы защиты
Поскольку ОС UNIX с самого своего зарождения задумывалась как многопользовательская операционная система, в ней всегда была актуальна проблема авторизации доступа различных пользователей к файлам файловой системы. Под авторизацией доступа мы понимаем действия системы, которые допускают или не допускают доступ данного пользователя к данному файлу в зависимости от прав доступа пользователя и ограничений доступа, установленных для файла. Схема авторизации доступа, примененная в ОС UNIX, настолько проста и удобна и одновременно настолько мощна, что стала фактическим стандартом современных операционных систем (не претендующих на качества систем с многоуровневой защитой).
Привилегированный пользователь
Ядро ОС UNIX идентифицирует каждого пользователя по его идентификатору (UID - User Identifier), уникальному целому значению, присваиваемому пользователю при регистрации в системе. Кроме того, каждый пользователь относится к некоторой группе пользователей, которая также идентифицируется некоторым целым значением (GID - Group IDentifier). Значения UID и GID для каждого зарегистрированного пользователя сохраняются в учетных файлах системы и приписываются процессу, в котором выполняется командный интерпретатор, запущенный при входе пользователя в систему. Эти значения наследуются каждым новым процессом, запущенным от имени данного пользователя, и используются ядром системы для контроля правомощности доступа к файлам, выполнения программ и т.д.
Понятно, что администратор системы, который, естественно, тоже является зарегистрированным пользователем, должен обладать большими возможностями, чем обычные пользователи. В ОС UNIX эта задача решается путем выделения одного значения UID (нулевого). Пользователь с таким UID называется суперпользователем (superuser) или root. Он имеет неограниченные права на доступ к любому файлу и на выполнение любой программы. Кроме того, такой пользователь имеет возможность полного контроля над системой. Он может остановить ее и даже разрушить.
В мире UNIX считается, что человек, получивший статус суперпользователя, должен понимать, что делает. Суперпользователь должен хорошо знать базовые процедуры администрирования ОС UNIX. Он отвечает за безопасность системы, ее правильное конфигурирование, добавление и исключение пользователей, регулярное копирование файлов и т.д.
Еще одним отличием суперпользователя от обычного пользователя ОС UNIX является то, что на суперпользователя не распространяются ограничения на используемые ресурсы. Для обычных пользователей устанавливаются такие ограничения как максимальный размер файла, максимальное число сегментов разделяемой памяти, максимально допустимое пространство на диске и т.д. Суперпользователь может изменять эти ограничения для других пользователей, но на него они не действуют.
Процессы
Процесс в ОС UNIX - это программа, выполняемая в собственном виртуальном адресном пространстве. Когда пользователь входит в систему, автоматически создается процесс, в котором выполняется программа командного интерпретатора. Если командному интерпретатору встречается команда, соответствующая выполняемому файлу, то он создает новый процесс и запускает в нем соответствующую программу, начиная с функции main. Эта запущенная программа, в свою очередь, может создать процесс и запустить в нем другую программу (она тоже должна содержать функцию main) и т.д.
Управление процессами подробно обсуждается в третьей части курса. Тем не менее кратко опишем здесь общий подход. Для образования нового процесса и запуска в нем программы используются два системных вызова (примитива ядра ОС UNIX) - fork() и exec (имя-выполняемого-файла). Системный вызов fork приводит к созданию нового адресного пространства, состояние которого абсолютно идентично состоянию адресного пространства основного процесса (т.е. в нем содержатся те же программы и данные).
Другими словами, сразу после выполнения системного вызова fork основной и порожденный процессы являются абсолютными близнецами; управление и в том, и в другом находится в точке, непосредственно следующей за вызовом fork. Чтобы программа могла разобраться, в каком процессе она теперь работает - в основном или порожденном, функция fork возвращает разные значения: 0 в порожденном процессе и целое положительное число (идентификатор порожденного процесса) в основном процессе.
Теперь, если мы хотим запустить новую программу в порожденном процессе, нужно обратиться к системному вызову exec, указав в качестве аргументов вызова имя файла, содержащего новую выполняемую программу, и, возможно, одну или несколько текстовых строк, которые будут переданы в качестве аргументов функции main новой программы. Выполнение системного вызова exec приводит к тому, что в адресное пространство порожденного процесса загружается новая выполняемая программа и запускается с адреса, соответствующего входу в функцию main.
В следующем примере пользовательская программа, вызываемая как команда shell, выполняет в отдельном процессе стандартную команду shell ls, которая выдает на экран содержимое текущего каталога файлов.
main()
{if(fork()==0) wait(0); /* родительский процесс */
else execl("ls", "ls", 0); /* порожденный процесс */
}
Проект операционной системы Multics: неудача с положительными последствиями
С 1965 по 1969 год компания Bell Labs совместно с компанией General Electric и группой исследователей из Масачусетского технологического института участвовала в проекте ОС Multics. Целью проекта было создание многопользовательской интерактивной операционной системы, обеспечивающей большое число пользователей удобными и мощными средствами доступа к вычислительным ресурсам. В этом курсе мы не ставим задачу познакомить слушателей с ОС Multics. Это могло бы быть темой отдельного большого курса. Однако отметим хотя бы некоторые идеи, которые содержались в проекте MAC (так назывался проект ОС Multics).
Во-первых, эта система основывалась на принципах многоуровневой защиты. Виртуальная память имела сегментно-страничную организацию, разделялись сегменты данных и сегменты программного кода, и с каждым сегментом связывался уровень доступа (по выполнению для сегментов команд и уровень чтения и записи для сегментов данных). Для того, чтобы какая-либо программа могла вызвать программу или обратиться к данным, располагающимся в некотором сегменте, требовалось, чтобы уровень выполнения этой программы (точнее, сегмента, в котором эта программа содержалась, был не ниже уровня доступа соответствующего сегмента). Такая организация позволяла практически полностью и с полной защитой содержать операционную систему в системных сегментах любого пользовательского виртуального адресного пространства.
Во-вторых, в ОС Multics была спроектирована и реализована полностью централизованная файловая система. В централизованной файловой системе файлы, физически располагающиеся на разных физических устройствах внешней памяти, логически объединяются в один централизованный архив или древовидную иерархическую структуру, промежуточными узлами которой являются именованные каталоги, а в листьях содержатся ссылки на файлы. В том случае, когда при поиске файла в архиве по его имени оказывалось, что соответствующий накопитель (магнитный диск или магнитная лента) не был установлен на устройство внешней памяти, ОС обращалась к оператору с требованием установить нужный том внешней памяти. Естественно, такая дисциплина существенно облегчала операторскую работу и администрирование файловой системы, хотя и затрудняла выполнение таких рутинных действий как перенос части файловой системы с одного компьютера на другой. Позже мы увидим, какой изящный компромисс был выбран при реализации ОС UNIX.
Далее, наличие большой сегментно- страничной виртуальной памяти позволило использовать отображение файлов в сегменты виртуальной памяти. Другими словами, при открытии файла в виртуальной памяти соответствующего процесса образовывался сегмент, в который полностью отображался файл, располагающийся во внешней памяти. (Следует отметить, что в файловой системе ОС Multics на базовом уровне поддерживались файлы со страничной структурой. Более сложные организации являлись надстройкой). Дальнейшая работа с файлом происходила на основе общего механизма управления виртуальной памятью.
Операционная система Multics, хотя и не была полностью доведена до стадии коммерческого продукта, обогатила мировое сообщество системных программистов массой ценных идей, многие из которых сохраняют свою актуальность по сей день и используются применительно не только к операционным системам. Основным недостатком ОС Multics, который, по всей видимости, и помешал довести систему до уровня программного продукта, была ее чрезмерная сложность. Среди участников проекта Multics находились Кен Томпсон и Деннис Ритчи.
Решение о прекращении участия в проекте Multics было принято на самом верхнем уровне руководства Bell Labs, и сотрудники, по существу, были поставлены перед свершившимся фактом. Более того, руководство компании, разочарованное результатами весьма дорогостоящего проекта, вообще не желало больше вести какие-либо работы, связанные с операционными системами.
Программирование на командном языке
Об этом мы уже говорили. Любой из упоминавшихся вариантов языка shell в принципе можно использовать как язык программирования. Среди пользователей ОС UNIX существует много людей, которые пишут на shell вполне серьезные программы. Однако по нашему мнению правильнее использовать shell для того, для чего он изначально предназначен - для непосредственного интерактивного взаимодействия с системой и для создания не очень сложных командных файлов. Для программирования лучше использовать языки программирования (Си, Си++, Паскаль и т.д.), а не командные языки. Хотя оговоримся, что это личное мнение автора, а выбор способа и стиля программирования является сугубо частным делом каждого программиста.
Программные гнезда (Sockets)
Механизм программных гнезд (Sockets) впервые был реализован в 1982 году в UNIX BSD 4.1 в качестве развитого средства межпроцессных взаимодействий. Это средство, вообще говоря, позволяет любому процессу обмениваться сообщениями с любым другим процессом, независимо от того, выполняются они на одном компьютере или на разных, соединенных сетью. Функционально механизм программных гнезд близок к возможностям TLI (пятого уровня в соответствии с моделью ISO/OSI).
Программные гнезда входят в число обязательных компонентов стандартной среды ОС UNIX, однако реализуются в разных системах по-разному. В BSD-ориентированных системах Sockets исторически реализуются в ядре ОС, и пользователям предоставляются пять специальных системных вызовов: socket, bind, listen, connect и accept (подробнее о функциях этих системных вызовов см. п. 3.4.5).
В UNIX System V Release 4 тоже поддерживается механизм программных гнезд, однако он реализован не внутри ядра системы, а в виде набора библиотечных функций (библиотеки /usr/lib/libsocket.a), которые написаны с использованием механизма TLI. Заметим, что это в очередной раз демонстрирует преимущества подхода открытых систем, который всегда поддерживался в мире ОС UNIX: при наличии четко определенных интерфейсов и развитых базовых средств прикладной программист и разработанные им программы не должны зависеть от конкретной реализации.
Тем не менее, разработчики и поставщики System V призывают не использовать механизм Sockets в новых программах, а опираться непосредственно на возможности TLI. По нашему мнению, это дело вкуса, поскольку существует так много давно написанных программ, использующих программные гнезда, что ни один поставщик ОС UNIX никогда не решится перестать поддерживать Sockets.
Взаимодействие процессов на основе программных гнезд основано на модели "клиент-сервер". Процесс-сервер "слушает (listens)" свое программное гнездо, одну из конечных точек двунаправленного пути коммуникаций, а процесс-клиент пытается общаться с процессом-сервером через другое программное гнездо, являющееся второй конечной точкой коммуникационного пути и, возможно, располагающееся на другом компьютере. Ядро поддерживает внутренние соединения и маршрутизацию данных от клиента к серверу.
Программные гнезда с общими коммуникационными свойствами, такими как способ именования и протокольный формат адреса, группируются в домены. Наиболее часто используемыми являются "домен системы UNIX" для процессов, которые взаимодействуют через программные гнезда в пределах одного компьютера, и "домен Internet" для процессов, которые взаимодействуют в сети в соответствии с семейством протоколов TCP/IP (см. п. 2.7.2).
Выделяются два типа программных гнезд - гнезда с виртуальным соединением (в начальной терминологии stream sockets) и датаграммные гнезда (datagram sockets). При использовании программных гнезд с виртуальным соединением обеспечивается передача данных от клиента к серверу в виде непрерывного потока байтов с гарантией доставки. При этом до начала передачи данных должно быть установлено соединение, которое поддерживается до конца коммуникационной сессии. Датаграммные программные гнезда не гарантируют абсолютной надежной, последовательной доставки сообщений и отсутствия дубликатов пакетов данных - датаграмм. Но для использования датаграммного режима не требуется предварительное дорогостоящее установление соединений, и поэтому этот режим во многих случаях является предпочтительным. Система по умолчанию сама обеспечивает подходящий протокол для каждой допустимой комбинации "домен-гнездо". Например, протокол TCP используется по умолчанию для виртуальных соединений, а протокол UDP - для датаграммного способа коммуникаций (информация об этих протоколах представлена в п. 2.7.2).
Для работы с программными гнездами поддерживается набор специальных библиотечных функций (в UNIX BSD это системные вызовы, однако, как мы отмечали в п. 2.7.3, в UNIX System V они реализованы на основе потокового интерфейса TLI). Рассмотрим кратко интерфейсы и семантику этих функций.
Для создания нового программного гнезда используется функция socket:
sd = socket(domain, type, protocol);
где значение параметра domain определяет домен данного гнезда, параметр type указывает тип создаваемого программного гнезда (с виртуальным соединением или датаграммное), а значение параметра protocol определяет желаемый сетевой протокол. Заметим, что если значением параметра protocol является нуль, то система сама выбирает подходящий протокол для комбинации значений параметров domain и type, это наиболее распространенный способ использования функции socket. Возвращаемое функцией значение является дескриптором программного гнезда и используется во всех последующих функциях. Вызов функции close(sd) приводит к закрытию (уничтожению) указанного программного гнезда.
Для связывания ранее созданного программного гнезда с именем используется функция bind:
bind(sd, socknm, socknlen);
Здесь sd - дескриптор ранее созданного программного гнезда, socknm - адрес структуры, которая содержит имя (идентификатор) гнезда, соответствующее требованиям домена данного гнезда и используемого протокола (в частности, для домена системы UNIX имя является именем объекта в файловой системе, и при создании программного гнезда действительно создается файл), параметр socknlen содержит длину в байтах структуры socknm (этот параметр необходим, поскольку длина имени может весьма различаться для разных комбинаций "домен-протокол").
С помощью функции connect процесс-клиент запрашивает систему связаться с существующим программным гнездом (у процесса-сервера):
connect(sd, socknm, socknlen);
Смысл параметров такой же, как у функции bind, однако в качестве имени указывается имя программного гнезда, которое должно находиться на другой стороне коммуникационного канала. Для нормального выполнения функции необходимо, чтобы у гнезда с дескриптором sd и у гнезда с именем socknm были одинаковые домен и протокол. Если тип гнезда с дескриптором sd является датаграммным, то функция connect служит только для информирования системы об адресе назначения пакетов, которые в дальнейшем будут посылаться с помощью функции send; никакие действия по установлению соединения в этом случае не производятся.
Функция listen предназначена для информирования системы о том, что процесс-сервер планирует установление виртуальных соединений через указанное гнездо:
listen(sd, qlength);
Здесь sd - это дескриптор существующего программного гнезда, а значением параметра qlength является максимальная длина очереди запросов на установление соединения, которые должны буферизоваться системой, пока их не выберет процесс-сервер.
Для выборки процессом-сервером очередного запроса на установление соединения с указанным программным гнездом служит функция accept:
nsd = accept(sd, address, addrlen);
Параметр sd задает дескриптор существующего программного гнезда, для которого ранее была выполнена функция listen; address указывает на массив данных, в который должна быть помещена информация, характеризующая имя программного гнезда клиента, со стороны которого поступает запрос на установление соединения; addrlen - адрес, по которому находится длина массива address. Если к моменту выполнения функции accept очередь запросов на установление соединений пуста, то процесс-сервер откладывается до поступления запроса. Выполнение функции приводит к установлению виртуального соединения, а ее значением является новый дескриптор программного гнезда, который должен использоваться при работе через данное соединение. По адресу addrlen помещается реальный размер массива данных, которые записаны по адресу address. Процесс-сервер может продолжать "слушать" следующие запросы на установление соединения, пользуясь установленным соединением.
Для передачи и приема данных через программные гнезда с установленным виртуальным соединением используются функции send и recv:
count = send(sd, msg, length, flags);
count = recv(sd, buf, length, flags);
В функции send параметр sd задает дескриптор существующего программного гнезда с установленным соединением; msg указывает на буфер с данными, которые требуется послать; length задает длину этого буфера. Наиболее полезным допустимым значением параметра flags является значение с символическим именем MSG_OOB, задание которого означает потребность во внеочередной посылке данных. "Внеочередные" сообщения посылаются помимо нормального для данного соединения потока данных, обгоняя все непрочитанные сообщения. Потенциальный получатель данных может получить специальный сигнал и в ходе его обработки немедленно прочитать внеочередные данные. Возвращаемое значение функции равняется числу реально посланных байтов и в нормальных ситуациях совпадает со значением параметра length.
В функции recv параметр sd задает дескриптор существующего программного гнезда с установленным соединением; buf указывает на буфер, в который следует поместить принимаемые данные; length задает максимальную длину этого буфера. Наиболее полезным допустимым значением параметра flags является значение с символическим именем MSG_PEEK, задание которого приводит к переписи сообщения в пользовательский буфер без его удаления из системных буферов. Возвращаемое значение функции является числом байтов, реально помещенных в buf.
Заметим, что в случае использования программных гнезд с виртуальным соединением вместо функций send и recv можно использовать обычные файловые системные вызовы read и write. Для программных гнезд они выполняются абсолютно аналогично функциям send и recv. Это позволяет создавать программы, не зависящие от того, работают ли они с обычными файлами, программными каналами или программными гнездами.
Для посылки и приема сообщений в датаграммном режиме используются функции sendto и recvfrom:
count = sendto(sd, msg, length, flags, socknm, socknlen);
count = recvfrom(sd, buf, length, flags, socknm, socknlen);
Смысл параметров sd, msg, buf и lenght аналогичен смыслу одноименных параметров функций send и recv. Параметры socknm и socknlen функции sendto задают имя программного гнезда, в которое посылается сообщение, и могут быть опущены, если до этого вызывалась функция connect. Параметры socknm и socknlen функции recvfrom позволяют серверу получить имя пославшего сообщение процесса-клиента.
Наконец, для немедленной ликвидации установленного соединения используется системный вызов shutdown:
shutdown(sd, mode);
Вызов этой функции означает, что нужно немедленно остановить коммуникации либо со стороны посылающего процесса, либо со стороны принимающего процесса, либо с обеих сторон (в зависимости от значения параметра mode). Действия функции shutdown отличаются от действий функции close тем, что, во-первых, выполнение последней "притормаживается" до окончания попыток системы доставить уже отправленные сообщения. Во-вторых, функция shutdown разрывает соединение, но не ликвидирует дескрипторы ранее соединенных гнезд. Для окончательной их ликвидации все равно требуется вызов функции close.
Замечание: приведенная в этом пункте информация может несколько отличаться от требований реально используемой вами системы. В основном это относится к символическим именам констант. Постоянная беда пользователей ОС UNIX состоит в том, что от версии к версии меняются символические имена и имена системных структурных типов.
Программные каналы
Как мы уже неоднократно отмечали, традиционным средством взаимодействия и синхронизации процессов в ОС UNIX являются программные каналы (pipes). Теоретически программный канал позволяет взаимодействовать любому числу процессов, обеспечивая дисциплину FIFO (first-in-first-out). Другими словами, процесс, читающий из программного канала, прочитает те данные, которые были записаны в программный канал наиболее давно. В традиционной реализации программных каналов для хранения данных использовались файлы. В современных версиях ОС UNIX для реализации программных каналов применяются другие средства IPC (в частности, очереди сообщений).
Различаются два вида программных каналов - именованные и неименованные. Именованный программный канал может служить для общения и синхронизации произвольных процессов, знающих имя данного программного канала и имеющих соответствующие права доступа. Неименованным программным каналом могут пользоваться только создавший его процесс и его потомки (необязательно прямые).
Для создания именованного программного канала (или получения к нему доступа) используется обычный файловый системный вызов open. Для создания же неименованного программного канала существует специальный системный вызов pipe (исторически более ранний). Однако после получения соответствующих дескрипторов оба вида программных каналов используются единообразно с помощью стандартных файловых системных вызовов read, write и close.
Системный вызов pipe имеет следующий синтаксис:
pipe(fdptr);
где fdptr - это указатель на массив из двух целых чисел, в который после создания неименованного программного канала будут помещены дескрипторы, предназначенные для чтения из программного канала (с помощью системного вызова read) и записи в программный канал (с помощью системного вызова write). Дескрипторы неименованного программного канала - это обычные дескрипторы файлов, т.е. такому программному каналу соответствуют два элемента таблицы открытых файлов процесса. Поэтому при последующем использовании системных вызовов read и write процесс совершенно не обязан отличать случай использования программных каналов от случая использования обычных файлов (собственно, на этом и основана идея перенаправления ввода/вывода и организации конвейеров).
Для создания именованных программных каналов (или получения доступа к уже существующим каналам) используется обычный системный вызов open. Основным отличием от случая открытия обычного файла является то, что если именованный программный канал открывается на запись, и ни один процесс не открыл тот же программный канал для чтения, то обращающийся процесс блокируется (усыпляется) до тех пор, пока некоторый процесс не откроет данный программный канал для чтения (аналогично обрабатывается открытие для чтения). Повод для использования такого режима работы состоит в том, что, вообще говоря, бессмысленно давать доступ к программному каналу на чтение (запись) до тех пор, пока некоторый другой процесс не обнаружит готовности писать в данный программный канал (соответственно читать из него). Понятно, что если бы эта схема была абсолютной, то ни один процесс не смог бы начать работать с заданным именованным программным каналом (кто-то должен быть первым). Поэтому в числе флагов системного вызова open имеется флаг NO_DELAY, задание которого приводит к тому, что именованный программный канал открывается независимо от наличия соответствующего партнера.
Запись данных в программный канал и чтение данных из программного канала (независимо от того, именованный он или неименованный) выполняются с помощью системных вызовов read и write. Отличие от случая использования обычных файлов состоит лишь в том, что при записи данные помещаются в начало канала, а при чтении выбираются (освобождая соответствующую область памяти) из конца канала. Как всегда, возможны ситуации, когда при попытке записи в программный канал оказывается, что канал переполнен, и тогда обращающийся процесс блокируется, пока канал не разгрузится (если только не указан флаг нежелательности блокировки в числе параметров системного вызова write), или когда при попытке чтения из программного канала оказывается, что канал пуст (или в нем отсутствует требуемое количество байтов информации), и тогда обращающийся процесс блокируется, пока канал не загрузится соответствующим образом (если только не указан флаг нежелательности блокировки в числе параметров системного вызова read).
Окончание работы процесса с программным каналом (независимо от того, именованный он или неименованный) производится с помощью системного вызова close. В основном, действия ядра при закрытии программного канала аналогичны действиям при закрытии обычного файла. Однако имеется отличие в том, что при выполнении последнего закрытия канала по записи все процессы, ожидающие чтения из программного канала (т.е. процессы, обратившиеся к ядру с системным вызовом read и отложенные по причине недостатка данных в канале), активизируются с возвратом кода ошибки из системного вызова. (Это совершенно оправданно в случае неименованных программных каналов: если достоверно известно, что больше нечего читать, то зачем заставлять далее ждать чтения. Для именованных программных каналов это решение не является очевидным, но соответствует общей политике ОС UNIX о раннем предупреждении процессов.)
Программы
ОС UNIX одновременно является операционной средой использования существующих прикладных программ и средой разработки новых приложений. Новые программы могут писаться на разных языках (Фортран, Паскаль, Модула, Ада и др.). Однако стандартным языком программирования в среде ОС UNIX является язык Си (который в последнее время все больше заменяется на Си++). Это объясняется тем, что во-первых, сама система UNIX написана на языке Си, а, во-вторых, язык Си является одним из наиболее качественно стандартизованных языков.
Поэтому программы, написанные на языке Си, при использовании правильного стиля программирования обладают весьма высоким уровнем мобильности, т.е. их можно достаточно просто переносить на другие аппаратные платформы, работающие как под управлением ОС UNIX, так и под управлением ряда других операционных систем (например, DEC Open VMS или MS Windows NT). Более подробно мы рассмотрим принципы мобильного программирования в среде ОС UNIX в четвертой части курса.
Приведем краткий обзор процесса разработки программы на языке Си (или Си++), которую можно выполнить в среде ОС UNIX. Любая выполняемая программа компонуется из одного или нескольких объектных файлов. Поэтому разработка программы начинается с создания исходных файлов, содержащих текст на языке Си. Эти файлы могут содержать определения глобальных имен переменных и/или функций (имен, которые могут быть видимы из других файлов), а также ссылки на внешние имена (объявленные как глобальные в одном из других файлов, которые будут составлять программу).
Текстовые файлы производятся с помощью одного из текстовых редакторов, поддерживаемых в среде UNIX. Традиционным текстовым редактором ОС UNIX является упоминавшийся в первом разделе редактор vi, исходная версия которого была разработана Биллом Джоем. Этот редактор достаточно старый, он может работать практически на всех терминалах и не является в полном смысле оконным.
В последние годы все большую популярность получает редактор Emacs (разработанный и непрерывно совершенствуемый президентом Free Software Foundation Ричардом Столлманом). Это очень мощный многооконный редактор, который позволяет не только писать программы (и другие тексты), но также и компилировать, компоновать и отлаживать программы (а также делать многое другое, например, принимать и отправлять электронную почту). Основным недостатком редактора Emacs является исключительно большой набор (более 200) функциональных клавиш. Следует, правда, заметить, что при использовании Emacs в оконной системе X он обеспечивает более удобный интерфейс.
Заметим также, что многие неудобства интерфейсов традиционных инструментальных средств ОС UNIX связаны с тем, что они ориентированы на использование и алфавитно-цифровых, и графических терминалов. Поэтому обычно эти средства поддерживают старомодный строчный интерфейс даже при наличии графического терминала. Естественно, в современных вариантах ОС UNIX все новые инструментальные средства поддерживают оконный графический интерфейс (и, следовательно, их невозможно использовать при наличии алфавитно-цифровых терминалов).
После того, как текстовый файл создан, его нужно откомпилировать для получения объектного файла. Наиболее популярными компиляторами для языка Си в среде ОС UNIX сейчас являются pcc (Ритчи и Томпсон) и gcc (Ричард Столлман). Оба эти компилятора являются полностью мобильными и обладают возможностью генерировать код для разнообразных компьютеров, т.е. эти компиляторы могут быть установлены практически на любой аппаратной платформе под управлением ОС UNIX.
Можно отметить следующие преимущества gcc. Во-первых, этот компилятор свободно, т.е. бесплатно (вместе со своими исходными текстами) распространяется Free Software Foundation. Во-вторых, gcc тщательно поддерживается и сопровождается. В-третьих, начиная с версии 2.0, gcc может компилировать программы, написанные на языках Си, Си++ и Objective C, а результирующая выполняемая программа может быть скомпонована из объектных файлов, полученных из текстовых файлов на любом из этих языков. В-четвертых, открытость исходных текстов gcc и тщательно разработанная структура компилятора позволяют сравнительно просто добавлять к gcc новые кодогенераторы. Относительным недостатком gcc является то, что используемый диалект языка Си включает слишком много расширений по сравнению со стандартом ANSI/ISO (однако имеется режим, в котором компилятор указывает все расширенные конструкции языка, встречающиеся в компилируемой программе).
Оба компилятора обрабатывают программу в два этапа. На первом этапе синтаксически правильный текст на языке Си преобразуется в текст на языке ассемблера. На втором этапе на основе текста на языке ассемблера генерируются машинные коды и получается объектный файл. Исторически в ОС UNIX использовались различные форматы объектных модулей. Для обеспечения совместимости с предыдущими версиями почти все они поддерживаются в современных версиях компиляторов. Однако в настоящее время преимущественно используется формат COFF (Common Object File Format). При желании можно остановить процесс компиляции после первого этапа и получить для изучения файл с текстом программы на языке ассемблера.
После того, как необходимый для построения выполняемой программы набор объектных файлов получен, необходимо произвести компоновку выполняемой программы. В ОС UNIX компоновщик выполняемых программ называется редактором связей (link editor) и обычно вызывается командой ld. Редактору связей указывается набор объектных файлов и набор библиотек, из которых нужно черпать недостающие для компоновки программы.
Процесс компоновки заключается в следующем. Сначала просматривается набор заданных объектных файлов. Для каждого внешнего имени ищется объектный файл, содержащий определение такого же глобального имени. Если поиск заканчивается успешно, то внешняя ссылка заменяется на ссылку на определение глобального имени. Если в конце этого этапа остаются внешние имена, для которых не удалось найти соответствующего определения глобального имени, то начинается поиск объектных файлов с нужными определениями глобальных имен в указанных библиотеках. Если, в конце концов, удается найти определения для всех внешних имен, все соответствующие объектные файлы собираются вместе и образуют выполняемый файл.
В ОС UNIX имеется несколько стандартных библиотек. В большинстве случаев наиболее важной является библиотека ввода/вывода (stdio). Грамотное использование стандартных библиотек способствует созданию легко переносимых прикладных программ (мы вернемся к обсуждению стандартных библиотек ОС UNIX в четвертой части курса).
Выполняемая программа может быть запущена в интерактивном режиме как команда shell или выполнена в отдельном процессе, образуемом уже запущенной программой.
Распределенные файловые системы
Основная идея распределенной файловой системы состоит в том, чтобы обеспечить совместный доступ к файлам локальной файловой системы для процессов, которые, вообще говоря, выполняются на других компьютерах. Эта идея может быть реализована многими разными способами, однако в среде ОС UNIX все известные подходы основываются на монтировании удаленной файловой системы к одному из каталогов локальной файловой системы. После выполнения этой процедуры файлы, хранимые в удаленной файловой системе, доступны процессам локального компьютера точно таким же образом, как если бы они хранились на локальном дисковом устройстве.
На рисунке 2.5 приведен пример, в котором два подкаталога удаленной файловой системы-сервера (share и X11) монтируются к двум (пустым) каталогам файловой системы-клиента.
Рис. 2.5. Схема монтирования удаленной файловой системы
В принципе, такая схема обладает и достоинствами, и недостатками. К достоинствам, конечно, относится то, что при работе в сети можно экономить дисковое пространство, поддерживая совместно используемые информационные ресурсы только в одном экземпляре. Но, с другой стороны, пользователи удаленной файловой системы неизбежно будут работать с удаленными файлами существенно более медленно, чем с локальными. Кроме того, реальная возможность доступа к удаленному файлу критически зависит от работоспособности сервера и сети. Заметим, что распространенные в мире UNIX сетевые файловые системы NFS (Network File System - сетевая файловая система) и RFS (Remote File Sharing - совместное использование удаленных файлов) являются достаточно тщательно спроектированными и разработанными продуктами, во многом сглаживающими отмеченные недостатки.
Разделяемая память
Для работы с разделяемой памятью используются четыре системных вызова:
shmget создает новый сегмент разделяемой памяти или находит существующий сегмент с тем же ключом;
shmat подключает сегмент с указанным дескриптором к виртуальной памяти обращающегося процесса;
shmdt отключает от виртуальной памяти ранее подключенный к ней сегмент с указанным виртуальным адресом начала;
наконец, системный вызов shmctl служит для управления разнообразными параметрами, связанными с существующим сегментом.
После того, как сегмент разделяемой памяти подключен к виртуальной памяти процесса, этот процесс может обращаться к соответствующим элементам памяти с использованием обычных машинных команд чтения и записи, не прибегая к использованию дополнительных системных вызовов.
Синтаксис системного вызова shmget выглядит следующим образом:
shmid = shmget(key, size, flag);
Параметр size определяет желаемый размер сегмента в байтах. Далее работа происходит по общим правилам. Если в таблице разделяемой памяти находится элемент, содержащий заданный ключ, и права доступа не противоречат текущим характеристикам обращающегося процесса, то значением системного вызова является дескриптор существующего сегмента (и обратившийся процесс так и не узнает реального размера сегмента, хотя впоследствии его все-таки можно узнать с помощью системного вызова shmctl). В противном случае создается новый сегмент с размером не меньше установленного в системе минимального размера сегмента разделяемой памяти и не больше установленного максимального размера. Создание сегмента не означает немедленного выделения под него основной памяти. Это действие откладывается до выполнения первого системного вызова подключения сегмента к виртуальной памяти некоторого процесса. Аналогично, при выполнении последнего системного вызова отключения сегмента от виртуальной памяти соответствующая основная память освобождается.
Подключение сегмента к виртуальной памяти выполняется путем обращения к системному вызову shmat:
virtaddr = shmat(id, addr, flags);
Здесь id - это ранее полученный дескриптор сегмента, а addr - желаемый процессом виртуальный адрес, который должен соответствовать началу сегмента в виртуальной памяти. Значением системного вызова является реальный виртуальный адрес начала сегмента (его значение не обязательно совпадает со значением прямого параметра addr). Если значением addr является нуль, ядро выбирает наиболее удобный виртуальный адрес начала сегмента. Кроме того, ядро старается обеспечить (но не гарантирует) выбор такого стартового виртуального адреса сегмента, который обеспечивал бы отсутствие перекрывающихся виртуальных адресов данного разделяемого сегмента, сегмента данных и сегмента стека процесса (два последних сегмента могут расширяться).
Для отключения сегмента от виртуальной памяти используется системный вызов shmdt:
shmdt(addr);
где addr - это виртуальный адрес начала сегмента в виртуальной памяти, ранее полученный от системного вызова shmat. Естественно, система гарантирует (на основе использования таблицы сегментов процесса), что указанный виртуальный адрес действительно является адресом начала (разделяемого) сегмента в виртуальной памяти данного процесса.
Системный вызов shmctl:
shmctl(id, cmd, shsstatbuf);
содержит прямой параметр cmd, идентифицирующий требуемое конкретное действие, и предназначен для выполнения различных функций. Видимо, наиболее важной является функция уничтожения сегмента разделяемой памяти. Уничтожение сегмента производится следующим образом. Если к моменту выполнения системного вызова ни один процесс не подключил сегмент к своей виртуальной памяти, то основная память, занимаемая сегментом, освобождается, а соответствующий элемент таблицы разделяемых сегментов объявляется свободным. В противном случае в элементе таблицы сегментов выставляется флаг, запрещающий выполнение системного вызова shmget по отношению к этому сегменту, но процессам, успевшим получить дескриптор сегмента, по-прежнему разрешается подключать сегмент к своей виртуальной памяти. При выполнении последнего системного вызова отключения сегмента от виртуальной памяти операция уничтожения сегмента завершается.
Разновидности файлов
Как мы неоднократно отмечали, в ОС UNIX понятие файла является универсальной абстракцией, позволяющей работать с обычными файлами, содержащимися на устройствах внешней памяти; с устройствами, вообще говоря, отличающимися от устройств внешней памяти; с информацией, динамически генерируемой другими процессами и т.д. Для поддержки этих возможностей единообразным способом файловые системы ОС UNIX поддерживают несколько типов файлов, наиболее существенные из которых мы рассмотрим в этом разделе.