Драйвер режима ядра что это
Процессор на компьютере под управлением Windows имеет два разных режима: режим пользователя и режим ядра.
Процессор переключается между двумя режимами в зависимости от типа кода, выполняемого на процессоре. Приложения выполняются в пользовательском режиме, а основные компоненты операционной системы выполняются в режиме ядра. Хотя многие драйверы работают в режиме ядра, некоторые драйверы могут работать в пользовательском режиме.
Режим пользователя
При запуске приложения в пользовательском режиме Windows создает процесс для приложения. Этот процесс предоставляет приложению частное виртуальное адресное пространство и таблицу частных дескрипторов. Так как виртуальное адресное пространство приложения является частным, одно приложение не может изменять данные, принадлежащие другому приложению. Каждое приложение выполняется в изоляции, и если приложение аварийно завершает работу, оно ограничивается одним приложением. Другие приложения и операционная система не затрагиваются сбоем.
Помимо частного, виртуальное адресное пространство приложения в пользовательском режиме ограничено. Процессор, работающий в пользовательском режиме, не может получить доступ к виртуальным адресам, зарезервированным для операционной системы. Ограничение виртуального адресного пространства приложения в пользовательском режиме предотвращает изменение и, возможно, повреждение критически важных данных операционной системы.
Режим ядра
Весь код, выполняющийся в режиме ядра, использует одно виртуальное адресное пространство. Это означает, что драйвер в режиме ядра не изолирован от других драйверов и самой операционной системы. Если драйвер в режиме ядра случайно записывает неправильный виртуальный адрес, данные, принадлежащие операционной системе или другому драйверу, могут быть скомпрометированы. Если драйвер в режиме ядра аварийно завершает работу, вся операционная система завершает работу.
На этой схеме показан обмен данными между компонентами пользовательского режима и режима ядра.
"Ну вот! Начали за здравие, кончили за упокой. При чем тут службы?" - спросите вы. и будете неправы. Очень даже при чем.
Я было начал эту статью с описания простейшего драйвера, но, по ходу дела, был вынужден отвлекаться на то, чтобы объяснять, как его зарегистрировать, запустить и т.д. и т.п. Тогда я решил, что будет логичнее, сначала поведать о том, как драйверы регистрируются, запускаются… Но тут возникла похожая проблема. Мне пришлось говорить о том, к каким действиям еще не написанного драйвера, приведет тот или иной вызов диспетчера управления службами или диспетчера ввода-вывода. Тут уж ничего не поделаешь. Слишком тесно эти компоненты связаны друг с другом. Как бы там ни было, но я остановился на втором варианте: сначала я глаголю о диспетчере управления службами, потом, в следующей статье, о простейшем драйвере, затем о диспетчере ввода-вывода, и наконец, разберем полнофункциональный драйвер. Когда вы прочитаете последующие статьи, будет неплохо вернуться к предыдущим - тогда многое встанет на свои места. Так что запаситесь терпением.
Поскольку у меня нет ни малейшего желания выступать в качестве переводчика официальной документации Microsoft, по крайней мере безвозмездно, то информацию о функциях, которыми мы будем пользоваться, принимаемых ими параметрах и их значениях, я буду давать лишь в объеме, необходимом для реализации наших целей. За подробностями обращайтесь к MSDN, API Reference и DDK.
- Диспетчер управления службами (Service Control Manager, SCM). Именно благодаря ему мы будем иметь возможность легко и просто загружать наши драйверы;
- Программа управления службой (Service Control Program, SCP). Работает в тесной связке с SCM;
- Собственно сам драйвер.
Диспетчер управления службами
На конечном этапе загрузки системы, перед появлением диалога регистрации пользователя, запускается SCM (\%SystemRoot%\System32\Services.exe), который, просматривая раздел реестра HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\, создает свою внутреннюю базу данных (ServicesActive database или SCM database). Далее SCM находит в созданной базе все драйверы устройств и службы, помеченные для автоматического запуска, и загружает их.
Чтобы получить кое-какое представление об этом, запустите редактор реестра (\%SystemRoot%\regedit.exe), откройте раздел HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\ и изучите его содержимое.
Теперь запустите оснастку Администрирование > Службы ( Administrative Tools > Services ). Вы увидите список установленных служб (именно служб, а не драйверов).
Чтобы просмотреть список загруженных драйверов, запустите Администрирование > Управление компьютером ( Administrative Tools > Computer Management ) и в левом окне откройте ветвь Служебные программы > Сведения о системе > Программная среда > Драйверы ( System Tools > System Information > Software Environment > Drivers ).
Проанализировав содержимое этих трех окон, вы заметите, что они во многом совпадают.
Вышеупомянутый раздел реестра содержит подразделы, обозначенные внутренним именем драйвера или службы. Соответственно, каждый подраздел содержит сведения о конфигурации драйвера или службы.
Рассмотрим минимально возможный набор параметров, необходимых для запуска драйвера. Более подробно можно почитать тут: Windows 2000 DDK > Setup, Plug Play, Power Management > Design Guide > Reference > Part3: Setup > 1.0 INF File Sections and Directives > INF AddService Directive . В качестве примера, возьмем простейший драйвер режима ядра beep.sys (о нем самом мы поговорим в следующий раз). Подраздел реестра соответствующий этому драйверу и его содержимое представлен на рис 2-1.
Рис. 2-1. Подраздел реестра для драйвера beep.sys
Автоматический запуск драйвера иногда называют статической загрузкой, по аналогии со статически загружаемыми VxD в Windows 9x. Это не правильно, т.к. автоматически загруженный драйвер можно выгрузить и удалить из базы данных SCM в любой момент.
Программа управления службой
Как следует из самого названия, программа управления службой (далее SCP) призвана выполнять некие действия по отношению к драйверу. Делает она это под наблюдением SCM, вызывая соответствующие функции. Все они экспортируются модулем \%SystemRoot%\System32\advapi.dll (Advanced API).
Вот код простейшей SCP, которая будет управлять драйвером beep.sys. Находится в файле scp.asm.
Все мало-мальски серьезные защитные приложения, будь то файрволы или антивирусы, используют собственные модули режима ядра (ring 0), через которые работает большинство их функций: защита процессов от завершения, фильтры различных событий, получение актуальной информации о состоянии сетевого трафика и количестве процессов в системе. Если у программы есть такой драйвер, то пробовать скрываться от нее из режима пользователя (ring 3) бессмысленно. Так же бесполезно пытаться на нее как-то воздействовать. Решение — написать собственный драйвер. В этой статье я покажу, как это делается.
Процессорные архитектуры x86 и x64 имеют четыре кольца защиты, из которых в Windows по факту используются всего два — это ring 3 (режим пользователя) и ring 0 (режим ядра). Бытует мнение, что код режима ядра — самый привилегированный и «ниже» ничего нет. На самом деле архитектура x86/x64 позволяет опускаться еще ниже: это технология виртуализации (hypervisor mode), которая считается кольцом −1 (ring −1), и режим системного управления (System Management Mode, SMM), считающийся кольцом −2 (ring −2), которому доступна память режима ядра и гипервизора.
Итак, мы решили писать собственный драйвер. Начнем с выбора инструментария. Я советую использовать Microsoft Visual Studio, как наиболее user-friendly IDE. Также необходимо будет установить Windows SDK и Windows Driver Kit (WDK) для твоей версии ОС. Кроме того, я крайне рекомендую запастись такими утилитами, как DebugView (просмотр отладочного вывода), DriverView (позволяет получить список всех установленных драйверов) и KmdManager (удобный загрузчик драйверов).
Драйверы в Windows начиная с Vista могут быть как режима пользователя (User-Mode Driver Framework, UMDF), так и режима ядра (Kernel-Mode Driver Framework, KMDF). Более ранние драйверы Windows Driver Model (WDM) появились в Windows 98 и сейчас считаются устаревшими.
Драйверы UMDF имеют намного более ограниченные права, чем KMDF, однако они используются, например, для управления устройствами, подключенными по USB. Помимо ограничений, у них есть очевидные плюсы: их намного проще отлаживать, а ошибка в их написании не вызовет глобальный системный сбой и синий экран смерти. Такие драйверы имеют расширение dll.
Что до драйверов режима ядра (KMDF), то им дозволено куда больше, а расширение файлов, закрепленное за ними, — это sys. В этой статье мы научимся писать простые драйверы режима ядра, напишем драйвер для скрытия процессов методом DKOM (Direct Kernel Object Manipulation) и его загрузчик.
Создание драйвера KMDF
После того как ты создашь проект драйвера, Visual Studio автоматически настроит некоторые параметры. Проект будет компилироваться в бинарный файл в соответствии с тем, какая выбрана подсистема. Наш вариант — это NATIVE, подсистема низкого уровня, как раз для того, чтобы писать драйверы.
Точка входа в драйвер
Строго говоря, точка входа в драйвер может быть любой — мы можем сами ее определить, добавив к параметрам компоновки проекта -entry:[DriverEntry] , где [DriverEntry] — название функции, которую мы хотим сделать стартовой. Если в обычных приложениях основная функция обычно называется main, то в драйверах точку входа принято называть DriverEntry.
Выглядеть это будет так:
Давай пройдемся по параметрам, которые передаются DriverEntry . pDriverObject имеет тип PDRIVER_OBJECT , это значит, что это указатель на структуру DRIVER_OBJECT , которая содержит информацию о нашем драйвере. Мы можем менять некоторые поля этой структуры, тем самым меняя свойства драйвера. Второй параметр имеет тип PUNICODE_STRING , который означает указатель на строку типа UNICODE . Она, в свою очередь, указывает, где в системном реестре хранится информация о нашем драйвере.
WARNING
Любая ошибка в драйвере может вызвать общесистемный сбой и BSOD. Вероятна потеря данных и повреждение системы. Все эксперименты я рекомендую проводить в виртуальной машине.
Interrupt Request Level (IRQL)
IRQL — это своеобразный «приоритет» для драйверов. Чем выше IRQL, тем меньшее число других драйверов будут прерывать выполнение нашего кода. Существует несколько уровней IRQL: Passive, APC, Dispatch и DIRQL. Если открыть документацию MSDN по функциям WinAPI, то можно увидеть примечания, которые регламентируют уровень IRQL, который требуется для обращения к каждой функции. Чем выше этот уровень, тем меньше WinAPI нам доступно для использования. Первые три уровня IRQL используются для синхронизации программных частей ОС, уровень DIRQL считается аппаратным и самым высоким по сравнению с программными уровнями.
Пакеты запроса ввода-вывода (Input/Output Request Packet)
IRP — это запросы, которые поступают к драйверу. Именно при помощи IRP один драйвер может «попросить» сделать что-то другой драйвер либо получить запрос от программы, которая им управляет. IRP используются диспетчером ввода-вывода ОС. Чтобы научить программу воспринимать наши IRP, мы должны зарегистрировать функцию обратного вызова и настроить на нее массив указателей на функции. Код весьма прост:
А вот код функции-заглушки, которая всегда возвращает статусный код STATUS_SUCCESS . В этой функции мы обрабатываем запрос IRP.
Теперь любой запрос к нашему драйверу вызовет функцию-заглушку, которая всегда возвращает STATUS_SUCCESS . Но что, если нам нужно попросить драйвер сделать что-то конкретное, например вызвать определенную функцию? Для этого регистрируем управляющую процедуру:
Здесь мы объявили процедуру с именем IRP_MY_FUNC и ее кодом — 0x801 . Чтобы драйвер ее обработал, мы должны настроить на нее ссылку, создав таким образом дополнительную точку входа в драйвер:
После этого нам нужно получить указатель на стек IRP, который мы будем обрабатывать. Это делается при помощи функции IoGetCurrentIrpStackLocation , на вход которой подается указатель на пакет. Кроме этого, необходимо будет получить от диспетчера ввода-вывода размеры буферов ввода-вывода, чтобы иметь возможность передавать и получать данные от пользовательского приложения. Шаблонный код каркаса обработчика управляющей процедуры:
Продолжение доступно только участникам
Членство в сообществе в течение указанного срока откроет тебе доступ ко ВСЕМ материалам «Хакера», позволит скачивать выпуски в PDF, отключит рекламу на сайте и увеличит личную накопительную скидку! Подробнее
Вариант 2. Открой один материал
Задача функции RtlInitUnicodeString измерить unicode-строку и заполнить структуру UNICODE_STRING (см. Часть 3). Т.к. сами unicode-строки в этом коде определены статически, т.е. никогда не меняются, то можно, еще на этапе компиляции, заполнить структуру UNICODE_STRING. Это проще, нагляднее и требует меньшего количества байт (8 байт на структуру UNICODE_STRING + максимум 3 байта на выравнивание. Против минимум 14 байт на вызов функции RtlInitUnicodeString + временные издержки). Именно так и поступим, а макрос CCOUNTED_UNICODE_STRING еще больше облегчит нам жизнь, и весь вышеприведенный код (мы добавим еще выравнивание) превратится в две элегантные строки.
Не знаю как вам, а мне этот вариант правится значительно больше. Функция же RtlInitUnicodeString требуется когда длина строки заранее неизвестна. В ntoskrnl.exe, кстати, имеется целый набор функций, как для заполнения структур *_STRING, так и для работы со строками вообще.
Таким образом, мы имеем две глобальные переменные g_usDeviceName и g_usSymbolicLinkName типа UNICODE_STRING с именем устройства и именем символьной ссылки соответственно. Как я уже говорил в прошлый раз, префиксы "dev" и "sl" я добавил только для наглядности.
На более ранних выпусках Windows NT в пространстве имен диспетчера объектов отсутствует каталог "\??". В этом случае надо заменить в имени символьной ссылки "\??" на "\DosDevices". Причем, в более поздних выпусках это тоже будет работать, т.к. для совместимости в корневом каталоге пространства имен диспетчера объектов имеется символьная ссылка "\DosDevices", значением которой является строка "\??"
Процедура инициализации драйвера
Кое-что о процедуре DriverEntry мы уже узнали в третьей части статьи. Прежде чем мы пойдем дальше, обратите внимание на строку:
Весь код, помеченный таким образом, помещается компоновщиком в отдельную секцию PE-файла с атрибутом Discardable. Благодаря этому, система автоматически выгрузит такой код при необходимости. Это позволяет экономно использовать ресурсы системной памяти. Весь код в процедуре DriverEntry нужен только один раз - во время инициализации драйвера. После этого он будет бесполезно занимать память. Но, так как он помещен в секцию "INIT", этого не произойдет. Но только в том случае, если кроме секции INIT на странице (страницах) больше ничего нет, т.к. частично выгружать страницы система не умеет.
Поскольку, процедура DriverEntry просто крошечная, и весь остальной код и данные тоже занимают очень мало места, а в параметре компоновщика /align:32 мы определили выравнивание секций по границе 32 байта, то весь драйвер, со всеми своими потрохами, умещается всего на одной странице памяти (4кБ). А как известно, минимальной единицей выделения памяти, на данный момент, является страница. Т.е. можно было и не использовать отдельную секцию, т.к. в данном случае это ничего не дает. В драйверах для более ранних выпусков Windows NT процедура DriverEntry занимала довольно приличное место, поэтому собственно Microsoft так и поступила.
Прежположим, что во время инициализации драйвера произойдет ошибка. Если это случится, то это значение и вернется системе, и на стороне режима пользователя вызов функции StartService завершится ошибкой.
Поскольку, драйвер призван управлять виртуальным устройством, то первым делом мы и пытаемся это устройство создать. Функция IoCreateDevice выделяет память под необходимые структуры, в частности DEVICE_OBJECT, и инициализирует их. Ее прототип выглядит следующим образом:
Этот параметр имеет смысл использовать если драйвер управляет несколькими устройствами. Т.о. он может хранить относящуюся к каждому устройству информацию в самом объекте "устройство". В нашем случае устройство одно, поэтому, нет большого смысла выделять в устройстве дополнительную память, хотя ничто не мешает нам это сделать;
Для нас этот параметр обязателен. Мы должны создать именованное устройство, иначе мы не сможем создать символьную ссылку, а значит, код режима пользователя не сможет получить доступ к устройству;
Этого момента мы коснулись в прошлый раз, при описании управляющего кода ввода-вывода. У нас этот параметр всегда будет FILE_DEVICE_UNKNOWN;
У нас ничего такого нет, поэтому ставим в 0;
Опять же в прошлый раз, при описании функции CreateFile, я говорил, что мне не удалось получить монопольный доступ к устройству используя параметр dwShareMode. Это можно сделать с помощью Exclusive. Трудно сказать нужно нам это или нет, поэтому, мы разрешаем одновременное использование устройства несколькими приложениями устанавливая этот параметр в FALSE;
Если создать символьную ссылку не удастся, то указатель на объект "устройство" потребуется для его удаления. Поэтому, мы передаем в этом параметре адрес локальной переменной pDeviceObject. Можно сохранить указатель на объект "устройство" и в глобальной переменной и использовать его в процедуре выгрузки драйвера, но я этого делать не стал, чтобы не создавать лишнюю секцию ?data. А при выгрузке драйвера мы извлечем этот указатель прямо из объекта "драйвер".
Если создание устройства прошло без ошибок, то следующим нашим шагом будет создание символьной ссылки (что это такое и зачем нужно, мы достаточно подробно обсудили в прошлой статье). Это позволит коду режима пользователя получить описатель устройства. Я не буду приводить описание функции IoCreateSymbolicLink - тут и так все ясно.
Это порядковые номера, определяющие положение указателя на процедуру диспетчеризации в массиве MajorFunction. Умножив соответствующую константу на размер указателя, равный 4 байтам, мы получим смещение в массиве MajorFunction, по которому должен быть расположен указатель на соответствующую процедуру диспетчеризации.
Еще одно ключевое поле структуры DRIVER_OBJECT DriverUnload. Туда мы должны поместить, если хотим иметь возможность динамически выгружать драйвер, указатель на процедуру, которую система будет вызывать при обращении кода режима пользователя к функции ControlService с параметром SERVICE_CONTROL_STOP.
Имена процедур инициализации, диспетчеризации и выгрузки драйвера могут быть произвольными.
Если мы благополучно дошли до этой точки, то инициализация драйвера прошла успешно, о чем мы и сообщаем системе, возвращая код успеха STATUS_SUCCESS.
Если же создать символьную ссылку не удалось, то мы должны удалить объект "устройство", созданный на предыдущем этапе функцией IoCreateDevice, чтобы привести систему в прежнее состояние. Именно должны! Это вам не режим пользователя, где после завершения процесса все объекты ему принадлежащие автоматически уничтожаются системой. В режиме ядра никто за нас делать грязную работу не будет. Это общее правило. Выделили память - освободите. Создали объект - удалите. Получили ссылку на объект - "отдайте" назад. И т.д. и т.п. Запомните это.
Из этого объекта (точнее из массива MajorFunction структуры DRIVER_OBJECT) диспетчер ввода-вывода получает адреса процедур диспетчеризации;
Из этого объекта (точнее из поля DriverObject структуры DEVICE_OBJECT) диспетчер ввода-вывода получает указатель на объект "драйвер" это устройство обслуживающий;
Из этого объекта (точнее из поля DeviceObject структуры FILE_OBJECT) диспетчер ввода-вывода получает указатель на объект "устройство";
Этот объект прозрачен для нас. О том, как объект "файл" возникает, мы говорили в прошлый раз;
Процедуры диспетчеризации запросов ввода-вывода
Все процедуры диспетчеризации имеют одинаковый прототип.
Если драйвер обслуживает несколько устройств, то по значению этого параметра можно определить, к какому именно устройству пришел запрос. Или обратиться к области дополнительной памяти устройства. В нашем случае, в процедурах диспетчеризации, использовать указатель на объект "устройство" не требуется;
Диспетчер ввода-вывода создает IRP, представляющий операцию ввода-вывода. Через параметр pIrp драйверу передается указатель на IRP. Получив IRP, драйвер выполняет указанную в пакете операцию и возвращает его диспетчеру ввода-вывода, чтобы тот, либо завершил эту операцию, либо передал пакет другому драйверу для дальнейшей обработки. Завершать IRP или передавать его дальше решает сам драйвер. Наш драйвер одноуровневый, поэтому никакой дальнейшей обработки не требуется. По завершении операции ввода-вывода, результаты возвратятся коду режима пользователя, а IRP будет удален.
Процедура обработки IRP типов IRP_MJ_CREATE и IRP_MJ_CLOSE
Для начала о том, почему процедура DispatchCreateClose обрабатывает у нас запросы такого разного типа. Запросы типов IRP_MJ_CREATE и IRP_MJ_CLOSE направляются драйверу, когда код режима пользователя вызывает функции CreateFile и CloseHandle, соответственно. Единственное, что нам надо сделать для обработки обоих запросов, это заполнить блок статуса ввода-вывода (I/O status block) - вложенную в _IRP структуру IO_STATUS_BLOCK - и вернуть код успеха. Поэтому, чтобы не плодить лишние процедуры-близнецы, я их и объединил.
Если же предполагается, при обработке запроса типа IRP_MJ_CREATE, выделять дополнительную память, инициализировать какие-либо переменные и .т.п, то при закрытии описателя устройства, т.е. при вызове кодом режима пользователя CloseHandle, эти структуры придется удалять. Т.е. действия драйвера при получении пакетов IRP_MJ_CREATE и IRP_MJ_CLOSE будут отличаться. В этом случае, естественно, процедуры обработки следует разделить.
Заполняем блок статуса ввода-вывода, определяющего состояние запроса ввода-вывода.
Значение поля Status определяет, как завершится на стороне режима пользователя вызов функций CreateFile и CloseHandle. Мы хотим, чтобы пользовательский процесс открывал и закрывал описатель устройства без ошибок. Поэтому, помещаем в поле Status код успеха STATUS_SUCCESS.
Смысл поля Information зависит от типа запроса. Это может быть значение количества байт переданных в пользовательское приложение или указатель на структуру. В данном случае мы поместим в это поле 0. При обработке запроса типа IRP_MJ_DEVICE_CONTROL (в процедуре DispatchControl), мы должны будем поместить туда количество байт, которое необходимо скопировать в пользовательский буфер.
Вызов функции IofCompleteRequest инициирует операцию завершения ввода-вывода (I/O completion).
Как следует из самого названия, эта функция уведомляет диспетчер ввода-вывода о том, что операция ввода-вывода завершена и можно отправлять ее результаты инициатору запроса (в нашем случае коду режима пользователя). О том, обработку какого именно запроса следует завершить, говорит первый параметр. Второй параметр определяет повышение уровня приоритета инициировавшего операцию потока после ее завершения.
Если драйвер обслуживает физическое устройство, то операция ввода-вывода может длиться ощутимое время. Пока поток ждет завершения операции, система не предоставляет ему процессорное время (при синхронных операциях ввода-вывода, как в нашем случае). После окончания операции ожидавший поток вправе немедленно возобновить выполнение и обработать полученные данные. Именно через второй параметр функции IofCompleteRequest драйвер и сообщает на какую величину повысить приоритет ожидавшего потока. Для устройств "тугодумов" предусмотрены бОльшие значения повышения приоритета. Например, для звуковых карт DDK рекомендует использовать константу IO_SOUND_INCREMENT, равную 8.
В нашем случае, повышения приоритета потока не требуется, и мы передаем IO_NO_INCREMENT равную нулю.
Функция IofCompleteRequest является fastcall-функцией (см. ниже). В префиксе имени функции присутствует символ 'f'. Существует, кстати сказать, и stdcall вариант IoCompleteRequest. Обратите внимание на отсутствие символа 'f' в префиксе. Но, в образовательных целях, мы будем использовать быструю версию. Эта не единственная fastcall-функция - есть и другие. И у них также есть свои stdcall аналоги, которые, как правило, являются оболочками вокруг соответствующих fastcall-функций.
Соглашение о передаче параметров
В функциях API ядра Windows NT используется три типа соглашений о передаче параметров (calling convention): stdcall, cdecl и fastcall. Последний тип не поддерживается компилятором masm.
Такие функции принимают первый аргумент в регистре ecx, второй в edx, остальные, при наличии таковых, помещаются в стек в обратном порядке (справа налево). Стек очищает вызванная функция.
Декорированные имена функций fastcall начинаются с символа "@", после имени добавляется символ "@" с десятичным числом, обозначающим суммарный размер аргументов в байтах. Например, функция IofCompleteRequest декорируется следующим образом:
Это значит, что это fastcall-функция, экспортируемое имя IofCompleteRequest, принимает два аргумента размером DWORD каждый.
В файле \include\w2k\ntoskrnl.inc она определена следующим образом (на ключевое слово SYSCALL можете не обращать внимание):
Для упрощения вызова таких функций я написал макрос fastcall:
Здесь, для экономии места, я привожу упрощенную версию этого макроса. Чтобы не плодить лишние файлы с макросами, мне пришлось поместить этот макрос в \include\w2k\ntddk.inc, где и можно посмотреть полный вариант. В оригинальном ntddk.h такого макроса, естественно, нет.
- буферизованный ввод-вывод (buffered I/O);
- прямой ввод-вывод (direct I/O);
- ввод-вывод без управления (neither I/O).
буферизованный ввод-вывод
Именно этот вид управления используется в драйвере VirtToPhys.
Диспетчер ввода-вывода выделяет в системном пуле неподкачиваемой памяти буфер, равный по размеру наибольшему (если определены, как входной, так и выходной) из пользовательских буферов.
Создавая IRP, при операции записи, диспетчер ввода-вывода копирует данные из пользовательского входного буфера в выделенный системный буфер и передает драйверу его адрес в поле AssociatedIrp.SystemBuffer структуры _IRP. Размер скопированных данных помещается в поле Parameters.DeviceIoControl.InputBufferLength структуры IO_STACK_LOCATION (на эту структуру указывает поле Tail.Overlay.CurrentStackLocation структуры _IRP, значение которого можно получить используя макрос IoGetCurrentIrpStackLocation).
Драйвер обрабатывает IRP и помещает выходные данные (при наличии таковых) в тот же самый системный буфер. При этом, находящиеся там входные данные, естественно, переписываются. Т.е. пользовательских буферов два, а системный один. Если входные данные нужны драйверу, то он должен их где-нибудь сохранить.
Завершая IRP, при операции чтения, диспетчер ввода-вывода копирует данные из системного буфера в пользовательский выходной буфер. Размер копируемых данных извлекается из поля IoStatus.Information структуры _IRP. Это поле должен заполнить драйвет - только он знает сколько байт поместил в системный буфер.
Таким образом, диспетчер ввода-вывода дважды копирует буферы. Именно поэтому, этот вид управления самый медленный, но при сравнительно малых размерах буферов издержки незначительны.
У этого вида управления есть одно большое преимущество - диспетчер ввода-вывода сам решает все проблемы связанные с возможными ошибками доступа к памяти. В худшем случае, вызов функции DeviceIoControl, на стороне режима пользователя, завершится неудачно.
Переданный драйверу буфер находится в системном диапазоне адресов, а значит, к нему можно обращаться из контекста любого процесса. Но нам это мало что дает, т.к. одноуровневый драйвер и так, при обработке операций ввода-вывода, будет находится в контексте процесса инициировавшего эту операцию.
Переданный драйверу буфер находится в неподкачиваемой области памяти, а значит, к нему можно обращаться при любом IRQL. Но и это нам тоже ничего не дает, т.к. код одноуровневого драйвера, при обработке операций ввода-вывода, выполняется при IRQL = PASSIVE_LEVEL. Поэтому, может обращаться даже к памяти сброшенной в файл подкачки - диспетчер памяти сам решит все вопросы по возвращению ее в физическую память. Правда, временные издержки будут.
Таким образом, этот вид управления буферами следует использовать при относительно небольших размерах буферов. Или если опереция ввода-вывода выполняется не часто.
Если же драйверу приходиться перегонять большие порции данных, например, потоки аудио или видео данных (эта статья даже вскользь не касается этой темы), то следует использовать более эффективный метод.
прямой ввод-вывод
Используется для организации прямого доступа к памяти (direct memory access, DMA).
Я не буду подробно рассматривать этот вид управления, т.к. в контексте данных статей ему нет применения.
Если очень коротко, то с входным пользовательским буфером ситуация полностью аналогична предыдущему виду управления. Выходной же буфер, не смотря на свое название, может быть использован как источник (METHOD_IN_DIRECT) или как приемник (METHOD_OUT_DIRECT) данных.
Диспетчер ввода-вывода блокирует выходной буфер в памяти, делая его неподкачиваемым, и создает MDL (Memory Descriptor List), описывающий физические страницы занимаемые буфером. Указатель на MDL помещается в поле MdlAddress структуры _IRP. Подробнее см. DDK.
Вы конечно можете использовать и этот метод в одноуровневом драйвере, но если уж вам потребовалось переслать большой объем данных, то лучше использовать следующий вид управления.
ввод-вывод без управления
Диспетчер ввода-вывода помещает в поле DeviceIoControl.Type3InputBuffer структуры IO_STACK_LOCATION указатель на пользовательский входной буфер, а в поле UserBuffer структуры _IRP указатель на пользовательский выходной буфер и оставляет драйверу возможность управлять ими самостоятельно. Т.о. вся ответственность за управление пользовательскими буферами ложится на драйвер. Он может блокировать их в памяти, отображать на системное адресное пространство или обращаться к ним напрямую и т.д.
Поскольку, мы всегда (имеется в виду одноуровневый драйвер), при обработке операции ввода-вывода, находимся в контексте пользовательского процесса, который инициировал эту операцию, мы можем обращаться к буферам напрямую. Т.е. отображать пользовательские буферы в системное адресное пространство не требуется.
Поскольку, мы всегда (в рассмотренных драйверах) выполняемся при IRQL = PASSIVE_LEVEL, то и блокировать пользовательские буферы тоже нет необходимости.
Остается одна проблема - пользовательский поток может передать заведомо неверный адрес буфера, например, попадающий в системное адресное пространство или адрес невыделенной области памяти и т.п. Или пользовательский процесс может быть многопоточным, и один из потоков может освободить память занимаемую буфером во время обработки драйвером запроса ввода-вывода. Такие ситуации надо просто предвидеть и корректно обрабатывать. Использование структурной обработки исключений (Structured Exception Handling, SEH) при этом обязательно (пример см. \src\Article4-5\NtBuild). Учтите только, что хотя структурная обработка исключений в режиме ядра принципиально ничем не отличается от режима пользователя, обрабатывать таким образом все исключения не удастся. Например, попытка деления на ноль приведет к появлению BSOD даже при установленном обработчике SEH.
Процедура обработки IRP типа IRP_MJ_DEVICE_CONTROL
Запрос типа IRP_MJ_DEVICE_CONTROL направляется драйверу, когда код режима пользователя вызывает функцию DeviceIoControl.
Вот и пришло время второй статьи из цикла о написании драйверов под Windows. Сейчас я тебя немного расстрою: в предыдущей статье я обещала, что в этой мы приступим собственно к практике. Но данная статья это, скорее, "полупрактика". Глупо "с места в карьер" бросаться разрабатывать драйвера режима ядра
(так как, если ты не забыл, в данном цикле мы разбираем именно этот тип драйверов), хотя бы поверхностно не изучив особенности и приёмы программирования в режиме ядра, что мы и сделаем в первой части этой статьи. Ну а во второй мы наконец - то разберём структуру настоящего драйвера под Windows
(Legacy и немного WDM) : его основные функции, их взаимодействие, а также параметры, принимаемые ими. Так что к третьей статье этого цикла ты уже, надо думать, будешь основательно подготовлен к написанию своего первого драйвера. Начинаем.
Особенности и приёмы программирования в режиме ядра
Программирование в режиме ядра имеет массу особенностей, для прикладников очень непривычных, а для новичков довольно сложных. Во-первых, у режима ядра своё, отличное от такового в пользовательском режиме, API. Кроме того, для кода, выполняющегося в режиме ядра, имеет очень большое значение его уровень IRQL, так как приложениям, выполняющимся на высоких уровнях IRQL, недоступны многие функции, к которым имеют доступ приложения низких IRQL уровней, и наоборот. Всё это необходимо учитывать.
Во-вторых, в режиме ядра есть свои дополнительные описатели типов. Полный их список можно найти в заголовочном файле ntdef.h. Его содержание примерно таково:
typedef unsigned char USHAR
typedef unsigned short USHORT
typedef unsigned long ULONG
.
Зачем это нужно? Ну, во-первых, для красоты. тьфу, для унификации стиля классических C - типов данных и нововведённых - таких, как WCHAR
(двухбайтный Unicode символ), LARGE_INTEGER (который, на самом деле, является объединением) и т.д. А также для унификации исходников для 32 - разрядных платформ и надвигающихся 64 - разрядных.
В исходниках драйверов часто встречаются макроопределения
IN, OUT, OPTIONAL. Что они означают? А ровным счётом ничего, и введены они только для повышения удобочитаемости исходника. OPTIONAL обозначает необязательные параметры, IN - параметры, передаваемые внутрь функции, например, OUT - соответственно, наоборот. А вот IN OUT означает, что параметр передаётся внутрь функции, а затем возвращается из неё обратно.
Функции драйвера, за исключением DriverEntry (главная процедура драйвера, подробнее см. во второй части статьи), могут называться как угодно, тем не менее, существуют определённые "правила хорошего тона" при разработке драйверов, в том числе и для именования процедур: например, все функции, относящиеся к HAL, желательно предварять префиксом HAL и т.д. Сама Microsoft практически постоянно следует этому правилу. А имена типов данных и макроопределения в листингах DDK написаны сплошь заглавными буквами. Советую тебе поступать также при разработке своих драйверов. Это и в самом деле во много раз повышает удобство работы с листингом.
А теперь, чтобы тебе стали более или менее понятны основные различия между программированием в пользовательском и системном режимах, расскажу об основных функциях для работы с памятью и реестром в режиме ядра.
Начнём с функций для работы с памятью, а для начала поговорим собственно об устройстве и работе с памятью в Windows. Единое 4-х гигабайтное адресное пространство памяти Windows
(я имею в виду 32-х разрядные версии Windows) делится на две части: 2 гигабайта для пользовательского пространства и 2 гигабайта для системного. 2 гигабайта системного пространства доступны для всех потоков режима ядра. Системное адресное пространство делится на следующие части:
Видов адресов в режиме ядра три: физические
(реально указывающие на область физической памяти), виртуальные
(которые перед использованием транслируются в физические), и логические
(используемые HAL уровнем при общении с устройствами; он же и отвечает за работу с такими адресами). Функции режима ядра, отвечающие за выделение и освобождение виртуальной памяти, отличаются от таковых в пользовательском режиме. Также, находясь на уровне режима ядра, становится возможным использовать функции выделения и освобождения физически непрерывной памяти. Разберём все эти функции поподробнее.
1) PVOID ExAllocatePool (уровень IRQL, на котором может выполняться эта функция - < DISPATCH_LEVEL) - выделяет область памяти. Принимает два параметра: параметр
(POOL_TYPE) , в котором содержится значение, означающее, какого типа область памяти нужно выделить: PagedPool - страничная, NonPagedPool - нестраничная
(в этом случае функцию можно вызвать с любого IRQL уровня). Второй параметр
(ULONG) - размер запрашиваемой области памяти. Функция возвращает указатель на выделенную область памяти, и NULL, если выделить память не удалось.
2) VOID ExFreePool (IRQL
DISPATCH_LEVEL. Возвращаемое значение - void.
3) PVOID MmAllocateContiguousMemory (IRQL==PASSIVE_LEVEL) - выделяет физически непрерывную область памяти. Принимает два параметра. Первый параметр
(ULONG) - размер запрашиваемой области памяти, второй - параметр
(PHYSICAL_ADDRESS), означающий верхний предел адресов для запрашиваемой области. Возвращаемое значение: виртуальный адрес выделенной области памяти или NULL
(при неудаче).
4) VOID MmFreeContiguousMemory (IRQL==PASSIVE_LEVEL) - освобождает физически непрерывную область памяти. Принимает единственный параметр
(PVOID) - указатель на область памяти, выделенную ранее с использованием функции MmAllocateContiguousMemory. Возвращаемое значение -
void.
5) BOOLEAN MmIsAddressValid (IRQL <=DISPATCH_LEVEL) - делает проверку виртуального адреса. Принимает параметр
(PVOID) - виртуальный адрес, нуждающийся в проверке. Функция возвращает TRUE, если адрес "валидный"
(т.е. присутствует в виртуальной памяти), и FALSE - в противном случае.
6) PHYSICAL_ADDRESS MmGetPhysicalAddress (IRQL - любой) - определяет физический адрес по виртуальному. Принимает параметр
(PVOID), содержащий анализируемый виртуальный адрес. Возвращаемое значение - полученный физический адрес.
Основные функции для работы с памятью рассмотрели, перейдём к таковым для работы с реестром. Сначала поговорим о функциях доступа к реестру, предоставляемых диспетчером ввода - вывода, потом о драйверных функциях прямого доступа к реестру, а затем о самом богатом по возможностям и удобству семействе функций для работы с реестром -
Zw~.
Драйверные функции, предоставляемые диспетчером ввода - вывода.
2) IoGetDeviceProperty - данная функция запрашивает из реестра установочную информацию об устройстве.
3) IoOpenDeviceRegistryKey - возвращает дескриптор доступа к подразделу реестра для драйвера или устройства по указателю на его объект.
Драйверные функции для прямого доступа к реестру.
1) RtlCheckRegistryKey - проверяет, существует ли указанный
подраздел внутри подраздела, переданного первым параметром. Что и каким
образом передавать в первом параметре - в рамках статьи всё не перечислить, отсылаю к ntddk.h и wdm.h. Если существует - возвращается
STATUS_SUCCESS.
2) RtlCreateRegistryKey - создаёт подраздел внутри раздела реестра, указанного вторым параметром. Далее - всё то же самое, что и у
RtlCheckRegistryKey.
3) RtlWriteRegistryValue - записывает значение параметра реестра. Первый параметр - куда пишем, второй - в какой подраздел
(если его нет, то он будет создан), а третий - какой параметр создаём.
4) RtlDeleteRegistryValue - удаляет параметр из подраздела. Параметры те же самые, что и у RtlWriteRegistryValue
(только с необходимыми поправками, конечно).
5) RtlQueryRegistryValues - данная функция позволяет за один вызов получить значения сразу нескольких параметров указанного подраздела.
И напоследок функции для работы с реестром семейства
Zw~.
1) ZwCreateKey - открывает доступ к подразделу реестра. Если такового нет - создаёт новый. Возвращает дескриптор открытого объекта.
2) ZwOpenKey - открывает доступ к существующему подразделу реестра.
3) ZwQueryKey - возвращает информацию о подразделе.
4) ZwEnumerateKey - возвращает информацию о вложенных подразделах уже открытого ранее подраздела.
5) ZwEnumerateValueKey - возвращает информацию о параметрах и их значениях открытого ранее подраздела.
6) ZwQueryValueKey - возвращает информацию о значении параметра в открытом ранее разделе реестра. Полнота возвращаемой информации определяется третьим параметром, передаваемым функции, который может принимать следующие значения
(дополнительные разъяснения не требуются, так как они имеют "говорящие" имена):
KeyValueBasicInformation, KeyValuePartialInformation и KeyValueFullInformation.
7) ZwSetValueKey - создаёт или изменяет значение параметра в открытом ранее подразделе реестра.
8) ZwFlushKey - принудительно сохраняет на диск изменения, сделанные в открытых функциями ZwCreateKey и ZwSetValueKey подразделах.
9) ZwDeleteKey - удаляет открытый подраздел из реестра.
10) ZwClose - закрывает дескриптор открытого ранее подраздела реестра, предварительно сохранив сделанные изменения на диске.
Практически все вышеперечисленные функции для работы с реестром должны вызываться с уровня IRQL
PASSIVE_LEVEL.
Думаю, пока достаточно. Конечно, у всех вышеперечисленных функций есть масса нюансов в применении. Да и вообще функций режима ядра - великое множество, их ничуть не меньше, чем в пользовательском режиме. Но моя задача была не рассказать обо всех API - функциях режима ядра
(что даже в рамках цикла невозможно сделать), а продемонстрировать отличия функций режима ядра, от таковых в пользовательском режиме, и хоть немного рассказать о нюансах их применения
(взять, к примеру, то, что в пользовательском режиме не имеет значения, в потоке какого приоритета будет выполняться приложение: оно будет иметь такой же полный доступ ко всем API функциям пользовательского режима, как и любые другие приложения; на уровне ядра, как ты только что, убедился, это не так). Ну а за более или менее полным списком и описанием всех этих API - функций советую обратиться к библии Гарри Нэббета.
Ну вот и всё, теперь ты готов к разговору о структуре драйвера, который мы сейчас и начнём.
Структура драйвера
Я уже говорила, что драйвер фактически можно представить как довольно-таки обычную dll-ку уровня ядра. Таким образом, далее можно представить драйвер просто как набор процедур, периодически вызываемых внешними программами. Несмотря на то, что процедуры драйверов для разных устройств сильно отличаются, есть общая структура и общие функции для всех драйверов. Главные из них мы сейчас и рассмотрим.
1) Поле DriverUnload - для регистрации собственной функции Unload, вызываемой при выгрузке драйвера. Подробнее о ней.
Эта функция вызывается только при динамической выгрузке драйвера
(т.е. происшедшей не в результате завершения работы системы). Legacy драйвера в этой функции выполняют полное освобождение всех занятых драйвером системных ресурсов. WDM драйвера выполняют такое освобождение в функции RemoveDevice при удалении каждого устройства
(если драйвер обслуживает несколько устройств). Функция Unload вызывается с уровня IRQL PASSIVE_LEVEL, принимает единственный параметр
(PDRIVER_OBJECT) - указатель на объект драйвера, и возвращает
void.
2) Поле DriverStartIo - для регистрации собственной функции
StartIo. Вкратце, регистрация функции StartIo нужна для участия в System Queuing - создании очередей необработанных запросов системными средствами, в отличие от DriverQueuing - когда то же самое реализуется средствами самого драйвера.
3) Поле AddDevice в подструктуре DriverExtension - для регистрации WDM драйвером своей процедуры
AddDevice.
4) Поле MajorFunction - для регистрации драйвером точек входа в свои рабочие процедуры.
Если выгрузка драйвера происходит в результате завершения работы системы, то функция Unload не вызывается. Это понятно: при выключении системы можно не заботиться об освобождении памяти и т.д. Поэтому вызывается функция Shutdown, которая просто предоставляет драйверу возможность оставить устройство в приемлемом состоянии покоя.
Может случиться так, что твоему драйверу необходимо будет получить управление при крахе системы. В этом случае ему нужно зарегистрировать callback процедуру Bugcheck. Если драйвер правильно выполнил регистрацию этой функции, то он будет вызван во время исполнения crash процесса.
Я перечислила основные функции драйвера для его загрузки и выгрузки. Теперь перейдём к рабочим процедурам драйвера.
Поговорим о следующих рабочих процедурах: обслуживания ввода - вывода
(включающих в себя процедуры передачи данных и обслуживания прерываний), callback процедуры для синхронизации доступа к объектам и некоторые другие.
Все драйверы должны иметь обработчик CreateDispatch, обрабатывающий пользовательский запрос CreateFile. Если драйверу нужно обрабатывать пользовательский запрос CloseHandle, то он должен иметь обработчик
CloseDispatch.
Перейдём к процедурам передачи данных. Это обработчики пользовательских запросов ReadFile, WriteFile и
DeviceIoControl.
Процедуру StartIo я уже рассмотрела, поэтому перейдём к процедуре обслуживания прерываний
(ISR - Interrupt Service Routine, напоминаю на всякий случай). Данная процедура вызвается диспетчером прерываний ядра
(Kernel`s Interrupt Dispatcher) при каждой генерации прерывания устройством, и она обязана полностью обслужить это прерывание.
Теперь о callback процедурах сихронизации доступа к объектам. Для начала разберёмся, в чём различия принципов сихронизации доступа к объектам в пользовательском и ядерном режимах. Например, в пользовательском режиме, если какой - либо поток обратился к объекту, уже занятому другим потоком, то он
(первый поток) запросто может быть заблокирован до лучших времён. Как ты сам понимаешь, в режиме ядра такая внеплановая "заморозка" потоков неприемлема, поэтому и применяется другая технология сихронизации. И заключается она в следующем. Когда какой-либо поток обращается к объекту, уже занятому другим потоком, то он оставляет свой запрос в очереди запросов. Если драйвер предварительно зарегистрировал особую callback функцию, то диспетчер ввода - вывода при освобождении
требуемого ресурса, уведомит об этом драйвер, вызвав callback - функцию. Таким образом, обеспечивается гарантия ответа на любой запрос к ресурсу, даже если он
(ответ) будет состоять только в том, чтобы уведомить о задержке в обработке и помещении запроса в очередь. Функции, это реализующие: IoAllocateController
(использующаяся для синхронизации доступа к контроллеру), AdapterControl
(использующаяся для синхронизации доступа к DMA каналам
(чаще всего)) и SynchCritSection (использующаяся для корректного обращения к ресурсам; точнее - эта функция позволяет коду с низким уровнем IRQL сделать работу при уровне DIRQL устройства без опасения возникновения конфликтов с ISR).
Также можно упомянуть ещё таймерные процедуры
(нужные для драйверов, выполняющих точный отсчёт временных интервалов; обычно реализуется с использованием IoTimer
(но не всегда)), процедуру IoCompletion (позволяющую WDM драйверу, работающему внутри многослойной драйверной структуры, получать уведомление о завершении обработки IRP запроса, направленного к драйверу нижнего уровня) и CancelRoutine
(если драйвер зарегистрирует эту callback процедуру при помощи вызова IoSetCancelRoutine, то диспетчер ввода - вывода сможет уведомить его об удалении IRP запросов, находящихся в ожидании обработки, что может понадобиться диспетчеру, если пользовательское приложение, инициировавшее эти IRP запросы, неожиданно завершит свою работу после снятия задачи диспетчером задач
(прошу прощения за необходимую тавтологию)).
Ну вот и всё, мы закончили обзор структуры драйвера и основных его процедур. Примеры их практического применения
(а также некоторых других функций) ты увидишь в третьей, заключительной статье этого цикла, в которой мы напишем свой полноценный Legacy драйвер.
Уфф, наконец-то мы покончили со скучной теорией. В этой статье ты узнал про некоторые приёмы программирования в режиме ядра, изучил структуру драйвера и его основные функции. Теперь ты полностью подготовлен
(на сей раз уже окончательно) к написанию своего первого
(или двадцать первого - не знаю) полноценного Legacy драйвера под Windows. Это мы проделаем в заключительной части моего рассказа о программировании драйверов под Windows. А пока что слегка отдохнём. Да не облысеют твои пятки!
Читайте также: