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

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

Глава 5

Подсистема ввода/вывода

 

 

Фактическая архитектура ввода/вывода скрыта от прикладного процесса несколькими интерфейсами. Один из них — интерфейс файловой системы был рассмотрен в предыдущей главе. Взаимодействие с удаленными ресурсами обеспечивается сетевыми интерфейсами сокетов или TLI (Transport Layer Interface), которые описываются в главе 6. Однако возможны ситуации, когда прикладному процессу требуется взаимодействие с периферийными устройствами на более низком уровне. Хотя в этом случае роль файловой подсистемы не столь велика, как при работе с обычными файлами, все равно ядро предоставляет процессу унифицированную схему, скрывающую истинную архитектуру того или иного устройства.

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

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

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

 

Драйверы устройств

 

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

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

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

 

Типы драйверов

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

Символьные драйверы Этот тип драйверов обеспечивает работу с устройствами с побайтовым доступом и обменом данными. К таким устройствам можно отнести модемы, терминалы, принтеры, манипуляторы мышь и т.д. Доступ к таким драйверам не включает использование буферного кэша, таким образом ввод и вывод как правило не буферизуется. При необходимости буферизации для символьных драйверов обычно используется подход, основанный на структурах данных, называемых clist.
Блочные драйверы Этот тип драйверов позволяет производить обмен данными с устройством фиксированными порциями (блоками). Например, для жесткого диска данные можно адресовать и, соответственно, читать только секторами, размер которых составляет несколько сотен байтов. Для блочных драйверов обычно используется буферный кэш, который и является интерфейсом между файловой системой и устройством. Хотя операции чтения и записи для процесса допускают обмен данными, размер которых меньше размера блока, на системном уровне это все равно приводит к считыванию всего блока, изменению части его данных и записи измененного блока обратно на диск.
Драйверы низкого уровня (raw drivers) Этот тип интерфейса блочных драйверов позволяет производить обмен данными с блочными устройствами, минуя буферный кэш. Это, в частности, означает, что устройство может быть адресовано элементами, размер которых не совпадает с размером блока. Обмен данными происходит независимо от файловой подсистемы и буферного кэша, что позволяет ядру производить передачу непосредственно между пользовательским процессом и устройством, без дополнительного копирования.

На рис. 5.1 приведена упрощенная схема взаимодействия драйверов устройств с другими подсистемами операционной системы UNIX.

Рис. 5.1. Драйверы устройств UNIX

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

/dev/kmem Обеспечивает доступ к виртуальной памяти ядра. Зная виртуальные адреса внутренних структур ядра, процесс может считывать хранящуюся в них информацию. С помощью этого драйвера может, например, быть реализована версия утилиты ps(1) , выводящей информацию о состоянии процессов в системе.
/ dev/ksyms Обеспечивает доступ к разделу исполняемого файла ядра, содержащего таблицу символов. Совместно с драйвером /dev/kmem обеспечивает удобный интерфейс для анализа внутренних структур ядра.
/dev/mem Обеспечивает доступ к физической памяти компьютера.
/dev/null Является "нулевым" устройством . При записи в это устройство данные просто удаляются, а при чтении процессу возвращается 0 байтов. Примеры использования этого устройства рассматривались в главе 1, когда с помощью /dev/null мы подавляли вывод сообщений об ошибках.
/dev/zero Обеспечивает заполнение нулями указанного буфера. Этот драйвер часто используется для инициализации области памяти.

 

Базовая архитектура драйверов

Драйвер устройства адресуется старшим номером (major number) устройства. Напомним, что среди атрибутов специальных файлов устройств, которые обеспечивают пользовательский интерфейс доступа к периферии компьютера, это число присутствует наряду с другим, также имеющим отношение к драйверу, — младшим номером (minor number). Младший номер интерпретируется самим драйвером (например, для клонов, оно задает старшее число устройства, которое требуется "размножить"). Другим примером использования младших номеров может служить драйвер диска. В то время как доступ к любому из разделов диска осуществляется одним и тем же драйвером и, соответственно, через один и тот же старший номер, младший номер указывает, к какому именно разделу требуется обеспечить доступ.

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

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

Типичное описание этих двух массивов имеет следующий вид (назначение различных точек входа мы рассмотрим далее в этом разделе):

struct bdevsw[] {

 int (*d_open)();

 int (*d_close)();

 int (*d_strategy)();

 int (*d_size)();

 int (*d_xhalt)();

 ...

} bdevsw[];

struct cdevsw[] {

 int (*d_open)();

 int (*d_close)();

 int (*d_read)();

 int (*d_write)();

 int (*d_ioctl)();

 int (*d_xpoll)();

 int (*d_xhalt)();

 struct streamtab *d_str;

 ...

} cdevsw[];

Ядро вызывает функцию open() требуемого драйвера следующим образом:

(*bdevsw[getmajor(dev)].d_open)(dev, ...);

передавая ей в качестве одного из параметров переменную dev (типа dev_t), содержащую старший и младший номера. Макрос getmajor() служит для извлечения старшего номера из переменной dev. Благодаря этому драйвер имеет возможность определить, с каким младшим номером была вызвана функция open(), и выполнить соответствующие действия.

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

В названиях точек входа драйвера используются определенные соглашения. Поскольку в ядре системы одновременно присутствует большое количество различных драйверов, каждый их них должен иметь уникальное имя во избежание проблем при компиляции (точнее, при редактировании связей) ядра. Каждый драйвер имеет уникальное двухсимвольное обозначение, используемое в качестве префикса названий функций. Например, драйвер виртуальной памяти ядра /dev/kmem имеет префикс mm, таким образом функции этого драйвера будут иметь названия mmopen(), mmclose(), mmread() и mmwrite().

В табл. 5.1 приведены некоторые точки входа, общие для различных типов драйверов, а символами xx, с которых начинается имя каждой функции, обозначен уникальный префикс драйвера. Стандартные точки входа драйвера отличаются для разных версий UNIX. Например, некоторые версии имеют расширенный коммутатор блочных устройств, включающий такие функции, как xx ioctl() , xx read() и xx write() . В некоторых версиях включены точки входа для инициализации и сброса шины данных.

Таблица 5.1. Типичные точки входа в драйвер устройства

Точка входа Сим- вольный Блочный Низкого уровня Назначение
xx open() + + + Вызывается при каждой операции открытии устройства. Обеспечивает необходимую реинициализацию физического устройства и внутренних данных драйвера. Например, для каждого последующего открытия драйвера могут размещаться дополнительные буферы, обеспечивающие возможность независимой работы с устройством нескольким процессам.
xx close() + + + Вызывается, когда число ссылок на данный драйвер становится равным нулю, т. е. ни один из процессов системы не работает с устройством (не имеет открытым соответствующий файл устройства). Может вызывать отключение физического устройства. Например, драйвер накопителя на магнитной ленте может перемотать ленту в начало.
xx read() + - + Производит чтение данных от устройства.
xx write() + - + Производит запись данных на устройство.
xx ioctl() + - + Является общим интерфейсом управления устройством. Драйвер может определить набор команд, которые могут быть переданы ему, например с помощью системного вызова ioctl(2) .
xx intr() + + + Вызывается при поступлении прерывания, связанного с данным устройством. Может выполнить копирование данных от устройства в промежуточные буферы, которые затем считываются функцией xx read() по запросу прикладного процесса.
xx poll() + - + Производит опрос устройства. Обычно используется для устройств, не поддерживающих прерывания, например, для определения поступления данных для чтения.
xx halt() + + + Вызывается для останова драйвера при останове системы или при выгрузке драйвера.
xx strategy() - + + Общая точка входа для операций блочного ввода/вывода. Название функции говорит о том, что устройство может обеспечивать собственную стратегию обработки поступающих запросов, например, изменять их порядок для повышения производительности ввода/вывода. Если устройство занято, функция помещает запросы в очередь. В этом случае фактический ввод/вывод инициирует функция обработки прерывания, которая вызывается, когда устройство закончит предыдущую операцию ввода/вывода.
xxprint() - + + Выводит сообщение драйвера на консоль, обычно при запуске системы.

Ядро вызывает те или иные функции драйвера в зависимости от запроса. Например, если процесс выполняет системный вызов read(2) для специального файла символьного устройства, ядро вызовет функцию xx read() для соответствующего символьного драйвера. Если же процесс запрашивает ту же операцию для обычного дискового файла, ядро вызовет процедуру xx strategy() для блочного драйвера, обслуживающего данную файловую систему.

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

□ Автоконфигурация. Обычно происходит в процессе инициализации UNIX, когда ядро определяет, какие устройства доступны в системе.

□ Ввод/вывод. Запрос на операцию ввода/вывода может быть инициирован как прикладным процессом, так и некоторыми подсистемами ядра, например, подсистемой управления памятью.

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

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

□ Реинициализация/Останов. Некоторые типы аппаратных архитектур могут требовать сброса и реинициализации устройства. Определенные функции драйвера также вызываются при останове операционной системы.

На рис. 5.2 и 5.3 приведены схемы доступа к драйверам символьного и блочного устройств.

Рис. 5.2. Доступ к драйверу символьного устройства

Рис. 5.3. Доступ к драйверу блочного устройства

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

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

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

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

□ Если функция вызывается в процессе обработки прерывания, то она имеет контекст прерывания — специальный вид системного контекста. Функции драйвера, отвечающие за обработку прерывания, например xx intr() имеют этот тип контекста.

Различия в контексте и причинах вызова тех или иных функций драйвера позволяют представить драйвер устройства состоящим из двух частей: верхней части (top half) и нижней части (bottom half). Функции верхней части драйвера имеют синхронный характер, т.е. вызываются по определенным запросам прикладного процесса и выполняются в его контексте. Таким образом, для этих функций доступно адресное пространство и u-area процесса, и при необходимости эти функции могут перевести процесс в состояние сна (вызовом функции sleep() ядра). Функции ввода/вывода и управления принадлежат верхней части драйвера.

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

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

Все представленные выше функции, за исключением xx halt() , xx poll() и xx intr() , принадлежат верхней части драйвера. Функция xx halt() вызывается ядром при останове системы и, таким образом, имеет системный контекст, не связанный с контекстом прикладного процесса.

Функция xx poll() обычно вызывается при обработке ядром прерывания таймера для всех устройств, указанных как опрашиваемые. Это необходимо, в частности, для устройств, которые не могут или "не хотят" использовать аппаратные прерывания. Вместо этого xx poll() может использоваться для эмуляции прерываний, например вызывая функцию xx intr() на каждый n-ный тик системного таймера. Поэтому и функция xx poll() и функция обработки прерывания xx intr() не могут рассчитывать на контекст прикладного процесса. В большинстве версий UNIX функции опроса и обработки прерываний вызываются не через коммутатор устройств, а через специальные таблицы ядра.

В UNIX SVR4 определены две дополнительные точки входа — init() и start(). Драйвер регистрирует эти функции в таблицах ядра io_init[] и io_start[]. Код начальной загрузки системы запускает функции xx init() перед инициализацией ядра, а функции xx start() сразу же после инициализации.

 

Файловый интерфейс

В главе 4 мы рассмотрели интерфейс т.н. независимой или виртуальной файловой системы, обеспечивающей унифицированный интерфейс работы с различными типами физических файловых систем (например, ufs или s5fs), имеющих разные внутренние структуры и возможности. При этом подходе используется унифицированный формат метаданных активных файлов, которые хранятся в памяти (в in-core — таблице индексных дескрипторов) и не зависят от конкретной реализации файловой системы. Эти объекты получили название виртуальных индексных дескрипторов или vnode. Для каждого vnode определен набор абстрактных операций, которые реализованы функциями реальных файловых систем. Например, vnode файла, расположенного в файловой системе s5fs, адресует вектор операций (или коммутатор файловых систем, FSS) s5fsops, содержащий конкретные функции этой файловой системы — s5fs_close(), s5fs_open() или s5fs_ulink().

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

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

Современные системы ветви System V используют для этого специальный тип файловой системы, называемый devfs или specfs. Для этого типа файловой системы все операции vnode адресуют соответствующие функции требуемого элемента коммутатора устройств. После первоначального открытия файла, когда создается vnode, все запросы, связанные со специальным файлом устройства, проходят через vnode файловой системы specfs.

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

Решение данной проблемы рассмотрим на конкретном примере. Допустим, процесс вызывает функцию open(2) для специального файла устройства /dev/kmem для работы с виртуальной памятью ядра. Функция трансляции имени файловой системы ufs — ufs_lookup() сначала откроет inode файла /dev, а затем, прочитав каталог, обнаружит inode файла kmem, при этом будет размещен vnode этого файла. Однако ufs_lookup() определит, что тип этого файла IFCHR, т.е. специальный файл символьного устройства. Поэтому вместо функции ufs_open(), бессмысленной для этого типа файла, будет вызвана специальная функция файловой системы specfs, которая создаст собственный индексный дескриптор, описываемой структурой snode (от special inode), для этого файла, если таковой уже не находится в памяти. Согласно стандартной процедуре, также будет создан и виртуальный индексный дескриптор vnode, который будет указывать на вектор операций specops, которые специально предназначены для работы с драйверами устройств. Например, функции spec_open(), spec_read() или spec_write() в свою очередь вызовут соответствующие точки входа драйвера — функции xx open() , xx read() или xx write() . После этого функции ufs_open() будет передан адрес этого vnode, который она, в свою очередь, передаст системному вызову open(2). В результате, open(2) вернет процессу файловый дескриптор, адресующий vnode файловой системы specfs, а не vnode файла /dev/kmem. Таким образом, все дальнейшие операции с /dev/kmem будут перехватываться файловой системой specfs. Схема связи процесса с этим vnode приведена на рис. 5.4.

Рис. 5.4. Связь процесса с файлом /dev/kmem после его открытия

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

Для решения этой проблемы файловая система specfs предусматривает наличие дополнительного snode, позволяющего контролировать доступ к конкретному устройству. Этот объект, получивший название общего snode (common snode), является единственным интерфейсом доступа к драйверу устройства. Для каждого устройства (драйвера устройства) существует единственный common snode, который создается при первом доступе к устройству. Каждый специальный файл устройства, в свою очередь, имеет собственный snode в файловой системе specfs и соответствующий ему vnode, а также inode физической файловой системы, где расположен специальный файл устройства, и соответствующий ему vnode.

Для связи всех этих индексных дескрипторов между собой snode имеет два поля: s_commonvp, указывающее на common snode, и s_realvp, указывающее на vnode специального файла устройства файловой системы, где расположен последний.

Использование тех или иных vnode и связанных с ними inode или snode зависит от конкретных операций, выполняемых процессом с устройством. Большинство из этих операций не зависят от имени специального файла устройства и, соответственно, от реальной файловой системы, в которой он расположен. Эти операции выполняются через vnode, соответствующий common snode. Однако существует ряд операций, выполнение которых зависит от конкретного специального файла устройства, через который процесс взаимодействует с драйвером. Примером может служить проверка прав доступа при открытии специального файла устройства, которые расположены в vnode/inode реальной файловой системы. В этом случае используется vnode соответствующего специального файла устройства.

Схема описанной архитектуры приведена на рис. 5.5.

Рис. 5.5. Доступ к устройству через различные специальные файлы

 

Клоны

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

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

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

Когда процесс открывает специальный файл устройства, происходит инициализация соответствующего snode и вызов функции spec_open(), реализованной в файловой системе specfs, о которой только что говорилось. Эта функция, в свою очередь, вызывает функцию драйвера xx open() , передавая ей в качестве аргумента указатель на номера устройства, сохраненного в поле s_dev snode. Одной из схем реализации клонов является использование зарезервированного младшего номера. Когда процесс открывает специальный файл устройства с этим номером, функция xx open() выбирает неиспользуемый младший номер и соответственно модифицирует данные snode (с помощью указателя на vnode, передаваемые ей spec_open()). Поскольку доступ процесса к драйверу осуществляется через vnode файловой системы specfs, все последующие операции будут использовать новый младший номер. Таким образом, процесс получит доступ к новому логическому устройству. Эта схема приведена на рис. 5.6.

Рис. 5.6. Создание клонов с помощью зарезервированного младшего номера

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

$ ls -l

...

crw-rw-rw- 1 root sys 11, 44 Oct 31 16:36 arp

crw------- 1 root sys 11,  5 Oct 31 16:36 icmp

crw-rw---- 1 root sys 11,  3 Nov  3  1995 ip

crw------- 1 root sys 11, 40 Nov  3  1995 le

crw-rw-rw- 1 root sys 11, 42 Oct 31 16:36 tcp

crw-rw-rw- 1 root sys 11, 41 Nov  3  1995 udp

...

В данном случае старший номер всех драйверов равен 11 — это драйвер клонов. Если проанализировать информацию файла, скажем, tcp, то станет понятно, что старший номер драйвера этого протокола равен 42, для файла tcp он представлен младшим номером устройства. Когда процесс открывает этот файл, производится вызов функции clopen() драйвера клонов, которой передаются номера устройства. Функция clopen() использует младший номер для поиска требуемых точек входа драйвера TCP в коммутаторе устройств cdevswf[]. После этого clopen() вызывает процедуру xx open() драйвера, в данном случае tcpopen(), передавая ей указатель на номера устройства и флаг CLONEOPEN. В ответ на это tcpopen() генерирует неиспользуемый младший номер, создает отдельный логический драйвер (т.е. копирует необходимые структуры данных) и соответствующим образом модифицирует поле s_dev индексного дескриптора файловой системы specfs. Таким образом, для получения уникального TCP-соединения процессу нет необходимости самостоятельно производить поиск неиспользуемого младшего номера.

 

Встраивание драйверов в ядро

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

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

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

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

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

□ Инициализация драйвера и устройства.

□ Добавление точек входа драйвера в соответствующий коммутатор устройств.

□ Установка обработчика прерываний драйвера.

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

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

_init() Функция инициализации и установки, вызываемая при загрузке драйвера
_fini() Функция, вызываемая перед выгрузкой драйвера, удаляющая его из системы
_infо() Функция, возвращающая информацию о драйвере по запросу ядра
struct modlinkage Структура, используемая функциями _init() , _fini() и _info() при загрузке, выгрузке и получении информации о драйвере
struct modldrv Структура, экспортируемая ядру при загрузке драйвера, в частности, содержит адреса точек входа в драйвер

Помимо этого Solaris 2.5 предоставляет ряд функций ядра для работы с динамически загружаемыми драйверами: mod_install(9F), mod_remove(9F) и mod_info(9F).

 

Блочные устройства

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

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

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

□ Чтением или записью в обычный файл.

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

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

Доступ к блочным устройствам осуществляется с помощью трех основных точек входа: xx open() , xx close() и xx strategy() . При этом за фактическое выполнение ввода/вывода отвечает xx strategy() . Единственным аргументом, передаваемым этой функции, является указатель на структуру buf, представляющую собой заголовок буфера обмена, с которой мы уже встречались в предыдущей главе при разговоре о буферном кэше. Структура buf содержит всю необходимую для операций ввода/вывода информацию. Основные поля структуры buf:

b_flags Флаги. Определяют состояние буфера (например, B_BUSY или B_DONE ) и направление передачи данных ( B_READ , B_WRITE , B_PHYS )
av_back , av_forw Указатели двухсвязного рабочего списка буферов, ожидающих обработки драйвером
b_bufsize Размер буфера
b_un.b_addr Виртуальный адрес буфера
b_blkno Номер блока начала данных на устройстве
b_bcount Число байтов, которые требуется передать
b_dev Старший и младший номера устройства

Использование заголовка buf при передачи блока данных показано на рис. 5.7.

Рис. 5.7. Обмен данными с блочным устройством (диском)

Ядро адресует дисковый блок, указывая vnode и смещение. Если доступ осуществляется к специальному файлу устройства, то смещение является физическим, отсчитываемым от начала устройства. Например, если специальный файл устройства /dev/dsk/c0t0d0s1 обеспечивает доступ ко второму разделу жесткого диска, то смещение будет отсчитываться от начала этого раздела. Если vnode представляет обычный файл, то смещение является логическим, отсчитываемым от начала файла.

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

Поскольку каждый дисковый блок связан с каким-либо файлом и соответственно с его vnode, а его образ в памяти — с физическими страницами, которые также связаны с vnode (через структуры описания физической памяти — page в SVR4, pfdat в SVR3), все операции ввода/вывода связаны с подкачкой и сохранением страниц и идентифицируются vnode.

 

Символьные устройства

 

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

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

Если процесс сделал системный вызов ввода/вывода, например, read(2) или write(2) со специальным файлом символьного устройства, запрос направляется в файловую подсистему. Поскольку доступ к устройству обслуживается файловой системой specfs, рассмотренной ранее, в ответ на выполнение системного вызова процесса ядро выполняет вызов функции spec_read() или spec_write() соответственно для read(2) или write(2). Действия функций spec_read() и spec_write() похожи. Обе проверяют тип vnode и определяют, что устройство является символьным. После этого с помощью коммутатора ядро выбирает соответствующую точку входа драйвера, используя старший номер, хранящийся в поле v_rdev vnode, и вызывает эту функцию (соответственно xx read() или xx write() ), передавая ей в качестве параметров старший и младший номера, ряд дополнительных параметров, зависящих от конкретного вызова, а также явно или неявно адресует область копирования данных в адресном пространстве процесса.

 

Интерфейс доступа низкого уровня

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

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

Интерфейс доступа низкого уровня используется многими системными утилитами обслуживания файловой системы, например, fsck(1M), а также рядом приложений, работающих с накопителями на магнитной ленте, например tar(1) или cpio(1). Этот интерфейс используется некоторыми приложениями, например СУБД, которые самостоятельно обеспечивают оптимизированные механизмы кэширования данных на уровне задачи.

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

Рис. 5.8. Различные типы доступа к блочным устройствам

 

Буферизация

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

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

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

Буферизация данных для символьных устройств осуществляется с помощью специальных структур данных, называемых clist. Каждая структура clist имеет следующие поля:

int           c_cc;

struct cblock *с_cf;

struct cblock *c_cl;

Поле с_cc содержит число символов в буфере cblock. Поля c_cf и c_cl указывают, соответственно, на первый и последний элементы cblock, организованные в виде связанного списка и фактически обеспечивающие буферы хранения данных. Каждая структура cblock может хранить несколько символов. Когда буфер хранения заполняется, ядро автоматически выделяет новую структуру cblock и помещает ее в связанный список. Поля структуры cblock и их использование приведены на рис. 5.9.

Рис. 5.9. Буферизация данных с помощью clist

Пример буферизации с использованием структуры clist в драйвере терминала показан на рис. 5.10.

Рис. 5.10. Пример использования буферов clist в драйвере терминала

 

Архитектура терминального доступа

 

Алфавитно-цифровой терминал — последовательное устройство, и операционная система производит обмен данными с терминалом через последовательный интерфейс, называемый терминальной линией. С каждой терминальной линией в UNIX ассоциирован специальный файл символьного устройства /dev/ttyxx.

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

Существует два режима терминального ввода/вывода:

1. Канонический режим. В этом режиме ввод с терминала обрабатывается в виде законченных строк.

2. Неканонический режим, при котором ввод не интерпретируется.

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

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

В функции модуля дисциплины линии входят:

1. Построчный разбор введенных последовательностей.

2. Обработка символов стирания.

3. Обработка символов удаления, отменяющих всех предыдущих символов.

4. Отображение символов, полученных терминалом.

5. Расширение выходных данных, например, преобразование символов табуляции в последовательности пробелов.

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

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

 

Псевдотерминалы

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

Рис. 5.11. Взаимодействие процессов с помощью псевдотерминала

Ярким примером использования псевдотерминалов является регистрация в системе по сети с использованием серверов удаленного доступа rlogin(1) или telnet(1), или использование графического эмулятора терминала xterm в системе X Window System. Когда пользователь регистрируется в системе подобным образом, псевдотерминал эмулирует обычную терминальную линию, поэтому пользователь не видит различия между удаленной и локальной работой с помощью терминала, подключенного по последовательной линии. Например, пользователь может установить различные режимы обработки и использовать соответствующие комбинации клавиш для генерации сигналов, как он это делает в случае обычного терминала.

Псевдотерминал по существу представляет собой два отдельных драйвера. Один из них выглядит как обычный терминальный драйвер и носит название подчиненного устройства (slave). Второй драйвер называется основным (master).

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

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

Пользователь удаленной системы запускает программу удаленного доступа rlogin(1), которая формирует запрос и передает его по сети на требуемый компьютер. Там этот запрос доставляется серверу удаленного доступа rlogind(1), который (после надлежащей проверки) запускает программу login(1). При этом стандартные потоки ввода, вывода и вывода ошибок программы login(1) связываются не с терминальным файлом, как в случае входа в систему с помощью сервера getty(1M), а с подчиненным устройством псевдотерминала. Основное же устройство оказывается связанным с сервером rlogind(1). Программа login(1) запрашивает имя пользователя и его пароль точно так же, как она это делает при входе через getty(1M). Более того, login(1) и "не представляет", что на самом деле работает с эмулятором терминала, а не с традиционной терминальной линией. Весь ввод login(1) поступает серверу rlogind(1) и затем передается по сети клиентской части rlogin(1) на удаленном компьютере. Далее работа ничем не отличается от работы локального пользователя, подключенного к системе с помощью обыкновенного терминала или консоли. Если имя пользователя и пароль были введены правильно, программа login(1) запустит требуемый командный интерпретатор (login shell), который также не заметит подмены. Действительно, по всем характеристикам терминал будет неотличим от традиционной последовательной линии, включая различные установки и генерацию сигналов при нажатии определенных клавиш клавиатуры. Следует, правда, оговориться, что поскольку псевдотерминал не является "полноценным" терминальным устройством, часть установок для него не имеют смысла (например, скорость передачи, четность и т.д.) и просто игнорируются.

На рис. 5.12 приведена схема работы удаленного пользователя в системе с использованием псевдотерминала.

Рис. 5.12. Архитектура удаленного доступа с использованием псевдотерминала

 

Подсистема STREAMS

 

Архитектура подсистемы потокового ввода/вывода STREAMS впервые была описана в статье Ритчи "Потоковая система ввода/вывода" (Ritchie, D.M., "A Stream Input-Output System", AT&T Bell Laboratories Technical Journal, Vol. 63, No. 8, Oct. 1984) в 1984 году. Двумя годами позднее эта система была реализована в коммерческой версии UNIX SVR3.

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

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

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

Наконец, необходимость поддержки сетевых протоколов, большинство из которых имеют уровневую организацию, требует соответствующей архитектуры подсистемы ввода/вывода. Передача сетевых данных производится в виде пакетов или сообщений, при этом каждый уровень сетевого протокола производит определенную обработку и передает их другому уровню. Каждый уровень имеет стандартные интерфейсы взаимодействия с другими (верхним и нижним уровнями) и при этом может работать с различными протоколами верхнего и нижнего уровней. Например, протокол IP (уровень 3 модели OSI) может поддерживать работу нескольких протоколов верхнего уровня: TCP и UDP. На нижнем уровне протокол IP также взаимодействует с несколькими протоколами, обеспечивая передачу данных через различные сетевые интерфейсы (например, Ethernet, Token Ring или последовательный канал). Такая организация сетевых протоколов предполагает иерархическую структуру подсистемы ввода/вывода, когда драйверы являются объединением независимых модулей.

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

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

 

Архитектура STREAMS

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

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

Рис. 5.13. Базовая архитектура потока

