Создание драйверов режима ядра в среде Borland Delphi

Автор: Геннадий Порев
Источник: RSDN Magazine #4-2004
Опубликовано: 20.02.2005
Версия текста: 1.0
Введение
Код: от исходного до машинного
Конфликт форматов объектных файлов
Пишем драйвер
Компиляция, сборка и проверка драйвера
Резюме

Введение

Программирование в системах Windows линейки NT можно условно разделить на две принципиально различных части — создание кода пользовательского режима и кода режима ядра.

Такое разделение вызвано особенностями внутреннего строения Windows. Поскольку основным семейством процессоров для всего семейства Windows являются процессоры Intel семейства x86. Известно, что эти процессоры этого семейства имеют четыре уровня защиты (от нулевого до третьего), называемые кольцами. Кольца различаются множеством разрешённых к выполнению операций, например в 3-м кольце существуют ограничения на операции с портами ввода-вывода и на доступ к памяти по физическим адресам.

В архитектуре ОС Windows используются всего два кольца: 0-е и 3-е. В нулевом кольце выполняется код уровня абстрагирования от аппаратуры (HAL), ядро системы и различные драйверы, в том числе и драйверы устройств. В 3-м кольце выполняются системные службы, программы, взаимодействующие с пользователем, а также вспомогательный код для вызова функций ядра из пользовательского режима.

Для разработки драйверов корпорация Microsoft предоставляет Driver Development Kit (DDK), представляющий собой набор заголовочных файлов, утилит и документации. Из соображений соблюдения внутрикорпоративного стандарта вся документация, примеры кода и инструменты сборки в DDK ориентированы на языки C/C++. Естественно, что для разработки драйверов большинство программистов пользуется легко интегрируемыми с DDK средствами, выпущенными, разумеется, той же корпорацией Microsoft — например Visual C. В сети Интернет также доступны материалы, касающиеся разработки драйверов на языке Assembler, но в качестве средства компиляции используется опять же Microsoft Macro Assembler и сборщик из комплекта DDK.

Говоря далее «сборщик из комплекта DDK», «сборщик от Microsoft» или просто «сборщик», мы подразумеваем link.exe, поставляемый с несколькими продуктами Microsoft, в том числе собственно DDK, Macro Assembler, Visual C и другими. Различия между версиями этой программы для данной статьи не принципиальны.

Код: от исходного до машинного

Так сложилось, что ОС семейства Windows пишутся на языках С/C++. Поэтому неудивительно, что DDK ориентирован на С/C++-компиляторы. Для этих языков процесс преобразования исходного кода программы в машинный код традиционно происходит в два этапа — компиляции и сборки. В процессе компиляции исходный код программы превращается в так называемые объектные модули, которые обычно содержат машинный код и информацию об экспорте переменных и функций. Слово "обычно" употреблено здесь по той причине, что некоторые компиляторы предоставляют возможность поместить в объектные модули не машинный код, а так называемый промежуточный код (отдаленно напоминающий MSIL или байт-код Java), что позволяет впоследствии оптимизировать код на уровне целого приложения, а не отдельных объектных модулей. Вторым этапом является сборка. Сборщик после компиляции формирует из одного или нескольких объектных модулей и статически подключаемых библиотек так называемый исполняемый образ (executable image). Исполняемый образ строится в соответствии с требованиями целевой ОС и содержит непосредственно выполняемый процессором машинный код, а также различную вспомогательную информацию.

Создание файла NT-драйвера режима ядра также подчиняется этой схеме. Сборщик генерирует выполняемый образ, указывая в его заголовке, что это именно NT-драйвер режима ядра, в соответствии с указанными при сборке опциями.

Конфликт форматов объектных файлов

На заре «компиляторостроения» под Windows все разработчики компиляторов придерживались единого формата объектных файлов — Object Module File (OMF), предложенного корпорацией Intel. В то время было можно, например, создать объектный файл в среде Borland Turbo Assembler и подключить к проекту, созданному в Microsoft C, без особых трудностей.

Ярким примером влияния политики корпораций на технические вопросы явился переход компиляторов корпорации Microsoft на стандарт формата объектных файлов COFF (Common Object File Format). С точки зрения пользователей компиляторов, форматы OMF и COFF практически ничем не отличаются, но с точки зрения Microsoft это было оправдано тем, что COFF является также стандартом формата объектных файлов в среде UNIX. Таким образом, переход следует рассматривать как дальний прицел на кросс-платформенность создаваемого программного обеспечения.

Корпорация Borland, также один из крупных производителей компиляторов под Windows, заявила, что Microsoft «переизобрела колесо», и что в продуктах Borland будет по-прежнему использоваться формат Intel OMF, но в тоже время подозрительно синхронно внесла в компиляторы некоторые изменения. К сожалению, Delphi версии выше 3 создают несовместимый с Intel OMF формат, хотя и внешне похожий.

На данный момент в результате «борьбы» компиляторных гигантов в проигрыше, как в основном и случается, оказались программисты. Стоит отметить, что современный сборщик от Microsoft имеет возможность преобразовывать формат Intel OMF в COFF. Современные компиляторы Borland по умолчанию (без указания дополнительных опций) выдают непосредственно исполняемый образ, минуя стадию генерации объектного файла. Опция, позволяющая сгенерировать объектный файл, в компиляторах Borland есть, но из-за внесенных Borland изменений формата такие объектные файлы понимают лишь компиляторы самой Borland, а сборщик Microsoft не распознаёт их как корректные.

Однако есть большое количество программистов, использующих в своей профессиональной деятельности, в основном, Borland Delphi. Некоторые из них в своих задачах рано или поздно переходят от техники визуального проектирования интерфейса и работы с технологиями баз данных к низкоуровневым задачам работы с операционной системой, упираются в непреодолимый на первый взгляд барьер, разделяющий пользовательский режим и режим ядра. В сознании большинства программистов на данный момент достаточно прочно укрепилось мнение, что средствами Borland Delphi создать NT-драйвер режима ядра нельзя. Так ли это?

Пишем драйвер

Исполняемый образ для native-подсистемы Windows, проще говоря — NT-драйвер режима ядра, исходный код которого написан на языке Object Pascal (сейчас и сам язык называется Delphi), создать можно. Но перед тем, как заняться этим, необходимо прояснить некоторые принципиальные моменты.

Во-первых, сборщик от Microsoft на сегодня единственный, который способен сгенерировать такой исполняемый образ, поэтому без него не обойтись в любом случае.

Во-вторых, встроенный в Delphi сборщик по умолчанию внедряет в любой исполняемый файл код своего Run-Time Library (RTL), а затем компилятор вызывает некоторые функции из этого RTL для реализации некоторых возможностей языка. Поскольку Delphi RTL рассчитан на выполнение в режиме пользователя, то, очевидно, и возможностями языка Delphi, которые ориентированы на функциональность RTL, придётся пожертвовать. К таким возможностям, относятся, например, поддержка динамических массивов, в том числе строковых типов и операций со строками, поддержка классов, Run-Time Type Information (RTTI) и т.д.

В-третьих, единственными Win32-версиями Delphi, которые создавали объектные файлы, полностью соответствующие стандарту OMF, являются Delphi 2 и Delphi 3. Так что тем, кто привык пользоваться возможностями языка позднейших версий, такими как перегрузка функций и процедур директивой overload, придётся отказаться от них.

В-четвёртых, исходный текст драйвера на Delphi также будет иметь свою специфику. Например, сборщику необходимо будет указать так называемую точку входа — функцию в теле драйвера, вызываемую системой при инициализации драйвера. Объектные файлы, которые генерирует Delphi для проектов типа program или library, не содержат символьного имени точки входа, поэтому придётся воспользоваться проектом типа unit.

Код, реализующий минимальную функциональность драйвера, а именно возможность его запуска и остановки, приведён далее.

      unit tiny;

interfaceTYPE
  UShort  = Word;   // unsigned 16-bit
  Short   = Smallint; // signed 16-bit
  ULong   = Cardinal;
  Size_T  = Cardinal;
  PVoid   = Pointer;

  NTStatus = ULong;
  CShort  = Short;

TYPE
  PUNICODE_STRING = ^UNICODE_STRING;
  UNICODE_STRING = packedrecord
  Length : UShort;
  MaximumLength : UShort;
  Buffer : PWideChar;
  end;

CONST
  NTOSKrnl = 'ntoskrnl.exe';

CONST
  IRP_MJ_MAXIMUM_FUNCTION     = $1B;

TYPE
  PDRIVER_OBJECT = ^DRIVER_OBJECT;
  DRIVER_OBJECT = packedrecord
  csType : CShort;
  csSize : CShort;
  DeviceObject : Pointer; // SHOULD BE PDEVICE_OBJECT
  Flags : ULong;
  DriverStart : Pointer;
  DriverSize : ULong;
  DriverSection : Pointer;
  DriverExtension : Pointer; // SHOULD BE PDRIVER_EXTENSION
  DriverName : UNICODE_STRING;
  HardwareDatabase : PUNICODE_STRING;
  FastIoDispatch : Pointer; // SHOULD BE PFAST_IO_DISPATCH
  DriverInit : Pointer; // PDRIVER_INITIALIZE
  DriverStartIo : Pointer; // PDRIVER_STARTIO
  DriverUnload : Pointer; // PDRIVER_UNLOAD
  MajorFunction : array [0..IRP_MJ_MAXIMUM_FUNCTION] of Pointer;
  end;

CONST STATUS_SUCCESS = NTStatus( $00000000 );

function DriverEntry(
  const DriverObject : PDRIVER_OBJECT;
  const RegistryPath : PUNICODE_STRING
  ) : NTStatus; stdcall;

implementationfunction DbgPrint(
  const Format : PAnsiChar
  ) : NTStatus; cdecl; external NTOSKrnl name '_DbgPrint';

procedure ADriverUnload(
            const DriverObject : PDRIVER_OBJECT
            ); stdcall;
begin
 DbgPrint('Tiny: DriverUnload()');
end;

function DriverEntry;
begin
  DriverObject^.DriverUnload := @ADriverUnload;
  DbgPrint('Tiny: DriverEntry()');
  Result := STATUS_SUCCESS;
end;

end.

Код следует немного прокомментировать. Точка входа DriverEntry вызывается операционной системой для инициализации драйвера после его загрузки в память. В качестве параметров передаётся указатель на специальную структуру типа DRIVER_OBJECT и указатель на структуру строки, которая содержит путь к ветке реестра, соответствующей драйверу. Драйвер, получив эти параметры, должен проинициализировать свои внутренние структуры, заполнить структуру DRIVER_OBJECT и вернуть код STATUS_SUCCESS, если всё прошло нормально или код ошибки, если что-то пошло не так. В этом примере в структуре DRIVER_OBJECT указан только адрес процедуры ADriverUnload, выполняющей выгрузку драйвера — в ней необходимо размещать код освобождения всех ресурсов и деинициализации.

Чтобы узнать, выполняется ли какой-либо машинный код так, как мы хотели, необходим отладчик. Для отладки драйвера режима ядра необходим соответствующий отладчик. В поставку NT DDK входит такой отладчик, но он требует использования второго компьютера. Есть профессиональный отладчик SoftICE, очень популярный среди программистов, пишущих драйверы, но он сам по себе очень громоздок и его функциональность для нашей демонстрационной задачи избыточна. Поэтому мы воспользуемся встроенным в native API средством — выводом заданной строки в буфер отладчика режима ядра, и DebugView — средством просмотра этого буфера от SysInternals (http://www.sysinternals.com/ntw2k/freeware/debugview.shtml).

Будьте внимательны с так называемыми «соглашениями о вызове» (calling conventions)! Как внутренние функции драйвера, так и импортируемые функции Native API подразумевают соглашение stdcall. Однако следует внимательно следить за сигнатурой каждой импортируемой функции в ntoskrnl.lib, так как если после имени функции не стоит знак «@» и количество бит в стеке, занимаемых параметрами функции, то такую функцию следует импортировать с соглашением cdecl, как это сделано выше с функцией DbgPrint. Вызов функции с неправильно указанным соглашением о вызове практически всегда приведет к зависанию системы или BSOD.

Компиляция, сборка и проверка драйвера

Ниже приведён перечень файлов, которые понадобятся для создания драйвера.

Из поставки Borland Delphi 3:

Из поставки Microsoft NT DDK:

Компиляция кода запускается так:

dcc32.exe -jP -$A-,B-,C-,D-,G-,H-,I-,J-,L-,M-,O+,P-,Q-,R-,T-,U-,V-,W+,X+,Y- tiny.pas

Принципиальным моментом здесь является наличие ключа –jP, вызывающего генерацию объектного файла. Сборка объектного файла в исполняемый образ вызывается такой командой:

link.exe /NOLOGO /ALIGN:32 /BASE:0x10000 /SUBSYSTEM:NATIVE /DRIVER
/FORCE:UNRESOLVED /ENTRY:DriverEntry$qqsxp13DRIVER_OBJECTxp14UNICODE_STRING
tiny.obj /out:tiny.sys ntoskrnl.lib

Здесь принципиальными являются опции /FORCE:UNRESOLVED и /ENTRY. Дело в том, что компилятор Delphi внёс в объектный файл несколько символов, относящихся к Delphi RTL, таких как @@HandleFinally$qqrv. Чтобы не писать пустые процедуры-заглушки с требуемыми именами, указываем опцию /FORCE, и сборщик пропускает сборку таких символов, так как они всё равно не нужны. Опция /ENTRY: указывает на символ, которым компилятор Delphi обозначил точку входа — функцию DriverEntry. Поскольку компилятор Delphi в название этого символа вносит также типы передаваемых параметров, то в случае несовпадения объявления функции следует в объектном файле найти правильное имя символа, начинающееся с DriverEntry.

В результате компиляции и сборки будет создан файл tiny.sys. Для проверки работоспособности драйвера скопируем его в каталог %SYSTEMROOT%\system32\drivers и воспользуемся примитивным инсталлятором драйвера:

      {$APPTYPE CONSOLE}
      program drvinst;

uses Windows, WinSVC;

var hSCM, hSRV : THandle;
  R : LongBool;
  Param : AnsiString;

beginif ParamCount = 1 thenbegin
    hSCM := OpenSCManager(nil, nil, SC_MANAGER_ALL_ACCESS);
    Writeln('OpenSCManager ', (hSCM <> INVALID_HANDLE_VALUE));
    Param := AnsiString(ParamStr(1));
    // создание системной записи о драйвере
    hSRV := CreateService(
      hSCM, 
      @Param[1], 
      @Param[1], 
      SERVICE_ALL_ACCESS, 
      SERVICE_KERNEL_DRIVER, 
      SERVICE_DEMAND_START, 
      SERVICE_ERROR_NORMAL, 
      PAnsiChar('System32\DRIVERS\' + Param + '.sys'), 
      nil, 
      nil, 
      nil, 
      nil, 
      nil);
    Writeln('CreateService ', hSRV <> INVALID_HANDLE_VALUE);
    // очистка ресурсов
    R := CloseServiceHandle(hSRV);
    Writeln('CloseServiceHandle ', R);
    R := CloseServiceHandle(hSCM);
    Writeln('CloseServiceHandle ', R);
  end;
end.

Запустив инсталлятор с параметром tiny, мы установим созданный драйвер в систему. Теперь запустим программу DebugView, а после этого запустим драйвер:

>net start tiny

The tiny service was started successfully.

Если в драйвере нет ошибок, и операционная система не вызвала BSOD, мы увидим, что драйвер успешно загружен, с помощью утилиты drivers.exe из DDK:

  ModuleName    Code    Data     Bss   Paged    Init         LinkDate
-----------------------------------------------------------------------------
ntoskrnl.exe  643072  114688       0 1400832  184320 Fri Aug 08 17:40:11 2003
     hal.dll   36864   49152       0   40960   16384 Fri Aug 08 15:58:53 2003
    tiny.sys     192      64       0       0      96 Fri Aug 06 12:54:19 2004
…
   ntdll.dll  503808   24576       0       0       0 Fri Aug 08 18:37:28 2003
-----------------------------------------------------------------------------
       Total 9296112 1675376       0 5221088  850272  

После этого остановим драйвер:

>net stop tiny

The tiny service was stopped successfully.

В окне программы DebugView увидим результаты деятельности драйвера.


С помощью drivers.exe можно проверить, выгрузился драйвер из памяти или нет.

Удалить запись о драйвере можно с помощью приведённого ниже примитивного деинсталлятора:

      {$APPTYPE CONSOLE}
      program drvremove;

uses Windows, WinSVC;

var hSCM, hSRV : THandle;
    R : LongBool;
    Param : AnsiString;

beginif ParamCount=1 thenbegin
   hSCM := OpenSCManager(nil, nil, SC_MANAGER_ALL_ACCESS);
   Writeln('OpenSCManager ', hSCM <> INVALID_HANDLE_VALUE);
   Param := AnsiString(ParamStr(1));
   // удаление системной записи о драйвере
   hSRV := OpenService(hSCM, @Param[1], SERVICE_ALL_ACCESS);
   Writeln('OpenService ', hSRV <> INVALID_HANDLE_VALUE);
   R := DeleteService(hSrv);
   Writeln('DeleteService ', R);
   // очистка ресурсов
   R := CloseServiceHandle(hSRV);
   Writeln('CloseServiceHandle ', R);
   R := CloseServiceHandle(hSCM);
   Writeln('CloseServiceHandle ', R);
  end;
end.

Резюме

Строго говоря, файл tiny.sys драйвером как таковым не является — он не работает с аппаратным обеспечением, не создаёт имён устройств. Но цель этой статьи — показать, что создание исполняемых модулей для подсистемы Windows native, в том числе и драйверов устройств, является принципиально возможным даже в такой изначально не предназначенной для этого среде, как Borland Delphi.


Эта статья опубликована в журнале RSDN Magazine #4-2004. Информацию о журнале можно найти здесь