часть 1: введение в предмет. Счастливое большинство пользователей или программистов компьютеров, использующихся в режиме настольного вычислительного инструмента, крайне редко сталкиваются с необходимостью взаимодействия с "внешним миром" и проблемой написания драйверов внешних устройств.
Тем не менее, одно только это обстоятельство не является достаточным основанием, для того, чтобы тотально относить желание подключить некоторое специализированное электрическое устройство к внутренним разъёмам компьютера к извращениям физического или психического свойств. Напротив, как только вам наскучит использовать компьютер в качестве печатающей машинки, игровой или мультимедийной приставки, и возникнет желание или необходимость задействовать его в несколько более продуктивном качестве, то почти наверняка, сразу же или несколько погодя, возникнет и нужда в подключении к нему дополнительных электрических устройств. Что это может быть? Вот только самый краткий перечень круга классических областей применения, в которых создание собственной системы драйверов становится насущной необходимостью:
- устройства сбора и обработки поступающего потока данных в научном эксперименте;
- сбор показаний от многочисленных датчиков в системах регулирования и управления технологическими процессами;
- каналы управления исполнительными механизмами, приводами, манипуляторами, роботами...;
- управление индикаторными панелями или отображение информации на демонстрационных табло;
- линии передачи, как в классических системах связи, так и в сетях взаимодействия между компьютерами;
- наконец, объединение нескольких компьютеров в "кластер", объединяемый каналами связи, подчинённых некоторым "дисциплине" и "протоколу", которые обеспечивают совместное функционирование компьютеров;
Здесь перечислена только малая толика экзотических устройств, которые принято в компьютерных технологиях именовать обобщённым термином "нестандартные". Но по той же схеме процессор взаимодействует и с множеством типовых узлов традиционного настольного компьютера: дисковые накопители, видеоадаптер монитора, звуковые карты... (т.е. во всех тех случаях, к которым конечный пользователь и привык, получая драйверную подсистему как "данность" вместе с устройством).
Если программирование вообще рассматривается многими как "искусство", то написание драйверов устройств - традиционно рассматривается уже как известное "шаманство". На то есть свои причины:
- Драйверы пишутся, за редким исключением, на языке ассемблера целевого компьютера. Даже в тех достаточно редких случаях, когда большая часть кода драйвера и записывается на С++ (например, Windows, начиная с Win98/2000) - этот код представляет собой последовательность вызовов специализированного набора функций и макроопределений, фактически только скрывающих объёмный ассемблерный код. Это резко повышает сложность, трудоёмкость, требования к профессиональному уровню, и требует полностью переписывать драйвер при переносе на новую архитектуру процессора.
- В классической "монолитной" операционной системе драйвер, являясь, по сути, некоторым модульным фрагментом (придатком) ядра операционной системы, обязан иметь специфические механизмы для встраивания "по живому" в тело функционирующего ядра. Это значит, что он должен иметь нестандартную (для пользовательского приложения) структуру, форматы и механизмы встраивания (как правило, в связный список заголовков) и т.д. Отсюда - множество макрокоманд с большими наборами редко замещаемых и плохо документированных параметров. Именно из-за проблем встраивания, редко какая OS позволяет динамически произвольным образом загружать и выгружать драйверы "на ходу", без перезагрузки, а если и позволяет, то с очень существенными ограничениями.
- Драйвер выполняется в адресном пространстве ядра операционной системы с неограниченными привилегиями доступа, что приводит к тому, что любая ошибка адресации памяти на этапе отладки может привести к полному разрушению целостности ядра. Это приводит к резкому усложнению процесса отладки и, зачастую, созданию специализированных технологий дистанционной отладки, требующих стендов из двух или более компьютеров. Более того, угроза фатальных ошибок, ведущих к краху системы, не исключена и в отлаженном и годами эксплуатируемом драйвере - вспомним утверждение мэтра от программирования Э. Дэйкстры (недавно, 9 августа 2002 года, умершего, к прискорбию): "нет законченных программ, не содержащих ошибок, различия только в числе и частоте проявления этих не выявленных ошибок...".
- Природа устройств, требуемых поддержки со стороны драйверов - крайне многообразна (как и природа вообще). Отсюда - принятое расчленение всех технологий создания драйверов на: драйвера потоковых устройств, драйвера блочных (файловых) устройств, драйвера системных устройств и др. И каждая линия порождает свою технологию создания.
Для операционных систем реального времени (real-time) для встраиваемого (embedded) оборудования необходимость взаимодействия с нестандартным оборудованием и написание драйверов - штатная потребность, которая возникает на порядки чаще. Понимая это, разработчики операционной системы QNX (фирма QSSL) создали беспрецедентную по своей простоте, и развили её до филигранности технологию создания драйверов - технологию менеджеров ресурса. Беспрецедентную - если её сравнить с техникой драйверов в системах: OS-360 - IBM/360; RT11 & RSX - DEC PDP-11; VMS - VAX-11; MS-DOS, Windows 95/98, NT, 2000 - Microsoft; Linux x86 и др. Развили - потому, что эта техника (ресурс менеджеров) появлялась и радикально изменялась от одной OS линии QNX к другой (QNX 2.XX - QNX 4.XX - QNX RTP 6.1 - QNX Momentics 6.2) на протяжении почти 20 лет.
Почему такую поразительную технологию оказалось возможным создать только в QNX (и, возможно, ограниченном числе подобных систем, например MARCH)? Ответ прост - микроядро (см. [1])! Операционная система QNX построена на тщательно проработанной в теории, но крайне редко реализуемой в OS концепции - коммутации сообщений. Ядро (точнее "микроядро") системы при этом подходе выступает в качестве компактного коммутатора сообщений между взаимодействующими программными компонентами. Все запросы, обслуживаемые драйвером, предусматриваемые POSIX (open(), read(), write(), seek(), close() ...) и не-POSIX (devctl() ...) - реально посылаются драйверу (менеджеру ресурса - Resource Manager) в виде сообщений уровня микроядра. Код сообщения при этом определяет тип операции (например, open()), а последующее тело сообщения - конкретные параметры запроса, зависящие от типа операции. (В этой же технике, без каких либо отличий, могут обрабатываться и сообщения произвольных, не специфицированных системой типов, несущих в себе любой пользовательский каприз.)
В QNX все привычные программисту запросы API (например, стандарта POSIX) пакуются в тело сообщений, а микроядро системы обеспечивает их синхронизацию и доставку от отправителя к получателю (рис. 1).
В этой OS все привычные программисту запросы API (например, стандарта POSIX) пакуются в тело сообщений, а микроядро системы обеспечивает их синхронизацию и доставку от отправителя к получателю (рис.1, нумерация рисунков по всей статье, независимо от деления её на части, здесь и далее - сквозная).
Идея, гениальная в своей простоте: для любых видов взаимодействий в OS иметь единый механизм передачи, приёма и обработки запросов. Удивляет не то, что мы наблюдаем в QNX - удивляет то, что мы не наблюдаем нечто подобное в других семействах OS.
Даже из такого простейшего описания процесса "на пальцах" должно быть понятно, что (позиции этого перечня я попытался расположить так, чтоб каждая последующая непосредственно вытекала из предыдущих и не требовала дополнительных пояснений):
- любой драйвер (менеджер ресурса) в этой логической модели рассматривается как сервер некоторой услуги ("ресурса"), предоставляющий сервис клиентам - пользовательским приложениям;
- драйвер теперь выглядит точно так же, как и всякое заурядное приложение в системе, а значит - может произвольно загружаться и выгружаться (замещаться) динамически;
- OS становится абсолютно защищённой от ошибок функционирования драйверов, как и от всякой задачи пользовательского уровня - они работают в разных кольцах аппаратной защиты памяти;
- драйвер может быть полностью написан не выходя за рамки выразительных средств языка высокого уровня (C или C++);
- ни структура, ни исходный код драйвера теперь не зависят (не привязаны) к отдельной архитектуре процессора - обеспечивается полная переносимость исходного кода во всём спектре платформ, поддерживаемых базовой операционной системой QNX;
- единообразно и автоматически обеспечиваются механизмы синхронизации в операционной системе (синхронные и асинхронные операции) на самом низшем уровне синхронизации сообщений микроядром;
- совершенно уникальное, недостижимое в монолитных OS качество, которое достигается "попутно", без дополнительных накладных затрат: такой драйвер-сервер совершенно одинаково обслуживает запросы клиентов как со "своего" локального компьютера, так и с любого иного компьютера, доступного по сети QNET (QNET - это "родная" сетевая система QNX). Но этот факт сам по себе настолько обстоятелен, что заслуживает отдельного рассмотрения.
Для написания ресурс менеджеров любых видов и свойств в QNX RTP 6.1 (тестирование проводилось в этой редакции системы) существует разработанная техника, построенная на основе шаблонов исходного программного кода. Дополняя эти шаблоны собственными фрагментами (на C/C++), реализующие функции обработчиков POSIX-запросов, и обеспечивающими функциональность специфического устройства, пользователь получает полнофункциональную работающую драйверную подсистему.
Очень упрощённо: шаблон менеджера ресурса - это единообразный диспетчер сообщений, направляемых данному менеджеру, и коммутатор поступающих сообщений к соответствующим программам обработки (если пользователь пишет собственный обработчик запроса - то к обработчику пользователя, если нет - то к обработчику по умолчанию, которые существуют для всех видов Posit-запросов). Драйвер-сервер может быть опционально как одно-потоковый (традиционно для других OS), так и много-потоковый, использующий отдельный thread из пула потоков для обслуживание каждого отдельного запроса (в том и другом случае используются различные шаблоны).
Как клиенты идентифицируют вновь созданный драйвер? Менеджер ресурсов при своём старте (как обычная пользовательская задача) регистрирует так называемый "путевой префикс" в дереве файловой системы UNIX (QNX - один из клонов UNIX). Обычно - это специфическое имя в каталоге /dev, например: /dev/xxx, хотя некоторые RM "предпочитают" и /proc - вообще, место регистрации драйвера в файловой системе может быть произвольно. После этого, любой клиент (задача) в системе может выполнить стандартный Posix-запрос open( "/dev/xxx", ... ), получить файловый дескриптор, и дальше оперировать им в стандартной манере для библиотечных функций C/C++.
Почему авторы системы используют термин "путевой префикс" (или "префикс пути", дословно: pathname prefix in the pathname space)? Потому, что дальше (ниже) от этого пути ("точка монтирования" в терминологии ресурс менеджера) может находиться целая "примонтированная" файловая система со своим деревом имён, например /dev/xxx/yyy/zzz/my.file. Именно так в QNX и реализованы "виртуальные" файловые системы ext2 & ext3 Linux, и ISO-9600 - поддержка формата CD-носителя. Да именно, вы не ошиблись: драйверы развитых файловых систем реализуются в QNX равно из того же шаблона, из которого образованы и потоковые устройства /dev/ser или даже /dev/nul!
Где регистрируется (и хранится) префикс пути, объявленный новым менеджером ресурса? Во внутренних таблицах ещё одной программы в системе - менеджера процессов: в этих таблицах поддерживается взаимно однозначное соответствие префиксов пути и идентификаторов процессов (PID), ответственных за их поддержание (обслуживание). Реально клиент, выполняя open("/dev/xxx", ... ) направляет первое сообщение менеджеру процесса, запрашивая его PID процесса, ответственного за путь (устройство) /dev/xxx. Следующее сообщение в ходе выполнения open(...), а также все последующие сообщения (read(), write() ... до close()) направляются уже процессу с полученным ранее PID.
Может возникнуть законный вопрос: а как клиент различает "фиктивное" имя в файловой системе /dev/xxx от множества "реальных" имён файлов на диске, или в подмонтированной файловой системе NFS? А никак - всё происходит единообразно! Просто при операциях с "реальными" файлами менеджер процессов возвратит PID менеджеров файловой системы или сетевой системы NFS.
Какие функциональные перспективы открывает для разработчика такая простая, единообразная (я бы воспользовался даже математическим термином "ортогональная") технология?
Представим: мы проектируем робота. Мы можем создать фиксированный набор драйверов: /dev/robot/rh, /dev/robot/lh, /dev/robot/rl, /dev/robot/ll (соответственно: правая-левая нога, правая-левая рука). Тогда привычные POSIX операции fd = open( "/dev/robot/rl", ...); write( fd, ...); - будут осуществлять некоторые манипуляции левой рукой. Можно даже переопределить операцию seek(), например, так, чтобы она означала "позиционировать манипулятор на определённый угол относительно горизонтали". Можно дополнить подмножество драйверов устройствами /dev/robot/ri и /dev/robot/li с допустимым режимом open() только read-only, которые будут представлять высокоскоростные каналы передачи изображений от правого и левого видеодатчиков (органов зрения).
Или представим: в мобильной системе цифровой связи может присутствовать некоторое (или даже неопределённое) число доступных каналов ретрансляции (их число может спонтанно меняться из-за затухания на направлениях, потери связи и т.д.). И эти каналы для нас равнозначные, т.е. нам безразлично - какой использовать из числа доступных. Тогда мы могли бы объявить путевое имя /dev/mobil, ниже которого будут возникать (и исчезать) путевые имена каналов: /dev/mobil/A, /dev/mobil/B... При необходимости ретрансляции мы open() первый доступный канал (ещё не задействованный приложением) и осуществляем операцию write().
Или ещё иная ситуация: нам не хотелось бы использовать разнородные имена для создания новых клонов ресурса (например, сетевых соединений). Пожалуйста! Определим единое много-потоковое устройство /dev/talk, последовательные операции open( "/dev/talk", ... ) к которому создавали всё новые файловые дескрипторы, а операции read( fd, ... ), write( fd, ... ) работали бы со своим экземпляром, идентифицируя его по fd. (Именно случай реализован в тексте драйвера, который я предлагаю рассмотреть ниже).
Журнальная статья - не место для изложения во всех деталях устройства и функционирования объёмного и развитого программного шаблона менеджера ресурса, поэтому я отсылаю всех заинтересованных к прекрасному руководству: [1], раздел "Writing Resource Manager", более 80-ти страниц изложения. (Интересующиеся могут получить от меня электронной почтой сделанный мною перевод, который в скором времени предполагается разместить для свободного использования на русскоязычном портале QNX
http://qnx.org.ru).
Источники дополнительной информации:
[1] - "Writing Resource Manager" в HELP подсистеме операционной системы QNX RTP 6.1.
[2] - Олег Цилюрик "Новое лицо QNX", журнал "Программист", М.:, 5, 2002.