Процесс взаимодействует с потоком, используя стандартные системные вызовы open(2), close(2), read(2), write(2) и ioctl(2). Дополнительные функции работы с потоками включают poll(2), putmsg(2) и getmsg(2). Передача данных по потоку осуществляется в виде сообщений, содержащих данные, тип сообщения и управляющую информацию. Для передачи данных каждый модуль, включая головной модуль и сам драйвер, имеет две очереди — очередь чтения (read queue) и очередь записи (write queue). Каждый модуль обеспечивает необходимую обработку данных и передает их в очередь следующего модуля. При этом передача в очередь записи осуществляется вниз по потоку (downstream), а в очередь чтения — вверх по потоку (upstream). Например, на рис. 5.13 из очереди записи модуля 2 сообщение может быть передано в очередь записи модуля 1, но не наоборот. В свою очередь сообщение из очереди чтения модуля 2 передается в очередь чтения головного модуля, который далее передает данные процессу в ответ на системный вызов read(2). Когда процесс выполняет системный вызов write(2), данные передаются головному модулю и далее вниз по потоку.

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

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

На рис. 5.14 показаны различные потоки, созданные из нескольких стандартных компонентов, для поддержки сетевых протоколов семейства TCP/IP. Причем модули IP, TCP и UDP могут поставляться одним производителем, а драйверы Ethernet или Token Ring соответствующими производителями сетевых адаптеров. В результате встраивания необходимых модулей первый поток будет обеспечивать передачу трафика TCP через адаптер Ethernet, в то время как второй — передачу трафика UDP через адаптер Token Ring.

Рис. 5.14. Использование одних и тех же модулей для создания различных потоков

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

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

Рис. 5.15. Конфигурация сетевого доступа с использованием подсистемы STREAMS

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

 

Модули

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

Рис. 5.16. Модуль STREAMS

Каждая очередь представлена структурой данных queue. Наиболее важными полями queue являются:

q_qinfo Указатель на структуру qinit , описывающую функции обработки сообщений данной очереди.
q_first , q_last Указатели на связанный список сообщений, ожидающих передачи вверх или вниз по потоку.
q_next Указатель на очередь следующего модуля вверх или вниз по потоку.
q_ptr Указатель на внутренние данные модуля (очереди).

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

Передача данных вверх или вниз по потоку осуществляется с помощью функций модуля, указатели на которые хранятся в структуре qinit. Модуль должен определить четыре процедуры для обработки каждой из очередей: xx put() , xx service() , xx open() и xx close() , где xx, как и прежде, обозначает уникальный префикс драйвера. Эти функции адресуются указателями (*qi_putp)(), (*qi_srvp)(), (*qi_qopen)(), (*qi_close)(). Этих четырех функций достаточно для взаимодействия с соседними модулями, обработки и передачи данных. Функция xx open() вызывается каждый раз, когда процесс открывает поток или при встраивании модуля. Соответственно функция xx close() вызывается при закрытии потока или извлечении модуля. Функция xx put() осуществляет обработку сообщений, проходящих через модуль. Если xx put() не может передать сообщение следующему модулю (например, в случае, если очередь следующего модуля переполнена), она помещает сообщение в собственную очередь. Периодически ядро вызывает процедуру xx service() каждого модуля для передачи отложенных сообщений.

Модуль должен иметь функцию xx put() для каждой очереди. Функция xx service() может не существовать, в этом случае xx put() не имеет возможности отложить передачу сообщения и должна передать его немедленно, даже если очередь следующего модуля переполнена. Таким образом модули, не имеющие процедур xx service() , не обладают возможностью управления потоком данных. Эти аспекты мы подробнее рассмотрим в следующих разделах.

Оставшиеся поля структуры qinit:

module_info В этой структуре хранятся базовые значения таких параметров, как ватерлинии, размер сообщений и т.д. Некоторые из этих параметров также находятся в структуре queue. Это дает возможность динамически изменять их, сохраняя при этом базовые значения.
module_stat Эта структура непосредственно не используется подсистемой STREAMS. Однако модуль имеет возможность осуществлять сбор разнообразной статистики своего участка потока с помощью полей этой структуры.

 

Сообщения

 

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

Сообщение описывается двумя структурами данных: заголовком сообщения msgb (message block) и заголовком блока данных datab (data block). Обе эти структуры адресуют буфер данных, где находятся фактические данные сообщения.

Заголовок сообщения msgb имеет следующие поля:

b_next , b_prev Используются для формирования связанного списка сообщений и соответственно адресуют следующее и предыдущее сообщение очереди
b_cont Указывает на продолжение сообщения и используется для связывания различных частей одного сообщения
b_datap Указатель на заголовок блока данных
b_rptr , b_wptr Указатели, определяющие расположение (начало и конец) данных в буфере данных
b_cont Содержит ссылку на следующую структуру msgb

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

db_base Адрес начала буфера
db_lim Адрес ячейки памяти, следующей непосредственно за буфером. Таким образом, размер буфера равен db_lim - db_base
db_type Тип сообщения
db_ref Число заголовков сообщения, адресующих этот блок

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

Рис. 5.17. Сообщения STREAMS

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

Рис. 5.18. Инкапсуляция данных с использованием составных сообщений

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

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

 

Типы сообщений

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

В подсистеме STREAMS определены следующие типы обычных сообщений:

M_DATA Содержит обычные данные. Например, системные вызовы read(2) и write(2) осуществляют передачу данных в виде сообщений этого типа.
M_PROTO Содержит управляющую информацию. Обычно сообщение этого типа содержит также несколько блоков типа M_DATA . С помощью системных вызовов putmsg(2) и getmsg(2) процесс имеет возможность отправлять и получать как управляющую часть сообщения (блок M_PROTO ), так и данные (блоки M_DATA ).
M_BREAK Посылается драйверу устройства для генерации команды break.
M_PASSFP Используется в каналах STREAMS (STREAMS pipe) для передачи файлового указателя от одного конца канала к другому.
M_SIG Генерируется модулями или драйверами и передается вверх по потоку головному модулю для отправления процессу сигнала.
M_DELAY Передается драйверу устройства и указывает задержку между последовательно передаваемыми символами. Как правило, используется при работе с медленными устройствами во избежание переполнения их буферов.
M_CTL Используется для взаимодействия модулей потока друг с другом. Все сообщения этого типа уничтожаются головным модулем и, таким образом, не могут распространяться за пределы потока.
M IOCTL Формируется головным модулем в ответ на управляющие команды, переданные процессом с помощью системного вызова ioctl(2) : I_LINK , I_UNLINK , I_PLINK , I_PUNLINK и I_STR . Эти команды используются для создания мультиплексированных потоков. Последняя команда используется для управления модулями потока.
M_SETOPTS Используется для задания различных характеристик головного модуля.
M_RSE Зарезервировано для внутреннего использования. Модули и драйверы должны передавать его без изменений.

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

