Что такое виртуальный драйвер
В данной статье описан процесс написания простейшего драйвера, который выводит скан-коды нажатых клавиш.
Также в данной статье описан процесс настройки рабочего места для написания драйверов.
Если Вам интересно, прошу под кат.
Подготовка стенда
Установка необходимого ПО для написания простейшего драйвера
- Windows DDK (Driver Development Kit);
- VMware Workstation или Virtual Box;
- Windows XP;
- Visual Studio 2005;
- DDKWizard;
- KmdManager
- DebugView;
Настройка рабочего места
Установка DDK
Установка предельно проста. Единственное на что необходимо обратить внимание — это диалог, в котором Вам предлагается выбрать компоненты, которые будут установлены. Настоятельно рекомендую отметить всю документацию и примеры.
Установка и настройка Microsoft® Visual Studio 2005
Установка Microsoft® Visual Studio 2005 ничем не сложнее установки DDK. Если Вы будете использовать её только для написания драйверов, то когда инсталлятор спросит какие компоненты необходимо установить, выберите только Visual C++.
Далее можно установить Visual Assist X. С помощью этой программы (аддона) можно будет легко настроить подсказки для удобного написания драйверов.
После установки Visual Assist X в Visual Studio 2005 появится новое меню VAssistX. Далее в этом меню: Visual Assist X Options -> Projects -> C/C++ Directories -> Platform: Custom, Show Directories for: Stable include files . Нажимаем Ins или на иконку добавить новую директорию и в появившейся строке, если у вас Windows XP вписываем %WXPBASE%\inc\ddk\wxp .
Установка и настройка DDKWizard
- Создать системные (рекомендуется) или пользовательские переменные со следующими именами и значением, которое соответствует пути к DDK
Версия DDK Имя переменной Путь по умолчанию Windows XP DDK WXPBASE C:\WINDDK\2600 Windows 2003 Server DDK WNETBASE C:\WINDDK\3790.1830 Windows Vista/Windows 2008 Server WDK WLHBASE Windows 7/Windows 2008 Server R2 WDK W7BASE
Например, если я использую Windows XP DDK, то я должен создать переменную WXPBASE со значением, которое соответствует пути к DDK. Так как я не изменял путь установки, то значение у меня будет C:\WINDDK\2600. - Скопируйте скачанный скрипт ddkbuild.cmd, например, в папку с DDK. У меня это C:\WINDDK\.
- Добавьте в конец системной переменной Path путь к скрипту ddkbuild.cmd.
Установка необходимого ПО для запуска драйверов
- DebugView (link) — это утилитка, которая позволяет просматривать отладочный вывод как режима пользователя так и режима ядра.
- KmdManager (link) — утилита динамической загрузки/выгрузки драйверов
Постановка задачи
Задача: написать драйвер, который будет выводить в дебаг скан-коды нажатых клавиш и их комбинаций.
Немного теории
- драйверы классов;
- минидрайверы;
- функциональные драйверы;
- фильтрующие драйверы.
Необязательно определять все возожные функции в своем драйвере, но он обязательно должен содержать DriverEntry и AddDevice .
IRP — это структура, которая используется драйверами для обмена данными.
- верхние фильтрующие драйверы;
- нижние фильтрующие драйверы.
Отличия между верхними и нижними фильтрующими драйверами
Через верхние фильтрующие драйверы проходят все запросы, а это значит, что они могут изменять и/или фильтровать информацию, идущую к функциональному драйверу, ну и далее, возможно, к устройству.
Пример использования верхних фильтрующих драйверов:
Фильтр-хук драйвер, который устанавливает свою хук-функцию для системного драйвера IpFilterDirver, для отслеживания и фильтрации траффика. Такие драйверы используются в брандмауэрах.
Через нижние фильтрующие драйверы проходит меньше запросов потому что большинство запросов выполняет и завершает функциональный драйвер.
Проблемы синхронизации
В драйвере, который мы будем писать, есть несколько «проблемных» секций. Для нашего драйвера вполне достаточно использования ассемблерных вставок:
Префикс lock позволяет безопасно выполнить идущую за ним команду. Она блокирует остальные процессоры, пока выполняется команда.
Экшен
Для начала необходимо включить заголовочные файлы «ntddk.h», «ntddkbd.h»
Также необходимо описать структуру DEVICE_EXTENSION
Объект pLowerDO это объект устройства, который находится ниже нас в стеке. Он нужен нам для того чтобы знать кому дальше отправлять IRP-пакеты.
Еще для работы нашего драйвера нам нужна переменная, в которой будет храниться количество не завершенных запросов.
Начнем с функции, которая является главной точкой входа нашего драйвера.
theDriverObject – объект драйвера, содержит указатели на все необходимые операционной системе функции, которые мы должны будем инициализировать.
ustrRegistryPath – имя раздела в реестре, где хранится информация о данном драйвере.
Для начала необходимо объявить и обнулить переменные:
Далее, как я и писал выше, нужно инициализировать указатели на функции
Функция DispatchRead будет обрабатывать запросы на чтение. Она будет вызываться, когда нажата или отпущена клавиша клавиатуры.
Функция DriverUnload вызывается, когда драйвер уже не нужен и его можно выгрузить из памяти, или когда пользователь сам выгружает драйвер. В данной функции должна производиться «зачистка», т.е. освобождаться ресурсы, которые использовались драйвером, завершаться все незавершенные запросы и т.д.
Функция DispatchThru это функция-заглушка. Все что она делает это передача IRP-пакета следующему драйверу (драйверу который находится под нашим в стеке, т.е. pLowerDO из DEVICE_EXTENSION ).
Далее мы вызываем нашу функцию, для создания и установки нашего устройства в стек устройств:
Эту функцию я опишу чуть ниже.
Возвращаем status , в котором, если функция InstallFilter завершилась удачей, хранится значение STATUS_SUCCESS .
Переходим к функции InstallFilter . Вот её прототип:
Эта функция создает объект устройства, настраивает его и включает в стек устройств поверх \\Device\\KeyboardClass0
pKeyboardDevice – это объект устройсва, которое мы должны создать.
Вызываем IoCreateDevice для создания нового устройства
- Первый аргумент это объект драйвера, который мы получили как параметр функции InstallFilter. Он передается в IoCreateDevice для того чтобы установить связь между нашим драйвером и новым устройством.
- Третий параметр это имя устройства
- Четвертый параметр это тип устройства
- Пятый параметр это флаги, которые обычно устанавливаются для запоминающих устройств.
- Шестой параметр описывает можно ли открывать манипуляторы устройства в количестве больше одного. Если FALSE можно открыть только один манипулятор. Иначе можно открыть любое количество манипуляторов.
- Седьмой параметр это память, в которой будем сохранен созданный объект устройства.
Флаги, которые мы устанавливаем для нашего устройства, должны быть эквивалентными флагам устройства, поверх которого мы включаемся в стек.
Далее мы должны выполнить преобразования имени устройства, которое мы включаем в стек.
Функция IoAttachDevice внедряет наше устройство в стек. В pdx->pLowerDO будет храниться объект следующего (нижнего) устройства.
Далее разберем функцию DispatchRead с прототипом:
Данная функция будет вызываться операционной системой при нажатии или отпускании клавиши клавиатуры
Увеличиваем счетчик незавершенных запросов
Перед тем как передать запрос следующему драйверу мы должны настроить указатель стека для драйвера. IoCopyCurrentIrpStackLocationToNext копирует участок памяти, который принадлежит текущему драйверу, в область памяти следующего драйвера.
Когда запрос идет вниз по стеку в нем еще нет нужных нам данных, поэтому мы должны задать функцию, которая вызовется, когда запрос будет идти вверх по стеку с нужными нам данными.
где ReadCompletionRoutine наша функция.
Передаем IRP следующему драйверу:
Теперь разберем функцию, которая будет вызываться каждый раз при завершении IRP . Прототип:
Структура PKEYBOARD_INPUT_DATA используется для описания нажатой клавиши.
Проверяем, удачно завершен запрос или нет
Чтобы достать структуру KEYBOARD_INPUT_DATA нужно обратиться к системному буферу IRP -пакета.
Узнаем количество клавиш
И выводим каждую клавишу:
И не забываем уменьшать количество не обработанных запросов
Возвращаем статус запроса
Разберем функцию завершения работы. Прототип:
Извлекаем устройство из стека:
Проверяем есть незавершенные запросы или нет. Если мы выгрузим драйвер без этой проверки, при первом нажатии на клавишу после выгрузки будет БСоД.
Как запустить драйвер и просмотреть отладочную информацию
Для запуска драйвера я использовал утилиту KmdManager. Для просмотра отладочной информации использовалась утилита DbgView.
P. S. Статью писал давно, ещё на третьем курсе, сейчас уже почти ничего не помню. Но если есть вопросы, постараюсь ответить.
P. P. S. Прошу обратить внимание на комментарии, в частности на этот
Виртуальный драйвер для обслуживания аппаратных прерываний
Как уже отмечалось ранее, виртуальные драйверы служат прежде всего для виртуализации аппаратуры, то есть для предоставления одновременно выполняемым задачам возможности совместного использования устройств компьютера. Измерительная или управляющая аппаратура, подключаемая к компьютеру с целью создания автоматизированной установки, вряд ли будет эксплуатироваться в многозадачном режиме, однако использование для ее управления виртуального драйвера может заметно сократить программные издержки и уменьшить время отклика. Рассмотрим пример виртуального драйвера, обслуживающего прерывания от описанной в предыдущей статье интерфейсной платы таймера-счетчика.
Очевидно, что в состав такого драйвера должен входить обработчик прерываний от платы. Функции этого обработчика определяет программист; в простейшем случае обработчик может просто прочитать данные из выходного регистра счетчика и замаскировать прерывания. Однако приложение при этом не узнает о завершении измерений; более естественно организовать вызов из обработчика прерываний драйвера некоторой функции приложения, в которую можно передать прочитанные данные. Фактически эта функция будет играть роль обработчика прерывания приложения, однако вызываться она будет не самим прерыванием, а обработчиком драйвера.
Предусмотрим следующую схему взаимодействия приложения и драйвера. Приложение по команде пользователя вызывает драйвер и передает ему константы настройки таймера. Драйвер инициализирует таймер и возвращает управление в приложение, которое продолжает свое выполнение. По сигналу прерывания от таймера приложение приостанавливается и активизируется обработчик прерываний, находящийся в драйвере. В этом обработчике выполняются чтение данных из выходного регистра счетчика, вызов (через VMM) обработчика прерывания приложения и передача в него считанных данных. Обработчик прерываний приложения принимает из драйвера данные и, завершаясь, возвращает управление в VMM, который наконец передает управление в приложение в точку его приостановки.
При разработке приложения, реализующего описанный алгоритм, за основу взят программный комплекс из предыдущей части статьи. В нем используется тот же файл ресурсов, однако программный файл модифицирован.
Приложение Windows, взаимодействующее с виртуальным драйвером обработки аппаратных прерываний
позволяет организовать бесконечный цикл, поскольку условие продолжения цикла, анализируемое оператором while, безусловно удовлетворяется (константа 1 никогда не может стать равной 0).
Фрагмент программы, выполняемый при выборе пользователем пункта меню «Пуск», содержит лишь вызов функции InitCard(). В ней вызовом вложенной функции GetAPIEntry определяется адрес API-процедуры драйвера, а затем, после заполнения ряда регистров параметрами, передаваемыми в драйвер, вызывается эта процедура. В драйвер передаются следующие параметры: селектор сегмента данных приложения, три константы для инициализации платы, а также селектор и смещение обработчика прерываний приложения isr(). Передача в драйвер содержимого сегментного регистра DS (селектора сегмента данных) необходима потому, что при вызове драйвером (точнее, VMM) нашей функции isr() не восстанавливается операционная среда приложения, в частности регистр DS не указывает на поля данных приложения, которые в результате оказываются недоступными. Передав в драйвер содержимое DS, мы сможем вернуть его назад вместе с другими данными, передаваемыми из драйвера в приложение, восстановив тем самым адресуемость данных.
При выборе пользователем пунктов меню «Чтение» или «Выход» выполняются те же действия, что и в предыдущем примере.
По сравнению с предыдущим примером упростилась функция OnDestroy(). Поскольку восстановление маски в контроллере прерываний возложено теперь на драйвер, а исходный вектор мы в этом варианте программы не восстанавливаем, то в функции OnDestroy() лишь вызывается функция Windows PostQuitMessage(), приводящая к завершению программы.
В обработчике прерываний приложения isr() после засылки в регистр DS нашего же селектора сегмента данных, переданного ранее в драйвер и полученного из него в качестве первого параметра функции isr(), выполняется инкремент флага request и пересылка в переменную data второго параметра функции isr() — результата измерений.
Перейдем к рассмотрению программы виртуального драйвера, входящего в состав нашего программного комплекса.
Текст виртуального драйвера, обрабатывающего аппаратные прерывания
В полях данных драйвера зарезервирован ряд ячеек для временного хранения полученных из приложения параметров, а также результата измерений. Особняком стоит ячейка IRQ_Handle, в которой хранится дескриптор виртуального прерывания. Этот дескриптор назначается системой на этапе инициализации драйвера и остается неизменным на все время его жизни, то есть до перезагрузки компьютера.
Макрос VPICD_IRQ_Descriptor позволяет описать в полях данных структуру с информацией о виртуализованном прерывании. Обязательными элементами этой структуры являются номер уровня виртуализуемого прерывания и адрес обработчика аппаратного прерывания (VMyD_Int_13 в нашем случае), включаемый в состав драйвера. Для того чтобы макросы виртуального контроллера прерываний были доступны ассемблеру, к программе необходимо подключить (оператором include) файл VPICD.INC.
Процедура VMyD_Device_Init содержит вызов функции виртуального контроллера прерываний (VPICD) VPICD_Virtualize_IRQ. Эта функция осуществляет виртуализацию указанного уровня прерываний и возвращает дескриптор виртуального прерывания, который сохраняется нами в ячейке IRQ_Handle с целью дальнейшего использования. Функция VPICD_Virtualize_IRQ фактически устанавливает в системе наш обработчик прерываний, имя которого включено нами в структуру VPICD_IRQ_Descriptor. Начиная с этого момента аппаратные прерывания IRQ5 будут вызывать по умолчанию, не обработчик этого уровня, находящийся в VPICD, а наш обработчик. Правда, для этого надо размаскировать уровень 5 в контроллере прерываний, чего мы еще не сделали.
При вызове драйвера из приложения управление передается API-процедуре драйвера API_Handler. В ней прежде всего извлекаются из структуры клиента переданные в драйвер параметры. Поскольку эти параметры (содержимое регистров клиента) хранятся в стеке уровня 0, то есть в памяти, их нельзя непосредственно перенести в ячейки данных драйвера. Для переноса параметров в некоторых случаях мы использовали стек, в других — регистры общего назначения.
Выполнив команду общего сброса программируемой платы, следует размаскировать прерывания в физическом (не виртуальном) контроллере прерываний. Эта операция осуществляется вызовом функции виртуального контроллера VPICD_Physically_Unmask с указанием ей в качестве параметра в регистре EAX дескриптора виртуального прерывания. Далее выполняется уже рассмотренная в предыдущей части статьи процедура инициализации платы (причем значения констант С0, С1 и С2 извлекаются из структуры клиента). После завершения API-процедуры управление возвращается в приложение до поступления аппаратного прерывания.
Аппаратное прерывание виртуализованного нами уровня через дескриптор таблицы прерываний IDT с номером 55h активизирует обработчик прерываний, входящий в состав VPICD, который, выполнив некоторые подготовительные действия (в частности, сформировав на стеке уровня 0 структуру клиента), передает управление непосредственно нашему драйверу, а именно процедуре обработки аппаратного прерывания VMyD_Int_13. Системные издержки этого перехода составляют около 40 команд процессора, то есть время от момента поступления прерывания до выполнения первой команды нашего обработчика составит на компьютере среднего быстродействия 10. 15 мкс.
В процедуре VMyD_Int_13 после выполнения содержательной части (в нашем случае — чтения и запоминания результата измерений) необходимо послать в контроллер прерываний команду EOI, как это полагается делать в конце любого обработчика аппаратного прерывания. Для виртуализованного прерывания это действие выполняется с помощью функции VPICD_Phys_EOI, единственным параметром которой является дескриптор прерывания, сохраненный нами в ячейке IRQ_Handle. Последней операцией является вызов функции VPICD_Physically_Mask, с помощью которой маскируется уровень 5 в физическом контроллере прерываний.
Следует отметить, что названия функций VPICD могут быть обманчивыми. Функция VPICD_Phys_EOI в действительности не разблокирует контроллер прерываний, а размаскирует наш уровень в регистре маски физического контроллера (чего мы, между прочим, не заказывали!). Что же касается команды EOI, то она была послана в контроллер по ходу выполнения фрагмента VPICD еще до перехода на наш обработчик (упомянутые выше 40 команд). Тем не менее вызов функции VPICD_Phys_EOI в конце обработчика прерываний обязателен. Если ею пренебречь, то операционная система будет вести себя точно так же, как если бы в контроллер не была послана команда EOI: первое прерывание обрабатывается нормально, но все последующие — блокируются. Так происходит потому, что при отсутствии вызова функции VPICD_Phys_EOI нарушается работа функции VPICD_Physically_Unmask, которая выполняется у нас на этапе инициализации. Эта функция, выполнив анализ системных полей и обнаружив, что предыдущее прерывание не завершилось вызовом VPICD_Phys_EOI, обходит те свои строки, в которых в порте 21h устанавливается 0 бит нашего уровня прерываний. В результате этот уровень остается замаскированным и прерывания не проходят.
Если обработчик прерываний, включенный в драйвер, выполняет только обслуживание аппаратуры, то на этом его программа может быть завершена. Однако мы хотим оповестить о прерывании приложение, вызвав одну из его функций. VMM предусматривает такую возможность (так называемое вложенное выполнение VM), но для ее реализации следует прежде всего перейти с асинхронного уровня на синхронный.
Проблема заключается в том, что VMM является нереентерабельной программой. Если переход в виртуальный драйвер осуществляется синхронным образом, вызовом из текущего приложения, то, хотя этот переход происходит при участии VMM и, так сказать, через него, в активизированной процедуре виртуального драйвера допустим вызов всех функций VMM. Если же переход в драйвер произошел асинхронно, в результате аппаратного прерывания, то состояние VMM в этот момент неизвестно и в процедуре драйвера допустим вызов лишь небольшого набора функций, относящихся к категории асинхронных. К ним, в частности, относятся все функции VPICD, а также те функции VMM, с помощью которых программа переводится на синхронный уровень (его иногда называют уровнем отложенных прерываний). В справочнике, входящем в состав DDK, указано, какие функции являются асинхронными, и на эту характеристику функций следует обращать внимание.
Идея перехода на уровень отложенных прерываний заключается в том, что в обработчике аппаратных прерываний с помощью одной из специально предназначенных для этого асинхронных функций VMM устанавливается запрос на вызов callback-функции (функции обратного вызова). Эта функция будет вызвана средствами VMM в тот момент, когда переход на нее не нарушит работоспособность VMM. Вся эта процедура носит название «обработка события».
В понятие «событие» входит не только callback-функция, но и набор условий, при которых она может быть вызвана или которыми должен сопровождаться ее вызов. Так, например, можно указать, что callback-функцию можно вызвать только вне критической секции или что вызов callback-функции должен сопровождаться повышением приоритета ее выполнения. Кроме того, при установке события можно определить данное (двойное слово), которое будет передано в callback-функцию при ее вызове. В составе VMM имеется целый ряд функций установки событий, различающихся условиями их обработки, например Call_When_Idle, Call_When_Not_Critical, Call_Restricted_Event, Schedule_Global_Event, Schedule_Thread_Event и др. Необходимо подчеркнуть, что момент фактического вызова callback-функции заранее определить невозможно. Она может быть вызвана немедленно либо спустя некоторое время, когда будут удовлетворены поставленные условия.
В нашем случае специальные условия отсутствуют и переход на синхронный уровень можно выполнить с помощью простой функции Call_VM_Event, в качестве параметра которой указывается 32-битовое смещение функции обратного вызова, располагаемой в тексте виртуального драйвера. В рассматриваемом примере эта функция названа Reflect_Int.
Команда ret, которой заканчивается обработчик прерываний виртуального драйвера, передает управление VMM, который в удобный для него момент времени вызывает функцию Reflect_Int (реально до вызова может пройти 50. 200 мкс). В этой функции вызовом Push_Client_State исходная структура клиента еще раз сохраняется на стеке уровня 0, после чего функцией Begin_Nest_Exec открывается блок вложенного выполнения. Внутри этого блока можно, во-первых, организовать переход на определенную функцию приложения, а во-вторых, создать условия для передачи ей требуемых параметров. Передача параметров осуществляется в соответствии с установленным интерфейсом используемого языка программирования. Поскольку наше приложение написано на языке Си, для его функций действуют правила этого языка: параметры передаются функции через стек, причем расположение параметров в стеке должно соответствовать их перечислению в прототипе и заголовке функции, то есть в глубине стека должен находиться последний параметр, а на вершине стека — первый (в функциях типа «Паскаль», в частности во всех системных функциях Windows, действует обратный порядок передачи параметров).
Помещение параметров в стек текущей VM осуществляется функцией VMM Simulate_Push, которая может проталкивать в стек как одинарные, так и двойные слова. В нашем случае в стек помещаются два слова — результат измерений и селектор сегмента данных приложения.
Следующая операция — подготовка вызова требуемой функции приложения. Эта операция осуществляется с помощью функции VMM Simulate_Far_Call, которая помещает передаваемые ей в качестве параметров селектор и смещение требуемой функции приложения в поля структуры клиента Client_CS и Client_IP. В результате, когда VMM, передавая управление приложению, снимет со стека структуру клиента и выполнит переход по оставшимся в стеке значениям Client_CS и Client_IP, то в регистрах CS:IP окажется адрес интересующей нас функции, которая и начнет немедленно выполняться. Для того чтобы не потерять то место в приложении, на котором произошла его приостановка из-за прихода прерывания, текущее содержимое полей Client_CS и Client_IP сохраняется в созданной перед этим копии структуры клиента.
Наконец, вызовом Resume_Exec управление передается в приложение. Еще раз подчеркнем, что этот вызов функции приложения является вложенным в VMM и что возможности вызываемой функции весьма ограниченны. Фактически она работает в чуждой для приложения операционной среде. В частности, как уже отмечалось, содержимое сегментных регистров (кроме CS) не соответствует сегментам приложения. Для того чтобы функция isr() могла обратиться к глобальным переменным приложения (адресуемым через регистр DS), мы передаем ей селектор сегмента данных приложения.
Вернемся ненадолго к тексту приложения. Функция isr(), которую мы вызываем из драйвера, имеет следующий вид:
Поскольку мы в драйвере протолкнули в стек сначала данные Data, а затем селектор DSseg, они расположились в стеке приложения в правильном с точки зрения этой функции порядке, поэтому она может обращаться к своим локальным переменным segment и dt, как если бы была вызвана обычным образом оператором:
После завершения функции isr() управление возвращается в VMM, а из него в драйвер на команду, следующую за вызовом Resume_Exec. Этот переход может потребовать пары сотен команд и нескольких десятков микросекунд.
Отложенная процедура драйвера завершается очевидными вызовами End_Nest_Exec — окончания вложенного блока выполнения и Pop_Client_State — восстановления структуры клиента.
Рассмотрим изменения, вносимые при использовании такого метода.
Приложение Windows, обрабатывающее аппаратные прерывания
В приведенном выше тексте файла .CPP детально показаны только измененные участки программы.
В функции OnCommand() удален фрагмент, связанный с пунктом меню «Чтение» (идентификатор MI_READ), поскольку в этом пункте уже нет необходимости.
Посмотрим теперь, как изменится программа драйвера.
Программа драйвера для обслуживания аппаратных прерываний
В сегменте данных удалены ячейки для адреса функции обратного вызова isr и селектора DS. Ячейка для результата измерений объявлена как два слова, поскольку все параметры функции Shell_PostMessage имеют размер 32 бит. Добавлена ячейка hwnd для получения в нее из приложения дескриптора главного окна. Сам дескриптор имеет размер 16 бит, однако передавать его той же функции Shell_PostMessage надо в виде длинного слова.
В начале API-процедуры из структуры клиента (конкретно — из регистра DI) извлекается дескриптор окна и после расширения до длинного слова помещается в ячейку hwnd.
Остальные изменения касаются лишь способа перехода на уровень отложенных прерываний и состава процедуры ReflectInt, работающей на этом уровне.
Для задач управления аппаратурой, работающей в режиме прерываний, важной характеристикой является время отклика на прерывание, то есть временная задержка от момента поступления прерывания до выполнения первой команды обработчика. Как мы увидели, при использовании виртуального драйвера системные издержки перехода на прикладной обработчик, включенный в состав драйвера, составляют около 40 команд, на выполнение которых на машине средней производительности может понадобиться 10. 15 мкс. При использовании системы MS-DOS этих издержек не было бы вовсе, так как в реальном режиме переход на обработчик прерываний процессор осуществляет практически мгновенно. Если же реализовать обработку прерываний без помощи виртуального драйвера, как это было сделано в предыдущей части статьи, то переход на прикладной обработчик прерываний потребовал бы 200. 300 команд, а время задержки увеличилось бы (на таком же компьютере) до 120. 180 мкс, то есть более чем на порядок.
Работа операционных систем Windows 95/98 (как и Windows 3.1) в значительной степени основана на использовании специального рода программ — так называемых виртуальных драйверов устройств (или просто виртуальных драйверов, virtual device). Основное назначение виртуального драйвера — виртуализация устройства, то есть возможность нескольким приложениям одновременно использовать одно и то же физическое устройство. Например, виртуальный драйвер дисплея (VDD) обеспечивает многооконный режим, в котором каждое приложение, выводя информацию на экран, считает, что весь физический экран находится в его распоряжении, в то время как в действительности вывод приложения поступает в выделенное для него окно. Виртуальный контроллер прерываний (VPICD) дает возможность нескольким приложениям совместно использовать единую систему прерываний компьютера. Виртуальный драйвер клавиатуры (VKD) позволяет вводить с клавиатуры символьные строки в любую из выполняемых программ. Разработка нового виртуального драйвера может понадобиться при установке на компьютер новой аппаратуры (или нового программного обеспечения, предназначенного для обслуживания других приложений), которая будет использоваться в многозадачном режиме и для которой в системе Windows не предусмотрено средств виртуализации.
Другое, возможно, более важное для прикладного программиста приложение виртуальных драйверов состоит в использовании их в качестве универсального инструментального средства. Дело в том, что виртуальный драйвер работает в плоской модели памяти на нулевом уровне привилегий. Плоской моделью памяти называется такая организация адресного пространства, когда в дескрипторе указаны нулевой базовый линейный адрес сегмента и предел, соответствующий максимальному размеру сегмента, — 4 Гбайт. Такой дескриптор может входить как в локальную, так и в глобальную таблицу дескрипторов. В первом случае речь идет о приложении, работающем в плоской модели памяти на уровне привилегий 3 (это характерно для 32-разрядных приложений Windows); во втором — о системных компонентах, в частности о виртуальных драйверах. Для виртуального драйвера доступно все линейное адресное пространство (4 Гбайт), все программные составляющие Windows (в частности, системные виртуальные драйверы) и все программно-управляемые аппаратные средства процессора и компьютера в целом. Виртуальный драйвер представляет собой идеальную среду для исследования системы Windows и контролируемого вмешательства в ее работу. Поэтому решение каких-либо нестандартных задач, например разработка программ управления измерительным оборудованием, подключенным к компьютеру, может потребовать написания специфических виртуальных драйверов, смысл использования которых состоит совсем не в виртуализации аппаратно-программных средств, а в возможности выполнения действий, допустимых лишь на нулевом уровне привилегий (например, обращения к запрещенным портам или к системе прерываний).
Одним из базовых понятий системы Windows является понятие виртуальной машины. Согласно документации Microsoft, виртуальной машиной (VM) называется выполняемая задача, состоящая из приложения, поддерживающего программное обеспечение (например, программы DOS и BIOS), памяти и регистров процессора. Каждая виртуальная машина обладает собственным адресным пространством, пространством ввода-вывода и таблицей векторов прерываний. В адресное пространство VM отображаются ПЗУ BIOS, драйверы и другие программы DOS, а также загруженные до запуска Windows резидентные программы, что обеспечивает доступ к ним прикладных программ, выполняемых под управлением Windows. В состав виртуальной машины входят виртуальные аппаратные регистры, в частности виртуальные маски прерываний, виртуальные флаги процессора и другие.
Первая виртуальная машина, создаваемая после загрузки Windows и называемая системной виртуальной машиной, в Windows 95/98 предназначена для выполнения 16- и 32-разрядных приложений Windows. Для каждого сеанса DOS создается своя виртуальная машина, и таким образом одновременно в системе может существовать несколько виртуальных машин.
Управление виртуальными машинами возлагается на менеджера виртуальных машин (Virtual machine manager, VMM), являющегося ядром операционных систем Windows 95/98. VMM представляет собой 32-разрядную систему защищенного режима, работающую на нулевом уровне привилегий в плоской модели памяти. В функции VMM входит создание, управление и завершение виртуальных машин, а также управление памятью, процессами, прерываниями и нарушениями защиты. Так, если приложение делает попытку записи или чтения по адресам памяти, не принадлежащим данной виртуальной машине, или выполняет команды ввода-вывода в запрещенные порты, процессор возбуждает исключение и управление передается VMM. VMM анализирует причину исключения и либо с помощью виртуальных драйверов обеспечивает выполнение затребованной операции, либо аварийно завершает приложение.
Для обеспечения функционирования VMM и виртуальных драйверов система создает два глобальных селектора: 28h — для программных кодов и 30h — для системных полей данных. Дескрипторы этих селекторов почти одинаковы: в них указано нулевое значение DPL, нулевой базовый линейный адрес и граница 4 Гбайт (рис. 1). В регистр CS загружается селектор 28h, во все остальные сегментные регистры (DS, SS, ES, FS и GS) — селектор 30h. Ни VMM, ни виртуальные драйверы никогда не изменяют содержимое сегментных регистров.
VMM вместе с системными виртуальными драйверами предоставляет большое количество служебных функций, позволяющих виртуальным драйверам активизировать по ходу своего выполнения те или иные системные процедуры и алгоритмы. Например, функция VMM _MapPhysToLinear отображает заданный участок физических адресов на линейное адресное пространство, что дает возможность виртуальному драйверу выполнять чтение или запись в физической памяти; функции Install_Handler и Install_Mult_IO_Handlers устанавливают в системе прикладные процедуры обслуживания заданных портов; функция Simulate_Push помещает указанный параметр в стек приложения. Служебные функции имеются и у системных виртуальных драйверов. Так, виртуальный контроллер прерываний VPICD позволяет с помощью функции VPICD_Virtualize_IRQ организовать прикладную обработку аппаратных прерываний, а виртуальный таймер VTD предоставляет функции VTD_Begin_Min_Period и VTD_End_Min_Period для управления частотой системного таймера. Служебные функции VMM и системных виртуальных драйверов, в отличие от функций DOS или Windows, не могут вызываться непосредственно из приложения; обращение к ним возможно только из виртуальных драйверов.
Взаимодействуя с VMM и виртуальными машинами, виртуальные драйверы используют целый ряд системных полей и структур данных. Одним из важнейших системных идентификаторов, создаваемых VMM для каждой виртуальной машины, является ее дескриптор. Он однозначно идентифицирует виртуальную машину и используется при вызовах функций VMM для задания VM, к которой обращено требуемое действие. Заметим, что дескриптор виртуальной машины является базовым адресом, от которого ведется отсчет адресов ряда важных полей VMM. Так, в двойном слове по адресу [дескриптор VM — 8] располагается адрес таблицы векторов прерываний защищенного режима.
Дескриптор VM фактически является линейным 32-разрядным адресом управляющего блока данной VM. Управляющий блок представляет собой структуру из пяти двойных слов, содержащую информацию о виртуальной машине. Эта структура определена в файле VMM.INC и имеет следующий вид:
В слове состояния VM каждый бит закреплен за определенной характеристикой текущего состояния VM. Так, установленный бит 1 (символическое обозначение VMStat_Background) говорит о том, что VM выполняется в фоновом режиме; бит 6 (VMStat_PM_App) свидетельствует о наличии в VM приложения защищенного режима, бит 7 (VMStat_PM_Use32) характеризует 32-разрядное приложение защищенного режима и т.д. Иногда в процессе отладки виртуального драйвера приходится обращаться к этому слову для определения состояния виртуальной машины.
Старший линейный адрес заслуживает отдельного рассмотрения. При запуске в рамках Windows одного или нескольких сеансов DOS (и создания соответствующих виртуальных машин) происходит копирование первого мегабайта физического адресного пространства в различные участки расширенной памяти. В каждой копии DOS имеется своя таблица векторов прерываний, своя видеопамять и даже своя копия ПЗУ BIOS, что дает возможность приложениям DOS различных сеансов параллельно и независимо работать каждому с собственной памятью, не разрушая память других сеансов. Для того чтобы с сеансами DOS могли взаимодействовать VMM или виртуальные драйверы, физическая память сеансов DOS отображается на некоторые линейные адреса (4-го гигабайта линейного адресного пространства), которые и называются старшими линейными адресами. Для каждой VM существует собственный старший адрес (рис. 2).
Сами программы DOS могут работать только в первом мегабайте линейных адресов (вспомним, что в реальном режиме физические адреса совпадают с линейными, а DOS располагается в первом мегабайте физических адресов). Поэтому для активного в настоящий момент сеанса DOS его физические адреса отображаются еще и на первый мегабайт линейных. В результате любое активное приложение DOS, работающее в пределах первого мегабайта линейных адресов, обращается только к собственной копии DOS, входящей в соответствующую виртуальную машину. В то же время отображение на старшие линейные адреса действует всегда, что позволяет виртуальным драйверам обращаться к физическому адресному пространству любой виртуальной машины; более того, виртуальные драйверы имеют право взаимодействовать с сеансами DOS исключительно через старшие линейные адреса. При этом линейный адрес любой адресуемой ячейки определяется суммированием ее физического адреса со старшим линейным адресом соответствующей VM.
Структура клиента, адрес которой CB_Client_Pointer входит в управляющий блок, является важнейшим набором данных, обеспечивающим обмен информацией между приложением и виртуальным драйвером. Когда при вызове виртуального драйвера процессор переходит на нулевой уровень привилегий, в стеке уровня 0 (не в стеке приложения!) сохраняются значения всех регистров вызывающего приложения, соответствующие точке вызова драйвера. Совокупность этих значений и называется структурой клиента. При входе в драйвер адрес структуры клиента находится в регистре EBP, а обращение к отдельным элементам этой структуры осуществляется с помощью наглядных символических обозначений вида Client_AX, Client_FLAGS, Client_CS и т. д.:
Возможно также обращение как к байтовым (Client_AL, Client_DH), так и к 32-разрядным (Client_EFLAGS, Client_ESI) регистрам. Структура клиента является чрезвычайно удобным средством обмена информацией между приложением и драйвером, поскольку регистры приложения не только сохраняются в структуре клиента при переходе в драйвер, но и восстанавливаются из нее при возврате в приложение. Поэтому изменение значений в структуре клиента в процессе выполнения программы драйвера приведет к соответствующему изменению регистров процессора после возврата в приложение.
Виртуальные драйверы обычно пишутся на языке ассемблера, хотя внутренние процедуры драйвера вполне могут составляться на языке С (С++). Использование языка высокого уровня упрощает реализацию вычислительных и логических алгоритмов в процедурах драйвера, однако делает текст драйвера менее наглядным. Поскольку нашей задачей является освоение основных принципов разработки драйверов и приводимые ниже примеры относительно просты, мы ограничимся языком ассемблера. Для создания выполнимого модуля необходимо использовать 32-разрядные ассемблер и компоновщик, входящие, в частности, в пакет DDK (Device Development Kit — пакет для разработки драйверов). В том же пакете можно найти заголовочные и другие используемые при разработке драйверов, включаемые файлы с многочисленными макросами и определениями констант.
Прежде всего рассмотрим общую структуру виртуального драйвера, а также правила его подготовки и загрузки. Ниже приведен текст простейшего виртуального драйвера для систем Windows 95/98, который не выполняет никакой работы, хотя и может быть установлен в системе как полноценный драйвер.
Структура виртуального драйвера
Исходный текст драйвера начинается с директивы ассемблера .386, позволяющей использовать расширенный набор команд современных процессоров, в частности привилегированных команд защищенного режима. Следующая далее директива .XLIST подавляет вывод в листинг трансляции всех последующих предложений программы вплоть до отмены ее действия директивой .LIST. В нашем случае подавляется вывод в листинг весьма обширного файла VMM.INC с определениями макросов и констант, фактически обеспечивающего использование в драйвере средств менеджера виртуальной машины VMM.
Как видно из приведенного листинга, драйвер в простейшем случае состоит из блока описания драйвера и трех сегментов: сегмента команд инициализации в реальном режиме VxD_REAL_INIT, в котором размещается процедура инициализации реального режима (вместе с относящимися к ней данными); сегмента данных VxD_DATA_SEG со всеми данными, которые могут потребоваться драйверу в процессе его работы; сегмента команд защищенного режима VxD_CODE, содержащего в нашем случае процедуры VMyD_Control, V86_API_Handler и V86_API_Handler (имена процедур произвольны).
В тексте драйвера широко используются макросы, определенные в файле VMM.INC. Так, макросы VxD_REAL_INIT_SEG и VxD_REAL_INIT_ENDS задают начало и конец сегмента инициализации реального режима, макросы VxD_CODE_SEG и VxD_CODE_ENDS — начало и конец сегмента защищенного режима, а макросы BeginProc и EndProc — границы процедур, входящих в состав драйвера. Имена макросов можно набирать как строчными, так и прописными буквами. Приведенное в примере начертание взято из документации Windows.
Блок описания драйвера, с которого начинается текст драйвера, вводится с помощью макроса Declare_Virtual_Device. Этот макрос требует указания восьми параметров, располагаемых в следующем порядке:
- имя драйвера;
- номер версии и подверсии;
- имя управляющей процедуры;
- идентификационный номер драйвера;
- порядок инициализации;
- имя процедуры обслуживания реального режима;
- имя процедуры обслуживания защищенного режима.
В нашем случае драйверу дано произвольное имя VMyD, управляющей процедуре (которая обязательно должна присутствовать, хотя у нас она фактически пуста) — VMyD_Control, процедурам обслуживания реального и защищенного режимов, которые в дальнейшем мы будем называть API-процедурами, — V86_API_Handler и PM_API_Handler соответственно. Для версии выбран номер 1.0, а для идентификационного кода — 8000h. При разработке коммерческого виртуального драйвера его следует зарегистрировать в корпорации Microsoft и получить для него идентификационный код. Порядок инициализации важен для драйверов, обращающихся друг к другу; для наших драйверов порядок инициализации не имеет значения, что указано с помощью константы Undefined_Init_Order.
API (Application Program Interface — интерфейс прикладных программ) процедуры являются, можно сказать, содержательной частью драйвера. Эти процедуры, выполняемые на нулевом уровне привилегий в плоской модели памяти, могут быть вызваны из любой прикладной программы (выполняемой, естественно, на третьем уровне защиты). Для обращения к драйверу из приложений DOS (реальный режим) и Windows (защищенный режим) предусматриваются две различные API-процедуры; если действия драйвера в ответ на обращение программ в обоих случаях одинаковы, в соответствующих полях блока описания драйвера можно указать имя единственной процедуры. В некоторых случаях драйвер работает «сам по себе», без обращения к нему из программы пользователя, тогда как и сами процедуры, и их имена в блоке описания драйвера отсутствуют. В дальнейшем мы будем рассматривать только драйверы, предназначенные для работы с приложениями Windows, и в блоке описания драйвера имя API-процедуры реального режима будет отсутствовать:
Для ускорения процесса подготовки выполнимого модуля виртуального драйвера удобно создать пакет из трех файлов. Приводимый ниже пример их содержимого предполагает, что пакет DDK установлен на диске G: в каталоге DDK95, исходный файл с текстом драйвера имеет имя VMYD.ASM, а выполнимый модуль драйвера — VMYD.VXD.
Для того чтобы в операторе include в исходном тексте драйвера не описывать длинный путь к файлу VMM.INC, можно в файл AUTOEXEC.BAT включить команду
Загружаемый модуль драйвера подготавливается запуском в сеансе DOS приведенного выше командного файла W95.BAT.
Установка созданного драйвера в системе осуществляется путем включения в раздел [386Enh] файла SYSTEM.INI следующей строки (в предположении, что программа драйвера находится в каталоге DRIVERS диска F:):
Первая статья про драйверы была уж совсем вводной, и мне подумалось, что её нельзя не дополнить рассказом про то, как устроены драйверы более современных устройств.
Для начала введём определение bus master: устройство, способное быть не только ведомым, но и ведущим на шине компьютера. То есть — не только отвечать на транзакции ввода-вывода, инициированные процессором, но и самостоятельно их инициировать — по собственной инициативе «ходить» в память.
История таких устройств уходит корнями в понятие DMA: ещё во времена прародителя микропроцессоров, микропроцессора 8080 (КР5080ИК80), появилось понимание, что процессор хорошо бы разгрузить от рутинной операции перетаскивания байтиков между устройствами в-в и памятью.
Контроллер DMA (Direct Memory Access) был внешней по отношению к устройству ввода-вывода подсистемой, которую нужно было явно запрограммировать — установить тип операции (пишем в память, читаем из памяти, копируем память-память), адрес(а) памяти, и пр. Собственно, я совершенно несправедливо пишу об этом в прошедшем времени — всё это вполне существует и сейчас, например, в микроконтроллерах.
Уже в режиме DMA работа драйвера выглядит сущственно иначе — от драйвера требуется не выполнять ввод-вывод, а подготовить настройки для устройства, активировать его, дождаться прерывания по окончанию ввода-вывода, и проверить успешность операции. Всё сказанное в предыдущей статье верно и для DMA устройств, но в дополнение к сказанному, драйвер должен понимать схему взаимодействия устройства и DMA контроллера, а иногда и явно аллоцировать и настраивать контроллер: если в старых устройствах привязка порта ввода-вывода к контроллеру DMA делалась фиксированно, то сейчас во многих случаях возможен полный роутинг или выбор канала DMA из 2-4 вариантов.
Отдельно следует заметить, что сама инициация очередной транзакции ввода-вывода может быть автоматической (DMA лупит с максимально возможной скоростью), автоматической с настройкой темпа (чтобы не съесть всю пропускную способность шины) или по событию.
При этом событием в развитых системах может быть прерывание, просто изменение состояния ножки микроконтроллера, или же источником события может быть другое устройство. Например, таймер. Это позволяет сопрячь воедино ЦАП, DMA engine и таймер так, чтобы подача очередного байта в ЦАП происходила с заданной (таймером) частотой. Есть и другие варианты агрегирования устройств, например, включение одного канала DMA по окончании работы другого. Без привлечения внимания процессора.
Уместно также сказать, что DMA контроллеры иногда умеют явно сопрягать пару каналов, чтобы обеспечивать непрерывность потока данных по окончании работы одного канала запускается второй и генерируется прерывание, по которому процессор снова загружает работой первый канал — для того же ЦАП это может быть жизненно важно.
Вернёмся из мира контроллеров во «взрослые» машины. Большинство современных подсистем ввода-вывода уже не базируются на внешнем DMA, а имеют его аналог прямо внутри.
Это устройства с режимом «мастер шины», bus master.
Проще всего их представить именно как устройства, имеющие встроенный в себя, личный и оптимально устроенный контроллер DMA.
Обычно такие устройства управляются через дерево дескрипторов в памяти: у устройства есть специальный регистр, в который процессор помещает адрес структуры в памяти, которая содержит задание для контроллера. Или, чаще, массив или список структур с такими заданиями. Контроллер самостоятельно читает из памяти задания и выполняет их шаг за шагом. Задание, как правило, состоит из идентификатора операции, адреса в памяти, где брать данные и дополнительных параметров, необходимых для выполнения операции. Например: < запись на диск, адрес на диске, адрес буфера в памяти >. Так устроены современные контроллеры всего: диска, USB, сетевого интерфейса.
Кроме структуры дескрипторов для такого устройства требуются ещё инструменты для обмена событиями: процессор должен уметь сообщить, что изменил или дополнил дескрипторы, а устройство — что закончило ввод-вывод частично или полностью. Второе выполняется, естественно, через прерывания, а для первого часто применяется регистр (дверной звонок — doorbell), в который процессор «стучится», чтобы обратить внимание устройства на изменения.
При этом есть опасность изменить именно то, что устройство сейчас обрабатывает, что накладывает дополнительные ограничения на структуру драйвера.
Отдельно в этом ряду стоят virtio устройства. Появились они как следствие победного шествия гипервизоров по миру. Традиционно гипервизор предлагал гостевой ОС некоторый набор виртуальных устройств, которые копировали то или иное популярное физическое устройство. Известную сетевую карту или дисковый контроллер. Но эмулировать «железное» устройство тяжело, неудобно и муторно — зачастую, приходится поддерживать совершенно не нужные в виртуальном мире свойства, при этом для целей виртуализации структура реального железного устройства, обычно, неоптимальна.
Это и навело авторов на то, чтобы спроектировать заведомо виртуальные устройства, которые никогда не будут (never say never © 007) реализованы в железе и нужны исключительно для общения гипервизора и гостевой ОС. Они созданы так, чтобы для большого числа разнотипных устройств можно было реализовать общую единообразную инфраструктуру, как в ядре гостевой ОС, так и в гипервизоре.
По сути драйвер virtio — это транспорт пакетов с запросами и ответами между гостевой ОС и гипервизором. Содержание пакета специфично для типа драйвера и его режима. Например, для сетевой карты это адрес пакета ethernet, а для диска — scatter-gather дескриптор с указанием типа дисковой операции и адреса на диске.
Кроме того, стандарт virtio поддерживает возможность стандартным образом ядру и устройству договариваться о режиме работ и поддерживаемых функциях. Например, сетевой драйвер virtio может уметь или не уметь считать и вставлять в отправляемые IP-пакеты контрольную сумму.
Нетрудно видеть, что стандарт virtio описывает довольно типовой обобщённый драйвер bus master устройства: мы передаём «устройству» запрос с адресом в памяти и параметрами запроса ввода-вывода, остальное происходит асинхронно.
На фоне вышесказанного говорить про DPC уже не так актуально, но раз в комментариях возникла дискуссия — дам краткое описание.
В некоторых ОС (Фантом «срисовал» это с NT, откуда они срисовали — не знаю) существует штатная поддержка запуска кода внутри «лёгких» нитей — Deferred Procedure Call. Это позволяет понизить время нахождения драйвера в прерывании: хендлер прерывания лишь фиксирует событие и, как максимум, считывает из устройства статус — один регистр. Остальное делается в DPC, которая быстро активируется и доделывает начатое.
Откровенно сказать, смысла в этом не так уж много — проще запустить в драйвере его собственную нить высокоприоритетную и делать ввод-вывод из неё. Однако, могут быть варианты. Можно выделить DPC группу приоритетов и гарантировать им приоритет всегда более высокий, чем у нити. Можно обеспечить сверхбыстрый шедулинг и передавать таким нитям процессор прямо сразу на выходе именно из того прерывания, которое этот DPC запросило, снизив латентность.
Отдельно отметим, что из DPC можно многое из того, что в прерывании нельзя. Что именно — зависит от ОС. В Фантоме внутри DPC можно всё, в том числе и заснуть на месяц. Грех, но — можно. NT, ЕМНИП, всё же, как-то ограничивает права DPC (то есть, это не обычные нити), но деталей я не помню.
Читайте также: