В предыдущей статье, описывающей алгоритм установки драйвера в систему 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 напишите статью?
напишу конечно, но вот когда это будет - точно сказать не могу. я ведь сам учусь, разобраться для начала надо в теории..