M_COPYIN Передается вверх по потоку головному модулю и указывает ему скопировать данные от процесса для команды ioctl(2) . Сообщение допустимо в интервале между получением сообщения M_IOCTL и сообщения M_IOCACK или M_IOCNAK .
M_COPYOUT Передается вверх по потоку головному модулю и указывает ему передать данные, связанные с вызовом ioctl(2) , процессу. Сообщение допустимо в интервале между получением сообщения M_IOCTL и сообщений M_IOCACK или M_IOCNAK .
M_ERROR Передается вверх по потоку головному модулю и указывает на возникновение ошибки вниз по потоку. Последующие операции с потоком будут заканчиваться ошибкой, за исключением системных вызовов close(2) и poll(2) .
M_FLUSH При получении этого сообщения модуль должен очистить очередь (чтения, записи или обе) от сообщений.
M_HANGUP Передается вверх по потоку головному модулю и указывает, что драйвер не может передавать данные, обычно из-за обрыва линии (связи с удаленным объектом).
M_IOCACK Подтверждение предыдущего сообщения M_IOCTL . В ответ головной модуль возвратит необходимые данные процессу, сделавшему системный вызов ioctl(2) .
M_IOCNAK Если выполнение команды ioctl(2) закончилось неудачей, это сообщение передается вверх по потоку головному модулю, в ответ на это последний возвратит процессу ошибку.
M_PCPROTO Высокоприоритетная версия сообщения M_PROTO .
M_PCSIG Высокоприоритетная версия сообщения M_SIG .
M_PCRSE Зарезервировано для внутреннего использования в подсистеме.
M_READ Сообщение передается вниз по потоку, когда от процесса поступает запрос на чтение, но в головном модуле отсутствуют данные.
M_STOP Предписывает немедленно прекратить передачу.
M_START Предписывает продолжить передачу после останова, вызванного сообщением M_STOP .

 

Передача данных

 

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

Сообщения передаются модулями с помощью системной функции putnext(9F):

#include

#include

int putnext(queue_t *q, mblk_t *mp);

Эта функция адресует очередь следующего модуля параметром q и вызывает процедуру xx put() этой очереди, передавая ей сообщение mp. Не поощряется непосредственный вызов функции xx put() следующего модуля, поскольку это может вызвать определенные проблемы переносимости.

Передача данных внутри потока осуществляется асинхронно и не может блокировать процесс. Блокирование процесса возможно только при передаче данных между процессом и головным модулем. Таким образом, функции обработки данных потока — xx put() и xx service() не могут блокироваться. Если процедура xx put() не может передать данные следующему модулю, она помещает сообщение в собственную очередь, откуда оно может быть передано позже процедурой xx service() . Если и процедура xx service() не может осуществить передачу сообщения, например, из-за переполнения очереди следующего модуля, она не будет ожидать изменения ситуации, а вернет сообщение обратно в собственную очередь и завершит выполнение. Попытка передачи повторится, когда ядро через некоторое время опять запустит xx service() .

Процедура xx service() вызывается в системном контексте, а не в контексте процесса, который инициировал передачу данных. Таким образом, блокирование процедуры xx service() может заблокировать (перевести в состояние сна) независимый процесс, что может привести к непредсказуемым результатам и потому недопустимо. Решение этой проблемы заключается в запрещении процедурам xx put() и xx service() блокирования своего выполнения.

Блокирование недопустимо и для драйвера. Обычно прием данных драйвером осуществляется с использованием прерываний. Таким образом процедура xx put() вызывается в контексте прерывания и не может блокировать свое выполнение.

Когда процедура xx put() не может передать сообщение следующему модулю, она вызывает функцию putq(9F), имеющую следующий вид:

#include

int putq(queue_t *q, mblk_t *mp);

Функция putq(9F) помещает сообщение mp в очередь q, где сообщение ожидает последующей передачи, и заносит очередь в список очередей, нуждающихся в обработке. Для таких очередей ядро автоматически вызывает процедуру xx service() . Планирование вызова процедур xx service() производится функцией ядра runqueues(). Функция runqueues() вызывается ядром в двух случаях:

□ Когда какой-либо процесс выполняет операцию ввода/вывода над потоком.

□ Непосредственно перед переходом какого-либо процесса из режима ядра в режим задачи.

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

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

 

Управление передачей данных

Деление процесса передачи данных на два этапа, выполняемых, соответственно, функциями xx put() и xx service() , позволяет реализовать механизм управления передачей данных.

Как уже упоминалось, обязательной для модуля является лишь функция xx put() . Рассмотрим ситуацию, когда модули потока не содержат процедур xx service() . В этом случае, проиллюстрированном на рис. 5.19, каждый предыдущий модуль вызывает функцию xx put() следующего, передавая ему сообщение, с помощью функции ядра putnext(9F). Функция xx put() немедленно вызывает putnext(9F) и т.д.:

xxput(queue_t *q, mblk_t *mp) {

 putnext(q, mp);

}

Рис. 5.19. Передача данных без управления потоком

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

Хотя такой вариант может применяться для некоторых драйверов (как правило, для псевдоустройств, например, /dev/null), в общем случае устройство не может быть все время готово к обработке данных, а потеря данных из-за занятости устройства недопустима. Таким образом, в потоке может происходить блокирование передачи данных, и эта ситуация не должна приводить к потере сообщений, во избежание которой необходим согласованный между модулями механизм управления потоком. Для этого сообщения обрабатываются и буферизуются в соответствующей очереди модуля, а их передача возлагается на функцию xx service() , вызываемую ядром автоматически. Для каждой очереди определены две ватерлинии — верхняя и нижняя, которые используются для контроля заполненности очереди. Если число сообщений превышает верхнюю ватерлинию, очередь считается переполненной, и передача сообщений блокируется, пока их число не станет меньше нижней ватерлинии.

Рассмотрим пример потока, модули 1 и 3 которого поддерживают управление потоком данных, а модуль 2 — нет. Другими словами, модуль 2 не имеет процедуры xx service() . Когда сообщение достигает модуля 3, вызывается его функция xx put() . После необходимой обработки сообщения, оно помещается в очередь модуля 3 с помощью функции putq(9F). Если при этом число сообщений в очереди превышает верхнюю ватерлинию, putq(9F) устанавливает специальный флаг, сигнализирующий о том, что очередь переполнена:

mod1put(queue_t* q, mblk_t* mp) {

 /* Необходимая обработка сообщения */

 ...

 putq(q, mp);

}

Через некоторое время ядро автоматически запускает процедуру xx service() модуля 3. Для каждого сообщения очереди xxput() вызывает функцию canput(9F), которая проверяет заполненность очереди следующего по потоку модуля. Функция canput(9F) имеет вид:

#include

int canput(queue_t* q);

Заметим, что canput(9F) проверяет заполненность очереди следующего модуля, реализующего механизм управления передачей данных, т.е. производящего обработку очереди с помощью процедуры xx service() . В противном случае, как уже говорилось, очередь модуля не принимает участия в передаче данных. В нашем примере, canput(9F) проверит заполненность очереди записи модуля 1. Функция возвращает истинное значение, если очередь может принять сообщение, и ложное — в противном случае. В зависимости от результата проверки процедура xx service() либо передаст сообщение следующему модулю (в нашем примере — модулю 2, который после необходимой обработки сразу же передаст его модулю 1), либо вернет сообщение обратно в очередь, если следующая очередь переполнена.

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

Рис. 5.20. Управление потоком данных

