Теория разработки драйверов

     Введение.

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

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

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

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

     Часть 1. Драйвера.

     1.1 Основные понятия.

     Драйвер – это часть кода операционной системы (небольшая программка не для  пользователя, а для ОС), отвечающая за взаимодействие с аппаратурой. Под  словом «аппаратура» можно подразумевать  как реальные физические устройства, так и виртуальные и логические. Например, есть много разных, принтеров  разных производителей. Чтобы на них  печатать, нужно знать, какие команды  этому принтеру нужно передавать. У разных производителей такие команды  могут сильно отличаться друг от друга. Нужно как-то научить ОС работать со всеми типами принтеров – в  этом помогают драйверы.

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

     Список  основных общих концепций драйверов в Windows и UNIX-системах выглядит так:

  1. Способ работы с драйверами как файлами. Т.е. функции, используемые при взаимодействии с файлами, практически идентичны таковым при взаимодействии с драйверами (лексически): open, close, read и т.д.
  2. Драйвер, как легко заменяемая часть ОС
  3. Существование режима ядра
 

     Классификация типов драйверов для ОС Windows:

  1. Драйверы пользовательского режима (User-Mode Drivers):

     - Драйверы виртуальных устройств (Virtual Device Drivers, VDD) – используются для поддержки программ MS-DOS;

     - Драйверы принтеров (Printer Drivers);

     2. Драйверы режима ядра (Kernel-Mode Drivers):

     - Драйверы файловой системы (File System Drivers) – осуществляют ввод/вывод на локальные и сетевые диски

     - Унаследованные драйверы (Legacy Drivers) – написаны для предыдущих версий Windows

     - Драйверы видеоадаптеров (Video Drivers) – реализуют графические операции

     - Драйверы потоков устройств (Streaming Drivers) – осуществляют ввод/вывод потоков видео и звука

     - WDM-драйверы (Windows Driver Model) – поддерживают технологию Plug and Play и управления электропитанием.

     Драйверы  бывают одно- и многоуровневыми. Если драйвер является многоуровневым, то обработка запросов ввода/вывода распределяется между несколькими драйверами, каждый из которых выполняет свою часть  работы. Между этими драйверами можно  поставить любое количество фильтр-драйверов (filter-drivers). При обработке запроса данные идут от вышестоящих драйверов к нижестоящим, а при возврате – наоборот. Одноуровневый драйвер (monolithic) драйвер является противоположностью многоуровневому.

     Для технологии Plug and Play существуют три уровня-типа драйверов:

     - шинные драйверы

     - фильтр-драйверы

     - функциональные драйверы

     Системное ПО, поддерживающее Plug and Play предоставляет следующие возможности:

     - автоматическое распознавание подключенных к системе устройств

     - распределение и перераспределение ресурсов (таких, как порты ввода/вывода и участки памяти) между запросившими их устройствами

     - загрузка необходимых драйверов

     - предоставление драйверам необходимого интерфейса для взаимодействия с технологией Plug and Play

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

     - предоставление драйверам необходимого интерфейса для взаимодействия с технологией Plug and Play

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

     1.2. Инструментарий.

     Существует  много утилит, которые могут понадобиться при  разработке драйверов. Основным средством разработки является Microsoft Windows DDK, Device Driver Kit, — пакет разработки драйверов, включающий компилятор, редактор связей (линкер), заголовочные файлы, библиотеки, большой набор примеров (часть  из которых является драйверами, реально  работающими в операционной системе) и, разумеется, документацию. В состав пакета входит также отладчик WinDbg, позволяющий  проводить интерактивную отладку  драйвера на двухкомпьютерной конфигурации и при наличии файлов отладочных идентификаторов операционной системы WinDbg кроме того, позволяет просматривать  файлы дампа (образа) памяти, полученного  при фатальных сбоях операционной системы (так называемый crash dump file).

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

     В бесплатно распространяемом пакете DDK всегда отсутствовала интегрированная  среда разработки. Поэтому есть необходимость  использовать Visual Studio в качестве средства редактирования исходного кода. При должной настройке этой среды процесс выявлений синтаксических ошибок существенно облегчается — неотъемлемое преимущество интегрированных сред программирования. Компилятор и редактор связей Visual Studio C++ создают нормальный бинарный код, вполне работоспособный при указании соответствующих опций (настроек) компиляции, однако эталоном следует считать бинарный код, получающийся при компиляции кода драйвера с использованием утилиты Build из состава пакета DDK. Разумеется, встроенный интерактивный отладчик Visual Studio и прилагаемая документация становятся для разработки драйвера совершенно бесполезными, поскольку не предназначены для работы с программным обеспечением для режима ядра.

     Есть  пакеты разработки драйверов и от других фирм: WinDriver или NuMega Driver Studio, но у них есть отличия базиса функций Microsoft (порой довольно большие) и масса других мелких неудобств.

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

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

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

     - статический анализатор кода общего назначения Gimpel Software PC-lint, который  обнаруживает помимо некоторых из перечисленных ошибок, еще и огромное количество ошибок, встречающихся в обычных программах.

     -  статический анализатор кода Viva64 предназначен для поиска ошибок в C++ программах, проявляющихся при переносе кода с 32-битных на 64-битные системы. 
 
 
 
 
 
 

     Часть 2.  Архитектура Windows.

     Цели разработки.

     Пять  фундаментальных задач Windows последнего поколения:

     - Совместимость. Операционная система должна поддерживать максимально возможное множество программного и аппаратного обеспечения.

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

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

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

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

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

     Уровни аппаратных привилегий в Windows.

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

     Пользовательское  приложение функционирует в специальном  режиме (относительно аппаратного обеспечения), называемом 'user mode' — пользовательский режим. В пределах этого режима, код приложения ограничен выполнением "безвредных" инструкций. Например, через реализацию "таинственного" маппинга (mapping, отображение) виртуальной памяти (страничное представление виртуальной памяти) пользовательский код лишается возможности доступа к виртуальной памяти, предоставленной другим приложениям (за исключением случаев обоюдного согласия, что реализуется специально предназначенными на тот случай методами). Инструкции аппаратного ввода/вывода также не могут быть выполнены кодом пользовательского режима. Целый класс инструкций центрального процессора (называемых привилегированными) запрещен в Windows для выполнения кодом пользовательского режима, как, например, команды процессора IN, OUT. Если вдруг приложению потребуется выполнить что-нибудь из числа таких запрещенных для нее действий, оно должно запросить соответствующую службу операционной системы.

     Код самой операционной системы выполняется  в так называемом 'kernel mode' — режиме ядра (режиме уровня ядра). Код режима ядра вправе выполнить любую процессорную инструкцию, не исключая инструкций ввода/вывода. Память, принадлежащая любому приложению, может быть доступна коду режима ядра, конечно, если страничная память приложения в данный момент не сброшена на жесткий  диск.

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

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

     Режим ядра (защищенный режим) – это основной режим работы процессора (32-разрядного). Главные механизмы, регулируемые режимом  ядра:

     - механизм защиты памяти и ввода/вывода, состоящий из 4 уровней

     - механизм переключения задач (любая задача имеет состояние – состояние всех регистров процессора, с ней связанных. Это состояние хранится на сегментах)

     - особая организация памяти. При этой организации памяти используется 2 способа ее преобразования: разбивка на страницы и сегментация (сегмент – это отдельный блок общего пространства памяти, в 32-разрядной адресации максимальный размер сегмента – 4 Гбайт, а максимальное количество сегментов 8192). Сегментация обеспечивает неплохую защиту данных. Страничный же способ организации помогает использовать большее количество памяти, чем сегментация. Базируется она также на 32-разрядной адресации, но в качестве базового объекта использует отдельный блок памяти размером 4 Кбайт.

     - механизм защиты из 4 уровней

     Драйверы  уровня ядра (режима ядра) работают в  привилегированном контексте. Соответственно, плохо написанный драйверный код  может оказаться вредоносным  для операционной системы. Разработчик  должен с особым вниманием относиться к создаваемому коду, чтобы не обрушить все здание операционной системы. Фирма Microsoft пытается решить проблему надежности драйверов, поставляемых в составе  дистрибутива Windows, через механизм тестирования и подписания драйверов.

     Переносимость.

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

             

     Слой  аппаратных абстракций (Hardware Abstraction Layer, HAL) изолирует процессорные и платформенные  особенности от кода операционной системы. Его услугами Microsoft предлагает пользоваться и разработчику драйверного кода. Вполне возможно так написать драйвер, что для перенесения его на другую платформу потребуется разве  что перекомпилировать его. Как  можно это сделать, если изначально драйвер есть такая программная  единица, которая жестко привязана  и к своему устройству, и к конкретному  процессору, и к конкретной платформе?! Просто драйвер должен обратиться к  использованию средств уровня HAL для взаимодействия с аппаратными регистрами и аппаратной поддержкой шин. В отдельных случаях разработчик драйвера может опереться на код, предоставляемый Диспетчером ввода/вывода, для работы с совместно используемыми аппаратными ресурсами. Например, при DMA операциях (прямого доступа к памяти) используется такая абстракция программирования, как объект адаптера

     Расширяемость.

     Ядро несет ответственность за планировку активности программных потоков (threads). Поток является всего лишь "независимой тропинкой" в выполнении программного кода. Чтобы сохранить независимость от деятельности других потоков, для каждого из них необходимо сохранять уникальный потоковый контекст (thread context). Потоковый контекст состоит из состояния регистров процессора (включая также изолированный стек и счетчик инструкций, Program Counter), сохраненного ID (идентификатора потока, так называемого Thread ID или TID), значения приоритета, распределения памяти, связанной с потоком (Thread Local Storage), и другой информации, имеющей отношение к данному потоку.

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

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

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

     Производительность.

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

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

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

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

     Часть 3. Архитектура WDM

     WDM (Windows Driver Model) – это драйверная модель от Microsoft для ОС Windows, пришедшая на смену предыдущей среде написания драйверов для ОС Windows – VxD (Virtual Device Driver).

     WDM в настоящий момент – одна из важнейших концепций в написании драйверов.  Ее главные особенности:

     - совместимость на уровне двоичных кодов между драйверами для систем Windows 98 и Windows NT

     - поддержка управления питанием

     - поддержка Plug and Play

     - поддержка «продвинутого» шинного управления (advanced bus management)

     «Жизненный  цикл» среднестатистического WDM – драйвера:

  1. Драйвер шины обнаруживает устройство.
  2. Plug and Play Manager определяет местонахождение ключа устройства в ветке Enum реестра. Этот ключ содержит указатель на другой ключ реестра, определяющий функциональный драйвер (который управляет отдельным устройством и является основным драйвером устройства). Pnp-менеджер динамически загружает функциональный драйвер.
  3. PnP-менеджер вызывает функцию драйвера AddDevice для того, чтобы создать DRIVER_OBJECT. Если драйвер соответствует больше, чем одному фактическому устройству, PnP-менеджер вызывает AddDevice для каждого из них. С этого момента вся коммуникация драйвера с внешним миром осуществляется с использованием IRP (I/O Request Packet) – пакетов.
  4. PnP-менеджер выделяет все необходимые драйверу ресурсы ввода/вывода (запросы на прерывание, номера портов и т.д.) и посылает запрос для инициализации устройства.
  5. Некоторые устройства могут быть удалены из системы без выключения компьютера. Если устройство – одно из таких, то PnP-менеджер посылает драйверу специальный IRP-пакет, в результате чего созданный функцией AddDevice объект устройства уничтожается.
  6. Когда все устройства удалены, то менеджер ввода/вывода (I/O Manager) вызывает функцию DriverUnload, которая удаляет образ драйвера из памяти.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

     Часть 4. Структура драйвера.

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

     DriverEntry – ключевая функция драйвера. Функция инициализации. Ее основные задачи – произвести все необходимые действия по инициализации и определить точки входа для остальных функций драйвера. Эта функция вызывается при загрузке драйвера. 

     NSTATUS

     DriverEntry{

     IN PDRIVER_OBJECT DriverOBject,

     IN PUNICODE_STRING RegistryPath

     };

     Она принимает 2 аргумента: первый – указатель на объект DriverObject типа PDRIVER_OBJECT. Он позволяет функции DriverEntry определить указатели на функции Dispatch, AddDevice, StartIo, а также на функцию выгрузки драйвера в объекте драйвера. Аргумент  RegistryPath передает функции DriverEntry указатель на Unicode строку, содержащую путь к ключу драйвера в реестре.

     Каждый  драйвер должен иметь, по крайней  мере, одну процедуру Dispatch

     NTSTATUS

     XxxDispatchpnP{

           IN PDEVICE_OBJECT DeviceObject,

         IN PIRP Irp

     };

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

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

     VIOD

     XxxStartIo{

           IN PDEVICE_OBJECT DeviceObject,

         IN PIRP Irp

     };

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

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

     VOID

     Reinitialize{

           IN PDRIVER_OBJECT DriverObject,

           IN PVOID Context,

         IN ULONG Count

     };

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

     BOOLEAN

     InterruptService{

           IN PKINTERRUPT Iterrupt,

           IN PVOID ServiceContext

     };

     Любой драйвер, имеющий ISR , должен иметь DpcForIsr или CustomDpc

     OID

     DpcForIsr{

           IN PKDPC Dpc,

           IN struct _DEVICE_OBJECT *DeviceObject,

           IN struct _IRP *Irp,

     IN PVOID Context

     };

     VOID

     CustomDpc{

           IN struct _KDPC *Dpc,

           IN PVOID DeferredContext,

           IN PVOID SystemArgument1,

           IN PVOID SystemArgument2

     };

     Любой низкоуровневый драйвер устройства, данные которого или регистры сопряженного устройства могут изменяться в его  ISR и других процедурах драйвера, должен иметь одну или более процедур SynchCritSection

     BOOLEAN

     SynchCritSection{

           IN PVOID SynchronizeContext

     };

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

     IO_ALLOCATION_ACTION

     AdapterControl{