Интернет-магазин

Просмотр корзины
В корзине:

товаров - 0 шт.



§ 19. Программирование драйверов: Пишем первый драйвер. Часть 2.

Дмитрий Иванов, 18 Ноября 2008 года
Статья доработана и обновлена 14 Мая 2014

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

В самом начале с помощью оператора #include подключаем файл htddk.h, в котором находятся необходимые определения констант, типов переменных, прототипов функций и макросов, используемых в исходных текстах программ драйверов. Некоторая доля этих определений входит в другие заголовочные файлы, на которые имеются ссылки в файле NTDDK.H. Два следующих предложения программы служат для задания символических имен (в данном случае NT_DEVICE_NAME и WIN32_DEVICE_NAME) текстовым строкам с именами объекта устройства, который будет создан нашим драйвером. Вопрос об объекте устройства и его именах будет подробнее рассмотрен ниже.

Предложения вида

#define IOCTL_READ CTL_CODE (FILE_DEVICE_UNKNOWN, 0x800, METHOD_BUFFERED, FILE_ANY_ACCESS)

позволяют определить коды действий, выполняемых драйвером по запросам приложения. Обращение приложения к драйверу, независимо от цели этого обращения, осуществляется с помощью единой функции DeviceIoControl(). Для того чтобы приложение могло запросить у драйвера выполнение конкретного действия (из числа предусмотренных в драйвере), в качестве одного из параметров этой функции выступает код действия (в нашем случае IOCTL_READ или IOCTL_WRITE). Процедура драйвера, вызываемая функцией Windows DeviceIoControl(), должна проанализировать поступивший в драйвер код действия и передать управление на соответствующий фрагмент драйвера. Коды действия, называемые в документации DDK NT управляющими кодами ввода-вывода (I/O control codes), строятся по определенным правилам. Каждый код представляет собой слово длиной 32 бита, в отдельных полях которого размещаются компоненты кода.

Файловый флаг устанавливается в случаях, когда пользователь создает новые коды действия для файловых устройств. Все наши устройства нефайловые, и этот бит должен быть сброшен. В поле "Тип устройства" помещается предопределенная константа, характеризующая устройство (FILE_DEVICE_CD_ROM, FILE_DEVICE_MOUSE и др.). В нашем случае можно использовать константу FILE_DEVICE_UNKNOWN, равную 0x22. Поле доступа определяет запрашиваемые пользователем права доступа к устройству (чтение, запись, чтение и запись). Мы будем использовать константу FILE_ANY ACCESS, равную нулю. Функциональный код может принимать произвольное значение в диапазоне 0x800...OxFFF (значения 0x000...0x7FF зарезервированы для кодов Microsoft). В рассматриваемом примере используем два кода действия. Для первого из них выбран функциональный код, равный 0x800, для следующего 0x801. Кодов действий может быть больше и им будут присваиваться функциональные коды 0x802, 0x803 и т. д. Тип передачи определяет способ связи приложения с драйвером. Для драйверов физических устройств, выполняющих пересылки незначительных объемов данных без использования канала прямого доступа, в качестве типа передачи обычно используется константа METHOD_BUFFERED, равная нулю. Такой выбор константы определенным образом задает местоположение системного буфера, через который пересылаются данные. В дальнейшем этот вопрос будет рассмотрен подробнее.

Код действия можно сформировать "вручную", как это мы сделаем в будущем в нашем пользовательском приложении:

#define IOCTL_ADDR (0х800<<2) || (0х22<<16)

Легко сообразить, что в этом случае предполагаются константы FILE_DEVICE_UNKNOWN=0x22, METHOD_BUFFERED=0 и FILE_ANY_ACCESS=O при значении функционального кода 0x800. В программе драйвера для формирования кода действия использован макрос CTL_CODE, который определен в файле NTDDK.H. Этот макрос позволяет обойтись без детального знания формата кода действия и значений конкретных констант.

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

Программная часть драйвера начинается с обязательной функции с именем DriverEntry(), которая автоматически вызывается системой на этапе загрузки драйвера и должна содержать все действия по его инициализации. В первых строках функции определяются используемые в ней данные - указатель на объект устройства типа PDEVICE_OBJECT и две символьные строки типа UNICODE_STRING с именами устройства. В качестве первого параметра функция получает указатель еще на один объект, именно на объект драйвера типа PDRIVER_OBJECT. О каких объектах идет речь и почему объект устройства имеет два имени? Windows NT является объектно-ориентированной системой. Каждый компонент системы представляет собой объект, включающий в себя необходимые для его функционирования структуры данных и наборы функций. Некоторые из этих функций служат для внутреннего использования данным объектом, другие же являются экспортируемыми, т.е. доступными другим объектам. Системные компоненты общаются друг с другом не напрямую, а исключительно с помощью экспортируемых объектами функций. Типы объектов Windows, т.е. состав входящих в них структур данных и функций, известны заранее, однако сами объекты (или, как говорят, экземпляры объектов) создаются динамически по мере возникновения в них необходимости. При загрузке драйвера система создает объект драйвера (driver object), олицетворяющий для системы образ драйвера в памяти. С другой стороны, объект драйвера представляет собой структуру, содержащую необходимые для функционирования драйвера данные и адреса (указатели) функций.

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