mod1service(queue_t *q) {

 mblk_t* mp;

 while ((mp = getq(q)) != NULL) {

 if (canput(q->q_next))

  putnext(q, mp);

 else {

  putbq(q, mp);

  break;

 }

}

В этом примере функция getq(9F) используется для извлечения следующего сообщения из очереди, а функция putbq(9F) — для помещения сообщения в начало очереди. Если модуль 1 блокирует передачу, т.е. canput(9F) вернет "ложно", процедура xx service() завершает свою работу, и сообщения начинают буферизоваться в очереди модуля 3. При этом очередь временно исключается из списка очередей, ожидающих обработки, и процедура xx service() для нее вызываться не будет. Данная ситуация продлится до тех пор, пока число сообщений очереди записи модуля 1 не станет меньше нижней ватерлинии.

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

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

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

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

 

Драйвер

Драйверы и модули очень похожи, они используют одинаковые структуры данных (streamtab, qinit, module_info) и одинаковый интерфейс (xx open() , xx put() , xx service() и xx close() ). Однако между драйверами и модулями существуют различия.

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

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

Наконец, процесс инициализации драйверов и модулей различен. Функция xx open() драйвера вызывается при открытии потока, в то время как инициализация модуля происходит при встраивании.

 

Головной модуль

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

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

□ Сообщение об ошибках и отправление сигналов процессам, связанным с потоком.

□ Распаковку сообщений, переданных вверх по потоку, и копирование данных в пространство ядра или задачи.

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

Системный вызов putmsg(2) имеет вид:

#include

int putmsg(int fildes, const struct strbuf *ctlptr,

 const struct strbuf* dataptr, int flags);

С помощью этого вызова головной модуль формирует сообщение, состоящее из управляющей части M_PROTO и данных, передаваемых в блоках M_DATA. Содержимое сообщения передается с помощью указателей на структуру strbuf — ctlptr для управляющего блока и dataptr для блоков данных.

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

struct strbuf {

 int maxlen;

 int len;

 void *buf;

}

где maxlen не используется, len — размер передаваемых данных, buf — указатель на буфер.

С помощью аргумента flags процесс может передавать экстренные сообщения, установив флаг RS_HIPRI.

В обоих случаях головной модуль формирует сообщение и с помощью функции canput(9F) проверяет, способен ли следующий вниз по потоку модуль, обеспечивающий механизм управления передачей, принять его. Если canput(9F) возвращает истинный ответ, сообщение передается вниз по потоку с помощью функции putnext(9F), а управление возвращается процессу. Если canput(9F) возвращает ложный ответ, выполнение процесса блокируется, и он переходит в состояние сна, пока не рассосется образовавшийся затор. Заметим, что возврат системного вызова еще не гарантирует, что данные получены устройством. Возврат из write(2) или putmsg(2) свидетельствует лишь о том, что данные были успешно скопированы в адресное пространство ядра, и в виде сообщения направлены вниз по потоку.

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

Вызов getmsg(2) имеет вид:

#include

int getmsg(int fildes, struct strbuf *ctlptr,

 struct strbuf *dataptr, int *flagsp);

С помощью вызова getmsg(2) прикладной процесс может получить сообщение, причем его управляющие и прикладные данные будут помещены в буферы, адресуемые ctlptr и dataptr соответственно. Так же как и в случае putmsg(2) эти указатели адресуют структуру strbuf, которая отличается только тем, что поле maxlen определяет максимальный размер буфера, a len устанавливается равным фактическому числу полученных байтов. По умолчанию getmsg(2) получает первое полученное сообщение, однако с помощью флага RS_HIPRI, установленного в переменной, адресуемой аргументом flagsp, процесс может потребовать получение только экстренных сообщений.

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

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

 

Доступ к потоку

Как и для обычных драйверов устройств, рассмотренных ранее, прежде чем процесс сможет получить доступ к драйверу STREAMS, необходимо встроить драйвер в ядро системы и создать специальный файл устройства — файловый интерфейс доступа. Независимо от того, как именно осуществляется встраивание (статически с перекомпиляцией ядра, или динамически), для этого используются три структуры данных, определенных для любого драйвера или модуля STREAMS: module_info, qinit и streamtab. Связь между ними представлена на рис. 5.21.

Рис. 5.21. Конфигурационные данные драйвера (модуля) STREAMS

Структура streamtab используется ядром для доступа к точкам входа драйвера или модуля — к процедурам его очередей xx open() , xx close() , xx put() и xx service() . Для этого streamtab содержит два указателя на структуры qinit, соответственно, для обработки сообщений очереди чтения и записи. Два других указателя, также на структуры qinit, используются только для мультиплексоров для обработки команды I_LINK, используемой при конфигурации мультиплексированного потока. Каждая структура qinit определяет процедуры, необходимые для обработки сообщений вверх и вниз по потоку (очередей чтения и записи). Функции xx open() и xx close() являются общими для всего модуля и определены только для очереди чтения. Все очереди модуля имеют ассоциированную с ними процедуру xx put() , в то время как процедура xx service() определяется только для очередей, реализующих управление передачей. Каждая структура qinit также имеет указатель на структуру module_info, которая обычно определяется для всего модуля и хранит базовые значения таких параметров, как максимальный и минимальный размеры передаваемых пакетов данных (mi_maxpsz, mi_minpsz), значения ватерлиний (mi_hiwat, mi_lowait), а также идентификатор и имя драйвера (модуля) (mi_idnum, mi_idname).

Доступ к драйверам STREAMS осуществляется с помощью коммутатора символьных устройств — таблицы cdevsw[]. Каждая запись этой таблицы имеет поле d_str, которое равно NULL для обычных символьных устройств. Для драйверов STREAMS это поле хранит указатель на структуру streamtab драйвера. Таким образом, через коммутатор устройств ядро имеет доступ к структуре streamtab драйвера, а значит и к его точкам входа. Для обеспечения доступа к драйверу из прикладного процесса необходимо создать файловый интерфейс — т.е. специальный файл символьного устройства, старший номер которого был бы равен номеру элемента cdevsw[], адресующего точки входа драйвера.

 

Создание потока

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

Процесс открывает поток с помощью системного вызова open(2), передавая ему в качестве аргумента имя специального файла устройства. При этом ядро производит трансляцию имени и обнаруживает, что адресуемый файл принадлежит файловой системе specfs, через которую в дальнейшем производятся все операции работы с файлом. В памяти размещается vnode этого файла и вызывается функция открытия файла для файловой системы specfs — spec_open(). В свою очередь spec_open() находит требуемый элемент коммутатора cdevsw[] и обнаруживает, что поле d_str ненулевое. Тогда она вызывает процедуру подсистемы STREAMS stropen(), которая отвечает за размещение головного модуля и подключение драйвера. После выполнения необходимых операций поток приобретает вид, изображенный на рис. 5.22.

Рис. 5.22. Структура потока после открытия

Головной модуль представлен структурой stdata, которая выполняет роль интерфейса между потоком и ядром системы при выполнении операций чтения, записи и управления. Индексный дескриптор vnode содержит указатель на эту структуру. Поля q_ptr структур queue головного модуля также указывают на stdata. Поля q_qinfo очередей queue указывают на структуры qinit, адресующие общие для всех головных модулей функции, реализованные самой подсистемой STREAMS.

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

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

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

fd = open("/dev/stream", O_RDWR);

ioctl(fd, I_PUSH, "module1");

ioctl(fd, I_PUSH, "module2");

...

ioctl(fd, I_POP, (char*)0);

ioctl(fd, I_POP, (char*)0);

close(fd);

В этом примере процесс открыл поток /dev/stream, а затем последовательно встроил модули module1 и module2. Заметим, что команда I_PUSH системного вызова ioctl(2) встраивает модуль непосредственно после головного модуля. После выполнения операций ввода/вывода, процесс извлек модули и закрыл поток.

Поскольку модули описываются такими же структурами данных, что и драйверы, схемы их встраивания похожи. Как и в случае драйверов, для заполнения полей q_qinfo структур queue используются данные из структуры streamtab модуля. Для хранения информации, необходимой для инициализации модуля, во многих версиях UNIX используется таблица fmodsw[], каждый элемент которой хранит имя модуля и указатель на структуру streamtab. После установления всех связей вызывается функция xx open() модуля.

 

Управление потоком

Управление потоком осуществляется прикладным процессом с помощью команд системного вызова ioctl(2):

#include

#include

#include

int ioctl(int fildes, int command, ... /* arg */);

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

Синхронизацию осуществляет головной модуль. Когда процесс выполняет системный вызов ioctl(2), который может быть обработан самим головным модулем, последний выполняет все операции в контексте процесса, и никаких проблем синхронизации и копирования данных не возникает. Именно так происходит обработка ioctl(2) для обычных драйверов устройств. Если же головной модуль не может обработать команду, он блокирует выполнение процесса и формирует сообщение M_IOCTL, содержащее команду и ее параметры, и отправляет его вниз по потоку. Если какой- либо модуль вниз по потоку может выполнить указанную команду, в ответ он направляет подтверждение в виде сообщения M_IOCACK. Если ни один из модулей и сам драйвер не смогли обработать команду, драйвер направляет вверх по потоку сообщение M_IOCNAK. При получении одного из этих сообщений головной модуль пробуждает процесс и передает ему результаты выполнения команды.

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

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

Для преодоления этой проблемы в подсистеме STREAMS предлагаются два подхода.

Первый из них основан на использовании специальной команды ioctl(2) I_STR. При этом в качестве параметра передается указатель на структуру strioctl:

ioctl(fd, I_STR, (struct strioctl*)arg);

struct strioctl {

 int ic_cmd;

 int ic_timout;

 int ic_len;

 char* ic_dp;

}

где ic_cmd — фактическая команда,

ic_timeout — число секунд, которое головной модуль будет ожидать подтверждения запроса, после он вернет процессу ошибку тайм-аута ETIME,

ic_len — размер блока параметров команды,

ic_dp — указатель на блок параметров.

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

Другой подход получил название прозрачных команд ioctl(2) (transparent ioctl). Он позволяет использовать стандартные команды ioctl(2), решая при этом проблему копирования данных. Когда процесс выполняет вызов ioctl(2), головной модуль формирует сообщение M_IOCTL и копирует в него параметры вызова — command и arg. Обычно параметр arg является указателем на блок параметров, размер и содержимое которого известны только модулю (или драйверу), отвечающему за обработку данной команды. Поэтому головной модуль просто копирует этот указатель, не интерпретируя его и тем более не копируя в сообщение сам блок параметров. Сообщение передается вниз по потоку.

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

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

После получения всех необходимых данных и выполнения команды в случае, если результат должен быть передан процессу, модуль формирует одно или несколько сообщений M_COPYOUT, помещая в них требуемые данные, и направляет их вверх по потоку. Головной модуль пробуждает процесс, передавая ему результаты выполнения команды. Когда все результаты переданы процессу, модуль посылает подтверждение M_IOCACK, в результате которого головной модуль пробуждает процесс в последний раз, завершая тем самым выполнение вызова ioctl(2).

 

Мультиплексирование

Подсистема STREAMS обеспечивает возможность мультиплексирования потоков с помощью мультиплексора, который может быть реализован только драйвером STREAMS. Различают три типа мультиплексоров — верхний, нижний и гибридный. Верхний мультиплексор, называемый также мультиплексором N:1, обеспечивает подключение нескольких каналов вверх по потоку к одному каналу вниз по потоку. Нижний мультиплексор, называемый также мультиплексором 1:M, обеспечивает подключение нескольких каналов вниз по потоку к одному каналу вверх по потоку. Гибридный мультиплексор, как следует из названия, позволяет мультиплексировать несколько каналов вверх по потоку с несколькими каналами вниз по потоку.

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

Мультиплексирование каналов вверх по потоку осуществляется в результате открытия одного и того же драйвера с различными младшими номерами. Верхний мультиплексор должен обеспечить возможность одновременной работы с устройством с использованием различных младших номеров. Если два процесса открывают поток, используя различные младшие номера, ядро создаст отдельный канал для каждого из них, каждый из них будет адресоваться отдельным vnode, и процедура xx open() драйвера будет вызвана дважды. Драйвер при этом будет обрабатывать две пары очередей, каждая из которых отвечает за отдельный поток. Когда данные поступают от устройства, драйвер должен принять решение, в какую очередь чтения их направить. Обычно такое решение делается на основании управляющей информации, содержащейся в полученных данных. На рис. 5.23 представлен вид верхнего мультиплексора с двумя подключенными потоками.

Рис. 5.23. Верхний мультиплексор

Нижний мультиплексор представляет собой драйвер псевдоустройства. Вместо работы с физическим устройством он взаимодействует с несколькими каналами вниз по потоку. Для этого нижний мультиплексор обеспечивает работу с еще одной парой очередей — нижними очередями чтения и записи. Структура streamtab нижнего мультиплексора адресует процедурный интерфейс работы с нижними очередями соответственно полями st_muxrinit и st_muxwinit.

Для работы с мультиплексированными потоками подсистема STREAMS поддерживает четыре команды ioctl(2):

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

Создание мультиплексированного потока происходит в два этапа. Поясним этот процесс на примере создания стека протокола IP, поддерживающего работу как с адаптером Ethernet, так и с адаптером FDDI. Для этого необходимо объединить драйвер адаптера Ethernet, драйвер адаптера FDDI и драйвер IP, который является нижним мультиплексором. Процесс должен выполнить следующие действия:

fdenet = open("/dev/le", O_RDWR);

fdfddi = open("/dev/fddi", O_RDWR);

fdip = open("/dev/ip", O_RDWR);

ioctl(fdip, I_LINK, fdenet);

ioctl(fdip, I_LINK, fdfddi);

Сначала процесс создает три независимых потока, адресуемых дескрипторами fdenet, fdfddi и fdip (рис. 5.24, а) Для объединения потоков используется команда I_LINK системного вызова ioctl(2). В результате получается конфигурация, представленная на рис. 5.24, б.

Рис. 5.24. Создание мультиплексированного потока

В результате объединения потоков очереди и процедурный интерфейс головного модуля нижнего потока (в данном случае, потока, подключенного к драйверу Ethernet или FDDI), реализованный самой подсистемой STREAMS, заменяются на нижние очереди и соответствующий процедурный интерфейс мультиплексора. Более детально процесс объединения потока IP и потока Ethernet показан на рис. 5.25.

Рис. 5.25. Объединение верхнего и нижнего потоков

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

 

Заключение

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

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