В предыдущей статье, описывающей алгоритм установки драйвера в систему Windows, мы немного разбирались в том, из каких шагов состоят алгоритмы обнаружения оборудования и установки соответствующих драйверов. Вскоре после этого родилась идея перейти к практической части и осуществить попытку написания собственного, самого простого драйвера на ассемблере. Нет, не каких-то монументальных циклов статей, а исключительно для себя, так сказать, что бы как-то уже начать изучение области разработки драйверов на Ассемблере под операционную систему Windows. После продолжительного обследования предметной области выяснилось, что более-менее серьезная разработка драйверов для Windows ведется на языках программирования C/C++, это объясняется тем, что данные языки являются фактически "языками системы", то есть на них и написаны многие части операционной системы. Однако это вовсе не исключает утверждения, что и на ассемблере вполне себе можно разработать драйвер. Да, соглашусь что делать это не так уж и сподручно, по сравнению с тем же C, поскольку для последнего в качестве серьезного подспорья выпущен специализированный пакет разработки (DDK), но тем не менее все же возможно.
Поскольку драйвер у нас будет первым и самым простейшим, то в качестве платформы разработки возьмем 32-битную версию системы Windows. Понимаю что сейчас на дворе уже 21 век, и доминирование 64-битных операционных систем на рынке с каждым годом все заметнее, тем не менее, на этапе "нулевого знания" пример с 32-битным драйверов будет не менее показателен. Соответственно:
После изучения некоторого количества материала, найденного в Сети, стало очевидным что не имея опыта программирования в режиме ядра, довольно сложно изобрести что-нибудь оригинальное и, что самое главное, это оригинальное потом еще и реализовать :) Первое, что приходит в незамутненную опытом голову после прочтения теории драйвера - это реализация элементарного драйвера, содержащего одну единственную процедуру инициализации DriverEntry. Как вы уже должны были понять по указанному выше материалу - без неё при написании драйвера нам никак не обойтись, она является "базовой" и должна присутствовать в каждом без исключения драйвере. Но чем эта процедура будет у нас заниматься, какой код мы могли бы в ней разместить? Понятное дело, что для простого драйвера на ассемблере и логика должна быть достаточно простой, поскольку при любом ином раскладе мы рискуем запутать процесс обучения настолько, что начинающие разработчики и вовсе могут потерять к теме интерес. Можно было бы просто-напросто скомпилировать пустой каркас драйвера, указав процедуру DriverEntry с инструкцией возврата ret, но при таком подходе как мы получим какое-либо подтверждение о том, что наш драйвер выполняется? Поэтому, было бы интересно получить какой-нибудь осязаемый результат, например визуальное или аудиальное подтверждение работоспособности. С пониманием, как именно выводить на экран монитора из кода ядра (драйвера) у нас пока тяжеловато, а вот со звуком дела обстоят попроще, поскольку в Сети можно встретить примеры с выводом на встроенный динамик персонального компьютера, всеми уже заезженная вдоль и поперек задача. И в нашем примере я решил реализовать проигрывание известной мелодии под названием "Имперский марш" из фильма Звездные войны (так же известной под названием "тема Дарта Вейдера"). Традиционно, для начала приведем исходный код драйвера:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 |
format PE native 4.0 at 10000h entry DriverEntry include '%include%\win32w.inc' include '%include%\DDK\ntstatus.inc' M = 3000000 ; Коэффициент задержки ;--- первая октава ------------------------------------------------------------------------------------------------------------------------- C4 = 0106h ; 261.63 Hz :: C :: (До) Db4 = 0115h ; 277.18 Hz :: Cs :: (До диез) D4 = 0126h ; 293.66 Hz :: D :: (Ре) Eb4 = 0137h ; 311.13 Hz :: Ds :: (Ре диез) E4 = 014Ah ; 329.63 Hz :: E :: (Ми) F4 = 015Dh ; 349.23 Hz :: F :: (Фа) Gb4 = 0172h ; 369.99 Hz :: Fs :: (Фа диез) G4 = 0188h ; 392.00 Hz :: G :: (Соль) Ab4 = 019Fh ; 415.30 Hz :: Gs :: (Соль диез) LA4 = 01B8h ; 440.00 Hz :: A :: (Ля) Bb4 = 01D2h ; 466.16 Hz :: As :: (Ля диез) B4 = 01EEh ; 493.88 Hz :: H :: (Си) ;--- вторая октава ------------------------------------------------------------------------------------------------------------------------------------------ C5 = 020Bh ; 523.25 Hz :: C :: (До) Db5 = 022Ah ; 554.37 Hz :: Cs :: (До диез) D5 = 024Bh ; 587.33 Hz :: D :: (Ре) Eb5 = 026Eh ; 622.25 Hz :: Ds :: (Ре диез) E5 = 0293h ; 659.26 Hz :: E :: (Ми) F5 = 02BAh ; 698.46 Hz :: F :: (Фа) Gb5 = 02E4h ; 739.99 Hz :: Fs :: (Фа диез) G5 = 0310h ; 783.99 Hz :: G :: (Соль) Ab5 = 033Fh ; 830.61 Hz :: Gs :: (Соль диез) LA5 = 0370h ; 880.00 Hz :: A :: (Ля) Bb5 = 03A4h ; 932.33 Hz :: As :: (Ля диез) B5 = 03DCh ; 987.77 Hz :: H :: (Си) ;=== сегмент кода ============================================================ section '.text' code readable executable notpageable proc DriverEntry DriverObject:DWORD, RegistryPath:DWORD stdcall PlaySound, sound_buffer mov eax, STATUS_DEVICE_CONFIGURATION_ERROR ret endp ;----------------------------------------------------------------------------- proc PlaySound buffer:DWORD push eax ebx ecx esi cld mov esi, [buffer] ; данные mov ecx, SIZEOF.sound_buffer / 8 ; размер буфера, счетчик = размер/8 (потому как для каждой записи нота-задержка используется 8 байт) .loop: lodsd pushad invoke HalMakeBeep, eax ; вывод сигнала необходимой частоты на динамик popad .delay: lodsd @@: dec eax jnz @b pushad invoke HalMakeBeep, 0 ; отключаем динамик popad dec ecx jnz .loop pop esi ecx ebx eax ret endp ;------------------------------------------------------------------------------ sound_buffer dd G4 ,350*M,G4 ,350*M,G4 ,350*M,Eb4,250*M,Bb4,100*M,G4 ,350*M,Eb4,250*M,Bb4,100*M,G4 ,700*M dd D5 ,350*M,D5 ,350*M,D5 ,350*M,Eb5,250*M,Bb4,100*M,Gb4,350*M,Eb4,250*M,Bb4,100*M,G4 ,700*M dd G5 ,350*M,G4 ,250*M,G4 ,100*M,G5 ,350*M,Gb5,250*M,F5 ,100*M,E5 ,100*M,Eb5,100*M,E5 ,450*M dd Ab4,150*M,Db5,350*M,C5 ,250*M,B4 ,100*M,Bb4,100*M,LA4,100*M,Bb4,450*M dd Eb4,150*M,Gb4,350*M,Eb4,250*M,Bb4,100*M,G4 ,750*M SIZEOF.sound_buffer = $-sound_buffer ;=== таблица импорта ========================================================= section '.idata' import readable writeable library hal,'hal.dll' import hal,\ HalMakeBeep, 'HalMakeBeep' ;=== таблица перемещений ===================================================== section '.relocs' fixups readable writeable discardable |
Самой первой строкой исходного кода является указание на формат выходного (получаемого по завершении процесса компиляции) исполняемого файла. В нашем примере впервые для многих встречается неизвестный нам ранее формат native, за которым следует указание некоего "смещения" со значением 10000h. Как мы уже писали в этой статье, native - это "родной" (нативный) формат приложения, часто используемый для драйверов (фактически библиотек/приложений режима ядра, которым не требуется инициализация подсистемы Win32 на стадии подготовки образа к исполнению). Значение 10000h - это предпочитаемый базовый адрес образа, то есть адрес загрузки драйвера в адресное пространство ядра, по которому он хотел бы расположиться. Тем не менее, загрузчик образов, проверяя значение в поле ImageBase, самостоятельно принимает решение по выбору загрузочного адреса, в подавляющем большинстве случаев загрузчик размещает драйвер по иному адресу. Тем не менее, для того, что бы выполнить подобное перемещение, требуется изменить указатели переходов, переменных и других данных в коде драйвера, ведь компилятор на этапе сборки бинарного модуля жестко привязывает адреса в коде к выбираемому при компиляции базовому смещению. Именно для подобных ситуаций в исполняемый файл включается таблица перемещений (relocation table, секция .relocs в конце), где указаны все адреса, которые требуют изменения. После обработки перемещений производится связывание (fix-up) импорта (адресов функций внешних библиотек).
В строке 2 кода драйвера мы наблюдаем определение точки входа (директива entry) драйвера на функцию под названием DriverEntry. На основании диалекта FASM, точкой входа является первая инструкция метки, указанной после директивы entry. После прохождения процедуры загрузки драйвера в память, менеджер ввода-вывода вызывает данную функцию, иными словами система передает управление по на первый байт функции DriverEntry после загрузки драйвера в память.
Как мы помним из общей теории драйвера, это функция инициализации, которая должна присутствовать в каждом драйвере, она входит в минимально-необходимый реализуемый набор функций драйвера. В штатных приложениях точка входа задается на произвольную часть кода, однако в драйвере точка входа является адресом функции. Давайте обратимся к официальной документации MSDN, и посмотрим прототип процедуры выглядит следующим образом:
NTSTATUS DriverEntry( _In_ struct _DRIVER_OBJECT *DriverObject, _In_ PUNICODE_STRING RegistryPath );
или более привычный нам по диалекту FASM:
DriverEntry DriverObject:DRIVER_OBJECT, RegistryPath:UNICODE_STRING
Как мы видим, процедура принимает два входных параметра: указатели на некие структуры с именами DriverObject и RegistryPath предопределенных типов.
определения этих типов выглядят следующим образом:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
struc DRIVER_OBJECT { .Type dw ? .Size dw ? .DeviceObject dd ? .Flags dd ? .DriverStart dd ? .DriverSize dd ? .DriverSection dd ? .DriverExtension dd ? ; 28 .DriverName UNICODE_STRING .HardwareDatabase dd ? .FastIoDispatch dd ? .DriverInit dd ? .DriverStartIo dd ? .DriverUnload dd ? .MajorFunction dd (IRP_MJ_MAXIMUM_FUNCTION + 1) dup (?) } ; DRIVER_OBJECT |
и
|
1 2 3 4 5 6 |
struc UNICODE_STRING ;size 8 { .Length dw ? .MaximumLength dw ? .Buffer dd ? } |
где:
- DriverObject - указатель на объект только что созданного драйвера (структура DRIVER_OBJECT). Первый параметр, передающийся в функцию DriverEntry. Перед вызовом процедуры DriverEntry диспетчер ввода-вывода, наряду с другими действиями по подготовке кода драйвера к запуску, создает объект "драйвер" (driver object), который представляет ключевые параметры драйвера для системы. Получается, что именно посредством этого объекта система "управляет" нашим драйвером. Объект представляет собой структуру данных типа DRIVER_OBJECT, некоторые поля которой заполняет система, а иные придется инициализировать разработчику, и часто это осуществляется именно в процедуре DriverEntry. Используя этот указатель в коде, мы можем заполнить требуемые нам поля структуры DRIVER_OBJECT, однако в рассматриваемом нами простейшем драйвере в этом нет необходимости.
- RegistryPath - указатель на раздел реестра (дерево \Registry\Machine\System\CurrentControlSet\Services\Имя_драйвера), содержащий параметры инициализации драйвера. Фактически это указатель на структуру типа UNICODE_STRING, которая содержит указатель непосредственно на саму Unicode-строку, содержащую имя раздела. Указатель используется в коде драйвера для чтения или записи в реестр ключевой информации, которую драйвер может в дальнейшем использовать. В нашем случае мы, опять же, не работаем с данным параметром.
Сама структура UNICODE_STRING выглядит следующим образом:
| Поле | Описание |
|---|---|
| Length | Длина строки в байтах (не в символах), без учета нуль-терминатора (символа завершающего нуля); |
| MaximumLength | Длина в байтах (не в символах) буфера, указываемого через член структуры Buffer; |
| Buffer | Указатель непосредственно на Unicode-строку (не всегда, кстати, завершающейся нулем). |
Итак, вернемся к рассмотрению исходного кода драйвера. В строка 10-34 у нас размещаются константы нот и соответствующие им частоты, из них у нас будет формироваться мелодия, данные которой размещаются в секции данных. Чуть ниже по листингу мы видим функцию DriverEntry, в которой происходит вызов внутренней процедуры с именем PlaySound, являющейся фактически основной полезной нагрузкой драйвера, именно она проигрывает мелодию на встроенном PC-спикере (динамике). Давайте подробнее рассмотрим код самой процедуры PlaySound (размещенный в строках 48-71). Начинается он с сохранения регистров, которые меняются в самой процедуре, потом выставляется флаг направления, как вы уже догадались для осуществления цикла чтения нот и их проигрывания. Загружаем в регистр esi указатель на буфер, который указывается входным параметром buffer процедуры, этот буфер должен содержать проигрываемую мелодию в следующем формате:
Частота, Задержка, Частота, Задержка, Частота, Задержка, Частота, Задержка ...
В регистр ecx загружается счетчик цикла, он же размерность массива (деленная на 8, поскольку каждая итерация цикла будет проигрывать целую тональность, а она у нас имеет по 4 байта на частоту и 4 на задержку). Следом происходит загрузка в регистр eax двойного слова из памяти, указанной смещением из регистра esi, это у нас частота. И считанная частота передается на вход следующей по коду функции HalMakeBeep, которая работает с динамиком напрямую через порты.
Функция HalMakeBeep экспортируется системной библиотекой hal.dll и является, честно говоря, не самым лучшим выбором, поскольку не документирована и, соответственно, официально разработчиками не гарантируется неизменное ее состояние при переходе системы от версии к версии. Тем не менее, что-то более простое для вывода звука придумать сложно, в противном случае для вывода мелодии нам пришлось бы программировать порты напрямую, что создало бы нам для первого раза довольно избыточно-усложненную логику, которая (в свою очередь) потянула бы за собой задачу по изучению (и описанию) принципов работы перепрограммируемого интервального таймер и программируемого контроллера периферийного интерфейса Intel 8255. А библиотека hal.dll предоставляет нам готовую функцию одной из библиотек ядра, являющейся самым "низким" уровнем перед оборудованием компьютера и используемых для сопряжения с аппаратной частью, функции её просты и интуитивно-понятны, и именно поэтому выбор был сделать в её пользу.
eax, ebx, ecx, edx ...), то будьте внимательны, поскольку многие функции режима ядра (как впрочем и некоторые функции Win32 API) в своем теле не сохраняют/не восстанавливают состояния некоторых регистров общего назначения, что в ситуации с кодом режима ядра приводит порой к падению в синий экран. Простым решением проблемы видится самостоятельное сохранение регистров перед вызовом и последующее восстановление.Поэтому вызов функции HalMakeBeep у нас обрамляет пара инструкций pushad-popad, что не является оптимальным решением, но для нашего говнокода вполне сгодится :) Двигаемся далее по коду, и в строке 60 у нас загружается следующее в буфере двойное слово, которое используется как задержка для звучащей в данный момент частоты, таким образом создается длительность звучания.
Как вы уже поняли, мелодия в нашем случае представляет собой набор частот с соответствующими задержками. Хотелось бы отдельно поговорить о задержках, и обратить внимания на два связанных с этим момента:
- задержки указаны в массиве в качестве числа (например: 350*M), поэтому на разных конфигурациях ПК они будут звучать с разным темпом, все зависит от значения константы M (указанной в строке
7). - задержки реализованы в коде в виде пустого цикла из пары инструкций dec + jnz (строки
61-62), то есть "грузят" ЦП.
Итак, вернемся к основному коду, далее в приложении (строка 64) динамик у нас отключается подачей значения 0 на вход функции HalMakeBeep. Ну и после этого код у нас входит в цикл повторения заданное количество раз всех описанных выше действий, перебирая каждую записи в буфере мелодии.
После того, как код функции DriverEntry отработал, управление возвращается диспетчеру ввода-вывода (строки 44-45). Но тут вот есть один интересный момент, функция DriverEntry, как и подавляющее большинство других функций Windows, должна вернуть код возврата, сигнализирующий о статусе завершения функции. В штатном режиме, когда инициализация драйвера проходит успешно, функция возвращает статус STATUS_SUCCESS, но в нашем случае мы пишем драйвер-пустышку, который кроме кода своей процедуры инициализации (проигрывание мелодии) ничего не делает, так зачем же он нам в памяти ядра постоянно загруженным? Поэтому есть предложение не оставлять его в памяти, а посему в нашем коде мы возвращаем системе в регистре eax значение STATUS_DEVICE_CONFIGURATION_ERROR (NTSTATUS код 0C0000182h), то есть код фиктивной ошибки, после чего система должна удалить драйвер из памяти (в действительности же этого не происходит и драйвер висит в памяти со статусом Stopped).
Сама мелодия размещается в блоке данных с меткой sound_buffer (строки 73-77). Блок содержит, как мы уже отмечали выше, чередующиеся значения констант частот и задержек. Частота у нас задается константами, определенными в начале исходного кода драйвера (строки 10-34) и носящими имена соответствующих нот. Каждой константе (ноте) соответствует своя частота.
Заключение
После компиляции исходного кода нашего с вами драйвера, на выходе мы получаем исполняемый файл с расширением .sys, который, теоретически, готов к выполнению. Осталось дело за малым - заставить драйвер загружаться в адресное пространство ядра. Сделать это можно несколькими способами (будут описаны позже в отдельной статье), и самый простой из них заключается в загрузке драйвера на одном из этапов загрузки операционной системы, путем создания и редактирования ключа реестра HKLM\SYSTEM\CurrentControlSet\Services\имя_драйвера и вложенных параметров.
А для x64 напишите статью?
напишу конечно, но вот когда это будет - точно сказать не могу. я ведь сам учусь, разобраться для начала надо в теории..