Системные программы взаимодействуют с объектом устройства, созданным драйвером, посредством указателя на него. Однако для прикладной программы объект устройства представляется одним из файловых объектов и обращение к нему осуществляется по имени. Вот это-то имя, в нашем случае MYDRIVER, и следует определить в программе драйвера. Дело усугубляется тем, что объект устройства должен иметь два имени, одно в пространстве имен NT, другое - в пространстве имен Win32. Оба эти имени должны, во-первых, быть определены с помощью кодировки Unicode, в которой под каждый символ выделяется не 1, а 2 байта, и, во-вторых, представлять собой не просто символьные строки, а специальные структуры типа UNICODE_STRING, в которые входят помимо самих строк еще и их длины ("структуры со счетчиками"). Кодировка Unicode задается с помощью символа L, помещаемого перед символьной строкой в кавычках, а преобразование строк символов в структуры типа UNICODE_STRING осуществляется вызовами функции RtlInitUnicodeString(), которые можно найти далее по тексту программы драйвера.

Имена объектов устройств составляются по определенным правилам. NT-имя предваряется префиксом \Device\, a Win32-имя- префиксом \??\ (или \DosDevice\). При указании имен в Си-программе знак обратной косой черты удваивается. Для того чтобы указанное в программе драйвера имя можно было использовать в приложении для открытия устройства, следует создать символическую связь между обоими заданными именами устройства. Эта связь создается функцией IoCreateSymbolicLink(), которой в качестве параметров передаются оба имени. Следующая обязательная операция - создание объекта устройства - осуществляется вызовом функции IoCreateDevice(), принимающей ряд параметров. Первый параметр, указатель на объект драйвера, поступает в функцию DriverEntry() при ее вызове из Windows (см. заголовок функции DriverEntry). Второй параметр определяет размер так называемого расширения устройства - области, служащей для передачи данных между функциями драйвера. В рассматриваемом драйвере расширение устройства не используется и на месте этого параметра указан 0. В качестве третьего параметра указывается созданное нами ранее NT-имя устройства. Наконец, последний параметр этой функции является выходным - через него функция возвращает указатель (типа DEVICE_OBJECT) на созданный объект устройства.

Последнее, что надо сделать на этапе инициализации драйвера, - это занести в объект драйвера адреса основных функций, включенных программистом в текст драйвера. Под основными функциями мы будем понимать те фрагменты драйвера, которые вызываются системой автоматически в ответ на определенные действия, выполняемые приложением или устройством. В наших примерах таких действий будет три: получение дескриптора драйвера функцией CreateFile(), запрос к драйверу на выполнение требуемого действия функцией DeviceIoControl() и закрытие драйвера функцией CloseHandle(). В более сложных драйверах основных функций может быть больше (вплоть до приблизительно трех десятков). Для хранения адресов основных функций в объекте драйвера предусмотрен массив (с именем MajorFunction) указателей на функции типа PDRIVERDISPATCH. В файле NTDDK.H определены символические смещения элементов этого массива. Так, в первом элементе массива (смещение IRP_MJ_CREATE=O) должен размещаться указатель на функцию, которая вызывается автоматически при выполнении в приложении функции CreateFile(). В элементе со смещением IRP_MJ_CLOSE=2 размещается указатель на функцию, вызываемую при закрытии устройства (функцией CloseHandle()). Наконец, в элементе со смещением IRP_MJ_DEVICE_CONTROL=0x0E должен находиться адрес функции диспетчеризации, которой система передает управление в ответ на вызов в выполняемом приложении Windows функции DeviceIoControl() с указанием кода требуемого действия. Назначение функции диспетчеризации - анализ кодов действий, направляемых в драйвер приложением, и осуществление переходов на соответствующие фрагменты драйвера. В рассматриваемом примере три упомянутые функции имеют (произвольные) имена CtlCreate, CtlClose и CtlDispatch. Структура нашего драйвера с указанием его функций точек входа приведена на рис. ниже.

Массив MajorFunction является одним из элементов структурной переменной. Если бы эта структура была объявлена в программе с указанием ее имени (пусть это имя будет DriverObject), то для обращения к элементу структуры с индексом 0 следовало бы использовать конструкцию с символом точки:

DriverObject.MajorFunction[0]=CtlCreate;

Однако у нас имеется не имя структурной переменной, а ее адрес pDriverObject, полученный в качестве первого параметра при активизации функции DriverEntry. В этом случае для обращения к элементу структуры следует вместо точки использовать обозначение->:

pDriverObject->MajorFunction[IRP_MJ_CREATE]=CtlCreate;

Разумеется, вместо численного значения индекса массива надежнее воспользоваться символическим. Функция DriverEntry(), как, впрочем, и все остальные функции, входящие в состав драйвера, завершается оператором return с указанием кода успешного завершения STATUS_SUCCESS (равного нулю). Как видно из прототипов функций CtlCreate(), CtlClose() и CtlDispatch(), все они принимают (из системы Windows) в качестве первого параметра указатель на объект драйвера, а в качестве второго - указатель на структуру типа IRP. Эта структура, так называемый пакет запроса ввода-вывода (in/out request packet, IRP), играет чрезвычайно важную роль в функционировании драйвера наряду с уже упоминавшимися объектами драйвера и устройства. Рассмотрим более детально создание и взаимодействие всех этих структур.

Как уже упоминалось выше, объект драйвера, олицетворяющий собой образ выполнимой программы драйвера в памяти, создается при загрузке драйвера на этапе запуска системы Windows. В этом объекте еще не заполнен массив MajorFunction, а также DeviceObject - указатель на объект устройства, поскольку сам объект устройства пока еще не существует. Загрузив драйвер, Windows активизирует его функцию инициализации DriverEntry(). Эта функция должна содержать вызов IoCreateDevice(), создающий объект устройства. В объекте устройства есть ссылка на объект драйвера, которому это устройство принадлежит, и, кроме того, адрес так называемого расширения устройства (device extension), поля произвольного размера, служащего для обеспечения передачи данных между запросами ввода-вывода. В настоящем примере драйвера расширение устройства не используется (и соответственно, не создается). Функция IoCreateDevice(), создав объект устройства, заносит его адрес в объект драйвера. Таким образом, обе эти структуры оказываются взаимосвязаны. Рассмотренные выше объекты существуют независимо от запуска и функционирования прикладной программы, работающей с драйвером. Уничтожены они будут только при закрытии всей системы или при динамической выгрузке драйвера из памяти, если такая возможность в драйвере предусмотрена, а она у нас предусмотрена в функции UnloadOperation(), которой мы воспользуемся в следующих статьях. Пакет запроса ввода-вывода создается заново при каждом обращении приложения к драйверу, т. е. при каждом вызове функции DeviceIoControl(). В терминологии драйверов Windows NT этот вызов носит название запроса ввода-вывода (I/O request). Выполнение функции IoCompleteRequest(), которой завершается любая активизируемая из приложения функция драйвера, приводит к уничтожению этого пакета, который, таким образом, существует лишь в течение времени выполнения активизированной функции драйвера. Обычно приложение за время своей жизни обращается к драйверу неоднократно; следующий запрос ввода-вывода снова создаст пакет IRP, который, разумеется, ничего не будет знать о предыдущем. Для того чтобы можно было передать данные, полученные в одном запросе ввода-вывода (например, данные, прочитанные из устройства), в другой запрос (например, с целью записи их в устройство), эти данные следует сохранить в расширении устройства. Пакет ввода-вывода IRP состоит из двух частей: фиксированной части и так называемой стековой области ввода-вывода (I/O stack location). Ссылка на стековую область ввода-вывода содержится в переменной CurrentStack Location, входящей в фиксированную часть IRP. Адрес же фиксированной части передается в качестве второго параметра в любую основную функцию драйвера при ее активизации. С другой стороны, адрес стековой области ввода-вывода можно получить с помощью специально предусмотренной функции IoGetCurrentIrpStackLocation().

Итак, в нашем драйвере имеются три основные функции: CtlCreate(), CtlClose() и CtlDispatch() и одна дополнительная UnloadOperation(), предназначенная для динамическом выгрузки драйвера из памяти. Это минимальный набор основных функций, при котором драйвер будет правильно функционировать. Активизация каждой из этих функций приводит к выделению системой (конкретно - диспетчером ввода-вывода I/O Manager) пакета запроса ввода-вывода со стековой областью, а завершение функции - к его возврату в систему. Для освобождения пакета запроса ввода-вывода необходимо заполнить в нем структуру блока состояния IO_STATUS_BLOCK и сообщить диспетчеру ввода-вывода вызовом функции IoCompleteRequest() о том, что мы завершили обработку этого пакета. В блок состояния входят две переменных: Status - для кода завершения и Information - для возврата в приложение некоторой числовой информации. В переменную Status естественно поместить код STATUS_SUCCESS, а переменная Information должна содержать число пересылаемых в приложение байтов. Функции CtlCreate() и CtlClose() ничего не пересылают в приложение, и значение этой переменной приравнивается нулю. Функция IoCompleteRequestQ требует указания двух параметров: указателя на текущий пакет запроса ввода-вывода и величины, на которую следует повысить приоритет вызывающей драйвер программы. В нашем случае запросы ввода-вывода обрабатываются очень быстро, за это время приоритет вызывающей программы снизиться не успевает, и нет необходимости его повышать. Поэтому в качестве второго параметра передается константа IO_NO_INCREMENT. Функции CtlCreate() и CtlClose() в нашем примере не выполняют никакой содержательный работы, и их тексты в результате оказались полностью совпадающими.

Перейдем к рассмотрению содержательной (с точки зрения прикладного программиста) части драйвера - функции диспетчеризации CtlDispatch(). Cистемный буфер, служащий для обмена информацией между драйвером и приложением, расположен в пакете запроса ввода-вывода IRP (переменная SystemBuffer). Таким образом, для организации взаимодействия пользовательского приложения и драйвера необходимо получить доступ к IRP, а через IRP - к SystemBuffer. С этой целью в функции CtlDispatch() объявляется переменная plrpStack типа указателя на стековую область ввода-вывода PIO_STACK_ LOCATION и, кроме того, переменная pIOBuffer, в которую будет помещен адрес системного буфера обмена. В структуре пакета запроса ввода-вывода этот адрес имеет тип PVOID - указатель на переменную произвольного типа. Действительно, тип передаваемых в приложение (или из приложения) данных может быть каким угодно: он определяется конкретными задачами данного запроса ввода-вывода. В нашем примере мы передаем через буфер обмена адреса портов, данные предназначенные для записи в порт и данные прочитанные из порта, поэтому для переменной pIOBuffer выбрали тип PUSHORT - указатель на short без знака. С помощью функции IoGetCurrentStackLocation() в переменную plrpStack помещается адрес стековой области ввода-вывода, а затем в переменную pIOBuffer заносится адрес системного буфера из структуры IRP. Системный буфер входит в объединение (union) с именем Associatedlrp, поэтому для доступа к переменной SystemBuffer использована конструкция pIrp->AssociatedIrp.SystemBuffer. Объединение можно рассматривать как эквивалент структурной переменной с тем отличием, что все члены объединения размещаются (альтернативно) в одной и той же области памяти. В синтаксическом плане обращения к объединению и к структуре выполняются одинаково. Конструкция switch-case анализирует содержимое ячейки IoControlCode, входящей в стековую область IRP и в зависимости от значения кода действия, содержащегося в этой ячейке, передает управление на тот или иной фрагмент программы драйвера. В рассматриваемом примере предусмотрены два кода действия:

  • IOCTL_READ - это действие возникает, когда пользовательское приложение хочет прочитать данные из указанного им порта
  • IOCTL_WRITE - это действие возникает, когда пользовательское приложение хочет записать данные в указанный им порт

Например, при поступлении в драйвер кода действия IOCTL_READ из системного буфера обмена в переменную драйвера Port читается адрес порта из которого надо прочесть данные. Этот адрес задает пользовательское приложение и передает его в драйвер. Далее в туже ячейку системного буфера, где раньше лежал адрес порта, записывается результат работы функции ядра Windows READ_PORT_UCHAR(), предназначенной для чтения байта данных из указанного порта. В переменную Irp->IoStatus.Information записывается число пересылаемых в пользовательское приложение байтов. Хоть мы и прочитали всего один байт, но т.к. pIOBuffer у нас определен как указатель на USHORT (т.е. там лежат указатели на USHORT, размер которых 2 байта) то и возвращаем мы в приложение два байта.

Последовательность действий при IOCTL_WRITE будет заключается в следующем: читаем первую ячейку буфера. Там лежит адрес порта, куда надо писать данные (что и как лежит в этом буфере определяем мы в пользовательском приложении. Когда доберемся до туда, станет понятнее). Читаем следующую ячейку и берем от туда байт данных, который надо записать в порт. Потом вызываем системную функцию WRITE_PORT_USHORT(), которая запишет данные по указанному адресу в порт. Т.к. в приложение мы никаких данных при этом не пересылаем, то Irp->IoStatus.Information присваиваем 0.

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


Большая часть материалов по описанию функционирования дравера для этой статьи были взяты из книги П.И. Рудакова и К.Г. Финогенова "Язык ассемблера: уроки программирования".



© Дмитрий Иванов
18 Ноября 2008 года
http://www.kernelchip.ru



© KERNELCHIP 2006 - 2017